Compare commits

...

11 Commits

Binary file not shown.

Binary file not shown.

Binary file not shown.

@ -1,3 +1,7 @@
"""
用户账户相关模型定义
扩展Django默认用户模型增加博客系统特有功能
"""
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
@ -6,30 +10,50 @@ from django.utils.translation import gettext_lazy as _
from djangoblog.utils import get_current_site from djangoblog.utils import get_current_site
# Create your models here.
class BlogUser(AbstractUser): class BlogUser(AbstractUser):
nickname = models.CharField(_('nick name'), max_length=100, blank=True) """
creation_time = models.DateTimeField(_('creation time'), default=now) 博客用户模型
last_modify_time = models.DateTimeField(_('last modify time'), default=now) 继承Django的AbstractUser扩展博客系统需要的字段
source = models.CharField(_('create source'), max_length=100, blank=True) """
# 用户昵称,用于显示而非用户名
nickname = models.CharField(_('昵称'), max_length=100, blank=True)
# 时间戳字段
creation_time = models.DateTimeField(_('创建时间'), default=now)
last_modify_time = models.DateTimeField(_('最后修改时间'), default=now)
# 用户来源,记录注册渠道
source = models.CharField(_('注册来源'), max_length=100, blank=True)
def get_absolute_url(self): def get_absolute_url(self):
"""
获取用户主页的URL
用于作者详情页面的链接
"""
return reverse( return reverse(
'blog:author_detail', kwargs={ 'blog:author_detail', kwargs={
'author_name': self.username}) 'author_name': self.username})
def __str__(self): def __str__(self):
"""
字符串表示
使用邮箱作为标识因为用户名可能不唯一
"""
return self.email return self.email
def get_full_url(self): def get_full_url(self):
"""
获取包含完整域名的用户主页URL
用于分享RSS等需要绝对URL的场景
"""
site = get_current_site().domain site = get_current_site().domain
url = "https://{site}{path}".format(site=site, url = "https://{site}{path}".format(site=site,
path=self.get_absolute_url()) path=self.get_absolute_url())
return url return url
class Meta: class Meta:
ordering = ['-id'] """元数据配置"""
verbose_name = _('user') ordering = ['-id'] # 按ID降序排列新的用户在前
verbose_name_plural = verbose_name verbose_name = _('用户') # 单数名称
get_latest_by = 'id' verbose_name_plural = verbose_name # 复数名称
get_latest_by = 'id' # 获取最新记录的依据字段

@ -1,3 +1,7 @@
"""
用户账户相关视图函数
处理用户注册登录退出密码找回等功能
"""
import logging import logging
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.conf import settings from django.conf import settings
@ -29,176 +33,220 @@ from .models import BlogUser
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Create your views here.
class RegisterView(FormView): class RegisterView(FormView):
form_class = RegisterForm """
template_name = 'account/registration_form.html' 用户注册视图
处理新用户注册流程包括邮箱验证
@method_decorator(csrf_protect) """
form_class = RegisterForm # 注册表单类
template_name = 'account/registration_form.html' # 注册页面模板
@method_decorator(csrf_protect) # CSRF保护
def dispatch(self, *args, **kwargs): def dispatch(self, *args, **kwargs):
return super(RegisterView, self).dispatch(*args, **kwargs) return super(RegisterView, self).dispatch(*args, **kwargs)
def form_valid(self, form): def form_valid(self, form):
"""
表单验证通过后的处理逻辑
创建用户发送验证邮件跳转结果页面
"""
if form.is_valid(): if form.is_valid():
user = form.save(False) user = form.save(False) # 不立即保存,先进行其他操作
user.is_active = False user.is_active = False # 新用户需要邮箱验证激活
user.source = 'Register' user.source = 'Register' # 记录注册来源
user.save(True) user.save(True) # 保存用户到数据库
# 构建邮箱验证链接
site = get_current_site().domain site = get_current_site().domain
sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
# 开发环境使用本地域名
if settings.DEBUG: if settings.DEBUG:
site = '127.0.0.1:8000' site = '127.0.0.1:8000'
path = reverse('account:result') path = reverse('account:result')
url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format( url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format(
site=site, path=path, id=user.id, sign=sign) site=site, path=path, id=user.id, sign=sign)
# 构建验证邮件内容
content = """ content = """
<p>请点击下面链接验证您的邮箱</p> <p>请点击下面链接验证您的邮箱</p>
<a href="{url}" rel="bookmark">{url}</a>
<a href="{url}" rel="bookmark">{url}</a> 再次感谢您
<br />
再次感谢您 如果上面链接无法打开请将此链接复制至浏览器
<br /> {url}
如果上面链接无法打开请将此链接复制至浏览器 """.format(url=url)
{url}
""".format(url=url) # 发送验证邮件
send_email( send_email(
emailto=[ emailto=[user.email],
user.email,
],
title='验证您的电子邮箱', title='验证您的电子邮箱',
content=content) content=content)
url = reverse('accounts:result') + \ # 跳转到注册结果页面
'?type=register&id=' + str(user.id) url = reverse('accounts:result') + '?type=register&id=' + str(user.id)
return HttpResponseRedirect(url) return HttpResponseRedirect(url)
else: else:
return self.render_to_response({ # 表单验证失败,重新渲染表单页
'form': form return self.render_to_response({'form': form})
})
class LogoutView(RedirectView): class LogoutView(RedirectView):
url = '/login/' """
用户退出登录视图
清除用户会话重定向到登录页
"""
url = '/login/' # 退出后重定向的URL
@method_decorator(never_cache) @method_decorator(never_cache) # 禁止缓存
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
return super(LogoutView, self).dispatch(request, *args, **kwargs) return super(LogoutView, self).dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
logout(request) """处理GET请求执行退出登录操作"""
delete_sidebar_cache() logout(request) # Django内置退出函数
delete_sidebar_cache() # 清理侧边栏缓存
return super(LogoutView, self).get(request, *args, **kwargs) return super(LogoutView, self).get(request, *args, **kwargs)
class LoginView(FormView): class LoginView(FormView):
form_class = LoginForm """
template_name = 'account/login.html' 用户登录视图
success_url = '/' 处理用户登录认证支持记住登录状态
redirect_field_name = REDIRECT_FIELD_NAME """
login_ttl = 2626560 # 一个月的时间 form_class = LoginForm # 登录表单类
template_name = 'account/login.html' # 登录页面模板
@method_decorator(sensitive_post_parameters('password')) success_url = '/' # 登录成功后的默认跳转地址
@method_decorator(csrf_protect) redirect_field_name = REDIRECT_FIELD_NAME # 重定向字段名
@method_decorator(never_cache) login_ttl = 2626560 # 记住登录状态的持续时间(一个月,单位:秒)
@method_decorator(sensitive_post_parameters('password')) # 保护密码参数
@method_decorator(csrf_protect) # CSRF保护
@method_decorator(never_cache) # 禁止缓存
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
return super(LoginView, self).dispatch(request, *args, **kwargs) return super(LoginView, self).dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""添加上下文数据,包括重定向地址"""
redirect_to = self.request.GET.get(self.redirect_field_name) redirect_to = self.request.GET.get(self.redirect_field_name)
if redirect_to is None: if redirect_to is None:
redirect_to = '/' redirect_to = '/' # 默认重定向到首页
kwargs['redirect_to'] = redirect_to kwargs['redirect_to'] = redirect_to
return super(LoginView, self).get_context_data(**kwargs) return super(LoginView, self).get_context_data(**kwargs)
def form_valid(self, form): def form_valid(self, form):
"""表单验证通过后的登录处理"""
form = AuthenticationForm(data=self.request.POST, request=self.request) form = AuthenticationForm(data=self.request.POST, request=self.request)
if form.is_valid(): if form.is_valid():
delete_sidebar_cache() delete_sidebar_cache() # 清理侧边栏缓存
logger.info(self.redirect_field_name) logger.info(self.redirect_field_name)
auth.login(self.request, form.get_user()) auth.login(self.request, form.get_user()) # 执行登录操作
# 处理"记住我"选项
if self.request.POST.get("remember"): if self.request.POST.get("remember"):
self.request.session.set_expiry(self.login_ttl) self.request.session.set_expiry(self.login_ttl) # 设置会话过期时间
return super(LoginView, self).form_valid(form) return super(LoginView, self).form_valid(form)
# return HttpResponseRedirect('/')
else: else:
return self.render_to_response({ # 登录失败,重新显示表单
'form': form return self.render_to_response({'form': form})
})
def get_success_url(self): def get_success_url(self):
"""获取登录成功后的跳转URL"""
redirect_to = self.request.POST.get(self.redirect_field_name) redirect_to = self.request.POST.get(self.redirect_field_name)
# 安全检查确保重定向URL在允许的域名内
if not url_has_allowed_host_and_scheme( if not url_has_allowed_host_and_scheme(
url=redirect_to, allowed_hosts=[ url=redirect_to, allowed_hosts=[self.request.get_host()]):
self.request.get_host()]): redirect_to = self.success_url # 使用默认URL
redirect_to = self.success_url
return redirect_to return redirect_to
def account_result(request): def account_result(request):
type = request.GET.get('type') """
id = request.GET.get('id') 账户操作结果页面
显示注册结果或邮箱验证结果
"""
type = request.GET.get('type') # 操作类型register或validation
id = request.GET.get('id') # 用户ID
user = get_object_or_404(get_user_model(), id=id) user = get_object_or_404(get_user_model(), id=id)
logger.info(type) logger.info(type)
# 如果用户已激活,直接跳转首页
if user.is_active: if user.is_active:
return HttpResponseRedirect('/') return HttpResponseRedirect('/')
if type and type in ['register', 'validation']: if type and type in ['register', 'validation']:
if type == 'register': if type == 'register':
# 注册成功页面
content = ''' content = '''
恭喜您注册成功一封验证邮件已经发送到您的邮箱请验证您的邮箱后登录本站 恭喜您注册成功一封验证邮件已经发送到您的邮箱请验证您的邮箱后登录本站
''' '''
title = '注册成功' title = '注册成功'
else: else:
# 邮箱验证处理
c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
sign = request.GET.get('sign') sign = request.GET.get('sign')
if sign != c_sign: if sign != c_sign:
return HttpResponseForbidden() return HttpResponseForbidden() # 签名验证失败
user.is_active = True
user.is_active = True # 激活用户
user.save() user.save()
content = ''' content = '''
恭喜您已经成功的完成邮箱验证您现在可以使用您的账号来登录本站 恭喜您已经成功的完成邮箱验证您现在可以使用您的账号来登录本站
''' '''
title = '验证成功' title = '验证成功'
# 渲染结果页面
return render(request, 'account/result.html', { return render(request, 'account/result.html', {
'title': title, 'title': title,
'content': content 'content': content
}) })
else: else:
return HttpResponseRedirect('/') return HttpResponseRedirect('/') # 参数错误,跳转首页
class ForgetPasswordView(FormView): class ForgetPasswordView(FormView):
form_class = ForgetPasswordForm """
template_name = 'account/forget_password.html' 忘记密码视图
通过邮箱重置用户密码
"""
form_class = ForgetPasswordForm # 忘记密码表单
template_name = 'account/forget_password.html' # 模板文件
def form_valid(self, form): def form_valid(self, form):
"""表单验证通过后的密码重置处理"""
if form.is_valid(): if form.is_valid():
# 根据邮箱查找用户
blog_user = BlogUser.objects.filter(email=form.cleaned_data.get("email")).get() blog_user = BlogUser.objects.filter(email=form.cleaned_data.get("email")).get()
# 使用Django的密码哈希函数设置新密码
blog_user.password = make_password(form.cleaned_data["new_password2"]) blog_user.password = make_password(form.cleaned_data["new_password2"])
blog_user.save() blog_user.save() # 保存新密码
return HttpResponseRedirect('/login/') return HttpResponseRedirect('/login/') # 重定向到登录页
else: else:
return self.render_to_response({'form': form}) return self.render_to_response({'form': form})
class ForgetPasswordEmailCode(View): class ForgetPasswordEmailCode(View):
"""
忘记密码邮箱验证码视图
发送密码重置验证码到用户邮箱
"""
def post(self, request: HttpRequest): def post(self, request: HttpRequest):
"""处理POST请求发送验证码邮件"""
form = ForgetPasswordCodeForm(request.POST) form = ForgetPasswordCodeForm(request.POST)
if not form.is_valid(): if not form.is_valid():
return HttpResponse("错误的邮箱") return HttpResponse("错误的邮箱") # 邮箱格式错误
to_email = form.cleaned_data["email"] to_email = form.cleaned_data["email"]
# 生成并发送验证码
code = generate_code() code = generate_code()
utils.send_verify_email(to_email, code) utils.send_verify_email(to_email, code) # 发送验证邮件
utils.set_code(to_email, code) utils.set_code(to_email, code) # 保存验证码(到缓存或数据库)
return HttpResponse("ok") return HttpResponse("ok") # 发送成功

@ -1,3 +1,7 @@
"""
博客系统核心数据模型定义
包含文章分类标签友情链接等主要数据模型
"""
import logging import logging
import re import re
from abc import abstractmethod from abc import abstractmethod
@ -18,25 +22,40 @@ logger = logging.getLogger(__name__)
class LinkShowType(models.TextChoices): class LinkShowType(models.TextChoices):
I = ('i', _('index')) """
L = ('l', _('list')) 链接显示类型选择枚举
P = ('p', _('post')) 定义友情链接在不同页面的显示方式
A = ('a', _('all')) """
S = ('s', _('slide')) I = ('i', _('首页')) # 首页显示
L = ('l', _('列表页')) # 文章列表页显示
P = ('p', _('文章详情页')) # 文章详情页显示
A = ('a', _('全站显示')) # 所有页面显示
S = ('s', _('幻灯片')) # 幻灯片形式显示
class BaseModel(models.Model): class BaseModel(models.Model):
id = models.AutoField(primary_key=True) """
creation_time = models.DateTimeField(_('creation time'), default=now) 抽象基类模型
last_modify_time = models.DateTimeField(_('modify time'), default=now) 为所有模型提供通用的字段和方法
"""
id = models.AutoField(primary_key=True) # 主键ID
creation_time = models.DateTimeField(_('创建时间'), default=now) # 记录创建时间
last_modify_time = models.DateTimeField(_('修改时间'), default=now) # 最后修改时间
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""
重写保存方法添加通用逻辑
包括自动生成slug更新时间戳等
"""
# 检查是否是更新文章浏览量的操作
is_update_views = isinstance( is_update_views = isinstance(
self, self,
Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views'] Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views']
if is_update_views: if is_update_views:
# 直接更新浏览量,避免触发其他信号
Article.objects.filter(pk=self.pk).update(views=self.views) Article.objects.filter(pk=self.pk).update(views=self.views)
else: else:
# 自动生成slug字段用于SEO友好的URL
if 'slug' in self.__dict__: if 'slug' in self.__dict__:
slug = getattr( slug = getattr(
self, 'title') if 'title' in self.__dict__ else getattr( self, 'title') if 'title' in self.__dict__ else getattr(
@ -45,79 +64,95 @@ class BaseModel(models.Model):
super().save(*args, **kwargs) super().save(*args, **kwargs)
def get_full_url(self): def get_full_url(self):
"""
获取完整的绝对URL包含域名
用于分享RSS订阅等场景
"""
site = get_current_site().domain site = get_current_site().domain
url = "https://{site}{path}".format(site=site, url = "https://{site}{path}".format(site=site,
path=self.get_absolute_url()) path=self.get_absolute_url())
return url return url
class Meta: class Meta:
abstract = True abstract = True # 抽象类,不会创建数据库表
@abstractmethod @abstractmethod
def get_absolute_url(self): def get_absolute_url(self):
"""抽象方法子类必须实现获取绝对URL的方法"""
pass pass
class Article(BaseModel): class Article(BaseModel):
"""文章""" """
文章核心模型
存储博客文章的所有信息支持Markdown格式
"""
# 文章状态选择
STATUS_CHOICES = ( STATUS_CHOICES = (
('d', _('Draft')), ('d', _('草稿')), # 草稿状态,仅作者可见
('p', _('Published')), ('p', _('已发布')), # 已发布状态,所有用户可见
) )
# 评论状态选择
COMMENT_STATUS = ( COMMENT_STATUS = (
('o', _('Open')), ('o', _('开启')), # 开启评论
('c', _('Close')), ('c', _('关闭')), # 关闭评论
) )
# 文章类型选择
TYPE = ( TYPE = (
('a', _('Article')), ('a', _('普通文章')), # 普通博客文章
('p', _('Page')), ('p', _('页面')), # 静态页面(如关于页面)
) )
title = models.CharField(_('title'), max_length=200, unique=True)
body = MDTextField(_('body')) # 基础字段
pub_time = models.DateTimeField( title = models.CharField(_('标题'), max_length=200, unique=True) # 文章标题,唯一
_('publish time'), blank=False, null=False, default=now) body = MDTextField(_('正文')) # 文章正文支持Markdown编辑
status = models.CharField( pub_time = models.DateTimeField(_('发布时间'), blank=False, null=False, default=now) # 发布时间
_('status'), status = models.CharField(_('状态'), max_length=1, choices=STATUS_CHOICES, default='p') # 文章状态
max_length=1, comment_status = models.CharField(_('评论状态'), max_length=1, choices=COMMENT_STATUS, default='o') # 评论开关
choices=STATUS_CHOICES, type = models.CharField(_('类型'), max_length=1, choices=TYPE, default='a') # 文章类型
default='p') views = models.PositiveIntegerField(_('浏览量'), default=0) # 浏览量统计
comment_status = models.CharField( author = models.ForeignKey( # 文章作者
_('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)
author = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
verbose_name=_('author'), verbose_name=_('作者'),
blank=False, blank=False,
null=False, null=False,
on_delete=models.CASCADE) on_delete=models.CASCADE) # 作者删除时级联删除文章
article_order = models.IntegerField(
_('order'), blank=False, null=False, default=0) # 排序和显示相关字段
show_toc = models.BooleanField(_('show toc'), blank=False, null=False, default=False) article_order = models.IntegerField(_('排序'), blank=False, null=False, default=0) # 文章排序权重
category = models.ForeignKey( show_toc = models.BooleanField(_('显示目录'), blank=False, null=False, default=False) # 是否显示文章目录
# 分类和标签关系
category = models.ForeignKey( # 文章分类
'Category', 'Category',
verbose_name=_('category'), verbose_name=_('分类'),
on_delete=models.CASCADE, on_delete=models.CASCADE,
blank=False, blank=False,
null=False) null=False) # 必须属于一个分类
tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True) tags = models.ManyToManyField('Tag', verbose_name=_('标签'), blank=True) # 文章标签,多对多关系
def body_to_string(self): def body_to_string(self):
"""将正文内容转换为字符串"""
return self.body return self.body
def __str__(self): def __str__(self):
"""字符串表示,返回文章标题"""
return self.title return self.title
class Meta: class Meta:
ordering = ['-article_order', '-pub_time'] """元数据配置"""
verbose_name = _('article') ordering = ['-article_order', '-pub_time'] # 默认按排序权重和发布时间降序排列
verbose_name_plural = verbose_name verbose_name = _('文章') # 单数名称
get_latest_by = 'id' verbose_name_plural = verbose_name # 复数名称
get_latest_by = 'id' # 获取最新记录的依据字段
def get_absolute_url(self): def get_absolute_url(self):
"""
获取文章详情页的URL
使用年月日+文章ID的SEO友好URL结构
"""
return reverse('blog:detailbyid', kwargs={ return reverse('blog:detailbyid', kwargs={
'article_id': self.id, 'article_id': self.id,
'year': self.creation_time.year, 'year': self.creation_time.year,
@ -127,7 +162,16 @@ class Article(BaseModel):
@cache_decorator(expiration=60 * 60 * 2) # 缓存2小时 @cache_decorator(expiration=60 * 60 * 2) # 缓存2小时
def get_related_posts(self, count=4): def get_related_posts(self, count=4):
"""获取相关文章""" """
获取相关文章推荐
策略先通过相同标签获取不够则通过相同分类补充还不够则随机推荐
Args:
count: 需要获取的相关文章数量默认4篇
Returns:
list: 相关文章列表
"""
# 通过相同的标签获取相关文章(排除自己) # 通过相同的标签获取相关文章(排除自己)
related_by_tags = Article.objects.filter( related_by_tags = Article.objects.filter(
tags__in=self.tags.all(), tags__in=self.tags.all(),
@ -161,26 +205,42 @@ class Article(BaseModel):
id=self.id id=self.id
).exclude( ).exclude(
id__in=[p.id for p in related_posts] id__in=[p.id for p in related_posts]
).order_by('?')[:remaining_count] # 随机排序 ).order_by('?')[:remaining_count] # 使用order_by('?')随机排序
related_posts.extend(list(random_posts)) related_posts.extend(list(random_posts))
return related_posts[:count] return related_posts[:count] # 确保返回指定数量的文章
@cache_decorator(60 * 60 * 10) @cache_decorator(60 * 60 * 10) # 缓存10小时
def get_category_tree(self): def get_category_tree(self):
"""
获取文章所属分类的完整层级树
用于面包屑导航显示
Returns:
list: 包含分类名称和URL的元组列表
"""
tree = self.category.get_category_tree() tree = self.category.get_category_tree()
names = list(map(lambda c: (c.name, c.get_absolute_url()), tree)) names = list(map(lambda c: (c.name, c.get_absolute_url()), tree))
return names return names
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""保存文章,调用父类保存逻辑"""
super().save(*args, **kwargs) super().save(*args, **kwargs)
def viewed(self): def viewed(self):
"""
增加文章浏览量
使用update_fields只更新views字段提高性能
"""
self.views += 1 self.views += 1
self.save(update_fields=['views']) self.save(update_fields=['views'])
def comment_list(self): def comment_list(self):
"""
获取文章评论列表带缓存
缓存100分钟减少数据库查询压力
"""
cache_key = 'article_comments_{id}'.format(id=self.id) cache_key = 'article_comments_{id}'.format(id=self.id)
value = cache.get(cache_key) value = cache.get(cache_key)
if value: if value:
@ -188,29 +248,33 @@ class Article(BaseModel):
return value return value
else: else:
comments = self.comment_set.filter(is_enable=True).order_by('-id') comments = self.comment_set.filter(is_enable=True).order_by('-id')
cache.set(cache_key, comments, 60 * 100) cache.set(cache_key, comments, 60 * 100) # 缓存100分钟
logger.info('set article comments:{id}'.format(id=self.id)) logger.info('set article comments:{id}'.format(id=self.id))
return comments return comments
def get_admin_url(self): def get_admin_url(self):
"""获取文章在Django Admin中的编辑URL"""
info = (self._meta.app_label, self._meta.model_name) info = (self._meta.app_label, self._meta.model_name)
return reverse('admin:%s_%s_change' % info, args=(self.pk,)) return reverse('admin:%s_%s_change' % info, args=(self.pk,))
@cache_decorator(expiration=60 * 100) @cache_decorator(expiration=60 * 100) # 缓存100分钟
def next_article(self): def next_article(self):
# 下一篇 """获取下一篇文章按ID顺序"""
return Article.objects.filter( return Article.objects.filter(
id__gt=self.id, status='p').order_by('id').first() id__gt=self.id, status='p').order_by('id').first()
@cache_decorator(expiration=60 * 100) @cache_decorator(expiration=60 * 100) # 缓存100分钟
def prev_article(self): def prev_article(self):
# 前一篇 """获取上一篇文章按ID顺序"""
return Article.objects.filter(id__lt=self.id, status='p').first() return Article.objects.filter(id__lt=self.id, status='p').first()
def get_first_image_url(self): def get_first_image_url(self):
""" """
Get the first image url from article.body. 从文章正文中提取第一张图片URL
:return: 用于文章列表的缩略图显示
Returns:
str: 图片URL或空字符串
""" """
match = re.search(r'!\[.*?\]\((.+?)\)', self.body) match = re.search(r'!\[.*?\]\((.+?)\)', self.body)
if match: if match:
@ -218,61 +282,87 @@ class Article(BaseModel):
return "" return ""
# ==================== 新增的点赞收藏方法 ==================== # ==================== 新增的点赞收藏方法 ====================
def get_like_count(self): def get_like_count(self):
"""获取点赞数量""" """获取文章点赞数量"""
return Like.objects.filter(article=self).count() return Like.objects.filter(article=self).count()
def get_favorite_count(self): def get_favorite_count(self):
"""获取收藏数量""" """获取文章收藏数量"""
return Favorite.objects.filter(article=self).count() return Favorite.objects.filter(article=self).count()
def is_liked_by_user(self, user): def is_liked_by_user(self, user):
"""检查用户是否点赞""" """
检查指定用户是否点赞了该文章
Args:
user: 用户对象
Returns:
bool: 是否点赞
"""
if not user.is_authenticated: if not user.is_authenticated:
return False return False
return Like.objects.filter(user=user, article=self).exists() return Like.objects.filter(user=user, article=self).exists()
def is_favorited_by_user(self, user): def is_favorited_by_user(self, user):
"""检查用户是否收藏""" """
检查指定用户是否收藏了该文章
Args:
user: 用户对象
Returns:
bool: 是否收藏
"""
if not user.is_authenticated: if not user.is_authenticated:
return False return False
return Favorite.objects.filter(user=user, article=self).exists() return Favorite.objects.filter(user=user, article=self).exists()
class Category(BaseModel): class Category(BaseModel):
"""文章分类""" """
name = models.CharField(_('category name'), max_length=30, unique=True) 文章分类模型
parent_category = models.ForeignKey( 支持多级分类结构
"""
name = models.CharField(_('分类名称'), max_length=30, unique=True) # 分类名称,唯一
parent_category = models.ForeignKey( # 父级分类,实现分类树
'self', 'self',
verbose_name=_('parent category'), verbose_name=_('父级分类'),
blank=True, blank=True,
null=True, null=True,
on_delete=models.CASCADE) on_delete=models.CASCADE) # 支持无限级分类
slug = models.SlugField(default='no-slug', max_length=60, blank=True) slug = models.SlugField(default='no-slug', max_length=60, blank=True) # URL别名
index = models.IntegerField(default=0, verbose_name=_('index')) index = models.IntegerField(default=0, verbose_name=_('排序索引')) # 分类排序
class Meta: class Meta:
ordering = ['-index'] ordering = ['-index'] # 按索引降序排列
verbose_name = _('category') verbose_name = _('分类')
verbose_name_plural = verbose_name verbose_name_plural = verbose_name
def get_absolute_url(self): def get_absolute_url(self):
"""获取分类详情页URL"""
return reverse( return reverse(
'blog:category_detail', kwargs={ 'blog:category_detail', kwargs={
'category_name': self.slug}) 'category_name': self.slug})
def __str__(self): def __str__(self):
"""字符串表示,返回分类名称"""
return self.name return self.name
@cache_decorator(60 * 60 * 10) @cache_decorator(60 * 60 * 10) # 缓存10小时
def get_category_tree(self): def get_category_tree(self):
""" """
递归获得分类目录的父级 递归获得分类目录的父级链
:return: 用于面包屑导航
Returns:
list: 从当前分类到根分类的列表
""" """
categorys = [] categorys = []
def parse(category): def parse(category):
"""递归解析分类层级"""
categorys.append(category) categorys.append(category)
if category.parent_category: if category.parent_category:
parse(category.parent_category) parse(category.parent_category)
@ -280,16 +370,20 @@ class Category(BaseModel):
parse(self) parse(self)
return categorys return categorys
@cache_decorator(60 * 60 * 10) @cache_decorator(60 * 60 * 10) # 缓存10小时
def get_sub_categorys(self): def get_sub_categorys(self):
""" """
获得当前分类目录所有子集 获得当前分类目录所有子分类
:return: 用于分类导航菜单
Returns:
list: 所有子分类列表
""" """
categorys = [] categorys = []
all_categorys = Category.objects.all() all_categorys = Category.objects.all()
def parse(category): def parse(category):
"""递归解析子分类"""
if category not in categorys: if category not in categorys:
categorys.append(category) categorys.append(category)
childs = all_categorys.filter(parent_category=category) childs = all_categorys.filter(parent_category=category)
@ -303,45 +397,52 @@ class Category(BaseModel):
class Tag(BaseModel): class Tag(BaseModel):
"""文章标签""" """
name = models.CharField(_('tag name'), max_length=30, unique=True) 文章标签模型
slug = models.SlugField(default='no-slug', max_length=60, blank=True) 用于文章的多标签分类
"""
name = models.CharField(_('标签名称'), max_length=30, unique=True) # 标签名称,唯一
slug = models.SlugField(default='no-slug', max_length=60, blank=True) # URL别名
def __str__(self): def __str__(self):
"""字符串表示,返回标签名称"""
return self.name return self.name
def get_absolute_url(self): def get_absolute_url(self):
"""获取标签详情页URL"""
return reverse('blog:tag_detail', kwargs={'tag_name': self.slug}) return reverse('blog:tag_detail', kwargs={'tag_name': self.slug})
@cache_decorator(60 * 60 * 10) @cache_decorator(60 * 60 * 10) # 缓存10小时
def get_article_count(self): def get_article_count(self):
"""获取该标签下的文章数量"""
return Article.objects.filter(tags__name=self.name).distinct().count() return Article.objects.filter(tags__name=self.name).distinct().count()
class Meta: class Meta:
ordering = ['name'] ordering = ['name'] # 按名称升序排列
verbose_name = _('tag') verbose_name = _('标签')
verbose_name_plural = verbose_name verbose_name_plural = verbose_name
class Links(models.Model): class Links(models.Model):
"""友情链接""" """
友情链接模型
name = models.CharField(_('link name'), max_length=30, unique=True) 管理站外友情链接
link = models.URLField(_('link')) """
sequence = models.IntegerField(_('order'), unique=True) name = models.CharField(_('链接名称'), max_length=30, unique=True) # 链接名称
is_enable = models.BooleanField( link = models.URLField(_('链接地址')) # 链接URL
_('is show'), default=True, blank=False, null=False) sequence = models.IntegerField(_('排序'), unique=True) # 显示顺序
show_type = models.CharField( is_enable = models.BooleanField(_('是否显示'), default=True, blank=False, null=False) # 是否启用
_('show type'), show_type = models.CharField( # 显示位置
_('显示类型'),
max_length=1, max_length=1,
choices=LinkShowType.choices, choices=LinkShowType.choices,
default=LinkShowType.I) default=LinkShowType.I)
creation_time = models.DateTimeField(_('creation time'), default=now) creation_time = models.DateTimeField(_('创建时间'), default=now)
last_mod_time = models.DateTimeField(_('modify time'), default=now) last_mod_time = models.DateTimeField(_('修改时间'), default=now)
class Meta: class Meta:
ordering = ['sequence'] ordering = ['sequence'] # 按排序字段升序排列
verbose_name = _('link') verbose_name = _('友情链接')
verbose_name_plural = verbose_name verbose_name_plural = verbose_name
def __str__(self): def __str__(self):
@ -349,17 +450,20 @@ class Links(models.Model):
class SideBar(models.Model): class SideBar(models.Model):
"""侧边栏,可以展示一些html内容""" """
name = models.CharField(_('title'), max_length=100) 侧边栏模型
content = models.TextField(_('content')) 管理网站侧边栏的自定义内容
sequence = models.IntegerField(_('order'), unique=True) """
is_enable = models.BooleanField(_('is enable'), default=True) name = models.CharField(_('标题'), max_length=100) # 侧边栏标题
creation_time = models.DateTimeField(_('creation time'), default=now) content = models.TextField(_('内容')) # HTML内容
last_mod_time = models.DateTimeField(_('modify time'), default=now) sequence = models.IntegerField(_('排序'), unique=True) # 显示顺序
is_enable = models.BooleanField(_('是否启用'), default=True) # 是否启用
creation_time = models.DateTimeField(_('创建时间'), default=now)
last_mod_time = models.DateTimeField(_('修改时间'), default=now)
class Meta: class Meta:
ordering = ['sequence'] ordering = ['sequence'] # 按排序字段升序排列
verbose_name = _('sidebar') verbose_name = _('侧边栏')
verbose_name_plural = verbose_name verbose_name_plural = verbose_name
def __str__(self): def __str__(self):
@ -367,118 +471,111 @@ class SideBar(models.Model):
class BlogSettings(models.Model): class BlogSettings(models.Model):
"""blog的配置""" """
site_name = models.CharField( 博客全局设置模型
_('site name'), 存储博客的各种配置信息单例模式
max_length=200, """
null=False, # 网站基本信息
blank=False, site_name = models.CharField(_('网站名称'), max_length=200, null=False, blank=False, default='')
default='') site_description = models.TextField(_('网站描述'), max_length=1000, null=False, blank=False, default='')
site_description = models.TextField( site_seo_description = models.TextField(_('SEO描述'), max_length=1000, null=False, blank=False, default='')
_('site description'), site_keywords = models.TextField(_('网站关键词'), max_length=1000, null=False, blank=False, default='')
max_length=1000,
null=False, # 内容显示设置
blank=False, article_sub_length = models.IntegerField(_('文章摘要长度'), default=300) # 文章摘要截取长度
default='') sidebar_article_count = models.IntegerField(_('侧边栏文章数量'), default=10) # 侧边栏最新文章数量
site_seo_description = models.TextField( sidebar_comment_count = models.IntegerField(_('侧边栏评论数量'), default=5) # 侧边栏最新评论数量
_('site seo description'), max_length=1000, null=False, blank=False, default='') article_comment_count = models.IntegerField(_('文章评论分页数量'), default=5) # 文章详情页评论分页大小
site_keywords = models.TextField(
_('site keywords'), # 广告和统计
max_length=1000, show_google_adsense = models.BooleanField(_('显示广告'), default=False) # 是否显示Google广告
null=False, google_adsense_codes = models.TextField(_('广告代码'), max_length=2000, null=True, blank=True, default='') # 广告代码
blank=False,
default='') # 评论设置
article_sub_length = models.IntegerField(_('article sub length'), default=300) open_site_comment = models.BooleanField(_('开启全站评论'), default=True) # 是否开启评论功能
sidebar_article_count = models.IntegerField(_('sidebar article count'), default=10) comment_need_review = models.BooleanField('评论是否需要审核', default=False, null=False) # 评论是否需要审核
sidebar_comment_count = models.IntegerField(_('sidebar comment count'), default=5)
article_comment_count = models.IntegerField(_('article comment count'), default=5) # 页面布局
show_google_adsense = models.BooleanField(_('show adsense'), default=False) global_header = models.TextField("公共头部", null=True, blank=True, default='') # 全局头部HTML
google_adsense_codes = models.TextField( global_footer = models.TextField("公共尾部", null=True, blank=True, default='') # 全局尾部HTML
_('adsense code'), max_length=2000, null=True, blank=True, default='')
open_site_comment = models.BooleanField(_('open site comment'), default=True) # 备案信息
global_header = models.TextField("公共头部", null=True, blank=True, default='') beian_code = models.CharField('备案号', max_length=2000, null=True, blank=True, default='') # ICP备案号
global_footer = models.TextField("公共尾部", null=True, blank=True, default='') show_gongan_code = models.BooleanField('是否显示公安备案号', default=False, null=False) # 是否显示公安备案
beian_code = models.CharField( gongan_beiancode = models.TextField('公安备案号', max_length=2000, null=True, blank=True, default='') # 公安备案号
'备案号',
max_length=2000, # 网站统计
null=True, analytics_code = models.TextField("网站统计代码", max_length=1000, null=False, blank=False,
blank=True, default='') # 统计代码如Google Analytics
default='')
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='')
comment_need_review = models.BooleanField(
'评论是否需要审核', default=False, null=False)
class Meta: class Meta:
verbose_name = _('Website configuration') verbose_name = _('网站配置')
verbose_name_plural = verbose_name verbose_name_plural = verbose_name
def __str__(self): def __str__(self):
return self.site_name return self.site_name
def clean(self): def clean(self):
"""
验证方法确保只能有一个配置实例
"""
if BlogSettings.objects.exclude(id=self.id).count(): if BlogSettings.objects.exclude(id=self.id).count():
raise ValidationError(_('There can only be one configuration')) raise ValidationError(_('只能有一个网站配置'))
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""保存配置后清空相关缓存"""
super().save(*args, **kwargs) super().save(*args, **kwargs)
from djangoblog.utils import cache from djangoblog.utils import cache
cache.clear() cache.clear() # 清除所有缓存,因为配置变更可能影响多个页面
class Like(models.Model): class Like(models.Model):
"""文章点赞模型""" """
文章点赞模型
记录用户对文章的点赞关系
"""
user = models.ForeignKey( user = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
verbose_name=_('用户'), verbose_name=_('用户'),
on_delete=models.CASCADE on_delete=models.CASCADE # 用户删除时删除点赞记录
) )
article = models.ForeignKey( article = models.ForeignKey(
Article, Article,
verbose_name=_('文章'), verbose_name=_('文章'),
on_delete=models.CASCADE on_delete=models.CASCADE # 文章删除时删除点赞记录
) )
created_time = models.DateTimeField(_('点赞时间'), auto_now_add=True) created_time = models.DateTimeField(_('点赞时间'), auto_now_add=True) # 点赞时间
class Meta: class Meta:
verbose_name = _('点赞') verbose_name = _('点赞')
verbose_name_plural = verbose_name verbose_name_plural = verbose_name
unique_together = ('user', 'article') unique_together = ('user', 'article') # 同一个用户对同一篇文章只能点赞一次
def __str__(self): def __str__(self):
return f"{self.user.username} 点赞了 {self.article.title}" return f"{self.user.username} 点赞了 {self.article.title}"
class Favorite(models.Model): class Favorite(models.Model):
"""文章收藏模型""" """
文章收藏模型
记录用户对文章的收藏关系
"""
user = models.ForeignKey( user = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
verbose_name=_('用户'), verbose_name=_('用户'),
on_delete=models.CASCADE on_delete=models.CASCADE # 用户删除时删除收藏记录
) )
article = models.ForeignKey( article = models.ForeignKey(
Article, Article,
verbose_name=_('文章'), verbose_name=_('文章'),
on_delete=models.CASCADE on_delete=models.CASCADE # 文章删除时删除收藏记录
) )
created_time = models.DateTimeField(_('收藏时间'), auto_now_add=True) created_time = models.DateTimeField(_('收藏时间'), auto_now_add=True) # 收藏时间
class Meta: class Meta:
verbose_name = _('收藏') verbose_name = _('收藏')
verbose_name_plural = verbose_name verbose_name_plural = verbose_name
unique_together = ('user', 'article') unique_together = ('user', 'article') # 同一个用户对同一篇文章只能收藏一次
def __str__(self): def __str__(self):
return f"{self.user.username} 收藏了 {self.article.title}" return f"{self.user.username} 收藏了 {self.article.title}"

@ -1,78 +1,97 @@
"""
博客应用URL配置
定义文章相关的所有URL路由
"""
from django.urls import path from django.urls import path
from django.views.decorators.cache import cache_page from django.views.decorators.cache import cache_page
from . import views from . import views
app_name = "blog" app_name = "blog" # 应用命名空间
urlpatterns = [ urlpatterns = [
path( # 首页
r'', path(r'', views.IndexView.as_view(), name='index'),
views.IndexView.as_view(),
name='index'), # 首页分页
path( path(r'page/<int:page>/', views.IndexView.as_view(), name='index_page'),
r'page/<int:page>/',
views.IndexView.as_view(), # 文章详情页SEO友好URL/年/月/日/文章ID.html
name='index_page'),
path( path(
r'article/<int:year>/<int:month>/<int:day>/<int:article_id>.html', r'article/<int:year>/<int:month>/<int:day>/<int:article_id>.html',
views.ArticleDetailView.as_view(), views.ArticleDetailView.as_view(),
name='detailbyid'), name='detailbyid'
),
# 分类详情页
path( path(
r'category/<slug:category_name>.html', r'category/<slug:category_name>.html',
views.CategoryDetailView.as_view(), views.CategoryDetailView.as_view(),
name='category_detail'), name='category_detail'
),
# 分类分页
path( path(
r'category/<slug:category_name>/<int:page>.html', r'category/<slug:category_name>/<int:page>.html',
views.CategoryDetailView.as_view(), views.CategoryDetailView.as_view(),
name='category_detail_page'), name='category_detail_page'
),
# 作者详情页
path( path(
r'author/<author_name>.html', r'author/<author_name>.html',
views.AuthorDetailView.as_view(), views.AuthorDetailView.as_view(),
name='author_detail'), name='author_detail'
),
# 作者文章分页
path( path(
r'author/<author_name>/<int:page>.html', r'author/<author_name>/<int:page>.html',
views.AuthorDetailView.as_view(), views.AuthorDetailView.as_view(),
name='author_detail_page'), name='author_detail_page'
),
# 标签详情页
path( path(
r'tag/<slug:tag_name>.html', r'tag/<slug:tag_name>.html',
views.TagDetailView.as_view(), views.TagDetailView.as_view(),
name='tag_detail'), name='tag_detail'
),
# 标签分页
path( path(
r'tag/<slug:tag_name>/<int:page>.html', r'tag/<slug:tag_name>/<int:page>.html',
views.TagDetailView.as_view(), views.TagDetailView.as_view(),
name='tag_detail_page'), name='tag_detail_page'
),
# 文章归档页缓存1小时
path( path(
'archives.html', 'archives.html',
cache_page( cache_page(60 * 60)(views.ArchivesView.as_view()), # 缓存1小时
60 * 60)( name='archives'
views.ArchivesView.as_view()), ),
name='archives'),
path( # 友情链接页
'links.html', path('links.html', views.LinkListView.as_view(), name='links'),
views.LinkListView.as_view(),
name='links'), # 文件上传接口
path( path(r'upload', views.fileupload, name='upload'),
r'upload',
views.fileupload, # 缓存清理接口(管理员功能)
name='upload'), path(r'clean', views.clean_cache_view, name='clean'),
path(
r'clean', # ==================== 新增的点赞收藏功能 ====================
views.clean_cache_view,
name='clean'), # 点赞/取消点赞
path( path('like/<int:article_id>/', views.toggle_like, name='toggle_like'),
'like/<int:article_id>/',
views.toggle_like, # 收藏/取消收藏
name='toggle_like'), path('favorite/<int:article_id>/', views.toggle_favorite, name='toggle_favorite'),
path(
'favorite/<int:article_id>/', # 用户收藏列表
views.toggle_favorite, path('favorites/', views.favorite_list, name='favorite_list'),
name='toggle_favorite'),
path( # 用户点赞列表
'favorites/', path('likes/', views.like_list, name='like_list'),
views.favorite_list,
name='favorite_list'),
path(
'likes/',
views.like_list,
name='like_list'),
] ]

@ -1,3 +1,7 @@
"""
博客系统视图函数定义
处理文章展示分类标签搜索等核心功能
"""
import logging import logging
import os import os
import uuid import uuid
@ -25,23 +29,33 @@ logger = logging.getLogger(__name__)
class ArticleListView(ListView): class ArticleListView(ListView):
"""
文章列表基类视图
提供文章列表的通用功能和缓存机制所有列表视图的父类
"""
# template_name属性用于指定使用哪个模板进行渲染 # template_name属性用于指定使用哪个模板进行渲染
template_name = 'blog/article_index.html' template_name = 'blog/article_index.html'
# context_object_name属性用于给上下文变量取名在模板中使用该名字 # context_object_name属性用于给上下文变量取名在模板中使用该名字
context_object_name = 'article_list' context_object_name = 'article_list'
# 页面类型,分类目录或标签列表等 # 页面类型,分类目录或标签列表等,用于模板显示标识
page_type = '' page_type = ''
paginate_by = settings.PAGINATE_BY
page_kwarg = 'page' # 分页设置
paginate_by = settings.PAGINATE_BY # 每页显示文章数量从settings读取
page_kwarg = 'page' # URL中页码参数名称
# 链接显示类型,控制友情链接的显示位置
link_type = LinkShowType.L link_type = LinkShowType.L
def get_view_cache_key(self): def get_view_cache_key(self):
"""获取视图缓存键已弃用使用get_queryset_cache_key代替"""
return self.request.get['pages'] return self.request.get['pages']
@property @property
def page_number(self): def page_number(self):
"""获取当前页码的属性方法"""
page_kwarg = self.page_kwarg page_kwarg = self.page_kwarg
page = self.kwargs.get( page = self.kwargs.get(
page_kwarg) or self.request.GET.get(page_kwarg) or 1 page_kwarg) or self.request.GET.get(page_kwarg) or 1
@ -49,21 +63,23 @@ class ArticleListView(ListView):
def get_queryset_cache_key(self): def get_queryset_cache_key(self):
""" """
子类重写.获得queryset的缓存key 子类重写获得queryset的缓存key
每个子类必须实现此方法以生成唯一的缓存键
""" """
raise NotImplementedError() raise NotImplementedError()
def get_queryset_data(self): def get_queryset_data(self):
""" """
子类重写.获取queryset的数据 子类重写获取queryset的数据
定义具体的查询逻辑返回文章查询集
""" """
raise NotImplementedError() raise NotImplementedError()
def get_queryset_from_cache(self, cache_key): def get_queryset_from_cache(self, cache_key):
''' '''
缓存页面数据 缓存页面数据提高列表页加载速度
:param cache_key: 缓存key :param cache_key: 缓存key
:return: :return: 文章查询集
''' '''
value = cache.get(cache_key) value = cache.get(cache_key)
if value: if value:
@ -71,57 +87,89 @@ class ArticleListView(ListView):
return value return value
else: else:
article_list = self.get_queryset_data() article_list = self.get_queryset_data()
cache.set(cache_key, article_list) cache.set(cache_key, article_list) # 设置缓存,默认过期时间
logger.info('set view cache.key:{key}'.format(key=cache_key)) logger.info('set view cache.key:{key}'.format(key=cache_key))
return article_list return article_list
def get_queryset(self): def get_queryset(self):
''' '''
重写默认从缓存获取数据 重写默认方法从缓存获取数据
:return: 缓存未命中时从数据库查询并缓存结果
:return: 文章查询集
''' '''
key = self.get_queryset_cache_key() key = self.get_queryset_cache_key()
value = self.get_queryset_from_cache(key) value = self.get_queryset_from_cache(key)
return value return value
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""添加上下文数据,传递链接类型到模板"""
kwargs['linktype'] = self.link_type kwargs['linktype'] = self.link_type
return super(ArticleListView, self).get_context_data(**kwargs) return super(ArticleListView, self).get_context_data(**kwargs)
class IndexView(ArticleListView): class IndexView(ArticleListView):
''' '''
首页 首页视图
显示最新的文章列表是博客的入口页面
''' '''
# 友情链接类型 # 友情链接类型:首页显示
link_type = LinkShowType.I link_type = LinkShowType.I
def get_queryset_data(self): def get_queryset_data(self):
"""获取首页文章数据:所有已发布的普通文章(非页面)"""
article_list = Article.objects.filter(type='a', status='p') article_list = Article.objects.filter(type='a', status='p')
return article_list return article_list
def get_queryset_cache_key(self): def get_queryset_cache_key(self):
"""生成首页缓存键index_页码支持分页缓存"""
cache_key = 'index_{page}'.format(page=self.page_number) cache_key = 'index_{page}'.format(page=self.page_number)
return cache_key return cache_key
class ArticleDetailView(DetailView): class ArticleDetailView(DetailView):
''' '''
文章详情页面 文章详情页面视图
显示单篇文章的完整内容评论相关文章等信息
''' '''
template_name = 'blog/article_detail.html' template_name = 'blog/article_detail.html' # 详情页模板
model = Article model = Article # 关联的文章模型
pk_url_kwarg = 'article_id' pk_url_kwarg = 'article_id' # URL中文章ID的参数名
context_object_name = "article" context_object_name = "article" # 模板中文章对象的变量名
def get(self, request, *args, **kwargs):
"""重写get方法在获取文章时增加浏览量防刷机制"""
response = super().get(request, *args, **kwargs)
# 增加浏览量使用Cookie防刷
cookie_name = f'article_{self.object.id}_viewed'
if not request.COOKIES.get(cookie_name):
self.object.viewed() # 增加浏览量
response.set_cookie(cookie_name, 'true', max_age=60 * 60 * 24) # 24小时内不重复计数
return response
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""
构建文章详情页的完整上下文数据
包含评论分页相关文章导航等所有需要的数据
"""
# 初始化评论表单
comment_form = CommentForm() comment_form = CommentForm()
# 获取文章的所有评论(已启用状态)
article_comments = self.object.comment_list() article_comments = self.object.comment_list()
# 筛选顶级评论(没有父评论的评论)
parent_comments = article_comments.filter(parent_comment=None) parent_comments = article_comments.filter(parent_comment=None)
# 获取博客设置
blog_setting = get_blog_setting() blog_setting = get_blog_setting()
# 评论分页
paginator = Paginator(parent_comments, blog_setting.article_comment_count) paginator = Paginator(parent_comments, blog_setting.article_comment_count)
page = self.request.GET.get('comment_page', '1') page = self.request.GET.get('comment_page', '1')
# 页码验证和转换
if not page.isnumeric(): if not page.isnumeric():
page = 1 page = 1
else: else:
@ -131,25 +179,33 @@ class ArticleDetailView(DetailView):
if page > paginator.num_pages: if page > paginator.num_pages:
page = paginator.num_pages page = paginator.num_pages
# 获取当前页的评论
p_comments = paginator.page(page) p_comments = paginator.page(page)
# 生成分页导航URL
next_page = p_comments.next_page_number() if p_comments.has_next() else None 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 prev_page = p_comments.previous_page_number() if p_comments.has_previous() else None
# 构建评论分页URL
if next_page: if next_page:
kwargs[ kwargs[
'comment_next_page_url'] = self.object.get_absolute_url() + f'?comment_page={next_page}#commentlist-container' 'comment_next_page_url'] = self.object.get_absolute_url() + f'?comment_page={next_page}#commentlist-container'
if prev_page: if prev_page:
kwargs[ kwargs[
'comment_prev_page_url'] = self.object.get_absolute_url() + f'?comment_page={prev_page}#commentlist-container' '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['form'] = comment_form # 评论表单
kwargs['comment_count'] = len( kwargs['article_comments'] = article_comments # 所有评论
article_comments) if article_comments else 0 kwargs['p_comments'] = p_comments # 当前页评论
kwargs['comment_count'] = len(article_comments) if article_comments else 0 # 评论总数
kwargs['next_article'] = self.object.next_article
kwargs['prev_article'] = self.object.prev_article # 文章导航
kwargs['related_posts'] = self.object.get_related_posts(4) kwargs['next_article'] = self.object.next_article # 下一篇文章
kwargs['prev_article'] = self.object.prev_article # 上一篇文章
kwargs['related_posts'] = self.object.get_related_posts(4) # 相关文章推荐
# 调用父类方法构建基础上下文
context = super(ArticleDetailView, self).get_context_data(**kwargs) context = super(ArticleDetailView, self).get_context_data(**kwargs)
article = self.object article = self.object
@ -159,28 +215,48 @@ class ArticleDetailView(DetailView):
# Action Hook, 通知插件"文章详情已获取" # Action Hook, 通知插件"文章详情已获取"
hooks.run_action('after_article_body_get', article=article, request=self.request) hooks.run_action('after_article_body_get', article=article, request=self.request)
# 添加点赞和收藏状态(用户相关功能)
user = self.request.user
if user.is_authenticated:
kwargs['is_liked'] = article.is_liked_by_user(user)
kwargs['is_favorited'] = article.is_favorited_by_user(user)
else:
kwargs['is_liked'] = False
kwargs['is_favorited'] = False
kwargs['like_count'] = article.get_like_count()
kwargs['favorite_count'] = article.get_favorite_count()
return context return context
class CategoryDetailView(ArticleListView): class CategoryDetailView(ArticleListView):
''' '''
分类目录列表 分类目录列表视图
显示指定分类下的所有文章
''' '''
page_type = "分类目录归档" page_type = "分类目录归档" # 页面类型标识
def get_queryset_data(self): def get_queryset_data(self):
slug = self.kwargs['category_name'] """获取分类下的文章数据,包括子分类的文章"""
category = get_object_or_404(Category, slug=slug) slug = self.kwargs['category_name'] # 从URL获取分类slug
category = get_object_or_404(Category, slug=slug) # 获取分类对象
categoryname = category.name categoryname = category.name
self.categoryname = categoryname self.categoryname = categoryname # 保存分类名供其他方法使用
# 获取所有子分类的名称(包括当前分类)
categorynames = list( categorynames = list(
map(lambda c: c.name, category.get_sub_categorys())) map(lambda c: c.name, category.get_sub_categorys()))
# 查询这些分类下的所有已发布文章
article_list = Article.objects.filter( article_list = Article.objects.filter(
category__name__in=categorynames, status='p') category__name__in=categorynames, status='p')
return article_list return article_list
def get_queryset_cache_key(self): def get_queryset_cache_key(self):
"""生成分类页缓存键category_list_分类名_页码"""
slug = self.kwargs['category_name'] slug = self.kwargs['category_name']
category = get_object_or_404(Category, slug=slug) category = get_object_or_404(Category, slug=slug)
categoryname = category.name categoryname = category.name
@ -190,89 +266,67 @@ class CategoryDetailView(ArticleListView):
return cache_key return cache_key
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""添加上下文数据:页面类型和分类名称"""
categoryname = self.categoryname categoryname = self.categoryname
try: try:
categoryname = categoryname.split('/')[-1] categoryname = categoryname.split('/')[-1] # 处理多层分类名
except BaseException: except BaseException:
pass pass
kwargs['page_type'] = CategoryDetailView.page_type kwargs['page_type'] = CategoryDetailView.page_type
kwargs['tag_name'] = categoryname kwargs['tag_name'] = categoryname # 在模板中统一使用tag_name变量
return super(CategoryDetailView, self).get_context_data(**kwargs) return super(CategoryDetailView, self).get_context_data(**kwargs)
class AuthorDetailView(ArticleListView): class AuthorDetailView(ArticleListView):
''' '''
作者详情页 作者详情页视图
显示指定作者的所有文章
''' '''
page_type = '作者文章归档' page_type = '作者文章归档' # 页面类型标识
template_name = 'blog/article_detail.html'
model = Article
pk_url_kwarg = 'article_id'
context_object_name = "article"
def get(self, request, *args, **kwargs):
"""重写get方法在获取文章时增加浏览量"""
response = super().get(request, *args, **kwargs)
# 增加浏览量使用Cookie防刷
cookie_name = f'article_{self.object.id}_viewed'
if not request.COOKIES.get(cookie_name):
self.object.viewed()
response.set_cookie(cookie_name, 'true', max_age=60 * 60 * 24) # 24小时
return response
def get_queryset_cache_key(self): def get_queryset_cache_key(self):
"""生成作者页缓存键author_作者名_页码"""
from uuslug import slugify from uuslug import slugify
author_name = slugify(self.kwargs['author_name']) author_name = slugify(self.kwargs['author_name']) # 标准化作者名
cache_key = 'author_{author_name}_{page}'.format( cache_key = 'author_{author_name}_{page}'.format(
author_name=author_name, page=self.page_number) author_name=author_name, page=self.page_number)
return cache_key return cache_key
def get_queryset_data(self): def get_queryset_data(self):
"""获取作者的所有已发布普通文章"""
author_name = self.kwargs['author_name'] author_name = self.kwargs['author_name']
article_list = Article.objects.filter( article_list = Article.objects.filter(
author__username=author_name, type='a', status='p') author__username=author_name, type='a', status='p')
return article_list return article_list
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""添加上下文数据:页面类型和作者名称"""
author_name = self.kwargs['author_name'] author_name = self.kwargs['author_name']
kwargs['page_type'] = AuthorDetailView.page_type kwargs['page_type'] = AuthorDetailView.page_type
kwargs['tag_name'] = author_name kwargs['tag_name'] = author_name # 在模板中统一使用tag_name变量
return super(AuthorDetailView, self).get_context_data(**kwargs) return super(AuthorDetailView, self).get_context_data(**kwargs)
# 添加点赞和收藏状态
article = self.object
user = self.request.user
if user.is_authenticated:
kwargs['is_liked'] = article.is_liked_by_user(user)
kwargs['is_favorited'] = article.is_favorited_by_user(user)
else:
kwargs['is_liked'] = False
kwargs['is_favorited'] = False
kwargs['like_count'] = article.get_like_count()
kwargs['favorite_count'] = article.get_favorite_count()
class TagDetailView(ArticleListView): class TagDetailView(ArticleListView):
''' '''
标签列表页面 标签列表页面视图
显示指定标签下的所有文章
''' '''
page_type = '分类标签归档' page_type = '分类标签归档' # 页面类型标识
def get_queryset_data(self): def get_queryset_data(self):
slug = self.kwargs['tag_name'] """获取标签下的所有已发布文章"""
tag = get_object_or_404(Tag, slug=slug) slug = self.kwargs['tag_name'] # 从URL获取标签slug
tag = get_object_or_404(Tag, slug=slug) # 获取标签对象
tag_name = tag.name tag_name = tag.name
self.name = tag_name self.name = tag_name # 保存标签名供其他方法使用
article_list = Article.objects.filter( article_list = Article.objects.filter(
tags__name=tag_name, type='a', status='p') tags__name=tag_name, type='a', status='p') # 多对多关系查询
return article_list return article_list
def get_queryset_cache_key(self): def get_queryset_cache_key(self):
"""生成标签页缓存键tag_标签名_页码"""
slug = self.kwargs['tag_name'] slug = self.kwargs['tag_name']
tag = get_object_or_404(Tag, slug=slug) tag = get_object_or_404(Tag, slug=slug)
tag_name = tag.name tag_name = tag.name
@ -282,7 +336,7 @@ class TagDetailView(ArticleListView):
return cache_key return cache_key
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
# tag_name = self.kwargs['tag_name'] """添加上下文数据:页面类型和标签名称"""
tag_name = self.name tag_name = self.name
kwargs['page_type'] = TagDetailView.page_type kwargs['page_type'] = TagDetailView.page_type
kwargs['tag_name'] = tag_name kwargs['tag_name'] = tag_name
@ -291,39 +345,55 @@ class TagDetailView(ArticleListView):
class ArchivesView(ArticleListView): class ArchivesView(ArticleListView):
''' '''
文章归档页面 文章归档页面视图
显示所有文章的按时间归档列表
''' '''
page_type = '文章归档' page_type = '文章归档' # 页面类型标识
paginate_by = None paginate_by = None # 归档页不分页,显示所有文章
page_kwarg = None page_kwarg = None # 无页码参数
template_name = 'blog/article_archives.html' template_name = 'blog/article_archives.html' # 专用模板
def get_queryset_data(self): def get_queryset_data(self):
"""获取所有已发布文章(用于归档显示)"""
return Article.objects.filter(status='p').all() return Article.objects.filter(status='p').all()
def get_queryset_cache_key(self): def get_queryset_cache_key(self):
"""生成归档页缓存键archives单页不分页"""
cache_key = 'archives' cache_key = 'archives'
return cache_key return cache_key
class LinkListView(ListView): class LinkListView(ListView):
model = Links """
template_name = 'blog/links_list.html' 友情链接页面视图
显示所有启用的友情链接
"""
model = Links # 关联的友情链接模型
template_name = 'blog/links_list.html' # 链接页模板
def get_queryset(self): def get_queryset(self):
"""获取所有启用的友情链接,按排序字段排列"""
return Links.objects.filter(is_enable=True) return Links.objects.filter(is_enable=True)
class EsSearchView(SearchView): class EsSearchView(SearchView):
"""
Elasticsearch搜索视图
提供全文搜索功能
"""
def get_context(self): def get_context(self):
paginator, page = self.build_page() """构建搜索结果的上下文数据"""
paginator, page = self.build_page() # 构建分页
context = { context = {
"query": self.query, "query": self.query, # 搜索关键词
"form": self.form, "form": self.form, # 搜索表单
"page": page, "page": page, # 当前页数据
"paginator": paginator, "paginator": paginator, # 分页器
"suggestion": None, "suggestion": None, # 搜索建议
} }
# 添加拼写建议(如果搜索引擎支持)
if hasattr(self.results, "query") and self.results.query.backend.include_spelling: if hasattr(self.results, "query") and self.results.query.backend.include_spelling:
context["suggestion"] = self.results.query.get_spelling_suggestion() context["suggestion"] = self.results.query.get_spelling_suggestion()
context.update(self.extra_context()) context.update(self.extra_context())
@ -331,52 +401,72 @@ class EsSearchView(SearchView):
return context return context
@csrf_exempt @csrf_exempt # 免除CSRF保护用于文件上传
def fileupload(request): def fileupload(request):
""" """
文件上传接口图床功能
该方法需自己写调用端来上传图片该方法仅提供图床功能 该方法需自己写调用端来上传图片该方法仅提供图床功能
:param request: :param request: HTTP请求对象
:return: :return: 上传文件的URL列表
""" """
if request.method == 'POST': if request.method == 'POST':
# 验证签名,防止未授权上传
sign = request.GET.get('sign', None) sign = request.GET.get('sign', None)
if not sign: if not sign:
return HttpResponseForbidden() return HttpResponseForbidden()
if not sign == get_sha256(get_sha256(settings.SECRET_KEY)): if not sign == get_sha256(get_sha256(settings.SECRET_KEY)):
return HttpResponseForbidden() return HttpResponseForbidden()
response = [] response = []
# 处理所有上传的文件
for filename in request.FILES: for filename in request.FILES:
timestr = timezone.now().strftime('%Y/%m/%d') timestr = timezone.now().strftime('%Y/%m/%d') # 按日期分目录
# 判断文件类型
imgextensions = ['jpg', 'png', 'jpeg', 'bmp'] imgextensions = ['jpg', 'png', 'jpeg', 'bmp']
fname = u''.join(str(filename)) fname = u''.join(str(filename))
isimage = len([i for i in imgextensions if fname.find(i) >= 0]) > 0 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) base_dir = os.path.join(settings.STATICFILES, "files" if not isimage else "image", timestr)
if not os.path.exists(base_dir): if not os.path.exists(base_dir):
os.makedirs(base_dir) os.makedirs(base_dir) # 创建目录
# 生成唯一文件名
savepath = os.path.normpath(os.path.join(base_dir, f"{uuid.uuid4().hex}{os.path.splitext(filename)[-1]}")) savepath = os.path.normpath(os.path.join(base_dir, f"{uuid.uuid4().hex}{os.path.splitext(filename)[-1]}"))
# 安全检查:确保保存路径在预期目录内
if not savepath.startswith(base_dir): if not savepath.startswith(base_dir):
return HttpResponse("only for post") return HttpResponse("only for post")
# 保存文件
with open(savepath, 'wb+') as wfile: with open(savepath, 'wb+') as wfile:
for chunk in request.FILES[filename].chunks(): for chunk in request.FILES[filename].chunks():
wfile.write(chunk) wfile.write(chunk)
# 如果是图片,进行压缩优化
if isimage: if isimage:
from PIL import Image from PIL import Image
image = Image.open(savepath) image = Image.open(savepath)
image.save(savepath, quality=20, optimize=True) image.save(savepath, quality=20, optimize=True) # 压缩质量20%
url = static(savepath)
url = static(savepath) # 生成静态文件URL
response.append(url) response.append(url)
return HttpResponse(response) return HttpResponse(response)
else: else:
return HttpResponse("only for post") return HttpResponse("only for post") # 只支持POST请求
def page_not_found_view( def page_not_found_view(
request, request,
exception, exception,
template_name='blog/error_page.html'): template_name='blog/error_page.html'):
"""
404页面未找到错误处理视图
"""
if exception: if exception:
logger.error(exception) logger.error(exception) # 记录错误日志
url = request.get_full_path() url = request.get_full_path()
return render(request, return render(request,
template_name, template_name,
@ -386,6 +476,9 @@ def page_not_found_view(
def server_error_view(request, template_name='blog/error_page.html'): def server_error_view(request, template_name='blog/error_page.html'):
"""
500服务器错误处理视图
"""
return render(request, return render(request,
template_name, template_name,
{'message': _('Sorry, the server is busy, please click the home page to see other?'), {'message': _('Sorry, the server is busy, please click the home page to see other?'),
@ -397,8 +490,11 @@ def permission_denied_view(
request, request,
exception, exception,
template_name='blog/error_page.html'): template_name='blog/error_page.html'):
"""
403权限拒绝错误处理视图
"""
if exception: if exception:
logger.error(exception) logger.error(exception) # 记录错误日志
return render( return render(
request, template_name, { request, template_name, {
'message': _('Sorry, you do not have permission to access this page?'), 'message': _('Sorry, you do not have permission to access this page?'),
@ -406,33 +502,44 @@ def permission_denied_view(
def clean_cache_view(request): def clean_cache_view(request):
"""
清理缓存视图管理员功能
用于手动清除所有缓存
"""
cache.clear() cache.clear()
return HttpResponse('ok') return HttpResponse('ok')
# blog/views.py - 添加以下视图 # ==================== 新增的点赞收藏功能视图 ====================
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.http import JsonResponse from django.http import JsonResponse
from .models import Like, Favorite from .models import Like, Favorite
@login_required @login_required # 需要登录
def toggle_like(request, article_id): def toggle_like(request, article_id):
"""点赞/取消点赞""" """
点赞/取消点赞功能
AJAX接口返回JSON响应
"""
if request.method == 'POST': if request.method == 'POST':
article = get_object_or_404(Article, id=article_id) article = get_object_or_404(Article, id=article_id)
# 使用get_or_create实现切换功能
like, created = Like.objects.get_or_create( like, created = Like.objects.get_or_create(
user=request.user, user=request.user,
article=article article=article
) )
# 如果已存在(不是新创建的),则删除(取消点赞)
if not created: if not created:
like.delete() like.delete()
is_liked = False is_liked = False
else: else:
is_liked = True is_liked = True
# 获取更新后的点赞数量
like_count = article.get_like_count() like_count = article.get_like_count()
return JsonResponse({ return JsonResponse({
@ -446,21 +553,27 @@ def toggle_like(request, article_id):
@login_required @login_required
def toggle_favorite(request, article_id): def toggle_favorite(request, article_id):
"""收藏/取消收藏""" """
收藏/取消收藏功能
AJAX接口返回JSON响应
"""
if request.method == 'POST': if request.method == 'POST':
article = get_object_or_404(Article, id=article_id) article = get_object_or_404(Article, id=article_id)
# 使用get_or_create实现切换功能
favorite, created = Favorite.objects.get_or_create( favorite, created = Favorite.objects.get_or_create(
user=request.user, user=request.user,
article=article article=article
) )
# 如果已存在(不是新创建的),则删除(取消收藏)
if not created: if not created:
favorite.delete() favorite.delete()
is_favorited = False is_favorited = False
else: else:
is_favorited = True is_favorited = True
# 获取更新后的收藏数量
favorite_count = article.get_favorite_count() favorite_count = article.get_favorite_count()
return JsonResponse({ return JsonResponse({
@ -474,13 +587,21 @@ def toggle_favorite(request, article_id):
@login_required @login_required
def favorite_list(request): def favorite_list(request):
"""用户的收藏列表""" """
用户的收藏列表页面
显示当前用户收藏的所有文章
"""
# 使用select_related优化查询避免N+1问题
favorites = Favorite.objects.filter(user=request.user).select_related('article') favorites = Favorite.objects.filter(user=request.user).select_related('article')
return render(request, 'blog/favorite_list.html', {'favorites': favorites}) return render(request, 'blog/favorite_list.html', {'favorites': favorites})
@login_required @login_required
def like_list(request): def like_list(request):
"""用户的点赞列表""" """
用户的点赞列表页面
显示当前用户点赞的所有文章
"""
# 使用select_related优化查询避免N+1问题
likes = Like.objects.filter(user=request.user).select_related('article') likes = Like.objects.filter(user=request.user).select_related('article')
return render(request, 'blog/like_list.html', {'likes': likes}) return render(request, 'blog/like_list.html', {'likes': likes})

@ -1,3 +1,7 @@
"""
评论系统数据模型定义
支持多级评论回复功能
"""
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from django.utils.timezone import now from django.utils.timezone import now
@ -6,34 +10,55 @@ from django.utils.translation import gettext_lazy as _
from blog.models import Article from blog.models import Article
# Create your models here.
class Comment(models.Model): class Comment(models.Model):
body = models.TextField('正文', max_length=300) """
creation_time = models.DateTimeField(_('creation time'), default=now) 评论模型
last_modify_time = models.DateTimeField(_('last modify time'), default=now) 支持对文章的评论和评论之间的回复
"""
# 评论内容
body = models.TextField('正文', max_length=300) # 评论正文限制300字
# 时间戳
creation_time = models.DateTimeField(_('创建时间'), default=now)
last_modify_time = models.DateTimeField(_('最后修改时间'), default=now)
# 关联关系
author = models.ForeignKey( author = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
verbose_name=_('author'), verbose_name=_('作者'),
on_delete=models.CASCADE) on_delete=models.CASCADE) # 用户删除时删除其所有评论
article = models.ForeignKey( article = models.ForeignKey(
Article, Article,
verbose_name=_('article'), verbose_name=_('文章'),
on_delete=models.CASCADE) on_delete=models.CASCADE) # 文章删除时删除其所有评论
# 父级评论,实现评论回复功能
parent_comment = models.ForeignKey( parent_comment = models.ForeignKey(
'self', 'self', # 自关联
verbose_name=_('parent comment'), verbose_name=_('父级评论'),
blank=True, blank=True,
null=True, null=True,
on_delete=models.CASCADE) on_delete=models.CASCADE) # 父评论删除时删除子评论
is_enable = models.BooleanField(_('enable'),
default=False, blank=False, null=False) # 评论状态
is_enable = models.BooleanField(
_('是否启用'),
default=False, # 默认需要审核
blank=False,
null=False
)
class Meta: class Meta:
ordering = ['-id'] """元数据配置"""
verbose_name = _('comment') ordering = ['-id'] # 按ID降序新的评论在前
verbose_name = _('评论')
verbose_name_plural = verbose_name verbose_name_plural = verbose_name
get_latest_by = 'id' get_latest_by = 'id'
def __str__(self): def __str__(self):
return self.body """
字符串表示
返回评论内容的前50个字符
"""
return self.body[:50] + '...' if len(self.body) > 50 else self.body

@ -1,4 +1,7 @@
# Create your views here. """
评论系统视图函数
处理评论的提交验证和显示
"""
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
@ -13,51 +16,70 @@ from .models import Comment
class CommentPostView(FormView): class CommentPostView(FormView):
form_class = CommentForm """
template_name = 'blog/article_detail.html' 评论提交视图
处理用户评论的提交和验证
"""
form_class = CommentForm # 评论表单类
template_name = 'blog/article_detail.html' # 错误时使用的模板
@method_decorator(csrf_protect) @method_decorator(csrf_protect) # CSRF保护
def dispatch(self, *args, **kwargs): def dispatch(self, *args, **kwargs):
return super(CommentPostView, self).dispatch(*args, **kwargs) return super(CommentPostView, self).dispatch(*args, **kwargs)
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
"""处理GET请求重定向到文章详情页的评论区域"""
article_id = self.kwargs['article_id'] article_id = self.kwargs['article_id']
article = get_object_or_404(Article, pk=article_id) article = get_object_or_404(Article, pk=article_id)
url = article.get_absolute_url() url = article.get_absolute_url()
return HttpResponseRedirect(url + "#comments") return HttpResponseRedirect(url + "#comments") # 锚点定位到评论区域
def form_invalid(self, form): def form_invalid(self, form):
"""表单验证失败的处理"""
article_id = self.kwargs['article_id'] article_id = self.kwargs['article_id']
article = get_object_or_404(Article, pk=article_id) article = get_object_or_404(Article, pk=article_id)
# 重新渲染文章详情页,显示表单错误
return self.render_to_response({ return self.render_to_response({
'form': form, 'form': form,
'article': article 'article': article
}) })
def form_valid(self, form): def form_valid(self, form):
"""提交的数据验证合法后的逻辑""" """
表单验证通过后的评论保存逻辑
创建评论对象处理父子评论关系
"""
user = self.request.user user = self.request.user
author = BlogUser.objects.get(pk=user.pk) author = BlogUser.objects.get(pk=user.pk) # 获取用户对象
article_id = self.kwargs['article_id'] article_id = self.kwargs['article_id']
article = get_object_or_404(Article, pk=article_id) article = get_object_or_404(Article, pk=article_id)
# 检查文章是否允许评论
if article.comment_status == 'c' or article.status == 'c': if article.comment_status == 'c' or article.status == 'c':
raise ValidationError("该文章评论已关闭.") raise ValidationError("该文章评论已关闭.")
# 创建评论对象(不立即保存)
comment = form.save(False) comment = form.save(False)
comment.article = article comment.article = article # 关联文章
# 根据博客设置决定评论是否需要审核
from djangoblog.utils import get_blog_setting from djangoblog.utils import get_blog_setting
settings = get_blog_setting() settings = get_blog_setting()
if not settings.comment_need_review: if not settings.comment_need_review:
comment.is_enable = True comment.is_enable = True # 无需审核,直接启用
comment.author = author
comment.author = author # 设置评论作者
# 处理回复评论(父子评论关系)
if form.cleaned_data['parent_comment_id']: if form.cleaned_data['parent_comment_id']:
parent_comment = Comment.objects.get( parent_comment = Comment.objects.get(
pk=form.cleaned_data['parent_comment_id']) pk=form.cleaned_data['parent_comment_id'])
comment.parent_comment = parent_comment comment.parent_comment = parent_comment # 设置父评论
comment.save(True) # 保存评论到数据库
comment.save(True) # 重定向到文章详情页的特定评论位置
return HttpResponseRedirect( return HttpResponseRedirect(
"%s#div-comment-%d" % "%s#div-comment-%d" % # 使用锚点定位到新评论
(article.get_absolute_url(), comment.pk)) (article.get_absolute_url(), comment.pk))

@ -1,78 +1,103 @@
"""djangoblog URL Configuration """
DjangoBlog项目主URL配置
The `urlpatterns` list routes URLs to views. For more information please see: 定义项目的所有URL路由和全局配置
https://docs.djangoproject.com/en/1.10/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: url(r'^$', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.conf.urls import url, include
2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls'))
""" """
from django.conf import settings from django.conf import settings
from django.conf.urls.i18n import i18n_patterns from django.conf.urls.i18n import i18n_patterns # 国际化URL支持
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib.sitemaps.views import sitemap from django.contrib.sitemaps.views import sitemap # 站点地图
from django.urls import path, include from django.urls import path, include
from django.urls import re_path from django.urls import re_path
from haystack.views import search_view_factory from haystack.views import search_view_factory # 搜索视图工厂
from django.http import JsonResponse from django.http import JsonResponse
import time import time # 时间相关功能
from blog.views import EsSearchView from blog.views import EsSearchView
from djangoblog.admin_site import admin_site from djangoblog.admin_site import admin_site # 自定义Admin站点
from djangoblog.elasticsearch_backend import ElasticSearchModelSearchForm from djangoblog.elasticsearch_backend import ElasticSearchModelSearchForm # ES搜索表单
from djangoblog.feeds import DjangoBlogFeed from djangoblog.feeds import DjangoBlogFeed # RSS订阅
from djangoblog.sitemap import ArticleSiteMap, CategorySiteMap, StaticViewSitemap, TagSiteMap, UserSiteMap from djangoblog.sitemap import ArticleSiteMap, CategorySiteMap, StaticViewSitemap, TagSiteMap, UserSiteMap
# 站点地图配置
sitemaps = { sitemaps = {
'blog': ArticleSiteMap, # 文章站点地图
'blog': ArticleSiteMap, 'Category': CategorySiteMap, # 分类站点地图
'Category': CategorySiteMap, 'Tag': TagSiteMap, # 标签站点地图
'Tag': TagSiteMap, 'User': UserSiteMap, # 用户站点地图
'User': UserSiteMap, 'static': StaticViewSitemap # 静态页面站点地图
'static': StaticViewSitemap
} }
handler404 = 'blog.views.page_not_found_view' # 全局错误处理视图配置
handler500 = 'blog.views.server_error_view' handler404 = 'blog.views.page_not_found_view' # 404错误处理
handle403 = 'blog.views.permission_denied_view' handler500 = 'blog.views.server_error_view' # 500错误处理
handle403 = 'blog.views.permission_denied_view' # 403错误处理
def health_check(request): def health_check(request):
""" """
健康检查接口 健康检查接口
简单返回服务健康状态 用于负载均衡健康检查和监控系统
:param request: HTTP请求对象
:return: JSON格式的健康状态响应
""" """
return JsonResponse({ return JsonResponse({
'status': 'healthy', 'status': 'healthy', # 服务状态
'timestamp': time.time() 'timestamp': time.time() # 当前时间戳
}) })
# 基础URL模式不包含语言前缀
urlpatterns = [ urlpatterns = [
path('i18n/', include('django.conf.urls.i18n')), path('i18n/', include('django.conf.urls.i18n')), # 国际化URL支持
path('health/', health_check, name='health_check'), path('health/', health_check, name='health_check'), # 健康检查端点
] ]
# 国际化URL模式包含语言前缀
urlpatterns += i18n_patterns( urlpatterns += i18n_patterns(
# 管理员后台
re_path(r'^admin/', admin_site.urls), re_path(r'^admin/', admin_site.urls),
# 博客应用URL
re_path(r'', include('blog.urls', namespace='blog')), re_path(r'', include('blog.urls', namespace='blog')),
# Markdown编辑器URL
re_path(r'mdeditor/', include('mdeditor.urls')), re_path(r'mdeditor/', include('mdeditor.urls')),
# 评论系统URL
re_path(r'', include('comments.urls', namespace='comment')), re_path(r'', include('comments.urls', namespace='comment')),
# 用户账户URL
re_path(r'', include('accounts.urls', namespace='account')), re_path(r'', include('accounts.urls', namespace='account')),
# OAuth认证URL
re_path(r'', include('oauth.urls', namespace='oauth')), re_path(r'', include('oauth.urls', namespace='oauth')),
# 站点地图URL
re_path(r'^sitemap\.xml$', sitemap, {'sitemaps': sitemaps}, re_path(r'^sitemap\.xml$', sitemap, {'sitemaps': sitemaps},
name='django.contrib.sitemaps.views.sitemap'), name='django.contrib.sitemaps.views.sitemap'),
# RSS订阅源
re_path(r'^feed/$', DjangoBlogFeed()), re_path(r'^feed/$', DjangoBlogFeed()),
re_path(r'^rss/$', DjangoBlogFeed()), re_path(r'^rss/$', DjangoBlogFeed()),
re_path('^search', search_view_factory(view_class=EsSearchView, form_class=ElasticSearchModelSearchForm),
# 搜索功能使用Elasticsearch
re_path('^search', search_view_factory(
view_class=EsSearchView,
form_class=ElasticSearchModelSearchForm),
name='search'), name='search'),
# 服务器管理URL
re_path(r'', include('servermanager.urls', namespace='servermanager')), re_path(r'', include('servermanager.urls', namespace='servermanager')),
# OwnTracks位置追踪URL
re_path(r'', include('owntracks.urls', namespace='owntracks')) re_path(r'', include('owntracks.urls', namespace='owntracks'))
, prefix_default_language=False) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
, prefix_default_language=False) # 不强制默认语言前缀
# 静态文件服务(开发环境)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
# 媒体文件服务(开发环境)
if settings.DEBUG: if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, urlpatterns += static(settings.MEDIA_URL,
document_root=settings.MEDIA_ROOT) document_root=settings.MEDIA_ROOT)

@ -1,20 +0,0 @@
The MIT License (MIT)
Copyright (c) 2025 车亮亮
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

@ -1,158 +0,0 @@
# DjangoBlog
<p align="center">
<a href="https://github.com/liangliangyy/DjangoBlog/actions/workflows/django.yml"><img src="https://github.com/liangliangyy/DjangoBlog/actions/workflows/django.yml/badge.svg" alt="Django CI"></a>
<a href="https://github.com/liangliangyy/DjangoBlog/actions/workflows/codeql-analysis.yml"><img src="https://github.com/liangliangyy/DjangoBlog/actions/workflows/codeql-analysis.yml/badge.svg" alt="CodeQL"></a>
<a href="https://codecov.io/gh/liangliangyy/DjangoBlog"><img src="https://codecov.io/gh/liangliangyy/DjangoBlog/branch/master/graph/badge.svg" alt="codecov"></a>
<a href="https://github.com/liangliangyy/DjangoBlog/blob/master/LICENSE"><img src="https://img.shields.io/github/license/liangliangyy/djangoblog.svg" alt="license"></a>
</p>
<p align="center">
<b>一款功能强大、设计优雅的现代化博客系统</b>
<br>
<a href="/docs/README-en.md">English</a><b>简体中文</b>
</p>
---
DjangoBlog 是一款基于 Python 3.10 和 Django 4.0 构建的高性能博客平台。它不仅提供了传统博客的所有核心功能还通过一个灵活的插件系统让您可以轻松扩展和定制您的网站。无论您是个人博主、技术爱好者还是内容创作者DjangoBlog 都旨在为您提供一个稳定、高效且易于维护的写作和发布环境。
## ✨ 特性亮点
- **强大的内容管理**: 支持文章、独立页面、分类和标签的完整管理。内置强大的 Markdown 编辑器,支持代码语法高亮。
- **全文搜索**: 集成搜索引擎,提供快速、精准的文章内容搜索。
- **互动评论系统**: 支持回复、邮件提醒等功能,评论内容同样支持 Markdown。
- **灵活的侧边栏**: 可自定义展示最新文章、最多阅读、标签云等模块。
- **社交化登录**: 内置 OAuth 支持,已集成 Google, GitHub, Facebook, 微博, QQ 等主流平台。
- **高性能缓存**: 原生支持 Redis 缓存,并提供自动刷新机制,确保网站高速响应。
- **SEO 友好**: 具备基础 SEO 功能,新内容发布后可自动通知 Google 和百度。
- **便捷的插件系统**: 通过创建独立的插件来扩展博客功能代码解耦易于维护。我们已经通过插件实现了文章浏览计数、SEO 优化等功能!
- **集成图床**: 内置简单的图床功能,方便图片上传和管理。
- **自动化前端**: 集成 `django-compressor`,自动压缩和优化 CSS 及 JavaScript 文件。
- **健壮的运维**: 内置网站异常邮件提醒和微信公众号管理功能。
## 🛠️ 技术栈
- **后端**: Python 3.10, Django 4.0
- **数据库**: MySQL, SQLite (可配置)
- **缓存**: Redis
- **前端**: HTML5, CSS3, JavaScript
- **搜索**: Whoosh, Elasticsearch (可配置)
- **编辑器**: Markdown (mdeditor)
## 🚀 快速开始
### 1. 环境准备
确保您的系统中已安装 Python 3.10+ 和 MySQL/MariaDB。
### 2. 克隆与安装
```bash
# 克隆项目到本地
git clone https://github.com/liangliangyy/DjangoBlog.git
cd DjangoBlog
# 安装依赖
pip install -r requirements.txt
```
### 3. 项目配置
- **数据库**:
打开 `djangoblog/settings.py` 文件,找到 `DATABASES` 配置项,修改为您的 MySQL 连接信息。
```python
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'djangoblog',
'USER': 'root',
'PASSWORD': 'your_password',
'HOST': '127.0.0.1',
'PORT': 3306,
}
}
```
在 MySQL 中创建数据库:
```sql
CREATE DATABASE `djangoblog` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
- **更多配置**:
关于邮件发送、OAuth 登录、缓存等更多高级配置,请参阅我们的 [详细配置文档](/docs/config.md)。
### 4. 初始化数据库
```bash
python manage.py makemigrations
python manage.py migrate
# 创建一个超级管理员账户
python manage.py createsuperuser
```
### 5. 运行项目
```bash
# (可选) 生成一些测试数据
python manage.py create_testdata
# (可选) 收集和压缩静态文件
python manage.py collectstatic --noinput
python manage.py compress --force
# 启动开发服务器
python manage.py runserver
```
现在,在您的浏览器中访问 `http://127.0.0.1:8000/`,您应该能看到 DjangoBlog 的首页了!
## 部署
- **传统部署**: 我们为您准备了非常详细的 [服务器部署教程](https://www.lylinux.net/article/2019/8/5/58.html)。
- **Docker 部署**: 项目已全面支持 Docker。如果您熟悉容器化技术请参考 [Docker 部署文档](/docs/docker.md) 来快速启动。
- **Kubernetes 部署**: 我们也提供了完整的 [Kubernetes 部署指南](/docs/k8s.md),助您轻松上云。
## 🧩 插件系统
插件系统是 DjangoBlog 的核心特色之一。它允许您在不修改核心代码的情况下,通过编写独立的插件来为您的博客添加新功能。
- **工作原理**: 插件通过在预定义的“钩子”上注册回调函数来工作。例如,当一篇文章被渲染时,`after_article_body_get` 钩子会被触发,所有注册到此钩子的函数都会被执行。
- **现有插件**: `view_count`(浏览计数), `seo_optimizer`SEO优化等都是通过插件系统实现的。
- **开发您自己的插件**: 只需在 `plugins` 目录下创建一个新的文件夹,并编写您的 `plugin.py`。欢迎探索并为 DjangoBlog 社区贡献您的创意!
## 🤝 贡献指南
我们热烈欢迎任何形式的贡献!如果您有好的想法或发现了 Bug请随时提交 Issue 或 Pull Request。
## 📄 许可证
本项目基于 [MIT License](LICENSE) 开源。
---
## ❤️ 支持与赞助
如果您觉得这个项目对您有帮助,并且希望支持我继续维护和开发新功能,欢迎请我喝杯咖啡!您的每一份支持都是我前进的最大动力。
<p align="center">
<img src="/docs/imgs/alipay.jpg" width="150" alt="支付宝赞助">
<img src="/docs/imgs/wechat.jpg" width="150" alt="微信赞助">
</p>
<p align="center">
<i>(左) 支付宝 / (右) 微信</i>
</p>
## 🙏 鸣谢
特别感谢 **JetBrains** 为本项目提供的免费开源许可证。
<p align="center">
<a href="https://www.jetbrains.com/?from=DjangoBlog">
<img src="/docs/imgs/pycharm_logo.png" width="150" alt="JetBrains Logo">
</a>
</p>
---
> 如果本项目帮助到了你,请在[这里](https://github.com/liangliangyy/DjangoBlog/issues/214)留下你的网址,让更多的人看到。您的回复将会是我继续更新维护下去的动力。

@ -1,15 +0,0 @@
FROM python:3.11
ENV PYTHONUNBUFFERED 1
WORKDIR /code/djangoblog/
RUN apt-get update && \
apt-get install default-libmysqlclient-dev gettext -y && \
rm -rf /var/lib/apt/lists/*
ADD requirements.txt requirements.txt
RUN pip install --upgrade pip && \
pip install --no-cache-dir -r requirements.txt && \
pip install --no-cache-dir gunicorn[gevent] && \
pip cache purge
ADD . .
RUN chmod +x /code/djangoblog/deploy/entrypoint.sh
ENTRYPOINT ["/code/djangoblog/deploy/entrypoint.sh"]

@ -1,20 +0,0 @@
The MIT License (MIT)
Copyright (c) 2025 车亮亮
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

@ -1,158 +0,0 @@
# DjangoBlog
<p align="center">
<a href="https://github.com/liangliangyy/DjangoBlog/actions/workflows/django.yml"><img src="https://github.com/liangliangyy/DjangoBlog/actions/workflows/django.yml/badge.svg" alt="Django CI"></a>
<a href="https://github.com/liangliangyy/DjangoBlog/actions/workflows/codeql-analysis.yml"><img src="https://github.com/liangliangyy/DjangoBlog/actions/workflows/codeql-analysis.yml/badge.svg" alt="CodeQL"></a>
<a href="https://codecov.io/gh/liangliangyy/DjangoBlog"><img src="https://codecov.io/gh/liangliangyy/DjangoBlog/branch/master/graph/badge.svg" alt="codecov"></a>
<a href="https://github.com/liangliangyy/DjangoBlog/blob/master/LICENSE"><img src="https://img.shields.io/github/license/liangliangyy/djangoblog.svg" alt="license"></a>
</p>
<p align="center">
<b>一款功能强大、设计优雅的现代化博客系统</b>
<br>
<a href="/docs/README-en.md">English</a><b>简体中文</b>
</p>
---
DjangoBlog 是一款基于 Python 3.10 和 Django 4.0 构建的高性能博客平台。它不仅提供了传统博客的所有核心功能还通过一个灵活的插件系统让您可以轻松扩展和定制您的网站。无论您是个人博主、技术爱好者还是内容创作者DjangoBlog 都旨在为您提供一个稳定、高效且易于维护的写作和发布环境。
## ✨ 特性亮点
- **强大的内容管理**: 支持文章、独立页面、分类和标签的完整管理。内置强大的 Markdown 编辑器,支持代码语法高亮。
- **全文搜索**: 集成搜索引擎,提供快速、精准的文章内容搜索。
- **互动评论系统**: 支持回复、邮件提醒等功能,评论内容同样支持 Markdown。
- **灵活的侧边栏**: 可自定义展示最新文章、最多阅读、标签云等模块。
- **社交化登录**: 内置 OAuth 支持,已集成 Google, GitHub, Facebook, 微博, QQ 等主流平台。
- **高性能缓存**: 原生支持 Redis 缓存,并提供自动刷新机制,确保网站高速响应。
- **SEO 友好**: 具备基础 SEO 功能,新内容发布后可自动通知 Google 和百度。
- **便捷的插件系统**: 通过创建独立的插件来扩展博客功能代码解耦易于维护。我们已经通过插件实现了文章浏览计数、SEO 优化等功能!
- **集成图床**: 内置简单的图床功能,方便图片上传和管理。
- **自动化前端**: 集成 `django-compressor`,自动压缩和优化 CSS 及 JavaScript 文件。
- **健壮的运维**: 内置网站异常邮件提醒和微信公众号管理功能。
## 🛠️ 技术栈
- **后端**: Python 3.10, Django 4.0
- **数据库**: MySQL, SQLite (可配置)
- **缓存**: Redis
- **前端**: HTML5, CSS3, JavaScript
- **搜索**: Whoosh, Elasticsearch (可配置)
- **编辑器**: Markdown (mdeditor)
## 🚀 快速开始
### 1. 环境准备
确保您的系统中已安装 Python 3.10+ 和 MySQL/MariaDB。
### 2. 克隆与安装
```bash
# 克隆项目到本地
git clone https://github.com/liangliangyy/DjangoBlog.git
cd DjangoBlog
# 安装依赖
pip install -r requirements.txt
```
### 3. 项目配置
- **数据库**:
打开 `djangoblog/settings.py` 文件,找到 `DATABASES` 配置项,修改为您的 MySQL 连接信息。
```python
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'djangoblog',
'USER': 'root',
'PASSWORD': 'your_password',
'HOST': '127.0.0.1',
'PORT': 3306,
}
}
```
在 MySQL 中创建数据库:
```sql
CREATE DATABASE `djangoblog` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
- **更多配置**:
关于邮件发送、OAuth 登录、缓存等更多高级配置,请参阅我们的 [详细配置文档](/docs/config.md)。
### 4. 初始化数据库
```bash
python manage.py makemigrations
python manage.py migrate
# 创建一个超级管理员账户
python manage.py createsuperuser
```
### 5. 运行项目
```bash
# (可选) 生成一些测试数据
python manage.py create_testdata
# (可选) 收集和压缩静态文件
python manage.py collectstatic --noinput
python manage.py compress --force
# 启动开发服务器
python manage.py runserver
```
现在,在您的浏览器中访问 `http://127.0.0.1:8000/`,您应该能看到 DjangoBlog 的首页了!
## 部署
- **传统部署**: 我们为您准备了非常详细的 [服务器部署教程](https://www.lylinux.net/article/2019/8/5/58.html)。
- **Docker 部署**: 项目已全面支持 Docker。如果您熟悉容器化技术请参考 [Docker 部署文档](/docs/docker.md) 来快速启动。
- **Kubernetes 部署**: 我们也提供了完整的 [Kubernetes 部署指南](/docs/k8s.md),助您轻松上云。
## 🧩 插件系统
插件系统是 DjangoBlog 的核心特色之一。它允许您在不修改核心代码的情况下,通过编写独立的插件来为您的博客添加新功能。
- **工作原理**: 插件通过在预定义的“钩子”上注册回调函数来工作。例如,当一篇文章被渲染时,`after_article_body_get` 钩子会被触发,所有注册到此钩子的函数都会被执行。
- **现有插件**: `view_count`(浏览计数), `seo_optimizer`SEO优化等都是通过插件系统实现的。
- **开发您自己的插件**: 只需在 `plugins` 目录下创建一个新的文件夹,并编写您的 `plugin.py`。欢迎探索并为 DjangoBlog 社区贡献您的创意!
## 🤝 贡献指南
我们热烈欢迎任何形式的贡献!如果您有好的想法或发现了 Bug请随时提交 Issue 或 Pull Request。
## 📄 许可证
本项目基于 [MIT License](LICENSE) 开源。
---
## ❤️ 支持与赞助
如果您觉得这个项目对您有帮助,并且希望支持我继续维护和开发新功能,欢迎请我喝杯咖啡!您的每一份支持都是我前进的最大动力。
<p align="center">
<img src="/docs/imgs/alipay.jpg" width="150" alt="支付宝赞助">
<img src="/docs/imgs/wechat.jpg" width="150" alt="微信赞助">
</p>
<p align="center">
<i>(左) 支付宝 / (右) 微信</i>
</p>
## 🙏 鸣谢
特别感谢 **JetBrains** 为本项目提供的免费开源许可证。
<p align="center">
<a href="https://www.jetbrains.com/?from=DjangoBlog">
<img src="/docs/imgs/pycharm_logo.png" width="150" alt="JetBrains Logo">
</a>
</p>
---
> 如果本项目帮助到了你,请在[这里](https://github.com/liangliangyy/DjangoBlog/issues/214)留下你的网址,让更多的人看到。您的回复将会是我继续更新维护下去的动力。

@ -1,59 +0,0 @@
from django import forms
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.forms import UserChangeForm
from django.contrib.auth.forms import UsernameField
from django.utils.translation import gettext_lazy as _
# Register your models here.
from .models import BlogUser
class BlogUserCreationForm(forms.ModelForm):
password1 = forms.CharField(label=_('password'), widget=forms.PasswordInput)
password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput)
class Meta:
model = BlogUser
fields = ('email',)
def clean_password2(self):
# Check that the two password entries match
password1 = self.cleaned_data.get("password1")
password2 = self.cleaned_data.get("password2")
if password1 and password2 and password1 != password2:
raise forms.ValidationError(_("passwords do not match"))
return password2
def save(self, commit=True):
# Save the provided password in hashed format
user = super().save(commit=False)
user.set_password(self.cleaned_data["password1"])
if commit:
user.source = 'adminsite'
user.save()
return user
class BlogUserChangeForm(UserChangeForm):
class Meta:
model = BlogUser
fields = '__all__'
field_classes = {'username': UsernameField}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class BlogUserAdmin(UserAdmin):
form = BlogUserChangeForm
add_form = BlogUserCreationForm
list_display = (
'id',
'nickname',
'username',
'email',
'last_login',
'date_joined',
'source')
list_display_links = ('id', 'username')
ordering = ('-id',)

@ -1,5 +0,0 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
name = 'accounts'

@ -1,117 +0,0 @@
from django import forms
from django.contrib.auth import get_user_model, password_validation
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
from django.core.exceptions import ValidationError
from django.forms import widgets
from django.utils.translation import gettext_lazy as _
from . import utils
from .models import BlogUser
class LoginForm(AuthenticationForm):
def __init__(self, *args, **kwargs):
super(LoginForm, self).__init__(*args, **kwargs)
self.fields['username'].widget = widgets.TextInput(
attrs={'placeholder': "username", "class": "form-control"})
self.fields['password'].widget = widgets.PasswordInput(
attrs={'placeholder': "password", "class": "form-control"})
class RegisterForm(UserCreationForm):
def __init__(self, *args, **kwargs):
super(RegisterForm, self).__init__(*args, **kwargs)
self.fields['username'].widget = widgets.TextInput(
attrs={'placeholder': "username", "class": "form-control"})
self.fields['email'].widget = widgets.EmailInput(
attrs={'placeholder': "email", "class": "form-control"})
self.fields['password1'].widget = widgets.PasswordInput(
attrs={'placeholder': "password", "class": "form-control"})
self.fields['password2'].widget = widgets.PasswordInput(
attrs={'placeholder': "repeat password", "class": "form-control"})
def clean_email(self):
email = self.cleaned_data['email']
if get_user_model().objects.filter(email=email).exists():
raise ValidationError(_("email already exists"))
return email
class Meta:
model = get_user_model()
fields = ("username", "email")
class ForgetPasswordForm(forms.Form):
new_password1 = forms.CharField(
label=_("New password"),
widget=forms.PasswordInput(
attrs={
"class": "form-control",
'placeholder': _("New password")
}
),
)
new_password2 = forms.CharField(
label="确认密码",
widget=forms.PasswordInput(
attrs={
"class": "form-control",
'placeholder': _("Confirm password")
}
),
)
email = forms.EmailField(
label='邮箱',
widget=forms.TextInput(
attrs={
'class': 'form-control',
'placeholder': _("Email")
}
),
)
code = forms.CharField(
label=_('Code'),
widget=forms.TextInput(
attrs={
'class': 'form-control',
'placeholder': _("Code")
}
),
)
def clean_new_password2(self):
password1 = self.data.get("new_password1")
password2 = self.data.get("new_password2")
if password1 and password2 and password1 != password2:
raise ValidationError(_("passwords do not match"))
password_validation.validate_password(password2)
return password2
def clean_email(self):
user_email = self.cleaned_data.get("email")
if not BlogUser.objects.filter(
email=user_email
).exists():
# todo 这里的报错提示可以判断一个邮箱是不是注册过,如果不想暴露可以修改
raise ValidationError(_("email does not exist"))
return user_email
def clean_code(self):
code = self.cleaned_data.get("code")
error = utils.verify(
email=self.cleaned_data.get("email"),
code=code,
)
if error:
raise ValidationError(error)
return code
class ForgetPasswordCodeForm(forms.Form):
email = forms.EmailField(
label=_('Email'),
)

@ -1,49 +0,0 @@
# Generated by Django 4.1.7 on 2023-03-02 07:14
import django.contrib.auth.models
import django.contrib.auth.validators
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='BlogUser',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('nickname', models.CharField(blank=True, max_length=100, verbose_name='昵称')),
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
('source', models.CharField(blank=True, max_length=100, verbose_name='创建来源')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': '用户',
'verbose_name_plural': '用户',
'ordering': ['-id'],
'get_latest_by': 'id',
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
]

@ -1,46 +0,0 @@
# Generated by Django 4.2.5 on 2023-09-06 13:13
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('accounts', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='bloguser',
options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'user', 'verbose_name_plural': 'user'},
),
migrations.RemoveField(
model_name='bloguser',
name='created_time',
),
migrations.RemoveField(
model_name='bloguser',
name='last_mod_time',
),
migrations.AddField(
model_name='bloguser',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
migrations.AddField(
model_name='bloguser',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
),
migrations.AlterField(
model_name='bloguser',
name='nickname',
field=models.CharField(blank=True, max_length=100, verbose_name='nick name'),
),
migrations.AlterField(
model_name='bloguser',
name='source',
field=models.CharField(blank=True, max_length=100, verbose_name='create source'),
),
]

@ -1,35 +0,0 @@
from django.contrib.auth.models import AbstractUser
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 djangoblog.utils import get_current_site
# Create your models here.
class BlogUser(AbstractUser):
nickname = models.CharField(_('nick name'), max_length=100, blank=True)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
source = models.CharField(_('create source'), max_length=100, blank=True)
def get_absolute_url(self):
return reverse(
'blog:author_detail', kwargs={
'author_name': self.username})
def __str__(self):
return self.email
def get_full_url(self):
site = get_current_site().domain
url = "https://{site}{path}".format(site=site,
path=self.get_absolute_url())
return url
class Meta:
ordering = ['-id']
verbose_name = _('user')
verbose_name_plural = verbose_name
get_latest_by = 'id'

@ -1,207 +0,0 @@
from django.test import Client, RequestFactory, TestCase
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from accounts.models import BlogUser
from blog.models import Article, Category
from djangoblog.utils import *
from . import utils
# Create your tests here.
class AccountTest(TestCase):
def setUp(self):
self.client = Client()
self.factory = RequestFactory()
self.blog_user = BlogUser.objects.create_user(
username="test",
email="admin@admin.com",
password="12345678"
)
self.new_test = "xxx123--="
def test_validate_account(self):
site = get_current_site().domain
user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com",
username="liangliangyy1",
password="qwer!@#$ggg")
testuser = BlogUser.objects.get(username='liangliangyy1')
loginresult = self.client.login(
username='liangliangyy1',
password='qwer!@#$ggg')
self.assertEqual(loginresult, True)
response = self.client.get('/admin/')
self.assertEqual(response.status_code, 200)
category = Category()
category.name = "categoryaaa"
category.creation_time = timezone.now()
category.last_modify_time = timezone.now()
category.save()
article = Article()
article.title = "nicetitleaaa"
article.body = "nicecontentaaa"
article.author = user
article.category = category
article.type = 'a'
article.status = 'p'
article.save()
response = self.client.get(article.get_admin_url())
self.assertEqual(response.status_code, 200)
def test_validate_register(self):
self.assertEquals(
0, len(
BlogUser.objects.filter(
email='user123@user.com')))
response = self.client.post(reverse('account:register'), {
'username': 'user1233',
'email': 'user123@user.com',
'password1': 'password123!q@wE#R$T',
'password2': 'password123!q@wE#R$T',
})
self.assertEquals(
1, len(
BlogUser.objects.filter(
email='user123@user.com')))
user = BlogUser.objects.filter(email='user123@user.com')[0]
sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
path = reverse('accounts:result')
url = '{path}?type=validation&id={id}&sign={sign}'.format(
path=path, id=user.id, sign=sign)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.client.login(username='user1233', password='password123!q@wE#R$T')
user = BlogUser.objects.filter(email='user123@user.com')[0]
user.is_superuser = True
user.is_staff = True
user.save()
delete_sidebar_cache()
category = Category()
category.name = "categoryaaa"
category.creation_time = timezone.now()
category.last_modify_time = timezone.now()
category.save()
article = Article()
article.category = category
article.title = "nicetitle333"
article.body = "nicecontentttt"
article.author = user
article.type = 'a'
article.status = 'p'
article.save()
response = self.client.get(article.get_admin_url())
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse('account:logout'))
self.assertIn(response.status_code, [301, 302, 200])
response = self.client.get(article.get_admin_url())
self.assertIn(response.status_code, [301, 302, 200])
response = self.client.post(reverse('account:login'), {
'username': 'user1233',
'password': 'password123'
})
self.assertIn(response.status_code, [301, 302, 200])
response = self.client.get(article.get_admin_url())
self.assertIn(response.status_code, [301, 302, 200])
def test_verify_email_code(self):
to_email = "admin@admin.com"
code = generate_code()
utils.set_code(to_email, code)
utils.send_verify_email(to_email, code)
err = utils.verify("admin@admin.com", code)
self.assertEqual(err, None)
err = utils.verify("admin@123.com", code)
self.assertEqual(type(err), str)
def test_forget_password_email_code_success(self):
resp = self.client.post(
path=reverse("account:forget_password_code"),
data=dict(email="admin@admin.com")
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.content.decode("utf-8"), "ok")
def test_forget_password_email_code_fail(self):
resp = self.client.post(
path=reverse("account:forget_password_code"),
data=dict()
)
self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱")
resp = self.client.post(
path=reverse("account:forget_password_code"),
data=dict(email="admin@com")
)
self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱")
def test_forget_password_email_success(self):
code = generate_code()
utils.set_code(self.blog_user.email, code)
data = dict(
new_password1=self.new_test,
new_password2=self.new_test,
email=self.blog_user.email,
code=code,
)
resp = self.client.post(
path=reverse("account:forget_password"),
data=data
)
self.assertEqual(resp.status_code, 302)
# 验证用户密码是否修改成功
blog_user = BlogUser.objects.filter(
email=self.blog_user.email,
).first() # type: BlogUser
self.assertNotEqual(blog_user, None)
self.assertEqual(blog_user.check_password(data["new_password1"]), True)
def test_forget_password_email_not_user(self):
data = dict(
new_password1=self.new_test,
new_password2=self.new_test,
email="123@123.com",
code="123456",
)
resp = self.client.post(
path=reverse("account:forget_password"),
data=data
)
self.assertEqual(resp.status_code, 200)
def test_forget_password_email_code_error(self):
code = generate_code()
utils.set_code(self.blog_user.email, code)
data = dict(
new_password1=self.new_test,
new_password2=self.new_test,
email=self.blog_user.email,
code="111111",
)
resp = self.client.post(
path=reverse("account:forget_password"),
data=data
)
self.assertEqual(resp.status_code, 200)

@ -1,28 +0,0 @@
from django.urls import path
from django.urls import re_path
from . import views
from .forms import LoginForm
app_name = "accounts"
urlpatterns = [re_path(r'^login/$',
views.LoginView.as_view(success_url='/'),
name='login',
kwargs={'authentication_form': LoginForm}),
re_path(r'^register/$',
views.RegisterView.as_view(success_url="/"),
name='register'),
re_path(r'^logout/$',
views.LogoutView.as_view(),
name='logout'),
path(r'account/result.html',
views.account_result,
name='result'),
re_path(r'^forget_password/$',
views.ForgetPasswordView.as_view(),
name='forget_password'),
re_path(r'^forget_password_code/$',
views.ForgetPasswordEmailCode.as_view(),
name='forget_password_code'),
]

@ -1,26 +0,0 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend
class EmailOrUsernameModelBackend(ModelBackend):
"""
允许使用用户名或邮箱登录
"""
def authenticate(self, request, username=None, password=None, **kwargs):
if '@' in username:
kwargs = {'email': username}
else:
kwargs = {'username': username}
try:
user = get_user_model().objects.get(**kwargs)
if user.check_password(password):
return user
except get_user_model().DoesNotExist:
return None
def get_user(self, username):
try:
return get_user_model().objects.get(pk=username)
except get_user_model().DoesNotExist:
return None

@ -1,49 +0,0 @@
import typing
from datetime import timedelta
from django.core.cache import cache
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from djangoblog.utils import send_email
_code_ttl = timedelta(minutes=5)
def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email")):
"""发送重设密码验证码
Args:
to_mail: 接受邮箱
subject: 邮件主题
code: 验证码
"""
html_content = _(
"You are resetting the password, the verification code is%(code)s, valid within 5 minutes, please keep it "
"properly") % {'code': code}
send_email([to_mail], subject, html_content)
def verify(email: str, code: str) -> typing.Optional[str]:
"""验证code是否有效
Args:
email: 请求邮箱
code: 验证码
Return:
如果有错误就返回错误str
Node:
这里的错误处理不太合理应该采用raise抛出
否测调用方也需要对error进行处理
"""
cache_code = get_code(email)
if cache_code != code:
return gettext("Verification code error")
def set_code(email: str, code: str):
"""设置code"""
cache.set(email, code, _code_ttl.seconds)
def get_code(email: str) -> typing.Optional[str]:
"""获取code"""
return cache.get(email)

@ -1,204 +0,0 @@
import logging
from django.utils.translation import gettext_lazy as _
from django.conf import settings
from django.contrib import auth
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.contrib.auth import get_user_model
from django.contrib.auth import logout
from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth.hashers import make_password
from django.http import HttpResponseRedirect, HttpResponseForbidden
from django.http.request import HttpRequest
from django.http.response import HttpResponse
from django.shortcuts import get_object_or_404
from django.shortcuts import render
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.http import url_has_allowed_host_and_scheme
from django.views import View
from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_protect
from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import FormView, RedirectView
from djangoblog.utils import send_email, get_sha256, get_current_site, generate_code, delete_sidebar_cache
from . import utils
from .forms import RegisterForm, LoginForm, ForgetPasswordForm, ForgetPasswordCodeForm
from .models import BlogUser
logger = logging.getLogger(__name__)
# Create your views here.
class RegisterView(FormView):
form_class = RegisterForm
template_name = 'account/registration_form.html'
@method_decorator(csrf_protect)
def dispatch(self, *args, **kwargs):
return super(RegisterView, self).dispatch(*args, **kwargs)
def form_valid(self, form):
if form.is_valid():
user = form.save(False)
user.is_active = False
user.source = 'Register'
user.save(True)
site = get_current_site().domain
sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
if settings.DEBUG:
site = '127.0.0.1:8000'
path = reverse('account:result')
url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format(
site=site, path=path, id=user.id, sign=sign)
content = """
<p>请点击下面链接验证您的邮箱</p>
<a href="{url}" rel="bookmark">{url}</a>
再次感谢您
<br />
如果上面链接无法打开请将此链接复制至浏览器
{url}
""".format(url=url)
send_email(
emailto=[
user.email,
],
title='验证您的电子邮箱',
content=content)
url = reverse('accounts:result') + \
'?type=register&id=' + str(user.id)
return HttpResponseRedirect(url)
else:
return self.render_to_response({
'form': form
})
class LogoutView(RedirectView):
url = '/login/'
@method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs):
return super(LogoutView, self).dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
logout(request)
delete_sidebar_cache()
return super(LogoutView, self).get(request, *args, **kwargs)
class LoginView(FormView):
form_class = LoginForm
template_name = 'account/login.html'
success_url = '/'
redirect_field_name = REDIRECT_FIELD_NAME
login_ttl = 2626560 # 一个月的时间
@method_decorator(sensitive_post_parameters('password'))
@method_decorator(csrf_protect)
@method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs):
return super(LoginView, self).dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
redirect_to = self.request.GET.get(self.redirect_field_name)
if redirect_to is None:
redirect_to = '/'
kwargs['redirect_to'] = redirect_to
return super(LoginView, self).get_context_data(**kwargs)
def form_valid(self, form):
form = AuthenticationForm(data=self.request.POST, request=self.request)
if form.is_valid():
delete_sidebar_cache()
logger.info(self.redirect_field_name)
auth.login(self.request, form.get_user())
if self.request.POST.get("remember"):
self.request.session.set_expiry(self.login_ttl)
return super(LoginView, self).form_valid(form)
# return HttpResponseRedirect('/')
else:
return self.render_to_response({
'form': form
})
def get_success_url(self):
redirect_to = self.request.POST.get(self.redirect_field_name)
if not url_has_allowed_host_and_scheme(
url=redirect_to, allowed_hosts=[
self.request.get_host()]):
redirect_to = self.success_url
return redirect_to
def account_result(request):
type = request.GET.get('type')
id = request.GET.get('id')
user = get_object_or_404(get_user_model(), id=id)
logger.info(type)
if user.is_active:
return HttpResponseRedirect('/')
if type and type in ['register', 'validation']:
if type == 'register':
content = '''
恭喜您注册成功一封验证邮件已经发送到您的邮箱请验证您的邮箱后登录本站
'''
title = '注册成功'
else:
c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
sign = request.GET.get('sign')
if sign != c_sign:
return HttpResponseForbidden()
user.is_active = True
user.save()
content = '''
恭喜您已经成功的完成邮箱验证您现在可以使用您的账号来登录本站
'''
title = '验证成功'
return render(request, 'account/result.html', {
'title': title,
'content': content
})
else:
return HttpResponseRedirect('/')
class ForgetPasswordView(FormView):
form_class = ForgetPasswordForm
template_name = 'account/forget_password.html'
def form_valid(self, form):
if form.is_valid():
blog_user = BlogUser.objects.filter(email=form.cleaned_data.get("email")).get()
blog_user.password = make_password(form.cleaned_data["new_password2"])
blog_user.save()
return HttpResponseRedirect('/login/')
else:
return self.render_to_response({'form': form})
class ForgetPasswordEmailCode(View):
def post(self, request: HttpRequest):
form = ForgetPasswordCodeForm(request.POST)
if not form.is_valid():
return HttpResponse("错误的邮箱")
to_email = form.cleaned_data["email"]
code = generate_code()
utils.send_verify_email(to_email, code)
utils.set_code(to_email, code)
return HttpResponse("ok")

@ -1,112 +0,0 @@
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 _
# Register your models here.
from .models import Article
class ArticleForm(forms.ModelForm):
# body = forms.CharField(widget=AdminPagedownWidget())
class Meta:
model = Article
fields = '__all__'
def makr_article_publish(modeladmin, request, queryset):
queryset.update(status='p')
def draft_article(modeladmin, request, queryset):
queryset.update(status='d')
def close_article_commentstatus(modeladmin, request, queryset):
queryset.update(comment_status='c')
def open_article_commentstatus(modeladmin, request, queryset):
queryset.update(comment_status='o')
makr_article_publish.short_description = _('Publish selected articles')
draft_article.short_description = _('Draft selected articles')
close_article_commentstatus.short_description = _('Close article comments')
open_article_commentstatus.short_description = _('Open article comments')
class ArticlelAdmin(admin.ModelAdmin):
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):
form = super(ArticlelAdmin, self).get_form(request, obj, **kwargs)
form.base_fields['author'].queryset = get_user_model(
).objects.filter(is_superuser=True)
return form
def save_model(self, request, obj, form, change):
super(ArticlelAdmin, self).save_model(request, obj, form, change)
def get_view_on_site_url(self, obj=None):
if obj:
url = obj.get_full_url()
return url
else:
from djangoblog.utils import get_current_site
site = get_current_site().domain
return site
class TagAdmin(admin.ModelAdmin):
exclude = ('slug', 'last_mod_time', 'creation_time')
class CategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'parent_category', 'index')
exclude = ('slug', 'last_mod_time', 'creation_time')
class LinksAdmin(admin.ModelAdmin):
exclude = ('last_mod_time', 'creation_time')
class SideBarAdmin(admin.ModelAdmin):
list_display = ('name', 'content', 'is_enable', 'sequence')
exclude = ('last_mod_time', 'creation_time')
class BlogSettingsAdmin(admin.ModelAdmin):
pass

@ -1,5 +0,0 @@
from django.apps import AppConfig
class BlogConfig(AppConfig):
name = 'blog'

@ -1,43 +0,0 @@
import logging
from django.utils import timezone
from djangoblog.utils import cache, get_blog_setting
from .models import Category, Article
logger = logging.getLogger(__name__)
def seo_processor(requests):
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,
'SHOW_GOOGLE_ADSENSE': setting.show_google_adsense,
'GOOGLE_ADSENSE_CODES': setting.google_adsense_codes,
'SITE_SEO_DESCRIPTION': setting.site_seo_description,
'SITE_DESCRIPTION': setting.site_description,
'SITE_KEYWORDS': setting.site_keywords,
'SITE_BASE_URL': requests.scheme + '://' + requests.get_host() + '/',
'ARTICLE_SUB_LENGTH': setting.article_sub_length,
'nav_category_list': Category.objects.all(),
'nav_pages': Article.objects.filter(
type='p',
status='p'),
'OPEN_SITE_COMMENT': setting.open_site_comment,
'BEIAN_CODE': setting.beian_code,
'ANALYTICS_CODE': setting.analytics_code,
"BEIAN_CODE_GONGAN": setting.gongan_beiancode,
"SHOW_GONGAN_CODE": setting.show_gongan_code,
"CURRENT_YEAR": timezone.now().year,
"GLOBAL_HEADER": setting.global_header,
"GLOBAL_FOOTER": setting.global_footer,
"COMMENT_NEED_REVIEW": setting.comment_need_review,
}
cache.set(key, value, 60 * 60 * 10)
return value

@ -1,213 +0,0 @@
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_ENABLED = hasattr(settings, 'ELASTICSEARCH_DSL')
if ELASTICSEARCH_ENABLED:
connections.create_connection(
hosts=[settings.ELASTICSEARCH_DSL['default']['hosts']])
from elasticsearch import Elasticsearch
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
from elasticsearch.client import IngestClient
c = IngestClient(es)
try:
c.get_pipeline('geoip')
except elasticsearch.exceptions.NotFoundError:
c.put_pipeline('geoip', body='''{
"description" : "Add geoip info",
"processors" : [
{
"geoip" : {
"field" : "ip"
}
}
]
}''')
class GeoIp(InnerDoc):
continent_name = Keyword()
country_iso_code = Keyword()
country_name = Keyword()
location = GeoPoint()
class UserAgentBrowser(InnerDoc):
Family = Keyword()
Version = Keyword()
class UserAgentOS(UserAgentBrowser):
pass
class UserAgentDevice(InnerDoc):
Family = Keyword()
Brand = Keyword()
Model = Keyword()
class UserAgent(InnerDoc):
browser = Object(UserAgentBrowser, required=False)
os = Object(UserAgentOS, required=False)
device = Object(UserAgentDevice, required=False)
string = Text()
is_bot = Boolean()
class ElapsedTimeDocument(Document):
url = Keyword()
time_taken = Long()
log_datetime = Date()
ip = Keyword()
geoip = Object(GeoIp, required=False)
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:
@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'])
es.indices.delete(index='performance', ignore=[400, 404])
@staticmethod
def create(url, time_taken, log_datetime, useragent, ip):
ElaspedTimeDocumentManager.build_index()
ua = UserAgent()
ua.browser = UserAgentBrowser()
ua.browser.Family = useragent.browser.family
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
ua.string = useragent.ua_string
ua.is_bot = useragent.is_bot
doc = ElapsedTimeDocument(
meta={
'id': int(
round(
time.time() *
1000))
},
url=url,
time_taken=time_taken,
log_datetime=log_datetime,
useragent=ua, ip=ip)
doc.save(pipeline="geoip")
class ArticleDocument(Document):
body = Text(analyzer='ik_max_word', search_analyzer='ik_smart')
title = Text(analyzer='ik_max_word', search_analyzer='ik_smart')
author = Object(properties={
'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():
def __init__(self):
self.create_index()
def create_index(self):
ArticleDocument.init()
def delete_index(self):
from elasticsearch import Elasticsearch
es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
es.indices.delete(index='blog', ignore=[400, 404])
def convert_to_doc(self, articles):
return [
ArticleDocument(
meta={
'id': article.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):
ArticleDocument.init()
articles = articles if articles else Article.objects.all()
docs = self.convert_to_doc(articles)
for doc in docs:
doc.save()
def update_docs(self, docs):
for doc in docs:
doc.save()

@ -1,19 +0,0 @@
import logging
from django import forms
from haystack.forms import SearchForm
logger = logging.getLogger(__name__)
class BlogSearchForm(SearchForm):
querydata = forms.CharField(required=True)
def search(self):
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

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

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

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

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

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

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

@ -1,42 +0,0 @@
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):
def __init__(self, get_response=None):
self.get_response = get_response
super().__init__()
def __call__(self, request):
''' page render time '''
start_time = time.time()
response = self.get_response(request)
http_user_agent = request.META.get('HTTP_USER_AGENT', '')
ip, _ = get_client_ip(request)
user_agent = parse(http_user_agent)
if not response.streaming:
try:
cast_time = time.time() - start_time
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.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

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

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

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

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

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

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

@ -1,376 +0,0 @@
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 = ('i', _('index'))
L = ('l', _('list'))
P = ('p', _('post'))
A = ('a', _('all'))
S = ('s', _('slide'))
class BaseModel(models.Model):
id = models.AutoField(primary_key=True)
creation_time = models.DateTimeField(_('creation time'), default=now)
last_modify_time = models.DateTimeField(_('modify time'), default=now)
def save(self, *args, **kwargs):
is_update_views = isinstance(
self,
Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views']
if is_update_views:
Article.objects.filter(pk=self.pk).update(views=self.views)
else:
if 'slug' in self.__dict__:
slug = getattr(
self, 'title') if 'title' in self.__dict__ else getattr(
self, 'name')
setattr(self, 'slug', slugify(slug))
super().save(*args, **kwargs)
def get_full_url(self):
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):
pass
class Article(BaseModel):
"""文章"""
STATUS_CHOICES = (
('d', _('Draft')),
('p', _('Published')),
)
COMMENT_STATUS = (
('o', _('Open')),
('c', _('Close')),
)
TYPE = (
('a', _('Article')),
('p', _('Page')),
)
title = models.CharField(_('title'), max_length=200, unique=True)
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)
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)
show_toc = models.BooleanField(_('show toc'), blank=False, null=False, default=False)
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:
ordering = ['-article_order', '-pub_time']
verbose_name = _('article')
verbose_name_plural = verbose_name
get_latest_by = 'id'
def get_absolute_url(self):
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):
tree = self.category.get_category_tree()
names = list(map(lambda c: (c.name, c.get_absolute_url()), tree))
return names
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
def viewed(self):
self.views += 1
self.save(update_fields=['views'])
def comment_list(self):
cache_key = 'article_comments_{id}'.format(id=self.id)
value = cache.get(cache_key)
if value:
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):
info = (self._meta.app_label, self._meta.model_name)
return reverse('admin:%s_%s_change' % info, args=(self.pk,))
@cache_decorator(expiration=60 * 100)
def next_article(self):
# 下一篇
return Article.objects.filter(
id__gt=self.id, status='p').order_by('id').first()
@cache_decorator(expiration=60 * 100)
def prev_article(self):
# 前一篇
return Article.objects.filter(id__lt=self.id, status='p').first()
def get_first_image_url(self):
"""
Get the first image url from article.body.
:return:
"""
match = re.search(r'!\[.*?\]\((.+?)\)', self.body)
if match:
return match.group(1)
return ""
class Category(BaseModel):
"""文章分类"""
name = models.CharField(_('category name'), max_length=30, unique=True)
parent_category = models.ForeignKey(
'self',
verbose_name=_('parent category'),
blank=True,
null=True,
on_delete=models.CASCADE)
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
index = models.IntegerField(default=0, verbose_name=_('index'))
class Meta:
ordering = ['-index']
verbose_name = _('category')
verbose_name_plural = verbose_name
def get_absolute_url(self):
return reverse(
'blog:category_detail', kwargs={
'category_name': self.slug})
def __str__(self):
return self.name
@cache_decorator(60 * 60 * 10)
def get_category_tree(self):
"""
递归获得分类目录的父级
:return:
"""
categorys = []
def parse(category):
categorys.append(category)
if category.parent_category:
parse(category.parent_category)
parse(self)
return categorys
@cache_decorator(60 * 60 * 10)
def get_sub_categorys(self):
"""
获得当前分类目录所有子集
:return:
"""
categorys = []
all_categorys = Category.objects.all()
def parse(category):
if category not in categorys:
categorys.append(category)
childs = all_categorys.filter(parent_category=category)
for child in childs:
if category not in categorys:
categorys.append(child)
parse(child)
parse(self)
return categorys
class Tag(BaseModel):
"""文章标签"""
name = models.CharField(_('tag name'), max_length=30, unique=True)
slug = models.SlugField(default='no-slug', max_length=60, blank=True)
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('blog:tag_detail', kwargs={'tag_name': self.slug})
@cache_decorator(60 * 60 * 10)
def get_article_count(self):
return Article.objects.filter(tags__name=self.name).distinct().count()
class Meta:
ordering = ['name']
verbose_name = _('tag')
verbose_name_plural = verbose_name
class Links(models.Model):
"""友情链接"""
name = models.CharField(_('link name'), max_length=30, unique=True)
link = models.URLField(_('link'))
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:
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)
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:
ordering = ['sequence']
verbose_name = _('sidebar')
verbose_name_plural = verbose_name
def __str__(self):
return self.name
class BlogSettings(models.Model):
"""blog的配置"""
site_name = models.CharField(
_('site name'),
max_length=200,
null=False,
blank=False,
default='')
site_description = models.TextField(
_('site description'),
max_length=1000,
null=False,
blank=False,
default='')
site_seo_description = models.TextField(
_('site seo description'), max_length=1000, null=False, blank=False, default='')
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)
show_google_adsense = models.BooleanField(_('show adsense'), default=False)
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)
global_header = models.TextField("公共头部", null=True, blank=True, default='')
global_footer = models.TextField("公共尾部", null=True, blank=True, default='')
beian_code = models.CharField(
'备案号',
max_length=2000,
null=True,
blank=True,
default='')
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='')
comment_need_review = models.BooleanField(
'评论是否需要审核', default=False, null=False)
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()

@ -1,13 +0,0 @@
from haystack import indexes
from blog.models import Article
class ArticleIndex(indexes.SearchIndex, indexes.Indexable):
text = indexes.CharField(document=True, use_template=True)
def get_model(self):
return Article
def index_queryset(self, using=None):
return self.get_model().objects.filter(status='p')

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

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

@ -1,13 +0,0 @@
/*!
* 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; }

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

Before

Width:  |  Height:  |  Size: 221 B

@ -1,51 +0,0 @@
// 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!')
}
})();

@ -1,23 +0,0 @@
/*!
* 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)
}
})();

@ -1,273 +0,0 @@
/*
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;
}

@ -1,74 +0,0 @@
/* 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); }
}

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

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

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

Loading…
Cancel
Save