Django轻量级任务追踪管理平台开发:四


前言

前一篇文章讲了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){
                       //后台写入数据库成功之后,前端动态的显示上传的内容,此处省略 
                    })
                }
            })
        })
    })
}

视图函数中的代码:

  1. 校验是否可以获取临时秘钥
# 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)

  1. 获取临时秘钥
# 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
  1. 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对象存储的代码也比较复杂,看官方文档真是个苦力活。。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值