diff --git a/.coveragerc b/.coveragerc
deleted file mode 100644
index 7656c5a9f..000000000
--- a/.coveragerc
+++ /dev/null
@@ -1,11 +0,0 @@
-[run]
-source = .
-include = *.py
-omit =
- *migrations*
- *tests*
- *.html
- *whoosh_cn_backend*
- *oauth*
- *settings.py*
- *venv*
diff --git a/.dockerignore b/.dockerignore
index 48ff43bfe..becd6f90f 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,11 +1,12 @@
bin/data/
# virtualenv
venv/
-migrations/
-!migrations/__init__.py
collectedstatic/
djangoblog/whoosh_index/
uploads/
settings_production.py
*.md
-docs/
\ No newline at end of file
+docs/
+logs/
+static/
+.github/
diff --git a/.gitattributes b/.gitattributes
index 4e1541e9a..fd52ece81 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,3 +1,6 @@
blog/static/* linguist-vendored
*.js linguist-vendored
*.css linguist-vendored
+* text=auto
+*.sh text eol=lf
+*.conf text eol=lf
\ No newline at end of file
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index b872d0737..52775e008 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -1,19 +1,8 @@
-# For most projects, this workflow file will not need changing; you simply need
-# to commit it to your repository.
-#
-# You may wish to alter this file to override the set of languages analyzed,
-# or to provide custom queries or build logic.
-#
-# ******** NOTE ********
-# We have attempted to detect the languages in your repository. Please check
-# the `language` matrix defined below to confirm you have the correct set of
-# supported CodeQL languages.
-#
name: "CodeQL"
on:
push:
- branches:
+ branches:
- master
- dev
paths-ignore:
@@ -23,7 +12,6 @@ on:
- '**/*.yml'
- '**/*.txt'
pull_request:
- # The branches below must be a subset of the branches above
branches:
- master
- dev
@@ -34,54 +22,28 @@ on:
- '**/*.yml'
- '**/*.txt'
schedule:
- - cron: '33 6 * * 0'
+ - cron: '30 1 * * 0'
+
jobs:
- analyze:
- name: Analyze
+ CodeQL-Build:
runs-on: ubuntu-latest
permissions:
+ security-events: write
actions: read
contents: read
- security-events: write
-
- strategy:
- fail-fast: false
- matrix:
- language: [ 'python' ]
- # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
- # Learn more:
- # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
steps:
- name: Checkout repository
- uses: actions/checkout@v2
+ uses: actions/checkout@v3
- # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@v1
+ uses: github/codeql-action/init@v3
with:
- languages: ${{ matrix.language }}
- # If you wish to specify custom queries, you can do so here or in a config file.
- # By default, queries listed here will override any specified in a config file.
- # Prefix the list here with "+" to use these queries and those in the config file.
- # queries: ./path/to/local/query, your-org/your-repo/queries@main
-
- # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
- # If this step fails, then you should remove it and run the build manually (see below)
+ languages: python
+
- name: Autobuild
- uses: github/codeql-action/autobuild@v1
-
- # ℹ️ Command-line programs to run using the OS shell.
- # 📚 https://git.io/JvXDl
-
- # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
- # and modify them (or add more) to build your code if your project
- # uses a compiled language
-
- #- run: |
- # make bootstrap
- # make release
+ uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v1
+ uses: github/codeql-action/analyze@v3
\ No newline at end of file
diff --git a/.github/workflows/deploy-master.yml b/.github/workflows/deploy-master.yml
new file mode 100644
index 000000000..c07a326c8
--- /dev/null
+++ b/.github/workflows/deploy-master.yml
@@ -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
\ No newline at end of file
diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml
index 9cf753512..ebe79535d 100644
--- a/.github/workflows/django.yml
+++ b/.github/workflows/django.yml
@@ -9,7 +9,6 @@ on:
- '**/*.md'
- '**/*.css'
- '**/*.js'
- - '**/*.yml'
pull_request:
branches:
- master
@@ -18,57 +17,61 @@ on:
- '**/*.md'
- '**/*.css'
- '**/*.js'
- - '**/*.yml'
jobs:
- build-normal:
+ test:
runs-on: ubuntu-latest
strategy:
- max-parallel: 4
+ fail-fast: false
matrix:
- python-version: [ 3.6, 3.7, 3.8, 3.9 ]
+ 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: Start MySQL
- uses: samin/mysql-action@v1.3
- with:
- host port: 3306
- container port: 3306
- character set server: utf8mb4
- collation server: utf8mb4_general_ci
- mysql version: latest
- mysql root password: root
- mysql database: djangoblog
- mysql user: root
- mysql password: root
-
- - uses: actions/checkout@v2
- - name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v1
- with:
- python-version: ${{ matrix.python-version }}
- - name: Install Dependencies
- run: |
- python -m pip install --upgrade pip
- pip install -r requirements.txt
- - name: Run Tests
- env:
- DJANGO_MYSQL_PASSWORD: root
- DJANGO_MYSQL_HOST: 127.0.0.1
+ - name: Checkout代码
+ uses: actions/checkout@v4
+
+ - name: 设置测试信息
+ id: test-info
run: |
- python manage.py makemigrations
- python manage.py migrate
- python manage.py test
-
- build-with-es:
- runs-on: ubuntu-latest
- strategy:
- max-parallel: 4
- matrix:
- python-version: [ 3.6, 3.7, 3.8, 3.9 ]
-
- steps:
- - name: Start MySQL
+ 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
@@ -80,55 +83,289 @@ jobs:
mysql database: djangoblog
mysql user: root
mysql password: root
-
- - name: Configure sysctl limits
+
+ # 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
-
- - uses: miyataka/elasticsearch-github-actions@1
+
+ - name: 启动Elasticsearch
+ if: matrix.elasticsearch == true
+ uses: miyataka/elasticsearch-github-actions@1
with:
stack-version: '7.12.1'
- plugins: 'https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.12.1/elasticsearch-analysis-ik-7.12.1.zip'
-
- - uses: actions/checkout@v2
- - name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v1
+ 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 }}
- - name: Install Dependencies
+ 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: |
- python -m pip install --upgrade pip
+ echo "📦 安装Python依赖 (Python ${{ matrix.python-version }})"
+ python -m pip install --upgrade pip setuptools wheel
+
+ # 安装基础依赖
pip install -r requirements.txt
- - name: Run Tests
+
+ # 根据测试类型安装额外依赖
+ 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: 127.0.0.1:9200
+ DJANGO_ELASTICSEARCH_HOST: ${{ matrix.elasticsearch && '127.0.0.1:9200' || '' }}
run: |
- python manage.py makemigrations
- python manage.py migrate
- coverage run manage.py test
- coverage xml
-
- - name: Upload coverage to Codecov
- uses: codecov/codecov-action@v1
-
- docker:
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@v2
- - name: Set up QEMU
- uses: docker/setup-qemu-action@v1
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v1
-
- - name: Build and push
- uses: docker/build-push-action@v2
+ 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:dev
+ 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
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 505444bf3..904fef520 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -7,38 +7,37 @@ on:
- '**/*.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@v2
+ uses: actions/checkout@v4
- name: Set up QEMU
- uses: docker/setup-qemu-action@v1
+ uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v1
+ uses: docker/setup-buildx-action@v3
+
- name: Login to DockerHub
- uses: docker/login-action@v1
+ uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
- uses: docker/build-push-action@v2
+ uses: docker/build-push-action@v5
with:
context: .
push: true
- tags: ${{ secrets.DOCKERHUB_USERNAME }}/djangoblog:latest
+ tags: ${{ secrets.DOCKERHUB_USERNAME }}/djangoblog:${{env.DOCKER_TAG}}
- - name: ssh deploy
- uses: appleboy/ssh-action@master
- with:
- host: ${{ secrets.HOST }}
- username: ${{ secrets.USERNAME }}
- key: ${{ secrets.KEY }}
- port: ${{ secrets.PORT }}
- script: |
- docker pull ${{ secrets.DOCKERHUB_USERNAME }}/djangoblog:latest
- cd ${{ secrets.DOCKERPATH }}
- docker-compose up -d
- docker restart memcached
+
diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml
index 0eeae4707..5eb08539e 100644
--- a/.github/workflows/publish-release.yml
+++ b/.github/workflows/publish-release.yml
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v2
+ uses: actions/checkout@v3
- name: Docker meta
id: meta
@@ -17,16 +17,16 @@ jobs:
with:
images: name/app
- name: Set up QEMU
- uses: docker/setup-qemu-action@v1
+ uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v1
+ uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
- uses: docker/login-action@v1
+ uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
- uses: docker/build-push-action@v2
+ uses: docker/build-push-action@v3
with:
context: .
push: true
diff --git a/.gitignore b/.gitignore
index b54f038cd..76302b1f7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -45,11 +45,11 @@ coverage.xml
*,cover
# Translations
-*.mo
*.pot
# Django stuff:
*.log
+logs/
# Sphinx documentation
docs/_build/
@@ -62,12 +62,9 @@ target/
# http://www.jetbrains.com/pycharm/webhelp/project.html
.idea
.iml
-
# virtualenv
venv/
-migrations/
-!migrations/__init__.py
collectedstatic/
djangoblog/whoosh_index/
google93fd32dbd906620a.html
diff --git a/Dockerfile b/Dockerfile
index f8a86f2c6..80b46acce 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,14 +1,15 @@
-FROM python:3
+FROM python:3.11
ENV PYTHONUNBUFFERED 1
WORKDIR /code/djangoblog/
-RUN apt-get install default-libmysqlclient-dev -y && \
- ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
+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 -Ur requirements.txt && \
- pip install gunicorn[gevent] && \
+ pip install --no-cache-dir -r requirements.txt && \
+ pip install --no-cache-dir gunicorn[gevent] && \
pip cache purge
ADD . .
-RUN chmod +x /code/djangoblog/bin/docker_start.sh
-ENTRYPOINT ["/code/djangoblog/bin/docker_start.sh"]
+RUN chmod +x /code/djangoblog/deploy/entrypoint.sh
+ENTRYPOINT ["/code/djangoblog/deploy/entrypoint.sh"]
diff --git a/LICENSE b/LICENSE
index 1e2295404..3b08474a6 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
The MIT License (MIT)
-Copyright (c) 2016 车亮亮
+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
diff --git a/README.md b/README.md
index 2f912fd61..56aa4cc54 100644
--- a/README.md
+++ b/README.md
@@ -1,142 +1,158 @@
# DjangoBlog
-🌍
-*[English](/docs/README-en.md) ∙ [简体中文](README.md)*
+
+
+
+
+
+
+
+
+ 一款功能强大、设计优雅的现代化博客系统
+
+ English • 简体中文
+
-基于`python3.8`和`Django3.0`的博客。
-
-[](https://github.com/liangliangyy/DjangoBlog/actions/workflows/django.yml) [](https://github.com/liangliangyy/DjangoBlog/actions/workflows/codeql-analysis.yml) [](https://codecov.io/gh/liangliangyy/DjangoBlog) []()
-
-## 主要功能:
-- 文章,页面,分类目录,标签的添加,删除,编辑等。文章及页面支持`Markdown`,支持代码高亮。
-- 支持文章全文搜索。
-- 完整的评论功能,包括发表回复评论,以及评论的邮件提醒,支持`Markdown`。
-- 侧边栏功能,最新文章,最多阅读,标签云等。
-- 支持Oauth登陆,现已有Google,GitHub,facebook,微博,QQ登录。
-- 支持`Memcache`缓存,支持缓存自动刷新。
-- 简单的SEO功能,新建文章等会自动通知Google和百度。
-- 集成了简单的图床功能。
-- 集成`django-compressor`,自动压缩`css`,`js`。
-- 网站异常邮件提醒,若有未捕捉到的异常会自动发送提醒邮件。
-- 集成了微信公众号功能,现在可以使用微信公众号来管理你的vps了。
+---
+DjangoBlog 是一款基于 Python 3.10 和 Django 4.0 构建的高性能博客平台。它不仅提供了传统博客的所有核心功能,还通过一个灵活的插件系统,让您可以轻松扩展和定制您的网站。无论您是个人博主、技术爱好者还是内容创作者,DjangoBlog 都旨在为您提供一个稳定、高效且易于维护的写作和发布环境。
-## 安装
-mysql客户端从`pymysql`修改成了`mysqlclient`,具体请参考 [pypi](https://pypi.org/project/mysqlclient/) 查看安装前的准备。
+## ✨ 特性亮点
-使用pip安装: `pip install -Ur requirements.txt`
+- **强大的内容管理**: 支持文章、独立页面、分类和标签的完整管理。内置强大的 Markdown 编辑器,支持代码语法高亮。
+- **全文搜索**: 集成搜索引擎,提供快速、精准的文章内容搜索。
+- **互动评论系统**: 支持回复、邮件提醒等功能,评论内容同样支持 Markdown。
+- **灵活的侧边栏**: 可自定义展示最新文章、最多阅读、标签云等模块。
+- **社交化登录**: 内置 OAuth 支持,已集成 Google, GitHub, Facebook, 微博, QQ 等主流平台。
+- **高性能缓存**: 原生支持 Redis 缓存,并提供自动刷新机制,确保网站高速响应。
+- **SEO 友好**: 具备基础 SEO 功能,新内容发布后可自动通知 Google 和百度。
+- **便捷的插件系统**: 通过创建独立的插件来扩展博客功能,代码解耦,易于维护。我们已经通过插件实现了文章浏览计数、SEO 优化等功能!
+- **集成图床**: 内置简单的图床功能,方便图片上传和管理。
+- **自动化前端**: 集成 `django-compressor`,自动压缩和优化 CSS 及 JavaScript 文件。
+- **健壮的运维**: 内置网站异常邮件提醒和微信公众号管理功能。
-如果你没有pip,使用如下方式安装:
-- OS X / Linux 电脑,终端下执行:
+## 🛠️ 技术栈
- ```
- curl http://peak.telecommunity.com/dist/ez_setup.py | python
- curl https://bootstrap.pypa.io/get-pip.py | python
- ```
+- **后端**: Python 3.10, Django 4.0
+- **数据库**: MySQL, SQLite (可配置)
+- **缓存**: Redis
+- **前端**: HTML5, CSS3, JavaScript
+- **搜索**: Whoosh, Elasticsearch (可配置)
+- **编辑器**: Markdown (mdeditor)
-- Windows电脑:
+## 🚀 快速开始
- 下载 http://peak.telecommunity.com/dist/ez_setup.py 和 https://raw.github.com/pypa/pip/master/contrib/get-pip.py 这两个文件,双击运行。
+### 1. 环境准备
+确保您的系统中已安装 Python 3.10+ 和 MySQL/MariaDB。
-## 运行
+### 2. 克隆与安装
- 修改`DjangoBlog/setting.py` 修改数据库配置,如下所示:
+```bash
+# 克隆项目到本地
+git clone https://github.com/liangliangyy/DjangoBlog.git
+cd DjangoBlog
-```python
-DATABASES = {
- 'default': {
- 'ENGINE': 'django.db.backends.mysql',
- 'NAME': 'djangoblog',
- 'USER': 'root',
- 'PASSWORD': 'password',
- 'HOST': 'host',
- 'PORT': 3306,
- }
-}
+# 安装依赖
+pip install -r requirements.txt
```
-### 创建数据库
-mysql数据库中执行:
-```sql
-CREATE DATABASE `djangoblog` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */;
-```
+### 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
-./manage.py makemigrations
-./manage.py migrate
+python manage.py makemigrations
+python manage.py migrate
+
+# 创建一个超级管理员账户
+python manage.py createsuperuser
```
-**注意:** 在使用 `./manage.py` 之前需要确定你系统中的 `python` 命令是指向 `python 3.6` 及以上版本的。如果不是如此,请使用以下两种方式中的一种:
+### 5. 运行项目
-- 修改 `manage.py` 第一行 `#!/usr/bin/env python` 为 `#!/usr/bin/env python3`
-- 直接使用 `python3 ./manage.py makemigrations`
+```bash
+# (可选) 生成一些测试数据
+python manage.py create_testdata
-### 创建超级用户
+# (可选) 收集和压缩静态文件
+python manage.py collectstatic --noinput
+python manage.py compress --force
- 终端下执行:
-```bash
-./manage.py createsuperuser
+# 启动开发服务器
+python manage.py runserver
```
-### 创建测试数据
-终端下执行:
-```bash
-./manage.py create_testdata
-```
+现在,在您的浏览器中访问 `http://127.0.0.1:8000/`,您应该能看到 DjangoBlog 的首页了!
-### 收集静态文件
-终端下执行:
-```bash
-./manage.py collectstatic --noinput
-./manage.py compress --force
-```
+## 部署
-### 开始运行:
-执行: `./manage.py runserver`
+- **传统部署**: 我们为您准备了非常详细的 [服务器部署教程](https://www.lylinux.net/article/2019/8/5/58.html)。
+- **Docker 部署**: 项目已全面支持 Docker。如果您熟悉容器化技术,请参考 [Docker 部署文档](/docs/docker.md) 来快速启动。
+- **Kubernetes 部署**: 我们也提供了完整的 [Kubernetes 部署指南](/docs/k8s.md),助您轻松上云。
+## 🧩 插件系统
-浏览器打开: http://127.0.0.1:8000/ 就可以看到效果了。
+插件系统是 DjangoBlog 的核心特色之一。它允许您在不修改核心代码的情况下,通过编写独立的插件来为您的博客添加新功能。
-## 服务器部署
+- **工作原理**: 插件通过在预定义的“钩子”上注册回调函数来工作。例如,当一篇文章被渲染时,`after_article_body_get` 钩子会被触发,所有注册到此钩子的函数都会被执行。
+- **现有插件**: `view_count`(浏览计数), `seo_optimizer`(SEO优化)等都是通过插件系统实现的。
+- **开发您自己的插件**: 只需在 `plugins` 目录下创建一个新的文件夹,并编写您的 `plugin.py`。欢迎探索并为 DjangoBlog 社区贡献您的创意!
-本地安装部署请参考 [DjangoBlog部署教程](https://www.lylinux.net/article/2019/8/5/58.html)
-有详细的部署介绍.
+## 🤝 贡献指南
-本项目已经支持使用docker来部署,如果你有docker环境那么可以使用docker来部署,具体请参考:[docker部署](/docs/docker.md)
+我们热烈欢迎任何形式的贡献!如果您有好的想法或发现了 Bug,请随时提交 Issue 或 Pull Request。
+## 📄 许可证
+本项目基于 [MIT License](LICENSE) 开源。
-## 更多配置:
-[更多配置介绍](/docs/config.md)
-[集成elasticsearch](/docs/es.md)
+---
-## 问题相关
+## ❤️ 支持与赞助
-有任何问题欢迎提Issue,或者将问题描述发送至我邮箱 `liangliangyy#gmail.com`.我会尽快解答.推荐提交Issue方式.
+如果您觉得这个项目对您有帮助,并且希望支持我继续维护和开发新功能,欢迎请我喝杯咖啡!您的每一份支持都是我前进的最大动力。
----
- ## 致大家🙋♀️🙋♂️
- 如果本项目帮助到了你,请在[这里](https://github.com/liangliangyy/DjangoBlog/issues/214)留下你的网址,让更多的人看到。
-您的回复将会是我继续更新维护下去的动力。
+
+
+
+
+
+ (左) 支付宝 / (右) 微信
+
+## 🙏 鸣谢
-## 捐赠
-如果您觉得本项目对您有所帮助,欢迎您请我喝杯咖啡,您的支持是我最大的动力,您可以扫描下方二维码为我付款,谢谢。
-### 支付宝:
-
-
-
+特别感谢 **JetBrains** 为本项目提供的免费开源许可证。
-### 微信:
-
-
-
+
+
+
+
+
---
-
-感谢jetbrains
-
-
-
+> 如果本项目帮助到了你,请在[这里](https://github.com/liangliangyy/DjangoBlog/issues/214)留下你的网址,让更多的人看到。您的回复将会是我继续更新维护下去的动力。
diff --git a/accounts/admin.py b/accounts/admin.py
index c3fbf3532..29d162acd 100644
--- a/accounts/admin.py
+++ b/accounts/admin.py
@@ -1,6 +1,5 @@
from django import forms
from django.contrib.auth.admin import UserAdmin
-from django.contrib.auth.forms import ReadOnlyPasswordHashField
from django.contrib.auth.forms import UserChangeForm
from django.contrib.auth.forms import UsernameField
from django.utils.translation import gettext_lazy as _
@@ -10,8 +9,8 @@
class BlogUserCreationForm(forms.ModelForm):
- password1 = forms.CharField(label='密码', widget=forms.PasswordInput)
- password2 = forms.CharField(label='再次输入密码', widget=forms.PasswordInput)
+ password1 = forms.CharField(label=_('password'), widget=forms.PasswordInput)
+ password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput)
class Meta:
model = BlogUser
@@ -22,7 +21,7 @@ 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("两次密码不一致")
+ raise forms.ValidationError(_("passwords do not match"))
return password2
def save(self, commit=True):
@@ -36,16 +35,6 @@ def save(self, commit=True):
class BlogUserChangeForm(UserChangeForm):
- password = ReadOnlyPasswordHashField(
- label=_("Password"),
- help_text=_(
- "Raw passwords are not stored, so there is no way to see this "
- "user's password, but you can change the password using "
- "this form ."
- ),
- )
- email = forms.EmailField(label="Email", widget=forms.EmailInput)
-
class Meta:
model = BlogUser
fields = '__all__'
@@ -68,3 +57,4 @@ class BlogUserAdmin(UserAdmin):
'source')
list_display_links = ('id', 'username')
ordering = ('-id',)
+ search_fields = ('username', 'nickname', 'email')
diff --git a/accounts/forms.py b/accounts/forms.py
index 70c492b16..fce4137e3 100644
--- a/accounts/forms.py
+++ b/accounts/forms.py
@@ -3,7 +3,7 @@
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
@@ -33,7 +33,7 @@ def __init__(self, *args, **kwargs):
def clean_email(self):
email = self.cleaned_data['email']
if get_user_model().objects.filter(email=email).exists():
- raise ValidationError("该邮箱已经存在.")
+ raise ValidationError(_("email already exists"))
return email
class Meta:
@@ -43,11 +43,11 @@ class Meta:
class ForgetPasswordForm(forms.Form):
new_password1 = forms.CharField(
- label="新密码",
+ label=_("New password"),
widget=forms.PasswordInput(
attrs={
"class": "form-control",
- 'placeholder': "密码"
+ 'placeholder': _("New password")
}
),
)
@@ -57,7 +57,7 @@ class ForgetPasswordForm(forms.Form):
widget=forms.PasswordInput(
attrs={
"class": "form-control",
- 'placeholder': "确认密码"
+ 'placeholder': _("Confirm password")
}
),
)
@@ -67,17 +67,17 @@ class ForgetPasswordForm(forms.Form):
widget=forms.TextInput(
attrs={
'class': 'form-control',
- 'placeholder': "邮箱"
+ 'placeholder': _("Email")
}
),
)
code = forms.CharField(
- label='验证码',
+ label=_('Code'),
widget=forms.TextInput(
attrs={
'class': 'form-control',
- 'placeholder': "验证码"
+ 'placeholder': _("Code")
}
),
)
@@ -86,7 +86,7 @@ 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("两次密码不一致")
+ raise ValidationError(_("passwords do not match"))
password_validation.validate_password(password2)
return password2
@@ -97,7 +97,7 @@ def clean_email(self):
email=user_email
).exists():
# todo 这里的报错提示可以判断一个邮箱是不是注册过,如果不想暴露可以修改
- raise ValidationError("未找到邮箱对应的用户")
+ raise ValidationError(_("email does not exist"))
return user_email
def clean_code(self):
@@ -113,5 +113,5 @@ def clean_code(self):
class ForgetPasswordCodeForm(forms.Form):
email = forms.EmailField(
- label="邮箱号"
+ label=_('Email'),
)
diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py
new file mode 100644
index 000000000..d2fbcab51
--- /dev/null
+++ b/accounts/migrations/0001_initial.py
@@ -0,0 +1,49 @@
+# Generated by Django 4.1.7 on 2023-03-02 07:14
+
+import django.contrib.auth.models
+import django.contrib.auth.validators
+from django.db import migrations, models
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('auth', '0012_alter_user_first_name_max_length'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='BlogUser',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('password', models.CharField(max_length=128, verbose_name='password')),
+ ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
+ ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
+ ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
+ ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
+ ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
+ ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
+ ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
+ ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
+ ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
+ ('nickname', models.CharField(blank=True, max_length=100, verbose_name='昵称')),
+ ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
+ ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
+ ('source', models.CharField(blank=True, max_length=100, verbose_name='创建来源')),
+ ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
+ ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
+ ],
+ options={
+ 'verbose_name': '用户',
+ 'verbose_name_plural': '用户',
+ 'ordering': ['-id'],
+ 'get_latest_by': 'id',
+ },
+ managers=[
+ ('objects', django.contrib.auth.models.UserManager()),
+ ],
+ ),
+ ]
diff --git a/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py b/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py
new file mode 100644
index 000000000..1a9f50956
--- /dev/null
+++ b/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py
@@ -0,0 +1,46 @@
+# Generated by Django 4.2.5 on 2023-09-06 13:13
+
+from django.db import migrations, models
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('accounts', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='bloguser',
+ options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'user', 'verbose_name_plural': 'user'},
+ ),
+ migrations.RemoveField(
+ model_name='bloguser',
+ name='created_time',
+ ),
+ migrations.RemoveField(
+ model_name='bloguser',
+ name='last_mod_time',
+ ),
+ migrations.AddField(
+ model_name='bloguser',
+ name='creation_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
+ ),
+ migrations.AddField(
+ model_name='bloguser',
+ name='last_modify_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
+ ),
+ migrations.AlterField(
+ model_name='bloguser',
+ name='nickname',
+ field=models.CharField(blank=True, max_length=100, verbose_name='nick name'),
+ ),
+ migrations.AlterField(
+ model_name='bloguser',
+ name='source',
+ field=models.CharField(blank=True, max_length=100, verbose_name='create source'),
+ ),
+ ]
diff --git a/accounts/models.py b/accounts/models.py
index 9f7454cd3..3baddbb2f 100644
--- a/accounts/models.py
+++ b/accounts/models.py
@@ -2,17 +2,17 @@
from django.db import models
from django.urls import reverse
from django.utils.timezone import now
-
+from django.utils.translation import gettext_lazy as _
from djangoblog.utils import get_current_site
# Create your models here.
class BlogUser(AbstractUser):
- nickname = models.CharField('昵称', max_length=100, blank=True)
- created_time = models.DateTimeField('创建时间', default=now)
- last_mod_time = models.DateTimeField('修改时间', default=now)
- source = models.CharField("创建来源", max_length=100, blank=True)
+ nickname = models.CharField(_('nick name'), max_length=100, blank=True)
+ creation_time = models.DateTimeField(_('creation time'), default=now)
+ last_modify_time = models.DateTimeField(_('last modify time'), default=now)
+ source = models.CharField(_('create source'), max_length=100, blank=True)
def get_absolute_url(self):
return reverse(
@@ -30,6 +30,6 @@ def get_full_url(self):
class Meta:
ordering = ['-id']
- verbose_name = "用户"
+ verbose_name = _('user')
verbose_name_plural = verbose_name
get_latest_by = 'id'
diff --git a/accounts/tests.py b/accounts/tests.py
index ae3ae6987..6893411c5 100644
--- a/accounts/tests.py
+++ b/accounts/tests.py
@@ -1,11 +1,11 @@
-from django.conf import settings
from django.test import Client, RequestFactory, TestCase
from django.urls import reverse
from django.utils import timezone
+from django.utils.translation import gettext_lazy as _
-from djangoblog.utils import *
from accounts.models import BlogUser
from blog.models import Article, Category
+from djangoblog.utils import *
from . import utils
@@ -39,8 +39,8 @@ def test_validate_account(self):
category = Category()
category.name = "categoryaaa"
- category.created_time = timezone.now()
- category.last_mod_time = timezone.now()
+ category.creation_time = timezone.now()
+ category.last_modify_time = timezone.now()
category.save()
article = Article()
@@ -86,8 +86,8 @@ def test_validate_register(self):
delete_sidebar_cache()
category = Category()
category.name = "categoryaaa"
- category.created_time = timezone.now()
- category.last_mod_time = timezone.now()
+ category.creation_time = timezone.now()
+ category.last_modify_time = timezone.now()
category.save()
article = Article()
@@ -187,12 +187,7 @@ def test_forget_password_email_not_user(self):
)
self.assertEqual(resp.status_code, 200)
- self.assertFormError(
- response=resp,
- form="form",
- field="email",
- errors="未找到邮箱对应的用户"
- )
+
def test_forget_password_email_code_error(self):
code = generate_code()
@@ -209,9 +204,4 @@ def test_forget_password_email_code_error(self):
)
self.assertEqual(resp.status_code, 200)
- self.assertFormError(
- response=resp,
- form="form",
- field="code",
- errors="验证码错误"
- )
+
diff --git a/accounts/urls.py b/accounts/urls.py
index 0dcdc731d..107a801d1 100644
--- a/accounts/urls.py
+++ b/accounts/urls.py
@@ -1,28 +1,28 @@
-from django.conf.urls import url
from django.urls import path
+from django.urls import re_path
from . import views
from .forms import LoginForm
app_name = "accounts"
-urlpatterns = [url(r'^login/$',
- views.LoginView.as_view(success_url='/'),
- name='login',
- kwargs={'authentication_form': LoginForm}),
- url(r'^register/$',
- views.RegisterView.as_view(success_url="/"),
- name='register'),
- url(r'^logout/$',
- views.LogoutView.as_view(),
- name='logout'),
+urlpatterns = [re_path(r'^login/$',
+ views.LoginView.as_view(success_url='/'),
+ name='login',
+ kwargs={'authentication_form': LoginForm}),
+ re_path(r'^register/$',
+ views.RegisterView.as_view(success_url="/"),
+ name='register'),
+ re_path(r'^logout/$',
+ views.LogoutView.as_view(),
+ name='logout'),
path(r'account/result.html',
views.account_result,
name='result'),
- url(r'^forget_password/$',
- views.ForgetPasswordView.as_view(),
- name='forget_password'),
- url(r'^forget_password_code/$',
- views.ForgetPasswordEmailCode.as_view(),
- name='forget_password_code'),
+ re_path(r'^forget_password/$',
+ views.ForgetPasswordView.as_view(),
+ name='forget_password'),
+ re_path(r'^forget_password_code/$',
+ views.ForgetPasswordEmailCode.as_view(),
+ name='forget_password_code'),
]
diff --git a/accounts/utils.py b/accounts/utils.py
index 66886678c..4b94bdfe9 100644
--- a/accounts/utils.py
+++ b/accounts/utils.py
@@ -2,20 +2,24 @@
from datetime import timedelta
from django.core.cache import cache
+from django.utils.translation import gettext
+from django.utils.translation import gettext_lazy as _
from djangoblog.utils import send_email
_code_ttl = timedelta(minutes=5)
-def send_verify_email(to_mail: str, code: str, subject: str = "邮件验证码"):
+def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email")):
"""发送重设密码验证码
Args:
to_mail: 接受邮箱
subject: 邮件主题
code: 验证码
"""
- html_content = f"您正在重设密码,验证码为:{code}, 5分钟内有效,请妥善保管"
+ 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)
@@ -32,7 +36,7 @@ def verify(email: str, code: str) -> typing.Optional[str]:
"""
cache_code = get_code(email)
if cache_code != code:
- return "验证码错误"
+ return gettext("Verification code error")
def set_code(email: str, code: str):
diff --git a/accounts/views.py b/accounts/views.py
index 627aa2de9..ae67aec41 100644
--- a/accounts/views.py
+++ b/accounts/views.py
@@ -1,5 +1,5 @@
import logging
-
+from django.utils.translation import gettext_lazy as _
from django.conf import settings
from django.contrib import auth
from django.contrib.auth import REDIRECT_FIELD_NAME
@@ -14,7 +14,7 @@
from django.shortcuts import render
from django.urls import reverse
from django.utils.decorators import method_decorator
-from django.utils.http import is_safe_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
@@ -35,6 +35,10 @@ class RegisterView(FormView):
form_class = RegisterForm
template_name = 'account/registration_form.html'
+ @method_decorator(csrf_protect)
+ def dispatch(self, *args, **kwargs):
+ return super(RegisterView, self).dispatch(*args, **kwargs)
+
def form_valid(self, form):
if form.is_valid():
user = form.save(False)
@@ -131,7 +135,7 @@ def form_valid(self, form):
def get_success_url(self):
redirect_to = self.request.POST.get(self.redirect_field_name)
- if not is_safe_url(
+ if not url_has_allowed_host_and_scheme(
url=redirect_to, allowed_hosts=[
self.request.get_host()]):
redirect_to = self.success_url
@@ -149,8 +153,8 @@ def account_result(request):
if type and type in ['register', 'validation']:
if type == 'register':
content = '''
- 恭喜您注册成功,一封验证邮件已经发送到您 {email} 的邮箱,请验证您的邮箱后登录本站。
- '''.format(email=user.email)
+ 恭喜您注册成功,一封验证邮件已经发送到您的邮箱,请验证您的邮箱后登录本站。
+ '''
title = '注册成功'
else:
c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
diff --git a/bin/docker_start.sh b/bin/docker_start.sh
deleted file mode 100644
index 911bbeec5..000000000
--- a/bin/docker_start.sh
+++ /dev/null
@@ -1,34 +0,0 @@
-#!/usr/bin/env bash
-NAME="djangoblog" # Name of the application
-DJANGODIR=/code/djangoBlog # Django project directory
-USER=root # the user to run as
-GROUP=root # the group to run as
-NUM_WORKERS=1 # how many worker processes should Gunicorn spawn
-#DJANGO_SETTINGS_MODULE=djangoblog.settings # which settings file should Django use
-DJANGO_WSGI_MODULE=djangoblog.wsgi # WSGI module name
-
-
-echo "Starting $NAME as `whoami`"
-
-# Activate the virtual environment
-cd $DJANGODIR
-
-export PYTHONPATH=$DJANGODIR:$PYTHONPATH
-#pip install -Ur requirements.txt -i http://pypi.douban.com/simple/ --trusted-host pypi.douban.com && \
-# pip install gunicorn -i http://pypi.douban.com/simple/ --trusted-host pypi.douban.com
-python manage.py makemigrations && \
- python manage.py migrate && \
- python manage.py collectstatic --noinput && \
- python manage.py compress --force && \
- python manage.py build_index && \
-# Start your Django Unicorn
-# Programs meant to be run under supervisor should not daemonize themselves (do not use --daemon)
-exec gunicorn ${DJANGO_WSGI_MODULE}:application \
---name $NAME \
---workers $NUM_WORKERS \
---user=$USER --group=$GROUP \
---bind 0.0.0.0:8000 \
---log-level=debug \
---log-file=- \
---worker-class gevent \
---threads 4
\ No newline at end of file
diff --git a/blog/admin.py b/blog/admin.py
index f812dc6cc..69d7f8e24 100644
--- a/blog/admin.py
+++ b/blog/admin.py
@@ -3,27 +3,10 @@
from django.contrib.auth import get_user_model
from django.urls import reverse
from django.utils.html import format_html
-from django.utils.translation import ugettext_lazy as _
+from django.utils.translation import gettext_lazy as _
# Register your models here.
-from .models import Article
-
-
-class ArticleListFilter(admin.SimpleListFilter):
- title = _("作者")
- parameter_name = 'author'
-
- def lookups(self, request, model_admin):
- authors = list(set(map(lambda x: x.author, Article.objects.all())))
- for author in authors:
- yield (author.id, _(author.username))
-
- def queryset(self, request, queryset):
- id = self.value()
- if id:
- return queryset.filter(author__id__exact=id)
- else:
- return queryset
+from .models import Article, Category, Tag, Links, SideBar, BlogSettings
class ArticleForm(forms.ModelForm):
@@ -50,10 +33,10 @@ def open_article_commentstatus(modeladmin, request, queryset):
queryset.update(comment_status='o')
-makr_article_publish.short_description = '发布选中文章'
-draft_article.short_description = '选中文章设置为草稿'
-close_article_commentstatus.short_description = '关闭文章评论'
-open_article_commentstatus.short_description = '打开文章评论'
+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):
@@ -65,28 +48,30 @@ class ArticlelAdmin(admin.ModelAdmin):
'title',
'author',
'link_to_category',
- 'created_time',
+ 'creation_time',
'views',
'status',
'type',
'article_order')
list_display_links = ('id', 'title')
- list_filter = (ArticleListFilter, 'status', 'type', 'category', 'tags')
+ list_filter = ('status', 'type', 'category')
+ date_hierarchy = 'creation_time'
filter_horizontal = ('tags',)
- exclude = ('created_time', 'last_mod_time')
+ exclude = ('creation_time', 'last_modify_time')
view_on_site = True
actions = [
makr_article_publish,
draft_article,
close_article_commentstatus,
open_article_commentstatus]
+ raw_id_fields = ('author', 'category',)
def link_to_category(self, obj):
info = (obj.category._meta.app_label, obj.category._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.category.id,))
return format_html(u'%s ' % (link, obj.category.name))
- link_to_category.short_description = '分类目录'
+ link_to_category.short_description = _('category')
def get_form(self, request, obj=None, **kwargs):
form = super(ArticlelAdmin, self).get_form(request, obj, **kwargs)
@@ -108,21 +93,21 @@ def get_view_on_site_url(self, obj=None):
class TagAdmin(admin.ModelAdmin):
- exclude = ('slug', 'last_mod_time', 'created_time')
+ exclude = ('slug', 'last_mod_time', 'creation_time')
class CategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'parent_category', 'index')
- exclude = ('slug', 'last_mod_time', 'created_time')
+ exclude = ('slug', 'last_mod_time', 'creation_time')
class LinksAdmin(admin.ModelAdmin):
- exclude = ('last_mod_time', 'created_time')
+ exclude = ('last_mod_time', 'creation_time')
class SideBarAdmin(admin.ModelAdmin):
list_display = ('name', 'content', 'is_enable', 'sequence')
- exclude = ('last_mod_time', 'created_time')
+ exclude = ('last_mod_time', 'creation_time')
class BlogSettingsAdmin(admin.ModelAdmin):
diff --git a/blog/context_processors.py b/blog/context_processors.py
index 3b2862d9d..73e3088b0 100644
--- a/blog/context_processors.py
+++ b/blog/context_processors.py
@@ -1,5 +1,6 @@
import logging
-from datetime import datetime
+
+from django.utils import timezone
from djangoblog.utils import cache, get_blog_setting
from .models import Category, Article
@@ -16,7 +17,7 @@ def seo_processor(requests):
logger.info('set processor cache.')
setting = get_blog_setting()
value = {
- 'SITE_NAME': setting.sitename,
+ 'SITE_NAME': setting.site_name,
'SHOW_GOOGLE_ADSENSE': setting.show_google_adsense,
'GOOGLE_ADSENSE_CODES': setting.google_adsense_codes,
'SITE_SEO_DESCRIPTION': setting.site_seo_description,
@@ -29,10 +30,14 @@ def seo_processor(requests):
type='p',
status='p'),
'OPEN_SITE_COMMENT': setting.open_site_comment,
- 'BEIAN_CODE': setting.beiancode,
- 'ANALYTICS_CODE': setting.analyticscode,
+ '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": datetime.now().year}
+ "CURRENT_YEAR": timezone.now().year,
+ "GLOBAL_HEADER": setting.global_header,
+ "GLOBAL_FOOTER": setting.global_footer,
+ "COMMENT_NEED_REVIEW": setting.comment_need_review,
+ }
cache.set(key, value, 60 * 60 * 10)
return value
diff --git a/blog/documents.py b/blog/documents.py
index 4554775ef..0f1db7b72 100644
--- a/blog/documents.py
+++ b/blog/documents.py
@@ -185,7 +185,7 @@ def convert_to_doc(self, articles):
body=article.body,
title=article.title,
author={
- 'nikename': article.author.username,
+ 'nickname': article.author.username,
'id': article.author.id},
category={
'name': article.category.name,
diff --git a/blog/management/commands/sync_user_avatar.py b/blog/management/commands/sync_user_avatar.py
index 263734c96..d0f46127c 100644
--- a/blog/management/commands/sync_user_avatar.py
+++ b/blog/management/commands/sync_user_avatar.py
@@ -1,24 +1,47 @@
+import requests
from django.core.management.base import BaseCommand
+from django.templatetags.static import static
from djangoblog.utils import save_user_avatar
from oauth.models import OAuthUser
+from oauth.oauthmanager import get_manager_by_type
class Command(BaseCommand):
help = 'sync user avatar'
+ def test_picture(self, url):
+ try:
+ if requests.get(url, timeout=2).status_code == 200:
+ return True
+ except:
+ pass
+
def handle(self, *args, **options):
- users = OAuthUser.objects.filter(picture__isnull=False).exclude(
- picture__istartswith='https://resource.lylinux.net').all()
- self.stdout.write('开始同步{count}个用户头像'.format(count=len(users)))
+ static_url = static("../")
+ users = OAuthUser.objects.all()
+ self.stdout.write(f'开始同步{len(users)}个用户头像')
for u in users:
- self.stdout.write('开始同步:{id}'.format(id=u.nikename))
+ self.stdout.write(f'开始同步:{u.nickname}')
url = u.picture
- url = save_user_avatar(url)
+ if url:
+ if url.startswith(static_url):
+ if self.test_picture(url):
+ continue
+ else:
+ if u.metadata:
+ manage = get_manager_by_type(u.type)
+ url = manage.get_picture(u.metadata)
+ url = save_user_avatar(url)
+ else:
+ url = static('blog/img/avatar.png')
+ else:
+ url = save_user_avatar(url)
+ else:
+ url = static('blog/img/avatar.png')
if url:
self.stdout.write(
- '结束同步:{id}.url:{url}'.format(
- id=u.nikename, url=url))
+ f'结束同步:{u.nickname}.url:{url}')
u.picture = url
u.save()
self.stdout.write('结束同步')
diff --git a/blog/migrations/0001_initial.py b/blog/migrations/0001_initial.py
new file mode 100644
index 000000000..3d391b628
--- /dev/null
+++ b/blog/migrations/0001_initial.py
@@ -0,0 +1,137 @@
+# Generated by Django 4.1.7 on 2023-03-02 07:14
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+import mdeditor.fields
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='BlogSettings',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('sitename', models.CharField(default='', max_length=200, verbose_name='网站名称')),
+ ('site_description', models.TextField(default='', max_length=1000, verbose_name='网站描述')),
+ ('site_seo_description', models.TextField(default='', max_length=1000, verbose_name='网站SEO描述')),
+ ('site_keywords', models.TextField(default='', max_length=1000, verbose_name='网站关键字')),
+ ('article_sub_length', models.IntegerField(default=300, verbose_name='文章摘要长度')),
+ ('sidebar_article_count', models.IntegerField(default=10, verbose_name='侧边栏文章数目')),
+ ('sidebar_comment_count', models.IntegerField(default=5, verbose_name='侧边栏评论数目')),
+ ('article_comment_count', models.IntegerField(default=5, verbose_name='文章页面默认显示评论数目')),
+ ('show_google_adsense', models.BooleanField(default=False, verbose_name='是否显示谷歌广告')),
+ ('google_adsense_codes', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='广告内容')),
+ ('open_site_comment', models.BooleanField(default=True, verbose_name='是否打开网站评论功能')),
+ ('beiancode', models.CharField(blank=True, default='', max_length=2000, null=True, verbose_name='备案号')),
+ ('analyticscode', models.TextField(default='', max_length=1000, verbose_name='网站统计代码')),
+ ('show_gongan_code', models.BooleanField(default=False, verbose_name='是否显示公安备案号')),
+ ('gongan_beiancode', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='公安备案号')),
+ ],
+ options={
+ 'verbose_name': '网站配置',
+ 'verbose_name_plural': '网站配置',
+ },
+ ),
+ migrations.CreateModel(
+ name='Links',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=30, unique=True, verbose_name='链接名称')),
+ ('link', models.URLField(verbose_name='链接地址')),
+ ('sequence', models.IntegerField(unique=True, verbose_name='排序')),
+ ('is_enable', models.BooleanField(default=True, verbose_name='是否显示')),
+ ('show_type', models.CharField(choices=[('i', '首页'), ('l', '列表页'), ('p', '文章页面'), ('a', '全站'), ('s', '友情链接页面')], default='i', max_length=1, verbose_name='显示类型')),
+ ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
+ ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
+ ],
+ options={
+ 'verbose_name': '友情链接',
+ 'verbose_name_plural': '友情链接',
+ 'ordering': ['sequence'],
+ },
+ ),
+ migrations.CreateModel(
+ name='SideBar',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=100, verbose_name='标题')),
+ ('content', models.TextField(verbose_name='内容')),
+ ('sequence', models.IntegerField(unique=True, verbose_name='排序')),
+ ('is_enable', models.BooleanField(default=True, verbose_name='是否启用')),
+ ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
+ ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
+ ],
+ options={
+ 'verbose_name': '侧边栏',
+ 'verbose_name_plural': '侧边栏',
+ 'ordering': ['sequence'],
+ },
+ ),
+ migrations.CreateModel(
+ name='Tag',
+ fields=[
+ ('id', models.AutoField(primary_key=True, serialize=False)),
+ ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
+ ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
+ ('name', models.CharField(max_length=30, unique=True, verbose_name='标签名')),
+ ('slug', models.SlugField(blank=True, default='no-slug', max_length=60)),
+ ],
+ options={
+ 'verbose_name': '标签',
+ 'verbose_name_plural': '标签',
+ 'ordering': ['name'],
+ },
+ ),
+ migrations.CreateModel(
+ name='Category',
+ fields=[
+ ('id', models.AutoField(primary_key=True, serialize=False)),
+ ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
+ ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
+ ('name', models.CharField(max_length=30, unique=True, verbose_name='分类名')),
+ ('slug', models.SlugField(blank=True, default='no-slug', max_length=60)),
+ ('index', models.IntegerField(default=0, verbose_name='权重排序-越大越靠前')),
+ ('parent_category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='父级分类')),
+ ],
+ options={
+ 'verbose_name': '分类',
+ 'verbose_name_plural': '分类',
+ 'ordering': ['-index'],
+ },
+ ),
+ migrations.CreateModel(
+ name='Article',
+ fields=[
+ ('id', models.AutoField(primary_key=True, serialize=False)),
+ ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
+ ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
+ ('title', models.CharField(max_length=200, unique=True, verbose_name='标题')),
+ ('body', mdeditor.fields.MDTextField(verbose_name='正文')),
+ ('pub_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='发布时间')),
+ ('status', models.CharField(choices=[('d', '草稿'), ('p', '发表')], default='p', max_length=1, verbose_name='文章状态')),
+ ('comment_status', models.CharField(choices=[('o', '打开'), ('c', '关闭')], default='o', max_length=1, verbose_name='评论状态')),
+ ('type', models.CharField(choices=[('a', '文章'), ('p', '页面')], default='a', max_length=1, verbose_name='类型')),
+ ('views', models.PositiveIntegerField(default=0, verbose_name='浏览量')),
+ ('article_order', models.IntegerField(default=0, verbose_name='排序,数字越大越靠前')),
+ ('show_toc', models.BooleanField(default=False, verbose_name='是否显示toc目录')),
+ ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')),
+ ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='分类')),
+ ('tags', models.ManyToManyField(blank=True, to='blog.tag', verbose_name='标签集合')),
+ ],
+ options={
+ 'verbose_name': '文章',
+ 'verbose_name_plural': '文章',
+ 'ordering': ['-article_order', '-pub_time'],
+ 'get_latest_by': 'id',
+ },
+ ),
+ ]
diff --git a/blog/migrations/0002_blogsettings_global_footer_and_more.py b/blog/migrations/0002_blogsettings_global_footer_and_more.py
new file mode 100644
index 000000000..adbaa36bf
--- /dev/null
+++ b/blog/migrations/0002_blogsettings_global_footer_and_more.py
@@ -0,0 +1,23 @@
+# Generated by Django 4.1.7 on 2023-03-29 06:08
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('blog', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='blogsettings',
+ name='global_footer',
+ field=models.TextField(blank=True, default='', null=True, verbose_name='公共尾部'),
+ ),
+ migrations.AddField(
+ model_name='blogsettings',
+ name='global_header',
+ field=models.TextField(blank=True, default='', null=True, verbose_name='公共头部'),
+ ),
+ ]
diff --git a/blog/migrations/0003_blogsettings_comment_need_review.py b/blog/migrations/0003_blogsettings_comment_need_review.py
new file mode 100644
index 000000000..e9f55024d
--- /dev/null
+++ b/blog/migrations/0003_blogsettings_comment_need_review.py
@@ -0,0 +1,17 @@
+# Generated by Django 4.2.1 on 2023-05-09 07:45
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('blog', '0002_blogsettings_global_footer_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='blogsettings',
+ name='comment_need_review',
+ field=models.BooleanField(default=False, verbose_name='评论是否需要审核'),
+ ),
+ ]
diff --git a/blog/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py b/blog/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py
new file mode 100644
index 000000000..ceb139823
--- /dev/null
+++ b/blog/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py
@@ -0,0 +1,27 @@
+# Generated by Django 4.2.1 on 2023-05-09 07:51
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('blog', '0003_blogsettings_comment_need_review'),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name='blogsettings',
+ old_name='analyticscode',
+ new_name='analytics_code',
+ ),
+ migrations.RenameField(
+ model_name='blogsettings',
+ old_name='beiancode',
+ new_name='beian_code',
+ ),
+ migrations.RenameField(
+ model_name='blogsettings',
+ old_name='sitename',
+ new_name='site_name',
+ ),
+ ]
diff --git a/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py b/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py
new file mode 100644
index 000000000..d08e85341
--- /dev/null
+++ b/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py
@@ -0,0 +1,300 @@
+# Generated by Django 4.2.5 on 2023-09-06 13:13
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+import mdeditor.fields
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('blog', '0004_rename_analyticscode_blogsettings_analytics_code_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='article',
+ options={'get_latest_by': 'id', 'ordering': ['-article_order', '-pub_time'], 'verbose_name': 'article', 'verbose_name_plural': 'article'},
+ ),
+ migrations.AlterModelOptions(
+ name='category',
+ options={'ordering': ['-index'], 'verbose_name': 'category', 'verbose_name_plural': 'category'},
+ ),
+ migrations.AlterModelOptions(
+ name='links',
+ options={'ordering': ['sequence'], 'verbose_name': 'link', 'verbose_name_plural': 'link'},
+ ),
+ migrations.AlterModelOptions(
+ name='sidebar',
+ options={'ordering': ['sequence'], 'verbose_name': 'sidebar', 'verbose_name_plural': 'sidebar'},
+ ),
+ migrations.AlterModelOptions(
+ name='tag',
+ options={'ordering': ['name'], 'verbose_name': 'tag', 'verbose_name_plural': 'tag'},
+ ),
+ migrations.RemoveField(
+ model_name='article',
+ name='created_time',
+ ),
+ migrations.RemoveField(
+ model_name='article',
+ name='last_mod_time',
+ ),
+ migrations.RemoveField(
+ model_name='category',
+ name='created_time',
+ ),
+ migrations.RemoveField(
+ model_name='category',
+ name='last_mod_time',
+ ),
+ migrations.RemoveField(
+ model_name='links',
+ name='created_time',
+ ),
+ migrations.RemoveField(
+ model_name='sidebar',
+ name='created_time',
+ ),
+ migrations.RemoveField(
+ model_name='tag',
+ name='created_time',
+ ),
+ migrations.RemoveField(
+ model_name='tag',
+ name='last_mod_time',
+ ),
+ migrations.AddField(
+ model_name='article',
+ name='creation_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
+ ),
+ migrations.AddField(
+ model_name='article',
+ name='last_modify_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
+ ),
+ migrations.AddField(
+ model_name='category',
+ name='creation_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
+ ),
+ migrations.AddField(
+ model_name='category',
+ name='last_modify_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
+ ),
+ migrations.AddField(
+ model_name='links',
+ name='creation_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
+ ),
+ migrations.AddField(
+ model_name='sidebar',
+ name='creation_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
+ ),
+ migrations.AddField(
+ model_name='tag',
+ name='creation_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
+ ),
+ migrations.AddField(
+ model_name='tag',
+ name='last_modify_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='article_order',
+ field=models.IntegerField(default=0, verbose_name='order'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='author',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='body',
+ field=mdeditor.fields.MDTextField(verbose_name='body'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='category',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='category'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='comment_status',
+ field=models.CharField(choices=[('o', 'Open'), ('c', 'Close')], default='o', max_length=1, verbose_name='comment status'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='pub_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='publish time'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='show_toc',
+ field=models.BooleanField(default=False, verbose_name='show toc'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='status',
+ field=models.CharField(choices=[('d', 'Draft'), ('p', 'Published')], default='p', max_length=1, verbose_name='status'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='tags',
+ field=models.ManyToManyField(blank=True, to='blog.tag', verbose_name='tag'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='title',
+ field=models.CharField(max_length=200, unique=True, verbose_name='title'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='type',
+ field=models.CharField(choices=[('a', 'Article'), ('p', 'Page')], default='a', max_length=1, verbose_name='type'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='views',
+ field=models.PositiveIntegerField(default=0, verbose_name='views'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='article_comment_count',
+ field=models.IntegerField(default=5, verbose_name='article comment count'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='article_sub_length',
+ field=models.IntegerField(default=300, verbose_name='article sub length'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='google_adsense_codes',
+ field=models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='adsense code'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='open_site_comment',
+ field=models.BooleanField(default=True, verbose_name='open site comment'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='show_google_adsense',
+ field=models.BooleanField(default=False, verbose_name='show adsense'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='sidebar_article_count',
+ field=models.IntegerField(default=10, verbose_name='sidebar article count'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='sidebar_comment_count',
+ field=models.IntegerField(default=5, verbose_name='sidebar comment count'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='site_description',
+ field=models.TextField(default='', max_length=1000, verbose_name='site description'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='site_keywords',
+ field=models.TextField(default='', max_length=1000, verbose_name='site keywords'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='site_name',
+ field=models.CharField(default='', max_length=200, verbose_name='site name'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='site_seo_description',
+ field=models.TextField(default='', max_length=1000, verbose_name='site seo description'),
+ ),
+ migrations.AlterField(
+ model_name='category',
+ name='index',
+ field=models.IntegerField(default=0, verbose_name='index'),
+ ),
+ migrations.AlterField(
+ model_name='category',
+ name='name',
+ field=models.CharField(max_length=30, unique=True, verbose_name='category name'),
+ ),
+ migrations.AlterField(
+ model_name='category',
+ name='parent_category',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='parent category'),
+ ),
+ migrations.AlterField(
+ model_name='links',
+ name='is_enable',
+ field=models.BooleanField(default=True, verbose_name='is show'),
+ ),
+ migrations.AlterField(
+ model_name='links',
+ name='last_mod_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
+ ),
+ migrations.AlterField(
+ model_name='links',
+ name='link',
+ field=models.URLField(verbose_name='link'),
+ ),
+ migrations.AlterField(
+ model_name='links',
+ name='name',
+ field=models.CharField(max_length=30, unique=True, verbose_name='link name'),
+ ),
+ migrations.AlterField(
+ model_name='links',
+ name='sequence',
+ field=models.IntegerField(unique=True, verbose_name='order'),
+ ),
+ migrations.AlterField(
+ model_name='links',
+ name='show_type',
+ field=models.CharField(choices=[('i', 'index'), ('l', 'list'), ('p', 'post'), ('a', 'all'), ('s', 'slide')], default='i', max_length=1, verbose_name='show type'),
+ ),
+ migrations.AlterField(
+ model_name='sidebar',
+ name='content',
+ field=models.TextField(verbose_name='content'),
+ ),
+ migrations.AlterField(
+ model_name='sidebar',
+ name='is_enable',
+ field=models.BooleanField(default=True, verbose_name='is enable'),
+ ),
+ migrations.AlterField(
+ model_name='sidebar',
+ name='last_mod_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
+ ),
+ migrations.AlterField(
+ model_name='sidebar',
+ name='name',
+ field=models.CharField(max_length=100, verbose_name='title'),
+ ),
+ migrations.AlterField(
+ model_name='sidebar',
+ name='sequence',
+ field=models.IntegerField(unique=True, verbose_name='order'),
+ ),
+ migrations.AlterField(
+ model_name='tag',
+ name='name',
+ field=models.CharField(max_length=30, unique=True, verbose_name='tag name'),
+ ),
+ ]
diff --git a/blog/migrations/0006_alter_blogsettings_options.py b/blog/migrations/0006_alter_blogsettings_options.py
new file mode 100644
index 000000000..e36feb4cf
--- /dev/null
+++ b/blog/migrations/0006_alter_blogsettings_options.py
@@ -0,0 +1,17 @@
+# Generated by Django 4.2.7 on 2024-01-26 02:41
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('blog', '0005_alter_article_options_alter_category_options_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='blogsettings',
+ options={'verbose_name': 'Website configuration', 'verbose_name_plural': 'Website configuration'},
+ ),
+ ]
diff --git a/blog/models.py b/blog/models.py
index 60a89c027..083788bb8 100644
--- a/blog/models.py
+++ b/blog/models.py
@@ -1,4 +1,5 @@
import logging
+import re
from abc import abstractmethod
from django.conf import settings
@@ -17,17 +18,17 @@
class LinkShowType(models.TextChoices):
- I = ('i', '首页')
- L = ('l', '列表页')
- P = ('p', '文章页面')
- A = ('a', '全站')
- S = ('s', '友情链接页面')
+ 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)
- created_time = models.DateTimeField('创建时间', default=now)
- last_mod_time = models.DateTimeField('修改时间', default=now)
+ 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(
@@ -60,49 +61,49 @@ def get_absolute_url(self):
class Article(BaseModel):
"""文章"""
STATUS_CHOICES = (
- ('d', '草稿'),
- ('p', '发表'),
+ ('d', _('Draft')),
+ ('p', _('Published')),
)
COMMENT_STATUS = (
- ('o', '打开'),
- ('c', '关闭'),
+ ('o', _('Open')),
+ ('c', _('Close')),
)
TYPE = (
- ('a', '文章'),
- ('p', '页面'),
+ ('a', _('Article')),
+ ('p', _('Page')),
)
- title = models.CharField('标题', max_length=200, unique=True)
- body = MDTextField('正文')
+ title = models.CharField(_('title'), max_length=200, unique=True)
+ body = MDTextField(_('body'))
pub_time = models.DateTimeField(
- '发布时间', blank=False, null=False, default=now)
+ _('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('类型', max_length=1, choices=TYPE, default='a')
- views = models.PositiveIntegerField('浏览量', default=0)
+ 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='作者',
+ verbose_name=_('author'),
blank=False,
null=False,
on_delete=models.CASCADE)
article_order = models.IntegerField(
- '排序,数字越大越靠前', blank=False, null=False, default=0)
- show_toc = models.BooleanField("是否显示toc目录", blank=False, null=False, default=False)
+ _('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='分类',
+ verbose_name=_('category'),
on_delete=models.CASCADE,
blank=False,
null=False)
- tags = models.ManyToManyField('Tag', verbose_name='标签集合', blank=True)
+ tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True)
def body_to_string(self):
return self.body
@@ -112,16 +113,16 @@ def __str__(self):
class Meta:
ordering = ['-article_order', '-pub_time']
- verbose_name = "文章"
+ verbose_name = _('article')
verbose_name_plural = verbose_name
get_latest_by = 'id'
def get_absolute_url(self):
return reverse('blog:detailbyid', kwargs={
'article_id': self.id,
- 'year': self.created_time.year,
- 'month': self.created_time.month,
- 'day': self.created_time.day
+ 'year': self.creation_time.year,
+ 'month': self.creation_time.month,
+ 'day': self.creation_time.day
})
@cache_decorator(60 * 60 * 10)
@@ -145,7 +146,7 @@ def comment_list(self):
logger.info('get article comments:{id}'.format(id=self.id))
return value
else:
- comments = self.comment_set.filter(is_enable=True)
+ comments = self.comment_set.filter(is_enable=True).order_by('-id')
cache.set(cache_key, comments, 60 * 100)
logger.info('set article comments:{id}'.format(id=self.id))
return comments
@@ -165,22 +166,32 @@ def prev_article(self):
# 前一篇
return Article.objects.filter(id__lt=self.id, status='p').first()
+ def get_first_image_url(self):
+ """
+ Get the first image url from article.body.
+ :return:
+ """
+ match = re.search(r'!\[.*?\]\((.+?)\)', self.body)
+ if match:
+ return match.group(1)
+ return ""
+
class Category(BaseModel):
"""文章分类"""
- name = models.CharField('分类名', max_length=30, unique=True)
+ name = models.CharField(_('category name'), max_length=30, unique=True)
parent_category = models.ForeignKey(
'self',
- verbose_name="父级分类",
+ verbose_name=_('parent category'),
blank=True,
null=True,
on_delete=models.CASCADE)
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
- index = models.IntegerField(default=0, verbose_name="权重排序-越大越靠前")
+ index = models.IntegerField(default=0, verbose_name=_('index'))
class Meta:
ordering = ['-index']
- verbose_name = "分类"
+ verbose_name = _('category')
verbose_name_plural = verbose_name
def get_absolute_url(self):
@@ -231,7 +242,7 @@ def parse(category):
class Tag(BaseModel):
"""文章标签"""
- name = models.CharField('标签名', max_length=30, unique=True)
+ name = models.CharField(_('tag name'), max_length=30, unique=True)
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
def __str__(self):
@@ -246,29 +257,29 @@ def get_article_count(self):
class Meta:
ordering = ['name']
- verbose_name = "标签"
+ verbose_name = _('tag')
verbose_name_plural = verbose_name
class Links(models.Model):
"""友情链接"""
- name = models.CharField('链接名称', max_length=30, unique=True)
- link = models.URLField('链接地址')
- sequence = models.IntegerField('排序', unique=True)
+ name = models.CharField(_('link name'), max_length=30, unique=True)
+ link = models.URLField(_('link'))
+ sequence = models.IntegerField(_('order'), unique=True)
is_enable = models.BooleanField(
- '是否显示', default=True, blank=False, null=False)
+ _('is show'), default=True, blank=False, null=False)
show_type = models.CharField(
- '显示类型',
+ _('show type'),
max_length=1,
choices=LinkShowType.choices,
default=LinkShowType.I)
- created_time = models.DateTimeField('创建时间', default=now)
- last_mod_time = models.DateTimeField('修改时间', default=now)
+ creation_time = models.DateTimeField(_('creation time'), default=now)
+ last_mod_time = models.DateTimeField(_('modify time'), default=now)
class Meta:
ordering = ['sequence']
- verbose_name = '友情链接'
+ verbose_name = _('link')
verbose_name_plural = verbose_name
def __str__(self):
@@ -277,16 +288,16 @@ def __str__(self):
class SideBar(models.Model):
"""侧边栏,可以展示一些html内容"""
- name = models.CharField('标题', max_length=100)
- content = models.TextField("内容")
- sequence = models.IntegerField('排序', unique=True)
- is_enable = models.BooleanField('是否启用', default=True)
- created_time = models.DateTimeField('创建时间', default=now)
- last_mod_time = models.DateTimeField('修改时间', default=now)
+ 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 = '侧边栏'
+ verbose_name = _('sidebar')
verbose_name_plural = verbose_name
def __str__(self):
@@ -294,41 +305,44 @@ def __str__(self):
class BlogSettings(models.Model):
- '''站点设置 '''
- sitename = models.CharField(
- "网站名称",
+ """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(
- "网站SEO描述", max_length=1000, null=False, blank=False, default='')
+ _('site seo description'), max_length=1000, null=False, blank=False, default='')
site_keywords = models.TextField(
- "网站关键字",
+ _('site keywords'),
max_length=1000,
null=False,
blank=False,
default='')
- article_sub_length = models.IntegerField("文章摘要长度", default=300)
- sidebar_article_count = models.IntegerField("侧边栏文章数目", default=10)
- sidebar_comment_count = models.IntegerField("侧边栏评论数目", default=5)
- show_google_adsense = models.BooleanField('是否显示谷歌广告', default=False)
+ article_sub_length = models.IntegerField(_('article sub length'), default=300)
+ sidebar_article_count = models.IntegerField(_('sidebar article count'), default=10)
+ sidebar_comment_count = models.IntegerField(_('sidebar comment count'), default=5)
+ article_comment_count = models.IntegerField(_('article comment count'), default=5)
+ show_google_adsense = models.BooleanField(_('show adsense'), default=False)
google_adsense_codes = models.TextField(
- '广告内容', max_length=2000, null=True, blank=True, default='')
- open_site_comment = models.BooleanField('是否打开网站评论功能', default=True)
- beiancode = models.CharField(
+ _('adsense code'), max_length=2000, null=True, blank=True, default='')
+ open_site_comment = models.BooleanField(_('open site comment'), default=True)
+ global_header = models.TextField("公共头部", null=True, blank=True, default='')
+ global_footer = models.TextField("公共尾部", null=True, blank=True, default='')
+ beian_code = models.CharField(
'备案号',
max_length=2000,
null=True,
blank=True,
default='')
- analyticscode = models.TextField(
+ analytics_code = models.TextField(
"网站统计代码",
max_length=1000,
null=False,
@@ -342,22 +356,19 @@ class BlogSettings(models.Model):
null=True,
blank=True,
default='')
- resource_path = models.CharField(
- "静态文件保存地址",
- max_length=300,
- null=False,
- default='/var/www/resource/')
+ comment_need_review = models.BooleanField(
+ '评论是否需要审核', default=False, null=False)
class Meta:
- verbose_name = '网站配置'
+ verbose_name = _('Website configuration')
verbose_name_plural = verbose_name
def __str__(self):
- return self.sitename
+ return self.site_name
def clean(self):
if BlogSettings.objects.exclude(id=self.id).count():
- raise ValidationError(_('只能有一个配置'))
+ raise ValidationError(_('There can only be one configuration'))
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
diff --git a/blog/static/blog/css/style.css b/blog/static/blog/css/style.css
index d43f7f38f..cdbd790b4 100644
--- a/blog/static/blog/css/style.css
+++ b/blog/static/blog/css/style.css
@@ -2017,12 +2017,7 @@ img#wpstats {
width: auto;
}
- .commentlist .avatar {
- height: 39px;
- left: 2.2em;
- top: 2.2em;
- width: 39px;
- }
+
.comments-area article header cite,
.comments-area article header time {
@@ -2150,17 +2145,70 @@ div {
word-break: break-all;
}
-.commentlist .comment-author,
-.commentlist .comment-meta,
+/* 评论整体布局 - 使用相对定位实现头像左侧布局 */
+.commentlist .comment-body {
+ position: relative;
+ padding-left: 60px; /* 为48px头像 + 12px间距留出空间 */
+ min-height: 48px; /* 确保有足够高度容纳头像 */
+}
+
+/* 评论作者信息 - 用户名和时间在同一行 */
+.commentlist .comment-author {
+ display: inline-block;
+ margin: 0 10px 5px 0;
+ font-size: 13px;
+ position: relative;
+}
+
+.commentlist .comment-meta {
+ display: inline-block;
+ margin: 0 0 8px 0;
+ font-size: 12px;
+ color: #666;
+}
+
.commentlist .comment-awaiting-moderation {
- float: left;
display: block;
font-size: 13px;
line-height: 22px;
}
-.commentlist .comment-author {
- margin-right: 6px;
+/* 头像样式 - 绝对定位到左侧 */
+.commentlist .comment-author .avatar {
+ position: absolute !important;
+ left: -60px; /* 定位到容器左侧 */
+ top: 0;
+ width: 48px !important;
+ height: 48px !important;
+ border-radius: 50%;
+ display: block;
+ object-fit: cover;
+ background-color: #f5f5f5;
+ border: 1px solid #ddd;
+}
+
+/* 评论作者名称样式 */
+.commentlist .comment-author .fn {
+ display: inline;
+ margin: 0;
+ font-weight: 600;
+ color: #2e7bb8;
+ font-size: 13px;
+}
+
+.commentlist .comment-author .fn a {
+ color: #2e7bb8;
+ text-decoration: none;
+}
+
+.commentlist .comment-author .fn a:hover {
+ text-decoration: underline;
+}
+
+/* 评论内容样式 */
+.commentlist .comment-body p {
+ margin: 5px 0 10px 0;
+ line-height: 1.5;
}
.commentlist .fn, .pinglist .ping-link {
@@ -2174,13 +2222,15 @@ div {
display: none;
}
+/* 通用头像样式 */
.commentlist .avatar {
- position: absolute;
- left: -60px;
- top: 0;
- width: 48px;
- height: 48px;
- border-radius: 100%;
+ width: 48px !important;
+ height: 48px !important;
+ border-radius: 50%;
+ display: block;
+ object-fit: cover;
+ background-color: #f5f5f5;
+ border: 1px solid #ddd;
}
.commentlist .comment-meta:before, .pinglist .ping-meta:before {
@@ -2290,15 +2340,87 @@ div {
padding-left: 48px;
}
-.commentlist li li .avatar {
- top: 0;
- left: -48px;
- width: 36px;
- height: 36px;
+/* 嵌套评论整体布局 */
+.commentlist li li .comment-body {
+ padding-left: 60px; /* 为48px头像 + 12px间距留出空间 */
+ min-height: 48px; /* 确保有足够高度容纳头像 */
+}
+
+/* 嵌套评论作者信息 */
+.commentlist li li .comment-author {
+ display: inline-block;
+ margin: 0 8px 5px 0;
+ font-size: 12px; /* 稍小一点 */
}
.commentlist li li .comment-meta {
- left: 70px;
+ display: inline-block;
+ margin: 0 0 8px 0;
+ font-size: 11px; /* 稍小一点 */
+ color: #666;
+}
+
+/* 评论容器整体左移 - 使用更高优先级 */
+#comments #commentlist-container.comment-tab {
+ margin-left: -15px !important; /* 在小屏幕上向左移动15px */
+ padding-left: 0 !important; /* 移除左内边距 */
+ position: relative !important; /* 确保定位正确 */
+}
+
+/* 在较大屏幕上进一步左移 */
+@media screen and (min-width: 600px) {
+ #comments #commentlist-container.comment-tab {
+ margin-left: -30px !important; /* 在大屏幕上向左移动30px */
+ }
+
+ /* 响应式设计下的评论布局 - 保持48px头像 */
+ .commentlist .comment-body {
+ padding-left: 60px !important; /* 为48px头像 + 12px间距留出空间 */
+ min-height: 48px !important;
+ }
+
+ .commentlist .comment-author {
+ display: inline-block !important;
+ margin: 0 8px 5px 0 !important;
+ }
+
+ .commentlist .comment-meta {
+ display: inline-block !important;
+ margin: 0 0 8px 0 !important;
+ }
+
+ /* 响应式设计下头像保持48px */
+ .commentlist .comment-author .avatar {
+ left: -60px !important;
+ width: 48px !important;
+ height: 48px !important;
+ }
+
+ /* 嵌套评论在响应式设计下也保持48px头像 */
+ .commentlist li li .comment-body {
+ padding-left: 60px !important;
+ min-height: 48px !important;
+ }
+
+ .commentlist li li .comment-author .avatar {
+ left: -60px !important;
+ width: 48px !important;
+ height: 48px !important;
+ }
+}
+
+/* 嵌套评论头像 */
+.commentlist li li .comment-author .avatar {
+ position: absolute !important;
+ left: -60px; /* 定位到容器左侧 */
+ top: 0;
+ width: 48px !important;
+ height: 48px !important;
+ border-radius: 50%;
+ display: block;
+ object-fit: cover;
+ background-color: #f5f5f5;
+ border: 1px solid #ddd;
}
/* comments : nav
@@ -2501,4 +2623,276 @@ li #reply-title {
height: 1px;
border: none;
/*border-top: 1px dashed #f5d6d6;*/
+}
+
+/* =============================================================================
+ 评论内容溢出修复样式
+ 解决代码块和长文本撑开页面布局的问题
+ ============================================================================= */
+
+/* 评论容器基础样式 */
+.comment-body {
+ overflow-wrap: break-word;
+ word-wrap: break-word;
+ word-break: break-word;
+ max-width: 100%;
+ box-sizing: border-box;
+}
+
+/* 修复评论中的代码块溢出 */
+.comment-content pre,
+.comment-body pre {
+ white-space: pre-wrap !important;
+ word-wrap: break-word !important;
+ overflow-wrap: break-word !important;
+ max-width: 100% !important;
+ overflow-x: auto;
+ padding: 10px;
+ background-color: #f8f8f8;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ font-size: 12px;
+ line-height: 1.4;
+ margin: 10px 0;
+}
+
+/* 修复评论中的行内代码 */
+.comment-content code,
+.comment-body code {
+ word-wrap: break-word !important;
+ overflow-wrap: break-word !important;
+ white-space: pre-wrap;
+ max-width: 100%;
+ display: inline-block;
+ vertical-align: top;
+}
+
+/* 修复评论中的长链接 */
+.comment-content a,
+.comment-body a {
+ word-wrap: break-word !important;
+ overflow-wrap: break-word !important;
+ word-break: break-all;
+ max-width: 100%;
+}
+
+/* 修复评论段落 */
+.comment-content p,
+.comment-body p {
+ word-wrap: break-word !important;
+ overflow-wrap: break-word !important;
+ max-width: 100%;
+ margin: 10px 0;
+}
+
+/* 特殊处理代码高亮块 - 关键修复! */
+.comment-content .codehilite,
+.comment-body .codehilite {
+ max-width: 100% !important;
+ overflow-x: auto;
+ margin: 10px 0;
+ background: #f8f8f8 !important;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ padding: 10px;
+ font-size: 12px;
+ line-height: 1.4;
+ /* 关键:防止内容撑开容器 */
+ width: 100%;
+ box-sizing: border-box;
+ display: block;
+}
+
+.comment-content .codehilite pre,
+.comment-body .codehilite pre {
+ white-space: pre-wrap !important;
+ word-wrap: break-word !important;
+ overflow-wrap: break-word !important;
+ margin: 0 !important;
+ padding: 0 !important;
+ background: transparent !important;
+ border: none !important;
+ font-size: inherit;
+ line-height: inherit;
+ /* 确保pre标签不会超出父容器 */
+ max-width: 100%;
+ width: 100%;
+ box-sizing: border-box;
+}
+
+/* 修复代码高亮中的span标签 */
+.comment-content .codehilite span,
+.comment-body .codehilite span {
+ word-wrap: break-word !important;
+ overflow-wrap: break-word !important;
+ /* 防止行内元素导致的溢出 */
+ display: inline;
+ max-width: 100%;
+}
+
+/* 针对特定的代码高亮类 */
+.comment-content .codehilite .kt,
+.comment-content .codehilite .nf,
+.comment-content .codehilite .n,
+.comment-content .codehilite .p,
+.comment-body .codehilite .kt,
+.comment-body .codehilite .nf,
+.comment-body .codehilite .n,
+.comment-body .codehilite .p {
+ word-wrap: break-word !important;
+ overflow-wrap: break-word !important;
+}
+
+/* 搜索结果高亮样式 */
+.search-result {
+ margin-bottom: 30px;
+ padding: 20px;
+ border: 1px solid #e1e1e1;
+ border-radius: 5px;
+ background: #fff;
+}
+
+.search-result .entry-title {
+ margin: 0 0 10px 0;
+ font-size: 1.5em;
+}
+
+.search-result .entry-title a {
+ color: #2c3e50;
+ text-decoration: none;
+}
+
+.search-result .entry-title a:hover {
+ color: #3498db;
+}
+
+.search-result .entry-meta {
+ color: #7f8c8d;
+ font-size: 0.9em;
+ margin-bottom: 15px;
+}
+
+.search-result .entry-meta span {
+ margin-right: 15px;
+}
+
+.search-excerpt {
+ line-height: 1.6;
+ color: #555;
+}
+
+.search-excerpt p {
+ margin: 10px 0;
+}
+
+/* 搜索关键词高亮 */
+.search-excerpt em,
+.search-result .entry-title em {
+ background-color: #fff3cd;
+ color: #856404;
+ font-style: normal;
+ font-weight: bold;
+ padding: 2px 4px;
+ border-radius: 3px;
+}
+
+.more-link {
+ color: #3498db;
+ text-decoration: none;
+ font-weight: bold;
+}
+
+.more-link:hover {
+ text-decoration: underline;
+}
+.comment-content .codehilite .w,
+.comment-content .codehilite .o,
+.comment-body .codehilite .kt,
+.comment-body .codehilite .nf,
+.comment-body .codehilite .n,
+.comment-body .codehilite .p,
+.comment-body .codehilite .w,
+.comment-body .codehilite .o {
+ word-break: break-all;
+ overflow-wrap: break-word;
+}
+
+/* 修复评论列表项 */
+.commentlist li {
+ max-width: 100%;
+ overflow: hidden;
+ box-sizing: border-box;
+}
+
+/* 确保评论内容不超出容器 */
+.commentlist .comment-body {
+ max-width: calc(100% - 20px); /* 留出一些边距 */
+ margin-left: 10px;
+ margin-right: 10px;
+ overflow: hidden; /* 防止内容溢出 */
+ word-wrap: break-word;
+}
+
+/* 重要:限制评论列表项的最大宽度 */
+.commentlist li[style*="margin-left"] {
+ max-width: calc(100% - 2rem) !important;
+ overflow: hidden;
+ box-sizing: border-box;
+}
+
+/* 特别处理深层嵌套的评论 */
+.commentlist li[style*="margin-left: 3rem"],
+.commentlist li[style*="margin-left: 6rem"],
+.commentlist li[style*="margin-left: 9rem"] {
+ max-width: calc(100% - 1rem) !important;
+}
+
+/* 移动端优化 */
+@media (max-width: 768px) {
+ .comment-content pre,
+ .comment-body pre {
+ font-size: 11px;
+ padding: 8px;
+ margin: 8px 0;
+ }
+
+ .commentlist .comment-body {
+ max-width: calc(100% - 10px);
+ margin-left: 5px;
+ margin-right: 5px;
+ }
+
+ /* 移动端评论缩进调整 */
+ .commentlist li[style*="margin-left"] {
+ margin-left: 1rem !important;
+ max-margin-left: 2rem !important;
+ }
+}
+
+/* 防止表格溢出 */
+.comment-content table,
+.comment-body table {
+ max-width: 100%;
+ overflow-x: auto;
+ display: block;
+ white-space: nowrap;
+}
+
+/* 修复图片溢出 */
+.comment-content img,
+.comment-body img {
+ max-width: 100% !important;
+ height: auto !important;
+}
+
+/* 修复引用块 */
+.comment-content blockquote,
+.comment-body blockquote {
+ max-width: 100%;
+ overflow-wrap: break-word;
+ word-wrap: break-word;
+ padding: 10px 15px;
+ margin: 10px 0;
+ border-left: 4px solid #ddd;
+ background-color: #f9f9f9;
}
\ No newline at end of file
diff --git a/blog/static/blog/fonts/fonts.css b/blog/static/blog/fonts/fonts.css
deleted file mode 100644
index c1a29cf04..000000000
--- a/blog/static/blog/fonts/fonts.css
+++ /dev/null
@@ -1,378 +0,0 @@
-/* cyrillic-ext */
-@font-face {
- font-family: 'Open Sans';
- font-style: italic;
- font-weight: 300;
- font-display: fallback;
- src: url(memnYaGs126MiZpBA-UFUKWyV9hmIqOjjg.woff2) format('woff2');
- unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
-}
-/* cyrillic */
-@font-face {
- font-family: 'Open Sans';
- font-style: italic;
- font-weight: 300;
- font-display: fallback;
- src: url(memnYaGs126MiZpBA-UFUKWyV9hvIqOjjg.woff2) format('woff2');
- unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
-}
-/* greek-ext */
-@font-face {
- font-family: 'Open Sans';
- font-style: italic;
- font-weight: 300;
- font-display: fallback;
- src: url(memnYaGs126MiZpBA-UFUKWyV9hnIqOjjg.woff2) format('woff2');
- unicode-range: U+1F00-1FFF;
-}
-/* greek */
-@font-face {
- font-family: 'Open Sans';
- font-style: italic;
- font-weight: 300;
- font-display: fallback;
- src: url(memnYaGs126MiZpBA-UFUKWyV9hoIqOjjg.woff2) format('woff2');
- unicode-range: U+0370-03FF;
-}
-/* vietnamese */
-@font-face {
- font-family: 'Open Sans';
- font-style: italic;
- font-weight: 300;
- font-display: fallback;
- src: url(memnYaGs126MiZpBA-UFUKWyV9hkIqOjjg.woff2) format('woff2');
- unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
-}
-/* latin-ext */
-@font-face {
- font-family: 'Open Sans';
- font-style: italic;
- font-weight: 300;
- font-display: fallback;
- src: url(memnYaGs126MiZpBA-UFUKWyV9hlIqOjjg.woff2) format('woff2');
- unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
-}
-/* latin */
-@font-face {
- font-family: 'Open Sans';
- font-style: italic;
- font-weight: 300;
- font-display: fallback;
- src: url(memnYaGs126MiZpBA-UFUKWyV9hrIqM.woff2) format('woff2');
- unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
-}
-/* cyrillic-ext */
-@font-face {
- font-family: 'Open Sans';
- font-style: italic;
- font-weight: 400;
- font-display: fallback;
- src: url(mem6YaGs126MiZpBA-UFUK0Udc1UAw.woff2) format('woff2');
- unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
-}
-/* cyrillic */
-@font-face {
- font-family: 'Open Sans';
- font-style: italic;
- font-weight: 400;
- font-display: fallback;
- src: url(mem6YaGs126MiZpBA-UFUK0ddc1UAw.woff2) format('woff2');
- unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
-}
-/* greek-ext */
-@font-face {
- font-family: 'Open Sans';
- font-style: italic;
- font-weight: 400;
- font-display: fallback;
- src: url(mem6YaGs126MiZpBA-UFUK0Vdc1UAw.woff2) format('woff2');
- unicode-range: U+1F00-1FFF;
-}
-/* greek */
-@font-face {
- font-family: 'Open Sans';
- font-style: italic;
- font-weight: 400;
- font-display: fallback;
- src: url(mem6YaGs126MiZpBA-UFUK0adc1UAw.woff2) format('woff2');
- unicode-range: U+0370-03FF;
-}
-/* vietnamese */
-@font-face {
- font-family: 'Open Sans';
- font-style: italic;
- font-weight: 400;
- font-display: fallback;
- src: url(mem6YaGs126MiZpBA-UFUK0Wdc1UAw.woff2) format('woff2');
- unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
-}
-/* latin-ext */
-@font-face {
- font-family: 'Open Sans';
- font-style: italic;
- font-weight: 400;
- font-display: fallback;
- src: url(mem6YaGs126MiZpBA-UFUK0Xdc1UAw.woff2) format('woff2');
- unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
-}
-/* latin */
-@font-face {
- font-family: 'Open Sans';
- font-style: italic;
- font-weight: 400;
- font-display: fallback;
- src: url(mem6YaGs126MiZpBA-UFUK0Zdc0.woff2) format('woff2');
- unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
-}
-/* cyrillic-ext */
-@font-face {
- font-family: 'Open Sans';
- font-style: italic;
- font-weight: 600;
- font-display: fallback;
- src: url(memnYaGs126MiZpBA-UFUKXGUdhmIqOjjg.woff2) format('woff2');
- unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
-}
-/* cyrillic */
-@font-face {
- font-family: 'Open Sans';
- font-style: italic;
- font-weight: 600;
- font-display: fallback;
- src: url(memnYaGs126MiZpBA-UFUKXGUdhvIqOjjg.woff2) format('woff2');
- unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
-}
-/* greek-ext */
-@font-face {
- font-family: 'Open Sans';
- font-style: italic;
- font-weight: 600;
- font-display: fallback;
- src: url(memnYaGs126MiZpBA-UFUKXGUdhnIqOjjg.woff2) format('woff2');
- unicode-range: U+1F00-1FFF;
-}
-/* greek */
-@font-face {
- font-family: 'Open Sans';
- font-style: italic;
- font-weight: 600;
- font-display: fallback;
- src: url(memnYaGs126MiZpBA-UFUKXGUdhoIqOjjg.woff2) format('woff2');
- unicode-range: U+0370-03FF;
-}
-/* vietnamese */
-@font-face {
- font-family: 'Open Sans';
- font-style: italic;
- font-weight: 600;
- font-display: fallback;
- src: url(memnYaGs126MiZpBA-UFUKXGUdhkIqOjjg.woff2) format('woff2');
- unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
-}
-/* latin-ext */
-@font-face {
- font-family: 'Open Sans';
- font-style: italic;
- font-weight: 600;
- font-display: fallback;
- src: url(memnYaGs126MiZpBA-UFUKXGUdhlIqOjjg.woff2) format('woff2');
- unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
-}
-/* latin */
-@font-face {
- font-family: 'Open Sans';
- font-style: italic;
- font-weight: 600;
- font-display: fallback;
- src: url(memnYaGs126MiZpBA-UFUKXGUdhrIqM.woff2) format('woff2');
- unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
-}
-/* cyrillic-ext */
-@font-face {
- font-family: 'Open Sans';
- font-style: normal;
- font-weight: 300;
- font-display: fallback;
- src: url(mem5YaGs126MiZpBA-UN_r8OX-hpOqc.woff2) format('woff2');
- unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
-}
-/* cyrillic */
-@font-face {
- font-family: 'Open Sans';
- font-style: normal;
- font-weight: 300;
- font-display: fallback;
- src: url(mem5YaGs126MiZpBA-UN_r8OVuhpOqc.woff2) format('woff2');
- unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
-}
-/* greek-ext */
-@font-face {
- font-family: 'Open Sans';
- font-style: normal;
- font-weight: 300;
- font-display: fallback;
- src: url(mem5YaGs126MiZpBA-UN_r8OXuhpOqc.woff2) format('woff2');
- unicode-range: U+1F00-1FFF;
-}
-/* greek */
-@font-face {
- font-family: 'Open Sans';
- font-style: normal;
- font-weight: 300;
- font-display: fallback;
- src: url(mem5YaGs126MiZpBA-UN_r8OUehpOqc.woff2) format('woff2');
- unicode-range: U+0370-03FF;
-}
-/* vietnamese */
-@font-face {
- font-family: 'Open Sans';
- font-style: normal;
- font-weight: 300;
- font-display: fallback;
- src: url(mem5YaGs126MiZpBA-UN_r8OXehpOqc.woff2) format('woff2');
- unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
-}
-/* latin-ext */
-@font-face {
- font-family: 'Open Sans';
- font-style: normal;
- font-weight: 300;
- font-display: fallback;
- src: url(mem5YaGs126MiZpBA-UN_r8OXOhpOqc.woff2) format('woff2');
- unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
-}
-/* latin */
-@font-face {
- font-family: 'Open Sans';
- font-style: normal;
- font-weight: 300;
- font-display: fallback;
- src: url(mem5YaGs126MiZpBA-UN_r8OUuhp.woff2) format('woff2');
- unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
-}
-/* cyrillic-ext */
-@font-face {
- font-family: 'Open Sans';
- font-style: normal;
- font-weight: 400;
- font-display: fallback;
- src: url(mem8YaGs126MiZpBA-UFWJ0bbck.woff2) format('woff2');
- unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
-}
-/* cyrillic */
-@font-face {
- font-family: 'Open Sans';
- font-style: normal;
- font-weight: 400;
- font-display: fallback;
- src: url(mem8YaGs126MiZpBA-UFUZ0bbck.woff2) format('woff2');
- unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
-}
-/* greek-ext */
-@font-face {
- font-family: 'Open Sans';
- font-style: normal;
- font-weight: 400;
- font-display: fallback;
- src: url(mem8YaGs126MiZpBA-UFWZ0bbck.woff2) format('woff2');
- unicode-range: U+1F00-1FFF;
-}
-/* greek */
-@font-face {
- font-family: 'Open Sans';
- font-style: normal;
- font-weight: 400;
- font-display: fallback;
- src: url(mem8YaGs126MiZpBA-UFVp0bbck.woff2) format('woff2');
- unicode-range: U+0370-03FF;
-}
-/* vietnamese */
-@font-face {
- font-family: 'Open Sans';
- font-style: normal;
- font-weight: 400;
- font-display: fallback;
- src: url(mem8YaGs126MiZpBA-UFWp0bbck.woff2) format('woff2');
- unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
-}
-/* latin-ext */
-@font-face {
- font-family: 'Open Sans';
- font-style: normal;
- font-weight: 400;
- font-display: fallback;
- src: url(mem8YaGs126MiZpBA-UFW50bbck.woff2) format('woff2');
- unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
-}
-/* latin */
-@font-face {
- font-family: 'Open Sans';
- font-style: normal;
- font-weight: 400;
- font-display: fallback;
- src: url(mem8YaGs126MiZpBA-UFVZ0b.woff2) format('woff2');
- unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
-}
-/* cyrillic-ext */
-@font-face {
- font-family: 'Open Sans';
- font-style: normal;
- font-weight: 600;
- font-display: fallback;
- src: url(mem5YaGs126MiZpBA-UNirkOX-hpOqc.woff2) format('woff2');
- unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
-}
-/* cyrillic */
-@font-face {
- font-family: 'Open Sans';
- font-style: normal;
- font-weight: 600;
- font-display: fallback;
- src: url(mem5YaGs126MiZpBA-UNirkOVuhpOqc.woff2) format('woff2');
- unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
-}
-/* greek-ext */
-@font-face {
- font-family: 'Open Sans';
- font-style: normal;
- font-weight: 600;
- font-display: fallback;
- src: url(mem5YaGs126MiZpBA-UNirkOXuhpOqc.woff2) format('woff2');
- unicode-range: U+1F00-1FFF;
-}
-/* greek */
-@font-face {
- font-family: 'Open Sans';
- font-style: normal;
- font-weight: 600;
- font-display: fallback;
- src: url(mem5YaGs126MiZpBA-UNirkOUehpOqc.woff2) format('woff2');
- unicode-range: U+0370-03FF;
-}
-/* vietnamese */
-@font-face {
- font-family: 'Open Sans';
- font-style: normal;
- font-weight: 600;
- font-display: fallback;
- src: url(mem5YaGs126MiZpBA-UNirkOXehpOqc.woff2) format('woff2');
- unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
-}
-/* latin-ext */
-@font-face {
- font-family: 'Open Sans';
- font-style: normal;
- font-weight: 600;
- font-display: fallback;
- src: url(mem5YaGs126MiZpBA-UNirkOXOhpOqc.woff2) format('woff2');
- unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
-}
-/* latin */
-@font-face {
- font-family: 'Open Sans';
- font-style: normal;
- font-weight: 600;
- font-display: fallback;
- src: url(mem5YaGs126MiZpBA-UNirkOUuhp.woff2) format('woff2');
- unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
-}
diff --git a/blog/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OUehpOqc.woff2 b/blog/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OUehpOqc.woff2
deleted file mode 100644
index 2c47cc54d..000000000
Binary files a/blog/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OUehpOqc.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OUuhp.woff2 b/blog/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OUuhp.woff2
deleted file mode 100644
index 601706aaf..000000000
Binary files a/blog/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OUuhp.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OVuhpOqc.woff2 b/blog/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OVuhpOqc.woff2
deleted file mode 100644
index 119f1d711..000000000
Binary files a/blog/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OVuhpOqc.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OX-hpOqc.woff2 b/blog/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OX-hpOqc.woff2
deleted file mode 100644
index d56688ffb..000000000
Binary files a/blog/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OX-hpOqc.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OXOhpOqc.woff2 b/blog/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OXOhpOqc.woff2
deleted file mode 100644
index e1f546c2f..000000000
Binary files a/blog/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OXOhpOqc.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OXehpOqc.woff2 b/blog/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OXehpOqc.woff2
deleted file mode 100644
index 0f17e3d3f..000000000
Binary files a/blog/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OXehpOqc.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OXuhpOqc.woff2 b/blog/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OXuhpOqc.woff2
deleted file mode 100644
index 50d818328..000000000
Binary files a/blog/static/blog/fonts/mem5YaGs126MiZpBA-UN_r8OXuhpOqc.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOUehpOqc.woff2 b/blog/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOUehpOqc.woff2
deleted file mode 100644
index b93519892..000000000
Binary files a/blog/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOUehpOqc.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOUuhp.woff2 b/blog/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOUuhp.woff2
deleted file mode 100644
index d77bb4c34..000000000
Binary files a/blog/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOUuhp.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOVuhpOqc.woff2 b/blog/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOVuhpOqc.woff2
deleted file mode 100644
index e293ffceb..000000000
Binary files a/blog/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOVuhpOqc.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOX-hpOqc.woff2 b/blog/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOX-hpOqc.woff2
deleted file mode 100644
index 46fd61bf1..000000000
Binary files a/blog/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOX-hpOqc.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOXOhpOqc.woff2 b/blog/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOXOhpOqc.woff2
deleted file mode 100644
index 88a1616ab..000000000
Binary files a/blog/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOXOhpOqc.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOXehpOqc.woff2 b/blog/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOXehpOqc.woff2
deleted file mode 100644
index 2100b6bba..000000000
Binary files a/blog/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOXehpOqc.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOXuhpOqc.woff2 b/blog/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOXuhpOqc.woff2
deleted file mode 100644
index d54c7c0fd..000000000
Binary files a/blog/static/blog/fonts/mem5YaGs126MiZpBA-UNirkOXuhpOqc.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Udc1UAw.woff2 b/blog/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Udc1UAw.woff2
deleted file mode 100644
index 683014d73..000000000
Binary files a/blog/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Udc1UAw.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Vdc1UAw.woff2 b/blog/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Vdc1UAw.woff2
deleted file mode 100644
index 72eb246f6..000000000
Binary files a/blog/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Vdc1UAw.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Wdc1UAw.woff2 b/blog/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Wdc1UAw.woff2
deleted file mode 100644
index 6da55624a..000000000
Binary files a/blog/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Wdc1UAw.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Xdc1UAw.woff2 b/blog/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Xdc1UAw.woff2
deleted file mode 100644
index 2f22c670e..000000000
Binary files a/blog/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Xdc1UAw.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Zdc0.woff2 b/blog/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Zdc0.woff2
deleted file mode 100644
index 28c6c76ee..000000000
Binary files a/blog/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0Zdc0.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0adc1UAw.woff2 b/blog/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0adc1UAw.woff2
deleted file mode 100644
index fdeb9a4a8..000000000
Binary files a/blog/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0adc1UAw.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0ddc1UAw.woff2 b/blog/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0ddc1UAw.woff2
deleted file mode 100644
index 2a48105cf..000000000
Binary files a/blog/static/blog/fonts/mem6YaGs126MiZpBA-UFUK0ddc1UAw.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/mem8YaGs126MiZpBA-UFUZ0bbck.woff2 b/blog/static/blog/fonts/mem8YaGs126MiZpBA-UFUZ0bbck.woff2
deleted file mode 100644
index 1ddef142a..000000000
Binary files a/blog/static/blog/fonts/mem8YaGs126MiZpBA-UFUZ0bbck.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/mem8YaGs126MiZpBA-UFVZ0b.woff2 b/blog/static/blog/fonts/mem8YaGs126MiZpBA-UFVZ0b.woff2
deleted file mode 100644
index 1d5e847b7..000000000
Binary files a/blog/static/blog/fonts/mem8YaGs126MiZpBA-UFVZ0b.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/mem8YaGs126MiZpBA-UFVp0bbck.woff2 b/blog/static/blog/fonts/mem8YaGs126MiZpBA-UFVp0bbck.woff2
deleted file mode 100644
index 0e22822bf..000000000
Binary files a/blog/static/blog/fonts/mem8YaGs126MiZpBA-UFVp0bbck.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/mem8YaGs126MiZpBA-UFW50bbck.woff2 b/blog/static/blog/fonts/mem8YaGs126MiZpBA-UFW50bbck.woff2
deleted file mode 100644
index f6210055d..000000000
Binary files a/blog/static/blog/fonts/mem8YaGs126MiZpBA-UFW50bbck.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/mem8YaGs126MiZpBA-UFWJ0bbck.woff2 b/blog/static/blog/fonts/mem8YaGs126MiZpBA-UFWJ0bbck.woff2
deleted file mode 100644
index 49018f9c4..000000000
Binary files a/blog/static/blog/fonts/mem8YaGs126MiZpBA-UFWJ0bbck.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/mem8YaGs126MiZpBA-UFWZ0bbck.woff2 b/blog/static/blog/fonts/mem8YaGs126MiZpBA-UFWZ0bbck.woff2
deleted file mode 100644
index a69a2efab..000000000
Binary files a/blog/static/blog/fonts/mem8YaGs126MiZpBA-UFWZ0bbck.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/mem8YaGs126MiZpBA-UFWp0bbck.woff2 b/blog/static/blog/fonts/mem8YaGs126MiZpBA-UFWp0bbck.woff2
deleted file mode 100644
index fb5fb994d..000000000
Binary files a/blog/static/blog/fonts/mem8YaGs126MiZpBA-UFWp0bbck.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hkIqOjjg.woff2 b/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hkIqOjjg.woff2
deleted file mode 100644
index db9a5bdbf..000000000
Binary files a/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hkIqOjjg.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hlIqOjjg.woff2 b/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hlIqOjjg.woff2
deleted file mode 100644
index 7a9e2e36e..000000000
Binary files a/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hlIqOjjg.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hmIqOjjg.woff2 b/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hmIqOjjg.woff2
deleted file mode 100644
index a9d17c0f6..000000000
Binary files a/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hmIqOjjg.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hnIqOjjg.woff2 b/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hnIqOjjg.woff2
deleted file mode 100644
index b76038f8c..000000000
Binary files a/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hnIqOjjg.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hoIqOjjg.woff2 b/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hoIqOjjg.woff2
deleted file mode 100644
index 06a53d531..000000000
Binary files a/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hoIqOjjg.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hrIqM.woff2 b/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hrIqM.woff2
deleted file mode 100644
index 94dc4e473..000000000
Binary files a/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hrIqM.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hvIqOjjg.woff2 b/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hvIqOjjg.woff2
deleted file mode 100644
index 8197c399f..000000000
Binary files a/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKWyV9hvIqOjjg.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhkIqOjjg.woff2 b/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhkIqOjjg.woff2
deleted file mode 100644
index b9cd540ab..000000000
Binary files a/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhkIqOjjg.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhlIqOjjg.woff2 b/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhlIqOjjg.woff2
deleted file mode 100644
index fa2e381c7..000000000
Binary files a/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhlIqOjjg.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhmIqOjjg.woff2 b/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhmIqOjjg.woff2
deleted file mode 100644
index da3f7ecf8..000000000
Binary files a/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhmIqOjjg.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhnIqOjjg.woff2 b/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhnIqOjjg.woff2
deleted file mode 100644
index 0b4211904..000000000
Binary files a/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhnIqOjjg.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhoIqOjjg.woff2 b/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhoIqOjjg.woff2
deleted file mode 100644
index 36bdef19a..000000000
Binary files a/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhoIqOjjg.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhrIqM.woff2 b/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhrIqM.woff2
deleted file mode 100644
index 4b60ed415..000000000
Binary files a/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhrIqM.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhvIqOjjg.woff2 b/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhvIqOjjg.woff2
deleted file mode 100644
index d2140906a..000000000
Binary files a/blog/static/blog/fonts/memnYaGs126MiZpBA-UFUKXGUdhvIqOjjg.woff2 and /dev/null differ
diff --git a/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqW106F15M.woff2 b/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqW106F15M.woff2
new file mode 100644
index 000000000..0fb066c55
Binary files /dev/null and b/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqW106F15M.woff2 differ
diff --git a/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWt06F15M.woff2 b/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWt06F15M.woff2
new file mode 100644
index 000000000..bc2aea0dc
Binary files /dev/null and b/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWt06F15M.woff2 differ
diff --git a/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtE6F15M.woff2 b/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtE6F15M.woff2
new file mode 100644
index 000000000..fcce594ee
Binary files /dev/null and b/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtE6F15M.woff2 differ
diff --git a/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtU6F15M.woff2 b/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtU6F15M.woff2
new file mode 100644
index 000000000..ffc8e9c4d
Binary files /dev/null and b/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtU6F15M.woff2 differ
diff --git a/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtk6F15M.woff2 b/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtk6F15M.woff2
new file mode 100644
index 000000000..6375e9ce1
Binary files /dev/null and b/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtk6F15M.woff2 differ
diff --git a/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWu06F15M.woff2 b/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWu06F15M.woff2
new file mode 100644
index 000000000..2e849f65a
Binary files /dev/null and b/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWu06F15M.woff2 differ
diff --git a/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuU6F.woff2 b/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuU6F.woff2
new file mode 100644
index 000000000..5de3feade
Binary files /dev/null and b/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuU6F.woff2 differ
diff --git a/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuk6F15M.woff2 b/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuk6F15M.woff2
new file mode 100644
index 000000000..e5c936bb1
Binary files /dev/null and b/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuk6F15M.woff2 differ
diff --git a/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWvU6F15M.woff2 b/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWvU6F15M.woff2
new file mode 100644
index 000000000..5cf8aff77
Binary files /dev/null and b/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWvU6F15M.woff2 differ
diff --git a/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWxU6F15M.woff2 b/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWxU6F15M.woff2
new file mode 100644
index 000000000..bdc12e8ed
Binary files /dev/null and b/blog/static/blog/fonts/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWxU6F15M.woff2 differ
diff --git a/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-muw.woff2 b/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-muw.woff2
new file mode 100644
index 000000000..b5d54e762
Binary files /dev/null and b/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-muw.woff2 differ
diff --git a/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS2mu1aB.woff2 b/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS2mu1aB.woff2
new file mode 100644
index 000000000..bed5b67c8
Binary files /dev/null and b/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS2mu1aB.woff2 differ
diff --git a/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSCmu1aB.woff2 b/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSCmu1aB.woff2
new file mode 100644
index 000000000..9164ccba9
Binary files /dev/null and b/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSCmu1aB.woff2 differ
diff --git a/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSGmu1aB.woff2 b/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSGmu1aB.woff2
new file mode 100644
index 000000000..08bed85e3
Binary files /dev/null and b/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSGmu1aB.woff2 differ
diff --git a/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSKmu1aB.woff2 b/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSKmu1aB.woff2
new file mode 100644
index 000000000..307b21403
Binary files /dev/null and b/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSKmu1aB.woff2 differ
diff --git a/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSOmu1aB.woff2 b/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSOmu1aB.woff2
new file mode 100644
index 000000000..0b0b3a41d
Binary files /dev/null and b/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSOmu1aB.woff2 differ
diff --git a/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSumu1aB.woff2 b/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSumu1aB.woff2
new file mode 100644
index 000000000..4bce1d029
Binary files /dev/null and b/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSumu1aB.woff2 differ
diff --git a/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu1aB.woff2 b/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu1aB.woff2
new file mode 100644
index 000000000..5bd7b8ff1
Binary files /dev/null and b/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu1aB.woff2 differ
diff --git a/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTUGmu1aB.woff2 b/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTUGmu1aB.woff2
new file mode 100644
index 000000000..b96960208
Binary files /dev/null and b/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTUGmu1aB.woff2 differ
diff --git a/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTVOmu1aB.woff2 b/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTVOmu1aB.woff2
new file mode 100644
index 000000000..a804b105c
Binary files /dev/null and b/blog/static/blog/fonts/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTVOmu1aB.woff2 differ
diff --git a/blog/static/blog/fonts/open-sans.css b/blog/static/blog/fonts/open-sans.css
new file mode 100644
index 000000000..e6dd4a947
--- /dev/null
+++ b/blog/static/blog/fonts/open-sans.css
@@ -0,0 +1,600 @@
+/* cyrillic-ext */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtE6F15M.woff2) format('woff2');
+ unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+/* cyrillic */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWvU6F15M.woff2) format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+/* greek-ext */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtU6F15M.woff2) format('woff2');
+ unicode-range: U+1F00-1FFF;
+}
+/* greek */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuk6F15M.woff2) format('woff2');
+ unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
+}
+/* hebrew */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWu06F15M.woff2) format('woff2');
+ unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
+}
+/* math */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWxU6F15M.woff2) format('woff2');
+ unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
+}
+/* symbols */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqW106F15M.woff2) format('woff2');
+ unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
+}
+/* vietnamese */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtk6F15M.woff2) format('woff2');
+ unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWt06F15M.woff2) format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuU6F.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* cyrillic-ext */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtE6F15M.woff2) format('woff2');
+ unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+/* cyrillic */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWvU6F15M.woff2) format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+/* greek-ext */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtU6F15M.woff2) format('woff2');
+ unicode-range: U+1F00-1FFF;
+}
+/* greek */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuk6F15M.woff2) format('woff2');
+ unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
+}
+/* hebrew */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWu06F15M.woff2) format('woff2');
+ unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
+}
+/* math */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWxU6F15M.woff2) format('woff2');
+ unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
+}
+/* symbols */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqW106F15M.woff2) format('woff2');
+ unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
+}
+/* vietnamese */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtk6F15M.woff2) format('woff2');
+ unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWt06F15M.woff2) format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuU6F.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* cyrillic-ext */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 600;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtE6F15M.woff2) format('woff2');
+ unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+/* cyrillic */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 600;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWvU6F15M.woff2) format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+/* greek-ext */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 600;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtU6F15M.woff2) format('woff2');
+ unicode-range: U+1F00-1FFF;
+}
+/* greek */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 600;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuk6F15M.woff2) format('woff2');
+ unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
+}
+/* hebrew */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 600;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWu06F15M.woff2) format('woff2');
+ unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
+}
+/* math */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 600;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWxU6F15M.woff2) format('woff2');
+ unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
+}
+/* symbols */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 600;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqW106F15M.woff2) format('woff2');
+ unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
+}
+/* vietnamese */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 600;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtk6F15M.woff2) format('woff2');
+ unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 600;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWt06F15M.woff2) format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 600;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuU6F.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* cyrillic-ext */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSKmu1aB.woff2) format('woff2');
+ unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+/* cyrillic */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSumu1aB.woff2) format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+/* greek-ext */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSOmu1aB.woff2) format('woff2');
+ unicode-range: U+1F00-1FFF;
+}
+/* greek */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu1aB.woff2) format('woff2');
+ unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
+}
+/* hebrew */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS2mu1aB.woff2) format('woff2');
+ unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
+}
+/* math */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTVOmu1aB.woff2) format('woff2');
+ unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
+}
+/* symbols */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTUGmu1aB.woff2) format('woff2');
+ unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
+}
+/* vietnamese */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSCmu1aB.woff2) format('woff2');
+ unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSGmu1aB.woff2) format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-muw.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* cyrillic-ext */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSKmu1aB.woff2) format('woff2');
+ unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+/* cyrillic */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSumu1aB.woff2) format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+/* greek-ext */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSOmu1aB.woff2) format('woff2');
+ unicode-range: U+1F00-1FFF;
+}
+/* greek */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu1aB.woff2) format('woff2');
+ unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
+}
+/* hebrew */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS2mu1aB.woff2) format('woff2');
+ unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
+}
+/* math */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTVOmu1aB.woff2) format('woff2');
+ unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
+}
+/* symbols */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTUGmu1aB.woff2) format('woff2');
+ unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
+}
+/* vietnamese */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSCmu1aB.woff2) format('woff2');
+ unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSGmu1aB.woff2) format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-muw.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* cyrillic-ext */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 600;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSKmu1aB.woff2) format('woff2');
+ unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+/* cyrillic */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 600;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSumu1aB.woff2) format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+/* greek-ext */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 600;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSOmu1aB.woff2) format('woff2');
+ unicode-range: U+1F00-1FFF;
+}
+/* greek */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 600;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu1aB.woff2) format('woff2');
+ unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
+}
+/* hebrew */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 600;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS2mu1aB.woff2) format('woff2');
+ unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
+}
+/* math */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 600;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTVOmu1aB.woff2) format('woff2');
+ unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
+}
+/* symbols */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 600;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTUGmu1aB.woff2) format('woff2');
+ unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
+}
+/* vietnamese */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 600;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSCmu1aB.woff2) format('woff2');
+ unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 600;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSGmu1aB.woff2) format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 600;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-muw.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
diff --git a/blog/static/blog/img/avatar.png b/blog/static/blog/img/avatar.png
index bf0a8ad27..320756f0d 100644
Binary files a/blog/static/blog/img/avatar.png and b/blog/static/blog/img/avatar.png differ
diff --git a/blog/static/blog/img/icon-sn.svg b/blog/static/blog/img/icon-sn.svg
index a1df6190d..2c2da0af5 100644
--- a/blog/static/blog/img/icon-sn.svg
+++ b/blog/static/blog/img/icon-sn.svg
@@ -1 +1 @@
-icon-sn
\ No newline at end of file
+icon-sn
\ No newline at end of file
diff --git a/blog/static/blog/js/blog.js b/blog/static/blog/js/blog.js
index c8f8822d5..c50dd7d76 100644
--- a/blog/static/blog/js/blog.js
+++ b/blog/static/blog/js/blog.js
@@ -30,42 +30,62 @@ $(document).ready(function () {
});
-
/** 侧边栏回到顶部 */
var rocket = $('#rocket');
$(window).on('scroll', debounce(slideTopSet, 300));
function debounce(func, wait) {
- var timeout;
- return function() {
- clearTimeout(timeout);
- timeout = setTimeout(func, wait);
- };
-};
+ var timeout;
+ return function () {
+ clearTimeout(timeout);
+ timeout = setTimeout(func, wait);
+ };
+}
+
function slideTopSet() {
- var top = $(document).scrollTop();
+ var top = $(document).scrollTop();
- if (top > 200) {
- rocket.addClass('show');
- } else {
- rocket.removeClass('show');
- }
+ if (top > 200) {
+ rocket.addClass('show');
+ } else {
+ rocket.removeClass('show');
+ }
}
-$(document).on('click', '#rocket', function(event) {
- rocket.addClass('move');
- $('body, html').animate({
- scrollTop: 0
- }, 800);
+
+$(document).on('click', '#rocket', function (event) {
+ rocket.addClass('move');
+ $('body, html').animate({
+ scrollTop: 0
+ }, 800);
});
-$(document).on('animationEnd', function() {
- setTimeout(function() {
- rocket.removeClass('move');
- }, 400);
+$(document).on('animationEnd', function () {
+ setTimeout(function () {
+ rocket.removeClass('move');
+ }, 400);
});
-$(document).on('webkitAnimationEnd', function() {
- setTimeout(function() {
- rocket.removeClass('move');
- }, 400);
+$(document).on('webkitAnimationEnd', function () {
+ setTimeout(function () {
+ rocket.removeClass('move');
+ }, 400);
});
+
+
+window.onload = function () {
+ var replyLinks = document.querySelectorAll(".comment-reply-link");
+ for (var i = 0; i < replyLinks.length; i++) {
+ replyLinks[i].onclick = function () {
+ var pk = this.getAttribute("data-pk");
+ do_reply(pk);
+ };
+ }
+};
+
+// $(document).ready(function () {
+// var form = $('#i18n-form');
+// var selector = $('.i18n-select');
+// selector.on('change', function () {
+// form.submit();
+// });
+// });
\ No newline at end of file
diff --git a/blog/static/blog/js/mathjax-loader.js b/blog/static/blog/js/mathjax-loader.js
new file mode 100644
index 000000000..c922fc7f6
--- /dev/null
+++ b/blog/static/blog/js/mathjax-loader.js
@@ -0,0 +1,142 @@
+/**
+ * MathJax 智能加载器
+ * 检测页面是否包含数学公式,如果有则动态加载和配置MathJax
+ */
+(function() {
+ 'use strict';
+
+ /**
+ * 检测页面是否包含数学公式
+ * @returns {boolean} 是否包含数学公式
+ */
+ function hasMathFormulas() {
+ const content = document.body.textContent || document.body.innerText || '';
+ // 检测常见的数学公式语法
+ return /\$.*?\$|\$\$.*?\$\$|\\begin\{.*?\}|\\end\{.*?\}|\\[a-zA-Z]+\{/.test(content);
+ }
+
+ /**
+ * 配置MathJax
+ */
+ function configureMathJax() {
+ window.MathJax = {
+ tex: {
+ // 行内公式和块级公式分隔符
+ inlineMath: [['$', '$']],
+ displayMath: [['$$', '$$']],
+ // 处理转义字符和LaTeX环境
+ processEscapes: true,
+ processEnvironments: true,
+ // 自动换行
+ tags: 'ams'
+ },
+ options: {
+ // 跳过这些HTML标签,避免处理代码块等
+ skipHtmlTags: ['script', 'noscript', 'style', 'textarea', 'pre', 'code', 'a'],
+ // CSS类控制
+ ignoreHtmlClass: 'tex2jax_ignore',
+ processHtmlClass: 'tex2jax_process'
+ },
+ // 启动配置
+ startup: {
+ ready() {
+ console.log('MathJax配置完成,开始初始化...');
+ MathJax.startup.defaultReady();
+
+ // 处理特定区域的数学公式
+ const contentEl = document.getElementById('content');
+ const commentsEl = document.getElementById('comments');
+
+ const promises = [];
+ if (contentEl) {
+ promises.push(MathJax.typesetPromise([contentEl]));
+ }
+ if (commentsEl) {
+ promises.push(MathJax.typesetPromise([commentsEl]));
+ }
+
+ // 等待所有渲染完成
+ Promise.all(promises).then(() => {
+ console.log('MathJax渲染完成');
+ // 触发自定义事件,通知其他脚本MathJax已就绪
+ document.dispatchEvent(new CustomEvent('mathjaxReady'));
+ }).catch(error => {
+ console.error('MathJax渲染失败:', error);
+ });
+ }
+ },
+ // 输出配置
+ chtml: {
+ scale: 1,
+ minScale: 0.5,
+ matchFontHeight: false,
+ displayAlign: 'center',
+ displayIndent: '0'
+ }
+ };
+ }
+
+ /**
+ * 加载MathJax库
+ */
+ function loadMathJax() {
+ console.log('检测到数学公式,开始加载MathJax...');
+
+ const script = document.createElement('script');
+ script.src = 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js';
+ script.async = true;
+ script.defer = true;
+
+ script.onload = function() {
+ console.log('MathJax库加载成功');
+ };
+
+ script.onerror = function() {
+ console.error('MathJax库加载失败,尝试备用CDN...');
+ // 备用CDN
+ const fallbackScript = document.createElement('script');
+ fallbackScript.src = 'https://polyfill.io/v3/polyfill.min.js?features=es6';
+ fallbackScript.onload = function() {
+ const mathJaxScript = document.createElement('script');
+ mathJaxScript.src = 'https://cdn.mathjax.org/mathjax/latest/MathJax.js?config=TeX-MML-AM_CHTML';
+ mathJaxScript.async = true;
+ document.head.appendChild(mathJaxScript);
+ };
+ document.head.appendChild(fallbackScript);
+ };
+
+ document.head.appendChild(script);
+ }
+
+ /**
+ * 初始化函数
+ */
+ function init() {
+ // 等待DOM完全加载
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+ return;
+ }
+
+ // 检测是否需要加载MathJax
+ if (hasMathFormulas()) {
+ // 先配置,再加载
+ configureMathJax();
+ loadMathJax();
+ } else {
+ console.log('未检测到数学公式,跳过MathJax加载');
+ }
+ }
+
+ // 提供重新渲染的全局方法,供动态内容使用
+ window.rerenderMathJax = function(element) {
+ if (window.MathJax && window.MathJax.typesetPromise) {
+ const target = element || document.body;
+ return window.MathJax.typesetPromise([target]);
+ }
+ return Promise.resolve();
+ };
+
+ // 启动初始化
+ init();
+})();
diff --git a/blog/static/blog/js/nprogress.js b/blog/static/blog/js/nprogress.js
index beb9d2cb9..d29c2aac7 100644
--- a/blog/static/blog/js/nprogress.js
+++ b/blog/static/blog/js/nprogress.js
@@ -161,7 +161,7 @@
if (!n) {
return NProgress.start();
} else if(n > 1) {
- return;
+
} else {
if (typeof amount !== 'number') {
if (n >= 0 && n < 0.2) { amount = 0.1; }
diff --git a/blog/templatetags/blog_tags.py b/blog/templatetags/blog_tags.py
index 3871e5012..024f2c852 100644
--- a/blog/templatetags/blog_tags.py
+++ b/blog/templatetags/blog_tags.py
@@ -3,27 +3,33 @@
import random
import urllib
-import bleach
from django import template
from django.conf import settings
from django.db.models import Q
from django.shortcuts import get_object_or_404
from django.template.defaultfilters import stringfilter
+from django.templatetags.static import static
from django.urls import reverse
from django.utils.safestring import mark_safe
from blog.models import Article, Category, Tag, Links, SideBar, LinkShowType
from comments.models import Comment
-from djangoblog.utils import CommonMarkdown
+from djangoblog.utils import CommonMarkdown, sanitize_html
from djangoblog.utils import cache
from djangoblog.utils import get_current_site
from oauth.models import OAuthUser
+from djangoblog.plugin_manage import hooks
logger = logging.getLogger(__name__)
register = template.Library()
+@register.simple_tag(takes_context=True)
+def head_meta(context):
+ return mark_safe(hooks.apply_filters('head_meta', '', context))
+
+
@register.simple_tag
def timeformat(data):
try:
@@ -45,15 +51,89 @@ def datetimeformat(data):
@register.filter()
@stringfilter
def custom_markdown(content):
- content = bleach.clean(content)
- return mark_safe(CommonMarkdown.get_markdown(content))
+ """
+ 通用markdown过滤器,应用文章内容插件
+ 主要用于文章内容处理
+ """
+ html_content = CommonMarkdown.get_markdown(content)
+
+ # 然后应用插件过滤器优化HTML
+ from djangoblog.plugin_manage import hooks
+ from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
+ optimized_html = hooks.apply_filters(ARTICLE_CONTENT_HOOK_NAME, html_content)
+
+ return mark_safe(optimized_html)
+
+
+@register.filter()
+@stringfilter
+def sidebar_markdown(content):
+ html_content = CommonMarkdown.get_markdown(content)
+ return mark_safe(html_content)
+
+
+@register.simple_tag(takes_context=True)
+def render_article_content(context, article, is_summary=False):
+ """
+ 渲染文章内容,包含完整的上下文信息供插件使用
+
+ Args:
+ context: 模板上下文
+ article: 文章对象
+ is_summary: 是否为摘要模式(首页使用)
+ """
+ if not article or not hasattr(article, 'body'):
+ return ''
+
+ # 先转换Markdown为HTML
+ html_content = CommonMarkdown.get_markdown(article.body)
+
+ # 如果是摘要模式,先截断内容再应用插件
+ if is_summary:
+ # 截断HTML内容到合适的长度(约300字符)
+ from django.utils.html import strip_tags
+ from django.template.defaultfilters import truncatechars
+
+ # 先去除HTML标签,截断纯文本,然后重新转换为HTML
+ plain_text = strip_tags(html_content)
+ truncated_text = truncatechars(plain_text, 300)
+
+ # 重新转换截断后的文本为HTML(简化版,避免复杂的插件处理)
+ html_content = CommonMarkdown.get_markdown(truncated_text)
+
+ # 然后应用插件过滤器,传递完整的上下文
+ from djangoblog.plugin_manage import hooks
+ from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
+
+ # 获取request对象
+ request = context.get('request')
+
+ # 应用所有文章内容相关的插件
+ # 注意:摘要模式下某些插件(如版权声明)可能不适用
+ optimized_html = hooks.apply_filters(
+ ARTICLE_CONTENT_HOOK_NAME,
+ html_content,
+ article=article,
+ request=request,
+ context=context,
+ is_summary=is_summary # 传递摘要标志,插件可以据此调整行为
+ )
+
+ return mark_safe(optimized_html)
@register.simple_tag
def get_markdown_toc(content):
from djangoblog.utils import CommonMarkdown
body, toc = CommonMarkdown.get_markdown_with_toc(content)
- return mark_safe(toc), mark_safe(body)
+ return mark_safe(toc)
+
+
+@register.filter()
+@stringfilter
+def comment_markdown(content):
+ content = CommonMarkdown.get_markdown(content)
+ return mark_safe(sanitize_html(content))
@register.filter(is_safe=True)
@@ -89,7 +169,7 @@ def load_breadcrumb(article):
from djangoblog.utils import get_blog_setting
blogsetting = get_blog_setting()
site = get_current_site().domain
- names.append((blogsetting.sitename, '/'))
+ names.append((blogsetting.site_name, '/'))
names = names[::-1]
return {
@@ -140,7 +220,7 @@ def load_sidebar(user, linktype):
is_enable=True).order_by('sequence')
most_read_articles = Article.objects.filter(status='p').order_by(
'-views')[:blogsetting.sidebar_article_count]
- dates = Article.objects.datetimes('created_time', 'month', order='DESC')
+ dates = Article.objects.datetimes('creation_time', 'month', order='DESC')
links = Links.objects.filter(is_enable=True).filter(
Q(show_type=str(linktype)) | Q(show_type=LinkShowType.A))
commment_list = Comment.objects.filter(is_enable=True).order_by(
@@ -174,6 +254,7 @@ def load_sidebar(user, linktype):
'extra_sidebars': extra_sidebars
}
cache.set("sidebar" + linktype, value, 60 * 60 * 60 * 3)
+ logger.info('set sidebar cache.key:{key}'.format(key="sidebar" + linktype))
value['user'] = user
return value
@@ -279,37 +360,49 @@ def load_article_detail(article, isindex, user):
}
-# return only the URL of the gravatar
-# TEMPLATE USE: {{ email|gravatar_url:150 }}
+# 返回用户头像URL
+# 模板使用方法: {{ email|gravatar_url:150 }}
@register.filter
def gravatar_url(email, size=40):
- """获得gravatar头像"""
- cachekey = 'gravatat/' + email
- if cache.get(cachekey):
- return cache.get(cachekey)
- else:
- usermodels = OAuthUser.objects.filter(email=email)
- if usermodels:
- o = list(filter(lambda x: x.picture is not None, usermodels))
- if o:
- return o[0].picture
- email = email.encode('utf-8')
-
- default = "https://resource.lylinux.net/image/2017/03/26/120117.jpg".encode(
- 'utf-8')
-
- url = "https://www.gravatar.com/avatar/%s?%s" % (hashlib.md5(
- email.lower()).hexdigest(), urllib.parse.urlencode({'d': default, 's': str(size)}))
- cache.set(cachekey, url, 60 * 60 * 10)
+ """获得用户头像 - 优先使用OAuth头像,否则使用默认头像"""
+ cachekey = 'avatar/' + email
+ url = cache.get(cachekey)
+ if url:
return url
+
+ # 检查OAuth用户是否有自定义头像
+ usermodels = OAuthUser.objects.filter(email=email)
+ if usermodels:
+ # 过滤出有头像的用户
+ users_with_picture = list(filter(lambda x: x.picture is not None, usermodels))
+ if users_with_picture:
+ # 获取默认头像路径用于比较
+ default_avatar_path = static('blog/img/avatar.png')
+
+ # 优先选择非默认头像的用户,否则选择第一个
+ non_default_users = [u for u in users_with_picture if u.picture != default_avatar_path and not u.picture.endswith('/avatar.png')]
+ selected_user = non_default_users[0] if non_default_users else users_with_picture[0]
+
+ url = selected_user.picture
+ cache.set(cachekey, url, 60 * 60 * 24) # 缓存24小时
+
+ avatar_type = 'non-default' if non_default_users else 'default'
+ logger.info('Using {} OAuth avatar for {} from {}'.format(avatar_type, email, selected_user.type))
+ return url
+
+ # 使用默认头像
+ url = static('blog/img/avatar.png')
+ cache.set(cachekey, url, 60 * 60 * 24) # 缓存24小时
+ logger.info('Using default avatar for {}'.format(email))
+ return url
@register.filter
def gravatar(email, size=40):
- """获得gravatar头像"""
+ """获得用户头像HTML标签"""
url = gravatar_url(email, size)
return mark_safe(
- ' ' %
+ ' ' %
(url, size, size))
@@ -328,3 +421,134 @@ def query(qs, **kwargs):
def addstr(arg1, arg2):
"""concatenate arg1 & arg2"""
return str(arg1) + str(arg2)
+
+
+# === 插件系统模板标签 ===
+
+@register.simple_tag(takes_context=True)
+def render_plugin_widgets(context, position, **kwargs):
+ """
+ 渲染指定位置的所有插件组件
+
+ Args:
+ context: 模板上下文
+ position: 位置标识
+ **kwargs: 传递给插件的额外参数
+
+ Returns:
+ 按优先级排序的所有插件HTML内容
+ """
+ from djangoblog.plugin_manage.loader import get_loaded_plugins
+
+ widgets = []
+
+ for plugin in get_loaded_plugins():
+ try:
+ widget_data = plugin.render_position_widget(
+ position=position,
+ context=context,
+ **kwargs
+ )
+ if widget_data:
+ widgets.append(widget_data)
+ except Exception as e:
+ logger.error(f"Error rendering widget from plugin {plugin.PLUGIN_NAME}: {e}")
+
+ # 按优先级排序(数字越小优先级越高)
+ widgets.sort(key=lambda x: x['priority'])
+
+ # 合并HTML内容
+ html_parts = [widget['html'] for widget in widgets]
+ return mark_safe(''.join(html_parts))
+
+
+@register.simple_tag(takes_context=True)
+def plugin_head_resources(context):
+ """渲染所有插件的head资源(仅自定义HTML,CSS已集成到压缩系统)"""
+ from djangoblog.plugin_manage.loader import get_loaded_plugins
+
+ resources = []
+
+ for plugin in get_loaded_plugins():
+ try:
+ # 只处理自定义head HTML(CSS文件已通过压缩系统处理)
+ head_html = plugin.get_head_html(context)
+ if head_html:
+ resources.append(head_html)
+
+ except Exception as e:
+ logger.error(f"Error loading head resources from plugin {plugin.PLUGIN_NAME}: {e}")
+
+ return mark_safe('\n'.join(resources))
+
+
+@register.simple_tag(takes_context=True)
+def plugin_body_resources(context):
+ """渲染所有插件的body资源(仅自定义HTML,JS已集成到压缩系统)"""
+ from djangoblog.plugin_manage.loader import get_loaded_plugins
+
+ resources = []
+
+ for plugin in get_loaded_plugins():
+ try:
+ # 只处理自定义body HTML(JS文件已通过压缩系统处理)
+ body_html = plugin.get_body_html(context)
+ if body_html:
+ resources.append(body_html)
+
+ except Exception as e:
+ logger.error(f"Error loading body resources from plugin {plugin.PLUGIN_NAME}: {e}")
+
+ return mark_safe('\n'.join(resources))
+
+
+@register.inclusion_tag('plugins/css_includes.html')
+def plugin_compressed_css():
+ """插件CSS压缩包含模板"""
+ from djangoblog.plugin_manage.loader import get_loaded_plugins
+
+ css_files = []
+ for plugin in get_loaded_plugins():
+ for css_file in plugin.get_css_files():
+ css_url = plugin.get_static_url(css_file)
+ css_files.append(css_url)
+
+ return {'css_files': css_files}
+
+
+@register.inclusion_tag('plugins/js_includes.html')
+def plugin_compressed_js():
+ """插件JS压缩包含模板"""
+ from djangoblog.plugin_manage.loader import get_loaded_plugins
+
+ js_files = []
+ for plugin in get_loaded_plugins():
+ for js_file in plugin.get_js_files():
+ js_url = plugin.get_static_url(js_file)
+ js_files.append(js_url)
+
+ return {'js_files': js_files}
+
+
+
+
+@register.simple_tag(takes_context=True)
+def plugin_widget(context, plugin_name, widget_type='default', **kwargs):
+ """
+ 渲染指定插件的组件
+
+ 使用方式:
+ {% plugin_widget 'article_recommendation' 'bottom' article=article count=5 %}
+ """
+ from djangoblog.plugin_manage.loader import get_plugin_by_slug
+
+ plugin = get_plugin_by_slug(plugin_name)
+ if plugin and hasattr(plugin, 'render_template'):
+ try:
+ widget_context = {**context.flatten(), **kwargs}
+ template_name = f"{widget_type}.html"
+ return mark_safe(plugin.render_template(template_name, widget_context))
+ except Exception as e:
+ logger.error(f"Error rendering plugin widget {plugin_name}.{widget_type}: {e}")
+
+ return ""
\ No newline at end of file
diff --git a/blog/tests.py b/blog/tests.py
index 4391f1757..ee1350528 100644
--- a/blog/tests.py
+++ b/blog/tests.py
@@ -4,15 +4,17 @@
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.management import call_command
from django.core.paginator import Paginator
+from django.templatetags.static import static
from django.test import Client, RequestFactory, TestCase
from django.urls import reverse
from django.utils import timezone
-from djangoblog.utils import get_current_site, get_sha256
from accounts.models import BlogUser
from blog.forms import BlogSearchForm
from blog.models import Article, Category, Tag, SideBar, Links
from blog.templatetags.blog_tags import load_pagination_info, load_articletags
+from djangoblog.utils import get_current_site, get_sha256
+from oauth.models import OAuthUser, OAuthConfig
# Create your tests here.
@@ -44,7 +46,7 @@ def test_validate_article(self):
category = Category()
category.name = "category"
- category.created_time = timezone.now()
+ category.creation_time = timezone.now()
category.last_mod_time = timezone.now()
category.save()
@@ -98,29 +100,24 @@ def test_validate_article(self):
s = load_articletags(article)
self.assertIsNotNone(s)
- rsp = self.client.get('/refresh')
- self.assertEqual(rsp.status_code, 302)
-
self.client.login(username='liangliangyy', password='liangliangyy')
- rsp = self.client.get('/refresh')
- self.assertEqual(rsp.status_code, 200)
response = self.client.get(reverse('blog:archives'))
self.assertEqual(response.status_code, 200)
- p = Paginator(Article.objects.all(), 2)
- self.__check_pagination__(p, '', '')
+ p = Paginator(Article.objects.all(), settings.PAGINATE_BY)
+ self.check_pagination(p, '', '')
- p = Paginator(Article.objects.filter(tags=tag), 2)
- self.__check_pagination__(p, '分类标签归档', tag.slug)
+ p = Paginator(Article.objects.filter(tags=tag), settings.PAGINATE_BY)
+ self.check_pagination(p, '分类标签归档', tag.slug)
p = Paginator(
Article.objects.filter(
- author__username='liangliangyy'), 2)
- self.__check_pagination__(p, '作者文章归档', 'liangliangyy')
+ author__username='liangliangyy'), settings.PAGINATE_BY)
+ self.check_pagination(p, '作者文章归档', 'liangliangyy')
- p = Paginator(Article.objects.filter(category=category), 2)
- self.__check_pagination__(p, '分类目录归档', category.slug)
+ p = Paginator(Article.objects.filter(category=category), settings.PAGINATE_BY)
+ self.check_pagination(p, '分类目录归档', category.slug)
f = BlogSearchForm()
f.search()
@@ -140,9 +137,6 @@ def test_validate_article(self):
response = self.client.get('/links.html')
self.assertEqual(response.status_code, 200)
- rsp = self.client.get('/refresh')
- self.assertEqual(rsp.status_code, 200)
-
response = self.client.get('/feed/')
self.assertEqual(response.status_code, 200)
@@ -151,27 +145,24 @@ def test_validate_article(self):
self.client.get("/admin/blog/article/1/delete/")
self.client.get('/admin/servermanager/emailsendlog/')
- self.client.get('admin/admin/logentry/')
-
- def __check_pagination__(self, p, type, value):
- s = load_pagination_info(p.page(1), type, value)
- self.assertIsNotNone(s)
- response = self.client.get(s['previous_url'])
- self.assertEqual(response.status_code, 200)
- response = self.client.get(s['next_url'])
- self.assertEqual(response.status_code, 200)
-
- s = load_pagination_info(p.page(2), type, value)
- self.assertIsNotNone(s)
- response = self.client.get(s['previous_url'])
- self.assertEqual(response.status_code, 200)
- response = self.client.get(s['next_url'])
- self.assertEqual(response.status_code, 200)
+ self.client.get('/admin/admin/logentry/')
+ self.client.get('/admin/admin/logentry/1/change/')
+
+ def check_pagination(self, p, type, value):
+ for page in range(1, p.num_pages + 1):
+ s = load_pagination_info(p.page(page), type, value)
+ self.assertIsNotNone(s)
+ if s['previous_url']:
+ response = self.client.get(s['previous_url'])
+ self.assertEqual(response.status_code, 200)
+ if s['next_url']:
+ response = self.client.get(s['next_url'])
+ self.assertEqual(response.status_code, 200)
def test_image(self):
import requests
rsp = requests.get(
- 'https://www.python.org/static/img/python-logo@2x.png')
+ 'https://www.python.org/static/img/python-logo.png')
imagepath = os.path.join(settings.BASE_DIR, 'python.png')
with open(imagepath, 'wb') as file:
file.write(rsp.content)
@@ -189,13 +180,48 @@ def test_image(self):
from djangoblog.utils import save_user_avatar, send_email
send_email(['qq@qq.com'], 'testTitle', 'testContent')
save_user_avatar(
- 'https://www.python.org/static/img/python-logo@2x.png')
+ 'https://www.python.org/static/img/python-logo.png')
def test_errorpage(self):
rsp = self.client.get('/eee')
self.assertEqual(rsp.status_code, 404)
def test_commands(self):
+ user = BlogUser.objects.get_or_create(
+ email="liangliangyy@gmail.com",
+ username="liangliangyy")[0]
+ user.set_password("liangliangyy")
+ user.is_staff = True
+ user.is_superuser = True
+ user.save()
+
+ c = OAuthConfig()
+ c.type = 'qq'
+ c.appkey = 'appkey'
+ c.appsecret = 'appsecret'
+ c.save()
+
+ u = OAuthUser()
+ u.type = 'qq'
+ u.openid = 'openid'
+ u.user = user
+ u.picture = static("/blog/img/avatar.png")
+ u.metadata = '''
+{
+"figureurl": "https://qzapp.qlogo.cn/qzapp/101513904/C740E30B4113EAA80E0D9918ABC78E82/30"
+}'''
+ u.save()
+
+ u = OAuthUser()
+ u.type = 'qq'
+ u.openid = 'openid1'
+ u.picture = 'https://qzapp.qlogo.cn/qzapp/101513904/C740E30B4113EAA80E0D9918ABC78E82/30'
+ u.metadata = '''
+ {
+ "figureurl": "https://qzapp.qlogo.cn/qzapp/101513904/C740E30B4113EAA80E0D9918ABC78E82/30"
+ }'''
+ u.save()
+
from blog.documents import ELASTICSEARCH_ENABLED
if ELASTICSEARCH_ENABLED:
call_command("build_index")
diff --git a/blog/urls.py b/blog/urls.py
index f04b1265d..adf270363 100644
--- a/blog/urls.py
+++ b/blog/urls.py
@@ -56,6 +56,7 @@
views.fileupload,
name='upload'),
path(
- r'refresh',
- views.refresh_memcache,
- name='refresh')]
+ r'clean',
+ views.clean_cache_view,
+ name='clean'),
+]
diff --git a/blog/views.py b/blog/views.py
index 710dbbf65..773bb7562 100644
--- a/blog/views.py
+++ b/blog/views.py
@@ -1,22 +1,25 @@
-import datetime
import logging
-# Create your views here.
import os
import uuid
-from django import forms
from django.conf import settings
-from django.contrib.auth.decorators import login_required
+from django.core.paginator import Paginator
from django.http import HttpResponse, HttpResponseForbidden
from django.shortcuts import get_object_or_404
from django.shortcuts import render
+from django.templatetags.static import static
+from django.utils import timezone
+from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.generic.detail import DetailView
from django.views.generic.list import ListView
+from haystack.views import SearchView
-from blog.models import Article, Category, Tag, Links, LinkShowType
+from blog.models import Article, Category, LinkShowType, Links, Tag
from comments.forms import CommentForm
-from djangoblog.utils import cache, get_sha256, get_blog_setting
+from djangoblog.plugin_manage import hooks
+from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
+from djangoblog.utils import cache, get_blog_setting, get_sha256
logger = logging.getLogger(__name__)
@@ -111,36 +114,52 @@ class ArticleDetailView(DetailView):
pk_url_kwarg = 'article_id'
context_object_name = "article"
- def get_object(self, queryset=None):
- obj = super(ArticleDetailView, self).get_object()
- obj.viewed()
- self.object = obj
- return obj
-
def get_context_data(self, **kwargs):
- articleid = int(self.kwargs[self.pk_url_kwarg])
comment_form = CommentForm()
- user = self.request.user
- # 如果用户已经登录,则隐藏邮件和用户名输入框
- if user.is_authenticated and not user.is_anonymous and user.email and user.username:
- comment_form.fields.update({
- 'email': forms.CharField(widget=forms.HiddenInput()),
- 'name': forms.CharField(widget=forms.HiddenInput()),
- })
- comment_form.fields["email"].initial = user.email
- comment_form.fields["name"].initial = user.username
article_comments = self.object.comment_list()
-
+ parent_comments = article_comments.filter(parent_comment=None)
+ blog_setting = get_blog_setting()
+ paginator = Paginator(parent_comments, blog_setting.article_comment_count)
+ page = self.request.GET.get('comment_page', '1')
+ if not page.isnumeric():
+ page = 1
+ else:
+ page = int(page)
+ if page < 1:
+ page = 1
+ if page > paginator.num_pages:
+ page = paginator.num_pages
+
+ p_comments = paginator.page(page)
+ next_page = p_comments.next_page_number() if p_comments.has_next() else None
+ prev_page = p_comments.previous_page_number() if p_comments.has_previous() else None
+
+ if next_page:
+ kwargs[
+ 'comment_next_page_url'] = self.object.get_absolute_url() + f'?comment_page={next_page}#commentlist-container'
+ if prev_page:
+ kwargs[
+ 'comment_prev_page_url'] = self.object.get_absolute_url() + f'?comment_page={prev_page}#commentlist-container'
kwargs['form'] = comment_form
kwargs['article_comments'] = article_comments
+ kwargs['p_comments'] = p_comments
kwargs['comment_count'] = len(
article_comments) if article_comments else 0
kwargs['next_article'] = self.object.next_article
kwargs['prev_article'] = self.object.prev_article
- return super(ArticleDetailView, self).get_context_data(**kwargs)
+ context = super(ArticleDetailView, self).get_context_data(**kwargs)
+ article = self.object
+
+ # 触发文章详情加载钩子,让插件可以添加额外的上下文数据
+ from djangoblog.plugin_manage.hook_constants import ARTICLE_DETAIL_LOAD
+ hooks.run_action(ARTICLE_DETAIL_LOAD, article=article, context=context, request=self.request)
+
+ # Action Hook, 通知插件"文章详情已获取"
+ hooks.run_action('after_article_body_get', article=article, request=self.request)
+ return context
class CategoryDetailView(ArticleListView):
@@ -265,6 +284,23 @@ def get_queryset(self):
return Links.objects.filter(is_enable=True)
+class EsSearchView(SearchView):
+ def get_context(self):
+ paginator, page = self.build_page()
+ context = {
+ "query": self.query,
+ "form": self.form,
+ "page": page,
+ "paginator": paginator,
+ "suggestion": None,
+ }
+ if hasattr(self.results, "query") and self.results.query.backend.include_spelling:
+ context["suggestion"] = self.results.query.get_spelling_suggestion()
+ context.update(self.extra_context())
+
+ return context
+
+
@csrf_exempt
def fileupload(request):
"""
@@ -280,24 +316,15 @@ def fileupload(request):
return HttpResponseForbidden()
response = []
for filename in request.FILES:
- timestr = datetime.datetime.now().strftime('%Y/%m/%d')
+ timestr = timezone.now().strftime('%Y/%m/%d')
imgextensions = ['jpg', 'png', 'jpeg', 'bmp']
fname = u''.join(str(filename))
isimage = len([i for i in imgextensions if fname.find(i) >= 0]) > 0
- blogsetting = get_blog_setting()
-
- basepath = r'{basedir}/{type}/{timestr}'.format(
- basedir=blogsetting.resource_path,
- type='files' if not isimage else 'image',
- timestr=timestr)
- if settings.TESTING:
- basepath = settings.BASE_DIR + '/uploads'
- url = 'https://resource.lylinux.net/{type}/{timestr}/{filename}'.format(
- type='files' if not isimage else 'image', timestr=timestr, filename=filename)
- if not os.path.exists(basepath):
- os.makedirs(basepath)
- savepath = os.path.normpath(os.path.join(basepath, f"{uuid.uuid4().hex}{os.path.splitext(filename)[-1]}"))
- if not savepath.startswith(basepath):
+ base_dir = os.path.join(settings.STATICFILES, "files" if not isimage else "image", timestr)
+ if not os.path.exists(base_dir):
+ os.makedirs(base_dir)
+ savepath = os.path.normpath(os.path.join(base_dir, f"{uuid.uuid4().hex}{os.path.splitext(filename)[-1]}"))
+ if not savepath.startswith(base_dir):
return HttpResponse("only for post")
with open(savepath, 'wb+') as wfile:
for chunk in request.FILES[filename].chunks():
@@ -306,6 +333,7 @@ def fileupload(request):
from PIL import Image
image = Image.open(savepath)
image.save(savepath, quality=20, optimize=True)
+ url = static(savepath)
response.append(url)
return HttpResponse(response)
@@ -313,22 +341,6 @@ def fileupload(request):
return HttpResponse("only for post")
-@login_required
-def refresh_memcache(request):
- try:
-
- if request.user.is_superuser:
- from djangoblog.utils import cache
- if cache and cache is not None:
- cache.clear()
- return HttpResponse("ok")
- else:
- return HttpResponseForbidden()
- except Exception as e:
- logger.error(e)
- return HttpResponse("error")
-
-
def page_not_found_view(
request,
exception,
@@ -338,7 +350,7 @@ def page_not_found_view(
url = request.get_full_path()
return render(request,
template_name,
- {'message': '哎呀,您访问的地址 ' + url + ' 是一个未知的地方。请点击首页看看别的?',
+ {'message': _('Sorry, the page you requested is not found, please click the home page to see other?'),
'statuscode': '404'},
status=404)
@@ -346,7 +358,7 @@ def page_not_found_view(
def server_error_view(request, template_name='blog/error_page.html'):
return render(request,
template_name,
- {'message': '哎呀,出错了,我已经收集到了错误信息,之后会抓紧抢修,请点击首页看看别的?',
+ {'message': _('Sorry, the server is busy, please click the home page to see other?'),
'statuscode': '500'},
status=500)
@@ -359,4 +371,10 @@ def permission_denied_view(
logger.error(exception)
return render(
request, template_name, {
- 'message': '哎呀,您没有权限访问此页面,请点击首页看看别的?', 'statuscode': '403'}, status=403)
+ 'message': _('Sorry, you do not have permission to access this page?'),
+ 'statuscode': '403'}, status=403)
+
+
+def clean_cache_view(request):
+ cache.clear()
+ return HttpResponse('ok')
diff --git a/codecov.yml b/codecov.yml
new file mode 100644
index 000000000..229882992
--- /dev/null
+++ b/codecov.yml
@@ -0,0 +1,87 @@
+codecov:
+ require_ci_to_pass: yes
+
+coverage:
+ precision: 2
+ round: down
+ range: "70...100"
+
+ status:
+ project:
+ default:
+ target: auto
+ threshold: 1%
+ informational: true
+ patch:
+ default:
+ target: auto
+ threshold: 1%
+ informational: true
+
+parsers:
+ gcov:
+ branch_detection:
+ conditional: yes
+ loop: yes
+ method: no
+ macro: no
+
+comment:
+ layout: "reach,diff,flags,tree"
+ behavior: default
+ require_changes: no
+
+ignore:
+ # Django 相关
+ - "*/migrations/*"
+ - "manage.py"
+ - "*/settings.py"
+ - "*/wsgi.py"
+ - "*/asgi.py"
+
+ # 测试相关
+ - "*/tests/*"
+ - "*/test_*.py"
+ - "*/*test*.py"
+
+ # 静态文件和模板
+ - "*/static/*"
+ - "*/templates/*"
+ - "*/collectedstatic/*"
+
+ # 国际化文件
+ - "*/locale/*"
+ - "**/*.po"
+ - "**/*.mo"
+
+ # 文档和部署
+ - "*/docs/*"
+ - "*/deploy/*"
+ - "README*.md"
+ - "LICENSE"
+ - "Dockerfile"
+ - "docker-compose*.yml"
+ - "*.yaml"
+ - "*.yml"
+
+ # 开发环境
+ - "*/venv/*"
+ - "*/__pycache__/*"
+ - "*.pyc"
+ - ".coverage"
+ - "coverage.xml"
+
+ # 日志文件
+ - "*/logs/*"
+ - "*.log"
+
+ # 特定文件
+ - "*/whoosh_cn_backend.py" # 搜索后端
+ - "*/elasticsearch_backend.py" # 搜索后端
+ - "*/MemcacheStorage.py" # 缓存存储
+ - "*/robot.py" # 机器人相关
+
+ # 配置文件
+ - "codecov.yml"
+ - ".coveragerc"
+ - "requirements*.txt"
diff --git a/comments/admin.py b/comments/admin.py
index 6897e4444..dbde14f55 100644
--- a/comments/admin.py
+++ b/comments/admin.py
@@ -1,7 +1,7 @@
from django.contrib import admin
-# Register your models here.
from django.urls import reverse
from django.utils.html import format_html
+from django.utils.translation import gettext_lazy as _
def disable_commentstatus(modeladmin, request, queryset):
@@ -12,8 +12,8 @@ def enable_commentstatus(modeladmin, request, queryset):
queryset.update(is_enable=True)
-disable_commentstatus.short_description = '禁用评论'
-enable_commentstatus.short_description = '启用评论'
+disable_commentstatus.short_description = _('Disable comments')
+enable_commentstatus.short_description = _('Enable comments')
class CommentAdmin(admin.ModelAdmin):
@@ -24,11 +24,13 @@ class CommentAdmin(admin.ModelAdmin):
'link_to_userinfo',
'link_to_article',
'is_enable',
- 'created_time')
- list_display_links = ('id', 'body')
- list_filter = ('author', 'article', 'is_enable')
- exclude = ('created_time', 'last_mod_time')
+ 'creation_time')
+ list_display_links = ('id', 'body', 'is_enable')
+ list_filter = ('is_enable',)
+ exclude = ('creation_time', 'last_modify_time')
actions = [disable_commentstatus, enable_commentstatus]
+ raw_id_fields = ('author', 'article')
+ search_fields = ('body',)
def link_to_userinfo(self, obj):
info = (obj.author._meta.app_label, obj.author._meta.model_name)
@@ -38,10 +40,10 @@ def link_to_userinfo(self, obj):
(link, obj.author.nickname if obj.author.nickname else obj.author.email))
def link_to_article(self, obj):
- info = (obj.author._meta.app_label, obj.author._meta.model_name)
+ info = (obj.article._meta.app_label, obj.article._meta.model_name)
link = reverse('admin:%s_%s_change' % info, args=(obj.article.id,))
return format_html(
u'%s ' % (link, obj.article.title))
- link_to_userinfo.short_description = '用户'
- link_to_article.short_description = '文章'
+ link_to_userinfo.short_description = _('User')
+ link_to_article.short_description = _('Article')
diff --git a/comments/forms.py b/comments/forms.py
index 8f4a480a2..e83737db2 100644
--- a/comments/forms.py
+++ b/comments/forms.py
@@ -5,16 +5,6 @@
class CommentForm(ModelForm):
- url = forms.URLField(label='网址', required=False)
- email = forms.EmailField(label='电子邮箱', required=True)
- name = forms.CharField(
- label='姓名',
- widget=forms.TextInput(
- attrs={
- 'value': "",
- 'size': "30",
- 'maxlength': "245",
- 'aria-required': 'true'}))
parent_comment_id = forms.IntegerField(
widget=forms.HiddenInput, required=False)
diff --git a/comments/migrations/0001_initial.py b/comments/migrations/0001_initial.py
new file mode 100644
index 000000000..61d1e539f
--- /dev/null
+++ b/comments/migrations/0001_initial.py
@@ -0,0 +1,38 @@
+# Generated by Django 4.1.7 on 2023-03-02 07:14
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('blog', '0001_initial'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Comment',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('body', models.TextField(max_length=300, 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='修改时间')),
+ ('is_enable', models.BooleanField(default=True, verbose_name='是否显示')),
+ ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='文章')),
+ ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')),
+ ('parent_comment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='上级评论')),
+ ],
+ options={
+ 'verbose_name': '评论',
+ 'verbose_name_plural': '评论',
+ 'ordering': ['-id'],
+ 'get_latest_by': 'id',
+ },
+ ),
+ ]
diff --git a/comments/migrations/0002_alter_comment_is_enable.py b/comments/migrations/0002_alter_comment_is_enable.py
new file mode 100644
index 000000000..17c44db81
--- /dev/null
+++ b/comments/migrations/0002_alter_comment_is_enable.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.1.7 on 2023-04-24 13:48
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('comments', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='comment',
+ name='is_enable',
+ field=models.BooleanField(default=False, verbose_name='是否显示'),
+ ),
+ ]
diff --git a/comments/migrations/0003_alter_comment_options_remove_comment_created_time_and_more.py b/comments/migrations/0003_alter_comment_options_remove_comment_created_time_and_more.py
new file mode 100644
index 000000000..a1ca9708a
--- /dev/null
+++ b/comments/migrations/0003_alter_comment_options_remove_comment_created_time_and_more.py
@@ -0,0 +1,60 @@
+# 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
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('blog', '0005_alter_article_options_alter_category_options_and_more'),
+ ('comments', '0002_alter_comment_is_enable'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='comment',
+ options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'comment', 'verbose_name_plural': 'comment'},
+ ),
+ migrations.RemoveField(
+ model_name='comment',
+ name='created_time',
+ ),
+ migrations.RemoveField(
+ model_name='comment',
+ name='last_mod_time',
+ ),
+ migrations.AddField(
+ model_name='comment',
+ name='creation_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
+ ),
+ migrations.AddField(
+ model_name='comment',
+ name='last_modify_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
+ ),
+ migrations.AlterField(
+ model_name='comment',
+ name='article',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='article'),
+ ),
+ migrations.AlterField(
+ model_name='comment',
+ name='author',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'),
+ ),
+ migrations.AlterField(
+ model_name='comment',
+ name='is_enable',
+ field=models.BooleanField(default=False, verbose_name='enable'),
+ ),
+ migrations.AlterField(
+ model_name='comment',
+ name='parent_comment',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='parent comment'),
+ ),
+ ]
diff --git a/comments/models.py b/comments/models.py
index 67df624cd..7c3bbc8da 100644
--- a/comments/models.py
+++ b/comments/models.py
@@ -1,6 +1,7 @@
from django.conf import settings
from django.db import models
from django.utils.timezone import now
+from django.utils.translation import gettext_lazy as _
from blog.models import Article
@@ -9,33 +10,30 @@
class Comment(models.Model):
body = models.TextField('正文', max_length=300)
- created_time = models.DateTimeField('创建时间', default=now)
- last_mod_time = models.DateTimeField('修改时间', default=now)
+ creation_time = models.DateTimeField(_('creation time'), default=now)
+ last_modify_time = models.DateTimeField(_('last modify time'), default=now)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
- verbose_name='作者',
+ verbose_name=_('author'),
on_delete=models.CASCADE)
article = models.ForeignKey(
Article,
- verbose_name='文章',
+ verbose_name=_('article'),
on_delete=models.CASCADE)
parent_comment = models.ForeignKey(
'self',
- verbose_name="上级评论",
+ verbose_name=_('parent comment'),
blank=True,
null=True,
on_delete=models.CASCADE)
- is_enable = models.BooleanField(
- '是否显示', default=True, blank=False, null=False)
+ is_enable = models.BooleanField(_('enable'),
+ default=False, blank=False, null=False)
class Meta:
- ordering = ['id']
- verbose_name = "评论"
+ ordering = ['-id']
+ verbose_name = _('comment')
verbose_name_plural = verbose_name
get_latest_by = 'id'
def __str__(self):
return self.body
-
- def save(self, *args, **kwargs):
- super().save(*args, **kwargs)
diff --git a/comments/tests.py b/comments/tests.py
index 3b95550dc..2a7f55f1f 100644
--- a/comments/tests.py
+++ b/comments/tests.py
@@ -1,74 +1,81 @@
-from django.test import Client, RequestFactory, TestCase
+from django.test import Client, RequestFactory, TransactionTestCase
from django.urls import reverse
-from django.utils import timezone
from accounts.models import BlogUser
from blog.models import Category, Article
from comments.models import Comment
from comments.templatetags.comments_tags import *
-from djangoblog.utils import get_current_site
from djangoblog.utils import get_max_articleid_commentid
# Create your tests here.
-class CommentsTest(TestCase):
+class CommentsTest(TransactionTestCase):
def setUp(self):
self.client = Client()
self.factory = RequestFactory()
+ from blog.models import BlogSettings
+ value = BlogSettings()
+ value.comment_need_review = True
+ value.save()
- def test_validate_comment(self):
- site = get_current_site().domain
- user = BlogUser.objects.create_superuser(
+ self.user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com",
username="liangliangyy1",
password="liangliangyy1")
+ def update_article_comment_status(self, article):
+ comments = article.comment_set.all()
+ for comment in comments:
+ comment.is_enable = True
+ comment.save()
+
+ def test_validate_comment(self):
self.client.login(username='liangliangyy1', password='liangliangyy1')
category = Category()
category.name = "categoryccc"
- category.created_time = timezone.now()
- category.last_mod_time = timezone.now()
category.save()
article = Article()
article.title = "nicetitleccc"
article.body = "nicecontentccc"
- article.author = user
+ article.author = self.user
article.category = category
article.type = 'a'
article.status = 'p'
article.save()
- commenturl = reverse(
+ comment_url = reverse(
'comments:postcomment', kwargs={
'article_id': article.id})
- response = self.client.post(commenturl,
+ response = self.client.post(comment_url,
{
'body': '123ffffffffff'
})
- self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.status_code, 302)
article = Article.objects.get(pk=article.pk)
self.assertEqual(len(article.comment_list()), 0)
+ self.update_article_comment_status(article)
+
+ self.assertEqual(len(article.comment_list()), 1)
- response = self.client.post(commenturl,
+ response = self.client.post(comment_url,
{
'body': '123ffffffffff',
- 'email': user.email,
- 'name': user.username
})
self.assertEqual(response.status_code, 302)
article = Article.objects.get(pk=article.pk)
- self.assertEqual(len(article.comment_list()), 1)
+ self.update_article_comment_status(article)
+ self.assertEqual(len(article.comment_list()), 2)
parent_comment_id = article.comment_list()[0].id
- response = self.client.post(commenturl,
+ response = self.client.post(comment_url,
{
'body': '''
# Title1
@@ -83,15 +90,13 @@ def test_validate_comment(self):
''',
- 'email': user.email,
- 'name': user.username,
'parent_comment_id': parent_comment_id
})
self.assertEqual(response.status_code, 302)
-
+ self.update_article_comment_status(article)
article = Article.objects.get(pk=article.pk)
- self.assertEqual(len(article.comment_list()), 2)
+ self.assertEqual(len(article.comment_list()), 3)
comment = Comment.objects.get(id=parent_comment_id)
tree = parse_commenttree(article.comment_list(), comment)
self.assertEqual(len(tree), 1)
diff --git a/comments/urls.py b/comments/urls.py
index bc22017b3..7df3fab47 100644
--- a/comments/urls.py
+++ b/comments/urls.py
@@ -4,7 +4,6 @@
app_name = "comments"
urlpatterns = [
- # url(r'^po456stcomment/(?P\d+)$', views.CommentPostView.as_view(), name='postcomment'),
path(
'article//postcomment',
views.CommentPostView.as_view(),
diff --git a/comments/utils.py b/comments/utils.py
index 0380f0823..f01dba7ef 100644
--- a/comments/utils.py
+++ b/comments/utils.py
@@ -1,5 +1,7 @@
import logging
+from django.utils.translation import gettext_lazy as _
+
from djangoblog.utils import get_current_site
from djangoblog.utils import send_email
@@ -8,29 +10,28 @@
def send_comment_email(comment):
site = get_current_site().domain
- subject = '感谢您发表的评论'
- article_url = "https://{site}{path}".format(
- site=site, path=comment.article.get_absolute_url())
- html_content = """
- 非常感谢您在本站发表评论
- 您可以访问
- %s
- 来查看您的评论,
- 再次感谢您!
-
- 如果上面链接无法打开,请将此链接复制至浏览器。
- %s
- """ % (article_url, comment.article.title, article_url)
+ subject = _('Thanks for your comment')
+ article_url = f"https://{site}{comment.article.get_absolute_url()}"
+ html_content = _("""Thank you very much for your comments on this site
+ You can visit %(article_title)s
+ to review your comments,
+ Thank you again!
+
+ If the link above cannot be opened, please copy this link to your browser.
+ %(article_url)s""") % {'article_url': article_url, 'article_title': comment.article.title}
tomail = comment.author.email
send_email([tomail], subject, html_content)
try:
if comment.parent_comment:
- html_content = """
- 您在 %s 的评论 %s 收到回复啦.快去看看吧
-
- 如果上面链接无法打开,请将此链接复制至浏览器。
- %s
- """ % (article_url, comment.article.title, comment.parent_comment.body, article_url)
+ html_content = _("""Your comment on %(article_title)s has
+ received a reply. %(comment_body)s
+
+ go check it out!
+
+ If the link above cannot be opened, please copy this link to your browser.
+ %(article_url)s
+ """) % {'article_url': article_url, 'article_title': comment.article.title,
+ 'comment_body': comment.parent_comment.body}
tomail = comment.parent_comment.author.email
send_email([tomail], subject, html_content)
except Exception as e:
diff --git a/comments/views.py b/comments/views.py
index 4484f5bf3..ad9b2b94c 100644
--- a/comments/views.py
+++ b/comments/views.py
@@ -1,9 +1,12 @@
# Create your views here.
-from django import forms
-from django.contrib.auth import get_user_model
+from django.core.exceptions import ValidationError
from django.http import HttpResponseRedirect
+from django.shortcuts import get_object_or_404
+from django.utils.decorators import method_decorator
+from django.views.decorators.csrf import csrf_protect
from django.views.generic.edit import FormView
+from accounts.models import BlogUser
from blog.models import Article
from .forms import CommentForm
from .models import Comment
@@ -13,26 +16,19 @@ class CommentPostView(FormView):
form_class = CommentForm
template_name = 'blog/article_detail.html'
+ @method_decorator(csrf_protect)
+ def dispatch(self, *args, **kwargs):
+ return super(CommentPostView, self).dispatch(*args, **kwargs)
+
def get(self, request, *args, **kwargs):
article_id = self.kwargs['article_id']
-
- article = Article.objects.get(pk=article_id)
+ article = get_object_or_404(Article, pk=article_id)
url = article.get_absolute_url()
return HttpResponseRedirect(url + "#comments")
def form_invalid(self, form):
article_id = self.kwargs['article_id']
- article = Article.objects.get(pk=article_id)
- u = self.request.user
-
- if self.request.user.is_authenticated:
- form.fields.update({
- 'email': forms.CharField(widget=forms.HiddenInput()),
- 'name': forms.CharField(widget=forms.HiddenInput()),
- })
- user = self.request.user
- form.fields["email"].initial = user.email
- form.fields["name"].initial = user.username
+ article = get_object_or_404(Article, pk=article_id)
return self.render_to_response({
'form': form,
@@ -42,20 +38,19 @@ def form_invalid(self, form):
def form_valid(self, form):
"""提交的数据验证合法后的逻辑"""
user = self.request.user
-
+ author = BlogUser.objects.get(pk=user.pk)
article_id = self.kwargs['article_id']
- article = Article.objects.get(pk=article_id)
- if not self.request.user.is_authenticated:
- email = form.cleaned_data['email']
- username = form.cleaned_data['name']
+ article = get_object_or_404(Article, pk=article_id)
- user = get_user_model().objects.get_or_create(
- username=username, email=email)[0]
- # auth.login(self.request, user)
+ if article.comment_status == 'c' or article.status == 'c':
+ raise ValidationError("该文章评论已关闭.")
comment = form.save(False)
comment.article = article
-
- comment.author = user
+ from djangoblog.utils import get_blog_setting
+ settings = get_blog_setting()
+ if not settings.comment_need_review:
+ comment.is_enable = True
+ comment.author = author
if form.cleaned_data['parent_comment_id']:
parent_comment = Comment.objects.get(
diff --git a/docker-compose.es.yml b/deploy/docker-compose/docker-compose.es.yml
similarity index 89%
rename from docker-compose.es.yml
rename to deploy/docker-compose/docker-compose.es.yml
index 5aa52909b..83e35ffd4 100644
--- a/docker-compose.es.yml
+++ b/deploy/docker-compose/docker-compose.es.yml
@@ -2,7 +2,7 @@ version: '3'
services:
es:
- image: liangliangyy/elasticsearch-analysis-ik:7.16.1
+ image: liangliangyy/elasticsearch-analysis-ik:8.6.1
container_name: es
restart: always
environment:
@@ -14,7 +14,7 @@ services:
- ./bin/datas/es/:/usr/share/elasticsearch/data/
kibana:
- image: kibana:7.13.2
+ image: kibana:8.6.1
restart: always
container_name: kibana
ports:
@@ -30,6 +30,7 @@ services:
- "8000:8000"
volumes:
- ./collectedstatic:/code/djangoblog/collectedstatic
+ - ./uploads:/code/djangoblog/uploads
environment:
- DJANGO_MYSQL_DATABASE=djangoblog
- DJANGO_MYSQL_USER=root
diff --git a/docker-compose.yml b/deploy/docker-compose/docker-compose.yml
similarity index 69%
rename from docker-compose.yml
rename to deploy/docker-compose/docker-compose.yml
index 307c66b32..9609af3f0 100644
--- a/docker-compose.yml
+++ b/deploy/docker-compose/docker-compose.yml
@@ -4,15 +4,6 @@ services:
db:
image: mysql:latest
restart: always
- command:
- - mysqld
- - --max_connections=3000
- - --wait_timeout=600
- - --interactive_timeout=600
- - --thread_cache_size=50
- - --default-authentication-plugin=mysql_native_password
- - --character-set-server=utf8
- - --collation-server=utf8_general_ci
environment:
- MYSQL_DATABASE=djangoblog
- MYSQL_ROOT_PASSWORD=DjAnGoBlOg!2!Q@W#E
@@ -21,27 +12,30 @@ services:
volumes:
- ./bin/datas/mysql/:/var/lib/mysql
depends_on:
- - memcached
+ - redis
container_name: db
djangoblog:
- build: .
+ build:
+ context: ../../
restart: always
command: bash -c 'sh /code/djangoblog/bin/docker_start.sh'
ports:
- "8000:8000"
volumes:
- ./collectedstatic:/code/djangoblog/collectedstatic
+ - ./logs:/code/djangoblog/logs
+ - ./uploads:/code/djangoblog/uploads
environment:
- DJANGO_MYSQL_DATABASE=djangoblog
- DJANGO_MYSQL_USER=root
- DJANGO_MYSQL_PASSWORD=DjAnGoBlOg!2!Q@W#E
- DJANGO_MYSQL_HOST=db
- DJANGO_MYSQL_PORT=3306
- - DJANGO_MEMCACHED_LOCATION=memcached:11211
+ - DJANGO_REDIS_URL=redis:6379
links:
- db
- - memcached
+ - redis
depends_on:
- db
container_name: djangoblog
@@ -58,9 +52,9 @@ services:
- djangoblog:djangoblog
container_name: nginx
- memcached:
+ redis:
restart: always
- image: memcached:latest
- container_name: memcached
+ image: redis:latest
+ container_name: redis
ports:
- - "11211:11211"
+ - "6379:6379"
diff --git a/deploy/entrypoint.sh b/deploy/entrypoint.sh
new file mode 100644
index 000000000..2fb64919f
--- /dev/null
+++ b/deploy/entrypoint.sh
@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+NAME="djangoblog"
+DJANGODIR=/code/djangoblog
+USER=root
+GROUP=root
+NUM_WORKERS=1
+DJANGO_WSGI_MODULE=djangoblog.wsgi
+
+
+echo "Starting $NAME as `whoami`"
+
+cd $DJANGODIR
+
+export PYTHONPATH=$DJANGODIR:$PYTHONPATH
+
+python manage.py makemigrations && \
+ python manage.py migrate && \
+ python manage.py collectstatic --noinput && \
+ python manage.py compress --force && \
+ python manage.py build_index && \
+ python manage.py compilemessages || exit 1
+
+exec gunicorn ${DJANGO_WSGI_MODULE}:application \
+--name $NAME \
+--workers $NUM_WORKERS \
+--user=$USER --group=$GROUP \
+--bind 0.0.0.0:8000 \
+--log-level=debug \
+--log-file=- \
+--worker-class gevent \
+--threads 4
diff --git a/deploy/k8s/configmap.yaml b/deploy/k8s/configmap.yaml
new file mode 100644
index 000000000..835d4ad0e
--- /dev/null
+++ b/deploy/k8s/configmap.yaml
@@ -0,0 +1,119 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: web-nginx-config
+ namespace: djangoblog
+data:
+ nginx.conf: |
+ user nginx;
+ worker_processes auto;
+ error_log /var/log/nginx/error.log notice;
+ pid /var/run/nginx.pid;
+
+ events {
+ worker_connections 1024;
+ multi_accept on;
+ use epoll;
+ }
+
+ http {
+ include /etc/nginx/mime.types;
+ default_type application/octet-stream;
+
+ log_format main '$remote_addr - $remote_user [$time_local] "$request" '
+ '$status $body_bytes_sent "$http_referer" '
+ '"$http_user_agent" "$http_x_forwarded_for"';
+
+ access_log /var/log/nginx/access.log main;
+
+ sendfile on;
+ keepalive_timeout 65;
+ gzip on;
+ gzip_disable "msie6";
+
+ gzip_vary on;
+ gzip_proxied any;
+ gzip_comp_level 8;
+ gzip_buffers 16 8k;
+ gzip_http_version 1.1;
+ gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png;
+
+ # Include server configurations
+ include /etc/nginx/conf.d/*.conf;
+ }
+ djangoblog.conf: |
+ server {
+ server_name lylinux.net;
+ root /code/djangoblog/collectedstatic/;
+ listen 80;
+ keepalive_timeout 70;
+ location /static/ {
+ expires max;
+ alias /code/djangoblog/collectedstatic/;
+ }
+
+ location ~* (robots\.txt|ads\.txt|favicon\.ico|favion\.ico|crossdomain\.xml|google93fd32dbd906620a\.html|BingSiteAuth\.xml|baidu_verify_Ijeny6KrmS\.html)$ {
+ root /resource/djangopub;
+ expires 1d;
+ access_log off;
+ error_log off;
+ }
+
+ location / {
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header Host $http_host;
+ proxy_set_header X-NginX-Proxy true;
+ proxy_redirect off;
+ if (!-f $request_filename) {
+ proxy_pass http://djangoblog:8000;
+ break;
+ }
+ }
+ }
+ server {
+ server_name www.lylinux.net;
+ listen 80;
+ return 301 https://lylinux.net$request_uri;
+ }
+ resource.lylinux.net.conf: |
+ server {
+ index index.html index.htm;
+ server_name resource.lylinux.net;
+ root /resource/;
+
+ location /djangoblog/ {
+ alias /code/djangoblog/collectedstatic/;
+ }
+
+ access_log off;
+ error_log off;
+ include lylinux/resource.conf;
+ }
+ lylinux.resource.conf: |
+ expires max;
+ access_log off;
+ log_not_found off;
+ add_header Pragma public;
+ add_header Cache-Control "public";
+ add_header "Access-Control-Allow-Origin" "*";
+
+---
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: djangoblog-env
+ namespace: djangoblog
+data:
+ DJANGO_MYSQL_DATABASE: djangoblog
+ DJANGO_MYSQL_USER: db_user
+ DJANGO_MYSQL_PASSWORD: db_password
+ DJANGO_MYSQL_HOST: db_host
+ DJANGO_MYSQL_PORT: db_port
+ DJANGO_REDIS_URL: "redis:6379"
+ DJANGO_DEBUG: "False"
+ MYSQL_ROOT_PASSWORD: db_password
+ MYSQL_DATABASE: djangoblog
+ MYSQL_PASSWORD: db_password
+ DJANGO_SECRET_KEY: xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+
diff --git a/deploy/k8s/deployment.yaml b/deploy/k8s/deployment.yaml
new file mode 100644
index 000000000..b50c4113b
--- /dev/null
+++ b/deploy/k8s/deployment.yaml
@@ -0,0 +1,274 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: djangoblog
+ namespace: djangoblog
+ labels:
+ app: djangoblog
+spec:
+ replicas: 3
+ selector:
+ matchLabels:
+ app: djangoblog
+ template:
+ metadata:
+ labels:
+ app: djangoblog
+ spec:
+ containers:
+ - name: djangoblog
+ image: liangliangyy/djangoblog:latest
+ imagePullPolicy: Always
+ ports:
+ - containerPort: 8000
+ envFrom:
+ - configMapRef:
+ name: djangoblog-env
+ readinessProbe:
+ httpGet:
+ path: /health/
+ port: 8000
+ initialDelaySeconds: 10
+ periodSeconds: 30
+ livenessProbe:
+ httpGet:
+ path: /health/
+ port: 8000
+ initialDelaySeconds: 10
+ periodSeconds: 30
+ resources:
+ requests:
+ cpu: 10m
+ memory: 100Mi
+ limits:
+ cpu: "2"
+ memory: 2Gi
+ volumeMounts:
+ - name: djangoblog
+ mountPath: /code/djangoblog/collectedstatic
+ - name: resource
+ mountPath: /resource
+ volumes:
+ - name: djangoblog
+ persistentVolumeClaim:
+ claimName: djangoblog-pvc
+ - name: resource
+ persistentVolumeClaim:
+ claimName: resource-pvc
+
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: redis
+ namespace: djangoblog
+ labels:
+ app: redis
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: redis
+ template:
+ metadata:
+ labels:
+ app: redis
+ spec:
+ containers:
+ - name: redis
+ image: redis:latest
+ imagePullPolicy: IfNotPresent
+ ports:
+ - containerPort: 6379
+ resources:
+ requests:
+ cpu: 10m
+ memory: 100Mi
+ limits:
+ cpu: 200m
+ memory: 2Gi
+
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: db
+ namespace: djangoblog
+ labels:
+ app: db
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: db
+ template:
+ metadata:
+ labels:
+ app: db
+ spec:
+ containers:
+ - name: db
+ image: mysql:latest
+ imagePullPolicy: IfNotPresent
+ ports:
+ - containerPort: 3306
+ envFrom:
+ - configMapRef:
+ name: djangoblog-env
+ readinessProbe:
+ exec:
+ command:
+ - mysqladmin
+ - ping
+ - "-h"
+ - "127.0.0.1"
+ - "-u"
+ - "root"
+ - "-p$MYSQL_ROOT_PASSWORD"
+ initialDelaySeconds: 10
+ periodSeconds: 10
+ livenessProbe:
+ exec:
+ command:
+ - mysqladmin
+ - ping
+ - "-h"
+ - "127.0.0.1"
+ - "-u"
+ - "root"
+ - "-p$MYSQL_ROOT_PASSWORD"
+ initialDelaySeconds: 10
+ periodSeconds: 10
+ resources:
+ requests:
+ cpu: 10m
+ memory: 100Mi
+ limits:
+ cpu: "2"
+ memory: 2Gi
+ volumeMounts:
+ - name: db-data
+ mountPath: /var/lib/mysql
+ volumes:
+ - name: db-data
+ persistentVolumeClaim:
+ claimName: db-pvc
+
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: nginx
+ namespace: djangoblog
+ labels:
+ app: nginx
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: nginx
+ template:
+ metadata:
+ labels:
+ app: nginx
+ spec:
+ containers:
+ - name: nginx
+ image: nginx:latest
+ imagePullPolicy: IfNotPresent
+ ports:
+ - containerPort: 80
+ resources:
+ requests:
+ cpu: 10m
+ memory: 100Mi
+ limits:
+ cpu: "2"
+ memory: 2Gi
+ volumeMounts:
+ - name: nginx-config
+ mountPath: /etc/nginx/nginx.conf
+ subPath: nginx.conf
+ - name: nginx-config
+ mountPath: /etc/nginx/conf.d/default.conf
+ subPath: djangoblog.conf
+ - name: nginx-config
+ mountPath: /etc/nginx/conf.d/resource.lylinux.net.conf
+ subPath: resource.lylinux.net.conf
+ - name: nginx-config
+ mountPath: /etc/nginx/lylinux/resource.conf
+ subPath: lylinux.resource.conf
+ - name: djangoblog-pvc
+ mountPath: /code/djangoblog/collectedstatic
+ - name: resource-pvc
+ mountPath: /resource
+ volumes:
+ - name: nginx-config
+ configMap:
+ name: web-nginx-config
+ - name: djangoblog-pvc
+ persistentVolumeClaim:
+ claimName: djangoblog-pvc
+ - name: resource-pvc
+ persistentVolumeClaim:
+ claimName: resource-pvc
+
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: elasticsearch
+ namespace: djangoblog
+ labels:
+ app: elasticsearch
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: elasticsearch
+ template:
+ metadata:
+ labels:
+ app: elasticsearch
+ spec:
+ containers:
+ - name: elasticsearch
+ image: liangliangyy/elasticsearch-analysis-ik:8.6.1
+ imagePullPolicy: IfNotPresent
+ env:
+ - name: discovery.type
+ value: single-node
+ - name: ES_JAVA_OPTS
+ value: "-Xms256m -Xmx256m"
+ - name: xpack.security.enabled
+ value: "false"
+ - name: xpack.monitoring.templates.enabled
+ value: "false"
+ ports:
+ - containerPort: 9200
+ resources:
+ requests:
+ cpu: 10m
+ memory: 100Mi
+ limits:
+ cpu: "2"
+ memory: 2Gi
+ readinessProbe:
+ httpGet:
+ path: /
+ port: 9200
+ initialDelaySeconds: 15
+ periodSeconds: 30
+ livenessProbe:
+ httpGet:
+ path: /
+ port: 9200
+ initialDelaySeconds: 15
+ periodSeconds: 30
+ volumeMounts:
+ - name: elasticsearch-data
+ mountPath: /usr/share/elasticsearch/data/
+ volumes:
+ - name: elasticsearch-data
+ persistentVolumeClaim:
+ claimName: elasticsearch-pvc
diff --git a/deploy/k8s/gateway.yaml b/deploy/k8s/gateway.yaml
new file mode 100644
index 000000000..a8de073ba
--- /dev/null
+++ b/deploy/k8s/gateway.yaml
@@ -0,0 +1,17 @@
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: nginx
+ namespace: djangoblog
+spec:
+ ingressClassName: nginx
+ rules:
+ - http:
+ paths:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: nginx
+ port:
+ number: 80
\ No newline at end of file
diff --git a/deploy/k8s/pv.yaml b/deploy/k8s/pv.yaml
new file mode 100644
index 000000000..874b72f37
--- /dev/null
+++ b/deploy/k8s/pv.yaml
@@ -0,0 +1,94 @@
+apiVersion: v1
+kind: PersistentVolume
+metadata:
+ name: local-pv-db
+spec:
+ capacity:
+ storage: 10Gi
+ volumeMode: Filesystem
+ accessModes:
+ - ReadWriteOnce
+ persistentVolumeReclaimPolicy: Retain
+ storageClassName: local-storage
+ local:
+ path: /mnt/local-storage-db
+ nodeAffinity:
+ required:
+ nodeSelectorTerms:
+ - matchExpressions:
+ - key: kubernetes.io/hostname
+ operator: In
+ values:
+ - master
+---
+apiVersion: v1
+kind: PersistentVolume
+metadata:
+ name: local-pv-djangoblog
+spec:
+ capacity:
+ storage: 5Gi
+ volumeMode: Filesystem
+ accessModes:
+ - ReadWriteOnce
+ persistentVolumeReclaimPolicy: Retain
+ storageClassName: local-storage
+ local:
+ path: /mnt/local-storage-djangoblog
+ nodeAffinity:
+ required:
+ nodeSelectorTerms:
+ - matchExpressions:
+ - key: kubernetes.io/hostname
+ operator: In
+ values:
+ - master
+
+
+---
+apiVersion: v1
+kind: PersistentVolume
+metadata:
+ name: local-pv-resource
+spec:
+ capacity:
+ storage: 5Gi
+ volumeMode: Filesystem
+ accessModes:
+ - ReadWriteOnce
+ persistentVolumeReclaimPolicy: Retain
+ storageClassName: local-storage
+ local:
+ path: /mnt/resource/
+ nodeAffinity:
+ required:
+ nodeSelectorTerms:
+ - matchExpressions:
+ - key: kubernetes.io/hostname
+ operator: In
+ values:
+ - master
+
+---
+apiVersion: v1
+kind: PersistentVolume
+metadata:
+ name: local-pv-elasticsearch
+spec:
+ capacity:
+ storage: 5Gi
+ volumeMode: Filesystem
+ accessModes:
+ - ReadWriteOnce
+ persistentVolumeReclaimPolicy: Retain
+ storageClassName: local-storage
+ local:
+ path: /mnt/local-storage-elasticsearch
+ nodeAffinity:
+ required:
+ nodeSelectorTerms:
+ - matchExpressions:
+ - key: kubernetes.io/hostname
+ operator: In
+ values:
+ - master
\ No newline at end of file
diff --git a/deploy/k8s/pvc.yaml b/deploy/k8s/pvc.yaml
new file mode 100644
index 000000000..ef238c5a3
--- /dev/null
+++ b/deploy/k8s/pvc.yaml
@@ -0,0 +1,60 @@
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: db-pvc
+ namespace: djangoblog
+spec:
+ storageClassName: local-storage
+ volumeName: local-pv-db
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 10Gi
+
+
+---
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: djangoblog-pvc
+ namespace: djangoblog
+spec:
+ volumeName: local-pv-djangoblog
+ storageClassName: local-storage
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 5Gi
+
+---
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: resource-pvc
+ namespace: djangoblog
+spec:
+ volumeName: local-pv-resource
+ storageClassName: local-storage
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 5Gi
+
+---
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: elasticsearch-pvc
+ namespace: djangoblog
+spec:
+ volumeName: local-pv-elasticsearch
+ storageClassName: local-storage
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 5Gi
+
\ No newline at end of file
diff --git a/deploy/k8s/service.yaml b/deploy/k8s/service.yaml
new file mode 100644
index 000000000..4ef2931e4
--- /dev/null
+++ b/deploy/k8s/service.yaml
@@ -0,0 +1,80 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: djangoblog
+ namespace: djangoblog
+ labels:
+ app: djangoblog
+spec:
+ selector:
+ app: djangoblog
+ ports:
+ - protocol: TCP
+ port: 8000
+ targetPort: 8000
+ type: ClusterIP
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: nginx
+ namespace: djangoblog
+ labels:
+ app: nginx
+spec:
+ selector:
+ app: nginx
+ ports:
+ - protocol: TCP
+ port: 80
+ targetPort: 80
+ type: ClusterIP
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: redis
+ namespace: djangoblog
+ labels:
+ app: redis
+spec:
+ selector:
+ app: redis
+ ports:
+ - protocol: TCP
+ port: 6379
+ targetPort: 6379
+ type: ClusterIP
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: db
+ namespace: djangoblog
+ labels:
+ app: db
+spec:
+ selector:
+ app: db
+ ports:
+ - protocol: TCP
+ port: 3306
+ targetPort: 3306
+ type: ClusterIP
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: elasticsearch
+ namespace: djangoblog
+ labels:
+ app: elasticsearch
+spec:
+ selector:
+ app: elasticsearch
+ ports:
+ - protocol: TCP
+ port: 9200
+ targetPort: 9200
+ type: ClusterIP
+
diff --git a/deploy/k8s/storageclass.yaml b/deploy/k8s/storageclass.yaml
new file mode 100644
index 000000000..5d5a14cd5
--- /dev/null
+++ b/deploy/k8s/storageclass.yaml
@@ -0,0 +1,10 @@
+apiVersion: storage.k8s.io/v1
+kind: StorageClass
+metadata:
+ name: local-storage
+ annotations:
+ storageclass.kubernetes.io/is-default-class: "true"
+provisioner: kubernetes.io/no-provisioner
+volumeBindingMode: Immediate
+
+
diff --git a/bin/nginx.conf b/deploy/nginx.conf
similarity index 100%
rename from bin/nginx.conf
rename to deploy/nginx.conf
diff --git a/djangoblog/__init__.py b/djangoblog/__init__.py
index e69de29bb..1e205f40a 100644
--- a/djangoblog/__init__.py
+++ b/djangoblog/__init__.py
@@ -0,0 +1 @@
+default_app_config = 'djangoblog.apps.DjangoblogAppConfig'
diff --git a/djangoblog/apps.py b/djangoblog/apps.py
new file mode 100644
index 000000000..d29e318a1
--- /dev/null
+++ b/djangoblog/apps.py
@@ -0,0 +1,11 @@
+from django.apps import AppConfig
+
+class DjangoblogAppConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'djangoblog'
+
+ def ready(self):
+ super().ready()
+ # Import and load plugins here
+ from .plugin_manage.loader import load_plugins
+ load_plugins()
\ No newline at end of file
diff --git a/djangoblog/blog_signals.py b/djangoblog/blog_signals.py
index 525d45dc4..393f441c6 100644
--- a/djangoblog/blog_signals.py
+++ b/djangoblog/blog_signals.py
@@ -9,18 +9,18 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
+from comments.models import Comment
+from comments.utils import send_comment_email
from djangoblog.spider_notify import SpiderNotify
from djangoblog.utils import cache, expire_view_cache, delete_sidebar_cache, delete_view_cache
from djangoblog.utils import get_current_site
-from comments.models import Comment
-from comments.utils import send_comment_email
from oauth.models import OAuthUser
logger = logging.getLogger(__name__)
-oauth_user_login_signal = django.dispatch.Signal(providing_args=['id'])
+oauth_user_login_signal = django.dispatch.Signal(['id'])
send_email_signal = django.dispatch.Signal(
- providing_args=['emailto', 'title', 'content'])
+ ['emailto', 'title', 'content'])
@receiver(send_email_signal)
@@ -88,26 +88,26 @@ def model_post_save_callback(
clearcache = True
if isinstance(instance, Comment):
-
- path = instance.article.get_absolute_url()
- site = get_current_site().domain
- if site.find(':') > 0:
- site = site[0:site.find(':')]
-
- expire_view_cache(
- path,
- servername=site,
- serverport=80,
- key_prefix='blogdetail')
- if cache.get('seo_processor'):
- cache.delete('seo_processor')
- comment_cache_key = 'article_comments_{id}'.format(
- id=instance.article.id)
- cache.delete(comment_cache_key)
- delete_sidebar_cache()
- delete_view_cache('article_comments', [str(instance.article.pk)])
-
- _thread.start_new(send_comment_email, (instance,))
+ if instance.is_enable:
+ path = instance.article.get_absolute_url()
+ site = get_current_site().domain
+ if site.find(':') > 0:
+ site = site[0:site.find(':')]
+
+ expire_view_cache(
+ path,
+ servername=site,
+ serverport=80,
+ key_prefix='blogdetail')
+ if cache.get('seo_processor'):
+ cache.delete('seo_processor')
+ comment_cache_key = 'article_comments_{id}'.format(
+ id=instance.article.id)
+ cache.delete(comment_cache_key)
+ delete_sidebar_cache()
+ delete_view_cache('article_comments', [str(instance.article.pk)])
+
+ _thread.start_new_thread(send_comment_email, (instance,))
if clearcache:
cache.clear()
diff --git a/djangoblog/elasticsearch_backend.py b/djangoblog/elasticsearch_backend.py
index 912058929..4afe4981f 100644
--- a/djangoblog/elasticsearch_backend.py
+++ b/djangoblog/elasticsearch_backend.py
@@ -1,6 +1,7 @@
-from django.utils.encoding import force_text
+from django.utils.encoding import force_str
from elasticsearch_dsl import Q
from haystack.backends import BaseEngine, BaseSearchBackend, BaseSearchQuery, log_query
+from haystack.forms import ModelSearchForm
from haystack.models import SearchResult
from haystack.utils import log as logging
@@ -18,6 +19,7 @@ def __init__(self, connection_alias, **connection_options):
connection_alias,
**connection_options)
self.manager = ArticleDocumentManager()
+ self.include_spelling = True
def _get_models(self, iterable):
models = iterable if iterable and iterable[0] else Article.objects.all()
@@ -51,6 +53,24 @@ def remove(self, obj_or_string):
def clear(self, models=None, commit=True):
self.remove(None)
+ @staticmethod
+ def get_suggestion(query: str) -> str:
+ """获取推荐词, 如果没有找到添加原搜索词"""
+
+ search = ArticleDocument.search() \
+ .query("match", body=query) \
+ .suggest('suggest_search', query, term={'field': 'body'}) \
+ .execute()
+
+ keywords = []
+ for suggest in search.suggest.suggest_search:
+ if suggest["options"]:
+ keywords.append(suggest["options"][0]["text"])
+ else:
+ keywords.append(suggest["text"])
+
+ return ' '.join(keywords)
+
@log_query
def search(self, query_string, **kwargs):
logger.info('search query_string:' + query_string)
@@ -58,8 +78,15 @@ def search(self, query_string, **kwargs):
start_offset = kwargs.get('start_offset')
end_offset = kwargs.get('end_offset')
- q = Q('bool', should=[Q('match', body=query_string), Q(
- 'match', title=query_string)], minimum_should_match="70%")
+ # 推荐词搜索
+ if getattr(self, "is_suggest", None):
+ suggestion = self.get_suggestion(query_string)
+ else:
+ suggestion = query_string
+
+ q = Q('bool',
+ should=[Q('match', body=suggestion), Q('match', title=suggestion)],
+ minimum_should_match="70%")
search = ArticleDocument.search() \
.query('bool', filter=[q]) \
@@ -85,7 +112,7 @@ def search(self, query_string, **kwargs):
**additional_fields)
raw_results.append(result)
facets = {}
- spelling_suggestion = None
+ spelling_suggestion = None if query_string == suggestion else suggestion
return {
'results': raw_results,
@@ -98,9 +125,9 @@ def search(self, query_string, **kwargs):
class ElasticSearchQuery(BaseSearchQuery):
def _convert_datetime(self, date):
if hasattr(date, 'hour'):
- return force_text(date.strftime('%Y%m%d%H%M%S'))
+ return force_str(date.strftime('%Y%m%d%H%M%S'))
else:
- return force_text(date.strftime('%Y%m%d000000'))
+ return force_str(date.strftime('%Y%m%d000000'))
def clean(self, query_fragment):
"""
@@ -134,6 +161,22 @@ def get_count(self):
results = self.get_results()
return len(results) if results else 0
+ def get_spelling_suggestion(self, preferred_query=None):
+ return self._spelling_suggestion
+
+ def build_params(self, spelling_query=None):
+ kwargs = super(ElasticSearchQuery, self).build_params(spelling_query=spelling_query)
+ return kwargs
+
+
+class ElasticSearchModelSearchForm(ModelSearchForm):
+
+ def search(self):
+ # 是否建议搜索
+ self.searchqueryset.query.backend.is_suggest = self.data.get("is_suggest") != "no"
+ sqs = super().search()
+ return sqs
+
class ElasticSearchEngine(BaseEngine):
backend = ElasticSearchBackend
diff --git a/djangoblog/feeds.py b/djangoblog/feeds.py
index 1c092463f..8c4e851cc 100644
--- a/djangoblog/feeds.py
+++ b/djangoblog/feeds.py
@@ -1,11 +1,10 @@
-from datetime import datetime
-
from django.contrib.auth import get_user_model
from django.contrib.syndication.views import Feed
+from django.utils import timezone
from django.utils.feedgenerator import Rss201rev2Feed
-from djangoblog.utils import CommonMarkdown
from blog.models import Article
+from djangoblog.utils import CommonMarkdown
class DjangoBlogFeed(Feed):
@@ -31,7 +30,7 @@ def item_description(self, item):
return CommonMarkdown.get_markdown(item.body)
def feed_copyright(self):
- now = datetime.now()
+ now = timezone.now()
return "Copyright© {year} 且听风吟".format(year=now.year)
def item_link(self, item):
diff --git a/djangoblog/logentryadmin.py b/djangoblog/logentryadmin.py
index 009ab9dc5..2f6a53533 100644
--- a/djangoblog/logentryadmin.py
+++ b/djangoblog/logentryadmin.py
@@ -1,45 +1,14 @@
from django.contrib import admin
-from django.contrib.admin.models import LogEntry, ADDITION, CHANGE, DELETION
+from django.contrib.admin.models import DELETION
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse, NoReverseMatch
-from django.utils.encoding import force_text
+from django.utils.encoding import force_str
from django.utils.html import escape
from django.utils.safestring import mark_safe
-from django.utils.translation import pgettext_lazy, ugettext_lazy as _
-
-action_names = {
- ADDITION: pgettext_lazy('logentry_admin:action_type', 'Addition'),
- DELETION: pgettext_lazy('logentry_admin:action_type', 'Deletion'),
- CHANGE: pgettext_lazy('logentry_admin:action_type', 'Change'),
-}
+from django.utils.translation import gettext_lazy as _
class LogEntryAdmin(admin.ModelAdmin):
- date_hierarchy = 'action_time'
-
- readonly_fields = ([f.name for f in LogEntry._meta.fields] +
- ['object_link', 'action_description', 'user_link',
- 'get_change_message'])
-
- fieldsets = (
- (_('Metadata'), {
- 'fields': (
- 'action_time',
- 'user_link',
- 'action_description',
- 'object_link',
- )
- }),
- (_('Details'), {
- 'fields': (
- 'get_change_message',
- 'content_type',
- 'object_id',
- 'object_repr',
- )
- }),
- )
-
list_filter = [
'content_type'
]
@@ -58,7 +27,6 @@ class LogEntryAdmin(admin.ModelAdmin):
'user_link',
'content_type',
'object_link',
- 'action_description',
'get_change_message',
]
@@ -67,9 +35,9 @@ def has_add_permission(self, request):
def has_change_permission(self, request, obj=None):
return (
- request.user.is_superuser or
- request.user.has_perm('admin.change_logentry')
- ) and request.method != 'POST'
+ request.user.is_superuser or
+ request.user.has_perm('admin.change_logentry')
+ ) and request.method != 'POST'
def has_delete_permission(self, request, obj=None):
return False
@@ -96,7 +64,7 @@ def object_link(self, obj):
def user_link(self, obj):
content_type = ContentType.objects.get_for_model(type(obj.user))
- user_link = escape(force_text(obj.user))
+ user_link = escape(force_str(obj.user))
try:
# try returning an actual link instead of object repr string
url = reverse(
@@ -121,13 +89,3 @@ def get_actions(self, request):
if 'delete_selected' in actions:
del actions['delete_selected']
return actions
-
- def action_description(self, obj):
- return action_names[obj.action_flag]
-
- action_description.short_description = _('action')
-
- def get_change_message(self, obj):
- return obj.get_change_message()
-
- get_change_message.short_description = _('change message')
diff --git a/djangoblog/plugin_manage/base_plugin.py b/djangoblog/plugin_manage/base_plugin.py
new file mode 100644
index 000000000..df1ce0b43
--- /dev/null
+++ b/djangoblog/plugin_manage/base_plugin.py
@@ -0,0 +1,194 @@
+import logging
+from pathlib import Path
+
+from django.template import TemplateDoesNotExist
+from django.template.loader import render_to_string
+
+logger = logging.getLogger(__name__)
+
+
+class BasePlugin:
+ # 插件元数据
+ PLUGIN_NAME = None
+ PLUGIN_DESCRIPTION = None
+ PLUGIN_VERSION = None
+ PLUGIN_AUTHOR = None
+
+ # 插件配置
+ SUPPORTED_POSITIONS = [] # 支持的显示位置
+ DEFAULT_PRIORITY = 100 # 默认优先级(数字越小优先级越高)
+ POSITION_PRIORITIES = {} # 各位置的优先级 {'sidebar': 50, 'article_bottom': 80}
+
+ def __init__(self):
+ if not all([self.PLUGIN_NAME, self.PLUGIN_DESCRIPTION, self.PLUGIN_VERSION]):
+ raise ValueError("Plugin metadata (PLUGIN_NAME, PLUGIN_DESCRIPTION, PLUGIN_VERSION) must be defined.")
+
+ # 设置插件路径
+ self.plugin_dir = self._get_plugin_directory()
+ self.plugin_slug = self._get_plugin_slug()
+
+ self.init_plugin()
+ self.register_hooks()
+
+ def _get_plugin_directory(self):
+ """获取插件目录路径"""
+ import inspect
+ plugin_file = inspect.getfile(self.__class__)
+ return Path(plugin_file).parent
+
+ def _get_plugin_slug(self):
+ """获取插件标识符(目录名)"""
+ return self.plugin_dir.name
+
+ def init_plugin(self):
+ """
+ 插件初始化逻辑
+ 子类可以重写此方法来实现特定的初始化操作
+ """
+ logger.info(f'{self.PLUGIN_NAME} initialized.')
+
+ def register_hooks(self):
+ """
+ 注册插件钩子
+ 子类可以重写此方法来注册特定的钩子
+ """
+ pass
+
+ # === 位置渲染系统 ===
+ def render_position_widget(self, position, context, **kwargs):
+ """
+ 根据位置渲染插件组件
+
+ Args:
+ position: 位置标识
+ context: 模板上下文
+ **kwargs: 额外参数
+
+ Returns:
+ dict: {'html': 'HTML内容', 'priority': 优先级} 或 None
+ """
+ if position not in self.SUPPORTED_POSITIONS:
+ return None
+
+ # 检查条件显示
+ if not self.should_display(position, context, **kwargs):
+ return None
+
+ # 调用具体的位置渲染方法
+ method_name = f'render_{position}_widget'
+ if hasattr(self, method_name):
+ html = getattr(self, method_name)(context, **kwargs)
+ if html:
+ priority = self.POSITION_PRIORITIES.get(position, self.DEFAULT_PRIORITY)
+ return {
+ 'html': html,
+ 'priority': priority,
+ 'plugin_name': self.PLUGIN_NAME
+ }
+
+ return None
+
+ def should_display(self, position, context, **kwargs):
+ """
+ 判断插件是否应该在指定位置显示
+ 子类可重写此方法实现条件显示逻辑
+
+ Args:
+ position: 位置标识
+ context: 模板上下文
+ **kwargs: 额外参数
+
+ Returns:
+ bool: 是否显示
+ """
+ return True
+
+ # === 各位置渲染方法 - 子类重写 ===
+ def render_sidebar_widget(self, context, **kwargs):
+ """渲染侧边栏组件"""
+ return None
+
+ def render_article_bottom_widget(self, context, **kwargs):
+ """渲染文章底部组件"""
+ return None
+
+ def render_article_top_widget(self, context, **kwargs):
+ """渲染文章顶部组件"""
+ return None
+
+ def render_header_widget(self, context, **kwargs):
+ """渲染页头组件"""
+ return None
+
+ def render_footer_widget(self, context, **kwargs):
+ """渲染页脚组件"""
+ return None
+
+ def render_comment_before_widget(self, context, **kwargs):
+ """渲染评论前组件"""
+ return None
+
+ def render_comment_after_widget(self, context, **kwargs):
+ """渲染评论后组件"""
+ return None
+
+ # === 模板系统 ===
+ def render_template(self, template_name, context=None):
+ """
+ 渲染插件模板
+
+ Args:
+ template_name: 模板文件名
+ context: 模板上下文
+
+ Returns:
+ HTML字符串
+ """
+ if context is None:
+ context = {}
+
+ template_path = f"plugins/{self.plugin_slug}/{template_name}"
+
+ try:
+ return render_to_string(template_path, context)
+ except TemplateDoesNotExist:
+ logger.warning(f"Plugin template not found: {template_path}")
+ return ""
+
+ # === 静态资源系统 ===
+ def get_static_url(self, static_file):
+ """获取插件静态文件URL"""
+ from django.templatetags.static import static
+ return static(f"{self.plugin_slug}/static/{self.plugin_slug}/{static_file}")
+
+ def get_css_files(self):
+ """获取插件CSS文件列表"""
+ return []
+
+ def get_js_files(self):
+ """获取插件JavaScript文件列表"""
+ return []
+
+ def get_head_html(self, context=None):
+ """获取需要插入到中的HTML内容"""
+ return ""
+
+ def get_body_html(self, context=None):
+ """获取需要插入到底部的HTML内容"""
+ return ""
+
+ def get_plugin_info(self):
+ """
+ 获取插件信息
+ :return: 包含插件元数据的字典
+ """
+ return {
+ 'name': self.PLUGIN_NAME,
+ 'description': self.PLUGIN_DESCRIPTION,
+ 'version': self.PLUGIN_VERSION,
+ 'author': self.PLUGIN_AUTHOR,
+ 'slug': self.plugin_slug,
+ 'directory': str(self.plugin_dir),
+ 'supported_positions': self.SUPPORTED_POSITIONS,
+ 'priorities': self.POSITION_PRIORITIES
+ }
diff --git a/djangoblog/plugin_manage/hook_constants.py b/djangoblog/plugin_manage/hook_constants.py
new file mode 100644
index 000000000..8ed4e8911
--- /dev/null
+++ b/djangoblog/plugin_manage/hook_constants.py
@@ -0,0 +1,22 @@
+ARTICLE_DETAIL_LOAD = 'article_detail_load'
+ARTICLE_CREATE = 'article_create'
+ARTICLE_UPDATE = 'article_update'
+ARTICLE_DELETE = 'article_delete'
+
+ARTICLE_CONTENT_HOOK_NAME = "the_content"
+
+# 位置钩子常量
+POSITION_HOOKS = {
+ 'article_top': 'article_top_widgets',
+ 'article_bottom': 'article_bottom_widgets',
+ 'sidebar': 'sidebar_widgets',
+ 'header': 'header_widgets',
+ 'footer': 'footer_widgets',
+ 'comment_before': 'comment_before_widgets',
+ 'comment_after': 'comment_after_widgets',
+}
+
+# 资源注入钩子
+HEAD_RESOURCES_HOOK = 'head_resources'
+BODY_RESOURCES_HOOK = 'body_resources'
+
diff --git a/djangoblog/plugin_manage/hooks.py b/djangoblog/plugin_manage/hooks.py
new file mode 100644
index 000000000..d71254020
--- /dev/null
+++ b/djangoblog/plugin_manage/hooks.py
@@ -0,0 +1,44 @@
+import logging
+
+logger = logging.getLogger(__name__)
+
+_hooks = {}
+
+
+def register(hook_name: str, callback: callable):
+ """
+ 注册一个钩子回调。
+ """
+ if hook_name not in _hooks:
+ _hooks[hook_name] = []
+ _hooks[hook_name].append(callback)
+ logger.debug(f"Registered hook '{hook_name}' with callback '{callback.__name__}'")
+
+
+def run_action(hook_name: str, *args, **kwargs):
+ """
+ 执行一个 Action Hook。
+ 它会按顺序执行所有注册到该钩子上的回调函数。
+ """
+ if hook_name in _hooks:
+ logger.debug(f"Running action hook '{hook_name}'")
+ for callback in _hooks[hook_name]:
+ try:
+ callback(*args, **kwargs)
+ except Exception as e:
+ logger.error(f"Error running action hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True)
+
+
+def apply_filters(hook_name: str, value, *args, **kwargs):
+ """
+ 执行一个 Filter Hook。
+ 它会把 value 依次传递给所有注册的回调函数进行处理。
+ """
+ if hook_name in _hooks:
+ logger.debug(f"Applying filter hook '{hook_name}'")
+ for callback in _hooks[hook_name]:
+ try:
+ value = callback(value, *args, **kwargs)
+ except Exception as e:
+ logger.error(f"Error applying filter hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True)
+ return value
diff --git a/djangoblog/plugin_manage/loader.py b/djangoblog/plugin_manage/loader.py
new file mode 100644
index 000000000..ee750d0dd
--- /dev/null
+++ b/djangoblog/plugin_manage/loader.py
@@ -0,0 +1,64 @@
+import os
+import logging
+from django.conf import settings
+
+logger = logging.getLogger(__name__)
+
+# 全局插件注册表
+_loaded_plugins = []
+
+def load_plugins():
+ """
+ Dynamically loads and initializes plugins from the 'plugins' directory.
+ This function is intended to be called when the Django app registry is ready.
+ """
+ global _loaded_plugins
+ _loaded_plugins = []
+
+ for plugin_name in settings.ACTIVE_PLUGINS:
+ plugin_path = os.path.join(settings.PLUGINS_DIR, plugin_name)
+ if os.path.isdir(plugin_path) and os.path.exists(os.path.join(plugin_path, 'plugin.py')):
+ try:
+ # 导入插件模块
+ plugin_module = __import__(f'plugins.{plugin_name}.plugin', fromlist=['plugin'])
+
+ # 获取插件实例
+ if hasattr(plugin_module, 'plugin'):
+ plugin_instance = plugin_module.plugin
+ _loaded_plugins.append(plugin_instance)
+ logger.info(f"Successfully loaded plugin: {plugin_name} - {plugin_instance.PLUGIN_NAME}")
+ else:
+ logger.warning(f"Plugin {plugin_name} does not have 'plugin' instance")
+
+ except ImportError as e:
+ logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e)
+ except AttributeError as e:
+ logger.error(f"Failed to get plugin instance: {plugin_name}", exc_info=e)
+ except Exception as e:
+ logger.error(f"Unexpected error loading plugin: {plugin_name}", exc_info=e)
+
+def get_loaded_plugins():
+ """获取所有已加载的插件"""
+ return _loaded_plugins
+
+def get_plugin_by_name(plugin_name):
+ """根据名称获取插件"""
+ for plugin in _loaded_plugins:
+ if plugin.plugin_slug == plugin_name:
+ return plugin
+ return None
+
+def get_plugin_by_slug(plugin_slug):
+ """根据slug获取插件"""
+ for plugin in _loaded_plugins:
+ if plugin.plugin_slug == plugin_slug:
+ return plugin
+ return None
+
+def get_plugins_info():
+ """获取所有插件的信息"""
+ return [plugin.get_plugin_info() for plugin in _loaded_plugins]
+
+def get_plugins_by_position(position):
+ """获取支持指定位置的插件"""
+ return [plugin for plugin in _loaded_plugins if position in plugin.SUPPORTED_POSITIONS]
\ No newline at end of file
diff --git a/djangoblog/settings.py b/djangoblog/settings.py
index 1ac8448ed..0bed20d39 100644
--- a/djangoblog/settings.py
+++ b/djangoblog/settings.py
@@ -11,6 +11,9 @@
"""
import os
import sys
+from pathlib import Path
+
+from django.utils.translation import gettext_lazy as _
def env_to_bool(env, default):
@@ -18,9 +21,8 @@ def env_to_bool(env, default):
return default if str_val is None else str_val == 'True'
-# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
-
-BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+# Build paths inside the project like this: BASE_DIR / 'subdir'.
+BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/
@@ -35,6 +37,8 @@ def env_to_bool(env, default):
# ALLOWED_HOSTS = []
ALLOWED_HOSTS = ['*', '127.0.0.1', 'example.com']
+# django 4.0新增配置
+CSRF_TRUSTED_ORIGINS = ['http://example.com']
# Application definition
@@ -56,12 +60,15 @@ def env_to_bool(env, default):
'oauth',
'servermanager',
'owntracks',
- 'compressor'
+ 'compressor',
+ 'djangoblog'
]
MIDDLEWARE = [
+
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
+ 'django.middleware.locale.LocaleMiddleware',
'django.middleware.gzip.GZipMiddleware',
# 'django.middleware.cache.UpdateCacheMiddleware',
'django.middleware.common.CommonMiddleware',
@@ -104,7 +111,7 @@ def env_to_bool(env, default):
'ENGINE': 'django.db.backends.mysql',
'NAME': os.environ.get('DJANGO_MYSQL_DATABASE') or 'djangoblog',
'USER': os.environ.get('DJANGO_MYSQL_USER') or 'root',
- 'PASSWORD': os.environ.get('DJANGO_MYSQL_PASSWORD') or 'djangoblog_123',
+ 'PASSWORD': os.environ.get('DJANGO_MYSQL_PASSWORD') or 'root',
'HOST': os.environ.get('DJANGO_MYSQL_HOST') or '127.0.0.1',
'PORT': int(
os.environ.get('DJANGO_MYSQL_PORT') or 3306),
@@ -130,8 +137,14 @@ def env_to_bool(env, default):
},
]
-# Internationalization
-# https://docs.djangoproject.com/en/1.10/topics/i18n/
+LANGUAGES = (
+ ('en', _('English')),
+ ('zh-hans', _('Simplified Chinese')),
+ ('zh-hant', _('Traditional Chinese')),
+)
+LOCALE_PATHS = (
+ os.path.join(BASE_DIR, 'locale'),
+)
LANGUAGE_CODE = 'zh-hans'
@@ -141,7 +154,7 @@ def env_to_bool(env, default):
USE_L10N = True
-USE_TZ = True
+USE_TZ = False
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.10/howto/static-files/
@@ -164,6 +177,11 @@ def env_to_bool(env, default):
STATIC_URL = '/static/'
STATICFILES = os.path.join(BASE_DIR, 'static')
+# 添加插件静态文件目录
+STATICFILES_DIRS = [
+ os.path.join(BASE_DIR, 'plugins'), # 让Django能找到插件的静态文件
+]
+
AUTH_USER_MODEL = 'accounts.BlogUser'
LOGIN_URL = '/login/'
@@ -187,6 +205,14 @@ def env_to_bool(env, default):
'LOCATION': 'unique-snowflake',
}
}
+# 使用redis作为缓存
+if os.environ.get("DJANGO_REDIS_URL"):
+ CACHES = {
+ 'default': {
+ 'BACKEND': 'django.core.cache.backends.redis.RedisCache',
+ 'LOCATION': f'redis://{os.environ.get("DJANGO_REDIS_URL")}',
+ }
+ }
SITE_ID = 1
BAIDU_NOTIFY_URL = os.environ.get('DJANGO_BAIDU_NOTIFY_URL') \
@@ -208,6 +234,10 @@ def env_to_bool(env, default):
WXADMIN = os.environ.get(
'DJANGO_WXADMIN_PASSWORD') or '995F03AC401D6CABABAEF756FC4D43C7'
+LOG_PATH = os.path.join(BASE_DIR, 'logs')
+if not os.path.exists(LOG_PATH):
+ os.makedirs(LOG_PATH, exist_ok=True)
+
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
@@ -231,10 +261,14 @@ def env_to_bool(env, default):
'handlers': {
'log_file': {
'level': 'INFO',
- 'class': 'logging.handlers.RotatingFileHandler',
- 'filename': 'djangoblog.log',
- 'maxBytes': 16777216, # 16 MB
- 'formatter': 'verbose'
+ 'class': 'logging.handlers.TimedRotatingFileHandler',
+ 'filename': os.path.join(LOG_PATH, 'djangoblog.log'),
+ 'when': 'D',
+ 'formatter': 'verbose',
+ 'interval': 1,
+ 'delay': True,
+ 'backupCount': 5,
+ 'encoding': 'utf-8'
},
'console': {
'level': 'DEBUG',
@@ -272,23 +306,76 @@ def env_to_bool(env, default):
'compressor.finders.CompressorFinder',
)
COMPRESS_ENABLED = True
-# COMPRESS_OFFLINE = True
+# 根据环境变量决定是否启用离线压缩
+COMPRESS_OFFLINE = os.environ.get('COMPRESS_OFFLINE', 'False').lower() == 'true'
+
+# 压缩输出目录
+COMPRESS_OUTPUT_DIR = 'compressed'
+# 压缩文件名模板 - 包含哈希值用于缓存破坏
+COMPRESS_CSS_HASHING_METHOD = 'mtime'
+COMPRESS_JS_HASHING_METHOD = 'mtime'
+# 高级CSS压缩过滤器
COMPRESS_CSS_FILTERS = [
- # creates absolute urls from relative ones
+ # 创建绝对URL
'compressor.filters.css_default.CssAbsoluteFilter',
- # css minimizer
- 'compressor.filters.cssmin.CSSMinFilter'
+ # CSS压缩器 - 高压缩等级
+ 'compressor.filters.cssmin.CSSCompressorFilter',
]
+
+# 高级JS压缩过滤器
COMPRESS_JS_FILTERS = [
- 'compressor.filters.jsmin.JSMinFilter'
+ # JS压缩器 - 高压缩等级
+ 'compressor.filters.jsmin.SlimItFilter',
]
+# 压缩缓存配置
+COMPRESS_CACHE_BACKEND = 'default'
+COMPRESS_CACHE_KEY_FUNCTION = 'compressor.cache.simple_cachekey'
+
+# 预压缩配置
+COMPRESS_PRECOMPILERS = (
+ # 支持SCSS/SASS
+ ('text/x-scss', 'django_libsass.SassCompiler'),
+ ('text/x-sass', 'django_libsass.SassCompiler'),
+)
+
+# 压缩性能优化
+COMPRESS_MINT_DELAY = 30 # 压缩延迟(秒)
+COMPRESS_MTIME_DELAY = 10 # 修改时间检查延迟
+COMPRESS_REBUILD_TIMEOUT = 2592000 # 重建超时(30天)
+
+# 压缩等级配置
+COMPRESS_CSS_COMPRESSOR = 'compressor.css.CssCompressor'
+COMPRESS_JS_COMPRESSOR = 'compressor.js.JsCompressor'
+
+# 静态文件缓存配置
+STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'
+
+# 浏览器缓存配置(通过中间件或服务器配置)
+COMPRESS_URL = STATIC_URL
+COMPRESS_ROOT = STATIC_ROOT
+
MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads')
MEDIA_URL = '/media/'
X_FRAME_OPTIONS = 'SAMEORIGIN'
+# 安全头部配置 - 防XSS和其他攻击
+SECURE_BROWSER_XSS_FILTER = True
+SECURE_CONTENT_TYPE_NOSNIFF = True
+SECURE_REFERRER_POLICY = 'strict-origin-when-cross-origin'
+
+# 内容安全策略 (CSP) - 防XSS攻击
+CSP_DEFAULT_SRC = ["'self'"]
+CSP_SCRIPT_SRC = ["'self'", "'unsafe-inline'", "cdn.mathjax.org", "*.googleapis.com"]
+CSP_STYLE_SRC = ["'self'", "'unsafe-inline'", "*.googleapis.com", "*.gstatic.com"]
+CSP_IMG_SRC = ["'self'", "data:", "*.lylinux.net", "*.gravatar.com", "*.githubusercontent.com"]
+CSP_FONT_SRC = ["'self'", "*.googleapis.com", "*.gstatic.com"]
+CSP_CONNECT_SRC = ["'self'"]
+CSP_FRAME_SRC = ["'none'"]
+CSP_OBJECT_SRC = ["'none'"]
+
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
if os.environ.get('DJANGO_ELASTICSEARCH_HOST'):
@@ -302,3 +389,16 @@ def env_to_bool(env, default):
'ENGINE': 'djangoblog.elasticsearch_backend.ElasticSearchEngine',
},
}
+
+# Plugin System
+PLUGINS_DIR = BASE_DIR / 'plugins'
+ACTIVE_PLUGINS = [
+ 'article_copyright',
+ 'reading_time',
+ 'external_links',
+ 'view_count',
+ 'seo_optimizer',
+ 'image_lazy_loading',
+ 'article_recommendation',
+]
+
diff --git a/djangoblog/sitemap.py b/djangoblog/sitemap.py
index 151492e5d..8b7d44608 100644
--- a/djangoblog/sitemap.py
+++ b/djangoblog/sitemap.py
@@ -23,7 +23,7 @@ def items(self):
return Article.objects.filter(status='p')
def lastmod(self, obj):
- return obj.last_mod_time
+ return obj.last_modify_time
class CategorySiteMap(Sitemap):
@@ -34,7 +34,7 @@ def items(self):
return Category.objects.all()
def lastmod(self, obj):
- return obj.last_mod_time
+ return obj.last_modify_time
class TagSiteMap(Sitemap):
@@ -45,7 +45,7 @@ def items(self):
return Tag.objects.all()
def lastmod(self, obj):
- return obj.last_mod_time
+ return obj.last_modify_time
class UserSiteMap(Sitemap):
diff --git a/djangoblog/spider_notify.py b/djangoblog/spider_notify.py
index f77c09bae..7b909e969 100644
--- a/djangoblog/spider_notify.py
+++ b/djangoblog/spider_notify.py
@@ -2,7 +2,6 @@
import requests
from django.conf import settings
-from django.contrib.sitemaps import ping_google
logger = logging.getLogger(__name__)
@@ -17,15 +16,6 @@ def baidu_notify(urls):
except Exception as e:
logger.error(e)
- @staticmethod
- def __google_notify():
- try:
- ping_google('/sitemap.xml')
- except Exception as e:
- logger.error(e)
-
@staticmethod
def notify(url):
-
SpiderNotify.baidu_notify(url)
- SpiderNotify.__google_notify()
diff --git a/djangoblog/urls.py b/djangoblog/urls.py
index c0d96fa02..6a9e1de61 100644
--- a/djangoblog/urls.py
+++ b/djangoblog/urls.py
@@ -14,14 +14,20 @@
2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls'))
"""
from django.conf import settings
-from django.conf.urls import url
+from django.conf.urls.i18n import i18n_patterns
from django.conf.urls.static import static
from django.contrib.sitemaps.views import sitemap
-from django.urls import include
+from django.urls import path, include
+from django.urls import re_path
+from haystack.views import search_view_factory
+from django.http import JsonResponse
+import time
+from blog.views import EsSearchView
from djangoblog.admin_site import admin_site
+from djangoblog.elasticsearch_backend import ElasticSearchModelSearchForm
from djangoblog.feeds import DjangoBlogFeed
-from djangoblog.sitemap import StaticViewSitemap, ArticleSiteMap, CategorySiteMap, TagSiteMap, UserSiteMap
+from djangoblog.sitemap import ArticleSiteMap, CategorySiteMap, StaticViewSitemap, TagSiteMap, UserSiteMap
sitemaps = {
@@ -35,21 +41,38 @@
handler404 = 'blog.views.page_not_found_view'
handler500 = 'blog.views.server_error_view'
handle403 = 'blog.views.permission_denied_view'
+
+
+def health_check(request):
+ """
+ 健康检查接口
+ 简单返回服务健康状态
+ """
+ return JsonResponse({
+ 'status': 'healthy',
+ 'timestamp': time.time()
+ })
+
urlpatterns = [
- url(r'^admin/', admin_site.urls),
- url(r'', include('blog.urls', namespace='blog')),
- url(r'mdeditor/', include('mdeditor.urls')),
- url(r'', include('comments.urls', namespace='comment')),
- url(r'', include('accounts.urls', namespace='account')),
- url(r'', include('oauth.urls', namespace='oauth')),
- url(r'^sitemap\.xml$', sitemap, {'sitemaps': sitemaps},
- name='django.contrib.sitemaps.views.sitemap'),
- url(r'^feed/$', DjangoBlogFeed()),
- url(r'^rss/$', DjangoBlogFeed()),
- url(r'^search', include('haystack.urls'), name='search'),
- url(r'', include('servermanager.urls', namespace='servermanager')),
- url(r'', include('owntracks.urls', namespace='owntracks'))
- ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
+ path('i18n/', include('django.conf.urls.i18n')),
+ path('health/', health_check, name='health_check'),
+]
+urlpatterns += i18n_patterns(
+ re_path(r'^admin/', admin_site.urls),
+ re_path(r'', include('blog.urls', namespace='blog')),
+ re_path(r'mdeditor/', include('mdeditor.urls')),
+ re_path(r'', include('comments.urls', namespace='comment')),
+ re_path(r'', include('accounts.urls', namespace='account')),
+ re_path(r'', include('oauth.urls', namespace='oauth')),
+ re_path(r'^sitemap\.xml$', sitemap, {'sitemaps': sitemaps},
+ name='django.contrib.sitemaps.views.sitemap'),
+ re_path(r'^feed/$', DjangoBlogFeed()),
+ re_path(r'^rss/$', DjangoBlogFeed()),
+ re_path('^search', search_view_factory(view_class=EsSearchView, form_class=ElasticSearchModelSearchForm),
+ name='search'),
+ re_path(r'', include('servermanager.urls', namespace='servermanager')),
+ re_path(r'', include('owntracks.urls', namespace='owntracks'))
+ , prefix_default_language=False) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL,
document_root=settings.MEDIA_ROOT)
diff --git a/djangoblog/utils.py b/djangoblog/utils.py
index c98dd3d27..91d2b9132 100644
--- a/djangoblog/utils.py
+++ b/djangoblog/utils.py
@@ -9,9 +9,13 @@
import uuid
from hashlib import sha256
+import bleach
+import markdown
import requests
+from django.conf import settings
from django.contrib.sites.models import Site
from django.core.cache import cache
+from django.templatetags.static import static
logger = logging.getLogger(__name__)
@@ -33,7 +37,7 @@ def news(*args, **kwargs):
try:
view = args[0]
key = view.get_cache_key()
- except BaseException:
+ except:
key = None
if not key:
unique_str = repr((func, args, kwargs))
@@ -48,7 +52,7 @@ def news(*args, **kwargs):
else:
return value
else:
- logger.info(
+ logger.debug(
'cache_decorator set cache:%s key:%s' %
(func.__name__, key))
value = func(*args, **kwargs)
@@ -97,7 +101,6 @@ def get_current_site():
class CommonMarkdown:
@staticmethod
def _convert_markdown(value):
- import markdown
md = markdown.Markdown(
extensions=[
'extra',
@@ -150,7 +153,7 @@ def get_blog_setting():
from blog.models import BlogSettings
if not BlogSettings.objects.count():
setting = BlogSettings()
- setting.sitename = 'djangoblog'
+ setting.site_name = 'djangoblog'
setting.site_description = '基于Django的博客系统'
setting.site_seo_description = '基于Django的博客系统'
setting.site_keywords = 'Django,Python'
@@ -159,9 +162,10 @@ def get_blog_setting():
setting.sidebar_comment_count = 5
setting.show_google_adsense = False
setting.open_site_comment = True
- setting.analyticscode = ''
- setting.beiancode = ''
+ setting.analytics_code = ''
+ setting.beian_code = ''
setting.show_gongan_code = False
+ setting.comment_need_review = False
setting.save()
value = BlogSettings.objects.first()
logger.info('set cache get_blog_setting')
@@ -175,34 +179,26 @@ def save_user_avatar(url):
:param url:头像url
:return: 本地路径
'''
- setting = get_blog_setting()
logger.info(url)
try:
- imgname = url.split('/')[-1]
- if imgname:
- path = r'{basedir}/avatar/{img}'.format(
- basedir=setting.resource_path, img=imgname)
- if os.path.exists(path):
- os.remove(path)
+ basedir = os.path.join(settings.STATICFILES, 'avatar')
rsp = requests.get(url, timeout=2)
if rsp.status_code == 200:
- basepath = r'{basedir}/avatar/'.format(
- basedir=setting.resource_path)
- if not os.path.exists(basepath):
- os.makedirs(basepath)
+ if not os.path.exists(basedir):
+ os.makedirs(basedir)
- imgextensions = ['.jpg', '.png', 'jpeg', '.gif']
- isimage = len([i for i in imgextensions if url.endswith(i)]) > 0
+ image_extensions = ['.jpg', '.png', 'jpeg', '.gif']
+ isimage = len([i for i in image_extensions if url.endswith(i)]) > 0
ext = os.path.splitext(url)[1] if isimage else '.jpg'
- savefilename = str(uuid.uuid4().hex) + ext
- logger.info('保存用户头像:' + basepath + savefilename)
- with open(basepath + savefilename, 'wb+') as file:
+ save_filename = str(uuid.uuid4().hex) + ext
+ logger.info('保存用户头像:' + basedir + save_filename)
+ with open(os.path.join(basedir, save_filename), 'wb+') as file:
file.write(rsp.content)
- return 'https://resource.lylinux.net/avatar/' + savefilename
+ return static('avatar/' + save_filename)
except Exception as e:
logger.error(e)
- return url
+ return static('blog/img/avatar.png')
def delete_sidebar_cache():
@@ -217,3 +213,60 @@ def delete_view_cache(prefix, keys):
from django.core.cache.utils import make_template_fragment_key
key = make_template_fragment_key(prefix, keys)
cache.delete(key)
+
+
+def get_resource_url():
+ if settings.STATIC_URL:
+ return settings.STATIC_URL
+ else:
+ site = get_current_site()
+ return 'http://' + site.domain + '/static/'
+
+
+ALLOWED_TAGS = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul', 'h1',
+ 'h2', 'p', 'span', 'div']
+
+# 安全的class值白名单 - 只允许代码高亮相关的class
+ALLOWED_CLASSES = [
+ 'codehilite', 'highlight', 'hll', 'c', 'err', 'k', 'l', 'n', 'o', 'p', 'cm', 'cp', 'c1', 'cs',
+ 'gd', 'ge', 'gr', 'gh', 'gi', 'go', 'gp', 'gs', 'gu', 'gt', 'kc', 'kd', 'kn', 'kp', 'kr', 'kt',
+ 'ld', 'm', 'mf', 'mh', 'mi', 'mo', 'na', 'nb', 'nc', 'no', 'nd', 'ni', 'ne', 'nf', 'nl', 'nn',
+ 'nt', 'nv', 'ow', 'w', 'mb', 'mh', 'mi', 'mo', 'sb', 'sc', 'sd', 'se', 'sh', 'si', 'sx', 's2',
+ 's1', 'ss', 'bp', 'vc', 'vg', 'vi', 'il'
+]
+
+def class_filter(tag, name, value):
+ """自定义class属性过滤器"""
+ if name == 'class':
+ # 只允许预定义的安全class值
+ allowed_classes = [cls for cls in value.split() if cls in ALLOWED_CLASSES]
+ return ' '.join(allowed_classes) if allowed_classes else False
+ return value
+
+# 安全的属性白名单
+ALLOWED_ATTRIBUTES = {
+ 'a': ['href', 'title'],
+ 'abbr': ['title'],
+ 'acronym': ['title'],
+ 'span': class_filter,
+ 'div': class_filter,
+ 'pre': class_filter,
+ 'code': class_filter
+}
+
+# 安全的协议白名单 - 防止javascript:等危险协议
+ALLOWED_PROTOCOLS = ['http', 'https', 'mailto']
+
+def sanitize_html(html):
+ """
+ 安全的HTML清理函数
+ 使用bleach库进行白名单过滤,防止XSS攻击
+ """
+ return bleach.clean(
+ html,
+ tags=ALLOWED_TAGS,
+ attributes=ALLOWED_ATTRIBUTES,
+ protocols=ALLOWED_PROTOCOLS, # 限制允许的协议
+ strip=True, # 移除不允许的标签而不是转义
+ strip_comments=True # 移除HTML注释
+ )
diff --git a/djangoblog/whoosh_cn_backend.py b/djangoblog/whoosh_cn_backend.py
index f246c81db..04e3f7fd1 100644
--- a/djangoblog/whoosh_cn_backend.py
+++ b/djangoblog/whoosh_cn_backend.py
@@ -12,8 +12,8 @@
import six
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
-from django.utils.datetime_safe import datetime
-from django.utils.encoding import force_text
+from datetime import datetime
+from django.utils.encoding import force_str
from haystack.backends import BaseEngine, BaseSearchBackend, BaseSearchQuery, EmptyResults, log_query
from haystack.constants import DJANGO_CT, DJANGO_ID, ID
from haystack.exceptions import MissingDependency, SearchBackendError, SkipDocument
@@ -376,7 +376,7 @@ def search(
'hits': 0,
}
- query_string = force_text(query_string)
+ query_string = force_str(query_string)
# A one-character query (non-wildcard) gets nabbed by a stopwords
# filter and should yield zero results.
@@ -467,7 +467,7 @@ def search(
for nq in narrow_queries:
recent_narrowed_results = narrow_searcher.search(
- self.parser.parse(force_text(nq)), limit=None)
+ self.parser.parse(force_str(nq)), limit=None)
if len(recent_narrowed_results) <= 0:
return {
@@ -614,7 +614,7 @@ def more_like_this(
for nq in narrow_queries:
recent_narrowed_results = narrow_searcher.search(
- self.parser.parse(force_text(nq)), limit=None)
+ self.parser.parse(force_str(nq)), limit=None)
if len(recent_narrowed_results) <= 0:
return {
@@ -771,7 +771,7 @@ def create_spelling_suggestion(self, query_string):
spelling_suggestion = None
reader = self.index.reader()
corrector = reader.corrector(self.content_field_name)
- cleaned_query = force_text(query_string)
+ cleaned_query = force_str(query_string)
if not query_string:
return spelling_suggestion
@@ -811,12 +811,12 @@ def _from_python(self, value):
else:
value = 'false'
elif isinstance(value, (list, tuple)):
- value = u','.join([force_text(v) for v in value])
+ value = u','.join([force_str(v) for v in value])
elif isinstance(value, (six.integer_types, float)):
# Leave it alone.
pass
else:
- value = force_text(value)
+ value = force_str(value)
return value
def _to_python(self, value):
@@ -873,9 +873,9 @@ def _to_python(self, value):
class WhooshSearchQuery(BaseSearchQuery):
def _convert_datetime(self, date):
if hasattr(date, 'hour'):
- return force_text(date.strftime('%Y%m%d%H%M%S'))
+ return force_str(date.strftime('%Y%m%d%H%M%S'))
else:
- return force_text(date.strftime('%Y%m%d000000'))
+ return force_str(date.strftime('%Y%m%d000000'))
def clean(self, query_fragment):
"""
diff --git a/docs/README-en.md b/docs/README-en.md
index 708dc3835..37ea0699b 100644
--- a/docs/README-en.md
+++ b/docs/README-en.md
@@ -1,126 +1,158 @@
# DjangoBlog
-🌍
-*[English](README-en.md) ∙ [简体中文](README.md)*
-
-A blog system based on `python3.8` and `Django3.0`.
+
+
+
+
+
+
+
+
+ A powerful, elegant, and modern blog system.
+
+ English • 简体中文
+
+---
-[](https://github.com/liangliangyy/DjangoBlog/actions/workflows/django.yml) [](https://github.com/liangliangyy/DjangoBlog/actions/workflows/codeql-analysis.yml) [](https://codecov.io/gh/liangliangyy/DjangoBlog) []()
+DjangoBlog is a high-performance blog platform built with Python 3.10 and Django 4.0. It not only provides all the core functionalities of a traditional blog but also features a flexible plugin system, allowing you to easily extend and customize your website. Whether you are a personal blogger, a tech enthusiast, or a content creator, DjangoBlog aims to provide a stable, efficient, and easy-to-maintain environment for writing and publishing.
+## ✨ Features
-## Main Features:
-- Articles, Pages, Categories, Tags(Add, Delete, Edit), edc. Articles and pages support `Markdown` and highlighting.
-- Articles support full-text search.
-- Complete comment feature, include posting reply comment and email notification. `Markdown` supporting.
-- Sidebar feature: new articles, most readings, tags, etc.
-- OAuth Login supported, including Google, GitHub, Facebook, Weibo, QQ.
-- `Memcache` supported, with cache auto refresh.
-- Simple SEO Features, notify Google and Baidu when there was a new article or other things.
-- Simple picture bed feature integrated.
-- `django-compressor` integrated, auto-compressed `css`, `js`.
-- Website exception email notification. When there is an unhandle exception, system will send an email notification.
-- Wechat official account feature integrated. Now, you can use wechat official account to manage your VPS.
+- **Powerful Content Management**: Full support for managing articles, standalone pages, categories, and tags. Comes with a powerful built-in Markdown editor with syntax highlighting.
+- **Full-Text Search**: Integrated search engine for fast and accurate content searching.
+- **Interactive Comment System**: Supports replies, email notifications, and Markdown formatting in comments.
+- **Flexible Sidebar**: Customizable modules for displaying recent articles, most viewed posts, tag cloud, and more.
+- **Social Login**: Built-in OAuth support, with integrations for Google, GitHub, Facebook, Weibo, QQ, and other major platforms.
+- **High-Performance Caching**: Native support for Redis caching with an automatic refresh mechanism to ensure high-speed website responses.
+- **SEO Friendly**: Basic SEO features are included, with automatic notifications to Google and Baidu upon new content publication.
+- **Extensible Plugin System**: Extend blog functionalities by creating standalone plugins, ensuring decoupled and maintainable code. We have already implemented features like view counting and SEO optimization through plugins!
+- **Integrated Image Hosting**: A simple, built-in image hosting feature for easy uploads and management.
+- **Automated Frontend**: Integrated with `django-compressor` to automatically compress and optimize CSS and JavaScript files.
+- **Robust Operations**: Built-in email notifications for website exceptions and management capabilities through a WeChat Official Account.
-## Installation
-Change MySQL client from `pymysql` to `mysqlclient`, more details please reference [pypi](https://pypi.org/project/mysqlclient/) , checkout preperation before installation.
+## 🛠️ Tech Stack
-Install via pip: `pip install -Ur requirements.txt`
+- **Backend**: Python 3.10, Django 4.0
+- **Database**: MySQL, SQLite (configurable)
+- **Cache**: Redis
+- **Frontend**: HTML5, CSS3, JavaScript
+- **Search**: Whoosh, Elasticsearch (configurable)
+- **Editor**: Markdown (mdeditor)
-If you do NOT have `pip`, please use the following methods to install:
-- OS X / Linux, run the following commands:
+## 🚀 Getting Started
- ```
- curl http://peak.telecommunity.com/dist/ez_setup.py | python
- curl https://raw.github.com/pypa/pip/master/contrib/get-pip.py | python
- ```
+### 1. Prerequisites
-- Windows:
+Ensure you have Python 3.10+ and MySQL/MariaDB installed on your system.
- Download http://peak.telecommunity.com/dist/ez_setup.py and https://raw.github.com/pypa/pip/master/contrib/get-pip.py, and run with python.
+### 2. Clone & Installation
-### Configuration
-Most configurations are in `setting.py`, others are in backend configurations.
+```bash
+# Clone the project to your local machine
+git clone https://github.com/liangliangyy/DjangoBlog.git
+cd DjangoBlog
-I set many `setting` configuration with my environment variables (such as: `SECRET_KEY`, `OAUTH`, `mysql` and some email configuration parts.) and they did NOT been submitted to the `GitHub`. You can change these in the code with your own configuration or just add them into your environment variables.
+# Install dependencies
+pip install -r requirements.txt
+```
-Files in `test` directory are for `travis` with automatic testing. You do not need to care about this. Or just use it, in this way to integrate `travis` for automatic testing.
+### 3. Project Configuration
+
+- **Database**:
+ Open `djangoblog/settings.py`, locate the `DATABASES` section, and update it with your MySQL connection details.
+
+ ```python
+ DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.mysql',
+ 'NAME': 'djangoblog',
+ 'USER': 'root',
+ 'PASSWORD': 'your_password',
+ 'HOST': '127.0.0.1',
+ 'PORT': 3306,
+ }
+ }
+ ```
+ Create the database in MySQL:
+ ```sql
+ CREATE DATABASE `djangoblog` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+ ```
+
+- **More Configurations**:
+ For advanced settings such as email, OAuth, caching, and more, please refer to our [Detailed Configuration Guide](/docs/config-en.md).
+
+### 4. Database Initialization
-In `bin` directory, we have scripts to deploy with `Nginx`+`Gunicorn`+`virtualenv`+`supervisor` on `linux` and `Nginx` configuration file. You can reference with my article
+```bash
+python manage.py makemigrations
+python manage.py migrate
->[DjangoBlog部署教程](https://www.lylinux.net/article/2019/8/5/58.html)
+# Create a superuser account
+python manage.py createsuperuser
+```
-More deploy detail in this article.
+### 5. Running the Project
-## Run
+```bash
+# (Optional) Generate some test data
+python manage.py create_testdata
-Modify `DjangoBlog/setting.py` with database settings, as following:
+# (Optional) Collect and compress static files
+python manage.py collectstatic --noinput
+python manage.py compress --force
-```python
-DATABASES = {
- 'default': {
- 'ENGINE': 'django.db.backends.mysql',
- 'NAME': 'djangoblog',
- 'USER': 'root',
- 'PASSWORD': 'password',
- 'HOST': 'host',
- 'PORT': 3306,
- }
-}
+# Start the development server
+python manage.py runserver
```
-### Create database
-Run the following command in MySQL shell:
-```sql
-CREATE DATABASE `djangoblog` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */;
-```
+Now, open your browser and navigate to `http://127.0.0.1:8000/`. You should see the DjangoBlog homepage!
-Run the following commands in Terminal:
-```bash
-./manage.py makemigrations
-./manage.py migrate
-```
+## Deployment
-**Attention: ** Before you using `./manage.py`, make sure the `python` command in your system is towards to `python 3.6` or above version. Otherwise you may solve this by one of the two following methods:
-- Modify the first line in `manage.py`, change `#!/usr/bin/env python` to `#!/usr/bin/env python3`
-- Just run with: `python3 ./manage.py makemigrations`
+- **Traditional Deployment**: A detailed guide for server deployment is available here: [Deployment Tutorial](https://www.lylinux.net/article/2019/8/5/58.html) (in Chinese).
+- **Docker Deployment**: This project fully supports Docker. If you are familiar with containerization, please refer to the [Docker Deployment Guide](/docs/docker-en.md) for a quick start.
+- **Kubernetes Deployment**: We also provide a complete [Kubernetes Deployment Guide](/docs/k8s-en.md) to help you go cloud-native easily.
-### Create super user
+## 🧩 Plugin System
-Run command in terminal:
-```bash
-./manage.py createsuperuser
-```
+The plugin system is a core feature of DjangoBlog. It allows you to add new functionalities to your blog without modifying the core codebase by writing standalone plugins.
-### Create testing data
-Run command in terminal:
-```bash
-./manage.py create_testdata
-```
+- **How it Works**: Plugins operate by registering callback functions to predefined "hooks". For instance, when an article is rendered, the `after_article_body_get` hook is triggered, and all functions registered to this hook are executed.
+- **Existing Plugins**: Features like `view_count` and `seo_optimizer` are implemented through this plugin system.
+- **Develop Your Own Plugin**: Simply create a new folder under the `plugins` directory and write your `plugin.py`. We welcome you to explore and contribute your creative ideas to the DjangoBlog community!
-### Collect static files
-Run command in terminal:
-```bash
-./manage.py collectstatic --noinput
-./manage.py compress --force
-```
+## 🤝 Contributing
-### Getting start to run server
-Execute: `./manage.py runserver`
+We warmly welcome contributions of any kind! If you have great ideas or have found a bug, please feel free to open an issue or submit a pull request.
-Open up a browser and visit: http://127.0.0.1:8000/ , the you will see the blog.
+## 📄 License
-## More configurations
-[More configurations details](/docs/config-en.md)
+This project is open-sourced under the [MIT License](LICENSE).
-## About the issues
+---
-If you have any *question*, please use Issue or send problem descriptions to my email `liangliangyy#gmail.com`. I will reponse you as soon as possible. And, we recommend you to use Issue.
+## ❤️ Support & Sponsorship
----
-## To Everyone 🙋♀️🙋♂️
-If this project helps you, please submit your site address [here](https://github.com/liangliangyy/DjangoBlog/issues/214) to let more people see it.
+If you find this project helpful and wish to support its continued maintenance and development, please consider buying me a coffee! Your support is my greatest motivation.
+
+
+
+
+
+
+ (Left) Alipay / (Right) WeChat
+
+
+## 🙏 Acknowledgements
-Your reply will be the driving force for me to continue to update and maintain this project.
+A special thanks to **JetBrains** for providing a free open-source license for this project.
-🙏🙏🙏
+
+
+
+
+
+
+---
+> If this project has helped you, please leave your website URL [here](https://github.com/liangliangyy/DjangoBlog/issues/214) to let more people see it. Your feedback is the driving force for my continued updates and maintenance.
diff --git a/docs/config.md b/docs/config.md
index 73555dc70..24673a377 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -1,22 +1,10 @@
# 主要功能配置介绍:
## 缓存:
-缓存默认使用`memcache`缓存,如果你没有`memcache`环境,则将`settings.py`中的`locmemcache`改为`default`,并删除默认的`default`配置即可。
-```python
-CACHES = {
- 'default': {
- 'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache',
- 'LOCATION': '127.0.0.1:11211',
- 'KEY_PREFIX': 'django_test' if TESTING else 'djangoblog',
- 'TIMEOUT': 60 * 60 * 10
- },
- 'locmemcache': {
- 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
- 'TIMEOUT': 10800,
- 'LOCATION': 'unique-snowflake',
- }
-}
-```
+缓存默认使用`localmem`缓存,如果你有`redis`环境,可以设置`DJANGO_REDIS_URL`环境变量,则会自动使用该redis来作为缓存,或者你也可以直接修改如下代码来使用。
+https://github.com/liangliangyy/DjangoBlog/blob/ffcb2c3711de805f2067dd3c1c57449cd24d84ee/djangoblog/settings.py#L185-L199
+
+
## oauth登录:
现在已经支持QQ,微博,Google,GitHub,Facebook登录,需要在其对应的开放平台申请oauth登录权限,然后在
@@ -62,3 +50,9 @@ SERVER_EMAIL = os.environ.get('DJANGO_EMAIL_USER')
django.db.migrations.exceptions.MigrationSchemaMissing: Unable to create the django_migrations table ((1064, "You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '(6) NOT NULL)' at line 1"))
```
可能是因为你的mysql版本低于5.6,需要升级mysql版本>=5.6即可。
+
+
+django 4.0登录可能会报错CSRF,需要配置下`settings.py`中的`CSRF_TRUSTED_ORIGINS`
+
+https://github.com/liangliangyy/DjangoBlog/blob/master/djangoblog/settings.py#L39
+
diff --git a/docs/docker-en.md b/docs/docker-en.md
new file mode 100644
index 000000000..8d5d59edf
--- /dev/null
+++ b/docs/docker-en.md
@@ -0,0 +1,114 @@
+# Deploying DjangoBlog with Docker
+
+
+
+
+
+This project fully supports containerized deployment using Docker, providing you with a fast, consistent, and isolated runtime environment. We recommend using `docker-compose` to launch the entire blog service stack with a single command.
+
+## 1. Prerequisites
+
+Before you begin, please ensure you have the following software installed on your system:
+- [Docker Engine](https://docs.docker.com/engine/install/)
+- [Docker Compose](https://docs.docker.com/compose/install/) (Included with Docker Desktop for Mac and Windows)
+
+## 2. Recommended Method: Using `docker-compose` (One-Click Deployment)
+
+This is the simplest and most recommended way to deploy. It automatically creates and manages the Django application, a MySQL database, and an optional Elasticsearch service for you.
+
+### Step 1: Start the Basic Services
+
+From the project's root directory, run the following command:
+
+```bash
+# Build and start the containers in detached mode (includes Django app and MySQL)
+docker-compose up -d --build
+```
+
+`docker-compose` will read the `docker-compose.yml` file, pull the necessary images, build the project image, and start all services.
+
+- **Access Your Blog**: Once the services are up, you can access the blog by navigating to `http://127.0.0.1` in your browser.
+- **Data Persistence**: MySQL data files will be stored in the `data/mysql` directory within the project root, ensuring that your data persists across container restarts.
+
+### Step 2: (Optional) Enable Elasticsearch for Full-Text Search
+
+If you want to use Elasticsearch for more powerful full-text search capabilities, you can include the `docker-compose.es.yml` configuration file:
+
+```bash
+# Build and start all services in detached mode (Django, MySQL, Elasticsearch)
+docker-compose -f docker-compose.yml -f deploy/docker-compose/docker-compose.es.yml up -d --build
+```
+- **Data Persistence**: Elasticsearch data will be stored in the `data/elasticsearch` directory.
+
+### Step 3: First-Time Initialization
+
+After the containers start for the first time, you'll need to execute some initialization commands inside the application container.
+
+```bash
+# Get a shell inside the djangoblog application container (named 'web')
+docker-compose exec web bash
+
+# Inside the container, run the following commands:
+# Create a superuser account (follow the prompts to set username, email, and password)
+python manage.py createsuperuser
+
+# (Optional) Create some test data
+python manage.py create_testdata
+
+# (Optional, if ES is enabled) Create the search index
+python manage.py rebuild_index
+
+# Exit the container
+exit
+```
+
+## 3. Alternative Method: Using the Standalone Docker Image
+
+If you already have an external MySQL database running, you can run the DjangoBlog application image by itself.
+
+```bash
+# Pull the latest image from Docker Hub
+docker pull liangliangyy/djangoblog:latest
+
+# Run the container and connect it to your external database
+docker run -d \
+ -p 8000:8000 \
+ -e DJANGO_SECRET_KEY='your-strong-secret-key' \
+ -e DJANGO_MYSQL_HOST='your-mysql-host' \
+ -e DJANGO_MYSQL_USER='your-mysql-user' \
+ -e DJANGO_MYSQL_PASSWORD='your-mysql-password' \
+ -e DJANGO_MYSQL_DATABASE='djangoblog' \
+ --name djangoblog \
+ liangliangyy/djangoblog:latest
+```
+
+- **Access Your Blog**: After startup, visit `http://127.0.0.1:8000`.
+- **Create Superuser**: `docker exec -it djangoblog python manage.py createsuperuser`
+
+## 4. Configuration (Environment Variables)
+
+Most of the project's configuration is managed through environment variables. You can modify them in the `docker-compose.yml` file or pass them using the `-e` flag with the `docker run` command.
+
+| Environment Variable | Default/Example Value | Notes |
+|---------------------------|--------------------------------------------------------------------------|---------------------------------------------------------------------|
+| `DJANGO_SECRET_KEY` | `your-strong-secret-key` | **Must be changed to a random, complex string!** |
+| `DJANGO_DEBUG` | `False` | Toggles Django's debug mode. |
+| `DJANGO_MYSQL_HOST` | `mysql` | Database hostname. |
+| `DJANGO_MYSQL_PORT` | `3306` | Database port. |
+| `DJANGO_MYSQL_DATABASE` | `djangoblog` | Database name. |
+| `DJANGO_MYSQL_USER` | `root` | Database username. |
+| `DJANGO_MYSQL_PASSWORD` | `djangoblog_123` | Database password. |
+| `DJANGO_REDIS_URL` | `redis:6379/0` | Redis connection URL (for caching). |
+| `DJANGO_ELASTICSEARCH_HOST`| `elasticsearch:9200` | Elasticsearch host address. |
+| `DJANGO_EMAIL_HOST` | `smtp.example.org` | Email server address. |
+| `DJANGO_EMAIL_PORT` | `465` | Email server port. |
+| `DJANGO_EMAIL_USER` | `user@example.org` | Email account username. |
+| `DJANGO_EMAIL_PASSWORD` | `your-email-password` | Email account password. |
+| `DJANGO_EMAIL_USE_SSL` | `True` | Whether to use SSL. |
+| `DJANGO_EMAIL_USE_TLS` | `False` | Whether to use TLS. |
+| `DJANGO_ADMIN_EMAIL` | `admin@example.org` | Admin email for receiving error reports. |
+| `DJANGO_BAIDU_NOTIFY_URL` | `http://data.zz.baidu.com/...` | Push API from [Baidu Webmaster Tools](https://ziyuan.baidu.com/linksubmit/index). |
+
+---
+
+After deployment, please review and adjust these environment variables according to your needs, especially `DJANGO_SECRET_KEY` and the database and email settings.
\ No newline at end of file
diff --git a/docs/docker.md b/docs/docker.md
index 92af9fa0a..e7c255aa8 100644
--- a/docs/docker.md
+++ b/docs/docker.md
@@ -1,59 +1,114 @@
-# 使用docker部署
+# 使用 Docker 部署 DjangoBlog
+



-使用docker部署支持如下两种方式:
-## docker镜像方式
-本项目已经支持了docker部署,如果你已经有了`mysql`,那么直接使用基础镜像即可,启动命令如下所示:
-```shell
-docker pull liangliangyy/djangoblog:latest
-docker run -d -p 8000:8000 -e DJANGO_MYSQL_HOST=mysqlhost -e DJANGO_MYSQL_PASSWORD=mysqlrootpassword -e DJANGO_MYSQL_USER=root -e DJANGO_MYSQL_DATABASE=djangoblog --name djangoblog liangliangyy/djangoblog:latest
+本项目全面支持使用 Docker 进行容器化部署,为您提供了快速、一致且隔离的运行环境。我们推荐使用 `docker-compose` 来一键启动整个博客服务栈。
+
+## 1. 环境准备
+
+在开始之前,请确保您的系统中已经安装了以下软件:
+- [Docker Engine](https://docs.docker.com/engine/install/)
+- [Docker Compose](https://docs.docker.com/compose/install/) (对于 Docker Desktop 用户,它已内置)
+
+## 2. 推荐方式:使用 `docker-compose` (一键部署)
+
+这是最简单、最推荐的部署方式。它会自动为您创建并管理 Django 应用、MySQL 数据库,以及可选的 Elasticsearch 服务。
+
+### 步骤 1: 启动基础服务
+
+在项目根目录下,执行以下命令:
+
+```bash
+# 构建并以后台模式启动容器 (包含 Django 应用和 MySQL)
+docker-compose up -d --build
```
-启动完成后,访问 http://127.0.0.1:8000
-## 使用docker-compose
-如果你没有mysql等基础服务,那么可以使用`docker-compose`来运行,
-具体命令如下所示:
-```shell
-docker-compose build
-docker-compose up -d
+
+`docker-compose` 会读取 `docker-compose.yml` 文件,自动拉取所需镜像、构建项目镜像,并启动所有服务。
+
+- **访问您的博客**: 服务启动后,在浏览器中访问 `http://127.0.0.1` 即可看到博客首页。
+- **数据持久化**: MySQL 的数据文件将存储在项目根目录下的 `data/mysql` 文件夹中,确保数据在容器重启后不丢失。
+
+### 步骤 2: (可选) 启用 Elasticsearch 全文搜索
+
+如果您希望使用 Elasticsearch 提供更强大的全文搜索功能,可以额外加载 `docker-compose.es.yml` 配置文件:
+
+```bash
+# 构建并以后台模式启动所有服务 (Django, MySQL, Elasticsearch)
+docker-compose -f docker-compose.yml -f deploy/docker-compose/docker-compose.es.yml up -d --build
```
-本方式生成的mysql数据文件在 `bin/datas/mysql` 文件夹。
-等启动完成后,访问 [http://127.0.0.1](http://127.0.0.1) 即可。
-### 使用es
-如果你期望使用es来作为后端的搜索引擎,那么可以使用如下命令来启动:
-```shell
-docker-compose -f docker-compose.yml -f docker-compose.es.yml build
-docker-compose -f docker-compose.yml -f docker-compose.es.yml up -d
+- **数据持久化**: Elasticsearch 的数据将存储在 `data/elasticsearch` 文件夹中。
+
+### 步骤 3: 首次运行的初始化操作
+
+当容器首次启动后,您需要进入容器来执行一些初始化命令。
+
+```bash
+# 进入 djangoblog 应用容器
+docker-compose exec web bash
+
+# 在容器内执行以下命令:
+# 创建超级管理员账户 (请按照提示设置用户名、邮箱和密码)
+python manage.py createsuperuser
+
+# (可选) 创建一些测试数据
+python manage.py create_testdata
+
+# (可选,如果启用了 ES) 创建索引
+python manage.py rebuild_index
+
+# 退出容器
+exit
```
-本方式生成的es数据文件在 `bin/datas/es` 文件夹。
-## 配置说明:
-
-本项目较多配置都基于环境变量,所有的环境变量如下所示:
-
-| 环境变量名称 | 默认值 | 备注 |
-|---------------------------|----------------------------------------------------------------------------|------------------------------------------------------------------------------------------------|
-| DJANGO_DEBUG | False | |
-| DJANGO_SECRET_KEY | DJANGO_BLOG_CHANGE_ME | 请务必修改,建议[随机生成](https://www.random.org/passwords/?num=5&len=24&format=html&rnd=new) |
-| DJANGO_MYSQL_DATABASE | djangoblog | |
-| DJANGO_MYSQL_USER | root | |
-| DJANGO_MYSQL_PASSWORD | djangoblog_123 | |
-| DJANGO_MYSQL_HOST | 127.0.0.1 | |
-| DJANGO_MYSQL_PORT | 3306 | |
-| DJANGO_MEMCACHED_ENABLE | True | |
-| DJANGO_MEMCACHED_LOCATION | 127.0.0.1:11211 | |
-| DJANGO_BAIDU_NOTIFY_URL | http://data.zz.baidu.com/urls?site=https://www.example.org&token=CHANGE_ME | 请在[百度站长平台](https://ziyuan.baidu.com/linksubmit/index)获取接口地址 |
-| DJANGO_EMAIL_TLS | False | |
-| DJANGO_EMAIL_SSL | True | |
-| DJANGO_EMAIL_HOST | smtp.example.org | |
-| DJANGO_EMAIL_PORT | 465 | |
-| DJANGO_EMAIL_USER | SMTP_USER_CHANGE_ME | |
-| DJANGO_EMAIL_PASSWORD | SMTP_PASSWORD_CHANGE_ME | |
-| DJANGO_ADMIN_EMAIL | admin@example.org | |
-| DJANGO_WEROBOT_TOKEN | DJANGO_BLOG_CHANGE_ME
-|DJANGO_ELASTICSEARCH_HOST|
-
-第一次启动之后,使用如下命令来创建超级用户:
-```shell
-docker exec -it djangoblog python /code/djangoblog/manage.py createsuperuser
+
+## 3. 备选方式:使用独立的 Docker 镜像
+
+如果您已经拥有一个正在运行的外部 MySQL 数据库,您也可以只运行 DjangoBlog 的应用镜像。
+
+```bash
+# 从 Docker Hub 拉取最新镜像
+docker pull liangliangyy/djangoblog:latest
+
+# 运行容器,并链接到您的外部数据库
+docker run -d \
+ -p 8000:8000 \
+ -e DJANGO_SECRET_KEY='your-strong-secret-key' \
+ -e DJANGO_MYSQL_HOST='your-mysql-host' \
+ -e DJANGO_MYSQL_USER='your-mysql-user' \
+ -e DJANGO_MYSQL_PASSWORD='your-mysql-password' \
+ -e DJANGO_MYSQL_DATABASE='djangoblog' \
+ --name djangoblog \
+ liangliangyy/djangoblog:latest
```
+
+- **访问您的博客**: 启动完成后,访问 `http://127.0.0.1:8000`。
+- **创建管理员**: `docker exec -it djangoblog python manage.py createsuperuser`
+
+## 4. 配置说明 (环境变量)
+
+本项目的大部分配置都通过环境变量来管理。您可以在 `docker-compose.yml` 文件中修改它们,或者在使用 `docker run` 命令时通过 `-e` 参数传入。
+
+| 环境变量名称 | 默认值/示例 | 备注 |
+|-------------------------|--------------------------------------------------------------------------|---------------------------------------------------------------------|
+| `DJANGO_SECRET_KEY` | `your-strong-secret-key` | **请务必修改为一个随机且复杂的字符串!** |
+| `DJANGO_DEBUG` | `False` | 是否开启 Django 的调试模式 |
+| `DJANGO_MYSQL_HOST` | `mysql` | 数据库主机名 |
+| `DJANGO_MYSQL_PORT` | `3306` | 数据库端口 |
+| `DJANGO_MYSQL_DATABASE` | `djangoblog` | 数据库名称 |
+| `DJANGO_MYSQL_USER` | `root` | 数据库用户名 |
+| `DJANGO_MYSQL_PASSWORD` | `djangoblog_123` | 数据库密码 |
+| `DJANGO_REDIS_URL` | `redis:6379/0` | Redis 连接地址 (用于缓存) |
+| `DJANGO_ELASTICSEARCH_HOST` | `elasticsearch:9200` | Elasticsearch 主机地址 |
+| `DJANGO_EMAIL_HOST` | `smtp.example.org` | 邮件服务器地址 |
+| `DJANGO_EMAIL_PORT` | `465` | 邮件服务器端口 |
+| `DJANGO_EMAIL_USER` | `user@example.org` | 邮件账户 |
+| `DJANGO_EMAIL_PASSWORD` | `your-email-password` | 邮件密码 |
+| `DJANGO_EMAIL_USE_SSL` | `True` | 是否使用 SSL |
+| `DJANGO_EMAIL_USE_TLS` | `False` | 是否使用 TLS |
+| `DJANGO_ADMIN_EMAIL` | `admin@example.org` | 接收异常报告的管理员邮箱 |
+| `DJANGO_BAIDU_NOTIFY_URL` | `http://data.zz.baidu.com/...` | [百度站长平台](https://ziyuan.baidu.com/linksubmit/index) 的推送接口 |
+
+---
+
+部署完成后,请务必检查并根据您的实际需求调整这些环境变量,特别是 `DJANGO_SECRET_KEY` 和数据库、邮件相关的配置。
diff --git a/docs/imgs/alipay.jpg b/docs/imgs/alipay.jpg
new file mode 100644
index 000000000..424d70a2f
Binary files /dev/null and b/docs/imgs/alipay.jpg differ
diff --git a/docs/imgs/pycharm_logo.png b/docs/imgs/pycharm_logo.png
new file mode 100644
index 000000000..7f2a4b0ea
Binary files /dev/null and b/docs/imgs/pycharm_logo.png differ
diff --git a/docs/imgs/wechat.jpg b/docs/imgs/wechat.jpg
new file mode 100644
index 000000000..7edf525ae
Binary files /dev/null and b/docs/imgs/wechat.jpg differ
diff --git a/docs/k8s-en.md b/docs/k8s-en.md
new file mode 100644
index 000000000..20e95272f
--- /dev/null
+++ b/docs/k8s-en.md
@@ -0,0 +1,141 @@
+# Deploying DjangoBlog with Kubernetes
+
+This document guides you through deploying the DjangoBlog application on a Kubernetes (K8s) cluster. We provide a complete set of `.yaml` configuration files in the `deploy/k8s` directory to deploy a full service stack, including the DjangoBlog application, Nginx, MySQL, Redis, and Elasticsearch.
+
+## Architecture Overview
+
+This deployment utilizes a microservices-based, cloud-native architecture:
+
+- **Core Components**: Each core service (DjangoBlog, Nginx, MySQL, Redis, Elasticsearch) runs as a separate `Deployment`.
+- **Configuration Management**: Nginx configurations and Django application environment variables are managed via `ConfigMap`. **Note: For sensitive information like passwords, using `Secret` is highly recommended.**
+- **Service Discovery**: All services are exposed internally within the cluster as `ClusterIP` type `Service`, enabling communication via service names.
+- **External Access**: An `Ingress` resource is used to route external HTTP traffic to the Nginx service, which acts as the single entry point for the entire blog application.
+- **Data Persistence**: A `local-storage` solution based on node-local paths is used. This requires you to manually create storage directories on a specific K8s node and statically bind them using `PersistentVolume` (PV) and `PersistentVolumeClaim` (PVC).
+
+## 1. Prerequisites
+
+Before you begin, please ensure you have the following:
+
+- A running Kubernetes cluster.
+- The `kubectl` command-line tool configured to connect to your cluster.
+- An [Nginx Ingress Controller](https://kubernetes.github.io/ingress-nginx/deploy/) installed and configured in your cluster.
+- Filesystem access to one of the nodes in your cluster (defaulted to `master` in the configs) to create local storage directories.
+
+## 2. Deployment Steps
+
+### Step 1: Create a Namespace
+
+We recommend deploying all DjangoBlog-related resources in a dedicated namespace for better management.
+
+```bash
+# Create a namespace named 'djangoblog'
+kubectl create namespace djangoblog
+```
+
+### Step 2: Configure Persistent Storage
+
+This setup uses Local Persistent Volumes. You need to create the data storage directories on a node within your cluster (the default is the `master` node in `pv.yaml`).
+
+```bash
+# Log in to your master node
+ssh user@master-node
+
+# Create the required storage directories
+sudo mkdir -p /mnt/local-storage-db
+sudo mkdir -p /mnt/local-storage-djangoblog
+sudo mkdir -p /mnt/resource/
+sudo mkdir -p /mnt/local-storage-elasticsearch
+
+# Log out from the node
+exit
+```
+**Note**: If you wish to store data on a different node or use different paths, you must modify the `nodeAffinity` and `local.path` settings in the `deploy/k8s/pv.yaml` file.
+
+After creating the directories, apply the storage-related configurations:
+
+```bash
+# Apply the StorageClass
+kubectl apply -f deploy/k8s/storageclass.yaml
+
+# Apply the PersistentVolumes (PVs)
+kubectl apply -f deploy/k8s/pv.yaml
+
+# Apply the PersistentVolumeClaims (PVCs)
+kubectl apply -f deploy/k8s/pvc.yaml
+```
+
+### Step 3: Configure the Application
+
+Before deploying the application, you need to edit the `deploy/k8s/configmap.yaml` file to modify sensitive information and custom settings.
+
+**It is strongly recommended to change the following fields:**
+- `DJANGO_SECRET_KEY`: Change to a random, complex string.
+- `DJANGO_MYSQL_PASSWORD` and `MYSQL_ROOT_PASSWORD`: Change to your own secure database password.
+
+```bash
+# Edit the ConfigMap file
+vim deploy/k8s/configmap.yaml
+
+# Apply the configuration
+kubectl apply -f deploy/k8s/configmap.yaml
+```
+
+### Step 4: Deploy the Application Stack
+
+Now, we can deploy all the core services.
+
+```bash
+# Deploy the Deployments (DjangoBlog, MySQL, Redis, Nginx, ES)
+kubectl apply -f deploy/k8s/deployment.yaml
+
+# Deploy the Services (to create internal endpoints for the Deployments)
+kubectl apply -f deploy/k8s/service.yaml
+```
+
+The deployment may take some time. You can run the following command to check if all Pods are running successfully (STATUS should be `Running`):
+
+```bash
+kubectl get pods -n djangoblog -w
+```
+
+### Step 5: Expose the Application Externally
+
+Finally, expose the Nginx service to external traffic by applying the `Ingress` rule.
+
+```bash
+# Apply the Ingress rule
+kubectl apply -f deploy/k8s/gateway.yaml
+```
+
+Once deployed, you can access your blog via the external IP address of your Ingress Controller. Use the following command to find the address:
+
+```bash
+kubectl get ingress -n djangoblog
+```
+
+### Step 6: First-Time Initialization
+
+Similar to the Docker deployment, you need to get a shell into the DjangoBlog application Pod to perform database initialization and create a superuser on the first run.
+
+```bash
+# First, get the name of a djangoblog pod
+kubectl get pods -n djangoblog | grep djangoblog
+
+# Exec into one of the Pods (replace [pod-name] with the name from the previous step)
+kubectl exec -it [pod-name] -n djangoblog -- bash
+
+# Inside the Pod, run the following commands:
+# Create a superuser account (follow the prompts)
+python manage.py createsuperuser
+
+# (Optional) Create some test data
+python manage.py create_testdata
+
+# (Optional, if ES is enabled) Create the search index
+python manage.py rebuild_index
+
+# Exit the Pod
+exit
+```
+
+Congratulations! You have successfully deployed DjangoBlog on your Kubernetes cluster.
\ No newline at end of file
diff --git a/docs/k8s.md b/docs/k8s.md
new file mode 100644
index 000000000..9da3c2896
--- /dev/null
+++ b/docs/k8s.md
@@ -0,0 +1,141 @@
+# 使用 Kubernetes 部署 DjangoBlog
+
+本文档将指导您如何在 Kubernetes (K8s) 集群上部署 DjangoBlog 应用。我们提供了一套完整的 `.yaml` 配置文件,位于 `deploy/k8s` 目录下,用于部署一个包含 DjangoBlog 应用、Nginx、MySQL、Redis 和 Elasticsearch 的完整服务栈。
+
+## 架构概览
+
+本次部署采用的是微服务化的云原生架构:
+
+- **核心组件**: 每个核心服务 (DjangoBlog, Nginx, MySQL, Redis, Elasticsearch) 都将作为独立的 `Deployment` 运行。
+- **配置管理**: Nginx 的配置文件和 Django 应用的环境变量通过 `ConfigMap` 进行管理。**注意:敏感信息(如密码)建议使用 `Secret` 进行管理。**
+- **服务发现**: 所有服务都通过 `ClusterIP` 类型的 `Service` 在集群内部暴露,并通过服务名相互通信。
+- **外部访问**: 使用 `Ingress` 资源将外部的 HTTP 流量路由到 Nginx 服务,作为整个博客应用的统一入口。
+- **数据持久化**: 采用基于节点本地路径的 `local-storage` 方案。这需要您在指定的 K8s 节点上手动创建存储目录,并通过 `PersistentVolume` (PV) 和 `PersistentVolumeClaim` (PVC) 进行静态绑定。
+
+## 1. 环境准备
+
+在开始之前,请确保您已具备以下环境:
+
+- 一个正在运行的 Kubernetes 集群。
+- `kubectl` 命令行工具已配置并能够连接到您的集群。
+- 集群中已安装并配置好 [Nginx Ingress Controller](https://kubernetes.github.io/ingress-nginx/deploy/)。
+- 对集群中的一个节点(默认为 `master`)拥有文件系统访问权限,用于创建本地存储目录。
+
+## 2. 部署步骤
+
+### 步骤 1: 创建命名空间
+
+我们建议将 DjangoBlog 相关的所有资源都部署在一个独立的命名空间中,便于管理。
+
+```bash
+# 创建一个名为 djangoblog 的命名空间
+kubectl create namespace djangoblog
+```
+
+### 步骤 2: 配置持久化存储
+
+此方案使用本地持久卷 (Local Persistent Volume)。您需要在集群的一个节点上(在 `pv.yaml` 文件中默认为 `master` 节点)创建用于数据存储的目录。
+
+```bash
+# 登录到您的 master 节点
+ssh user@master-node
+
+# 创建所需的存储目录
+sudo mkdir -p /mnt/local-storage-db
+sudo mkdir -p /mnt/local-storage-djangoblog
+sudo mkdir -p /mnt/resource/
+sudo mkdir -p /mnt/local-storage-elasticsearch
+
+# 退出节点
+exit
+```
+**注意**: 如果您希望将数据存储在其他节点或使用不同的路径,请务必修改 `deploy/k8s/pv.yaml` 文件中 `nodeAffinity` 和 `local.path` 的配置。
+
+创建目录后,应用存储相关的配置文件:
+
+```bash
+# 应用 StorageClass
+kubectl apply -f deploy/k8s/storageclass.yaml
+
+# 应用 PersistentVolume (PV)
+kubectl apply -f deploy/k8s/pv.yaml
+
+# 应用 PersistentVolumeClaim (PVC)
+kubectl apply -f deploy/k8s/pvc.yaml
+```
+
+### 步骤 3: 配置应用
+
+在部署应用之前,您需要编辑 `deploy/k8s/configmap.yaml` 文件,修改其中的敏感信息和个性化配置。
+
+**强烈建议修改以下字段:**
+- `DJANGO_SECRET_KEY`: 修改为一个随机且复杂的字符串。
+- `DJANGO_MYSQL_PASSWORD` 和 `MYSQL_ROOT_PASSWORD`: 修改为您自己的数据库密码。
+
+```bash
+# 编辑 ConfigMap 文件
+vim deploy/k8s/configmap.yaml
+
+# 应用配置
+kubectl apply -f deploy/k8s/configmap.yaml
+```
+
+### 步骤 4: 部署应用服务栈
+
+现在,我们可以部署所有的核心服务了。
+
+```bash
+# 部署 Deployments (DjangoBlog, MySQL, Redis, Nginx, ES)
+kubectl apply -f deploy/k8s/deployment.yaml
+
+# 部署 Services (为 Deployments 创建内部访问端点)
+kubectl apply -f deploy/k8s/service.yaml
+```
+
+部署需要一些时间,您可以运行以下命令检查所有 Pod 是否都已成功运行 (STATUS 为 `Running`):
+
+```bash
+kubectl get pods -n djangoblog -w
+```
+
+### 步骤 5: 暴露应用到外部
+
+最后,通过应用 `Ingress` 规则来将外部流量引导至我们的 Nginx 服务。
+
+```bash
+# 应用 Ingress 规则
+kubectl apply -f deploy/k8s/gateway.yaml
+```
+
+部署完成后,您可以通过 Ingress Controller 的外部 IP 地址来访问您的博客。执行以下命令获取地址:
+
+```bash
+kubectl get ingress -n djangoblog
+```
+
+### 步骤 6: 首次运行的初始化操作
+
+与 Docker 部署类似,首次运行时,您需要进入 DjangoBlog 应用的 Pod 来执行数据库初始化和创建管理员账户。
+
+```bash
+# 首先,获取 djangoblog pod 的名称
+kubectl get pods -n djangoblog | grep djangoblog
+
+# 进入其中一个 Pod (将 [pod-name] 替换为上一步获取到的名称)
+kubectl exec -it [pod-name] -n djangoblog -- bash
+
+# 在 Pod 内部执行以下命令:
+# 创建超级管理员账户 (请按照提示操作)
+python manage.py createsuperuser
+
+# (可选) 创建测试数据
+python manage.py create_testdata
+
+# (可选,如果启用了 ES) 创建索引
+python manage.py rebuild_index
+
+# 退出 Pod
+exit
+```
+
+至此,您已成功在 Kubernetes 集群上完成了 DjangoBlog 的部署!
\ No newline at end of file
diff --git a/locale/en/LC_MESSAGES/django.mo b/locale/en/LC_MESSAGES/django.mo
new file mode 100644
index 000000000..f63669f46
Binary files /dev/null and b/locale/en/LC_MESSAGES/django.mo differ
diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po
new file mode 100644
index 000000000..c80b30ac7
--- /dev/null
+++ b/locale/en/LC_MESSAGES/django.po
@@ -0,0 +1,685 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the PACKAGE package.
+# FIRST AUTHOR , YEAR.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE VERSION\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2023-09-13 16:02+0800\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME \n"
+"Language-Team: LANGUAGE \n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+#: .\accounts\admin.py:12
+msgid "password"
+msgstr "password"
+
+#: .\accounts\admin.py:13
+msgid "Enter password again"
+msgstr "Enter password again"
+
+#: .\accounts\admin.py:24 .\accounts\forms.py:89
+msgid "passwords do not match"
+msgstr "passwords do not match"
+
+#: .\accounts\forms.py:36
+msgid "email already exists"
+msgstr "email already exists"
+
+#: .\accounts\forms.py:46 .\accounts\forms.py:50
+msgid "New password"
+msgstr "New password"
+
+#: .\accounts\forms.py:60
+msgid "Confirm password"
+msgstr "Confirm password"
+
+#: .\accounts\forms.py:70 .\accounts\forms.py:116
+msgid "Email"
+msgstr "Email"
+
+#: .\accounts\forms.py:76 .\accounts\forms.py:80
+msgid "Code"
+msgstr "Code"
+
+#: .\accounts\forms.py:100 .\accounts\tests.py:194
+msgid "email does not exist"
+msgstr "email does not exist"
+
+#: .\accounts\models.py:12 .\oauth\models.py:17
+msgid "nick name"
+msgstr "nick name"
+
+#: .\accounts\models.py:13 .\blog\models.py:29 .\blog\models.py:266
+#: .\blog\models.py:284 .\comments\models.py:13 .\oauth\models.py:23
+#: .\oauth\models.py:53
+msgid "creation time"
+msgstr "creation time"
+
+#: .\accounts\models.py:14 .\comments\models.py:14 .\oauth\models.py:24
+#: .\oauth\models.py:54
+msgid "last modify time"
+msgstr "last modify time"
+
+#: .\accounts\models.py:15
+msgid "create source"
+msgstr "create source"
+
+#: .\accounts\models.py:33 .\djangoblog\logentryadmin.py:81
+msgid "user"
+msgstr "user"
+
+#: .\accounts\tests.py:216 .\accounts\utils.py:39
+msgid "Verification code error"
+msgstr "Verification code error"
+
+#: .\accounts\utils.py:13
+msgid "Verify Email"
+msgstr "Verify Email"
+
+#: .\accounts\utils.py:21
+#, python-format
+msgid ""
+"You are resetting the password, the verification code is:%(code)s, valid "
+"within 5 minutes, please keep it properly"
+msgstr ""
+"You are resetting the password, the verification code is:%(code)s, valid "
+"within 5 minutes, please keep it properly"
+
+#: .\blog\admin.py:13 .\blog\models.py:92 .\comments\models.py:17
+#: .\oauth\models.py:12
+msgid "author"
+msgstr "author"
+
+#: .\blog\admin.py:53
+msgid "Publish selected articles"
+msgstr "Publish selected articles"
+
+#: .\blog\admin.py:54
+msgid "Draft selected articles"
+msgstr "Draft selected articles"
+
+#: .\blog\admin.py:55
+msgid "Close article comments"
+msgstr "Close article comments"
+
+#: .\blog\admin.py:56
+msgid "Open article comments"
+msgstr "Open article comments"
+
+#: .\blog\admin.py:89 .\blog\models.py:101 .\blog\models.py:183
+#: .\templates\blog\tags\sidebar.html:40
+msgid "category"
+msgstr "category"
+
+#: .\blog\models.py:20 .\blog\models.py:179 .\templates\share_layout\nav.html:8
+msgid "index"
+msgstr "index"
+
+#: .\blog\models.py:21
+msgid "list"
+msgstr "list"
+
+#: .\blog\models.py:22
+msgid "post"
+msgstr "post"
+
+#: .\blog\models.py:23
+msgid "all"
+msgstr "all"
+
+#: .\blog\models.py:24
+msgid "slide"
+msgstr "slide"
+
+#: .\blog\models.py:30 .\blog\models.py:267 .\blog\models.py:285
+msgid "modify time"
+msgstr "modify time"
+
+#: .\blog\models.py:63
+msgid "Draft"
+msgstr "Draft"
+
+#: .\blog\models.py:64
+msgid "Published"
+msgstr "Published"
+
+#: .\blog\models.py:67
+msgid "Open"
+msgstr "Open"
+
+#: .\blog\models.py:68
+msgid "Close"
+msgstr "Close"
+
+#: .\blog\models.py:71 .\comments\admin.py:47
+msgid "Article"
+msgstr "Article"
+
+#: .\blog\models.py:72
+msgid "Page"
+msgstr "Page"
+
+#: .\blog\models.py:74 .\blog\models.py:280
+msgid "title"
+msgstr "title"
+
+#: .\blog\models.py:75
+msgid "body"
+msgstr "body"
+
+#: .\blog\models.py:77
+msgid "publish time"
+msgstr "publish time"
+
+#: .\blog\models.py:79
+msgid "status"
+msgstr "status"
+
+#: .\blog\models.py:84
+msgid "comment status"
+msgstr "comment status"
+
+#: .\blog\models.py:88 .\oauth\models.py:43
+msgid "type"
+msgstr "type"
+
+#: .\blog\models.py:89
+msgid "views"
+msgstr "views"
+
+#: .\blog\models.py:97 .\blog\models.py:258 .\blog\models.py:282
+msgid "order"
+msgstr "order"
+
+#: .\blog\models.py:98
+msgid "show toc"
+msgstr "show toc"
+
+#: .\blog\models.py:105 .\blog\models.py:249
+msgid "tag"
+msgstr "tag"
+
+#: .\blog\models.py:115 .\comments\models.py:21
+msgid "article"
+msgstr "article"
+
+#: .\blog\models.py:171
+msgid "category name"
+msgstr "category name"
+
+#: .\blog\models.py:174
+msgid "parent category"
+msgstr "parent category"
+
+#: .\blog\models.py:234
+msgid "tag name"
+msgstr "tag name"
+
+#: .\blog\models.py:256
+msgid "link name"
+msgstr "link name"
+
+#: .\blog\models.py:257 .\blog\models.py:271
+msgid "link"
+msgstr "link"
+
+#: .\blog\models.py:260
+msgid "is show"
+msgstr "is show"
+
+#: .\blog\models.py:262
+msgid "show type"
+msgstr "show type"
+
+#: .\blog\models.py:281
+msgid "content"
+msgstr "content"
+
+#: .\blog\models.py:283 .\oauth\models.py:52
+msgid "is enable"
+msgstr "is enable"
+
+#: .\blog\models.py:289
+msgid "sidebar"
+msgstr "sidebar"
+
+#: .\blog\models.py:299
+msgid "site name"
+msgstr "site name"
+
+#: .\blog\models.py:305
+msgid "site description"
+msgstr "site description"
+
+#: .\blog\models.py:311
+msgid "site seo description"
+msgstr "site seo description"
+
+#: .\blog\models.py:313
+msgid "site keywords"
+msgstr "site keywords"
+
+#: .\blog\models.py:318
+msgid "article sub length"
+msgstr "article sub length"
+
+#: .\blog\models.py:319
+msgid "sidebar article count"
+msgstr "sidebar article count"
+
+#: .\blog\models.py:320
+msgid "sidebar comment count"
+msgstr "sidebar comment count"
+
+#: .\blog\models.py:321
+msgid "article comment count"
+msgstr "article comment count"
+
+#: .\blog\models.py:322
+msgid "show adsense"
+msgstr "show adsense"
+
+#: .\blog\models.py:324
+msgid "adsense code"
+msgstr "adsense code"
+
+#: .\blog\models.py:325
+msgid "open site comment"
+msgstr "open site comment"
+
+#: .\blog\models.py:352
+msgid "Website configuration"
+msgstr "Website configuration"
+
+#: .\blog\models.py:360
+msgid "There can only be one configuration"
+msgstr "There can only be one configuration"
+
+#: .\blog\views.py:348
+msgid ""
+"Sorry, the page you requested is not found, please click the home page to "
+"see other?"
+msgstr ""
+"Sorry, the page you requested is not found, please click the home page to "
+"see other?"
+
+#: .\blog\views.py:356
+msgid "Sorry, the server is busy, please click the home page to see other?"
+msgstr "Sorry, the server is busy, please click the home page to see other?"
+
+#: .\blog\views.py:369
+msgid "Sorry, you do not have permission to access this page?"
+msgstr "Sorry, you do not have permission to access this page?"
+
+#: .\comments\admin.py:15
+msgid "Disable comments"
+msgstr "Disable comments"
+
+#: .\comments\admin.py:16
+msgid "Enable comments"
+msgstr "Enable comments"
+
+#: .\comments\admin.py:46
+msgid "User"
+msgstr "User"
+
+#: .\comments\models.py:25
+msgid "parent comment"
+msgstr "parent comment"
+
+#: .\comments\models.py:29
+msgid "enable"
+msgstr "enable"
+
+#: .\comments\models.py:34 .\templates\blog\tags\article_info.html:30
+msgid "comment"
+msgstr "comment"
+
+#: .\comments\utils.py:13
+msgid "Thanks for your comment"
+msgstr "Thanks for your comment"
+
+#: .\comments\utils.py:15
+#, python-format
+msgid ""
+"Thank you very much for your comments on this site
\n"
+" You can visit %(article_title)s \n"
+" to review your comments,\n"
+" Thank you again!\n"
+" \n"
+" If the link above cannot be opened, please copy this "
+"link to your browser.\n"
+" %(article_url)s"
+msgstr ""
+"Thank you very much for your comments on this site
\n"
+" You can visit %(article_title)s \n"
+" to review your comments,\n"
+" Thank you again!\n"
+" \n"
+" If the link above cannot be opened, please copy this "
+"link to your browser.\n"
+" %(article_url)s"
+
+#: .\comments\utils.py:26
+#, python-format
+msgid ""
+"Your comment on "
+"%(article_title)s has \n"
+" received a reply. %(comment_body)s\n"
+" \n"
+" go check it out!\n"
+" \n"
+" If the link above cannot be opened, please copy this "
+"link to your browser.\n"
+" %(article_url)s\n"
+" "
+msgstr ""
+"Your comment on "
+"%(article_title)s has \n"
+" received a reply. %(comment_body)s\n"
+" \n"
+" go check it out!\n"
+" \n"
+" If the link above cannot be opened, please copy this "
+"link to your browser.\n"
+" %(article_url)s\n"
+" "
+
+#: .\djangoblog\logentryadmin.py:63
+msgid "object"
+msgstr "object"
+
+#: .\djangoblog\settings.py:140
+msgid "English"
+msgstr "English"
+
+#: .\djangoblog\settings.py:141
+msgid "Simplified Chinese"
+msgstr "Simplified Chinese"
+
+#: .\djangoblog\settings.py:142
+msgid "Traditional Chinese"
+msgstr "Traditional Chinese"
+
+#: .\oauth\models.py:30
+msgid "oauth user"
+msgstr "oauth user"
+
+#: .\oauth\models.py:37
+msgid "weibo"
+msgstr "weibo"
+
+#: .\oauth\models.py:38
+msgid "google"
+msgstr "google"
+
+#: .\oauth\models.py:48
+msgid "callback url"
+msgstr "callback url"
+
+#: .\oauth\models.py:59
+msgid "already exists"
+msgstr "already exists"
+
+#: .\oauth\views.py:154
+#, python-format
+msgid ""
+"\n"
+" Congratulations, you have successfully bound your email address. You "
+"can use\n"
+" %(oauthuser_type)s to directly log in to this website without a "
+"password.
\n"
+" You are welcome to continue to follow this site, the address is\n"
+" %(site)s \n"
+" Thank you again!\n"
+" \n"
+" If the link above cannot be opened, please copy this link to your "
+"browser.\n"
+" %(site)s\n"
+" "
+msgstr ""
+"\n"
+" Congratulations, you have successfully bound your email address. You "
+"can use\n"
+" %(oauthuser_type)s to directly log in to this website without a "
+"password.
\n"
+" You are welcome to continue to follow this site, the address is\n"
+" %(site)s \n"
+" Thank you again!\n"
+" \n"
+" If the link above cannot be opened, please copy this link to your "
+"browser.\n"
+" %(site)s\n"
+" "
+
+#: .\oauth\views.py:165
+msgid "Congratulations on your successful binding!"
+msgstr "Congratulations on your successful binding!"
+
+#: .\oauth\views.py:217
+#, python-format
+msgid ""
+"\n"
+" Please click the link below to bind your email
\n"
+"\n"
+" %(url)s \n"
+"\n"
+" Thank you again!\n"
+" \n"
+" If the link above cannot be opened, please copy this link "
+"to your browser.\n"
+" \n"
+" %(url)s\n"
+" "
+msgstr ""
+"\n"
+" Please click the link below to bind your email
\n"
+"\n"
+" %(url)s \n"
+"\n"
+" Thank you again!\n"
+" \n"
+" If the link above cannot be opened, please copy this link "
+"to your browser.\n"
+" \n"
+" %(url)s\n"
+" "
+
+#: .\oauth\views.py:228 .\oauth\views.py:240
+msgid "Bind your email"
+msgstr "Bind your email"
+
+#: .\oauth\views.py:242
+msgid ""
+"Congratulations, the binding is just one step away. Please log in to your "
+"email to check the email to complete the binding. Thank you."
+msgstr ""
+"Congratulations, the binding is just one step away. Please log in to your "
+"email to check the email to complete the binding. Thank you."
+
+#: .\oauth\views.py:245
+msgid "Binding successful"
+msgstr "Binding successful"
+
+#: .\oauth\views.py:247
+#, python-format
+msgid ""
+"Congratulations, you have successfully bound your email address. You can use "
+"%(oauthuser_type)s to directly log in to this website without a password. "
+"You are welcome to continue to follow this site."
+msgstr ""
+"Congratulations, you have successfully bound your email address. You can use "
+"%(oauthuser_type)s to directly log in to this website without a password. "
+"You are welcome to continue to follow this site."
+
+#: .\templates\account\forget_password.html:7
+msgid "forget the password"
+msgstr "forget the password"
+
+#: .\templates\account\forget_password.html:18
+msgid "get verification code"
+msgstr "get verification code"
+
+#: .\templates\account\forget_password.html:19
+msgid "submit"
+msgstr "submit"
+
+#: .\templates\account\login.html:36
+msgid "Create Account"
+msgstr "Create Account"
+
+#: .\templates\account\login.html:42
+#, fuzzy
+#| msgid "forget the password"
+msgid "Forget Password"
+msgstr "forget the password"
+
+#: .\templates\account\result.html:18 .\templates\blog\tags\sidebar.html:126
+msgid "login"
+msgstr "login"
+
+#: .\templates\account\result.html:22
+msgid "back to the homepage"
+msgstr "back to the homepage"
+
+#: .\templates\blog\article_archives.html:7
+#: .\templates\blog\article_archives.html:24
+msgid "article archive"
+msgstr "article archive"
+
+#: .\templates\blog\article_archives.html:32
+msgid "year"
+msgstr "year"
+
+#: .\templates\blog\article_archives.html:36
+msgid "month"
+msgstr "month"
+
+#: .\templates\blog\tags\article_info.html:12
+msgid "pin to top"
+msgstr "pin to top"
+
+#: .\templates\blog\tags\article_info.html:28
+msgid "comments"
+msgstr "comments"
+
+#: .\templates\blog\tags\article_info.html:58
+msgid "toc"
+msgstr "toc"
+
+#: .\templates\blog\tags\article_meta_info.html:6
+msgid "posted in"
+msgstr "posted in"
+
+#: .\templates\blog\tags\article_meta_info.html:14
+msgid "and tagged"
+msgstr "and tagged"
+
+#: .\templates\blog\tags\article_meta_info.html:25
+msgid "by "
+msgstr "by"
+
+#: .\templates\blog\tags\article_meta_info.html:29
+#, python-format
+msgid ""
+"\n"
+" title=\"View all articles published by "
+"%(article.author.username)s\"\n"
+" "
+msgstr ""
+"\n"
+" title=\"View all articles published by "
+"%(article.author.username)s\"\n"
+" "
+
+#: .\templates\blog\tags\article_meta_info.html:44
+msgid "on"
+msgstr "on"
+
+#: .\templates\blog\tags\article_meta_info.html:54
+msgid "edit"
+msgstr "edit"
+
+#: .\templates\blog\tags\article_pagination.html:4
+msgid "article navigation"
+msgstr "article navigation"
+
+#: .\templates\blog\tags\article_pagination.html:9
+msgid "earlier articles"
+msgstr "earlier articles"
+
+#: .\templates\blog\tags\article_pagination.html:12
+msgid "newer articles"
+msgstr "newer articles"
+
+#: .\templates\blog\tags\article_tag_list.html:5
+msgid "tags"
+msgstr "tags"
+
+#: .\templates\blog\tags\sidebar.html:7
+msgid "search"
+msgstr "search"
+
+#: .\templates\blog\tags\sidebar.html:50
+msgid "recent comments"
+msgstr "recent comments"
+
+#: .\templates\blog\tags\sidebar.html:57
+msgid "published on"
+msgstr "published on"
+
+#: .\templates\blog\tags\sidebar.html:65
+msgid "recent articles"
+msgstr "recent articles"
+
+#: .\templates\blog\tags\sidebar.html:77
+msgid "bookmark"
+msgstr "bookmark"
+
+#: .\templates\blog\tags\sidebar.html:96
+msgid "Tag Cloud"
+msgstr "Tag Cloud"
+
+#: .\templates\blog\tags\sidebar.html:107
+msgid "Welcome to star or fork the source code of this site"
+msgstr "Welcome to star or fork the source code of this site"
+
+#: .\templates\blog\tags\sidebar.html:118
+msgid "Function"
+msgstr "Function"
+
+#: .\templates\blog\tags\sidebar.html:120
+msgid "management site"
+msgstr "management site"
+
+#: .\templates\blog\tags\sidebar.html:122
+msgid "logout"
+msgstr "logout"
+
+#: .\templates\blog\tags\sidebar.html:129
+msgid "Track record"
+msgstr "Track record"
+
+#: .\templates\blog\tags\sidebar.html:135
+msgid "Click me to return to the top"
+msgstr "Click me to return to the top"
+
+#: .\templates\oauth\oauth_applications.html:5
+#| msgid "login"
+msgid "quick login"
+msgstr "quick login"
+
+#: .\templates\share_layout\nav.html:26
+msgid "Article archive"
+msgstr "Article archive"
diff --git a/locale/zh_Hans/LC_MESSAGES/django.mo b/locale/zh_Hans/LC_MESSAGES/django.mo
new file mode 100644
index 000000000..a2d36e98a
Binary files /dev/null and b/locale/zh_Hans/LC_MESSAGES/django.mo differ
diff --git a/locale/zh_Hans/LC_MESSAGES/django.po b/locale/zh_Hans/LC_MESSAGES/django.po
new file mode 100644
index 000000000..200b7e6c0
--- /dev/null
+++ b/locale/zh_Hans/LC_MESSAGES/django.po
@@ -0,0 +1,667 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the PACKAGE package.
+# FIRST AUTHOR , YEAR.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE VERSION\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2023-09-13 16:02+0800\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME \n"
+"Language-Team: LANGUAGE \n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=1; plural=0;\n"
+
+#: .\accounts\admin.py:12
+msgid "password"
+msgstr "密码"
+
+#: .\accounts\admin.py:13
+msgid "Enter password again"
+msgstr "再次输入密码"
+
+#: .\accounts\admin.py:24 .\accounts\forms.py:89
+msgid "passwords do not match"
+msgstr "密码不匹配"
+
+#: .\accounts\forms.py:36
+msgid "email already exists"
+msgstr "邮箱已存在"
+
+#: .\accounts\forms.py:46 .\accounts\forms.py:50
+msgid "New password"
+msgstr "新密码"
+
+#: .\accounts\forms.py:60
+msgid "Confirm password"
+msgstr "确认密码"
+
+#: .\accounts\forms.py:70 .\accounts\forms.py:116
+msgid "Email"
+msgstr "邮箱"
+
+#: .\accounts\forms.py:76 .\accounts\forms.py:80
+msgid "Code"
+msgstr "验证码"
+
+#: .\accounts\forms.py:100 .\accounts\tests.py:194
+msgid "email does not exist"
+msgstr "邮箱不存在"
+
+#: .\accounts\models.py:12 .\oauth\models.py:17
+msgid "nick name"
+msgstr "昵称"
+
+#: .\accounts\models.py:13 .\blog\models.py:29 .\blog\models.py:266
+#: .\blog\models.py:284 .\comments\models.py:13 .\oauth\models.py:23
+#: .\oauth\models.py:53
+msgid "creation time"
+msgstr "创建时间"
+
+#: .\accounts\models.py:14 .\comments\models.py:14 .\oauth\models.py:24
+#: .\oauth\models.py:54
+msgid "last modify time"
+msgstr "最后修改时间"
+
+#: .\accounts\models.py:15
+msgid "create source"
+msgstr "来源"
+
+#: .\accounts\models.py:33 .\djangoblog\logentryadmin.py:81
+msgid "user"
+msgstr "用户"
+
+#: .\accounts\tests.py:216 .\accounts\utils.py:39
+msgid "Verification code error"
+msgstr "验证码错误"
+
+#: .\accounts\utils.py:13
+msgid "Verify Email"
+msgstr "验证邮箱"
+
+#: .\accounts\utils.py:21
+#, python-format
+msgid ""
+"You are resetting the password, the verification code is:%(code)s, valid "
+"within 5 minutes, please keep it properly"
+msgstr "您正在重置密码,验证码为:%(code)s,5分钟内有效 请妥善保管."
+
+#: .\blog\admin.py:13 .\blog\models.py:92 .\comments\models.py:17
+#: .\oauth\models.py:12
+msgid "author"
+msgstr "作者"
+
+#: .\blog\admin.py:53
+msgid "Publish selected articles"
+msgstr "发布选中的文章"
+
+#: .\blog\admin.py:54
+msgid "Draft selected articles"
+msgstr "选中文章设为草稿"
+
+#: .\blog\admin.py:55
+msgid "Close article comments"
+msgstr "关闭文章评论"
+
+#: .\blog\admin.py:56
+msgid "Open article comments"
+msgstr "打开文章评论"
+
+#: .\blog\admin.py:89 .\blog\models.py:101 .\blog\models.py:183
+#: .\templates\blog\tags\sidebar.html:40
+msgid "category"
+msgstr "分类目录"
+
+#: .\blog\models.py:20 .\blog\models.py:179 .\templates\share_layout\nav.html:8
+msgid "index"
+msgstr "首页"
+
+#: .\blog\models.py:21
+msgid "list"
+msgstr "列表"
+
+#: .\blog\models.py:22
+msgid "post"
+msgstr "文章"
+
+#: .\blog\models.py:23
+msgid "all"
+msgstr "所有"
+
+#: .\blog\models.py:24
+msgid "slide"
+msgstr "侧边栏"
+
+#: .\blog\models.py:30 .\blog\models.py:267 .\blog\models.py:285
+msgid "modify time"
+msgstr "修改时间"
+
+#: .\blog\models.py:63
+msgid "Draft"
+msgstr "草稿"
+
+#: .\blog\models.py:64
+msgid "Published"
+msgstr "发布"
+
+#: .\blog\models.py:67
+msgid "Open"
+msgstr "打开"
+
+#: .\blog\models.py:68
+msgid "Close"
+msgstr "关闭"
+
+#: .\blog\models.py:71 .\comments\admin.py:47
+msgid "Article"
+msgstr "文章"
+
+#: .\blog\models.py:72
+msgid "Page"
+msgstr "页面"
+
+#: .\blog\models.py:74 .\blog\models.py:280
+msgid "title"
+msgstr "标题"
+
+#: .\blog\models.py:75
+msgid "body"
+msgstr "内容"
+
+#: .\blog\models.py:77
+msgid "publish time"
+msgstr "发布时间"
+
+#: .\blog\models.py:79
+msgid "status"
+msgstr "状态"
+
+#: .\blog\models.py:84
+msgid "comment status"
+msgstr "评论状态"
+
+#: .\blog\models.py:88 .\oauth\models.py:43
+msgid "type"
+msgstr "类型"
+
+#: .\blog\models.py:89
+msgid "views"
+msgstr "阅读量"
+
+#: .\blog\models.py:97 .\blog\models.py:258 .\blog\models.py:282
+msgid "order"
+msgstr "排序"
+
+#: .\blog\models.py:98
+msgid "show toc"
+msgstr "显示目录"
+
+#: .\blog\models.py:105 .\blog\models.py:249
+msgid "tag"
+msgstr "标签"
+
+#: .\blog\models.py:115 .\comments\models.py:21
+msgid "article"
+msgstr "文章"
+
+#: .\blog\models.py:171
+msgid "category name"
+msgstr "分类名"
+
+#: .\blog\models.py:174
+msgid "parent category"
+msgstr "上级分类"
+
+#: .\blog\models.py:234
+msgid "tag name"
+msgstr "标签名"
+
+#: .\blog\models.py:256
+msgid "link name"
+msgstr "链接名"
+
+#: .\blog\models.py:257 .\blog\models.py:271
+msgid "link"
+msgstr "链接"
+
+#: .\blog\models.py:260
+msgid "is show"
+msgstr "是否显示"
+
+#: .\blog\models.py:262
+msgid "show type"
+msgstr "显示类型"
+
+#: .\blog\models.py:281
+msgid "content"
+msgstr "内容"
+
+#: .\blog\models.py:283 .\oauth\models.py:52
+msgid "is enable"
+msgstr "是否启用"
+
+#: .\blog\models.py:289
+msgid "sidebar"
+msgstr "侧边栏"
+
+#: .\blog\models.py:299
+msgid "site name"
+msgstr "站点名称"
+
+#: .\blog\models.py:305
+msgid "site description"
+msgstr "站点描述"
+
+#: .\blog\models.py:311
+msgid "site seo description"
+msgstr "站点SEO描述"
+
+#: .\blog\models.py:313
+msgid "site keywords"
+msgstr "关键字"
+
+#: .\blog\models.py:318
+msgid "article sub length"
+msgstr "文章摘要长度"
+
+#: .\blog\models.py:319
+msgid "sidebar article count"
+msgstr "侧边栏文章数目"
+
+#: .\blog\models.py:320
+msgid "sidebar comment count"
+msgstr "侧边栏评论数目"
+
+#: .\blog\models.py:321
+msgid "article comment count"
+msgstr "文章页面默认显示评论数目"
+
+#: .\blog\models.py:322
+msgid "show adsense"
+msgstr "是否显示广告"
+
+#: .\blog\models.py:324
+msgid "adsense code"
+msgstr "广告内容"
+
+#: .\blog\models.py:325
+msgid "open site comment"
+msgstr "公共头部"
+
+#: .\blog\models.py:352
+msgid "Website configuration"
+msgstr "网站配置"
+
+#: .\blog\models.py:360
+msgid "There can only be one configuration"
+msgstr "只能有一个配置"
+
+#: .\blog\views.py:348
+msgid ""
+"Sorry, the page you requested is not found, please click the home page to "
+"see other?"
+msgstr "抱歉,你所访问的页面找不到,请点击首页看看别的?"
+
+#: .\blog\views.py:356
+msgid "Sorry, the server is busy, please click the home page to see other?"
+msgstr "抱歉,服务出错了,请点击首页看看别的?"
+
+#: .\blog\views.py:369
+msgid "Sorry, you do not have permission to access this page?"
+msgstr "抱歉,你没用权限访问此页面。"
+
+#: .\comments\admin.py:15
+msgid "Disable comments"
+msgstr "禁用评论"
+
+#: .\comments\admin.py:16
+msgid "Enable comments"
+msgstr "启用评论"
+
+#: .\comments\admin.py:46
+msgid "User"
+msgstr "用户"
+
+#: .\comments\models.py:25
+msgid "parent comment"
+msgstr "上级评论"
+
+#: .\comments\models.py:29
+msgid "enable"
+msgstr "启用"
+
+#: .\comments\models.py:34 .\templates\blog\tags\article_info.html:30
+msgid "comment"
+msgstr "评论"
+
+#: .\comments\utils.py:13
+msgid "Thanks for your comment"
+msgstr "感谢你的评论"
+
+#: .\comments\utils.py:15
+#, python-format
+msgid ""
+"Thank you very much for your comments on this site
\n"
+" You can visit %(article_title)s \n"
+" to review your comments,\n"
+" Thank you again!\n"
+" \n"
+" If the link above cannot be opened, please copy this "
+"link to your browser.\n"
+" %(article_url)s"
+msgstr ""
+"非常感谢您对此网站的评论
\n"
+" 您可以访问%(article_title)s \n"
+"查看您的评论,\n"
+"再次感谢您!\n"
+" \n"
+" 如果上面的链接打不开,请复制此链接链接到您的浏览器。\n"
+"%(article_url)s"
+
+#: .\comments\utils.py:26
+#, python-format
+msgid ""
+"Your comment on "
+"%(article_title)s has \n"
+" received a reply. %(comment_body)s\n"
+" \n"
+" go check it out!\n"
+" \n"
+" If the link above cannot be opened, please copy this "
+"link to your browser.\n"
+" %(article_url)s\n"
+" "
+msgstr ""
+"您对 %(article_title)s "
+"的评论有\n"
+" 收到回复。 %(comment_body)s\n"
+" \n"
+"快去看看吧!\n"
+" \n"
+" 如果上面的链接打不开,请复制此链接链接到您的浏览器。\n"
+" %(article_url)s\n"
+" "
+
+#: .\djangoblog\logentryadmin.py:63
+msgid "object"
+msgstr "对象"
+
+#: .\djangoblog\settings.py:140
+msgid "English"
+msgstr "英文"
+
+#: .\djangoblog\settings.py:141
+msgid "Simplified Chinese"
+msgstr "简体中文"
+
+#: .\djangoblog\settings.py:142
+msgid "Traditional Chinese"
+msgstr "繁体中文"
+
+#: .\oauth\models.py:30
+msgid "oauth user"
+msgstr "第三方用户"
+
+#: .\oauth\models.py:37
+msgid "weibo"
+msgstr "微博"
+
+#: .\oauth\models.py:38
+msgid "google"
+msgstr "谷歌"
+
+#: .\oauth\models.py:48
+msgid "callback url"
+msgstr "回调地址"
+
+#: .\oauth\models.py:59
+msgid "already exists"
+msgstr "已经存在"
+
+#: .\oauth\views.py:154
+#, python-format
+msgid ""
+"\n"
+" Congratulations, you have successfully bound your email address. You "
+"can use\n"
+" %(oauthuser_type)s to directly log in to this website without a "
+"password.
\n"
+" You are welcome to continue to follow this site, the address is\n"
+" %(site)s \n"
+" Thank you again!\n"
+" \n"
+" If the link above cannot be opened, please copy this link to your "
+"browser.\n"
+" %(site)s\n"
+" "
+msgstr ""
+"\n"
+" 恭喜你已经绑定成功 你可以使用\n"
+" %(oauthuser_type)s 来免密登录本站
\n"
+" 欢迎继续关注本站, 地址是\n"
+" %(site)s \n"
+" 再次感谢你\n"
+" \n"
+" 如果上面链接无法打开,请复制此链接到你的浏览器 \n"
+" %(site)s\n"
+" "
+
+#: .\oauth\views.py:165
+msgid "Congratulations on your successful binding!"
+msgstr "恭喜你绑定成功"
+
+#: .\oauth\views.py:217
+#, python-format
+msgid ""
+"\n"
+" Please click the link below to bind your email
\n"
+"\n"
+" %(url)s \n"
+"\n"
+" Thank you again!\n"
+" \n"
+" If the link above cannot be opened, please copy this link "
+"to your browser.\n"
+" \n"
+" %(url)s\n"
+" "
+msgstr ""
+"\n"
+" 请点击下面的链接绑定您的邮箱
\n"
+"\n"
+" %(url)s \n"
+"\n"
+"再次感谢您!\n"
+" \n"
+"如果上面的链接打不开,请复制此链接到您的浏览器。\n"
+"%(url)s\n"
+" "
+
+#: .\oauth\views.py:228 .\oauth\views.py:240
+msgid "Bind your email"
+msgstr "绑定邮箱"
+
+#: .\oauth\views.py:242
+msgid ""
+"Congratulations, the binding is just one step away. Please log in to your "
+"email to check the email to complete the binding. Thank you."
+msgstr "恭喜您,还差一步就绑定成功了,请登录您的邮箱查看邮件完成绑定,谢谢。"
+
+#: .\oauth\views.py:245
+msgid "Binding successful"
+msgstr "绑定成功"
+
+#: .\oauth\views.py:247
+#, python-format
+msgid ""
+"Congratulations, you have successfully bound your email address. You can use "
+"%(oauthuser_type)s to directly log in to this website without a password. "
+"You are welcome to continue to follow this site."
+msgstr ""
+"恭喜您绑定成功,您以后可以使用%(oauthuser_type)s来直接免密码登录本站啦,感谢"
+"您对本站对关注。"
+
+#: .\templates\account\forget_password.html:7
+msgid "forget the password"
+msgstr "忘记密码"
+
+#: .\templates\account\forget_password.html:18
+msgid "get verification code"
+msgstr "获取验证码"
+
+#: .\templates\account\forget_password.html:19
+msgid "submit"
+msgstr "提交"
+
+#: .\templates\account\login.html:36
+msgid "Create Account"
+msgstr "创建账号"
+
+#: .\templates\account\login.html:42
+#| msgid "forget the password"
+msgid "Forget Password"
+msgstr "忘记密码"
+
+#: .\templates\account\result.html:18 .\templates\blog\tags\sidebar.html:126
+msgid "login"
+msgstr "登录"
+
+#: .\templates\account\result.html:22
+msgid "back to the homepage"
+msgstr "返回首页吧"
+
+#: .\templates\blog\article_archives.html:7
+#: .\templates\blog\article_archives.html:24
+msgid "article archive"
+msgstr "文章归档"
+
+#: .\templates\blog\article_archives.html:32
+msgid "year"
+msgstr "年"
+
+#: .\templates\blog\article_archives.html:36
+msgid "month"
+msgstr "月"
+
+#: .\templates\blog\tags\article_info.html:12
+msgid "pin to top"
+msgstr "置顶"
+
+#: .\templates\blog\tags\article_info.html:28
+msgid "comments"
+msgstr "评论"
+
+#: .\templates\blog\tags\article_info.html:58
+msgid "toc"
+msgstr "目录"
+
+#: .\templates\blog\tags\article_meta_info.html:6
+msgid "posted in"
+msgstr "发布于"
+
+#: .\templates\blog\tags\article_meta_info.html:14
+msgid "and tagged"
+msgstr "并标记为"
+
+#: .\templates\blog\tags\article_meta_info.html:25
+msgid "by "
+msgstr "由"
+
+#: .\templates\blog\tags\article_meta_info.html:29
+#, python-format
+msgid ""
+"\n"
+" title=\"View all articles published by "
+"%(article.author.username)s\"\n"
+" "
+msgstr ""
+"\n"
+" title=\"查看所有由 %(article.author.username)s\"发布的文章\n"
+" "
+
+#: .\templates\blog\tags\article_meta_info.html:44
+msgid "on"
+msgstr "在"
+
+#: .\templates\blog\tags\article_meta_info.html:54
+msgid "edit"
+msgstr "编辑"
+
+#: .\templates\blog\tags\article_pagination.html:4
+msgid "article navigation"
+msgstr "文章导航"
+
+#: .\templates\blog\tags\article_pagination.html:9
+msgid "earlier articles"
+msgstr "早期文章"
+
+#: .\templates\blog\tags\article_pagination.html:12
+msgid "newer articles"
+msgstr "较新文章"
+
+#: .\templates\blog\tags\article_tag_list.html:5
+msgid "tags"
+msgstr "标签"
+
+#: .\templates\blog\tags\sidebar.html:7
+msgid "search"
+msgstr "搜索"
+
+#: .\templates\blog\tags\sidebar.html:50
+msgid "recent comments"
+msgstr "近期评论"
+
+#: .\templates\blog\tags\sidebar.html:57
+msgid "published on"
+msgstr "发表于"
+
+#: .\templates\blog\tags\sidebar.html:65
+msgid "recent articles"
+msgstr "近期文章"
+
+#: .\templates\blog\tags\sidebar.html:77
+msgid "bookmark"
+msgstr "书签"
+
+#: .\templates\blog\tags\sidebar.html:96
+msgid "Tag Cloud"
+msgstr "标签云"
+
+#: .\templates\blog\tags\sidebar.html:107
+msgid "Welcome to star or fork the source code of this site"
+msgstr "欢迎您STAR或者FORK本站源代码"
+
+#: .\templates\blog\tags\sidebar.html:118
+msgid "Function"
+msgstr "功能"
+
+#: .\templates\blog\tags\sidebar.html:120
+msgid "management site"
+msgstr "管理站点"
+
+#: .\templates\blog\tags\sidebar.html:122
+msgid "logout"
+msgstr "登出"
+
+#: .\templates\blog\tags\sidebar.html:129
+msgid "Track record"
+msgstr "运动轨迹记录"
+
+#: .\templates\blog\tags\sidebar.html:135
+msgid "Click me to return to the top"
+msgstr "点我返回顶部"
+
+#: .\templates\oauth\oauth_applications.html:5
+#| msgid "login"
+msgid "quick login"
+msgstr "快捷登录"
+
+#: .\templates\share_layout\nav.html:26
+msgid "Article archive"
+msgstr "文章归档"
diff --git a/locale/zh_Hant/LC_MESSAGES/django.mo b/locale/zh_Hant/LC_MESSAGES/django.mo
new file mode 100644
index 000000000..fe2ea17dc
Binary files /dev/null and b/locale/zh_Hant/LC_MESSAGES/django.mo differ
diff --git a/locale/zh_Hant/LC_MESSAGES/django.po b/locale/zh_Hant/LC_MESSAGES/django.po
new file mode 100644
index 000000000..a2920ce51
--- /dev/null
+++ b/locale/zh_Hant/LC_MESSAGES/django.po
@@ -0,0 +1,668 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the PACKAGE package.
+# FIRST AUTHOR , YEAR.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE VERSION\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2023-09-13 16:02+0800\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME \n"
+"Language-Team: LANGUAGE \n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=1; plural=0;\n"
+
+#: .\accounts\admin.py:12
+msgid "password"
+msgstr "密碼"
+
+#: .\accounts\admin.py:13
+msgid "Enter password again"
+msgstr "再次輸入密碼"
+
+#: .\accounts\admin.py:24 .\accounts\forms.py:89
+msgid "passwords do not match"
+msgstr "密碼不匹配"
+
+#: .\accounts\forms.py:36
+msgid "email already exists"
+msgstr "郵箱已存在"
+
+#: .\accounts\forms.py:46 .\accounts\forms.py:50
+msgid "New password"
+msgstr "新密碼"
+
+#: .\accounts\forms.py:60
+msgid "Confirm password"
+msgstr "確認密碼"
+
+#: .\accounts\forms.py:70 .\accounts\forms.py:116
+msgid "Email"
+msgstr "郵箱"
+
+#: .\accounts\forms.py:76 .\accounts\forms.py:80
+msgid "Code"
+msgstr "驗證碼"
+
+#: .\accounts\forms.py:100 .\accounts\tests.py:194
+msgid "email does not exist"
+msgstr "郵箱不存在"
+
+#: .\accounts\models.py:12 .\oauth\models.py:17
+msgid "nick name"
+msgstr "昵稱"
+
+#: .\accounts\models.py:13 .\blog\models.py:29 .\blog\models.py:266
+#: .\blog\models.py:284 .\comments\models.py:13 .\oauth\models.py:23
+#: .\oauth\models.py:53
+msgid "creation time"
+msgstr "創建時間"
+
+#: .\accounts\models.py:14 .\comments\models.py:14 .\oauth\models.py:24
+#: .\oauth\models.py:54
+msgid "last modify time"
+msgstr "最後修改時間"
+
+#: .\accounts\models.py:15
+msgid "create source"
+msgstr "來源"
+
+#: .\accounts\models.py:33 .\djangoblog\logentryadmin.py:81
+msgid "user"
+msgstr "用戶"
+
+#: .\accounts\tests.py:216 .\accounts\utils.py:39
+msgid "Verification code error"
+msgstr "驗證碼錯誤"
+
+#: .\accounts\utils.py:13
+msgid "Verify Email"
+msgstr "驗證郵箱"
+
+#: .\accounts\utils.py:21
+#, python-format
+msgid ""
+"You are resetting the password, the verification code is:%(code)s, valid "
+"within 5 minutes, please keep it properly"
+msgstr "您正在重置密碼,驗證碼為:%(code)s,5分鐘內有效 請妥善保管."
+
+#: .\blog\admin.py:13 .\blog\models.py:92 .\comments\models.py:17
+#: .\oauth\models.py:12
+msgid "author"
+msgstr "作者"
+
+#: .\blog\admin.py:53
+msgid "Publish selected articles"
+msgstr "發布選中的文章"
+
+#: .\blog\admin.py:54
+msgid "Draft selected articles"
+msgstr "選中文章設為草稿"
+
+#: .\blog\admin.py:55
+msgid "Close article comments"
+msgstr "關閉文章評論"
+
+#: .\blog\admin.py:56
+msgid "Open article comments"
+msgstr "打開文章評論"
+
+#: .\blog\admin.py:89 .\blog\models.py:101 .\blog\models.py:183
+#: .\templates\blog\tags\sidebar.html:40
+msgid "category"
+msgstr "分類目錄"
+
+#: .\blog\models.py:20 .\blog\models.py:179 .\templates\share_layout\nav.html:8
+msgid "index"
+msgstr "首頁"
+
+#: .\blog\models.py:21
+msgid "list"
+msgstr "列表"
+
+#: .\blog\models.py:22
+msgid "post"
+msgstr "文章"
+
+#: .\blog\models.py:23
+msgid "all"
+msgstr "所有"
+
+#: .\blog\models.py:24
+msgid "slide"
+msgstr "側邊欄"
+
+#: .\blog\models.py:30 .\blog\models.py:267 .\blog\models.py:285
+msgid "modify time"
+msgstr "修改時間"
+
+#: .\blog\models.py:63
+msgid "Draft"
+msgstr "草稿"
+
+#: .\blog\models.py:64
+msgid "Published"
+msgstr "發布"
+
+#: .\blog\models.py:67
+msgid "Open"
+msgstr "打開"
+
+#: .\blog\models.py:68
+msgid "Close"
+msgstr "關閉"
+
+#: .\blog\models.py:71 .\comments\admin.py:47
+msgid "Article"
+msgstr "文章"
+
+#: .\blog\models.py:72
+msgid "Page"
+msgstr "頁面"
+
+#: .\blog\models.py:74 .\blog\models.py:280
+msgid "title"
+msgstr "標題"
+
+#: .\blog\models.py:75
+msgid "body"
+msgstr "內容"
+
+#: .\blog\models.py:77
+msgid "publish time"
+msgstr "發布時間"
+
+#: .\blog\models.py:79
+msgid "status"
+msgstr "狀態"
+
+#: .\blog\models.py:84
+msgid "comment status"
+msgstr "評論狀態"
+
+#: .\blog\models.py:88 .\oauth\models.py:43
+msgid "type"
+msgstr "類型"
+
+#: .\blog\models.py:89
+msgid "views"
+msgstr "閱讀量"
+
+#: .\blog\models.py:97 .\blog\models.py:258 .\blog\models.py:282
+msgid "order"
+msgstr "排序"
+
+#: .\blog\models.py:98
+msgid "show toc"
+msgstr "顯示目錄"
+
+#: .\blog\models.py:105 .\blog\models.py:249
+msgid "tag"
+msgstr "標簽"
+
+#: .\blog\models.py:115 .\comments\models.py:21
+msgid "article"
+msgstr "文章"
+
+#: .\blog\models.py:171
+msgid "category name"
+msgstr "分類名"
+
+#: .\blog\models.py:174
+msgid "parent category"
+msgstr "上級分類"
+
+#: .\blog\models.py:234
+msgid "tag name"
+msgstr "標簽名"
+
+#: .\blog\models.py:256
+msgid "link name"
+msgstr "鏈接名"
+
+#: .\blog\models.py:257 .\blog\models.py:271
+msgid "link"
+msgstr "鏈接"
+
+#: .\blog\models.py:260
+msgid "is show"
+msgstr "是否顯示"
+
+#: .\blog\models.py:262
+msgid "show type"
+msgstr "顯示類型"
+
+#: .\blog\models.py:281
+msgid "content"
+msgstr "內容"
+
+#: .\blog\models.py:283 .\oauth\models.py:52
+msgid "is enable"
+msgstr "是否啟用"
+
+#: .\blog\models.py:289
+msgid "sidebar"
+msgstr "側邊欄"
+
+#: .\blog\models.py:299
+msgid "site name"
+msgstr "站點名稱"
+
+#: .\blog\models.py:305
+msgid "site description"
+msgstr "站點描述"
+
+#: .\blog\models.py:311
+msgid "site seo description"
+msgstr "站點SEO描述"
+
+#: .\blog\models.py:313
+msgid "site keywords"
+msgstr "關鍵字"
+
+#: .\blog\models.py:318
+msgid "article sub length"
+msgstr "文章摘要長度"
+
+#: .\blog\models.py:319
+msgid "sidebar article count"
+msgstr "側邊欄文章數目"
+
+#: .\blog\models.py:320
+msgid "sidebar comment count"
+msgstr "側邊欄評論數目"
+
+#: .\blog\models.py:321
+msgid "article comment count"
+msgstr "文章頁面默認顯示評論數目"
+
+#: .\blog\models.py:322
+msgid "show adsense"
+msgstr "是否顯示廣告"
+
+#: .\blog\models.py:324
+msgid "adsense code"
+msgstr "廣告內容"
+
+#: .\blog\models.py:325
+msgid "open site comment"
+msgstr "公共頭部"
+
+#: .\blog\models.py:352
+msgid "Website configuration"
+msgstr "網站配置"
+
+#: .\blog\models.py:360
+msgid "There can only be one configuration"
+msgstr "只能有一個配置"
+
+#: .\blog\views.py:348
+msgid ""
+"Sorry, the page you requested is not found, please click the home page to "
+"see other?"
+msgstr "抱歉,你所訪問的頁面找不到,請點擊首頁看看別的?"
+
+#: .\blog\views.py:356
+msgid "Sorry, the server is busy, please click the home page to see other?"
+msgstr "抱歉,服務出錯了,請點擊首頁看看別的?"
+
+#: .\blog\views.py:369
+msgid "Sorry, you do not have permission to access this page?"
+msgstr "抱歉,你沒用權限訪問此頁面。"
+
+#: .\comments\admin.py:15
+msgid "Disable comments"
+msgstr "禁用評論"
+
+#: .\comments\admin.py:16
+msgid "Enable comments"
+msgstr "啟用評論"
+
+#: .\comments\admin.py:46
+msgid "User"
+msgstr "用戶"
+
+#: .\comments\models.py:25
+msgid "parent comment"
+msgstr "上級評論"
+
+#: .\comments\models.py:29
+msgid "enable"
+msgstr "啟用"
+
+#: .\comments\models.py:34 .\templates\blog\tags\article_info.html:30
+msgid "comment"
+msgstr "評論"
+
+#: .\comments\utils.py:13
+msgid "Thanks for your comment"
+msgstr "感謝你的評論"
+
+#: .\comments\utils.py:15
+#, python-format
+msgid ""
+"Thank you very much for your comments on this site
\n"
+" You can visit %(article_title)s \n"
+" to review your comments,\n"
+" Thank you again!\n"
+" \n"
+" If the link above cannot be opened, please copy this "
+"link to your browser.\n"
+" %(article_url)s"
+msgstr ""
+"非常感謝您對此網站的評論
\n"
+" 您可以訪問%(article_title)s \n"
+"查看您的評論,\n"
+"再次感謝您!\n"
+" \n"
+" 如果上面的鏈接打不開,請復製此鏈接鏈接到您的瀏覽器。\n"
+"%(article_url)s"
+
+#: .\comments\utils.py:26
+#, python-format
+msgid ""
+"Your comment on "
+"%(article_title)s has \n"
+" received a reply. %(comment_body)s\n"
+" \n"
+" go check it out!\n"
+" \n"
+" If the link above cannot be opened, please copy this "
+"link to your browser.\n"
+" %(article_url)s\n"
+" "
+msgstr ""
+"您對 %(article_title)s "
+"的評論有\n"
+" 收到回復。 %(comment_body)s\n"
+" \n"
+"快去看看吧!\n"
+" \n"
+" 如果上面的鏈接打不開,請復製此鏈接鏈接到您的瀏覽器。\n"
+" %(article_url)s\n"
+" "
+
+#: .\djangoblog\logentryadmin.py:63
+msgid "object"
+msgstr "對象"
+
+#: .\djangoblog\settings.py:140
+msgid "English"
+msgstr "英文"
+
+#: .\djangoblog\settings.py:141
+msgid "Simplified Chinese"
+msgstr "簡體中文"
+
+#: .\djangoblog\settings.py:142
+msgid "Traditional Chinese"
+msgstr "繁體中文"
+
+#: .\oauth\models.py:30
+msgid "oauth user"
+msgstr "第三方用戶"
+
+#: .\oauth\models.py:37
+msgid "weibo"
+msgstr "微博"
+
+#: .\oauth\models.py:38
+msgid "google"
+msgstr "谷歌"
+
+#: .\oauth\models.py:48
+msgid "callback url"
+msgstr "回調地址"
+
+#: .\oauth\models.py:59
+msgid "already exists"
+msgstr "已經存在"
+
+#: .\oauth\views.py:154
+#, python-format
+msgid ""
+"\n"
+" Congratulations, you have successfully bound your email address. You "
+"can use\n"
+" %(oauthuser_type)s to directly log in to this website without a "
+"password.
\n"
+" You are welcome to continue to follow this site, the address is\n"
+" %(site)s \n"
+" Thank you again!\n"
+" \n"
+" If the link above cannot be opened, please copy this link to your "
+"browser.\n"
+" %(site)s\n"
+" "
+msgstr ""
+"\n"
+" 恭喜你已經綁定成功 你可以使用\n"
+" %(oauthuser_type)s 來免密登錄本站
\n"
+" 歡迎繼續關註本站, 地址是\n"
+" %(site)s \n"
+" 再次感謝你\n"
+" \n"
+" 如果上面鏈接無法打開,請復製此鏈接到你的瀏覽器 \n"
+" %(site)s\n"
+" "
+
+#: .\oauth\views.py:165
+msgid "Congratulations on your successful binding!"
+msgstr "恭喜你綁定成功"
+
+#: .\oauth\views.py:217
+#, python-format
+msgid ""
+"\n"
+" Please click the link below to bind your email
\n"
+"\n"
+" %(url)s \n"
+"\n"
+" Thank you again!\n"
+" \n"
+" If the link above cannot be opened, please copy this link "
+"to your browser.\n"
+" \n"
+" %(url)s\n"
+" "
+msgstr ""
+"\n"
+" 請點擊下面的鏈接綁定您的郵箱
\n"
+"\n"
+" %(url)s \n"
+"\n"
+"再次感謝您!\n"
+" \n"
+"如果上面的鏈接打不開,請復製此鏈接到您的瀏覽器。\n"
+"%(url)s\n"
+" "
+
+#: .\oauth\views.py:228 .\oauth\views.py:240
+msgid "Bind your email"
+msgstr "綁定郵箱"
+
+#: .\oauth\views.py:242
+msgid ""
+"Congratulations, the binding is just one step away. Please log in to your "
+"email to check the email to complete the binding. Thank you."
+msgstr "恭喜您,還差一步就綁定成功了,請登錄您的郵箱查看郵件完成綁定,謝謝。"
+
+#: .\oauth\views.py:245
+msgid "Binding successful"
+msgstr "綁定成功"
+
+#: .\oauth\views.py:247
+#, python-format
+msgid ""
+"Congratulations, you have successfully bound your email address. You can use "
+"%(oauthuser_type)s to directly log in to this website without a password. "
+"You are welcome to continue to follow this site."
+msgstr ""
+"恭喜您綁定成功,您以後可以使用%(oauthuser_type)s來直接免密碼登錄本站啦,感謝"
+"您對本站對關註。"
+
+#: .\templates\account\forget_password.html:7
+msgid "forget the password"
+msgstr "忘記密碼"
+
+#: .\templates\account\forget_password.html:18
+msgid "get verification code"
+msgstr "獲取驗證碼"
+
+#: .\templates\account\forget_password.html:19
+msgid "submit"
+msgstr "提交"
+
+#: .\templates\account\login.html:36
+msgid "Create Account"
+msgstr "創建賬號"
+
+#: .\templates\account\login.html:42
+#, fuzzy
+#| msgid "forget the password"
+msgid "Forget Password"
+msgstr "忘記密碼"
+
+#: .\templates\account\result.html:18 .\templates\blog\tags\sidebar.html:126
+msgid "login"
+msgstr "登錄"
+
+#: .\templates\account\result.html:22
+msgid "back to the homepage"
+msgstr "返回首頁吧"
+
+#: .\templates\blog\article_archives.html:7
+#: .\templates\blog\article_archives.html:24
+msgid "article archive"
+msgstr "文章歸檔"
+
+#: .\templates\blog\article_archives.html:32
+msgid "year"
+msgstr "年"
+
+#: .\templates\blog\article_archives.html:36
+msgid "month"
+msgstr "月"
+
+#: .\templates\blog\tags\article_info.html:12
+msgid "pin to top"
+msgstr "置頂"
+
+#: .\templates\blog\tags\article_info.html:28
+msgid "comments"
+msgstr "評論"
+
+#: .\templates\blog\tags\article_info.html:58
+msgid "toc"
+msgstr "目錄"
+
+#: .\templates\blog\tags\article_meta_info.html:6
+msgid "posted in"
+msgstr "發布於"
+
+#: .\templates\blog\tags\article_meta_info.html:14
+msgid "and tagged"
+msgstr "並標記為"
+
+#: .\templates\blog\tags\article_meta_info.html:25
+msgid "by "
+msgstr "由"
+
+#: .\templates\blog\tags\article_meta_info.html:29
+#, python-format
+msgid ""
+"\n"
+" title=\"View all articles published by "
+"%(article.author.username)s\"\n"
+" "
+msgstr ""
+"\n"
+" title=\"查看所有由 %(article.author.username)s\"發布的文章\n"
+" "
+
+#: .\templates\blog\tags\article_meta_info.html:44
+msgid "on"
+msgstr "在"
+
+#: .\templates\blog\tags\article_meta_info.html:54
+msgid "edit"
+msgstr "編輯"
+
+#: .\templates\blog\tags\article_pagination.html:4
+msgid "article navigation"
+msgstr "文章導航"
+
+#: .\templates\blog\tags\article_pagination.html:9
+msgid "earlier articles"
+msgstr "早期文章"
+
+#: .\templates\blog\tags\article_pagination.html:12
+msgid "newer articles"
+msgstr "較新文章"
+
+#: .\templates\blog\tags\article_tag_list.html:5
+msgid "tags"
+msgstr "標簽"
+
+#: .\templates\blog\tags\sidebar.html:7
+msgid "search"
+msgstr "搜索"
+
+#: .\templates\blog\tags\sidebar.html:50
+msgid "recent comments"
+msgstr "近期評論"
+
+#: .\templates\blog\tags\sidebar.html:57
+msgid "published on"
+msgstr "發表於"
+
+#: .\templates\blog\tags\sidebar.html:65
+msgid "recent articles"
+msgstr "近期文章"
+
+#: .\templates\blog\tags\sidebar.html:77
+msgid "bookmark"
+msgstr "書簽"
+
+#: .\templates\blog\tags\sidebar.html:96
+msgid "Tag Cloud"
+msgstr "標簽雲"
+
+#: .\templates\blog\tags\sidebar.html:107
+msgid "Welcome to star or fork the source code of this site"
+msgstr "歡迎您STAR或者FORK本站源代碼"
+
+#: .\templates\blog\tags\sidebar.html:118
+msgid "Function"
+msgstr "功能"
+
+#: .\templates\blog\tags\sidebar.html:120
+msgid "management site"
+msgstr "管理站點"
+
+#: .\templates\blog\tags\sidebar.html:122
+msgid "logout"
+msgstr "登出"
+
+#: .\templates\blog\tags\sidebar.html:129
+msgid "Track record"
+msgstr "運動軌跡記錄"
+
+#: .\templates\blog\tags\sidebar.html:135
+msgid "Click me to return to the top"
+msgstr "點我返回頂部"
+
+#: .\templates\oauth\oauth_applications.html:5
+#| msgid "login"
+msgid "quick login"
+msgstr "快捷登錄"
+
+#: .\templates\share_layout\nav.html:26
+msgid "Article archive"
+msgstr "文章歸檔"
diff --git a/oauth/admin.py b/oauth/admin.py
index ec0e3c997..57eab5f52 100644
--- a/oauth/admin.py
+++ b/oauth/admin.py
@@ -9,17 +9,17 @@
class OAuthUserAdmin(admin.ModelAdmin):
- search_fields = ('nikename', 'email')
+ search_fields = ('nickname', 'email')
list_per_page = 20
list_display = (
'id',
- 'nikename',
+ 'nickname',
'link_to_usermodel',
'show_user_image',
'type',
'email',
)
- list_display_links = ('id', 'nikename')
+ list_display_links = ('id', 'nickname')
list_filter = ('author', 'type',)
readonly_fields = []
diff --git a/oauth/migrations/0001_initial.py b/oauth/migrations/0001_initial.py
new file mode 100644
index 000000000..3aa3e0317
--- /dev/null
+++ b/oauth/migrations/0001_initial.py
@@ -0,0 +1,57 @@
+# Generated by Django 4.1.7 on 2023-03-07 09:53
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='OAuthConfig',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('type', models.CharField(choices=[('weibo', '微博'), ('google', '谷歌'), ('github', 'GitHub'), ('facebook', 'FaceBook'), ('qq', 'QQ')], default='a', max_length=10, verbose_name='类型')),
+ ('appkey', models.CharField(max_length=200, verbose_name='AppKey')),
+ ('appsecret', models.CharField(max_length=200, verbose_name='AppSecret')),
+ ('callback_url', models.CharField(default='http://www.baidu.com', max_length=200, 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': 'oauth配置',
+ 'verbose_name_plural': 'oauth配置',
+ 'ordering': ['-created_time'],
+ },
+ ),
+ migrations.CreateModel(
+ name='OAuthUser',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('openid', models.CharField(max_length=50)),
+ ('nickname', models.CharField(max_length=50, verbose_name='昵称')),
+ ('token', models.CharField(blank=True, max_length=150, null=True)),
+ ('picture', models.CharField(blank=True, max_length=350, null=True)),
+ ('type', models.CharField(max_length=50)),
+ ('email', models.CharField(blank=True, max_length=50, null=True)),
+ ('metadata', models.TextField(blank=True, null=True)),
+ ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
+ ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
+ ('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='用户')),
+ ],
+ options={
+ 'verbose_name': 'oauth用户',
+ 'verbose_name_plural': 'oauth用户',
+ 'ordering': ['-created_time'],
+ },
+ ),
+ ]
diff --git a/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py b/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py
new file mode 100644
index 000000000..d5cc70ef2
--- /dev/null
+++ b/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py
@@ -0,0 +1,86 @@
+# 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
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('oauth', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='oauthconfig',
+ options={'ordering': ['-creation_time'], 'verbose_name': 'oauth配置', 'verbose_name_plural': 'oauth配置'},
+ ),
+ migrations.AlterModelOptions(
+ name='oauthuser',
+ options={'ordering': ['-creation_time'], 'verbose_name': 'oauth user', 'verbose_name_plural': 'oauth user'},
+ ),
+ migrations.RemoveField(
+ model_name='oauthconfig',
+ name='created_time',
+ ),
+ migrations.RemoveField(
+ model_name='oauthconfig',
+ name='last_mod_time',
+ ),
+ migrations.RemoveField(
+ model_name='oauthuser',
+ name='created_time',
+ ),
+ migrations.RemoveField(
+ model_name='oauthuser',
+ name='last_mod_time',
+ ),
+ migrations.AddField(
+ model_name='oauthconfig',
+ name='creation_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
+ ),
+ migrations.AddField(
+ model_name='oauthconfig',
+ name='last_modify_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
+ ),
+ migrations.AddField(
+ model_name='oauthuser',
+ name='creation_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
+ ),
+ migrations.AddField(
+ model_name='oauthuser',
+ name='last_modify_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
+ ),
+ migrations.AlterField(
+ model_name='oauthconfig',
+ name='callback_url',
+ field=models.CharField(default='', max_length=200, verbose_name='callback url'),
+ ),
+ migrations.AlterField(
+ model_name='oauthconfig',
+ name='is_enable',
+ field=models.BooleanField(default=True, verbose_name='is enable'),
+ ),
+ migrations.AlterField(
+ model_name='oauthconfig',
+ name='type',
+ field=models.CharField(choices=[('weibo', 'weibo'), ('google', 'google'), ('github', 'GitHub'), ('facebook', 'FaceBook'), ('qq', 'QQ')], default='a', max_length=10, verbose_name='type'),
+ ),
+ migrations.AlterField(
+ model_name='oauthuser',
+ name='author',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'),
+ ),
+ migrations.AlterField(
+ model_name='oauthuser',
+ name='nickname',
+ field=models.CharField(max_length=50, verbose_name='nickname'),
+ ),
+ ]
diff --git a/oauth/migrations/0003_alter_oauthuser_nickname.py b/oauth/migrations/0003_alter_oauthuser_nickname.py
new file mode 100644
index 000000000..6af08ebbd
--- /dev/null
+++ b/oauth/migrations/0003_alter_oauthuser_nickname.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.7 on 2024-01-26 02:41
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('oauth', '0002_alter_oauthconfig_options_alter_oauthuser_options_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='oauthuser',
+ name='nickname',
+ field=models.CharField(max_length=50, verbose_name='nick name'),
+ ),
+ ]
diff --git a/oauth/models.py b/oauth/models.py
index 08cdd6f28..be838edd5 100644
--- a/oauth/models.py
+++ b/oauth/models.py
@@ -9,55 +9,54 @@
class OAuthUser(models.Model):
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
- verbose_name='用户',
+ verbose_name=_('author'),
blank=True,
null=True,
on_delete=models.CASCADE)
openid = models.CharField(max_length=50)
- nikename = models.CharField(max_length=50, verbose_name='昵称')
+ nickname = models.CharField(max_length=50, verbose_name=_('nick name'))
token = models.CharField(max_length=150, null=True, blank=True)
picture = models.CharField(max_length=350, blank=True, null=True)
type = models.CharField(blank=False, null=False, max_length=50)
email = models.CharField(max_length=50, null=True, blank=True)
- matedata = models.TextField(null=True, blank=True)
- created_time = models.DateTimeField('创建时间', default=now)
- last_mod_time = models.DateTimeField('修改时间', default=now)
+ metadata = models.TextField(null=True, blank=True)
+ creation_time = models.DateTimeField(_('creation time'), default=now)
+ last_modify_time = models.DateTimeField(_('last modify time'), default=now)
def __str__(self):
- return self.nikename
+ return self.nickname
class Meta:
- verbose_name = 'oauth用户'
+ verbose_name = _('oauth user')
verbose_name_plural = verbose_name
- ordering = ['-created_time']
+ ordering = ['-creation_time']
class OAuthConfig(models.Model):
TYPE = (
- ('weibo', '微博'),
- ('google', '谷歌'),
+ ('weibo', _('weibo')),
+ ('google', _('google')),
('github', 'GitHub'),
('facebook', 'FaceBook'),
('qq', 'QQ'),
)
- type = models.CharField('类型', max_length=10, choices=TYPE, default='a')
+ type = models.CharField(_('type'), max_length=10, choices=TYPE, default='a')
appkey = models.CharField(max_length=200, verbose_name='AppKey')
appsecret = models.CharField(max_length=200, verbose_name='AppSecret')
callback_url = models.CharField(
max_length=200,
- verbose_name='回调地址',
+ verbose_name=_('callback url'),
blank=False,
- default='http://www.baidu.com')
+ default='')
is_enable = models.BooleanField(
- '是否显示', default=True, blank=False, null=False)
- created_time = models.DateTimeField('创建时间', default=now)
- last_mod_time = models.DateTimeField('修改时间', default=now)
+ _('is enable'), default=True, blank=False, null=False)
+ creation_time = models.DateTimeField(_('creation time'), default=now)
+ last_modify_time = models.DateTimeField(_('last modify time'), default=now)
def clean(self):
if OAuthConfig.objects.filter(
- type=self.type).exclude(
- id=self.id).count():
- raise ValidationError(_(self.type + '已经存在'))
+ type=self.type).exclude(id=self.id).count():
+ raise ValidationError(_(self.type + _('already exists')))
def __str__(self):
return self.type
@@ -65,4 +64,4 @@ def __str__(self):
class Meta:
verbose_name = 'oauth配置'
verbose_name_plural = verbose_name
- ordering = ['-created_time']
+ ordering = ['-creation_time']
diff --git a/oauth/oauthmanager.py b/oauth/oauthmanager.py
index 16dc55c29..2e7ceef2f 100644
--- a/oauth/oauthmanager.py
+++ b/oauth/oauthmanager.py
@@ -1,5 +1,6 @@
import json
import logging
+import os
import urllib.parse
from abc import ABCMeta, abstractmethod
@@ -51,6 +52,10 @@ def get_access_token_by_code(self, code):
def get_oauth_userinfo(self):
pass
+ @abstractmethod
+ def get_picture(self, metadata):
+ pass
+
def do_get(self, url, params, headers=None):
rsp = requests.get(url=url, params=params, headers=headers)
logger.info(rsp.text)
@@ -122,9 +127,9 @@ def get_oauth_userinfo(self):
try:
datas = json.loads(rsp)
user = OAuthUser()
- user.matedata = rsp
+ user.metadata = rsp
user.picture = datas['avatar_large']
- user.nikename = datas['screen_name']
+ user.nickname = datas['screen_name']
user.openid = datas['id']
user.type = 'weibo'
user.token = self.access_token
@@ -136,8 +141,33 @@ def get_oauth_userinfo(self):
logger.error('weibo oauth error.rsp:' + rsp)
return None
+ def get_picture(self, metadata):
+ datas = json.loads(metadata)
+ return datas['avatar_large']
+
+
+class ProxyManagerMixin:
+ def __init__(self, *args, **kwargs):
+ if os.environ.get("HTTP_PROXY"):
+ self.proxies = {
+ "http": os.environ.get("HTTP_PROXY"),
+ "https": os.environ.get("HTTP_PROXY")
+ }
+ else:
+ self.proxies = None
+
+ def do_get(self, url, params, headers=None):
+ rsp = requests.get(url=url, params=params, headers=headers, proxies=self.proxies)
+ logger.info(rsp.text)
+ return rsp.text
+
+ def do_post(self, url, params, headers=None):
+ rsp = requests.post(url, params, headers=headers, proxies=self.proxies)
+ logger.info(rsp.text)
+ return rsp.text
-class GoogleOauthManager(BaseOauthManager):
+
+class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager):
AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
TOKEN_URL = 'https://www.googleapis.com/oauth2/v4/token'
API_URL = 'https://www.googleapis.com/oauth2/v3/userinfo'
@@ -161,7 +191,6 @@ def get_authorization_url(self, nexturl='/'):
'redirect_uri': self.callback_url,
'scope': 'openid email',
}
- # url = self.AUTH_URL + "?" + urllib.parse.urlencode(params, quote_via=urllib.parse.quote)
url = self.AUTH_URL + "?" + urllib.parse.urlencode(params)
return url
@@ -197,9 +226,9 @@ def get_oauth_userinfo(self):
datas = json.loads(rsp)
user = OAuthUser()
- user.matedata = rsp
+ user.metadata = rsp
user.picture = datas['picture']
- user.nikename = datas['name']
+ user.nickname = datas['name']
user.openid = datas['sub']
user.token = self.access_token
user.type = 'google'
@@ -211,8 +240,12 @@ def get_oauth_userinfo(self):
logger.error('google oauth error.rsp:' + rsp)
return None
+ def get_picture(self, metadata):
+ datas = json.loads(metadata)
+ return datas['picture']
+
-class GitHubOauthManager(BaseOauthManager):
+class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager):
AUTH_URL = 'https://github.com/login/oauth/authorize'
TOKEN_URL = 'https://github.com/login/oauth/access_token'
API_URL = 'https://api.github.com/user'
@@ -229,14 +262,13 @@ def __init__(self, access_token=None, openid=None):
access_token=access_token,
openid=openid)
- def get_authorization_url(self, nexturl='/'):
+ def get_authorization_url(self, next_url='/'):
params = {
'client_id': self.client_id,
'response_type': 'code',
- 'redirect_uri': self.callback_url + '&next_url=' + nexturl,
+ 'redirect_uri': f'{self.callback_url}&next_url={next_url}',
'scope': 'user'
}
- # url = self.AUTH_URL + "?" + urllib.parse.urlencode(params, quote_via=urllib.parse.quote)
url = self.AUTH_URL + "?" + urllib.parse.urlencode(params)
return url
@@ -268,11 +300,11 @@ def get_oauth_userinfo(self):
datas = json.loads(rsp)
user = OAuthUser()
user.picture = datas['avatar_url']
- user.nikename = datas['name']
+ user.nickname = datas['name']
user.openid = datas['id']
user.type = 'github'
user.token = self.access_token
- user.matedata = rsp
+ user.metadata = rsp
if 'email' in datas and datas['email']:
user.email = datas['email']
return user
@@ -281,10 +313,14 @@ def get_oauth_userinfo(self):
logger.error('github oauth error.rsp:' + rsp)
return None
+ def get_picture(self, metadata):
+ datas = json.loads(metadata)
+ return datas['avatar_url']
-class FaceBookOauthManager(BaseOauthManager):
- AUTH_URL = 'https://www.facebook.com/v2.10/dialog/oauth'
- TOKEN_URL = 'https://graph.facebook.com/v2.10/oauth/access_token'
+
+class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager):
+ AUTH_URL = 'https://www.facebook.com/v16.0/dialog/oauth'
+ TOKEN_URL = 'https://graph.facebook.com/v16.0/oauth/access_token'
API_URL = 'https://graph.facebook.com/me'
ICON_NAME = 'facebook'
@@ -299,11 +335,11 @@ def __init__(self, access_token=None, openid=None):
access_token=access_token,
openid=openid)
- def get_authorization_url(self, nexturl='/'):
+ def get_authorization_url(self, next_url='/'):
params = {
'client_id': self.client_id,
'response_type': 'code',
- 'redirect_uri': self.callback_url, # + '&next_url=' + nexturl,
+ 'redirect_uri': self.callback_url,
'scope': 'email,public_profile'
}
url = self.AUTH_URL + "?" + urllib.parse.urlencode(params)
@@ -337,11 +373,11 @@ def get_oauth_userinfo(self):
rsp = self.do_get(self.API_URL, params)
datas = json.loads(rsp)
user = OAuthUser()
- user.nikename = datas['name']
+ user.nickname = datas['name']
user.openid = datas['id']
user.type = 'facebook'
user.token = self.access_token
- user.matedata = rsp
+ user.metadata = rsp
if 'email' in datas and datas['email']:
user.email = datas['email']
if 'picture' in datas and datas['picture'] and datas['picture']['data'] and datas['picture']['data']['url']:
@@ -351,6 +387,10 @@ def get_oauth_userinfo(self):
logger.error(e)
return None
+ def get_picture(self, metadata):
+ datas = json.loads(metadata)
+ return str(datas['picture']['data']['url'])
+
class QQOauthManager(BaseOauthManager):
AUTH_URL = 'https://graph.qq.com/oauth2.0/authorize'
@@ -370,11 +410,11 @@ def __init__(self, access_token=None, openid=None):
access_token=access_token,
openid=openid)
- def get_authorization_url(self, nexturl='/'):
+ def get_authorization_url(self, next_url='/'):
params = {
'response_type': 'code',
'client_id': self.client_id,
- 'redirect_uri': self.callback_url + '&next_url=' + nexturl,
+ 'redirect_uri': self.callback_url + '&next_url=' + next_url,
}
url = self.AUTH_URL + "?" + urllib.parse.urlencode(params)
return url
@@ -392,7 +432,7 @@ def get_access_token_by_code(self, code):
d = urllib.parse.parse_qs(rsp)
if 'access_token' in d:
token = d['access_token']
- self.access_token = token
+ self.access_token = token[0]
return token
else:
raise OAuthAccessTokenException(rsp)
@@ -425,17 +465,21 @@ def get_oauth_userinfo(self):
logger.info(rsp)
obj = json.loads(rsp)
user = OAuthUser()
- user.nikename = obj['nickname']
+ user.nickname = obj['nickname']
user.openid = openid
user.type = 'qq'
user.token = self.access_token
- user.matedata = rsp
+ user.metadata = rsp
if 'email' in obj:
user.email = obj['email']
if 'figureurl' in obj:
user.picture = str(obj['figureurl'])
return user
+ def get_picture(self, metadata):
+ datas = json.loads(metadata)
+ return str(datas['figureurl'])
+
@cache_decorator(expiration=100 * 60)
def get_oauth_apps():
diff --git a/oauth/tests.py b/oauth/tests.py
index c8fcfc87b..bb23b9bab 100644
--- a/oauth/tests.py
+++ b/oauth/tests.py
@@ -1,13 +1,249 @@
-from django.test import TestCase
+import json
+from unittest.mock import patch
-from .models import OAuthConfig
+from django.conf import settings
+from django.contrib import auth
+from django.test import Client, RequestFactory, TestCase
+from django.urls import reverse
+
+from djangoblog.utils import get_sha256
+from oauth.models import OAuthConfig
+from oauth.oauthmanager import BaseOauthManager
# Create your tests here.
class OAuthConfigTest(TestCase):
- def config_save_test(self):
+ def setUp(self):
+ self.client = Client()
+ self.factory = RequestFactory()
+
+ def test_oauth_login_test(self):
c = OAuthConfig()
c.type = 'weibo'
c.appkey = 'appkey'
c.appsecret = 'appsecret'
c.save()
+
+ response = self.client.get('/oauth/oauthlogin?type=weibo')
+ self.assertEqual(response.status_code, 302)
+ self.assertTrue("api.weibo.com" in response.url)
+
+ response = self.client.get('/oauth/authorize?type=weibo&code=code')
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, '/')
+
+
+class OauthLoginTest(TestCase):
+ def setUp(self) -> None:
+ self.client = Client()
+ self.factory = RequestFactory()
+ self.apps = self.init_apps()
+
+ def init_apps(self):
+ applications = [p() for p in BaseOauthManager.__subclasses__()]
+ for application in applications:
+ c = OAuthConfig()
+ c.type = application.ICON_NAME.lower()
+ c.appkey = 'appkey'
+ c.appsecret = 'appsecret'
+ c.save()
+ return applications
+
+ def get_app_by_type(self, type):
+ for app in self.apps:
+ if app.ICON_NAME.lower() == type:
+ return app
+
+ @patch("oauth.oauthmanager.WBOauthManager.do_post")
+ @patch("oauth.oauthmanager.WBOauthManager.do_get")
+ def test_weibo_login(self, mock_do_get, mock_do_post):
+ weibo_app = self.get_app_by_type('weibo')
+ assert weibo_app
+ url = weibo_app.get_authorization_url()
+ mock_do_post.return_value = json.dumps({"access_token": "access_token",
+ "uid": "uid"
+ })
+ mock_do_get.return_value = json.dumps({
+ "avatar_large": "avatar_large",
+ "screen_name": "screen_name",
+ "id": "id",
+ "email": "email",
+ })
+ userinfo = weibo_app.get_access_token_by_code('code')
+ self.assertEqual(userinfo.token, 'access_token')
+ self.assertEqual(userinfo.openid, 'id')
+
+ @patch("oauth.oauthmanager.GoogleOauthManager.do_post")
+ @patch("oauth.oauthmanager.GoogleOauthManager.do_get")
+ def test_google_login(self, mock_do_get, mock_do_post):
+ google_app = self.get_app_by_type('google')
+ assert google_app
+ url = google_app.get_authorization_url()
+ mock_do_post.return_value = json.dumps({
+ "access_token": "access_token",
+ "id_token": "id_token",
+ })
+ mock_do_get.return_value = json.dumps({
+ "picture": "picture",
+ "name": "name",
+ "sub": "sub",
+ "email": "email",
+ })
+ token = google_app.get_access_token_by_code('code')
+ userinfo = google_app.get_oauth_userinfo()
+ self.assertEqual(userinfo.token, 'access_token')
+ self.assertEqual(userinfo.openid, 'sub')
+
+ @patch("oauth.oauthmanager.GitHubOauthManager.do_post")
+ @patch("oauth.oauthmanager.GitHubOauthManager.do_get")
+ def test_github_login(self, mock_do_get, mock_do_post):
+ github_app = self.get_app_by_type('github')
+ assert github_app
+ url = github_app.get_authorization_url()
+ self.assertTrue("github.com" in url)
+ self.assertTrue("client_id" in url)
+ mock_do_post.return_value = "access_token=gho_16C7e42F292c6912E7710c838347Ae178B4a&scope=repo%2Cgist&token_type=bearer"
+ mock_do_get.return_value = json.dumps({
+ "avatar_url": "avatar_url",
+ "name": "name",
+ "id": "id",
+ "email": "email",
+ })
+ token = github_app.get_access_token_by_code('code')
+ userinfo = github_app.get_oauth_userinfo()
+ self.assertEqual(userinfo.token, 'gho_16C7e42F292c6912E7710c838347Ae178B4a')
+ self.assertEqual(userinfo.openid, 'id')
+
+ @patch("oauth.oauthmanager.FaceBookOauthManager.do_post")
+ @patch("oauth.oauthmanager.FaceBookOauthManager.do_get")
+ def test_facebook_login(self, mock_do_get, mock_do_post):
+ facebook_app = self.get_app_by_type('facebook')
+ assert facebook_app
+ url = facebook_app.get_authorization_url()
+ self.assertTrue("facebook.com" in url)
+ mock_do_post.return_value = json.dumps({
+ "access_token": "access_token",
+ })
+ mock_do_get.return_value = json.dumps({
+ "name": "name",
+ "id": "id",
+ "email": "email",
+ "picture": {
+ "data": {
+ "url": "url"
+ }
+ }
+ })
+ token = facebook_app.get_access_token_by_code('code')
+ userinfo = facebook_app.get_oauth_userinfo()
+ self.assertEqual(userinfo.token, 'access_token')
+
+ @patch("oauth.oauthmanager.QQOauthManager.do_get", side_effect=[
+ 'access_token=access_token&expires_in=3600',
+ 'callback({"client_id":"appid","openid":"openid"} );',
+ json.dumps({
+ "nickname": "nickname",
+ "email": "email",
+ "figureurl": "figureurl",
+ "openid": "openid",
+ })
+ ])
+ def test_qq_login(self, mock_do_get):
+ qq_app = self.get_app_by_type('qq')
+ assert qq_app
+ url = qq_app.get_authorization_url()
+ self.assertTrue("qq.com" in url)
+ token = qq_app.get_access_token_by_code('code')
+ userinfo = qq_app.get_oauth_userinfo()
+ self.assertEqual(userinfo.token, 'access_token')
+
+ @patch("oauth.oauthmanager.WBOauthManager.do_post")
+ @patch("oauth.oauthmanager.WBOauthManager.do_get")
+ def test_weibo_authoriz_login_with_email(self, mock_do_get, mock_do_post):
+
+ mock_do_post.return_value = json.dumps({"access_token": "access_token",
+ "uid": "uid"
+ })
+ mock_user_info = {
+ "avatar_large": "avatar_large",
+ "screen_name": "screen_name1",
+ "id": "id",
+ "email": "email",
+ }
+ mock_do_get.return_value = json.dumps(mock_user_info)
+
+ response = self.client.get('/oauth/oauthlogin?type=weibo')
+ self.assertEqual(response.status_code, 302)
+ self.assertTrue("api.weibo.com" in response.url)
+
+ response = self.client.get('/oauth/authorize?type=weibo&code=code')
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, '/')
+
+ user = auth.get_user(self.client)
+ assert user.is_authenticated
+ self.assertTrue(user.is_authenticated)
+ self.assertEqual(user.username, mock_user_info['screen_name'])
+ self.assertEqual(user.email, mock_user_info['email'])
+ self.client.logout()
+
+ response = self.client.get('/oauth/authorize?type=weibo&code=code')
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, '/')
+
+ user = auth.get_user(self.client)
+ assert user.is_authenticated
+ self.assertTrue(user.is_authenticated)
+ self.assertEqual(user.username, mock_user_info['screen_name'])
+ self.assertEqual(user.email, mock_user_info['email'])
+
+ @patch("oauth.oauthmanager.WBOauthManager.do_post")
+ @patch("oauth.oauthmanager.WBOauthManager.do_get")
+ def test_weibo_authoriz_login_without_email(self, mock_do_get, mock_do_post):
+
+ mock_do_post.return_value = json.dumps({"access_token": "access_token",
+ "uid": "uid"
+ })
+ mock_user_info = {
+ "avatar_large": "avatar_large",
+ "screen_name": "screen_name1",
+ "id": "id",
+ }
+ mock_do_get.return_value = json.dumps(mock_user_info)
+
+ response = self.client.get('/oauth/oauthlogin?type=weibo')
+ self.assertEqual(response.status_code, 302)
+ self.assertTrue("api.weibo.com" in response.url)
+
+ response = self.client.get('/oauth/authorize?type=weibo&code=code')
+
+ self.assertEqual(response.status_code, 302)
+
+ oauth_user_id = int(response.url.split('/')[-1].split('.')[0])
+ self.assertEqual(response.url, f'/oauth/requireemail/{oauth_user_id}.html')
+
+ response = self.client.post(response.url, {'email': 'test@gmail.com', 'oauthid': oauth_user_id})
+
+ self.assertEqual(response.status_code, 302)
+ sign = get_sha256(settings.SECRET_KEY +
+ str(oauth_user_id) + settings.SECRET_KEY)
+
+ url = reverse('oauth:bindsuccess', kwargs={
+ 'oauthid': oauth_user_id,
+ })
+ self.assertEqual(response.url, f'{url}?type=email')
+
+ path = reverse('oauth:email_confirm', kwargs={
+ 'id': oauth_user_id,
+ 'sign': sign
+ })
+ response = self.client.get(path)
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, f'/oauth/bindsuccess/{oauth_user_id}.html?type=success')
+ user = auth.get_user(self.client)
+ from oauth.models import OAuthUser
+ oauth_user = OAuthUser.objects.get(author=user)
+ self.assertTrue(user.is_authenticated)
+ self.assertEqual(user.username, mock_user_info['screen_name'])
+ self.assertEqual(user.email, 'test@gmail.com')
+ self.assertEqual(oauth_user.pk, oauth_user_id)
diff --git a/oauth/views.py b/oauth/views.py
index fdfb542c7..12e3a6ea1 100644
--- a/oauth/views.py
+++ b/oauth/views.py
@@ -1,4 +1,3 @@
-import datetime
import logging
# Create your views here.
from urllib.parse import urlparse
@@ -13,6 +12,8 @@
from django.shortcuts import get_object_or_404
from django.shortcuts import render
from django.urls import reverse
+from django.utils import timezone
+from django.utils.translation import gettext_lazy as _
from django.views.generic import FormView
from djangoblog.blog_signals import oauth_user_login_signal
@@ -72,14 +73,13 @@ def authorize(request):
return HttpResponseRedirect(manager.get_authorization_url(nexturl))
user = manager.get_oauth_userinfo()
if user:
- if not user.nikename or not user.nikename.strip():
- import datetime
- user.nikename = "djangoblog" + datetime.datetime.now().strftime('%y%m%d%I%M%S')
+ if not user.nickname or not user.nickname.strip():
+ user.nickname = "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S')
try:
temp = OAuthUser.objects.get(type=type, openid=user.openid)
temp.picture = user.picture
- temp.matedata = user.matedata
- temp.nikename = user.nikename
+ temp.metadata = user.metadata
+ temp.nickname = user.nickname
user = temp
except ObjectDoesNotExist:
pass
@@ -98,11 +98,11 @@ def authorize(request):
author = result[0]
if result[1]:
try:
- get_user_model().objects.get(username=user.nikename)
+ get_user_model().objects.get(username=user.nickname)
except ObjectDoesNotExist:
- author.username = user.nikename
+ author.username = user.nickname
else:
- author.username = "djangoblog" + datetime.datetime.now().strftime('%y%m%d%I%M%S')
+ author.username = "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S')
author.source = 'authorize'
author.save()
@@ -140,8 +140,8 @@ def emailconfirm(request, id, sign):
author = result[0]
if result[1]:
author.source = 'emailconfirm'
- author.username = oauthuser.nikename.strip() if oauthuser.nikename.strip(
- ) else "djangoblog" + datetime.datetime.now().strftime('%y%m%d%I%M%S')
+ author.username = oauthuser.nickname.strip() if oauthuser.nickname.strip(
+ ) else "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S')
author.save()
oauthuser.author = author
oauthuser.save()
@@ -150,19 +150,19 @@ def emailconfirm(request, id, sign):
id=oauthuser.id)
login(request, author)
- site = get_current_site().domain
- content = '''
- 恭喜您,您已经成功绑定您的邮箱,您可以使用{type}来直接免密码登录本网站.欢迎您继续关注本站,地址是
-
- {url}
-
- 再次感谢您!
-
- 如果上面链接无法打开,请将此链接复制至浏览器。
- {url}
- '''.format(type=oauthuser.type, url='http://' + site)
-
- send_email(emailto=[oauthuser.email, ], title='恭喜您绑定成功!', content=content)
+ site = 'http://' + get_current_site().domain
+ content = _('''
+ Congratulations, you have successfully bound your email address. You can use
+ %(oauthuser_type)s to directly log in to this website without a password.
+ You are welcome to continue to follow this site, the address is
+ %(site)s
+ Thank you again!
+
+ If the link above cannot be opened, please copy this link to your browser.
+ %(site)s
+ ''') % {'oauthuser_type': oauthuser.type, 'site': site}
+
+ send_email(emailto=[oauthuser.email, ], title=_('Congratulations on your successful binding!'), content=content)
url = reverse('oauth:bindsuccess', kwargs={
'oauthid': id
})
@@ -214,17 +214,18 @@ def form_valid(self, form):
})
url = "http://{site}{path}".format(site=site, path=path)
- content = """
- 请点击下面链接绑定您的邮箱
+ content = _("""
+ Please click the link below to bind your email
- {url}
+ %(url)s
- 再次感谢您!
-
- 如果上面链接无法打开,请将此链接复制至浏览器。
- {url}
- """.format(url=url)
- send_email(emailto=[email, ], title='绑定您的电子邮箱', content=content)
+ Thank you again!
+
+ If the link above cannot be opened, please copy this link to your browser.
+
+ %(url)s
+ """) % {'url': url}
+ send_email(emailto=[email, ], title=_('Bind your email'), content=content)
url = reverse('oauth:bindsuccess', kwargs={
'oauthid': oauthid
})
@@ -236,12 +237,16 @@ def bindsuccess(request, oauthid):
type = request.GET.get('type', None)
oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
if type == 'email':
- title = '绑定成功'
- content = "恭喜您,还差一步就绑定成功了,请登录您的邮箱查看邮件完成绑定,谢谢。"
+ title = _('Bind your email')
+ content = _(
+ 'Congratulations, the binding is just one step away. '
+ 'Please log in to your email to check the email to complete the binding. Thank you.')
else:
- title = '绑定成功'
- content = "恭喜您绑定成功,您以后可以使用{type}来直接免密码登录本站啦,感谢您对本站对关注。".format(
- type=oauthuser.type)
+ title = _('Binding successful')
+ content = _(
+ "Congratulations, you have successfully bound your email address. You can use %(oauthuser_type)s"
+ " to directly log in to this website without a password. You are welcome to continue to follow this site." % {
+ 'oauthuser_type': oauthuser.type})
return render(request, 'oauth/bindsuccess.html', {
'title': title,
'content': content
diff --git a/owntracks/migrations/0001_initial.py b/owntracks/migrations/0001_initial.py
new file mode 100644
index 000000000..9eee55c06
--- /dev/null
+++ b/owntracks/migrations/0001_initial.py
@@ -0,0 +1,31 @@
+# Generated by Django 4.1.7 on 2023-03-02 07:14
+
+from django.db import migrations, models
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='OwnTrackLog',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('tid', models.CharField(max_length=100, verbose_name='用户')),
+ ('lat', models.FloatField(verbose_name='纬度')),
+ ('lon', models.FloatField(verbose_name='经度')),
+ ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
+ ],
+ options={
+ 'verbose_name': 'OwnTrackLogs',
+ 'verbose_name_plural': 'OwnTrackLogs',
+ 'ordering': ['created_time'],
+ 'get_latest_by': 'created_time',
+ },
+ ),
+ ]
diff --git a/owntracks/migrations/0002_alter_owntracklog_options_and_more.py b/owntracks/migrations/0002_alter_owntracklog_options_and_more.py
new file mode 100644
index 000000000..b4f8decc5
--- /dev/null
+++ b/owntracks/migrations/0002_alter_owntracklog_options_and_more.py
@@ -0,0 +1,22 @@
+# Generated by Django 4.2.5 on 2023-09-06 13:19
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('owntracks', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='owntracklog',
+ options={'get_latest_by': 'creation_time', 'ordering': ['creation_time'], 'verbose_name': 'OwnTrackLogs', 'verbose_name_plural': 'OwnTrackLogs'},
+ ),
+ migrations.RenameField(
+ model_name='owntracklog',
+ old_name='created_time',
+ new_name='creation_time',
+ ),
+ ]
diff --git a/owntracks/models.py b/owntracks/models.py
index 30f6ec17e..760942c66 100644
--- a/owntracks/models.py
+++ b/owntracks/models.py
@@ -8,13 +8,13 @@ class OwnTrackLog(models.Model):
tid = models.CharField(max_length=100, null=False, verbose_name='用户')
lat = models.FloatField(verbose_name='纬度')
lon = models.FloatField(verbose_name='经度')
- created_time = models.DateTimeField('创建时间', default=now)
+ creation_time = models.DateTimeField('创建时间', default=now)
def __str__(self):
return self.tid
class Meta:
- ordering = ['created_time']
+ ordering = ['creation_time']
verbose_name = "OwnTrackLogs"
verbose_name_plural = verbose_name
- get_latest_by = 'created_time'
+ get_latest_by = 'creation_time'
diff --git a/owntracks/tests.py b/owntracks/tests.py
index 1ad14fc88..3b4b9d8f1 100644
--- a/owntracks/tests.py
+++ b/owntracks/tests.py
@@ -58,7 +58,7 @@ def test_own_track_log(self):
self.assertEqual(rsp.status_code, 200)
rsp = self.client.get('/owntracks/show_maps')
self.assertEqual(rsp.status_code, 200)
- # rsp = self.client.get('/owntracks/get_datas')
- # self.assertEqual(rsp.status_code, 200)
- # rsp = self.client.get('/owntracks/get_datas?date=2018-02-26')
- # self.assertEqual(rsp.status_code, 200)
+ rsp = self.client.get('/owntracks/get_datas')
+ self.assertEqual(rsp.status_code, 200)
+ rsp = self.client.get('/owntracks/get_datas?date=2018-02-26')
+ self.assertEqual(rsp.status_code, 200)
diff --git a/owntracks/views.py b/owntracks/views.py
index 65bfd3e1b..4c72bdd11 100644
--- a/owntracks/views.py
+++ b/owntracks/views.py
@@ -3,8 +3,10 @@
import itertools
import json
import logging
+from datetime import timezone
from itertools import groupby
+import django
import requests
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
@@ -45,7 +47,7 @@ def manage_owntrack_log(request):
@login_required
def show_maps(request):
if request.user.is_superuser:
- defaultdate = str(datetime.datetime.now().date())
+ defaultdate = str(datetime.datetime.now(timezone.utc).date())
date = request.GET.get('date', defaultdate)
context = {
'date': date
@@ -58,7 +60,7 @@ def show_maps(request):
@login_required
def show_log_dates(request):
- dates = OwnTrackLog.objects.values_list('created_time', flat=True)
+ dates = OwnTrackLog.objects.values_list('creation_time', flat=True)
results = list(sorted(set(map(lambda x: x.strftime('%Y-%m-%d'), dates))))
context = {
@@ -85,7 +87,8 @@ def convert_to_amap(locations):
}
rsp = requests.get(url=api, params=query)
result = json.loads(rsp.text)
- convert_result.append(result['locations'])
+ if "locations" in result:
+ convert_result.append(result['locations'])
item = list(itertools.islice(it, 30))
return ";".join(convert_result)
@@ -93,20 +96,16 @@ def convert_to_amap(locations):
@login_required
def get_datas(request):
- import django.utils.timezone
- from django.utils.timezone import utc
-
- now = django.utils.timezone.now().replace(tzinfo=utc)
+ now = django.utils.timezone.now().replace(tzinfo=timezone.utc)
querydate = django.utils.timezone.datetime(
now.year, now.month, now.day, 0, 0, 0)
if request.GET.get('date', None):
date = list(map(lambda x: int(x), request.GET.get('date').split('-')))
querydate = django.utils.timezone.datetime(
date[0], date[1], date[2], 0, 0, 0)
- querydate = django.utils.timezone.make_aware(querydate)
nextdate = querydate + datetime.timedelta(days=1)
models = OwnTrackLog.objects.filter(
- created_time__range=(querydate, nextdate))
+ creation_time__range=(querydate, nextdate))
result = list()
if models and len(models):
for tid, item in groupby(
@@ -115,10 +114,14 @@ def get_datas(request):
d = dict()
d["name"] = tid
paths = list()
- locations = convert_to_amap(
- sorted(item, key=lambda x: x.created_time))
- for i in locations.split(';'):
- paths.append(i.split(','))
+ # 使用高德转换后的经纬度
+ # locations = convert_to_amap(
+ # sorted(item, key=lambda x: x.creation_time))
+ # for i in locations.split(';'):
+ # paths.append(i.split(','))
+ # 使用GPS原始经纬度
+ for location in sorted(item, key=lambda x: x.creation_time):
+ paths.append([str(location.lon), str(location.lat)])
d["path"] = paths
result.append(d)
return JsonResponse(result, safe=False)
diff --git a/plugins/__init__.py b/plugins/__init__.py
new file mode 100644
index 000000000..e88afca29
--- /dev/null
+++ b/plugins/__init__.py
@@ -0,0 +1 @@
+# This file makes this a Python package
diff --git a/plugins/article_copyright/__init__.py b/plugins/article_copyright/__init__.py
new file mode 100644
index 000000000..e88afca29
--- /dev/null
+++ b/plugins/article_copyright/__init__.py
@@ -0,0 +1 @@
+# This file makes this a Python package
diff --git a/plugins/article_copyright/plugin.py b/plugins/article_copyright/plugin.py
new file mode 100644
index 000000000..5dba3b3db
--- /dev/null
+++ b/plugins/article_copyright/plugin.py
@@ -0,0 +1,37 @@
+from djangoblog.plugin_manage.base_plugin import BasePlugin
+from djangoblog.plugin_manage import hooks
+from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
+
+
+class ArticleCopyrightPlugin(BasePlugin):
+ PLUGIN_NAME = '文章结尾版权声明'
+ PLUGIN_DESCRIPTION = '一个在文章正文末尾添加版权声明的插件。'
+ PLUGIN_VERSION = '0.2.0'
+ PLUGIN_AUTHOR = 'liangliangyy'
+
+ # 2. 实现 register_hooks 方法,专门用于注册钩子
+ def register_hooks(self):
+ # 在这里将插件的方法注册到指定的钩子上
+ hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.add_copyright_to_content)
+
+ def add_copyright_to_content(self, content, *args, **kwargs):
+ """
+ 这个方法会被注册到 'the_content' 过滤器钩子上。
+ 它接收原始内容,并返回添加了版权信息的新内容。
+ """
+ article = kwargs.get('article')
+ if not article:
+ return content
+
+ # 如果是摘要模式(首页),不添加版权声明
+ is_summary = kwargs.get('is_summary', False)
+ if is_summary:
+ return content
+
+ copyright_info = f"\n本文由 {article.author.username} 原创,转载请注明出处。
"
+ return content + copyright_info
+
+
+# 3. 实例化插件。
+# 这会自动调用 BasePlugin.__init__,然后 BasePlugin.__init__ 会调用我们上面定义的 register_hooks 方法。
+plugin = ArticleCopyrightPlugin()
diff --git a/plugins/article_recommendation/__init__.py b/plugins/article_recommendation/__init__.py
new file mode 100644
index 000000000..951f2ffe0
--- /dev/null
+++ b/plugins/article_recommendation/__init__.py
@@ -0,0 +1 @@
+# 文章推荐插件
diff --git a/plugins/article_recommendation/plugin.py b/plugins/article_recommendation/plugin.py
new file mode 100644
index 000000000..6656a07c2
--- /dev/null
+++ b/plugins/article_recommendation/plugin.py
@@ -0,0 +1,205 @@
+import logging
+from djangoblog.plugin_manage.base_plugin import BasePlugin
+from djangoblog.plugin_manage import hooks
+from djangoblog.plugin_manage.hook_constants import ARTICLE_DETAIL_LOAD
+from blog.models import Article
+
+logger = logging.getLogger(__name__)
+
+
+class ArticleRecommendationPlugin(BasePlugin):
+ PLUGIN_NAME = '文章推荐'
+ PLUGIN_DESCRIPTION = '智能文章推荐系统,支持多位置展示'
+ PLUGIN_VERSION = '1.0.0'
+ PLUGIN_AUTHOR = 'liangliangyy'
+
+ # 支持的位置
+ SUPPORTED_POSITIONS = ['article_bottom']
+
+ # 各位置优先级
+ POSITION_PRIORITIES = {
+ 'article_bottom': 80, # 文章底部优先级
+ }
+
+ # 插件配置
+ CONFIG = {
+ 'article_bottom_count': 8, # 文章底部推荐数量
+ 'sidebar_count': 5, # 侧边栏推荐数量
+ 'enable_category_fallback': True, # 启用分类回退
+ 'enable_popular_fallback': True, # 启用热门文章回退
+ }
+
+ def register_hooks(self):
+ """注册钩子"""
+ hooks.register(ARTICLE_DETAIL_LOAD, self.on_article_detail_load)
+
+ def on_article_detail_load(self, article, context, request, *args, **kwargs):
+ """文章详情页加载时的处理"""
+ # 可以在这里预加载推荐数据到context中
+ recommendations = self.get_recommendations(article)
+ context['article_recommendations'] = recommendations
+
+ def should_display(self, position, context, **kwargs):
+ """条件显示逻辑"""
+ # 只在文章详情页底部显示
+ if position == 'article_bottom':
+ article = kwargs.get('article') or context.get('article')
+ # 检查是否有文章对象,以及是否不是索引页面
+ is_index = context.get('isindex', False) if hasattr(context, 'get') else False
+ return article is not None and not is_index
+
+ return False
+
+ def render_article_bottom_widget(self, context, **kwargs):
+ """渲染文章底部推荐"""
+ article = kwargs.get('article') or context.get('article')
+ if not article:
+ return None
+
+ # 使用配置的数量,也可以通过kwargs覆盖
+ count = kwargs.get('count', self.CONFIG['article_bottom_count'])
+ recommendations = self.get_recommendations(article, count=count)
+ if not recommendations:
+ return None
+
+ # 将RequestContext转换为普通字典
+ context_dict = {}
+ if hasattr(context, 'flatten'):
+ context_dict = context.flatten()
+ elif hasattr(context, 'dicts'):
+ # 合并所有上下文字典
+ for d in context.dicts:
+ context_dict.update(d)
+
+ template_context = {
+ 'recommendations': recommendations,
+ 'article': article,
+ 'title': '相关推荐',
+ **context_dict
+ }
+
+ return self.render_template('bottom_widget.html', template_context)
+
+ def render_sidebar_widget(self, context, **kwargs):
+ """渲染侧边栏推荐"""
+ article = context.get('article')
+
+ # 使用配置的数量,也可以通过kwargs覆盖
+ count = kwargs.get('count', self.CONFIG['sidebar_count'])
+
+ if article:
+ # 文章页面,显示相关文章
+ recommendations = self.get_recommendations(article, count=count)
+ title = '相关文章'
+ else:
+ # 其他页面,显示热门文章
+ recommendations = self.get_popular_articles(count=count)
+ title = '热门推荐'
+
+ if not recommendations:
+ return None
+
+ # 将RequestContext转换为普通字典
+ context_dict = {}
+ if hasattr(context, 'flatten'):
+ context_dict = context.flatten()
+ elif hasattr(context, 'dicts'):
+ # 合并所有上下文字典
+ for d in context.dicts:
+ context_dict.update(d)
+
+ template_context = {
+ 'recommendations': recommendations,
+ 'title': title,
+ **context_dict
+ }
+
+ return self.render_template('sidebar_widget.html', template_context)
+
+ def get_css_files(self):
+ """返回CSS文件"""
+ return ['css/recommendation.css']
+
+ def get_js_files(self):
+ """返回JS文件"""
+ return ['js/recommendation.js']
+
+ def get_recommendations(self, article, count=5):
+ """获取推荐文章"""
+ if not article:
+ return []
+
+ recommendations = []
+
+ # 1. 基于标签的推荐
+ if article.tags.exists():
+ tag_ids = list(article.tags.values_list('id', flat=True))
+ tag_based = list(Article.objects.filter(
+ status='p',
+ tags__id__in=tag_ids
+ ).exclude(
+ id=article.id
+ ).exclude(
+ title__isnull=True
+ ).exclude(
+ title__exact=''
+ ).distinct().order_by('-views')[:count])
+ recommendations.extend(tag_based)
+
+ # 2. 如果数量不够,基于分类推荐
+ if len(recommendations) < count and self.CONFIG['enable_category_fallback']:
+ needed = count - len(recommendations)
+ existing_ids = [r.id for r in recommendations] + [article.id]
+
+ category_based = list(Article.objects.filter(
+ status='p',
+ category=article.category
+ ).exclude(
+ id__in=existing_ids
+ ).exclude(
+ title__isnull=True
+ ).exclude(
+ title__exact=''
+ ).order_by('-views')[:needed])
+ recommendations.extend(category_based)
+
+ # 3. 如果还是不够,推荐热门文章
+ if len(recommendations) < count and self.CONFIG['enable_popular_fallback']:
+ needed = count - len(recommendations)
+ existing_ids = [r.id for r in recommendations] + [article.id]
+
+ popular_articles = list(Article.objects.filter(
+ status='p'
+ ).exclude(
+ id__in=existing_ids
+ ).exclude(
+ title__isnull=True
+ ).exclude(
+ title__exact=''
+ ).order_by('-views')[:needed])
+ recommendations.extend(popular_articles)
+
+ # 过滤掉无效的推荐
+ valid_recommendations = []
+ for rec in recommendations:
+ if rec.title and len(rec.title.strip()) > 0:
+ valid_recommendations.append(rec)
+ else:
+ logger.warning(f"过滤掉空标题文章: ID={rec.id}, 标题='{rec.title}'")
+
+ # 调试:记录推荐结果
+ logger.info(f"原始推荐数量: {len(recommendations)}, 有效推荐数量: {len(valid_recommendations)}")
+ for i, rec in enumerate(valid_recommendations):
+ logger.info(f"推荐 {i+1}: ID={rec.id}, 标题='{rec.title}', 长度={len(rec.title)}")
+
+ return valid_recommendations[:count]
+
+ def get_popular_articles(self, count=3):
+ """获取热门文章"""
+ return list(Article.objects.filter(
+ status='p'
+ ).order_by('-views')[:count])
+
+
+# 实例化插件
+plugin = ArticleRecommendationPlugin()
diff --git a/plugins/article_recommendation/static/article_recommendation/css/recommendation.css b/plugins/article_recommendation/static/article_recommendation/css/recommendation.css
new file mode 100644
index 000000000..b223f4189
--- /dev/null
+++ b/plugins/article_recommendation/static/article_recommendation/css/recommendation.css
@@ -0,0 +1,166 @@
+/* 文章推荐插件样式 - 与网站风格保持一致 */
+
+/* 文章底部推荐样式 */
+.article-recommendations {
+ margin: 30px 0;
+ padding: 20px;
+ background: #fff;
+ border: 1px solid #e1e1e1;
+ border-radius: 3px;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+
+.recommendations-title {
+ margin: 0 0 15px 0;
+ font-size: 18px;
+ color: #444;
+ font-weight: bold;
+ padding-bottom: 8px;
+ border-bottom: 2px solid #21759b;
+ display: inline-block;
+}
+
+.recommendations-icon {
+ margin-right: 5px;
+ font-size: 16px;
+}
+
+.recommendations-grid {
+ display: grid;
+ gap: 15px;
+ grid-template-columns: 1fr;
+ margin-top: 15px;
+}
+
+.recommendation-card {
+ background: #fff;
+ border: 1px solid #e1e1e1;
+ border-radius: 3px;
+ transition: all 0.2s ease;
+ overflow: hidden;
+}
+
+.recommendation-card:hover {
+ border-color: #21759b;
+ box-shadow: 0 2px 5px rgba(33, 117, 155, 0.1);
+}
+
+.recommendation-link {
+ display: block;
+ padding: 15px;
+ text-decoration: none;
+ color: inherit;
+}
+
+.recommendation-title {
+ margin: 0 0 8px 0;
+ font-size: 15px;
+ font-weight: normal;
+ color: #444;
+ line-height: 1.4;
+ transition: color 0.2s ease;
+}
+
+.recommendation-card:hover .recommendation-title {
+ color: #21759b;
+}
+
+.recommendation-meta {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 12px;
+ color: #757575;
+}
+
+.recommendation-category {
+ background: #ebebeb;
+ color: #5e5e5e;
+ padding: 2px 6px;
+ border-radius: 2px;
+ font-size: 11px;
+ font-weight: normal;
+}
+
+.recommendation-date {
+ font-weight: normal;
+ color: #757575;
+}
+
+/* 侧边栏推荐样式 */
+.widget_recommendations {
+ margin-bottom: 20px;
+}
+
+.widget_recommendations .widget-title {
+ font-size: 16px;
+ font-weight: bold;
+ margin-bottom: 15px;
+ color: #333;
+ border-bottom: 2px solid #007cba;
+ padding-bottom: 5px;
+}
+
+.recommendations-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+.recommendations-list .recommendation-item {
+ padding: 8px 0;
+ border-bottom: 1px solid #eee;
+ background: none;
+ border: none;
+ border-radius: 0;
+}
+
+.recommendations-list .recommendation-item:last-child {
+ border-bottom: none;
+}
+
+.recommendations-list .recommendation-item a {
+ color: #333;
+ text-decoration: none;
+ font-size: 14px;
+ line-height: 1.4;
+ display: block;
+ margin-bottom: 4px;
+ transition: color 0.3s ease;
+}
+
+.recommendations-list .recommendation-item a:hover {
+ color: #007cba;
+}
+
+.recommendations-list .recommendation-meta {
+ font-size: 11px;
+ color: #999;
+ margin: 0;
+}
+
+.recommendations-list .recommendation-meta span {
+ margin-right: 10px;
+}
+
+/* 响应式设计 - 分栏显示 */
+@media (min-width: 768px) {
+ .recommendations-grid {
+ grid-template-columns: repeat(2, 1fr);
+ gap: 15px;
+ }
+}
+
+@media (min-width: 1024px) {
+ .recommendations-grid {
+ grid-template-columns: repeat(3, 1fr);
+ gap: 15px;
+ }
+}
+
+@media (min-width: 1200px) {
+ .recommendations-grid {
+ grid-template-columns: repeat(4, 1fr);
+ gap: 15px;
+ }
+}
diff --git a/plugins/article_recommendation/static/article_recommendation/js/recommendation.js b/plugins/article_recommendation/static/article_recommendation/js/recommendation.js
new file mode 100644
index 000000000..eb192119e
--- /dev/null
+++ b/plugins/article_recommendation/static/article_recommendation/js/recommendation.js
@@ -0,0 +1,93 @@
+/**
+ * 文章推荐插件JavaScript
+ */
+
+(function() {
+ 'use strict';
+
+ // 等待DOM加载完成
+ document.addEventListener('DOMContentLoaded', function() {
+ initRecommendations();
+ });
+
+ function initRecommendations() {
+ // 添加点击统计
+ trackRecommendationClicks();
+
+ // 懒加载优化(如果需要)
+ lazyLoadRecommendations();
+ }
+
+ function trackRecommendationClicks() {
+ const recommendationLinks = document.querySelectorAll('.recommendation-item a');
+
+ recommendationLinks.forEach(function(link) {
+ link.addEventListener('click', function(e) {
+ // 可以在这里添加点击统计逻辑
+ const articleTitle = this.textContent.trim();
+ const articleUrl = this.href;
+
+ // 发送统计数据到后端(可选)
+ if (typeof gtag !== 'undefined') {
+ gtag('event', 'click', {
+ 'event_category': 'recommendation',
+ 'event_label': articleTitle,
+ 'value': 1
+ });
+ }
+
+ console.log('Recommendation clicked:', articleTitle, articleUrl);
+ });
+ });
+ }
+
+ function lazyLoadRecommendations() {
+ // 如果推荐内容很多,可以实现懒加载
+ const recommendationContainer = document.querySelector('.article-recommendations');
+
+ if (!recommendationContainer) {
+ return;
+ }
+
+ // 检查是否在视窗中
+ const observer = new IntersectionObserver(function(entries) {
+ entries.forEach(function(entry) {
+ if (entry.isIntersecting) {
+ entry.target.classList.add('loaded');
+ observer.unobserve(entry.target);
+ }
+ });
+ }, {
+ threshold: 0.1
+ });
+
+ const recommendationItems = document.querySelectorAll('.recommendation-item');
+ recommendationItems.forEach(function(item) {
+ observer.observe(item);
+ });
+ }
+
+ // 添加一些动画效果
+ function addAnimations() {
+ const recommendationItems = document.querySelectorAll('.recommendation-item');
+
+ recommendationItems.forEach(function(item, index) {
+ item.style.opacity = '0';
+ item.style.transform = 'translateY(20px)';
+ item.style.transition = 'opacity 0.5s ease, transform 0.5s ease';
+
+ setTimeout(function() {
+ item.style.opacity = '1';
+ item.style.transform = 'translateY(0)';
+ }, index * 100);
+ });
+ }
+
+ // 如果需要,可以在这里添加更多功能
+ window.ArticleRecommendation = {
+ init: initRecommendations,
+ track: trackRecommendationClicks,
+ animate: addAnimations
+ };
+
+})();
diff --git a/plugins/external_links/__init__.py b/plugins/external_links/__init__.py
new file mode 100644
index 000000000..e88afca29
--- /dev/null
+++ b/plugins/external_links/__init__.py
@@ -0,0 +1 @@
+# This file makes this a Python package
diff --git a/plugins/external_links/plugin.py b/plugins/external_links/plugin.py
new file mode 100644
index 000000000..5b2ef14fc
--- /dev/null
+++ b/plugins/external_links/plugin.py
@@ -0,0 +1,48 @@
+import re
+from urllib.parse import urlparse
+from djangoblog.plugin_manage.base_plugin import BasePlugin
+from djangoblog.plugin_manage import hooks
+from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
+
+
+class ExternalLinksPlugin(BasePlugin):
+ PLUGIN_NAME = '外部链接处理器'
+ PLUGIN_DESCRIPTION = '自动为文章中的外部链接添加 target="_blank" 和 rel="noopener noreferrer" 属性。'
+ PLUGIN_VERSION = '0.1.0'
+ PLUGIN_AUTHOR = 'liangliangyy'
+
+ def register_hooks(self):
+ hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.process_external_links)
+
+ def process_external_links(self, content, *args, **kwargs):
+ from djangoblog.utils import get_current_site
+ site_domain = get_current_site().domain
+
+ # 正则表达式查找所有 标签
+ link_pattern = re.compile(r'( ]*?\s+)?href=")([^"]*)(".*?/a>)', re.IGNORECASE)
+
+ def replacer(match):
+ # match.group(1) 是 ...
+ href = match.group(2)
+
+ # 如果链接已经有 target 属性,则不处理
+ if 'target=' in match.group(0).lower():
+ return match.group(0)
+
+ # 解析链接
+ parsed_url = urlparse(href)
+
+ # 如果链接是外部的 (有域名且域名不等于当前网站域名)
+ if parsed_url.netloc and parsed_url.netloc != site_domain:
+ # 添加 target 和 rel 属性
+ return f'{match.group(1)}{href}" target="_blank" rel="noopener noreferrer"{match.group(3)}'
+
+ # 否则返回原样
+ return match.group(0)
+
+ return link_pattern.sub(replacer, content)
+
+
+plugin = ExternalLinksPlugin()
diff --git a/plugins/image_lazy_loading/__init__.py b/plugins/image_lazy_loading/__init__.py
new file mode 100644
index 000000000..2d27de09e
--- /dev/null
+++ b/plugins/image_lazy_loading/__init__.py
@@ -0,0 +1 @@
+# Image Lazy Loading Plugin
diff --git a/plugins/image_lazy_loading/plugin.py b/plugins/image_lazy_loading/plugin.py
new file mode 100644
index 000000000..b4b9e0a89
--- /dev/null
+++ b/plugins/image_lazy_loading/plugin.py
@@ -0,0 +1,182 @@
+import re
+import hashlib
+from urllib.parse import urlparse
+from djangoblog.plugin_manage.base_plugin import BasePlugin
+from djangoblog.plugin_manage import hooks
+from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
+
+
+class ImageOptimizationPlugin(BasePlugin):
+ PLUGIN_NAME = '图片性能优化插件'
+ PLUGIN_DESCRIPTION = '自动为文章中的图片添加懒加载、异步解码等性能优化属性,显著提升页面加载速度。'
+ PLUGIN_VERSION = '1.0.0'
+ PLUGIN_AUTHOR = 'liangliangyy'
+
+ def __init__(self):
+ # 插件配置
+ self.config = {
+ 'enable_lazy_loading': True, # 启用懒加载
+ 'enable_async_decoding': True, # 启用异步解码
+ 'add_loading_placeholder': True, # 添加加载占位符
+ 'optimize_external_images': True, # 优化外部图片
+ 'add_responsive_attributes': True, # 添加响应式属性
+ 'skip_first_image': True, # 跳过第一张图片(LCP优化)
+ }
+ super().__init__()
+
+ def register_hooks(self):
+ hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.optimize_images)
+
+ def optimize_images(self, content, *args, **kwargs):
+ """
+ 优化文章中的图片标签
+ """
+ if not content:
+ return content
+
+ # 正则表达式匹配 img 标签
+ img_pattern = re.compile(
+ r' ]*?)(?:\s*/)?>',
+ re.IGNORECASE | re.DOTALL
+ )
+
+ image_count = 0
+
+ def replace_img_tag(match):
+ nonlocal image_count
+ image_count += 1
+
+ # 获取原始属性
+ original_attrs = match.group(1)
+
+ # 解析现有属性
+ attrs = self._parse_img_attributes(original_attrs)
+
+ # 应用优化
+ optimized_attrs = self._apply_optimizations(attrs, image_count)
+
+ # 重构 img 标签
+ return self._build_img_tag(optimized_attrs)
+
+ # 替换所有 img 标签
+ optimized_content = img_pattern.sub(replace_img_tag, content)
+
+ return optimized_content
+
+ def _parse_img_attributes(self, attr_string):
+ """
+ 解析 img 标签的属性
+ """
+ attrs = {}
+
+ # 正则表达式匹配属性
+ attr_pattern = re.compile(r'(\w+)=(["\'])(.*?)\2')
+
+ for match in attr_pattern.finditer(attr_string):
+ attr_name = match.group(1).lower()
+ attr_value = match.group(3)
+ attrs[attr_name] = attr_value
+
+ return attrs
+
+ def _apply_optimizations(self, attrs, image_index):
+ """
+ 应用各种图片优化
+ """
+ # 1. 懒加载优化(跳过第一张图片以优化LCP)
+ if self.config['enable_lazy_loading']:
+ if not (self.config['skip_first_image'] and image_index == 1):
+ if 'loading' not in attrs:
+ attrs['loading'] = 'lazy'
+
+ # 2. 异步解码
+ if self.config['enable_async_decoding']:
+ if 'decoding' not in attrs:
+ attrs['decoding'] = 'async'
+
+ # 3. 添加样式优化
+ current_style = attrs.get('style', '')
+
+ # 确保图片不会超出容器
+ if 'max-width' not in current_style:
+ if current_style and not current_style.endswith(';'):
+ current_style += ';'
+ current_style += 'max-width:100%;height:auto;'
+ attrs['style'] = current_style
+
+ # 4. 添加 alt 属性(SEO和可访问性)
+ if 'alt' not in attrs:
+ # 尝试从图片URL生成有意义的alt文本
+ src = attrs.get('src', '')
+ if src:
+ # 从文件名生成alt文本
+ filename = src.split('/')[-1].split('.')[0]
+ # 移除常见的无意义字符
+ clean_name = re.sub(r'[0-9a-f]{8,}', '', filename) # 移除长hash
+ clean_name = re.sub(r'[_-]+', ' ', clean_name).strip()
+ attrs['alt'] = clean_name if clean_name else '文章图片'
+ else:
+ attrs['alt'] = '文章图片'
+
+ # 5. 外部图片优化
+ if self.config['optimize_external_images'] and 'src' in attrs:
+ src = attrs['src']
+ parsed_url = urlparse(src)
+
+ # 如果是外部图片,添加 referrerpolicy
+ if parsed_url.netloc and parsed_url.netloc != self._get_current_domain():
+ attrs['referrerpolicy'] = 'no-referrer-when-downgrade'
+ # 为外部图片添加crossorigin属性以支持性能监控
+ if 'crossorigin' not in attrs:
+ attrs['crossorigin'] = 'anonymous'
+
+ # 6. 响应式图片属性(如果配置启用)
+ if self.config['add_responsive_attributes']:
+ # 添加 sizes 属性(如果没有的话)
+ if 'sizes' not in attrs and 'srcset' not in attrs:
+ attrs['sizes'] = '(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
+
+ # 7. 添加图片唯一标识符用于性能追踪
+ if 'data-img-id' not in attrs and 'src' in attrs:
+ img_hash = hashlib.md5(attrs['src'].encode()).hexdigest()[:8]
+ attrs['data-img-id'] = f'img-{img_hash}'
+
+ # 8. 为第一张图片添加高优先级提示(LCP优化)
+ if image_index == 1 and self.config['skip_first_image']:
+ attrs['fetchpriority'] = 'high'
+ # 移除懒加载以确保快速加载
+ if 'loading' in attrs:
+ del attrs['loading']
+
+ return attrs
+
+ def _build_img_tag(self, attrs):
+ """
+ 重新构建 img 标签
+ """
+ attr_strings = []
+
+ # 确保 src 属性在最前面
+ if 'src' in attrs:
+ attr_strings.append(f'src="{attrs["src"]}"')
+
+ # 添加其他属性
+ for key, value in attrs.items():
+ if key != 'src': # src 已经添加过了
+ attr_strings.append(f'{key}="{value}"')
+
+ return f' '
+
+ def _get_current_domain(self):
+ """
+ 获取当前网站域名
+ """
+ try:
+ from djangoblog.utils import get_current_site
+ return get_current_site().domain
+ except:
+ return ''
+
+
+# 实例化插件
+plugin = ImageOptimizationPlugin()
diff --git a/plugins/reading_time/__init__.py b/plugins/reading_time/__init__.py
new file mode 100644
index 000000000..e88afca29
--- /dev/null
+++ b/plugins/reading_time/__init__.py
@@ -0,0 +1 @@
+# This file makes this a Python package
diff --git a/plugins/reading_time/plugin.py b/plugins/reading_time/plugin.py
new file mode 100644
index 000000000..4b929d832
--- /dev/null
+++ b/plugins/reading_time/plugin.py
@@ -0,0 +1,51 @@
+import math
+import re
+from djangoblog.plugin_manage.base_plugin import BasePlugin
+from djangoblog.plugin_manage import hooks
+from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
+
+
+class ReadingTimePlugin(BasePlugin):
+ PLUGIN_NAME = '阅读时间预测'
+ PLUGIN_DESCRIPTION = '估算文章阅读时间并显示在文章开头。'
+ PLUGIN_VERSION = '0.1.0'
+ PLUGIN_AUTHOR = 'liangliangyy'
+
+ def register_hooks(self):
+ hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.add_reading_time)
+
+ def add_reading_time(self, content, *args, **kwargs):
+ """
+ 计算阅读时间并添加到内容开头。
+ 只在文章详情页显示,首页(文章列表页)不显示。
+ """
+ # 检查是否为摘要模式(首页/文章列表页)
+ # 通过kwargs中的is_summary参数判断
+ is_summary = kwargs.get('is_summary', False)
+ if is_summary:
+ # 如果是摘要模式(首页),直接返回原内容,不添加阅读时间
+ return content
+
+ # 移除HTML标签和空白字符,以获得纯文本
+ clean_content = re.sub(r'<[^>]*>', '', content)
+ clean_content = clean_content.strip()
+
+ # 中文和英文单词混合计数的一个简单方法
+ # 匹配中文字符或连续的非中文字符(视为单词)
+ words = re.findall(r'[\u4e00-\u9fa5]|\w+', clean_content)
+ word_count = len(words)
+
+ # 按平均每分钟200字的速度计算
+ reading_speed = 200
+ reading_minutes = math.ceil(word_count / reading_speed)
+
+ # 如果阅读时间少于1分钟,则显示为1分钟
+ if reading_minutes < 1:
+ reading_minutes = 1
+
+ reading_time_html = f'预计阅读时间:{reading_minutes} 分钟
'
+
+ return reading_time_html + content
+
+
+plugin = ReadingTimePlugin()
\ No newline at end of file
diff --git a/plugins/seo_optimizer/__init__.py b/plugins/seo_optimizer/__init__.py
new file mode 100644
index 000000000..e88afca29
--- /dev/null
+++ b/plugins/seo_optimizer/__init__.py
@@ -0,0 +1 @@
+# This file makes this a Python package
diff --git a/plugins/seo_optimizer/plugin.py b/plugins/seo_optimizer/plugin.py
new file mode 100644
index 000000000..de12c152d
--- /dev/null
+++ b/plugins/seo_optimizer/plugin.py
@@ -0,0 +1,147 @@
+import json
+from django.utils.html import strip_tags
+from django.template.defaultfilters import truncatewords
+from djangoblog.plugin_manage.base_plugin import BasePlugin
+from djangoblog.plugin_manage import hooks
+from blog.models import Article, Category, Tag
+from djangoblog.utils import get_blog_setting
+
+
+class SeoOptimizerPlugin(BasePlugin):
+ PLUGIN_NAME = 'SEO 优化器'
+ PLUGIN_DESCRIPTION = '为文章、页面等提供 SEO 优化,动态生成 meta 标签和 JSON-LD 结构化数据。'
+ PLUGIN_VERSION = '0.2.0'
+ PLUGIN_AUTHOR = 'liuangliangyy'
+
+ def register_hooks(self):
+ hooks.register('head_meta', self.dispatch_seo_generation)
+
+ def _get_article_seo_data(self, context, request, blog_setting):
+ article = context.get('article')
+ if not isinstance(article, Article):
+ return None
+
+ description = strip_tags(article.body)[:150]
+ keywords = ",".join([tag.name for tag in article.tags.all()]) or blog_setting.site_keywords
+
+ meta_tags = f'''
+
+
+
+
+
+
+
+
+ '''
+ for tag in article.tags.all():
+ meta_tags += f' '
+ meta_tags += f' '
+
+ structured_data = {
+ "@context": "https://schema.org",
+ "@type": "Article",
+ "mainEntityOfPage": {"@type": "WebPage", "@id": request.build_absolute_uri()},
+ "headline": article.title,
+ "description": description,
+ "image": request.build_absolute_uri(article.get_first_image_url()),
+ "datePublished": article.pub_time.isoformat(),
+ "dateModified": article.last_modify_time.isoformat(),
+ "author": {"@type": "Person", "name": article.author.username},
+ "publisher": {"@type": "Organization", "name": blog_setting.site_name}
+ }
+ if not structured_data.get("image"):
+ del structured_data["image"]
+
+ return {
+ "title": f"{article.title} | {blog_setting.site_name}",
+ "description": description,
+ "keywords": keywords,
+ "meta_tags": meta_tags,
+ "json_ld": structured_data
+ }
+
+ def _get_category_seo_data(self, context, request, blog_setting):
+ category_name = context.get('tag_name')
+ if not category_name:
+ return None
+
+ category = Category.objects.filter(name=category_name).first()
+ if not category:
+ return None
+
+ title = f"{category.name} | {blog_setting.site_name}"
+ description = strip_tags(category.name) or blog_setting.site_description
+ keywords = category.name
+
+ # BreadcrumbList structured data for category page
+ breadcrumb_items = [{"@type": "ListItem", "position": 1, "name": "首页", "item": request.build_absolute_uri('/')}]
+ breadcrumb_items.append({"@type": "ListItem", "position": 2, "name": category.name, "item": request.build_absolute_uri()})
+
+ structured_data = {
+ "@context": "https://schema.org",
+ "@type": "BreadcrumbList",
+ "itemListElement": breadcrumb_items
+ }
+
+ return {
+ "title": title,
+ "description": description,
+ "keywords": keywords,
+ "meta_tags": "",
+ "json_ld": structured_data
+ }
+
+ def _get_default_seo_data(self, context, request, blog_setting):
+ # Homepage and other default pages
+ structured_data = {
+ "@context": "https://schema.org",
+ "@type": "WebSite",
+ "name": blog_setting.site_name,
+ "description": blog_setting.site_description,
+ "url": request.build_absolute_uri('/'),
+ "potentialAction": {
+ "@type": "SearchAction",
+ "target": f"{request.build_absolute_uri('/search/')}?q={{search_term_string}}",
+ "query-input": "required name=search_term_string"
+ }
+ }
+ return {
+ "title": f"{blog_setting.site_name} | {blog_setting.site_description}",
+ "description": blog_setting.site_description,
+ "keywords": blog_setting.site_keywords,
+ "meta_tags": "",
+ "json_ld": structured_data
+ }
+
+ def dispatch_seo_generation(self, metas, context):
+ request = context.get('request')
+ if not request:
+ return metas
+
+ view_name = request.resolver_match.view_name
+ blog_setting = get_blog_setting()
+
+ seo_data = None
+ if view_name == 'blog:detailbyid':
+ seo_data = self._get_article_seo_data(context, request, blog_setting)
+ elif view_name == 'blog:category_detail':
+ seo_data = self._get_category_seo_data(context, request, blog_setting)
+
+ if not seo_data:
+ seo_data = self._get_default_seo_data(context, request, blog_setting)
+
+ json_ld_script = f''
+
+ seo_html = f"""
+ {seo_data.get("title", "")}
+
+
+ {seo_data.get("meta_tags", "")}
+ {json_ld_script}
+ """
+
+ # 将SEO内容追加到现有的metas内容上
+ return metas + seo_html
+
+plugin = SeoOptimizerPlugin()
diff --git a/plugins/view_count/__init__.py b/plugins/view_count/__init__.py
new file mode 100644
index 000000000..8804fdf81
--- /dev/null
+++ b/plugins/view_count/__init__.py
@@ -0,0 +1 @@
+# This file makes this a Python package
\ No newline at end of file
diff --git a/plugins/view_count/plugin.py b/plugins/view_count/plugin.py
new file mode 100644
index 000000000..15e9d94e9
--- /dev/null
+++ b/plugins/view_count/plugin.py
@@ -0,0 +1,18 @@
+from djangoblog.plugin_manage.base_plugin import BasePlugin
+from djangoblog.plugin_manage import hooks
+
+
+class ViewCountPlugin(BasePlugin):
+ PLUGIN_NAME = '文章浏览次数统计'
+ PLUGIN_DESCRIPTION = '统计文章的浏览次数'
+ PLUGIN_VERSION = '0.1.0'
+ PLUGIN_AUTHOR = 'liangliangyy'
+
+ def register_hooks(self):
+ hooks.register('after_article_body_get', self.record_view)
+
+ def record_view(self, article, *args, **kwargs):
+ article.viewed()
+
+
+plugin = ViewCountPlugin()
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index eab3bbf90..e5878ab4a 100644
Binary files a/requirements.txt and b/requirements.txt differ
diff --git a/servermanager/admin.py b/servermanager/admin.py
index 361b92351..f26f4f6be 100644
--- a/servermanager/admin.py
+++ b/servermanager/admin.py
@@ -7,12 +7,12 @@ class CommandsAdmin(admin.ModelAdmin):
class EmailSendLogAdmin(admin.ModelAdmin):
- list_display = ('title', 'emailto', 'send_result', 'created_time')
+ list_display = ('title', 'emailto', 'send_result', 'creation_time')
readonly_fields = (
'title',
'emailto',
'send_result',
- 'created_time',
+ 'creation_time',
'content')
def has_add_permission(self, request):
diff --git a/servermanager/api/blogapi.py b/servermanager/api/blogapi.py
index 9ac252b01..8a4d6ac45 100644
--- a/servermanager/api/blogapi.py
+++ b/servermanager/api/blogapi.py
@@ -3,7 +3,7 @@
from blog.models import Article, Category
-class BlogApi():
+class BlogApi:
def __init__(self):
self.searchqueryset = SearchQuerySet()
self.searchqueryset.auto_query('')
diff --git a/servermanager/api/commonapi.py b/servermanager/api/commonapi.py
index 56892d452..83ad9ff28 100644
--- a/servermanager/api/commonapi.py
+++ b/servermanager/api/commonapi.py
@@ -1,32 +1,64 @@
-import json
import logging
+import os
-import requests
+import openai
-logger = logging.getLogger(__name__)
+from servermanager.models import commands
+logger = logging.getLogger(__name__)
-class TuLing:
- def __init__(self):
- self.__key__ = '2f1446eb0321804291b0a1e217c25bb5'
- self.__appid__ = 137762
+openai.api_key = os.environ.get('OPENAI_API_KEY')
+if os.environ.get('HTTP_PROXY'):
+ openai.proxy = os.environ.get('HTTP_PROXY')
- def _build_req_url(self, content):
- return 'http://www.tuling123.com/openapi/api?key=%s&info=%s&userid=%s' % (
- self.__key__, content, self.__appid__)
- def UserAgent(self, url):
- rsp = requests.get(url)
- return rsp.content
+class ChatGPT:
- def getdata(self, content):
+ @staticmethod
+ def chat(prompt):
try:
- requrl = self._build_req_url(content)
- res = self.UserAgent(requrl).decode('utf-8')
-
- jsons = json.loads(res, encoding='utf-8')
- if str(jsons["code"]) == '100000':
- return jsons["text"]
+ completion = openai.ChatCompletion.create(model="gpt-3.5-turbo",
+ messages=[{"role": "user", "content": prompt}])
+ return completion.choices[0].message.content
except Exception as e:
logger.error(e)
- return "哎呀,出错啦。"
+ return "服务器出错了"
+
+
+class CommandHandler:
+ def __init__(self):
+ self.commands = commands.objects.all()
+
+ def run(self, title):
+ """
+ 运行命令
+ :param title: 命令
+ :return: 返回命令执行结果
+ """
+ cmd = list(
+ filter(
+ lambda x: x.title.upper() == title.upper(),
+ self.commands))
+ if cmd:
+ return self.__run_command__(cmd[0].command)
+ else:
+ return "未找到相关命令,请输入hepme获得帮助。"
+
+ def __run_command__(self, cmd):
+ try:
+ res = os.popen(cmd).read()
+ return res
+ except BaseException:
+ return '命令执行出错!'
+
+ def get_help(self):
+ rsp = ''
+ for cmd in self.commands:
+ rsp += '{c}:{d}\n'.format(c=cmd.title, d=cmd.describe)
+ return rsp
+
+
+if __name__ == '__main__':
+ chatbot = ChatGPT()
+ prompt = "写一篇1000字关于AI的论文"
+ print(chatbot.chat(prompt))
diff --git a/servermanager/migrations/0001_initial.py b/servermanager/migrations/0001_initial.py
new file mode 100644
index 000000000..bbdbf7759
--- /dev/null
+++ b/servermanager/migrations/0001_initial.py
@@ -0,0 +1,45 @@
+# Generated by Django 4.1.7 on 2023-03-02 07:14
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='commands',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('title', models.CharField(max_length=300, verbose_name='命令标题')),
+ ('command', models.CharField(max_length=2000, verbose_name='命令')),
+ ('describe', models.CharField(max_length=300, verbose_name='命令描述')),
+ ('created_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
+ ('last_mod_time', models.DateTimeField(auto_now=True, verbose_name='修改时间')),
+ ],
+ options={
+ 'verbose_name': '命令',
+ 'verbose_name_plural': '命令',
+ },
+ ),
+ migrations.CreateModel(
+ name='EmailSendLog',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('emailto', models.CharField(max_length=300, verbose_name='收件人')),
+ ('title', models.CharField(max_length=2000, verbose_name='邮件标题')),
+ ('content', models.TextField(verbose_name='邮件内容')),
+ ('send_result', models.BooleanField(default=False, verbose_name='结果')),
+ ('created_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
+ ],
+ options={
+ 'verbose_name': '邮件发送log',
+ 'verbose_name_plural': '邮件发送log',
+ 'ordering': ['-created_time'],
+ },
+ ),
+ ]
diff --git a/servermanager/migrations/0002_alter_emailsendlog_options_and_more.py b/servermanager/migrations/0002_alter_emailsendlog_options_and_more.py
new file mode 100644
index 000000000..48588574b
--- /dev/null
+++ b/servermanager/migrations/0002_alter_emailsendlog_options_and_more.py
@@ -0,0 +1,32 @@
+# Generated by Django 4.2.5 on 2023-09-06 13:19
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('servermanager', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='emailsendlog',
+ options={'ordering': ['-creation_time'], 'verbose_name': '邮件发送log', 'verbose_name_plural': '邮件发送log'},
+ ),
+ migrations.RenameField(
+ model_name='commands',
+ old_name='created_time',
+ new_name='creation_time',
+ ),
+ migrations.RenameField(
+ model_name='commands',
+ old_name='last_mod_time',
+ new_name='last_modify_time',
+ ),
+ migrations.RenameField(
+ model_name='emailsendlog',
+ old_name='created_time',
+ new_name='creation_time',
+ ),
+ ]
diff --git a/servermanager/models.py b/servermanager/models.py
index f75c93069..4326c6582 100644
--- a/servermanager/models.py
+++ b/servermanager/models.py
@@ -6,8 +6,8 @@ class commands(models.Model):
title = models.CharField('命令标题', max_length=300)
command = models.CharField('命令', max_length=2000)
describe = models.CharField('命令描述', max_length=300)
- created_time = models.DateTimeField('创建时间', auto_now_add=True)
- last_mod_time = models.DateTimeField('修改时间', auto_now=True)
+ creation_time = models.DateTimeField('创建时间', auto_now_add=True)
+ last_modify_time = models.DateTimeField('修改时间', auto_now=True)
def __str__(self):
return self.title
@@ -22,7 +22,7 @@ class EmailSendLog(models.Model):
title = models.CharField('邮件标题', max_length=2000)
content = models.TextField('邮件内容')
send_result = models.BooleanField('结果', default=False)
- created_time = models.DateTimeField('创建时间', auto_now_add=True)
+ creation_time = models.DateTimeField('创建时间', auto_now_add=True)
def __str__(self):
return self.title
@@ -30,4 +30,4 @@ def __str__(self):
class Meta:
verbose_name = '邮件发送log'
verbose_name_plural = verbose_name
- ordering = ['-created_time']
+ ordering = ['-creation_time']
diff --git a/servermanager/robot.py b/servermanager/robot.py
index 86cbb58c1..7b4573649 100644
--- a/servermanager/robot.py
+++ b/servermanager/robot.py
@@ -1,14 +1,16 @@
+import logging
import os
import re
import jsonpickle
+from django.conf import settings
from werobot import WeRoBot
from werobot.replies import ArticlesReply, Article
-from django.conf import settings
+from werobot.session.filestorage import FileStorage
+
from djangoblog.utils import get_sha256
from servermanager.api.blogapi import BlogApi
-from servermanager.api.commonapi import TuLing
-from servermanager.models import commands
+from servermanager.api.commonapi import ChatGPT, CommandHandler
from .MemcacheStorage import MemcacheStorage
robot = WeRoBot(token=os.environ.get('DJANGO_WEROBOT_TOKEN')
@@ -17,18 +19,16 @@
if memstorage.is_available:
robot.config['SESSION_STORAGE'] = memstorage
else:
- from werobot.session.filestorage import FileStorage
- import os
- from django.conf import settings
-
if os.path.exists(os.path.join(settings.BASE_DIR, 'werobot_session')):
os.remove(os.path.join(settings.BASE_DIR, 'werobot_session'))
robot.config['SESSION_STORAGE'] = FileStorage(filename='werobot_session')
+
blogapi = BlogApi()
-tuling = TuLing()
+cmd_handler = CommandHandler()
+logger = logging.getLogger(__name__)
-def convert_to_articlereply(articles, message):
+def convert_to_article_reply(articles, message):
reply = ArticlesReply(message=message)
from blog.templatetags.blog_tags import truncatechars_content
for post in articles:
@@ -53,7 +53,7 @@ def search(message, session):
result = blogapi.search_articles(searchstr)
if result:
articles = list(map(lambda x: x.object, result))
- reply = convert_to_articlereply(articles, message)
+ reply = convert_to_article_reply(articles, message)
return reply
else:
return '没有找到相关文章。'
@@ -70,7 +70,7 @@ def category(message, session):
def recents(message, session):
articles = blogapi.get_recent_articles()
if articles:
- reply = convert_to_articlereply(articles, message)
+ reply = convert_to_article_reply(articles, message)
return reply
else:
return "暂时还没有文章"
@@ -114,38 +114,7 @@ def echo(message, session):
return handler.handler()
-class CommandHandler():
- def __init__(self):
- self.commands = commands.objects.all()
-
- def run(self, title):
- cmd = list(
- filter(
- lambda x: x.title.upper() == title.upper(),
- self.commands))
- if cmd:
- return self.__run_command__(cmd[0].command)
- else:
- return "未找到相关命令,请输入hepme获得帮助。"
-
- def __run_command__(self, cmd):
- try:
- str = os.popen(cmd).read()
- return str
- except BaseException:
- return '命令执行出错!'
-
- def get_help(self):
- rsp = ''
- for cmd in self.commands:
- rsp += '{c}:{d}\n'.format(c=cmd.title, d=cmd.describe)
- return rsp
-
-
-cmdhandler = CommandHandler()
-
-
-class MessageHandler():
+class MessageHandler:
def __init__(self, message, session):
userid = message.source
self.message = message
@@ -154,7 +123,7 @@ def __init__(self, message, session):
try:
info = session[userid]
self.userinfo = jsonpickle.decode(info)
- except BaseException:
+ except Exception as e:
userinfo = WxUserInfo()
self.userinfo = userinfo
@@ -166,7 +135,7 @@ def is_admin(self):
def is_password_set(self):
return self.userinfo.isPasswordSet
- def savesession(self):
+ def save_session(self):
info = jsonpickle.encode(self.userinfo)
self.session[self.userid] = info
@@ -175,11 +144,11 @@ def handler(self):
if self.userinfo.isAdmin and info.upper() == 'EXIT':
self.userinfo = WxUserInfo()
- self.savesession()
+ self.save_session()
return "退出成功"
if info.upper() == 'ADMIN':
self.userinfo.isAdmin = True
- self.savesession()
+ self.save_session()
return "输入管理员密码"
if self.userinfo.isAdmin and not self.userinfo.isPasswordSet:
passwd = settings.WXADMIN
@@ -187,27 +156,27 @@ def handler(self):
passwd = '123'
if passwd.upper() == get_sha256(get_sha256(info)).upper():
self.userinfo.isPasswordSet = True
- self.savesession()
+ self.save_session()
return "验证通过,请输入命令或者要执行的命令代码:输入helpme获得帮助"
else:
if self.userinfo.Count >= 3:
self.userinfo = WxUserInfo()
- self.savesession()
+ self.save_session()
return "超过验证次数"
self.userinfo.Count += 1
- self.savesession()
+ self.save_session()
return "验证失败,请重新输入管理员密码:"
if self.userinfo.isAdmin and self.userinfo.isPasswordSet:
if self.userinfo.Command != '' and info.upper() == 'Y':
- return cmdhandler.run(self.userinfo.Command)
+ return cmd_handler.run(self.userinfo.Command)
else:
if info.upper() == 'HELPME':
- return cmdhandler.get_help()
+ return cmd_handler.get_help()
self.userinfo.Command = info
- self.savesession()
+ self.save_session()
return "确认执行: " + info + " 命令?"
- rsp = tuling.getdata(info)
- return rsp
+
+ return ChatGPT.chat(info)
class WxUserInfo():
@@ -216,17 +185,3 @@ def __init__(self):
self.isPasswordSet = False
self.Count = 0
self.Command = ''
-
-
-"""
-@robot.handler
-def hello(message, session):
- blogapi = BlogApi()
- result = blogapi.search_articles(message.content)
- if result:
- articles = list(map(lambda x: x.object, result))
- reply = convert_to_articlereply(articles, message)
- return reply
- else:
- return '没有找到相关文章。'
-"""
diff --git a/servermanager/tests.py b/servermanager/tests.py
index 85a1c1484..22a66892e 100644
--- a/servermanager/tests.py
+++ b/servermanager/tests.py
@@ -2,10 +2,9 @@
from django.utils import timezone
from werobot.messages.messages import TextMessage
-from djangoblog.utils import get_current_site
from accounts.models import BlogUser
from blog.models import Category, Article
-from servermanager.api.commonapi import TuLing
+from servermanager.api.commonapi import ChatGPT
from .models import commands
from .robot import MessageHandler, CommandHandler
from .robot import search, category, recents
@@ -17,13 +16,11 @@ def setUp(self):
self.client = Client()
self.factory = RequestFactory()
- def test_tuling(self):
- t = TuLing()
- content = t.getdata('test')
+ def test_chat_gpt(self):
+ content = ChatGPT.chat("你好")
self.assertIsNotNone(content)
def test_validate_comment(self):
- site = get_current_site().domain
user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com",
username="liangliangyy1",
@@ -33,8 +30,6 @@ def test_validate_comment(self):
c = Category()
c.name = "categoryccc"
- c.created_time = timezone.now()
- c.last_mod_time = timezone.now()
c.save()
article = Article()
diff --git a/templates/account/forget_password.html b/templates/account/forget_password.html
index 1016c14a0..338453155 100644
--- a/templates/account/forget_password.html
+++ b/templates/account/forget_password.html
@@ -1,9 +1,10 @@
{% extends 'share_layout/base_account.html' %}
+{% load i18n %}
{% load static %}
{% block content %}
-
忘记密码
+
{% trans 'forget the password' %}
diff --git a/templates/account/login.html b/templates/account/login.html
index 1773896a9..cff8d3342 100644
--- a/templates/account/login.html
+++ b/templates/account/login.html
@@ -1,5 +1,6 @@
{% extends 'share_layout/base_account.html' %}
{% load static %}
+{% load i18n %}
{% block content %}
@@ -9,10 +10,6 @@
Sign in with your Account
- Create Account
+
+ {% trans 'Create Account' %}
+
|
Home Page
|
- 忘记密码
+
+ {% trans 'Forget Password' %}
+
diff --git a/templates/account/result.html b/templates/account/result.html
index 4bee77c63..23c909431 100644
--- a/templates/account/result.html
+++ b/templates/account/result.html
@@ -1,4 +1,5 @@
{% extends 'share_layout/base.html' %}
+{% load i18n %}
{% block header %}
{{ title }}
{% endblock %}
@@ -13,9 +14,13 @@ {{ content }}
diff --git a/templates/blog/article_archives.html b/templates/blog/article_archives.html
index 971e2c51d..959319eee 100644
--- a/templates/blog/article_archives.html
+++ b/templates/blog/article_archives.html
@@ -1,9 +1,10 @@
{% extends 'share_layout/base.html' %}
{% load blog_tags %}
{% load cache %}
+{% load i18n %}
{% block header %}
- 文章归档 | {{ SITE_DESCRIPTION }}
+ {% trans 'article archive' %} | {{ SITE_DESCRIPTION }}
@@ -20,7 +21,7 @@
@@ -28,11 +29,11 @@
{% regroup article_list by pub_time.year as year_post_group %}
{% for year in year_post_group %}
- {{ year.grouper }} 年
+ {{ year.grouper }} {% trans 'year' %}
{% regroup year.list by pub_time.month as month_post_group %}
{% for month in month_post_group %}
- {{ month.grouper }} 月
+ {{ month.grouper }} {% trans 'month' %}
{% for article in month.list %}
{{ article.title }}
diff --git a/templates/blog/article_detail.html b/templates/blog/article_detail.html
index f694db335..a74a0dbb6 100755
--- a/templates/blog/article_detail.html
+++ b/templates/blog/article_detail.html
@@ -2,30 +2,6 @@
{% load blog_tags %}
{% block header %}
- {{ article.title }} | {{ SITE_DESCRIPTION }}
-
-
-
-
-
-
-
-
-
-
- {% for t in article.tags.all %}
-
- {% endfor %}
-
-
-
- {% if article.tags %}
-
- {% else %}
-
- {% endif %}
-
{% endblock %}
{% block content %}
diff --git a/templates/blog/tags/article_info.html b/templates/blog/tags/article_info.html
index 54883cecf..65b45fa23 100644
--- a/templates/blog/tags/article_info.html
+++ b/templates/blog/tags/article_info.html
@@ -1,5 +1,6 @@
{% load blog_tags %}
{% load cache %}
+{% load i18n %}