博客项目(一)
版权声明:本博客来自路飞学城Python全栈开发培训课件,仅用于学习之用,严禁用于商业用途。
欢迎访问路飞学城官网:https://www.luffycity.com/
1. 项目分析
本项目是在图书管理项目的基础上开发一个BBS博客系统,博客系统具有用户主页、文章详情页、评论、点赞与踩等功能。
项目需求如下:
- 基于ajax和用户认证组件实现登录验证
- 基于ajax和form组件实现注册功能
- 系统首页文章列表的渲染
- 个人站点页面设计
- 文章详情的继承
- 点赞与踩
- 评论功能
- 富文本编辑器的使用
- 防止xss攻击
2. 表结构
表结构设计:
-
用户表UserInfo:nid、telephone、avatar(用户头像)、create_time、blog(一对一)
-
个人博客Blog:nid、title(个人博客标题) 、 site_name(个人博客站点名称)、 theme(个人博客主题样式)
-
文章Article:
- nid
- title(文章标题)
- desc(文章简述)
- create_time
- content(文章内容)
- comment_count(评论数量)
- up_count(点赞数量)
- down_count(踩数量)
- user(文章发布人)
- category(一对多,文章分类)
- tags(多对多,文章标签)
-
文章分类Category:nid 、title、blog(外键,一对多)
-
文章标签Tag:nid 、title、blog(多对多)
-
文章标签关系Article2Tag:nid、article(外键,一对多)、tag(外键,一对多)
-
文章点赞与踩ArticleUpDown:
- nid
- user(外键,一对多,点赞或踩的人)
- article(外键,一对多,点赞或踩的文章)
- is_up(点赞还是踩)
-
评论表Comment:
- nid
- article(评论文章)
- user(评论人)
- content(评论内容)
- create_time
- parent_comment(自关联外键,用于标识是一级评论还是对评论的评论)
创建和配置数据库(略)
创建模型
from django.db import models
from django.contrib.auth.models import AbstractUser
# Create your models here.
class UserInfo(AbstractUser):
"""
用户信息
"""
nid = models.AutoField(primary_key=True)
telephone = models.CharField(max_length=11, null=True, unique=True)
avatar = models.FileField(upload_to='avatars/', default="/avatars/default.png")
create_time = models.DateTimeField(verbose_name='创建时间', auto_now_add=True)
blog = models.OneToOneField(to='Blog', to_field='nid', null=True, on_delete=models.CASCADE)
def __str__(self):
return self.username
class Blog(models.Model):
"""
博客信息
"""
nid = models.AutoField(primary_key=True)
title = models.CharField(verbose_name='个人博客标题', max_length=64)
site_name = models.CharField(verbose_name='站点名称', max_length=64)
theme = models.CharField(verbose_name='博客主题', max_length=32)
def __str__(self):
return self.title
class Category(models.Model):
"""
博主个人文章分类表
"""
nid = models.AutoField(primary_key=True)
title = models.CharField(verbose_name='分类标题', max_length=32)
blog = models.ForeignKey(verbose_name='所属博客', to='Blog', to_field='nid', on_delete=models.CASCADE)
def __str__(self):
return self.title
class Tag(models.Model):
"""
文章标签表
"""
nid = models.AutoField(primary_key=True)
title = models.CharField(verbose_name='标签名称', max_length=32)
blog = models.ForeignKey(verbose_name='所属博客', to='Blog', to_field='nid', on_delete=models.CASCADE)
def __str__(self):
return self.title
class Article(models.Model):
"""
文章表
"""
nid = models.AutoField(primary_key=True)
title = models.CharField(verbose_name='文章标题', max_length=50)
desc = models.CharField(verbose_name='文章描述', max_length=255)
create_time = models.DateTimeField(verbose_name='创建时间', auto_now_add=True)
content = models.TextField()
comment_count = models.IntegerField(default=0)
up_count = models.IntegerField(default=0)
down_count = models.IntegerField(default=0)
user = models.ForeignKey(verbose_name='作者', to='UserInfo', to_field='nid', on_delete=models.CASCADE)
category = models.ForeignKey(to='Category', to_field='nid', on_delete=models.CASCADE)
tags = models.ManyToManyField(
to='Tag',
through='Article2Tag',
through_fields=('article', 'tag'),
)
def __str__(self):
return self.title
class Article2Tag(models.Model):
nid = models.AutoField(primary_key=True)
article = models.ForeignKey(verbose_name='文章', to='Article', to_field='nid', on_delete=models.CASCADE)
tag = models.ForeignKey(verbose_name='标签', to='Tag', to_field='nid', on_delete=models.CASCADE)
class Meta:
unique_together = [
('article', 'tag'),
]
def __str__(self):
v = self.article.title + "---" + self.tag.title
return v
class ArticleUpDown(models.Model):
"""
点赞表
"""
nid = models.AutoField(primary_key=True)
user = models.ForeignKey('UserInfo', null=True, on_delete=models.CASCADE)
article = models.ForeignKey('Article', null=True, on_delete=models.CASCADE)
is_up = models.BooleanField(default=True)
class Meta:
unique_together = [
('article', 'user'),
]
class Comment(models.Model):
"""
评论表
"""
nid = models.AutoField(primary_key=True)
article = models.ForeignKey(verbose_name='评论文章', to='Article', to_field='nid', on_delete=models.CASCADE)
user = models.ForeignKey(verbose_name='评论者', to='UserInfo', to_field='nid', on_delete=models.CASCADE)
content = models.CharField(verbose_name='评论内容', max_length=255)
create_time = models.DateTimeField(verbose_name='创建时间', auto_now_add=True)
parent_comment = models.ForeignKey('self', null=True, on_delete=models.CASCADE)
def __str__(self):
return self.content
创建表结构(略)
头像上传相关知识补充:
-
FileField与ImageFiled
# modeld.py表模型 class UserInfo(AbstractUser): avatar = models.FileField(upload_to='avatars/', default="/avatars/default.png") # views.py添加数据 avatar_obj=request.FILES.get("avatar") user_obj=UserInfo.objects.create_user(username=user,password=pwd,email=email,avatar=avatar_obj) """ Dajngo实现: 会将文件对象下载到项目的根目录中avatars文件夹中(如果没有avatar文件夹,Django会自动建), user_obj的avatar存的是文件的相对路径。 """
-
media配置
Media 配置之MEDIA_ROOT: Dajngo有两种静态文件: /static/ : js,css,img /media/ : 用户上传文件 一旦在settings.py配置了 MEDIA_ROOT=os.path.join(BASE_DIR,"media") Dajngo会将文件对象下载到MEDIA_ROOT中avatars文件夹中(如果没有avatar文件夹,Django会自动创建),user_obj的avatar存的是文件的相对路径。 Media 配置之MEDIA_URl: 浏览器如何能直接访问到media中的数据 第一步:在settings.py中配置: MEDIA_URL="/media/" 第二步:在urls.pt中配置一条路由: # media配置: re_path(r"media/(?P<path>.*)$",serve,{"document_root":settings.MEDIA_ROOT})
3. 用户功能
setting配置
# setting.py文件
...
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'cnblog',
'USER': 'root',
'PASSWORD': '123',
'HOST': '127.0.0.1',
'PORT': 3306
}
}
...
AUTH_USER_MODEL = "blog.UserInfo"
...
# 静态文件配置
STATIC_URL = '/static/'
STATICFILES_DIRS = [
os.path.join(BASE_DIR, 'static'),
]
LOGIN_URL = '/login/'
# 与用户上传相关的配置
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
MEDIA_URL = "/media/"
...
创建From模型
# MyForms.py
import re
from django import forms
from django.forms import widgets
from blog import models
from django.core.exceptions import ValidationError
from django.contrib import auth
# 自定义验证规则
def mobile_validate(value):
mobile_re = re.compile(r'^(13[0-9]|15[012356789]|17[678]|18[0-9]|14[57])[0-9]{8}$')
if not mobile_re.match(value):
raise ValidationError('手机号码格式错误')
class UserForm(forms.Form):
username = forms.CharField(max_length=32,
min_length=5,
label="用户名",
error_messages={"required": "该字段不能为空",
'min_length': '用户名最少为5个字符',
'max_length': '用户名最多为32个字符'},
widget=widgets.TextInput(attrs={"class": "form-control",
'placeholder': '用户名'}, ))
pwd = forms.CharField(max_length=32,
error_messages={"required": "该字段不能为空"},
label="密码",
widget=widgets.PasswordInput(attrs={"class": "form-control",
'placeholder': '密码'}, ))
re_pwd = forms.CharField(max_length=32,
error_messages={"required": "该字段不能为空"},
label="确认密码",
widget=widgets.PasswordInput(attrs={"class": "form-control",
'placeholder': '确认密码'}, ))
email = forms.EmailField(max_length=32,
error_messages={"required": "该字段不能为空",
'invalid': u'邮箱格式错误'},
label="邮箱",
widget=widgets.EmailInput(attrs={"class": "form-control",
'placeholder': u'邮箱'}, ))
telephone = forms.CharField(validators=[mobile_validate, ],
error_messages={"required": "该字段不能为空"},
label="手机号码",
widget=widgets.TextInput(attrs={"class": "form-control",
'placeholder': u'手机号码'}, ))
def clean_user(self):
val = self.cleaned_data.get("user")
user = models.UserInfo.objects.filter(username=val).first()
if not user:
return val
else:
raise ValidationError("该用户已注册!")
def clean(self):
pwd = self.cleaned_data.get("pwd")
re_pwd = self.cleaned_data.get("re_pwd")
if pwd and re_pwd:
if pwd == re_pwd:
return self.cleaned_data
else:
raise ValidationError("两次密码不一致!")
else:
return self.cleaned_data
配置路由
# url.py
from django.contrib import admin
from django.urls import path, re_path
from django.views.static import serve
from blog import views
from cnblog import settings
urlpatterns = [
path('admin/', admin.site.urls),
path('register/', views.register),
path('login/', views.login),
path('logout/', views.logout),
path('reset_pwd/', views.reset_pwd),
path('valid_code_img/', views.valid_code_img),
...
# media配置:
re_path(r"media/(?P<path>.*)$", serve, {"document_root": settings.MEDIA_ROOT}),
...
]
创建视图函数
# views.py文件
import os
import re
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from django.db import transaction
from django.shortcuts import render, HttpResponse, redirect
from django.contrib.auth.decorators import login_required
from django.contrib import auth
from django.http import JsonResponse
from django.db.models import F, aggregates, Count
import json
from django.core.mail import send_mail
from cnblog import settings
import threading
from bs4 import BeautifulSoup
from blog.utils import validCode
from blog import models
from blog import MyForms
# Create your views here.
def register(request):
if request.method == "POST":
form = MyForms.UserForm(request.POST)
response = {"username": None, "msg": None}
if form.is_valid():
username = form.cleaned_data.get("username")
pwd = form.cleaned_data.get("pwd")
email = form.cleaned_data.get("email")
avatar_obj = request.FILES.get("avatar")
extra = {}
# 头像为非必需参数,需要判断用户是否上传头像
# 如果上传了该参数,则在创建用户时传入该参数;
# 否则传入一个空字典,在创建用户时就会设置为默认头像
if avatar_obj:
extra["avatar"] = avatar_obj
models.UserInfo.objects.create_user(username=username,
password=pwd,
email=email,
**extra)
response["username"] = username
else:
response["msg"] = form.errors
return JsonResponse(response)
else:
form = MyForms.UserForm()
return render(request, 'register.html', {"form": form})
def login(request):
if request.method == 'GET':
return render(request, 'login.html')
response = {"username": None, "msg": None}
username = request.POST.get("username")
pwd = request.POST.get("pwd")
valid_code = request.POST.get("valid_code")
valid_code_session = request.session.get("valid_code_str")
if valid_code.upper() == valid_code_session.upper():
user = auth.authenticate(username=username, password=pwd)
if user:
auth.login(request, user)
response["username"] = user.username
else:
response["msg"] = "用户名或密码错误!"
else:
response["msg"] = "验证码错误!"
return JsonResponse(response)
def logout(request):
auth.logout(request)
return redirect('/login/')
@login_required()
def reset_pwd(request):
if request.method == "GET":
return render(request, "reset_pwd.html")
response = {"flag": False, "msg": None}
username = request.user.username
pwd_old = request.POST.get("pwd_old")
pwd_new = request.POST.get("pwd_new")
re_pwd_new = request.POST.get("re_pwd_new")
if pwd_new and re_pwd_new:
if pwd_new != re_pwd_new:
response["msg"] = "两次密码不一致!"
elif not re.match(r'^\w{1,33}$', pwd_new):
response["msg"] = "密码格式不正确!"
else:
user_obj = auth.authenticate(username=username, password=pwd_old)
if user_obj:
user_obj.set_password(pwd_new)
user_obj.save()
response["flag"] = True
else:
response["msg"] = "原密码不正确!"
else:
response["msg"] = "新密码格式不正确!"
return JsonResponse(response)
def valid_code_img(request):
img_data = validCode.get_valid_code_img(request)
return HttpResponse(img_data)
验证码函数
# utils/validCode.py文件
from PIL import Image, ImageDraw, ImageFont
from io import BytesIO
import random
def get_random_color():
return random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)
def get_valid_code_img(request):
img = Image.new("RGB", (270, 40), color=get_random_color())
draw = ImageDraw.Draw(img)
kumo_font = ImageFont.truetype("static/font/kumo.ttf", size=32)
valid_code_str = ""
for i in range(5):
random_num = str(random.randint(0, 9))
random_low_alpha = chr(random.randint(95, 122))
random_upper_alpha = chr(random.randint(65, 90))
random_char = random.choice([random_num, random_low_alpha, random_upper_alpha])
draw.text((i * 50 + 20, 5), random_char, get_random_color(), font=kumo_font)
# 保存验证码字符串
valid_code_str += random_char
# 生成噪点
# width=270
# height=40
# for i in range(10):
# x1=random.randint(0,width)
# x2=random.randint(0,width)
# y1=random.randint(0,height)
# y2=random.randint(0,height)
# draw.line((x1,y1,x2,y2),fill=get_random_color())
#
# for i in range(100):
# draw.point([random.randint(0, width), random.randint(0, height)], fill=get_random_color())
# x = random.randint(0, width)
# y = random.randint(0, height)
# draw.arc((x, y, x + 4, y + 4), 0, 90, fill=get_random_color())
request.session["valid_code_str"] = valid_code_str
f = BytesIO()
img.save(f, "png")
data = f.getvalue()
return data
创建模板
register.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>用户注册</title>
<link rel="stylesheet" href="/static/blog/bs/css/bootstrap.min.css">
<script src="/static/js/jquery-3.2.1.min.js"></script>
<style>
.error{
color:red;
margin-left:10px
}
#avatar {
display: none;
}
#avatar_img{
width: 60px;
height: 60px;
margin-left: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="row">
<div class="col-md-6 col-md-offset-3">
<h2>用户注册</h2>
<hr>
<form id="form">
{% csrf_token %}
{% for field in form %}
<div class="form-group">
<label for="{{ field.auto_id }}">{{ field.label }}</label>
{{ field }} <span class="error pull-right"></span>
</div>
{% endfor %}
<div>
<label for="avatar">
头像
<img id="avatar_img" src="/media/avatars/default.png" alt="">
</label>
<input type="file" id="avatar" name="avatar">
</div>
<input type="button" class="btn btn-default reg_btn pull-right" value="提交">
</form>
</div>
</div>
</div>
<script>
$(function () {
// 头像预览
$("#avatar").change(function () {
// 获取用户选中的文件对象
var file_obj = $(this)[0].files[0];
// 获取文件对象的绝对路径
var reader = new FileReader();
reader.readAsDataURL(file_obj);
reader.onload = function(){
$("#avatar_img").attr("src", reader.result)
};
});
// 基于Ajax提交数据并校验
$(".reg_btn").click(function () {
var form_data = new FormData();
var request_data = $("#form").serializeArray();
$.each(request_data,function (index, data) {
form_data.append(data.name, data.value);
});
form_data.append("avatar", $("#avatar")[0].files[0]);
$.ajax({
url:"",
type:"post",
contentType:false,
processData:false,
data:form_data,
success:function (data) {
if(data.username){
location.href="/login/";
}else{
// 清空错误信息
$("span.error").html("");
// 设置form-group校验错误样式(bootstrap)
$(".form-group").removeClass("has-error");
// 显示此次提交的错误校验信息
$.each(data.msg, function (field, error_list) {
// 全局钩子校验错误信息
if(field=="__all__"){
$("#id_re_pwd").next().html(error_list[0]).parent().addClass("has-error");
$("#id_re_pwd").parent().addClass("has-error");
}
$("#id_" + field).next().html(error_list[0]);
$("#id_" + field).parent().addClass("has-error");
})
}
}
})
})
})
</script>
</body>
</html>
小技巧:
1、用户头像的表单通过label的for属性指向file标签,而file标签设置为隐藏状态。lable标签内包含头像图片,实现点击头像弹出文本框的功能。
2、js中FormData 对象的使用:
- 用一些键值对来模拟一系列表单控件:即将form 中表单元素的 name 与 value 组装成一个 queryString
- 异步上传二进制文件
3、$("#form").serializeArray():
- serializeArray() 方法序列化表单元素,返回 JSON 数据结构数据。
login.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>用户登录</title>
<link rel="stylesheet" href="/static/blog/bs/css/bootstrap.min.css">
<script src="/static/js/jquery-3.2.1.min.js"></script>
<style>
.error{
color:red;
margin-left: 10px;
}
</style>
</head>
<body>
<div class="container">
<div class="row">
<div class="col-md-6 col-md-offset-3">
<h2>用户登录</h2>
<hr>
<form >
{% csrf_token %}
<div class="form-group">
<label for="username">用户名</label>
<input type="text" id="username" class="form-control">
</div>
<div class="form-group">
<label for="pwd">密码</label>
<input type="password" id="pwd" class="form-control">
</div>
<div class="form-group">
<label for="valid_code">验证码</label>
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control" id="valid_code">
</div>
<div class="col-md-6">
<img width="270" height="36" id="valid_code_img" src="/valid_code_img/" alt="">
</div>
</div>
</div>
<input type="button" class="btn btn-default login_btn" value="登录">
<span class="error"></span>
<a href="/register/" class="btn btn-primary pull-right">注册</a>
</form>
</div>
</div>
</div>
<script>
$(function () {
// 刷新验证码
$("#valid_code_img").click(function () {
$(this)[0].src +="?"
})
// 登录验证
$('.login_btn').click(function () {
$.ajax({
url:"",
type:"post",
data: {
username: $("#username").val(),
pwd: $("#pwd").val(),
valid_code: $("#valid_code").val(),
csrfmiddlewaretoken: $("[name='csrfmiddlewaretoken']").val(),
},
success:function (data) {
if(data.username){
location.href="/index/";
}else{
$(".error").text(data.msg);
}
}
})
})
})
</script>
</body>
</html>
小技巧:
通过改变验证码图像的src实现验证码的异步刷新。
效果展示
用户登录:
用户注册:
修改密码:
未完待续
学python,找路飞!更多精彩,尽在路飞学城