기술정리/Django

Django 다중 업로드 지원하기

bingual 2024. 5. 6. 15:37
반응형

장고에서의 다중업로드 지원

장고에서는 단일 업로드만을 기본적으로 지원한다.

프로필 이미지 같이 한 개의 이미지만 등록하는 경우라면 상관없겠지만 여러 이미지를 등록하기 위해서는 별도의 작업이 필요하다.

 

 

 

다중 업로드 구현

`models.py`

class TimeStampedModel(models.Model):
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        abstract = True


class Note(LifecycleModelMixin, TimeStampedModel):
    author = models.ForeignKey(User, on_delete=models.CASCADE)
    title = models.CharField(max_length=100)
    content = models.TextField()
    tags = TaggableManager(blank=True, help_text="쉼표로 구분하여 작성해주세요.")

    class Meta:
        ordering = ["-pk"]

    def get_absolute_url(self) -> str:
        return reverse("photolog:note_detail", kwargs={"pk": self.pk})


class Photo(TimeStampedModel):
    note = models.ForeignKey(Note, on_delete=models.CASCADE)
    image = models.ImageField(upload_to=uuid_name_upload_to)

    @classmethod
    def create_photos(cls, note: Note, photo_file_list: List[File]) -> List["Photo"]:
        if not note.pk:
            raise ValueError("Note를 먼저 저장해주세요.")

        photo_list = []
        for photo_file in photo_file_list:
            photo = cls(note=note)
            photo.image.save(photo_file.name, photo_file, save=False)
            photo_list.append(photo)

        cls.objects.bulk_create(photo_list)

        return photo_list

 

 

 

 

`forms.py`

class MultipleFileInput(forms.ClearableFileInput):
    allow_multiple_selected = True


class MultipleImageField(forms.ImageField):
    widget = MultipleFileInput

    def clean(self, data, initial=None):
        single_clean = super().clean  # 함수를 호출하지 않습니다.
        if isinstance(data, (list, tuple)):
            return [single_clean(file) for file in data]
        else:
            return single_clean(data)

 

 

  • `ClearbleFileInput`을 상속받아서 `allow_multiple_selected`를 `True`로 설정해 주어 다중 파일 선택이 가능해지도록 한다.
  • `ImageField`를 상속받아서 `super().clean` 객체를 할당한다. data의 값이 이터러블 객체일 경우 `super().clean`을 호출하여 각각 파일을 개별로 처리한다.

위에 위젯을 사용하여 forms에서 이미지 업로드 필드를 재정의하여 사용할 수 있다.

 

 

 

`forms.py`

class NoteCreateForm(forms.ModelForm):
    photos = MultipleImageField(required=True)

    class Meta:
        model = Note
        fields = ["title", "content", "tags"]

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.helper = FormHelper()
        self.helper.attrs = {"novalidate": True}
        self.helper.form_class = "helper_form"
        self.helper.label_class = "helper_label"

        self.helper.layout = Layout("title", "content", "photos", "tags")
        self.helper.add_input(
            Submit(
                "submit",
                "생성",
                css_class="helper_submit",
            )
        )

    def clean_photos(self):
        is_required = self.fields["photos"].required

        file_list: List[File] = self.cleaned_data.get("photos")
        if not file_list and is_required:
            raise forms.ValidationError("최소 1개의 사진을 등록해주세요.")
        elif file_list:
            try:
                file_list = [make_thumb(file) for file in file_list]
            except Exception as e:
                raise forms.ValidationError(
                    "썸네일 생성 중에 오류가 발생했습니다."
                ) from e
        return file_list

 

https://django-crispy-forms.readthedocs.io/en/latest/install.html
`crispy_forms` 라이브러리를 사용하였다. 해당 라이브러리는 form을 손쉽게 디자인할 수 있게 도와주는 라이브러리이다. 다만 `tailwind`를 사용한다면 `crispy-tailwind` 라이브러리를 추가로 사용할 수 있지만 아직 개발 초기단계라 다크모드에 대한 지원을 하지 않기 때문에 따로 설정을 해주어야 한다. 또한 forms에서는 클래스 힌트를 제공하지 않으므로 간단한 작업을 할 때에만 사용하는 게 바람직하다.

 

`make_thumb`는 이전 포스팅에서 직접 구현한 이미지 최적화 함수이다.

 

https://github.com/matthewwithanm/django-imagekit

`django-imagekit`을 사용하여 손쉽게 최적화하는 방법도 있다. 현재는 기능 테스트용 프로젝트이기에 이후 사용하고 있지는 않다.

 

 

 

`views.py`

class NoteCreateView(LoginRequiredMixin, CreateView):
    model = Note
    form_class = NoteCreateForm
    template_name = "crispy_form.html"
    extra_context = {"form_title": "기록 생성"}

    def form_valid(self, form):
        self.object = form.save(commit=False)  # noqa

        new_note = self.object
        new_note.author = self.request.user
        new_note.save()

        photo_file_list = form.cleaned_data.get("photos")
        if photo_file_list:
            Photo.create_photos(new_note, photo_file_list)

        messages.success(self.request, "새 기록을 저장했습니다.")

        return redirect(self.get_success_url())


note_new = NoteCreateView.as_view()

 

생성한 form을 사용하여 위와 같이 다중 업로드 기능을 구현할 수 있다.
`create_photos` 또한 직접 구현된 함수이다. `make_thumb`으로 최적화한 파일 리스트를 인스턴스에 저장하고 `bulk_create`을 통해 일괄 처리한다.

 

 

 

다중 업로드된 파일을 수정하기

다중 업로드된 파일은 개별로 수정을 처리할 필요가 있다.

 

https://docs.djangoproject.com/en/5.0/topics/forms/formsets/

장고에서 해당 기능을 구현하기 위해 `formset` 사용할 수 있으며 해당 포스팅에서는 `crispy forms`에 내장된 `formset`을 사용한다. 해당 포스팅에서는 `formset`의 사용법은 다루지 않는다.

 

 

`forms.py`

class NoteUpdateForm(NoteCreateForm):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields["photos"].required = False
        self.helper.form_tag = False
        self.helper.inputs = []


class PhotoInlineForm(forms.ModelForm):
    class Meta:
        model = Photo
        fields = ["image"]


class CustomBaseInlineFormSet(BaseInlineFormSet):
    def add_fields(self, form, index):
        super().add_fields(form, index)
        if index == 0:
            form.fields["DELETE"].widget = forms.HiddenInput()


PhotoUpdateFormSet = inlineformset_factory(
    parent_model=Note,
    model=Photo,
    form=PhotoInlineForm,
    formset=CustomBaseInlineFormSet,
    extra=0,
    can_delete=True,
)
PhotoUpdateFormSet.helper = FormHelper()
PhotoUpdateFormSet.helper.form_tag = False

 

 

  • Update의 기능은 Create와 비슷하기 때문에 `NoteCreateForm`을 상속받아서 재정의한다.
  • `PhotoInlineForm`으로 다중 이미지 처리를 위한 form을 생성해 준다.
  • `CustomBaseInlineFormSet`으로 `BaseInlineFormSet`을 상속받아 재정의한다. 현재 `NoteCreateForm`에서는 최소 1개의 이미지를 받기를 기대하고 있다. 따라서 첫 번째 이미지 객체는 수정은 가능해도 삭제되어서는 안 된다.
  • `inlineformset_factory`을 사용하여 다중 폼을 생성하고 `crispy_forms`로 디자인 한다.

 

 

`views.py`

@login_required
def note_edit(request, pk):
    note = get_object_or_404(Note, pk=pk, author=request.user)
    photo_qs = note.photo_set.all()

    if request.method == "GET":
        note_form = NoteUpdateForm(instance=note, prefix="note")
        photo_formset = PhotoUpdateFormSet(
            queryset=photo_qs,
            instance=note,
            prefix="photos",
        )
    else:
        note_form = NoteUpdateForm(
            data=request.POST, files=request.FILES, instance=note, prefix="note"
        )
        photo_formset = PhotoUpdateFormSet(
            queryset=photo_qs,
            instance=note,
            data=request.POST,
            files=request.FILES,
            prefix="photos",
        )

        if note_form.is_valid() and photo_formset.is_valid():
            saved_note = note_form.save()

            photo_file_list = note_form.cleaned_data.get("photos")
            if photo_file_list:
                Photo.create_photos(saved_note, photo_file_list)

            photo_formset.save()
            messages.success(request, f"기록#{saved_note.pk}을 수정했습니다.")

            return redirect(saved_note)

    return render(
        request,
        "crispy_form_and_formset.html",
        {
            "form_title": "기록 수정",
            "form": note_form,
            "formset": photo_formset,
            "form_submit_label": "저장하기",
        },
    )

 

`formset`을 `CBV`로 다루기에는 다소 복잡하고 코드가 길어지기 때문에 `FBV`으로 다룬다. `FBV`는 레거시가 아니며 상황에 따라 사용해야 한다.

 

http://django-vanilla-views.org/

`django-vanilla-views`을 사용할 수 도 있다. 이는 보다 손쉽게 `FBV`에 추가적인 기능을 제공해 준다. `CBV`는 상속이 다소 복잡하게 얽혀 있기 때문에 ` django-vanilla-views`을 통해 직관적으로 코드의 흐름을 파악할 수 있다.

 

https://ccbv.co.uk/

`CBV`는 해당 링크에서 어떻게 작동하는지 자세히 확인할 수 있다.

 

 

 

결과


관련커밋

 

생성: 소스코드 

수정: 소스코드