Compare commits

...

2 Commits

Author SHA1 Message Date
shiqi 265fa87338 第7-8周注释
3 months ago
shiqi cae4eee358 add newfile
4 months ago

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

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

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 B

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

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

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

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

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

File diff suppressed because one or more lines are too long

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

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

Loading…
Cancel
Save