Django:批量上传确认

Django : bulk upload with confirmation

关于风格和良好做法的另一个问题。 我将展示的代码可以工作并执行功能。但我想知道它是否可以作为解决方案,还是它太丑陋了?

由于问题有点晦涩难懂,我在最后给出一些要点。

所以,用例。

我有一个包含这些项目的网站。有一个由用户添加项目的功能。现在我想要通过 csv 文件添加多个项目的功能。

它应该如何运作?

  1. 用户转到特殊上传页面。
  2. 用户选择一个csv文件,点击上传。
  3. 然后他被重定向到显示 csv 文件内容的页面(作为 table)。
  4. 如果用户没问题,他点击“是”(带有“confirm_items_upload”值的按钮)并将文件中的项目添加到数据库(如果它们没问题)。

我已经看到了 django 的批量上传示例,它们看起来很清楚。但是我没有找到带有中介“验证-确认”页面的示例。 那么我是怎么做到的:

  1. in views.py : 查看上传 csv 文件页面
def upload_item_csv_file(request):
    if request.method == 'POST':
        form = UploadItemCsvFileForm(request.POST, request.FILES)
        if form.is_valid():
            uploaded_file_name = handle_uploaded_item_csv_file(request.FILES['item_csv_file'])
            request.session['uploaded_file'] = uploaded_file_name
            return redirect('show_upload_csv_item')
    else:
        form = UploadItemCsvFileForm()
    return render(request, 'myapp/item_csv_upload.html', {'form': form})
  1. in utils.py : handle_uploaded_item_csv_file - 只需保存文件和 return 文件名
def handle_uploaded_item_csv_file(f):
    now = datetime.now()
    # YY_mm_dd_HH_MM
    dt_string = now.strftime("%Y_%m_%d_%H_%M")
    file_name = os.path.join(settings.MEDIA_ROOT, f"tmp_csv/item_csv_{dt_string}.csv")
    with open(file_name, 'wb+') as destination:
        for chunk in f.chunks():
            destination.write(chunk)

    return f"tmp_csv/item_csv_{dt_string}.csv"
  1. views.py 中:查看 show_upload_csv_item
@transaction.atomic
def show_uploaded_file(request):
    if 'uploaded_file' in request.session :
        file_name = request.session['uploaded_file']
    else :
        print("Something wrong : raise 404")
        raise Http404
    if not os.path.isfile(os.path.join(settings.MEDIA_ROOT, file_name)):
        print("Something wrong, file does not exist : raise 404")
        raise Http404

    with open(os.path.join(settings.MEDIA_ROOT, file_name)) as csvfile :
        fieldnames = ['serial_number', 'type', 'shipping_date', 'comments']
        csv_reader = csv.DictReader(csvfile, delimiter=';', fieldnames=fieldnames)
        list_items = list(csv_reader)

    if request.POST and ("confirm_items_upload" in request.POST) :
        if request.POST["confirm_items_upload"] == "yes" :
            for cur_item in list_items :
                if not cur_item['shipping_date'] :
                    cur_item.pop('shipping_date', None)

                try :
                    Item.objects.create(**cur_item)
                except IntegrityError :
                    messages.warning(request, f"This Item : {cur_item} - already exists. No items were added." )
            os.remove(os.path.join(settings.MEDIA_ROOT, file_name))
            return redirect('items')
    else :
        return render(request, 'myapp/item_csv_uploaded.html', {'items': list_items})
  1. In forms.py : 形式很明显,但只是为了清楚
class UploadItemCsvFileForm(forms.Form):
    item_csv_file = forms.FileField()

这是questions/points。

a) 即使显然它可以更好,这个解决方案是接受table还是根本不接受?

b) 我使用 "request.session" 从一个视图传递 'uploaded_file' 是不是好的做法?有没有不使用 GET 变量的另一种方法?

c) 起初我希望避免保存 csv 文件。但我不知道该怎么做? 将所有文件读取到 request.session 对我来说似乎不是一个好主意。有没有可能将文件上传到 Django 的内存中?

d) 如果我必须使用 tmp 文件。如果用户中途放弃上传(比如他看到确认页面,但没有点击“是”并决定重新写入他的文件),我应该如何处理?如何删除 tmp 文件?

e) 小附加问题:Django 中对上传文件有什么样的检查?例如,我如何检查该文件是否至少是一个文本文件?我应该这样做吗?

也欢迎所有其他评论。

a) Even if obviously it could be better, is this solution is acceptable or not at all ?

我认为它有一些你想解决的问题,但是使用文件系统和只存储文件名的一般想法是可以接受的,这取决于你需要服务的用户数量以及数据一致性和并发访问的保证你想做。

我会考虑上传的文件临时数据可能会在系统故障时丢失。如果您想提供任何不丢失数据的保证,您希望将其存储在数据库中而不是文件系统中。

b) I pass 'uploaded_file' from one view to another using "request.session" is it a good practice? Is there another way to do it without using GET variables?

使用 request.session 有优点也有缺点。

  • 攻击者无法更改文件名从而检索其他用户的数据。这也是您不应在此处使用 GET 参数的原因:如果您使用 GET 参数,攻击者可以简单地更改该参数并访问其他用户的文件。
  • 用户可以上传文件,然后去做其他事情,然后回来实际导入文件,但是:
  • 如果用户结束他们的会话,您将丢失文件名。此外,用户无法在一台设备上上传文件,然后切换到另一台设备,然后继续导入,因为另一台设备会有不同的会话。

最后一点与剩余文件问题相关:如果您丢失了有关仍需要哪些文件的信息,这会使清理工作变得更加困难(尽管理论上,您可以从会话存储中检索仍需要哪些文件).

如果由于用户清除 cookie 或更改设备而导致会话可能结束或更改是一个问题,您可以考虑将文件名添加到数据库中的 UserProfile。这样,它就不会绑定到会话。

c) At first my wish was to avoid to save the csv-file. But I could not figure out how to do it? Reading all the file to request.session seems not a good idea for me. Is there some possibility to upload the file into memory in Django?

您想存储状态。存储状态的首选方法是数据库或会话存储。您可以加载整个 CSVFile 并将其作为文本放入数据库。这是否可以接受取决于您的数据库处理大型非结构化数据的能力。传统数据库最初并不是为此而构建的,但是,如今它们中的大多数都可以很好地处理小型二进制文件。数据库可以为您提供诸如 ACID 保证之类的优势,其中对文件系统上同一文件的并发写入可能会破坏文件。参见 this discussion on the dba stackexchange

您的数据库可能有关于该主题的文档,例如有这个 page about binary data in postgres.

d) If I have to use the tmp-file. How should I handle the situation if user abandon upload at the middle (for example, he sees the confirmation page, but does not click "yes" and decide to re-write his file). How to remove the tmp-file?

一些想法:

  • 通过设计将每位用户上传的文件数限制为一个。目前,您的文件名基于时间戳。如果两个用户同时决定上传一个文件,这就会中断:他们都将获得相同的时间戳,并且磁盘上的文件可能已损坏。如果您改用用户的主键,则可以保证每个用户最多有一个文件。如果他们稍后上传另一个文件,他们的旧文件将被覆盖。如果您的用户数量足够少,您可以为每个用户存储一个剩余文件,则不需要额外清理。但是,如果同一用户同时上传两个文件,这仍然会中断。
  • 使用唯一标识符,例如 UUID,并在用户上传新文件时删除旧的存储文件。这要求您仍然使用旧文件名,因此不能使用会话存储。您仍将始终拥有文件系统中用户的最后一个文件。
  • 为文件名使用唯一标识符并设置一些任意的最长存储期限。设置定期检查文件的 cronjob 或类似任务,并删除所有存储时间超过指定最长持续时间的文件。如果用户上传文件,但没有及时进行实际导入,他们的数据将被删除,他们将不得不再次上传。在这里,您的代码必须处理具有存储文件名的文件不再存在的情况(甚至可能在您读取文件时被删除)。

您可能希望将您的服务器限制为每个用户存储一个文件,这样攻击者就无法填满您的文件系统。

e) Small additional question : what kind of checks there are in Django about uploaded file? For example, how could I check that the file is at least a text-file? Should I do it?

您肯定想为文件设置一些最大文件大小,例如here. You could limit the allowed file extensions,但这只是可用性问题。攻击者还可以使用任何可接受的扩展名向您提供垃圾数据。

请记住:如果您仅将 csv 存储为每次访问特定视图时加载和解析的文本数据,这可能是攻击者耗尽您的服务器的一种简单方法,使他们可以轻松进行 DoS 攻击。


总的来说,这取决于你想做出什么样的保证,你有多少用户以及他们的可信度。如果用户可能是恶意的,您需要牢记所有可能的数据提取和资源耗尽攻击。文件系统不会横向扩展(至少不像数据库那么容易)。

我知道在一个项目中有一个类似的设置,只允许少数特权用户上传内容,我们可以容忍在失败时删除所有临时文件。用户只需重新上传他们的文件。这很好用。

即使我接受了答案,我对我的解决方案也不完全满意。 最后我找到了如何做得更好(看起来)。

想法:

  1. 上传文件

  2. 使用处理函数将上传的文件保存到临时文件(NamedTemporaryFile)中,然后从上传的文件中读取到dict

  3. 创建使用此字典初始化的表单集,呈现页面以显示此表单集

  4. 在此表单集页面上提交按钮将我们发送到 /validate_upload url(show_uploaded_file 函数)

  5. 检查 show_uploaded_file() 中的表单集和表单,如果正确则验证

这个解决方案似乎更适合我,因为我不将文件保存在驱动器上。只有自己销毁的临时文件。 所以我们不用担心删除这个文件并处理 cron-jobs 来删除 Django 没有删除的文件。

为什么我一开始没有使用它? 主要是因为我忘记了formset。使用 formset 允许将文件读入表单,因此我们不需要文件。文件中的所有信息都在表格中。用户可以看到它,如果它很好,他可以像普通表单一样验证表单。

这是新版本的代码。 html-template 以一种好的方式显示 formset 对我来说有点棘手。但是我没有在这里发布它,因为它会占用很多 space。如果有人需要它:在评论中问我。

  • 上传文件到views.py

    @login_required()
    def upload_item_csv_file(request):
        if request.method == 'POST':
            form = UploadItemCsvFileForm(request.POST, request.FILES)
            if form.is_valid():
                list_items = handle_uploaded_item_csv_file(request.FILES['item_csv_file'])
                MyFormset = formset_factory(ItemForm, max_num=len(list_items))
                formset = MyFormset(initial=list_items)
                return render(request, 'myapp/item_csv_uploaded.html', {'formset': formset})
        else:
            form = UploadItemCsvFileForm()
        return render(request, 'myapp/item_csv_upload.html', {'form': form})

  • 处理上传的文件(保存到字典中),在utils.py
from tempfile import NamedTemporaryFile

def handle_uploaded_item_csv_file(f):
    data_file = NamedTemporaryFile()
    with data_file as destination:
        for chunk in f.chunks():
            destination.write(chunk)
        data_file.seek(os.SEEK_SET, os.SEEK_END)

        with open(data_file.name) as csvfile :
            fieldnames = ['serial_number', 'type', 'shipping_date', 'comments']
            csv_reader = csv.DictReader(csvfile, delimiter=';', fieldnames=fieldnames)
            list_items = list(csv_reader)

    return list_items
  • 验证由 dict 预填充的表单集,如果正确则保存对象。在views.py
@transaction.atomic
@login_required()
def show_uploaded_file(request):
    MyFormset = formset_factory(ItemForm)

    if request.POST :
        formset = MyFormset(request.POST)
        if formset.is_valid() :
            for form in formset :
                if form.is_valid() :
                    form.save()
            messages.success(request, f"Items were sucessfully added")
            return redirect(reverse('items'))
        else :
            formset = MyFormset(request.POST)

    return render(request, 'myapp/item_csv_uploaded.html', {'formset': formset})