> For the complete documentation index, see [llms.txt](https://sangmandu.gitbook.io/til/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://sangmandu.gitbook.io/til/til_python_and_math/do_it_django+bootstrap/10.md).

# Relations - 다대일 관계

## 다대일 관계 - author (작성자) 추가하기

#### 다대일 관계

A모델의 여러 레코드가 B모델의 한 레코드에 연결될 수 있는 관계

* 한 유저가 여러개의 포스트를 작성했을 때 포스트라는 여러 레코드가 유저라는 한 레코드에 연결된다

위 관계를 가지는 작성자 필드를 추가할 것임

`models.py`

```python
from django.contrib.auth.models import User

class Post(models.Model):
    author = models.ForeignKey(User, on_delete=models.CASCADE)
    
def __str__(self):
    return f"{self.pk} {self.title} :: {self.author}"    
```

* 외래키 필드를 추가한다. 이 때 `on_delete` 속성은 만약 author가 삭제되었을 경우, 해당 포스트를 삭제할 것이냐고 묻는 것이다.
  * cascade는 삭제하라는 뜻이며 6가지의 속성을 설정할 수 있다.
  * [여기](https://vallhalla-edition.tistory.com/60) 참고
* 이 때 migrations을 진행하면 다음과 같은 텍스트가 뜨는데, default값을 정해달라는 뜻이다.
  * 1번은 migration 할테니, default를 지금 정해달라.
  * 2번은 migration를 취소하고 default를 정하라

```
You are trying to add a non-nullable field 'author' to post without a default; we can't do that (the database needs
 something to populate existing rows).
Please select a fix:
 1) Provide a one-off default now (will be set on all existing rows with a null value for this column)
 2) Quit, and let me add a default in models.py
Select an option:
```

여기서는 1을 누른다.

```
Please enter the default value now, as valid Python
The datetime and django.utils.timezone modules are available, so you can do e.g. timezone.now
Type 'exit' to exit this prompt
>>> 1
```

그리고 1을 입력한다. 그러면 1번째 사용자의 이름이 기본 이름으로 설정된다.

## 연결된 레코드가 삭제될 때 동작 결정하기 - CASCADE, SETNULL

```
author = models.ForeignKey(User, null=True, on_delete=models.SET_NULL)
```

다음과 같이 `on_delete` 속성을 `SET_NULL`로 설정할 수도 있다. 이 때 반드시 `null=True`를 같이 설정해줘야 한다.

이 때 포스트를 작성한 적 있는 유저를 삭제하면 해당 포스트의 이름은 None으로 남게 된다.

## 포스트 목록, 포스트 상세 페이지에 작성자 추가하기

`tests.py` 에서는 매번 테스트를 진행할 때 마다 DB가 초기화되었다고 가정하고 실행된다. 이 때 `setUp` 함수에서 이러한 데이터를 미리 초기화해줄 수 있다.

`tests.py`

```python
from django.contrib.auth.models import User


class TestView(TestCase):
    def setUp(self):
        self.client = Client()
        self.user_trump = User.object.create_user(
            username='trump',
            password='somepassword'
        )
        self.user_obama = User.object.create_user(
            username='obama',
            password='somepassword'
        )
```

## Category 만들기

`models.py`

```python
class Category(models.Model):
    name = models.CharField(max_length=50, unique=True)
    slug = models.SlugField(max_length=200, unique=True, allow_unicode=True)

    def __str__(self):
        return self.name

    class Meta:
        verbose_name_plural = 'Categories'
```

* `unique` 속성은 동일한 name을 가진 카테고리가 없도록 설정하는 것
* `Meta` 클래스를 추가하고 `verbose_name_plural` 변수를 추가하면 Categorys 항목의 이름을 바꿀 수 있다
* 마이그리에션 작업 할 것

`admin.py`

```python
from django.contrib import admin
from .models import Post, Category

admin.site.register(Post)


class CategoryAdmin(admin.ModelAdmin):
    prepopulated_fields = {'slug': ('name',)}


admin.site.register(Category, CategoryAdmin)

```

* 관리자 계정에서 카테고리 페이지를 볼 수 있도록 추가한다.
* 이 때 `CategoryAdmin(admin.ModelAdmin)` 클래스를 추가하고 `admin.site.register` 에 이 클래스를 인자로 받으면 category name이 입력될 때 slug도 동일하게 같이 입력되게 된다.

`blank = True` vs `null = True`

* blank = True : 데이터 폼을 작성할 때 필수적이어야 되는지에 대한 선택지
* null = True : 데이터베이스에 마이그레이트 될 때 해당 필드가 필수적이어야 되는지에 대한 선택지
* author를 설정하고, author가 탈퇴해서 해당 게시물의 author가 None 이 더라도 null = True로 되어있으면 상관없다.

## django shell로 다대일구조 연결 확인

기존 쉘은 다음과 같다

```
python manage.py shell

Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)

>>> from blog.models import Post, Category
>>> Post.objects.count()
5
>>> Category.objects.count()
4

>>> for p in Post.objects.all():
...     print(p)
...
1 new post :: bini
2 second post :: mini
3 third post :: mini
4 Meteor :: mini
5 여름이 오면 서핑을 하고싶어요 :: bini
>>> for c in Category.objects.all():
...     print(c)
...
programming
culture&art
game
entertainment
```

무언가 칙칙하게 느껴질 수 있다. 이 때 쓸 수 있는 것이 있다. 일단

`pip install django_extensions, ipython` 을 한다. 그리고 `settings.py` 의 `INSTALLED_APPS` 에 `django_extensions` 를 추가해준다.

그러면 다음과 같이 바뀐 모습을 볼 수 있다.

![](/files/-MgAuU9JMXyC0Z0b1TGV)

* 일단 색이 추가되어서 가독성이 좋아졌다.
* 그리고 따로 import를 하지 않아도 된다.

![](/files/-MgAuhOUYmmhuPO6zQkj)

그 외에도 이런것들이 된다.

```
In [5]: category_programming = Category.objects.get(slug='programming')

In [6]: category_programming
Out[6]: <Category: programming>

In [7]: category_programming = Category.objects.get(name__startswith='c')

In [8]: category_programming
Out[8]: <Category: culture&art>

In [9]: for p in category_programming.post_set.all():
    ...:     print(p)
    ...: 
5 여름이 오면 서핑을 하고싶어요 :: bini
```

## 포스트 목록 페이지 수정하기 1

일단 지금까지 작성한 `tests.py` 를 정리해보자

첫번째, 각 테스트마다 포스트를 만들었다. 네비게이션도 한번에 관리하기 위해서 함수로 만들었는데 포스트도 동일하게 관리할 수 있지 않을까? 이 때 카테고리까지 고려해서 관리해보자

따라서 SetUp을 다음과 같이 수정한다.

`tests.py`

```python
class TestView(TestCase):
    def setUp(self):
        self.client = Client()
        self.user_trump = User.objects.create_user(
            username='trump',
            password='somepassword'
        )
        self.user_obama = User.objects.create_user(
            username='obama',
            password='somepassword'
        )
        self.category_programming = Category.objects.create(
            name='programming',
            slug='programming'
        )
        self.category_music = Category.objects.create(
            name='music',
            slug='music'
        )

        self.post_001 = Post.objects.create(
            title='첫번째 포스트 입니다.',
            content='Hello, World. We are the World.',
            author=self.user_trump,
            category=self.category_programming,
        )
        self.post_002 = Post.objects.create(
            title='두번째 포스트 입니다.',
            content='안녕 여러분, 나도 여러분의 일부야.',
            author=self.user_obama,
            category=self.category_music,
        )
        self.post_003 = Post.objects.create(
            title='세번째 포스트 입니다.',
            content='카테고리 없어.',
            author=self.user_obama,
        )
```

그리고, 이후에 나오는 post에 관한 코드들을 self.post로 바꾸어준다.

이제, 포스트가 3개로 고정되었다. 따라서 test를 할 때 post가 있을때와 없을 때로 나누어준다.

```python
def test_post_list_without_posts(self):
    Post.objects.all().delete()
    self.assertEqual(Post.objects.count(), 0)

    response = self.client.get('/blog/')
    self.assertEqual(response.status_code, 200)

    soup = BeautifulSoup(response.content, 'html.parser')
    self.navbar_Test(soup)
    self.assertIn('상만두', soup.title.text)

    main_area = soup.find('div', id='main-area')
    self.assertIn('아직 게시물이 없습니다.', main_area.text)

def test_post_list_with_posts(self):
    self.assertEqual(Post.objects.count(), 3)

    response = self.client.get('/blog/')
    self.assertEqual(response.status_code, 200)

    soup = BeautifulSoup(response.content, 'html.parser')
    self.assertIn('상만두', soup.title.text)

    self.navbar_Test(soup)

    main_area = soup.find('div', id='main-area')
    self.assertIn(self.post_001.title, main_area.text)
    self.assertIn(self.post_002.title, main_area.text)
    # 3.4 "아직 게시물이 없습니다" 라는 문구가 없어야 한다
    self.assertNotIn('아직 게시물이 없습니다.', main_area.text)

    self.assertIn(self.post_001.author.username.upper(), main_area.text)
    self.assertIn(self.post_002.author.username.upper(), main_area.text)

```

## 포스트 목록 페이지 수정하기 2

![](/files/-MgB3JInD5BgyE0QJvMD)

위 페이지의 카테고리 박스처럼 하려고한다. 이 때 이 항목은 Model의 Category를 참조해야한다. 또, Post 개수에 대한 정보도 얻어야 한다. 이는 일단 여기서 먼저 손보자

`views.py`

```python
from .models import Post, Category

class PostList(ListView):
    model = Post
    ordering = '-pk'

    def get_context_data(self, **kwargs):
        context = super(PostList, self).get_context_data()
        context['categories'] = Category.objects.all()
        context['no_category_post_count'] = Post.objects.filter(category=None).count()
        return context
```

다음과 같이 함수를 추가해줘서, 카테고리 정보를 가져올 수 있도록 한다.

그리고 실제로 템플릿에서도 보일 수 있도록 수정해준다

`base.html`

```markup
{% for category in categories %}
<li>
    <a href="#!"> {{ category.name }} ({{ category.post_set.count }})</a>
</li>
{% endfor %}
<li>
    <a href="#!"> 미분류 ({{ no_category_post_count }})</a>
</li>
```

그리고 `post_list.html` 에 카테고리 항목을 보여주는 뱃지를 추가해준다.

```markup
{% if p.category %}
    <span class="badge badge-success float-right"> {{ p.category }}</span>
{% else %}
    <span class="badge badge-success float-right"> 미분류 </span>
{% endif %}
```

* 이 때 float-right는 오른쪽으로 정렬되게 한다.
  * 부트스트랩에서 지원하는 기능

## 포스트 상세 페이지 수정하기

`post_detail.html` 도 마찬가지로 수정해준다.

```markup
<div id="post-area">
    {% if post.category %}
    <span class="badge badge-success float-right"> {{ post.category }}</span>
    {% else %}
    <span class="badge badge-success float-right"> 미분류 </span>
    {% endif %}
```

늘 주의할 것은 p.category 가 아니라 post.category!

## 카테고리 페이지 만들기

![](/files/-MgBB4ibRFuIS4fubxh8)

![](/files/-MgBB7MIMCCeDz-9PvNB)

이러한 구조를 가지는 것이 목표. 이 때 이 페이지는 카테고리 카드에 있는 항목을 클릭했을 때 이동하는 페이지이다.

`views.py`

```python
def category_page(request, slug):
    if slug == "no_category":
        category = '미분류'
        post_list = Post.objects.filter(category=None)
    else:
        category = Category.objects.get(slug=slug)
        post_list = Post.objects.filter(category=category)
    return render(
        request,
        'blog/post_list.html',
        {
            'post_list': post_list,
            'categories': Category.objects.all(),
            'no_category_post_count': Post.objects.filter(category=None).count(),
            'category': category
        }
    )
```

이전에 `index.html`을 만드는 것 처럼 class가 아니라 함수로 만들어준다. 이 때 `slug`가 `미분류`일때의 `category`와 `post_list`를 따로 정의해줘야 한다.

`models.py`

```python
class Category(models.Model):
    name = models.CharField(max_length=50, unique=True)
    slug = models.SlugField(max_length=200, unique=True, allow_unicode=True)

    def __str__(self):
        return self.name

    def get_absolute_url(self):
        return f'/blog/category/{self.slug}/'
```

위와 같이 Post class와 동일하게 `get_absolute_url` 함수를 정의해준다. 이 때 주소는 `/blog/category/slug/` 가 될 수 있도록 한다.

근데, 나도 어떻게 새로운 url에 페이지가 새로운 템플릿 없이 구성된 것인지 잘 모르겠다.. 아마 기존 템플릿에 조건문으로 덮어쓴 것 같다는 예상 뿐... ㅠㅠ
