과제

📌 Django Todo 프로젝트: 이미지 업로드 + 썸네일 생성 + Summernote 적용 전체 흐름 정리

Chansman 2025. 5. 12. 18:19

📌 Django Todo 프로젝트: 이미지 업로드 + 썸네일 생성 + Summernote 적용 전체 흐름 정리


1️⃣ 라이브러리 설치 및 설정

poetry add django-summernote
poetry add pillow
poetry add django-cleanup

🔧 settings.py 설정

  • INSTALLED_APPS에 django_summernote, django_cleanup 추가
  • MEDIA_URL, MEDIA_ROOT 설정
  • SUMMERNOTE_CONFIG 설정으로 iframe, 툴바, 보안 세팅 포함

✅ pillow는 Django 앱이 아니므로 INSTALLED_APPS에 추가하지 않음


2️⃣ models.py 수정 - ImageField, thumbnail 생성 및 썸네일 저장 처리

from PIL import Image
from io import BytesIO
from pathlib import Path

class Todo(models.Model):
    ...
    completed_image = models.ImageField(upload_to='todo/completed_images', null=True, blank=True)
    thumbnail = models.ImageField(upload_to='todo/thumbnails', default='todo/no_image/NO-IMAGE.gif', null=True, blank=True)

    def save(self, *args, **kwargs):
        if not self.completed_image:
            return super().save(*args, **kwargs)

        image = Image.open(self.completed_image)
        image.thumbnail((100, 100))

        image_path = Path(self.completed_image.name)
        thumbnail_filename = f"{image_path.stem}_thumbnail{image_path.suffix}"

        file_type = 'JPEG' if image_path.suffix in ['.jpg', '.jpeg'] else 'PNG' if image_path.suffix == '.png' else 'GIF'

        temp_thumb = BytesIO()
        image.save(temp_thumb, format=file_type)
        temp_thumb.seek(0)
        self.thumbnail.save(thumbnail_filename, temp_thumb, save=False)
        temp_thumb.close()

        return super().save(*args, **kwargs)

📌 마이그레이션 필수: makemigrations → migrate


3️⃣ forms.py 수정 - Summernote + Bootstrap 적용

from django_summernote.widgets import SummernoteWidget

class TodoForm(forms.ModelForm):
    class Meta:
        model = Todo
        fields = ['title', 'description', 'start_date', 'end_date']
        widgets = {
            'description': SummernoteWidget(),
            'title': forms.TextInput(attrs={'class': 'form-control'}),
            'start_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
            'end_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'})
        }

class TodoUpdateForm(forms.ModelForm):
    class Meta:
        model = Todo
        fields = ['title', 'description', 'start_date', 'end_date', 'is_completed', 'completed_image']
        widgets = {
            'description': SummernoteWidget(),
            'completed_image': forms.FileInput(attrs={'class': 'form-control'}),
            'is_completed': forms.CheckboxInput(attrs={'class': 'form-check-input'})
        }

4️⃣ views.py 수정 - form_class 적용

class TodoCreateView(LoginRequiredMixin, CreateView):
    model = Todo
    form_class = TodoForm
    template_name = 'todo/todo_create.html'

    def form_valid(self, form):
        self.object = form.save(commit=False)
        self.object.user = self.request.user
        self.object.save()
        return redirect(self.get_success_url())

    def get_success_url(self):
        return reverse_lazy('cbv_todo_info', kwargs={'pk': self.object.id})

class TodoUpdateView(LoginRequiredMixin, UpdateView):
    model = Todo
    form_class = TodoUpdateForm
    template_name = 'todo/todo_update.html'

    def get_object(self, queryset=None):
        obj = super().get_object(queryset)
        if obj.user != self.request.user and not self.request.user.is_superuser:
            raise Http404()
        return obj

    def get_success_url(self):
        return reverse_lazy('cbv_todo_info', kwargs={'pk': self.object.id})

5️⃣ admin.py 설정 - summernote 필드 + 이미지 필드 표시

class TodoAdmin(SummernoteModelAdmin):
    summernote_fields = ('description',)
    fieldsets = (
        ('Todo Info', {'fields': ('user', 'title', 'description', 'completed_image', 'is_completed')}),
        ('Date Range', {'fields': ('start_date', 'end_date')})
    )

urls.py 설정

from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import path, include
from todo.views import todo_list, todo_info, todo_create, todo_update, todo_delete
from users import views as user_views

urlpatterns = [
    path('todo/', todo_list, name='todo_list'),
    path('todo/create/', todo_create, name='todo_create'),
    path('todo/<int:todo_id>/', todo_info, name='todo_info'),
    path('todo/<int:todo_id>/update/', todo_update, name='todo_update'),
    path('todo/<int:todo_id>/delete/', todo_delete, name='todo_delete'),
    path('admin/', admin.site.urls),
    path('accounts/', include('django.contrib.auth.urls')),
    path('accounts/login/', user_views.login, name='login'),
    path('accounts/signup/', user_views.sign_up, name='signup'),
    # CBV URL include
    path('cbv/', include('todo.urls')),
    # summernote URL include
    path('summernote/', include('django_summernote.urls')),
]

if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

6️⃣ 템플릿 수정 - todo_list / create / update

todo_list.html

  • 썸네일 이미지 출력
  • 완료 상태 표시
  • Bootstrap list-group 사용
{% extends 'todo/base.html' %}
{% block content %}
<style>
    body {
        font-family: Arial, sans-serif;
        background-color: #f4f4f4;
        margin: 0;
        padding: 20px;
    }
    .custom-container {
        max-width: 600px;
        margin: auto;
        background: #fff;
        padding: 20px;
        border-radius: 10px;
        box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
    }
    h1 {
        text-align: center;
        color: #333;
    }
    input[type='text'] {
        height: 2vh;
    }
    .comment-list-item {
        padding: 10px;
        margin: 5px 0;
        background: #eaeaea;
        border-radius: 5px;
        transition: background 0.3s;
    }
</style>
<div class="mt-lg-5 custom-container">
    <div class="d-flex justify-content-between align-items-center mb-3">
        <h1>{{ todo.title }}</h1>
        <div class="d-flex">
            <button class="btn btn-primary me-2" onclick="location.href='{% url 'cbv_todo_update' todo.id %}'">수정하기</button>
            <form method="POST" action="{% url 'cbv_todo_delete' todo.id %}">
                {% csrf_token %}
                <button id="delete-button" type="submit" class="btn btn-danger">삭제하기</button>
            </form>
        </div>
    </div>
    <table class="table">
        {% for key, value in todo.items %}
            {% if key == 'completed_image' and value %}
                <div>
                    <img src="http://127.0.0.1:8000/media/{{ value }}" alt="완료 인증 이미지" class="w-100" style="max-height: 400px; object-fit: cover;">
                </div>
            {% endif %}
            {% if key in 'description start_date end_date is_completed created_at updated_at' %}
                <tr>
                    <th class="bg-light">{{ key }}</th>
                    {% if key == 'description' %}
                        <td>{{ value | safe }}</td>
                    {% else %}
                        <td>{{ value }}</td>
                    {% endif %}
                </tr>
            {% endif %}
        {% endfor %}
    </table>
</div>
<div class="custom-container mt-lg-2">
    <h2>Comment</h2>
    <hr>
    <form method="POST" action="{% url 'comment_create' todo.id %}" class="d-flex justify-content-evenly align-items-center">
        {% csrf_token %}
        {{ comment_form.as_p }}
        <button class="btn btn-primary">댓글달기</button>
    </form>
    <ul class="list-unstyled" id="comment_wrapper">
    {% for comment in page_obj %}
        <li class="comment-list-item container-fluid">
            <div class="ps-2 d-flex justify-content-between align-items-center">
                <p class="mb-0">{{ comment.user }}</p>
                {% if request.user == comment.user or request.user.is_staff %}
                <div class="text-decoration-none a-group">
                    <form method="POST" action="{% url 'comment_delete' comment.id %}">
                        {% csrf_token %}
                        <div class="btn-group-sm">
                            <button class="btn btn-primary" type="button" onclick="modify_view({{ comment.id }})">수정하기</button>
                            <button type=submit class="btn btn-danger">삭제하기</button>
                        </div>
                    </form>
                </div>
                {% endif %}
            </div>
            <hr>
            <div>
                <p class="p-lg-2">{{ comment.message }}</p>
                <p class="text-end">{{ comment.created_at }}</p>
            </div>
            <form id="comment_modify_form_{{ comment.id }}" style="display: none" method="POST" action="{% url 'comment_update' comment.id %}">
                {% csrf_token %}
                {{ comment_form.as_p }}
                <button class="btn btn-primary btn-sm">수정하기</button>
            </form>
        </li>
    {% endfor %}
    </ul>
    {% include 'todo/pagination.html' with fragment='comment_wrapper' %}
</div>
<script>
    function modify_view(commentId) {
        const modifyForm = document.getElementById(`comment_modify_form_${commentId}`);
        if (modifyForm.style.display === "none") {
            modifyForm.style.display = ""
        } else if (modifyForm.style.display === "") {
            modifyForm.style.display = "none"
        }
    }
</script>
{% endblock %}

todo_create.html & todo_update.html

  • enctype="multipart/form-data" 설정 필수
  • Bootstrap form 스타일 적용
<form method="POST" enctype="multipart/form-data" class="form-control">
    {% csrf_token %}
    {{ form.as_p }}
    <button class="btn btn-primary">Create / Update</button>
</form>

7️⃣ 테스트 흐름 정리 (서버 실행 후)

  1. runserver 실행 후 /cbv/todo/ 접속
  2. Todo 생성 시 summernote UI 확인
  3. 이미지 없이 생성 시 썸네일 기본 이미지 표시
  4. Todo 수정 페이지에서 이미지 업로드 + 완료 체크
  5. 수정 후 썸네일 자동 변경 확인
  6. Todo 삭제 시 django-cleanup으로 이미지 자동 삭제 확인

✅ media/ 경로에 실제 이미지와 썸네일이 생성되고, 삭제 시 자동 제거됨


이로써 텍스트 편집기, 이미지 처리, 자동 클린업, UI 개선을 모두 반영한 Django Todo 확장 기능이 완성됩니다.