ly_blog APP注释 #25

Merged
pnry42fjm merged 2 commits from ly_branch into master 3 months ago

@ -0,0 +1,212 @@
#ly:
# coding:utf-8
# ==========================================
# Blog模块 - Django Admin配置
# ==========================================
from django import forms
from django.contrib import admin
from django.contrib.auth import get_user_model
from django.urls import reverse
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from .models import Article
# ==========================================
# 文章表单
# ==========================================
class ArticleForm(forms.ModelForm):
"""
文章编辑表单
用于Django Admin中编辑文章
"""
# body = forms.CharField(widget=AdminPagedownWidget())
class Meta:
model = Article
fields = '__all__'
# ==========================================
# 文章管理操作函数
# ==========================================
def makr_article_publish(modeladmin, request, queryset):
"""
批量发布选中的文章
将选中文章的状态改为'p'已发布
问题函数名拼写错误应为 mark_article_publish
"""
queryset.update(status='p')
def draft_article(modeladmin, request, queryset):
"""
批量把选中的文章设为草稿
将选中文章的状态改为'd'草稿
"""
queryset.update(status='d')
def close_article_commentstatus(modeladmin, request, queryset):
"""
批量关闭选中文章的评论
将选中文章的评论状态改为'c'关闭
"""
queryset.update(comment_status='c')
def open_article_commentstatus(modeladmin, request, queryset):
"""
批量开启选中文章的评论
将选中文章的评论状态改为'o'开放
"""
queryset.update(comment_status='o')
# 设置操作函数在Admin中的显示描述
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')
# ==========================================
# 文章Admin配置
# ==========================================
class ArticlelAdmin(admin.ModelAdmin):
"""
文章管理配置
在Django Admin中管理文章
"""
# 每页显示的记录数
list_per_page = 20
# 搜索字段 - 可以搜索文章标题和内容
search_fields = ('body', 'title')
# 使用自定义表单
form = ArticleForm
# 列表显示的字段
list_display = (
'id',
'title',
'author',
'link_to_category',
'creation_time',
'views',
'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 = [
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')
def get_form(self, request, obj=None, **kwargs):
"""
获取表单
只允许超级用户作为文章作者
问题如果没有超级用户queryset会为空UI会显示为空列表
应该有备用方案或提示信息
"""
form = super(ArticlelAdmin, self).get_form(request, obj, **kwargs)
# 将author字段的选项限制为超级用户
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
url = obj.get_full_url()
return url
else:
# 返回网站首页URL
from djangoblog.utils import get_current_site
site = get_current_site().domain
return site
# ==========================================
# 标签Admin配置
# ==========================================
class TagAdmin(admin.ModelAdmin):
"""标签管理"""
# 不显示的字段(由系统自动管理)
exclude = ('slug', 'last_mod_time', 'creation_time')
# ==========================================
# 分类Admin配置
# ==========================================
class CategoryAdmin(admin.ModelAdmin):
"""分类管理"""
# 列表显示的字段
list_display = ('name', 'parent_category', 'index')
# 不显示的字段(由系统自动管理)
exclude = ('slug', 'last_mod_time', 'creation_time')
# ==========================================
# 友情链接Admin配置
# ==========================================
class LinksAdmin(admin.ModelAdmin):
"""友情链接管理"""
# 不显示的字段(由系统自动管理)
exclude = ('last_mod_time', 'creation_time')
# ==========================================
# 侧边栏Admin配置
# ==========================================
class SideBarAdmin(admin.ModelAdmin):
"""侧边栏管理"""
# 列表显示的字段
list_display = ('name', 'content', 'is_enable', 'sequence')
# 不显示的字段(由系统自动管理)
exclude = ('last_mod_time', 'creation_time')
# ==========================================
# 博客配置Admin
# ==========================================
class BlogSettingsAdmin(admin.ModelAdmin):
"""
博客配置管理
问题此类为空实现应该删除或添加实际配置
"""
pass

@ -0,0 +1,16 @@
#ly:
# coding:utf-8
# ==========================================
# Blog模块 - 应用配置
# ==========================================
from django.apps import AppConfig
class BlogConfig(AppConfig):
"""
Blog模块应用配置
用于Django应用初始化和配置
"""
# 应用名称
name = 'blog'

@ -0,0 +1,91 @@
#ly:
# coding:utf-8
# ==========================================
# Blog模块 - 模板上下文处理器
# ==========================================
# 为所有模板提供全局上下文数据
import logging
from django.utils import timezone
from djangoblog.utils import cache, get_blog_setting
from .models import Category, Article
logger = logging.getLogger(__name__)
# ==========================================
# SEO信息上下文处理器
# ==========================================
def seo_processor(requests):
"""
为所有模板提供SEO和网站配置相关的上下文数据
此处理器会被自动调用以便在所有模板中使用相关变量
缓存10小时以提高性能
参数:
requests: HTTP请求对象
返回:
上下文字典包含SEO和配置信息
"""
# 缓存键
key = 'seo_processor'
# 尝试从缓存获取
value = cache.get(key)
if value:
# 缓存命中,直接返回
return value
else:
# 缓存未命中,重新生成
logger.info('set processor cache.')
# 获取博客配置
setting = get_blog_setting()
value = {
# ========== 网站基本信息 ==========
'SITE_NAME': setting.site_name, # 网站名称
'SITE_DESCRIPTION': setting.site_description, # 网站描述
'SITE_SEO_DESCRIPTION': setting.site_seo_description, # SEO描述
'SITE_KEYWORDS': setting.site_keywords, # 网站关键词
# ========== 网站URL ==========
# 【问题】缓存的是处理器返回值但其中包含了requests.scheme和requests.get_host()
# 这些值可能会因请求而异http/https, 不同host缓存可能导致错误
'SITE_BASE_URL': requests.scheme + '://' + requests.get_host() + '/',
# ========== 文章显示配置 ==========
'ARTICLE_SUB_LENGTH': setting.article_sub_length, # 文章摘要长度
# ========== 导航菜单 ==========
'nav_category_list': Category.objects.all(), # 所有分类
'nav_pages': Article.objects.filter( # 所有页面type='p'
type='p',
status='p'),
# ========== 评论配置 ==========
'OPEN_SITE_COMMENT': setting.open_site_comment, # 是否开放评论
'COMMENT_NEED_REVIEW': setting.comment_need_review, # 评论是否需要审核
# ========== 广告配置 ==========
'SHOW_GOOGLE_ADSENSE': setting.show_google_adsense, # 是否显示Google AdSense
'GOOGLE_ADSENSE_CODES': setting.google_adsense_codes, # Google AdSense代码
# ========== 备案信息(中国特有) ==========
'BEIAN_CODE': setting.beian_code, # ICP备案号
'BEIAN_CODE_GONGAN': setting.gongan_beiancode, # 公安备案号
'SHOW_GONGAN_CODE': setting.show_gongan_code, # 是否显示公安备案号
# ========== 网站分析代码 ==========
'ANALYTICS_CODE': setting.analytics_code, # 网站统计代码
# ========== 其他 ==========
'CURRENT_YEAR': timezone.now().year, # 当前年份
'GLOBAL_HEADER': setting.global_header, # 公共头部HTML
'GLOBAL_FOOTER': setting.global_footer, # 公共尾部HTML
}
# 缓存10小时
cache.set(key, value, 60 * 60 * 10)
return value

@ -0,0 +1,386 @@
#ly:
# coding:utf-8
# ==========================================
# Blog模块 - Elasticsearch文档定义
# ==========================================
import time
import elasticsearch.client
from django.conf import settings
from elasticsearch_dsl import Document, InnerDoc, Date, Integer, Long, Text, Object, GeoPoint, Keyword, Boolean
from elasticsearch_dsl.connections import connections
from blog.models import Article
# 检查是否启用了Elasticsearch
ELASTICSEARCH_ENABLED = hasattr(settings, 'ELASTICSEARCH_DSL')
# Elasticsearch初始化和配置
if ELASTICSEARCH_ENABLED:
# 创建Elasticsearch连接
connections.create_connection(
hosts=[settings.ELASTICSEARCH_DSL['default']['hosts']])
from elasticsearch import Elasticsearch
# 创建ES客户端实例
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
from elasticsearch.client import IngestClient
# 创建Ingest API客户端用于管理管道
c = IngestClient(es)
try:
# 检查'geoip'管道是否存在
c.get_pipeline('geoip')
except elasticsearch.exceptions.NotFoundError:
# 如果不存在创建geoip管道用于地理位置查询
c.put_pipeline('geoip', body='''{
"description" : "Add geoip info",
"processors" : [
{
"geoip" : {
"field" : "ip"
}
}
]
}''')
# ==========================================
# Elasticsearch文档内部字段定义
# ==========================================
class GeoIp(InnerDoc):
"""
地理位置信息
由Elasticsearch的geoip处理器生成
"""
# 大陆名称
continent_name = Keyword()
# 国家ISO编码
country_iso_code = Keyword()
# 国家名称
country_name = Keyword()
# 地理坐标(用于地图显示)
location = GeoPoint()
class UserAgentBrowser(InnerDoc):
"""浏览器信息"""
# 浏览器名称
Family = Keyword()
# 浏览器版本
Version = Keyword()
class UserAgentOS(UserAgentBrowser):
"""
操作系统信息
继承自UserAgentBrowser
"""
pass
class UserAgentDevice(InnerDoc):
"""设备信息"""
# 设备类型
Family = Keyword()
# 品牌
Brand = Keyword()
# 型号
Model = Keyword()
class UserAgent(InnerDoc):
"""
User-Agent信息
包含浏览器操作系统设备等信息
"""
# 浏览器信息
browser = Object(UserAgentBrowser, required=False)
# 操作系统信息
os = Object(UserAgentOS, required=False)
# 设备信息
device = Object(UserAgentDevice, required=False)
# 原始User-Agent字符串
string = Text()
# 是否为机器人
is_bot = Boolean()
# ==========================================
# 页面加载性能文档
# ==========================================
class ElapsedTimeDocument(Document):
"""
Elasticsearch文档 - 页面加载性能数据
记录每个页面请求的加载时间用户信息等
"""
# 请求的URL路径
url = Keyword()
# 加载时间(毫秒)
time_taken = Long()
# 记录时间
log_datetime = Date()
# 客户端IP地址
ip = Keyword()
# 地理位置信息由geoip处理器生成
geoip = Object(GeoIp, required=False)
# User-Agent信息浏览器、操作系统等
useragent = Object(UserAgent, required=False)
class Index:
# 索引名称
name = 'performance'
# 索引设置
settings = {
"number_of_shards": 1, # 分片数
"number_of_replicas": 0 # 副本数
}
class Meta:
# 文档类型
doc_type = 'ElapsedTime'
# ==========================================
# 性能文档管理器
# ==========================================
class ElaspedTimeDocumentManager:
"""
页面加载性能数据管理器
问题类名拼写错误应为 ElapsedTimeDocumentManager
"""
@staticmethod
def build_index():
"""
构建性能索引
如果索引不存在则创建它
"""
from elasticsearch import Elasticsearch
client = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
# 检查索引是否存在
res = client.indices.exists(index="performance")
if not res:
# 创建索引
ElapsedTimeDocument.init()
@staticmethod
def delete_index():
"""删除性能索引"""
from elasticsearch import Elasticsearch
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
# 删除索引忽略404错误
es.indices.delete(index='performance', ignore=[400, 404])
@staticmethod
def create(url, time_taken, log_datetime, useragent, ip):
"""
创建并保存一条性能记录
参数:
url: 请求的URL路径
time_taken: 加载时间毫秒
log_datetime: 记录时间
useragent: User-Agent对象
ip: 客户端IP地址
"""
# 确保索引存在
ElaspedTimeDocumentManager.build_index()
# 构建User-Agent信息对象
ua = UserAgent()
# 浏览器信息
ua.browser = UserAgentBrowser()
ua.browser.Family = useragent.browser.family
ua.browser.Version = useragent.browser.version_string
# 操作系统信息
ua.os = UserAgentOS()
ua.os.Family = useragent.os.family
ua.os.Version = useragent.os.version_string
# 设备信息
ua.device = UserAgentDevice()
ua.device.Family = useragent.device.family
ua.device.Brand = useragent.device.brand
ua.device.Model = useragent.device.model
# 原始User-Agent字符串
ua.string = useragent.ua_string
# 是否为机器人
ua.is_bot = useragent.is_bot
# 创建性能文档
doc = ElapsedTimeDocument(
meta={
# 使用当前时间戳作为文档ID
'id': int(round(time.time() * 1000))
},
url=url,
time_taken=time_taken,
log_datetime=log_datetime,
useragent=ua,
ip=ip)
# 保存文档使用geoip管道自动添加地理位置信息
doc.save(pipeline="geoip")
# ==========================================
# 文章搜索文档
# ==========================================
class ArticleDocument(Document):
"""
Elasticsearch文档 - 文章搜索索引
用于全文搜索文章内容
分析器说明
- ik_max_word: 中文最细粒度分词
- ik_smart: 中文最粗粒度分词
"""
# 文章内容 - 使用ik分析器处理中文
body = Text(analyzer='ik_max_word', search_analyzer='ik_smart')
# 文章标题
title = Text(analyzer='ik_max_word', search_analyzer='ik_smart')
# 文章作者信息
author = Object(properties={
'nickname': Text(analyzer='ik_max_word', search_analyzer='ik_smart'),
'id': Integer()
})
# 文章分类信息
category = Object(properties={
'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'),
'id': Integer()
})
# 文章标签列表
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()
class Index:
# 索引名称
name = 'blog'
# 索引设置
settings = {
"number_of_shards": 1, # 分片数
"number_of_replicas": 0 # 副本数
}
class Meta:
# 文档类型
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'])
# 删除索引忽略404错误
es.indices.delete(index='blog', ignore=[400, 404])
def convert_to_doc(self, articles):
"""
将Article模型对象转换为ArticleDocument对象
参数:
articles: Article对象的QuerySet或列表
返回:
ArticleDocument对象列表
"""
return [
ArticleDocument(
meta={'id': article.id}, # 文档ID = 文章ID
body=article.body,
title=article.title,
author={
'nickname': article.author.username,
'id': article.author.id
},
category={
'name': article.category.name,
'id': article.category.id
},
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,
article_order=article.article_order
) for article in articles
]
def rebuild(self, articles=None):
"""
重建文章索引
删除旧索引创建新索引并重新索引所有文章
参数:
articles: Article对象QuerySet或列表默认为所有已发布文章
"""
# 重新初始化索引(会删除旧索引)
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):
"""
更新文档
参数:
docs: ArticleDocument对象列表
"""
for doc in docs:
doc.save()

@ -0,0 +1,46 @@
#ly:
# coding:utf-8
# ==========================================
# Blog模块 - 表单定义
# ==========================================
import logging
from django import forms
from haystack.forms import SearchForm
logger = logging.getLogger(__name__)
# ==========================================
# 博客搜索表单
# ==========================================
class BlogSearchForm(SearchForm):
"""
继承自haystack的搜索表单
用于处理博客文章的全文搜索
"""
# 搜索关键词字段 - 必填
querydata = forms.CharField(required=True)
def search(self):
"""
执行搜索
验证表单数据并返回搜索结果
返回: 搜索结果QuerySet
"""
# 调用父类的search()方法获取搜索结果
datas = super(BlogSearchForm, self).search()
# 检查表单是否有效
if not self.is_valid():
# 返回空结果
return self.no_query_found()
# 【问题】:直接记录用户搜索查询词,可能记录敏感信息
# 【问题】:没有进行任何有意义的处理,此代码无用
if self.cleaned_data['querydata']:
logger.info(self.cleaned_data['querydata'])
return datas

@ -0,0 +1,19 @@
from django.core.management.base import BaseCommand
from blog.documents import ElapsedTimeDocument, ArticleDocumentManager, ElaspedTimeDocumentManager, \
ELASTICSEARCH_ENABLED
#ly:
# coding:utf-8
# 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,14 @@
from django.core.management.base import BaseCommand
from blog.models import Tag, Category
#ly:
# coding:utf-8
# 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,12 @@
from django.core.management.base import BaseCommand
from djangoblog.utils import cache
#ly:
# coding:utf-8
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,41 @@
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
#ly:
# coding:utf-8
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,51 @@
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
#ly:
# coding:utf-8
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,48 @@
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
#ly:
# coding:utf-8
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,87 @@
#ly:
# coding:utf-8
# ==========================================
# Blog模块 - 中间件
# ==========================================
# 用于记录页面加载时间和用户信息
import logging
import time
from ipware import get_client_ip
from user_agents import parse
from blog.documents import ELASTICSEARCH_ENABLED, ElaspedTimeDocumentManager
logger = logging.getLogger(__name__)
# ==========================================
# 在线中间件 - 记录页面加载性能
# ==========================================
class OnlineMiddleware(object):
"""
中间件记录页面加载时间和用户信息
功能
1. 计算每个请求的响应时间
2. 获取用户的IP地址和User-Agent信息
3. 如果启用了Elasticsearch将性能数据存储到ES
4. 将加载时间插入到响应内容中用于在页面显示
"""
def __init__(self, get_response=None):
"""初始化中间件"""
self.get_response = get_response
super().__init__()
def __call__(self, request):
"""
处理请求和响应
参数:
request: HTTP请求对象
返回:
response: HTTP响应对象
"""
# 记录请求开始时间(用于计算加载时间)
start_time = time.time()
# 获取响应对象
response = self.get_response(request)
# 获取用户代理字符串
http_user_agent = request.META.get('HTTP_USER_AGENT', '')
# 获取客户端IP地址
ip, _ = get_client_ip(request)
# 解析User-Agent信息包括浏览器、操作系统、设备等
user_agent = parse(http_user_agent)
# 只处理非流式响应普通HTTP响应
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
from django.utils import timezone
# 创建性能记录文档
ElaspedTimeDocumentManager.create(
url=url,
time_taken=time_taken,
log_datetime=timezone.now(),
useragent=user_agent,
ip=ip)
# 【问题】对整个response内容进行字符串替换对大文件性能影响大
# 应该使用流式处理或在模板层面处理
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

@ -0,0 +1,138 @@
# 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
#ly:
# coding:utf-8
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,24 @@
# Generated by Django 4.1.7 on 2023-03-29 06:08
from django.db import migrations, models
#ly:
# coding:utf-8
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,18 @@
# Generated by Django 4.2.1 on 2023-05-09 07:45
from django.db import migrations, models
#ly:
# coding:utf-8
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,28 @@
# Generated by Django 4.2.1 on 2023-05-09 07:51
from django.db import migrations
#ly:
# coding:utf-8
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,301 @@
# 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
#ly:
# coding:utf-8
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,18 @@
# Generated by Django 4.2.7 on 2024-01-26 02:41
from django.db import migrations
#ly:
# coding:utf-8
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,630 @@
#ly:
# coding:utf-8
# ==========================================
# Blog模块 - 数据模型定义
# ==========================================
# 包含文章、分类、标签、友情链接等核心数据模型
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'))
# ==========================================
# 基础模型类 - 所有Blog模型的父类
# ==========================================
class BaseModel(models.Model):
"""
Blog模块所有模型的基类
提供通用的字段和方法ID创建时间修改时间等
"""
# 主键ID
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):
"""
重写保存方法
功能
1. 对Article只更新views时使用update()方法提高性能
2. 自动生成slug字段从title或name字段
问题当更新views时不会更新last_modify_time时间戳
"""
# 检查是否仅更新views字段用于提高统计性能
is_update_views = isinstance(
self,
Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views']
if is_update_views:
# 直接使用update()更新绕过save()方法
Article.objects.filter(pk=self.pk).update(views=self.views)
else:
# 自动生成slug字段URL友好的标识符
if 'slug' in self.__dict__:
# 优先使用title否则使用name
slug = getattr(
self, 'title') if 'title' in self.__dict__ else getattr(
self, 'name')
# 使用uuslug库生成URL友好的slug
setattr(self, 'slug', slugify(slug))
super().save(*args, **kwargs)
def get_full_url(self):
"""
获取完整的网址包含域名和协议
用于生成SEO友好的完整URL
返回格式: https://example.com/path/to/article/
"""
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地址: /article/2024/01/01/1.html
"""
pass
# ==========================================
# 文章模型
# ==========================================
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: 页面(如关于、友链等)
)
# ========== 文章基本内容字段 ==========
# 文章标题 - 唯一最多200字符
title = models.CharField(_('title'), max_length=200, unique=True)
# 文章正文 - 使用Markdown编辑器字段支持富文本编辑
body = MDTextField(_('body'))
# 文章发布时间 - 默认为当前时间
pub_time = models.DateTimeField(
_('publish time'), blank=False, null=False, default=now)
# ========== 文章状态字段 ==========
# 文章发布状态(草稿或已发布)
status = models.CharField(
_('status'),
max_length=1,
choices=STATUS_CHOICES,
default='p')
# 文章评论状态(开放或关闭)
comment_status = models.CharField(
_('comment status'),
max_length=1,
choices=COMMENT_STATUS,
default='o')
# 文章类型(普通文章或独立页面)
type = models.CharField(_('type'), max_length=1, choices=TYPE, default='a')
# ========== 统计字段 ==========
# 文章阅读次数 - 每次访问文章时递增
views = models.PositiveIntegerField(_('views'), default=0)
# ========== 关联字段 ==========
# 文章作者 - 关联到Django User模型
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
blank=False,
null=False,
on_delete=models.CASCADE)
# ========== 排序和显示字段 ==========
# 文章排序值 - 值越大越靠前显示,用于置顶
article_order = models.IntegerField(
_('order'), blank=False, null=False, default=0)
# 是否显示文章目录(Table of Contents)
show_toc = models.BooleanField(_('show toc'), blank=False, null=False, default=False)
# 文章所属分类 - 外键关联Category必填
category = models.ForeignKey(
'Category',
verbose_name=_('category'),
on_delete=models.CASCADE,
blank=False,
null=False)
# 文章标签 - 多对多关系,一篇文章可有多个标签,可选
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:
# 排序先按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
URL格式: /article/YYYY/MM/DD/article_id.html
这样的格式便于SEO和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):
"""
获取该文章分类的完整分类树从当前分类到根分类
返回格式: [(分类名, 分类URL), ...]
缓存时间: 10小时
"""
tree = self.category.get_category_tree()
# 提取分类名和URL
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):
"""
记录文章被浏览 - 增加阅读计数
使用update()方法直接更新views字段提高性能不经过save()方法
"""
self.views += 1
self.save(update_fields=['views'])
def comment_list(self):
"""
获取文章的评论列表只包含已启用的评论
使用缓存避免重复查询缓存时间: 100分钟
返回: QuerySet对象包含所有启用的评论按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:
# 缓存未命中,从数据库查询
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):
"""获取Django Admin中该文章的编辑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):
"""
获取下一篇文章ID更大且已发布的文章
缓存时间: 100分钟
返回: Article对象或None
"""
return Article.objects.filter(
id__gt=self.id, status='p').order_by('id').first()
@cache_decorator(expiration=60 * 100)
def prev_article(self):
"""
获取上一篇文章ID更小且已发布的文章
缓存时间: 100分钟
返回: Article对象或None
"""
return Article.objects.filter(id__lt=self.id, status='p').first()
def get_first_image_url(self):
"""
从文章Markdown内容中提取第一张图片的URL
使用正则表达式匹配Markdown图片语法: ![alt](url)
返回: 图片URL字符串如果没有找到则返回空字符串
"""
# 正则表达式匹配Markdown图片语法
match = re.search(r'!\[.*?\]\((.+?)\)', self.body)
if match:
return match.group(1)
return ""
# ==========================================
# 文章分类模型
# ==========================================
class Category(BaseModel):
"""
文章分类模型
支持多级分类结构通过parent_category实现自引用的树形结构
"""
# 分类名称 - 唯一最多30字符
name = models.CharField(_('category name'), max_length=30, unique=True)
# 父分类 - 自引用,支持无限级分类,可选
parent_category = models.ForeignKey(
'self',
verbose_name=_('parent category'),
blank=True,
null=True,
on_delete=models.CASCADE)
# URL友好的标识符 - 自动生成用于URL中
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
# 排序索引 - 越大越靠前显示
index = models.IntegerField(default=0, verbose_name=_('index'))
class Meta:
# 按index降序排列排序值大的在前
ordering = ['-index']
verbose_name = _('category')
verbose_name_plural = verbose_name
def get_absolute_url(self):
"""
获取分类页面的相对URL
URL格式: /category/{slug}.html
"""
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):
"""
递归获取该分类的完整分类树从该分类到根分类
返回分类链表最后一个是根分类
缓存时间: 10小时
问题如果存在循环引用某个分类的parent指向自己或形成环
会导致无限递归应该添加深度限制和循环检测
"""
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):
"""
获取该分类的所有子分类包括嵌套的子分类
返回所有直接和间接子分类
缓存时间: 10小时
问题逻辑有误第235行检查的是category而非child
当childs为空时child未定义会导致UnboundLocalError
"""
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:
# 【问题】检查的是category而非child
if category not in categorys:
categorys.append(child)
# 【问题】当childs为空时child未定义
parse(child)
parse(self)
return categorys
# ==========================================
# 文章标签模型
# ==========================================
class Tag(BaseModel):
"""
文章标签模型
用于给文章添加灵活的分类标签相比分类更加灵活
"""
# 标签名称 - 唯一最多30字符
name = models.CharField(_('tag name'), max_length=30, unique=True)
# URL友好的标识符 - 自动生成
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
def __str__(self):
"""字符串表示 - 显示标签名称"""
return self.name
def get_absolute_url(self):
"""
获取标签页面的相对URL
URL格式: /tag/{slug}.html
"""
return reverse('blog:tag_detail', kwargs={'tag_name': self.slug})
@cache_decorator(60 * 60 * 10)
def get_article_count(self):
"""
获取该标签下的文章数量
使用distinct()是因为一篇文章可能有多个标签
缓存时间: 10小时
返回: 文章数量
"""
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):
"""
友情链接模型
用于管理博客的友情链接支持在不同页面显示
"""
# 链接名称 - 唯一最多30字符
name = models.CharField(_('link name'), max_length=30, unique=True)
# 链接URL地址
link = models.URLField(_('link'))
# 排序序号 - 唯一,控制显示顺序(小的排前)
sequence = models.IntegerField(_('order'), unique=True)
# 是否启用显示
is_enable = models.BooleanField(
_('is show'), default=True, blank=False, null=False)
# 显示位置 - 在首页、列表页、文章页等位置显示
show_type = models.CharField(
_('show type'),
max_length=1,
choices=LinkShowType.choices,
default=LinkShowType.I)
# 创建时间
creation_time = models.DateTimeField(_('creation time'), default=now)
# 最后修改时间
last_mod_time = models.DateTimeField(_('modify time'), default=now)
class Meta:
# 按sequence升序排列小的排前
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)
# 侧边栏内容 - 支持HTML
content = models.TextField(_('content'))
# 排序序号 - 唯一,控制显示顺序(小的排前)
sequence = models.IntegerField(_('order'), unique=True)
# 是否启用显示
is_enable = models.BooleanField(_('is enable'), default=True)
# 创建时间
creation_time = models.DateTimeField(_('creation time'), default=now)
# 最后修改时间
last_mod_time = models.DateTimeField(_('modify time'), default=now)
class Meta:
# 按sequence升序排列
ordering = ['sequence']
verbose_name = _('sidebar')
verbose_name_plural = verbose_name
def __str__(self):
"""字符串表示 - 显示侧边栏名称"""
return self.name
# ==========================================
# 博客全局设置模型
# ==========================================
class BlogSettings(models.Model):
"""
博客全局配置模型
该模型应该只有一条记录包含所有博客全局配置
通过clean()方法确保只有一条记录
"""
# ========== 基本信息字段 ==========
# 网站名称
site_name = models.CharField(
_('site name'),
max_length=200,
null=False,
blank=False,
default='')
# 网站简介/描述
site_description = models.TextField(
_('site description'),
max_length=1000,
null=False,
blank=False,
default='')
# SEO描述用于meta description标签
site_seo_description = models.TextField(
_('site seo description'), max_length=1000, null=False, blank=False, default='')
# 网站关键词用于SEO meta keywords标签
site_keywords = models.TextField(
_('site keywords'),
max_length=1000,
null=False,
blank=False,
default='')
# ========== 文章显示配置 ==========
# 文章摘要长度(首页列表显示的字数)
article_sub_length = models.IntegerField(_('article sub length'), default=300)
# 侧边栏显示的最新文章数
sidebar_article_count = models.IntegerField(_('sidebar article count'), default=10)
# 侧边栏显示的最新评论数
sidebar_comment_count = models.IntegerField(_('sidebar comment count'), default=5)
# 文章详情页显示的评论数
article_comment_count = models.IntegerField(_('article comment count'), default=5)
# ========== Google AdSense广告配置 ==========
# 是否显示Google AdSense广告
show_google_adsense = models.BooleanField(_('show adsense'), default=False)
# Google AdSense代码
google_adsense_codes = models.TextField(
_('adsense code'), max_length=2000, null=True, blank=True, default='')
# ========== 评论配置 ==========
# 是否开放网站评论功能
open_site_comment = models.BooleanField(_('open site comment'), default=True)
# 评论是否需要管理员审核false:直接发布 true:需要审核)
comment_need_review = models.BooleanField(
'评论是否需要审核', default=False, null=False)
# ========== 公共HTML代码 ==========
# 公共头部HTML会显示在所有页面的<head>中)
global_header = models.TextField("公共头部", null=True, blank=True, default='')
# 公共尾部HTML会显示在所有页面的底部
global_footer = models.TextField("公共尾部", null=True, blank=True, default='')
# ========== ICP备案信息中国特有 ==========
# ICP备案号
beian_code = models.CharField(
'备案号',
max_length=2000,
null=True,
blank=True,
default='')
# ========== 网站分析/统计代码 ==========
# 网站统计代码如Google Analytics、百度统计等JavaScript代码
analytics_code = models.TextField(
"网站统计代码",
max_length=1000,
null=False,
blank=False,
default='')
# ========== 公安备案信息(中国特有) ==========
# 是否显示公安备案号
show_gongan_code = models.BooleanField(
'是否显示公安备案号', default=False, null=False)
# 公安备案号
gongan_beiancode = models.TextField(
'公安备案号',
max_length=2000,
null=True,
blank=True,
default='')
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()

@ -0,0 +1,43 @@
#ly:
# coding:utf-8
# ==========================================
# Blog模块 - Haystack搜索索引配置
# ==========================================
from haystack import indexes
from blog.models import Article
# ==========================================
# 文章搜索索引
# ==========================================
class ArticleIndex(indexes.SearchIndex, indexes.Indexable):
"""
文章搜索索引定义
用于Haystack搜索框架支持Whoosh等本地搜索引擎
索引的数据来自模板search/indexes/blog/article_text.txt
"""
# 文档字段 - 搜索的主要内容
# use_template=True 表示从模板文件获取内容
text = indexes.CharField(document=True, use_template=True)
def get_model(self):
"""
获取要索引的模型
返回: Article模型
"""
return Article
def index_queryset(self, using=None):
"""
获取要被索引的QuerySet
只索引已发布的文章status='p'
参数:
using: 搜索后端名称可选
返回:
QuerySet对象
"""
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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 25 KiB

@ -0,0 +1,91 @@
/**
* Created by liangliang on 2016/11/20.
*/
function do_reply(parentid) {
console.log(parentid);
$("#id_parent_comment_id").val(parentid)
$("#commentform").appendTo($("#div-comment-" + parentid));
$("#reply-title").hide();
$("#cancel_comment").show();
}
function cancel_reply() {
$("#reply-title").show();
$("#cancel_comment").hide();
$("#id_parent_comment_id").val('')
$("#commentform").appendTo($("#respond"));
}
NProgress.start();
NProgress.set(0.4);
//Increment
var interval = setInterval(function () {
NProgress.inc();
}, 1000);
$(document).ready(function () {
NProgress.done();
clearInterval(interval);
});
/** 侧边栏回到顶部 */
var rocket = $('#rocket');
$(window).on('scroll', debounce(slideTopSet, 300));
function debounce(func, wait) {
var timeout;
return function () {
clearTimeout(timeout);
timeout = setTimeout(func, wait);
};
}
function slideTopSet() {
var top = $(document).scrollTop();
if (top > 200) {
rocket.addClass('show');
} else {
rocket.removeClass('show');
}
}
$(document).on('click', '#rocket', function (event) {
rocket.addClass('move');
$('body, html').animate({
scrollTop: 0
}, 800);
});
$(document).on('animationEnd', function () {
setTimeout(function () {
rocket.removeClass('move');
}, 400);
});
$(document).on('webkitAnimationEnd', function () {
setTimeout(function () {
rocket.removeClass('move');
}, 400);
});
window.onload = function () {
var replyLinks = document.querySelectorAll(".comment-reply-link");
for (var i = 0; i < replyLinks.length; i++) {
replyLinks[i].onclick = function () {
var pk = this.getAttribute("data-pk");
do_reply(pk);
};
}
};
// $(document).ready(function () {
// var form = $('#i18n-form');
// var selector = $('.i18n-select');
// selector.on('change', function () {
// form.submit();
// });
// });

@ -0,0 +1,8 @@
/*
HTML5 Shiv v3.7.0 | @afarkas @jdalton @jon_neal @rem | MIT/GPL2 Licensed
*/
(function(l,f){function m(){var a=e.elements;return"string"==typeof a?a.split(" "):a}function i(a){var b=n[a[o]];b||(b={},h++,a[o]=h,n[h]=b);return b}function p(a,b,c){b||(b=f);if(g)return b.createElement(a);c||(c=i(b));b=c.cache[a]?c.cache[a].cloneNode():r.test(a)?(c.cache[a]=c.createElem(a)).cloneNode():c.createElem(a);return b.canHaveChildren&&!s.test(a)?c.frag.appendChild(b):b}function t(a,b){if(!b.cache)b.cache={},b.createElem=a.createElement,b.createFrag=a.createDocumentFragment,b.frag=b.createFrag();
a.createElement=function(c){return!e.shivMethods?b.createElem(c):p(c,a,b)};a.createDocumentFragment=Function("h,f","return function(){var n=f.cloneNode(),c=n.createElement;h.shivMethods&&("+m().join().replace(/[\w\-]+/g,function(a){b.createElem(a);b.frag.createElement(a);return'c("'+a+'")'})+");return n}")(e,b.frag)}function q(a){a||(a=f);var b=i(a);if(e.shivCSS&&!j&&!b.hasCSS){var c,d=a;c=d.createElement("p");d=d.getElementsByTagName("head")[0]||d.documentElement;c.innerHTML="x<style>article,aside,dialog,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}mark{background:#FF0;color:#000}template{display:none}</style>";
c=d.insertBefore(c.lastChild,d.firstChild);b.hasCSS=!!c}g||t(a,b);return a}var k=l.html5||{},s=/^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i,r=/^(?:a|b|code|div|fieldset|h1|h2|h3|h4|h5|h6|i|label|li|ol|p|q|span|strong|style|table|tbody|td|th|tr|ul)$/i,j,o="_html5shiv",h=0,n={},g;(function(){try{var a=f.createElement("a");a.innerHTML="<xyz></xyz>";j="hidden"in a;var b;if(!(b=1==a.childNodes.length)){f.createElement("a");var c=f.createDocumentFragment();b="undefined"==typeof c.cloneNode||
"undefined"==typeof c.createDocumentFragment||"undefined"==typeof c.createElement}g=b}catch(d){g=j=!0}})();var e={elements:k.elements||"abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output progress section summary template time video",version:"3.7.0",shivCSS:!1!==k.shivCSS,supportsUnknownElements:g,shivMethods:!1!==k.shivMethods,type:"default",shivDocument:q,createElement:p,createDocumentFragment:function(a,b){a||(a=f);
if(g)return a.createDocumentFragment();for(var b=b||i(a),c=b.frag.cloneNode(),d=0,e=m(),h=e.length;d<h;d++)c.createElement(e[d]);return c}};l.html5=e;q(f)})(this,document);

File diff suppressed because one or more lines are too long

@ -0,0 +1,55 @@
/**
* Handles toggling the navigation menu for small screens and
* accessibility for submenu items.
*/
( function() {
var nav = document.getElementById( 'site-navigation' ), button, menu;
if ( ! nav ) {
return;
}
button = nav.getElementsByTagName( 'button' )[0];
menu = nav.getElementsByTagName( 'ul' )[0];
if ( ! button ) {
return;
}
// Hide button if menu is missing or empty.
if ( ! menu || ! menu.childNodes.length ) {
button.style.display = 'none';
return;
}
button.onclick = function() {
if ( -1 === menu.className.indexOf( 'nav-menu' ) ) {
menu.className = 'nav-menu';
}
if ( -1 !== button.className.indexOf( 'toggled-on' ) ) {
button.className = button.className.replace( ' toggled-on', '' );
menu.className = menu.className.replace( ' toggled-on', '' );
} else {
button.className += ' toggled-on';
menu.className += ' toggled-on';
}
};
} )();
// Better focus for hidden submenu items for accessibility.
( function( $ ) {
$( '.main-navigation' ).find( 'a' ).on( 'focus.twentytwelve blur.twentytwelve', function() {
$( this ).parents( '.menu-item, .page_item' ).toggleClass( 'focus' );
} );
if ( 'ontouchstart' in window ) {
$('body').on( 'touchstart.twentytwelve', '.menu-item-has-children > a, .page_item_has_children > a', function( e ) {
var el = $( this ).parent( 'li' );
if ( ! el.hasClass( 'focus' ) ) {
e.preventDefault();
el.toggleClass( 'focus' );
el.siblings( '.focus').removeClass( 'focus' );
}
} );
}
} )( jQuery );

@ -0,0 +1,480 @@
/* NProgress, (c) 2013, 2014 Rico Sta. Cruz - http://ricostacruz.com/nprogress
* @license MIT */
;(function(root, factory) {
if (typeof define === 'function' && define.amd) {
define(factory);
} else if (typeof exports === 'object') {
module.exports = factory();
} else {
root.NProgress = factory();
}
})(this, function() {
var NProgress = {};
NProgress.version = '0.2.0';
var Settings = NProgress.settings = {
minimum: 0.08,
easing: 'linear',
positionUsing: '',
speed: 200,
trickle: true,
trickleSpeed: 200,
showSpinner: true,
barSelector: '[role="bar"]',
spinnerSelector: '[role="spinner"]',
parent: 'body',
template: '<div class="bar" role="bar"><div class="peg"></div></div><div class="spinner" role="spinner"><div class="spinner-icon"></div></div>'
};
/**
* Updates configuration.
*
* NProgress.configure({
* minimum: 0.1
* });
*/
NProgress.configure = function(options) {
var key, value;
for (key in options) {
value = options[key];
if (value !== undefined && options.hasOwnProperty(key)) Settings[key] = value;
}
return this;
};
/**
* Last number.
*/
NProgress.status = null;
/**
* Sets the progress bar status, where `n` is a number from `0.0` to `1.0`.
*
* NProgress.set(0.4);
* NProgress.set(1.0);
*/
NProgress.set = function(n) {
var started = NProgress.isStarted();
n = clamp(n, Settings.minimum, 1);
NProgress.status = (n === 1 ? null : n);
var progress = NProgress.render(!started),
bar = progress.querySelector(Settings.barSelector),
speed = Settings.speed,
ease = Settings.easing;
progress.offsetWidth; /* Repaint */
queue(function(next) {
// Set positionUsing if it hasn't already been set
if (Settings.positionUsing === '') Settings.positionUsing = NProgress.getPositioningCSS();
// Add transition
css(bar, barPositionCSS(n, speed, ease));
if (n === 1) {
// Fade out
css(progress, {
transition: 'none',
opacity: 1
});
progress.offsetWidth; /* Repaint */
setTimeout(function() {
css(progress, {
transition: 'all ' + speed + 'ms linear',
opacity: 0
});
setTimeout(function() {
NProgress.remove();
next();
}, speed);
}, speed);
} else {
setTimeout(next, speed);
}
});
return this;
};
NProgress.isStarted = function() {
return typeof NProgress.status === 'number';
};
/**
* Shows the progress bar.
* This is the same as setting the status to 0%, except that it doesn't go backwards.
*
* NProgress.start();
*
*/
NProgress.start = function() {
if (!NProgress.status) NProgress.set(0);
var work = function() {
setTimeout(function() {
if (!NProgress.status) return;
NProgress.trickle();
work();
}, Settings.trickleSpeed);
};
if (Settings.trickle) work();
return this;
};
/**
* Hides the progress bar.
* This is the *sort of* the same as setting the status to 100%, with the
* difference being `done()` makes some placebo effect of some realistic motion.
*
* NProgress.done();
*
* If `true` is passed, it will show the progress bar even if its hidden.
*
* NProgress.done(true);
*/
NProgress.done = function(force) {
if (!force && !NProgress.status) return this;
return NProgress.inc(0.3 + 0.5 * Math.random()).set(1);
};
/**
* Increments by a random amount.
*/
NProgress.inc = function(amount) {
var n = NProgress.status;
if (!n) {
return NProgress.start();
} else if(n > 1) {
} else {
if (typeof amount !== 'number') {
if (n >= 0 && n < 0.2) { amount = 0.1; }
else if (n >= 0.2 && n < 0.5) { amount = 0.04; }
else if (n >= 0.5 && n < 0.8) { amount = 0.02; }
else if (n >= 0.8 && n < 0.99) { amount = 0.005; }
else { amount = 0; }
}
n = clamp(n + amount, 0, 0.994);
return NProgress.set(n);
}
};
NProgress.trickle = function() {
return NProgress.inc();
};
/**
* Waits for all supplied jQuery promises and
* increases the progress as the promises resolve.
*
* @param $promise jQUery Promise
*/
(function() {
var initial = 0, current = 0;
NProgress.promise = function($promise) {
if (!$promise || $promise.state() === "resolved") {
return this;
}
if (current === 0) {
NProgress.start();
}
initial++;
current++;
$promise.always(function() {
current--;
if (current === 0) {
initial = 0;
NProgress.done();
} else {
NProgress.set((initial - current) / initial);
}
});
return this;
};
})();
/**
* (Internal) renders the progress bar markup based on the `template`
* setting.
*/
NProgress.render = function(fromStart) {
if (NProgress.isRendered()) return document.getElementById('nprogress');
addClass(document.documentElement, 'nprogress-busy');
var progress = document.createElement('div');
progress.id = 'nprogress';
progress.innerHTML = Settings.template;
var bar = progress.querySelector(Settings.barSelector),
perc = fromStart ? '-100' : toBarPerc(NProgress.status || 0),
parent = document.querySelector(Settings.parent),
spinner;
css(bar, {
transition: 'all 0 linear',
transform: 'translate3d(' + perc + '%,0,0)'
});
if (!Settings.showSpinner) {
spinner = progress.querySelector(Settings.spinnerSelector);
spinner && removeElement(spinner);
}
if (parent != document.body) {
addClass(parent, 'nprogress-custom-parent');
}
parent.appendChild(progress);
return progress;
};
/**
* Removes the element. Opposite of render().
*/
NProgress.remove = function() {
removeClass(document.documentElement, 'nprogress-busy');
removeClass(document.querySelector(Settings.parent), 'nprogress-custom-parent');
var progress = document.getElementById('nprogress');
progress && removeElement(progress);
};
/**
* Checks if the progress bar is rendered.
*/
NProgress.isRendered = function() {
return !!document.getElementById('nprogress');
};
/**
* Determine which positioning CSS rule to use.
*/
NProgress.getPositioningCSS = function() {
// Sniff on document.body.style
var bodyStyle = document.body.style;
// Sniff prefixes
var vendorPrefix = ('WebkitTransform' in bodyStyle) ? 'Webkit' :
('MozTransform' in bodyStyle) ? 'Moz' :
('msTransform' in bodyStyle) ? 'ms' :
('OTransform' in bodyStyle) ? 'O' : '';
if (vendorPrefix + 'Perspective' in bodyStyle) {
// Modern browsers with 3D support, e.g. Webkit, IE10
return 'translate3d';
} else if (vendorPrefix + 'Transform' in bodyStyle) {
// Browsers without 3D support, e.g. IE9
return 'translate';
} else {
// Browsers without translate() support, e.g. IE7-8
return 'margin';
}
};
/**
* Helpers
*/
function clamp(n, min, max) {
if (n < min) return min;
if (n > max) return max;
return n;
}
/**
* (Internal) converts a percentage (`0..1`) to a bar translateX
* percentage (`-100%..0%`).
*/
function toBarPerc(n) {
return (-1 + n) * 100;
}
/**
* (Internal) returns the correct CSS for changing the bar's
* position given an n percentage, and speed and ease from Settings
*/
function barPositionCSS(n, speed, ease) {
var barCSS;
if (Settings.positionUsing === 'translate3d') {
barCSS = { transform: 'translate3d('+toBarPerc(n)+'%,0,0)' };
} else if (Settings.positionUsing === 'translate') {
barCSS = { transform: 'translate('+toBarPerc(n)+'%,0)' };
} else {
barCSS = { 'margin-left': toBarPerc(n)+'%' };
}
barCSS.transition = 'all '+speed+'ms '+ease;
return barCSS;
}
/**
* (Internal) Queues a function to be executed.
*/
var queue = (function() {
var pending = [];
function next() {
var fn = pending.shift();
if (fn) {
fn(next);
}
}
return function(fn) {
pending.push(fn);
if (pending.length == 1) next();
};
})();
/**
* (Internal) Applies css properties to an element, similar to the jQuery
* css method.
*
* While this helper does assist with vendor prefixed property names, it
* does not perform any manipulation of values prior to setting styles.
*/
var css = (function() {
var cssPrefixes = [ 'Webkit', 'O', 'Moz', 'ms' ],
cssProps = {};
function camelCase(string) {
return string.replace(/^-ms-/, 'ms-').replace(/-([\da-z])/gi, function(match, letter) {
return letter.toUpperCase();
});
}
function getVendorProp(name) {
var style = document.body.style;
if (name in style) return name;
var i = cssPrefixes.length,
capName = name.charAt(0).toUpperCase() + name.slice(1),
vendorName;
while (i--) {
vendorName = cssPrefixes[i] + capName;
if (vendorName in style) return vendorName;
}
return name;
}
function getStyleProp(name) {
name = camelCase(name);
return cssProps[name] || (cssProps[name] = getVendorProp(name));
}
function applyCss(element, prop, value) {
prop = getStyleProp(prop);
element.style[prop] = value;
}
return function(element, properties) {
var args = arguments,
prop,
value;
if (args.length == 2) {
for (prop in properties) {
value = properties[prop];
if (value !== undefined && properties.hasOwnProperty(prop)) applyCss(element, prop, value);
}
} else {
applyCss(element, args[1], args[2]);
}
}
})();
/**
* (Internal) Determines if an element or space separated list of class names contains a class name.
*/
function hasClass(element, name) {
var list = typeof element == 'string' ? element : classList(element);
return list.indexOf(' ' + name + ' ') >= 0;
}
/**
* (Internal) Adds a class to an element.
*/
function addClass(element, name) {
var oldList = classList(element),
newList = oldList + name;
if (hasClass(oldList, name)) return;
// Trim the opening space.
element.className = newList.substring(1);
}
/**
* (Internal) Removes a class from an element.
*/
function removeClass(element, name) {
var oldList = classList(element),
newList;
if (!hasClass(element, name)) return;
// Replace the class name.
newList = oldList.replace(' ' + name + ' ', ' ');
// Trim the opening and closing spaces.
element.className = newList.substring(1, newList.length - 1);
}
/**
* (Internal) Gets a space separated list of the class names on the element.
* The list is wrapped with a single space on each end to facilitate finding
* matches within the list.
*/
function classList(element) {
return (' ' + (element && element.className || '') + ' ').replace(/\s+/gi, ' ');
}
/**
* (Internal) Removes an element from the DOM.
*/
function removeElement(element) {
element && element.parentNode && element.parentNode.removeChild(element);
}
return NProgress;
});

@ -0,0 +1,21 @@
$(function () {
MathJax.Hub.Config({
showProcessingMessages: false, //关闭js加载过程信息
messageStyle: "none", //不显示信息
extensions: ["tex2jax.js"], jax: ["input/TeX", "output/HTML-CSS"], displayAlign: "left", tex2jax: {
inlineMath: [["$", "$"]], //行内公式选择$
displayMath: [["$$", "$$"]], //段内公式选择$$
skipTags: ['script', 'noscript', 'style', 'textarea', 'pre', 'code', 'a'], //避开某些标签
}, "HTML-CSS": {
availableFonts: ["STIX", "TeX"], //可选字体
showMathMenu: false //关闭右击菜单显示
}
});
// 识别范围 => 文章内容、评论内容标签
const contentId = document.getElementById("content");
const commentId = document.getElementById("comments");
MathJax.Hub.Queue(["Typeset", MathJax.Hub, contentId, commentId]);
})

@ -0,0 +1,293 @@
.codehilite .hll {
background-color: #ffffcc
}
.codehilite {
background: #ffffff;
}
.codehilite .c {
color: #177500
}
/* Comment */
.codehilite .err {
color: #000000
}
/* Error */
.codehilite .k {
color: #A90D91
}
/* Keyword */
.codehilite .l {
color: #1C01CE
}
/* Literal */
.codehilite .n {
color: #000000
}
/* Name */
.codehilite .o {
color: #000000
}
/* Operator */
.codehilite .ch {
color: #177500
}
/* Comment.Hashbang */
.codehilite .cm {
color: #177500
}
/* Comment.Multiline */
.codehilite .cp {
color: #633820
}
/* Comment.Preproc */
.codehilite .cpf {
color: #177500
}
/* Comment.PreprocFile */
.codehilite .c1 {
color: #177500
}
/* Comment.Single */
.codehilite .cs {
color: #177500
}
/* Comment.Special */
.codehilite .kc {
color: #A90D91
}
/* Keyword.Constant */
.codehilite .kd {
color: #A90D91
}
/* Keyword.Declaration */
.codehilite .kn {
color: #A90D91
}
/* Keyword.Namespace */
.codehilite .kp {
color: #A90D91
}
/* Keyword.Pseudo */
.codehilite .kr {
color: #A90D91
}
/* Keyword.Reserved */
.codehilite .kt {
color: #A90D91
}
/* Keyword.Type */
.codehilite .ld {
color: #1C01CE
}
/* Literal.Date */
.codehilite .m {
color: #1C01CE
}
/* Literal.Number */
.codehilite .s {
color: #C41A16
}
/* Literal.String */
.codehilite .na {
color: #836C28
}
/* Name.Attribute */
.codehilite .nb {
color: #A90D91
}
/* Name.Builtin */
.codehilite .nc {
color: #3F6E75
}
/* Name.Class */
.codehilite .no {
color: #000000
}
/* Name.Constant */
.codehilite .nd {
color: #000000
}
/* Name.Decorator */
.codehilite .ni {
color: #000000
}
/* Name.Entity */
.codehilite .ne {
color: #000000
}
/* Name.Exception */
.codehilite .nf {
color: #000000
}
/* Name.Function */
.codehilite .nl {
color: #000000
}
/* Name.Label */
.codehilite .nn {
color: #000000
}
/* Name.Namespace */
.codehilite .nx {
color: #000000
}
/* Name.Other */
.codehilite .py {
color: #000000
}
/* Name.Property */
.codehilite .nt {
color: #000000
}
/* Name.Tag */
.codehilite .nv {
color: #000000
}
/* Name.Variable */
.codehilite .ow {
color: #000000
}
/* Operator.Word */
.codehilite .mb {
color: #1C01CE
}
/* Literal.Number.Bin */
.codehilite .mf {
color: #1C01CE
}
/* Literal.Number.Float */
.codehilite .mh {
color: #1C01CE
}
/* Literal.Number.Hex */
.codehilite .mi {
color: #1C01CE
}
/* Literal.Number.Integer */
.codehilite .mo {
color: #1C01CE
}
/* Literal.Number.Oct */
.codehilite .sb {
color: #C41A16
}
/* Literal.String.Backtick */
.codehilite .sc {
color: #2300CE
}
/* Literal.String.Char */
.codehilite .sd {
color: #C41A16
}
/* Literal.String.Doc */
.codehilite .s2 {
color: #C41A16
}
/* Literal.String.Double */
.codehilite .se {
color: #C41A16
}
/* Literal.String.Escape */
.codehilite .sh {
color: #C41A16
}
/* Literal.String.Heredoc */
.codehilite .si {
color: #C41A16
}
/* Literal.String.Interpol */
.codehilite .sx {
color: #C41A16
}
/* Literal.String.Other */
.codehilite .sr {
color: #C41A16
}
/* Literal.String.Regex */
.codehilite .s1 {
color: #C41A16
}
/* Literal.String.Single */
.codehilite .ss {
color: #C41A16
}
/* Literal.String.Symbol */
.codehilite .bp {
color: #5B269A
}
/* Name.Builtin.Pseudo */
.codehilite .vc {
color: #000000
}
/* Name.Variable.Class */
.codehilite .vg {
color: #000000
}
/* Name.Variable.Global */
.codehilite .vi {
color: #000000
}
/* Name.Variable.Instance */
.codehilite .il {
color: #1C01CE
}
/* Literal.Number.Integer.Long */

@ -0,0 +1,346 @@
#ly:
# coding:utf-8
import hashlib
import logging
import random
import urllib
from django import template
from django.conf import settings
from django.db.models import Q
from django.shortcuts import get_object_or_404
from django.template.defaultfilters import stringfilter
from django.templatetags.static import static
from django.urls import reverse
from django.utils.safestring import mark_safe
from blog.models import Article, Category, Tag, Links, SideBar, LinkShowType
from comments.models import Comment
from djangoblog.utils import CommonMarkdown, sanitize_html
from djangoblog.utils import cache
from djangoblog.utils import get_current_site
from oauth.models import OAuthUser
from djangoblog.plugin_manage import hooks
logger = logging.getLogger(__name__)
register = template.Library()
@register.simple_tag(takes_context=True)
def head_meta(context):
return mark_safe(hooks.apply_filters('head_meta', '', context))
@register.simple_tag
def timeformat(data):
try:
return data.strftime(settings.TIME_FORMAT)
except Exception as e:
logger.error(e)
return ""
@register.simple_tag
def datetimeformat(data):
try:
return data.strftime(settings.DATE_TIME_FORMAT)
except Exception as e:
logger.error(e)
return ""
@register.filter()
@stringfilter
def custom_markdown(content):
return mark_safe(CommonMarkdown.get_markdown(content))
@register.simple_tag
def get_markdown_toc(content):
from djangoblog.utils import CommonMarkdown
body, toc = CommonMarkdown.get_markdown_with_toc(content)
return mark_safe(toc)
@register.filter()
@stringfilter
def comment_markdown(content):
content = CommonMarkdown.get_markdown(content)
return mark_safe(sanitize_html(content))
@register.filter(is_safe=True)
@stringfilter
def truncatechars_content(content):
"""
获得文章内容的摘要
:param content:
:return:
"""
from django.template.defaultfilters import truncatechars_html
from djangoblog.utils import get_blog_setting
blogsetting = get_blog_setting()
return truncatechars_html(content, blogsetting.article_sub_length)
@register.filter(is_safe=True)
@stringfilter
def truncate(content):
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:
"""
names = article.get_category_tree()
from djangoblog.utils import get_blog_setting
blogsetting = get_blog_setting()
site = get_current_site().domain
names.append((blogsetting.site_name, '/'))
names = names[::-1]
return {
'names': names,
'title': article.title,
'count': len(names) + 1
}
@register.inclusion_tag('blog/tags/article_tag_list.html')
def load_articletags(article):
"""
文章标签
:param article:
:return:
"""
tags = article.tags.all()
tags_list = []
for tag in tags:
url = tag.get_absolute_url()
count = tag.get_article_count()
tags_list.append((
url, count, tag, random.choice(settings.BOOTSTRAP_COLOR_TYPES)
))
return {
'article_tags_list': tags_list
}
@register.inclusion_tag('blog/tags/sidebar.html')
def load_sidebar(user, linktype):
"""
加载侧边栏
:return:
"""
value = cache.get("sidebar" + linktype)
if value:
value['user'] = user
return value
else:
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)
value = {
'recent_articles': recent_articles,
'sidebar_categorys': sidebar_categorys,
'most_read_articles': most_read_articles,
'article_dates': dates,
'sidebar_comments': commment_list,
'sidabar_links': links,
'show_google_adsense': blogsetting.show_google_adsense,
'google_adsense_codes': blogsetting.google_adsense_codes,
'open_site_comment': blogsetting.open_site_comment,
'show_gongan_code': blogsetting.show_gongan_code,
'sidebar_tags': sidebar_tags,
'extra_sidebars': extra_sidebars
}
cache.set("sidebar" + linktype, value, 60 * 60 * 60 * 3)
logger.info('set sidebar cache.key:{key}'.format(key="sidebar" + linktype))
value['user'] = user
return value
@register.inclusion_tag('blog/tags/article_meta_info.html')
def load_article_metas(article, user):
"""
获得文章meta信息
:param article:
:return:
"""
return {
'article': article,
'user': user
}
@register.inclusion_tag('blog/tags/article_pagination.html')
def load_pagination_info(page_obj, page_type, tag_name):
previous_url = ''
next_url = ''
if page_type == '':
if page_obj.has_next():
next_number = page_obj.next_page_number()
next_url = reverse('blog:index_page', kwargs={'page': next_number})
if page_obj.has_previous():
previous_number = page_obj.previous_page_number()
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():
next_number = page_obj.next_page_number()
next_url = reverse(
'blog:tag_detail_page',
kwargs={
'page': next_number,
'tag_name': tag.slug})
if page_obj.has_previous():
previous_number = page_obj.previous_page_number()
previous_url = reverse(
'blog:tag_detail_page',
kwargs={
'page': previous_number,
'tag_name': tag.slug})
if page_type == '作者文章归档':
if page_obj.has_next():
next_number = page_obj.next_page_number()
next_url = reverse(
'blog:author_detail_page',
kwargs={
'page': next_number,
'author_name': tag_name})
if page_obj.has_previous():
previous_number = page_obj.previous_page_number()
previous_url = reverse(
'blog:author_detail_page',
kwargs={
'page': previous_number,
'author_name': tag_name})
if page_type == '分类目录归档':
category = get_object_or_404(Category, name=tag_name)
if page_obj.has_next():
next_number = page_obj.next_page_number()
next_url = reverse(
'blog:category_detail_page',
kwargs={
'page': next_number,
'category_name': category.slug})
if page_obj.has_previous():
previous_number = page_obj.previous_page_number()
previous_url = reverse(
'blog:category_detail_page',
kwargs={
'page': previous_number,
'category_name': category.slug})
return {
'previous_url': previous_url,
'next_url': next_url,
'page_obj': page_obj
}
@register.inclusion_tag('blog/tags/article_info.html')
def load_article_detail(article, isindex, user):
"""
加载文章详情
:param article:
:param isindex:是否列表页若是列表页只显示摘要
:return:
"""
from djangoblog.utils import get_blog_setting
blogsetting = get_blog_setting()
return {
'article': article,
'isindex': isindex,
'user': user,
'open_site_comment': blogsetting.open_site_comment,
}
# return only the URL of the gravatar
# TEMPLATE USE: {{ email|gravatar_url:150 }}
@register.filter
def gravatar_url(email, size=40):
"""获得gravatar头像"""
cachekey = 'gravatat/' + email
url = cache.get(cachekey)
if url:
return url
else:
usermodels = OAuthUser.objects.filter(email=email)
if usermodels:
o = list(filter(lambda x: x.picture is not None, usermodels))
if o:
return o[0].picture
email = email.encode('utf-8')
default = static('blog/img/avatar.png')
url = "https://www.gravatar.com/avatar/%s?%s" % (hashlib.md5(
email.lower()).hexdigest(), urllib.parse.urlencode({'d': default, 's': str(size)}))
cache.set(cachekey, url, 60 * 60 * 10)
logger.info('set gravatar cache.key:{key}'.format(key=cachekey))
return url
@register.filter
def gravatar(email, size=40):
"""获得gravatar头像"""
url = gravatar_url(email, size)
return mark_safe(
'<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 %}
"""
return qs.filter(**kwargs)
@register.filter
def addstr(arg1, arg2):
"""concatenate arg1 & arg2"""
return str(arg1) + str(arg2)

@ -0,0 +1,234 @@
#ly:
# coding:utf-8
import os
from django.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.management import call_command
from django.core.paginator import Paginator
from django.templatetags.static import static
from django.test import Client, RequestFactory, TestCase
from django.urls import reverse
from django.utils import timezone
from accounts.models import BlogUser
from blog.forms import BlogSearchForm
from blog.models import Article, Category, Tag, SideBar, Links
from blog.templatetags.blog_tags import load_pagination_info, load_articletags
from djangoblog.utils import get_current_site, get_sha256
from oauth.models import OAuthUser, OAuthConfig
# Create your tests here.
class ArticleTest(TestCase):
def setUp(self):
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]
user.set_password("liangliangyy")
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'
s.content = 'test content'
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"
article.author = user
article.category = category
article.type = 'a'
article.status = 'p'
article.save()
self.assertEqual(0, article.tags.count())
article.tags.add(tag)
article.save()
self.assertEqual(1, article.tags.count())
for i in range(20):
article = Article()
article.title = "nicetitle" + str(i)
article.body = "nicetitle" + str(i)
article.author = user
article.category = category
article.type = 'a'
article.status = 'p'
article.save()
article.tags.add(tag)
article.save()
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, '', '')
p = Paginator(Article.objects.filter(tags=tag), settings.PAGINATE_BY)
self.check_pagination(p, '分类标签归档', tag.slug)
p = Paginator(
Article.objects.filter(
author__username='liangliangyy'), settings.PAGINATE_BY)
self.check_pagination(p, '作者文章归档', 'liangliangyy')
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",
link='https://wwww.lylinux.net')
link.save()
response = self.client.get('/links.html')
self.assertEqual(response.status_code, 200)
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(
'python.png', file.read(), content_type='image/jpg')
form_data = {'python.png': imgfile}
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]
user.set_password("liangliangyy")
user.is_staff = True
user.is_superuser = True
user.save()
c = OAuthConfig()
c.type = 'qq'
c.appkey = 'appkey'
c.appsecret = 'appsecret'
c.save()
u = OAuthUser()
u.type = 'qq'
u.openid = 'openid'
u.user = user
u.picture = static("/blog/img/avatar.png")
u.metadata = '''
{
"figureurl": "https://qzapp.qlogo.cn/qzapp/101513904/C740E30B4113EAA80E0D9918ABC78E82/30"
}'''
u.save()
u = OAuthUser()
u.type = 'qq'
u.openid = 'openid1'
u.picture = 'https://qzapp.qlogo.cn/qzapp/101513904/C740E30B4113EAA80E0D9918ABC78E82/30'
u.metadata = '''
{
"figureurl": "https://qzapp.qlogo.cn/qzapp/101513904/C740E30B4113EAA80E0D9918ABC78E82/30"
}'''
u.save()
from blog.documents import ELASTICSEARCH_ENABLED
if ELASTICSEARCH_ENABLED:
call_command("build_index")
call_command("ping_baidu", "all")
call_command("create_testdata")
call_command("clear_cache")
call_command("sync_user_avatar")
call_command("build_search_words")

@ -0,0 +1,99 @@
#ly:
# coding:utf-8
# ==========================================
# Blog模块 - URL路由配置
# ==========================================
from django.urls import path
from django.views.decorators.cache import cache_page
from . import views
# 应用命名空间 - 用于URL反向查询
app_name = "blog"
# URL路由配置
urlpatterns = [
# ========== 首页路由 ==========
# 首页(第一页)
path(
'',
views.IndexView.as_view(),
name='index'),
# 首页分页第N页
path(
'page/<int:page>/',
views.IndexView.as_view(),
name='index_page'),
# ========== 文章详情页路由 ==========
# URL格式: /article/YYYY/MM/DD/article_id.html
# 例如: /article/2024/01/15/123.html
path(
'article/<int:year>/<int:month>/<int:day>/<int:article_id>.html',
views.ArticleDetailView.as_view(),
name='detailbyid'),
# ========== 分类页路由 ==========
# 某个分类的文章列表(第一页)
path(
'category/<slug:category_name>.html',
views.CategoryDetailView.as_view(),
name='category_detail'),
# 某个分类的文章列表分页第N页
path(
'category/<slug:category_name>/<int:page>.html',
views.CategoryDetailView.as_view(),
name='category_detail_page'),
# ========== 作者页路由 ==========
# 某个作者的文章列表(第一页)
path(
'author/<author_name>.html',
views.AuthorDetailView.as_view(),
name='author_detail'),
# 某个作者的文章列表分页第N页
path(
'author/<author_name>/<int:page>.html',
views.AuthorDetailView.as_view(),
name='author_detail_page'),
# ========== 标签页路由 ==========
# 某个标签的文章列表(第一页)
path(
'tag/<slug:tag_name>.html',
views.TagDetailView.as_view(),
name='tag_detail'),
# 某个标签的文章列表分页第N页
path(
'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'),
# ========== API路由 ==========
# 文件上传API为Markdown编辑器提供图床功能
path(
'upload',
views.fileupload,
name='upload'),
# 缓存清除API用于发布文章后清除缓存
path(
'clean',
views.clean_cache_view,
name='clean'),
]

@ -0,0 +1,691 @@
#ly:
# coding:utf-8
# ==========================================
# Blog模块 - 视图
# ==========================================
# 包含文章列表、详情、分类、标签、搜索等视图
import logging
import os
import uuid
from django.conf import settings
from django.core.paginator import Paginator
from django.http import HttpResponse, HttpResponseForbidden
from django.shortcuts import get_object_or_404
from django.shortcuts import render
from django.templatetags.static import static
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.generic.detail import DetailView
from django.views.generic.list import ListView
from haystack.views import SearchView
from blog.models import Article, Category, LinkShowType, Links, Tag
from comments.forms import CommentForm
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__)
class ArticleListView(ListView):
"""
文章列表视图基类
为所有文章列表页面提供分页缓存等通用功能
子类需要实现 get_queryset_cache_key() get_queryset_data() 方法
"""
# 使用的模板文件
template_name = 'blog/article_index.html'
# 上下文中QuerySet对象的变量名在模板中使用该名字
context_object_name = 'article_list'
# 页面类型标识,子类可重写(如'分类目录','标签列表'等)
page_type = ''
# 每页显示的文章数
paginate_by = settings.PAGINATE_BY
# URL中分页参数的名称
page_kwarg = 'page'
# 友情链接显示类型
link_type = LinkShowType.L
def get_view_cache_key(self):
"""
获取视图缓存的键
问题此方法代码有bugself.request.get['pages']错误
应该是 self.request.GET.get('pages')会导致KeyError异常
"""
return self.request.get['pages']
@property
def page_number(self):
"""
获取当前页码
优先级URL参数 > GET参数 > 默认为1
"""
page_kwarg = self.page_kwarg
page = self.kwargs.get(
page_kwarg) or self.request.GET.get(page_kwarg) or 1
return page
def get_queryset_cache_key(self):
"""
抽象方法 - 子类必须实现
获取QuerySet缓存的键
返回: 缓存键字符串
"""
raise NotImplementedError()
def get_queryset_data(self):
"""
抽象方法 - 子类必须实现
获取QuerySet数据
返回: Article对象QuerySet或列表
"""
raise NotImplementedError()
def get_queryset_from_cache(self, cache_key):
"""
从缓存获取QuerySet数据
如果缓存不存在则从数据库获取并存入缓存
参数:
cache_key: 缓存键
返回:
QuerySet对象或列表
"""
value = cache.get(cache_key)
if value:
# 缓存命中
logger.info('get view cache.key:{key}'.format(key=cache_key))
return value
else:
# 缓存未命中,从数据库查询
article_list = self.get_queryset_data()
cache.set(cache_key, article_list)
logger.info('set view cache.key:{key}'.format(key=cache_key))
return article_list
def get_queryset(self):
"""
重写Django默认方法
从缓存获取QuerySet而不是每次都查询数据库
返回: QuerySet对象或列表
"""
key = self.get_queryset_cache_key()
value = self.get_queryset_from_cache(key)
return value
def get_context_data(self, **kwargs):
"""
为模板添加上下文数据
添加友情链接显示类型
"""
kwargs['linktype'] = self.link_type
return super(ArticleListView, self).get_context_data(**kwargs)
# ==========================================
# 首页视图
# ==========================================
class IndexView(ArticleListView):
"""
博客首页
显示所有已发布的文章列表分页显示
"""
# 首页显示友情链接的类型
link_type = LinkShowType.I
def get_queryset_data(self):
"""
获取所有类型为'文章'且状态为'已发布'的文章
返回: QuerySet
"""
article_list = Article.objects.filter(type='a', status='p')
return article_list
def get_queryset_cache_key(self):
"""
首页缓存键 - 格式: index_{page}
返回: 缓存键
"""
cache_key = 'index_{page}'.format(page=self.page_number)
return cache_key
# ==========================================
# 文章详情视图
# ==========================================
class ArticleDetailView(DetailView):
"""
文章详情页面
显示完整的文章内容评论前后文章链接等
"""
# 使用的模板
template_name = 'blog/article_detail.html'
# 数据模型
model = Article
# URL参数中对象的键名
pk_url_kwarg = 'article_id'
# 上下文中对象的变量名
context_object_name = "article"
def get_context_data(self, **kwargs):
"""
为模板准备上下文数据
包括评论表单评论列表分页等
"""
# 创建评论表单
comment_form = CommentForm()
# 获取文章的所有启用评论
article_comments = self.object.comment_list()
# 只获取顶级评论没有parent_comment的
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():
page = 1
else:
page = int(page)
# 页码边界检查
if page < 1:
page = 1
if page > paginator.num_pages:
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
# 生成前后页的URL
if next_page:
kwargs[
'comment_next_page_url'] = self.object.get_absolute_url() + f'?comment_page={next_page}#commentlist-container'
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
# 获取前后文章
# 【问题】:缺少方法调用括号,应为 next_article() 和 prev_article()
kwargs['next_article'] = self.object.next_article
kwargs['prev_article'] = self.object.prev_article
# 调用父类方法获取基础上下文
context = super(ArticleDetailView, self).get_context_data(**kwargs)
article = self.object
# 插件系统:执行"文章详情已获取"动作钩子
hooks.run_action('after_article_body_get', article=article, request=self.request)
# 插件系统:过滤文章内容钩子(允许插件修改文章正文)
article.body = hooks.apply_filters(ARTICLE_CONTENT_HOOK_NAME, article.body, article=article,
request=self.request)
return context
# ==========================================
# 分类详情视图
# ==========================================
class CategoryDetailView(ArticleListView):
"""
分类目录页面
显示某个分类下的所有文章包括子分类的文章
"""
# 页面类型标识
page_type = "分类目录归档"
def get_queryset_data(self):
"""
获取指定分类及其所有子分类下的已发布文章
返回: QuerySet
"""
slug = self.kwargs['category_name']
category = get_object_or_404(Category, slug=slug)
categoryname = category.name
self.categoryname = categoryname
# 获取该分类的所有子分类的名称
categorynames = list(
map(lambda c: c.name, category.get_sub_categorys()))
# 查询这些分类下的所有已发布文章
article_list = Article.objects.filter(
category__name__in=categorynames, status='p')
return article_list
def get_queryset_cache_key(self):
"""
分类缓存键 - 格式: category_list_{categoryname}_{page}
返回: 缓存键
"""
slug = self.kwargs['category_name']
category = get_object_or_404(Category, slug=slug)
categoryname = category.name
self.categoryname = categoryname
cache_key = 'category_list_{categoryname}_{page}'.format(
categoryname=categoryname, page=self.page_number)
return cache_key
def get_context_data(self, **kwargs):
"""
为模板添加分类信息
"""
categoryname = self.categoryname
try:
# 只取最后一级分类名(如果是多级分类)
# 【问题】异常处理过于宽泛应该捕获AttributeError而非BaseException
categoryname = categoryname.split('/')[-1]
except BaseException:
pass
kwargs['page_type'] = CategoryDetailView.page_type
kwargs['tag_name'] = categoryname
return super(CategoryDetailView, self).get_context_data(**kwargs)
# ==========================================
# 作者详情视图
# ==========================================
class AuthorDetailView(ArticleListView):
"""
作者页面
显示某个作者发布的所有文章
"""
# 页面类型标识
page_type = '作者文章归档'
def get_queryset_cache_key(self):
"""
作者缓存键 - 格式: author_{author_name}_{page}
返回: 缓存键
"""
from uuslug import slugify
author_name = slugify(self.kwargs['author_name'])
cache_key = 'author_{author_name}_{page}'.format(
author_name=author_name, page=self.page_number)
return cache_key
def get_queryset_data(self):
"""
获取指定作者发布的所有文章
返回: QuerySet
"""
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
return super(AuthorDetailView, self).get_context_data(**kwargs)
# ==========================================
# 标签详情视图
# ==========================================
class TagDetailView(ArticleListView):
"""
标签页面
显示某个标签下的所有文章
"""
# 页面类型标识
page_type = '分类标签归档'
def get_queryset_data(self):
"""
获取指定标签下的所有已发布文章
返回: QuerySet
"""
slug = self.kwargs['tag_name']
tag = get_object_or_404(Tag, slug=slug)
tag_name = tag.name
self.name = tag_name
# 查询包含该标签的所有文章
article_list = Article.objects.filter(
tags__name=tag_name, type='a', status='p')
return article_list
def get_queryset_cache_key(self):
"""
标签缓存键 - 格式: tag_{tag_name}_{page}
返回: 缓存键
"""
slug = self.kwargs['tag_name']
tag = get_object_or_404(Tag, slug=slug)
tag_name = tag.name
self.name = tag_name
cache_key = 'tag_{tag_name}_{page}'.format(
tag_name=tag_name, page=self.page_number)
return cache_key
def get_context_data(self, **kwargs):
"""
为模板添加标签信息
"""
tag_name = self.name
kwargs['page_type'] = TagDetailView.page_type
kwargs['tag_name'] = tag_name
return super(TagDetailView, self).get_context_data(**kwargs)
# ==========================================
# 文章归档视图
# ==========================================
class ArchivesView(ArticleListView):
"""
文章归档页面
显示所有已发布文章的完整列表按时间排序
"""
# 页面类型标识
page_type = '文章归档'
# 不分页,一次性显示所有
paginate_by = None
page_kwarg = None
# 使用的模板
template_name = 'blog/article_archives.html'
def get_queryset_data(self):
"""
获取所有已发布文章
返回: QuerySet
"""
return Article.objects.filter(status='p').all()
def get_queryset_cache_key(self):
"""
归档缓存键
返回: 缓存键
"""
cache_key = 'archives'
return cache_key
# ==========================================
# 友情链接列表视图
# ==========================================
class LinkListView(ListView):
"""
友情链接页面
显示所有启用的友情链接
"""
# 数据模型
model = Links
# 使用的模板
template_name = 'blog/links_list.html'
def get_queryset(self):
"""
获取所有启用的链接
返回: QuerySet
"""
return Links.objects.filter(is_enable=True)
# ==========================================
# Elasticsearch搜索视图
# ==========================================
class EsSearchView(SearchView):
"""
使用Elasticsearch的搜索视图
重写get_context()方法以自定义返回的上下文
"""
def get_context(self):
"""
获取搜索上下文数据
包括搜索结果分页建议等
返回: 上下文字典
"""
# 构建分页器和当前页
paginator, page = self.build_page()
context = {
"query": self.query, # 搜索查询词
"form": self.form, # 搜索表单
"page": page, # 当前页对象
"paginator": paginator, # 分页器对象
"suggestion": None, # 拼写建议
}
# 如果启用了拼写检查,获取建议
if hasattr(self.results, "query") and self.results.query.backend.include_spelling:
context["suggestion"] = self.results.query.get_spelling_suggestion()
# 添加额外的上下文
context.update(self.extra_context())
return context
# ==========================================
# 文件上传视图 - 图床功能
# ==========================================
@csrf_exempt
def fileupload(request):
"""
处理文件上传的视图函数
为Markdown编辑器提供图床功能上传图片和文件
安全性说明
1. 必须提供正确的签名sign参数
2. 签名验证通过后才允许上传
3. 文件保存路径进行安全检查防止目录遍历
质量问题
- 🔴 路径遍历防护虽然有检查但不够完善
- 🔴 文件名长度限制缺失可能导致长文件名问题
- 🔴 MIME类型验证只检查扩展名不够安全
- 🔴 并发写入问题多个上传可能产生竞争条件
- 🔴 内存消耗整个文件加载到内存大文件不友好
- 🔴 错误处理缺少创建目录文件写入操作的异常处理
- 🔴 返回格式返回列表的字符串表示而非JSON
参数:
request: HTTP请求对象
返回:
HttpResponse: 上传结果
"""
if request.method == 'POST':
# 获取签名参数(用于验证请求合法性)
sign = request.GET.get('sign', None)
if not sign:
return HttpResponseForbidden()
# 验证签名 - 签名应该是 SHA256(SHA256(SECRET_KEY))
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)
# 生成唯一的文件名,避免名称冲突
# 使用UUID生成唯一的前缀保留原始扩展名
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)
# 使用PIL压缩图片质量设为20压缩率较高
image.save(savepath, quality=20, optimize=True)
# 获取文件的Web访问URL
url = static(savepath)
response.append(url)
# 【问题】应该返回JSON格式而非列表的字符串表示
return HttpResponse(response)
else:
# 非POST请求返回错误
return HttpResponse("only for post")
# ==========================================
# 错误处理视图
# ==========================================
def page_not_found_view(
request,
exception,
template_name='blog/error_page.html'):
"""
404 页面未找到错误处理视图
当请求的资源不存在时调用显示友好的错误页面
参数:
request: HTTP请求对象
exception: 异常对象
template_name: 错误页面模板名称
返回:
HttpResponse: 404错误页面
"""
if exception:
# 记录异常信息用于调试
logger.error(exception)
url = request.get_full_path()
return render(
request,
template_name,
{
'message': _('Sorry, the page you requested is not found, please click the home page to see other?'),
'statuscode': '404'
},
status=404
)
def server_error_view(request, template_name='blog/error_page.html'):
"""
500 服务器内部错误处理视图
当服务器发生未处理的异常时调用显示友好的错误页面
参数:
request: HTTP请求对象
template_name: 错误页面模板名称
返回:
HttpResponse: 500错误页面
"""
return render(
request,
template_name,
{
'message': _('Sorry, the server is busy, please click the home page to see other?'),
'statuscode': '500'
},
status=500
)
def permission_denied_view(
request,
exception,
template_name='blog/error_page.html'):
"""
403 权限拒绝错误处理视图
当用户无权访问资源时调用显示友好的错误页面
参数:
request: HTTP请求对象
exception: 异常对象
template_name: 错误页面模板名称
返回:
HttpResponse: 403错误页面
"""
if exception:
# 记录异常信息用于调试
logger.error(exception)
return render(
request,
template_name,
{
'message': _('Sorry, you do not have permission to access this page?'),
'statuscode': '403'
},
status=403
)
# ==========================================
# 缓存清除视图
# ==========================================
def clean_cache_view(request):
"""
清除所有缓存的视图
用于发布文章或修改设置后立即更新前端显示
使用方法访问 /blog/clean/ 路径
参数:
request: HTTP请求对象
返回:
HttpResponse: 清除结果提示
"""
# 清除所有缓存
cache.clear()
return HttpResponse('ok')
Loading…
Cancel
Save