文章目录
前言
前一篇文章讲了wiki管理,今天我们实现文件管理。主要包括文件夹的创建、编辑,以及文件的上传、下载和删除。
一、进入文件页面
1. url配置
# app01/urls.py
re_path(r'^manage/(?P<project_id>\d+)/', include([
# ...
# 进入文件页面
re_path(r'^file/$', file.file, name='file'),
]))
2. 视图函数
# app01/views/files.py
def file(request, project_id):
return render(request, 'app01/file.html', back_dict)
最后再编写一下file.html
就可以看到页面了。
二、 文件操作
1. 新建文件夹
点击新建文件夹
,弹出模态对话框,渲染一个表格,输入文件夹名称之后会做验证,有问题显示错误,没有问题则创建。
1.1 表结构设计
# app01/models.py
class FileRepository(models.Model):
"""文件库"""
name = models.CharField(verbose_name="文件夹名", max_length=32)
project = models.ForeignKey(verbose_name="所属项目", to="Project", on_delete=models.CASCADE)
file_type = models.SmallIntegerField(choices=((1, "文件"), (2, "文件夹")))
file_size = models.IntegerField(verbose_name="文件大小", null=True, blank=True)
# 文件上传要通过腾讯对象存储
# 下载文件会用到文件路径
# 存储文件会用到key
file_path = models.CharField(verbose_name="文件路径", max_length=256, null=True, blank=True)
key = models.CharField(verbose_name="COS中的key", max_length=256, blank=True, null=True)
update_user = models.ForeignKey(verbose_name="更新者", to="UserInfo", on_delete=models.CASCADE)
update_time = models.DateTimeField(verbose_name="创建时间", auto_now_add=True)
# 问显示文件嵌套关系,需要父文件
parent = models.ForeignKey(verbose_name="父文件夹", to="self", related_name="children", on_delete=models.CASCADE, blank=True, null=True)
def __str__(self):
return '<FileRepository %s>' % self.name
1.2 ModelForm
渲染表格和校验数据用ModelForm
# app01/forms/files.py
class FileRepositoryForm(BootstrapForm, forms.ModelForm):
class Meta:
model = models.FileRepository
fields = ["name"]
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
def clean_name(self):
"""校验文件夹是否重复"""
name = self.cleaned_data.get("name")
queryset = models.FileRepository.objects.filter(name=name, project=self.request.project, type=2)
parent_id = self.request.GET.get("folder", "")
if parent_id.isdecimal():
# 判断是否在跟路径下,没有parent_id就表示在根路径下,否则就在父路径下
exist = queryset.filter(parent_id=parent_id).exists()
else:
exist = queryset.filter(parent__isnull=True).exists()
if exist:
self.add_error("name", "文件夹已存在")
return name
这里有一点要注意,ModelForm
中要根据parent_id
再次筛选,也就是说文件夹不是完全不能重名,只是相同的文件夹下才不能重名。怎么实现的呢?
前端的每一个文件夹都是一个可点击的<a>
标签,可以进入文件夹内部。标签内部是这样写的:
<!-- app01/templates/app01/files.html-->
<a href="{% url 'app01:file' request.project.id %}?folder={{ file_obj.id }}">{{ file_obj.name }}</a>
即在路径后增加了参数folder
,后端通过这个参数就可以判断出当前所处的是哪个文件夹。
但是新建文件夹发送的是ajax
请求,在填url
的时候,如果只是填写一个普通的后台校验的路径,那么由于这个folder
是拼在路径后面的,是发不过去的,因此使用location.href
将当前路径直接发过去:
// app01/templates/app01/files.html
function bindModalSubmit() {
$("#addFileSubmit").click(function(){
$.ajax({
// location.href,发送当前url
url: location.href,
type: "post",
data: $("#fileform").serialize(), // 文件夹名称是放在form表单中的
dataType: "json",
success: function (ret) {
if (ret.status===1){
// 校验通过,直接刷新
location.reload();
}else{
// 否则报错提示
$.each(ret.msg, function(index, value){
$("#id_"+index).next().html(value);
})
}
}
})
})
}
我还实现了一个功能,就是每点击一个文件夹,上方导航条都会显示当前所处的位置:
因此每次渲染页面,都要维护一个列表,通过循环将所有文件名显示出来。
添加了导航条和父文件判断的视图函数如下:
# app01/views/files.py
def file(request, project_id):
parent_id = request.GET.get("folder", "")
parent_obj = None
if request.method == 'GET':
# 导航条
nav_list = []
file_list = models.FileRepository.objects.filter(project_id=project_id)
if parent_id.isdecimal():
parent = models.FileRepository.objects.filter(id=parent_id, project_id=project_id).first()
parent_obj = parent
while parent:
# 循环迭代,将所有的父文件放在列表前面
nav_list.insert(0, {"id": parent.id, "name": parent.name})
parent = parent.parent
file_list = file_list.filter(parent_id=parent_id)
else:
file_list = file_list.filter(parent_id=None)
form = FileRepositoryForm(request)
back_dict = {
"file_list": file_list,
"form": form,
"nav_list": nav_list,
"parent_folder": parent_obj
}
return render(request, 'app01/file.html', back_dict)
2. 编辑文件夹
编辑文件夹和创建文件夹的逻辑是一样的,验证通过之后直接放到数据库里就好了。为了节省代码,可以用同一个视图函数进行判断,前端的模态框也可以用同一个。但是这样也会存在两个问题:
- 编辑和添加都是post请求,一个视图函数如何区分要进行哪种操作?
- 模态框的标题如果想要使动态的,即添加文件夹时显示“添加文件夹”,编辑文件夹时显示“编辑文件夹”,该如何实现?
方法就是给编辑的按钮一个属性data-fid
,值是当前文件夹的id。即:
<!-- app01/templates/app01/files.html-->
<a class="btn btn-default" href="#" data-toggle="modal" data-target="#addFile" data-fid="{{ file_obj.id }}" data-name="{{ file_obj.name }}">
<i class="fa fa-pencil-square-o" aria-hidden="true"></i><span class="hidden">编辑文件夹</span>
</a>
bootstrap的模态框有一个shown.bs.modal
事件,意思就是模态框显示完成之后执行什么。
// app01/templates/app01/files.html
function initModal(){
$('#addFile').on('shown.bs.modal', function (e) {
// 哪个元素触发了模态框的出现
let btn = $(e.relatedTarget);
// 获取data-fid值
let fid = btn.data("fid");
// 获取data-name值
let name = btn.data("name");
let modal = $(this);
// 改变标题文本值
modal.find(".modal-title").text($(btn).text());
if(fid){
// 有data-fid,是编辑,展示原有数据
console.log(fid);
modal.find("#id_name").val(name);
modal.find("#fid").val(fid);
}else{
// 是新增,表格置空
modal.find(".error-msg").empty();
$("#fileform")[0].reset();
}
})
}
后端利用fid
也可以区分对待编辑和新增了:
# app01/views/files.py
fid = request.POST.get("fid","")
ret = {"status": 1, "msg": ""}
if fid.isdecimal():
# 编辑
file_obj = models.FileRepository.objects.filter(id=fid, project_id=project_id).first()
# 这里新定义了一个ModelForm,否则如果文件夹的名字和原来一样,会报错
form = EditFolderForm(instance=file_obj, data=request.POST)
if form.is_valid():
form.save()
else:
ret["status"] = 0
ret["msg"] = form.errors
return JsonResponse(ret)
else:
# 添加
form = FileRepositoryForm(request, data=request.POST)
if form.is_valid():
form.instance.project = request.project
form.instance.type = 2
form.instance.update_user = request.tracer
form.instance.parent_id = parent_id
form.save()
else:
ret["status"] = 0
ret["msg"] = form.errors
return JsonResponse(ret)
三、文件操作
1. 文件上传
文件上传使用腾讯COS对象存储,通过js直接将文件上传到桶中。js上传需要获取临时秘钥。
流程是:js向后台发送ajax请求,获取临时秘钥;后台对当前用户进行校验,如果已经超过套餐容量限制,直接返回,否则调用获取临时秘钥的函数,将结果返回。
// app01/templates/app01/files.html
// 获取临时秘钥的路由
let COS_UPLOAD_URL = "{% url 'app01:cos_credential' request.project.id %}";
// 当前父文件夹ID,决定上传在哪个文件夹下
let CURRENT_FOLDER_ID = "{{ parent_folder.id }}";
// 上传文件并写入数据库
let POST_UPLOAD_URL = "{% url 'app01:cos_post' request.project.id %}";
function bindUploadEvent(){
$("#upload").change(function(){
// 获取上传的文件
let file_list = $(this)[0].files
console.log(file_list);
let upload_data = []
$.each(file_list, function(index, file){
upload_data.push({"name": file.name, "size": file.size})
})
// 获取临时秘钥
var cos = new COS({
// getAuthorization 必选参数
getAuthorization: function (options, callback) {
$.post(COS_UPLOAD_URL, JSON.stringify(upload_data), function(data){
// 后台如果返回错误信息,前端要提示
if (!data.status){
alert("data.message");
}else{
let credentials = data.credentials;
if (!data || !credentials) {
return console.error('credentials invalid:\n' + JSON.stringify(data, null, 2))
}
// 秘钥没有问题就上传
callback({
TmpSecretId: credentials.tmpSecretId,
TmpSecretKey: credentials.tmpSecretKey,
SecurityToken: credentials.sessionToken,
// 建议返回服务器时间作为签名的开始时间,避免用户浏览器本地时间偏差过大导致签名错误
StartTime: data.startTime, // 时间戳,单位秒,如:1580000000
ExpiredTime: data.expiredTime, // 时间戳,单位秒,如:1580000000
});
}
})
}
});
$.each(file_list, function(index, file){
// 构造一个key,用来作cos的key
let time = new Date().getTime();
let key = time+'-'+file.name;
// cos.putObject与上面的new COS配套使用
cos.putObject({
Bucket: '{{ request.project.bucket }}', /* 必须 */
Region: 'ap-shanghai', /* 存储桶所在地域,必须字段 */
Key: key, /* 必须 */
Body: file, // 上传文件对象
onProgress: function(progressData) {
}
}, function(err, data) {
console.log(err || data);
if(data && data.statusCode===200){
$.post(POST_UPLOAD_URL, {
name: file.name,
key: key,
file_size: file.size,
file_path: data.Location,
parent: CURRENT_FOLDER_ID,
}, function(ret){
//后台写入数据库成功之后,前端动态的显示上传的内容,此处省略
})
}
})
})
})
}
视图函数中的代码:
- 校验是否可以获取临时秘钥
# app01/views/file.py
from app01.utils.tencent.cos import get_credential
@csrf_exempt
def cos_credential(request, project_id):
"""上传文件获取临时凭证"""
data = json.loads(request.body.decode("utf-8"))
print(data)
# 已用空间容量
used_space = request.project.use_space
for item in data:
used_space += item.get("size")
# 当前套餐空间容量限制
limit = request.price_policy.price_policy.project_space * 1024 * 1024
if used_space > limit:
return JsonResponse({"status": False, "message": "已超出容量限制,请升级套餐!"})
data = get_credential(request.project.bucket)
data.update({"status": True})
return JsonResponse(data)
- 获取临时秘钥
# app01/utils/tencent/cos.py
secret_id = settings.TENCENT_COS_SECRET_ID
secret_key = settings.TENCENT_COS_SECRET_KEY
def get_credential(bucket, region='ap-shanghai'):
config = {
'url': 'https://sts.tencentcloudapi.com/',
# 域名,非必须,默认为 sts.tencentcloudapi.com
'domain': 'sts.tencentcloudapi.com',
# 临时密钥有效时长,单位是秒
'duration_seconds': 1800,
'secret_id': secret_id,
# 固定密钥
'secret_key': secret_key,
# 换成你的 bucket
'bucket': bucket,
# 换成 bucket 所在地区
'region': region,
# 这里改成允许的路径前缀,可以根据自己网站的用户登录态判断允许上传的具体路径
# 例子: a.jpg 或者 a/* 或者 * (使用通配符*存在重大安全风险, 请谨慎评估使用)
'allow_prefix': '*',
# 密钥的权限列表。简单上传和分片需要以下的权限,其他权限列表请看 https://cloud.tencent.com/document/product/436/31923
'allow_actions': [
# 简单上传
'name/cos:PutObject',
],
}
sts = Sts(config)
response = sts.get_credential()
return response
- js上传完成之后将数据写入数据库
# app01/views/file.py
@csrf_exempt
def cos_post(request, project_id):
"""上传文件提交到数据库"""
form = UploadPostForm(request.POST)
if form.is_valid():
data_dict = form.cleaned_data
print(data_dict)
data_dict.update({
"project": request.project,
"file_type": 1,
"update_user": request.tracer,
})
instance = models.FileRepository.objects.create(**data_dict)
# 更新项目的已用空间
current_project = models.Project.objects.filter(id=project_id).first()
current_project.use_space += instance.file_size
current_project.save()
print(current_project.use_space)
result = {
"id": instance.id,
"name": instance.name,
"file_size": instance.file_size,
"update_user": instance.update_user.username,
"update_time": instance.update_time.strftime("%Y年%m月%d日 %H:%M:%S"),
"download_link": reverse("app01:file_download", kwargs={"project_id":project_id, "file_id": instance.id})
}
return JsonResponse({"status": 1, "data": result})
else:
print(form.errors)
return JsonResponse({"status": 0, "data": "文件上传错误"})
2. 文件下载
使用requests模块进行下载,这就用到了COS中存储的文件路径。requests.get获取数据之后,返回的响应中增加一个'Content-Disposition'
的响应头,值为"attachment; filename=xxx"
就可以实现下载。
# app01/views/file.py
def download(request, project_id, file_id):
"""下载文件"""
file_obj = models.FileRepository.objects.filter(id=file_id, project_id=project_id).first()
import requests
data = requests.get(file_obj.file_path).content
response = HttpResponse(data)
response['Content-Disposition'] = "attachment; filename={}".format(file_obj.name)
return response
3. 文件删除
这个项目由于用到了COS对象存储,因此不仅仅要去数据库中删除,还要把COS桶中的文件也一起删了。
在文件删除的视图函数中,还包含了文件夹的删除。如果只是删除文件,不论是操作数据库还是存储桶都很容易,但如果是文件夹就比较麻烦了,要不断的判断文件夹下是否还有文件夹,因为COS中只能删除文件夹下的文件。实现的代码如下:
# app01/views/file.py
from app01.utils.tencent.cos import get_credential, cos_delete_file
def delete(request, project_id):
fid = request.GET.get("fid", "")
used_space = request.project.use_space
# 创建一个存储文件的列表
file_list = []
if fid.isdecimal():
obj = models.FileRepository.objects.filter(id=fid, project_id=project_id).first()
if obj.file_type == 1:
# 是文件,放入列表中
used_space -= obj.file_size
file_list.append(obj)
else:
folder_list = [obj,]
for item in folder_list:
# 排序,文件夹在前,文件在后
children_list = item.children.order_by("-type")
# children_list为空也不会报错
for child in children_list:
if child.file_type == '1':
file_list.append(child)
used_space -= child.file_size
else:
folder_list.append(child)
# 循环完成之后列表中只剩下一个个文件
# 删除cos
cos_delete_file(request.project.bucket, file_list)
# 删除数据库数据
models.FileRepository.objects.filter(id=fid, project_id=project_id).delete()
instance = request.project
instance.use_space = used_space
instance.save()
return JsonResponse({"status": True})
COS中删除文件的代码:
# app01/utils/tencent/cos.py
secret_id = settings.TENCENT_COS_SECRET_ID
secret_key = settings.TENCENT_COS_SECRET_KEY
def cos_delete_file(bucket, file_list, region='ap-shanghai'):
config = CosConfig(Region=region, SecretId=secret_id, SecretKey=secret_key)
client = CosS3Client(config)
print(file_list)
# 构造批量删除的字典格式
delete = []
for file in file_list:
delete.append({'Key': file.key})
response = client.delete_objects(
Bucket=bucket,
Delete={
'Object': delete,
'Quiet': 'true'
}
)
四、总结
在文件这一块儿,主要的难点是文件夹和文件的嵌套关系的处理,以及同一视图函数进行多个操作时的区分判断。我们使用前端传入数据的方法做了很好的解决。
另外,COS对象存储的代码也比较复杂,看官方文档真是个苦力活。。