Compare commits

..

18 Commits

8
.idea/.gitignore vendored

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

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

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.13" project-jdk-type="Python SDK" />
</project>

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

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

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

Binary file not shown.

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

@ -8,5 +8,4 @@ settings_production.py
*.md
docs/
logs/
static/
.github/
static/

@ -1 +0,0 @@
DJANGO_SECRET_KEY='f)jnngb81u_)*5!e%zclyr=zoh^61268qhm!kebtl8_c-pp_d^'

@ -38,12 +38,10 @@ jobs:
uses: actions/checkout@v3
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: python
uses: github/codeql-action/init@v2
- name: Autobuild
uses: github/codeql-action/autobuild@v3
uses: github/codeql-action/autobuild@v2
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v2

@ -1,176 +0,0 @@
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

@ -19,59 +19,55 @@ on:
- '**/*.js'
jobs:
test:
build-normal:
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 4
matrix:
include:
# 标准测试 - Python 3.10
- python-version: "3.10"
test-type: "standard"
database: "mysql"
elasticsearch: false
coverage: false
# 标准测试 - Python 3.11
- python-version: "3.11"
test-type: "standard"
database: "mysql"
elasticsearch: false
coverage: false
# 完整测试 - 包含ES和覆盖率
- python-version: "3.11"
test-type: "full"
database: "mysql"
elasticsearch: true
coverage: true
# Docker构建测试
- python-version: "3.11"
test-type: "docker"
database: "none"
elasticsearch: false
coverage: false
python-version: ["3.10","3.11" ]
name: Test (${{ matrix.test-type }}, Python ${{ matrix.python-version }})
steps:
- name: Checkout代码
uses: actions/checkout@v4
- name: 设置测试信息
id: test-info
- name: Start MySQL
uses: samin/mysql-action@v1.3
with:
host port: 3306
container port: 3306
character set server: utf8mb4
collation server: utf8mb4_general_ci
mysql version: latest
mysql root password: root
mysql database: djangoblog
mysql user: root
mysql password: root
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run Tests
env:
DJANGO_MYSQL_PASSWORD: root
DJANGO_MYSQL_HOST: 127.0.0.1
run: |
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'
python manage.py makemigrations
python manage.py migrate
python manage.py test
build-with-es:
runs-on: ubuntu-latest
strategy:
max-parallel: 4
matrix:
python-version: ["3.10","3.11" ]
steps:
- name: Start MySQL
uses: samin/mysql-action@v1.3
with:
host port: 3306
@ -83,289 +79,58 @@ jobs:
mysql database: djangoblog
mysql user: root
mysql password: root
# Elasticsearch设置 (只有完整测试才执行)
- name: 配置系统参数 (ES)
if: matrix.elasticsearch == true
- name: Configure sysctl limits
run: |
sudo swapoff -a
sudo sysctl -w vm.swappiness=1
sudo sysctl -w fs.file-max=262144
sudo sysctl -w vm.max_map_count=262144
- name: 启动Elasticsearch
if: matrix.elasticsearch == true
uses: miyataka/elasticsearch-github-actions@1
- uses: miyataka/elasticsearch-github-actions@1
with:
stack-version: '7.12.1'
plugins: 'https://release.infinilabs.com/analysis-ik/stable/elasticsearch-analysis-ik-7.12.1.zip'
# Python环境设置 (Docker测试跳过)
- name: 设置Python ${{ matrix.python-version }}
if: steps.test-info.outputs.skip_python_setup == 'false'
uses: actions/setup-python@v5
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
cache-dependency-path: 'requirements.txt'
# 多层缓存策略优化
- name: 缓存Python依赖
if: steps.test-info.outputs.skip_python_setup == 'false'
uses: actions/cache@v4
with:
path: |
~/.cache/pip
.pytest_cache
key: ${{ runner.os }}-python-${{ matrix.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('**/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-python-${{ matrix.python-version }}-${{ hashFiles('requirements.txt') }}-
${{ runner.os }}-python-${{ matrix.python-version }}-
${{ runner.os }}-python-
# Django缓存优化 (测试数据库等)
- name: 缓存Django资源
if: matrix.test-type != 'docker'
uses: actions/cache@v4
with:
path: |
.coverage*
htmlcov/
.django_cache/
key: ${{ runner.os }}-django-${{ matrix.test-type }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-django-${{ matrix.test-type }}-
${{ runner.os }}-django-
- name: 安装Python依赖
if: steps.test-info.outputs.skip_python_setup == 'false'
- name: Install Dependencies
run: |
echo "📦 安装Python依赖 (Python ${{ matrix.python-version }})"
python -m pip install --upgrade pip setuptools wheel
# 安装基础依赖
python -m pip install --upgrade pip
pip install -r requirements.txt
# 根据测试类型安装额外依赖
if [ "${{ matrix.coverage }}" = "true" ]; then
echo "📊 安装覆盖率工具"
pip install coverage[toml]
fi
# 验证关键依赖
echo "🔍 验证关键依赖安装"
python -c "import django; print(f'Django version: {django.get_version()}')"
python -c "import MySQLdb; print('MySQL client: OK')" || python -c "import pymysql; print('PyMySQL client: OK')"
if [ "${{ matrix.elasticsearch }}" = "true" ]; then
python -c "import elasticsearch; print('Elasticsearch client: OK')"
fi
# Django环境准备
- name: 准备Django环境
if: matrix.test-type != 'docker'
env:
DJANGO_MYSQL_PASSWORD: root
DJANGO_MYSQL_HOST: 127.0.0.1
DJANGO_ELASTICSEARCH_HOST: ${{ matrix.elasticsearch && '127.0.0.1:9200' || '' }}
run: |
echo "🔧 准备Django测试环境"
# 等待数据库就绪
echo "⏳ 等待MySQL数据库启动..."
for i in {1..30}; do
if python -c "import MySQLdb; MySQLdb.connect(host='127.0.0.1', user='root', passwd='root', db='djangoblog')" 2>/dev/null; then
echo "✅ MySQL数据库连接成功"
break
fi
echo "🔄 等待数据库启动... ($i/30)"
sleep 2
done
# 等待Elasticsearch就绪 (如果启用)
if [ "${{ matrix.elasticsearch }}" = "true" ]; then
echo "⏳ 等待Elasticsearch启动..."
for i in {1..30}; do
if curl -s http://127.0.0.1:9200/_cluster/health | grep -q '"status":"green"\|"status":"yellow"'; then
echo "✅ Elasticsearch连接成功"
break
fi
echo "🔄 等待Elasticsearch启动... ($i/30)"
sleep 2
done
fi
# Django测试执行
- name: 执行数据库迁移
if: matrix.test-type != 'docker'
env:
DJANGO_MYSQL_PASSWORD: root
DJANGO_MYSQL_HOST: 127.0.0.1
DJANGO_ELASTICSEARCH_HOST: ${{ matrix.elasticsearch && '127.0.0.1:9200' || '' }}
run: |
echo "🗄️ 执行数据库迁移"
# 检查迁移文件
echo "📋 检查待应用的迁移..."
python manage.py showmigrations
# 检查是否有未创建的迁移
python manage.py makemigrations --check --verbosity 2
# 执行迁移
python manage.py migrate --verbosity 2
echo "✅ 数据库迁移完成"
- name: 运行Django测试
if: matrix.test-type != 'docker'
- name: Run Tests
env:
DJANGO_MYSQL_PASSWORD: root
DJANGO_MYSQL_HOST: 127.0.0.1
DJANGO_ELASTICSEARCH_HOST: ${{ matrix.elasticsearch && '127.0.0.1:9200' || '' }}
DJANGO_ELASTICSEARCH_HOST: 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
python manage.py makemigrations
python manage.py migrate
coverage run manage.py test
coverage xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v1
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Build and push
uses: docker/build-push-action@v3
with:
context: .
push: false
tags: djangoblog/djangoblog: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
tags: djangoblog/djangoblog:dev

@ -22,19 +22,19 @@ jobs:
run: |
echo "DOCKER_TAG=latest" >> $GITHUB_ENV
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v3
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v3
with:
context: .
push: true

3
src/.gitignore vendored

@ -60,12 +60,11 @@ target/
# PyCharm
# http://www.jetbrains.com/pycharm/webhelp/project.html
.idea
.iml
static/
# virtualenv
venv/
collectedstatic/
djangoblog/whoosh_index/
google93fd32dbd906620a.html
baidu_verify_FlHL7cUyC9.html

@ -90,7 +90,6 @@ python manage.py migrate
# 创建一个超级管理员账户
python manage.py createsuperuser
--zyd202020 zbzydzfy123456
```
### 5. 运行项目
@ -147,7 +146,7 @@ python manage.py runserver
## 🙏 鸣谢
特别感谢 **JetBrains** 为本项目提供的免费开源许可证。
特别感谢 **JetBrains** 为本项目提供的免费开源许可证。gs130 ls syj zyd164
<p align="center">
<a href="https://www.jetbrains.com/?from=DjangoBlog">

@ -9,14 +9,21 @@ from .models import BlogUser
class BlogUserCreationForm(forms.ModelForm):
"""
用于在管理员界面创建BlogUser的表单
"""
# 密码输入字段
password1 = forms.CharField(label=_('password'), widget=forms.PasswordInput)
password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput)
class Meta:
model = BlogUser
fields = ('email',)
fields = ('email',) # 表单包含的字段
def clean_password2(self):
"""
验证两个密码字段是否匹配
"""
# Check that the two password entries match
password1 = self.cleaned_data.get("password1")
password2 = self.cleaned_data.get("password2")
@ -25,28 +32,39 @@ class BlogUserCreationForm(forms.ModelForm):
return password2
def save(self, commit=True):
"""
以哈希格式保存密码
"""
# Save the provided password in hashed format
user = super().save(commit=False)
user.set_password(self.cleaned_data["password1"])
if commit:
user.source = 'adminsite'
user.source = 'adminsite' # 设置用户来源为管理站点
user.save()
return user
class BlogUserChangeForm(UserChangeForm):
"""
用于在管理员界面修改BlogUser信息的表单
"""
class Meta:
model = BlogUser
fields = '__all__'
field_classes = {'username': UsernameField}
fields = '__all__' # 包含所有字段
field_classes = {'username': UsernameField} # 指定username字段的类型
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class BlogUserAdmin(UserAdmin):
form = BlogUserChangeForm
add_form = BlogUserCreationForm
"""
BlogUser模型在管理员界面的配置类
"""
form = BlogUserChangeForm # 修改用户时使用的表单
add_form = BlogUserCreationForm # 创建用户时使用的表单
# 在列表中显示的字段
list_display = (
'id',
'nickname',
@ -55,24 +73,10 @@ class BlogUserAdmin(UserAdmin):
'last_login',
'date_joined',
'source')
# 在列表中可以点击跳转到编辑页的字段
list_display_links = ('id', 'username')
# 默认排序方式
ordering = ('-id',)
search_fields = ('username', 'nickname', 'email')
# 修复字段集配置
fieldsets = (
(None, {'fields': ('username', 'password')}),
(_('Personal info'), {'fields': ('first_name', 'last_name', 'email', 'nickname')}),
(_('Permissions'), {
'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions'),
}),
(_('Important dates'), {'fields': ('last_login', 'date_joined')}),
(_('Additional info'), {'fields': ('source', 'creation_time', 'last_modify_time')}),
)
add_fieldsets = (
(None, {
'classes': ('wide',),
'fields': ('username', 'email', 'password1', 'password2'),
}),
)

@ -2,4 +2,8 @@ from django.apps import AppConfig
class AccountsConfig(AppConfig):
name = 'accounts'
"""
Django应用配置类用于配置accounts应用的基本信息
"""
name = 'accounts' # 定义应用的名称,与项目中的应用目录名一致

@ -9,28 +9,43 @@ from .models import BlogUser
class LoginForm(AuthenticationForm):
"""
用户登录表单继承自Django内置的AuthenticationForm
"""
def __init__(self, *args, **kwargs):
super(LoginForm, self).__init__(*args, **kwargs)
# 自定义用户名输入框的样式和属性
self.fields['username'].widget = widgets.TextInput(
attrs={'placeholder': "username", "class": "form-control"})
# 自定义密码输入框的样式和属性
self.fields['password'].widget = widgets.PasswordInput(
attrs={'placeholder': "password", "class": "form-control"})
class RegisterForm(UserCreationForm):
"""
用户注册表单继承自Django内置的UserCreationForm
"""
def __init__(self, *args, **kwargs):
super(RegisterForm, self).__init__(*args, **kwargs)
# 自定义用户名输入框样式
self.fields['username'].widget = widgets.TextInput(
attrs={'placeholder': "username", "class": "form-control"})
# 自定义邮箱输入框样式
self.fields['email'].widget = widgets.EmailInput(
attrs={'placeholder': "email", "class": "form-control"})
# 自定义密码输入框样式
self.fields['password1'].widget = widgets.PasswordInput(
attrs={'placeholder': "password", "class": "form-control"})
# 自定义确认密码输入框样式
self.fields['password2'].widget = widgets.PasswordInput(
attrs={'placeholder': "repeat password", "class": "form-control"})
def clean_email(self):
"""
验证邮箱是否已经被注册
"""
email = self.cleaned_data['email']
if get_user_model().objects.filter(email=email).exists():
raise ValidationError(_("email already exists"))
@ -38,10 +53,14 @@ class RegisterForm(UserCreationForm):
class Meta:
model = get_user_model()
fields = ("username", "email")
fields = ("username", "email") # 定义表单包含的字段
class ForgetPasswordForm(forms.Form):
"""
忘记密码表单用于用户重置密码
"""
# 新密码输入字段
new_password1 = forms.CharField(
label=_("New password"),
widget=forms.PasswordInput(
@ -52,6 +71,7 @@ class ForgetPasswordForm(forms.Form):
),
)
# 确认新密码字段
new_password2 = forms.CharField(
label="确认密码",
widget=forms.PasswordInput(
@ -62,6 +82,7 @@ class ForgetPasswordForm(forms.Form):
),
)
# 邮箱字段
email = forms.EmailField(
label='邮箱',
widget=forms.TextInput(
@ -72,6 +93,7 @@ class ForgetPasswordForm(forms.Form):
),
)
# 验证码字段
code = forms.CharField(
label=_('Code'),
widget=forms.TextInput(
@ -83,6 +105,9 @@ class ForgetPasswordForm(forms.Form):
)
def clean_new_password2(self):
"""
验证两次输入的密码是否一致并验证密码强度
"""
password1 = self.data.get("new_password1")
password2 = self.data.get("new_password2")
if password1 and password2 and password1 != password2:
@ -92,6 +117,9 @@ class ForgetPasswordForm(forms.Form):
return password2
def clean_email(self):
"""
验证邮箱是否存在
"""
user_email = self.cleaned_data.get("email")
if not BlogUser.objects.filter(
email=user_email
@ -101,6 +129,9 @@ class ForgetPasswordForm(forms.Form):
return user_email
def clean_code(self):
"""
验证验证码是否正确
"""
code = self.cleaned_data.get("code")
error = utils.verify(
email=self.cleaned_data.get("email"),
@ -112,6 +143,10 @@ class ForgetPasswordForm(forms.Form):
class ForgetPasswordCodeForm(forms.Form):
"""
忘记密码时获取验证码的表单
"""
email = forms.EmailField(
label=_('Email'),
)

@ -7,43 +7,66 @@ import django.utils.timezone
class Migration(migrations.Migration):
# 标记这是一个初始迁移文件
initial = True
# 定义依赖关系依赖于auth应用的0012_alter_user_first_name_max_length迁移
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
# 定义具体的操作
operations = [
# 创建BlogUser模型
migrations.CreateModel(
name='BlogUser',
fields=[
# 主键字段自动创建的BigAutoField
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 密码字段最大长度128字符
('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',
'verbose_name': '用户', # 单数名称
'verbose_name_plural': '用户', # 复数名称
'ordering': ['-id'], # 默认排序方式
'get_latest_by': 'id', # 获取最新记录的字段
},
# 模型管理器
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
]

@ -5,42 +5,56 @@ import django.utils.timezone
class Migration(migrations.Migration):
# 定义该迁移文件的依赖关系依赖于accounts应用的0001_initial迁移
dependencies = [
('accounts', '0001_initial'),
]
# 定义具体的迁移操作
operations = [
# 修改BlogUser模型的选项配置
migrations.AlterModelOptions(
name='bloguser',
options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'user', 'verbose_name_plural': 'user'},
options={
'get_latest_by': 'id', # 获取最新记录的字段
'ordering': ['-id'], # 默认排序方式
'verbose_name': 'user', # 单数名称(英文)
'verbose_name_plural': 'user' # 复数名称(英文)
},
),
# 移除BlogUser模型中的created_time字段
migrations.RemoveField(
model_name='bloguser',
name='created_time',
),
# 移除BlogUser模型中的last_mod_time字段
migrations.RemoveField(
model_name='bloguser',
name='last_mod_time',
),
# 添加creation_time字段到BlogUser模型
migrations.AddField(
model_name='bloguser',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 添加last_modify_time字段到BlogUser模型
migrations.AddField(
model_name='bloguser',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
),
# 修改BlogUser模型中的nickname字段
migrations.AlterField(
model_name='bloguser',
name='nickname',
field=models.CharField(blank=True, max_length=100, verbose_name='nick name'),
),
# 修改BlogUser模型中的source字段
migrations.AlterField(
model_name='bloguser',
name='source',
field=models.CharField(blank=True, max_length=100, verbose_name='create source'),
),
]

@ -9,27 +9,93 @@ from djangoblog.utils import get_current_site
# Create your models here.
class BlogUser(AbstractUser):
"""
<<<<<<< HEAD
博客用户模型类
继承自Django的AbstractUser扩展了额外的用户信息字段
Attributes:
nickname (CharField): 用户昵称最大长度100个字符可为空
creation_time (DateTimeField): 用户创建时间默认为当前时间
last_modify_time (DateTimeField): 用户信息最后修改时间默认为当前时间
source (CharField): 用户创建来源最大长度100个字符可为空
Meta:
ordering: 按照id倒序排列
verbose_name: 用户的可读性名称
verbose_name_plural: 用户的复数形式名称
get_latest_by: 指定用于latest()查询的字段
"""
=======
博客用户模型继承自Django的AbstractUser
扩展了用户的基本信息
"""
# 用户昵称,可为空
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
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):
"""
<<<<<<< HEAD
获取用户详情页URL
通过reverse函数解析URL使用author_detail命名URL模式
Returns:
str: 用户详情页的相对URL路径
=======
获取用户详情页的绝对URL
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
return reverse(
'blog:author_detail', kwargs={
'author_name': self.username})
def __str__(self):
"""
<<<<<<< HEAD
模型的字符串表示
Returns:
str: 用户邮箱地址
=======
定义对象的字符串表示返回用户的邮箱
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
return self.email
def get_full_url(self):
"""
<<<<<<< HEAD
获取用户页面的完整URL包含域名
用于构建完整的用户页面链接包含协议和域名
Returns:
str: 完整的用户页面URL格式为 https://{site}{path}
=======
获取用户的完整URL地址
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
site = get_current_site().domain
url = "https://{site}{path}".format(site=site,
path=self.get_absolute_url())
return url
class Meta:
<<<<<<< HEAD
ordering = ['-id']
verbose_name = _('user')
verbose_name_plural = verbose_name
get_latest_by = 'id'
=======
ordering = ['-id'] # 默认按ID降序排列
verbose_name = _('user') # 单数名称
verbose_name_plural = verbose_name # 复数名称
get_latest_by = 'id' # 获取最新记录的字段
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc

@ -12,17 +12,26 @@ from . import utils
# Create your tests here.
class AccountTest(TestCase):
"""
账户相关功能的测试类
"""
def setUp(self):
self.client = Client()
self.factory = RequestFactory()
"""
测试前的准备工作创建测试客户端请求工厂和测试用户
"""
self.client = Client() # 创建测试客户端
self.factory = RequestFactory() # 创建请求工厂
self.blog_user = BlogUser.objects.create_user(
username="test",
email="admin@admin.com",
password="12345678"
)
self.new_test = "xxx123--="
self.new_test = "xxx123--=" # 测试用的新密码
def test_validate_account(self):
"""
测试账户验证功能
"""
site = get_current_site().domain
user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com",
@ -30,6 +39,7 @@ class AccountTest(TestCase):
password="qwer!@#$ggg")
testuser = BlogUser.objects.get(username='liangliangyy1')
# 测试登录
loginresult = self.client.login(
username='liangliangyy1',
password='qwer!@#$ggg')
@ -37,12 +47,14 @@ class AccountTest(TestCase):
response = self.client.get('/admin/')
self.assertEqual(response.status_code, 200)
# 创建测试分类
category = Category()
category.name = "categoryaaa"
category.creation_time = timezone.now()
category.last_modify_time = timezone.now()
category.save()
# 创建测试文章
article = Article()
article.title = "nicetitleaaa"
article.body = "nicecontentaaa"
@ -52,21 +64,26 @@ class AccountTest(TestCase):
article.status = 'p'
article.save()
# 测试访问文章管理URL
response = self.client.get(article.get_admin_url())
self.assertEqual(response.status_code, 200)
def test_validate_register(self):
self.assertEqual(
"""
测试用户注册功能
"""
self.assertEquals(
0, len(
BlogUser.objects.filter(
email='user123@user.com')))
# 测试用户注册
response = self.client.post(reverse('account:register'), {
'username': 'user1233',
'email': 'user123@user.com',
'password1': 'password123!q@wE#R$T',
'password2': 'password123!q@wE#R$T',
})
self.assertEqual(
self.assertEquals(
1, len(
BlogUser.objects.filter(
email='user123@user.com')))
@ -78,6 +95,7 @@ class AccountTest(TestCase):
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
# 测试登录后操作
self.client.login(username='user1233', password='password123!q@wE#R$T')
user = BlogUser.objects.filter(email='user123@user.com')[0]
user.is_superuser = True
@ -103,12 +121,14 @@ class AccountTest(TestCase):
response = self.client.get(article.get_admin_url())
self.assertEqual(response.status_code, 200)
# 测试登出
response = self.client.get(reverse('account:logout'))
self.assertIn(response.status_code, [301, 302, 200])
response = self.client.get(article.get_admin_url())
self.assertIn(response.status_code, [301, 302, 200])
# 测试错误密码登录
response = self.client.post(reverse('account:login'), {
'username': 'user1233',
'password': 'password123'
@ -119,18 +139,26 @@ class AccountTest(TestCase):
self.assertIn(response.status_code, [301, 302, 200])
def test_verify_email_code(self):
"""
测试邮箱验证码验证功能
"""
to_email = "admin@admin.com"
code = generate_code()
utils.set_code(to_email, code)
utils.send_verify_email(to_email, code)
# 测试正确验证码
err = utils.verify("admin@admin.com", code)
self.assertEqual(err, None)
# 测试错误邮箱
err = utils.verify("admin@123.com", code)
self.assertEqual(type(err), str)
def test_forget_password_email_code_success(self):
"""
测试忘记密码时成功获取验证码
"""
resp = self.client.post(
path=reverse("account:forget_password_code"),
data=dict(email="admin@admin.com")
@ -140,12 +168,17 @@ class AccountTest(TestCase):
self.assertEqual(resp.content.decode("utf-8"), "ok")
def test_forget_password_email_code_fail(self):
"""
测试忘记密码时获取验证码失败的情况
"""
# 测试空邮箱
resp = self.client.post(
path=reverse("account:forget_password_code"),
data=dict()
)
self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱")
# 测试无效邮箱格式
resp = self.client.post(
path=reverse("account:forget_password_code"),
data=dict(email="admin@com")
@ -153,6 +186,9 @@ class AccountTest(TestCase):
self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱")
def test_forget_password_email_success(self):
"""
测试通过邮箱成功重置密码
"""
code = generate_code()
utils.set_code(self.blog_user.email, code)
data = dict(
@ -175,6 +211,9 @@ class AccountTest(TestCase):
self.assertEqual(blog_user.check_password(data["new_password1"]), True)
def test_forget_password_email_not_user(self):
"""
测试为不存在的用户重置密码
"""
data = dict(
new_password1=self.new_test,
new_password2=self.new_test,
@ -188,8 +227,10 @@ class AccountTest(TestCase):
self.assertEqual(resp.status_code, 200)
def test_forget_password_email_code_error(self):
"""
测试使用错误验证码重置密码
"""
code = generate_code()
utils.set_code(self.blog_user.email, code)
data = dict(
@ -205,31 +246,3 @@ class AccountTest(TestCase):
self.assertEqual(resp.status_code, 200)
def test_favorites_page(self):
user = BlogUser.objects.create_superuser(
email="fav@user.com",
username="favuser",
password="favpass")
category = Category()
category.name = "favcategory"
category.save()
article = Article()
article.title = "favorite title"
article.body = "favorite body"
article.author = user
article.category = category
article.type = 'a'
article.status = 'p'
article.save()
# 收藏
article.favorite_users.add(user)
# 登录并访问收藏页
self.client.login(username='favuser', password='favpass')
resp = self.client.get(reverse('account:favorites'))
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, "favorite title")

@ -4,26 +4,38 @@ from django.urls import re_path
from . import views
from .forms import LoginForm
app_name = "accounts"
app_name = "accounts" # 定义应用的命名空间
urlpatterns = [re_path(r'^login/$',
urlpatterns = [
# 登录URL使用自定义的LoginForm表单
re_path(r'^login/$',
views.LoginView.as_view(success_url='/'),
name='login',
kwargs={'authentication_form': LoginForm}),
re_path(r'^register/$',
# 注册URL
re_path(r'^register/$',
views.RegisterView.as_view(success_url="/"),
name='register'),
re_path(r'^logout/$',
# 登出URL
re_path(r'^logout/$',
views.LogoutView.as_view(),
name='logout'),
path(r'account/result.html',
# 账户操作结果页面URL
path(r'account/result.html',
views.account_result,
name='result'),
re_path(r'^forget_password/$',
# 忘记密码页面URL
re_path(r'^forget_password/$',
views.ForgetPasswordView.as_view(),
name='forget_password'),
re_path(r'^forget_password_code/$',
# 忘记密码验证码发送URL
re_path(r'^forget_password_code/$',
views.ForgetPasswordEmailCode.as_view(),
name='forget_password_code'),
path('favorites/', views.FavoriteArticlesView.as_view(), name='favorites'),
]
]

@ -4,23 +4,52 @@ from django.contrib.auth.backends import ModelBackend
class EmailOrUsernameModelBackend(ModelBackend):
"""
允许使用用户名或邮箱登录
自定义认证后端允许用户使用用户名或邮箱进行登录
"""
def authenticate(self, request, username=None, password=None, **kwargs):
"""
重写authenticate方法支持邮箱或用户名登录
Args:
request: HTTP请求对象
username: 用户名或邮箱
password: 密码
**kwargs: 其他参数
Returns:
用户对象或None
"""
# 判断输入的是邮箱还是用户名
if '@' in username:
kwargs = {'email': username}
kwargs = {'email': username} # 使用邮箱查询
else:
kwargs = {'username': username}
kwargs = {'username': username} # 使用用户名查询
try:
# 获取用户对象
user = get_user_model().objects.get(**kwargs)
# 验证密码是否正确
if user.check_password(password):
return user
except get_user_model().DoesNotExist:
# 用户不存在时返回None
return None
def get_user(self, username):
"""
根据用户ID获取用户对象
Args:
username: 用户ID
Returns:
用户对象或None
"""
try:
# 根据主键获取用户
return get_user_model().objects.get(pk=username)
except get_user_model().DoesNotExist:
# 用户不存在时返回None
return None

@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _
from djangoblog.utils import send_email
_code_ttl = timedelta(minutes=5)
_code_ttl = timedelta(minutes=5) # 验证码有效期为5分钟
def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email")):
@ -17,6 +17,7 @@ def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email"))
subject: 邮件主题
code: 验证码
"""
# 构造邮件内容,包含验证码和有效期提示
html_content = _(
"You are resetting the password, the verification code is%(code)s, valid within 5 minutes, please keep it "
"properly") % {'code': code}
@ -30,20 +31,32 @@ def verify(email: str, code: str) -> typing.Optional[str]:
code: 验证码
Return:
如果有错误就返回错误str
Node:
Note:
这里的错误处理不太合理应该采用raise抛出
否测调用方也需要对error进行处理
"""
cache_code = get_code(email)
cache_code = get_code(email) # 从缓存中获取存储的验证码
if cache_code != code:
return gettext("Verification code error")
return gettext("Verification code error") # 验证码不匹配时返回错误信息
def set_code(email: str, code: str):
"""设置code"""
cache.set(email, code, _code_ttl.seconds)
"""设置code
将验证码存储到缓存中设置过期时间
Args:
email: 邮箱地址作为缓存的键
code: 验证码作为缓存的值
"""
cache.set(email, code, _code_ttl.seconds) # 使用邮箱作为键存储验证码
def get_code(email: str) -> typing.Optional[str]:
"""获取code"""
return cache.get(email)
"""获取code
从缓存中获取指定邮箱的验证码
Args:
email: 邮箱地址
Returns:
验证码字符串或None如果不存在或已过期
"""
return cache.get(email) # 从缓存中获取验证码

@ -20,42 +20,62 @@ from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_protect
from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import FormView, RedirectView
from django.views.generic.list import ListView
from django.contrib.auth.mixins import LoginRequiredMixin
from djangoblog.utils import send_email, get_sha256, get_current_site, generate_code, delete_sidebar_cache
from . import utils
from .forms import RegisterForm, LoginForm, ForgetPasswordForm, ForgetPasswordCodeForm
from .models import BlogUser
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__) # 创建日志记录器
# Create your views here.
class RegisterView(FormView):
"""
用户注册视图类
<<<<<<< HEAD
处理用户注册表单提交和验证邮箱功能
"""
form_class = RegisterForm
template_name = 'account/registration_form.html'
=======
"""
form_class = RegisterForm # 使用的表单类
template_name = 'account/registration_form.html' # 模板文件
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
@method_decorator(csrf_protect)
@method_decorator(csrf_protect) # 添加CSRF保护装饰器
def dispatch(self, *args, **kwargs):
"""
调度方法添加CSRF保护装饰器
"""
return super(RegisterView, self).dispatch(*args, **kwargs)
def form_valid(self, form):
"""
<<<<<<< HEAD
处理有效的注册表单
保存用户信息发送验证邮件
=======
表单验证成功时的处理方法
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
if form.is_valid():
user = form.save(False)
user.is_active = False
user.source = 'Register'
user.save(True)
site = get_current_site().domain
sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
user = form.save(False) # 保存表单但不提交到数据库
user.is_active = False # 设置用户为非活跃状态,需要邮箱验证
user.source = 'Register' # 设置用户来源
user.save(True) # 提交到数据库
site = get_current_site().domain # 获取当前站点域名
sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) # 生成验证签名
if settings.DEBUG:
site = '127.0.0.1:8000'
path = reverse('account:result')
site = '127.0.0.1:8000' # 调试模式下使用本地地址
path = reverse('account:result') # 获取验证结果页面URL
url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format(
site=site, path=path, id=user.id, sign=sign)
# 构造验证邮件内容
content = """
<p>请点击下面链接验证您的邮箱</p>
@ -66,6 +86,7 @@ class RegisterView(FormView):
如果上面链接无法打开请将此链接复制至浏览器
{url}
""".format(url=url)
# 发送验证邮件
send_email(
emailto=[
user.email,
@ -75,7 +96,7 @@ class RegisterView(FormView):
url = reverse('accounts:result') + \
'?type=register&id=' + str(user.id)
return HttpResponseRedirect(url)
return HttpResponseRedirect(url) # 重定向到注册结果页面
else:
return self.render_to_response({
'form': form
@ -83,33 +104,80 @@ class RegisterView(FormView):
class LogoutView(RedirectView):
"""
用户登出视图类
<<<<<<< HEAD
处理用户登出逻辑并重定向到登录页面
"""
url = '/login/'
=======
"""
url = '/login/' # 登出后重定向的URL
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
@method_decorator(never_cache)
@method_decorator(never_cache) # 添加不缓存装饰器
def dispatch(self, request, *args, **kwargs):
"""
调度方法添加不缓存装饰器
"""
return super(LogoutView, self).dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
"""
处理GET请求执行登出操作
"""
<<<<<<< HEAD
logout(request)
delete_sidebar_cache()
return super(LogoutView, self).get(request, *args, **kwargs)
=======
logout(request) # 执行登出
delete_sidebar_cache() # 删除侧边栏缓存
return super(LogoutView, self).get(request, *args, **kwargs) # 重定向到登录页
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
class LoginView(FormView):
"""
用户登录视图类
<<<<<<< HEAD
处理用户登录表单和认证逻辑
"""
form_class = LoginForm
template_name = 'account/login.html'
success_url = '/'
redirect_field_name = REDIRECT_FIELD_NAME
login_ttl = 2626560 # 一个月的时间
@method_decorator(sensitive_post_parameters('password'))
@method_decorator(csrf_protect)
@method_decorator(never_cache)
=======
"""
form_class = LoginForm # 使用的表单类
template_name = 'account/login.html' # 模板文件
success_url = '/' # 登录成功后重定向的URL
redirect_field_name = REDIRECT_FIELD_NAME # 重定向字段名
login_ttl = 2626560 # 登录会话保持时间(一个月)
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
@method_decorator(sensitive_post_parameters('password')) # 敏感参数保护
@method_decorator(csrf_protect) # CSRF保护
@method_decorator(never_cache) # 不缓存
def dispatch(self, request, *args, **kwargs):
<<<<<<< HEAD
"""
调度方法添加敏感参数保护CSRF保护和不缓存装饰器
"""
=======
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
return super(LoginView, self).dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
"""
<<<<<<< HEAD
获取上下文数据处理重定向URL
=======
获取上下文数据添加重定向URL
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
redirect_to = self.request.GET.get(self.redirect_field_name)
if redirect_to is None:
redirect_to = '/'
@ -118,25 +186,39 @@ class LoginView(FormView):
return super(LoginView, self).get_context_data(**kwargs)
def form_valid(self, form):
"""
<<<<<<< HEAD
处理有效的登录表单
进行用户认证并登录
=======
表单验证成功时的处理方法
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
form = AuthenticationForm(data=self.request.POST, request=self.request)
if form.is_valid():
delete_sidebar_cache()
delete_sidebar_cache() # 删除侧边栏缓存
logger.info(self.redirect_field_name)
auth.login(self.request, form.get_user())
if self.request.POST.get("remember"):
self.request.session.set_expiry(self.login_ttl)
auth.login(self.request, form.get_user()) # 执行登录
if self.request.POST.get("remember"): # 如果选择了记住登录
self.request.session.set_expiry(self.login_ttl) # 设置会话过期时间
return super(LoginView, self).form_valid(form)
# return HttpResponseRedirect('/')
else:
return self.render_to_response({
'form': form
})
def get_success_url(self):
"""
获取登录成功后的重定向URL
"""
<<<<<<< HEAD
=======
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
redirect_to = self.request.POST.get(self.redirect_field_name)
# 验证重定向URL是否安全
if not url_has_allowed_host_and_scheme(
url=redirect_to, allowed_hosts=[
self.request.get_host()]):
@ -145,79 +227,117 @@ class LoginView(FormView):
def account_result(request):
"""
<<<<<<< HEAD
账户操作结果视图函数
处理注册和邮箱验证的结果页面显示
"""
type = request.GET.get('type')
id = request.GET.get('id')
user = get_object_or_404(get_user_model(), id=id)
=======
账户操作结果页面
"""
type = request.GET.get('type') # 获取操作类型
id = request.GET.get('id') # 获取用户ID
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
user = get_object_or_404(get_user_model(), id=id) # 获取用户对象
logger.info(type)
if user.is_active:
return HttpResponseRedirect('/')
return HttpResponseRedirect('/') # 如果用户已激活,重定向到首页
if type and type in ['register', 'validation']:
if type == 'register':
# 注册成功提示
content = '''
恭喜您注册成功一封验证邮件已经发送到您的邮箱请验证您的邮箱后登录本站
'''
title = '注册成功'
else:
# 邮箱验证处理
c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
sign = request.GET.get('sign')
if sign != c_sign:
return HttpResponseForbidden()
user.is_active = True
return HttpResponseForbidden() # 签名验证失败
user.is_active = True # 激活用户
user.save()
content = '''
恭喜您已经成功的完成邮箱验证您现在可以使用您的账号来登录本站
'''
title = '验证成功'
# 渲染结果页面
return render(request, 'account/result.html', {
'title': title,
'content': content
})
else:
return HttpResponseRedirect('/')
return HttpResponseRedirect('/') # 重定向到首页
class ForgetPasswordView(FormView):
"""
忘记密码视图类
<<<<<<< HEAD
处理用户忘记密码的重置操作
"""
form_class = ForgetPasswordForm
template_name = 'account/forget_password.html'
def form_valid(self, form):
"""
处理有效的忘记密码表单
更新用户密码
=======
"""
form_class = ForgetPasswordForm # 使用的表单类
template_name = 'account/forget_password.html' # 模板文件
def form_valid(self, form):
"""
表单验证成功时的处理方法
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
if form.is_valid():
# 根据邮箱查找用户并更新密码
blog_user = BlogUser.objects.filter(email=form.cleaned_data.get("email")).get()
blog_user.password = make_password(form.cleaned_data["new_password2"])
blog_user.password = make_password(form.cleaned_data["new_password2"]) # 加密新密码
blog_user.save()
return HttpResponseRedirect('/login/')
return HttpResponseRedirect('/login/') # 重定向到登录页面
else:
return self.render_to_response({'form': form})
class ForgetPasswordEmailCode(View):
"""
<<<<<<< HEAD
忘记密码邮箱验证码视图类
处理通过邮箱发送验证码的请求
=======
忘记密码时发送验证码视图类
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
def post(self, request: HttpRequest):
"""
<<<<<<< HEAD
处理POST请求发送验证码到用户邮箱
"""
form = ForgetPasswordCodeForm(request.POST)
=======
处理POST请求发送验证码邮件
"""
form = ForgetPasswordCodeForm(request.POST) # 验证表单
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
if not form.is_valid():
return HttpResponse("错误的邮箱")
to_email = form.cleaned_data["email"]
code = generate_code()
utils.send_verify_email(to_email, code)
utils.set_code(to_email, code)
return HttpResponse("错误的邮箱") # 表单验证失败返回错误信息
to_email = form.cleaned_data["email"] # 获取邮箱
return HttpResponse("ok")
class FavoriteArticlesView(LoginRequiredMixin, ListView):
template_name = 'blog/article_index.html'
context_object_name = 'article_list'
paginate_by = settings.PAGINATE_BY if hasattr(settings, 'PAGINATE_BY') else 10
code = generate_code() # 生成验证码
utils.send_verify_email(to_email, code) # 发送验证码邮件
utils.set_code(to_email, code) # 将验证码存储到缓存
def get_queryset(self):
return self.request.user.favorite_articles.filter(status='p').order_by('-pub_time')
return HttpResponse("ok") # 返回成功信息
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['page_type'] = '我的收藏'
ctx['tag_name'] = None
ctx['linktype'] = 'i'
ctx['sort'] = 'latest'
return ctx
<<<<<<< HEAD
return HttpResponse("ok")
=======
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc

@ -6,33 +6,49 @@ from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
# Register your models here.
from .models import Article, Category, Tag, Links, SideBar, BlogSettings
from .models import Article
class ArticleForm(forms.ModelForm):
"""
文章表单类用于在管理界面编辑文章
"""
# body = forms.CharField(widget=AdminPagedownWidget())
class Meta:
model = Article
fields = '__all__'
fields = '__all__' # 包含所有字段
def makr_article_publish(modeladmin, request, queryset):
queryset.update(status='p')
"""
批量发布文章操作
"""
queryset.update(status='p') # 将选中的文章状态设置为已发布
def draft_article(modeladmin, request, queryset):
queryset.update(status='d')
"""
批量将文章设为草稿操作
"""
queryset.update(status='d') # 将选中的文章状态设置为草稿
def close_article_commentstatus(modeladmin, request, queryset):
queryset.update(comment_status='c')
"""
批量关闭文章评论功能
"""
queryset.update(comment_status='c') # 将选中的文章评论状态设置为关闭
def open_article_commentstatus(modeladmin, request, queryset):
queryset.update(comment_status='o')
"""
批量开启文章评论功能
"""
queryset.update(comment_status='o') # 将选中的文章评论状态设置为开启
# 为批量操作设置显示名称
makr_article_publish.short_description = _('Publish selected articles')
draft_article.short_description = _('Draft selected articles')
close_article_commentstatus.short_description = _('Close article comments')
@ -40,10 +56,13 @@ open_article_commentstatus.short_description = _('Open article comments')
class ArticlelAdmin(admin.ModelAdmin):
list_per_page = 20
search_fields = ('body', 'title')
form = ArticleForm
list_display = (
"""
文章模型在管理界面的配置类
"""
list_per_page = 20 # 每页显示20条记录
search_fields = ('body', 'title') # 设置搜索字段
form = ArticleForm # 使用自定义表单
list_display = ( # 列表页显示的字段
'id',
'title',
'author',
@ -53,36 +72,46 @@ class ArticlelAdmin(admin.ModelAdmin):
'status',
'type',
'article_order')
list_display_links = ('id', 'title')
list_filter = ('status', 'type', 'category')
date_hierarchy = 'creation_time'
filter_horizontal = ('tags',)
exclude = ('creation_time', 'last_modify_time')
view_on_site = True
actions = [
list_display_links = ('id', 'title') # 列表页中可点击进入编辑页的字段
list_filter = ('status', 'type', 'category') # 设置过滤器
filter_horizontal = ('tags',) # 标签字段使用水平过滤器
exclude = ('creation_time', 'last_modify_time') # 在表单中排除这些字段
view_on_site = True # 显示"在站点上查看"链接
actions = [ # 注册批量操作
makr_article_publish,
draft_article,
close_article_commentstatus,
open_article_commentstatus]
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'<a href="%s">%s</a>' % (link, obj.category.name))
link_to_category.short_description = _('category')
link_to_category.short_description = _('category') # 设置列名
def get_form(self, request, obj=None, **kwargs):
"""
获取表单限制作者字段只能选择超级用户
"""
form = super(ArticlelAdmin, self).get_form(request, obj, **kwargs)
form.base_fields['author'].queryset = get_user_model(
).objects.filter(is_superuser=True)
return form
def save_model(self, request, obj, form, change):
"""
保存模型实例
"""
super(ArticlelAdmin, self).save_model(request, obj, form, change)
def get_view_on_site_url(self, obj=None):
"""
获取在站点上查看的URL
"""
if obj:
url = obj.get_full_url()
return url
@ -93,22 +122,38 @@ class ArticlelAdmin(admin.ModelAdmin):
class TagAdmin(admin.ModelAdmin):
exclude = ('slug', 'last_mod_time', 'creation_time')
"""
标签模型在管理界面的配置类
"""
exclude = ('slug', 'last_mod_time', 'creation_time') # 排除字段
class CategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'parent_category', 'index')
exclude = ('slug', 'last_mod_time', 'creation_time')
"""
分类模型在管理界面的配置类
"""
list_display = ('name', 'parent_category', 'index') # 列表页显示字段
exclude = ('slug', 'last_mod_time', 'creation_time') # 排除字段
class LinksAdmin(admin.ModelAdmin):
exclude = ('last_mod_time', 'creation_time')
"""
友情链接模型在管理界面的配置类
"""
exclude = ('last_mod_time', 'creation_time') # 排除字段
class SideBarAdmin(admin.ModelAdmin):
list_display = ('name', 'content', 'is_enable', 'sequence')
exclude = ('last_mod_time', 'creation_time')
"""
侧边栏模型在管理界面的配置类
"""
list_display = ('name', 'content', 'is_enable', 'sequence') # 列表页显示字段
exclude = ('last_mod_time', 'creation_time') # 排除字段
class BlogSettingsAdmin(admin.ModelAdmin):
"""
博客设置模型在管理界面的配置类
"""
pass

@ -2,4 +2,7 @@ from django.apps import AppConfig
class BlogConfig(AppConfig):
name = 'blog'
"""
Django应用配置类用于配置blog应用的基本信息
"""
name = 'blog' # 定义应用的名称,与项目中的应用目录名一致

@ -5,39 +5,55 @@ from django.utils import timezone
from djangoblog.utils import cache, get_blog_setting
from .models import Category, Article
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__) # 创建日志记录器
def seo_processor(requests):
key = 'seo_processor'
value = cache.get(key)
"""
SEO上下文处理器为模板提供SEO相关数据
Args:
requests: HTTP请求对象
Returns:
dict: 包含SEO和网站配置信息的字典
"""
key = 'seo_processor' # 缓存键名
value = cache.get(key) # 从缓存中获取数据
if value:
# 如果缓存中存在数据,直接返回
return value
else:
# 如果缓存中没有数据,记录日志并生成新数据
logger.info('set processor cache.')
setting = get_blog_setting()
setting = get_blog_setting() # 获取博客设置
# 构造返回值字典包含网站SEO和配置信息
value = {
'SITE_NAME': setting.site_name,
'SHOW_GOOGLE_ADSENSE': setting.show_google_adsense,
'GOOGLE_ADSENSE_CODES': setting.google_adsense_codes,
'SITE_SEO_DESCRIPTION': setting.site_seo_description,
'SITE_DESCRIPTION': setting.site_description,
'SITE_KEYWORDS': setting.site_keywords,
'SITE_BASE_URL': requests.scheme + '://' + requests.get_host() + '/',
'ARTICLE_SUB_LENGTH': setting.article_sub_length,
'nav_category_list': Category.objects.all(),
'nav_pages': Article.objects.filter(
'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, # 网站SEO描述
'SITE_DESCRIPTION': setting.site_description, # 网站描述
'SITE_KEYWORDS': setting.site_keywords, # 网站关键词
'SITE_BASE_URL': requests.scheme + '://' + requests.get_host() + '/', # 网站基础URL
'ARTICLE_SUB_LENGTH': setting.article_sub_length, # 文章摘要长度
'nav_category_list': Category.objects.all(), # 导航分类列表
'nav_pages': Article.objects.filter( # 导航页面列表
type='p',
status='p'),
'OPEN_SITE_COMMENT': setting.open_site_comment,
'BEIAN_CODE': setting.beian_code,
'ANALYTICS_CODE': setting.analytics_code,
"BEIAN_CODE_GONGAN": setting.gongan_beiancode,
"SHOW_GONGAN_CODE": setting.show_gongan_code,
"CURRENT_YEAR": timezone.now().year,
"GLOBAL_HEADER": setting.global_header,
"GLOBAL_FOOTER": setting.global_footer,
"COMMENT_NEED_REVIEW": setting.comment_need_review,
'OPEN_SITE_COMMENT': setting.open_site_comment, # 是否开启网站评论
'BEIAN_CODE': setting.beian_code, # 备案号
'ANALYTICS_CODE': setting.analytics_code, # 网站统计代码
"BEIAN_CODE_GONGAN": setting.gongan_beiancode, # 公安备案号
"SHOW_GONGAN_CODE": setting.show_gongan_code, # 是否显示公安备案号
"CURRENT_YEAR": timezone.now().year, # 当前年份
"GLOBAL_HEADER": setting.global_header, # 公共头部内容
"GLOBAL_FOOTER": setting.global_footer, # 公共尾部内容
"COMMENT_NEED_REVIEW": setting.comment_need_review, # 评论是否需要审核
}
# 将数据缓存10小时
cache.set(key, value, 60 * 60 * 10)
return value

@ -7,9 +7,11 @@ from elasticsearch_dsl.connections import connections
from blog.models import Article
# 检查是否在Django设置中配置了Elasticsearch
ELASTICSEARCH_ENABLED = hasattr(settings, 'ELASTICSEARCH_DSL')
if ELASTICSEARCH_ENABLED:
# 创建Elasticsearch连接
connections.create_connection(
hosts=[settings.ELASTICSEARCH_DSL['default']['hosts']])
from elasticsearch import Elasticsearch
@ -19,8 +21,10 @@ if ELASTICSEARCH_ENABLED:
c = IngestClient(es)
try:
# 尝试获取geoip管道
c.get_pipeline('geoip')
except elasticsearch.exceptions.NotFoundError:
# 如果geoip管道不存在则创建一个用于添加地理位置信息的管道
c.put_pipeline('geoip', body='''{
"description" : "Add geoip info",
"processors" : [
@ -34,57 +38,81 @@ if ELASTICSEARCH_ENABLED:
class GeoIp(InnerDoc):
continent_name = Keyword()
country_iso_code = Keyword()
country_name = Keyword()
location = GeoPoint()
"""
地理IP信息内部文档类
"""
continent_name = Keyword() # 大洲名称
country_iso_code = Keyword() # 国家ISO代码
country_name = Keyword() # 国家名称
location = GeoPoint() # 地理位置坐标
class UserAgentBrowser(InnerDoc):
Family = Keyword()
Version = Keyword()
"""
用户代理浏览器信息内部文档类
"""
Family = Keyword() # 浏览器家族
Version = Keyword() # 浏览器版本
class UserAgentOS(UserAgentBrowser):
"""
用户代理操作系统信息内部文档类
"""
pass
class UserAgentDevice(InnerDoc):
Family = Keyword()
Brand = Keyword()
Model = Keyword()
"""
用户代理设备信息内部文档类
"""
Family = Keyword() # 设备家族
Brand = Keyword() # 设备品牌
Model = Keyword() # 设备型号
class UserAgent(InnerDoc):
browser = Object(UserAgentBrowser, required=False)
os = Object(UserAgentOS, required=False)
device = Object(UserAgentDevice, required=False)
string = Text()
is_bot = Boolean()
"""
用户代理完整信息内部文档类
"""
browser = Object(UserAgentBrowser, required=False) # 浏览器信息
os = Object(UserAgentOS, required=False) # 操作系统信息
device = Object(UserAgentDevice, required=False) # 设备信息
string = Text() # 完整的User-Agent字符串
is_bot = Boolean() # 是否为爬虫
class ElapsedTimeDocument(Document):
url = Keyword()
time_taken = Long()
log_datetime = Date()
ip = Keyword()
geoip = Object(GeoIp, required=False)
useragent = Object(UserAgent, required=False)
"""
页面响应时间文档类用于记录网站性能数据
"""
url = Keyword() # 请求URL
time_taken = Long() # 耗时(毫秒)
log_datetime = Date() # 记录时间
ip = Keyword() # 访问者IP
geoip = Object(GeoIp, required=False) # 地理位置信息
useragent = Object(UserAgent, required=False) # 用户代理信息
class Index:
name = 'performance'
name = 'performance' # 索引名称
settings = {
"number_of_shards": 1,
"number_of_replicas": 0
"number_of_shards": 1, # 分片数量
"number_of_replicas": 0 # 副本数量
}
class Meta:
doc_type = 'ElapsedTime'
doc_type = 'ElapsedTime' # 文档类型
class ElaspedTimeDocumentManager:
"""
页面响应时间文档管理器类
"""
@staticmethod
def build_index():
"""
构建性能监控索引
"""
from elasticsearch import Elasticsearch
client = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
res = client.indices.exists(index="performance")
@ -93,13 +121,20 @@ class ElaspedTimeDocumentManager:
@staticmethod
def delete_index():
"""
删除性能监控索引
"""
from elasticsearch import Elasticsearch
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
es.indices.delete(index='performance', ignore=[400, 404])
@staticmethod
def create(url, time_taken, log_datetime, useragent, ip):
"""
创建并保存页面响应时间记录
"""
ElaspedTimeDocumentManager.build_index()
# 构建用户代理信息对象
ua = UserAgent()
ua.browser = UserAgentBrowser()
ua.browser.Family = useragent.browser.family
@ -116,98 +151,125 @@ class ElaspedTimeDocumentManager:
ua.string = useragent.ua_string
ua.is_bot = useragent.is_bot
# 创建文档实例
doc = ElapsedTimeDocument(
meta={
'id': int(
round(
time.time() *
1000))
1000)) # 使用当前时间戳作为文档ID
},
url=url,
time_taken=time_taken,
log_datetime=log_datetime,
useragent=ua, ip=ip)
useragent=ua,
ip=ip)
# 保存文档并使用geoip管道处理IP地理位置信息
doc.save(pipeline="geoip")
class ArticleDocument(Document):
body = Text(analyzer='ik_max_word', search_analyzer='ik_smart')
title = Text(analyzer='ik_max_word', search_analyzer='ik_smart')
author = Object(properties={
"""
文章文档类用于Elasticsearch全文搜索
"""
body = Text(analyzer='ik_max_word', search_analyzer='ik_smart') # 文章正文使用IK分词器
title = Text(analyzer='ik_max_word', search_analyzer='ik_smart') # 文章标题使用IK分词器
author = Object(properties={ # 作者信息
'nickname': Text(analyzer='ik_max_word', search_analyzer='ik_smart'),
'id': Integer()
})
category = Object(properties={
category = Object(properties={ # 分类信息
'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'),
'id': Integer()
})
tags = Object(properties={
tags = Object(properties={ # 标签信息
'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'),
'id': Integer()
})
pub_time = Date()
status = Text()
comment_status = Text()
type = Text()
views = Integer()
article_order = Integer()
pub_time = Date() # 发布时间
status = Text() # 文章状态
comment_status = Text() # 评论状态
type = Text() # 文章类型
views = Integer() # 浏览量
article_order = Integer() # 文章排序
class Index:
name = 'blog'
name = 'blog' # 索引名称
settings = {
"number_of_shards": 1,
"number_of_replicas": 0
"number_of_shards": 1, # 分片数量
"number_of_replicas": 0 # 副本数量
}
class Meta:
doc_type = 'Article'
doc_type = 'Article' # 文档类型
class ArticleDocumentManager():
"""
文章文档管理器类用于管理文章在Elasticsearch中的索引
"""
def __init__(self):
self.create_index()
def create_index(self):
"""
创建文章索引
"""
ArticleDocument.init()
def delete_index(self):
"""
删除文章索引
"""
from elasticsearch import Elasticsearch
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
es.indices.delete(index='blog', ignore=[400, 404])
def convert_to_doc(self, articles):
"""
将Django文章对象转换为Elasticsearch文档对象
"""
return [
ArticleDocument(
meta={
'id': article.id},
body=article.body,
title=article.title,
author={
'id': article.id}, # 文档ID
body=article.body, # 文章正文
title=article.title, # 文章标题
author={ # 作者信息
'nickname': article.author.username,
'id': article.author.id},
category={
category={ # 分类信息
'name': article.category.name,
'id': article.category.id},
tags=[
tags=[ # 标签信息
{
'name': t.name,
'id': t.id} for t in article.tags.all()],
pub_time=article.pub_time,
status=article.status,
comment_status=article.comment_status,
type=article.type,
views=article.views,
pub_time=article.pub_time, # 发布时间
status=article.status, # 文章状态
comment_status=article.comment_status, # 评论状态
type=article.type, # 文章类型
views=article.views, # 浏览量
article_order=article.article_order) for article in articles]
def rebuild(self, articles=None):
"""
重新构建文章索引
"""
ArticleDocument.init()
# 如果没有提供文章列表,则获取所有文章
articles = articles if articles else Article.objects.all()
docs = self.convert_to_doc(articles)
# 逐个保存文档
for doc in docs:
doc.save()
def update_docs(self, docs):
"""
更新文档
"""
for doc in docs:
doc.save()

@ -3,17 +3,33 @@ import logging
from django import forms
from haystack.forms import SearchForm
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__) # 创建日志记录器
class BlogSearchForm(SearchForm):
"""
博客搜索表单类继承自Haystack的SearchForm
"""
# 定义搜索查询字段,设置为必填
querydata = forms.CharField(required=True)
def search(self):
"""
执行搜索操作
Returns:
搜索结果数据
"""
# 调用父类的搜索方法
datas = super(BlogSearchForm, self).search()
# 验证表单数据
if not self.is_valid():
return self.no_query_found()
return self.no_query_found() # 如果表单无效,返回无查询结果
# 如果查询数据存在,记录查询日志
if self.cleaned_data['querydata']:
logger.info(self.cleaned_data['querydata'])
return datas
return datas # 返回搜索结果

@ -6,13 +6,39 @@ from blog.documents import ElapsedTimeDocument, ArticleDocumentManager, ElaspedT
# TODO 参数化
class Command(BaseCommand):
"""
<<<<<<< HEAD
Django管理命令用于构建Elasticsearch搜索索引
"""
help = 'build search index'
def handle(self, *args, **options):
"""
执行命令时的处理逻辑
如果启用了Elasticsearch则构建性能和文章的索引
"""
=======
Django管理命令用于构建搜索引擎索引
"""
help = 'build search index' # 命令帮助信息
def handle(self, *args, **options):
"""
命令处理函数执行索引构建操作
"""
# 检查是否启用了Elasticsearch
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
if ELASTICSEARCH_ENABLED:
# 构建耗时文档索引
ElaspedTimeDocumentManager.build_index()
# 初始化耗时文档
manager = ElapsedTimeDocument()
manager.init()
# 重新构建文章文档索引
manager = ArticleDocumentManager()
manager.delete_index()
manager.rebuild()
manager.delete_index() # 删除现有索引
manager.rebuild() # 重新构建索引
else:
# 如果未启用Elasticsearch可以添加提示信息或处理逻辑
pass

@ -5,9 +5,19 @@ from blog.models import Tag, Category
# TODO 参数化
class Command(BaseCommand):
help = 'build search words'
"""
Django管理命令用于构建搜索词列表
"""
help = 'build search words' # 命令帮助信息
def handle(self, *args, **options):
datas = set([t.name for t in Tag.objects.all()] +
[t.name for t in Category.objects.all()])
"""
命令处理函数生成标签和分类名称的搜索词列表
"""
# 收集所有标签和分类的名称使用set去重
datas = set([t.name for t in Tag.objects.all()] + # 获取所有标签名称
[t.name for t in Category.objects.all()]) # 获取所有分类名称
# 将搜索词列表按行打印输出
print('\n'.join(datas))

@ -4,8 +4,17 @@ from djangoblog.utils import cache
class Command(BaseCommand):
help = 'clear the whole cache'
"""
Django管理命令用于清除整个缓存
"""
help = 'clear the whole cache' # 命令帮助信息
def handle(self, *args, **options):
cache.clear()
"""
命令处理函数执行缓存清除操作
"""
cache.clear() # 清除所有缓存
# 输出成功信息到控制台
self.stdout.write(self.style.SUCCESS('Cleared cache\n'))

@ -2,52 +2,163 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.hashers import make_password
from django.core.management.base import BaseCommand
from blog.models import Article, Tag, Category
from blog.models import Article, Tag, Category, BlogSettings
class Command(BaseCommand):
help = 'create test datas'
"""
Django管理命令用于创建测试数据
"""
help = 'create test datas' # 命令帮助信息
def handle(self, *args, **options):
# 先尝试通过用户名查找用户
try:
user = get_user_model().objects.get(username='测试用户')
except get_user_model().DoesNotExist:
# 如果用户不存在,则创建新用户
user = get_user_model().objects.create(
email='test@test.com',
username='测试用户',
password=make_password('test!q@w#eTYU')
)
<<<<<<< HEAD
"""
命令处理函数创建测试用的用户分类标签和文章数据
"""
# 创建或获取测试用户
user = get_user_model().objects.get_or_create(
email='test@test.com',
username='测试用户',
password=make_password('test!q@w#eTYU'))[0]
# 创建父分类
pcategory = Category.objects.get_or_create(
name='我是父类目', parent_category=None)[0]
name='我是父类目',
parent_category=None)[0]
# 创建子分类
category = Category.objects.get_or_create(
name='子类目', parent_category=pcategory)[0]
name='子类目',
parent_category=pcategory)[0]
category.save()
# 使用 get_or_create 处理基础标签
basetag, created = Tag.objects.get_or_create(name="标签")
# 创建基础标签
basetag = Tag()
basetag.name = "标签"
basetag.save()
# 创建20篇文章及其对应的标签
for i in range(1, 20):
article, article_created = Article.objects.get_or_create(
# 创建文章
article = Article.objects.get_or_create(
category=category,
title='nice title ' + str(i),
body='nice content ' + str(i),
author=user
)
# 使用 get_or_create 处理每个标签
tag, tag_created = Tag.objects.get_or_create(name="标签" + str(i))
# 只有在文章是新创建的时候才添加标签
if article_created:
author=user)[0]
# 为每篇文章创建专属标签
tag = Tag()
tag.name = "标签" + str(i)
tag.save()
# 将标签关联到文章
article.tags.add(tag)
article.tags.add(basetag)
=======
# Ensure BlogSettings reflect movie discussion theme
setting = BlogSettings.objects.first()
if not setting:
setting = BlogSettings()
setting.site_name = '电影交流'
setting.site_description = '一个专注电影资讯、影评、演员导演与类型讨论的社区'
setting.site_seo_description = '电影交流社区,分享最新电影资讯、深度影评、演员与导演作品讨论,涵盖科幻、动作、剧情、喜剧等类型'
setting.site_keywords = '电影, 影评, 电影资讯, 演员, 导演, 科幻, 动作, 剧情, 喜剧'
setting.save()
# Make test user idempotent (avoid unique constraint on username)
User = get_user_model()
user, _ = User.objects.get_or_create(username='测试用户', defaults={
'email': 'test@test.com',
'password': make_password('test!q@w#eTYU')
})
# Update email/password if needed (in case user already existed with different email)
changed = False
if not user.email:
user.email = 'test@test.com'
changed = True
# ensure password is set to known value for demo
if not user.has_usable_password():
user.password = make_password('test!q@w#eTYU')
changed = True
if changed:
user.save()
# Movie-themed categories
pcategory = Category.objects.get_or_create(
name='电影', parent_category=None)[0]
if not pcategory.slug or pcategory.slug == 'no-slug':
pcategory.slug = 'dianying'
pcategory.save()
category = Category.objects.get_or_create(
name='影评', parent_category=pcategory)[0]
if not category.slug or category.slug == 'no-slug':
category.slug = 'yingping'
category.save()
category.save()
# Base tags for movie domain
base_tags = ['科幻', '动作', '剧情', '喜剧', '悬疑', '动画', '纪录片']
basetag_objects = []
slug_map = {
'科幻': 'kehuan',
'动作': 'dongzuo',
'剧情': 'juqing',
'喜剧': 'xiju',
'悬疑': 'xuanyi',
'动画': 'donghua',
'纪录片': 'jilupian',
}
for t in base_tags:
tag_obj = Tag.objects.get_or_create(name=t)[0]
if not tag_obj.slug or tag_obj.slug == 'no-slug':
tag_obj.slug = slug_map.get(t, 'tag')
tag_obj.save()
basetag_objects.append(tag_obj)
movie_samples = [
('星际穿越', '一次关于爱与时空的伟大旅程,诺兰的硬核科幻经典。'),
('盗梦空间', '层层梦境之间的较量,现实与幻觉的边界。'),
('蝙蝠侠:黑暗骑士', '混沌与秩序的对决,小丑与蝙蝠侠的哲学博弈。'),
('阿甘正传', '傻人有傻福,一部跨越时代的人生史诗。'),
('寄生虫', '阶层冲突下的人性与荒诞,奥斯卡最佳影片。'),
('千与千寻', '成长与记忆的奇幻旅程,宫崎骏的温柔。'),
('霸王别姬', '半世纪浮沉与爱恨,戏里戏外都是人生。'),
('这个杀手不太冷', '冷酷与温情的相遇,一朵花和一个女孩。'),
('当幸福来敲门', '逆境中不放弃的父爱与坚持。'),
('少年的你', '校园与成长的伤痛与守护。'),
('航站情缘', '在机场“漂泊”的温柔喜剧。'),
('海上钢琴师', '一生在海上的天才与自由的代价。'),
('美丽人生', '荒诞中的爱与幽默,生命的顽强。'),
('摔跤吧!爸爸', '不服输的父亲与女儿,梦想与性别刻板印象。'),
('你的名字。', '命运与时间的交错,青春与思念。'),
('流浪地球', '家园与牺牲的宏大叙事,中国科幻新里程。'),
('让子弹飞', '黑色幽默与权力游戏,姜文的快意江湖。'),
('釜山行', '人性与灾难中的生存与选择。'),
('无间道', '身份与信念的撕裂与救赎。')
]
for title, summary in movie_samples:
article = Article.objects.get_or_create(
category=category,
title=title,
defaults={
'body': summary,
'author': user,
}
)[0]
# attach 2-3 random base tags
from random import sample
for tag in sample(basetag_objects, k=min(3, len(basetag_objects))):
article.tags.add(tag)
article.tags.add(basetag)
article.save()
>>>>>>> 9a6c41e696594d7c9488468755f726a36b5d45bb
article.save()
# 清除缓存
from djangoblog.utils import cache
cache.clear()
# 输出成功信息
self.stdout.write(self.style.SUCCESS('created test datas \n'))

@ -4,47 +4,73 @@ from djangoblog.spider_notify import SpiderNotify
from djangoblog.utils import get_current_site
from blog.models import Article, Tag, Category
site = get_current_site().domain
site = get_current_site().domain # 获取当前站点域名
class Command(BaseCommand):
help = 'notify baidu url'
"""
Django管理命令用于向百度搜索引擎提交URL进行收录通知
"""
help = 'notify baidu url' # 命令帮助信息
def add_arguments(self, parser):
"""
添加命令行参数
"""
parser.add_argument(
'data_type',
type=str,
choices=[
'data_type', # 参数名称
type=str, # 参数类型
choices=[ # 参数可选值
'all',
'article',
'tag',
'category'],
help='article : all article,tag : all tag,category: all category,all: All of these')
help='article : all article,tag : all tag,category: all category,all: All of these') # 参数帮助信息
def get_full_url(self, path):
"""
根据相对路径生成完整URL
Args:
path: 相对路径
Returns:
完整的HTTPS URL
"""
url = "https://{site}{path}".format(site=site, path=path)
return url
def handle(self, *args, **options):
type = options['data_type']
self.stdout.write('start get %s' % type)
"""
命令处理函数执行百度URL提交操作
"""
type = options['data_type'] # 获取数据类型参数
self.stdout.write('start get %s' % type) # 输出开始信息
urls = []
urls = [] # 存储需要提交的URL列表
# 根据数据类型收集相应的URL
if type == 'article' or type == 'all':
# 收集所有已发布文章的URL
for article in Article.objects.filter(status='p'):
urls.append(article.get_full_url())
if type == 'tag' or type == 'all':
# 收集所有标签页面的URL
for tag in Tag.objects.all():
url = tag.get_absolute_url()
urls.append(self.get_full_url(url))
if type == 'category' or type == 'all':
# 收集所有分类页面的URL
for category in Category.objects.all():
url = category.get_absolute_url()
urls.append(self.get_full_url(url))
# 输出准备提交的URL数量
self.stdout.write(
self.style.SUCCESS(
'start notify %d urls' %
len(urls)))
'start notify %d urls' % len(urls)))
# 向百度提交URL
SpiderNotify.baidu_notify(urls)
# 输出完成信息
self.stdout.write(self.style.SUCCESS('finish notify'))

@ -8,40 +8,68 @@ from oauth.oauthmanager import get_manager_by_type
class Command(BaseCommand):
help = 'sync user avatar'
"""
Django管理命令用于同步用户头像
"""
help = 'sync user avatar' # 命令帮助信息
def test_picture(self, url):
"""
测试图片URL是否有效
Args:
url: 图片URL
Returns:
bool: URL是否有效
"""
try:
# 发送GET请求测试图片URL超时时间为2秒
if requests.get(url, timeout=2).status_code == 200:
return True
except:
# 出现异常说明URL无效
pass
def handle(self, *args, **options):
static_url = static("../")
users = OAuthUser.objects.all()
self.stdout.write(f'开始同步{len(users)}个用户头像')
"""
命令处理函数执行用户头像同步操作
"""
static_url = static("../") # 获取静态文件基础URL
users = OAuthUser.objects.all() # 获取所有OAuth用户
self.stdout.write(f'开始同步{len(users)}个用户头像') # 输出开始信息
# 遍历所有用户进行头像同步
for u in users:
self.stdout.write(f'开始同步:{u.nickname}')
url = u.picture
self.stdout.write(f'开始同步:{u.nickname}') # 输出当前同步的用户
url = u.picture # 获取用户当前头像URL
if url:
# 如果URL以静态URL开头说明是本地头像
if url.startswith(static_url):
# 测试图片是否有效
if self.test_picture(url):
continue
continue # 有效则跳过
else:
# 无效则尝试从元数据重新获取
if u.metadata:
manage = get_manager_by_type(u.type)
url = manage.get_picture(u.metadata)
url = save_user_avatar(url)
manage = get_manager_by_type(u.type) # 获取对应的OAuth管理器
url = manage.get_picture(u.metadata) # 从元数据获取新图片URL
url = save_user_avatar(url) # 保存用户头像
else:
# 没有元数据则使用默认头像
url = static('blog/img/avatar.png')
else:
# 非本地头像则保存新头像
url = save_user_avatar(url)
else:
# 没有头像URL则使用默认头像
url = static('blog/img/avatar.png')
# 如果获取到有效的头像URL则更新用户头像
if url:
self.stdout.write(
f'结束同步:{u.nickname}.url:{url}')
u.picture = url
u.save()
self.stdout.write('结束同步')
f'结束同步:{u.nickname}.url:{url}') # 输出同步结果
u.picture = url # 更新用户头像URL
u.save() # 保存到数据库
self.stdout.write('结束同步') # 输出结束信息

@ -6,37 +6,66 @@ from user_agents import parse
from blog.documents import ELASTICSEARCH_ENABLED, ElaspedTimeDocumentManager
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__) # 创建日志记录器
class OnlineMiddleware(object):
"""
在线中间件类用于记录页面加载时间和用户访问信息
"""
def __init__(self, get_response=None):
"""
初始化中间件
Args:
get_response: Django的响应获取函数
"""
self.get_response = get_response
super().__init__()
def __call__(self, request):
"""
中间件调用方法处理请求和响应
Args:
request: HTTP请求对象
Returns:
HTTP响应对象
"""
''' page render time '''
start_time = time.time()
response = self.get_response(request)
http_user_agent = request.META.get('HTTP_USER_AGENT', '')
start_time = time.time() # 记录请求开始时间
response = self.get_response(request) # 获取响应
# 获取用户IP地址
ip, _ = get_client_ip(request)
# 解析用户代理信息
http_user_agent = request.META.get('HTTP_USER_AGENT', '')
user_agent = parse(http_user_agent)
# 如果响应不是流式响应,则处理性能数据
if not response.streaming:
try:
# 计算页面渲染耗时
cast_time = time.time() - start_time
# 如果启用了Elasticsearch则记录性能数据
if ELASTICSEARCH_ENABLED:
time_taken = round((cast_time) * 1000, 2)
url = request.path
time_taken = round((cast_time) * 1000, 2) # 转换为毫秒
url = request.path # 获取请求路径
from django.utils import timezone
# 创建并保存性能文档
ElaspedTimeDocumentManager.create(
url=url,
time_taken=time_taken,
log_datetime=timezone.now(),
useragent=user_agent,
ip=ip)
# 在响应内容中替换加载时间占位符
response.content = response.content.replace(
b'<!!LOAD_TIMES!!>', str.encode(str(cast_time)[:5]))
except Exception as e:
# 记录错误日志
logger.error("Error OnlineMiddleware: %s" % e)
return response
return response # 返回响应

@ -8,14 +8,17 @@ import mdeditor.fields
class Migration(migrations.Migration):
# 标记这是一个初始迁移文件
initial = True
# 定义依赖关系
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), # 依赖用户模型
]
# 定义具体的操作
operations = [
# 创建BlogSettings模型网站配置
migrations.CreateModel(
name='BlogSettings',
fields=[
@ -41,6 +44,8 @@ class Migration(migrations.Migration):
'verbose_name_plural': '网站配置',
},
),
# 创建Links模型友情链接
migrations.CreateModel(
name='Links',
fields=[
@ -59,6 +64,8 @@ class Migration(migrations.Migration):
'ordering': ['sequence'],
},
),
# 创建SideBar模型侧边栏
migrations.CreateModel(
name='SideBar',
fields=[
@ -76,6 +83,8 @@ class Migration(migrations.Migration):
'ordering': ['sequence'],
},
),
# 创建Tag模型标签
migrations.CreateModel(
name='Tag',
fields=[
@ -91,6 +100,8 @@ class Migration(migrations.Migration):
'ordering': ['name'],
},
),
# 创建Category模型分类
migrations.CreateModel(
name='Category',
fields=[
@ -108,6 +119,8 @@ class Migration(migrations.Migration):
'ordering': ['-index'],
},
),
# 创建Article模型文章
migrations.CreateModel(
name='Article',
fields=[
@ -115,7 +128,7 @@ class Migration(migrations.Migration):
('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='正文')),
('body', mdeditor.fields.MDTextField(verbose_name='正文')), # 使用Markdown编辑器字段
('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='评论状态')),
@ -130,8 +143,9 @@ class Migration(migrations.Migration):
options={
'verbose_name': '文章',
'verbose_name_plural': '文章',
'ordering': ['-article_order', '-pub_time'],
'ordering': ['-article_order', '-pub_time'], # 按排序和发布时间降序排列
'get_latest_by': 'id',
},
),
]

@ -4,20 +4,24 @@ from django.db import migrations, models
class Migration(migrations.Migration):
# 定义该迁移文件的依赖关系依赖于blog应用的0001_initial迁移
dependencies = [
('blog', '0001_initial'),
]
# 定义具体的迁移操作
operations = [
# 为BlogSettings模型添加global_footer字段
migrations.AddField(
model_name='blogsettings',
name='global_footer',
field=models.TextField(blank=True, default='', null=True, verbose_name='公共尾部'),
),
# 为BlogSettings模型添加global_header字段
migrations.AddField(
model_name='blogsettings',
name='global_header',
field=models.TextField(blank=True, default='', null=True, verbose_name='公共头部'),
),
]

@ -4,14 +4,18 @@ from django.db import migrations, models
class Migration(migrations.Migration):
# 定义该迁移文件的依赖关系依赖于blog应用的0002_blogsettings_global_footer_and_more迁移
dependencies = [
('blog', '0002_blogsettings_global_footer_and_more'),
]
# 定义具体的迁移操作
operations = [
# 为BlogSettings模型添加comment_need_review字段
migrations.AddField(
model_name='blogsettings',
name='comment_need_review',
field=models.BooleanField(default=False, verbose_name='评论是否需要审核'),
),
]

@ -4,24 +4,30 @@ from django.db import migrations
class Migration(migrations.Migration):
# 定义该迁移文件的依赖关系依赖于blog应用的0003_blogsettings_comment_need_review迁移
dependencies = [
('blog', '0003_blogsettings_comment_need_review'),
]
# 定义具体的迁移操作
operations = [
# 将BlogSettings模型中的analyticscode字段重命名为analytics_code
migrations.RenameField(
model_name='blogsettings',
old_name='analyticscode',
new_name='analytics_code',
),
# 将BlogSettings模型中的beiancode字段重命名为beian_code
migrations.RenameField(
model_name='blogsettings',
old_name='beiancode',
new_name='beian_code',
),
# 将BlogSettings模型中的sitename字段重命名为site_name
migrations.RenameField(
model_name='blogsettings',
old_name='sitename',
new_name='site_name',
),
]

@ -8,293 +8,357 @@ 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 = [
# 修改Article模型的选项配置将显示名称改为英文
migrations.AlterModelOptions(
name='article',
options={'get_latest_by': 'id', 'ordering': ['-article_order', '-pub_time'], 'verbose_name': 'article', 'verbose_name_plural': 'article'},
),
# 修改Category模型的选项配置将显示名称改为英文
migrations.AlterModelOptions(
name='category',
options={'ordering': ['-index'], 'verbose_name': 'category', 'verbose_name_plural': 'category'},
),
# 修改Links模型的选项配置将显示名称改为英文
migrations.AlterModelOptions(
name='links',
options={'ordering': ['sequence'], 'verbose_name': 'link', 'verbose_name_plural': 'link'},
),
# 修改SideBar模型的选项配置将显示名称改为英文
migrations.AlterModelOptions(
name='sidebar',
options={'ordering': ['sequence'], 'verbose_name': 'sidebar', 'verbose_name_plural': 'sidebar'},
),
# 修改Tag模型的选项配置将显示名称改为英文
migrations.AlterModelOptions(
name='tag',
options={'ordering': ['name'], 'verbose_name': 'tag', 'verbose_name_plural': 'tag'},
),
# 移除Article模型中的created_time字段
migrations.RemoveField(
model_name='article',
name='created_time',
),
# 移除Article模型中的last_mod_time字段
migrations.RemoveField(
model_name='article',
name='last_mod_time',
),
# 移除Category模型中的created_time字段
migrations.RemoveField(
model_name='category',
name='created_time',
),
# 移除Category模型中的last_mod_time字段
migrations.RemoveField(
model_name='category',
name='last_mod_time',
),
# 移除Links模型中的created_time字段
migrations.RemoveField(
model_name='links',
name='created_time',
),
# 移除SideBar模型中的created_time字段
migrations.RemoveField(
model_name='sidebar',
name='created_time',
),
# 移除Tag模型中的created_time字段
migrations.RemoveField(
model_name='tag',
name='created_time',
),
# 移除Tag模型中的last_mod_time字段
migrations.RemoveField(
model_name='tag',
name='last_mod_time',
),
# 添加Article模型中的creation_time字段
migrations.AddField(
model_name='article',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 添加Article模型中的last_modify_time字段
migrations.AddField(
model_name='article',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
# 添加Category模型中的creation_time字段
migrations.AddField(
model_name='category',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 添加Category模型中的last_modify_time字段
migrations.AddField(
model_name='category',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
# 添加Links模型中的creation_time字段
migrations.AddField(
model_name='links',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 添加SideBar模型中的creation_time字段
migrations.AddField(
model_name='sidebar',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 添加Tag模型中的creation_time字段
migrations.AddField(
model_name='tag',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 添加Tag模型中的last_modify_time字段
migrations.AddField(
model_name='tag',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
# 修改Article模型中的article_order字段显示名称
migrations.AlterField(
model_name='article',
name='article_order',
field=models.IntegerField(default=0, verbose_name='order'),
),
# 修改Article模型中的author字段显示名称
migrations.AlterField(
model_name='article',
name='author',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'),
),
# 修改Article模型中的body字段显示名称
migrations.AlterField(
model_name='article',
name='body',
field=mdeditor.fields.MDTextField(verbose_name='body'),
),
# 修改Article模型中的category字段显示名称
migrations.AlterField(
model_name='article',
name='category',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='category'),
),
# 修改Article模型中的comment_status字段显示名称和选项
migrations.AlterField(
model_name='article',
name='comment_status',
field=models.CharField(choices=[('o', 'Open'), ('c', 'Close')], default='o', max_length=1, verbose_name='comment status'),
),
# 修改Article模型中的pub_time字段显示名称
migrations.AlterField(
model_name='article',
name='pub_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='publish time'),
),
# 修改Article模型中的show_toc字段显示名称
migrations.AlterField(
model_name='article',
name='show_toc',
field=models.BooleanField(default=False, verbose_name='show toc'),
),
# 修改Article模型中的status字段显示名称和选项
migrations.AlterField(
model_name='article',
name='status',
field=models.CharField(choices=[('d', 'Draft'), ('p', 'Published')], default='p', max_length=1, verbose_name='status'),
),
# 修改Article模型中的tags字段显示名称
migrations.AlterField(
model_name='article',
name='tags',
field=models.ManyToManyField(blank=True, to='blog.tag', verbose_name='tag'),
),
# 修改Article模型中的title字段显示名称
migrations.AlterField(
model_name='article',
name='title',
field=models.CharField(max_length=200, unique=True, verbose_name='title'),
),
# 修改Article模型中的type字段显示名称和选项
migrations.AlterField(
model_name='article',
name='type',
field=models.CharField(choices=[('a', 'Article'), ('p', 'Page')], default='a', max_length=1, verbose_name='type'),
),
# 修改Article模型中的views字段显示名称
migrations.AlterField(
model_name='article',
name='views',
field=models.PositiveIntegerField(default=0, verbose_name='views'),
),
# 修改BlogSettings模型中的article_comment_count字段显示名称
migrations.AlterField(
model_name='blogsettings',
name='article_comment_count',
field=models.IntegerField(default=5, verbose_name='article comment count'),
),
# 修改BlogSettings模型中的article_sub_length字段显示名称
migrations.AlterField(
model_name='blogsettings',
name='article_sub_length',
field=models.IntegerField(default=300, verbose_name='article sub length'),
),
# 修改BlogSettings模型中的google_adsense_codes字段显示名称
migrations.AlterField(
model_name='blogsettings',
name='google_adsense_codes',
field=models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='adsense code'),
),
# 修改BlogSettings模型中的open_site_comment字段显示名称
migrations.AlterField(
model_name='blogsettings',
name='open_site_comment',
field=models.BooleanField(default=True, verbose_name='open site comment'),
),
# 修改BlogSettings模型中的show_google_adsense字段显示名称
migrations.AlterField(
model_name='blogsettings',
name='show_google_adsense',
field=models.BooleanField(default=False, verbose_name='show adsense'),
),
# 修改BlogSettings模型中的sidebar_article_count字段显示名称
migrations.AlterField(
model_name='blogsettings',
name='sidebar_article_count',
field=models.IntegerField(default=10, verbose_name='sidebar article count'),
),
# 修改BlogSettings模型中的sidebar_comment_count字段显示名称
migrations.AlterField(
model_name='blogsettings',
name='sidebar_comment_count',
field=models.IntegerField(default=5, verbose_name='sidebar comment count'),
),
# 修改BlogSettings模型中的site_description字段显示名称
migrations.AlterField(
model_name='blogsettings',
name='site_description',
field=models.TextField(default='', max_length=1000, verbose_name='site description'),
),
# 修改BlogSettings模型中的site_keywords字段显示名称
migrations.AlterField(
model_name='blogsettings',
name='site_keywords',
field=models.TextField(default='', max_length=1000, verbose_name='site keywords'),
),
# 修改BlogSettings模型中的site_name字段显示名称
migrations.AlterField(
model_name='blogsettings',
name='site_name',
field=models.CharField(default='', max_length=200, verbose_name='site name'),
),
# 修改BlogSettings模型中的site_seo_description字段显示名称
migrations.AlterField(
model_name='blogsettings',
name='site_seo_description',
field=models.TextField(default='', max_length=1000, verbose_name='site seo description'),
),
# 修改Category模型中的index字段显示名称
migrations.AlterField(
model_name='category',
name='index',
field=models.IntegerField(default=0, verbose_name='index'),
),
# 修改Category模型中的name字段显示名称
migrations.AlterField(
model_name='category',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='category name'),
),
# 修改Category模型中的parent_category字段显示名称
migrations.AlterField(
model_name='category',
name='parent_category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='parent category'),
),
# 修改Links模型中的is_enable字段显示名称
migrations.AlterField(
model_name='links',
name='is_enable',
field=models.BooleanField(default=True, verbose_name='is show'),
),
# 修改Links模型中的last_mod_time字段显示名称
migrations.AlterField(
model_name='links',
name='last_mod_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
# 修改Links模型中的link字段显示名称
migrations.AlterField(
model_name='links',
name='link',
field=models.URLField(verbose_name='link'),
),
# 修改Links模型中的name字段显示名称
migrations.AlterField(
model_name='links',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='link name'),
),
# 修改Links模型中的sequence字段显示名称
migrations.AlterField(
model_name='links',
name='sequence',
field=models.IntegerField(unique=True, verbose_name='order'),
),
# 修改Links模型中的show_type字段显示名称和选项
migrations.AlterField(
model_name='links',
name='show_type',
field=models.CharField(choices=[('i', 'index'), ('l', 'list'), ('p', 'post'), ('a', 'all'), ('s', 'slide')], default='i', max_length=1, verbose_name='show type'),
),
# 修改SideBar模型中的content字段显示名称
migrations.AlterField(
model_name='sidebar',
name='content',
field=models.TextField(verbose_name='content'),
),
# 修改SideBar模型中的is_enable字段显示名称
migrations.AlterField(
model_name='sidebar',
name='is_enable',
field=models.BooleanField(default=True, verbose_name='is enable'),
),
# 修改SideBar模型中的last_mod_time字段显示名称
migrations.AlterField(
model_name='sidebar',
name='last_mod_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
),
# 修改SideBar模型中的name字段显示名称
migrations.AlterField(
model_name='sidebar',
name='name',
field=models.CharField(max_length=100, verbose_name='title'),
),
# 修改SideBar模型中的sequence字段显示名称
migrations.AlterField(
model_name='sidebar',
name='sequence',
field=models.IntegerField(unique=True, verbose_name='order'),
),
# 修改Tag模型中的name字段显示名称
migrations.AlterField(
model_name='tag',
name='name',
field=models.CharField(max_length=30, unique=True, verbose_name='tag name'),
),
]

@ -4,14 +4,17 @@ from django.db import migrations
class Migration(migrations.Migration):
# 定义该迁移文件的依赖关系依赖于blog应用的0005_alter_article_options_alter_category_options_and_more迁移
dependencies = [
('blog', '0005_alter_article_options_alter_category_options_and_more'),
]
# 定义具体的迁移操作
operations = [
# 修改BlogSettings模型的选项配置将显示名称改为英文
migrations.AlterModelOptions(
name='blogsettings',
options={'verbose_name': 'Website configuration', 'verbose_name_plural': 'Website configuration'},
),
]

@ -1,33 +0,0 @@
from django.db import migrations, models
from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('blog', '0006_alter_blogsettings_options'),
]
operations = [
migrations.AddField(
model_name='article',
name='like_count',
field=models.PositiveIntegerField(default=0, verbose_name='likes'),
),
migrations.AddField(
model_name='article',
name='favorite_count',
field=models.PositiveIntegerField(default=0, verbose_name='favorites'),
),
migrations.AddField(
model_name='article',
name='like_users',
field=models.ManyToManyField(blank=True, related_name='liked_articles', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='article',
name='favorite_users',
field=models.ManyToManyField(blank=True, related_name='favorite_articles', to=settings.AUTH_USER_MODEL),
),
]

@ -14,29 +14,41 @@ from uuslug import slugify
from djangoblog.utils import cache_decorator, cache
from djangoblog.utils import get_current_site
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__) # 创建日志记录器
class LinkShowType(models.TextChoices):
I = ('i', _('index'))
L = ('l', _('list'))
P = ('p', _('post'))
A = ('a', _('all'))
S = ('s', _('slide'))
"""
链接显示类型枚举类
"""
I = ('i', _('index')) # 首页
L = ('l', _('list')) # 列表页
P = ('p', _('post')) # 文章页面
A = ('a', _('all')) # 全站
S = ('s', _('slide')) # 幻灯片
class BaseModel(models.Model):
"""
基础模型类提供通用字段和方法
"""
id = models.AutoField(primary_key=True)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_modify_time = models.DateTimeField(_('modify time'), default=now)
def save(self, *args, **kwargs):
"""
重写保存方法处理slug生成和视图计数更新
"""
# 判断是否是仅更新浏览量的操作
is_update_views = isinstance(
self,
Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views']
if is_update_views:
# 如果是更新浏览量,直接更新数据库避免触发其他逻辑
Article.objects.filter(pk=self.pk).update(views=self.views)
else:
# 如果有slug字段自动生成slug
if 'slug' in self.__dict__:
slug = getattr(
self, 'title') if 'title' in self.__dict__ else getattr(
@ -45,35 +57,42 @@ class BaseModel(models.Model):
super().save(*args, **kwargs)
def get_full_url(self):
"""
获取完整的URL地址
"""
site = get_current_site().domain
url = "https://{site}{path}".format(site=site,
path=self.get_absolute_url())
return url
class Meta:
abstract = True
abstract = True # 声明为抽象类
@abstractmethod
def get_absolute_url(self):
"""
抽象方法子类必须实现获取绝对URL的方法
"""
pass
class Article(BaseModel):
"""文章"""
"""文章模型"""
STATUS_CHOICES = (
('d', _('Draft')),
('p', _('Published')),
('d', _('Draft')), # 草稿
('p', _('Published')), # 已发布
)
COMMENT_STATUS = (
('o', _('Open')),
('c', _('Close')),
('o', _('Open')), # 开启评论
('c', _('Close')), # 关闭评论
)
TYPE = (
('a', _('Article')),
('p', _('Page')),
('a', _('Article')), # 文章
('p', _('Page')), # 页面
)
title = models.CharField(_('title'), max_length=200, unique=True)
body = MDTextField(_('body'))
body = MDTextField(_('body')) # 使用Markdown编辑器字段
pub_time = models.DateTimeField(
_('publish time'), blank=False, null=False, default=now)
status = models.CharField(
@ -88,18 +107,6 @@ class Article(BaseModel):
default='o')
type = models.CharField(_('type'), max_length=1, choices=TYPE, default='a')
views = models.PositiveIntegerField(_('views'), default=0)
like_count = models.PositiveIntegerField('likes', default=0)
favorite_count = models.PositiveIntegerField('favorites', default=0)
like_users = models.ManyToManyField(
settings.AUTH_USER_MODEL,
related_name='liked_articles',
blank=True
)
favorite_users = models.ManyToManyField(
settings.AUTH_USER_MODEL,
related_name='favorite_articles',
blank=True
)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
@ -118,18 +125,24 @@ class Article(BaseModel):
tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True)
def body_to_string(self):
"""
将文章正文转换为字符串
"""
return self.body
def __str__(self):
return self.title
class Meta:
ordering = ['-article_order', '-pub_time']
ordering = ['-article_order', '-pub_time'] # 按排序和发布时间降序排列
verbose_name = _('article')
verbose_name_plural = verbose_name
get_latest_by = 'id'
def get_absolute_url(self):
"""
获取文章绝对URL
"""
return reverse('blog:detailbyid', kwargs={
'article_id': self.id,
'year': self.creation_time.year,
@ -139,39 +152,31 @@ class Article(BaseModel):
@cache_decorator(60 * 60 * 10)
def get_category_tree(self):
"""
获取分类树结构
"""
tree = self.category.get_category_tree()
names = list(map(lambda c: (c.name, c.get_absolute_url()), tree))
return names
def save(self, *args, **kwargs):
"""
保存文章
"""
super().save(*args, **kwargs)
def viewed(self):
"""
增加文章浏览量
"""
self.views += 1
self.save(update_fields=['views'])
def like_once_by_user(self, user):
if not user.is_authenticated:
return False
if self.like_users.filter(pk=user.pk).exists():
return False
self.like_users.add(user)
self.like_count += 1
self.save(update_fields=['like_count'])
return True
def favorite_once_by_user(self, user):
if not user.is_authenticated:
return False
if self.favorite_users.filter(pk=user.pk).exists():
return False
self.favorite_users.add(user)
self.favorite_count += 1
self.save(update_fields=['favorite_count'])
return True
def comment_list(self):
"""
获取文章评论列表带缓存
"""
cache_key = 'article_comments_{id}'.format(id=self.id)
value = cache.get(cache_key)
if value:
@ -184,24 +189,33 @@ class Article(BaseModel):
return comments
def get_admin_url(self):
"""
获取管理后台的文章编辑URL
"""
info = (self._meta.app_label, self._meta.model_name)
return reverse('admin:%s_%s_change' % info, args=(self.pk,))
@cache_decorator(expiration=60 * 100)
def next_article(self):
"""
获取下一篇已发布的文章
"""
# 下一篇
return Article.objects.filter(
id__gt=self.id, status='p').order_by('id').first()
@cache_decorator(expiration=60 * 100)
def prev_article(self):
"""
获取上一篇已发布的文章
"""
# 前一篇
return Article.objects.filter(id__lt=self.id, status='p').first()
def get_first_image_url(self):
"""
Get the first image url from article.body.
:return:
从文章正文中获取第一张图片的URL
:return: 图片URL或空字符串
"""
match = re.search(r'!\[.*?\]\((.+?)\)', self.body)
if match:
@ -210,7 +224,7 @@ class Article(BaseModel):
class Category(BaseModel):
"""文章分类"""
"""文章分类模型"""
name = models.CharField(_('category name'), max_length=30, unique=True)
parent_category = models.ForeignKey(
'self',
@ -222,11 +236,14 @@ class Category(BaseModel):
index = models.IntegerField(default=0, verbose_name=_('index'))
class Meta:
ordering = ['-index']
ordering = ['-index'] # 按索引降序排列
verbose_name = _('category')
verbose_name_plural = verbose_name
def get_absolute_url(self):
"""
获取分类绝对URL
"""
return reverse(
'blog:category_detail', kwargs={
'category_name': self.slug})
@ -237,8 +254,8 @@ class Category(BaseModel):
@cache_decorator(60 * 60 * 10)
def get_category_tree(self):
"""
递归获得分类目录的父级
:return:
递归获得分类目录的父级分类树
:return: 分类列表
"""
categorys = []
@ -253,8 +270,8 @@ class Category(BaseModel):
@cache_decorator(60 * 60 * 10)
def get_sub_categorys(self):
"""
获得当前分类目录所有子
:return:
获得当前分类目录所有子分类
:return: 子分类列表
"""
categorys = []
all_categorys = Category.objects.all()
@ -273,7 +290,7 @@ class Category(BaseModel):
class Tag(BaseModel):
"""文章标签"""
"""文章标签模型"""
name = models.CharField(_('tag name'), max_length=30, unique=True)
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
@ -281,20 +298,26 @@ class Tag(BaseModel):
return self.name
def get_absolute_url(self):
"""
获取标签绝对URL
"""
return reverse('blog:tag_detail', kwargs={'tag_name': self.slug})
@cache_decorator(60 * 60 * 10)
def get_article_count(self):
"""
获取该标签下的文章数量
"""
return Article.objects.filter(tags__name=self.name).distinct().count()
class Meta:
ordering = ['name']
ordering = ['name'] # 按名称升序排列
verbose_name = _('tag')
verbose_name_plural = verbose_name
class Links(models.Model):
"""友情链接"""
"""友情链接模型"""
name = models.CharField(_('link name'), max_length=30, unique=True)
link = models.URLField(_('link'))
@ -310,7 +333,7 @@ class Links(models.Model):
last_mod_time = models.DateTimeField(_('modify time'), default=now)
class Meta:
ordering = ['sequence']
ordering = ['sequence'] # 按顺序排列
verbose_name = _('link')
verbose_name_plural = verbose_name
@ -319,7 +342,7 @@ class Links(models.Model):
class SideBar(models.Model):
"""侧边栏,可以展示一些html内容"""
"""侧边栏模型,可以展示一些html内容"""
name = models.CharField(_('title'), max_length=100)
content = models.TextField(_('content'))
sequence = models.IntegerField(_('order'), unique=True)
@ -328,7 +351,7 @@ class SideBar(models.Model):
last_mod_time = models.DateTimeField(_('modify time'), default=now)
class Meta:
ordering = ['sequence']
ordering = ['sequence'] # 按顺序排列
verbose_name = _('sidebar')
verbose_name_plural = verbose_name
@ -337,7 +360,7 @@ class SideBar(models.Model):
class BlogSettings(models.Model):
"""blog的配置"""
"""博客配置模型"""
site_name = models.CharField(
_('site name'),
max_length=200,
@ -399,10 +422,631 @@ class BlogSettings(models.Model):
return self.site_name
def clean(self):
"""
验证模型数据确保只存在一个配置实例
"""
if BlogSettings.objects.exclude(id=self.id).count():
raise ValidationError(_('There can only be one configuration'))
def save(self, *args, **kwargs):
"""
保存配置并清除缓存
"""
super().save(*args, **kwargs)
from djangoblog.utils import cache
cache.clear()
import logging
import re
from abc import abstractmethod
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from mdeditor.fields import MDTextField
from uuslug import slugify
from djangoblog.utils import cache_decorator, cache
from djangoblog.utils import get_current_site
logger = logging.getLogger(__name__)
class LinkShowType(models.TextChoices):
"""
链接显示类型枚举类
I: 首页显示
L: 列表页显示
P: 文章页显示
A: 所有页面显示
S: 幻灯片显示
"""
I = ('i', _('index'))
L = ('l', _('list'))
P = ('p', _('post'))
A = ('a', _('all'))
S = ('s', _('slide'))
class BaseModel(models.Model):
"""
基础模型类
提供所有模型共有的字段和方法
包含主键创建时间和最后修改时间字段
Attributes:
id (AutoField): 主键字段
creation_time (DateTimeField): 创建时间
last_modify_time (DateTimeField): 最后修改时间
"""
id = models.AutoField(
primary_key=True,
help_text='主键ID')
creation_time = models.DateTimeField(
_('creation time'),
default=now,
help_text='记录创建时间')
last_modify_time = models.DateTimeField(
_('modify time'),
default=now,
help_text='记录最后修改时间')
def save(self, *args, **kwargs):
"""
重写保存方法
如果是更新文章浏览量则只更新浏览量字段
否则处理slug字段并调用父类保存方法
"""
is_update_views = isinstance(
self,
Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views']
if is_update_views:
Article.objects.filter(pk=self.pk).update(views=self.views)
else:
if 'slug' in self.__dict__:
slug = getattr(
self, 'title') if 'title' in self.__dict__ else getattr(
self, 'name')
setattr(self, 'slug', slugify(slug))
super().save(*args, **kwargs)
def get_full_url(self):
"""
获取完整URL地址
:return: 完整的URL地址包含域名
"""
site = get_current_site().domain
url = "https://{site}{path}".format(site=site,
path=self.get_absolute_url())
return url
class Meta:
abstract = True
@abstractmethod
def get_absolute_url(self):
"""
抽象方法子类必须实现
返回模型对象的绝对URL路径
"""
pass
class Article(BaseModel):
"""文章"""
STATUS_CHOICES = (
('d', _('Draft')),
('p', _('Published')),
)
COMMENT_STATUS = (
('o', _('Open')),
('c', _('Close')),
)
TYPE = (
('a', _('Article')),
('p', _('Page')),
)
title = models.CharField(
_('title'),
max_length=200,
unique=True,
help_text='文章标题,必须唯一')
body = MDTextField(
_('body'),
help_text='文章正文内容支持Markdown语法')
pub_time = models.DateTimeField(
_('publish time'),
blank=False,
null=False,
default=now,
help_text='文章发布时间')
status = models.CharField(
_('status'),
max_length=1,
choices=STATUS_CHOICES,
default='p',
help_text='文章状态:草稿或已发布')
comment_status = models.CharField(
_('comment status'),
max_length=1,
choices=COMMENT_STATUS,
default='o',
help_text='评论状态:开放或关闭')
type = models.CharField(
_('type'),
max_length=1,
choices=TYPE,
default='a',
help_text='文章类型:普通文章或页面')
views = models.PositiveIntegerField(
_('views'),
default=0,
help_text='文章浏览量')
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
blank=False,
null=False,
on_delete=models.CASCADE,
help_text='文章作者')
article_order = models.IntegerField(
_('order'),
blank=False,
null=False,
default=0,
help_text='文章排序')
show_toc = models.BooleanField(
_('show toc'),
blank=False,
null=False,
default=False,
help_text='是否显示目录')
category = models.ForeignKey(
'Category',
verbose_name=_('category'),
on_delete=models.CASCADE,
blank=False,
null=False,
help_text='文章分类')
tags = models.ManyToManyField(
'Tag',
verbose_name=_('tag'),
blank=True,
help_text='文章标签')
def body_to_string(self):
"""
将文章内容转换为字符串
:return: 文章内容字符串
"""
return self.body
def __str__(self):
return self.title
class Meta:
ordering = ['-article_order', '-pub_time']
verbose_name = _('article')
verbose_name_plural = verbose_name
get_latest_by = 'id'
def get_absolute_url(self):
"""
获取文章详情页的URL
:return: 文章详情页URL
"""
return reverse('blog:detailbyid', kwargs={
'article_id': self.id,
'year': self.creation_time.year,
'month': self.creation_time.month,
'day': self.creation_time.day
})
@cache_decorator(60 * 60 * 10)
def get_category_tree(self):
"""
获取分类目录树
:return: 分类目录树列表
"""
tree = self.category.get_category_tree()
names = list(map(lambda c: (c.name, c.get_absolute_url()), tree))
return names
def save(self, *args, **kwargs):
"""
重写保存方法调用父类保存方法
"""
super().save(*args, **kwargs)
def viewed(self):
"""
增加文章浏览量
"""
self.views += 1
self.save(update_fields=['views'])
def comment_list(self):
"""
获取文章的评论列表
使用缓存机制提高性能
:return: 评论列表
"""
cache_key = 'article_comments_{id}'.format(id=self.id)
value = cache.get(cache_key)
if value:
logger.info('get article comments:{id}'.format(id=self.id))
return value
else:
comments = self.comment_set.filter(is_enable=True).order_by('-id')
cache.set(cache_key, comments, 60 * 100)
logger.info('set article comments:{id}'.format(id=self.id))
return comments
def get_admin_url(self):
"""
获取文章在管理后台的编辑URL
:return: 管理后台编辑URL
"""
info = (self._meta.app_label, self._meta.model_name)
return reverse('admin:%s_%s_change' % info, args=(self.pk,))
@cache_decorator(expiration=60 * 100)
def next_article(self):
"""
获取下一篇文章
:return: 下一篇文章对象
"""
# 下一篇
return Article.objects.filter(
id__gt=self.id, status='p').order_by('id').first()
@cache_decorator(expiration=60 * 100)
def prev_article(self):
"""
获取上一篇文章
:return: 上一篇文章对象
"""
# 前一篇
return Article.objects.filter(id__lt=self.id, status='p').first()
def get_first_image_url(self):
"""
从文章正文中获取第一张图片的URL
使用正则表达式匹配Markdown格式的图片
:return: 第一张图片的URL如果没有找到则返回空字符串
"""
match = re.search(r'!\[.*?\]\((.+?)\)', self.body)
if match:
return match.group(1)
return ""
class Category(BaseModel):
"""文章分类"""
name = models.CharField(
_('category name'),
max_length=30,
unique=True,
help_text='分类名称,必须唯一')
parent_category = models.ForeignKey(
'self',
verbose_name=_('parent category'),
blank=True,
null=True,
on_delete=models.CASCADE,
help_text='父级分类')
slug = models.SlugField(
default='no-slug',
max_length=60,
blank=True,
help_text='分类slug用于生成URL')
index = models.IntegerField(
default=0,
verbose_name=_('index'),
help_text='分类索引,用于排序')
class Meta:
ordering = ['-index']
verbose_name = _('category')
verbose_name_plural = verbose_name
def get_absolute_url(self):
"""
获取分类详情页URL
:return: 分类详情页URL
"""
return reverse(
'blog:category_detail', kwargs={
'category_name': self.slug})
def __str__(self):
return self.name
@cache_decorator(60 * 60 * 10)
def get_category_tree(self):
"""
递归获得分类目录的父级
:return: 包含当前分类及其所有父级分类的列表
"""
categorys = []
def parse(category):
categorys.append(category)
if category.parent_category:
parse(category.parent_category)
parse(self)
return categorys
@cache_decorator(60 * 60 * 10)
def get_sub_categorys(self):
"""
获得当前分类目录所有子集
:return: 包含当前分类及其所有子分类的列表
"""
categorys = []
all_categorys = Category.objects.all()
def parse(category):
if category not in categorys:
categorys.append(category)
childs = all_categorys.filter(parent_category=category)
for child in childs:
if category not in categorys:
categorys.append(child)
parse(child)
parse(self)
return categorys
class Tag(BaseModel):
"""文章标签"""
name = models.CharField(
_('tag name'),
max_length=30,
unique=True,
help_text='标签名称,必须唯一')
slug = models.SlugField(
default='no-slug',
max_length=60,
blank=True,
help_text='标签slug用于生成URL')
def __str__(self):
return self.name
def get_absolute_url(self):
"""
获取标签详情页URL
:return: 标签详情页URL
"""
return reverse('blog:tag_detail', kwargs={'tag_name': self.slug})
@cache_decorator(60 * 60 * 10)
def get_article_count(self):
"""
获取使用该标签的文章数量
:return: 文章数量
"""
return Article.objects.filter(tags__name=self.name).distinct().count()
class Meta:
ordering = ['name']
verbose_name = _('tag')
verbose_name_plural = verbose_name
class Links(models.Model):
"""友情链接"""
name = models.CharField(
_('link name'),
max_length=30,
unique=True,
help_text='链接名称')
link = models.URLField(
_('link'),
help_text='链接地址')
sequence = models.IntegerField(
_('order'),
unique=True,
help_text='链接排序')
is_enable = models.BooleanField(
_('is show'),
default=True,
blank=False,
null=False,
help_text='是否显示')
show_type = models.CharField(
_('show type'),
max_length=1,
choices=LinkShowType.choices,
default=LinkShowType.I,
help_text='链接显示类型')
creation_time = models.DateTimeField(
_('creation time'),
default=now,
help_text='链接创建时间')
last_mod_time = models.DateTimeField(
_('modify time'),
default=now,
help_text='链接最后修改时间')
class Meta:
ordering = ['sequence']
verbose_name = _('link')
verbose_name_plural = verbose_name
def __str__(self):
return self.name
class SideBar(models.Model):
"""侧边栏,可以展示一些html内容"""
name = models.CharField(
_('title'),
max_length=100,
help_text='侧边栏标题')
content = models.TextField(
_('content'),
help_text='侧边栏内容支持HTML')
sequence = models.IntegerField(
_('order'),
unique=True,
help_text='侧边栏排序')
is_enable = models.BooleanField(
_('is enable'),
default=True,
help_text='是否启用')
creation_time = models.DateTimeField(
_('creation time'),
default=now,
help_text='侧边栏创建时间')
last_mod_time = models.DateTimeField(
_('modify time'),
default=now,
help_text='侧边栏最后修改时间')
class Meta:
ordering = ['sequence']
verbose_name = _('sidebar')
verbose_name_plural = verbose_name
def __str__(self):
return self.name
class BlogSettings(models.Model):
"""blog的配置"""
site_name = models.CharField(
_('site name'),
max_length=200,
null=False,
blank=False,
default='',
help_text='网站名称')
site_description = models.TextField(
_('site description'),
max_length=1000,
null=False,
blank=False,
default='',
help_text='网站描述')
site_seo_description = models.TextField(
_('site seo description'),
max_length=1000,
null=False,
blank=False,
default='',
help_text='网站SEO描述')
site_keywords = models.TextField(
_('site keywords'),
max_length=1000,
null=False,
blank=False,
default='',
help_text='网站关键词')
article_sub_length = models.IntegerField(
_('article sub length'),
default=300,
help_text='文章摘要长度')
sidebar_article_count = models.IntegerField(
_('sidebar article count'),
default=10,
help_text='侧边栏文章显示数量')
sidebar_comment_count = models.IntegerField(
_('sidebar comment count'),
default=5,
help_text='侧边栏评论显示数量')
article_comment_count = models.IntegerField(
_('article comment count'),
default=5,
help_text='文章评论显示数量')
show_google_adsense = models.BooleanField(
_('show adsense'),
default=False,
help_text='是否显示Google广告')
google_adsense_codes = models.TextField(
_('adsense code'),
max_length=2000,
null=True,
blank=True,
default='',
help_text='Google广告代码')
open_site_comment = models.BooleanField(
_('open site comment'),
default=True,
help_text='是否开放站点评论')
global_header = models.TextField(
"公共头部",
null=True,
blank=True,
default='',
help_text='公共头部代码')
global_footer = models.TextField(
"公共尾部",
null=True,
blank=True,
default='',
help_text='公共尾部代码')
beian_code = models.CharField(
'备案号',
max_length=2000,
null=True,
blank=True,
default='',
help_text='网站备案号')
analytics_code = models.TextField(
"网站统计代码",
max_length=1000,
null=False,
blank=False,
default='',
help_text='网站统计代码,如百度统计')
show_gongan_code = models.BooleanField(
'是否显示公安备案号',
default=False,
null=False,
help_text='是否显示公安备案号')
gongan_beiancode = models.TextField(
'公安备案号',
max_length=2000,
null=True,
blank=True,
default='',
help_text='公安备案号')
comment_need_review = models.BooleanField(
'评论是否需要审核',
default=False,
null=False,
help_text='评论是否需要审核')
class Meta:
verbose_name = _('Website configuration')
verbose_name_plural = verbose_name
def __str__(self):
return self.site_name
def clean(self):
"""
验证模型数据
确保只存在一个配置实例
"""
if BlogSettings.objects.exclude(id=self.id).count():
raise ValidationError(_('There can only be one configuration'))
def save(self, *args, **kwargs):
"""
重写保存方法
保存后清除缓存
"""
super().save(*args, **kwargs)
from djangoblog.utils import cache
cache.clear()

@ -4,10 +4,25 @@ from blog.models import Article
class ArticleIndex(indexes.SearchIndex, indexes.Indexable):
"""
文章搜索索引类用于Haystack全文搜索
"""
# 定义文档字段use_template=True表示使用模板来构建索引内容
text = indexes.CharField(document=True, use_template=True)
def get_model(self):
"""
返回索引对应的模型类
"""
return Article
def index_queryset(self, using=None):
return self.get_model().objects.filter(status='p')
"""
返回需要建立索引的查询集
Args:
using: 数据库别名
Returns:
已发布文章的查询集
"""
return self.get_model().objects.filter(status='p') # 只对已发布的文章建立索引

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

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

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

@ -1,600 +0,0 @@
/* 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;
}

@ -1,142 +0,0 @@
/**
* 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();
})();

@ -20,18 +20,38 @@ from djangoblog.utils import get_current_site
from oauth.models import OAuthUser
from djangoblog.plugin_manage import hooks
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__) # 创建日志记录器
register = template.Library()
register = template.Library() # 创建模板标签注册器
@register.simple_tag(takes_context=True)
def head_meta(context):
"""
<<<<<<< HEAD
头部元数据标签
通过插件系统应用过滤器生成头部元数据
:param context: 模板上下文
:return: 头部元数据HTML
=======
生成页面头部meta信息
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
return mark_safe(hooks.apply_filters('head_meta', '', context))
@register.simple_tag
def timeformat(data):
"""
<<<<<<< HEAD
时间格式化标签
将时间数据格式化为设置中指定的时间格式
=======
格式化时间显示
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
:param data: 时间数据
:return: 格式化后的时间字符串
"""
try:
return data.strftime(settings.TIME_FORMAT)
except Exception as e:
@ -41,6 +61,17 @@ def timeformat(data):
@register.simple_tag
def datetimeformat(data):
"""
<<<<<<< HEAD
日期时间格式化标签
将时间数据格式化为设置中指定的日期时间格式
:param data: 时间数据
=======
格式化日期时间显示
:param data: 日期时间数据
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
:return: 格式化后的日期时间字符串
"""
try:
return data.strftime(settings.DATE_TIME_FORMAT)
except Exception as e:
@ -52,78 +83,33 @@ def datetimeformat(data):
@stringfilter
def custom_markdown(content):
"""
通用markdown过滤器应用文章内容插件
主要用于文章内容处理
<<<<<<< HEAD
自定义Markdown转换过滤器
将Markdown格式的内容转换为HTML
:param content: Markdown格式的内容
:return: 转换后的HTML内容
=======
将内容转换为markdown格式
:param content: 原始内容
:return: markdown格式的内容
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
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)
return mark_safe(CommonMarkdown.get_markdown(content))
@register.simple_tag
def get_markdown_toc(content):
"""
<<<<<<< HEAD
获取Markdown目录标签
从Markdown内容中提取目录结构
:param content: Markdown格式的内容
=======
获取markdown内容的目录
:param content: markdown内容
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
:return: 目录HTML
"""
from djangoblog.utils import CommonMarkdown
body, toc = CommonMarkdown.get_markdown_with_toc(content)
return mark_safe(toc)
@ -132,6 +118,18 @@ def get_markdown_toc(content):
@register.filter()
@stringfilter
def comment_markdown(content):
"""
<<<<<<< HEAD
评论Markdown转换过滤器
将Markdown格式的评论内容转换为HTML并清理不安全标签
:param content: Markdown格式的评论内容
:return: 转换并清理后的HTML内容
=======
将评论内容转换为markdown格式并清理HTML
:param content: 评论内容
:return: 清理后的markdown格式内容
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
content = CommonMarkdown.get_markdown(content)
return mark_safe(sanitize_html(content))
@ -141,8 +139,14 @@ def comment_markdown(content):
def truncatechars_content(content):
"""
获得文章内容的摘要
:param content:
:return:
<<<<<<< HEAD
根据博客设置中的文章摘要长度截取内容
:param content: 原始内容
:return: 截取后的内容
=======
:param content: 文章内容
:return: 截取后的摘要内容
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
from django.template.defaultfilters import truncatechars_html
from djangoblog.utils import get_blog_setting
@ -153,17 +157,33 @@ def truncatechars_content(content):
@register.filter(is_safe=True)
@stringfilter
def truncate(content):
"""
<<<<<<< HEAD
内容截取过滤器
移除HTML标签并截取前150个字符
:param content: 原始内容
:return: 截取后的内容
=======
截取内容前150个字符去除HTML标签
:param content: 原始内容
:return: 截取后的纯文本内容
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
from django.utils.html import strip_tags
return strip_tags(content)[:150]
@register.inclusion_tag('blog/tags/breadcrumb.html')
def load_breadcrumb(article):
"""
获得文章面包屑
:param article:
:return:
<<<<<<< HEAD
加载面包屑导航标签
生成文章的面包屑导航信息
=======
获得文章面包屑导航
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
:param article: 文章对象
:return: 面包屑导航数据
"""
names = article.get_category_tree()
from djangoblog.utils import get_blog_setting
@ -182,9 +202,16 @@ def load_breadcrumb(article):
@register.inclusion_tag('blog/tags/article_tag_list.html')
def load_articletags(article):
"""
文章标签
:param article:
:return:
<<<<<<< HEAD
加载文章标签列表标签
生成文章标签的显示数据
:param article: 文章对象
:return: 文章标签列表数据
=======
加载文章标签列表
:param article: 文章对象
:return: 标签列表数据
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
tags = article.tags.all()
tags_list = []
@ -202,8 +229,18 @@ def load_articletags(article):
@register.inclusion_tag('blog/tags/sidebar.html')
def load_sidebar(user, linktype):
"""
<<<<<<< HEAD
加载侧边栏标签
生成侧边栏显示数据包括文章分类标签等信息
使用缓存提高性能
:param user: 当前用户
:param linktype: 链接类型
=======
加载侧边栏
:return:
:param user: 当前用户
:param linktype: 链接显示类型
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
:return: 侧边栏数据
"""
value = cache.get("sidebar" + linktype)
if value:
@ -213,28 +250,38 @@ def load_sidebar(user, linktype):
logger.info('load sidebar')
from djangoblog.utils import get_blog_setting
blogsetting = get_blog_setting()
# 获取最新文章
recent_articles = Article.objects.filter(
status='p')[:blogsetting.sidebar_article_count]
# 获取所有分类
sidebar_categorys = Category.objects.all()
# 获取启用的额外侧边栏
extra_sidebars = SideBar.objects.filter(
is_enable=True).order_by('sequence')
# 获取阅读量最高的文章
most_read_articles = Article.objects.filter(status='p').order_by(
'-views')[:blogsetting.sidebar_article_count]
# 获取文章发布日期
dates = Article.objects.datetimes('creation_time', 'month', order='DESC')
# 获取友情链接
links = Links.objects.filter(is_enable=True).filter(
Q(show_type=str(linktype)) | Q(show_type=LinkShowType.A))
# 获取最新评论
commment_list = Comment.objects.filter(is_enable=True).order_by(
'-id')[:blogsetting.sidebar_comment_count]
# 标签云 计算字体大小
# 根据总数计算出平均值 大小为 (数目/平均值)*步长
increment = 5
tags = Tag.objects.all()
sidebar_tags = None
if tags and len(tags) > 0:
# 获取每个标签的文章数量
s = [t for t in [(t, t.get_article_count()) for t in tags] if t[1]]
count = sum([t[1] for t in s])
dd = 1 if (count == 0 or not len(tags)) else count / len(tags)
import random
# 计算标签字体大小
sidebar_tags = list(
map(lambda x: (x[0], x[1], (x[1] / dd) * increment + 10), s))
random.shuffle(sidebar_tags)
@ -253,6 +300,7 @@ def load_sidebar(user, linktype):
'sidebar_tags': sidebar_tags,
'extra_sidebars': extra_sidebars
}
# 缓存侧边栏数据3小时
cache.set("sidebar" + linktype, value, 60 * 60 * 60 * 3)
logger.info('set sidebar cache.key:{key}'.format(key="sidebar" + linktype))
value['user'] = user
@ -262,9 +310,18 @@ def load_sidebar(user, linktype):
@register.inclusion_tag('blog/tags/article_meta_info.html')
def load_article_metas(article, user):
"""
<<<<<<< HEAD
加载文章元信息标签
生成文章元信息显示数据
:param article: 文章对象
:param user: 当前用户
:return: 文章元信息数据
=======
获得文章meta信息
:param article:
:return:
:param article: 文章对象
:param user: 当前用户
:return: 文章meta信息数据
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
return {
'article': article,
@ -273,9 +330,27 @@ def load_article_metas(article, user):
@register.inclusion_tag('blog/tags/article_pagination.html')
def load_pagination_info(page_obj, page_type, tag_name, sort=None):
def load_pagination_info(page_obj, page_type, tag_name):
"""
<<<<<<< HEAD
加载分页信息标签
根据页面类型生成分页导航链接
:param page_obj: 分页对象
:param page_type: 页面类型
:param tag_name: 标签名称
:return: 分页信息数据
=======
加载分页信息
:param page_obj: 分页对象
:param page_type: 页面类型
:param tag_name: 标签名/分类名/作者名
:return: 分页链接数据
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
previous_url = ''
next_url = ''
# 首页分页
if page_type == '':
if page_obj.has_next():
next_number = page_obj.next_page_number()
@ -285,6 +360,8 @@ def load_pagination_info(page_obj, page_type, tag_name, sort=None):
previous_url = reverse(
'blog:index_page', kwargs={
'page': previous_number})
# 标签页面分页
if page_type == '分类标签归档':
tag = get_object_or_404(Tag, name=tag_name)
if page_obj.has_next():
@ -301,6 +378,8 @@ def load_pagination_info(page_obj, page_type, tag_name, sort=None):
kwargs={
'page': previous_number,
'tag_name': tag.slug})
# 作者文章页面分页
if page_type == '作者文章归档':
if page_obj.has_next():
next_number = page_obj.next_page_number()
@ -317,6 +396,7 @@ def load_pagination_info(page_obj, page_type, tag_name, sort=None):
'page': previous_number,
'author_name': tag_name})
# 分类目录页面分页
if page_type == '分类目录归档':
category = get_object_or_404(Category, name=tag_name)
if page_obj.has_next():
@ -334,12 +414,6 @@ def load_pagination_info(page_obj, page_type, tag_name, sort=None):
'page': previous_number,
'category_name': category.slug})
if sort:
if next_url:
next_url = f"{next_url}?sort={sort}"
if previous_url:
previous_url = f"{previous_url}?sort={sort}"
return {
'previous_url': previous_url,
'next_url': next_url,
@ -347,228 +421,105 @@ def load_pagination_info(page_obj, page_type, tag_name, sort=None):
}
@register.inclusion_tag('blog/tags/article_info.html', takes_context=True)
def load_article_detail(context, article, isindex, user):
@register.inclusion_tag('blog/tags/article_info.html')
def load_article_detail(article, isindex, user):
"""
<<<<<<< HEAD
加载文章详情标签
生成文章详情显示数据
=======
加载文章详情
:param article:
:param isindex:是否列表页若是列表页只显示摘要
:return:
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
:param article: 文章对象
:param isindex: 是否为列表页
:param user: 当前用户
:return: 文章详情数据
"""
from djangoblog.utils import get_blog_setting
blogsetting = get_blog_setting()
request = context.get('request')
liked_ids = request.session.get('liked_articles', []) if request else []
favorited_ids = request.session.get('favorited_articles', []) if request else []
has_liked = False
has_favorited = False
if request and hasattr(request, 'user') and request.user.is_authenticated:
has_liked = article.like_users.filter(pk=request.user.pk).exists()
has_favorited = article.favorite_users.filter(pk=request.user.pk).exists()
else:
has_liked = article.id in liked_ids
has_favorited = article.id in favorited_ids
return {
'article': article,
'isindex': isindex,
'user': user,
'open_site_comment': blogsetting.open_site_comment,
'has_liked': has_liked,
'has_favorited': has_favorited,
}
# 返回用户头像URL
# 模板使用方法: {{ email|gravatar_url:150 }}
# return only the URL of the gravatar
# TEMPLATE USE: {{ email|gravatar_url:150 }}
@register.filter
def gravatar_url(email, size=40):
"""获得用户头像 - 优先使用OAuth头像否则使用默认头像"""
cachekey = 'avatar/' + email
"""
获得gravatar头像URL
:param email: 邮箱地址
:param size: 头像大小
:return: gravatar头像URL
"""
cachekey = 'gravatat/' + 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
else:
# 查找OAuth用户是否有自定义头像
usermodels = OAuthUser.objects.filter(email=email)
if usermodels:
o = list(filter(lambda x: x.picture is not None, usermodels))
if o:
return o[0].picture
email = email.encode('utf-8')
default = static('blog/img/avatar.png')
# 生成gravatar URL
url = "https://www.gravatar.com/avatar/%s?%s" % (hashlib.md5(
email.lower()).hexdigest(), urllib.parse.urlencode({'d': default, 's': str(size)}))
# 缓存头像URL 10小时
cache.set(cachekey, url, 60 * 60 * 10)
logger.info('set gravatar cache.key:{key}'.format(key=cachekey))
return url
@register.filter
def gravatar(email, size=40):
"""获得用户头像HTML标签"""
"""
获得gravatar头像HTML标签
:param email: 邮箱地址
:param size: 头像大小
:return: 头像img标签
"""
url = gravatar_url(email, size)
return mark_safe(
'<img src="%s" height="%d" width="%d" class="avatar" alt="用户头像">' %
(url, size, size))
'<img src="%s" height="%d" width="%d">' % (url, size, size))
@register.simple_tag
def query(qs, **kwargs):
""" template tag which allows queryset filtering. Usage:
{% query books author=author as mybooks %}
{% for book in mybooks %}
...
{% endfor %}
"""
模板标签允许查询集过滤用法:
{% query books author=author as mybooks %}
{% for book in mybooks %}
...
{% endfor %}
:param qs: 查询集
:param kwargs: 过滤条件
:return: 过滤后的查询集
"""
return qs.filter(**kwargs)
@register.filter
def addstr(arg1, arg2):
<<<<<<< HEAD
"""concatenate arg1 & arg2"""
return str(arg1) + str(arg2)
=======
"""
连接两个字符串
:param arg1: 第一个字符串
:param arg2: 第二个字符串
:return: 连接后的字符串
"""
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资源仅自定义HTMLCSS已集成到压缩系统"""
from djangoblog.plugin_manage.loader import get_loaded_plugins
resources = []
for plugin in get_loaded_plugins():
try:
# 只处理自定义head HTMLCSS文件已通过压缩系统处理
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资源仅自定义HTMLJS已集成到压缩系统"""
from djangoblog.plugin_manage.loader import get_loaded_plugins
resources = []
for plugin in get_loaded_plugins():
try:
# 只处理自定义body HTMLJS文件已通过压缩系统处理
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 ""
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc

@ -20,12 +20,22 @@ from oauth.models import OAuthUser, OAuthConfig
# Create your tests here.
class ArticleTest(TestCase):
"""
文章相关功能测试类
"""
def setUp(self):
self.client = Client()
self.factory = RequestFactory()
"""
测试前的准备工作
"""
self.client = Client() # 创建测试客户端
self.factory = RequestFactory() # 创建请求工厂
def test_validate_article(self):
"""
测试文章相关功能验证
"""
site = get_current_site().domain
# 创建或获取测试用户
user = BlogUser.objects.get_or_create(
email="liangliangyy@gmail.com",
username="liangliangyy")[0]
@ -33,10 +43,16 @@ class ArticleTest(TestCase):
user.is_staff = True
user.is_superuser = True
user.save()
# 测试用户详情页访问
response = self.client.get(user.get_absolute_url())
self.assertEqual(response.status_code, 200)
# 测试管理页面访问
response = self.client.get('/admin/servermanager/emailsendlog/')
response = self.client.get('admin/admin/logentry/')
# 创建侧边栏
s = SideBar()
s.sequence = 1
s.name = 'test'
@ -44,16 +60,19 @@ class ArticleTest(TestCase):
s.is_enable = True
s.save()
# 创建分类
category = Category()
category.name = "category"
category.creation_time = timezone.now()
category.last_mod_time = timezone.now()
category.save()
# 创建标签
tag = Tag()
tag.name = "nicetag"
tag.save()
# 创建文章
article = Article()
article.title = "nicetitle"
article.body = "nicecontent"
@ -68,6 +87,7 @@ class ArticleTest(TestCase):
article.save()
self.assertEqual(1, article.tags.count())
# 创建更多测试文章
for i in range(20):
article = Article()
article.title = "nicetitle" + str(i)
@ -79,32 +99,46 @@ class ArticleTest(TestCase):
article.save()
article.tags.add(tag)
article.save()
# 如果启用了Elasticsearch测试搜索功能
from blog.documents import ELASTICSEARCH_ENABLED
if ELASTICSEARCH_ENABLED:
call_command("build_index")
response = self.client.get('/search', {'q': 'nicetitle'})
self.assertEqual(response.status_code, 200)
# 测试文章详情页访问
response = self.client.get(article.get_absolute_url())
self.assertEqual(response.status_code, 200)
# 测试蜘蛛通知功能
from djangoblog.spider_notify import SpiderNotify
SpiderNotify.notify(article.get_absolute_url())
# 测试标签页访问
response = self.client.get(tag.get_absolute_url())
self.assertEqual(response.status_code, 200)
# 测试分类页访问
response = self.client.get(category.get_absolute_url())
self.assertEqual(response.status_code, 200)
# 测试搜索功能
response = self.client.get('/search', {'q': 'django'})
self.assertEqual(response.status_code, 200)
# 测试加载文章标签功能
s = load_articletags(article)
self.assertIsNotNone(s)
# 用户登录测试
self.client.login(username='liangliangyy', password='liangliangyy')
# 测试归档页面访问
response = self.client.get(reverse('blog:archives'))
self.assertEqual(response.status_code, 200)
# 测试分页功能
p = Paginator(Article.objects.all(), settings.PAGINATE_BY)
self.check_pagination(p, '', '')
@ -119,16 +153,20 @@ class ArticleTest(TestCase):
p = Paginator(Article.objects.filter(category=category), settings.PAGINATE_BY)
self.check_pagination(p, '分类目录归档', category.slug)
# 测试搜索表单
f = BlogSearchForm()
f.search()
# self.client.login(username='liangliangyy', password='liangliangyy')
# 测试百度通知功能
from djangoblog.spider_notify import SpiderNotify
SpiderNotify.baidu_notify([article.get_full_url()])
# 测试头像功能
from blog.templatetags.blog_tags import gravatar_url, gravatar
u = gravatar_url('liangliangyy@gmail.com')
u = gravatar('liangliangyy@gmail.com')
# 测试友情链接
link = Links(
sequence=1,
name="lylinux",
@ -137,37 +175,53 @@ class ArticleTest(TestCase):
response = self.client.get('/links.html')
self.assertEqual(response.status_code, 200)
# 测试RSS订阅
response = self.client.get('/feed/')
self.assertEqual(response.status_code, 200)
# 测试站点地图
response = self.client.get('/sitemap.xml')
self.assertEqual(response.status_code, 200)
# 测试管理后台功能
self.client.get("/admin/blog/article/1/delete/")
self.client.get('/admin/servermanager/emailsendlog/')
self.client.get('/admin/admin/logentry/')
self.client.get('/admin/admin/logentry/1/change/')
def check_pagination(self, p, type, value):
"""
检查分页功能
"""
for page in range(1, p.num_pages + 1):
s = load_pagination_info(p.page(page), type, value)
self.assertIsNotNone(s)
# 测试上一页链接
if s['previous_url']:
response = self.client.get(s['previous_url'])
self.assertEqual(response.status_code, 200)
# 测试下一页链接
if s['next_url']:
response = self.client.get(s['next_url'])
self.assertEqual(response.status_code, 200)
def test_image(self):
"""
测试图片上传功能
"""
import requests
# 下载测试图片
rsp = requests.get(
'https://www.python.org/static/img/python-logo.png')
imagepath = os.path.join(settings.BASE_DIR, 'python.png')
with open(imagepath, 'wb') as file:
file.write(rsp.content)
# 测试未授权上传
rsp = self.client.post('/upload')
self.assertEqual(rsp.status_code, 403)
# 测试授权上传
sign = get_sha256(get_sha256(settings.SECRET_KEY))
with open(imagepath, 'rb') as file:
imgfile = SimpleUploadedFile(
@ -176,17 +230,28 @@ class ArticleTest(TestCase):
rsp = self.client.post(
'/upload?sign=' + sign, form_data, follow=True)
self.assertEqual(rsp.status_code, 200)
# 清理测试文件
os.remove(imagepath)
# 测试用户头像保存和邮件发送功能
from djangoblog.utils import save_user_avatar, send_email
send_email(['qq@qq.com'], 'testTitle', 'testContent')
save_user_avatar(
'https://www.python.org/static/img/python-logo.png')
def test_errorpage(self):
"""
测试错误页面
"""
rsp = self.client.get('/eee')
self.assertEqual(rsp.status_code, 404)
def test_commands(self):
"""
测试管理命令
"""
# 创建测试用户
user = BlogUser.objects.get_or_create(
email="liangliangyy@gmail.com",
username="liangliangyy")[0]
@ -195,12 +260,14 @@ class ArticleTest(TestCase):
user.is_superuser = True
user.save()
# 创建OAuth配置
c = OAuthConfig()
c.type = 'qq'
c.appkey = 'appkey'
c.appsecret = 'appsecret'
c.save()
# 创建OAuth用户
u = OAuthUser()
u.type = 'qq'
u.openid = 'openid'
@ -222,6 +289,7 @@ class ArticleTest(TestCase):
}'''
u.save()
# 测试各种管理命令
from blog.documents import ELASTICSEARCH_ENABLED
if ELASTICSEARCH_ENABLED:
call_command("build_index")
@ -231,56 +299,3 @@ class ArticleTest(TestCase):
call_command("sync_user_avatar")
call_command("build_search_words")
def test_like_and_favorite(self):
user = BlogUser.objects.get_or_create(
email="user@test.com",
username="user1")[0]
user.set_password("pwd12345")
user.save()
category = Category()
category.name = "likecat"
category.save()
article = Article()
article.title = "likefavtitle"
article.body = "body"
article.author = user
article.category = category
article.type = 'a'
article.status = 'p'
article.save()
like_url = reverse('blog:like_article', kwargs={'article_id': article.id})
fav_url = reverse('blog:favorite_article', kwargs={'article_id': article.id})
rsp = self.client.post(like_url)
self.assertEqual(rsp.status_code, 200)
article.refresh_from_db()
self.assertEqual(article.like_count, 1)
rsp = self.client.post(like_url)
article.refresh_from_db()
self.assertEqual(article.like_count, 1)
rsp = self.client.post(fav_url)
self.assertEqual(rsp.status_code, 200)
article.refresh_from_db()
self.assertEqual(article.favorite_count, 1)
rsp = self.client.post(fav_url)
article.refresh_from_db()
self.assertEqual(article.favorite_count, 1)
self.client.login(username='user1', password='pwd12345')
rsp = self.client.post(like_url)
article.refresh_from_db()
self.assertEqual(article.like_count, 2)
rsp = self.client.post(like_url)
article.refresh_from_db()
self.assertEqual(article.like_count, 2)
rsp = self.client.post(fav_url)
article.refresh_from_db()
self.assertEqual(article.favorite_count, 2)
rsp = self.client.post(fav_url)
article.refresh_from_db()
self.assertEqual(article.favorite_count, 2)

@ -3,68 +3,74 @@ from django.views.decorators.cache import cache_page
from . import views
app_name = "blog"
app_name = "blog" # 定义应用的命名空间
urlpatterns = [
# 首页路由
path(
r'',
views.IndexView.as_view(),
name='index'),
# 首页分页路由
path(
r'page/<int:page>/',
views.IndexView.as_view(),
name='index_page'),
# 文章详情页路由根据年月日和文章ID访问
path(
r'article/<int:year>/<int:month>/<int:day>/<int:article_id>.html',
views.ArticleDetailView.as_view(),
name='detailbyid'),
# 分类详情页路由
path(
r'category/<slug:category_name>.html',
views.CategoryDetailView.as_view(),
name='category_detail'),
# 分类详情页分页路由
path(
r'category/<slug:category_name>/<int:page>.html',
views.CategoryDetailView.as_view(),
name='category_detail_page'),
# 作者详情页路由
path(
r'author/<author_name>.html',
views.AuthorDetailView.as_view(),
name='author_detail'),
# 作者详情页分页路由
path(
r'author/<author_name>/<int:page>.html',
views.AuthorDetailView.as_view(),
name='author_detail_page'),
# 标签详情页路由
path(
r'tag/<slug:tag_name>.html',
views.TagDetailView.as_view(),
name='tag_detail'),
# 标签详情页分页路由
path(
r'tag/<slug:tag_name>/<int:page>.html',
views.TagDetailView.as_view(),
name='tag_detail_page'),
path(
r'article/<int:article_id>/like/',
views.like_article,
name='like_article'),
path(
r'article/<int:article_id>/favorite/',
views.favorite_article,
name='favorite_article'),
# 归档页面路由缓存1小时
path(
'archives.html',
cache_page(
60 * 60)(
views.ArchivesView.as_view()),
name='archives'),
# 友情链接页面路由
path(
'links.html',
views.LinkListView.as_view(),
name='links'),
# 文件上传路由
path(
r'upload',
views.fileupload,
name='upload'),
# 清理缓存路由
path(
r'clean',
views.clean_cache_view,
name='clean'),
]

@ -5,7 +5,6 @@ import uuid
from django.conf import settings
from django.core.paginator import Paginator
from django.http import HttpResponse, HttpResponseForbidden
from django.http import JsonResponse, HttpResponseBadRequest
from django.shortcuts import get_object_or_404
from django.shortcuts import render
from django.templatetags.static import static
@ -22,10 +21,18 @@ 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__)
logger = logging.getLogger(__name__) # 创建日志记录器
class ArticleListView(ListView):
"""
<<<<<<< HEAD
文章列表视图基类
继承自Django的ListView提供文章列表的通用功能
=======
文章列表视图基类提供分页和缓存功能
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
# template_name属性用于指定使用哪个模板进行渲染
template_name = 'blog/article_index.html'
@ -34,15 +41,30 @@ class ArticleListView(ListView):
# 页面类型,分类目录或标签列表等
page_type = ''
paginate_by = settings.PAGINATE_BY
page_kwarg = 'page'
link_type = LinkShowType.L
paginate_by = settings.PAGINATE_BY # 每页文章数量
page_kwarg = 'page' # 分页参数名
link_type = LinkShowType.L # 链接显示类型
def get_view_cache_key(self):
"""
<<<<<<< HEAD
获取视图缓存键
:return: 缓存键
=======
获取视图缓存key
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
return self.request.get['pages']
@property
def page_number(self):
"""
获取当前页码
<<<<<<< HEAD
:return: 页码
=======
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
page_kwarg = self.page_kwarg
page = self.kwargs.get(
page_kwarg) or self.request.GET.get(page_kwarg) or 1
@ -60,25 +82,11 @@ class ArticleListView(ListView):
"""
raise NotImplementedError()
def get_sort(self):
sort = self.request.GET.get('sort', 'latest')
return sort if sort in ('latest', 'hot') else 'latest'
def order_queryset(self, qs):
sort = self.get_sort()
if sort == 'hot':
from django.utils import timezone
from datetime import timedelta
week_ago = timezone.now() - timedelta(days=7)
qs = qs.filter(pub_time__gte=week_ago)
return qs.order_by('-article_order', '-views', '-pub_time')
return qs.order_by('-article_order', '-pub_time')
def get_queryset_from_cache(self, cache_key):
'''
缓存页面数据
:param cache_key: 缓存key
:return:
:return: 查询结果集
'''
value = cache.get(cache_key)
if value:
@ -93,37 +101,68 @@ class ArticleListView(ListView):
def get_queryset(self):
'''
重写默认从缓存获取数据
:return:
:return: 查询结果集
'''
key = self.get_queryset_cache_key()
value = self.get_queryset_from_cache(key)
return value
def get_context_data(self, **kwargs):
"""
<<<<<<< HEAD
获取上下文数据
=======
添加额外的上下文数据
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
kwargs['linktype'] = self.link_type
kwargs['sort'] = self.get_sort()
return super(ArticleListView, self).get_context_data(**kwargs)
class IndexView(ArticleListView):
'''
首页
<<<<<<< HEAD
首页视图类
显示所有已发布文章的列表
=======
首页视图
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
'''
# 友情链接类型
link_type = LinkShowType.I
def get_queryset_data(self):
qs = Article.objects.filter(type='a', status='p')
return self.order_queryset(qs)
"""
获取首页文章数据
<<<<<<< HEAD
:return: 已发布文章查询集
=======
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
article_list = Article.objects.filter(type='a', status='p')
return article_list
def get_queryset_cache_key(self):
cache_key = 'index_{page}_sort_{sort}'.format(page=self.page_number, sort=self.get_sort())
"""
<<<<<<< HEAD
获取首页缓存键
:return: 缓存键
=======
获取首页缓存key
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
cache_key = 'index_{page}'.format(page=self.page_number)
return cache_key
class ArticleDetailView(DetailView):
'''
文章详情页面
<<<<<<< HEAD
文章详情页面视图类
显示单篇文章的详细内容及相关信息
=======
文章详情页面视图
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
'''
template_name = 'blog/article_detail.html'
model = Article
@ -131,11 +170,22 @@ class ArticleDetailView(DetailView):
context_object_name = "article"
def get_context_data(self, **kwargs):
"""
<<<<<<< HEAD
获取文章详情页上下文数据
包括评论表单评论列表分页信息等
"""
comment_form = CommentForm()
=======
获取文章详情页的上下文数据
"""
comment_form = CommentForm() # 评论表单
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
article_comments = self.object.comment_list()
parent_comments = article_comments.filter(parent_comment=None)
blog_setting = get_blog_setting()
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():
@ -148,6 +198,7 @@ class ArticleDetailView(DetailView):
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
@ -157,42 +208,49 @@ class ArticleDetailView(DetailView):
if prev_page:
kwargs[
'comment_prev_page_url'] = self.object.get_absolute_url() + f'?comment_page={prev_page}#commentlist-container'
# 添加评论相关数据到上下文
kwargs['form'] = comment_form
kwargs['article_comments'] = article_comments
kwargs['p_comments'] = p_comments
kwargs['comment_count'] = len(
article_comments) if article_comments else 0
# 添加上下篇文章信息
kwargs['next_article'] = self.object.next_article
kwargs['prev_article'] = self.object.prev_article
context = super(ArticleDetailView, self).get_context_data(**kwargs)
article = self.object
# 触发文章详情加载钩子,让插件可以添加额外的上下文数据
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)
liked_ids = self.request.session.get('liked_articles', [])
favorited_ids = self.request.session.get('favorited_articles', [])
context['has_liked'] = (
self.request.user.is_authenticated and article.like_users.filter(pk=self.request.user.pk).exists()
) or (article.id in liked_ids)
context['has_favorited'] = (
self.request.user.is_authenticated and article.favorite_users.filter(pk=self.request.user.pk).exists()
) or (article.id in favorited_ids)
# Filter Hook, 允许插件修改文章正文
article.body = hooks.apply_filters(ARTICLE_CONTENT_HOOK_NAME, article.body, article=article,
request=self.request)
return context
class CategoryDetailView(ArticleListView):
'''
分类目录列表
<<<<<<< HEAD
分类目录详情视图类
显示指定分类下的所有文章
=======
分类目录列表视图
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
'''
page_type = "分类目录归档"
def get_queryset_data(self):
"""
<<<<<<< HEAD
获取指定分类下的文章数据
:return: 文章查询集
=======
获取分类文章数据
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
slug = self.kwargs['category_name']
category = get_object_or_404(Category, slug=slug)
@ -200,21 +258,37 @@ class CategoryDetailView(ArticleListView):
self.categoryname = categoryname
categorynames = list(
map(lambda c: c.name, category.get_sub_categorys()))
qs = Article.objects.filter(
article_list = Article.objects.filter(
category__name__in=categorynames, status='p')
return self.order_queryset(qs)
return article_list
def get_queryset_cache_key(self):
"""
<<<<<<< HEAD
获取分类详情页缓存键
:return: 缓存键
=======
获取分类列表缓存key
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
slug = self.kwargs['category_name']
category = get_object_or_404(Category, slug=slug)
categoryname = category.name
self.categoryname = categoryname
cache_key = 'category_list_{categoryname}_{page}_sort_{sort}'.format(
categoryname=categoryname, page=self.page_number, sort=self.get_sort())
cache_key = 'category_list_{categoryname}_{page}'.format(
categoryname=categoryname, page=self.page_number)
return cache_key
def get_context_data(self, **kwargs):
"""
<<<<<<< HEAD
获取分类详情页上下文数据
"""
=======
添加分类相关上下文数据
"""
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
categoryname = self.categoryname
try:
categoryname = categoryname.split('/')[-1]
@ -227,24 +301,33 @@ class CategoryDetailView(ArticleListView):
class AuthorDetailView(ArticleListView):
'''
作者详情页
作者详情页视图
'''
page_type = '作者文章归档'
def get_queryset_cache_key(self):
"""
获取作者文章列表缓存key
"""
from uuslug import slugify
author_name = slugify(self.kwargs['author_name'])
cache_key = 'author_{author_name}_{page}_sort_{sort}'.format(
author_name=author_name, page=self.page_number, sort=self.get_sort())
cache_key = 'author_{author_name}_{page}'.format(
author_name=author_name, page=self.page_number)
return cache_key
def get_queryset_data(self):
"""
获取作者文章数据
"""
author_name = self.kwargs['author_name']
qs = Article.objects.filter(
article_list = Article.objects.filter(
author__username=author_name, type='a', status='p')
return self.order_queryset(qs)
return article_list
def get_context_data(self, **kwargs):
"""
添加作者相关上下文数据
"""
author_name = self.kwargs['author_name']
kwargs['page_type'] = AuthorDetailView.page_type
kwargs['tag_name'] = author_name
@ -253,29 +336,38 @@ class AuthorDetailView(ArticleListView):
class TagDetailView(ArticleListView):
'''
标签列表页面
标签列表页面视图
'''
page_type = '分类标签归档'
def get_queryset_data(self):
"""
获取标签文章数据
"""
slug = self.kwargs['tag_name']
tag = get_object_or_404(Tag, slug=slug)
tag_name = tag.name
self.name = tag_name
qs = Article.objects.filter(
article_list = Article.objects.filter(
tags__name=tag_name, type='a', status='p')
return self.order_queryset(qs)
return article_list
def get_queryset_cache_key(self):
"""
获取标签列表缓存key
"""
slug = self.kwargs['tag_name']
tag = get_object_or_404(Tag, slug=slug)
tag_name = tag.name
self.name = tag_name
cache_key = 'tag_{tag_name}_{page}_sort_{sort}'.format(
tag_name=tag_name, page=self.page_number, sort=self.get_sort())
cache_key = 'tag_{tag_name}_{page}'.format(
tag_name=tag_name, page=self.page_number)
return cache_key
def get_context_data(self, **kwargs):
"""
添加标签相关上下文数据
"""
# tag_name = self.kwargs['tag_name']
tag_name = self.name
kwargs['page_type'] = TagDetailView.page_type
@ -285,31 +377,49 @@ class TagDetailView(ArticleListView):
class ArchivesView(ArticleListView):
'''
文章归档页面
文章归档页面视图
'''
page_type = '文章归档'
paginate_by = None
page_kwarg = None
paginate_by = None # 不分页
page_kwarg = None # 无分页参数
template_name = 'blog/article_archives.html'
def get_queryset_data(self):
"""
获取所有已发布文章
"""
return Article.objects.filter(status='p').all()
def get_queryset_cache_key(self):
"""
获取归档页面缓存key
"""
cache_key = 'archives'
return cache_key
class LinkListView(ListView):
"""
友情链接列表视图
"""
model = Links
template_name = 'blog/links_list.html'
def get_queryset(self):
"""
获取启用的友情链接
"""
return Links.objects.filter(is_enable=True)
class EsSearchView(SearchView):
"""
Elasticsearch搜索视图
"""
def get_context(self):
"""
获取搜索上下文数据
"""
paginator, page = self.build_page()
context = {
"query": self.query,
@ -328,31 +438,37 @@ class EsSearchView(SearchView):
@csrf_exempt
def fileupload(request):
"""
该方法需自己写调用端来上传图片该方法仅提供图床功能
:param request:
:return:
文件上传视图该方法需自己写调用端来上传图片该方法仅提供图床功能
:param request: HTTP请求对象
:return: 上传文件的URL列表
"""
if request.method == 'POST':
sign = request.GET.get('sign', None)
# 验证签名,确保是授权上传
if not sign:
return HttpResponseForbidden()
if not sign == get_sha256(get_sha256(settings.SECRET_KEY)):
return HttpResponseForbidden()
response = []
# 处理上传的每个文件
for filename in request.FILES:
timestr = timezone.now().strftime('%Y/%m/%d')
imgextensions = ['jpg', 'png', 'jpeg', 'bmp']
fname = u''.join(str(filename))
isimage = len([i for i in imgextensions if fname.find(i) >= 0]) > 0
# 根据文件类型确定保存目录
base_dir = os.path.join(settings.STATICFILES, "files" if not isimage else "image", timestr)
if not os.path.exists(base_dir):
os.makedirs(base_dir)
# 生成安全的保存路径
savepath = os.path.normpath(os.path.join(base_dir, f"{uuid.uuid4().hex}{os.path.splitext(filename)[-1]}"))
if not savepath.startswith(base_dir):
return HttpResponse("only for post")
# 保存文件
with open(savepath, 'wb+') as wfile:
for chunk in request.FILES[filename].chunks():
wfile.write(chunk)
# 如果是图片,进行压缩优化
if isimage:
from PIL import Image
image = Image.open(savepath)
@ -369,6 +485,9 @@ def page_not_found_view(
request,
exception,
template_name='blog/error_page.html'):
"""
404页面不存在视图
"""
if exception:
logger.error(exception)
url = request.get_full_path()
@ -379,51 +498,10 @@ def page_not_found_view(
status=404)
@csrf_exempt
def like_article(request, article_id):
if request.method != 'POST':
return HttpResponseBadRequest('only post')
article = get_object_or_404(Article, pk=article_id)
if request.user.is_authenticated:
if article.like_users.filter(pk=request.user.pk).exists():
return JsonResponse({'ok': False, 'message': '已点赞', 'like_count': article.like_count})
article.like_users.add(request.user)
article.like_count += 1
article.save(update_fields=['like_count'])
return JsonResponse({'ok': True, 'message': '点赞成功', 'like_count': article.like_count})
liked = request.session.get('liked_articles', [])
if article.id in liked:
return JsonResponse({'ok': False, 'message': '已点赞', 'like_count': article.like_count})
liked.append(article.id)
request.session['liked_articles'] = liked
article.like_count += 1
article.save(update_fields=['like_count'])
return JsonResponse({'ok': True, 'message': '点赞成功', 'like_count': article.like_count})
@csrf_exempt
def favorite_article(request, article_id):
if request.method != 'POST':
return HttpResponseBadRequest('only post')
article = get_object_or_404(Article, pk=article_id)
if request.user.is_authenticated:
if article.favorite_users.filter(pk=request.user.pk).exists():
return JsonResponse({'ok': False, 'message': '已收藏', 'favorite_count': article.favorite_count})
article.favorite_users.add(request.user)
article.favorite_count += 1
article.save(update_fields=['favorite_count'])
return JsonResponse({'ok': True, 'message': '收藏成功', 'favorite_count': article.favorite_count})
favorited = request.session.get('favorited_articles', [])
if article.id in favorited:
return JsonResponse({'ok': False, 'message': '已收藏', 'favorite_count': article.favorite_count})
favorited.append(article.id)
request.session['favorited_articles'] = favorited
article.favorite_count += 1
article.save(update_fields=['favorite_count'])
return JsonResponse({'ok': True, 'message': '收藏成功', 'favorite_count': article.favorite_count})
def server_error_view(request, template_name='blog/error_page.html'):
"""
500服务器错误视图
"""
return render(request,
template_name,
{'message': _('Sorry, the server is busy, please click the home page to see other?'),
@ -435,6 +513,9 @@ def permission_denied_view(
request,
exception,
template_name='blog/error_page.html'):
"""
403权限拒绝视图
"""
if exception:
logger.error(exception)
return render(
@ -444,5 +525,9 @@ def permission_denied_view(
def clean_cache_view(request):
"""
清理缓存视图
"""
cache.clear()
return HttpResponse('ok')

@ -1,87 +0,0 @@
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"

File diff suppressed because one or more lines are too long

@ -0,0 +1,688 @@
/*
*
*/
/* Google 图标样式 */
.icon-sn-google {
background-position: 0 -28px;
}
.icon-sn-bg-google {
background-color: #4285f4;
background-position: 0 0;
}
.fa-sn-google {
color: #4285f4;
}
/* GitHub 图标样式 */
.icon-sn-github {
background-position: -28px -28px;
}
.icon-sn-bg-github {
background-color: #333;
background-position: -28px 0;
}
.fa-sn-github {
color: #333;
}
/* 微博图标样式 */
.icon-sn-weibo {
background-position: -56px -28px;
}
.icon-sn-bg-weibo {
background-color: #e90d24;
background-position: -56px 0;
}
.fa-sn-weibo {
color: #e90d24;
}
/* QQ 图标样式 */
.icon-sn-qq {
background-position: -84px -28px;
}
.icon-sn-bg-qq {
background-color: #0098e6;
background-position: -84px 0;
}
.fa-sn-qq {
color: #0098e6;
}
/* Twitter 图标样式 */
.icon-sn-twitter {
background-position: -112px -28px;
}
.icon-sn-bg-twitter {
background-color: #50abf1;
background-position: -112px 0;
}
.fa-sn-twitter {
color: #50abf1;
}
/* Facebook 图标样式 */
.icon-sn-facebook {
background-position: -140px -28px;
}
.icon-sn-bg-facebook {
background-color: #4862a3;
background-position: -140px 0;
}
.fa-sn-facebook {
color: #4862a3;
}
/* 人人网图标样式 */
.icon-sn-renren {
background-position: -168px -28px;
}
.icon-sn-bg-renren {
background-color: #197bc8;
background-position: -168px 0;
}
.fa-sn-renren {
color: #197bc8;
}
/* 腾讯微博图标样式 */
.icon-sn-tqq {
background-position: -196px -28px;
}
.icon-sn-bg-tqq {
background-color: #1f9ed2;
background-position: -196px 0;
}
.fa-sn-tqq {
color: #1f9ed2;
}
/* 豆瓣图标样式 */
.icon-sn-douban {
background-position: -224px -28px;
}
.icon-sn-bg-douban {
background-color: #279738;
background-position: -224px 0;
}
.fa-sn-douban {
color: #279738;
}
/* 微信图标样式 */
.icon-sn-weixin {
background-position: -252px -28px;
}
.icon-sn-bg-weixin {
background-color: #00b500;
background-position: -252px 0;
}
.fa-sn-weixin {
color: #00b500;
}
/* 虚线图标样式 */
.icon-sn-dotted {
background-position: -280px -28px;
}
.icon-sn-bg-dotted {
background-color: #eee;
background-position: -280px 0;
}
.fa-sn-dotted {
color: #eee;
}
/* 站点图标样式 */
.icon-sn-site {
background-position: -308px -28px;
}
.icon-sn-bg-site {
background-color: #00b500;
background-position: -308px 0;
}
.fa-sn-site {
color: #00b500;
}
/* LinkedIn 图标样式 */
.icon-sn-linkedin {
background-position: -336px -28px;
}
.icon-sn-bg-linkedin {
background-color: #0077b9;
background-position: -336px 0;
}
.fa-sn-linkedin {
color: #0077b9;
}
/* 通用图标样式 */
[class*=icon-sn-] {
display: inline-block;
background-image: url('/static/blog/img/icon-sn.svg?56272f05e520');
background-repeat: no-repeat;
width: 28px;
height: 28px;
vertical-align: middle;
background-size: auto 56px;
}
[class*=icon-sn-]:hover {
opacity: .8;
filter: alpha(opacity=80);
}
/*
*
*/
/* Google 按钮样式 */
.btn-sn-google {
background: #4285f4;
}
.btn-sn-google:active,
.btn-sn-google:focus,
.btn-sn-google:hover {
background: #2a75f3;
}
/* GitHub 按钮样式 */
.btn-sn-github {
background: #333;
}
.btn-sn-github:active,
.btn-sn-github:focus,
.btn-sn-github:hover {
background: #262626;
}
/* 微博按钮样式 */
.btn-sn-weibo {
background: #e90d24;
}
.btn-sn-weibo:active,
.btn-sn-weibo:focus,
.btn-sn-weibo:hover {
background: #d10c20;
}
/* QQ 按钮样式 */
.btn-sn-qq {
background: #0098e6;
}
.btn-sn-qq:active,
.btn-sn-qq:focus,
.btn-sn-qq:hover {
background: #0087cd;
}
/* Twitter 按钮样式 */
.btn-sn-twitter {
background: #50abf1;
}
.btn-sn-twitter:active,
.btn-sn-twitter:focus,
.btn-sn-twitter:hover {
background: #38a0ef;
}
/* Facebook 按钮样式 */
.btn-sn-facebook {
background: #4862a3;
}
.btn-sn-facebook:active,
.btn-sn-facebook:focus,
.btn-sn-facebook:hover {
background: #405791;
}
/* 人人网按钮样式 */
.btn-sn-renren {
background: #197bc8;
}
.btn-sn-renren:active,
.btn-sn-renren:focus,
.btn-sn-renren:hover {
background: #166db1;
}
/* 腾讯微博按钮样式 */
.btn-sn-tqq {
background: #1f9ed2;
}
.btn-sn-tqq:active,
.btn-sn-tqq:focus,
.btn-sn-tqq:hover {
background: #1c8dbc;
}
/* 豆瓣按钮样式 */
.btn-sn-douban {
background: #279738;
}
.btn-sn-douban:active,
.btn-sn-douban:focus,
.btn-sn-douban:hover {
background: #228330;
}
/* 微信按钮样式 */
.btn-sn-weixin {
background: #00b500;
}
.btn-sn-weixin:active,
.btn-sn-weixin:focus,
.btn-sn-weixin:hover {
background: #009c00;
}
/* 虚线按钮样式 */
.btn-sn-dotted {
background: #eee;
}
.btn-sn-dotted:active,
.btn-sn-dotted:focus,
.btn-sn-dotted:hover {
background: #e1e1e1;
}
/* 站点按钮样式 */
.btn-sn-site {
background: #00b500;
}
.btn-sn-site:active,
.btn-sn-site:focus,
.btn-sn-site:hover {
background: #009c00;
}
/* LinkedIn 按钮样式 */
.btn-sn-linkedin {
background: #0077b9;
}
.btn-sn-linkedin:active,
.btn-sn-linkedin:focus,
.btn-sn-linkedin:hover {
background: #0067a0;
}
/* 通用按钮样式 */
[class*=btn-sn-],
[class*=btn-sn-]:active,
[class*=btn-sn-]:focus,
[class*=btn-sn-]:hover {
border: none;
color: #fff;
}
.btn-sn-more {
padding: 0;
}
.btn-sn-more,
.btn-sn-more:active,
.btn-sn-more:hover {
box-shadow: none;
}
[class*=btn-sn-] [class*=icon-sn-] {
background-color: transparent;
}
/*
*
*/
.codehilite .hll {
background-color: #ffffcc;
}
.codehilite {
background: #ffffff;
}
.codehilite .c {
color: #177500;
}
.codehilite .err {
color: #000000;
}
.codehilite .k {
color: #A90D91;
}
.codehilite .l {
color: #1C01CE;
}
.codehilite .n {
color: #000000;
}
.codehilite .o {
color: #000000;
}
.codehilite .ch {
color: #177500;
}
.codehilite .cm {
color: #177500;
}
.codehilite .cp {
color: #633820;
}
.codehilite .cpf {
color: #177500;
}
.codehilite .c1 {
color: #177500;
}
.codehilite .cs {
color: #177500;
}
.codehilite .kc {
color: #A90D91;
}
.codehilite .kd {
color: #A90D91;
}
.codehilite .kn {
color: #A90D91;
}
.codehilite .kp {
color: #A90D91;
}
.codehilite .kr {
color: #A90D91;
}
.codehilite .kt {
color: #A90D91;
}
.codehilite .ld {
color: #1C01CE;
}
.codehilite .m {
color: #1C01CE;
}
.codehilite .s {
color: #C41A16;
}
.codehilite .na {
color: #836C28;
}
.codehilite .nb {
color: #A90D91;
}
.codehilite .nc {
color: #3F6E75;
}
.codehilite .no {
color: #000000;
}
.codehilite .nd {
color: #000000;
}
.codehilite .ni {
color: #000000;
}
.codehilite .ne {
color: #000000;
}
.codehilite .nf {
color: #000000;
}
.codehilite .nl {
color: #000000;
}
.codehilite .nn {
color: #000000;
}
.codehilite .nx {
color: #000000;
}
.codehilite .py {
color: #000000;
}
.codehilite .nt {
color: #000000;
}
.codehilite .nv {
color: #000000;
}
.codehilite .ow {
color: #000000;
}
.codehilite .mb {
color: #1C01CE;
}
.codehilite .mf {
color: #1C01CE;
}
.codehilite .mh {
color: #1C01CE;
}
.codehilite .mi {
color: #1C01CE;
}
.codehilite .mo {
color: #1C01CE;
}
.codehilite .sb {
color: #C41A16;
}
.codehilite .sc {
color: #2300CE;
}
.codehilite .sd {
color: #C41A16;
}
.codehilite .s2 {
color: #C41A16;
}
.codehilite .se {
color: #C41A16;
}
.codehilite .sh {
color: #C41A16;
}
.codehilite .si {
color: #C41A16;
}
.codehilite .sx {
color: #C41A16;
}
.codehilite .sr {
color: #C41A16;
}
.codehilite .s1 {
color: #C41A16;
}
.codehilite .ss {
color: #C41A16;
}
.codehilite .bp {
color: #5B269A;
}
.codehilite .vc {
color: #000000;
}
.codehilite .vg {
color: #000000;
}
.codehilite .vi {
color: #000000;
}
.codehilite .il {
color: #1C01CE;
}
/*
* (NProgress)
*/
#nprogress {
pointer-events: none;
}
#nprogress .bar {
background: red;
position: fixed;
z-index: 1031;
top: 0;
left: 0;
width: 100%;
height: 2px;
}
#nprogress .peg {
display: block;
position: absolute;
right: 0px;
width: 100px;
height: 100%;
box-shadow: 0 0 10px #29d, 0 0 5px #29d;
opacity: 1.0;
-webkit-transform: rotate(3deg) translate(0px, -4px);
-ms-transform: rotate(3deg) translate(0px, -4px);
transform: rotate(3deg) translate(0px, -4px);
}
#nprogress .spinner {
display: block;
position: fixed;
z-index: 1031;
top: 15px;
right: 15px;
}
#nprogress .spinner-icon {
width: 18px;
height: 18px;
box-sizing: border-box;
border: solid 2px transparent;
border-top-color: red;
border-left-color: red;
border-radius: 50%;
-webkit-animation: nprogress-spinner 400ms linear infinite;
animation: nprogress-spinner 400ms linear infinite;
}
.nprogress-custom-parent {
overflow: hidden;
position: relative;
}
.nprogress-custom-parent #nprogress .spinner,
.nprogress-custom-parent #nprogress .bar {
position: absolute;
}
@-webkit-keyframes nprogress-spinner {
0% {
-webkit-transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
}
}
@keyframes nprogress-spinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -0,0 +1,26 @@
/*!
* IE10 viewport hack for Surface/desktop Windows 8 bug
* Copyright 2014-2015 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
*/(function(){'use strict';if(navigator.userAgent.match(/IEMobile\/10\.0/)){var msViewportStyle=document.createElement('style')
msViewportStyle.appendChild(document.createTextNode('@-ms-viewport{width:auto!important}'))
document.querySelector('head').appendChild(msViewportStyle)}})();;/*!
* Copyright 2014-2015 Twitter, Inc.
*
* Licensed under the Creative Commons Attribution 3.0 Unported License. For
* details, see https://creativecommons.org/licenses/by/3.0/.
*/(function(){'use strict';function emulatedIEMajorVersion(){var groups=/MSIE ([0-9.]+)/.exec(window.navigator.userAgent)
if(groups===null){return null}
var ieVersionNum=parseInt(groups[1],10)
var ieMajorVersion=Math.floor(ieVersionNum)
return ieMajorVersion}
function actualNonEmulatedIEMajorVersion(){var jscriptVersion=new Function('/*@cc_on return @_jscript_version; @*/')()
if(jscriptVersion===undefined){return 11}
if(jscriptVersion<9){return 8}
return jscriptVersion}
var ua=window.navigator.userAgent
if(ua.indexOf('Opera')>-1||ua.indexOf('Presto')>-1){return}
var emulated=emulatedIEMajorVersion()
if(emulated===null){return}
var nonEmulated=actualNonEmulatedIEMajorVersion()
if(emulated!==nonEmulated){window.alert('WARNING: You appear to be using IE'+nonEmulated+' in IE'+emulated+' emulation mode.\nIE emulation modes can behave significantly differently from ACTUAL older versions of IE.\nPLEASE DON\'T FILE BOOTSTRAP BUGS based on testing in IE emulation modes!')}})();;

@ -0,0 +1,6 @@
{
"e33c03e5cececc24214f8c70d7bfaf0079144308b8384c2b99378574a030b9a3": "<link rel=\"stylesheet\" href=\"/__compressor_url_placeholder__/CACHE/css/output.758e876fbda7.css\" type=\"text/css\" media=\"all\"><link rel=\"stylesheet\" href=\"/__compressor_url_placeholder__/CACHE/css/output.20f74afba408.css\" type=\"text/css\">",
"8efb09bae7df3f7f0c86c3573cd92622e620c23d356cee88d602cc06cd8ba0a5": "<link rel=\"stylesheet\" href=\"/__compressor_url_placeholder__/CACHE/css/output.5ee9ff3cb1a7.css\" type=\"text/css\">",
"602f536ee15494b2c004d9caae6d729f444aeae603707972c22cb7085ef69aa8": "<script src=\"/__compressor_url_placeholder__/CACHE/js/output.bc55ccd28723.js\"></script>",
"2e8e3574500075700cfa894c402675e08c87a3b71b31ed7d0002d493a526bcc8": "<script src=\"/__compressor_url_placeholder__/CACHE/js/output.de188198a436.js\"></script>"
}

@ -0,0 +1,25 @@
/* 按钮样式 */
/* Button styles */
.button {
/* 移除边框 */
/* Remove border */
border: none;
/* 设置内边距 */
/* Set padding */
padding: 4px 80px;
/* 设置文本对齐方式 */
/* Set text alignment */
text-align: center;
/* 移除文本装饰 */
/* Remove text decoration */
text-decoration: none;
/* 设置为行内块元素 */
/* Set as inline-block element */
display: inline-block;
/* 设置字体大小 */
/* Set font size */
font-size: 16px;
/* 设置外边距 */
/* Set margin */
margin: 4px 2px;
}

@ -0,0 +1,101 @@
// 设置倒计时初始值为60秒
// Set initial countdown value to 60 seconds
let wait = 60;
// 倒计时函数,用于在发送验证码按钮上显示倒计时
// Countdown function to display countdown on the send verification code button
function time(o) {
// 如果倒计时结束
// If countdown is over
if (wait == 0) {
// 移除按钮的禁用状态
// Remove button disabled state
o.removeAttribute("disabled");
// 恢复按钮文本
// Restore button text
o.value = "获取验证码";
// 重置倒计时
// Reset countdown
wait = 60
return false
} else {
// 设置按钮为禁用状态
// Set button to disabled state
o.setAttribute("disabled", true);
// 更新按钮文本显示倒计时
// Update button text to show countdown
o.value = "重新发送(" + wait + ")";
// 倒计时减1
// Decrease countdown by 1
wait--;
// 1秒后递归调用此函数
// Recursively call this function after 1 second
setTimeout(function () {
time(o)
},
1000)
}
}
// 获取验证码按钮点击事件处理
// Verification code button click event handler
document.getElementById("btn").onclick = function () {
// 获取邮箱输入框元素
// Get email input element
let id_email = $("#id_email")
// 获取CSRF令牌
// Get CSRF token
let token = $("*[name='csrfmiddlewaretoken']").val()
// 获取当前按钮元素
// Get current button element
let ts = this
// 获取错误信息显示元素
// Get error message display element
let myErr = $("#myErr")
// 发送AJAX请求
// Send AJAX request
$.ajax(
{
// 请求URL
// Request URL
url: "/forget_password_code/",
// 请求类型
// Request type
type: "POST",
// 请求数据
// Request data
data: {
"email": id_email.val(),
"csrfmiddlewaretoken": token
},
// 请求成功回调函数
// Success callback function
success: function (result) {
// 如果返回结果不是"ok"
// If the returned result is not "ok"
if (result != "ok") {
// 移除旧的错误信息
// Remove old error message
myErr.remove()
// 在邮箱输入框后添加新的错误信息
// Add new error message after email input
id_email.after("<ul className='errorlist' id='myErr'><li>" + result + "</li></ul>")
return
}
// 移除错误信息
// Remove error message
myErr.remove()
// 启动倒计时
// Start countdown
time(ts)
},
// 请求失败回调函数
// Error callback function
error: function (e) {
// 弹出发送失败提示
// Show send failure alert
alert("发送失败,请重试")
}
}
);
}

@ -0,0 +1,332 @@
/* 管理界面自动补全样式 */
/* Admin autocomplete styles */
/* 选择器宽度设置 */
select.admin-autocomplete {
width: 20em;
}
/* 自动补全容器最小高度设置 */
.select2-container--admin-autocomplete.select2-container {
min-height: 30px;
}
/* 单选和多选容器最小高度设置 */
.select2-container--admin-autocomplete .select2-selection--single,
.select2-container--admin-autocomplete .select2-selection--multiple {
min-height: 30px;
padding: 0;
}
/* 聚焦或打开状态下的选择器边框颜色 */
.select2-container--admin-autocomplete.select2-container--focus .select2-selection,
.select2-container--admin-autocomplete.select2-container--open .select2-selection {
border-color: var(--body-quiet-color);
min-height: 30px;
}
/* 聚焦或打开状态下单选选择器内边距 */
.select2-container--admin-autocomplete.select2-container--focus .select2-selection.select2-selection--single,
.select2-container--admin-autocomplete.select2-container--open .select2-selection.select2-selection--single {
padding: 0;
}
/* 聚焦或打开状态下多选选择器内边距 */
.select2-container--admin-autocomplete.select2-container--focus .select2-selection.select2-selection--multiple,
.select2-container--admin-autocomplete.select2-container--open .select2-selection.select2-selection--multiple {
padding: 0;
}
/* 单选选择器背景、边框和圆角设置 */
.select2-container--admin-autocomplete .select2-selection--single {
background-color: var(--body-bg);
border: 1px solid var(--border-color);
border-radius: 4px;
}
/* 单选选择器渲染内容颜色和行高设置 */
.select2-container--admin-autocomplete .select2-selection--single .select2-selection__rendered {
color: var(--body-fg);
line-height: 30px;
}
/* 单选选择器清除按钮光标和浮动设置 */
.select2-container--admin-autocomplete .select2-selection--single .select2-selection__clear {
cursor: pointer;
float: right;
font-weight: bold;
}
/* 单选选择器占位符颜色设置 */
.select2-container--admin-autocomplete .select2-selection--single .select2-selection__placeholder {
color: var(--body-quiet-color);
}
/* 单选选择器箭头高度、位置和宽度设置 */
.select2-container--admin-autocomplete .select2-selection--single .select2-selection__arrow {
height: 26px;
position: absolute;
top: 1px;
right: 1px;
width: 20px;
}
/* 单选选择器箭头图标样式设置 */
.select2-container--admin-autocomplete .select2-selection--single .select2-selection__arrow b {
border-color: #888 transparent transparent transparent;
border-style: solid;
border-width: 5px 4px 0 4px;
height: 0;
left: 50%;
margin-left: -4px;
margin-top: -2px;
position: absolute;
top: 50%;
width: 0;
}
/* 从右到左语言环境下单选选择器清除按钮浮动设置 */
.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--single .select2-selection__clear {
float: left;
}
/* 从右到左语言环境下单选选择器箭头位置设置 */
.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--single .select2-selection__arrow {
left: 1px;
right: auto;
}
/* 禁用状态下单选选择器背景和光标设置 */
.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--single {
background-color: var(--darkened-bg);
cursor: default;
}
/* 禁用状态下单选选择器清除按钮显示设置 */
.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--single .select2-selection__clear {
display: none;
}
/* 打开状态下单选选择器箭头图标样式设置 */
.select2-container--admin-autocomplete.select2-container--open .select2-selection--single .select2-selection__arrow b {
border-color: transparent transparent #888 transparent;
border-width: 0 4px 5px 4px;
}
/* 多选选择器背景、边框、圆角和光标设置 */
.select2-container--admin-autocomplete .select2-selection--multiple {
background-color: var(--body-bg);
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: text;
}
/* 多选选择器渲染内容盒模型、列表样式、外边距、内边距和宽度设置 */
.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__rendered {
box-sizing: border-box;
list-style: none;
margin: 0;
padding: 0 10px 5px 5px;
width: 100%;
display: flex;
flex-wrap: wrap;
}
/* 多选选择器渲染内容列表项样式设置 */
.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__rendered li {
list-style: none;
}
/* 多选选择器占位符颜色、上外边距和浮动设置 */
.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__placeholder {
color: var(--body-quiet-color);
margin-top: 5px;
float: left;
}
/* 多选选择器清除按钮光标、浮动、字体粗细、外边距、位置和右边距设置 */
.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__clear {
cursor: pointer;
float: right;
font-weight: bold;
margin: 5px;
position: absolute;
right: 0;
}
/* 多选选择器选项背景、边框、圆角、光标、浮动、右边距、上外边距和内边距设置 */
.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice {
background-color: var(--darkened-bg);
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: default;
float: left;
margin-right: 5px;
margin-top: 5px;
padding: 0 5px;
}
/* 多选选择器选项移除按钮颜色、光标、显示、字体粗细和右边距设置 */
.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice__remove {
color: var(--body-quiet-color);
cursor: pointer;
display: inline-block;
font-weight: bold;
margin-right: 2px;
}
/* 多选选择器选项移除按钮悬停状态颜色设置 */
.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice__remove:hover {
color: var(--body-fg);
}
/* 从右到左语言环境下多选选择器选项、占位符和内联搜索浮动设置 */
.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice, .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__placeholder, .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-search--inline {
float: right;
}
/* 从右到左语言环境下多选选择器选项左边距和右边距设置 */
.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice {
margin-left: 5px;
margin-right: auto;
}
/* 从右到左语言环境下多选选择器选项移除按钮左边距和右边距设置 */
.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove {
margin-left: 2px;
margin-right: auto;
}
/* 聚焦状态下多选选择器边框设置 */
.select2-container--admin-autocomplete.select2-container--focus .select2-selection--multiple {
border: solid var(--body-quiet-color) 1px;
outline: 0;
}
/* 禁用状态下多选选择器背景和光标设置 */
.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--multiple {
background-color: var(--darkened-bg);
cursor: default;
}
/* 禁用状态下多选选择器选项移除按钮显示设置 */
.select2-container--admin-autocomplete.select2-container--disabled .select2-selection__choice__remove {
display: none;
}
/* 打开状态下选择器上边框圆角设置 */
.select2-container--admin-autocomplete.select2-container--open.select2-container--above .select2-selection--single, .select2-container--admin-autocomplete.select2-container--open.select2-container--above .select2-selection--multiple {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
/* 打开状态下选择器下边框圆角设置 */
.select2-container--admin-autocomplete.select2-container--open.select2-container--below .select2-selection--single, .select2-container--admin-autocomplete.select2-container--open.select2-container--below .select2-selection--multiple {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
/* 下拉搜索框背景设置 */
.select2-container--admin-autocomplete .select2-search--dropdown {
background: var(--darkened-bg);
}
/* 下拉搜索框字段背景、颜色、边框、圆角设置 */
.select2-container--admin-autocomplete .select2-search--dropdown .select2-search__field {
background: var(--body-bg);
color: var(--body-fg);
border: 1px solid var(--border-color);
border-radius: 4px;
}
/* 内联搜索框字段背景、颜色、边框、轮廓和外观设置 */
.select2-container--admin-autocomplete .select2-search--inline .select2-search__field {
background: transparent;
color: var(--body-fg);
border: none;
outline: 0;
box-shadow: none;
-webkit-appearance: textfield;
}
/* 结果选项最大高度、溢出和颜色设置 */
.select2-container--admin-autocomplete .select2-results > .select2-results__options {
max-height: 200px;
overflow-y: auto;
color: var(--body-fg);
background: var(--body-bg);
}
/* 分组结果选项内边距设置 */
.select2-container--admin-autocomplete .select2-results__option[role=group] {
padding: 0;
}
/* 禁用结果选项颜色设置 */
.select2-container--admin-autocomplete .select2-results__option[aria-disabled=true] {
color: var(--body-quiet-color);
}
/* 选中结果选项背景和颜色设置 */
.select2-container--admin-autocomplete .select2-results__option[aria-selected=true] {
background-color: var(--selected-bg);
color: var(--body-fg);
}
/* 结果选项嵌套内边距设置 */
.select2-container--admin-autocomplete .select2-results__option .select2-results__option {
padding-left: 1em;
}
/* 结果选项嵌套分组内边距设置 */
.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__group {
padding-left: 0;
}
/* 结果选项嵌套第二层内边距和左边距设置 */
.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option {
margin-left: -1em;
padding-left: 2em;
}
/* 结果选项嵌套第三层内边距和左边距设置 */
.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
margin-left: -2em;
padding-left: 3em;
}
/* 结果选项嵌套第四层内边距和左边距设置 */
.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
margin-left: -3em;
padding-left: 4em;
}
/* 结果选项嵌套第五层内边距和左边距设置 */
.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
margin-left: -4em;
padding-left: 5em;
}
/* 结果选项嵌套第六层内边距和左边距设置 */
.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
margin-left: -5em;
padding-left: 6em;
}
/* 高亮结果选项背景和颜色设置 */
.select2-container--admin-autocomplete .select2-results__option--highlighted[aria-selected] {
background-color: var(--primary);
color: var(--primary-fg);
}
/* 分组结果选项光标、显示、内边距设置 */
.select2-container--admin-autocomplete .select2-results__group {
cursor: default;
display: block;
padding: 6px;
}
/* 错误状态下选择器边框设置 */
.errors .select2-selection {
border: 1px solid var(--error-fg);
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,350 @@
/* 变更列表 */
/* CHANGELISTS */
#changelist {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
#changelist .changelist-form-container {
flex: 1 1 auto;
min-width: 0;
}
#changelist table {
width: 100%;
}
.change-list .hiddenfields { display:none; }
.change-list .filtered table {
border-right: none;
}
.change-list .filtered {
min-height: 400px;
}
.change-list .filtered .results, .change-list .filtered .paginator,
.filtered #toolbar, .filtered div.xfull {
width: auto;
}
.change-list .filtered table tbody th {
padding-right: 1em;
}
#changelist-form .results {
overflow-x: auto;
width: 100%;
}
#changelist .toplinks {
border-bottom: 1px solid var(--hairline-color);
}
#changelist .paginator {
color: var(--body-quiet-color);
border-bottom: 1px solid var(--hairline-color);
background: var(--body-bg);
overflow: hidden;
}
/* 变更列表表格 */
/* CHANGELIST TABLES */
#changelist table thead th {
padding: 0;
white-space: nowrap;
vertical-align: middle;
}
#changelist table thead th.action-checkbox-column {
width: 1.5em;
text-align: center;
}
#changelist table tbody td.action-checkbox {
text-align: center;
}
#changelist table tfoot {
color: var(--body-quiet-color);
}
/* 工具栏 */
/* TOOLBAR */
#toolbar {
padding: 8px 10px;
margin-bottom: 15px;
border-top: 1px solid var(--hairline-color);
border-bottom: 1px solid var(--hairline-color);
background: var(--darkened-bg);
color: var(--body-quiet-color);
}
#toolbar form input {
border-radius: 4px;
font-size: 0.875rem;
padding: 5px;
color: var(--body-fg);
}
#toolbar #searchbar {
height: 1.1875rem;
border: 1px solid var(--border-color);
padding: 2px 5px;
margin: 0;
vertical-align: top;
font-size: 0.8125rem;
max-width: 100%;
}
#toolbar #searchbar:focus {
border-color: var(--body-quiet-color);
}
#toolbar form input[type="submit"] {
border: 1px solid var(--border-color);
font-size: 0.8125rem;
padding: 4px 8px;
margin: 0;
vertical-align: middle;
background: var(--body-bg);
box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset;
cursor: pointer;
color: var(--body-fg);
}
#toolbar form input[type="submit"]:focus,
#toolbar form input[type="submit"]:hover {
border-color: var(--body-quiet-color);
}
#changelist-search img {
vertical-align: middle;
margin-right: 4px;
}
#changelist-search .help {
word-break: break-word;
}
/* 过滤器列 */
/* FILTER COLUMN */
#changelist-filter {
flex: 0 0 240px;
order: 1;
background: var(--darkened-bg);
border-left: none;
margin: 0 0 0 30px;
}
@media (forced-colors: active) {
#changelist-filter {
border: 1px solid;
}
}
#changelist-filter h2 {
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 5px 15px;
margin-bottom: 12px;
border-bottom: none;
}
#changelist-filter h3,
#changelist-filter details summary {
font-weight: 400;
padding: 0 15px;
margin-bottom: 10px;
}
#changelist-filter details summary > * {
display: inline;
}
#changelist-filter details > summary {
list-style-type: none;
}
#changelist-filter details > summary::-webkit-details-marker {
display: none;
}
#changelist-filter details > summary::before {
content: '→';
font-weight: bold;
color: var(--link-hover-color);
}
#changelist-filter details[open] > summary::before {
content: '↓';
}
#changelist-filter ul {
margin: 5px 0;
padding: 0 15px 15px;
border-bottom: 1px solid var(--hairline-color);
}
#changelist-filter ul:last-child {
border-bottom: none;
}
#changelist-filter li {
list-style-type: none;
margin-left: 0;
padding-left: 0;
}
#changelist-filter a {
display: block;
color: var(--body-quiet-color);
word-break: break-word;
}
#changelist-filter li.selected {
border-left: 5px solid var(--hairline-color);
padding-left: 10px;
margin-left: -15px;
}
#changelist-filter li.selected a {
color: var(--link-selected-fg);
}
#changelist-filter a:focus, #changelist-filter a:hover,
#changelist-filter li.selected a:focus,
#changelist-filter li.selected a:hover {
color: var(--link-hover-color);
}
#changelist-filter #changelist-filter-extra-actions {
font-size: 0.8125rem;
margin-bottom: 10px;
border-bottom: 1px solid var(--hairline-color);
}
/* 日期钻取 */
/* DATE DRILLDOWN */
.change-list .toplinks {
display: flex;
padding-bottom: 5px;
flex-wrap: wrap;
gap: 3px 17px;
font-weight: bold;
}
.change-list .toplinks a {
font-size: 0.8125rem;
}
.change-list .toplinks .date-back {
color: var(--body-quiet-color);
}
.change-list .toplinks .date-back:focus,
.change-list .toplinks .date-back:hover {
color: var(--link-hover-color);
}
/* 操作 */
/* ACTIONS */
.filtered .actions {
border-right: none;
}
#changelist table input {
margin: 0;
vertical-align: baseline;
}
/* 一旦所有浏览器都支持:has()伪类可以移除tr.selected选择器和添加该类的JS代码 */
/* Once the :has() pseudo-class is supported by all browsers, the tr.selected
selector and the JS adding the class can be removed. */
#changelist tbody tr.selected {
background-color: var(--selected-row);
}
#changelist tbody tr:has(.action-select:checked) {
background-color: var(--selected-row);
}
@media (forced-colors: active) {
#changelist tbody tr.selected {
background-color: SelectedItem;
}
#changelist tbody tr:has(.action-select:checked) {
background-color: SelectedItem;
}
}
#changelist .actions {
padding: 10px;
background: var(--body-bg);
border-top: none;
border-bottom: none;
line-height: 1.5rem;
color: var(--body-quiet-color);
width: 100%;
}
#changelist .actions span.all,
#changelist .actions span.action-counter,
#changelist .actions span.clear,
#changelist .actions span.question {
font-size: 0.8125rem;
margin: 0 0.5em;
}
#changelist .actions:last-child {
border-bottom: none;
}
#changelist .actions select {
vertical-align: top;
height: 1.5rem;
color: var(--body-fg);
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 0.875rem;
padding: 0 0 0 4px;
margin: 0;
margin-left: 10px;
}
#changelist .actions select:focus {
border-color: var(--body-quiet-color);
}
#changelist .actions label {
display: inline-block;
vertical-align: middle;
font-size: 0.8125rem;
}
#changelist .actions .button {
font-size: 0.8125rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--body-bg);
box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset;
cursor: pointer;
height: 1.5rem;
line-height: 1;
padding: 4px 8px;
margin: 0;
color: var(--body-fg);
}
#changelist .actions .button:focus, #changelist .actions .button:hover {
border-color: var(--body-quiet-color);
}

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

Loading…
Cancel
Save