Django 다중 업로드 지원하기
장고에서의 다중업로드 지원
장고에서는 단일 업로드만을 기본적으로 지원한다.
프로필 이미지 같이 한 개의 이미지만 등록하는 경우라면 상관없겠지만 여러 이미지를 등록하기 위해서는 별도의 작업이 필요하다.
다중 업로드 구현
`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`을 통해 직관적으로 코드의 흐름을 파악할 수 있다.
`CBV`는 해당 링크에서 어떻게 작동하는지 자세히 확인할 수 있다.
결과
관련커밋
생성: 소스코드
수정: 소스코드