Repository: codingjoe/django-s3file (language: python)

Issue:
Admin file upload with `blank=True` fails due to a missing file
Hey there, thank you for this useful project.

I'm using
django ==5.0.3 
django-s3file ==5.5.5
django-storages ==1.14.2

I'm using `django-pictures` with `django-s3file` to serve and store my images.
I have a model field setup like so

```python
    image = PictureField(
        _("image"),
        upload_to=FilePattern(
            filename_pattern=("fancy-pattern),
        ),
        aspect_ratios=["1/1"],
        storage=some_s3_storage,
        blank=True,
        file_types=["WEBP", "JPEG"],
        width_field="image_width",
        height_field="image_height",
    )
```

Now if I try to save an instance of the model in the admin, I'm getting the following error message:

```js
TypeError: object null is not iterable (cannot read property Symbol(Symbol.iterator))
  at Function.from(<anonymous>)
  at uploadFiles(/static/s3file/js/s3file.min.7bb6c3f3de1e.js:1:164)
  at ? (/static/s3file/js/s3file.min.7bb6c3f3de1e.js:1:2879)
  at Array.forEach(<anonymous>)
  at uploadS3Inputs(/static/s3file/js/s3file.min.7bb6c3f3de1e.js:1:2835)
  at HTMLFormElement.<anonymous>(/static/s3file/js/s3file.min.7bb6c3f3de1e.js:1:3350)
```

If I remove `blank=True` from the model definition, the upload works without a problem.
Do you have an idea what is wrong here?



Discussion / hints:

Fix patch:
diff --git a/s3file/static/s3file/js/s3file.js b/s3file/static/s3file/js/s3file.js
index e4c573d..ad88901 100644
--- a/s3file/static/s3file/js/s3file.js
+++ b/s3file/static/s3file/js/s3file.js
@@ -118,7 +118,7 @@
     window.uploading = 0
     form.loaded = 0
     form.total = 0
-    var inputs = Array.from(form.querySelectorAll('.s3file'))
+    var inputs = Array.from(form.querySelectorAll('input[type=file].s3file'))
 
     inputs.forEach(function (input) {
       var hiddenS3Input = document.createElement('input')
@@ -140,7 +140,7 @@
   }
 
   document.addEventListener('DOMContentLoaded', function () {
-    var forms = Array.from(document.querySelectorAll('.s3file')).map(function (input) {
+    var forms = Array.from(document.querySelectorAll('input[type=file].s3file')).map(function (input) {
       return input.closest('form')
     })
     forms = new Set(forms)


Test patch:
diff --git a/tests/test_forms.py b/tests/test_forms.py
index e16ca82..9f4ee39 100644
--- a/tests/test_forms.py
+++ b/tests/test_forms.py
@@ -3,20 +3,17 @@
 from contextlib import contextmanager
 
 import pytest
+from django.core.files.uploadedfile import SimpleUploadedFile
 from django.forms import ClearableFileInput
+from django.urls import reverse_lazy
 from selenium.common.exceptions import NoSuchElementException
 from selenium.webdriver.common.by import By
 from selenium.webdriver.support.expected_conditions import staleness_of
 from selenium.webdriver.support.wait import WebDriverWait
 
 from s3file.storages import storage
-from tests.testapp.forms import UploadForm
-
-try:
-    from django.urls import reverse
-except ImportError:
-    # Django 1.8 support
-    from django.core.urlresolvers import reverse
+from tests.testapp.forms import FileForm
+from tests.testapp.models import FileModel
 
 
 @contextmanager
@@ -27,15 +24,13 @@ def wait_for_page_load(driver, timeout=30):
 
 
 class TestS3FileInput:
-    @property
-    def url(self):
-        return reverse("upload")
+    create_url = reverse_lazy("example-create")
 
     def test_value_from_datadict(self, freeze_upload_folder, client, upload_file):
         with open(upload_file) as f:
             uploaded_file = storage.save(freeze_upload_folder / "test.jpg", f)
         response = client.post(
-            reverse("upload"),
+            self.create_url,
             {
                 "file": f"custom/location/{uploaded_file}",
                 "file-s3f-signature": "FxQXie3wnVnCUFqGzFZ8DCFKAXFA3bnQ8tE96U11o80",
@@ -46,12 +41,12 @@ def test_value_from_datadict(self, freeze_upload_folder, client, upload_file):
         assert response.status_code == 201
 
     def test_value_from_datadict_initial_data(self, filemodel):
-        form = UploadForm(instance=filemodel)
+        form = FileForm(instance=filemodel)
         assert filemodel.file.name in form.as_p(), form.as_p()
         assert not form.is_valid()
 
     def test_file_does_not_exist_no_fallback(self, filemodel):
-        form = UploadForm(
+        form = FileForm(
             data={"file": "foo.bar", "s3file": "file"},
             instance=filemodel,
         )
@@ -59,18 +54,18 @@ def test_file_does_not_exist_no_fallback(self, filemodel):
         assert form.cleaned_data["file"] == filemodel.file
 
     def test_initial_no_file_uploaded(self, filemodel):
-        form = UploadForm(data={"file": ""}, instance=filemodel)
+        form = FileForm(data={"file": ""}, instance=filemodel)
         assert form.is_valid(), form.errors
         assert not form.has_changed()
         assert form.cleaned_data["file"] == filemodel.file
 
     def test_initial_fallback(self, filemodel):
-        form = UploadForm(data={"file": ""}, instance=filemodel)
+        form = FileForm(data={"file": ""}, instance=filemodel)
         assert form.is_valid()
         assert form.cleaned_data["file"] == filemodel.file
 
     def test_clear(self, filemodel):
-        form = UploadForm(data={"file-clear": "1"}, instance=filemodel)
+        form = FileForm(data={"file-clear": "1"}, instance=filemodel)
         assert form.is_valid()
         assert not form.cleaned_data["file"]
 
@@ -137,7 +132,7 @@ def test_accept(self):
 
     @pytest.mark.selenium
     def test_no_js_error(self, driver, live_server):
-        driver.get(live_server + self.url)
+        driver.get(live_server + self.create_url)
 
         with pytest.raises(NoSuchElementException):
             error = driver.find_element(By.XPATH, "//body[@JSError]")
@@ -147,7 +142,28 @@ def test_no_js_error(self, driver, live_server):
     def test_file_insert(
         self, request, driver, live_server, upload_file, freeze_upload_folder
     ):
-        driver.get(live_server + self.url)
+        driver.get(live_server + self.create_url)
+        file_input = driver.find_element(By.XPATH, "//input[@name='file']")
+        file_input.send_keys(upload_file)
+        assert file_input.get_attribute("name") == "file"
+        with wait_for_page_load(driver, timeout=10):
+            file_input.submit()
+        assert storage.exists("tmp/s3file/%s.txt" % request.node.name)
+
+        with pytest.raises(NoSuchElementException):
+            error = driver.find_element(By.XPATH, "//body[@JSError]")
+            pytest.fail(error.get_attribute("JSError"))
+
+    @pytest.mark.selenium
+    def test_file_update(
+        self, request, driver, live_server, upload_file, freeze_upload_folder
+    ):
+        FileModel.objects.create(
+            file=SimpleUploadedFile(
+                f"{request.node.name}.txt", request.node.name.encode()
+            )
+        )
+        driver.get(live_server + reverse_lazy("example-update", kwargs={"pk": 1}))
         file_input = driver.find_element(By.XPATH, "//input[@name='file']")
         file_input.send_keys(upload_file)
         assert file_input.get_attribute("name") == "file"
@@ -163,7 +179,7 @@ def test_file_insert(
     def test_file_insert_submit_value(
         self, driver, live_server, upload_file, freeze_upload_folder
     ):
-        driver.get(live_server + self.url)
+        driver.get(live_server + self.create_url)
         file_input = driver.find_element(By.XPATH, "//input[@name='file']")
         file_input.send_keys(upload_file)
         assert file_input.get_attribute("name") == "file"
@@ -172,7 +188,7 @@ def test_file_insert_submit_value(
             save_button.click()
         assert "save" in driver.page_source
 
-        driver.get(live_server + self.url)
+        driver.get(live_server + self.create_url)
         file_input = driver.find_element(By.XPATH, "//input[@name='file']")
         file_input.send_keys(upload_file)
         assert file_input.get_attribute("name") == "file"
@@ -184,7 +200,7 @@ def test_file_insert_submit_value(
 
     @pytest.mark.selenium
     def test_progress(self, driver, live_server, upload_file, freeze_upload_folder):
-        driver.get(live_server + self.url)
+        driver.get(live_server + self.create_url)
         file_input = driver.find_element(By.XPATH, "//input[@name='file']")
         file_input.send_keys(upload_file)
         assert file_input.get_attribute("name") == "file"
@@ -193,7 +209,7 @@ def test_progress(self, driver, live_server, upload_file, freeze_upload_folder):
             save_button.click()
         assert "save" in driver.page_source
 
-        driver.get(live_server + self.url)
+        driver.get(live_server + self.create_url)
         file_input = driver.find_element(By.XPATH, "//input[@name='file']")
         file_input.send_keys(upload_file)
         assert file_input.get_attribute("name") == "file"
@@ -213,7 +229,7 @@ def test_multi_file(
         another_upload_file,
         yet_another_upload_file,
     ):
-        driver.get(live_server + reverse("upload-multi"))
+        driver.get(live_server + reverse_lazy("upload-multi"))
         file_input = driver.find_element(By.XPATH, "//input[@name='file']")
         file_input.send_keys(
             " \n ".join(
diff --git a/tests/testapp/forms.py b/tests/testapp/forms.py
index bd7f2cb..84bc9d4 100644
--- a/tests/testapp/forms.py
+++ b/tests/testapp/forms.py
@@ -1,4 +1,5 @@
 from django import forms
+from django.contrib.admin.widgets import AdminFileWidget
 
 from s3file.forms import S3FileInputMixin
 
@@ -10,10 +11,13 @@
     ) + forms.ClearableFileInput.__bases__
 
 
-class UploadForm(forms.ModelForm):
+class FileForm(forms.ModelForm):
     class Meta:
         model = FileModel
         fields = ("file", "other_file")
+        widgets = {
+            "file": AdminFileWidget,
+        }
 
 
 class MultipleFileInput(forms.ClearableFileInput):
diff --git a/tests/testapp/settings.py b/tests/testapp/settings.py
index 345369f..869f009 100644
--- a/tests/testapp/settings.py
+++ b/tests/testapp/settings.py
@@ -16,7 +16,9 @@
     "django.contrib.auth",
     "django.contrib.contenttypes",
     "django.contrib.sessions",
+    "django.contrib.messages",
     "django.contrib.staticfiles",
+    "django.contrib.admin",
     "storages",
     "s3file",
     "tests.testapp",
@@ -41,6 +43,12 @@
     {
         "BACKEND": "django.template.backends.django.DjangoTemplates",
         "APP_DIRS": True,
+        "OPTIONS": {
+            "context_processors": [
+                "django.contrib.auth.context_processors.auth",
+                "django.contrib.messages.context_processors.messages",
+            ],
+        },
     },
 ]
 
diff --git a/tests/testapp/urls.py b/tests/testapp/urls.py
index f9da4df..9f54c24 100644
--- a/tests/testapp/urls.py
+++ b/tests/testapp/urls.py
@@ -1,11 +1,25 @@
 try:
-    from django.urls import path
+    from django.urls import include, path
 except ImportError:
     from django.conf.urls import url as path
 
 from . import views
 
 urlpatterns = [
-    path("", views.ExampleFormView.as_view(), name="upload"),
+    path(
+        "example/",
+        include(
+            [
+                path(
+                    "create", views.ExampleCreateView.as_view(), name="example-create"
+                ),
+                path(
+                    "<int:pk>/update",
+                    views.ExampleUpdateView.as_view(),
+                    name="example-update",
+                ),
+            ]
+        ),
+    ),
     path("multi/", views.MultiExampleFormView.as_view(), name="upload-multi"),
 ]
diff --git a/tests/testapp/views.py b/tests/testapp/views.py
index af2f257..85e8f54 100644
--- a/tests/testapp/views.py
+++ b/tests/testapp/views.py
@@ -3,7 +3,7 @@
 from django.http import JsonResponse
 from django.views import generic
 
-from tests.testapp import forms
+from . import forms, models
 
 
 class FileEncoder(DjangoJSONEncoder):
@@ -13,8 +13,28 @@ def default(self, o):
         return super().default(o)
 
 
-class ExampleFormView(generic.FormView):
-    form_class = forms.UploadForm
+class ExampleCreateView(generic.CreateView):
+    model = models.FileModel
+    fields = ["file", "other_file"]
+    template_name = "form.html"
+
+    def form_valid(self, form):
+        return JsonResponse(
+            {
+                "POST": self.request.POST,
+                "FILES": {
+                    "file": self.request.FILES.getlist("file"),
+                    "other_file": self.request.FILES.getlist("other_file"),
+                },
+            },
+            status=201,
+            encoder=FileEncoder,
+        )
+
+
+class ExampleUpdateView(generic.UpdateView):
+    model = models.FileModel
+    form_class = forms.FileForm
     template_name = "form.html"
 
     def form_valid(self, form):