Compare commits

...

11 Commits

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

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

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

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

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

@ -0,0 +1,421 @@
# 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%
- 支持移动端访问
- 提供多语言支持

Binary file not shown.

@ -146,7 +146,7 @@ python manage.py runserver
## 🙏 鸣谢
特别感谢 **JetBrains** 为本项目提供的免费开源许可证。gs ls syj zyd164
特别感谢 **JetBrains** 为本项目提供的免费开源许可证。gs130 ls syj zyd164
<p align="center">
<a href="https://www.jetbrains.com/?from=DjangoBlog">

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

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

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

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

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

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

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

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

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

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

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

@ -10,29 +10,45 @@ from .models import Article
class ArticleForm(forms.ModelForm):
"""
文章表单类用于在管理界面编辑文章
"""
# body = forms.CharField(widget=AdminPagedownWidget())
class Meta:
model = Article
fields = '__all__'
fields = '__all__' # 包含所有字段
def makr_article_publish(modeladmin, request, queryset):
queryset.update(status='p')
"""
批量发布文章操作
"""
queryset.update(status='p') # 将选中的文章状态设置为已发布
def draft_article(modeladmin, request, queryset):
queryset.update(status='d')
"""
批量将文章设为草稿操作
"""
queryset.update(status='d') # 将选中的文章状态设置为草稿
def close_article_commentstatus(modeladmin, request, queryset):
queryset.update(comment_status='c')
"""
批量关闭文章评论功能
"""
queryset.update(comment_status='c') # 将选中的文章评论状态设置为关闭
def open_article_commentstatus(modeladmin, request, queryset):
queryset.update(comment_status='o')
"""
批量开启文章评论功能
"""
queryset.update(comment_status='o') # 将选中的文章评论状态设置为开启
# 为批量操作设置显示名称
makr_article_publish.short_description = _('Publish selected articles')
draft_article.short_description = _('Draft selected articles')
close_article_commentstatus.short_description = _('Close article comments')
@ -40,10 +56,13 @@ open_article_commentstatus.short_description = _('Open article comments')
class ArticlelAdmin(admin.ModelAdmin):
list_per_page = 20
search_fields = ('body', 'title')
form = ArticleForm
list_display = (
"""
文章模型在管理界面的配置类
"""
list_per_page = 20 # 每页显示20条记录
search_fields = ('body', 'title') # 设置搜索字段
form = ArticleForm # 使用自定义表单
list_display = ( # 列表页显示的字段
'id',
'title',
'author',
@ -53,34 +72,46 @@ 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') # 设置过滤器
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]
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
@ -91,22 +122,38 @@ class ArticlelAdmin(admin.ModelAdmin):
class TagAdmin(admin.ModelAdmin):
exclude = ('slug', 'last_mod_time', 'creation_time')
"""
标签模型在管理界面的配置类
"""
exclude = ('slug', 'last_mod_time', 'creation_time') # 排除字段
class CategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'parent_category', 'index')
exclude = ('slug', 'last_mod_time', 'creation_time')
"""
分类模型在管理界面的配置类
"""
list_display = ('name', 'parent_category', 'index') # 列表页显示字段
exclude = ('slug', 'last_mod_time', 'creation_time') # 排除字段
class LinksAdmin(admin.ModelAdmin):
exclude = ('last_mod_time', 'creation_time')
"""
友情链接模型在管理界面的配置类
"""
exclude = ('last_mod_time', 'creation_time') # 排除字段
class SideBarAdmin(admin.ModelAdmin):
list_display = ('name', 'content', 'is_enable', 'sequence')
exclude = ('last_mod_time', 'creation_time')
"""
侧边栏模型在管理界面的配置类
"""
list_display = ('name', 'content', 'is_enable', 'sequence') # 列表页显示字段
exclude = ('last_mod_time', 'creation_time') # 排除字段
class BlogSettingsAdmin(admin.ModelAdmin):
"""
博客设置模型在管理界面的配置类
"""
pass

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

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

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

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

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

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

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

@ -6,35 +6,61 @@ from blog.models import Article, Tag, Category
class Command(BaseCommand):
help = 'create test datas'
"""
Django管理命令用于创建测试数据
"""
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]
email='test@test.com',
username='测试用户',
password=make_password('test!q@w#eTYU'))[0]
# 创建父分类
pcategory = Category.objects.get_or_create(
name='我是父类目', parent_category=None)[0]
name='我是父类目',
parent_category=None)[0]
# 创建子分类
category = Category.objects.get_or_create(
name='子类目', parent_category=pcategory)[0]
name='子类目',
parent_category=pcategory)[0]
category.save()
# 创建基础标签
basetag = Tag()
basetag.name = "标签"
basetag.save()
# 创建20篇文章及其对应的标签
for i in range(1, 20):
# 创建文章
article = Article.objects.get_or_create(
category=category,
title='nice title ' + str(i),
body='nice content ' + str(i),
author=user)[0]
# 为每篇文章创建专属标签
tag = Tag()
tag.name = "标签" + str(i)
tag.save()
# 将标签关联到文章
article.tags.add(tag)
article.tags.add(basetag)
article.save()
# 清除缓存
from djangoblog.utils import cache
cache.clear()
# 输出成功信息
self.stdout.write(self.style.SUCCESS('created test datas \n'))

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

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

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

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

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

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

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

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

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

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

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

@ -20,18 +20,38 @@ from djangoblog.utils import get_current_site
from oauth.models import OAuthUser
from djangoblog.plugin_manage import hooks
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__) # 创建日志记录器
register = template.Library()
register = template.Library() # 创建模板标签注册器
@register.simple_tag(takes_context=True)
def head_meta(context):
"""
<<<<<<< HEAD
头部元数据标签
通过插件系统应用过滤器生成头部元数据
:param context: 模板上下文
:return: 头部元数据HTML
=======
生成页面头部meta信息
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
return mark_safe(hooks.apply_filters('head_meta', '', context))
@register.simple_tag
def timeformat(data):
"""
<<<<<<< HEAD
时间格式化标签
将时间数据格式化为设置中指定的时间格式
=======
格式化时间显示
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
:param data: 时间数据
:return: 格式化后的时间字符串
"""
try:
return data.strftime(settings.TIME_FORMAT)
except Exception as e:
@ -41,6 +61,17 @@ def timeformat(data):
@register.simple_tag
def datetimeformat(data):
"""
<<<<<<< HEAD
日期时间格式化标签
将时间数据格式化为设置中指定的日期时间格式
:param data: 时间数据
=======
格式化日期时间显示
:param data: 日期时间数据
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
:return: 格式化后的日期时间字符串
"""
try:
return data.strftime(settings.DATE_TIME_FORMAT)
except Exception as e:
@ -51,11 +82,34 @@ def datetimeformat(data):
@register.filter()
@stringfilter
def custom_markdown(content):
"""
<<<<<<< HEAD
自定义Markdown转换过滤器
将Markdown格式的内容转换为HTML
:param content: Markdown格式的内容
:return: 转换后的HTML内容
=======
将内容转换为markdown格式
:param content: 原始内容
:return: markdown格式的内容
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
return mark_safe(CommonMarkdown.get_markdown(content))
@register.simple_tag
def get_markdown_toc(content):
"""
<<<<<<< HEAD
获取Markdown目录标签
从Markdown内容中提取目录结构
:param content: Markdown格式的内容
=======
获取markdown内容的目录
:param content: markdown内容
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
:return: 目录HTML
"""
from djangoblog.utils import CommonMarkdown
body, toc = CommonMarkdown.get_markdown_with_toc(content)
return mark_safe(toc)
@ -64,6 +118,18 @@ def get_markdown_toc(content):
@register.filter()
@stringfilter
def comment_markdown(content):
"""
<<<<<<< HEAD
评论Markdown转换过滤器
将Markdown格式的评论内容转换为HTML并清理不安全标签
:param content: Markdown格式的评论内容
:return: 转换并清理后的HTML内容
=======
将评论内容转换为markdown格式并清理HTML
:param content: 评论内容
:return: 清理后的markdown格式内容
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
content = CommonMarkdown.get_markdown(content)
return mark_safe(sanitize_html(content))
@ -73,8 +139,14 @@ def comment_markdown(content):
def truncatechars_content(content):
"""
获得文章内容的摘要
:param content:
:return:
<<<<<<< HEAD
根据博客设置中的文章摘要长度截取内容
:param content: 原始内容
:return: 截取后的内容
=======
:param content: 文章内容
:return: 截取后的摘要内容
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
from django.template.defaultfilters import truncatechars_html
from djangoblog.utils import get_blog_setting
@ -85,17 +157,33 @@ def truncatechars_content(content):
@register.filter(is_safe=True)
@stringfilter
def truncate(content):
"""
<<<<<<< HEAD
内容截取过滤器
移除HTML标签并截取前150个字符
:param content: 原始内容
:return: 截取后的内容
=======
截取内容前150个字符去除HTML标签
:param content: 原始内容
:return: 截取后的纯文本内容
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
from django.utils.html import strip_tags
return strip_tags(content)[:150]
@register.inclusion_tag('blog/tags/breadcrumb.html')
def load_breadcrumb(article):
"""
获得文章面包屑
:param article:
:return:
<<<<<<< HEAD
加载面包屑导航标签
生成文章的面包屑导航信息
=======
获得文章面包屑导航
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
:param article: 文章对象
:return: 面包屑导航数据
"""
names = article.get_category_tree()
from djangoblog.utils import get_blog_setting
@ -114,9 +202,16 @@ def load_breadcrumb(article):
@register.inclusion_tag('blog/tags/article_tag_list.html')
def load_articletags(article):
"""
文章标签
:param article:
:return:
<<<<<<< HEAD
加载文章标签列表标签
生成文章标签的显示数据
:param article: 文章对象
:return: 文章标签列表数据
=======
加载文章标签列表
:param article: 文章对象
:return: 标签列表数据
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
tags = article.tags.all()
tags_list = []
@ -134,8 +229,18 @@ def load_articletags(article):
@register.inclusion_tag('blog/tags/sidebar.html')
def load_sidebar(user, linktype):
"""
<<<<<<< HEAD
加载侧边栏标签
生成侧边栏显示数据包括文章分类标签等信息
使用缓存提高性能
:param user: 当前用户
:param linktype: 链接类型
=======
加载侧边栏
:return:
:param user: 当前用户
:param linktype: 链接显示类型
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
:return: 侧边栏数据
"""
value = cache.get("sidebar" + linktype)
if value:
@ -145,28 +250,38 @@ def load_sidebar(user, linktype):
logger.info('load sidebar')
from djangoblog.utils import get_blog_setting
blogsetting = get_blog_setting()
# 获取最新文章
recent_articles = Article.objects.filter(
status='p')[:blogsetting.sidebar_article_count]
# 获取所有分类
sidebar_categorys = Category.objects.all()
# 获取启用的额外侧边栏
extra_sidebars = SideBar.objects.filter(
is_enable=True).order_by('sequence')
# 获取阅读量最高的文章
most_read_articles = Article.objects.filter(status='p').order_by(
'-views')[:blogsetting.sidebar_article_count]
# 获取文章发布日期
dates = Article.objects.datetimes('creation_time', 'month', order='DESC')
# 获取友情链接
links = Links.objects.filter(is_enable=True).filter(
Q(show_type=str(linktype)) | Q(show_type=LinkShowType.A))
# 获取最新评论
commment_list = Comment.objects.filter(is_enable=True).order_by(
'-id')[:blogsetting.sidebar_comment_count]
# 标签云 计算字体大小
# 根据总数计算出平均值 大小为 (数目/平均值)*步长
increment = 5
tags = Tag.objects.all()
sidebar_tags = None
if tags and len(tags) > 0:
# 获取每个标签的文章数量
s = [t for t in [(t, t.get_article_count()) for t in tags] if t[1]]
count = sum([t[1] for t in s])
dd = 1 if (count == 0 or not len(tags)) else count / len(tags)
import random
# 计算标签字体大小
sidebar_tags = list(
map(lambda x: (x[0], x[1], (x[1] / dd) * increment + 10), s))
random.shuffle(sidebar_tags)
@ -185,6 +300,7 @@ def load_sidebar(user, linktype):
'sidebar_tags': sidebar_tags,
'extra_sidebars': extra_sidebars
}
# 缓存侧边栏数据3小时
cache.set("sidebar" + linktype, value, 60 * 60 * 60 * 3)
logger.info('set sidebar cache.key:{key}'.format(key="sidebar" + linktype))
value['user'] = user
@ -194,9 +310,18 @@ def load_sidebar(user, linktype):
@register.inclusion_tag('blog/tags/article_meta_info.html')
def load_article_metas(article, user):
"""
<<<<<<< HEAD
加载文章元信息标签
生成文章元信息显示数据
:param article: 文章对象
:param user: 当前用户
:return: 文章元信息数据
=======
获得文章meta信息
:param article:
:return:
:param article: 文章对象
:param user: 当前用户
:return: 文章meta信息数据
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
return {
'article': article,
@ -206,8 +331,26 @@ 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
"""
previous_url = ''
next_url = ''
# 首页分页
if page_type == '':
if page_obj.has_next():
next_number = page_obj.next_page_number()
@ -217,6 +360,8 @@ 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():
@ -233,6 +378,8 @@ 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()
@ -249,6 +396,7 @@ 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():
@ -276,10 +424,16 @@ 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):
"""
<<<<<<< HEAD
加载文章详情标签
生成文章详情显示数据
=======
加载文章详情
:param article:
:param isindex:是否列表页若是列表页只显示摘要
:return:
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
:param article: 文章对象
:param isindex: 是否为列表页
:param user: 当前用户
:return: 文章详情数据
"""
from djangoblog.utils import get_blog_setting
blogsetting = get_blog_setting()
@ -296,12 +450,18 @@ def load_article_detail(article, isindex, user):
# TEMPLATE USE: {{ email|gravatar_url:150 }}
@register.filter
def gravatar_url(email, size=40):
"""获得gravatar头像"""
"""
获得gravatar头像URL
:param email: 邮箱地址
:param size: 头像大小
:return: gravatar头像URL
"""
cachekey = 'gravatat/' + 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))
@ -311,8 +471,10 @@ def gravatar_url(email, size=40):
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
@ -320,25 +482,44 @@ def gravatar_url(email, size=40):
@register.filter
def gravatar(email, size=40):
"""获得gravatar头像"""
"""
获得gravatar头像HTML标签
:param email: 邮箱地址
:param size: 头像大小
:return: 头像img标签
"""
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">' % (url, size, size))
@register.simple_tag
def query(qs, **kwargs):
""" template tag which allows queryset filtering. Usage:
{% query books author=author as mybooks %}
{% for book in mybooks %}
...
{% endfor %}
"""
模板标签允许查询集过滤用法:
{% query books author=author as mybooks %}
{% for book in mybooks %}
...
{% endfor %}
:param qs: 查询集
:param kwargs: 过滤条件
:return: 过滤后的查询集
"""
return qs.filter(**kwargs)
@register.filter
def addstr(arg1, arg2):
<<<<<<< HEAD
"""concatenate arg1 & arg2"""
return str(arg1) + str(arg2)
=======
"""
连接两个字符串
:param arg1: 第一个字符串
:param arg2: 第二个字符串
:return: 连接后的字符串
"""
return str(arg1) + str(arg2)
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc

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

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

@ -21,10 +21,18 @@ from djangoblog.plugin_manage import hooks
from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
from djangoblog.utils import cache, get_blog_setting, get_sha256
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__) # 创建日志记录器
class ArticleListView(ListView):
"""
<<<<<<< HEAD
文章列表视图基类
继承自Django的ListView提供文章列表的通用功能
=======
文章列表视图基类提供分页和缓存功能
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
# template_name属性用于指定使用哪个模板进行渲染
template_name = 'blog/article_index.html'
@ -33,15 +41,30 @@ class ArticleListView(ListView):
# 页面类型,分类目录或标签列表等
page_type = ''
paginate_by = settings.PAGINATE_BY
page_kwarg = 'page'
link_type = LinkShowType.L
paginate_by = settings.PAGINATE_BY # 每页文章数量
page_kwarg = 'page' # 分页参数名
link_type = LinkShowType.L # 链接显示类型
def get_view_cache_key(self):
"""
<<<<<<< HEAD
获取视图缓存键
:return: 缓存键
=======
获取视图缓存key
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
return self.request.get['pages']
@property
def page_number(self):
"""
获取当前页码
<<<<<<< HEAD
:return: 页码
=======
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
page_kwarg = self.page_kwarg
page = self.kwargs.get(
page_kwarg) or self.request.GET.get(page_kwarg) or 1
@ -63,7 +86,7 @@ class ArticleListView(ListView):
'''
缓存页面数据
:param cache_key: 缓存key
:return:
:return: 查询结果集
'''
value = cache.get(cache_key)
if value:
@ -78,36 +101,68 @@ class ArticleListView(ListView):
def get_queryset(self):
'''
重写默认从缓存获取数据
:return:
:return: 查询结果集
'''
key = self.get_queryset_cache_key()
value = self.get_queryset_from_cache(key)
return value
def get_context_data(self, **kwargs):
"""
<<<<<<< HEAD
获取上下文数据
=======
添加额外的上下文数据
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
kwargs['linktype'] = self.link_type
return super(ArticleListView, self).get_context_data(**kwargs)
class IndexView(ArticleListView):
'''
首页
<<<<<<< HEAD
首页视图类
显示所有已发布文章的列表
=======
首页视图
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
'''
# 友情链接类型
link_type = LinkShowType.I
def get_queryset_data(self):
"""
获取首页文章数据
<<<<<<< HEAD
:return: 已发布文章查询集
=======
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
article_list = Article.objects.filter(type='a', status='p')
return article_list
def get_queryset_cache_key(self):
"""
<<<<<<< HEAD
获取首页缓存键
:return: 缓存键
=======
获取首页缓存key
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
cache_key = 'index_{page}'.format(page=self.page_number)
return cache_key
class ArticleDetailView(DetailView):
'''
文章详情页面
<<<<<<< HEAD
文章详情页面视图类
显示单篇文章的详细内容及相关信息
=======
文章详情页面视图
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
'''
template_name = 'blog/article_detail.html'
model = Article
@ -115,11 +170,22 @@ class ArticleDetailView(DetailView):
context_object_name = "article"
def get_context_data(self, **kwargs):
"""
<<<<<<< HEAD
获取文章详情页上下文数据
包括评论表单评论列表分页信息等
"""
comment_form = CommentForm()
=======
获取文章详情页的上下文数据
"""
comment_form = CommentForm() # 评论表单
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
article_comments = self.object.comment_list()
parent_comments = article_comments.filter(parent_comment=None)
blog_setting = get_blog_setting()
article_comments = self.object.comment_list() # 获取文章评论列表
parent_comments = article_comments.filter(parent_comment=None) # 获取父级评论
blog_setting = get_blog_setting() # 获取博客设置
# 对父级评论进行分页
paginator = Paginator(parent_comments, blog_setting.article_comment_count)
page = self.request.GET.get('comment_page', '1')
if not page.isnumeric():
@ -132,6 +198,7 @@ class ArticleDetailView(DetailView):
page = paginator.num_pages
p_comments = paginator.page(page)
# 计算评论分页的前后页链接
next_page = p_comments.next_page_number() if p_comments.has_next() else None
prev_page = p_comments.previous_page_number() if p_comments.has_previous() else None
@ -141,12 +208,15 @@ class ArticleDetailView(DetailView):
if prev_page:
kwargs[
'comment_prev_page_url'] = self.object.get_absolute_url() + f'?comment_page={prev_page}#commentlist-container'
# 添加评论相关数据到上下文
kwargs['form'] = comment_form
kwargs['article_comments'] = article_comments
kwargs['p_comments'] = p_comments
kwargs['comment_count'] = len(
article_comments) if article_comments else 0
# 添加上下篇文章信息
kwargs['next_article'] = self.object.next_article
kwargs['prev_article'] = self.object.prev_article
@ -154,7 +224,7 @@ class ArticleDetailView(DetailView):
article = self.object
# Action Hook, 通知插件"文章详情已获取"
hooks.run_action('after_article_body_get', article=article, request=self.request)
# # Filter Hook, 允许插件修改文章正文
# Filter Hook, 允许插件修改文章正文
article.body = hooks.apply_filters(ARTICLE_CONTENT_HOOK_NAME, article.body, article=article,
request=self.request)
@ -163,11 +233,24 @@ class ArticleDetailView(DetailView):
class CategoryDetailView(ArticleListView):
'''
分类目录列表
<<<<<<< HEAD
分类目录详情视图类
显示指定分类下的所有文章
=======
分类目录列表视图
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
'''
page_type = "分类目录归档"
def get_queryset_data(self):
"""
<<<<<<< HEAD
获取指定分类下的文章数据
:return: 文章查询集
=======
获取分类文章数据
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
slug = self.kwargs['category_name']
category = get_object_or_404(Category, slug=slug)
@ -180,6 +263,14 @@ class CategoryDetailView(ArticleListView):
return article_list
def get_queryset_cache_key(self):
"""
<<<<<<< HEAD
获取分类详情页缓存键
:return: 缓存键
=======
获取分类列表缓存key
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
"""
slug = self.kwargs['category_name']
category = get_object_or_404(Category, slug=slug)
categoryname = category.name
@ -189,7 +280,15 @@ class CategoryDetailView(ArticleListView):
return cache_key
def get_context_data(self, **kwargs):
"""
<<<<<<< HEAD
获取分类详情页上下文数据
"""
=======
添加分类相关上下文数据
"""
>>>>>>> ff8be05a63440d45c1cc36269ce152191eac8fdc
categoryname = self.categoryname
try:
categoryname = categoryname.split('/')[-1]
@ -202,11 +301,14 @@ class CategoryDetailView(ArticleListView):
class AuthorDetailView(ArticleListView):
'''
作者详情页
作者详情页视图
'''
page_type = '作者文章归档'
def get_queryset_cache_key(self):
"""
获取作者文章列表缓存key
"""
from uuslug import slugify
author_name = slugify(self.kwargs['author_name'])
cache_key = 'author_{author_name}_{page}'.format(
@ -214,12 +316,18 @@ class AuthorDetailView(ArticleListView):
return cache_key
def get_queryset_data(self):
"""
获取作者文章数据
"""
author_name = self.kwargs['author_name']
article_list = Article.objects.filter(
author__username=author_name, type='a', status='p')
return article_list
def get_context_data(self, **kwargs):
"""
添加作者相关上下文数据
"""
author_name = self.kwargs['author_name']
kwargs['page_type'] = AuthorDetailView.page_type
kwargs['tag_name'] = author_name
@ -228,11 +336,14 @@ class AuthorDetailView(ArticleListView):
class TagDetailView(ArticleListView):
'''
标签列表页面
标签列表页面视图
'''
page_type = '分类标签归档'
def get_queryset_data(self):
"""
获取标签文章数据
"""
slug = self.kwargs['tag_name']
tag = get_object_or_404(Tag, slug=slug)
tag_name = tag.name
@ -242,6 +353,9 @@ class TagDetailView(ArticleListView):
return article_list
def get_queryset_cache_key(self):
"""
获取标签列表缓存key
"""
slug = self.kwargs['tag_name']
tag = get_object_or_404(Tag, slug=slug)
tag_name = tag.name
@ -251,6 +365,9 @@ class TagDetailView(ArticleListView):
return cache_key
def get_context_data(self, **kwargs):
"""
添加标签相关上下文数据
"""
# tag_name = self.kwargs['tag_name']
tag_name = self.name
kwargs['page_type'] = TagDetailView.page_type
@ -260,31 +377,49 @@ class TagDetailView(ArticleListView):
class ArchivesView(ArticleListView):
'''
文章归档页面
文章归档页面视图
'''
page_type = '文章归档'
paginate_by = None
page_kwarg = None
paginate_by = None # 不分页
page_kwarg = None # 无分页参数
template_name = 'blog/article_archives.html'
def get_queryset_data(self):
"""
获取所有已发布文章
"""
return Article.objects.filter(status='p').all()
def get_queryset_cache_key(self):
"""
获取归档页面缓存key
"""
cache_key = 'archives'
return cache_key
class LinkListView(ListView):
"""
友情链接列表视图
"""
model = Links
template_name = 'blog/links_list.html'
def get_queryset(self):
"""
获取启用的友情链接
"""
return Links.objects.filter(is_enable=True)
class EsSearchView(SearchView):
"""
Elasticsearch搜索视图
"""
def get_context(self):
"""
获取搜索上下文数据
"""
paginator, page = self.build_page()
context = {
"query": self.query,
@ -303,31 +438,37 @@ class EsSearchView(SearchView):
@csrf_exempt
def fileupload(request):
"""
该方法需自己写调用端来上传图片该方法仅提供图床功能
:param request:
:return:
文件上传视图该方法需自己写调用端来上传图片该方法仅提供图床功能
:param request: HTTP请求对象
:return: 上传文件的URL列表
"""
if request.method == 'POST':
sign = request.GET.get('sign', None)
# 验证签名,确保是授权上传
if not sign:
return HttpResponseForbidden()
if not sign == get_sha256(get_sha256(settings.SECRET_KEY)):
return HttpResponseForbidden()
response = []
# 处理上传的每个文件
for filename in request.FILES:
timestr = timezone.now().strftime('%Y/%m/%d')
imgextensions = ['jpg', 'png', 'jpeg', 'bmp']
fname = u''.join(str(filename))
isimage = len([i for i in imgextensions if fname.find(i) >= 0]) > 0
# 根据文件类型确定保存目录
base_dir = os.path.join(settings.STATICFILES, "files" if not isimage else "image", timestr)
if not os.path.exists(base_dir):
os.makedirs(base_dir)
# 生成安全的保存路径
savepath = os.path.normpath(os.path.join(base_dir, f"{uuid.uuid4().hex}{os.path.splitext(filename)[-1]}"))
if not savepath.startswith(base_dir):
return HttpResponse("only for post")
# 保存文件
with open(savepath, 'wb+') as wfile:
for chunk in request.FILES[filename].chunks():
wfile.write(chunk)
# 如果是图片,进行压缩优化
if isimage:
from PIL import Image
image = Image.open(savepath)
@ -344,6 +485,9 @@ def page_not_found_view(
request,
exception,
template_name='blog/error_page.html'):
"""
404页面不存在视图
"""
if exception:
logger.error(exception)
url = request.get_full_path()
@ -355,6 +499,9 @@ def page_not_found_view(
def server_error_view(request, template_name='blog/error_page.html'):
"""
500服务器错误视图
"""
return render(request,
template_name,
{'message': _('Sorry, the server is busy, please click the home page to see other?'),
@ -366,6 +513,9 @@ def permission_denied_view(
request,
exception,
template_name='blog/error_page.html'):
"""
403权限拒绝视图
"""
if exception:
logger.error(exception)
return render(
@ -375,5 +525,9 @@ def permission_denied_view(
def clean_cache_view(request):
"""
清理缓存视图
"""
cache.clear()
return HttpResponse('ok')

@ -1,7 +1,13 @@
ARTICLE_DETAIL_LOAD = 'article_detail_load'
ARTICLE_CREATE = 'article_create'
ARTICLE_UPDATE = 'article_update'
ARTICLE_DELETE = 'article_delete'
"""
钩子常量定义文件
定义了系统中使用的各种钩子名称常量
"""
ARTICLE_CONTENT_HOOK_NAME = "the_content"
# 文章相关钩子
ARTICLE_DETAIL_LOAD = 'article_detail_load' # 文章详情加载
ARTICLE_CREATE = 'article_create' # 文章创建
ARTICLE_UPDATE = 'article_update' # 文章更新
ARTICLE_DELETE = 'article_delete' # 文章删除
# 内容过滤钩子
ARTICLE_CONTENT_HOOK_NAME = "the_content" # 文章内容过滤

@ -8,6 +8,8 @@ _hooks = {}
def register(hook_name: str, callback: callable):
"""
注册一个钩子回调
:param hook_name: 钩子名称
:param callback: 回调函数
"""
if hook_name not in _hooks:
_hooks[hook_name] = []
@ -19,6 +21,7 @@ def run_action(hook_name: str, *args, **kwargs):
"""
执行一个 Action Hook
它会按顺序执行所有注册到该钩子上的回调函数
:param hook_name: 钩子名称
"""
if hook_name in _hooks:
logger.debug(f"Running action hook '{hook_name}'")
@ -33,6 +36,9 @@ def apply_filters(hook_name: str, value, *args, **kwargs):
"""
执行一个 Filter Hook
它会把 value 依次传递给所有注册的回调函数进行处理
:param hook_name: 钩子名称
:param value: 要处理的值
:return: 处理后的值
"""
if hook_name in _hooks:
logger.debug(f"Applying filter hook '{hook_name}'")
@ -41,4 +47,4 @@ def apply_filters(hook_name: str, value, *args, **kwargs):
value = callback(value, *args, **kwargs)
except Exception as e:
logger.error(f"Error applying filter hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True)
return value
return value

@ -6,8 +6,12 @@ logger = logging.getLogger(__name__)
def load_plugins():
"""
Dynamically loads and initializes plugins from the 'plugins' directory.
This function is intended to be called when the Django app registry is ready.
动态加载并初始化'plugins'目录中的插件
此函数应在Django应用注册表准备就绪时调用
通过遍历ACTIVE_PLUGINS设置中的插件名称
检查插件目录和plugin.py文件是否存在
如果存在则尝试导入插件模块
"""
for plugin_name in settings.ACTIVE_PLUGINS:
plugin_path = os.path.join(settings.PLUGINS_DIR, plugin_name)
@ -16,4 +20,4 @@ def load_plugins():
__import__(f'plugins.{plugin_name}.plugin')
logger.info(f"Successfully loaded plugin: {plugin_name}")
except ImportError as e:
logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e)
logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e)

@ -17,6 +17,12 @@ from django.utils.translation import gettext_lazy as _
def env_to_bool(env, default):
"""
将环境变量转换为布尔值
:param env: 环境变量名
:param default: 默认值
:return: 布尔值
"""
str_val = os.environ.get(env)
return default if str_val is None else str_val == 'True'

@ -61,4 +61,4 @@ urlpatterns += i18n_patterns(
, prefix_default_language=False) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL,
document_root=settings.MEDIA_ROOT)
document_root=settings.MEDIA_ROOT)

@ -21,17 +21,32 @@ logger = logging.getLogger(__name__)
def get_max_articleid_commentid():
"""
获取最大的文章ID和评论ID
:return: (最大文章ID, 最大评论ID)
"""
from blog.models import Article
from comments.models import Comment
return (Article.objects.latest().pk, Comment.objects.latest().pk)
def get_sha256(str):
"""
计算字符串的SHA256哈希值
:param str: 输入字符串
:return: SHA256哈希值
"""
m = sha256(str.encode('utf-8'))
return m.hexdigest()
def cache_decorator(expiration=3 * 60):
"""
缓存装饰器
用于缓存函数的返回值提高性能
:param expiration: 缓存过期时间默认3分钟
:return: 装饰器函数
"""
def wrapper(func):
def news(*args, **kwargs):
try:
@ -94,13 +109,27 @@ def expire_view_cache(path, servername, serverport, key_prefix=None):
@cache_decorator()
def get_current_site():
"""
获取当前站点信息
使用缓存装饰器提高性能
:return: 当前站点对象
"""
site = Site.objects.get_current()
return site
class CommonMarkdown:
"""
Markdown处理工具类
提供Markdown到HTML的转换功能
"""
@staticmethod
def _convert_markdown(value):
"""
转换Markdown为HTML
:param value: Markdown格式的文本
:return: (HTML内容, 目录)
"""
md = markdown.Markdown(
extensions=[
'extra',
@ -115,16 +144,33 @@ class CommonMarkdown:
@staticmethod
def get_markdown_with_toc(value):
"""
获取带目录的Markdown HTML
:param value: Markdown格式的文本
:return: (HTML内容, 目录)
"""
body, toc = CommonMarkdown._convert_markdown(value)
return body, toc
@staticmethod
def get_markdown(value):
"""
获取Markdown HTML不带目录
:param value: Markdown格式的文本
:return: HTML内容
"""
body, toc = CommonMarkdown._convert_markdown(value)
return body
def send_email(emailto, title, content):
"""
发送邮件
通过信号机制发送邮件
:param emailto: 收件人列表
:param title: 邮件标题
:param content: 邮件内容
"""
from djangoblog.blog_signals import send_email_signal
send_email_signal.send(
send_email.__class__,
@ -134,11 +180,19 @@ def send_email(emailto, title, content):
def generate_code() -> str:
"""生成随机数验证码"""
"""
生成随机数验证码
:return: 6位随机数字验证码
"""
return ''.join(random.sample(string.digits, 6))
def parse_dict_to_url(dict):
"""
将字典转换为URL参数
:param dict: 字典对象
:return: URL参数字符串
"""
from urllib.parse import quote
url = '&'.join(['{}={}'.format(quote(k, safe='/'), quote(v, safe='/'))
for k, v in dict.items()])
@ -146,6 +200,11 @@ def parse_dict_to_url(dict):
def get_blog_setting():
"""
获取博客设置
使用缓存提高性能如果缓存不存在则从数据库获取
:return: 博客设置对象
"""
value = cache.get('get_blog_setting')
if value:
return value
@ -202,6 +261,10 @@ def save_user_avatar(url):
def delete_sidebar_cache():
"""
删除侧边栏缓存
清除所有侧边栏相关的缓存
"""
from blog.models import LinkShowType
keys = ["sidebar" + x for x in LinkShowType.values]
for k in keys:
@ -210,12 +273,21 @@ def delete_sidebar_cache():
def delete_view_cache(prefix, keys):
"""
删除模板片段缓存
:param prefix: 缓存前缀
:param keys: 缓存键列表
"""
from django.core.cache.utils import make_template_fragment_key
key = make_template_fragment_key(prefix, keys)
cache.delete(key)
def get_resource_url():
"""
获取资源URL
:return: 静态资源URL
"""
if settings.STATIC_URL:
return settings.STATIC_URL
else:
@ -229,4 +301,10 @@ ALLOWED_ATTRIBUTES = {'a': ['href', 'title'], 'abbr': ['title'], 'acronym': ['ti
def sanitize_html(html):
return bleach.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES)
"""
清理HTML内容
只保留允许的标签和属性防止XSS攻击
:param html: 原始HTML内容
:return: 清理后的HTML内容
"""
return bleach.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES)

@ -7,48 +7,88 @@ from django.utils.html import format_html
logger = logging.getLogger(__name__)
class OAuthUserAdmin(admin.ModelAdmin):
"""
OAuth用户模型在Django管理后台的配置类
"""
# 设置搜索字段,可以在管理后台通过昵称或邮箱搜索用户
search_fields = ('nickname', 'email')
# 每页显示的记录数
list_per_page = 20
# 在列表页显示的字段
list_display = (
'id',
'nickname',
'link_to_usermodel',
'show_user_image',
'type',
'email',
'id', # 用户ID
'nickname', # 用户昵称
'link_to_usermodel', # 关联的系统用户链接
'show_user_image', # 显示用户头像
'type', # OAuth类型如GitHub、微博等
'email', # 用户邮箱
)
# 可以点击进入详情页的字段
list_display_links = ('id', 'nickname')
# 列表过滤器,可以通过作者和类型进行筛选
list_filter = ('author', 'type',)
# 只读字段列表(初始为空)
readonly_fields = []
def get_readonly_fields(self, request, obj=None):
"""
获取只读字段列表
将所有字段都设置为只读禁止在管理后台修改OAuth用户信息
"""
# 将所有模型字段和多对多字段都设置为只读
return list(self.readonly_fields) + \
[field.name for field in obj._meta.fields] + \
[field.name for field in obj._meta.many_to_many]
def has_add_permission(self, request):
"""
控制是否允许添加新记录
返回False表示禁止在管理后台手动添加OAuth用户
"""
return False
def link_to_usermodel(self, obj):
"""
创建指向关联系统用户的链接
如果该OAuth用户关联了系统用户则显示一个链接到该系统用户详情页的链接
"""
if obj.author:
# 获取关联用户模型的app_label和model_name
info = (obj.author._meta.app_label, obj.author._meta.model_name)
# 构造管理后台编辑页面的URL
link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,))
# 返回格式化的HTML链接
return format_html(
u'<a href="%s">%s</a>' %
(link, obj.author.nickname if obj.author.nickname else obj.author.email))
def show_user_image(self, obj):
"""
显示用户头像
从picture字段获取图片URL并显示为50x50像素的缩略图
"""
img = obj.picture
# 返回格式化的HTML图片标签
return format_html(
u'<img src="%s" style="width:50px;height:50px"></img>' %
(img))
link_to_usermodel.short_description = '用户'
show_user_image.short_description = '用户头像'
# 设置自定义方法在列表中的显示名称
link_to_usermodel.short_description = '用户' # 关联用户列的显示名称
show_user_image.short_description = '用户头像' # 用户头像列的显示名称
class OAuthConfigAdmin(admin.ModelAdmin):
list_display = ('type', 'appkey', 'appsecret', 'is_enable')
"""
OAuth配置模型在Django管理后台的配置类
"""
# 在列表页显示的字段
list_display = (
'type', # OAuth类型
'appkey', # App Key
'appsecret', # App Secret
'is_enable' # 是否启用
)
# 列表过滤器,可以通过类型进行筛选
list_filter = ('type',)

@ -1,5 +1,30 @@
"""
OAuth应用配置
该模块定义了OAuth应用的配置类用于Django应用的初始化和配置
"""
from django.apps import AppConfig
class OauthConfig(AppConfig):
"""
OAuth应用的配置类
该类继承自Django的AppConfig用于配置OAuth应用的基本信息
当Django启动时会使用这个配置类来初始化OAuth应用
Attributes:
name (str): 应用名称必须与应用的目录名一致
verbose_name (str): 应用的显示名称可选
default_auto_field (str): 默认主键字段类型可选
"""
# 应用名称Django会根据这个名称来识别和加载相应的应用
# 必须与应用的目录名oauth一致
name = 'oauth'
# 应用的显示名称在Django管理后台中显示
verbose_name = 'OAuth第三方登录'
# 指定默认的主键字段类型Django 3.2+推荐使用
default_auto_field = 'django.db.models.BigAutoField'

@ -1,12 +1,55 @@
"""
OAuth模块表单定义
该模块包含OAuth登录过程中使用的表单类主要用于处理第三方登录时的用户信息收集
"""
from django.contrib.auth.forms import forms
from django.forms import widgets
class RequireEmailForm(forms.Form):
email = forms.EmailField(label='电子邮箱', required=True)
oauthid = forms.IntegerField(widget=forms.HiddenInput, required=False)
"""
要求用户提供邮箱的表单类
该表单用于在OAuth登录过程中要求用户输入电子邮箱地址
通常在第三方登录无法获取用户邮箱时使用例如某些OAuth提供商
可能不会在授权时返回用户的邮箱信息此时需要用户手动输入邮箱
来完成账号绑定
Attributes:
email (EmailField): 用户邮箱地址必填字段
oauthid (IntegerField): OAuth用户ID隐藏字段用于关联OAuth用户记录
"""
# 邮箱字段,设置为必填项,用于用户输入电子邮箱地址
email = forms.EmailField(
label='电子邮箱',
required=True,
help_text='请输入您的电子邮箱地址以完成账号绑定'
)
# OAuth ID字段用于存储第三方平台的用户ID隐藏字段非必填
oauthid = forms.IntegerField(
widget=forms.HiddenInput,
required=False,
help_text='OAuth用户ID由系统自动填充'
)
def __init__(self, *args, **kwargs):
"""
初始化表单设置邮箱输入框的样式和属性
Args:
*args: 位置参数
**kwargs: 关键字参数
"""
super(RequireEmailForm, self).__init__(*args, **kwargs)
# 自定义邮箱输入框的widget属性设置占位符和CSS样式类
self.fields['email'].widget = widgets.EmailInput(
attrs={'placeholder': "email", "class": "form-control"})
attrs={
'placeholder': "请输入您的邮箱地址",
"class": "form-control",
"autocomplete": "email"
}
)

@ -5,53 +5,76 @@ from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
# 标记这是一个初始迁移文件
initial = True
# 定义此迁移依赖的其他迁移
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
# 定义要执行的操作
operations = [
# 创建 OAuthConfig 模型
migrations.CreateModel(
name='OAuthConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# OAuth类型选择包含微博、谷歌、GitHub、Facebook和QQ
('type', models.CharField(choices=[('weibo', '微博'), ('google', '谷歌'), ('github', 'GitHub'), ('facebook', 'FaceBook'), ('qq', 'QQ')], default='a', max_length=10, verbose_name='类型')),
# AppKey字段
('appkey', models.CharField(max_length=200, verbose_name='AppKey')),
# AppSecret字段
('appsecret', models.CharField(max_length=200, verbose_name='AppSecret')),
# 回调URL默认为百度首页
('callback_url', models.CharField(default='http://www.baidu.com', max_length=200, verbose_name='回调地址')),
# 是否启用该配置
('is_enable', models.BooleanField(default=True, verbose_name='是否显示')),
# 创建时间,默认为当前时间
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
# 最后修改时间,默认为当前时间
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
],
# 模型选项设置
options={
'verbose_name': 'oauth配置',
'verbose_name_plural': 'oauth配置',
'ordering': ['-created_time'],
'ordering': ['-created_time'], # 按创建时间倒序排列
},
),
# 创建 OAuthUser 模型
migrations.CreateModel(
name='OAuthUser',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 用户在第三方平台的唯一标识
('openid', models.CharField(max_length=50)),
# 用户昵称
('nickname', models.CharField(max_length=50, verbose_name='昵称')),
# 访问令牌
('token', models.CharField(blank=True, max_length=150, null=True)),
# 用户头像URL
('picture', models.CharField(blank=True, max_length=350, null=True)),
# OAuth类型如weibo, github等
('type', models.CharField(max_length=50)),
# 用户邮箱
('email', models.CharField(blank=True, max_length=50, null=True)),
# 其他元数据信息
('metadata', models.TextField(blank=True, null=True)),
# 创建时间,默认为当前时间
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
# 最后修改时间,默认为当前时间
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
# 关联到系统用户,允许为空
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='用户')),
],
# 模型选项设置
options={
'verbose_name': 'oauth用户',
'verbose_name_plural': 'oauth用户',
'ordering': ['-created_time'],
'ordering': ['-created_time'], # 按创建时间倒序排列
},
),
]

@ -5,79 +5,124 @@ from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
# 定义此迁移依赖的其他迁移文件
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('oauth', '0001_initial'),
('oauth', '0001_initial'), # 依赖于oauth应用的初始迁移
]
operations = [
# 修改 OAuthConfig 模型选项
migrations.AlterModelOptions(
name='oauthconfig',
options={'ordering': ['-creation_time'], 'verbose_name': 'oauth配置', 'verbose_name_plural': 'oauth配置'},
options={
'ordering': ['-creation_time'], # 按创建时间倒序排列
'verbose_name': 'oauth配置', # 单数名称
'verbose_name_plural': 'oauth配置' # 复数名称
},
),
# 修改 OAuthUser 模型选项
migrations.AlterModelOptions(
name='oauthuser',
options={'ordering': ['-creation_time'], 'verbose_name': 'oauth user', 'verbose_name_plural': 'oauth user'},
options={
'ordering': ['-creation_time'], # 按创建时间倒序排列
'verbose_name': 'oauth user', # 单数名称
'verbose_name_plural': 'oauth user' # 复数名称
},
),
# 移除 OAuthConfig 模型中的旧时间字段
migrations.RemoveField(
model_name='oauthconfig',
name='created_time',
name='created_time', # 删除created_time字段
),
migrations.RemoveField(
model_name='oauthconfig',
name='last_mod_time',
name='last_mod_time', # 删除last_mod_time字段
),
# 移除 OAuthUser 模型中的旧时间字段
migrations.RemoveField(
model_name='oauthuser',
name='created_time',
name='created_time', # 删除created_time字段
),
migrations.RemoveField(
model_name='oauthuser',
name='last_mod_time',
name='last_mod_time', # 删除last_mod_time字段
),
# 为 OAuthConfig 模型添加新的时间字段
migrations.AddField(
model_name='oauthconfig',
name='creation_time',
name='creation_time', # 添加creation_time字段
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
migrations.AddField(
model_name='oauthconfig',
name='last_modify_time',
name='last_modify_time', # 添加last_modify_time字段
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
),
# 为 OAuthUser 模型添加新的时间字段
migrations.AddField(
model_name='oauthuser',
name='creation_time',
name='creation_time', # 添加creation_time字段
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
migrations.AddField(
model_name='oauthuser',
name='last_modify_time',
name='last_modify_time', # 添加last_modify_time字段
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
),
# 修改 OAuthConfig 模型的callback_url字段
migrations.AlterField(
model_name='oauthconfig',
name='callback_url',
field=models.CharField(default='', max_length=200, verbose_name='callback url'),
),
# 修改 OAuthConfig 模型的is_enable字段仅更改显示名称
migrations.AlterField(
model_name='oauthconfig',
name='is_enable',
field=models.BooleanField(default=True, verbose_name='is enable'),
),
# 修改 OAuthConfig 模型的type字段选项中文标签改为英文
migrations.AlterField(
model_name='oauthconfig',
name='type',
field=models.CharField(choices=[('weibo', 'weibo'), ('google', 'google'), ('github', 'GitHub'), ('facebook', 'FaceBook'), ('qq', 'QQ')], default='a', max_length=10, verbose_name='type'),
field=models.CharField(
choices=[
('weibo', 'weibo'),
('google', 'google'),
('github', 'GitHub'),
('facebook', 'FaceBook'),
('qq', 'QQ')
],
default='a',
max_length=10,
verbose_name='type'
),
),
# 修改 OAuthUser 模型的author字段显示名称
migrations.AlterField(
model_name='oauthuser',
name='author',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'),
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
verbose_name='author'
),
),
# 修改 OAuthUser 模型的nickname字段显示名称中文改为英文
migrations.AlterField(
model_name='oauthuser',
name='nickname',

@ -2,17 +2,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
# 定义此迁移依赖的前一个迁移文件
dependencies = [
('oauth', '0002_alter_oauthconfig_options_alter_oauthuser_options_and_more'),
]
# 定义要执行的迁移操作
operations = [
# 修改 OAuthUser 模型中的 nickname 字段
migrations.AlterField(
model_name='oauthuser',
name='nickname',
field=models.CharField(max_length=50, verbose_name='nick name'),
model_name='oauthuser', # 目标模型为 OAuthUser
name='nickname', # 字段名为 nickname
field=models.CharField(
max_length=50, # 最大长度仍为50个字符
verbose_name='nick name' # 将字段的显示名称从 "nickname" 改为 "nick name"
),
),
]

@ -7,6 +7,27 @@ from django.utils.translation import gettext_lazy as _
class OAuthUser(models.Model):
"""
OAuth用户模型
用于存储通过第三方平台登录的用户信息
Attributes:
author (ForeignKey): 关联的本地用户可为空
openid (CharField): 第三方平台的用户唯一标识最大长度50
nickname (CharField): 用户在第三方平台的昵称最大长度50
token (CharField): OAuth认证令牌最大长度150可为空
picture (CharField): 用户头像URL最大长度350可为空
type (CharField): 第三方平台类型如weibogoogle等最大长度50
email (CharField): 用户邮箱最大长度50可为空
metadata (TextField): 其他元数据信息可为空
creation_time (DateTimeField): 记录创建时间默认为当前时间
last_modify_time (DateTimeField): 记录最后修改时间默认为当前时间
Meta:
verbose_name: OAuth用户的可读性名称
verbose_name_plural: OAuth用户的复数形式名称
ordering: 按照创建时间倒序排列
"""
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
@ -24,6 +45,12 @@ class OAuthUser(models.Model):
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
def __str__(self):
"""
模型的字符串表示
Returns:
str: 用户昵称
"""
return self.nickname
class Meta:
@ -33,6 +60,25 @@ class OAuthUser(models.Model):
class OAuthConfig(models.Model):
"""
OAuth配置模型
用于存储第三方OAuth登录的配置信息
Attributes:
TYPE (tuple): 支持的OAuth平台类型选项
type (CharField): OAuth平台类型从TYPE中选择
appkey (CharField): 第三方平台分配的应用Key最大长度200
appsecret (CharField): 第三方平台分配的应用密钥最大长度200
callback_url (CharField): OAuth回调URL最大长度200
is_enable (BooleanField): 是否启用该OAuth配置
creation_time (DateTimeField): 配置创建时间默认为当前时间
last_modify_time (DateTimeField): 配置最后修改时间默认为当前时间
Meta:
verbose_name: OAuth配置的可读性名称
verbose_name_plural: OAuth配置的复数形式名称
ordering: 按照创建时间倒序排列
"""
TYPE = (
('weibo', _('weibo')),
('google', _('google')),
@ -54,14 +100,27 @@ class OAuthConfig(models.Model):
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
def clean(self):
"""
验证模型数据
确保同类型的配置只能存在一个实例
Raises:
ValidationError: 当已存在相同类型的配置时抛出验证错误
"""
if OAuthConfig.objects.filter(
type=self.type).exclude(id=self.id).count():
raise ValidationError(_(self.type + _('already exists')))
def __str__(self):
"""
模型的字符串表示
Returns:
str: 配置类型
"""
return self.type
class Meta:
verbose_name = 'oauth配置'
verbose_name_plural = verbose_name
ordering = ['-creation_time']
ordering = ['-creation_time']

@ -29,55 +29,133 @@ class BaseOauthManager(metaclass=ABCMeta):
ICON_NAME = None
def __init__(self, access_token=None, openid=None):
"""
初始化OAuth管理器
Args:
access_token (str, optional): 访问令牌
openid (str, optional): 用户唯一标识
"""
self.access_token = access_token
self.openid = openid
@property
def is_access_token_set(self):
"""检查访问令牌是否已设置"""
return self.access_token is not None
@property
def is_authorized(self):
"""检查是否已授权访问令牌和openid都已设置"""
return self.is_access_token_set and self.access_token is not None and self.openid is not None
@abstractmethod
def get_authorization_url(self, nexturl='/'):
"""
获取授权URL
Args:
nexturl (str): 授权后跳转的URL
Returns:
str: 授权页面URL
"""
pass
@abstractmethod
def get_access_token_by_code(self, code):
"""
通过授权码获取访问令牌
Args:
code (str): 授权码
Returns:
str: 访问令牌
"""
pass
@abstractmethod
def get_oauth_userinfo(self):
"""
获取OAuth用户信息
Returns:
OAuthUser: OAuth用户对象
"""
pass
@abstractmethod
def get_picture(self, metadata):
"""
从元数据中获取用户头像
Args:
metadata (str): 用户元数据JSON字符串
Returns:
str: 头像URL
"""
pass
def do_get(self, url, params, headers=None):
"""
发送GET请求
Args:
url (str): 请求URL
params (dict): 请求参数
headers (dict, optional): 请求头
Returns:
str: 响应文本
"""
rsp = requests.get(url=url, params=params, headers=headers)
logger.info(rsp.text)
return rsp.text
def do_post(self, url, params, headers=None):
"""
发送POST请求
Args:
url (str): 请求URL
params (dict): 请求参数
headers (dict, optional): 请求头
Returns:
str: 响应文本
"""
rsp = requests.post(url, params, headers=headers)
logger.info(rsp.text)
return rsp.text
def get_config(self):
"""
获取OAuth配置信息
Returns:
OAuthConfig: OAuth配置对象
"""
value = OAuthConfig.objects.filter(type=self.ICON_NAME)
return value[0] if value else None
class WBOauthManager(BaseOauthManager):
"""新浪微博OAuth管理器"""
AUTH_URL = 'https://api.weibo.com/oauth2/authorize'
TOKEN_URL = 'https://api.weibo.com/oauth2/access_token'
API_URL = 'https://api.weibo.com/2/users/show.json'
ICON_NAME = 'weibo'
def __init__(self, access_token=None, openid=None):
"""
初始化新浪微博OAuth管理器
Args:
access_token (str, optional): 访问令牌
openid (str, optional): 用户唯一标识
"""
config = self.get_config()
self.client_id = config.appkey if config else ''
self.client_secret = config.appsecret if config else ''
@ -89,6 +167,15 @@ class WBOauthManager(BaseOauthManager):
openid=openid)
def get_authorization_url(self, nexturl='/'):
"""
获取新浪微博授权URL
Args:
nexturl (str): 授权后跳转的URL
Returns:
str: 新浪微博授权页面URL
"""
params = {
'client_id': self.client_id,
'response_type': 'code',
@ -98,7 +185,18 @@ class WBOauthManager(BaseOauthManager):
return url
def get_access_token_by_code(self, code):
"""
通过授权码获取新浪微博访问令牌
Args:
code (str): 授权码
Returns:
OAuthUser: OAuth用户对象
Raises:
OAuthAccessTokenException: 获取访问令牌失败时抛出异常
"""
params = {
'client_id': self.client_id,
'client_secret': self.client_secret,
@ -117,6 +215,12 @@ class WBOauthManager(BaseOauthManager):
raise OAuthAccessTokenException(rsp)
def get_oauth_userinfo(self):
"""
获取新浪微博用户信息
Returns:
OAuthUser: OAuth用户对象
"""
if not self.is_authorized:
return None
params = {
@ -142,12 +246,26 @@ class WBOauthManager(BaseOauthManager):
return None
def get_picture(self, metadata):
"""
从新浪微博元数据中获取用户头像
Args:
metadata (str): 用户元数据JSON字符串
Returns:
str: 头像URL
"""
datas = json.loads(metadata)
return datas['avatar_large']
class ProxyManagerMixin:
"""代理管理混入类用于支持HTTP代理"""
def __init__(self, *args, **kwargs):
"""
初始化代理设置
"""
if os.environ.get("HTTP_PROXY"):
self.proxies = {
"http": os.environ.get("HTTP_PROXY"),
@ -157,23 +275,53 @@ class ProxyManagerMixin:
self.proxies = None
def do_get(self, url, params, headers=None):
"""
发送带代理的GET请求
Args:
url (str): 请求URL
params (dict): 请求参数
headers (dict, optional): 请求头
Returns:
str: 响应文本
"""
rsp = requests.get(url=url, params=params, headers=headers, proxies=self.proxies)
logger.info(rsp.text)
return rsp.text
def do_post(self, url, params, headers=None):
"""
发送带代理的POST请求
Args:
url (str): 请求URL
params (dict): 请求参数
headers (dict, optional): 请求头
Returns:
str: 响应文本
"""
rsp = requests.post(url, params, headers=headers, proxies=self.proxies)
logger.info(rsp.text)
return rsp.text
class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager):
"""Google OAuth管理器"""
AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
TOKEN_URL = 'https://www.googleapis.com/oauth2/v4/token'
API_URL = 'https://www.googleapis.com/oauth2/v3/userinfo'
ICON_NAME = 'google'
def __init__(self, access_token=None, openid=None):
"""
初始化Google OAuth管理器
Args:
access_token (str, optional): 访问令牌
openid (str, optional): 用户唯一标识
"""
config = self.get_config()
self.client_id = config.appkey if config else ''
self.client_secret = config.appsecret if config else ''
@ -185,6 +333,15 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager):
openid=openid)
def get_authorization_url(self, nexturl='/'):
"""
获取Google授权URL
Args:
nexturl (str): 授权后跳转的URL
Returns:
str: Google授权页面URL
"""
params = {
'client_id': self.client_id,
'response_type': 'code',
@ -195,6 +352,18 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager):
return url
def get_access_token_by_code(self, code):
"""
通过授权码获取Google访问令牌
Args:
code (str): 授权码
Returns:
str: 访问令牌
Raises:
OAuthAccessTokenException: 获取访问令牌失败时抛出异常
"""
params = {
'client_id': self.client_id,
'client_secret': self.client_secret,
@ -216,6 +385,12 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager):
raise OAuthAccessTokenException(rsp)
def get_oauth_userinfo(self):
"""
获取Google用户信息
Returns:
OAuthUser: OAuth用户对象
"""
if not self.is_authorized:
return None
params = {
@ -241,17 +416,34 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager):
return None
def get_picture(self, metadata):
"""
从Google元数据中获取用户头像
Args:
metadata (str): 用户元数据JSON字符串
Returns:
str: 头像URL
"""
datas = json.loads(metadata)
return datas['picture']
class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager):
"""GitHub OAuth管理器"""
AUTH_URL = 'https://github.com/login/oauth/authorize'
TOKEN_URL = 'https://github.com/login/oauth/access_token'
API_URL = 'https://api.github.com/user'
ICON_NAME = 'github'
def __init__(self, access_token=None, openid=None):
"""
初始化GitHub OAuth管理器
Args:
access_token (str, optional): 访问令牌
openid (str, optional): 用户唯一标识
"""
config = self.get_config()
self.client_id = config.appkey if config else ''
self.client_secret = config.appsecret if config else ''
@ -263,6 +455,15 @@ class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager):
openid=openid)
def get_authorization_url(self, next_url='/'):
"""
获取GitHub授权URL
Args:
next_url (str): 授权后跳转的URL
Returns:
str: GitHub授权页面URL
"""
params = {
'client_id': self.client_id,
'response_type': 'code',
@ -273,6 +474,18 @@ class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager):
return url
def get_access_token_by_code(self, code):
"""
通过授权码获取GitHub访问令牌
Args:
code (str): 授权码
Returns:
str: 访问令牌
Raises:
OAuthAccessTokenException: 获取访问令牌失败时抛出异常
"""
params = {
'client_id': self.client_id,
'client_secret': self.client_secret,
@ -292,7 +505,12 @@ class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager):
raise OAuthAccessTokenException(rsp)
def get_oauth_userinfo(self):
"""
获取GitHub用户信息
Returns:
OAuthUser: OAuth用户对象
"""
rsp = self.do_get(self.API_URL, params={}, headers={
"Authorization": "token " + self.access_token
})
@ -314,17 +532,34 @@ class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager):
return None
def get_picture(self, metadata):
"""
从GitHub元数据中获取用户头像
Args:
metadata (str): 用户元数据JSON字符串
Returns:
str: 头像URL
"""
datas = json.loads(metadata)
return datas['avatar_url']
class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager):
"""Facebook OAuth管理器"""
AUTH_URL = 'https://www.facebook.com/v16.0/dialog/oauth'
TOKEN_URL = 'https://graph.facebook.com/v16.0/oauth/access_token'
API_URL = 'https://graph.facebook.com/me'
ICON_NAME = 'facebook'
def __init__(self, access_token=None, openid=None):
"""
初始化Facebook OAuth管理器
Args:
access_token (str, optional): 访问令牌
openid (str, optional): 用户唯一标识
"""
config = self.get_config()
self.client_id = config.appkey if config else ''
self.client_secret = config.appsecret if config else ''
@ -336,6 +571,15 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager):
openid=openid)
def get_authorization_url(self, next_url='/'):
"""
获取Facebook授权URL
Args:
next_url (str): 授权后跳转的URL
Returns:
str: Facebook授权页面URL
"""
params = {
'client_id': self.client_id,
'response_type': 'code',
@ -346,6 +590,18 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager):
return url
def get_access_token_by_code(self, code):
"""
通过授权码获取Facebook访问令牌
Args:
code (str): 授权码
Returns:
str: 访问令牌
Raises:
OAuthAccessTokenException: 获取访问令牌失败时抛出异常
"""
params = {
'client_id': self.client_id,
'client_secret': self.client_secret,
@ -365,6 +621,12 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager):
raise OAuthAccessTokenException(rsp)
def get_oauth_userinfo(self):
"""
获取Facebook用户信息
Returns:
OAuthUser: OAuth用户对象
"""
params = {
'access_token': self.access_token,
'fields': 'id,name,picture,email'
@ -388,11 +650,21 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager):
return None
def get_picture(self, metadata):
"""
从Facebook元数据中获取用户头像
Args:
metadata (str): 用户元数据JSON字符串
Returns:
str: 头像URL
"""
datas = json.loads(metadata)
return str(datas['picture']['data']['url'])
class QQOauthManager(BaseOauthManager):
"""QQ OAuth管理器"""
AUTH_URL = 'https://graph.qq.com/oauth2.0/authorize'
TOKEN_URL = 'https://graph.qq.com/oauth2.0/token'
API_URL = 'https://graph.qq.com/user/get_user_info'
@ -400,6 +672,13 @@ class QQOauthManager(BaseOauthManager):
ICON_NAME = 'qq'
def __init__(self, access_token=None, openid=None):
"""
初始化QQ OAuth管理器
Args:
access_token (str, optional): 访问令牌
openid (str, optional): 用户唯一标识
"""
config = self.get_config()
self.client_id = config.appkey if config else ''
self.client_secret = config.appsecret if config else ''
@ -411,6 +690,15 @@ class QQOauthManager(BaseOauthManager):
openid=openid)
def get_authorization_url(self, next_url='/'):
"""
获取QQ授权URL
Args:
next_url (str): 授权后跳转的URL
Returns:
str: QQ授权页面URL
"""
params = {
'response_type': 'code',
'client_id': self.client_id,
@ -420,6 +708,18 @@ class QQOauthManager(BaseOauthManager):
return url
def get_access_token_by_code(self, code):
"""
通过授权码获取QQ访问令牌
Args:
code (str): 授权码
Returns:
str: 访问令牌
Raises:
OAuthAccessTokenException: 获取访问令牌失败时抛出异常
"""
params = {
'grant_type': 'authorization_code',
'client_id': self.client_id,
@ -438,6 +738,12 @@ class QQOauthManager(BaseOauthManager):
raise OAuthAccessTokenException(rsp)
def get_open_id(self):
"""
获取QQ用户openid
Returns:
str: 用户openid
"""
if self.is_access_token_set:
params = {
'access_token': self.access_token
@ -454,6 +760,12 @@ class QQOauthManager(BaseOauthManager):
return openid
def get_oauth_userinfo(self):
"""
获取QQ用户信息
Returns:
OAuthUser: OAuth用户对象
"""
openid = self.get_open_id()
if openid:
params = {
@ -477,12 +789,27 @@ class QQOauthManager(BaseOauthManager):
return user
def get_picture(self, metadata):
"""
从QQ元数据中获取用户头像
Args:
metadata (str): 用户元数据JSON字符串
Returns:
str: 头像URL
"""
datas = json.loads(metadata)
return str(datas['figureurl'])
@cache_decorator(expiration=100 * 60)
def get_oauth_apps():
"""
获取所有启用的OAuth应用
Returns:
list: OAuth应用管理器实例列表
"""
configs = OAuthConfig.objects.filter(is_enable=True).all()
if not configs:
return []
@ -493,6 +820,15 @@ def get_oauth_apps():
def get_manager_by_type(type):
"""
根据类型获取OAuth管理器
Args:
type (str): OAuth类型('weibo', 'google', 'github')
Returns:
BaseOauthManager: 对应的OAuth管理器实例
"""
applications = get_oauth_apps()
if applications:
finds = list(

@ -1,22 +1,65 @@
"""
OAuth模块模板标签
该模块提供用于在模板中显示OAuth登录按钮的模板标签
"""
from django import template
from django.urls import reverse
from oauth.oauthmanager import get_oauth_apps
# 注册模板标签库
register = template.Library()
@register.inclusion_tag('oauth/oauth_applications.html')
def load_oauth_applications(request):
"""
加载OAuth应用程序模板标签
该模板标签用于在模板中生成OAuth登录按钮它会获取所有已启用的OAuth配置
并为每个OAuth提供商生成相应的登录链接
Args:
request: HTTP请求对象用于获取当前页面路径作为登录后的跳转地址
Returns:
dict: 包含OAuth应用程序信息的字典
- apps: OAuth应用程序列表每个元素包含(平台名称, 登录URL)
Template:
该标签会渲染'oauth/oauth_applications.html'模板文件
Usage:
在模板中使用: {% load oauth_tags %} {% load_oauth_applications request %}
"""
# 获取所有已启用的OAuth应用程序配置
applications = get_oauth_apps()
if applications:
# 获取OAuth登录的基础URL
baseurl = reverse('oauth:oauthlogin')
# 获取当前请求的完整路径,用作登录成功后的跳转地址
path = request.get_full_path()
apps = list(map(lambda x: (x.ICON_NAME, '{baseurl}?type={type}&next_url={next}'.format(
baseurl=baseurl, type=x.ICON_NAME, next=path)), applications))
# 为每个OAuth应用程序生成登录URL
# 格式: baseurl?type={platform}&next_url={current_path}
apps = list(map(
lambda x: (
x.ICON_NAME, # OAuth平台名称如weibo、google、github等
'{baseurl}?type={type}&next_url={next}'.format(
baseurl=baseurl,
type=x.ICON_NAME,
next=path
)
),
applications
))
else:
# 如果没有可用的OAuth应用程序返回空列表
apps = []
return {
'apps': apps
'apps': apps # 返回OAuth应用程序列表供模板使用
}

@ -13,33 +13,55 @@ from oauth.oauthmanager import BaseOauthManager
# Create your tests here.
class OAuthConfigTest(TestCase):
"""OAuth配置测试类"""
def setUp(self):
"""
测试初始化设置
"""
self.client = Client()
self.factory = RequestFactory()
def test_oauth_login_test(self):
"""
测试OAuth登录功能
"""
# 创建微博OAuth配置
c = OAuthConfig()
c.type = 'weibo'
c.appkey = 'appkey'
c.appsecret = 'appsecret'
c.save()
# 测试获取OAuth登录链接
response = self.client.get('/oauth/oauthlogin?type=weibo')
self.assertEqual(response.status_code, 302)
self.assertTrue("api.weibo.com" in response.url)
# 测试授权回调
response = self.client.get('/oauth/authorize?type=weibo&code=code')
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, '/')
class OauthLoginTest(TestCase):
"""OAuth登录测试类"""
def setUp(self) -> None:
"""
测试初始化设置
"""
self.client = Client()
self.factory = RequestFactory()
self.apps = self.init_apps()
def init_apps(self):
"""
初始化所有OAuth应用
Returns:
list: OAuth应用管理器实例列表
"""
applications = [p() for p in BaseOauthManager.__subclasses__()]
for application in applications:
c = OAuthConfig()
@ -50,6 +72,15 @@ class OauthLoginTest(TestCase):
return applications
def get_app_by_type(self, type):
"""
根据类型获取OAuth应用管理器
Args:
type (str): OAuth类型
Returns:
BaseOauthManager: 对应的OAuth管理器实例
"""
for app in self.apps:
if app.ICON_NAME.lower() == type:
return app
@ -57,12 +88,21 @@ class OauthLoginTest(TestCase):
@patch("oauth.oauthmanager.WBOauthManager.do_post")
@patch("oauth.oauthmanager.WBOauthManager.do_get")
def test_weibo_login(self, mock_do_get, mock_do_post):
"""
测试微博登录流程
Args:
mock_do_get: 模拟GET请求
mock_do_post: 模拟POST请求
"""
weibo_app = self.get_app_by_type('weibo')
assert weibo_app
url = weibo_app.get_authorization_url()
# 模拟返回访问令牌
mock_do_post.return_value = json.dumps({"access_token": "access_token",
"uid": "uid"
})
# 模拟返回用户信息
mock_do_get.return_value = json.dumps({
"avatar_large": "avatar_large",
"screen_name": "screen_name",
@ -76,13 +116,22 @@ class OauthLoginTest(TestCase):
@patch("oauth.oauthmanager.GoogleOauthManager.do_post")
@patch("oauth.oauthmanager.GoogleOauthManager.do_get")
def test_google_login(self, mock_do_get, mock_do_post):
"""
测试Google登录流程
Args:
mock_do_get: 模拟GET请求
mock_do_post: 模拟POST请求
"""
google_app = self.get_app_by_type('google')
assert google_app
url = google_app.get_authorization_url()
# 模拟返回访问令牌
mock_do_post.return_value = json.dumps({
"access_token": "access_token",
"id_token": "id_token",
})
# 模拟返回用户信息
mock_do_get.return_value = json.dumps({
"picture": "picture",
"name": "name",
@ -97,12 +146,21 @@ class OauthLoginTest(TestCase):
@patch("oauth.oauthmanager.GitHubOauthManager.do_post")
@patch("oauth.oauthmanager.GitHubOauthManager.do_get")
def test_github_login(self, mock_do_get, mock_do_post):
"""
测试GitHub登录流程
Args:
mock_do_get: 模拟GET请求
mock_do_post: 模拟POST请求
"""
github_app = self.get_app_by_type('github')
assert github_app
url = github_app.get_authorization_url()
self.assertTrue("github.com" in url)
self.assertTrue("client_id" in url)
# 模拟返回访问令牌
mock_do_post.return_value = "access_token=gho_16C7e42F292c6912E7710c838347Ae178B4a&scope=repo%2Cgist&token_type=bearer"
# 模拟返回用户信息
mock_do_get.return_value = json.dumps({
"avatar_url": "avatar_url",
"name": "name",
@ -117,13 +175,22 @@ class OauthLoginTest(TestCase):
@patch("oauth.oauthmanager.FaceBookOauthManager.do_post")
@patch("oauth.oauthmanager.FaceBookOauthManager.do_get")
def test_facebook_login(self, mock_do_get, mock_do_post):
"""
测试Facebook登录流程
Args:
mock_do_get: 模拟GET请求
mock_do_post: 模拟POST请求
"""
facebook_app = self.get_app_by_type('facebook')
assert facebook_app
url = facebook_app.get_authorization_url()
self.assertTrue("facebook.com" in url)
# 模拟返回访问令牌
mock_do_post.return_value = json.dumps({
"access_token": "access_token",
})
# 模拟返回用户信息
mock_do_get.return_value = json.dumps({
"name": "name",
"id": "id",
@ -149,6 +216,12 @@ class OauthLoginTest(TestCase):
})
])
def test_qq_login(self, mock_do_get):
"""
测试QQ登录流程
Args:
mock_do_get: 模拟GET请求序列
"""
qq_app = self.get_app_by_type('qq')
assert qq_app
url = qq_app.get_authorization_url()
@ -160,6 +233,13 @@ class OauthLoginTest(TestCase):
@patch("oauth.oauthmanager.WBOauthManager.do_post")
@patch("oauth.oauthmanager.WBOauthManager.do_get")
def test_weibo_authoriz_login_with_email(self, mock_do_get, mock_do_post):
"""
测试微博授权登录带邮箱
Args:
mock_do_get: 模拟GET请求
mock_do_post: 模拟POST请求
"""
mock_do_post.return_value = json.dumps({"access_token": "access_token",
"uid": "uid"
@ -172,14 +252,17 @@ class OauthLoginTest(TestCase):
}
mock_do_get.return_value = json.dumps(mock_user_info)
# 测试获取OAuth登录链接
response = self.client.get('/oauth/oauthlogin?type=weibo')
self.assertEqual(response.status_code, 302)
self.assertTrue("api.weibo.com" in response.url)
# 测试授权回调
response = self.client.get('/oauth/authorize?type=weibo&code=code')
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, '/')
# 验证用户登录状态
user = auth.get_user(self.client)
assert user.is_authenticated
self.assertTrue(user.is_authenticated)
@ -187,6 +270,7 @@ class OauthLoginTest(TestCase):
self.assertEqual(user.email, mock_user_info['email'])
self.client.logout()
# 再次测试授权登录
response = self.client.get('/oauth/authorize?type=weibo&code=code')
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, '/')
@ -200,6 +284,13 @@ class OauthLoginTest(TestCase):
@patch("oauth.oauthmanager.WBOauthManager.do_post")
@patch("oauth.oauthmanager.WBOauthManager.do_get")
def test_weibo_authoriz_login_without_email(self, mock_do_get, mock_do_post):
"""
测试微博授权登录不带邮箱需要用户输入邮箱
Args:
mock_do_get: 模拟GET请求
mock_do_post: 模拟POST请求
"""
mock_do_post.return_value = json.dumps({"access_token": "access_token",
"uid": "uid"
@ -211,17 +302,21 @@ class OauthLoginTest(TestCase):
}
mock_do_get.return_value = json.dumps(mock_user_info)
# 测试获取OAuth登录链接
response = self.client.get('/oauth/oauthlogin?type=weibo')
self.assertEqual(response.status_code, 302)
self.assertTrue("api.weibo.com" in response.url)
# 测试授权回调(无邮箱情况)
response = self.client.get('/oauth/authorize?type=weibo&code=code')
self.assertEqual(response.status_code, 302)
# 验证跳转到邮箱输入页面
oauth_user_id = int(response.url.split('/')[-1].split('.')[0])
self.assertEqual(response.url, f'/oauth/requireemail/{oauth_user_id}.html')
# 提交邮箱表单
response = self.client.post(response.url, {'email': 'test@gmail.com', 'oauthid': oauth_user_id})
self.assertEqual(response.status_code, 302)
@ -233,6 +328,7 @@ class OauthLoginTest(TestCase):
})
self.assertEqual(response.url, f'{url}?type=email')
# 验证邮箱确认链接
path = reverse('oauth:email_confirm', kwargs={
'id': oauth_user_id,
'sign': sign
@ -240,6 +336,8 @@ class OauthLoginTest(TestCase):
response = self.client.get(path)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, f'/oauth/bindsuccess/{oauth_user_id}.html?type=success')
# 验证用户登录状态和信息
user = auth.get_user(self.client)
from oauth.models import OAuthUser
oauth_user = OAuthUser.objects.get(author=user)

@ -1,25 +1,62 @@
"""
OAuth模块URL配置
该模块定义了OAuth登录相关的所有URL路由包括授权回调邮箱绑定
登录入口等功能的路由配置
"""
from django.urls import path
from . import views
# 应用命名空间用于URL反向解析
app_name = "oauth"
# OAuth相关URL路由配置
urlpatterns = [
# OAuth授权回调处理路由
# 当用户在第三方平台完成授权后第三方平台会重定向到这个URL
# 系统会处理授权码,获取用户信息并完成登录流程
path(
r'oauth/authorize',
views.authorize),
views.authorize,
name='authorize'
),
# 要求用户输入邮箱的页面路由
# 当OAuth登录时第三方平台未返回邮箱信息时引导用户输入邮箱
# oauthid: OAuth用户记录的ID用于关联用户信息
path(
r'oauth/requireemail/<int:oauthid>.html',
views.RequireEmailView.as_view(),
name='require_email'),
name='require_email'
),
# 邮箱确认路由
# 用户点击邮箱中的确认链接后访问此路由,完成邮箱绑定
# id: OAuth用户ID
# sign: 安全签名,用于验证链接的有效性
path(
r'oauth/emailconfirm/<int:id>/<sign>.html',
views.emailconfirm,
name='email_confirm'),
name='email_confirm'
),
# OAuth绑定成功页面路由
# 显示绑定成功或需要验证邮箱的提示信息
# oauthid: OAuth用户记录的ID
path(
r'oauth/bindsuccess/<int:oauthid>.html',
views.bindsuccess,
name='bindsuccess'),
name='bindsuccess'
),
# OAuth登录入口路由
# 用户点击第三方登录按钮时访问此路由,系统会重定向到第三方授权页面
# 支持通过type参数指定第三方平台类型如weibo、google、github等
path(
r'oauth/oauthlogin',
views.oauthlogin,
name='oauthlogin')]
name='oauthlogin'
)
]

@ -27,6 +27,11 @@ logger = logging.getLogger(__name__)
def get_redirecturl(request):
"""
获取重定向URL
:param request: HTTP请求对象
:return: 重定向URL
"""
nexturl = request.GET.get('next_url', None)
if not nexturl or nexturl == '/login/' or nexturl == '/login':
nexturl = '/'
@ -41,6 +46,12 @@ def get_redirecturl(request):
def oauthlogin(request):
"""
OAuth登录入口
根据请求类型重定向到相应的OAuth提供商
:param request: HTTP请求对象
:return: 重定向响应
"""
type = request.GET.get('type', None)
if not type:
return HttpResponseRedirect('/')
@ -53,6 +64,12 @@ def oauthlogin(request):
def authorize(request):
"""
OAuth授权回调处理
处理OAuth提供商返回的授权码获取用户信息并登录
:param request: HTTP请求对象
:return: 重定向响应
"""
type = request.GET.get('type', None)
if not type:
return HttpResponseRedirect('/')
@ -125,6 +142,14 @@ def authorize(request):
def emailconfirm(request, id, sign):
"""
邮箱确认绑定处理
验证签名并完成邮箱绑定流程
:param request: HTTP请求对象
:param id: OAuth用户ID
:param sign: 签名
:return: 重定向响应
"""
if not sign:
return HttpResponseForbidden()
if not get_sha256(settings.SECRET_KEY +
@ -171,10 +196,18 @@ def emailconfirm(request, id, sign):
class RequireEmailView(FormView):
"""
要求邮箱视图类
当OAuth用户没有邮箱时要求用户提供邮箱地址
"""
form_class = RequireEmailForm
template_name = 'oauth/require_email.html'
def get(self, request, *args, **kwargs):
"""
处理GET请求
检查OAuth用户是否已有邮箱
"""
oauthid = self.kwargs['oauthid']
oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
if oauthuser.email:
@ -184,6 +217,10 @@ class RequireEmailView(FormView):
return super(RequireEmailView, self).get(request, *args, **kwargs)
def get_initial(self):
"""
获取表单初始数据
:return: 包含OAuth用户ID的字典
"""
oauthid = self.kwargs['oauthid']
return {
'email': '',
@ -191,6 +228,10 @@ class RequireEmailView(FormView):
}
def get_context_data(self, **kwargs):
"""
获取上下文数据
添加用户头像等信息到模板上下文
"""
oauthid = self.kwargs['oauthid']
oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
if oauthuser.picture:
@ -198,6 +239,10 @@ class RequireEmailView(FormView):
return super(RequireEmailView, self).get_context_data(**kwargs)
def form_valid(self, form):
"""
处理有效的表单提交
保存邮箱信息并发送确认邮件
"""
email = form.cleaned_data['email']
oauthid = form.cleaned_data['oauthid']
oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
@ -234,6 +279,13 @@ class RequireEmailView(FormView):
def bindsuccess(request, oauthid):
"""
绑定成功页面
显示绑定成功或需要验证邮箱的提示信息
:param request: HTTP请求对象
:param oauthid: OAuth用户ID
:return: 渲染的模板响应
"""
type = request.GET.get('type', None)
oauthuser = get_object_or_404(OAuthUser, pk=oauthid)
if type == 'email':
@ -250,4 +302,4 @@ def bindsuccess(request, oauthid):
return render(request, 'oauth/bindsuccess.html', {
'title': title,
'content': content
})
})

@ -1,7 +1,65 @@
"""
OwnTracks位置跟踪模块Django管理后台配置
该模块定义了OwnTracks相关模型在Django管理后台的配置
包括列表显示搜索过滤等功能
"""
from django.contrib import admin
# Register your models here.
from .models import OwnTrackLog
@admin.register(OwnTrackLog)
class OwnTrackLogsAdmin(admin.ModelAdmin):
pass
"""
OwnTrackLogs模型的Django管理后台配置类
用于在Django管理界面中管理OwnTracks位置日志数据
提供搜索过滤分页等功能方便管理员查看和管理位置数据
Attributes:
list_display: 列表页显示的字段
list_filter: 右侧过滤器字段
search_fields: 搜索字段
list_per_page: 每页显示的记录数
ordering: 默认排序方式
readonly_fields: 只读字段
"""
# 在列表页显示的字段
list_display = (
'id', # 记录ID
'tid', # 用户/设备标识符
'lat', # 纬度
'lon', # 经度
'creation_time' # 创建时间
)
# 右侧过滤器,可以通过这些字段进行筛选
list_filter = (
'tid', # 按用户/设备筛选
'creation_time', # 按创建时间筛选
)
# 搜索字段,支持在这些字段中进行关键词搜索
search_fields = (
'tid', # 搜索用户/设备标识符
)
# 每页显示的记录数
list_per_page = 50
# 默认按创建时间倒序排列(最新的记录在前)
ordering = ['-creation_time']
# 只读字段,这些字段在编辑时不可修改
readonly_fields = (
'creation_time', # 创建时间通常不允许修改
)
# 可以点击进入详情页的字段
list_display_links = ('id', 'tid')
# 日期层次导航,按创建时间分层显示
date_hierarchy = 'creation_time'

@ -1,5 +1,30 @@
"""
OwnTracks位置跟踪应用配置
该模块定义了OwnTracks应用的配置类用于Django应用的初始化和配置
"""
from django.apps import AppConfig
class OwntracksConfig(AppConfig):
"""
OwnTracks应用的配置类
该类继承自Django的AppConfig用于配置OwnTracks位置跟踪应用的基本信息
当Django启动时会使用这个配置类来初始化OwnTracks应用
Attributes:
name (str): 应用名称必须与应用的目录名一致
verbose_name (str): 应用的显示名称可选
default_auto_field (str): 默认主键字段类型可选
"""
# 应用名称Django会根据这个名称来识别和加载相应的应用
# 必须与应用的目录名owntracks一致
name = 'owntracks'
# 应用的显示名称在Django管理后台中显示
verbose_name = 'OwnTracks位置跟踪'
# 指定默认的主键字段类型Django 3.2+推荐使用
default_auto_field = 'django.db.models.BigAutoField'

@ -5,27 +5,37 @@ import django.utils.timezone
class Migration(migrations.Migration):
"""
初始迁移文件用于创建OwnTrackLog模型表
"""
initial = True
dependencies = [
# 依赖列表为空,表示这是初始迁移
]
operations = [
# 创建OwnTrackLog模型的操作
migrations.CreateModel(
name='OwnTrackLog',
fields=[
# 主键字段,自动创建
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# 用户标识字段最大长度100
('tid', models.CharField(max_length=100, verbose_name='用户')),
# 纬度字段,浮点数类型
('lat', models.FloatField(verbose_name='纬度')),
# 经度字段,浮点数类型
('lon', models.FloatField(verbose_name='经度')),
# 创建时间字段,默认值为当前时间
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
],
options={
'verbose_name': 'OwnTrackLogs',
'verbose_name_plural': 'OwnTrackLogs',
'ordering': ['created_time'],
'get_latest_by': 'created_time',
'verbose_name': 'OwnTrackLogs', # 模型的可读名称
'verbose_name_plural': 'OwnTrackLogs', # 模型的复数可读名称
'ordering': ['created_time'], # 默认排序字段
'get_latest_by': 'created_time', # 获取最新记录的字段
},
),
]

@ -4,16 +4,27 @@ from django.db import migrations
class Migration(migrations.Migration):
"""
数据库迁移文件用于修改 OwnTrackLog 模型的选项和字段名
"""
dependencies = [
# 依赖于 owntracks 应用的 0001_initial 迁移文件
('owntracks', '0001_initial'),
]
operations = [
# 修改 OwnTrackLog 模型的选项配置
migrations.AlterModelOptions(
name='owntracklog',
options={'get_latest_by': 'creation_time', 'ordering': ['creation_time'], 'verbose_name': 'OwnTrackLogs', 'verbose_name_plural': 'OwnTrackLogs'},
options={
'get_latest_by': 'creation_time', # 更改获取最新记录的字段为 creation_time
'ordering': ['creation_time'], # 更改默认排序字段为 creation_time
'verbose_name': 'OwnTrackLogs', # 模型的可读名称
'verbose_name_plural': 'OwnTrackLogs' # 模型的复数可读名称
},
),
# 重命名字段:将 created_time 字段更名为 creation_time
migrations.RenameField(
model_name='owntracklog',
old_name='created_time',

@ -1,20 +1,89 @@
"""
OwnTracks位置跟踪模块数据模型
该模块定义了OwnTracks位置跟踪应用的数据模型
OwnTracks是一个开源的位置跟踪应用用户可以通过手机应用
自动记录位置信息并发送到服务器进行存储和可视化
主要功能
- 接收来自OwnTracks客户端的位置数据
- 存储用户的位置轨迹信息
- 提供位置数据的查询和展示功能
"""
from django.db import models
from django.utils.timezone import now
# Create your models here.
class OwnTrackLog(models.Model):
tid = models.CharField(max_length=100, null=False, verbose_name='用户')
lat = models.FloatField(verbose_name='纬度')
lon = models.FloatField(verbose_name='经度')
creation_time = models.DateTimeField('创建时间', default=now)
"""
OwnTracks位置日志模型
用于存储来自OwnTracks客户端的位置跟踪数据每条记录包含
用户标识经纬度坐标和记录时间等信息
Attributes:
tid (CharField): 用户/设备标识符用于区分不同的用户或设备
最大长度100个字符不能为空
lat (FloatField): 纬度坐标使用WGS84坐标系
lon (FloatField): 经度坐标使用WGS84坐标系
creation_time (DateTimeField): 日志创建时间默认为当前时间
Meta:
ordering: 按照创建时间升序排列便于按时间顺序查看轨迹
verbose_name: OwnTrack日志的可读性名称
verbose_name_plural: OwnTrack日志的复数形式名称
get_latest_by: 指定用于latest()查询的字段
Example:
>>> log = OwnTrackLog(tid='user123', lat=39.9042, lon=116.4074)
>>> log.save()
"""
# 用户/设备标识符,用于区分不同的用户或设备
tid = models.CharField(
max_length=100,
null=False,
verbose_name='用户',
help_text='用户或设备的唯一标识符'
)
# 纬度坐标使用WGS84坐标系
lat = models.FloatField(
verbose_name='纬度',
help_text='位置纬度使用WGS84坐标系'
)
# 经度坐标使用WGS84坐标系
lon = models.FloatField(
verbose_name='经度',
help_text='位置经度使用WGS84坐标系'
)
# 记录创建时间,默认为当前时间
creation_time = models.DateTimeField(
'创建时间',
default=now,
help_text='位置记录的时间戳'
)
def __str__(self):
"""
模型的字符串表示
返回用户ID用于在Django管理后台和调试时显示
Returns:
str: 用户标识符
"""
return self.tid
class Meta:
# 按照创建时间升序排列,便于按时间顺序查看轨迹
ordering = ['creation_time']
verbose_name = "OwnTrackLogs"
verbose_name_plural = verbose_name
get_latest_by = 'creation_time'
# 在Django管理后台中的显示名称
verbose_name = "OwnTracks位置日志"
verbose_name_plural = "OwnTracks位置日志"
# 指定用于latest()查询的字段
get_latest_by = 'creation_time'

@ -9,11 +9,32 @@ from .models import OwnTrackLog
# Create your tests here.
class OwnTrackLogTest(TestCase):
"""
OwnTrackLog模型的测试类
用于测试OwnTracks功能的相关接口和数据处理逻辑
"""
def setUp(self):
"""
测试初始化方法
创建测试客户端和请求工厂实例用于模拟HTTP请求
"""
self.client = Client()
self.factory = RequestFactory()
def test_own_track_log(self):
"""
测试OwnTrack日志记录功能
验证以下功能
1. 正常的日志数据能够正确保存到数据库
2. 缺少必要字段的日志数据会被拒绝
3. 地图展示功能的访问权限控制
4. 管理员用户登录后可以正常访问地图和数据接口
"""
# 测试正常数据提交
o = {
'tid': 12,
'lat': 123.123,
@ -27,6 +48,7 @@ class OwnTrackLogTest(TestCase):
length = len(OwnTrackLog.objects.all())
self.assertEqual(length, 1)
# 测试缺少必要字段的数据提交(应被拒绝)
o = {
'tid': 12,
'lat': 123.123
@ -39,21 +61,26 @@ class OwnTrackLogTest(TestCase):
length = len(OwnTrackLog.objects.all())
self.assertEqual(length, 1)
# 测试未登录用户访问地图功能(应重定向到登录页面)
rsp = self.client.get('/owntracks/show_maps')
self.assertEqual(rsp.status_code, 302)
# 创建管理员用户并登录
user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com",
username="liangliangyy1",
password="liangliangyy1")
self.client.login(username='liangliangyy1', password='liangliangyy1')
# 添加测试数据
s = OwnTrackLog()
s.tid = 12
s.lon = 123.234
s.lat = 34.234
s.save()
# 测试各种数据展示接口的访问
rsp = self.client.get('/owntracks/show_dates')
self.assertEqual(rsp.status_code, 200)
rsp = self.client.get('/owntracks/show_maps')

@ -1,12 +1,60 @@
"""
OwnTracks位置跟踪模块URL配置
该模块定义了OwnTracks位置跟踪相关的所有URL路由包括
- 接收客户端位置数据的API接口
- 显示位置轨迹地图的页面
- 提供位置数据的JSON API
- 管理位置日志数据的页面
主要路由
1. logtracks: 接收OwnTracks客户端发送的位置数据
2. show_maps: 显示位置轨迹地图页面
3. get_datas: 获取位置数据的JSON API接口
4. show_dates: 显示有位置记录的日期列表页面
"""
from django.urls import path
from . import views
# 应用命名空间用于URL反向解析
app_name = "owntracks"
# OwnTracks位置跟踪相关URL路由配置
urlpatterns = [
path('owntracks/logtracks', views.manage_owntrack_log, name='logtracks'),
path('owntracks/show_maps', views.show_maps, name='show_maps'),
path('owntracks/get_datas', views.get_datas, name='get_datas'),
path('owntracks/show_dates', views.show_log_dates, name='show_dates')
# 接收OwnTracks客户端位置数据的日志记录接口
# 该接口接收POST请求包含JSON格式的位置数据
# OwnTracks客户端会自动向此接口发送位置信息
path(
'owntracks/logtracks',
views.manage_owntrack_log,
name='logtracks'
),
# 显示地图页面,展示位置轨迹
# 仅允许超级用户访问,显示指定日期的位置轨迹数据
# 支持通过GET参数指定查询日期格式: ?date=YYYY-MM-DD
path(
'owntracks/show_maps',
views.show_maps,
name='show_maps'
),
# 获取位置数据接口,用于前端地图渲染
# 返回JSON格式的位置轨迹数据供前端JavaScript调用
# 支持通过GET参数指定查询日期格式: ?date=YYYY-MM-DD
path(
'owntracks/get_datas',
views.get_datas,
name='get_datas'
),
# 显示可用的日志日期列表页面
# 显示数据库中有位置记录的所有日期,方便用户选择查看
path(
'owntracks/show_dates',
views.show_log_dates,
name='show_dates'
)
]

@ -1,4 +1,19 @@
# Create your views here.
"""
OwnTracks位置跟踪模块视图函数
该模块包含处理OwnTracks位置跟踪相关的所有视图函数包括
- 接收来自OwnTracks客户端的位置数据
- 显示位置轨迹地图
- 提供位置数据的API接口
- 管理位置日志数据
主要功能
1. manage_owntrack_log: 接收并存储位置数据
2. show_maps: 显示位置轨迹地图页面
3. show_log_dates: 显示有位置记录的日期列表
4. get_datas: 提供位置数据的JSON API接口
"""
import datetime
import itertools
import json
@ -16,11 +31,24 @@ from django.views.decorators.csrf import csrf_exempt
from .models import OwnTrackLog
# 获取日志记录器
logger = logging.getLogger(__name__)
@csrf_exempt
def manage_owntrack_log(request):
"""
处理OwnTracks客户端发送的位置日志数据
该视图接收来自OwnTracks应用程序的POST请求解析其中的tid(设备ID)
lat(纬度)lon(经度)等位置信息并保存到OwnTrackLog数据库中
Args:
request: HTTP请求对象包含JSON格式的位置数据
Returns:
HttpResponse: 返回处理结果状态('ok'/'data error'/'error')
"""
try:
s = json.loads(request.read().decode('utf-8'))
tid = s['tid']
@ -46,6 +74,18 @@ def manage_owntrack_log(request):
@login_required
def show_maps(request):
"""
显示位置轨迹地图页面
仅允许超级用户访问显示指定日期的位置轨迹数据
如果未指定日期则默认显示当天数据
Args:
request: HTTP请求对象
Returns:
HttpResponse: 地图页面或403 Forbidden响应
"""
if request.user.is_superuser:
defaultdate = str(datetime.datetime.now(timezone.utc).date())
date = request.GET.get('date', defaultdate)
@ -60,6 +100,17 @@ def show_maps(request):
@login_required
def show_log_dates(request):
"""
显示有位置记录的日期列表
获取数据库中所有位置记录的日期并去重排序后返回给前端
Args:
request: HTTP请求对象
Returns:
HttpResponse: 包含日期列表的页面
"""
dates = OwnTrackLog.objects.values_list('creation_time', flat=True)
results = list(sorted(set(map(lambda x: x.strftime('%Y-%m-%d'), dates))))
@ -70,58 +121,131 @@ def show_log_dates(request):
def convert_to_amap(locations):
"""
将GPS坐标转换为高德地图坐标
由于高德地图API限制每次最多转换30个坐标点因此需要分批处理
GPS坐标使用WGS84坐标系而高德地图使用GCJ02坐标系需要进行坐标转换
Args:
locations: OwnTrackLog对象列表包含需要转换的位置数据
Returns:
str: 转换后的坐标字符串格式为"经度,纬度;经度,纬度;..."
Note:
- 该函数使用了高德地图的坐标转换API
- API Key是硬编码的在生产环境中应该从配置文件读取
- 每次最多转换30个坐标点超过的需要分批处理
"""
convert_result = []
it = iter(locations)
# 每次处理30个坐标点高德地图API限制
item = list(itertools.islice(it, 30))
while item:
# 将坐标点转换为"经度,纬度"格式的字符串,并用分号连接
# 使用set去重避免重复的坐标点
datas = ';'.join(
set(map(lambda x: str(x.lon) + ',' + str(x.lat), item)))
# 高德地图API配置注意生产环境中应从配置文件读取
key = '8440a376dfc9743d8924bf0ad141f28e'
api = 'http://restapi.amap.com/v3/assistant/coordinate/convert'
# 构建API请求参数
query = {
'key': key,
'locations': datas,
'coordsys': 'gps'
'key': key, # API密钥
'locations': datas, # 需要转换的坐标点
'coordsys': 'gps' # 源坐标系GPS/WGS84
}
# 发送API请求
rsp = requests.get(url=api, params=query)
result = json.loads(rsp.text)
# 检查API响应是否包含转换后的坐标
if "locations" in result:
convert_result.append(result['locations'])
# 获取下一批坐标点
item = list(itertools.islice(it, 30))
# 将所有转换结果用分号连接返回
return ";".join(convert_result)
@login_required
def get_datas(request):
"""
获取位置轨迹数据接口
根据指定日期查询位置数据按设备ID分组并按时间排序
返回JSON格式的数据供前端地图渲染使用
Args:
request: HTTP请求对象可通过GET参数指定查询日期
- date: 查询日期格式为'YYYY-MM-DD'如未指定则使用当天
Returns:
JsonResponse: 位置轨迹数据格式为:
[
{
"name": "设备ID",
"path": [["经度", "纬度"], ["经度", "纬度"], ...]
},
...
]
Example:
请求: GET /owntracks/get_datas?date=2023-12-01
响应: [{"name": "user123", "path": [["116.4074", "39.9042"], ...]}]
"""
# 获取当前时间作为默认查询日期
now = django.utils.timezone.now().replace(tzinfo=timezone.utc)
querydate = django.utils.timezone.datetime(
now.year, now.month, now.day, 0, 0, 0)
# 如果请求中指定了日期参数,则使用指定的日期
if request.GET.get('date', None):
date = list(map(lambda x: int(x), request.GET.get('date').split('-')))
date_str = request.GET.get('date')
# 解析日期字符串,格式: YYYY-MM-DD
date = list(map(lambda x: int(x), date_str.split('-')))
querydate = django.utils.timezone.datetime(
date[0], date[1], date[2], 0, 0, 0)
# 计算查询的结束时间第二天0点
nextdate = querydate + datetime.timedelta(days=1)
# 查询指定日期范围内的位置数据
models = OwnTrackLog.objects.filter(
creation_time__range=(querydate, nextdate))
result = list()
if models and len(models):
# 按设备ID分组每个设备生成一条轨迹记录
for tid, item in groupby(
sorted(models, key=lambda k: k.tid), key=lambda k: k.tid):
d = dict()
d["name"] = tid
d["name"] = tid # 设备ID作为轨迹名称
paths = list()
# 使用高德转换后的经纬度
# 选项1: 使用高德地图转换后的经纬度(需要坐标转换)
# 注意当前代码中这部分被注释掉了直接使用原始GPS坐标
# locations = convert_to_amap(
# sorted(item, key=lambda x: x.creation_time))
# for i in locations.split(';'):
# paths.append(i.split(','))
# 使用GPS原始经纬度
# 选项2: 使用GPS原始经纬度当前使用的方案
# 按时间排序,确保轨迹点的顺序正确
for location in sorted(item, key=lambda x: x.creation_time):
paths.append([str(location.lon), str(location.lat)])
d["path"] = paths
result.append(d)
# 返回JSON响应safe=False允许返回非字典类型的数据
return JsonResponse(result, safe=False)

Loading…
Cancel
Save