Compare commits

..

8 Commits

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

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

@ -1,52 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="877d29e8-c5ba-4dbc-a42a-857f1cd347e9" name="更改" comment="" />
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 8
}</component>
<component name="ProjectId" id="330YPTLh2LaUWXcwdtA98gswyKs" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;git-widget-placeholder&quot;: &quot;main&quot;,
&quot;last_opened_file_path&quot;: &quot;E:/zyd2025&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;preferences.pluginManager&quot;
}
}</component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-python-sdk-8753aad8314d-d902c0275401-com.jetbrains.pycharm.community.sharedIndexes.bundled-PC-252.25557.178" />
</set>
</attachedChunks>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="默认任务">
<changelist id="877d29e8-c5ba-4dbc-a42a-857f1cd347e9" name="更改" comment="" />
<created>1758455435191</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1758455435191</updated>
</task>
<servers />
</component>
</project>

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module version="4">
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
</module>

@ -1,161 +0,0 @@
# DjangoBlog系统用例列表
## 一、参与者列表
| 参与者 | 描述 | 权限范围 |
|--------|------|----------|
| 访客 (Visitor) | 未注册的网站访问者 | 浏览公开内容,基本认证操作 |
| 注册用户 (Registered User) | 已完成注册并登录的用户 | 访客权限 + 评论和互动功能 |
| 作者 (Author) | 具有内容创建权限的用户 | 注册用户权限 + 内容管理功能 |
| 管理员 (Administrator) | 具有系统管理权限的用户 | 所有功能权限 |
---
## 二、用例详细列表
### 内容浏览模块
| 用例标识 | 用例名称 | 执行者 | 用例描述 |
|----------|----------|--------|----------|
| UC001 | 浏览首页 | 访客、注册用户、作者、管理员 | 访问博客系统首页,显示最新文章列表,支持分页浏览,显示侧边栏信息 |
| UC002 | 查看文章详情 | 访客、注册用户、作者、管理员 | 查看文章完整内容,包括标题、正文、作者、发布时间、标签等,自动增加浏览量 |
| UC003 | 浏览分类文章 | 访客、注册用户、作者、管理员 | 显示指定分类下的所有文章列表,支持分页浏览 |
| UC004 | 浏览标签文章 | 访客、注册用户、作者、管理员 | 显示包含指定标签的所有文章列表,支持分页浏览 |
| UC005 | 浏览作者文章 | 访客、注册用户、作者、管理员 | 显示指定作者发布的所有文章列表,显示作者基本信息 |
| UC006 | 查看文章归档 | 访客、注册用户、作者、管理员 | 按时间顺序显示所有已发布文章,支持按年月分组 |
| UC007 | 搜索文章 | 访客、注册用户、作者、管理员 | 全文搜索匹配的文章,返回搜索结果列表,高亮显示关键词 |
| UC008 | 浏览友情链接 | 访客、注册用户、作者、管理员 | 显示所有已启用的友情链接,支持链接分类显示 |
### 用户认证模块
| 用例标识 | 用例名称 | 执行者 | 用例描述 |
|----------|----------|--------|----------|
| UC009 | 用户注册 | 访客 | 填写注册表单,验证数据有效性,创建用户账户并发送验证邮件 |
| UC010 | 用户登录 | 访客、注册用户、作者、管理员 | 输入用户名/邮箱和密码,验证用户凭据,支持"记住我"功能 |
| UC011 | 用户登出 | 注册用户、作者、管理员 | 清除用户会话信息,跳转到首页或登录页面 |
| UC012 | 忘记密码 | 访客、注册用户、作者、管理员 | 输入注册邮箱,发送密码重置邮件,用户设置新密码 |
| UC013 | OAuth第三方登录 | 访客、注册用户、作者、管理员 | 选择第三方平台登录,完成授权,获取用户信息并关联账户 |
| UC014 | 邮箱验证 | 访客 | 点击验证链接,验证链接有效性,激活用户账户 |
### 评论系统模块
| 用例标识 | 用例名称 | 执行者 | 用例描述 |
|----------|----------|--------|----------|
| UC015 | 发表评论 | 注册用户、作者、管理员 | 填写评论内容,验证有效性,根据设置决定是否需要审核 |
| UC016 | 回复评论 | 注册用户、作者、管理员 | 点击回复按钮,填写回复内容,保存回复并建立父子关系 |
| UC017 | 查看评论 | 访客、注册用户、作者、管理员 | 查看评论列表,按时间顺序显示,支持层级显示回复关系 |
### 内容管理模块
| 用例标识 | 用例名称 | 执行者 | 用例描述 |
|----------|----------|--------|----------|
| UC018 | 创建文章 | 作者、管理员 | 访问创建页面填写文章信息使用Markdown编辑器设置文章状态 |
| UC019 | 编辑文章 | 作者、管理员 | 访问编辑页面,修改文章内容,更新相关信息 |
| UC020 | 删除文章 | 作者、管理员 | 确认删除文章,删除文章及相关数据,更新统计信息 |
| UC021 | 发布文章 | 作者、管理员 | 将草稿状态的文章发布,更新文章状态,设置发布时间 |
| UC022 | 保存草稿 | 作者、管理员 | 保存文章内容为草稿,不发布,可在稍后继续编辑 |
| UC023 | 上传文件 | 作者、管理员 | 上传图片或其他文件验证文件类型和大小返回文件访问URL |
### 系统管理模块
| 用例标识 | 用例名称 | 执行者 | 用例描述 |
|----------|----------|--------|----------|
| UC024 | 管理用户 | 管理员 | 查看用户列表,编辑用户信息,设置用户权限,管理用户账户 |
| UC025 | 管理文章 | 管理员 | 查看所有文章,编辑任何用户文章,删除文章,批量管理文章状态 |
| UC026 | 管理评论 | 管理员 | 查看所有评论,审核待审核评论,删除不当评论,批量管理评论状态 |
| UC027 | 管理分类 | 管理员 | 创建文章分类,编辑分类信息,删除分类,设置分类显示顺序 |
| UC028 | 管理标签 | 管理员 | 创建文章标签,编辑标签信息,删除无用标签,合并相似标签 |
| UC029 | 管理友情链接 | 管理员 | 添加友情链接,编辑链接信息,设置链接显示状态,删除失效链接 |
| UC030 | 系统设置 | 管理员 | 配置博客基本信息设置SEO参数配置邮件发送管理OAuth配置 |
| UC031 | 查看统计 | 管理员 | 查看网站访问统计,查看文章浏览数据,查看用户注册统计,导出数据 |
### 位置跟踪模块
| 用例标识 | 用例名称 | 执行者 | 用例描述 |
|----------|----------|--------|----------|
| UC032 | 查看位置轨迹 | 注册用户、作者、管理员 | 访问位置轨迹页面,在地图上显示用户位置轨迹,支持按日期筛选 |
| UC033 | 管理位置数据 | 管理员 | 查看所有位置数据,删除无效记录,管理数据存储和备份,配置相关设置 |
---
## 三、用例关系列表
### 包含关系 (Include)
| 主用例 | 被包含用例 | 关系说明 |
|--------|------------|----------|
| 所有需要身份验证的用例 | UC010 用户登录 | 执行前需要先登录 |
| 文章相关操作 | UC010 用户登录 | 内容管理需要身份验证 |
### 扩展关系 (Extend)
| 主用例 | 扩展用例 | 关系说明 |
|--------|----------|----------|
| UC010 用户登录 | UC013 OAuth第三方登录 | OAuth是登录的扩展方式 |
| UC015 发表评论 | UC016 回复评论 | 回复是评论的扩展功能 |
### 泛化关系 (Generalization)
| 父用例 | 子用例 | 关系说明 |
|--------|--------|----------|
| 注册用户 | 作者 | 作者是注册用户的特殊类型 |
| 作者 | 管理员 | 管理员是作者的特殊类型 |
---
## 四、权限矩阵
| 功能模块 | 访客 | 注册用户 | 作者 | 管理员 |
|----------|------|----------|------|--------|
| **内容浏览** | ✅ | ✅ | ✅ | ✅ |
| - 浏览首页 | ✅ | ✅ | ✅ | ✅ |
| - 查看文章详情 | ✅ | ✅ | ✅ | ✅ |
| - 浏览分类文章 | ✅ | ✅ | ✅ | ✅ |
| - 浏览标签文章 | ✅ | ✅ | ✅ | ✅ |
| - 浏览作者文章 | ✅ | ✅ | ✅ | ✅ |
| - 查看文章归档 | ✅ | ✅ | ✅ | ✅ |
| - 搜索文章 | ✅ | ✅ | ✅ | ✅ |
| - 浏览友情链接 | ✅ | ✅ | ✅ | ✅ |
| **用户认证** | ✅ | ✅ | ✅ | ✅ |
| - 用户注册 | ✅ | - | - | - |
| - 用户登录 | ✅ | ✅ | ✅ | ✅ |
| - 用户登出 | - | ✅ | ✅ | ✅ |
| - 忘记密码 | ✅ | ✅ | ✅ | ✅ |
| - OAuth第三方登录 | ✅ | ✅ | ✅ | ✅ |
| - 邮箱验证 | ✅ | - | - | - |
| **评论系统** | - | ✅ | ✅ | ✅ |
| - 发表评论 | - | ✅ | ✅ | ✅ |
| - 回复评论 | - | ✅ | ✅ | ✅ |
| - 查看评论 | ✅ | ✅ | ✅ | ✅ |
| **内容管理** | - | - | ✅ | ✅ |
| - 创建文章 | - | - | ✅ | ✅ |
| - 编辑文章 | - | - | ✅ | ✅ |
| - 删除文章 | - | - | ✅ | ✅ |
| - 发布文章 | - | - | ✅ | ✅ |
| - 保存草稿 | - | - | ✅ | ✅ |
| - 上传文件 | - | - | ✅ | ✅ |
| **系统管理** | - | - | - | ✅ |
| - 管理用户 | - | - | - | ✅ |
| - 管理文章 | - | - | - | ✅ |
| - 管理评论 | - | - | - | ✅ |
| - 管理分类 | - | - | - | ✅ |
| - 管理标签 | - | - | - | ✅ |
| - 管理友情链接 | - | - | - | ✅ |
| - 系统设置 | - | - | - | ✅ |
| - 查看统计 | - | - | - | ✅ |
| **位置跟踪** | - | ✅ | ✅ | ✅ |
| - 查看位置轨迹 | - | ✅ | ✅ | ✅ |
| - 管理位置数据 | - | - | - | ✅ |
---
## 五、统计汇总
| 统计项目 | 数量 |
|----------|------|
| **总用例数** | 33 |
| **参与者数** | 4 |
| **功能模块数** | 6 |
| **内容浏览模块用例** | 8 |
| **用户认证模块用例** | 6 |
| **评论系统模块用例** | 3 |
| **内容管理模块用例** | 6 |
| **系统管理模块用例** | 8 |
| **位置跟踪模块用例** | 2 |

@ -1,421 +0,0 @@
# DjangoBlog系统用例结构描述
## 系统概述
DjangoBlog是一个基于Python Django框架构建的现代化博客系统支持多用户、多角色管理提供完整的博客功能包括内容管理、评论系统、用户认证、位置跟踪等。
## 参与者Actors
### 1. 访客Visitor
- **描述**:未注册的网站访问者
- **权限**:只能浏览公开内容和进行基本认证操作
### 2. 注册用户Registered User
- **描述**:已完成注册并登录的用户
- **权限**:在访客基础上增加评论和互动功能
### 3. 作者Author
- **描述**:具有内容创建权限的用户
- **权限**:在注册用户基础上增加内容管理功能
### 4. 管理员Administrator
- **描述**:具有系统管理权限的用户
- **权限**:拥有所有功能权限,包括系统管理功能
---
## 用例详细描述
### 一、内容浏览模块
#### UC001 浏览首页
- **用例标识**UC001
- **用例名称**:浏览首页
- **执行者**:访客、注册用户、作者、管理员
- **用例描述**
- 用户访问博客系统首页
- 系统显示最新的文章列表,支持分页浏览
- 显示侧边栏信息(最新文章、热门文章、标签云等)
- 用户可以通过导航菜单访问其他功能页面
#### UC002 查看文章详情
- **用例标识**UC002
- **用例名称**:查看文章详情
- **执行者**:访客、注册用户、作者、管理员
- **用例描述**
- 用户点击文章标题或链接
- 系统显示文章完整内容,包括标题、正文、作者、发布时间、标签等
- 自动增加文章浏览量
- 如果文章开启评论,显示评论区域
- 显示相关文章推荐
#### UC003 浏览分类文章
- **用例标识**UC003
- **用例名称**:浏览分类文章
- **执行者**:访客、注册用户、作者、管理员
- **用例描述**
- 用户点击分类链接或导航菜单中的分类
- 系统显示该分类下的所有文章列表
- 支持分页浏览
- 显示分类名称和文章数量
#### UC004 浏览标签文章
- **用例标识**UC004
- **用例名称**:浏览标签文章
- **执行者**:访客、注册用户、作者、管理员
- **用例描述**
- 用户点击标签链接或标签云中的标签
- 系统显示包含该标签的所有文章列表
- 支持分页浏览
- 显示标签名称和文章数量
#### UC005 浏览作者文章
- **用例标识**UC005
- **用例名称**:浏览作者文章
- **执行者**:访客、注册用户、作者、管理员
- **用例描述**
- 用户点击作者名称或访问作者页面
- 系统显示该作者发布的所有文章列表
- 支持分页浏览
- 显示作者基本信息和文章统计
#### UC006 查看文章归档
- **用例标识**UC006
- **用例名称**:查看文章归档
- **执行者**:访客、注册用户、作者、管理员
- **用例描述**
- 用户访问归档页面
- 系统按时间顺序显示所有已发布的文章
- 支持按年月分组显示
- 提供时间轴式的浏览体验
#### UC007 搜索文章
- **用例标识**UC007
- **用例名称**:搜索文章
- **执行者**:访客、注册用户、作者、管理员
- **用例描述**
- 用户输入关键词进行搜索
- 系统在全文中搜索匹配的文章
- 返回搜索结果列表,高亮显示关键词
- 支持高级搜索选项(按作者、分类、时间等筛选)
#### UC008 浏览友情链接
- **用例标识**UC008
- **用例名称**:浏览友情链接
- **执行者**:访客、注册用户、作者、管理员
- **用例描述**
- 用户访问友情链接页面
- 系统显示所有已启用的友情链接
- 支持链接分类显示
- 提供链接描述和访问统计
---
### 二、用户认证模块
#### UC009 用户注册
- **用例标识**UC009
- **用例名称**:用户注册
- **执行者**:访客
- **用例描述**
- 访客填写注册表单(用户名、邮箱、密码等)
- 系统验证表单数据的有效性
- 检查用户名和邮箱的唯一性
- 创建用户账户并发送验证邮件
- 显示注册结果页面
#### UC010 用户登录
- **用例标识**UC010
- **用例名称**:用户登录
- **执行者**:访客、注册用户、作者、管理员
- **用例描述**
- 用户输入用户名/邮箱和密码
- 系统验证用户凭据
- 支持"记住我"功能
- 登录成功后跳转到目标页面或首页
- 记录登录日志
#### UC011 用户登出
- **用例标识**UC011
- **用例名称**:用户登出
- **执行者**:注册用户、作者、管理员
- **用例描述**
- 用户点击登出按钮
- 系统清除用户会话信息
- 跳转到首页或登录页面
- 清除相关缓存数据
#### UC012 忘记密码
- **用例标识**UC012
- **用例名称**:忘记密码
- **执行者**:访客、注册用户、作者、管理员
- **用例描述**
- 用户输入注册邮箱
- 系统发送密码重置邮件
- 用户通过邮件链接访问密码重置页面
- 用户设置新密码并完成重置
#### UC013 OAuth第三方登录
- **用例标识**UC013
- **用例名称**OAuth第三方登录
- **执行者**:访客、注册用户、作者、管理员
- **用例描述**
- 用户选择第三方登录平台Google、GitHub、微博、QQ等
- 系统重定向到第三方授权页面
- 用户完成第三方平台授权
- 系统获取用户信息并创建或关联账户
- 自动登录并跳转到目标页面
#### UC014 邮箱验证
- **用例标识**UC014
- **用例名称**:邮箱验证
- **执行者**:访客
- **用例描述**
- 用户点击注册邮件中的验证链接
- 系统验证链接的有效性
- 激活用户账户
- 显示验证结果页面
---
### 三、评论系统模块
#### UC015 发表评论
- **用例标识**UC015
- **用例名称**:发表评论
- **执行者**:注册用户、作者、管理员
- **用例描述**
- 用户在文章页面填写评论内容
- 系统验证评论内容的有效性
- 根据系统设置决定是否需要审核
- 保存评论并显示结果
- 发送评论通知邮件(如配置)
#### UC016 回复评论
- **用例标识**UC016
- **用例名称**:回复评论
- **执行者**:注册用户、作者、管理员
- **用例描述**
- 用户点击评论的回复按钮
- 系统显示回复表单
- 用户填写回复内容
- 系统保存回复并建立父子关系
- 显示回复结果
#### UC017 查看评论
- **用例标识**UC017
- **用例名称**:查看评论
- **执行者**:访客、注册用户、作者、管理员
- **用例描述**
- 用户在文章页面查看评论列表
- 系统按时间顺序显示评论
- 支持评论的层级显示(回复关系)
- 显示评论者信息和评论时间
---
### 四、内容管理模块
#### UC018 创建文章
- **用例标识**UC018
- **用例名称**:创建文章
- **执行者**:作者、管理员
- **用例描述**
- 作者访问文章创建页面
- 填写文章标题、正文、分类、标签等信息
- 使用Markdown编辑器编写内容
- 设置文章状态(草稿/发布)
- 保存文章到数据库
#### UC019 编辑文章
- **用例标识**UC019
- **用例名称**:编辑文章
- **执行者**:作者、管理员
- **用例描述**
- 作者访问文章编辑页面
- 修改文章标题、正文、分类、标签等
- 更新文章内容
- 保存修改并记录修改时间
#### UC020 删除文章
- **用例标识**UC020
- **用例名称**:删除文章
- **执行者**:作者、管理员
- **用例描述**
- 作者确认删除文章
- 系统删除文章及相关数据
- 删除相关评论和文件
- 更新相关统计信息
#### UC021 发布文章
- **用例标识**UC021
- **用例名称**:发布文章
- **执行者**:作者、管理员
- **用例描述**
- 作者将草稿状态的文章发布
- 系统更新文章状态为已发布
- 设置发布时间
- 通知搜索引擎(如配置)
#### UC022 保存草稿
- **用例标识**UC022
- **用例名称**:保存草稿
- **执行者**:作者、管理员
- **用例描述**
- 作者在编辑文章时保存为草稿
- 系统保存文章内容但不发布
- 文章状态设置为草稿
- 可在稍后继续编辑
#### UC023 上传文件
- **用例标识**UC023
- **用例名称**:上传文件
- **执行者**:作者、管理员
- **用例描述**
- 作者上传图片或其他文件
- 系统验证文件类型和大小
- 保存文件到指定目录
- 返回文件访问URL供文章使用
---
### 五、系统管理模块
#### UC024 管理用户
- **用例标识**UC024
- **用例名称**:管理用户
- **执行者**:管理员
- **用例描述**
- 管理员查看用户列表
- 编辑用户信息
- 设置用户权限
- 禁用或删除用户账户
#### UC025 管理文章
- **用例标识**UC025
- **用例名称**:管理文章
- **执行者**:管理员
- **用例描述**
- 管理员查看所有文章列表
- 编辑任何用户的文章
- 删除文章
- 批量管理文章状态
#### UC026 管理评论
- **用例标识**UC026
- **用例名称**:管理评论
- **执行者**:管理员
- **用例描述**
- 管理员查看所有评论
- 审核待审核的评论
- 删除不当评论
- 批量管理评论状态
#### UC027 管理分类
- **用例标识**UC027
- **用例名称**:管理分类
- **执行者**:管理员
- **用例描述**
- 管理员创建文章分类
- 编辑分类名称和描述
- 删除分类
- 设置分类显示顺序
#### UC028 管理标签
- **用例标识**UC028
- **用例名称**:管理标签
- **执行者**:管理员
- **用例描述**
- 管理员创建文章标签
- 编辑标签信息
- 删除无用标签
- 合并相似标签
#### UC029 管理友情链接
- **用例标识**UC029
- **用例名称**:管理友情链接
- **执行者**:管理员
- **用例描述**
- 管理员添加友情链接
- 编辑链接信息
- 设置链接显示状态
- 删除失效链接
#### UC030 系统设置
- **用例标识**UC030
- **用例名称**:系统设置
- **执行者**:管理员
- **用例描述**
- 管理员配置博客基本信息
- 设置SEO相关参数
- 配置邮件发送设置
- 管理OAuth登录配置
#### UC031 查看统计
- **用例标识**UC031
- **用例名称**:查看统计
- **执行者**:管理员
- **用例描述**
- 管理员查看网站访问统计
- 查看文章浏览数据
- 查看用户注册统计
- 导出统计数据
---
### 六、位置跟踪模块
#### UC032 查看位置轨迹
- **用例标识**UC032
- **用例名称**:查看位置轨迹
- **执行者**:注册用户、作者、管理员
- **用例描述**
- 用户访问位置轨迹页面
- 系统在地图上显示用户的位置轨迹
- 支持按日期筛选轨迹数据
- 提供轨迹数据的可视化展示
#### UC033 管理位置数据
- **用例标识**UC033
- **用例名称**:管理位置数据
- **执行者**:管理员
- **用例描述**
- 管理员查看所有位置数据
- 删除无效或错误的位置记录
- 管理位置数据的存储和备份
- 配置位置跟踪相关设置
---
## 用例关系说明
### 包含关系Include
- 所有需要身份验证的用例都包含"用户登录"用例
- 文章相关操作都包含"身份验证"用例
### 扩展关系Extend
- "OAuth第三方登录"扩展"用户登录"
- "回复评论"扩展"发表评论"
### 泛化关系Generalization
- "作者"泛化"注册用户"
- "管理员"泛化"作者"
## 非功能性需求
### 性能要求
- 页面加载时间不超过3秒
- 支持1000并发用户访问
- 搜索结果响应时间不超过1秒
### 安全要求
- 所有用户输入必须进行验证和过滤
- 敏感操作需要身份验证
- 防止SQL注入和XSS攻击
### 可用性要求
- 系统可用性达到99.9%
- 支持移动端访问
- 提供多语言支持

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

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

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

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

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

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

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

3
src/.gitignore vendored

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

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

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

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

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

@ -7,66 +7,43 @@ import django.utils.timezone
class Migration(migrations.Migration):
# 标记这是一个初始迁移文件
initial = True
# 定义依赖关系依赖于auth应用的0012_alter_user_first_name_max_length迁移
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
# 定义具体的操作
operations = [
# 创建BlogUser模型
migrations.CreateModel(
name='BlogUser',
fields=[
# 主键字段自动创建的BigAutoField
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 密码字段最大长度128字符
('password', models.CharField(max_length=128, verbose_name='password')),
# 最后登录时间,可为空
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
# 超级用户状态,用于标识是否具有所有权限
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
# 用户名字段,唯一且有验证器
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
# 名字字段,可为空
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
# 姓氏字段,可为空
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
# 邮箱地址字段,可为空
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
# 员工状态,标识用户是否可以登录管理站点
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
# 活跃状态,标识用户是否应该被视为活跃用户
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
# 加入日期,默认为当前时间
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
# 昵称字段,可为空
('nickname', models.CharField(blank=True, max_length=100, verbose_name='昵称')),
# 创建时间,默认为当前时间
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
# 最后修改时间,默认为当前时间
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
# 创建来源字段,可为空
('source', models.CharField(blank=True, max_length=100, verbose_name='创建来源')),
# 用户组关联字段,多对多关系
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
# 用户权限字段,多对多关系
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
# 模型选项配置
options={
'verbose_name': '用户', # 单数名称
'verbose_name_plural': '用户', # 复数名称
'ordering': ['-id'], # 默认排序方式
'get_latest_by': 'id', # 获取最新记录的字段
'verbose_name': '用户',
'verbose_name_plural': '用户',
'ordering': ['-id'],
'get_latest_by': 'id',
},
# 模型管理器
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -6,61 +6,48 @@ from blog.models import Article, Tag, Category
class Command(BaseCommand):
"""
Django管理命令用于创建测试数据
"""
help = 'create test datas' # 命令帮助信息
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]
# 先尝试通过用户名查找用户
try:
user = get_user_model().objects.get(username='测试用户')
except get_user_model().DoesNotExist:
# 如果用户不存在,则创建新用户
user = get_user_model().objects.create(
email='test@test.com',
username='测试用户',
password=make_password('test!q@w#eTYU')
)
# 创建父分类
pcategory = Category.objects.get_or_create(
name='我是父类目',
parent_category=None)[0]
name='我是父类目', parent_category=None)[0]
# 创建子分类
category = Category.objects.get_or_create(
name='子类目',
parent_category=pcategory)[0]
name='子类目', parent_category=pcategory)[0]
category.save()
# 创建基础标签
basetag = Tag()
basetag.name = "标签"
basetag.save()
# 创建20篇文章及其对应的标签
# 使用 get_or_create 处理基础标签
basetag, created = Tag.objects.get_or_create(name="标签")
for i in range(1, 20):
# 创建文章
article = Article.objects.get_or_create(
article, article_created = 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()
author=user
)
# 使用 get_or_create 处理每个标签
tag, tag_created = Tag.objects.get_or_create(name="标签" + str(i))
# 只有在文章是新创建的时候才添加标签
if article_created:
article.tags.add(tag)
article.tags.add(basetag)
article.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'))

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

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

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

@ -8,17 +8,14 @@ import mdeditor.fields
class Migration(migrations.Migration):
# 标记这是一个初始迁移文件
initial = True
# 定义依赖关系
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL), # 依赖用户模型
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
# 定义具体的操作
operations = [
# 创建BlogSettings模型网站配置
migrations.CreateModel(
name='BlogSettings',
fields=[
@ -44,8 +41,6 @@ class Migration(migrations.Migration):
'verbose_name_plural': '网站配置',
},
),
# 创建Links模型友情链接
migrations.CreateModel(
name='Links',
fields=[
@ -64,8 +59,6 @@ class Migration(migrations.Migration):
'ordering': ['sequence'],
},
),
# 创建SideBar模型侧边栏
migrations.CreateModel(
name='SideBar',
fields=[
@ -83,8 +76,6 @@ class Migration(migrations.Migration):
'ordering': ['sequence'],
},
),
# 创建Tag模型标签
migrations.CreateModel(
name='Tag',
fields=[
@ -100,8 +91,6 @@ class Migration(migrations.Migration):
'ordering': ['name'],
},
),
# 创建Category模型分类
migrations.CreateModel(
name='Category',
fields=[
@ -119,8 +108,6 @@ class Migration(migrations.Migration):
'ordering': ['-index'],
},
),
# 创建Article模型文章
migrations.CreateModel(
name='Article',
fields=[
@ -128,7 +115,7 @@ class Migration(migrations.Migration):
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
('title', models.CharField(max_length=200, unique=True, verbose_name='标题')),
('body', mdeditor.fields.MDTextField(verbose_name='正文')), # 使用Markdown编辑器字段
('body', mdeditor.fields.MDTextField(verbose_name='正文')),
('pub_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='发布时间')),
('status', models.CharField(choices=[('d', '草稿'), ('p', '发表')], default='p', max_length=1, verbose_name='文章状态')),
('comment_status', models.CharField(choices=[('o', '打开'), ('c', '关闭')], default='o', max_length=1, verbose_name='评论状态')),
@ -143,9 +130,8 @@ class Migration(migrations.Migration):
options={
'verbose_name': '文章',
'verbose_name_plural': '文章',
'ordering': ['-article_order', '-pub_time'], # 按排序和发布时间降序排列
'ordering': ['-article_order', '-pub_time'],
'get_latest_by': 'id',
},
),
]

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

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

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

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

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

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

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

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

@ -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;
}

Before

Width:  |  Height:  |  Size: 221 B

After

Width:  |  Height:  |  Size: 221 B

@ -1,18 +1,13 @@
/*
IEIE9
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;
@ -20,20 +15,14 @@ body.custom-background-white .site {
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%;
}
/* 防止IE8中具有高度和宽度属性的完整尺寸和大尺寸图像被拉伸 */
img.size-full,
img.size-large,
img.header-image,
@ -41,23 +30,17 @@ img.wp-post-image,
img[class*="align"],
img[class*="wp-image-"],
img[class*="attachment-"] {
width: auto; /* 防止在IE8中拉伸完整尺寸和大尺寸图像 */
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;
@ -65,39 +48,27 @@ img[class*="attachment-"] {
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;
@ -106,46 +77,32 @@ body.full-width .site-content {
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浏览器特殊处理 */
.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;
@ -157,23 +114,17 @@ body.full-width .site-content {
overflow: hidden;
clip: rect(1px, 1px, 1px, 1px);
}
/* IE7浏览器下拉列表特殊处理 */
.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 {
@ -183,14 +134,10 @@ body.full-width .site-content {
height: inherit;
width: inherit;
}
/* IE7浏览器下拉菜单显示特殊处理 */
.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;
@ -200,14 +147,10 @@ body.full-width .site-content {
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,
@ -215,58 +158,39 @@ body.full-width .site-content {
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首页模板小工具修复 */
/* 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 {
@ -274,14 +198,10 @@ body.full-width .site-content {
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 {
@ -289,88 +209,64 @@ body.full-width .site-content {
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;
}
/* 为IE9之前的密码框添加字体以使圆点显示 */
/* add input font for <IE9 Password Box to make the bullets show up */
input[type="password"] {
font-family: Helvetica, Arial, sans-serif;
}
/* IE7IE8RTL
/* RTL overrides for IE7 and IE8
-------------------------------------------------------------- */
/* RTL布局站点标题 */
.rtl .site-header h1,
.rtl .site-header h2 {
text-align: right;
}
/* RTL布局小工具区域和作者描述 */
.rtl .widget-area,
.rtl .author-description {
float: left;
}
/* RTL布局作者头像和站点内容 */
.rtl .author-avatar,
.rtl .site-content {
float: right;
}
/* RTL布局导航菜单 */
.rtl .main-navigation ul.nav-menu,
.rtl .main-navigation div.nav-menu > ul {
text-align: right;
}
/* RTL布局多级导航菜单项 */
.rtl .main-navigation ul li ul li,
.rtl .main-navigation ul li ul li ul li {
margin-left: 40px;
margin-right: auto;
}
/* RTL布局三级导航菜单 */
.rtl .main-navigation li ul ul {
position: absolute;
bottom: 0;
right: 100%;
z-index: 1;
}
/* IE7浏览器RTL布局三级导航菜单特殊处理 */
.ie7 .rtl .main-navigation li ul ul {
position: absolute;
bottom: 0;
right: 100%;
z-index: 1;
}
/* IE7浏览器RTL布局导航菜单项 */
.ie7 .rtl .main-navigation ul li {
z-index: 99;
}
/* IE7浏览器RTL布局下拉菜单 */
.ie7 .rtl .main-navigation li ul {
position: absolute;
bottom: 100%;
right: 0;
z-index: 1;
}
/* IE7浏览器RTL布局导航菜单项外边距 */
.ie7 .rtl .main-navigation li {
margin-right: auto;
margin-left: 40px;
}
/* IE7浏览器RTL布局四级导航菜单 */
.ie7 .rtl .main-navigation li ul ul ul {
position: relative;
z-index: 1;

@ -1,11 +1,10 @@
/* 使点击事件穿透 */
/* Make clicks pass-through */
#nprogress {
pointer-events: none;
}
/* 进度条样式 */
#nprogress .bar {
background: red; /* 进度条颜色 */
background: red;
position: fixed;
z-index: 1031;
@ -16,7 +15,7 @@
height: 2px;
}
/* 花哨的模糊效果 */
/* Fancy blur effect */
#nprogress .peg {
display: block;
position: absolute;
@ -31,7 +30,7 @@
transform: rotate(3deg) translate(0px, -4px);
}
/* 移除这些来去掉旋转器 */
/* Remove these to get rid of the spinner */
#nprogress .spinner {
display: block;
position: fixed;
@ -40,7 +39,6 @@
right: 15px;
}
/* 旋转器图标样式 */
#nprogress .spinner-icon {
width: 18px;
height: 18px;
@ -55,26 +53,22 @@
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内核浏览器的旋转器动画 */
@-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); }
}
}

@ -1,212 +1,173 @@
/* Google图标样式 */
.icon-sn-google {
background-position: 0 -28px;
}
/* Google背景图标样式 */
.icon-sn-bg-google {
background-color: #4285f4;
background-position: 0 0;
}
/* Google字体图标颜色 */
.fa-sn-google {
color: #4285f4;
}
/* GitHub图标样式 */
.icon-sn-github {
background-position: -28px -28px;
}
/* GitHub背景图标样式 */
.icon-sn-bg-github {
background-color: #333;
background-position: -28px 0;
}
/* GitHub字体图标颜色 */
.fa-sn-github {
color: #333;
}
/* 微博图标样式 */
.icon-sn-weibo {
background-position: -56px -28px;
}
/* 微博背景图标样式 */
.icon-sn-bg-weibo {
background-color: #e90d24;
background-position: -56px 0;
}
/* 微博字体图标颜色 */
.fa-sn-weibo {
color: #e90d24;
}
/* QQ图标样式 */
.icon-sn-qq {
background-position: -84px -28px;
}
/* QQ背景图标样式 */
.icon-sn-bg-qq {
background-color: #0098e6;
background-position: -84px 0;
}
/* QQ字体图标颜色 */
.fa-sn-qq {
color: #0098e6;
}
/* Twitter图标样式 */
.icon-sn-twitter {
background-position: -112px -28px;
}
/* Twitter背景图标样式 */
.icon-sn-bg-twitter {
background-color: #50abf1;
background-position: -112px 0;
}
/* Twitter字体图标颜色 */
.fa-sn-twitter {
color: #50abf1;
}
/* Facebook图标样式 */
.icon-sn-facebook {
background-position: -140px -28px;
}
/* Facebook背景图标样式 */
.icon-sn-bg-facebook {
background-color: #4862a3;
background-position: -140px 0;
}
/* Facebook字体图标颜色 */
.fa-sn-facebook {
color: #4862a3;
}
/* 人人网图标样式 */
.icon-sn-renren {
background-position: -168px -28px;
}
/* 人人网背景图标样式 */
.icon-sn-bg-renren {
background-color: #197bc8;
background-position: -168px 0;
}
/* 人人网字体图标颜色 */
.fa-sn-renren {
color: #197bc8;
}
/* 腾讯微博图标样式 */
.icon-sn-tqq {
background-position: -196px -28px;
}
/* 腾讯微博背景图标样式 */
.icon-sn-bg-tqq {
background-color: #1f9ed2;
background-position: -196px 0;
}
/* 腾讯微博字体图标颜色 */
.fa-sn-tqq {
color: #1f9ed2;
}
/* 豆瓣图标样式 */
.icon-sn-douban {
background-position: -224px -28px;
}
/* 豆瓣背景图标样式 */
.icon-sn-bg-douban {
background-color: #279738;
background-position: -224px 0;
}
/* 豆瓣字体图标颜色 */
.fa-sn-douban {
color: #279738;
}
/* 微信图标样式 */
.icon-sn-weixin {
background-position: -252px -28px;
}
/* 微信背景图标样式 */
.icon-sn-bg-weixin {
background-color: #00b500;
background-position: -252px 0;
}
/* 微信字体图标颜色 */
.fa-sn-weixin {
color: #00b500;
}
/* 虚线图标样式 */
.icon-sn-dotted {
background-position: -280px -28px;
}
/* 虚线背景图标样式 */
.icon-sn-bg-dotted {
background-color: #eee;
background-position: -280px 0;
}
/* 虚线字体图标颜色 */
.fa-sn-dotted {
color: #eee;
}
/* 网站图标样式 */
.icon-sn-site {
background-position: -308px -28px;
}
/* 网站背景图标样式 */
.icon-sn-bg-site {
background-color: #00b500;
background-position: -308px 0;
}
/* 网站字体图标颜色 */
.fa-sn-site {
color: #00b500;
}
/* LinkedIn图标样式 */
.icon-sn-linkedin {
background-position: -336px -28px;
}
/* LinkedIn背景图标样式 */
.icon-sn-bg-linkedin {
background-color: #0077b9;
background-position: -336px 0;
}
/* LinkedIn字体图标颜色 */
.fa-sn-linkedin {
color: #0077b9;
}
/* 社交网络图标通用样式 */
[class*=icon-sn-] {
display: inline-block;
background-image: url('../img/icon-sn.svg');
@ -217,159 +178,128 @@
background-size: auto 56px;
}
/* 社交网络图标悬停效果 */
[class*=icon-sn-]:hover {
opacity: .8;
filter: alpha(opacity=80);
}
/* Google按钮样式 */
.btn-sn-google {
background: #4285f4;
}
/* Google按钮激活、聚焦、悬停样式 */
.btn-sn-google:active, .btn-sn-google:focus, .btn-sn-google:hover {
background: #2a75f3;
}
/* GitHub按钮样式 */
.btn-sn-github {
background: #333;
}
/* GitHub按钮激活、聚焦、悬停样式 */
.btn-sn-github:active, .btn-sn-github:focus, .btn-sn-github:hover {
background: #262626;
}
/* 微博按钮样式 */
.btn-sn-weibo {
background: #e90d24;
}
/* 微博按钮激活、聚焦、悬停样式 */
.btn-sn-weibo:active, .btn-sn-weibo:focus, .btn-sn-weibo:hover {
background: #d10c20;
}
/* QQ按钮样式 */
.btn-sn-qq {
background: #0098e6;
}
/* QQ按钮激活、聚焦、悬停样式 */
.btn-sn-qq:active, .btn-sn-qq:focus, .btn-sn-qq:hover {
background: #0087cd;
}
/* Twitter按钮样式 */
.btn-sn-twitter {
background: #50abf1;
}
/* Twitter按钮激活、聚焦、悬停样式 */
.btn-sn-twitter:active, .btn-sn-twitter:focus, .btn-sn-twitter:hover {
background: #38a0ef;
}
/* Facebook按钮样式 */
.btn-sn-facebook {
background: #4862a3;
}
/* Facebook按钮激活、聚焦、悬停样式 */
.btn-sn-facebook:active, .btn-sn-facebook:focus, .btn-sn-facebook:hover {
background: #405791;
}
/* 人人网按钮样式 */
.btn-sn-renren {
background: #197bc8;
}
/* 人人网按钮激活、聚焦、悬停样式 */
.btn-sn-renren:active, .btn-sn-renren:focus, .btn-sn-renren:hover {
background: #166db1;
}
/* 腾讯微博按钮样式 */
.btn-sn-tqq {
background: #1f9ed2;
}
/* 腾讯微博按钮激活、聚焦、悬停样式 */
.btn-sn-tqq:active, .btn-sn-tqq:focus, .btn-sn-tqq:hover {
background: #1c8dbc;
}
/* 豆瓣按钮样式 */
.btn-sn-douban {
background: #279738;
}
/* 豆瓣按钮激活、聚焦、悬停样式 */
.btn-sn-douban:active, .btn-sn-douban:focus, .btn-sn-douban:hover {
background: #228330;
}
/* 微信按钮样式 */
.btn-sn-weixin {
background: #00b500;
}
/* 微信按钮激活、聚焦、悬停样式 */
.btn-sn-weixin:active, .btn-sn-weixin:focus, .btn-sn-weixin:hover {
background: #009c00;
}
/* 虚线按钮样式 */
.btn-sn-dotted {
background: #eee;
}
/* 虚线按钮激活、聚焦、悬停样式 */
.btn-sn-dotted:active, .btn-sn-dotted:focus, .btn-sn-dotted:hover {
background: #e1e1e1;
}
/* 网站按钮样式 */
.btn-sn-site {
background: #00b500;
}
/* 网站按钮激活、聚焦、悬停样式 */
.btn-sn-site:active, .btn-sn-site:focus, .btn-sn-site:hover {
background: #009c00;
}
/* LinkedIn按钮样式 */
.btn-sn-linkedin {
background: #0077b9;
}
/* LinkedIn按钮激活、聚焦、悬停样式 */
.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;
}

@ -0,0 +1,600 @@
/* cyrillic-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtE6F15M.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWvU6F15M.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtU6F15M.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuk6F15M.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* hebrew */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWu06F15M.woff2) format('woff2');
unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
}
/* math */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWxU6F15M.woff2) format('woff2');
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
}
/* symbols */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqW106F15M.woff2) format('woff2');
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
}
/* vietnamese */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtk6F15M.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWt06F15M.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuU6F.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtE6F15M.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWvU6F15M.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtU6F15M.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuk6F15M.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* hebrew */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWu06F15M.woff2) format('woff2');
unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
}
/* math */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWxU6F15M.woff2) format('woff2');
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
}
/* symbols */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqW106F15M.woff2) format('woff2');
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
}
/* vietnamese */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtk6F15M.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWt06F15M.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuU6F.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtE6F15M.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWvU6F15M.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtU6F15M.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuk6F15M.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* hebrew */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWu06F15M.woff2) format('woff2');
unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
}
/* math */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWxU6F15M.woff2) format('woff2');
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
}
/* symbols */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqW106F15M.woff2) format('woff2');
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
}
/* vietnamese */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtk6F15M.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWt06F15M.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Open Sans';
font-style: italic;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuU6F.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSKmu1aB.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSumu1aB.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSOmu1aB.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu1aB.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* hebrew */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS2mu1aB.woff2) format('woff2');
unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
}
/* math */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTVOmu1aB.woff2) format('woff2');
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
}
/* symbols */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTUGmu1aB.woff2) format('woff2');
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
}
/* vietnamese */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSCmu1aB.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSGmu1aB.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 300;
font-stretch: 100%;
font-display: swap;
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-muw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSKmu1aB.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSumu1aB.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSOmu1aB.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu1aB.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* hebrew */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS2mu1aB.woff2) format('woff2');
unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
}
/* math */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTVOmu1aB.woff2) format('woff2');
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
}
/* symbols */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTUGmu1aB.woff2) format('woff2');
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
}
/* vietnamese */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSCmu1aB.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSGmu1aB.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-muw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSKmu1aB.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSumu1aB.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSOmu1aB.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu1aB.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* hebrew */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS2mu1aB.woff2) format('woff2');
unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
}
/* math */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTVOmu1aB.woff2) format('woff2');
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
}
/* symbols */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTUGmu1aB.woff2) format('woff2');
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
}
/* vietnamese */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSCmu1aB.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSGmu1aB.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 600;
font-stretch: 100%;
font-display: swap;
src: url(memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-muw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

@ -1,62 +1,40 @@
/**
* 博客前端交互功能
* Created by liangliang on 2016/11/20.
*/
/**
* 回复评论功能
* @param {string} parentid - 父评论ID
*/
function do_reply(parentid) {
console.log(parentid);
// 设置父评论ID
$("#id_parent_comment_id").val(parentid)
// 将评论表单移动到对应评论下
$("#commentform").appendTo($("#div-comment-" + parentid));
// 隐藏回复标题,显示取消回复按钮
$("#reply-title").hide();
$("#cancel_comment").show();
}
/**
* 取消回复功能
*/
function cancel_reply() {
// 显示回复标题,隐藏取消回复按钮
$("#reply-title").show();
$("#cancel_comment").hide();
// 清空父评论ID
$("#id_parent_comment_id").val('')
// 将评论表单移回原位置
$("#commentform").appendTo($("#respond"));
}
// 页面加载进度条控制
NProgress.start();
NProgress.set(0.4);
// 增量更新进度条
//Increment
var interval = setInterval(function () {
NProgress.inc();
}, 1000);
// 文档加载完成后停止进度条
$(document).ready(function () {
NProgress.done();
clearInterval(interval);
});
/** 侧边栏回到顶部功能 */
/** 侧边栏回到顶部 */
var rocket = $('#rocket');
// 监听窗口滚动事件,防抖处理
$(window).on('scroll', debounce(slideTopSet, 300));
/**
* 防抖函数
* @param {Function} func - 需要防抖的函数
* @param {number} wait - 等待时间毫秒
* @returns {Function} 防抖后的函数
*/
function debounce(func, wait) {
var timeout;
return function () {
@ -65,54 +43,45 @@ function debounce(func, wait) {
};
}
/**
* 设置回到顶部按钮显示状态
*/
function slideTopSet() {
var top = $(document).scrollTop();
if (top > 200) {
rocket.addClass('show'); // 滚动超过200px时显示回到顶部按钮
rocket.addClass('show');
} else {
rocket.removeClass('show'); // 否则隐藏
rocket.removeClass('show');
}
}
// 点击回到顶部按钮事件
$(document).on('click', '#rocket', function (event) {
rocket.addClass('move'); // 添加动画类
// 页面平滑滚动到顶部
rocket.addClass('move');
$('body, html').animate({
scrollTop: 0
}, 800);
});
// 动画结束事件处理
$(document).on('animationEnd', function () {
setTimeout(function () {
rocket.removeClass('move'); // 移除动画类
rocket.removeClass('move');
}, 400);
});
// Webkit内核浏览器动画结束事件处理
});
$(document).on('webkitAnimationEnd', function () {
setTimeout(function () {
rocket.removeClass('move'); // 移除动画类
rocket.removeClass('move');
}, 400);
});
// 页面加载完成后初始化评论回复功能
window.onload = function () {
var replyLinks = document.querySelectorAll(".comment-reply-link");
for (var i = 0; i < replyLinks.length; i++) {
replyLinks[i].onclick = function () {
var pk = this.getAttribute("data-pk");
do_reply(pk); // 点击回复链接时调用回复功能
do_reply(pk);
};
}
};
// 国际化语言切换功能(已注释)
// $(document).ready(function () {
// var form = $('#i18n-form');
// var selector = $('.i18n-select');

@ -0,0 +1,142 @@
/**
* MathJax 智能加载器
* 检测页面是否包含数学公式如果有则动态加载和配置MathJax
*/
(function() {
'use strict';
/**
* 检测页面是否包含数学公式
* @returns {boolean} 是否包含数学公式
*/
function hasMathFormulas() {
const content = document.body.textContent || document.body.innerText || '';
// 检测常见的数学公式语法
return /\$.*?\$|\$\$.*?\$\$|\\begin\{.*?\}|\\end\{.*?\}|\\[a-zA-Z]+\{/.test(content);
}
/**
* 配置MathJax
*/
function configureMathJax() {
window.MathJax = {
tex: {
// 行内公式和块级公式分隔符
inlineMath: [['$', '$']],
displayMath: [['$$', '$$']],
// 处理转义字符和LaTeX环境
processEscapes: true,
processEnvironments: true,
// 自动换行
tags: 'ams'
},
options: {
// 跳过这些HTML标签避免处理代码块等
skipHtmlTags: ['script', 'noscript', 'style', 'textarea', 'pre', 'code', 'a'],
// CSS类控制
ignoreHtmlClass: 'tex2jax_ignore',
processHtmlClass: 'tex2jax_process'
},
// 启动配置
startup: {
ready() {
console.log('MathJax配置完成开始初始化...');
MathJax.startup.defaultReady();
// 处理特定区域的数学公式
const contentEl = document.getElementById('content');
const commentsEl = document.getElementById('comments');
const promises = [];
if (contentEl) {
promises.push(MathJax.typesetPromise([contentEl]));
}
if (commentsEl) {
promises.push(MathJax.typesetPromise([commentsEl]));
}
// 等待所有渲染完成
Promise.all(promises).then(() => {
console.log('MathJax渲染完成');
// 触发自定义事件通知其他脚本MathJax已就绪
document.dispatchEvent(new CustomEvent('mathjaxReady'));
}).catch(error => {
console.error('MathJax渲染失败:', error);
});
}
},
// 输出配置
chtml: {
scale: 1,
minScale: 0.5,
matchFontHeight: false,
displayAlign: 'center',
displayIndent: '0'
}
};
}
/**
* 加载MathJax库
*/
function loadMathJax() {
console.log('检测到数学公式开始加载MathJax...');
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js';
script.async = true;
script.defer = true;
script.onload = function() {
console.log('MathJax库加载成功');
};
script.onerror = function() {
console.error('MathJax库加载失败尝试备用CDN...');
// 备用CDN
const fallbackScript = document.createElement('script');
fallbackScript.src = 'https://polyfill.io/v3/polyfill.min.js?features=es6';
fallbackScript.onload = function() {
const mathJaxScript = document.createElement('script');
mathJaxScript.src = 'https://cdn.mathjax.org/mathjax/latest/MathJax.js?config=TeX-MML-AM_CHTML';
mathJaxScript.async = true;
document.head.appendChild(mathJaxScript);
};
document.head.appendChild(fallbackScript);
};
document.head.appendChild(script);
}
/**
* 初始化函数
*/
function init() {
// 等待DOM完全加载
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
return;
}
// 检测是否需要加载MathJax
if (hasMathFormulas()) {
// 先配置,再加载
configureMathJax();
loadMathJax();
} else {
console.log('未检测到数学公式跳过MathJax加载');
}
}
// 提供重新渲染的全局方法,供动态内容使用
window.rerenderMathJax = function(element) {
if (window.MathJax && window.MathJax.typesetPromise) {
const target = element || document.body;
return window.MathJax.typesetPromise([target]);
}
return Promise.resolve();
};
// 启动初始化
init();
})();

@ -1,5 +1,6 @@
/**
* 处理小屏幕上的导航菜单切换和子菜单项的可访问性
* Handles toggling the navigation menu for small screens and
* accessibility for submenu items.
*/
( function() {
var nav = document.getElementById( 'site-navigation' ), button, menu;
@ -13,19 +14,17 @@
return;
}
// 如果菜单不存在或为空则隐藏按钮
// Hide button if menu is missing or empty.
if ( ! menu || ! menu.childNodes.length ) {
button.style.display = 'none';
return;
}
// 按钮点击事件处理
button.onclick = function() {
if ( -1 === menu.className.indexOf( 'nav-menu' ) ) {
menu.className = 'nav-menu';
}
// 切换菜单展开/收起状态
if ( -1 !== button.className.indexOf( 'toggled-on' ) ) {
button.className = button.className.replace( ' toggled-on', '' );
menu.className = menu.className.replace( ' toggled-on', '' );
@ -36,14 +35,12 @@
};
} )();
// 为隐藏的子菜单项提供更好的焦点支持以提高可访问性
// Better focus for hidden submenu items for accessibility.
( function( $ ) {
// 为导航菜单中的链接添加焦点和失焦事件
$( '.main-navigation' ).find( 'a' ).on( 'focus.twentytwelve blur.twentytwelve', function() {
$( this ).parents( '.menu-item, .page_item' ).toggleClass( 'focus' );
} );
// 移动端触摸事件处理
if ( 'ontouchstart' in window ) {
$('body').on( 'touchstart.twentytwelve', '.menu-item-has-children > a, .page_item_has_children > a', function( e ) {
var el = $( this ).parent( 'li' );
@ -55,4 +52,4 @@
}
} );
}
} )( jQuery );
} )( jQuery );

@ -1,352 +1,293 @@
/* 代码高亮样式 */
.codehilite .hll {
background-color: #ffffcc
}
/* 代码块背景 */
.codehilite {
background: #ffffff;
}
/* 注释 */
.codehilite .c {
color: #177500
}
/* 注释 */
/* Error */
/* Comment */
.codehilite .err {
color: #000000
}
/* 错误 */
/* Keyword */
/* Error */
.codehilite .k {
color: #A90D91
}
/* 关键字 */
/* Literal */
/* Keyword */
.codehilite .l {
color: #1C01CE
}
/* 字面量 */
/* Name */
/* Literal */
.codehilite .n {
color: #000000
}
/* 名称 */
/* Operator */
/* Name */
.codehilite .o {
color: #000000
}
/* 操作符 */
/* Comment.Hashbang */
/* Operator */
.codehilite .ch {
color: #177500
}
/* Hashbang注释 */
/* Comment.Multiline */
/* Comment.Hashbang */
.codehilite .cm {
color: #177500
}
/* 多行注释 */
/* Comment.Preproc */
/* Comment.Multiline */
.codehilite .cp {
color: #633820
}
/* 预处理注释 */
/* Comment.PreprocFile */
/* Comment.Preproc */
.codehilite .cpf {
color: #177500
}
/* 文件预处理注释 */
/* Comment.Single */
/* Comment.PreprocFile */
.codehilite .c1 {
color: #177500
}
/* 单行注释 */
/* Comment.Special */
/* Comment.Single */
.codehilite .cs {
color: #177500
}
/* 特殊注释 */
/* Keyword.Constant */
/* Comment.Special */
.codehilite .kc {
color: #A90D91
}
/* 常量关键字 */
/* Keyword.Declaration */
/* Keyword.Constant */
.codehilite .kd {
color: #A90D91
}
/* 声明关键字 */
/* Keyword.Namespace */
/* Keyword.Declaration */
.codehilite .kn {
color: #A90D91
}
/* 命名空间关键字 */
/* Keyword.Pseudo */
/* Keyword.Namespace */
.codehilite .kp {
color: #A90D91
}
/* 伪关键字 */
/* Keyword.Reserved */
/* Keyword.Pseudo */
.codehilite .kr {
color: #A90D91
}
/* 保留关键字 */
/* Keyword.Type */
/* Keyword.Reserved */
.codehilite .kt {
color: #A90D91
}
/* 类型关键字 */
/* Literal.Date */
/* Keyword.Type */
.codehilite .ld {
color: #1C01CE
}
/* 日期字面量 */
/* Literal.Number */
/* Literal.Date */
.codehilite .m {
color: #1C01CE
}
/* 数字字面量 */
/* Literal.String */
/* Literal.Number */
.codehilite .s {
color: #C41A16
}
/* 字符串字面量 */
/* Name.Attribute */
/* Literal.String */
.codehilite .na {
color: #836C28
}
/* 属性名称 */
/* Name.Builtin */
/* Name.Attribute */
.codehilite .nb {
color: #A90D91
}
/* 内置名称 */
/* Name.Class */
/* Name.Builtin */
.codehilite .nc {
color: #3F6E75
}
/* 类名 */
/* Name.Constant */
/* Name.Class */
.codehilite .no {
color: #000000
}
/* 常量名称 */
/* Name.Decorator */
/* Name.Constant */
.codehilite .nd {
color: #000000
}
/* 装饰器名称 */
/* Name.Entity */
/* Name.Decorator */
.codehilite .ni {
color: #000000
}
/* 实体名称 */
/* Name.Exception */
/* Name.Entity */
.codehilite .ne {
color: #000000
}
/* 异常名称 */
/* Name.Function */
/* Name.Exception */
.codehilite .nf {
color: #000000
}
/* 函数名称 */
/* Name.Label */
/* Name.Function */
.codehilite .nl {
color: #000000
}
/* 标签名称 */
/* Name.Namespace */
/* Name.Label */
.codehilite .nn {
color: #000000
}
/* 命名空间名称 */
/* Name.Other */
/* Name.Namespace */
.codehilite .nx {
color: #000000
}
/* 其他名称 */
/* Name.Property */
/* Name.Other */
.codehilite .py {
color: #000000
}
/* 属性名称 */
/* Name.Tag */
/* Name.Property */
.codehilite .nt {
color: #000000
}
/* 标签名称 */
/* Name.Variable */
/* Name.Tag */
.codehilite .nv {
color: #000000
}
/* 变量名称 */
/* Operator.Word */
/* Name.Variable */
.codehilite .ow {
color: #000000
}
/* 操作符单词 */
/* Literal.Number.Bin */
/* Operator.Word */
.codehilite .mb {
color: #1C01CE
}
/* 二进制数字 */
/* Literal.Number.Float */
/* Literal.Number.Bin */
.codehilite .mf {
color: #1C01CE
}
/* 浮点数 */
/* Literal.Number.Hex */
/* Literal.Number.Float */
.codehilite .mh {
color: #1C01CE
}
/* 十六进制数 */
/* Literal.Number.Integer */
/* Literal.Number.Hex */
.codehilite .mi {
color: #1C01CE
}
/* 整数 */
/* Literal.Number.Oct */
/* Literal.Number.Integer */
.codehilite .mo {
color: #1C01CE
}
/* 八进制数 */
/* Literal.String.Backtick */
/* Literal.Number.Oct */
.codehilite .sb {
color: #C41A16
}
/* 反引号字符串 */
/* Literal.String.Char */
/* Literal.String.Backtick */
.codehilite .sc {
color: #2300CE
}
/* 字符 */
/* Literal.String.Doc */
/* Literal.String.Char */
.codehilite .sd {
color: #C41A16
}
/* 文档字符串 */
/* Literal.String.Double */
/* Literal.String.Doc */
.codehilite .s2 {
color: #C41A16
}
/* 双引号字符串 */
/* Literal.String.Escape */
/* Literal.String.Double */
.codehilite .se {
color: #C41A16
}
/* 转义字符串 */
/* Literal.String.Heredoc */
/* Literal.String.Escape */
.codehilite .sh {
color: #C41A16
}
/* Heredoc字符串 */
/* Literal.String.Interpol */
/* Literal.String.Heredoc */
.codehilite .si {
color: #C41A16
}
/* 插值字符串 */
/* Literal.String.Other */
/* Literal.String.Interpol */
.codehilite .sx {
color: #C41A16
}
/* 其他字符串 */
/* Literal.String.Regex */
/* Literal.String.Other */
.codehilite .sr {
color: #C41A16
}
/* 正则表达式 */
/* Literal.String.Single */
/* Literal.String.Regex */
.codehilite .s1 {
color: #C41A16
}
/* 单引号字符串 */
/* Literal.String.Symbol */
/* Literal.String.Single */
.codehilite .ss {
color: #C41A16
}
/* 符号 */
/* Name.Builtin.Pseudo */
/* Literal.String.Symbol */
.codehilite .bp {
color: #5B269A
}
/* 伪内置名称 */
/* Name.Variable.Class */
/* Name.Builtin.Pseudo */
.codehilite .vc {
color: #000000
}
/* 类变量 */
/* Name.Variable.Global */
/* Name.Variable.Class */
.codehilite .vg {
color: #000000
}
/* 全局变量 */
/* Name.Variable.Instance */
/* Name.Variable.Global */
.codehilite .vi {
color: #000000
}
/* 实例变量 */
/* Literal.Number.Integer.Long */
/* Name.Variable.Instance */
.codehilite .il {
color: #1C01CE
}
/* 长整数 */
/* Literal.Number.Integer.Long */

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

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

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

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

Loading…
Cancel
Save