Compare commits

...

16 Commits

@ -0,0 +1,18 @@
<!--
如果你不认真勾选下面的内容,我可能会直接关闭你的 Issue。
提问之前,建议先阅读 https://github.com/ruby-china/How-To-Ask-Questions-The-Smart-Way
-->
**我确定我已经查看了** (标注`[ ]`为`[x]`)
- [ ] [DjangoBlog的readme](https://github.com/liangliangyy/DjangoBlog/blob/master/README.md)
- [ ] [配置说明](https://github.com/liangliangyy/DjangoBlog/blob/master/bin/config.md)
- [ ] [其他 Issues](https://github.com/liangliangyy/DjangoBlog/issues)
----
**我要申请** (标注`[ ]`为`[x]`)
- [ ] BUG 反馈
- [ ] 添加新的特性或者功能
- [ ] 请求技术支持

@ -0,0 +1,47 @@
name: "CodeQL"
on:
push:
branches:
- master
- dev
paths-ignore:
- '**/*.md'
- '**/*.css'
- '**/*.js'
- '**/*.yml'
- '**/*.txt'
pull_request:
branches:
- master
- dev
paths-ignore:
- '**/*.md'
- '**/*.css'
- '**/*.js'
- '**/*.yml'
- '**/*.txt'
schedule:
- cron: '30 1 * * 0'
jobs:
CodeQL-Build:
runs-on: ubuntu-latest
permissions:
security-events: write
actions: read
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
- name: Autobuild
uses: github/codeql-action/autobuild@v2
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2

@ -0,0 +1,136 @@
name: Django CI
on:
push:
branches:
- master
- dev
paths-ignore:
- '**/*.md'
- '**/*.css'
- '**/*.js'
pull_request:
branches:
- master
- dev
paths-ignore:
- '**/*.md'
- '**/*.css'
- '**/*.js'
jobs:
build-normal:
runs-on: ubuntu-latest
strategy:
max-parallel: 4
matrix:
python-version: ["3.10","3.11" ]
steps:
- name: Start MySQL
uses: samin/mysql-action@v1.3
with:
host port: 3306
container port: 3306
character set server: utf8mb4
collation server: utf8mb4_general_ci
mysql version: latest
mysql root password: root
mysql database: djangoblog
mysql user: root
mysql password: root
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run Tests
env:
DJANGO_MYSQL_PASSWORD: root
DJANGO_MYSQL_HOST: 127.0.0.1
run: |
python manage.py makemigrations
python manage.py migrate
python manage.py test
build-with-es:
runs-on: ubuntu-latest
strategy:
max-parallel: 4
matrix:
python-version: ["3.10","3.11" ]
steps:
- name: Start MySQL
uses: samin/mysql-action@v1.3
with:
host port: 3306
container port: 3306
character set server: utf8mb4
collation server: utf8mb4_general_ci
mysql version: latest
mysql root password: root
mysql database: djangoblog
mysql user: root
mysql password: root
- name: Configure sysctl limits
run: |
sudo swapoff -a
sudo sysctl -w vm.swappiness=1
sudo sysctl -w fs.file-max=262144
sudo sysctl -w vm.max_map_count=262144
- uses: miyataka/elasticsearch-github-actions@1
with:
stack-version: '7.12.1'
plugins: 'https://release.infinilabs.com/analysis-ik/stable/elasticsearch-analysis-ik-7.12.1.zip'
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run Tests
env:
DJANGO_MYSQL_PASSWORD: root
DJANGO_MYSQL_HOST: 127.0.0.1
DJANGO_ELASTICSEARCH_HOST: 127.0.0.1:9200
run: |
python manage.py makemigrations
python manage.py migrate
coverage run manage.py test
coverage xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v1
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Build and push
uses: docker/build-push-action@v3
with:
context: .
push: false
tags: djangoblog/djangoblog:dev

@ -0,0 +1,43 @@
name: docker
on:
push:
paths-ignore:
- '**/*.md'
- '**/*.yml'
branches:
- 'master'
- 'dev'
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Set env to docker dev tag
if: endsWith(github.ref, '/dev')
run: |
echo "DOCKER_TAG=test" >> $GITHUB_ENV
- name: Set env to docker latest tag
if: endsWith(github.ref, '/master')
run: |
echo "DOCKER_TAG=latest" >> $GITHUB_ENV
- name: Checkout
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v3
with:
context: .
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/djangoblog:${{env.DOCKER_TAG}}

@ -0,0 +1,39 @@
name: publish release
on:
release:
types: [ published ]
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v3
with:
images: name/app
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v3
with:
context: .
push: true
platforms: |
linux/amd64
linux/arm64
linux/arm/v7
linux/arm/v6
linux/386
tags: ${{ secrets.DOCKERHUB_USERNAME }}/djangoblog:${{ github.event.release.tag_name }}

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

@ -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$" />
<option name="settingsModule" value="djangoblog/settings.py" />
<option name="manageScript" value="$MODULE_DIR$/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$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.12 (pythonProject)" 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="Django" />
<option name="TEMPLATE_FOLDERS">
<list>
<option value="$MODULE_DIR$/templates" />
</list>
</option>
</component>
</module>

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

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.12 (DjangoBlog)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (pythonProject)" 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/DjangoBlog.iml" filepath="$PROJECT_DIR$/.idea/DjangoBlog.iml" />
</modules>
</component>
</project>

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

Binary file not shown.

@ -0,0 +1,87 @@
# 导入Django表单基类
from django import forms
# 导入Django内置的用户管理员类用于扩展自定义用户的admin配置
from django.contrib.auth.admin import UserAdmin
# 导入Django内置的用户修改表单用于扩展自定义用户的修改表单
from django.contrib.auth.forms import UserChangeForm
# 导入Django内置的用户名字段类用于自定义用户名字段验证
from django.contrib.auth.forms import UsernameField
# 导入翻译工具,用于实现字段名称的国际化
from django.utils.translation import gettext_lazy as _
# 注册模型时需要导入自定义的用户模型
from .models import BlogUser
class BlogUserCreationForm(forms.ModelForm):
"""自定义用户创建表单用于在admin站点添加新用户时使用"""
# 密码字段,使用密码输入框(输入内容隐藏),标签支持国际化
password1 = forms.CharField(label=_('password'), widget=forms.PasswordInput)
# 确认密码字段,用于验证两次输入的密码是否一致
password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput)
class Meta:
# 关联的模型为自定义的BlogUser
model = BlogUser
# 创建用户时需要填写的字段这里仅指定email其他字段可默认或后续补充
fields = ('email',)
def clean_password2(self):
"""验证两次输入的密码是否一致"""
# 获取清洗后的密码1和密码2
password1 = self.cleaned_data.get("password1")
password2 = self.cleaned_data.get("password2")
# 如果两次密码都存在且不一致,抛出验证错误
if password1 and password2 and password1 != password2:
raise forms.ValidationError(_("passwords do not match"))
return password2
def save(self, commit=True):
"""保存用户,将密码加密处理,并设置创建来源"""
# 调用父类的save方法先不提交到数据库commit=False
user = super().save(commit=False)
# 对密码进行加密处理Django内置的密码哈希方法
user.set_password(self.cleaned_data["password1"])
# 如果需要提交到数据库
if commit:
# 设置用户的创建来源为'adminsite'表示通过admin站点创建
user.source = 'adminsite'
user.save()
return user
class BlogUserChangeForm(UserChangeForm):
"""自定义用户修改表单用于在admin站点编辑用户信息时使用"""
class Meta:
# 关联的模型为BlogUser
model = BlogUser
# 显示所有字段(可根据需要指定具体字段)
fields = '__all__'
# 指定用户名字段的处理类为UsernameField提供内置验证
field_classes = {'username': UsernameField}
def __init__(self, *args, **kwargs):
"""初始化方法,调用父类的初始化逻辑"""
super().__init__(*args, **kwargs)
class BlogUserAdmin(UserAdmin):
"""自定义用户管理员类用于在admin站点配置BlogUser的展示和操作"""
# 指定修改用户时使用的表单
form = BlogUserChangeForm
# 指定添加用户时使用的表单
add_form = BlogUserCreationForm
# 列表页展示的字段
list_display = (
'id', # 用户ID
'nickname', # 昵称
'username', # 用户名
'email', # 邮箱
'last_login', # 最后登录时间
'date_joined', # 注册时间
'source' # 创建来源
)
# 列表页中可点击跳转详情页的字段
list_display_links = ('id', 'username')
# 列表页的排序方式按ID降序即最新创建的用户在前
ordering = ('-id',)

@ -0,0 +1,12 @@
# 从Django的apps模块导入AppConfig类用于定义应用的配置信息
from django.apps import AppConfig
class AccountsConfig(AppConfig):
"""
accounts应用的配置类用于设置应用的基本信息
继承自Django的AppConfig通过此类可以配置应用的名称默认自动生成的主键类型等元数据
"""
# 定义应用的名称Django通过此名称识别该应用与项目settings.py中INSTALLED_APPS里的配置对应
name = 'accounts'

@ -0,0 +1,153 @@
# 导入Django表单基类
from django import forms
# 导入Django用户模型工具及密码验证功能
from django.contrib.auth import get_user_model, password_validation
# 导入Django内置的认证表单登录、用户创建
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
# 导入Django验证错误类用于自定义表单验证
from django.core.exceptions import ValidationError
# 导入Django表单控件用于自定义输入框样式
from django.forms import widgets
# 导入翻译工具,实现字段名称/提示的国际化
from django.utils.translation import gettext_lazy as _
# 导入自定义工具类(可能用于验证码验证等)
from . import utils
# 导入自定义用户模型
from .models import BlogUser
class LoginForm(AuthenticationForm):
"""自定义登录表单继承自Django内置的AuthenticationForm"""
def __init__(self, *args, **kwargs):
"""初始化方法,重写父类初始化逻辑以自定义表单控件样式"""
super(LoginForm, self).__init__(*args, **kwargs)
# 自定义用户名字段的输入控件设置占位符和CSS类
self.fields['username'].widget = widgets.TextInput(
attrs={'placeholder': "username", "class": "form-control"})
# 自定义密码字段的输入控件使用密码输入框设置占位符和CSS类
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)
# 自定义用户名字段控件文本输入框设置占位符和CSS类
self.fields['username'].widget = widgets.TextInput(
attrs={'placeholder': "username", "class": "form-control"})
# 自定义邮箱字段控件邮箱输入框设置占位符和CSS类
self.fields['email'].widget = widgets.EmailInput(
attrs={'placeholder': "email", "class": "form-control"})
# 自定义密码1字段控件密码输入框设置占位符和CSS类
self.fields['password1'].widget = widgets.PasswordInput(
attrs={'placeholder': "password", "class": "form-control"})
# 自定义密码2字段控件密码输入框确认密码设置占位符和CSS类
self.fields['password2'].widget = widgets.PasswordInput(
attrs={'placeholder': "repeat password", "class": "form-control"})
def clean_email(self):
"""自定义邮箱验证:检查邮箱是否已被注册"""
email = self.cleaned_data['email']
# 如果该邮箱已存在于用户表中,抛出验证错误
if get_user_model().objects.filter(email=email).exists():
raise ValidationError(_("email already exists"))
return email
class Meta:
# 关联的用户模型通过get_user_model获取项目配置的用户模型
model = get_user_model()
# 注册表单需填写的字段:用户名和邮箱
fields = ("username", "email")
class ForgetPasswordForm(forms.Form):
"""忘记密码表单,用于用户重置密码(包含密码重置、邮箱验证、验证码验证)"""
# 新密码字段标签国际化使用密码输入框设置CSS类和占位符
new_password1 = forms.CharField(
label=_("New password"),
widget=forms.PasswordInput(
attrs={
"class": "form-control",
'placeholder': _("New password")
}
),
)
# 确认新密码字段:标签为“确认密码”,使用密码输入框,设置样式
new_password2 = forms.CharField(
label="确认密码",
widget=forms.PasswordInput(
attrs={
"class": "form-control",
'placeholder': _("Confirm password")
}
),
)
# 邮箱字段:用于验证用户身份,使用文本输入框,设置样式
email = forms.EmailField(
label='邮箱',
widget=forms.TextInput(
attrs={
'class': 'form-control',
'placeholder': _("Email")
}
),
)
# 验证码字段:用于验证用户真实性,使用文本输入框,设置样式
code = forms.CharField(
label=_('Code'),
widget=forms.TextInput(
attrs={
'class': 'form-control',
'placeholder': _("Code")
}
),
)
def clean_new_password2(self):
"""验证两次输入的新密码是否一致,并验证密码强度"""
password1 = self.data.get("new_password1") # 获取第一次输入的密码
password2 = self.data.get("new_password2") # 获取第二次输入的密码
# 如果两次密码都存在且不一致,抛出验证错误
if password1 and password2 and password1 != password2:
raise ValidationError(_("passwords do not match"))
# 使用Django内置密码验证器验证密码强度如长度、复杂度等
password_validation.validate_password(password2)
return password2
def clean_email(self):
"""验证邮箱是否已注册(存在于用户表中)"""
user_email = self.cleaned_data.get("email")
# 如果该邮箱不存在于BlogUser表中抛出验证错误
if not BlogUser.objects.filter(
email=user_email
).exists():
# 注意:此处提示可能暴露邮箱是否注册,可根据需求修改
raise ValidationError(_("email does not exist"))
return user_email
def clean_code(self):
"""验证验证码是否有效调用自定义工具类的verify方法"""
code = self.cleaned_data.get("code")
# 调用utils.verify验证邮箱和验证码是否匹配返回错误信息若有
error = utils.verify(
email=self.cleaned_data.get("email"),
code=code,
)
# 如果验证失败(有错误信息),抛出验证错误
if error:
raise ValidationError(error)
return code
class ForgetPasswordCodeForm(forms.Form):
"""获取忘记密码验证码的表单,用于提交邮箱以发送验证码"""
# 邮箱字段:用于指定需要发送验证码的邮箱,标签国际化
email = forms.EmailField(
label=_('Email'),
)

@ -0,0 +1,77 @@
# Generated by Django 4.1.7 on 2023-03-02 07:14
# 以上注释为Django自动生成标识该迁移文件由Django 4.1.7版本在2023-03-02 07:14生成
# 导入Django内置的用户模型相关模块
import django.contrib.auth.models
# 导入Django内置的用户验证器
import django.contrib.auth.validators
# 从django.db导入迁移和模型相关类
from django.db import migrations, models
# 导入Django的时区工具
import django.utils.timezone
# 定义迁移类继承自migrations.Migration
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'
('password', models.CharField(max_length=128, verbose_name='password')),
# 最后登录时间字段,可为空且允许空白,显示名称为'last login'
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
# 是否为超级用户字段默认False包含帮助文本和显示名称
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
# 用户名字段包含错误信息、帮助文本、最大长度150、唯一约束、验证器和显示名称
('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')),
# 名字段允许空白最大长度150显示名称为'first name'
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
# 姓字段允许空白最大长度150显示名称为'last name'
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
# 邮箱字段允许空白最大长度254显示名称为'email address'
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
# 是否为管理员可登录admin站点默认False包含帮助文本和显示名称
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
# 是否激活字段默认True包含帮助文本建议通过此选项禁用账户而非删除和显示名称
('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'
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
# 自定义昵称字段允许空白最大长度100显示名称为'昵称'
('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='修改时间')),
# 自定义创建来源字段允许空白最大长度100显示名称为'创建来源'
('source', models.CharField(blank=True, max_length=100, verbose_name='创建来源')),
# 与auth.Group的多对多关系用于用户组权限管理包含帮助文本、关联名称和显示名称
('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')),
# 与auth.Permission的多对多关系用于用户单独权限管理包含帮助文本、关联名称和显示名称
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={ # 模型的元数据配置
'verbose_name': '用户', # 模型的单数显示名称
'verbose_name_plural': '用户', # 模型的复数显示名称
'ordering': ['-id'], # 排序方式按id降序
'get_latest_by': 'id', # 获取最新记录的依据字段id
},
managers=[ # 模型的管理器配置
# 使用Django内置的UserManager作为模型的管理器
('objects', django.contrib.auth.models.UserManager()),
],
),
]

@ -0,0 +1,68 @@
# Generated by Django 4.2.5 on 2023-09-06 13:13
# 以上注释为Django自动生成标识该迁移文件由Django 4.2.5版本在2023-09-06 13:13生成
# 从django.db导入迁移和模型相关类用于定义数据库迁移操作
from django.db import migrations, models
# 导入Django的时区工具用于处理时间字段的默认值
import django.utils.timezone
# 定义迁移类继承自migrations.Migration用于描述数据库结构的变更
class Migration(migrations.Migration):
# 依赖的其他迁移文件依赖于accounts应用的0001_initial迁移
# 表示当前迁移需要在0001_initial迁移执行之后才能运行
dependencies = [
('accounts', '0001_initial'),
]
# 迁移操作列表,包含一系列对数据库模型的修改操作
operations = [
# 修改BlogUser模型的元数据配置
migrations.AlterModelOptions(
name='bloguser', # 目标模型名称
# 新的元数据选项:
# get_latest_by指定通过id字段获取最新记录
# ordering按id降序排序
# verbose_name/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', # 要移除的字段名
),
# 向BlogUser模型添加'creation_time'字段
migrations.AddField(
model_name='bloguser', # 目标模型名称
name='creation_time', # 新增字段名
# 字段类型为DateTimeField默认值为当前时区时间显示名称为'creation time'
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
# 向BlogUser模型添加'last_modify_time'字段
migrations.AddField(
model_name='bloguser', # 目标模型名称
name='last_modify_time', # 新增字段名
# 字段类型为DateTimeField默认值为当前时区时间显示名称为'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', # 要修改的字段名
# 字段仍为CharField允许空白最大长度100显示名称改为'nick name'
field=models.CharField(blank=True, max_length=100, verbose_name='nick name'),
),
# 修改BlogUser模型的'source'字段属性
migrations.AlterField(
model_name='bloguser', # 目标模型名称
name='source', # 要修改的字段名
# 字段仍为CharField允许空白最大长度100显示名称改为'create source'
field=models.CharField(blank=True, max_length=100, verbose_name='create source'),
),
]

@ -0,0 +1,64 @@
# 导入Django内置的抽象用户模型用于扩展自定义用户功能包含基础用户名、密码等字段
from django.contrib.auth.models import AbstractUser
# 导入Django模型相关类用于定义数据库表结构
from django.db import models
# 导入reverse函数用于通过URL名称生成对应的URL路径
from django.urls import reverse
# 导入now函数用于获取当前时区的时间作为字段默认值
from django.utils.timezone import now
# 导入翻译工具,用于实现模型字段名称的国际化
from django.utils.translation import gettext_lazy as _
# 导入自定义工具函数get_current_site用于获取当前站点的域名信息
from djangoblog.utils import get_current_site
class BlogUser(AbstractUser):
"""
自定义用户模型继承自Django的AbstractUser
扩展了内置用户模型增加了昵称创建时间修改时间创建来源等自定义字段
"""
# 昵称字段支持国际化标签最大长度100允许空白不强制填写
nickname = models.CharField(_('nick name'), max_length=100, blank=True)
# 创建时间字段支持国际化标签默认值为当前时间调用now函数
creation_time = models.DateTimeField(_('creation time'), default=now)
# 最后修改时间字段:支持国际化标签,默认值为当前时间
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
# 创建来源字段记录用户创建的渠道如adminsite、frontend等支持国际化标签允许空白
source = models.CharField(_('create source'), max_length=100, blank=True)
def get_absolute_url(self):
"""
定义模型实例的绝对URL标准Django方法
通过URL名称'blog:author_detail'生成用户详情页的URL参数为用户名
"""
return reverse(
'blog:author_detail', kwargs={
'author_name': self.username}) # kwargs传递URL所需的用户名参数
def __str__(self):
"""
定义模型实例的字符串表示
当打印或引用用户实例时返回用户的邮箱地址便于识别
"""
return self.email
def get_full_url(self):
"""
生成用户详情页的完整URL包含站点域名
结合当前站点域名和get_absolute_url生成的相对路径组成完整链接
"""
# 获取当前站点的域名如www.example.com
site = get_current_site().domain
# 拼接域名和相对路径形成完整URL使用HTTPS协议
url = "https://{site}{path}".format(site=site,
path=self.get_absolute_url())
return url
class Meta:
"""
模型的元数据配置用于定义模型的显示和行为规则
"""
ordering = ['-id'] # 数据查询时的默认排序按ID降序最新创建的用户在前
verbose_name = _('user') # 模型的单数显示名称(支持国际化)
verbose_name_plural = verbose_name # 模型的复数显示名称(与单数一致,避免英文复数变形问题)
get_latest_by = 'id' # 获取"最新记录"时的依据字段按ID判断ID最大的为最新

@ -0,0 +1,276 @@
# 导入Django测试所需的核心类Client模拟HTTP请求、RequestFactory创建请求对象、TestCase测试基类
from django.test import Client, RequestFactory, TestCase
# 导入reverse通过URL名称生成路径用于测试中定位接口
from django.urls import reverse
# 导入timezone处理时间相关字段用于创建测试数据
from django.utils import timezone
# 导入翻译工具,用于处理国际化文本(测试中未直接使用,但保持原导入)
from django.utils.translation import gettext_lazy as _
# 导入需要测试的自定义模型:用户模型、文章模型、分类模型
from accounts.models import BlogUser
from blog.models import Article, Category
# 导入项目工具函数(如获取当前站点、加密、缓存操作等)
from djangoblog.utils import *
# 导入当前模块的工具函数(如验证码处理)
from . import utils
# 定义账户相关测试类继承Django的TestCase提供测试框架支持
class AccountTest(TestCase):
def setUp(self):
"""
测试初始化方法在每个测试方法执行前自动调用
用于创建共用的测试对象避免代码重复
"""
# 创建测试客户端用于模拟用户发送HTTP请求
self.client = Client()
# 创建请求工厂,用于生成原始请求对象(按需使用)
self.factory = RequestFactory()
# 创建普通测试用户用户名test、邮箱admin@admin.com、密码12345678
self.blog_user = BlogUser.objects.create_user(
username="test",
email="admin@admin.com",
password="12345678"
)
# 定义测试用的新密码,后续忘记密码测试中使用
self.new_test = "xxx123--="
def test_validate_account(self):
"""测试账户基础功能超级用户创建、登录验证、admin访问、内容创建与管理"""
# 获取当前站点域名(测试中未实际使用,保持原逻辑)
site = get_current_site().domain
# 创建超级用户拥有admin管理权限
user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com",
username="liangliangyy1",
password="qwer!@#$ggg")
# 通过用户名查询刚创建的超级用户,验证创建成功
testuser = BlogUser.objects.get(username='liangliangyy1')
# 使用测试客户端模拟超级用户登录
loginresult = self.client.login(
username='liangliangyy1',
password='qwer!@#$ggg')
# 断言登录成功返回True
self.assertEqual(loginresult, True)
# 模拟访问admin后台页面
response = self.client.get('/admin/')
# 断言admin页面访问成功状态码200
self.assertEqual(response.status_code, 200)
# 创建测试分类:设置名称、创建时间、修改时间并保存
category = Category()
category.name = "categoryaaa"
category.creation_time = timezone.now()
category.last_modify_time = timezone.now()
category.save()
# 创建测试文章:关联作者(超级用户)和分类,设置标题、内容、类型、状态并保存
article = Article()
article.title = "nicetitleaaa"
article.body = "nicecontentaaa"
article.author = user
article.category = category
article.type = 'a' # 假设'a'代表普通文章类型
article.status = 'p' # 假设'p'代表已发布状态
article.save()
# 模拟访问文章的admin管理页面通过文章模型的get_admin_url方法获取路径
response = self.client.get(article.get_admin_url())
# 断言文章管理页面访问成功状态码200
self.assertEqual(response.status_code, 200)
def test_validate_register(self):
"""测试用户注册流程:注册请求、注册后用户存在性、邮箱验证、登录与权限升级、内容管理"""
# 注册前断言邮箱为user123@user.com的用户不存在初始状态
self.assertEquals(
0, len(
BlogUser.objects.filter(
email='user123@user.com')))
# 模拟发送注册请求向account:register接口提交用户名、邮箱、两次一致的密码
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',
})
# 注册后断言邮箱为user123@user.com的用户存在注册成功
self.assertEquals(
1, len(
BlogUser.objects.filter(
email='user123@user.com')))
# 获取刚注册的用户,生成邮箱验证链接(使用项目加密逻辑生成签名)
user = BlogUser.objects.filter(email='user123@user.com')[0]
sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) # 双重SHA256加密签名
path = reverse('accounts:result') # 验证结果页的URL路径
url = '{path}?type=validation&id={id}&sign={sign}'.format(
path=path, id=user.id, sign=sign) # 拼接完整验证URL
# 模拟访问邮箱验证链接
response = self.client.get(url)
# 断言验证页面访问成功状态码200
self.assertEqual(response.status_code, 200)
# 模拟刚注册的用户登录
self.client.login(username='user1233', password='password123!q@wE#R$T')
# 升级用户权限为超级用户便于测试admin功能
user = BlogUser.objects.filter(email='user123@user.com')[0]
user.is_superuser = True # 设为超级用户
user.is_staff = True # 允许登录admin
user.save()
# 删除侧边栏缓存(项目自定义缓存操作,测试中保持原逻辑)
delete_sidebar_cache()
# 创建测试分类(用于后续创建文章)
category = Category()
category.name = "categoryaaa"
category.creation_time = timezone.now()
category.last_modify_time = timezone.now()
category.save()
# 创建测试文章(关联升级权限后的用户)
article = Article()
article.category = category
article.title = "nicetitle333"
article.body = "nicecontentttt"
article.author = user
article.type = 'a'
article.status = 'p'
article.save()
# 模拟访问文章的admin管理页面
response = self.client.get(article.get_admin_url())
self.assertEqual(response.status_code, 200)
# 模拟用户登出
response = self.client.get(reverse('account:logout'))
# 断言登出请求响应正常允许301/302重定向或200成功
self.assertIn(response.status_code, [301, 302, 200])
# 登出后尝试访问文章admin页面应被拒绝或重定向
response = self.client.get(article.get_admin_url())
self.assertIn(response.status_code, [301, 302, 200])
# 模拟使用错误密码登录(密码不匹配)
response = self.client.post(reverse('account:login'), {
'username': 'user1233',
'password': 'password123'
})
# 断言登录请求响应正常(无论成功失败,状态码合法)
self.assertIn(response.status_code, [301, 302, 200])
# 错误登录后尝试访问文章admin页面应被拒绝或重定向
response = self.client.get(article.get_admin_url())
self.assertIn(response.status_code, [301, 302, 200])
def test_verify_email_code(self):
"""测试邮箱验证码验证逻辑:正确验证码验证成功,错误邮箱验证失败"""
# 定义测试邮箱和生成随机验证码
to_email = "admin@admin.com"
code = generate_code() # 调用工具函数生成验证码
# 存储验证码(关联邮箱,用于后续验证)
utils.set_code(to_email, code)
# 发送验证邮件(测试中仅执行流程,不实际校验邮件发送结果)
utils.send_verify_email(to_email, code)
# 验证1使用正确的邮箱和验证码断言无错误返回验证成功
err = utils.verify("admin@admin.com", code)
self.assertEqual(err, None)
# 验证2使用错误的邮箱与存储的验证码不匹配断言返回错误信息字符串类型
err = utils.verify("admin@123.com", code)
self.assertEqual(type(err), str)
def test_forget_password_email_code_success(self):
"""测试获取忘记密码验证码的成功场景:提交正确邮箱,返回成功响应"""
# 模拟向account:forget_password_code接口提交正确邮箱
resp = self.client.post(
path=reverse("account:forget_password_code"),
data=dict(email="admin@admin.com")
)
# 断言请求成功状态码200且返回内容为"ok"(表示验证码发送成功)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.content.decode("utf-8"), "ok")
def test_forget_password_email_code_fail(self):
"""测试获取忘记密码验证码的失败场景:无邮箱、邮箱格式错误"""
# 失败场景1不提交邮箱空数据
resp = self.client.post(
path=reverse("account:forget_password_code"),
data=dict()
)
# 断言返回"错误的邮箱"(参数缺失)
self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱")
# 失败场景2提交格式错误的邮箱admin@com不符合标准格式
resp = self.client.post(
path=reverse("account:forget_password_code"),
data=dict(email="admin@com")
)
# 断言返回"错误的邮箱"(格式校验失败)
self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱")
def test_forget_password_email_success(self):
"""测试忘记密码重置成功场景:提交正确验证码和新密码,密码修改生效"""
# 生成验证码并关联测试用户的邮箱(模拟用户已获取验证码)
code = generate_code()
utils.set_code(self.blog_user.email, code)
# 构造忘记密码重置请求数据:新密码(两次一致)、用户邮箱、正确验证码
data = dict(
new_password1=self.new_test,
new_password2=self.new_test,
email=self.blog_user.email,
code=code,
)
# 模拟发送密码重置请求
resp = self.client.post(
path=reverse("account:forget_password"),
data=data
)
# 断言重置成功重定向到结果页状态码302
self.assertEqual(resp.status_code, 302)
# 验证密码是否真的修改成功:查询用户并校验新密码
blog_user = BlogUser.objects.filter(
email=self.blog_user.email,
).first() # 获取用户实例
self.assertNotEqual(blog_user, None) # 断言用户存在
# 使用check_password方法验证新密码是否匹配Django内置密码校验自动处理哈希
self.assertEqual(blog_user.check_password(data["new_password1"]), True)
def test_forget_password_email_not_user(self):
"""测试忘记密码重置失败场景:使用不存在的邮箱"""
# 构造请求数据:新密码、不存在的邮箱、任意验证码
data = dict(
new_password1=self.new_test,
new_password2=self.new_test,
email="123@123.com",
code="123456",
)
# 模拟发送密码重置请求
resp = self.client.post(
path=reverse("account:forget_password"),
data=data
)
# 断言请求响应正常页面返回错误提示状态码200
self.assertEqual(resp.status_code, 200)
def test_forget_password_email_code_error(self):
"""测试忘记密码重置失败场景:验证码错误"""
# 生成正确验证码并关联用户邮箱,但请求时提交错误验证码
code = generate_code()
utils.set_code(self.blog_user.email, code)
data = dict(
new_password1=self.new_test,
new_password2=self.new_test,
email=self.blog_user.email,
code="111111", # 错误的验证码
)
# 模拟发送密码重置请求
resp = self.client.post(
path=reverse("account:forget_password"),
data=data
)
# 断言请求响应正常页面返回验证码错误提示状态码200
self.assertEqual(resp.status_code, 200)

@ -0,0 +1,45 @@
# 导入Django的URL路径定义工具path用于精确路径匹配re_path支持正则表达式匹配
from django.urls import path
from django.urls import re_path
# 导入当前应用accounts的视图模块包含登录、注册等业务逻辑处理
from . import views
# 导入当前应用的自定义登录表单,用于登录页面的表单渲染和验证
from .forms import LoginForm
# 定义应用的命名空间为"accounts"避免不同应用间URL名称冲突
app_name = "accounts"
# URL路由配置列表映射URL路径到对应的视图
urlpatterns = [
# 1. 登录页面URL
re_path(r'^login/$', # 正则匹配路径:以"login/"开头并结束(即精确匹配"/login/"
views.LoginView.as_view(success_url='/'), # 关联LoginView视图登录成功后重定向到网站根路径"/"
name='login', # URL的命名用于模板或视图中通过reverse('accounts:login')生成路径
kwargs={'authentication_form': LoginForm}), # 传递参数指定登录使用自定义的LoginForm表单
# 2. 注册页面URL
re_path(r'^register/$', # 正则匹配路径:精确匹配"/register/"
views.RegisterView.as_view(success_url="/"), # 关联RegisterView视图注册成功后重定向到网站根路径
name='register'), # URL命名用于反向生成注册页面路径
# 3. 登出功能URL
re_path(r'^logout/$', # 正则匹配路径:精确匹配"/logout/"
views.LogoutView.as_view(), # 关联LogoutView视图处理登出逻辑默认登出后重定向到登录页
name='logout'), # URL命名用于反向生成登出路径
# 4. 账户操作结果页URL如登录/注册/密码重置后的结果提示)
path(r'account/result.html', # 精确路径匹配:固定路径"/account/result.html"
views.account_result, # 关联普通函数视图account_result处理结果页渲染
name='result'), # URL命名用于反向生成结果页路径
# 5. 忘记密码页面URL密码重置表单页
re_path(r'^forget_password/$', # 正则匹配路径:精确匹配"/forget_password/"
views.ForgetPasswordView.as_view(), # 关联ForgetPasswordView视图处理密码重置表单逻辑
name='forget_password'), # URL命名用于反向生成忘记密码页面路径
# 6. 获取忘记密码验证码的URL发送验证码到邮箱
re_path(r'^forget_password_code/$', # 正则匹配路径:精确匹配"/forget_password_code/"
views.ForgetPasswordEmailCode.as_view(), # 关联ForgetPasswordEmailCode视图处理发送验证码逻辑
name='forget_password_code'), # URL命名用于反向生成获取验证码的路径
]

@ -0,0 +1,53 @@
# 导入Django的用户模型工具用于获取项目配置的用户模型支持自定义用户模型
from django.contrib.auth import get_user_model
# 导入Django内置的模型认证后端基类用于扩展自定义认证逻辑
from django.contrib.auth.backends import ModelBackend
class EmailOrUsernameModelBackend(ModelBackend):
"""
自定义认证后端继承自Django的ModelBackend
功能允许用户使用用户名或邮箱地址进行登录验证
"""
def authenticate(self, request, username=None, password=None, **kwargs):
"""
重写认证方法实现用户名/邮箱登录逻辑
:param request: 请求对象
:param username: 登录时输入的标识可能是用户名或邮箱
:param password: 登录密码
:param kwargs: 其他关键字参数
:return: 验证成功返回用户对象失败返回None
"""
# 判断输入的"username"是否包含@符号,若包含则视为邮箱登录
if '@' in username:
# 构建查询条件使用email字段匹配
kwargs = {'email': username}
else:
# 否则视为用户名登录构建查询条件使用username字段匹配
kwargs = {'username': username}
try:
# 根据构建的条件查询用户(使用项目配置的用户模型)
user = get_user_model().objects.get(**kwargs)
# 验证查询到的用户密码是否正确Django内置的密码校验自动处理哈希对比
if user.check_password(password):
# 密码正确,返回用户对象
return user
except get_user_model().DoesNotExist:
# 若用户不存在查询失败返回None表示认证失败
return None
def get_user(self, username):
"""
重写获取用户的方法根据用户主键获取用户对象
Django认证系统会调用此方法获取已认证用户的详细信息
:param username: 实际为用户的主键pk
:return: 存在则返回用户对象不存在返回None
"""
try:
# 根据主键查询用户
return get_user_model().objects.get(pk=username)
except get_user_model().DoesNotExist:
# 用户不存在返回None
return None

@ -0,0 +1,81 @@
# 导入类型提示模块,用于定义函数参数和返回值的类型(增强代码可读性和类型检查)
import typing
# 导入timedelta用于定义时间间隔此处用于设置验证码有效期
from datetime import timedelta
# 导入Django缓存模块用于临时存储验证码避免数据库频繁读写
from django.core.cache import cache
# 导入Django翻译工具gettext用于实时翻译字符串gettext_lazy用于延迟翻译适合定义常量时使用
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
# 导入项目自定义的发送邮件工具函数,用于实际发送验证码邮件
from djangoblog.utils import send_email
# 定义验证码的有效期5分钟全局常量所有验证码共用此有效期
_code_ttl = timedelta(minutes=5)
def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email")):
"""
发送密码重置验证邮件将验证码通过邮件发送给指定邮箱
Args:
to_mail: 接收邮件的目标邮箱地址字符串类型
code: 生成的验证码字符串类型用于后续验证
subject: 邮件主题默认值为"Verify Email"支持国际化可根据语言设置自动翻译
"""
# 构造邮件的HTML内容包含验证码和有效期提示使用国际化翻译通过%(code)s格式化插入验证码
html_content = _(
"You are resetting the password, the verification code is%(code)s, valid within 5 minutes, please keep it "
"properly") % {'code': code}
# 调用自定义的send_email函数发送邮件参数为收件人列表、邮件主题、邮件内容
send_email([to_mail], subject, html_content)
def verify(email: str, code: str) -> typing.Optional[str]:
"""
验证用户输入的验证码是否有效与缓存中存储的验证码对比
Args:
email: 用户提交的邮箱地址用于匹配缓存中对应的验证码
code: 用户输入的验证码需要验证的字符串
Return:
验证失败时返回错误提示字符串"Verification code error"验证成功时返回None
Note:
原代码注释说明当前错误处理方式不合理建议通过raise抛出异常替代返回错误字符串
避免调用方需要额外处理返回的错误信息使错误处理更符合Python规范
"""
# 从缓存中获取该邮箱对应的验证码调用get_code函数
cache_code = get_code(email)
# 对比用户输入的验证码与缓存中的验证码,不一致则返回错误提示
if cache_code != code:
return gettext("Verification code error")
def set_code(email: str, code: str):
"""
将邮箱与对应的验证码存储到Django缓存中并设置过期时间使用全局的_code_ttl
Args:
email: 作为缓存键key的邮箱地址确保每个邮箱的验证码唯一
code: 作为缓存值value的验证码需要存储的字符串
"""
# 调用cache.set存储数据key=emailvalue=codetimeout=_code_ttl.seconds有效期转换为秒数
cache.set(email, code, _code_ttl.seconds)
def get_code(email: str) -> typing.Optional[str]:
"""
根据邮箱地址从Django缓存中获取对应的验证码
Args:
email: 用于查询的缓存键key即目标邮箱地址
Return:
缓存中存在该邮箱对应的验证码时返回字符串类型的验证码不存在或过期时返回None
"""
# 调用cache.get获取缓存值key=email不存在则返回None
return cache.get(email)

@ -0,0 +1,330 @@
# 导入日志模块,用于记录视图操作中的关键信息(如请求类型、用户状态等)
import logging
# 导入Django国际化翻译工具用于视图中文本的多语言支持
from django.utils.translation import gettext_lazy as _
# 导入Django项目配置用于获取SECRET_KEY等全局设置
from django.conf import settings
# 导入Django认证相关模块auth处理登录逻辑REDIRECT_FIELD_NAME定义重定向参数名get_user_model获取自定义用户模型
from django.contrib import auth
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.contrib.auth import get_user_model
from django.contrib.auth import logout # 登出功能函数
# 导入Django内置认证表单用于登录表单的基础验证
from django.contrib.auth.forms import AuthenticationForm
# 导入密码哈希工具,用于生成加密后的密码(忘记密码功能中更新密码时使用)
from django.contrib.auth.hashers import make_password
# 导入DjangoHTTP响应类重定向、403禁止访问、基础响应
from django.http import HttpResponseRedirect, HttpResponseForbidden
from django.http.request import HttpRequest
from django.http.response import HttpResponse
# 导入Django快捷函数get_object_or_404获取对象不存在时返回404、render渲染模板
from django.shortcuts import get_object_or_404
from django.shortcuts import render
# 导入reverse函数通过URL名称生成路径避免硬编码路径
from django.urls import reverse
# 导入Django视图装饰器method_decorator为类视图方法添加装饰器、never_cache禁止缓存、csrf_protectCSRF保护、sensitive_post_parameters保护敏感POST参数
from django.utils.decorators import method_decorator
from django.utils.http import url_has_allowed_host_and_scheme # 验证重定向地址是否安全
# 导入Django类视图基类View基础视图类、FormView处理表单的视图类、RedirectView处理重定向的视图类
from django.views import View
from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_protect
from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import FormView, RedirectView
# 导入项目自定义工具函数发送邮件、SHA256加密、获取当前站点、生成验证码、删除侧边栏缓存
from djangoblog.utils import send_email, get_sha256, get_current_site, generate_code, delete_sidebar_cache
# 导入当前应用accounts的工具函数验证码相关操作
from . import utils
# 导入当前应用的自定义表单:注册、登录、忘记密码、获取忘记密码验证码的表单
from .forms import RegisterForm, LoginForm, ForgetPasswordForm, ForgetPasswordCodeForm
# 导入当前应用的自定义用户模型
from .models import BlogUser
# 创建日志记录器,用于记录当前视图模块的日志
logger = logging.getLogger(__name__)
# Create your views here.
class RegisterView(FormView):
"""
处理用户注册的类视图继承自FormView专门处理表单提交的视图基类
功能展示注册表单验证表单数据创建未激活用户发送邮箱验证链接跳转注册结果页
"""
form_class = RegisterForm # 关联的表单类自定义的RegisterForm
template_name = 'account/registration_form.html' # 渲染的模板文件路径
@method_decorator(csrf_protect)
def dispatch(self, *args, **kwargs):
"""
重写dispatch方法为视图添加CSRF保护装饰器
dispatch是类视图的入口方法所有请求GET/POST都会先经过此方法
"""
return super(RegisterView, self).dispatch(*args, **kwargs)
def form_valid(self, form):
"""
表单数据验证通过后执行的逻辑用户提交的注册信息合法时
"""
if form.is_valid():
# 1. 创建用户但不立即提交到数据库commit=False后续手动设置额外字段
user = form.save(False)
# 2. 设置用户初始状态:未激活(需邮箱验证后激活)、注册来源为"Register"
user.is_active = False
user.source = 'Register'
# 3. 提交用户数据到数据库
user.save(True)
# 4. 生成邮箱验证链接包含当前站点域名、验证路径、用户ID、加密签名
site = get_current_site().domain # 获取当前站点域名如www.example.com
# 双重SHA256加密使用SECRET_KEY+用户ID生成签名防止链接被篡改
sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
# 开发环境DEBUG=True站点域名替换为本地地址127.0.0.1:8000
if settings.DEBUG:
site = '127.0.0.1:8000'
# 获取验证结果页的路径通过URL名称反向生成
path = reverse('account:result')
# 拼接完整的邮箱验证链接
url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format(
site=site, path=path, id=user.id, sign=sign)
# 5. 构造验证邮件内容(包含验证链接)
content = """
<p>请点击下面链接验证您的邮箱</p>
<a href="{url}" rel="bookmark">{url}</a>
再次感谢您
<br />
如果上面链接无法打开请将此链接复制至浏览器
{url}
""".format(url=url)
# 6. 发送验证邮件到用户注册邮箱
send_email(
emailto=[user.email], # 收件人列表
title='验证您的电子邮箱', # 邮件标题
content=content # 邮件HTML内容
)
# 7. 重定向到注册结果页携带注册成功的类型和用户ID参数
url = reverse('accounts:result') + '?type=register&id=' + str(user.id)
return HttpResponseRedirect(url)
else:
# 表单验证失败(如用户名已存在、邮箱格式错误),重新渲染表单并显示错误信息
return self.render_to_response({'form': form})
class LogoutView(RedirectView):
"""
处理用户登出的类视图继承自RedirectView专门处理重定向的视图基类
功能执行登出逻辑删除侧边栏缓存重定向到登录页
"""
url = '/login/' # 登出后默认重定向的目标路径(登录页)
@method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs):
"""
重写dispatch方法添加禁止缓存装饰器
避免浏览器缓存登出页面防止用户后退到已登出的页面
"""
return super(LogoutView, self).dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
"""
处理GET请求用户访问登出URL时
"""
# 1. 执行Django内置的登出函数清除用户的session信息
logout(request)
# 2. 删除侧边栏缓存(可能存储了用户相关信息,登出后需更新)
delete_sidebar_cache()
# 3. 调用父类的get方法执行重定向到登录页
return super(LogoutView, self).get(request, *args, **kwargs)
class LoginView(FormView):
"""
处理用户登录的类视图继承自FormView
功能展示登录表单验证登录信息处理"记住我"功能重定向到目标页面
"""
form_class = LoginForm # 关联的表单类自定义的LoginForm
template_name = 'account/login.html' # 渲染的登录模板路径
success_url = '/' # 登录成功后的默认重定向路径(网站根目录)
redirect_field_name = REDIRECT_FIELD_NAME # 重定向参数名(默认是"next"
login_ttl = 2626560 # 记住登录状态的有效期(秒),约等于一个月
@method_decorator(sensitive_post_parameters('password'))
@method_decorator(csrf_protect)
@method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs):
"""
重写dispatch方法添加三个装饰器
1. sensitive_post_parameters('password')保护密码参数不在错误日志中显示
2. csrf_protect开启CSRF保护防止跨站请求伪造
3. never_cache禁止缓存登录页面确保每次访问都是最新状态
"""
return super(LoginView, self).dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
"""
扩展上下文数据将重定向地址next参数传递到模板中
模板中可根据该参数决定登录成功后跳转到哪里
"""
# 从GET请求中获取重定向地址如访问需要登录的页面时会携带next参数
redirect_to = self.request.GET.get(self.redirect_field_name)
# 如果没有重定向地址,默认设置为网站根目录
if redirect_to is None:
redirect_to = '/'
# 将重定向地址添加到上下文
kwargs['redirect_to'] = redirect_to
# 调用父类方法,返回完整的上下文数据
return super(LoginView, self).get_context_data(**kwargs)
def form_valid(self, form):
"""
表单数据验证通过后执行的逻辑用户提交的登录信息合法时
注意此处重新初始化了AuthenticationForm用于Django内置的登录验证
"""
# 用请求的POST数据和request对象初始化Django内置的认证表单
form = AuthenticationForm(data=self.request.POST, request=self.request)
if form.is_valid():
# 1. 登录成功,删除侧边栏缓存(可能存储了未登录状态的内容)
delete_sidebar_cache()
# 2. 记录重定向参数名到日志(用于调试)
logger.info(self.redirect_field_name)
# 3. 执行Django内置的登录函数将用户信息存入session
auth.login(self.request, form.get_user())
# 4. 处理"记住我"功能:如果用户勾选了"remember"设置session有效期
if self.request.POST.get("remember"):
self.request.session.set_expiry(self.login_ttl)
# 5. 调用父类的form_valid方法执行重定向到成功页面
return super(LoginView, self).form_valid(form)
else:
# 表单验证失败(如用户名不存在、密码错误),重新渲染表单并显示错误
return self.render_to_response({'form': form})
def get_success_url(self):
"""
自定义登录成功后的重定向地址
优先使用POST请求中的"next"参数如果合法否则使用默认的success_url
"""
# 从POST请求中获取重定向地址用户登录时提交的next参数
redirect_to = self.request.POST.get(self.redirect_field_name)
# 验证重定向地址是否安全(是否属于当前站点,防止恶意重定向)
if not url_has_allowed_host_and_scheme(
url=redirect_to, allowed_hosts=[self.request.get_host()]):
# 不安全的地址,使用默认的成功重定向路径
redirect_to = self.success_url
return redirect_to
def account_result(request):
"""
处理账户操作结果的函数视图注册成功提示邮箱验证结果
功能根据URL参数type和id展示不同的结果信息激活用户邮箱
"""
# 从GET请求中获取操作类型register/validation和用户ID
type = request.GET.get('type')
id = request.GET.get('id')
# 根据用户ID查询用户不存在则返回404页面
user = get_object_or_404(get_user_model(), id=id)
# 记录操作类型到日志
logger.info(type)
# 如果用户已激活is_active=True直接重定向到根目录无需再展示结果
if user.is_active:
return HttpResponseRedirect('/')
# 如果操作类型合法属于register或validation处理对应的逻辑
if type and type in ['register', 'validation']:
if type == 'register':
# 1. 注册成功场景:展示注册成功提示,告知用户验证邮件已发送
content = '''
恭喜您注册成功一封验证邮件已经发送到您的邮箱请验证您的邮箱后登录本站
'''
title = '注册成功'
else:
# 2. 邮箱验证场景:验证签名是否合法,合法则激活用户
# 重新生成签名用于与请求中的sign参数对比
c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
# 从GET请求中获取签名参数
sign = request.GET.get('sign')
# 如果签名不匹配链接被篡改返回403禁止访问
if sign != c_sign:
return HttpResponseForbidden()
# 签名匹配激活用户设置is_active=True并保存
user.is_active = True
user.save()
# 展示验证成功提示
content = '''
恭喜您已经成功的完成邮箱验证您现在可以使用您的账号来登录本站
'''
title = '验证成功'
# 渲染结果页面,传递标题和内容
return render(request, 'account/result.html', {
'title': title,
'content': content
})
else:
# 操作类型不合法,重定向到根目录
return HttpResponseRedirect('/')
class ForgetPasswordView(FormView):
"""
处理忘记密码的类视图继承自FormView
功能展示忘记密码表单验证表单数据验证码邮箱密码更新用户密码
"""
form_class = ForgetPasswordForm # 关联的表单类自定义的ForgetPasswordForm
template_name = 'account/forget_password.html' # 渲染的忘记密码模板路径
def form_valid(self, form):
"""
表单数据验证通过后执行的逻辑验证码邮箱密码均合法时
"""
if form.is_valid():
# 1. 根据表单中的邮箱查询对应的用户
blog_user = BlogUser.objects.filter(email=form.cleaned_data.get("email")).get()
# 2. 用新密码(加密处理)更新用户密码
blog_user.password = make_password(form.cleaned_data["new_password2"])
# 3. 保存密码更新结果到数据库
blog_user.save()
# 4. 重定向到登录页(密码重置成功后需重新登录)
return HttpResponseRedirect('/login/')
else:
# 表单验证失败(如验证码错误、密码不一致),重新渲染表单并显示错误
return self.render_to_response({'form': form})
class ForgetPasswordEmailCode(View):
"""
处理发送忘记密码验证码的类视图继承自基础View类
功能接收邮箱验证邮箱格式生成验证码发送验证邮件存储验证码到缓存
"""
def post(self, request: HttpRequest):
"""
处理POST请求用户提交邮箱以获取验证码时
"""
# 1. 用请求的POST数据初始化验证码表单
form = ForgetPasswordCodeForm(request.POST)
# 2. 验证表单(主要验证邮箱格式是否合法)
if not form.is_valid():
# 表单验证失败(邮箱格式错误),返回"错误的邮箱"响应
return HttpResponse("错误的邮箱")
# 3. 表单验证通过,获取清洗后的邮箱地址
to_email = form.cleaned_data["email"]
# 4. 生成随机验证码
code = generate_code()
# 5. 发送验证码邮件到用户邮箱调用utils中的send_verify_email函数
utils.send_verify_email(to_email, code)
# 6. 将验证码存储到缓存(关联邮箱,设置有效期)
utils.set_code(to_email, code)
# 7. 发送成功,返回"ok"响应
return HttpResponse("ok")

@ -0,0 +1,204 @@
# 导入Django表单模块用于创建自定义表单
from django import forms
# 导入Django admin模块用于配置后台管理界面
from django.contrib import admin
# 导入Django用户模型获取工具兼容自定义用户模型场景
from django.contrib.auth import get_user_model
# 导入Django URL反向解析模块用于生成后台管理页面的链接
from django.urls import reverse
# 导入Django HTML格式化工具用于在后台显示自定义HTML内容如链接
from django.utils.html import format_html
# 导入Django国际化翻译工具用于实现后台文字的多语言支持
from django.utils.translation import gettext_lazy as _
# 注册自定义模型到admin后台的标识注释固定写法
# Register your models here.
# 从当前应用的models.py文件中导入Article模型文章模型
from .models import Article
# 自定义Article模型的列表页过滤器继承自Django内置的SimpleListFilter
class ArticleListFilter(admin.SimpleListFilter):
# 过滤器在后台页面显示的标题(支持国际化),这里是“作者”
title = _("author")
# 过滤器对应的URL参数名用于URL中传递过滤条件如?author=1
parameter_name = 'author'
# 定义过滤器的选项列表,返回(值, 显示文本)格式的元组
def lookups(self, request, model_admin):
# 1. 从Article模型中获取所有文章的作者用set去重避免同一作者多次出现
# 2. 转换为list便于遍历map函数提取每篇文章的author字段
authors = list(set(map(lambda x: x.author, Article.objects.all())))
# 遍历去重后的作者列表,生成过滤器选项
for author in authors:
# 选项值为作者ID用于数据库查询显示文本为作者用户名支持国际化
yield (author.id, _(author.username))
# 根据过滤器选择的参数过滤文章查询集queryset
def queryset(self, request, queryset):
# 获取当前过滤器选中的参数值即作者ID
id = self.value()
# 如果有选中的作者ID返回该作者的所有文章
if id:
return queryset.filter(author__id__exact=id)
# 如果未选中任何作者,返回全部文章查询集
else:
return queryset
# 自定义Article模型的表单类继承自Django内置的ModelForm
class ArticleForm(forms.ModelForm):
# 注释此处原本计划使用AdminPagedownWidget富文本编辑器渲染body字段暂未启用
# body = forms.CharField(widget=AdminPagedownWidget())
# 表单元数据配置类,用于关联模型与字段
class Meta:
# 关联的模型为Article表示该表单用于操作Article模型数据
model = Article
# 表单包含模型的所有字段__all__为通配符也可指定具体字段列表
fields = '__all__'
# 自定义批量操作函数将选中的文章状态设为“已发布”假设status='p'代表发布)
def makr_article_publish(modeladmin, request, queryset):
# 批量更新选中的文章查询集将status字段设为'p'
queryset.update(status='p')
# 自定义批量操作函数将选中的文章状态设为“草稿”假设status='d'代表草稿)
def draft_article(modeladmin, request, queryset):
# 批量更新选中的文章查询集将status字段设为'd'
queryset.update(status='d')
# 自定义批量操作函数关闭选中文章的评论功能假设comment_status='c'代表关闭)
def close_article_commentstatus(modeladmin, request, queryset):
# 批量更新选中的文章查询集将comment_status字段设为'c'
queryset.update(comment_status='c')
# 自定义批量操作函数开启选中文章的评论功能假设comment_status='o'代表开启)
def open_article_commentstatus(modeladmin, request, queryset):
# 批量更新选中的文章查询集将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') # “关闭选中文章的评论”
open_article_commentstatus.short_description = _('Open article comments') # “开启选中文章的评论”
# 自定义Article模型的Admin配置类继承自Django内置的ModelAdmin
class ArticlelAdmin(admin.ModelAdmin):
# 后台列表页每页显示的文章数量这里是20条/页
list_per_page = 20
# 后台列表页的搜索框配置支持搜索文章的“内容body”和“标题title”字段
search_fields = ('body', 'title')
# 后台添加/编辑文章时使用的自定义表单即上面定义的ArticleForm
form = ArticleForm
# 后台列表页显示的字段列表,按顺序展示
list_display = (
'id', # 文章ID
'title', # 文章标题
'author', # 文章作者
'link_to_category', # 自定义字段:文章分类(带跳转链接)
'creation_time', # 文章创建时间
'views', # 文章浏览量
'status', # 文章状态(发布/草稿等)
'type', # 文章类型(如原创/转载等需在Article模型中定义
'article_order' # 文章排序权重(用于自定义排序)
)
# 后台列表页中,点击哪些字段可以跳转到文章编辑页
list_display_links = ('id', 'title')
# 后台列表页的过滤器配置,包含自定义的作者过滤器和内置字段过滤器
list_filter = (ArticleListFilter, 'status', 'type', 'category', 'tags')
# 多对多字段tags标签的编辑界面样式使用水平选择框默认是垂直
filter_horizontal = ('tags',)
# 后台添加/编辑文章时,隐藏的字段(不允许管理员手动修改)
exclude = ('creation_time', 'last_modify_time')
# 是否显示“在站点上查看”按钮(跳转到文章的前台页面)
view_on_site = True
# 后台列表页的批量操作功能列表关联上面定义的4个批量操作函数
actions = [
makr_article_publish,
draft_article,
close_article_commentstatus,
open_article_commentstatus]
# 自定义列表页字段:显示文章分类,并添加跳转到分类编辑页的链接
def link_to_category(self, obj):
# 1. 获取文章分类obj.category的模型元数据应用名、模型名
# 2. 用于生成admin后台分类编辑页的URL
info = (obj.category._meta.app_label, obj.category._meta.model_name)
# 3. 反向解析分类编辑页的URL参数为分类IDobj.category.id
link = reverse('admin:%s_%s_change' % info, args=(obj.category.id,))
# 4. 生成HTML链接显示分类名称点击跳转到分类编辑页
return format_html(u'<a href="%s">%s</a>' % (link, obj.category.name))
# 自定义字段“link_to_category”在后台列表页的显示标题支持国际化
link_to_category.short_description = _('category')
# 重写获取表单的方法自定义作者字段author的可选值
def get_form(self, request, obj=None, **kwargs):
# 1. 先调用父类的get_form方法获取默认表单
form = super(ArticlelAdmin, self).get_form(request, obj, **kwargs)
# 2. 限制作者字段的可选范围仅显示超级用户is_superuser=True
form.base_fields['author'].queryset = get_user_model(
).objects.filter(is_superuser=True)
# 3. 返回修改后的表单
return form
# 重写保存模型的方法(此处未修改逻辑,仅保留父类功能,便于后续扩展)
def save_model(self, request, obj, form, change):
# 调用父类的save_model方法完成默认的保存逻辑如更新时间、权限验证等
super(ArticlelAdmin, self).save_model(request, obj, form, change)
# 重写“在站点上查看”的链接地址跳转到文章的前台完整URL
def get_view_on_site_url(self, obj=None):
# 如果有具体的文章对象obj不为空调用文章模型的get_full_url方法获取前台URL
if obj:
url = obj.get_full_url()
return url
# 如果没有文章对象(如在列表页顶部的“查看站点”按钮),返回当前站点的域名
else:
# 从自定义工具模块导入获取当前站点的函数需确保djangoblog.utils存在
from djangoblog.utils import get_current_site
# 获取当前站点的域名如www.example.com
site = get_current_site().domain
return site
# 自定义Tag模型标签模型的Admin配置类假设Tag模型在models.py中定义
class TagAdmin(admin.ModelAdmin):
# 后台添加/编辑标签时,隐藏的字段(不允许手动修改)
exclude = ('slug', 'last_mod_time', 'creation_time')
# 自定义Category模型分类模型的Admin配置类假设Category模型在models.py中定义
class CategoryAdmin(admin.ModelAdmin):
# 后台分类列表页显示的字段:分类名称、父分类、排序索引
list_display = ('name', 'parent_category', 'index')
# 后台添加/编辑分类时,隐藏的字段(不允许手动修改)
exclude = ('slug', 'last_mod_time', 'creation_time')
# 自定义Links模型友情链接模型的Admin配置类假设Links模型在models.py中定义
class LinksAdmin(admin.ModelAdmin):
# 后台添加/编辑友情链接时,隐藏的字段(不允许手动修改)
exclude = ('last_mod_time', 'creation_time')
# 自定义SideBar模型侧边栏模型的Admin配置类假设SideBar模型在models.py中定义
class SideBarAdmin(admin.ModelAdmin):
# 后台侧边栏列表页显示的字段:名称、内容、是否启用、排序序号
list_display = ('name', 'content', 'is_enable', 'sequence')
# 后台添加/编辑侧边栏时,隐藏的字段(不允许手动修改)
exclude = ('last_mod_time', 'creation_time')
# 自定义BlogSettings模型博客设置模型的Admin配置类假设BlogSettings模型在models.py中定义
class BlogSettingsAdmin(admin.ModelAdmin):
# 空配置使用ModelAdmin的默认功能如需自定义可后续添加字段
pass

@ -0,0 +1,9 @@
# 导入Django的AppConfig类该类用于定义单个Django应用的配置信息
from django.apps import AppConfig
# 定义当前应用blog的配置类继承自Django提供的AppConfig基类
class BlogConfig(AppConfig):
# 配置当前应用的唯一标识名称即应用目录名Django通过该名称识别和管理应用
# 这里'blog'表示当前配置对应的是名为'blog'的Django应用
name = 'blog'

@ -0,0 +1,204 @@
# 导入Django表单模块用于创建自定义表单
from django import forms
# 导入Django admin模块用于配置后台管理界面
from django.contrib import admin
# 导入Django用户模型获取工具兼容自定义用户模型场景
from django.contrib.auth import get_user_model
# 导入Django URL反向解析模块用于生成后台管理页面的链接
from django.urls import reverse
# 导入Django HTML格式化工具用于在后台显示自定义HTML内容如链接
from django.utils.html import format_html
# 导入Django国际化翻译工具用于实现后台文字的多语言支持
from django.utils.translation import gettext_lazy as _
# 注册自定义模型到admin后台的标识注释固定写法
# Register your models here.
# 从当前应用的models.py文件中导入Article模型文章模型
from .models import Article
# 自定义Article模型的列表页过滤器继承自Django内置的SimpleListFilter
class ArticleListFilter(admin.SimpleListFilter):
# 过滤器在后台页面显示的标题(支持国际化),这里是“作者”
title = _("author")
# 过滤器对应的URL参数名用于URL中传递过滤条件如?author=1
parameter_name = 'author'
# 定义过滤器的选项列表,返回(值, 显示文本)格式的元组
def lookups(self, request, model_admin):
# 1. 从Article模型中获取所有文章的作者用set去重避免同一作者多次出现
# 2. 转换为list便于遍历map函数提取每篇文章的author字段
authors = list(set(map(lambda x: x.author, Article.objects.all())))
# 遍历去重后的作者列表,生成过滤器选项
for author in authors:
# 选项值为作者ID用于数据库查询显示文本为作者用户名支持国际化
yield (author.id, _(author.username))
# 根据过滤器选择的参数过滤文章查询集queryset
def queryset(self, request, queryset):
# 获取当前过滤器选中的参数值即作者ID
id = self.value()
# 如果有选中的作者ID返回该作者的所有文章
if id:
return queryset.filter(author__id__exact=id)
# 如果未选中任何作者,返回全部文章查询集
else:
return queryset
# 自定义Article模型的表单类继承自Django内置的ModelForm
class ArticleForm(forms.ModelForm):
# 注释此处原本计划使用AdminPagedownWidget富文本编辑器渲染body字段暂未启用
# body = forms.CharField(widget=AdminPagedownWidget())
# 表单元数据配置类,用于关联模型与字段
class Meta:
# 关联的模型为Article表示该表单用于操作Article模型数据
model = Article
# 表单包含模型的所有字段__all__为通配符也可指定具体字段列表
fields = '__all__'
# 自定义批量操作函数将选中的文章状态设为“已发布”假设status='p'代表发布)
def makr_article_publish(modeladmin, request, queryset):
# 批量更新选中的文章查询集将status字段设为'p'
queryset.update(status='p')
# 自定义批量操作函数将选中的文章状态设为“草稿”假设status='d'代表草稿)
def draft_article(modeladmin, request, queryset):
# 批量更新选中的文章查询集将status字段设为'd'
queryset.update(status='d')
# 自定义批量操作函数关闭选中文章的评论功能假设comment_status='c'代表关闭)
def close_article_commentstatus(modeladmin, request, queryset):
# 批量更新选中的文章查询集将comment_status字段设为'c'
queryset.update(comment_status='c')
# 自定义批量操作函数开启选中文章的评论功能假设comment_status='o'代表开启)
def open_article_commentstatus(modeladmin, request, queryset):
# 批量更新选中的文章查询集将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') # “关闭选中文章的评论”
open_article_commentstatus.short_description = _('Open article comments') # “开启选中文章的评论”
# 自定义Article模型的Admin配置类继承自Django内置的ModelAdmin
class ArticlelAdmin(admin.ModelAdmin):
# 后台列表页每页显示的文章数量这里是20条/页
list_per_page = 20
# 后台列表页的搜索框配置支持搜索文章的“内容body”和“标题title”字段
search_fields = ('body', 'title')
# 后台添加/编辑文章时使用的自定义表单即上面定义的ArticleForm
form = ArticleForm
# 后台列表页显示的字段列表,按顺序展示
list_display = (
'id', # 文章ID
'title', # 文章标题
'author', # 文章作者
'link_to_category', # 自定义字段:文章分类(带跳转链接)
'creation_time', # 文章创建时间
'views', # 文章浏览量
'status', # 文章状态(发布/草稿等)
'type', # 文章类型(如原创/转载等需在Article模型中定义
'article_order' # 文章排序权重(用于自定义排序)
)
# 后台列表页中,点击哪些字段可以跳转到文章编辑页
list_display_links = ('id', 'title')
# 后台列表页的过滤器配置,包含自定义的作者过滤器和内置字段过滤器
list_filter = (ArticleListFilter, 'status', 'type', 'category', 'tags')
# 多对多字段tags标签的编辑界面样式使用水平选择框默认是垂直
filter_horizontal = ('tags',)
# 后台添加/编辑文章时,隐藏的字段(不允许管理员手动修改)
exclude = ('creation_time', 'last_modify_time')
# 是否显示“在站点上查看”按钮(跳转到文章的前台页面)
view_on_site = True
# 后台列表页的批量操作功能列表关联上面定义的4个批量操作函数
actions = [
makr_article_publish,
draft_article,
close_article_commentstatus,
open_article_commentstatus]
# 自定义列表页字段:显示文章分类,并添加跳转到分类编辑页的链接
def link_to_category(self, obj):
# 1. 获取文章分类obj.category的模型元数据应用名、模型名
# 2. 用于生成admin后台分类编辑页的URL
info = (obj.category._meta.app_label, obj.category._meta.model_name)
# 3. 反向解析分类编辑页的URL参数为分类IDobj.category.id
link = reverse('admin:%s_%s_change' % info, args=(obj.category.id,))
# 4. 生成HTML链接显示分类名称点击跳转到分类编辑页
return format_html(u'<a href="%s">%s</a>' % (link, obj.category.name))
# 自定义字段“link_to_category”在后台列表页的显示标题支持国际化
link_to_category.short_description = _('category')
# 重写获取表单的方法自定义作者字段author的可选值
def get_form(self, request, obj=None, **kwargs):
# 1. 先调用父类的get_form方法获取默认表单
form = super(ArticlelAdmin, self).get_form(request, obj, **kwargs)
# 2. 限制作者字段的可选范围仅显示超级用户is_superuser=True
form.base_fields['author'].queryset = get_user_model(
).objects.filter(is_superuser=True)
# 3. 返回修改后的表单
return form
# 重写保存模型的方法(此处未修改逻辑,仅保留父类功能,便于后续扩展)
def save_model(self, request, obj, form, change):
# 调用父类的save_model方法完成默认的保存逻辑如更新时间、权限验证等
super(ArticlelAdmin, self).save_model(request, obj, form, change)
# 重写“在站点上查看”的链接地址跳转到文章的前台完整URL
def get_view_on_site_url(self, obj=None):
# 如果有具体的文章对象obj不为空调用文章模型的get_full_url方法获取前台URL
if obj:
url = obj.get_full_url()
return url
# 如果没有文章对象(如在列表页顶部的“查看站点”按钮),返回当前站点的域名
else:
# 从自定义工具模块导入获取当前站点的函数需确保djangoblog.utils存在
from djangoblog.utils import get_current_site
# 获取当前站点的域名如www.example.com
site = get_current_site().domain
return site
# 自定义Tag模型标签模型的Admin配置类假设Tag模型在models.py中定义
class TagAdmin(admin.ModelAdmin):
# 后台添加/编辑标签时,隐藏的字段(不允许手动修改)
exclude = ('slug', 'last_mod_time', 'creation_time')
# 自定义Category模型分类模型的Admin配置类假设Category模型在models.py中定义
class CategoryAdmin(admin.ModelAdmin):
# 后台分类列表页显示的字段:分类名称、父分类、排序索引
list_display = ('name', 'parent_category', 'index')
# 后台添加/编辑分类时,隐藏的字段(不允许手动修改)
exclude = ('slug', 'last_mod_time', 'creation_time')
# 自定义Links模型友情链接模型的Admin配置类假设Links模型在models.py中定义
class LinksAdmin(admin.ModelAdmin):
# 后台添加/编辑友情链接时,隐藏的字段(不允许手动修改)
exclude = ('last_mod_time', 'creation_time')
# 自定义SideBar模型侧边栏模型的Admin配置类假设SideBar模型在models.py中定义
class SideBarAdmin(admin.ModelAdmin):
# 后台侧边栏列表页显示的字段:名称、内容、是否启用、排序序号
list_display = ('name', 'content', 'is_enable', 'sequence')
# 后台添加/编辑侧边栏时,隐藏的字段(不允许手动修改)
exclude = ('last_mod_time', 'creation_time')
# 自定义BlogSettings模型博客设置模型的Admin配置类假设BlogSettings模型在models.py中定义
class BlogSettingsAdmin(admin.ModelAdmin):
# 空配置使用ModelAdmin的默认功能如需自定义可后续添加字段
pass

@ -0,0 +1,277 @@
# 导入Python内置time模块用于生成唯一ID时间戳毫秒级
import time
# 导入Elasticsearch客户端模块用于直接操作Elasticsearch服务如创建管道、删除索引
import elasticsearch.client
# 导入Django配置模块用于读取项目中的Elasticsearch配置settings.py中
from django.conf import settings
# 从elasticsearch-dsl库导入核心组件
# DocumentElasticsearch文档模型基类类似Django的Model
# InnerDoc嵌套文档基类用于存储结构化子数据如地理位置、用户代理信息
# 字段类型Date(日期)、Integer(整数)、Long(长整数)、Text(可分词文本)、Object(对象类型)、GeoPoint(地理坐标)、Keyword(不可分词文本)、Boolean(布尔值)
from elasticsearch_dsl import Document, InnerDoc, Date, Integer, Long, Text, Object, GeoPoint, Keyword, Boolean
# 导入elasticsearch-dsl的连接管理模块用于建立与Elasticsearch服务的连接
from elasticsearch_dsl.connections import connections
# 从当前应用blog的models.py导入Article模型用于将文章数据同步到Elasticsearch
from blog.models import Article
# 判断项目是否启用Elasticsearch检查settings.py中是否配置了ELASTICSEARCH_DSL
ELASTICSEARCH_ENABLED = hasattr(settings, 'ELASTICSEARCH_DSL')
# 如果启用了Elasticsearch执行以下初始化操作
if ELASTICSEARCH_ENABLED:
# 建立与Elasticsearch服务的连接从settings中读取配置的主机地址如['http://localhost:9200']
connections.create_connection(
hosts=[settings.ELASTICSEARCH_DSL['default']['hosts']])
# 导入Elasticsearch原生客户端用于执行更底层的操作如创建索引、删除索引
from elasticsearch import Elasticsearch
# 初始化Elasticsearch原生客户端传入服务地址
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
# 导入Elasticsearch的IngestClient数据处理管道客户端用于创建数据预处理管道
from elasticsearch.client import IngestClient
# 初始化IngestClient绑定到上面创建的Elasticsearch客户端
c = IngestClient(es)
try:
# 尝试获取名为'geoip'的数据处理管道用于解析IP地址对应的地理位置
c.get_pipeline('geoip')
# 如果管道不存在捕获NotFoundError异常则创建该管道
except elasticsearch.exceptions.NotFoundError:
# 创建'geoip'管道定义数据处理逻辑通过geoip处理器解析IP地址
c.put_pipeline('geoip', body='''{
"description" : "Add geoip info", // 管道描述添加地理位置信息
"processors" : [ // 处理器列表定义数据处理步骤
{
"geoip" : { // geoip处理器Elasticsearch内置用于解析IP
"field" : "ip" // 待解析的字段文档中的'ip'字段
}
}
]
}''')
# 定义GeoIp嵌套文档类InnerDoc存储IP地址解析后的地理位置信息
class GeoIp(InnerDoc):
continent_name = Keyword() # 洲名Keyword类型不可分词适合精确查询/排序)
country_iso_code = Keyword() # 国家ISO代码如CN、USKeyword类型
country_name = Keyword() # 国家名称Keyword类型
location = GeoPoint() # 地理坐标经纬度GeoPoint类型支持地理位置查询
# 定义UserAgentBrowser嵌套文档类存储用户代理UA中的浏览器信息
class UserAgentBrowser(InnerDoc):
Family = Keyword() # 浏览器家族如Chrome、FirefoxKeyword类型
Version = Keyword() # 浏览器版本如120.0Keyword类型
# 定义UserAgentOS嵌套文档类存储用户代理中的操作系统信息继承自UserAgentBrowser结构一致
class UserAgentOS(UserAgentBrowser):
pass # 直接继承父类字段,无需额外定义
# 定义UserAgentDevice嵌套文档类存储用户代理中的设备信息
class UserAgentDevice(InnerDoc):
Family = Keyword() # 设备家族如iPhone、WindowsKeyword类型
Brand = Keyword() # 设备品牌如Apple、HuaweiKeyword类型
Model = Keyword() # 设备型号如iPhone 15Keyword类型
# 定义UserAgent嵌套文档类存储完整的用户代理信息包含浏览器、OS、设备
class UserAgent(InnerDoc):
browser = Object(UserAgentBrowser, required=False) # 浏览器信息Object类型关联UserAgentBrowser
os = Object(UserAgentOS, required=False) # 操作系统信息Object类型关联UserAgentOS
device = Object(UserAgentDevice, required=False) # 设备信息Object类型关联UserAgentDevice
string = Text() # 完整UA字符串Text类型可分词支持模糊查询
is_bot = Boolean() # 是否为爬虫Boolean类型true/false
# 定义ElapsedTimeDocument文档类Elasticsearch中的"性能监控"文档模型(记录请求耗时、访问信息)
class ElapsedTimeDocument(Document):
url = Keyword() # 访问URLKeyword类型精确匹配不分词
time_taken = Long() # 请求耗时毫秒Long类型支持大范围数值存储
log_datetime = Date() # 日志记录时间Date类型支持时间范围查询
ip = Keyword() # 访问IP地址Keyword类型精确匹配
geoip = Object(GeoIp, required=False) # 地理位置信息Object类型关联GeoIp嵌套文档非必填
useragent = Object(UserAgent, required=False) # 用户代理信息Object类型关联UserAgent嵌套文档非必填
# 定义文档对应的Elasticsearch索引配置
class Index:
name = 'performance' # 索引名称Elasticsearch中存储性能数据的索引名
settings = { # 索引设置
"number_of_shards": 1, # 分片数1个小型索引无需多分片
"number_of_replicas": 0 # 副本数0个开发/小型场景无需副本,节省资源)
}
# 定义文档元数据兼容Elasticsearch旧版本doc_type在7.x后已废弃此处保留兼容
class Meta:
doc_type = 'ElapsedTime' # 文档类型:标识索引中的文档类别
# 定义ElaspedTimeDocumentManager类ElapsedTimeDocument的管理类封装索引创建、数据插入等操作
class ElaspedTimeDocumentManager:
# 静态方法:创建性能监控索引(如果不存在)
@staticmethod
def build_index():
# 导入Elasticsearch原生客户端
from elasticsearch import Elasticsearch
# 初始化客户端读取settings中的Elasticsearch地址
client = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
# 检查名为'performance'的索引是否已存在
res = client.indices.exists(index="performance")
# 如果索引不存在初始化ElapsedTimeDocument创建索引及映射
if not res:
ElapsedTimeDocument.init()
# 静态方法:删除性能监控索引
@staticmethod
def delete_index():
from elasticsearch import Elasticsearch
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
# 删除'performance'索引忽略400请求错误和404索引不存在异常
es.indices.delete(index='performance', ignore=[400, 404])
# 静态方法:创建性能监控文档(插入一条访问耗时记录)
@staticmethod
def create(url, time_taken, log_datetime, useragent, ip):
# 确保索引已创建调用build_index方法
ElaspedTimeDocumentManager.build_index()
# 初始化UserAgent嵌套文档对象
ua = UserAgent()
# 赋值浏览器信息从传入的useragent对象中提取浏览器家族和版本
ua.browser = UserAgentBrowser()
ua.browser.Family = useragent.browser.family
ua.browser.Version = useragent.browser.version_string
# 赋值操作系统信息从传入的useragent对象中提取OS家族和版本
ua.os = UserAgentOS()
ua.os.Family = useragent.os.family
ua.os.Version = useragent.os.version_string
# 赋值设备信息从传入的useragent对象中提取设备家族、品牌、型号
ua.device = UserAgentDevice()
ua.device.Family = useragent.device.family
ua.device.Brand = useragent.device.brand
ua.device.Model = useragent.device.model
# 赋值完整UA字符串和是否为爬虫的标识
ua.string = useragent.ua_string
ua.is_bot = useragent.is_bot
# 初始化ElapsedTimeDocument文档对象设置字段值
doc = ElapsedTimeDocument(
meta={
'id': int(round(time.time() * 1000)) # 文档ID毫秒级时间戳确保唯一
},
url=url, # 访问URL
time_taken=time_taken, # 请求耗时(毫秒)
log_datetime=log_datetime,# 记录时间
useragent=ua, # 用户代理信息(嵌套文档)
ip=ip) # 访问IP
# 保存文档到Elasticsearch并指定使用'geoip'管道预处理解析IP地址
doc.save(pipeline="geoip")
# 定义ArticleDocument文档类Elasticsearch中的"文章"文档模型(用于文章搜索)
class ArticleDocument(Document):
# 文章内容Text类型使用ik_max_word分词器分词更细适合全文搜索搜索时用ik_smart分词更粗提升效率
body = Text(analyzer='ik_max_word', search_analyzer='ik_smart')
# 文章标题:同上,支持中文分词搜索
title = Text(analyzer='ik_max_word', search_analyzer='ik_smart')
# 作者信息Object类型包含昵称可分词和ID整数
author = Object(properties={
'nickname': Text(analyzer='ik_max_word', search_analyzer='ik_smart'),
'id': Integer()
})
# 分类信息Object类型包含分类名称可分词和ID整数
category = Object(properties={
'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'),
'id': Integer()
})
# 标签信息Object类型数组每个标签包含名称可分词和ID整数
tags = Object(properties={
'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'),
'id': Integer()
})
pub_time = Date() # 发布时间Date类型支持按时间排序/筛选)
status = Text() # 文章状态(如'p'=发布,'d'=草稿Text类型
comment_status = Text() # 评论状态(如'o'=开启,'c'=关闭Text类型
type = Text() # 文章类型(如'p'=页面,'a'=普通文章Text类型
views = Integer() # 浏览量Integer类型支持数值排序
article_order = Integer() # 排序权重Integer类型用于自定义文章排序
# 定义文档对应的Elasticsearch索引配置
class Index:
name = 'blog' # 索引名称:存储文章数据的索引名
settings = { # 索引设置
"number_of_shards": 1, # 分片数1个小型博客无需多分片
"number_of_replicas": 0 # 副本数0个开发/小型场景节省资源)
}
# 文档元数据兼容旧版本Elasticsearch的doc_type
class Meta:
doc_type = 'Article' # 文档类型:标识为文章类文档
# 定义ArticleDocumentManager类ArticleDocument的管理类封装文章索引的创建、重建、更新等操作
class ArticleDocumentManager():
# 构造方法:实例化管理类时自动创建文章索引(如果不存在)
def __init__(self):
self.create_index()
# 实例方法创建文章索引调用ArticleDocument的init方法生成索引和字段映射
def create_index(self):
ArticleDocument.init()
# 实例方法:删除文章索引
def delete_index(self):
from elasticsearch import Elasticsearch
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
# 删除'blog'索引忽略400和404异常
es.indices.delete(index='blog', ignore=[400, 404])
# 实例方法将Django的Article模型对象列表转换为Elasticsearch的ArticleDocument列表
def convert_to_doc(self, articles):
# 列表推导式遍历每篇文章构建对应的ArticleDocument
return [
ArticleDocument(
meta={'id': article.id}, # 文档ID与Django Article模型ID一致便于关联
body=article.body, # 文章内容
title=article.title, # 文章标题
author={ # 作者信息从Article模型的author字段提取
'nickname': article.author.username,
'id': article.author.id
},
category={ # 分类信息从Article模型的category字段提取
'name': article.category.name,
'id': article.category.id
},
tags=[ # 标签信息遍历Article模型的tags多对多字段提取每个标签的名称和ID
{'name': t.name, 'id': t.id} for t in article.tags.all()
],
pub_time=article.pub_time, # 发布时间
status=article.status, # 文章状态
comment_status=article.comment_status, # 评论状态
type=article.type, # 文章类型
views=article.views, # 浏览量
article_order=article.article_order # 排序权重
) for article in articles]
# 实例方法重建文章索引全量同步文章数据到Elasticsearch
def rebuild(self, articles=None):
# 确保索引已创建(初始化索引和映射)
ArticleDocument.init()
# 如果传入了articles参数则同步指定文章否则同步所有文章Article.objects.all()
articles = articles if articles else Article.objects.all()
# 将Django Article对象转换为Elasticsearch文档列表
docs = self.convert_to_doc(articles)
# 遍历文档列表逐个保存到Elasticsearch
for doc in docs:
doc.save()
# 实例方法批量更新Elasticsearch中的文章文档
def update_docs(self, docs):
# 遍历文档列表,逐个保存(已存在的文档会执行更新操作)
for doc in docs:
doc.save()

@ -0,0 +1,38 @@
# 导入Python内置logging模块用于记录搜索相关日志如搜索关键词
import logging
# 导入Django表单基础模块用于创建自定义表单字段
from django import forms
# 从Haystack库导入基础搜索表单类SearchFormHaystack是Django的搜索引擎集成框架
from haystack.forms import SearchForm
# 创建日志记录器日志名称与当前模块一致__name__便于定位日志来源区分其他模块日志
logger = logging.getLogger(__name__)
# 定义自定义搜索表单类BlogSearchForm继承自Haystack提供的SearchForm基础搜索表单
# 作用扩展Haystack默认搜索表单添加自定义字段和搜索逻辑
class BlogSearchForm(SearchForm):
# 定义搜索输入字段querydata搜索关键词字段
# required=True表示该字段为必填项用户必须输入关键词才能提交搜索
# CharField单行文本输入框适合接收搜索关键词
querydata = forms.CharField(required=True)
# 重写父类的search方法自定义搜索逻辑保留父类核心功能添加日志记录
def search(self):
# 1. 调用父类SearchForm的search方法执行Haystack默认搜索流程
# 父类会自动处理索引查询、关键词匹配等核心逻辑返回搜索结果集SearchQuerySet对象
datas = super(BlogSearchForm, self).search()
# 2. 验证表单数据是否合法根据字段定义的规则如required=True
if not self.is_valid():
# 若表单数据不合法如未输入关键词调用父类的no_query_found方法返回默认空结果
return self.no_query_found()
# 3. 若表单验证通过获取清理后的搜索关键词cleaned_data是Django表单验证后的安全数据字典
if self.cleaned_data['querydata']:
# 记录搜索日志:将用户输入的关键词写入日志(便于统计热门搜索、排查问题)
logger.info(self.cleaned_data['querydata'])
# 4. 返回搜索结果集datas该结果集会传递给搜索结果页面模板进行渲染
return datas

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

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

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

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

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

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

@ -0,0 +1,79 @@
# 导入Python内置logging模块用于记录中间件运行过程中的日志如错误信息
import logging
# 导入Python内置time模块用于计算请求处理耗时页面加载时间
import time
# 导入ipware库的get_client_ip函数用于获取请求客户端的真实IP地址兼容多种部署场景
from ipware import get_client_ip
# 导入user_agents库的parse函数用于解析用户代理UA字符串提取浏览器、设备、系统信息
from user_agents import parse
# 从当前应用的documents模块导入
# 1. ELASTICSEARCH_ENABLED判断项目是否启用Elasticsearch之前定义的全局变量
# 2. ElaspedTimeDocumentManager性能监控文档管理类用于将耗时数据存入Elasticsearch
from blog.documents import ELASTICSEARCH_ENABLED, ElaspedTimeDocumentManager
# 创建日志记录器日志名称与当前模块一致__name__便于定位日志来源区分其他模块日志
logger = logging.getLogger(__name__)
# 定义自定义中间件类OnlineMiddleware遵循Django中间件接口规范
# 作用1. 计算请求处理耗时页面加载时间2. 记录访问IP、设备信息到Elasticsearch3. 替换页面中的加载时间占位符
class OnlineMiddleware(object):
# 中间件初始化方法接收get_response参数Django 1.10+中间件必需参数,代表后续中间件/视图的响应流程)
def __init__(self, get_response=None):
# 保存get_response到实例属性后续在__call__方法中调用确保请求流程继续向下执行
self.get_response = get_response
# 调用父类object的初始化方法确保基础类功能正常Python 2/3兼容写法
super().__init__()
# 中间件核心执行方法,处理每个请求的入口和出口(请求到达时执行前半部分,响应返回时执行后半部分)
def __call__(self, request):
''' page render time ''' # 注释:该方法用于计算页面渲染耗时
# 记录请求开始时间(时间戳,单位:秒),作为耗时计算的起始点
start_time = time.time()
# 调用后续中间件/视图函数获取响应对象response此时请求已完成业务处理
response = self.get_response(request)
# 从请求的META信息中获取用户代理UA字符串
# HTTP_USER_AGENT是请求头中的字段包含浏览器、设备、系统等信息默认值为空字符串
http_user_agent = request.META.get('HTTP_USER_AGENT', '')
# 调用get_client_ip函数获取客户端真实IP
# 返回值为元组ip地址, 是否为代理IP此处仅取IP地址_忽略代理标记
ip, _ = get_client_ip(request)
# 解析UA字符串调用parse函数将原始UA字符串转换为结构化对象可通过属性获取浏览器/设备/系统信息)
user_agent = parse(http_user_agent)
# 判断响应是否为非流式响应(流式响应如文件下载,无需处理加载时间和替换占位符)
if not response.streaming:
try:
# 计算请求处理总耗时:当前时间 - 开始时间(单位:秒)
cast_time = time.time() - start_time
# 如果启用了Elasticsearch将性能数据存入Elasticsearch
if ELASTICSEARCH_ENABLED:
# 耗时转换为毫秒保留2位小数更符合性能监控的常用单位
time_taken = round((cast_time) * 1000, 2)
# 获取请求的路径(如"/article/1/"作为性能记录的URL标识
url = request.path
# 导入Django的timezone模块延迟导入避免循环导入问题用于获取当前时间
from django.utils import timezone
# 调用ElaspedTimeDocumentManager的create方法插入性能记录到Elasticsearch
# 包含URL、耗时、记录时间、用户代理信息、IP地址
ElaspedTimeDocumentManager.create(
url=url,
time_taken=time_taken,
log_datetime=timezone.now(),
useragent=user_agent,
ip=ip)
# 替换响应内容中的占位符:
# 将页面中的'<!!LOAD_TIMES!!>'字符串替换为实际耗时保留前5个字符如"0.321"
# 注意response.content是字节类型需用str.encode将字符串耗时转换为字节
response.content = response.content.replace(
b'<!!LOAD_TIMES!!>', str.encode(str(cast_time)[:5]))
# 捕获所有异常,避免中间件报错导致响应失败
except Exception as e:
# 记录异常日志将错误信息写入日志便于后续排查问题如Elasticsearch连接失败、占位符替换失败
logger.error("Error OnlineMiddleware: %s" % e)
# 返回处理后的响应对象,最终返回给客户端
return response

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

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

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

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

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

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

@ -0,0 +1,515 @@
# 导入Python内置logging模块用于记录模型操作相关日志如缓存命中/设置、数据验证错误)
import logging
# 从abc模块导入abstractmethod装饰器用于定义抽象方法强制子类实现
from abc import abstractmethod
# 导入Django配置模块用于获取项目配置如AUTH_USER_MODEL
from django.conf import settings
# 导入Django数据验证异常类用于自定义数据验证逻辑如博客配置唯一性校验
from django.core.exceptions import ValidationError
# 导入Django模型核心模块用于定义数据模型对应数据库表
from django.db import models
# 导入Django URL反向解析模块用于生成模型的绝对URL
from django.urls import reverse
# 导入Django时区工具用于处理时间字段确保时间戳一致性
from django.utils.timezone import now
# 导入Django国际化翻译工具用于模型字段/选项的多语言支持
from django.utils.translation import gettext_lazy as _
# 导入MDTextField字段来自mdeditor库用于支持Markdown格式的富文本编辑
from mdeditor.fields import MDTextField
# 导入uuslug库的slugify函数用于将中文标题/名称转换为URL友好的slug如"我的博客"→"wo-de-bo-ke"
from uuslug import slugify
# 从自定义工具模块导入缓存相关工具:
# 1. cache_decorator缓存装饰器用于缓存函数返回结果
# 2. cache缓存操作对象用于直接读写缓存
from djangoblog.utils import cache_decorator, cache
# 从自定义工具模块导入获取当前站点信息的函数用于生成完整URL
from djangoblog.utils import get_current_site
# 创建日志记录器日志名称与当前模块一致__name__便于定位日志来源
logger = logging.getLogger(__name__)
# 定义链接显示类型枚举类LinkShowType继承自Django的TextChoices枚举基类
# 作用:规范友情链接的显示位置选项,避免硬编码字符串
class LinkShowType(models.TextChoices):
I = ('i', _('index')) # 首页显示
L = ('l', _('list')) # 列表页显示
P = ('p', _('post')) # 文章详情页显示
A = ('a', _('all')) # 所有页面显示
S = ('s', _('slide')) # 幻灯片区域显示
# 定义抽象基础模型类BaseModel继承自Django的models.Model
# 作用封装所有模型共有的字段和方法如创建时间、修改时间、URL生成避免代码重复
# 注abstract=TrueMeta类中表示该模型为抽象模型不会生成数据库表仅用于被子类继承
class BaseModel(models.Model):
# 主键ID自增整数类型Django默认主键此处显式定义以统一规范
id = models.AutoField(primary_key=True)
# 创建时间DateTimeField类型默认值为当前时间now()支持国际化显示_('creation time')
creation_time = models.DateTimeField(_('creation time'), default=now)
# 最后修改时间DateTimeField类型默认值为当前时间用于记录数据更新时间
last_modify_time = models.DateTimeField(_('modify time'), default=now)
# 重写save方法扩展保存逻辑处理slug生成和浏览量更新优化
def save(self, *args, **kwargs):
# 判断是否为Article模型的浏览量更新操作
# 1. 实例是Article类的实例
# 2. save方法传入了update_fields参数
# 3. 仅更新views字段浏览量
is_update_views = isinstance(
self,
Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views']
# 如果是浏览量单独更新直接执行SQL更新避免触发完整save流程提升性能
if is_update_views:
Article.objects.filter(pk=self.pk).update(views=self.views)
# 非浏览量更新场景,执行正常保存逻辑
else:
# 判断当前模型是否有slug字段需要生成URL友好标识的模型如Category、Tag
if 'slug' in self.__dict__:
# 确定slug的生成源优先取title字段如Article无则取name字段如Category、Tag
slug = getattr(
self, 'title') if 'title' in self.__dict__ else getattr(
self, 'name')
# 调用slugify函数生成slug并赋值给当前实例的slug字段
setattr(self, 'slug', slugify(slug))
# 调用父类的save方法完成数据入库必须调用否则数据不会保存
super().save(*args, **kwargs)
# 生成模型实例的完整URL含域名用于前端跳转、SEO等场景
def get_full_url(self):
# 获取当前站点的域名(如"www.example.com"通过get_current_site工具函数
site = get_current_site().domain
# 拼接完整URL协议默认https+ 域名 + 实例的相对URL通过get_absolute_url获取
url = "https://{site}{path}".format(site=site,
path=self.get_absolute_url())
return url
# 模型元数据配置
class Meta:
abstract = True # 标记为抽象模型,不生成数据库表
# 定义抽象方法get_absolute_url强制子类实现
# 作用每个具体模型必须提供自己的相对URL生成逻辑如文章详情页URL、分类页URL
@abstractmethod
def get_absolute_url(self):
pass
# 定义文章模型Article继承自抽象基础模型BaseModel
class Article(BaseModel):
"""文章模型:存储博客文章/页面数据(如博客文章、关于页、联系页等)"""
# 文章状态选项:元组形式,每个元素为(存储值,显示文本),支持国际化
STATUS_CHOICES = (
('d', _('Draft')), # 'd':草稿状态
('p', _('Published')),# 'p':已发布状态
)
# 评论状态选项:控制文章是否允许评论
COMMENT_STATUS = (
('o', _('Open')), # 'o':开放评论
('c', _('Close')), # 'c':关闭评论
)
# 文章类型选项:区分普通文章和独立页面
TYPE = (
('a', _('Article')), # 'a':普通文章(如博客博文)
('p', _('Page')), # 'p':独立页面(如关于页、隐私政策页)
)
# 文章标题CharField类型最大长度200唯一约束避免重复标题
title = models.CharField(_('title'), max_length=200, unique=True)
# 文章内容MDTextField类型支持Markdown格式编辑富文本
body = MDTextField(_('body'))
# 发布时间DateTimeField类型必填默认值为当前时间用于控制文章发布时间点
pub_time = models.DateTimeField(
_('publish time'), blank=False, null=False, default=now)
# 文章状态CharField类型长度1可选值为STATUS_CHOICES默认已发布'p'
status = models.CharField(
_('status'),
max_length=1,
choices=STATUS_CHOICES,
default='p')
# 评论状态CharField类型长度1可选值为COMMENT_STATUS默认开放评论'o'
comment_status = models.CharField(
_('comment status'),
max_length=1,
choices=COMMENT_STATUS,
default='o')
# 文章类型CharField类型长度1可选值为TYPE默认普通文章'a'
type = models.CharField(_('type'), max_length=1, choices=TYPE, default='a')
# 浏览量PositiveIntegerField类型仅允许非负整数默认0
views = models.PositiveIntegerField(_('views'), default=0)
# 作者外键关联Django用户模型settings.AUTH_USER_MODEL兼容自定义用户模型
# on_delete=models.CASCADE用户被删除时关联的文章也会被删除
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
blank=False,
null=False,
on_delete=models.CASCADE)
# 文章排序权重IntegerField类型默认0用于自定义文章显示顺序值越大越靠前
article_order = models.IntegerField(
_('order'), blank=False, null=False, default=0)
# 是否显示目录BooleanField类型默认False控制文章详情页是否显示TOC目录
show_toc = models.BooleanField(_('show toc'), blank=False, null=False, default=False)
# 分类外键关联Category模型自应用内的分类模型
# on_delete=models.CASCADE分类被删除时关联的文章也会被删除
category = models.ForeignKey(
'Category',
verbose_name=_('category'),
on_delete=models.CASCADE,
blank=False,
null=False)
# 标签多对多关联Tag模型一篇文章可多个标签一个标签可关联多篇文章允许为空
tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True)
# 辅助方法:返回文章内容字符串(用于需要直接获取纯文本内容的场景)
def body_to_string(self):
return self.body
# 重写__str__方法后台管理界面和打印实例时显示文章标题友好显示
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' # 指定获取最新记录的字段按ID降序
# 实现抽象基类的get_absolute_url方法生成文章的相对URL
def get_absolute_url(self):
# 反向解析'blog:detailbyid'路由传递文章ID、发布年月日作为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
})
# 缓存装饰器缓存结果10小时60*60*10秒避免重复查询数据库
@cache_decorator(60 * 60 * 10)
# 获取文章分类的层级关系(如"技术→Python→Django"
def get_category_tree(self):
# 调用分类模型的get_category_tree方法获取当前文章分类的所有父级分类
tree = self.category.get_category_tree()
# 转换为分类名称分类URL的元组列表用于前端显示分类面包屑
names = list(map(lambda c: (c.name, c.get_absolute_url()), tree))
return names
# 重写save方法此处仅调用父类方法便于后续扩展自定义逻辑
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
# 文章浏览量递增方法:用于文章详情页访问时更新浏览量
def viewed(self):
self.views += 1 # 浏览量+1
# 仅更新views字段通过update_fields参数优化避免更新其他字段
self.save(update_fields=['views'])
# 获取文章的评论列表(已启用的评论)
def comment_list(self):
# 定义缓存键包含文章ID确保不同文章的评论缓存不冲突
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:
# 缓存未命中查询当前文章的已启用评论按ID降序最新评论在前
comments = self.comment_set.filter(is_enable=True).order_by('-id')
# 将评论列表存入缓存有效期100分钟60*100秒
cache.set(cache_key, comments, 60 * 100)
# 记录日志:缓存设置成功
logger.info('set article comments:{id}'.format(id=self.id))
return comments
# 生成文章在Django后台的编辑页URL用于快速跳转到后台编辑
def get_admin_url(self):
# 获取模型的元数据:(应用名,模型名)
info = (self._meta.app_label, self._meta.model_name)
# 反向解析admin的模型修改路由传递文章主键
return reverse('admin:%s_%s_change' % info, args=(self.pk,))
# 缓存装饰器缓存结果100分钟
@cache_decorator(expiration=60 * 100)
# 获取当前文章的下一篇文章已发布状态ID大于当前文章
def next_article(self):
return Article.objects.filter(
id__gt=self.id, status='p').order_by('id').first()
# 缓存装饰器缓存结果100分钟
@cache_decorator(expiration=60 * 100)
# 获取当前文章的前一篇文章已发布状态ID小于当前文章
def prev_article(self):
return Article.objects.filter(id__lt=self.id, status='p').first()
# 定义分类模型Category继承自抽象基础模型BaseModel
class Category(BaseModel):
"""文章分类模型:存储博客文章的分类数据(支持层级分类,如父分类→子分类)"""
# 分类名称CharField类型最大长度30唯一约束避免重复分类名
name = models.CharField(_('category name'), max_length=30, unique=True)
# 父分类:自关联外键(分类可作为其他分类的父分类),允许为空(顶级分类)
# on_delete=models.CASCADE父分类被删除时子分类也会被删除
parent_category = models.ForeignKey(
'self',
verbose_name=_('parent category'),
blank=True,
null=True,
on_delete=models.CASCADE)
# 分类slugURL友好标识默认值'no-slug'用于生成分类页URL
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
# 排序索引IntegerField类型默认0用于控制分类显示顺序值越大越靠前
index = models.IntegerField(default=0, verbose_name=_('index'))
# 模型元数据配置
class Meta:
ordering = ['-index'] # 默认排序:按排序索引降序
verbose_name = _('category') # 模型单数显示名称
verbose_name_plural = verbose_name # 模型复数显示名称
# 实现抽象基类的get_absolute_url方法生成分类的相对URL
def get_absolute_url(self):
# 反向解析'blog:category_detail'路由传递分类slug作为参数
return reverse(
'blog:category_detail', kwargs={
'category_name': self.slug})
# 重写__str__方法友好显示分类名称
def __str__(self):
return self.name
# 缓存装饰器缓存结果10小时
@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
# 缓存装饰器缓存结果10小时
@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
# 定义标签模型Tag继承自抽象基础模型BaseModel
class Tag(BaseModel):
"""文章标签模型:存储博客文章的标签数据(用于文章分类和搜索)"""
# 标签名称CharField类型最大长度30唯一约束避免重复标签名
name = models.CharField(_('tag name'), max_length=30, unique=True)
# 标签slugURL友好标识默认值'no-slug'用于生成标签页URL
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
# 重写__str__方法友好显示标签名称
def __str__(self):
return self.name
# 实现抽象基类的get_absolute_url方法生成标签的相对URL
def get_absolute_url(self):
# 反向解析'blog:tag_detail'路由传递标签slug作为参数
return reverse('blog:tag_detail', kwargs={'tag_name': self.slug})
# 缓存装饰器缓存结果10小时
@cache_decorator(60 * 60 * 10)
# 获取当前标签关联的文章数量(去重,避免重复计数)
def get_article_count(self):
return Article.objects.filter(tags__name=self.name).distinct().count()
# 模型元数据配置
class Meta:
ordering = ['name'] # 默认排序:按标签名称升序
verbose_name = _('tag') # 模型单数显示名称
verbose_name_plural = verbose_name # 模型复数显示名称
# 定义友情链接模型Links未继承BaseModel单独定义时间字段
class Links(models.Model):
"""友情链接模型:存储博客的友情链接数据"""
# 链接名称CharField类型最大长度30唯一约束避免重复链接名
name = models.CharField(_('link name'), max_length=30, unique=True)
# 链接URLURLField类型自动验证URL格式如http://、https://
link = models.URLField(_('link'))
# 排序序号IntegerField类型唯一约束控制友情链接显示顺序值越小越靠前
sequence = models.IntegerField(_('order'), unique=True)
# 是否启用BooleanField类型默认True控制是否在前端显示该链接
is_enable = models.BooleanField(
_('is show'), default=True, blank=False, null=False)
# 显示位置CharField类型长度1可选值为LinkShowType枚举默认首页显示'i'
show_type = models.CharField(
_('show type'),
max_length=1,
choices=LinkShowType.choices,
default=LinkShowType.I)
# 创建时间DateTimeField类型默认当前时间
creation_time = models.DateTimeField(_('creation time'), default=now)
# 最后修改时间DateTimeField类型默认当前时间
last_mod_time = models.DateTimeField(_('modify time'), default=now)
# 模型元数据配置
class Meta:
ordering = ['sequence'] # 默认排序:按排序序号升序
verbose_name = _('link') # 模型单数显示名称
verbose_name_plural = verbose_name # 模型复数显示名称
# 重写__str__方法友好显示链接名称
def __str__(self):
return self.name
# 定义侧边栏模型SideBar未继承BaseModel单独定义时间字段
class SideBar(models.Model):
"""侧边栏模型存储博客侧边栏内容支持自定义HTML内容如公告、广告"""
# 侧边栏标题CharField类型最大长度100
name = models.CharField(_('title'), max_length=100)
# 侧边栏内容TextField类型支持HTML文本如公告、推荐文章列表
content = models.TextField(_('content'))
# 排序序号IntegerField类型唯一约束控制侧边栏显示顺序值越小越靠前
sequence = models.IntegerField(_('order'), unique=True)
# 是否启用BooleanField类型默认True控制是否在前端显示该侧边栏
is_enable = models.BooleanField(_('is enable'), default=True)
# 创建时间DateTimeField类型默认当前时间
creation_time = models.DateTimeField(_('creation time'), default=now)
# 最后修改时间DateTimeField类型默认当前时间
last_mod_time = models.DateTimeField(_('modify time'), default=now)
# 模型元数据配置
class Meta:
ordering = ['sequence'] # 默认排序:按排序序号升序
verbose_name = _('sidebar') # 模型单数显示名称
verbose_name_plural = verbose_name # 模型复数显示名称
# 重写__str__方法友好显示侧边栏标题
def __str__(self):
return self.name
# 定义博客配置模型BlogSettings
class BlogSettings(models.Model):
"""博客全局配置模型存储博客的全局设置如站点名称、SEO信息、备案号等"""
# 站点名称CharField类型必填默认空字符串
site_name = models.CharField(
_('site name'),
max_length=200,
null=False,
blank=False,
default='')
# 站点描述TextField类型必填用于前端显示站点简介如首页底部
site_description = models.TextField(
_('site description'),
max_length=1000,
null=False,
blank=False,
default='')
# 站点SEO描述TextField类型必填用于网页meta标签的description提升搜索引擎排名
site_seo_description = models.TextField(
_('site seo description'), max_length=1000, null=False, blank=False, default='')
# 站点关键词TextField类型必填用于网页meta标签的keywords提升搜索引擎排名
site_keywords = models.TextField(
_('site keywords'),
max_length=1000,
null=False,
blank=False,
default='')
# 文章摘要长度IntegerField类型默认300控制前端显示文章摘要的字符数
article_sub_length = models.IntegerField(_('article sub length'), default=300)
# 侧边栏文章数量IntegerField类型默认10控制侧边栏显示的最新/热门文章数量
sidebar_article_count = models.IntegerField(_('sidebar article count'), default=10)
# 侧边栏评论数量IntegerField类型默认5控制侧边栏显示的最新评论数量
sidebar_comment_count = models.IntegerField(_('sidebar comment count'), default=5)
# 文章页评论数量IntegerField类型默认5控制文章详情页默认显示的评论数量
article_comment_count = models.IntegerField(_('article comment count'), default=5)
# 是否显示谷歌广告BooleanField类型默认False控制是否在前端显示谷歌广告
show_google_adsense = models.BooleanField(_('show adsense'), default=False)
# 谷歌广告代码TextField类型可选存储谷歌广告的HTML代码
google_adsense_codes = models.TextField(
_('adsense code'), max_length=2000, null=True, blank=True, default='')
# 是否开放全站评论BooleanField类型默认True控制整个站点是否允许评论
open_site_comment = models.BooleanField(_('open site comment'), default=True)
# 公共头部代码TextField类型可选存储全局头部的自定义HTML如额外CSS、JS
global_header = models.TextField("公共头部", null=True, blank=True, default='')
# 公共尾部代码TextField类型可选存储全局尾部的自定义HTML如备案信息、统计代码
global_footer = models.TextField("公共尾部", null=True, blank=True, default='')
# 备案号CharField类型可选存储网站ICP备案号如"粤ICP备xxxx号"
beian_code = models.CharField(
'备案号',
max_length=2000,
null=True,
blank=True,
default='')
# 网站统计代码TextField类型必填存储统计工具的JS代码如百度统计、谷歌分析
analytics_code = models.TextField(
"网站统计代码",
max_length=1000,
null=False,
blank=False,
default='')
# 是否显示公安备案号BooleanField类型默认False控制是否显示公安备案信息
show_gongan_code = models.BooleanField(
'是否显示公安备案号', default=False, null=False)
# 公安备案号TextField类型可选存储公安备案号如"粤公网安备xxxx号"
gongan_beiancode = models.TextField(
'公安备案号',
max_length=2000,
null=True,
blank=True,
default='')
# 评论是否需要审核BooleanField类型默认False控制用户提交的评论是否需管理员审核后显示
comment_need_review = models.BooleanField(
'评论是否需要审核', default=False, null=False)
# 模型元数据配置
class Meta:
verbose_name = _('Website configuration') # 模型单数显示名称(网站配置)
verbose_name_plural = verbose_name # 模型复数显示名称
# 重写__str__方法友好显示站点名称
def __str__(self):
return self.site_name
# 自定义数据验证方法:确保博客配置只能有一条记录(全局唯一配置)
def clean(self):
# 排除当前实例ID后查询是否已有其他配置记录
if BlogSettings.objects.exclude(id=self.id).count():
# 若存在其他记录,抛出验证错误(阻止保存)
raise ValidationError(_('There can only be one configuration'))
# 重写save方法保存配置后清空缓存确保前端能立即获取最新配置
def save(self, *args, **kwargs):
super().save(*args, **kwargs) # 调用父类save方法完成数据入库
from djangoblog.utils import cache # 延迟导入缓存模块,避免循环导入
cache.clear() # 清空所有缓存

@ -0,0 +1,28 @@
# 从Haystack库导入索引相关核心类
# 1. SearchIndex搜索索引基类定义搜索索引的核心结构如搜索字段
# 2. Indexable索引可访问性基类要求子类实现get_model方法指定关联的Django模型
from haystack import indexes
# 从当前应用blog的models.py导入Article模型用于将文章数据同步到搜索索引
from blog.models import Article
# 定义文章搜索索引类ArticleIndex继承自SearchIndex搜索索引核心和Indexable索引关联模型
# 作用告诉Haystack如何构建Article模型的搜索索引指定搜索字段、关联模型及索引数据范围
class ArticleIndex(indexes.SearchIndex, indexes.Indexable):
# 定义核心搜索字段text
# - document=True标记该字段为Haystack的"文档字段"(全文搜索的核心字段,所有搜索都会基于该字段匹配)
# - use_template=True指定使用模板来构建该字段的搜索内容模板路径默认是 templates/search/indexes/blog/article_text.txt
# 模板中可包含文章标题、正文、标签等需要被搜索的字段Haystack会将这些内容整合为text字段用于搜索
text = indexes.CharField(document=True, use_template=True)
# 实现Indexable基类的强制方法指定当前索引关联的Django模型
def get_model(self):
# 返回Article模型告诉Haystack该索引是为Article模型构建的
return Article
# 定义索引查询集指定哪些Article数据需要被纳入搜索索引
def index_queryset(self, using=None):
# using参数指定使用的搜索引擎如Elasticsearch、Whoosh默认None使用配置的默认引擎
# 过滤条件:仅将状态为"已发布"status='p')的文章纳入索引,草稿文章不参与搜索
return self.get_model().objects.filter(status='p')

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

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

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

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 B

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

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

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

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

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

File diff suppressed because one or more lines are too long

@ -0,0 +1,378 @@
/* cyrillic-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-display: fallback;
src: url(memnYaGs126MiZpBA-UFUKWyV9hmIqOjjg.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-display: fallback;
src: url(memnYaGs126MiZpBA-UFUKWyV9hvIqOjjg.woff2) format('woff2');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-display: fallback;
src: url(memnYaGs126MiZpBA-UFUKWyV9hnIqOjjg.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-display: fallback;
src: url(memnYaGs126MiZpBA-UFUKWyV9hoIqOjjg.woff2) format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-display: fallback;
src: url(memnYaGs126MiZpBA-UFUKWyV9hkIqOjjg.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-display: fallback;
src: url(memnYaGs126MiZpBA-UFUKWyV9hlIqOjjg.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-display: fallback;
src: url(memnYaGs126MiZpBA-UFUKWyV9hrIqM.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-display: fallback;
src: url(mem6YaGs126MiZpBA-UFUK0Udc1UAw.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-display: fallback;
src: url(mem6YaGs126MiZpBA-UFUK0ddc1UAw.woff2) format('woff2');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-display: fallback;
src: url(mem6YaGs126MiZpBA-UFUK0Vdc1UAw.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-display: fallback;
src: url(mem6YaGs126MiZpBA-UFUK0adc1UAw.woff2) format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-display: fallback;
src: url(mem6YaGs126MiZpBA-UFUK0Wdc1UAw.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-display: fallback;
src: url(mem6YaGs126MiZpBA-UFUK0Xdc1UAw.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-display: fallback;
src: url(mem6YaGs126MiZpBA-UFUK0Zdc0.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 600;
font-display: fallback;
src: url(memnYaGs126MiZpBA-UFUKXGUdhmIqOjjg.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 600;
font-display: fallback;
src: url(memnYaGs126MiZpBA-UFUKXGUdhvIqOjjg.woff2) format('woff2');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 600;
font-display: fallback;
src: url(memnYaGs126MiZpBA-UFUKXGUdhnIqOjjg.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 600;
font-display: fallback;
src: url(memnYaGs126MiZpBA-UFUKXGUdhoIqOjjg.woff2) format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 600;
font-display: fallback;
src: url(memnYaGs126MiZpBA-UFUKXGUdhkIqOjjg.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 600;
font-display: fallback;
src: url(memnYaGs126MiZpBA-UFUKXGUdhlIqOjjg.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 600;
font-display: fallback;
src: url(memnYaGs126MiZpBA-UFUKXGUdhrIqM.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 300;
font-display: fallback;
src: url(mem5YaGs126MiZpBA-UN_r8OX-hpOqc.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 300;
font-display: fallback;
src: url(mem5YaGs126MiZpBA-UN_r8OVuhpOqc.woff2) format('woff2');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 300;
font-display: fallback;
src: url(mem5YaGs126MiZpBA-UN_r8OXuhpOqc.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 300;
font-display: fallback;
src: url(mem5YaGs126MiZpBA-UN_r8OUehpOqc.woff2) format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 300;
font-display: fallback;
src: url(mem5YaGs126MiZpBA-UN_r8OXehpOqc.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 300;
font-display: fallback;
src: url(mem5YaGs126MiZpBA-UN_r8OXOhpOqc.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 300;
font-display: fallback;
src: url(mem5YaGs126MiZpBA-UN_r8OUuhp.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-display: fallback;
src: url(mem8YaGs126MiZpBA-UFWJ0bbck.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-display: fallback;
src: url(mem8YaGs126MiZpBA-UFUZ0bbck.woff2) format('woff2');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-display: fallback;
src: url(mem8YaGs126MiZpBA-UFWZ0bbck.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-display: fallback;
src: url(mem8YaGs126MiZpBA-UFVp0bbck.woff2) format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-display: fallback;
src: url(mem8YaGs126MiZpBA-UFWp0bbck.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-display: fallback;
src: url(mem8YaGs126MiZpBA-UFW50bbck.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-display: fallback;
src: url(mem8YaGs126MiZpBA-UFVZ0b.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 600;
font-display: fallback;
src: url(mem5YaGs126MiZpBA-UNirkOX-hpOqc.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 600;
font-display: fallback;
src: url(mem5YaGs126MiZpBA-UNirkOVuhpOqc.woff2) format('woff2');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 600;
font-display: fallback;
src: url(mem5YaGs126MiZpBA-UNirkOXuhpOqc.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 600;
font-display: fallback;
src: url(mem5YaGs126MiZpBA-UNirkOUehpOqc.woff2) format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 600;
font-display: fallback;
src: url(mem5YaGs126MiZpBA-UNirkOXehpOqc.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 600;
font-display: fallback;
src: url(mem5YaGs126MiZpBA-UNirkOXOhpOqc.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 600;
font-display: fallback;
src: url(mem5YaGs126MiZpBA-UNirkOUuhp.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

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

Loading…
Cancel
Save