Mini Project: Pystagram 만들기

  • 코드출처: 이한영의 Django 입문(디지털북스)

글 관리 기능 구현

1. 모델 설정

1.1 APP 생성

```bash
python manage.py startapp posts
```
  • config/settings.py에 추가

      INSTALLED_APPS = [
          ...
          'posts',
      ]
    

1.2 임시설정 변경

  • templates/users/feeds.html 파일을 templates/posts/feeds.html 로 이동
  • config/views.py 파일에서 users/feeds를 posts/feeds로 변경
  • users/views.py 파일에서 users/feeds를 posts/feeds로 변경

  • config/urls.py

      urlpatterns = [
          path('admin/', admin.site.urls),
          path('', index),
          path("users/", include("users.urls")),
          path("posts/", include("posts.urls")),
      ]
    
  • users/urls.py

      from django.urls import path
      from users.views import login_view, logout_view, signup
    
      urlpatterns = [
          path('login/', login_view),
          path('logout/', logout_view),
          path("signup/", signup),
      ]
    
  • posts/urls.py

      from django.urls import path
      from users.views import feeds 
    
      urlpatterns = [
          path('feeds/', feeds),
      ]
    

1.3 글/이미지/댓글 모델링

  • Model 구성
    • posts/models.py

        from django.db import models
      
        class Post(models.Model):
            user = models.ForeignKey("users.User", verbose_name="작성자", on_delete=models.CASCADE)
            content = models.TextField("내용")
            created = models.DateTimeField("생성일시", auto_now_add=True)
      
        class PostImage(models.Model):
            post = models.ForeignKey(Post, verbose_name="포스트", on_delete=models.CASCADE)
            photo = models.ImageField("사진", upload_to="post")
      
        class Comment(models.Model):
            user = models.ForeignKey("users.User", verbose_name="작성자", on_delete=models.CASCADE)
            post = models.ForeignKey(Post, verbose_name="포스트", on_delete=models.CASCADE)
            content = models.TextField("내용")
            created = models.DateTimeField("생성일시", auto_now_add=True)
      
    • 시스템에 반영

        python manage.py makemigrations
        python manage.py migrate
      

1.4 관리자 페이지에 모델 등록

  • posts/admin.py

      from django.contrib import admin
      from posts.models import Post, PostImage, Comment
    
      @admin.register(Post)
      class PostAdmin(admin.ModelAdmin):
          list_display = [
              "id",
              "content",
          ]
    
      @admin.register(PostImage)
      class PostImageAdmin(admin.ModelAdmin):
          list_display = [
              "id",
              "post",
              "photo",
          ]
    
      @admin.register(Comment)
      class CommentAdmin(admin.ModelAdmin):
          list_display = [
              "id",
              "post",
              "content",
          ]
    

1.5 admin에 연관 객체 표시

  • ForeignKey로 연결된 객체 확인
    • posts/admin.py

        class CommentInline(admin.TabularInline):
            model = Comment
            extra = 1
      
        @admin.register(Post)
        class PostAdmin(admin.ModelAdmin):
            ...
            inlines = [
                CommentInline,
            ]
      
        class CommentInline(admin.TabularInline):
            ...
      
        class PostImageInline(admin.TabularInline):
            model = PostImage
            extra = 1
      
        @admin.register(Post)
        class PostAdmin(admin.ModelAdmin):
            ...
            inlines = [
                CommentInline,
                PostImageInline,
            ]
      

2. 썸네일 이미지 표시

2.1 직접 admin을 조작해서 썸네일 표시 코드 추가

  • posts/admin.py

      from django.contrib.admin.widgets import AdminFileWidget
      from django.db import models
      from django.utils.safestring import mark_safe
      ...
    
      class CommentInline(admin.TabularInline):
          ...
    
      # AdminFileWidget은 관리자 페이지에서 '파일 선택' 버튼을 보여주는 부분
      # 이 widget을 커스텀하여 <img> 태그를 추가함
      class InlineImageWidget(AdminFileWidget):
          def render(self, name, value, attrs=None, renderer=None):
              html = super().render(name, value, attrs, renderer)
              if value and getattr(value, "url", None):
                  html = mark_safe(f'<img src="{value.url}" width="150" height="150">') + html
              return html
    
      # ImageField를 표시할 때, AdminFileWidget을 커스텀한 InlineImageWidget을 사용함
      class PostImageInline(admin.TabularInline):
          model = PostImage
          extra = 1
          formfield_overrides = {
              models.ImageField: {
                  "widget": InlineImageWidget,
              }
          }
    

2.2 오픈소스 라이브러리를 사용한 썸네일 표시

pip install django-admin-thumbnails
# 위에서 추가한 코드들은 모두 삭제하고 썸네일 라이브러리를 사용함
import admin_thumbnails

@admin_thumbnails.thumbnail("photo")
class PostImageInline(admin.TabularInline):
    model = PostImage
    extra = 1


3. 피드 페이지

3.1 View 작성

  • posts/views.py

      from posts.models import Post
    
      def feeds(request):
          user = request.user
          if not user.is_authenticated:
              return redirect("/users/login/")
    
          posts = Post.objects.all()
          context = { "posts": posts }
          return render(request, "posts/feeds.html", context)
    

3.2 작성자 정보 표시

  • templates/posts/feeds.html

      { % raw %}
      { % extends 'base_slider.html' %}
      { % block content %}
          <nav>
              <h1>Pystagram</h1>
          </nav>
          <div id="feeds" class="post-container">
              { % for post in posts %}
                  <article class="post">
                      <header class="post-header">
                          { % if post.user.profile_image %}
                              <img src="{ { post.user.profile_image.url }}">
                          { % endif %}
                          <span>{ { post.user.username }}</span>
                      </header>
                  </article>
              { % endfor %}
          </div>
      { % endblock %}
      { % endraw %}
    

3.3 이미지 슬라이더 구현

  • 이미지 슬라이드 자바스크립트, CSS 파일 불러오기
    • templates/base.html

        { % raw %}
        { % load static %}
        <!doctype html>
        <html lang="ko">
        <head>
            <link rel="stylesheet" href="{ % static 'css/style.css' %}">
            <link rel="stylesheet" href="{ % static 'splide/splide.css' %}">
            <script src="{ % static 'splide/splide.js' %}"></script>
        </head>
        <body>
        ...
        { % endraw %}
      
  • Splide 라이브러리 사용
    • templates/posts/feeds.html

        { % raw %}
        { % extends 'base.html' %}
        { % block content %}
        ...
            <div id="feeds" class="post-container">
                { % for post in posts %}
                    <article class="post">
                        <header class="post-header">
                            ...
                        </header>
      
                        <!-- 이미지 슬라이드 영역 시작 -->
                        <div class="post-images splide">
                            <div class="splide__track">
                                <ul class="splide__list">
                                    { % for image in post.postimage_set.all %}
                                        { % if image.photo %}
                                            <li class="splide__slide">
                                                <img src="{ { image.photo.url }}">
                                            </li>
                                        { % endif %}
                                    { % endfor %}
                                </ul>
                            </div>
                        </div>
                        <!-- 이미지 슬라이드 영역 종료 -->
                    </article>
                { % endfor %}
            </div>
        { % endblock %}
        { % endraw %}
      

3.4 템플릿 하단에 자바스크립트 코드 작성

  • templates/posts/feeds.html

      { % raw %}
      { % block content %}
          <div id="feeds" class="post-container">
              ...
          </div>
          <!-- content 블록의 최하단에 작성함 -->
          <script>
              const elms = document.getElementsByClassName('splide')
              for (let i = 0; i < elms.length; i++){
                  new Splide(elms[i]).mount();
              }
          </script>
      { % endblock %}
      { % endraw %}
    

3.5 글 속성 출력

  • templates/posts/feeds.html
    • 글 내용 출력

        <article class="post">
            <header class="post-header">...</header>
            <div class="post-images">...</div>
            <div class="post-content">
                { { post.content|linebreaksbr }}
            </div>
      
    • 좋아요/댓글 버튼 표시

        <div class="post-content">...</div>
        <div class="post-buttons">
            <button>Likes(0)</button>
            <span>Comments(0)</span>
        </div>
      
    • 댓글 목록 표시

        { % raw %}
        <div class="post-buttons">...</div>
        <div class="post-comments">
            <ul>
                <1--  Post에 연결된 PostComment들을 순회 -->
                { % for comment in post.comment_set.all %}
                    <li>
                        <span>{ { comment.user.username }}</span>
                        <span>{ { comment.content }}</span>
                    </li>
                { % endfor %}
            </ul>
            <button>Likes(0)</button>
            <span>Comments(0)</span>
        </div>
        { % endraw %}
      
    • 작성일자, 댓글 입력창 표시

        <div class="post-comments">...</div>
        <small>{ { post.created }}</small>
        <div class="post-comments-create">
            <input type="text" placeholder="댓글 달기...">
            <button type="submit">게시</button>
        </div>
      

3.6 Template에 링크 추가

  • templates/posts/feeds.html
    • 메인 링크 추가

        <nav>
            <h1>
                <a href="/posts/feeds/">Pystagram</a>
            </h1>
        </nav>
      
    • 로그아웃 버튼 추가

        <nav>
            <h1>
                <a href="/posts/feeds/">Pystagram</a>
            </h1>
            <a href="/users/logout/">Logout</a>
        </nav>
      


4. 글과 댓글

4.1 댓글 작성

  • CommentForm 구현
    • posts/forms.py

        from django import forms
        from posts.models import Comment
      
        class CommentForm(forms.ModelForm):
            class Meta:
                model = Comment
                fields = [
                    "content",
                ]
      
  • 오류 발생
    • posts_comment 테이블의 post_id 필드는 NULL을 허용하지 않는다는 메시지
      • Terminal

          python manage.py shell
        
          from post.forms import CommentForm
        
          data = {"content": "SampleContent"}
          form = CommentForm(data=data)
          form.is_valid()
          form.save()
        
  • 오류 해결 방법
    • CommentForm으로 Comment 객체를 일단 만들되, 메모리 상에 객체를 만들고 필요한 데이터를 나중에 채우기
    • CommentForm에 NULL을 허용하지 않는 모든 필드를 선언하고 인스턴스 생성 시 유효한 데이터를 전달

    • 첫 번째 방법으로 해결해보기

        python manage.py shell
      
        from post.forms import CommentForm
      
        data = {"content": "SampleContent"}
        form = CommentForm(data=data)
        form.is_valid()
        comment = form.save(commit=False)
        print(comment.id)
      
        from users.models import User
        from posts.models import Post
      
        user = User.objects.all()[0]
        post = Post.objects.all()[0]
        print(user)
        print(post)
      
        comment.user = user
        comment.post = post
        comment.save()
      
        comment.id
      
    • 두 번째 방법으로 해결해보기
      • posts/forms.py

          class CommentForm(forms.ModelForm):
              class Meta:
                  model = Comment
                  fields = [
                      "user",
                      "post",
                      "content",
                  ]
        
      • Terminal

        python manage.py shell
      
        python manage.py shell
      
        from post.forms import CommentForm
      
        data = {"content": "SampleContent"}
        form = CommentForm(data=data)
        form.is_valid()
        form.errors
      
        from users.models import User
        from posts.models import Post
      
        user = User.objects.all()[0]
        post = Post.objects.all()[0]
        data = {"content": "SampleContent", "user": user, "post": post}
        form = CommentForm(data=data)
        form.is_valid()
      
        comment = form.save()
        comment.id
      
  • Comment를 생성하기 위해 필요한 데이터
    • 어떤 글(Post)의 댓글인지
    • 어떤 사용자(User)의 댓글인지
    • 어떤 내용(Comment)을 가지고 있는지
  • View에서 Template으로 Form 전달
    • posts/views.py

        from posts.forms import CommentForm
      
        def feeds(request):
            ...
      
            posts = Post.objects.all()
            comment_form = CommentForm()
            context = {
                "posts": posts,
                "comment_form": comment_form,
            }
            return render(request, "posts/feeds.html", context)
      
    • templates/posts/feeds.html
      • 직접 작성했던 input 요소를 삭제하고 comment_form.as_p 변수 사용

          { % raw %}
          <div class="post-comments-create">
              <form method="POST">
                  { % csrf_token %}
                  { { comment_form.as_p }}
                  <button type="submit">게시</button>
              </form>
          </div>
          { % endraw %}
        
      • CommentForm에는 post, content 필드가 있고 이 둘을 as_p로 렌더링한 경우

        • 포스트의 드롭다운 요소를 클릭하면 Post 객체를 선택할 수 있음. 사용자가 어떤 글에 댓글을 다는지는 직접 입력할 필요 없이 템플릿에서 알아서 처리해 주어야 함
        • 자동으로 < label>요소와 < input>요소가 만들어짐. 여기서는 “내용:”으로 나타나는 < label>요소가 필요하지 않음. content 값을 입력받을 < input> 요소만 있으면 됨
          { % raw %}
          <div class="post-comments-create">
              <form method="POST">
                  { % csrf_token %}
                  { { comment_form.content }}
                  <button type="submit">게시</button>
              </form>
          </div>
          { % endraw %}
        
    • posts/forms.py

        class CommentForm(forms.ModelForm):
            class Meta:
                model = Comment
                fields = [ ... ]
                widgets = {
                    "content": forms.Textarea(
                        attrs={
                            "placeholder": "댓글 달기...",
                        }
                    )
                }
      


4.2 댓글 작성 처리를 위한 View 구현

  • posts/views.py

      from django.views.decorators.http import require_POST
    
      def feeds(request):
          ...
    
      @require_POST
      def comment_add(request):
          print(request.POST)
    
  • posts/urls.py

      from django.urls import path
      from posts.views import feeds, comment_add
      ...
    
      urlpatterns = [
          path("feeds/", feeds),
          path("comment_add/", comment_add),
      ]
    


4.3 form에서 comment_add View로 데이터 전달 및 처리

  • form의 action 속성
    • method: GET과 POST 중 어떤 방식으로 데이터를 전달할지
    • enctype: 기본값(application/x-www-form-urlencoded)과 파일 전송을 위한 값(multipart/form-data) 중 선택
  • 사용자가 직접 입력하지 않는 고정된 데이터를 form 내부에 위치
    • templates/posts/feeds.html

        { % raw %}
        <div class="post-comments-create">
            <form method="POST" action="/posts/comment_add/">
                { % csrf_token %}
                <input type="hidden" name="post" value="{ { post.id }}">
                { { comment_form.content }}
                <button type="submit">게시</button>
            </form>
        </div>
        { % endraw %}
      
  • 사용자 정보를 View에서 직접 할당
    • posts/views.py

        @require_POST
        def comment_add(request):
            form = CommentForm(data=request.POST)
            if form.is_valid():
                comment = form.save(commit=False)
                comment.user = request.user
                comment.save()
      
                print(comment.id)
                print(comment.content)
                print(comment.user)
      
                return redirect{"/posts/feeds/"}
      
  • 작성 완료 후 원하는 Post 위치로 이동
    • templates/posts/feeds.html

        { % raw %}
        <div id="feeds" class="post-container">
            { % for post in posts %}
                <article id="post-{ { post.id }}" class="post">
        { % endraw %}
      
    • posts/views.py

        from django.http import HttpResponseRedirect
      
        @require_POST
        def comment_add(request):
            form = CommentForm(data=request.POST)
            if form.is_valid():
                comment = form.save(commit=False)
                ...
      
                return HttpResponseRedirect{f"/posts/feeds/#post-{comment.post.id}"}
      


4.4 글의 댓글 수 표시

  • Terminal에서 확인

      python manage.py shell
    
      from posts.models import Post
    
      for post in Post.objects.all():
          print(f"id: {post.id}, comment_count: {post.comment_set.count()}")
    
  • templates/posts/feeds.html

      <div class="post-buttons">
          <button type="submit">Likes(0)</button>
          <span>Comments({ { post.comment_set.count }})</span>
      </div>
    


4.5 댓글 삭제

  • posts/views.py

      from posts.models import Post, Comment
    
      @require_POST
      def comment_add(request):
          ...
    
      @require_POST
      def comment_delete(request, comment_id):
          if request.method == "POST":
              comment = Comment.objects.get(id=comment_id)
              comment.delete()
              return HttpResponseRedirect(f"/posts/feeds/#post-{comment.post.id}")
    
  • posts/urls.py

      from django.urls import path
      from posts.views import feeds, comment_add, comment_delete
      ...
    
      urlpatterns = [
          path("feeds/", feeds),
          path("comment_add/", comment_add),
          path("comment_delete/<int:comment_id>/", comment_delete)
      ]
    
  • 삭제할 Comment가 요청한 사용자가 작성한 것인지 확인
    • posts/views.py

        from django.http import HttpResponseRedirect, HttpResponseForbidden
      
        @require_POST
        def comment_delete(request, comment_id):
            comment = Comment.objects.get(id=comment_id)
            if comment.user == request.user:
                comment.delete()
                return HttpResponseRedirect(f"/posts/feeds/#post-{comment.post.id}")
            else:
                return HttpResponseForbidden("이 댓글을 삭제할 권한이 없습니다.")
      
  • 템플릿에 삭제 버튼 추가
    • templates/posts/feeds.html

        { % raw %}
        <div class="post-comments">
            <ul>
                { % for comment in post.comment_set.all %}
                    <li>
                        <span>{ { comment.user.username }}</span>
                        <span>{ { comment.content }}</span>
      
                        <!-- 댓글 삭제 form 추가 -->
                        { % if user == comment.user %}
                            <form method="POST" action="/posts/comment_delete/{ { comment.id }}/">
                                { % csrf_token %}
                                <button type="submit">삭제</button>
                            </form>
                        { % endif %}
                    </li>
                { % endfor %}
            </ul>
        </div>
        { % endraw %}
      


5. 글 작성하기

5.1 글 작성 기본 구조

  • View: /posts/post_add/
  • URL: post_add
  • Template: templates/posts/post_add.html


5.2 글 작성 기본 구조 구현

  • posts/views.py

      ...
      def post_add(request):
          return render(request, "posts/post_add.html")
    
  • posts/urls.py

      ...
      from posts.views import feeds, comment_add, comment_delete, post_add
      ...
    
      urlpatterns = [
          ...
          path("post_add/", post_add),
      ]
    
  • templates/posts/post_add.html

      { % raw %}
      { % externds 'base.html' %}
    
      { % block content %}
          <div id="post-add">
              <h1>Post Add</h1>
          </div>
      { % endblock %}
      { % endraw %}
    


5.3 PostForm 클래스 구현

  • posts/forms.py

      from posts.models import Comment, Post
    
      class PostForm(forms.ModelForm):
          class Meta:
              model = Post
              fields = [
                  "content",
              ]
    


5.4 View 로직, Template 구현

  • posts/views.py

      from posts.forms import CommentForm, PostForm
      ...
    
      def post_add(request):
          form = PostForm()
          context = {"form": form}
          return render(request, "posts/post_add.html", context)
    
  • templates/posts/post_add.html

      { % raw %}
      { % externds 'base.html' %}
    
      { % block content %}
          <nav>
              <h1>Pystagram</h1>
          </nav>
          <div id="post-add">
              <h1>Post Add</h1>
              <form method="POST">
                  { % csrf_token %}
                  { { form.as_p }}
                  <button type="submit">게시</button>
              </form>
          </div>
      { % endblock %}
      { % endraw %}
    


5.5 여러 장의 이미지 업로드

  • Template에 직접 < input type=”file”> 구성
    • templates/posts/post_add.html

        { % raw %}
        <form method="POST" enctype="multipart/form-data">
            { % csrf_token %}
            <div>
                <labl for="id_images">이미지</label>
                <input id="id_images" name="images" type="file" multiple>
            </div>
            { { form.as_p }}
            <button type="submit">게시</button>
        </form>
        { % endraw %}
      
  • View에서 multiple 속성을 가진 file input의 데이터 받기
    • posts/views.py

        from posts.models import Post, Comment, PostImage
      
        ...
      
        def post_add(request):
            if request.method == "POST":
                # request.POST로 온 데이터 ("content")는 PostForm으로 처리
                form = PostForm(request.POST)
      
                if form.is_valid():
                    # Post의 "user"값은 request에서 가져와 자동할당한다
                    post = form.save(commit=False)
                    post.user = request.user
                    post.save()
      
                    # Post를 생성 한 후
                    # request.FILES.getlist("images")로 전송된 이미지들을 순회하며 PostImage객체를 생성한다
                    for image_file in request.FILES.getlist("images"):
                        # request.FILES또는 request.FILES.getlist()로 가져온 파일은
                        # Model의 ImageField부분에 곧바로 할당한다
                        PostImage.objects.create(
                            post=post,
                            photo=image_file,
                        )
      
                    # 모든 PostImage와 Post의 생성이 완료되면
                    # 피드페이지로 이동하여 생성된 Post의 위치로 스크롤되도록 한다
                    url = reverse("posts:feeds") + f"#post-{post.id}"
                    return HttpResponseRedirect(url)
      
            # GET요청일 때는 빈 form을 보여주도록한다
            else:
                form = PostForm()
      
            context = {"form": form}
            return render(request, "posts/post_add.html", context)
      

5.6 내비게이션 바에 링크 추가

  • 피드 페이지에서 글 작성 페이지로의 작성 추가
    • templates/posts/feeds.html

        <nav>
            <h1>
                <a href="/posts/feeds/">Pystagram</a>
            </h1>
            <a href="/posts/post_add/">Add post</a>
            <a href="/users/logout/">Logout</a>
        </nav>
      
  • 글 작성 페이지에서 피드 페이지로 돌아오는 링크 추가
    • templates/posts/post_add.html

        <nav>
            <h1>
                <a href="/posts/feeds/">Pystagram</a>
            </h1>
            <a href="/users/logout/">Logout</a>
        </nav>