본문 바로가기

명사 美 비격식 (무리 중에서) 아주 뛰어난[눈에 띄는] 사람[것]

Personal/Book

Do it! 장고+부트스트랩 파이썬 웹 개발의 정석 - 셋째마당 테스트하며 블로그 핵심 기능 구현하기

테스트 주도 개발 TDD

바로 개발부터 하는것이 아니라 개발하려는항목에 대한 점검 사항을 테스트코드로 만들고 그 테스트를 통과시키는 방식으로 개발을 진행하는 방법

프로그램이 복잡해질수록 추가한 기능사이에 상호 연관성이 점점 늘어나기 때문에 점검 사항이늘어나 문제가 발생했을때 너무 많은 요소들과 얽혀 있다면 쉽게 손을 댈 수가 없게 되기 때문이다. 

실제 데이터베이스는 건들이지 않고 가상의 더이터베이스를 만들어 테스트한다. 

테스트를 많이 할수록 실수를 방지할수있을 뿐만아니라 견고한 프로그램을 만들 수있다. 

테스트 코드를 어디까지 만들어야 하는지는 개발자의 판단이 필요한 영역으로 구현하는 모든 기능에 테스트하는 코드를 만들면 좋겠지만 나중에 문제가 될 수있겠다라는 생각이 드는 코드를 작성해두는것이 여러모로 편하다. 

 

blog/tests.py에 TestCase 클래스를 상속받아 클래스를 정의한다. 

python manage.py test

* 특정 앱만 테스트하려면 python manage.py test blog 와같이, 특정 클래스만 테스트하려면 python manage.py test blog.tests.PostTest, 특정 메서드만 테스트하려면 python manage.py test blog.tests.PostTest.test_example.

from django.test import TestCase

class PostTest(TestCase):
    def test_example(self):
        self.assertEqual(1 + 1, 2)
def test_post_list(self):
    response = self.client.get('/blog/')
    self.assertEqual(response.status_code, 200)

 

HTML로 나타나는 페이지 요소를 쉽게 다룰 수 있는 도구 설치

pip install beautifulsoup4

 

 

 

모듈화 공통부분을 base.html로 빼 {% block content %}{% endblock %}로 정의하면 자식 템플릿에서의 정보로 채워진다.

이와 반대로는 {% include "blog/navbar.html" %}가 있다.

<!-- templates/base.html -->

<!DOCTYPE html>
<html>
<body>

{% include "blog/navbar.html" %}

<main>
    {% block content %}
    {% endblock %}
</main>

</body>
</html>
<!-- templates/blog/navbar.html -->

<nav>
    <a href="/">홈</a>
    <a href="/blog/">블로그</a>
    <a href="/about/">소개</a>
</nav>
<!-- blog/post_list.html -->

{% extends "base.html" %}

{% block content %}
    <h2>글 목록</h2>

    {% for post in posts %}
        <h3>{{ post.title }}</h3>
        <p>{{ post.content|truncatewords:20 }}</p>
    {% endfor %}
{% endblock %}

 

 

foreignkey로 author 필드 user를 import해 구현

on_delete=models.CASCADE user 삭제시 사용자가 작성한 post도 함께 삭제.

on_delete=SET_NULL user 삭제시 사용자가 작성한 post에 작성자명을 NULL, 빈칸으로 둠. 이때 User, null=True, on_delete~ null True를 설정해야한다

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


class Post(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()
    author = models.ForeignKey(
        User,
        on_delete=models.CASCADE,
        related_name="posts"
    )
    created_at = models.DateTimeField(auto_now_add=True)
post = Post.objects.create(
    title="Django 공부",
    content="ForeignKey 공부 중",
    author=request.user
)

 

커스텀으로 작성가능. 실무에서는 참조보다는 settings.AUTH_USER_MODEL 직접 작성한다.

기본적으로 별도로 설정하지 않았다면 AUTH_USER_MODEL = "auth.User" Django 기본 User 모델. 추후 accounts.User라는 커스텀 User를 사용하게 된다면 간단히 설정값을 바꾸기만 해도된다. 

from django.conf import settings

class Post(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()

    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="posts"
    )

 

반영

python manage.py makemigrations
python manage.py migrate

 

 

 

 

카테고리 기능 구현하기

blog/models.py 에 Category 모델 추가하기

사람이 보는 CharField, url에 쓰기좋은 SlugField 생성, 

Django가 기본적으로 모델명 Category를 보고 복수형을 자동 생성할 때 Categorys처럼 만들 수 있다  verbose_name, verbose_name_plural 설정해 title 설정. verbose_name는 단수형, verbose_name_plural는 복수형. 일반적인 영어단어는 Django가 어느정도 자동으로 처리하기 때문에 매번 직접 지정할 필요는 없다. 

model.ForeignKey 시 blank=True 설정을 넣으면 카테고리를 빈칸으로 지정할 수 있게된다. 

from django.db import models
from django.conf import settings


class Category(models.Model):
# name은 사람이 보는 이름, slug는 URL에 쓰기 좋은 식별자
    name = models.CharField(
        max_length=50,
        unique=True,
    )
    slug = models.SlugField(
        max_length=50,
        unique=True,
        allow_unicode=True,
    )
    
    #class Meta:
    #    verbose_name = "카테고리"
    #    verbose_name_plural = "카테고리들"
        
    class Meta:
    verbose_name = "Category"
    verbose_name_plural = "Categories"

    def __str__(self):
        return self.name


class Post(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()

    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="posts"
    )

    category = models.ForeignKey(
        Category,
        on_delete=models.PROTECT,
        related_name="posts"
    )

    created_at = models.DateTimeField(auto_now_add=True)

 

 

관리자페이지에도 등록

from django.contrib import admin
from .models import Category


@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
    list_display = ("id", "name")


반영
python manage.py makemigrations
python manage.py migrate

 

 

category를 import해 PostList 보여주기

함수형 뷰에서는 context를 만들어서 render()하고, 클래스 뷰에선 get_context_data() 를 사용한다. 

from django.shortcuts import render
from .models import Category


def post_list(request):
    categories = Category.objects.all()

    return render(
        request,
        "posts/post_list.html",
        {"categories": categories}
    )
from django.views.generic import ListView

class PostListView(ListView):
    model = Post
    template_name = "post_list.html"

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)

        context["categories"] = Category.objects.all()

        return context

 

 

 

화면에서 보여주기

{% for category in categories %}
    <a href="#">{{ category.name }}</a>
{% endfor %}

 

 

 

settings.py에서 context_processors를 설정해 base.html import 없이 각 페이지에서 카테고리를 보여줄 수 있다. 

TEMPLATES = [
    {
        ...
        "OPTIONS": {
            "context_processors": [
                ...
                "posts.context_processors.categories",
            ],
        },
    },
]

 

 

 

미분류 카테고리를 제어하기

def uncategorized_posts(request):
    posts = Post.objects.filter(category__isnull=True)

    context = {
        "posts": posts,
    }

    return render(request, "posts/uncategorized.html", context)
from django.views.generic import ListView

class UncategorizedPostListView(ListView):
    model = Post
    template_name = "posts/uncategorized.html"
    context_object_name = "posts"

    def get_queryset(self):
        return Post.objects.filter(category__isnull=True)

 

 

장고셀 마리고 셸 플러스 사용해보기, 파이썬 코드에 따라 글자 색상이 다르게 표현되 가독성이 높아지고 for문을 사용할때 들어쓰기도 자동으로 맞춰진다. 또 post나 category 모델을 임포트하지않고도 바로 사용해 쓸 수 있다. 

exit()

pip install django_extensions

pip install ipython

settings.py의 INSTALLED_APPS에 'django_extensions'추가. 

python manage.py shell_plus 

 

 

Tag 모델 만들기, 다대다관계, ManyToManyField, models.py 에 작성하기

class Tag(models.Model):
    name = models.CharField(
        max_length=50,
        unique=True,
    )

    def __str__(self):
        return self.name
class Post(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()

    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="posts",
    )

    category = models.ForeignKey(
        Category,
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="posts",
    )

    tags = models.ManyToManyField(
        Tag,
        blank=True,
        related_name="posts",
    )

    created_at = models.DateTimeField(auto_now_add=True)

 

 

 

기본방식의 admin.site.register(Category)보다 데코레이션 문법으로 깔끔하게 작성할 수 있다. 

@admin.register(Category)

class CategoryAdmin(admin.ModelAdmin):
    list_display = ("id", "name")

admin.site.register(Category, CategoryAdmin)
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
    list_display = ("id", "name")

 

 

 

admin.py 에적용후 html페이지에서 보여주기

from django.contrib import admin
from .models import Category, Post, Tag


@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
    list_display = ("id", "name")


@admin.register(Tag)
class TagAdmin(admin.ModelAdmin):
    list_display = ("id", "name")
    search_fields = ("name",)


@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
    list_display = ("id", "title", "author", "category", "created_at")
    search_fields = ("title", "content")
{% for post in posts %}
    <article>
        <h2>{{ post.title }}</h2>

        <p>{{ post.content }}</p>

        <div>
            {% for tag in post.tags.all %}
                <span>#{{ tag.name }}</span>
            {% empty %}
                <span>태그 없음</span>
            {% endfor %}
        </div>
    </article>
{% endfor %}
<h1>{{ post.title }}</h1>

<p>{{ post.content }}</p>

<div>
    {% for tag in post.tags.all %}
        <span>#{{ tag.name }}</span>
    {% empty %}
        <span>태그 없음</span>
    {% endfor %}
</div>

 

 

반영
python manage.py makemigrations
python manage.py migrate

 

 

 

 

포스트 작성 페이지 작성 전 test.py

from django.test import TestCase
from django.contrib.auth import get_user_model

from .models import Post, Category, Tag


User = get_user_model()


class PostModelTest(TestCase):

    def setUp(self):
        self.user = User.objects.create_user(
            username="testuser",
            password="test1234",
        )

        self.category = Category.objects.create(
            name="개발"
        )

        self.tag1 = Tag.objects.create(
            name="Django"
        )

        self.tag2 = Tag.objects.create(
            name="Python"
        )

        self.post = Post.objects.create(
            title="Django 테스트",
            content="테스트 내용입니다.",
            author=self.user,
            category=self.category,
        )

        self.post.tags.add(self.tag1, self.tag2)

    def test_post_author(self):
        self.assertEqual(self.post.author, self.user)

    def test_post_category(self):
        self.assertEqual(self.post.category, self.category)

    def test_post_tags(self):
        self.assertEqual(self.post.tags.count(), 2)
        self.assertIn(self.tag1, self.post.tags.all())
        self.assertIn(self.tag2, self.post.tags.all())
        
    def test_uncategorized_post(self):
        post = Post.objects.create(
            title="미분류 게시글",
            content="내용",
            author=self.user,
        )

        posts = Post.objects.filter(category__isnull=True)

        self.assertIn(post, posts)

 

 

forms.py 에 author을 제외하고 views.py 뷰 추가, urls.py 에 정의, 

csrf_token CSRF 공격으로 부터 보호.

<h1>포스트 작성</h1>

<form method="post">
    {% csrf_token %}

    {{ form.as_p }}

    <button type="submit">작성</button>
</form>
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from django.views.generic import CreateView

from .models import Post
from .forms import PostForm


class PostCreateView(LoginRequiredMixin, CreateView):
    model = Post
    form_class = PostForm
    template_name = "posts/post_form.html"
    success_url = reverse_lazy("post-list")

    def form_valid(self, form):
        form.instance.author = self.request.user
        return super().form_valid(form)
from django.urls import path
from .views import PostCreateView

urlpatterns = [
    path("posts/new/", PostCreateView.as_view(), name="post-create"),
]

 

 

 

로그인한 사용자로 author자동입려고디게하기

forms.py에 author 필드를 넣지않는다, views.py에서 user값으로 정의한다. 

LoginRequiredMixin는 로그인여부를 체크한다.  

Django의 Class-Based Generic View가 제공하는 form_valid() 메서드를 오버라이드해 사용할 수 있다. 

외 Django CBV에서 get_queryset(), get_context_data(), form_valid(), form_invalid()를 사용할 수 있다. 

class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        fields = ["title", "content", "category", "tags"]
class PostCreateView(LoginRequiredMixin, CreateView):
    model = Post
    form_class = PostForm
    template_name = "posts/post_form.html"

    def form_valid(self, form):
        form.instance.author = self.request.user
        return super().form_valid(form)

 

 

 

 

 

수정페이지 만들기

페이지는 추가페이지와 동일해 form.html을 그대로 재사용이 가능하다. 

model=Post, from_class=PostForm이 updateview가 기존 객체를 찾아 PostForm에 Djangor가 내부적으로 값을 채워준다. 

<form method="post">
    {% csrf_token %}
    {{ form.as_p }}
    <button type="submit">저장</button>
</form>
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import UpdateView

class PostUpdateView(LoginRequiredMixin, UpdateView):
    model = Post
    form_class = PostForm
    template_name = "posts/post_form.html"

    def get_queryset(self):
        return Post.objects.filter(author=self.request.user)
path(
    "posts/<int:pk>/edit/",
    PostUpdateView.as_view(),
    name="post-update",
)

 

 

dispatch() 방문자가 웹 사이트 서버에 get, post 요청했는지 판단하는 기능

CBV(Class-Based View)에서 요청을 실제 HTTP 메서드 처리 함수로 분기해주는 메서드

이 요청을 어떤 메서드가 처리할지 결정하는 중간 관문, 사용자가 요청을 보내면 View에서 View.dispatch()로 적절한 메서드를 호출한다.

직접 오버라이딩 하는 경우 return request, *args, **kwargs 가 핵심.

인증, 권한체크에도 자주 사용되 권한이 없는 사용자가 postupdate를 사용하려고 하면 dispatch를 def로 활용해 제어할 수 있다. 

from django.views import View
from django.http import HttpResponse

class MyView(View):

    def get(self, request):
        return HttpResponse("GET 요청")

    def post(self, request):
        return HttpResponse("POST 요청")
class MyView(View):

    def dispatch(self, request, *args, **kwargs):
        print("요청이 들어왔습니다.")

        return super().dispatch(request, *args, **kwargs)

    def get(self, request):
        return HttpResponse("GET")

    def post(self, request):
        return HttpResponse("POST")
class AdminAgentView(LoginRequiredMixin, View):

    def dispatch(self, request, *args, **kwargs):

        if not request.user.is_staff:
            raise PermissionDenied

        if not request.user.has_perm("agent.view_agent"):
            raise PermissionDenied

        return super().dispatch(request, *args, **kwargs)

 

 

 

form_valid() 

createview, updateview의 form_valid() 함수는 폼 안에 들어온 값을 바탕으로 모델 인스턴스 를 만들어 데이터베이스에 저장한 다음 그 경로로 리다이렉트한다.

createview의 form_valid 함수를 오버라이딩해서 사용했던 이유는 데이터베이스에 저장하기 전에 폼에 담고 있지 않았던 작성자 정보를 추가하고 싶기 때문이다. 

핵심은 form_valid() 안에서 form.save() 이후 태그를 저장하는 것

CBV에서 createview를 사용할떄 super().form_valid(form)이 실행되면 form_valid() - form_save() - object 생성 - selft.object 저장 - success_url로 이동이 된다. 

from django.views.generic import CreateView

class ProjectCreateView(CreateView):
    model = Project
    form_class = ProjectForm
    template_name = "project/create.html"

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

 

태그와 같은 입력값을 가공해야할경우, def로 확장해 form.instance.tags 를 strip() 이후 split하는등으로 가공할 수 있다. 

def form_valid(self, form):
    tags = form.cleaned_data["tags"]

    form.instance.tags = [
        tag.strip()
        for tag in tags.split(",")
        if tag.strip()
    ]

    return super().form_valid(form)

 

위 처럼 보통 저장 던에 form.instance를 수정하는 방식이 깔끔하나 tag 모델이 따로있는 ManyToMany등의 형일 경우 form.save() 이후 관계를 저장 할 수 있다. 

def form_valid(self, form):
    response = super().form_valid(form)

    tags = form.cleaned_data["tags"]

    for tag_name in tags:
        tag, _ = Tag.objects.get_or_create(name=tag_name)
        self.object.tags.add(tag)

    return response

 

 

assertIn, assertNotIn 

Django 테스트에서 특정 값이 어떤 컬렉션이 나 문자열에 포함되어있는지 검사하는 테스트 메서드
NotIn은 포함되어있지않은가를 말한다. 

class ProjectTest(TestCase):

    def test_project_list(self):
        response = self.client.get("/projects/")

        self.assertEqual(response.status_code, 200)
        self.assertIn("Project 01", response.content.decode())
        self.assertNotIn("Project 99", response.content.decode())
self.assertIn("Django", ["Python", "Django", "Java"])
self.assertIn("login", response.content.decode())
self.assertNotIn("Spring", ["Python", "Django", "Java"])

 

assertEqual(a, b) a와 b가 같은가
assertNotEqual(a, b) a와 b가 다른가
assertIn(a, b) a가 b 안에 있는가
assertNotIn(a, b) a가 b 안에 없는가
assertTrue(x) x가 True인가
assertFalse(x) x가 False인가
assertIsNone(x) x가 None인가
assertIsNotNone(x) x가 None이 아닌가

 

 

 

dijango-crispy-forms

Django Form의 HTML렌더링을 깔끔하게 만들어준다. 폼을 어떻게 화면에 보여줄지를 편하게 하는 것. 

기존에 작업을 하려면 class지정후 html를 구성해 bootstrap이나 css를 지정해야해 번거로웠다 

class ProjectForm(forms.ModelForm):
    class Meta:
        model = Project
        fields = ["name", "description"]
        
<form method="post">
    {% csrf_token %}

    {{ form.name }}
    {{ form.description }}

    <button type="submit">저장</button>
</form>

 

 


pip install django-crispy-forms, pip install crispy-bootstraps  - settings,py installed_apps 추가.

django form을 bootstrap 스타일에 맞게 렌더링해준다. 

INSTALLED_APPS = [
    ...
    "crispy_forms",
    "crispy_bootstrap5",
]

CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
CRISPY_TEMPLATE_PACK = "bootstrap5"

{% load crispy_forms_tags %}

<form method="post">
    {% csrf_token %}

    {{ form|crispy }}

    <button type="submit" class="btn btn-primary">
        저장
    </button>
</form>

 

 

FormHelper을 사용하면 세밀하게 제어가 가능한데 django-crispy-forms를 사용할때 특히 편하다. 

Django Form의 기능을 바꾸는 라이브러리가 아니라, Form을 예쁘고 일관된 HTML UI로 렌더링하기 위한 라이브러리

{% load crispy_forms_tags %} {{ form|crispy }} 등의 한줄로 label, input, textarea, 에러 메시지 등의 HTML을 자동 생성하는것. 

from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit
from django import forms


class ProjectForm(forms.ModelForm):

    class Meta:
        model = Project
        fields = ["name", "description"]

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.helper = FormHelper()
        self.helper.form_method = "post"
        self.helper.add_input(
            Submit("submit", "저장")
        )
{% extends "base.html" %}

{% block content %}

<h1>프로젝트 생성</h1>

<form method="post">
    {% csrf_token %}
    {{ form|crispy }}

    <button type="submit">저장</button>
</form>

{% endblock %}

 

 

html을 자동으로 만들어주긴 하지만 formhelper, layout, field를 이용해서 원하는 클래스나 속성을 지정할 수 있다 .

from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field

class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        fields = ['title', 'content']

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.helper = FormHelper()

        self.helper.layout = Layout(
            Field(
                'title',
                css_class='form-control custom-title'
            ),
            Field(
                'content',
                css_class='form-control custom-content'
            ),
        )

 

혹은 기존 widget에서 attrs를 추가하는 방법으로 직접 지정한다.

.custom-title {
    border-radius: 10px;
    font-size: 18px;
}

.custom-content {
    min-height: 300px;
}
class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        fields = ['title', 'content']

        widgets = {
            'title': forms.TextInput(
                attrs={
                    'class': 'form-control custom-title',
                    'placeholder': '제목을 입력하세요'
                }
            ),
            'content': forms.Textarea(
                attrs={
                    'class': 'form-control custom-content',
                    'rows': 10
                }
            ),
        }

 

 

html 구조를 바꾸고싶다면 layout을 사용하고, Row(), Colum등을 활용해 필드를 묶을 수도 있다 .

단순히 html을 자동 생성해주는 도구라기보다는 Djanfo Form 기반으로 HTMl 구조와 Bootstrap등의 스타일을 편하게 구성하도록 도와주는 form 렌더링 라이브러

self.helper.layout = Layout(
    Field('title'),
    Field('content'),
    Submit('submit', '저장', css_class='btn btn-primary')
)

from crispy_forms.layout import Row, Column

self.helper.layout = Layout(
    Row(
        Column('title', css_class='col-md-8'),
        Column('category', css_class='col-md-4'),
    ),
    Field('content'),
    Submit('submit', '저장')
)

 

 

 

Django + Bootstrap으로 직접 UI를 설계하고 있는 프로젝트라면 나는 HTML에서 직접 제어하는것이 추천된다. 
화면마다 UI가 꽤 다르다면 Form Helper가 무조건 편한게 아니고 오히려 html에서 정의하고 수정하는게 훨씬 편리하나 폼이 매우 많고 폼마다 비슷한 bootstrap 구조가 반복된다면 form helper로 공통 처리하는게 좋다 .

실무에서도 상황에 따라 섞어 쓰는게 가능하다. 복잡한 커스텀 UI는 직접, 단순한 반복 폼은 form helper를 사용하는 것.

예를 들어 간단한 관리자 CRUD 폼 은 Form Helper  로 반복되는 label, input, error 등을 알아서 처리  하는것이다. 

 

 

 

 

 

django-markdownx 마크다운 문법 적용하기 markdown 으로 글을 작성하고 html로 렌더링해서 보여주기 위한 패키지 예를 들어 Coming Soon Editor 같은 화면에 어울린다 . pip install django-markdownx - settings.py의 installed_apps에 markdownx를 추가, urls.py에 경로 추가

INSTALLED_APPS = [
    # Django apps
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',

    # Third-party
    'markdownx',

    # Local apps
    'coming_soon',
]
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path('admin/', admin.site.urls),

    path('coming-soon/', include('coming_soon.urls')),

    # MarkdownX
    path('markdownx/', include('markdownx.urls')),
]

 

원하는 model의 필드를 markdownfield로 작성하고 마이그레이션 python manage.py makemigrations, python manage.py migrate, html에 추가, forms.py에 MOdelform을 사용하면 markdownxfield 가 markdown 편집 필드로 연결된다. 

from django.db import models
from markdownx.models import MarkdownxField


class ComingSoon(models.Model):
    title = models.CharField(max_length=200)

    content = MarkdownxField()

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self):
        return self.title
from django import forms
from .models import ComingSoon


class ComingSoonForm(forms.ModelForm):

    class Meta:
        model = ComingSoon
        fields = ['title', 'content']

 

MarkdownX가 form.content를 렌더링하면서 Markdown 입력 영역 + Preview 영역을 만들어준다.

{{ form.content }}가 렌더링하는 <textarea> 에 사용자가 입력하게됨.

{% extends 'base.html' %}

{% block content %}

<div class="container mt-4">

    <h2>Coming Soon Editor</h2>

    <form method="post">
        {% csrf_token %}

        <div class="mb-3">
            <label class="form-label">
                제목
            </label>

            {{ form.title }}
        </div>

        <div class="mb-3">
            <label class="form-label">
                내용
            </label>

            {{ form.content }}
        </div>

        <button type="submit" class="btn btn-primary">
            저장
        </button>
    </form>

</div>

{% endblock %}

 

이 사용자가 입력한 내용을 출력시 safe를 사용해 djanfo가 html를 escape하지않고 그대로 랜더링 하도록 한다 .

단, 사용자가 <script> 악성 코드 </script> 를 첨부할 수도 있음으로 무조건 붙이지않는다. XSS 공격에 취약해질수도 있기때문, 따라서 일반적으로 안전한 html일때만 safe를 붙인다. 

{{ content_html|safe }}

 

해당 content를 get해 가져올수있지만 , html로 변환해서 가져오고 싶다면 별도의 메서드를 만들어야한다 .

from django.db import models
from markdownx.models import MarkdownxField


class ComingSoon(models.Model):
    title = models.CharField(max_length=200)
    content = MarkdownxField()

    def get_content_markdown(self):
        return self.content
import markdown

from django.db import models
from markdownx.models import MarkdownxField


class ComingSoon(models.Model):
    title = models.CharField(max_length=200)
    content = MarkdownxField()

    def get_content_markdown(self):
        return self.content

    def get_content_html(self):
        return markdown.markdown(self.content)

 

 


HTML sanitization( sanitization은 Markdown → HTML로 변환한 다음, 위험한 HTML 태그/속성을 제거하는 과정 )을 거친 결과만 출력하는 게 안전하다. pip install bleach markdown

단순한 Coming Soon 문서라면 허용 태그를 상당히 좁게 잡는것이 좋다 .

특히 script, iframe, style, onclick 같은 이벤트 속성은 허용하지 않는 것이 중요하다 .

import markdown
import bleach

from django.db import models
from markdownx.models import MarkdownxField


class ComingSoon(models.Model):
    title = models.CharField(max_length=200)
    content = MarkdownxField()

    def get_content_markdown(self):
        return self.content

    def get_content_html(self):
        html = markdown.markdown(
            self.content,
            extensions=[
                'fenced_code',
                'tables',
            ]
        )

        allowed_tags = [
            'p',
            'br',
            'strong',
            'em',
            'h1',
            'h2',
            'h3',
            'h4',
            'ul',
            'ol',
            'li',
            'blockquote',
            'code',
            'pre',
            'table',
            'thead',
            'tbody',
            'tr',
            'th',
            'td',
            'a',
        ]

        allowed_attributes = {
            'a': ['href', 'title'],
        }

        return bleach.clean(
            html,
            tags=allowed_tags,
            attributes=allowed_attributes,
            strip=True,
        )
allowed_tags = [
    'p', 'br',
    'h1', 'h2', 'h3',
    'strong', 'em',
    'ul', 'ol', 'li',
    'code', 'pre',
    'blockquote',
    'a',
]

 

 

 

 

django-allauth 

Django에서 회원가입, 로그인, 소셜 로그인 OAuth등을 쉽게 구현하게 해주는 패키지.

특히 Google OAuth 로그인을 구현하려고 할때 적합하다. 

pip install django-allauth, 최근버전에서 기본적인 인증 기능 외에 소셜 로그인을 provider를 추가해 사용한다.

pip install "django-allauth[socialaccount]" = settings.py - INSTALLED_APPS 에 추가 -

INSTALLED_APPS = [
    # Django
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'django.contrib.sites',

    # django-allauth
    'allauth',
    'allauth.account',
    'allauth.socialaccount',

    # Google
    'allauth.socialaccount.providers.google',

    # Local
    'coming_soon',
]

그리고  settings.py에 맨 아래에(어디든 전역 설정으로 넣어도 상관없다. ) site_id = 1를 추가한다. django.contrib.sites를 사용하기 때문에 필요한 설정

이때 회원가입시 이메일을 필수로 받으려면 ACCOUNT_SIGNUP_FIELDS를 설정하고, *가 붙을경우 필수라는 의미이다. 

이메일을 입력받고 그 이메일이 실제 사용자의 이메일인지 검증하는것 또한 별개로 ACCOUNT_EMAIL_VERIFICATION =  mandatory로 설정할 수 있다. 비활성화시 NONE.

정상적으로 로그인, 로그아웃 되었을때 리다이렉터될 url을 지정한다 . 로그아웃은 선택으로 설정하지 않아도 allauth의 기본 로그아웃 흐름은 작동한다 .

SITE_ID = 1

ACCOUNT_SIGNUP_FIELDS = [
    'email*',
    'password1*',
    'password2*',
]
ACCOUNT_EMAIL_VERIFICATION = 'mandatory'
LOGIN_REDIRECT_URL = '/dashboard/'
LOGOUT_REDIRECT_URL = '/'

 

settings.py의 MIDDLEWEAR 에 AccountMiddlewear추가

django-allauth의 계정 기능을 정상적으로 사용하기 위한 필수 설정

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',

    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',

    # allauth
    'allauth.account.middleware.AccountMiddleware',

    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

AUTHENTICATION_BACKWNDS에 ModelBackend, AuthenticationBackend두개를 같이 넣는다. 

이유는 ModelBackend는 Django의 기본 인증이고 allauth의 Backend는 allauth의 로그인 계정 인증을 처리한다.

Django 기본 관리자 로그인 + allauth Google 로그인을 같이 사용할 거라면  보통 둘다  사용한다 .

AUTHENTICATION_BACKENDS = [
    # Django 기본 로그인
    'django.contrib.auth.backends.ModelBackend',

    # django-allauth
    'allauth.account.auth_backends.AuthenticationBackend',
]

 

urls.py 에 추가

이 경우 allauth가 site.urls 에 맞춰 여러 url을 제공하게된다 ./accounts/password/change/

django-allauth를 사용하려면 데이터베이스에도 반영을 해야한다. - python manage,py migrate

* urls.py에 allauth url을 이미 추가했다면 로그아웃 url도 자동으로 등록된다. (예) /accounts/logout/

from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path('admin/', admin.site.urls),

    path('accounts/', include('allauth.urls')),
]

 

 

만약 google 로그인을 하려면 'allauth.socialaccount.providers.google'을 추가하고

google oath client id, secret을 설정해야한다.  dajgnso admin에서 사이트를 설정하고 google을 연결할 수 있다. 

구글계정으로 구글 개발자 콘솔 - console.develpers.google.com - 새프로젝트 생성 - 이름설정 - 만들기 - OAuth 동의화면 - 애플리케이션 이름 입력  - usertype선택하는 단계에서 구글 계정을 가진 모든 사람이 이용할 수 있도록 외부를 선택 -  새프로젝트 생성 완료. - 사용자 인증정보 - +사용자 인증 정보만들기 - OAuth 클라이언트 id - 애플리케이션유형: 웹애플리케이션, 도메인  http://127.0.0.1:8000 입력 - 승인된 리다이렉션 url입력 (예) http://127.0.0.1:8000/accounts/google/login/callback - 만들기 - 클라이언트 id과 클라이언트 보안 비밀번호를 알려준다. 복사해 다른곳에 잘 저장. 

 

관리자 페이지를 열어보면 sites와 social accounts 메뉴가 추가되어있고 sites - sites - example.com이라는 항목이 있는데 현재 site_id = 1에 해당하는 도메인이 example.com이니 이를 127.0.0.1:8000으로 수정하고 save.

 

관리자페이지에서 social accounts - social applications - add social application - provider:google, name: 사용자 입력, client id: 구글이 부여한 id 입력, sectet key 구글이 부여한 비밀번호 입력, sites availablesites에 있는 (예) 127.0.0.1:8000울 선택해 chosen sites에 옮기기- save

 

navbar.html를 열어 맨 위에 {% load socialaccount %} 추가

일반 이메일/비밀번호 로그인만 navbar에 넣는다면 socialaccount를 로드할 필요가 없고, Google 같은 소셜 로그인 버튼을 만들 때 필요하다 Google URL로 하드코딩하는 게 아니라 {% provider_login_url 'google' %}를 사용하는 게 핵심

{% load socialaccount %}

<nav class="navbar">
    <a href="{% url 'account_login' %}">
        로그인
    </a>

    <a href="{% provider_login_url 'google' %}">
        Google 로그인
    </a>
</nav>
<a href="{% url 'account_login' %}">
    로그인
</a>

<a href="{% url 'account_signup' %}">
    회원가입
</a>

<form method="post" action="{% url 'account_logout' %}">
    {% csrf_token %}
    <button type="submit">로그아웃</button>
</form>

 

 

 

django-allauth에서 제공하느 로그아웃, 별도의 View를 만들 필요가 없다.

<form method="post" action="{% url 'account_logout' %}">
    {% csrf_token %}

    <button type="submit" class="btn btn-danger">
        로그아웃
    </button>
</form>

 

 

django에서 Listview에서 paginate_by를 지원한다. 

Listview가 페이지네이션 정보를자동으로 넘겨준다.

from django.views.generic import ListView
from .models import Board


class BoardListView(ListView):
    model = Board

    # 한 페이지에 게시글 10개씩 표시
    paginate_by = 10

    # 사용할 템플릿
    template_name = "board/board_list.html"

    # 템플릿에서 사용할 객체 이름
    context_object_name = "boards"
<h1>게시글 목록</h1>

{% for board in page_obj %}
    <h3>{{ board.title }}</h3>
    <p>{{ board.content }}</p>
{% empty %}
    <p>게시글이 없습니다.</p>
{% endfor %}


{% if is_paginated %}

    <div>

        <!-- 이전 페이지 -->
        {% if page_obj.has_previous %}
            <a href="?page={{ page_obj.previous_page_number }}">
                이전
            </a>
        {% endif %}


        <!-- 현재 페이지 / 전체 페이지 -->
        <span>
            {{ page_obj.number }}
            /
            {{ page_obj.paginator.num_pages }}
        </span>


        <!-- 다음 페이지 -->
        {% if page_obj.has_next %}
            <a href="?page={{ page_obj.next_page_number }}">
                다음
            </a>
        {% endif %}

    </div>

{% endif %}

 

 

 

 

검색기능

검색기능이 내장되어있지느 ㄴ않지만 ORM Queryset을 이용해서 검색 조건을 만들고 결과를 listview가 보여주게 한다 .

from django.views.generic import ListView
from .models import Board


class BoardListView(ListView):
    model = Board
    template_name = "board/board_list.html"
    context_object_name = "boards"

    # 한 페이지에 10개
    paginate_by = 10

    def get_queryset(self):
        # 기본 게시글 목록
        queryset = Board.objects.all()

        # URL의 ?q=django 값을 가져옴
        keyword = self.request.GET.get("q")

        # 검색어가 있으면 제목에 포함된 게시글만 조회
        if keyword:
            queryset = queryset.filter(title__icontains=keyword)

        return queryset
<form method="get">

    <input
        type="text"
        name="q"
        placeholder="검색어 입력"
        value="{{ request.GET.q }}"
    >

    <button type="submit">
        검색
    </button>

</form>


{% for board in page_obj %}
    <h3>{{ board.title }}</h3>
{% empty %}
    <p>검색 결과가 없습니다.</p>
{% endfor %}

 

 

 

 

만일 제목과 내용을 둘다 검색하고 싶다면 ORM의 Q 객체를 사용할 수 있다 .

from django.db.models import Q


def get_queryset(self):
    queryset = Board.objects.all()

    keyword = self.request.GET.get("q")

    if keyword:
        queryset = queryset.filter(
            Q(title__icontains=keyword) |
            Q(content__icontains=keyword)
        )

    return queryset

 

 

 

구글 로그인 했을때 구글 프로필 이미지 사진을 가져오게 만들기

로그인 성공후 이미지 파일 자체가 아니라 이미지 url이 저장되낟 .

class User(models.Model):
    email = models.EmailField(unique=True)
    name = models.CharField(max_length=100)
    profile_image = models.URLField(blank=True)
user.profile_image = google_user_info["picture"]
user.save()
<img src="{{ user.profile_image }}" alt="프로필">