동아리

[멋쟁이사자처럼] 아이디어톤(2022.06.20~06.30) - (1) 백엔드1

땅콩콩 2022. 6. 29. 21:31

python과 Django MTV패턴을 사용하여 구현한 맛집 커뮤니티 로직이다.

해당 포스팅의 내용은 유저관련기능(회원가입, 로그인, 마이페이지, 회원정보수정, 아이디비번찾기 등)과 게시판(게시글, 댓글 crud, 페이지네이션 등)에 대한 설명이다.

 

1. 유저관련기능 (common앱)

 

유저확장을 위해 AbstractUser를 상속받아서 User라는 모델을 재정의했다.

그리고 UserCreationForm, UserChangeForm을 재정의해서 원하는 형태의 폼을 만들어줬다.

#common/models.py

from django.db import models
from django.contrib.auth.models import AbstractUser


class User(AbstractUser):
    nickname = models.CharField(max_length=10)
    region = models.CharField(max_length=20)
    email = models.EmailField(max_length=45, unique=True)
#common/forms.py

from django import forms
from django.contrib.auth import get_user_model
from django.contrib.auth.forms import UserCreationForm, UserChangeForm
from common.models import User


class UserForm(UserCreationForm):
    email = forms.EmailField(label="이메일")
    nickname = forms.CharField(max_length=10)
    region = forms.CharField(max_length=20)

    class Meta:
        model = User
        fields = ("username", "password1", "password2", "email", "nickname", "region")


class UserUpdateForm(UserChangeForm):
    email = forms.EmailField(label="이메일")
    nickname = forms.CharField(max_length=10)
    region = forms.CharField(max_length=10)

    class Meta:
        model = get_user_model()
        fields = ("username", "email", "nickname", "region")

 

 

여기는 유저관련기능들의 view함수를 url과 매핑해주는 부분이다.

마이페이지나 내가쓴글 페이지처럼 pk가 필요한 부분은 url에도 pk를 포함해줬다.

#common/urls.py

from django.urls import path
from django.contrib.auth import views as auth_views
from . import views

app_name = 'common'

urlpatterns = [
    path('login/', auth_views.LoginView.as_view(template_name='common/login.html'), name='login'),
    path('logout/', auth_views.LogoutView.as_view(), name='logout'),
    path('signup/', views.signup, name='signup'),
    path('mypage/<int:pk>/', views.mypage, name='mypage'),
    path('update/', views.update, name='update'),
    path('mypost/<int:pk>/', views.mypost, name='mypost'),
    path('forget_id/', views.forget_id, name='forget_id'),
    path('password_reset/',
         views.UserPasswordResetView.as_view(template_name='common/password_reset_form.html'), name="password_reset"),
    path('password_reset_done/',
         views.UserPasswordResetDoneView.as_view(template_name='common/password_reset_done.html'),
         name="password_reset_done"),
    path('password_reset_complete/',
         views.UserPasswordResetCompleteView.as_view(template_name='common/password_reset_complete.html'),
         name="password_reset_complete"),
]

다음은 유저관련 view들을 소개할텐데 먼저 import해온 부분이다 좀 많다...

#common/views.py

from django.contrib.auth import authenticate, login, get_user_model
from django.contrib.auth.decorators import login_required
from django.contrib.auth.views import PasswordResetView, PasswordResetDoneView, PasswordResetConfirmView, PasswordResetCompleteView
from django.shortcuts import render, redirect, get_object_or_404, resolve_url
from django.template.loader import render_to_string
from django.contrib.auth.forms import PasswordResetForm, SetPasswordForm
from django.urls import reverse_lazy
from common.forms import UserForm, UserUpdateForm
from community.models import Post
from django.core.paginator import Paginator
from django.conf import settings
from django.contrib import messages
from common.models import User
from django.core.mail import EmailMessage
try:
    from django.utils import simplejson as json
except ImportError:
    import json

회원가입 view이다. 그냥 get 요청으로 회원가입페이지로 들어왔을 경우에는 회원가입 양식만 보여주고, 이후에 제출버튼을 통해서 post 요청으로 들어왔을 경우에는 유효성 검사를 진행하고, 회원가입을 진행한다. 사용자 인증 후에는 로그인이 자동으로 되고, 게시판 홈으로 redirect되도록 구현했다.

def signup(request):
    if request.method == "POST":
        form = UserForm(request.POST)
        if form.is_valid():
            form.save()
            username = form.cleaned_data.get('username')
            raw_password = form.cleaned_data.get('password1')
            user = authenticate(username=username, password=raw_password)  # 사용자 인증
            login(request, user)  # 로그인
            return redirect('index')
    else:
        form = UserForm()
    return render(request, 'common/signup.html', {'form': form})

회원정보 수정 view이다. 회원가입과 구조는 유사하고, login_required 데코레이터를 사용했다.

사실 회원정보 수정은 마이페이지에서만 접근할 수 있는데, 마이페이지는 로그인상태에서만 보이도록 되어있으므로 정상적인 접근이라면 필요가 없을것이다. 하지만 url을 임의로 수정하는식으로 접근이 될수도 있기때문에 그런경우를 생각해서 데코레이터를 포함했다.

@login_required
def update(request):
    if request.method == "POST":
        form = UserUpdateForm(request.POST, instance=request.user)
        if form.is_valid():
            form.save()
            return render(request, 'common/mypage.html', {'form': form})
    else:
        form = UserUpdateForm(instance=request.user)
    return render(request, 'common/user_update.html', {'form': form})

마이페이지와 내가쓴글페이지 view이다. 

내가쓴글은 게시판형태로 구현했기때문에 paginator를 포함한다.

그리고 pagination을 위해서 sub필터를 따로 만들어서 사용했다.

def mypage(request, pk):
    User = get_user_model()
    user = get_object_or_404(User, pk=pk)
    context = {'user': user}
    return render(request, 'common/mypage.html', context)


def mypost(request, pk):
    post_list = Post.objects.order_by('-create_date')
    sort = request.GET.get('sort', '')
    page = request.GET.get('page', 1)
    User = get_user_model()
    user = get_object_or_404(User, pk=pk)
    paginator = Paginator(post_list, 10)
    page_obj = paginator.get_page(page)
    context = {'user': user, 'post_list': page_obj, 'page': page, 'sort': sort}
    return render(request, 'common/mypost.html', context)
#common/community_filter.py

from django import template

register = template.Library()


@register.filter
def sub(value, arg):
    return value - arg

다음은 아이디찾기 view이다. 아이디를 분실했을경우 유저가 회원가입시 기재한 이메일로 아이디정보를 알려주는 기능이다. 유저로부터 이메일을 입력받고, User에서 그 이메일을 가진 유저가 존재하는지 조회한다. 그래서 존재한다면 이메일템플릿으로 유저의 정보를 넘기고 이것을 발송하고, 존재하지않는다면 그런 유저 없다는 메시지 알림을 띄운다.

def forget_id(request):
    context = {}
    if request.method == 'POST':
        email = request.POST.get('email')
        try:
            user = User.objects.get(email=email)
            if user is not None:
                template = render_to_string('common/id_email_template.html', {'nickname': user.nickname, 'id': user.username})
                method_email = EmailMessage(
                    '[먹키네이터] 회원님의 아이디를 알려드립니다.',
                    template,
                    settings.EMAIL_HOST_USER,
                    [email],
                )
                method_email.send(fail_silently=False)
                return render(request, 'common/id_sent.html', context)
        except:
            messages.info(request, "There is no username along with the email!")
    return render(request, 'common/forget_id.html', context)

다음은 진짜 애먹었던 비밀번호 초기화 view이다. 비밀번호 초기화와 관련해서 장고가 제공하는 클래스들이 이미 존재하지만, 그걸 그대로 가져다쓰면 장고 기본 템플릿을 사용하게 되는 관계로 ㅠㅠ 전부 상속해서 템플릿이나 기타 필요한부분들을 모두 재정의해줬다.

class UserPasswordResetView(PasswordResetView):
    template_name = 'common/password_reset_form.html'
    success_url = reverse_lazy('common:password_reset_done')
    form_class = PasswordResetForm
    email_template_name = "common/password_reset_email.html"

    def form_valid(self, form):
        if User.objects.filter(email=self.request.POST.get("email")).exists():
            print("if")
            return super().form_valid(form)
        else:
            print("else")
            return render(self.request, 'common/password_reset_done_fail.html')


class UserPasswordResetDoneView(PasswordResetDoneView):
    template_name = 'common/password_reset_done.html'


class UserPasswordResetConfirmView(PasswordResetConfirmView):
    form_class = SetPasswordForm
    success_url = reverse_lazy('common:password_reset_complete')
    template_name = 'common/password_reset_confirm.html'

    def form_valid(self, form):
        return super().form_valid(form)


class UserPasswordResetCompleteView(PasswordResetCompleteView):
    template_name = 'common/password_reset_complete.html'

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['login_url'] = resolve_url(settings.LOGIN_URL)
        return context

아이디찾기, 비밀번호 초기화 이 부분을 구현하기 위해서 메일등록하고 smtp설정하면서 진짜진짜 웃긴 (사실 안웃긴) 일을 겪었는데,,, 우리가 발신할 이메일로 settings.py에 적어둔 계정을 깃헙에 퍼블릭으로 올리면서 계정을 해킹당한거다,,ㅋㅋㅋㅋ

우리 계정으로 프랑스 야후 메일 2천통 보낸사람 누구냐고....

 

그래서 결국 그 계정은 네이버에 의해 정지당하고 다른계정을 파서 이번에는 secret.py라는 로컬파일에 정보를 넣고, setting.py에서 import해서 전역변수로 사용했다. 그리고 secret.py는 gitignore에 올리고! 암튼 앞으로는 좀 보안에 대해서도 경각심을 가지고 작업하는 감자가 되어야겠다....ㅠ

일단 이런느낌으로 했다..

2. 게시판 기능 (comminity앱)

 

게시판기능의 메인이 되는 post, comment모델, photo모델이다. photo모델과 comment모델은 각각의 속성으로 post를 가지게 해서 특정 글에 대한 photo, comment를 구별할 수 있게 구현했다.

또, post모델 내부에서는 update_counter함수를 통해서 글이 조회될때마다 조회수가 +1되도록 했다.

 

그리고 바로 아래있는건 글과 댓글을 입력받기 위한 form인데, 장고의 forms.ModelForm을 상속받아서 구현했다. 글에는 제목과 글내용, 지역이 포함되고, 댓글에는 내용만 포함된다.

#community/models.py

from django.db import models
from common.models import User


class Post(models.Model):
    author = models.ForeignKey(User, on_delete=models.CASCADE)
    subject = models.CharField(max_length=200)
    content = models.TextField()
    create_date = models.DateTimeField()
    modify_date = models.DateTimeField(null=True, blank=True)
    hit = models.PositiveIntegerField(default=0)
    category_Choices = (('서울','서울'), ('수원','수원'), ('용인','용인'),
                        ('부산','부산'), ('김해','김해'))
    region = models.CharField(max_length=10, choices=category_Choices)

    def __str__(self):
        return self.subject

    @property
    def update_counter(self):
        self.hit = self.hit + 1
        self.save()
        return ""


class Photo(models.Model):
    post = models.ForeignKey(Post, on_delete=models.CASCADE, null=True)
    image = models.ImageField(upload_to='images/', blank=True, null=True)



class Comment(models.Model):
    author = models.ForeignKey(User, on_delete=models.CASCADE)
    post = models.ForeignKey(Post, on_delete=models.CASCADE)
    content = models.TextField()
    create_date = models.DateTimeField()
    modify_date = models.DateTimeField(null=True, blank=True)

 

#comminity/forms.py

from django import forms
from community.models import Post, Comment


class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        fields = ['subject', 'content', 'region']
        labels = {
            'subject': '제목',
            'content': '내용',
            'region': '지역',
        }


class CommentForm(forms.ModelForm):
    class Meta:
        model = Comment
        fields = ['content']
        labels = {
            'content': '내용',
        }

 

 

 

다음은 urls.py인데, 커뮤니티앱같은 경우 내가 뷰파일을 분리시켜놔서, url매핑을 볼때 그부분을 참고해서 봐야한다!

from django.urls import path

from .views import base_views, post_views, comment_views

app_name = 'community'

urlpatterns = [
    # base_views.py
    path('',
         base_views.index, name='index'),
    path('<int:post_id>/',
         base_views.detail, name='detail'),

    # post_views.py
    path('post/create/',
         post_views.post_create, name='post_create'),
    path('post/modify/<int:post_id>/',
         post_views.post_modify, name='post_modify'),
    path('post/delete/<int:post_id>',
         post_views.post_delete, name='post_delete'),

    # comment_views.py
    path('comment/create/<int:post_id>/',
         comment_views.comment_create, name='comment_create'),
    path('comment/modify/<int:comment_id>',
         comment_views.comment_modify, name='comment_modify'),
    path('comment/delete/<int:comment_id>',
         comment_views.comment_delete, name='comment_delete'),
]

이런식으로 분리시켜놨다

다음은 게시판의 메인화면이 되는 index view이다.

html에서 페이지를 조회할때 조회수기준 인기글 두개를 상단에 띄우기 위해 인기글인 hit_list와 일반글인 post_list를 분리했다.

인기글이 아닌 일반글의 경우, 셀렉트박스로 선택된 인자가 있으면 그 지역의 글들을 볼수있게/ 아닐경우 최근 등록순으로 정렬했다.

또, 검색기능을 추가하여 제목/내용/글쓴이로 글을 검색할수있도록 했고, 중복된 결과는 제거되도록 처리했다.

 

#community/views/base_views.py

def index(request):
    # 인기글
    hit_list = Post.objects.all().order_by('-hit')[:2]  # 조회수순으로 2개
    sort = request.GET.get('sort', '')
    page = request.GET.get('page', 1)  # 페이지
    kw = request.GET.get('kw', '')  # 검색어
    # 지역 셀렉트박스
    if sort == 'seoul':
        post_list = Post.objects.filter(region='서울').order_by('-create_date')  # 복수를 가져올 수 있음
    elif sort == 'suwon':
        post_list = Post.objects.filter(region='수원').order_by('-create_date')
    elif sort == 'busan':
        post_list = Post.objects.filter(region='부산').order_by('-create_date')
    elif sort == 'yongin':
        post_list = Post.objects.filter(region='용인').order_by('-create_date')
    elif sort == 'gimhae':
        post_list = Post.objects.filter(region='김해').order_by('-create_date')
    else:
        post_list = Post.objects.order_by('-create_date')
    #검색 기능
    if kw:
        post_list = post_list.filter(
            Q(subject__icontains=kw) |  # 제목 검색
            Q(content__icontains=kw) |  # 내용 검색
            Q(author__username__icontains=kw)  # 글쓴이 검색
        ).distinct()
    paginator = Paginator(post_list, 10)
    page_obj = paginator.get_page(page)
    context = {'post_list': page_obj, 'page': page, 'sort': sort, 'kw': kw, 'hit_list': hit_list}
    return render(request, 'community/post_list.html', context)

셀렉트박스나 검색기능의 경우 js로 프론트에서도 구현했다.

그리고 글의 상세화면을 볼수있게 해주는 detail view이다. 참고로 get_object_or_404는 객체를 받아오고, 만약 객체가 존재하지않으면 404에러를 뜨게한다.  check는 이후 소개될 댓글 수정부분 구현을 위해 추가된 인자이다.

#community/views/base_views.py

def detail(request, post_id):
    post = get_object_or_404(Post, pk=post_id)
    context = {'post': post, 'check': False}
    return render(request, 'community/post_detail.html', context)

글 작성을 위한 post_create view다.

@login_required를 달아 로그인이 안된 상태에서 글작성 버튼을 누르면 로그인페이지로 자동으로 넘어가게 구현했다. 단순히 글작성버튼만 눌러서 들어온 get요청일경우 글 작성 양식을 보여주고, 저장하기버튼을 눌러 들어온 post요청일경우 폼을 저장하고 파일로 들어온 이미지도 저장한 뒤에 게시판 메인으로 리디렉션한다.

#community/views/post_views.py

@login_required(login_url='common:login')
def post_create(request):
    if request.method == 'POST':
        form = PostForm(request.POST)
        if form.is_valid():
            post = form.save(commit=False)
            post.author = request.user
            post.create_date = timezone.now()
            post.save()
            for img in request.FILES.getlist('imgs'):
                photo = Photo()
                photo.post = post
                photo.image = img
                photo.save()
            return redirect('community:index')
    else:
        form = PostForm()
    context = {'form': form}
    return render(request, 'community/post_form.html', context)

글의 수정을 위한 post_modify view이다. 사실 내가 프론트에서 템플릿문법으로 작업을 해뒀기때문에 로그인상태가 아닐경우 수정/삭제버튼은 보이지않는다. 하지만 만일의 경우를 대비해서 request로 들어온 user가 post의 author인지 확인하는 코드를 넣었고, post요청일경우 폼을 저장+이미지는 초기화 후 다시 저장하는 구조로 구현했다.

#community/views/post_views.py

@login_required(login_url='common:login')
def post_modify(request, post_id):
    post = get_object_or_404(Post, pk=post_id)
    if request.user != post.author:
        messages.error(request, '수정권한이 없습니다')
        return redirect('community:detail', post_id=post.id)
    if request.method == "POST":
        form = PostForm(request.POST, instance=post)
        if form.is_valid():
            post = form.save(commit=False)
            post.modify_date = timezone.now()
            post.save()
            #해당 post의 photo객체 다 삭제하기
            for photo in Photo.objects.all():
                if photo.post == post:
                    photo.delete()
            #새롭게 받은 이미지파일들로 photo객체 다시 만들어 저장하기
            for img in request.FILES.getlist('imgs'):
                photo = Photo()
                photo.post = post
                photo.image = img
                photo.save()
            return redirect('community:detail', post_id=post.id)
    else:
        form = PostForm(instance=post)
    context = {'form': form, 'check': True}
    return render(request, 'community/post_form.html', context)

글의 삭제를 위한 post_delete view이다.  역시 마찬가지로 만일의 경우를 생각해서 request.user와 post.author가 같은지 검증하는 코드를 포함했고, 같을 경우 post객체를 삭제한다. 그런데 이때 실수로 클릭해서 삭제되는것에 대비하기 위해 프론트에서 class에 delete값을 주고 js로 다시 한번 물어보는 부분을 구현했다.

#community/views/post_views.py

@login_required(login_url='common:login')
def post_delete(request, post_id):
    post = get_object_or_404(Post, pk=post_id)
    if request.user != post.author:
        messages.error(request, '삭제권한이 없습니다')
        return redirect('community:detail', post_id=post.id)
    post.delete()
    return redirect('community:index')

다음은 댓글 작성을 위한 comment_create view이다.  구현구조는 글작성 뷰와 비슷하다.

다만 어떤글에 달린 댓글인지를 구별하기 위해 pk로 post_id를 가지도록 구현했다. 그리고 중간에 form.save에서 commit=False를 해주는 이유는 임시저장으로, 댓글 작성일자를 현재시각으로 등록해주기 위해서이다. 

#community/views/comment_views.py

@login_required(login_url='common:login')
def comment_create(request, post_id):
    post = get_object_or_404(Post, pk=post_id)
    if request.method == "POST":
        form = CommentForm(request.POST)
        if form.is_valid():
            comment = form.save(commit=False)
            comment.author = request.user
            comment.create_date = timezone.now()
            comment.post = post
            comment.save()
            return redirect('community:detail', post_id=post.id)
    else:
        return HttpResponseNotAllowed('Only POST is possible')
    context = {'post': post, 'form':form}
    return render(request, 'community/post_detail.html', context)

그리고 구현하면서 애를 좀 많이먹은 comment_modify view이다.... 그냥 글수정하는것처럼 새로운 페이지로 넘겨서 수정할 내용을 받는다면 구현이 매우 간단했겠지만 팀에서 원하는 방향이 한 페이지내에서 댓글이 수정되도록 하는것이었기때문에 그렇게 구현을 했다.

일단 기본적인 방향은 check = False일 경우 일반 댓글들이 쭉 보이게/ 댓글수정 버튼을 눌러(get요청) check=True일 경우 댓글을 수정할수있는 폼이 나오게 구현했다. (장고의 템플릿문법 사용)

그리고 request 받아오는거 생각하면서 조금씩 수정해서 완성됐다..

분명 더 효과적이고 쉬운 방법이 있을것같은데 내가 찾기로는 쉽지 않아서 이부분은 좀 더 공부해보고싶다.

#community/views/comment_views.py

@login_required(login_url='common:login')
def comment_modify(request, comment_id):
    comment = get_object_or_404(Comment, pk=comment_id)
    post = get_object_or_404(Post, pk=comment.post.id)
    check = False
    if request.user != comment.author:
        messages.error(request, '수정권한이 없습니다')
        return redirect('community:detail', comment_id=comment.id)
    if request.method == "POST":
        form = CommentForm(request.POST, instance=comment)
        if form.is_valid():
            comment = form.save(commit=False)
            comment.modify_date = timezone.now()
            comment.save()
            return redirect('community:detail', post_id=comment.post.id)
    else:
        check = True
        form = CommentForm(instance=comment)
    context = {'comment': comment, 'form': form, 'check': check, 'post': post, 'comment_id': comment_id}
    return render(request, 'community/post_detail.html', context)
#글 상세페이지의 댓글부분 프론트 코드

{% for comment in post.comment_set.all %}
    <a id="comment_{{ comment.id }}"></a>
    <div class="card my-3">
        <div class="card-body">
            {% if check %}
                {% if comment.id == comment_id %}
                    <form method="post">
                        {% csrf_token %}
                        {% include "form_errors.html" %}
                        <div class="card-text">
                            <label for="content" class="form-label">댓글</label>
                            <textarea class="form-control text-dark" name="content" id="content"
                                rows="3">{{ form.content.value }}</textarea>
                        </div>
                        <br>
                        <button type="submit" class="btn btn-primary">저장하기</button>
                    </form>
                {% else %}
                    <div class="card-text text-dark">{{ comment.content }}</div>
                {% endif %}
            {% else %}
                    <div class="card-text text-dark">{{ comment.content }}</div>
                    <div class="d-flex justify-content-end">
                        {% if comment.modify_date %}
                        <div class="badge bg-light text-dark p-2 text-start mx-3">
                            <div class="mb-2 text-dark">modified at</div>
                            <div class="mb-2 text-dark">{{ comment.modify_date }}</div>
                        </div>
                        {% endif %}
                        <div class="badge bg-light text-dark p-2 text-start">
                            <div class="mb-2 text-dark">{{ comment.author.nickname }}</div>
                            <div class="mb-2 text-dark">{{ comment.create_date }}</div>
                        </div>
                    </div>
                    <div class="my-3">
                        {% if request.user == comment.author %}
                        <a href="{% url 'community:comment_modify' comment.id  %}"
                           class="btn btn-sm btn-outline-secondary">수정</a>
                        <a href="javascript:void(0)" class="delete btn btn-sm btn-outline-secondary "
                           data-uri="{% url 'community:comment_delete' comment.id  %}">삭제</a>
                        {% endif %}
                    </div>
            {% endif %}
        </div>
    </div>
{% endfor %}

댓글 삭제를 위한 comment_delete view이다. 포스트 삭제하는 뷰와 비슷하다.

#community/views/comment_views.py

@login_required(login_url='common:login')
def comment_delete(request, comment_id):
    comment = get_object_or_404(Comment, pk=comment_id)
    if request.user != comment.author:
        messages.error(request, '삭제권한이 없습니다')
    else:
        comment.delete()
    return redirect('community:detail', post_id=comment.post.id)

상세 코드가 궁금하다면 깃헙에서 볼 수 있당!

https://github.com/jia5232/likelion_project_backend

 

GitHub - jia5232/likelion_project_backend

Contribute to jia5232/likelion_project_backend development by creating an account on GitHub.

github.com