【问题标题】:Django : bulk upload with confirmationDjango:批量上传并确认
【发布时间】:2021-08-13 14:04:57
【问题描述】:

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

由于问题有点晦涩,我在最后给出几点。

所以,用例。

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

它应该如何工作?

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

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

  1. 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. utils.py 中:handle_uploaded_item_csv_file - 只需保存文件并返回文件名
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. forms.py 中:表格非常明显,但要清楚
class UploadItemCsvFileForm(forms.Form):
    item_csv_file = forms.FileField()

这是问题/要点。

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

b) 我使用 "request.session"'uploaded_file' 从一个视图传递到另一个视图,这是一个好习惯吗?不使用 GET 变量还有其他方法吗?

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

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

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

也欢迎所有其他评论。

【问题讨论】:

    标签: python django django-views bulk


    【解决方案1】:

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

    想法:

    1. 上传文件

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

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

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

    5. 检查 show_uploaded_file() 中的表单集和表单,是否正确验证

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

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

    这是新版本的代码。 以一种好的方式显示表单集的 html 模板对我来说有点棘手。但是我没有在这里发布它,因为它会占用很多空间。如果有人需要它:在 cmets 中问我。

    • 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 中处理上传的文件(保存到 dict 中)
    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})
    

    【讨论】:

      【解决方案2】:

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

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

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

      b) 我使用“request.session”将“uploaded_file”从一个视图传递到另一个视图,这是一个好习惯吗?不使用 GET 变量还有其他方法吗?

      使用 request.session 有利有弊。

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

      最后一点与剩余文件问题有关:如果您丢失了有关哪些文件仍需要的信息,则清理起来会更加困难(尽管理论上,您可以从会话存储中检索哪些文件仍然需要)。

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

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

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

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

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

      一些想法:

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

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

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

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

      请记住:如果您只将 csv 存储为您在每次访问某个视图时加载和解析的文本数据,这可能是攻击者耗尽您的服务器的一种简单方法,从而给他们一个简单的 DoS 攻击。


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

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

      【讨论】:

      • 非常感谢您的详细回复。您的反馈并不完全符合我的预期,但仍然非常有用。回复你的一些观点。当然,临时文件名应该(并且将)包括用户名,但目前我们在系统中没有用户。目前还没有实现(我们仍处于 PoC 阶段,甚至还没有预生产)。另一个时刻。您经常谈论“存储状态”和文件持久性。这对我来说似乎有点矫枉过正。用户上传的文件显然是一个临时文件。
      • 用户在他的计算机上有这个文件,他上传它,我们从这个文件创建项目并且应该删除该文件。如果在上传/添加过程中发生了什么事,这没什么大不了的:用户仍然可以重新上传文件。对我来说,如何删除文件(如果出现问题)的问题似乎更重要。不过,我非常感谢您的评论和反馈。
      • 另一个非常感谢的事情是向我指出 UUID。我不知道这个库,可能这是命名文件的最佳方式。所以既不需要时间戳,也不需要用户名。由于文件是临时文件:它会完美地完成这项工作。
      • @PaulZakharov 关于确保删除文件:我描述的 cronjob 方法应该适用于任何场景:拥有一个定期运行的 python 脚本并删除文件系统中不再需要的所有文件,要么是因为它们不再从数据库中引用,要么是因为它们太旧了。无论如何,即使一切都崩溃了,这最终也会清理系统。
      • 你说得对,如果这只是一个概念证明,你不需要太担心我提到的大部分观点。如果它有效,它就有效。关于答案,我还有什么可以改进的吗?
      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2014-02-19
      相关资源
      最近更新 更多