[Django] (점프투장고) 프로필 화면 추가하기

azzurri21·2022년 1월 24일
0

Django

목록 보기
7/7

점프투장고 3-16 파이보 추가 기능프로필 기능을 추가하는 과정이다.

본 글에서는 비밀번호 찾기, 초기화를 다룬다.

[완성 소스] : https://github.com/jseop-lim/pybo/tree/a92610bcb4d0fdc27d7ce4230dc6e41ccc27f052


서론

점프투장고 저자분께서 제작한 실제 Pybo 사이트의 프로필 화면을 직접 구현해보았다. 프로필 화면의 모양은 사진과 같다.

이에 더불어 사용자가 추천한 질문과 답변의 목록도 확인할 수 있게 만들 것이다. 완성한 모습은 아래와 같다.

하위 폴더 생성

View.py 분리

프로필 기능은 common 앱에 추가할 예정이다. common 앱은 현재 로그인, 로그아웃, 비밀번호 변경 및 초기화 등 계정 관련 기능을 가지고 있다. 그래서 프로필 기능을 모아두기 위해 [BASE_DIR]\common\view.py를 대신 views 폴더를 생성하고, 내부에 account_views.pyprofile_views.py를 생성한다.

완성된 디렉토리 형태는 아래와 같다. account_views.py에는 기존 views.py에 있던 계정 관련 함수들이 존재한다. 이번 글에서 작성하는 프로필 관련 뷰 함수는 모두 profile_views.py에 작성하게 된다.

Template 하위 폴더 생성

기존 common 앱의 템플릿 파일은 모두 [BASE_DIR]\templates\common디렉토리에 존재한다. profile이라는 이름의 하위 디렉토리를 생성하고 이번에 작성한 프로필 관련 템플릿을 모두 저장한다. 최종 완성된 common 앱의 템플릿 파일들과 경로는 아래와 같다.

URL

[mysite\common\urls.py]

from django.urls import path
from django.contrib.auth import views as auth_views
from common.views import account_views, profile_views

app_name = 'common'

urlpatterns = [
	# 계정
    (... 생략 ...)

    # 프로필
    path('profile/base/<int:user_id>/', profile_views.profile_base, name='profile_base'),
    path('profile/question/<int:user_id>/', profile_views.ProfileQuestionListView.as_view(), name='profile_question'),
    path('profile/answer/<int:user_id>/', profile_views.ProfileAnswerListView.as_view(), name='profile_answer'),
    path('profile/comment/<int:user_id>/', profile_views.ProfileCommentListView.as_view(), name='profile_comment'),
    path('profile/vote/<int:user_id>/', profile_views.ProfileVoteListView.as_view(), name='profile_vote'),
]

user_id를 포함한 프로필 화면의 URL을 작성한다. user_id는 현재 프로필이 나오는 사용자의 DB 상에서의 인덱스이다. 이는 현재 로그인된 사용자와 다를 수 있는데, 질문 목록에서 글쓴이를 클릭했을 때 해당 사용자의 프로필 화면으로 이동하기 위해서이다

프로필 기본정보를 제외한 나머지 탭의 뷰는 클래스로 정의했으므로 as_view() 메서드를 호출한다.

[참고] 이전 글에서는 common 앱의 네임스페이스를 제거했었지만, 일부 수정을 거쳐 다시 생성하게 되었다.

Template

[mysite\templates\common\profile\profile_tabs.html]

<ul class="nav nav-tabs">
    <li class="nav-item">
        <a class="nav-link {% if profile_type == 'base' %}active{% endif %}" href="{% url 'common:profile_base' profile_user.id %}">기본정보</a>
    </li>
    <li class="nav-item">
        <a class="nav-link {% if profile_type == 'question' %}active{% endif %}" href="{% url 'common:profile_question' profile_user.id %}">게시</a>
    </li>
    <li class="nav-item">
        <a class="nav-link {% if profile_type == 'answer' %}active{% endif %}" href="{% url 'common:profile_answer' profile_user.id %}">답변</a>
    </li>
    <li class="nav-item">
        <a class="nav-link {% if profile_type == 'comment' %}active{% endif %}" href="{% url 'common:profile_comment' profile_user.id %}">댓글</a>
    </li>
    <li class="nav-item">
        <a class="nav-link {% if profile_type == 'vote' %}active{% endif %}" href="{% url 'common:profile_vote' profile_user.id %}">추천</a>
    </li>
</ul>

모든 프로필 화면에 공통적으로 보이는 탭이다. 별도 파일에 작성한 후, 각 탭의 HTML 파일에서 {% include "common/profile/profile_tabs.html" %}과 같이 불러온다.

[mysite\templates\common\profile\profile_base.html]

{% extends "base.html" %}

{% block content %}
<h4 class="border-bottom pb-2 my-3">{{ profile_user.username }}</h4>
{% include "common/profile/profile_tabs.html" %}

<div id="profile_base">
    {% if user == profile_user %}
        <div class="profile_title">이메일</div>
        <p class="mb-4">{{ profile_user.email }}</p>
    {% endif %}
</div>

{% endblock %}

profile_user는 현재 프로필이 보이는 사용자, user는 현재 로그인 중인 사용자이다. 로그인 중인 사용자만 자신의 프로필을 볼 때 이메일이 출력되도록 만들었다.

모든 뷰는 request를 통해 현재 로그인 중인 사용자를 user라는 이름으로 항상 전달한다. 따라서 프로필에 나타난 사용자와 구분하기 위해, 모든 프로필 뷰는 프로필 화면의 사용자(User 모델의 인스턴스)를 profile_user라는 이름으로 context에 포함하도록 만들었다.

[mysite\templates\common\profile\profile_question.html]

{% extends "base.html" %}
{% load pybo_filter %}

{% block content %}
<h4 class="border-bottom pb-2 my-3">{{ profile_user.username }}</h4>
{% include "common/profile/profile_tabs.html" %}

<!-- 답변 표시 Start -->
<select class="form-control so my-3">
    <option value="recent" {% if so == 'recent' %}selected{% endif %}>최신순</option>
    <option value="recommend" {% if so == 'recommend' %}selected{% endif %}>추천순</option>
</select>

<table class="table">
    <thead>
        <tr class="text-center thead-dark">
            <th>번호</th>
            <th>구분</th>
            <th>추천</th>
            <th style="width:50%">제목</th>
            <th>작성일시</th>
        </tr>
    </thead>
    <tbody>
    {% if page_obj %}
    {% for object in page_obj %}
    <tr class="text-center">
        <td>
            <!-- 번호 = 전체건수 - 시작인덱스 - 현재인덱스 + 1 -->
            {{ page_obj.paginator.count|sub:page_obj.start_index|sub:forloop.counter0|add:1 }}
        </td>
        <td>{{ object.category.description }}</td>
        <td>
            {% if object.voter.count > 0 %}
                <span class="badge badge-warning px-2 py-1">{{ object.voter.count }}</span>
            {% else %}
                <span class="badge badge-light px-2 py-1" style="color:#ccc;">0</span>
            {% endif %}
        </td>
        <td class="text-left">
            <a href="{{ object.get_absolute_url }}">{{ object }}</a>
            {% if object.answer_set.count > 0 %}
                <span class="text-danger small ml-2">{{ object.answer_set.count }}</span>
            {% endif %}
        </td>
        <td>{{ object.create_date }}</td>
    </tr>
    {% endfor %}
    {% else %}
    <tr>
        <td colspan="3">등록한 질문이 없습니다.</td>
    </tr>
    {% endif %}
    </tbody>
</table>

<!-- 페이징처리 시작 -->
<ul class="pagination justify-content-center">
    <!-- 이전페이지 -->
    {% if page_obj.has_previous %}
        <li class="page-item">
            <a class="page-link" data-page="1" href="#">처음</a>
        </li>
       <li class="page-item">
            <a class="page-link" data-page="{{ page_obj.previous_page_number }}" href="#">이전</a>
        </li>
    {% else %}
        <li class="page-item disabled">
            <a class="page-link" tabindex="-1" aria-disabled="true" href="#">처음</a>
        </li>
        <li class="page-item disabled">
            <a class="page-link" tabindex="-1" aria-disabled="true" href="#">이전</a>
        </li>
    {% endif %}
    <!-- 페이지리스트 -->
    {% for page_number in page_obj.paginator.page_range %}
        {% if page_number >= page_obj.number|add:-4 and page_number <= page_obj.number|add:4 %}
            {% if page_number == page_obj.number %}
            <li class="page-item active" aria-current="page">
                <a class="page-link" data-page="{{ page_number }}" href="#">{{ page_number }}</a>
            </li>
            {% else %}
            <li class="page-item">
                <a class="page-link" data-page="{{ page_number }}" href="#">{{ page_number }}</a>
            </li>
            {% endif %}
        {% endif %}
    {% endfor %}
    <!-- 다음페이지 -->
    {% if page_obj.has_next %}
        <li class="page-item">
             <a class="page-link" data-page="{{ page_obj.next_page_number }}" href="#">다음</a>
        </li>
        <li class="page-item">
             <a class="page-link" data-page="{{ page_obj.paginator.num_pages }}" href="#"></a>
        </li>
    {% else %}
        <li class="page-item disabled">
            <a class="page-link" tabindex="-1" aria-disabled="true" href="#">다음</a>
        </li>
        <li class="page-item disabled">
            <a class="page-link" tabindex="-1" aria-disabled="true" href="#"></a>
        </li>
    {% endif %}
</ul>
<!-- 페이징처리 끝 -->

<form id="searchForm" method="get">
    <input type="hidden" id="page" name="page" value="{{ page }}">
    <input type="hidden" id="so" name="so" value="{{ so }}">
</form>
{% endblock %}

{% block script %}
<script type='text/javascript'>
    $(document).ready(function(){
        $(".page-link").on('click', function() {
            $("#page").val($(this).data("page"));
            $("#searchForm").submit();
        });
        $(".so").on('change', function() {
            $("#so").val($(this).val());
            $("#page").val(1);  // 새로운 기준으로 정렬할 경우 1페이지부터 조회한다.
            $("#searchForm").submit();
        });
    });
</script>
{% endblock %}

프로필 화면 게시(질문) 탭의 템플릿이다. 질문 목록 템플릿(templates\pybo\question_list.html)을 약간 수정했다. 가장 상단에 common/profile/profile_tabs.html을 불러오는 것과 question_list라는 이름 대신 page_obj, 각 질문을 question 대신 object라고 이름 붙인 것 외에 거의 차이가 없다.

답변 탭댓글 탭의 템플릿도 거의 비슷하게 작성했다.

[mysite\templates\common\profile\profile_vote.html]

{% extends "base.html" %}
{% load pybo_filter %}

{% block content %}
<h4 class="border-bottom pb-2 my-3">{{ profile_user.username }}</h4>
{% include "common/profile/profile_tabs.html" %}

<!-- 답변 표시 Start -->
<select class="form-control so my-3">
    <option value="recent" {% if so == 'recent' %}selected{% endif %}>최신순</option>
</select>

<table class="table">
    <thead>
        <tr class="text-center thead-dark">
            <th>번호</th>
            <th>구분</th>
            <th>추천</th>
            <th style="width:50%">제목 및 내용</th>
            <th>작성일시</th>
        </tr>
    </thead>
    <tbody>
    {% if page_obj %}
    {% for object in page_obj %}
    <tr class="text-center">
        <td>
            <!-- 번호 = 전체건수 - 시작인덱스 - 현재인덱스 + 1 -->
            {{ page_obj.paginator.count|sub:page_obj.start_index|sub:forloop.counter0|add:1 }}
        </td>
        <td>{{ object.category }}</td>
        <td>
            {% if object.voter.count > 0 %}
                <span class="badge badge-warning px-2 py-1">{{ object.voter.count }}</span>
            {% else %}
                <span class="badge badge-light px-2 py-1" style="color:#ccc;">0</span>
            {% endif %}
        </td>
        <td class="text-left">
            {% if object.subject %}
                <a href="{{ object.get_absolute_url }}">{{ object.subject }}</a>
                <span class="text-danger small ml-2">{{ object.answer_set.count }}</span>
            {% else %}
                <a href="{{ object.get_absolute_url }}">{{ object.content }}</a>
            {% endif %}
        </td>
        <td>{{ object.create_date }}</td>
    </tr>
    {% endfor %}
    {% else %}
    <tr>
        <td colspan="3">추천한 질문과 답변이 없습니다.</td>
    </tr>
    {% endif %}
    </tbody>
</table>

<!-- 페이징처리 시작 -->
(... 생략 ...)

<form id="searchForm" method="get">
    <input type="hidden" id="page" name="page" value="{{ page }}">
{#    <input type="hidden" id="so" name="so" value="{{ so }}">#}
</form>
{% endblock %}

{% block script %}
<script type='text/javascript'>
    $(document).ready(function(){
        $(".page-link").on('click', function() {
            $("#page").val($(this).data("page"));
            $("#searchForm").submit();
        });
        {#$(".so").on('change', function() {#}
        {#    $("#so").val($(this).val());#}
        {#    $("#page").val(1);  // 새로운 기준으로 정렬할 경우 1페이지부터 조회한다.#}
        {#    $("#searchForm").submit();#}
        //});
    });
</script>
{% endblock %}

추천 탭은 질문과 답변을 동시에 보여주므로 조건문을 추가하여 출력을 조금 다르게 했다. 그리고 정렬기준을 최신순 하나만 사용했다.

{{ object.category }}를 이용해 카테고리 이름을 출력하기 위해서 Category 모델에 __str__()메서드를 추가했다. 왜냐하면 답변의 경우 이후 category 필드에 해당 카테고리의 이름(description)이 저장되도록 만들었지만, 질문은 category 필드에 Category 모델의 인스턴스가 저장되어 있기 때문이다. 수정된 Category 클래스의 정의는 아래와 같다.

[mysite\pybo\models.py]

class Category(models.Model):
    name = models.CharField(max_length=20, unique=True)
    description = models.CharField(max_length=200, null=True, blank=True)
    has_answer = models.BooleanField(default=True)  # 답변가능 여부

    def __str__(self):
        return self.description

    def get_absolute_url(self):
        return reverse('pybo:index', args=[self.name])

View

프로필 구현의 핵심은 뷰에 있다. 기존 질문 목록 뷰의 함수를 손수 만들었던 것과 달리, 이번엔 Django generic view의 일종인 ListView를 상속받는 클래스로 뷰를 정의한다. 이어서 profile_views.py 파일 전체를 세 부분으로 나누어 자세히 설명한다.

[mysite\common\views\profile_views.py]

from django.db.models import F, Count
from django.contrib.auth.models import User
from django.shortcuts import render, get_object_or_404
from django.views.generic import ListView

from pybo.models import Question, Answer, Comment


def profile_base(request, user_id):
    """
    프로필 기본정보
    """
    user = get_object_or_404(User, pk=user_id)
    context = {'profile_user': user, 'profile_type': 'base'}
    return render(request, 'common/profile/profile_base.html', context)

기본정보 탭은 사용자의 속성 값들을 보여주는 기능이 전부이다. 따라서 프로필 화면의 사용자를 User 모델의 인스턴스 형태로 템플릿에 전달한다.

class ProfileObjectListView(ListView):
    """
    프로필 목록 추상 클래스 뷰
    """
    paginate_by = 10

    class Meta:
        abstract = True

    def get_queryset(self):
        self.profile_user = get_object_or_404(User, pk=self.kwargs['user_id'])
        self.so = self.request.GET.get('so', 'recent')  # 정렬기준
        object_list = self.model.objects.filter(author=self.profile_user)
        # 정렬
        object_list = Answer.order_by_so(object_list, self.so)
        return object_list

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context.update({
            'profile_user': self.profile_user,
            'profile_type': self.profile_type,
            'so': self.so
        })
        return context


class ProfileQuestionListView(ProfileObjectListView):
    """
    작성한 질문 목록
    """
    model = Question
    template_name = 'common/profile/profile_question.html'
    profile_type = 'question'


class ProfileAnswerListView(ProfileObjectListView):
    """
    작성한 답변 목록
    """
    model = Answer
    template_name = 'common/profile/profile_answer.html'
    profile_type = 'answer'


class ProfileCommentListView(ProfileObjectListView):
    """
    작성한 댓글 목록
    """
    model = Comment
    template_name = 'common/profile/profile_comment.html'
    profile_type = 'comment'

게시(질문), 답변, 댓글 탭은 모두 사용자가 작성한 글들을 목록으로 보여준다. 세 뷰의 공통점과 차이점은 아래와 같다.

  • 공통점
    • 작성 목록을 10개씩 한 페이지에 보여준다.
    • 각 모델에 대해, 사용자가 작성한 것을 추출하고서 GET 방식으로 전달받은 기준(so)에 맞추어 정렬한다.
    • 현재 프로필 화면의 사용자, 현재 탭(profile_type), 정렬기준을 템플릿에 전달한다.
  • 차이점
    • 각 뷰마다 목록을 가져오는 모델(model)이 다르다.
    • 탭(profile_type)이 다르다.
    • 각 뷰가 나타나는 템플릿 파일(template_name)이 다르다.

공통점은 주로 뷰의 메서드를 통해 구현된다. 따라서 이를 한 데 모아 추상클래스 ProfileObjectListView를 정의한다. 각 탭의 뷰는 추상클래스를 상속받아 만든다. paginate_by 클래스 변수에 자연수를 할당하면 부모클래스 ListView는 자동으로 Paginator의 인스턴스와 현재 페이지에 해당하는 Page의 인스턴스를 생성하여 context에 포함시킨다.

기본적으로 ListView에서 전달하는 객체 목록 QuerySet의 이름은 object_list이다. (클래스 변수 context_object_name에 사용자 정의 이름을 할당할 수 있다.) 만약 페이징을 적용하지 않았다면, 템플릿 파일에서 object_list를 for문으로 순회하며 각 원소에 접근할 것이다.

Django Docs - Multiple object mixins를 보면, paginate_by 에 값이 할당되어 페이징이 적용된 경우엔 object_list 이외에 is_paginated, paginator, page_obj가 추가로 context에 포함되어 템플릿에 전달된다. 단위 개수만큼의 객체가 담긴 목록은 page_obj이므로, 페이징 적용 시에는 템플릿에서 object_list 대신 page_obj를 for문으로 순회해야 한다.

profile_type은 템플릿 common\profile\profile_tabs.html에서 active 상태인 탭을 식별하기 위해 전달하는 문자열이다.

프로필 화면에서의 정렬기준은 최신순과 추천순 만을 사용하며, 이는 질문 상세 화면에서 답변의 정렬기준과 같다. 그래서 user_id로 추출한 QuerySet을 Answer.order_by_so()를 호출하여 정렬한다.

from itertools import chain

class ProfileVoteListView(ProfileObjectListView):
    """
    작성한 댓글 목록
    """
    template_name = 'common/profile/profile_vote.html'
    profile_type = 'vote'

    def get_queryset(self):
        self.profile_user = get_object_or_404(User, pk=self.kwargs['user_id'])
        question_list = self.profile_user.voter_question.all()
        answer_list = self.profile_user.voter_answer.annotate(category=F('question__category__description'))

        _queryset = sorted(
            chain(question_list, answer_list),
            key=lambda obj: obj.create_date,
            reverse=True,
        )
        return _queryset

    def get_context_data(self, **kwargs):
        context = ListView.get_context_data(self, **kwargs)
        context.update({
            'profile_user': self.profile_user,
            'profile_type': self.profile_type,
            # 'so': self.so
        })
        return context

ListView의 get_queryset() 메서드는 목록에 포함할 모델의 인스턴스 모음을 반환한다. 반환 자료형은 QuerySet이며, 모델 인스턴스가 원소인 iterable이다. 추천 탭은 프로필 화면의 사용자가 추천한 질문과 답변 최신순으로 전부 나타낸다. 표시되는 질문과 답변의 속성들은 아래와 같다. 질문의 경우 제목을, 답변의 경우 내용을 표시한다.

get_queryset()의 작동 과정은 아래와 같다.

  1. 프로필 화면의 사용자 객체를 변수 self.profile_user에 저장
  2. self.profile_user가 추천한 질문을 추출
  3. self.profile_user가 추천한 답변을 추출, 카테고리 설명 속성(칼럼)을 생성
  4. 두 QuerySet을 하나로 합치고 최신순 정렬하여 반환

추천 탭 구현의 가장 큰 난관은 서로 다른 모델인 질문과 답변의 QuerySet을 하나로 합치기였다.

해결 과정은 아래와 같다. 처음엔 SQL의 UNION 연산에 대응하는 Django의 메서드인 union()을 이용해보았다.

1. values()로 SELECT하고 두 테이블의 필드 순서 맞춰서 UNION

   question_list = Question.objects.annotate(category_des=F('category__description'), num_voter=Count('voter')).\
       filter(voter=self.profile_user).values('subject', 'create_date', 'category_des', 'num_voter')
   answer_list = Answer.objects.annotate(category=F('question__category__description'), num_voter=Count('voter')).\
       filter(voter=self.profile_user).values('content', 'create_date', 'category', 'num_voter')
   
   _queryset = answer_list.union(question_list).order_by('-create_date')
  • self.profile_user.vote_question를 이용하면 Django ORM을 SQL로 번역할 때 pybo_question 테이블과 pybo_question_voter (연관)테이블이 INNER JOIN 되고 프로필 사용자와 같은 id의 행들만 추출되어 num_voter에 항상 1만 저장된다. 따라서 filterannotate 순서를 거꾸로했다.
  • 결정적으로, 반환값이 모델 인스턴스 모음이 아닌 필드들의 딕셔너리이므로 get_absolute_url() 등의 메서드나 다른 필드에 접근 불가하다.
  • 템플릿 common/profile/profile_vote.html에서 투표 수 변수 부분을 {{ object.num_voter }}로 작성해야 한다.

2. 그냥 QuerySet끼리 UNION

   question_list = self.profile_user.voter_question.all()
   answer_list = self.profile_user.voter_answer.annotate(category=F('question__category__description'))
   
   _queryset = answer_list.union(question_list).order_by('-create_date')
  • 1번 방법의 코드에서 values() 부분을 지워도 union() 메서드가 작동한다. question_list와 answer_list의 type이 각각 딕셔너리가 아닌 QuerySet이라는 차이점이 있다. 따라서 모델 클래스의 메서드나 모델에 연결된 다른 모델을 참조하는 것도 가능하다.
  • 문제는 union() 메서드의 반환 type이다. union()은 메서드를 호출한 왼쪽 객체의 모델로 통일한 QuerySet을 반환한다. 따라서 질문 객체들도 모두 Answer 클래스로 변환된다. 따라서 템플릿에서 질문 제목을 클릭하면 Answer 클래스의 get_absolute_url()이 호출되어 잘못된 경로로 리다이렉트 해버린다.

3. itertools 모듈의 chain 함수를 이용

  • Django ORM을 이용한 SQL 연산이 아닌, 단순히 파이썬 상에서 iterable을 합치는 연산이므로 서로 다른 종류의 모델 인스턴스끼리 이어 붙일 수 있다. 템플릿에서의 필드와 메서드 접근도 문제 없으며, 파이썬 내장 sorted 함수를 이용하여 create_date의 내림차순으로 정렬도 잘 된다.
     # 완성 코드에서 발췌
     question_list = self.profile_user.voter_question.all()
     answer_list = self.profile_user.voter_answer.annotate(category=F('question__category__description'))
     
     _queryset = sorted(
         chain(question_list, answer_list),
         key=lambda obj: obj.create_date,
         reverse=True,
     )

이처럼 세 번째 방법으로 추천 탭 뷰의 queryset을 완성할 수 있었다.

배운 점

  • Djangp generic view 사용법
  • Django MixIn의 개념
  • Django에서 여러 QuerySet을 합치는(union) 방법
  • itertools 모듈 익히기
profile
파이썬 백엔드 개발자

0개의 댓글