728x90
인스타그램 구현
1. 개발 환경 구성
- pystagram 프로젝트 생성
- 정적파일 설정
- BASE_DIR / "static"
- 유저가 업로드한 정적 파일 설정
- url = "media/"
- root = BASE_DIR/"media"
# pystagram/settings.py
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
TEMPLATES_DIR = BASE_DIR / "templates"
...
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [TEMPLATES_DIR],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
...
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.1/howto/static-files/
STATIC_URL = "static/"
STATICFILES_DIRS = [BASE_DIR / "static"]
MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media"
# pystagram/urls.py
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path("admin/", admin.site.urls),
]
urlpatterns += static(
prefix = settings.MEDIA_URL,
document_root = settings.MEDIA_ROOT,
)
2. 인덱스 페이지 구성
- 127.0.0.1:8000/ 경로에 보여줄 인덱스 페이지 구성
# pystagram/views.py
from django.shortcuts import render
def index(request):
return render(request, "index.html")
#pystagram/urls.py
from pystagram import views
urlpatterns = [
path("admin/", admin.site.urls),
path("", views.index)
]
urlpatterns += static(
prefix = settings.MEDIA_URL,
document_root = settings.MEDIA_ROOT,
)
# templates/index.html
<!DOCTYPE html>
<html lang="ko">
<body>
<h1>pystagram</h1>
</body>
</html>
3. 인증 시스템
- 회원가입, 로그인과 같은 사용자 정보를 활용하는 기능을 통틀어 인증 시스템(Authentication system)이라 부름
4. CustomUser
- Django는 기본적으로 로그인을 처리할 수 있는 기본 User 모델을 지원함
- 기본 User 모델은 ID와 비밀번호, 이름과 같은 최소한의 정보만을 지원
- 사용자 모델에 추가 정보를 저장하고 싶다면 별도의 User 모델을 구성해야 함
5. User모델 생성
# users/models.py
from django.db import models
from django.contrib.auth.models import AbstractUser
# Create your models here.
class User(AbstractUser):
pass
- AbstractUser : Django 가 CustomUser 모델을 만들기 위해 제공하는 기본 유저 형태를 가진 모델 클래스
- AbstractUser 를 상속받으면 자동적으로 다음 필드들이 모델에 추가됨
- username : 사용자명, 로그인 할 때의 아이디
- password : 비밀번호
- first_name : 이름
- last_name : 성
- email : 이메일
- is_staff : 관리자 여부
- is_active : 활성화 여부
- date_joined : 가입 일시
- last_login : 마지막 로그인 일시
- 커스텀 유저 모델을 사용하는 경우, 어떤 모델을 User 모델로 사용하는지 settings.py에 정의해야함
※ 마이그레이션은 커스텀 유저 모델을 만들고 나서 할것(기본 유저 모델이 마이그레이션 돼버리기 때문)
# pystagram/settings.py
# 사용법: 앱이름.모델이름
AUTH_USER_MODEL = "users.User"
- 관리자 계정을 만들고 로그인하면 관리자 페이지에 Users모델이 없음
- CustomUser 모델을 정의했다면 관리자 페이지에 수동으로 등록해야함
# users/admin.py
from django.contrib.auth.admin import UserAdmin
from users.models import User
# Register your models here.
@admin.register(User)
class CustomUserAdmin(UserAdmin):
pass
6. CustomUser에 필드 추가
- CustomUser 에 프로필 이미지와 소개글 필드를 추가
# users/models.py
class User(AbstractUser):
profile_image = models.ImageField("프로필 이미지",
upload_to = "users/profile",
blank=True)
short_description = models.TextField("소개글", blank=True)
- CustomUser 모델에 추가한 필드는 admin에 자동으로 나타나지 않으므로 별도로 정의해야함
# users/admin.py
@admin.register(User)
class CustomUserAdmin(UserAdmin):
fieldsets = [
(None, {"fields" : ("username", "password")}),
("개인정보", {"fields" : ("first_name", "last_name", "email")}),
("추가필드", {"fields" : ("profile_image", "short_description")}),
("권한", {"fields" : ("is_active", "is_staff", "is_superuser")}),
("중요한 일정", {"fields" : ("last_login", "date_joined")}),
]
7. 로그인/피드 페이지 기본 구조
- 인스타그램에 접속하면, 로그인 중이라면 바로 피드(feeds) 페이지가 나타나지만 로그인되지 않았거나 처음 접속한 경우에는 로그인(login)페이지로 이동
- 아래 두 가지 조건에 맞도록 View에서 동작을 제어해야함
- 이미 사용자가 브라우저에서 로그인을 했다면
- 피드(새 글 목록) 페이지를 보여줌
- 사용자가 로그인을 한 적이 없다면(또는 로그아웃을 했다면)
- 로그인 페이지를 보여줌
- 이미 사용자가 브라우저에서 로그인을 했다면
■ 로그인(Login) 페이지
- 기본구조 구성
- View : login_view
- Template : templates/users/login.html
- URL : 127.0.0.1:8000/users/login/
# users.views.py
def login_view(request):
return render(request, "users/login.html")
# templates/users/login.html
<!DOCTYPE html>
<html lang="ko">
<body>
<h1>Login</h1>
</body>
</html>
# users/urls.py
from django.urls import path
from users import views
urlpatterns = [
path("login/", views.login_view),
]
- page not found(404) 에러
- 페이지를 찾을 수 없다는 에러
- 요청한 URL에 대한 페이지를 찾을 수 없다는 응답 코드
- URL을 해석할 때 매칭되는 패턴을 찾지 못했을 때 발생
- 현재 정의된 URL들은
- "admin/"으로 시작하는 URL -> 관리자 페이지
- 공백 문자열 -> index View
- "media/"로 시작하는 URL -> 사용자가 업로드한 정적파일
- 새로 추가한 users/urls.py에 정의한 URL은 여기에 나타나지 않음
- 새 urls.py는 Root URLconf 에 등록해야함
# pystagram/urls.py
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
from pystagram import views
urlpatterns = [
path("admin/", admin.site.urls),
path("", views.index),
path("users/", include("users.urls")),
]
urlpatterns += static(
prefix = settings.MEDIA_URL,
document_root = settings.MEDIA_ROOT,
)
- pystagram.urls.py 에서 사용된 include 함수는 users/ 로 시작하는 URL을 users/urls.py 에서 처리하게 하며, 127.0.0.1:8000/users/login/ 의 URL은 다음의 과정을 거쳐 View에 전달됨
- pystagram/urls.py
- /users/ 로 시작하는 URL을 users/urls.py 로 전달(users/ 부분은 제외)
- users/urls.py
- 나머지 login/ 부분을 login_view로 전달
- users/views.py -> login_view
- urls에서 전달해준 요청을 처리
- pystagram/urls.py
### 피드(Feeds) 페이지
- 글 목록을 보여줄 posts 앱을 생성
- View : feeds
- URL : 127.0.0.1:8000/posts/feeds/
- Template : templates/posts/feeds.html
8. 로그인 여부에 따른 접속 제한
- 로그인 여부에 따라 동작을 구분하려면 요청을 보낸 사용자의 정보가 필요
- View 함수에 전달된 요청(request)에서 사용자 정보는 request.user 속성으로 가져올 수 있으며, 가져온 request.user 가 로그인된 사용자인지 여부는 is_authenticated 속성으로 확인할 수 있음
# posts/views.py
def feeds(request):
# 요청(request)으로부터 사용자 정보를 가져옴
user = request.user
# 가져온 사용자가 "로그인 했는지" 여부를 가져옴
is_authenticated = user.is_authenticated
print("user: ", user)
print("is_authenticated: ", is_authenticated)
return render(request, "posts/feeds.html")
- 로그인 되어 있다면 사용자명과 인증 여부가 True로 표시됨
- 로그인되어 있지 않은 경우는 사용자가 AnonymousUser(익명 사용자)로 출력되며, 인증 여부는 False로 표시됨
- 관리자 페이지에 로그인했다면 사이트에 로그인한 것과 동일하게 취급
- 로그인되지 않았다면 관리자페이지에 로그인하고 피드 페이지에 다시 접속하면 로그인된 경우의 로그가 나타남
■ 로그인 여부에 따라 페이지 이동
- 사용자가 로그인했는지 여부에 따라 Template의 내용을 보여줄지, 아니면 redirect함수를 사용해 다른 URL로 이동시킬지 결정
# posts/views.py
from django.shortcuts import render, redirect
# Create your views here.
def feeds(request):
# 요청(request)으로부터 사용자 정보를 가져옴
user = request.user
# 가져온 사용자가 "로그인 했는지" 여부를 가져옴
is_authenticated = user.is_authenticated
# print("user: ", user)
# print("is_authenticated: ", is_authenticated)
# 요청에 포함된 사용자가 로그인하지 않은 경우
if not request.user.is_authenticated:
# /users/login/ URL 로 이동시킴
return redirect("/users/login/")
return render(request, "posts/feeds.html")
# users/views.py
from django.shortcuts import render, redirect
# Create your views here.
def login_view(request):
# 이미 로그인되어 있다면
if request.user.is_authenticated:
return redirect("/posts/feeds/")
return render(request, "users/login.html")
■ 루트 경로에서 로그인 여부에 따라 페이지 이동
- 127.0.0.1:8000/ 에 접근시 로그인 여부에 따라 다른 페이지로 이동하게 하기
- 로그인되어 있다면 피드 페이지로
- 로그인되어 있지 않다면 로그인 페이지로 이동
# pystagram/views.py
from django.shortcuts import render, redirect
def index(request):
# return render(request, "index.html")
# 로그인되어 있는 경우 피드페이지로 redirect
if request.user.is_authenticated:
return redirect("/posts/feeds/")
# 로그인되어 있지 않은 경우 로그인 페이지로 redirect
else:
return redirect("/users/login/")
9. 로그인 기능
- 사이트에 직접 로그인/로그아웃 기능을 구현
- 사용자 입력을 제어하는 form요소를 쉽게 다룰 수 있도록 Django 가 지원하는 Form 클래스를 사용
# users/forms.py
from django import forms
class LoginForm(forms.Form):
username = forms.CharField(min_length=3)
password = forms.CharField(min_length=4)
- Model 클래스와 유사한 형태
- username과 password는 텍스트 입력을 받을 수 있고, username은 최소 3글자 이상, password는 최소 4글자 이상 사용하도록 min_lenght 인수를 추가
- LoginForm 인스턴스 생성에 딕셔너리를 전달하면 Form 클래스는 정의된 필드들에 올바른 값이 들어왔는지, 제약조건을 지킨 데이터가 들어왔는지를 검사
- is_valid 메서드를 호출할 때 이 검사가 실행되며, 검사 결과가 올바른지를 True/False로 리턴
- 전달된 데이터를 검증하는 것 외에도, Form클래스는 Template에서 input 태그를 생성하는 기능도 수행
# users/views.py
def login_view(request):
# 이미 로그인되어 있다면
if request.user.is_authenticated:
return redirect("/posts/feeds/")
# LoginForm 인스턴스를 생성
form = LoginForm()
# 생성한 LoginForm 인스턴스를 템플릿에 "form"이라는 키로 전달
context = {
"form" : form,
}
return render(request, "users/login.html", context)
# templates/users/Login.html
<body>
<h1>Login</h1>
<form method="POST">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">로그인</button>
</form>
</body>
- 로그인은 아이디와 패스워드를 서버에 데이터를 전송하는 작업이므로 보내는 데이터가 외부에 노출되어서는 안됨
- POST 방식 요청
# users/views.py
def login_view(request):
# 이미 로그인되어 있다면
if request.user.is_authenticated:
return redirect("/posts/feeds/")
if request.method == "POST":
#LoginForm 인스턴스를 만들며, 입력데이터는 request.POST를 사용
form = LoginForm(data = request.POST)
#LoginForm에 들어온 데이터가 적절한지 유효성 검사
print("form.is_valid():", form.is_valid())
# 유효성 검사 이후에는 cleaned_data 에서 데이터를 가져와 사용
print("form.cleaned_data: ", form.cleaned_data)
context = {"form" : form}
return render(request, "users/login.html", context)
else:
# LoginForm 인스턴스를 생성
form = LoginForm()
# 생성한 LoginForm 인스턴스를 템플릿에 "form"이라는 키로 전달
context = {
"form" : form,
}
return render(request, "users/login.html", context)
- GET 요청시 진행되는 else문 아래에는 Form 인스턴스를 생성할 때 data를 전달해주지 않으나, POST요청 시에는 전달된 데이터를 data인수로 전달해서 Form인스턴스를 생성
- Form은 Template에 input요소들을 생성할 때와 자신에게 전달된 데이터를 검증할 때 사용
- 일반적으로 data가 없이 생성된 Form은 Template에 form정보를 전달하기 위해 사용
- data인수를 채운채로 생성된 Form은 해당 data의 유효성을 검증하기 위해 사용
- Form.is_valid()
- is_valid() 메서드를 실행하기 전에는 form의 cleaned_data에 접근할 수 없음
- Form 클래스를 사용해 데이터를 받았다면 반드시 is_valid()를 호출해야함
# users/views.py
from django.shortcuts import render, redirect
from users.forms import LoginForm
from django.contrib.auth import authenticate, login
# Create your views here.
def login_view(request):
# 이미 로그인되어 있다면
if request.user.is_authenticated:
return redirect("/posts/feeds/")
if request.method == "POST":
#LoginForm 인스턴스를 만들며, 입력데이터는 request.POST를 사용
form = LoginForm(data = request.POST)
# LoginForm에 전달된 데이터가 유효하다면
if form.is_valid():
# username과 password 값을 가져와 변수에 할당
username = form.cleaned_data["username"]
password = form.cleaned_data["password"]
# username, password 에 해당하는 사용자가 있는지 검사
user = authenticate(username=username, password=password)
# 해당 사용자가 존재한다면
if user:
#로그인 처리 후, 피드 페이지로 redirect
login(request, user)
return redirect("/posts/feeds/")
# 사용자가 없다면 "실패했습니다" 로그 출력
else:
print("로그인에 실패했습니다")
#어떤 경우든 실패한 경우 다시 LoginForm을 사용한 로그인 페이지 렌더링
context = {"form":form}
return render(request, "users/login.html", context)
else:
# LoginForm 인스턴스를 생성
form = LoginForm()
# 생성한 LoginForm 인스턴스를 템플릿에 "form"이라는 키로 전달
context = {
"form" : form,
}
return render(request, "users/login.html", context)
- authenticate
- 주어진 값에 해당하는 사용자가 있는지 판단
- username 과 password에 해당하는 사용자가 있다면 함수의 실행 결과로 User 인스턴스가 반환되며, 없다면 반환되지 않음
- authenticate 함수의 실행 결과가 User 객체라면 입력한 값(credentials; 자격증명)에 해당하는 사용자가 리턴
- login
- 브라우저에 해당 사용자를 유지시켜주는 기능
- authenticate가 단순히 입력한 username/password에 해당하는 사용자가 있는지 검사하고 User객체를 되돌려준다면
- login 함수는 우리가 웹사이트에 로그인 했다면 기대하는, 로그인 상태로 변환 및 유지 기능을 담당
- login 함수 호출에는 현재 요청(request) 객체와 사용자(User) 객체가 필요
10. 로그아웃 구현 및 로그인 개선
- 로그아웃은 로그인과 달리 입력값을 받지 않으므로 Template 없이 View 만으로 구현할 수 있음
- 로그아웃 기본 구조
- View : logout_view
- URL : /users/logout/
- Template : 없음
# users/views.py
from django.contrib.auth import authenticate, login, logout
...
def logout_view(request):
# logout 함수 호출에 request를 전달
logout(request)
# logout 처리 후, 로그인 페이지로 이동
return redirect("/users/login/")
# users/urls.py
urlpatterns = [
path("login/", views.login_view),
path("logout/", views.logout_view),
]
- Django 기본 규칙에서 logout은 GET, POST 에 관계없이 동작함
- 만약 POST에서만 동작하게 하고 싶다면 request.method에 따라 다르게 동작을 하도록 변형할 수도 있음
로그인 개선
- 로그아웃 버튼과 함께 현재 로그인한 유저의 정보를 표시
- Template 에는 user 값이 자동으로 전달되므로 이 값을 사용하면 구현 가능
# templates/posts/feeds.html
<!DOCTYPE html>
<html lang="ko">
<body>
<h1>Feeds</h1>
<div>{{ user.username }} (ID: {{ user.id }})</div>
<a href="/users/logout/">로그아웃</a>
</body>
</html>
로그인 실패 시 정보 표시
- 로그인 성공했을 때의 로직은 구현되었지만 입력한 자격증명이 올바르지 않은 경우 어떤 오류가 발생하는지 알 수 없음
# users/views.py
def login_view(request):
...
# 해당 사용자가 존재한다면
if user:
#로그인 처리 후, 피드 페이지로 redirect
login(request, user)
return redirect("/posts/feeds/")
# 사용자가 없다면 "실패했습니다" 로그 출력
else:
form.add_error(None, "입력한 자격증명에 해당하는 사용자가 없습니다")
- 사용자가 없을 때 print로 출력하는 것이 아니라 에러를 추가하는 방식으로 수정함
- add_error의 입력값으로는 어떤 필드에서 에러가 발생했는지 필드명을 입력하지만, 특정 필드에 국한된 문제가 아니라면 None으로 지정
# users/forms.py
class LoginForm(forms.Form):
username = forms.CharField(
min_length = 3,
widget = forms.TextInput(
attrs = {"placeholder" : "사용자명 (3자리 이상)"},
)
)
password = forms.CharField(
min_length = 4,
widget = forms.PasswordInput(
attrs = {"placeholder" : "비밀번호 (4자리 이상)"},
)
)
- 로그인 창에서 비밀번호는 입력하는 동안에도 값을 숨겨야 함
- 또한 입력하지 않은 input에 어떤 값을 넣어야 하는지 힌트 메시지를 넣기 위해 placeholder를 설정
11. 회원가입
- 회원가입은 Post 객체를 만드는 글쓰기와 유사하게 입력받은 정보로 User 객체를 만드는 작업
- 다만 User 객체는 다른 일반적인 Model 클래스와는 다른 특징들을 갖고 있어, 몇 가지 더 고려할 사항들이 있음
- 기본 구조
- View : signup
- URL : /users/signup/
- Template : templates/users/signup.html
SignupForm을 사용한 Template 구성
# users/forms.py
class SignupForm(forms.Form):
username = forms.CharField()
password1 = forms.CharField(widget = forms.PasswordInput)
password2 = forms.CharField(widget = forms.PasswordInput)
profile_image = forms.ImageField()
short_description = forms.CharField()
- LoginForm과 비슷하지만 username과 password를 포함한 User 모델의 모든 정보를 받음
- 비밀번호는 widget을 사용해 입력한 값을 직접 볼 수 없도록 함
- 가입 시 비밀번호를 잘못 입력하는 것을 막기 위해 비밀번호/비밀번호 확인용으로 두 필드를 선언
view에서 Template에 SignupForm 전달
# users/views.py
def signup(request):
# SignupForm 인스턴스를 생성, Template에 전달
form = SignupForm()
context = {"form": form}
return render(request, "users/signup.html", context)
# templates/users/signup.html
<!DOCTYPE html>
<html lang="ko">
<body>
<h1>Sign up</h1>
<form method="POST" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p}}
<button type="submit">회원가입</button>
</form>
</body>
</html>
View에 회원가입 로직 구현
- 회원가입(User 생성) 로직은 일반적인 Model의 생성과는 조금 다름
- Django는 User의 비밀번호를 변형해서 저장함
- 사용자가 입력한 비밀번호를 암호화하지 않고 DB에 저장하는 것은 대한민국 개인정보 보호법 위반
- Django 의 User 모델에는 비밀번호를 변형해서 저장하는 기능이 내장되어 있음
- 사용자 정보는 create_user() 메서드를 사용해야함
SignupForm 의 데이터 가져오기
- 요청 유형이 POST일 때의 회원가입 form의 내용을 가져오기
# users/views.py
def signup(request):
if request.method == "POST":
# print(request.POST)
# print(request.FILES)
form = SignupForm(data = request.POST, files = request.FILES)
if form.is_valid():
username = form.cleaned_data["username"]
password1 = form.cleaned_data["password1"]
password2 = form.cleaned_data["password2"]
profile_image = form.cleaned_data["profile_image"]
short_description = form.cleaned_data["short_description"]
print(username)
print(password1, password2)
print(profile_image)
print(short_description)
context = {"form" : form}
return render(request, "users/signup.html", context)
User 생성하기
- User를 생성할 때는 몇 가지 고려해야할 사항이 있음
- 비밀번호와 비밀번호 확인의 값이 같아야 함
- 같은 사용자명을 사용하는 User는 생성 불가 및 오류 전달
# users/views.py
from users.models import User
...
def signup(request):
if request.method == "POST":
# print(request.POST)
# print(request.FILES)
form = SignupForm(data = request.POST, files = request.FILES)
if form.is_valid():
username = form.cleaned_data["username"]
password1 = form.cleaned_data["password1"]
password2 = form.cleaned_data["password2"]
profile_image = form.cleaned_data["profile_image"]
short_description = form.cleaned_data["short_description"]
# print(username)
# print(password1, password2)
# print(profile_image)
# print(short_description)
# 비밀번호와 비밀번호 확인의 값이 같은지 검사
if password1 != password2:
form.add_error("password2", "비밀번호와 비밀번호 확인란의 값이 다릅니다")
#username을 사용중인 User가 이미 있는지 검사
if User.objects.filter(username=username).exists():
form.add_error("username", "입력한 사용자명은 이미 사용중입니다")
# 에러가 존재한다면, 에러를 포함한 form을 사용해 회원가입 페이지를 다시 렌더링
if form.errors:
context = {"form": form}
return render(request, "users/signup.html", context)
else:
user = User.objects.create_user(
username = username,
password = password1,
profile_image = profile_image,
short_description = short_description,
)
login(request, user)
return redirect("/posts/feeds/")
context = {"form" : form}
return render(request, "users/signup.html", context)
# GET 요청에서는 빈 form을 보여줌
else:
# SignupForm 인스턴스를 생성, Template에 전달
form = SignupForm()
context = {"form": form}
return render(request, "users/signup.html", context)
- 개선사항
- Form의 유효성 검사는 is_valid()함수로 이미 진행했는데, 추가적인 유효성 검사가 View함수 내부의 로직에서 수행되는 것은 다소 어색함
- 입력된 값의 유효성 검사 위치를 Form내부로 바꾸어 볼 수 있음
SignupForm 내부에서 데이터 유효성 검사
- Form 클래스는 기본적으로 탑재된 유효성 검사 외에 추가적인 검사를 하도록 커스터마이징 할 수 있음
- 회원가입시 입력 받는 데이터는 username과 password1, password2 에 대한 데이터 검증이 필요
- 하나의 필드에 대한 유효성 검사는 clean_필드명 메서드가 담당
- Form에 전달된 전체 data에 대한 유효성 검사는 clean 메서드가 담당
- 예) 하나의 필드인 username은 clean_username 메서드에 검증 로직을 작성
- 예) 비밀번호는 두 개의 필드 내용을 동시에 사용해야 하므로(password1, password2)하나의 필드 데이터만 가지고 검증할 수 없음. 이 때는 전체 데이터를 사용할 수 있는 clean 메서드를 사용
# users/forms.py
from django.core.exceptions import ValidationError
from users.models import User
...
class SignupForm(forms.Form):
username = forms.CharField()
password1 = forms.CharField(widget = forms.PasswordInput)
password2 = forms.CharField(widget = forms.PasswordInput)
profile_image = forms.ImageField()
short_description = forms.CharField()
def clean_username(self):
username = self.cleaned_data["username"]
if User.objects.filter(username=username).exists():
raise ValidationError(f"입력한 사용자명 ({username})은 이미 사용 중 입니다")
return username
- clean_username 은 SignupForm에 전달된 username 키에 해당하는 값을 검증할 때 사용됨
- 검증하려는 필드 데이터에 접근할 때는 self.cleaned_data["필드명"]에서 값을 가져오며
- 이 값을 사용할 수 있다면 함수에서 return 해주고, 유효하지 않다면 ValidationError를 발생시킴
- clean_username에서 ValidationError를 발생시키는 것은 Form.add_error("username", 입력한 에러 메시지)를 호출하는 것과 같음
# users/forms.py
class SignupForm(forms.Form):
...
def clean_username(self):
...
def clean(self):
password1 = self.cleaned_data["password1"]
password2 = self.cleaned_data["password2"]
if password1 != password2:
# password2 필드에 오류를 추가
self.add_error("password2", "비밀번호와 비밀번호 확인란의 값이 다릅니다")
- 두 개 이상의 필드값을 동시에 비교해야 할 때는 전체 데이터의 검증을 수행하는 clean 메서드 내부에 로직을 구현
- clean_필드명 메서드와는 달리, clean메서드는 마지막 값을 리턴하지 않아도 됨
View 함수와 SignupForm 리팩터링
# users/view.py
def signup(request):
if request.method == "POST":
# print(request.POST)
# print(request.FILES)
form = SignupForm(data = request.POST, files = request.FILES)
if form.is_valid():
username = form.cleaned_data["username"]
password1 = form.cleaned_data["password1"]
profile_image = form.cleaned_data["profile_image"]
short_description = form.cleaned_data["short_description"]
user = User.objects.create_user(
username = username,
password = password1,
profile_image = profile_image,
short_description = short_description,
)
login(request, user)
return redirect("/posts/feeds/")
else:
context = {"form" : form}
return render(request, "users/signup.html", context)
# GET 요청에서는 빈 form을 보여줌
else:
# SignupForm 인스턴스를 생성, Template에 전달
form = SignupForm()
context = {"form": form}
return render(request, "users/signup.html", context)
- 회원 가입과 관련된 모든 데이터는 SignupForm에 존재하므로, SignupForm이 회원가입 기능까지 담당하게 할 수 있음
# users/forms.py
class SignupForm(forms.Form):
...
def clean_username(self):
...
def clean(self):
...
def save(self):
username = self.cleaned_data["username"]
password1 = self.cleaned_data["password1"]
profile_image = self.cleaned_data["profile_image"]
short_description = self.cleaned_data["short_description"]
user = User.objects.create_user(
username = username,
password = password1,
profile_image = profile_image,
short_description = short_description,
)
return user
- View에서 form.cleaned_data 의 값을 사용해서 사용자를 만들던 로직을 Form내부의 메서드로 이동시켰음
- 이제부터 is_valid() 를 호출해 유효성 검증을 통과한 상태라면 save 메서드를 사용해 새 User를 생성할 수 있음
# users/views.py
def signup(request):
if request.method == "POST":
form = SignupForm(data = request.POST, files = request.FILES)
# Form에 에러가 없다면 form의 save() 메서드로 사용자를 생성
if form.is_valid():
user = form.save()
login(request, user)
return redirect("/posts/feeds/")
# GET 요청에서는 빈 form을 보여줌
else:
# SignupForm 인스턴스를 생성, Template에 전달
form = SignupForm()
# context로 전달되는 form은 두 가지 경우가 존재
# 1. POST 요청에서 생성된 form이 유효하지 않은 경우
# -> 에러를 포함한 form이 사용자에게 보여짐
# 2. GET 요청으로 빈 form이 생성된 경우
# -> 빈 form이 사용자에게 보여짐
context = {"form": form}
return render(request, "users/signup.html", context)
- signup 함수 진행 Case
- GET 요청
- SignupForm() 으로 생성된 빈 form을 사용자에게 보여줌
- POST 요청이며, 데이터를 받은 SignupForm이 유효한 경우
- SignupForm(data)로 생성된 form의 save() 메서드로 User 생성, redirect로 경로가 변경됨
- POST 요청이며, 데이터를 받은 SignupForm이 유효하지 않은 경우
- SignupForm(data) 로 생성된 form에는 error가 추가되며, 그 form이 사용자에게 보여짐
# templates/users/signup.html
{% load static %}
<!DOCTYPE html>
<html lang="ko">
<head>
<link rel="stylesheet" href="{% static 'css/pystagram_style.css' %}"/>
</head>
<body>
<div id="signup">
<form method="POST" enctype="multipart/form-data">
<h1>Pystagram</h1>
{% csrf_token %}
{{ form.as_p}}
<button type="submit" class="btn btn-signup">회원가입</button>
</form>
</div>
</body>
</html>
12. Template 스타일링과 구조 리팩터링
- 해당 프로젝트에서는 모든 템플릿에 동일한 css파일을 쓸 것이고, 전체 사이트의 기반 구조도 반복적인 구조가 될 것임
- 이런 반복적인 구조를 모든 Template에 각각 작성하는 것은 비효율적
- Template 파일의 고정 요소를 재사용하도록 개선
Template을 확장하는 {% extends %} 태그
- {% extends "템플릿 경로" %} 태그는 입력한 경로의 Template 을 기반으로 새 Template 을 생성
# templates/base.html
{% load static %}
<!DOCTYPE html>
<html lang="ko">
<head>
<link rel="stylesheet" href="{% static 'css/pystagram_style.css' %}">
</head>
<body>
{% block content %}{% endblock %}
</body>
</html>
- 공통되는 부분은 남겨두고, Template마다 변경되는 부분은 {% block content %}{% endblock %} 으로 치환
- {% block %} 영역은 이 Template을 확장하는 하위 템플릿에서 변경 가능한 부분들
- base.html에는 하나의 block밖에 없으므로, base.html을 확장(extend)하는 하위 Template들은 content block 내의 영역만 편집 가능하며 나머지 부분은 base.html의 변경사항을 따라가게 됨
- base.html의 내용을 기반으로 하도록 login.html의 내용을 수정
# users/login.html
{% extends 'base.html' %}
{% block content %}
<div id="login">
<form method="POST">
<h1>Pystagram</h1>
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn btn-login">로그인</button>
</form>
</div>
{% endblock %}
- content block 내부를 채우려면 {% block content %}로 블록 영역이 시작함을 알리고, {% endblock %}으로 영역이 끝났음을 선언해 주어야 함
# templates/users/signup.html
{% extends 'base.html' %}
{% block content %}
<div id="signup">
<form method="POST" enctype="multipart/form-data">
<h1>Pystagram</h1>
{% csrf_token %}
{{ form.as_p}}
<button type="submit" class="btn btn-signup">회원가입</button>
</form>
</div>
{% endblock %}
피드 페이지
- 하나의 글에 여러 이미지와 댓글이 연결될 수 있도록 ForeignKey를 사용해 일대다 관계를 구성
# posts/models.py
from django.db import models
# Create your models here.
class Post(models.Model):
user = models.ForeignKey("users.User",
verbose_name = "작성자",
on_delete = models.CASCADE)
content = models.TextField("내용", blank = True)
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)
admin 구현
- 기능을 View와 Template으로 구현하기 전에 관리자 기능을 먼저 만들어 테스트데이터를 추가하며 모델 구성이 올바른지 확인
# posts/admin.py
from django.contrib import admin
from posts.models import Post, PostImage, Comment
# Register your models here.
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = [
"id",
"content",
"created",
]
@admin.register(Comment)
class CommentAdmin(admin.ModelAdmin):
list_display = [
"id",
"post",
"content",
"created",
]
@admin.register(PostImage)
class PostImageAdmin(admin.ModelAdmin):
list_display = [
"id",
"post",
"photo",
]
- 현재 관리자페이지에서는 하나의 Post에 연결된 다른 객체들을 확인하기 어려움
- 관리자 페이지의 Post 목록이나 상세 화면에서는 각각의 글에 얼마나 많은 PostImage와 Commnet가 연결되어 있는지 알 수 없으며, 관리자페이지의 PostImage 목록이나 Comment목록 화면으로 와야만 내용을 확인할 수 있음
- PostAdmin에서 연결된 이미지/댓글에 해당하는 모든 객체를 확인할 수 있도록 개선
admin 에 연관 객체 표시
- ForeignKey 로 연결된 다른 객체들을 보려면 admin의 Inline 기능을 사용
# posts/admin.py
class CommentInline(admin.TabularInline):
model = Comment
extra = 1
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = [
"id",
"content",
"created",
]
inlines = [
CommentInline,
]
- 글 변경 페이지에 접근하면 내용 외에도 COMMENT 항목을 볼 수 있음
- 여기에는 지금까지 작성된 댓글 목록이 출력되며, 아래의 빈칸에 내용을 추가하고 저장 버튼을 눌러 새 댓글을 추가할 수도 있음
- 삭제 필드의 체크박스에 체크한 후 저장하면, 체크된 댓글은 삭제됨
# posts/admin.py
class PostImageInline(admin.TabularInline):
model = PostImage
extra = 1
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = [
"id",
"content",
"created",
]
inlines = [
CommentInline,
PostImageInline,
]
썸네일 이미지 표시
- Django admin은 inline에 이미지 썸네일을 표시하는 기능을 제공하지 않음
- 하지만 admin의 내부동작을 조작해서 썸네일을 표시하거나, 썸네일을 표시해주는 오픈소스를 사용해서 기능을 구현할 수 있음
오픈소스 라이브러리를 사용한 썸네일 표시
pip install django-admin-thumbnails
# posts/admin.py
import admin_thumbnails
...
@admin_thumbnails.thumbnail("photo")
class PostImageInline(admin.TabularInline):
model = PostImage
extra = 1
피드 페이지
# templates/posts/feeds.html
{% extends 'base.html' %}
{% block content %}
<nav>
<h1>Pystagram</h1>
</nav>
<div id="feeds" class="post-container">
<!-- 전달된 Post QuerySet 객체를 순회 -->
{% 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 %}
# posts/views.py
from django.shortcuts import render, redirect
from posts.models import Post
# Create your views here.
def feeds(request):
# 요청에 포함된 사용자가 로그인하지 않은 경우
if not request.user.is_authenticated:
# /users/login/ URL 로 이동시킴
return redirect("/users/login/")
# 모든 글 목록을 템플릿으로 전달
posts = Post.objects.all()
context = {"posts" : posts}
return render(request, "posts/feeds.html", context)
이미지 슬라이더 구현
# templates/base.html
{% load static %}
<!DOCTYPE html>
<html lang="ko">
<head>
<link rel="stylesheet" href="{% static 'css/pystagram_style.css' %}">
<link rel="stylesheet" href="{% static 'splide/splide.css' %}">
<script src="{% static 'splide/splide.js' %}"></script>
</head>
<body>
{% block content %}{% endblock %}
</body>
</html>
# templates/posts/feeds.html
{% extends 'base.html' %}
{% block content %}
<nav>
<h1>Pystagram</h1>
</nav>
<div id="feeds" class="post-container">
<!-- 전달된 Post QuerySet 객체를 순회 -->
{% 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>
<!-- 이미지 슬라이드 영역 시작 -->
<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>
<script>
const elms = document.getElementsByClassName('splide')
for(let i = 0; i < elms.length; i++){
new Splide(elms[i]).mount();
}
</script>
{% endblock %}
- {% for image in post.postimage_set.all %} 구문으로 각각의 Post에 연결된 PostImage 객체를 순회
- 각각의 PostImage 객체는 photo 라는 ImageField를 가지고 있으므로 photo.url 속성으로 저장도니 이미지 파일의 URL 값을 가져옴
- HTML 요소의 작성과 별개로 슬라이더 기능은 자바스크립트로 별도의 동작을 해야함
- 템플릿 하단에 script 태그를 추가해 자바스크립트 코드를 작성
글 속성 출력
# templates/posts/feeds.html
<!-- 전달된 Post QuerySet 객체를 순회 -->
{% 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>
<!-- 이미지 슬라이드 영역 시작 -->
<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>
<div class="post-content">
{{ post.content|linebreaksbr }}
</div>
</article>
{% endfor %}
좋아요/댓글 버튼 표시
# templates/posts/feeds.html
<div class="post-content">
{{ post.content|linebreaksbr }}
</div>
<div class="post-buttons">
<button>Likes(0)</button>
<span>Comments(0)</span>
</div>
</article>
{% endfor %}
- 좋아요는 누를 수 있는 버튼 역할을 할 것이므로 button 태그를 사용
- 댓글 수는 단순히 보여주기만 할 것이므로 특별한 역할을 가지지 않는 span 태그를 사용
댓글 목록 표시
- Post와 연결된 PostComment들을 표시
# templates/post/feeds.html
<div class="post-buttons">
<button>Likes(53)</button>
<span>Comments(5)</span>
</div>
<div class="post-comments">
<ul>
<!-- 각 Post에 연결된 Comment들을 순회 -->
{% for comment in post.comment_set.all %}
<li>
<span>{{ comment.user.username }}</span>
<span>{{ comment.content }}</span>
</li>
{% endfor %}
</ul>
</div>
작성일자, 댓글 입력창 표시
# templates/posts/feeds.html
<div class="post-comments">
<ul>
<!-- 각 Post에 연결된 Comment들을 순회 -->
{% for comment in post.comment_set.all %}
<li>
<span>{{ comment.user.username }}</span>
<span>{{ comment.content }}</span>
</li>
{% endfor %}
</ul>
</div>
<small>{{ post.created }}</small>
<div class="post-commnet-create">
<input type="text" placeholder="댓글 달기...">
<button type="submit">게시</button>
</div>
</article>
{% endfor %}
글과 댓글
댓글 작성
- 이전의 블로그 프로젝트에서는 목록 화면에서는 댓글을 보여주김나 하면 되며, 댓글 작성은 글 내부에서만 가능했지만
- 피드 페이지에서는 댓글 달기 기능을 한 화면에서 각 Post마다 구현해야함
CommentForm 구현
- 사용자의 입력을 받는 input을 직접 만들 수도 있지만 Form 클래스를 사용하는 것이 Django의 기본 규칙
ModelForm
- 이전까지는 forms.Form 클래스를 사용했지만 ModelForm클래스를 사용하면 DB테이블에 해당하는 모델 클래스와 연관된 기능들을 제공함
# posts/forms.py
from django import forms
from posts.models import Comment
class CommentForm(forms.ModelForm):
class Meta:
model = Comment
fields = [
"content",
]
- Form클래스에서는 data에 전달된 딕셔너리의 content키로 전달된 데이터를 받기 위해 content = forms.CharField() 로 필드를 선언해야 했음
- ModelForm에서는 class Meta 속성 내에 fields 를 선언
- Django 모델에 있는 필드인 models.CharField() 나 models.IntegerField()는 ModelForm에서 forms.CharField() 나 forms.IntegerField()와 같은 Form에서 사용하는 Field로 자동으로 변환됨
- ModelForm 인스턴스에서 save() 를 호출하면 전달받은 데이터를 사용해서 지정된 모델 인스턴스를 생성해야하지만 오류가 발생
- 발생한 오류는 post_comment 테이블의 post_id는 NULL을 허용하지 않는다는 메시지
- post_comment 는 Comment 모델의 실제 DB 테이블명
- post_id 는 Comment모델의 post = models.ForeignKey(Post) 필드에 해당함
- Comment 객체를 생성할 때는 반드시 어떤 Post와 연결될지 지정해 주어야 함
- 발생한 오류는 post_comment 테이블의 post_id는 NULL을 허용하지 않는다는 메시지
- 오류 해결 방법은 두 가지
- CommentForm으로 Comment 객체를 일단 만들되, 메모리상에 객체를 만들고 필요한 데이터를 나중에 채우기
- CommnetForm 에 NULL을 허용하지 않는 모든 필드를 선언하고, 인스턴스 생성 시 유효한 데이터를 전달
- ModelFrom 에 모든 필드를 지정하면 별도 작업 없이 save() 만 호출하면 새 모델 인스턴스가 생기므로 fields 리스트에 모든 필드를 지정하는게 맞다고 생각할 수 있음
- 하지만 Form에서 전달받는 데이터는 사용자가 입력한 데이터 임
- Comment를 생성하기 위해 필요한 데이터는 3가지
- 어떤 글(Post)의 댓글인지
- 어떤 사용자(User)의 댓글인지
- 어떤 내용(Content)을 가지고 있는지
- 사용자가 입력하는 데이터는 1번과 3번
- 어떤 사용자가 댓글을 생성했는지는 사용자가 입력한 데이터에 있으면 안되는 값이며, 시스템에서 자동으로 입력되어야 함
- 이 부분을 사용자가 전달하는 데이터에서 가져온다면, 악의적인 사용자는 자신이 보내는 값을 변조하여 자신이 아닌 다른 사용자가 댓글을 작성한 것처럼 보이게 만들 수 있음
- 그러므로 CommentForm은 post와 content만을 전달받은 값으로 지정해야 하며, 작성자 정보인 user는 시스템에서 채을 수 있어야 함
# post/forms.py
from django import forms
from posts.models import Comment
class CommentForm(forms.ModelForm):
class Meta:
model = Comment
fields = [
"post",
"content",
]
View 에서 Template으로 Form전달
- 피드 페이지의 댓글 입력 input을 CommentForm 으로 생성할 수 있도록 전달
# posts/views.py
from posts.forms import CommentForm
# Create your views here.
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
<div class="post-comment-create">
<form method="POST">
{% csrf_token %}
<!-- <input type="text" placeholder="댓글 달기..." /> -->
{{ comment_form.as_p }}
<button type="submit">게시</button>
</form>
</div>
- CommentForm 에는 post와 content필드가 있고, 이 둘을 as_p 로 렌더링한 결과
- 여기엔 두 가지 문제가 있음
- 포스트(post필드)의 드롭다운 요소를 클릭하면 Post객체를 선택할 수 있음
- 사용자가 어떤 글에 댓글을 다는지는 직접 입력할 필요 없이 템플릿에서 알아서 처리해주어야 함
- 자동으로 label 태그와 input 태그가 만들어짐
- 여기서는 내용: 으로 나타나는 label 태그가 필요하지 않으며, content 값을 입력받을 input 태그만 있으면 됨
- 포스트(post필드)의 드롭다운 요소를 클릭하면 Post객체를 선택할 수 있음
- 결과적으로 지금은 필드마다 별도 설정을 사용해야 함
# templates/posts/feeds.html
<div class="post-comment-create">
<form method="POST">
{% csrf_token %}
<!-- <input type="text" placeholder="댓글 달기..." /> -->
<!-- {{ comment_form.as_p }} -->
<!-- 전달된 CommentForm 의 필드들 중 "content" 필드만 렌더링 -->
{{ comment_form.content }}
<button type="submit">게시</button>
</form>
</div>
- CommentForm 의 전체 필드를 렌더링할 필요가 없다면, Form의 필드명을 사용해 특정 필듬나 지정하여 렌더링 할 수 있음
- Form의 필드명을 직접 렌더링하면 label태그는 제외되며 데이터를 입력받는 input이나 textarea같이 실제로 값을 받는 요소만 만들어짐
# posts/forms.py
class CommentForm(forms.ModelForm):
class Meta:
model = Comment
fields = [
"post",
"content",
]
widgets = {
"content": forms.Textarea(
attrs={
"placeholder": "댓글 달기...",
}
)
}
댓글 작성 처리를 위한 View 구현
- 지금까지는 form을 사용한 POST 요청으로 받은 데이터를 같은 페이지에서 처리했음
- 블로그 프로젝트에서는 글 상세 페이지에 있는 댓글 작성 form에서 전송한 데이터는 글 작성 View에서 request.method에 따라 분기해서 처리
- 많은 역할을 하나의 View에서 처리하게 되면 코드를 유지보수하기 어려워짐
- 댓글 작성 form은 피드 페이지에 위치하지만
- 피드 페이지 View가 댓글 작성 기능을 처리할 이유는 없음
- 댓글 작성 form에서 전송한 데이터는 별도의 댓글 작성 View에서 처리
# posts/views.py
from django.views.decorators.http import require_POST
...
@require_POST # 댓글 작성을 처리할 View, Post 요청만 허용
def comment_add(request):
print(request.POST)
- view에 require_POST 데코레이터를 사용하면 오로지 POST 유형의 요청만 처리하며, 이 외 유형의 요청에는 405 Method Not Allowed 응답을 돌려줌
# posts/urls.py
urlpatterns = [
path("feeds/", views.feeds),
path("comment_add/", views.comment_add),
]
form에서 comment_add View 로 데이터 전달 및 처리
- 지금까지 form 요소에서 지정해본 속성값
- method : GET과 POST중 어떤 방식으로 데이터를 전달할지
- enctype : 기본값과 파일 전송을 위한 값 중 선택
- 이번에 사용할 속성값은 action
- action : 이 form 의 요청을 어디로 보낼지를 지정
- 비어 있는 경우에는 현재 브라우저의 URL을 사용
- 블로그 프로젝트에서 글 상세페이지에 있는 form은 method가 POST이며, action 속성을 지정하지 않았으므로, 글 상세 페이지에 해당하는 URL에 POST방식으로 데이터가 전달됨
- 이번에는 form 은 피드 페이지에 있지만, 요청의 데이터는 댓글 생성(comment_add) View 에 보낼 것이므로 action의 값을 댓글 생성 View와 연결되는 URL로 지정
- action : 이 form 의 요청을 어디로 보낼지를 지정
# templates/posts/feeds.html
<form method="POST" action="/posts/comment_add/">
{% csrf_token %}
<!-- <input type="text" placeholder="댓글 달기..." /> -->
<!-- {{ comment_form.as_p }} -->
<!-- 전달된 CommentForm 의 필드들 중 "content" 필드만 렌더링 -->
{{ comment_form.content }}
<button type="submit">게시</button>
</form>
- comment_add View 에서는 아직 적절한 HttpResponse 나 render를 돌려주지 않으므로, ValueError가 발생하며 HttpResponse object를 리턴하지 않았다는 오류가 발생
# templates/posts/feeds.html
<div class="post-comment-create">
<form method="POST" action="/posts/comment_add/">
{% csrf_token %}
<!-- 사용자가 직접 입력하지 않는 고정된 데이터를 form 내부에 위치 -->
<input type="hidden" name="post" value="{{ post.id }}">
<!-- 전달된 CommentForm 의 필드들 중 "content" 필드만 렌더링 -->
{{ comment_form.content }}
<button type="submit">게시</button>
</form>
</div>
- hidden 타입의 input값은 사용자에게 표시되지 않음
사용자 정보를 view에서 직접 할당
- 남은 값은 user값으로, 이 값을 사용자가 보내게 되면 악용할 수 있는 여지가 있으므로 view 함수 내부에서 Comment 생성 시 직접 지정하도록 함
- 댓글 생성이 완료되면, 다시 피드 페이지로 돌아가게 함
# posts/views.py
@require_POST # 댓글 작성을 처리할 View, Post 요청만 허용
def comment_add(request):
# print(request.POST)
# request.POST 로 전달된 데이터를 사용해 CommentForm 인스턴스를 생성
form = CommentForm(data = request.POST)
if form.is_valid():
# commit=False 옵션으로 메모리상에 Comment 객체 생성
comment = form.save(commit = False)
# Comment 생성에 필요한 사용자정보를 request에서 가져와 할당
comment.user = request.user
# DB에 Commit 객체 저장
comment.save()
# 생성된 Comment 의 정보 확인
#print(comment.id)
#print(comment.content)
#print(comment.user)
return redirect("/posts/feeds/")
- user 값에는 View 함수가 매개변수로 받는 request(요청) 객체에 있는 사용자 정보를 사용
- request 로 전달되는 User 객체는 사용자가 임의로 지정하거나 변경할 수 없음
작성 완료 후 원하는 Post 위치로 이동
- 댓글은 잘 작성되지만, 보완해야 할 부분이 있음
- 맨 위의 글에 댓글을 추가하면 작성 완료 후 피드 페이지로 이동했을 때 해당 글과 추가 글이 잘 보이지만
- 아래쪽 글에 댓글을 추가하면 댓글 작성 후 페이지 맨 위로 이동하게 되어 댓글을 찾기 힘듦
- 글에 댓글을 추가한 후 피드 페이지의 최상단이 아니라 댓글을 추가한 글로 돌아올 수 있도록 기능 구현
- HTML 요소의 id속성을 활용할 수 있음
# templates/posts/feeds.html
<div id="feeds" class="post-container">
<!-- 전달된 Post QuerySet 객체를 순회 -->
{% for post in posts %}
<article class="post" id="post-{{ post.id }}">
- post 를 순회하며 렌더링되는 각각의 Post 요소에 id 속성을 추가했음
- HTML 요소의 id값은 현재 브라우저에서 보고 있는 페이지에 유일해야함
- 이를 위해 post-{{ post.id }} 로 각 Post 마다 유일한 값을 갖는 속성인 id를 동적으로 할당했음
- 현재 피드 페이지 URL 뒤에 #HTML요소의 id를 입력하면 그 id를 가진 요소의 위치로 이동하게 됨
- 이를 이용해 댓글 작성이 완료된 후 피드 페이지에서 스크롤될 위치를 지정할 수 있음
# posts/views.py
@require_POST # 댓글 작성을 처리할 View, Post 요청만 허용
def comment_add(request):
...
# DB에 Commit 객체 저장
comment.save()
return redirect(f"/posts/feeds/#post-{comment.post.id}")
글의 댓글 수 표시
- Post에 몇 개의 Comment가 연결되어 있는지 표시
- 연결된 객체의 정보가 전부 필요하지 않고 단순히 개수만 필요하다면 QuerySet의 count메서드를 사용하면 좋음
# templates/posts/feeds.html
<div class="post-buttons">
<button>Likes(0)</button>
<span>Comments({{ post.comment_set.count }})</span>
</div>
댓글 삭제
- 삭제할 댓글의 id 정보를 받고, 받은 id에 해당하는 Comment 객체를 delete() 메서드로 삭제
# posts/views.py
@require_POST
def comment_delete(request, comment_id):
comment = Comment.objects.get(id = comment_id)
comment.delete()
return redirect(f"/posts/feeds/#post-{comment.post.id}")
# posts/urls.py
urlpatterns = [
path("feeds/", views.feeds),
path("comment_add/", views.comment_add),
path("comment_delete/<int:comment_id>/" , views.comment_delete),
]
- view 함수에서 comment_id를 받기로 했으므로, urls.py의 path를 정의할 때 comment_id를 int로 받을 수 있도록 해줌
삭제할 Comment가 요청한 사용자가 작성한 것인지 확인
- 현재 상태로는 Comment 를 작성한 소유자가 아니어도 댓글을 삭제할 수 있음
- View 함수에서 삭제 요청이 들어온 Comment의 작성자가 요청한 사용자와 일치하는지 먼저 확인해야함
# posts/views.py
from django.http import HttpResponseForbidden
...
@require_POST
def comment_delete(request, comment_id):
comment = Comment.objects.get(id = comment_id)
if comment.user == request.user:
comment.delete()
return redirect(f"/posts/feeds/#post-{comment.post.id}")
else:
return HttpResponseForbidden("이 댓글을 삭제할 권한이 없습니다")
- 요청한 사용자가 댓글의 작성자와 같은지 검사하고, 일치하는 경우에만 삭제
- 일치하지 않는다면, 권한이 없음을 브라우저에게 알려주는 HttpResponseForbidden을 return
- 403 상태코드 : 요청 데이터는 유효하나 해당 요청을 실행할 권한이 없다는 의미
- 일치하지 않는다면, 권한이 없음을 브라우저에게 알려주는 HttpResponseForbidden을 return
템플릿에 삭제 버튼 추가
- 현재 로그인한 사용자가 작성한 글에만 삭제 버튼을 추가
# templates/posts/feeds.html
<!-- 각 Post에 연결된 Comment들을 순회 -->
{% 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="subimit">삭제</button>
</form>
{% endif %}
- 현재 로그인된 사용자가 댓글의 작성자인지 확인 후 일치할 경우에만 삭제 버튼을 만들어줌
- 댓글 작성 기능에서는 action 이 고정 URL이었지만, 댓글 삭제시에는 어떤 댓글을 삭제할지 URL을 통해 받기 때문에 action에 들어가는 URL이 Comment 별로 다르게 만들어져야 함
글 작성하기
- 글 작성 기본구조 구현
- View : post_add
- URL : /posts/post_add
- Template : templates/posts/post_add.html
# posts/view.py
from posts.forms import CommentForm, PostForm
...
def post_add(request):
form = PostForm()
context = {"form" : form}
return render(request, "posts/post_add.html", context)
# posts/urls.py
urlpatterns = [
path("feeds/", views.feeds),
path("comment_add/", views.comment_add),
path("comment_delete/<int:comment_id>/" , views.comment_delete),
path("post_add/", views.post_add),
]
# templates/post_add.html
{% extends 'base.html' %}
{% block content %}
<nav>
<h1>Pystagram</h1>
</nav>
<div id="post-add">
<h1>Post 작성</h1>
<form method="POST">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">게시</button>
</form>
</div>
{% endblock %}
# posts/forms.py
class PostForm(forms.ModelForm):
class Meta:
model = Post
fields = [
"content",
]
- Post를 만들기 위해서는 내용 뿐 아니라 이미지 파일들도 필요한데, 이미지 파일을 저장하는 필드는 Post 모델이 아닌 PostImage 모델에 있음
- ModelForm은 기본적으로 class Meta 속성에 정의한 하나의 모델만을 생성할 수 있으며, 이미지 파일 여러장을 추가로 받아 처리하는 기능은 가지고 있지 않음
- 여러 장의 이미지를 업로드해서 PostImage 객체를 여러 개 생성하는 기능은 PostForm 과는 별도로 구성
여러 장의 이미지 업로드
- Template에 직접 <input type="file"> 구성
# templates/post/post_add.html
<form method="POST" enctype="multipart/form-data">
{% csrf_token %}
<div>
<!-- label의 for속성에는 가리키는 input의 id값을 입력 -->
<label for="id_images">이미지</label>
<input id="id_images" name="images" type="file" multiple>
</div>
{{ form.as_p }}
<button type="submit">게시</button>
</form>
- 파일을 첨부하기 위해 input의 type속성을 file로 선언했음
- 여러 개의 파일을 첨부하기 위해서는 multiple속성이 추가로 선언되어야 함
- multiple 속성은 선언만 하면 되며, 따로 값을 지정하지는 않음
- 여러 개의 파일을 첨부하기 위해서는 multiple속성이 추가로 선언되어야 함
- 파일을 첨부할 것이므로 form의 enctype을 multipart/form-data 로 지정
- label에 있는 for 속성은 이 label이 어떤 input에 대한 설명인지를 지정하는 역할이며, 값을 가리키는 input의 id속성값을 지정해야함
view에서 multiple속성을 가진 file input의 데이터 받기
- Template의 images 와 content중 content는 PostForm으로 전달하고 images로 전달된 여러 개의 파일을 별도로 처리해야 함
- multiple 속성으로 전달한 여러 개의 파일 데이터는 request.FILES 대신 request.FILES.getlist("전달된 input의 'name'속성의 값")으로 가져옴
# 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를 생성 한 후
# 전송된 이미지들을 순회하며 PostImage 객체를 생성
for image_file in request.FILES.getlist("images"):
PostImage.objects.create(
post = post,
photo = image_file,
)
# 모든 PostImage와 Post의 생성이 완료되면
# 피드 페이지로 이동하여 생성된 Post의 위치로 스크롤
url = f"/posts/feeds/#post-{post.id}"
return redirect(url)
# GET 요청일 때는 빈 form을 보여줌
else:
form = PostForm()
context = {"form" : form}
return render(request, "posts/post_add.html", context)
- 글 작성 화면에서 여러 파일을 선택하고 게시 버튼을 누르면 선택한 파일들이 request.FILES.getlist("images")에 리스트로 전달됨
- 파일들이 리스트 안에 들어 있으므로, 해당 파일 리스트를 for문으로 순회하며 각각의 이미지 파일을 사용해 PostForm으로 생성한 Post객체와 연결되는 PostImage 객체를 생성
동적 URL
URL 경로 변경
URL 경로를 변경할 때 생기는 중복작업
- 지금까지 템플릿과 View에서 특정 URL로 이동하거나, action에 URL을 입력하거나 할 때에 직접 URL 경로를 입력해왔음
- 이 방법은 직관적이지만, URL과 관련된 코드를 수정할 때 반드시 두 부분을 동시에 수정해야 함
- 로그인 페이지의 주소를 변경한닫고 가정하면
- 지금 로그인 페이지의 URL은 /users/login/
- 이 URL을 /users/login2/ 로 변경한다면 /users/login/ 을 사용한 모든 부분을 찾아서 변경해야함
- 프로젝트가 복잡해질수록 한 URL을 여러 곳에서 사용할 것이고, 변경할 부분이 점점 많아짐
- URLconf에 있는 로그인 페이지 URL 값이 변경되었을 때 자동으로 변경된 내용을 반영할 수 있다면 관리가 쉬워짐
- Django에서는 이를 위해 동적 URL을 사용할 수 있는 기능을 제공함
Template의 동적 URL 변경
동적 URL 생성을 위한 요소 추가
- 동적으로 URL을 생성해서 사용하기 위해서는 app별로 분리된 하위 urls.py 에 app_name 이라는 속성이 필요
- 일반적으로는 app의 패키지명을 사용
# users/urls.py
app_name = "users"
urlpatterns = [
path("login/", views.login_view),
path("logout/", views.logout_view),
path("signup/", views.signup),
]
# posts/urls.py
app_name = "posts"
urlpatterns = [
path("feeds/", views.feeds),
path("comment_add/", views.comment_add),
path("comment_delete/<int:comment_id>/" , views.comment_delete),
path("post_add/", views.post_add),
]
Template을 위한 {% url %} 태그
- {% 'URL pattern name' %} 태그는 Template 에서 urls.py 의 내용을 이용해 동적으로 URL을 생성해줌
- URL pattern name 은 {urls.py에 있는 app_name}:{path()에 지정된 name} 의 구조를 가짐
# users/urls.py
app_name = "users"
urlpatterns = [
path("login/", views.login_view, name = "login"),
path("logout/", views.logout_view, name = "logout"),
path("signup/", views.signup, name = "signup"),
]
- {% url %} 태그에 사용하는 app_name 과 path() 의 name 속성으로 만들 수 있는 이름을 URL pattern name 이라 부름
- 여기서 login_view와 연결되는 URL pattern name 은 users:login 이 됨
View의 동적 URL 변경
View를 위한 reverse 함수
- template에서 {% url %}태그를 사용하듯, View에서는 reverse함수로 동적 URL을 생성할 수 있음
reverse 함수를 사용하도록 기존 View 코드 수정
- redirect 함수에 reverse 함수에서 사용할 수 있는 값(: 으로 구분된 app_name:path_name)을 전달하면 reverse함수의 결괏값이 자동으로 적용됨
해시태그
다대다 관계 모델
- 다대일(Many-to-one, N:1) 관계는 한 테이블의 한 레코드가 다른 테이블의 여러 레코드와 연관됨을 나타냄
- 다대다(Many-to-Many, N:N) 관계는 한 테이블의 여러 레코드가 다른 테이블의 여러 레코드와 연관되는 관계
- 일반적으로 학생은 하나의 대학교에만 속함
- 이 관계는 다대일(학생:학교) 로 정의할 수 있음
- 한 학생은 여러 관계의 수업을 수강할 수 있으며, 하나의 수업은 그 수업을 수강신청한 여러 명의 학생을 가질 수 있음
- 이러한 관계를 다대다 관계라 부름
- 다대다 관계는 두 테이블의 연결을 정의하는 또 하나의 테이블이 필요
해시태그 모델 생성, ManyToMany 연결
- 해시태그 역시 학생과 수업과 같은 다대다 관계임
- 하나의 글은 여러 개의 해시태그를 가질 수 있으며, 해시태그로 검색하면 해당 해시태그를 가진 모든 글을 가져올 수 있어야 함
# posts/models.py
class HashTag(models.Model):
name = models.CharField("태그명", max_length=50)
- 다대다 관계는 관계를 정의하는 테이블이 각각의 모델과는 별개의 테이블로 만들어짐
- Post모델에서 다대다 관계를 선언하거나, HashTag모델에서 다대다 관계를 선언하거나 어느 쪽이든 중간에 테이블이 하나 만들어진다는 결과는 같음
- 둘 중에서 좀 더 타당하게 느껴지는 쪽을 다대다를 선언하는 모델로 정하면 됨
- 글(Post)에 해시태그 여러 개를 포함
- 해시태그(HashTag)에 글 여러 개를 포함
- 둘 중에서 좀 더 타당하게 느껴지는 쪽을 다대다를 선언하는 모델로 정하면 됨
# posts/models.py
class Post(models.Model):
user = models.ForeignKey("users.User",
verbose_name = "작성자",
on_delete = models.CASCADE)
content = models.TextField("내용", blank = True)
created = models.DateTimeField("작성일시", auto_now_add = True)
# posts앱 안에 있는 HashTag클래스
tags = models.ManyToManyField("posts.HashTag", verbose_name="해시태그 목록", blank = True)
...
class HashTag(models.Model):
name = models.CharField("태그명", max_length=50)
- {app이름}:{Model클래스이름} 형태로 문자열을 지정하면, 보다 아래쪽에서 선언한 Model 클래스도 참조할 수 있음
다대다 모델 admin
admin 구현
- django admin은 다대다 관계를 편집할 수 있는 admin을 내장하고 있음
- admin에서 해시태그를 관리하려면 관리자 페이지에 해시태그 모델을 등록해야함
# posts/admin.py
from posts.models import Post, PostImage, Comment, HashTag
...
@admin.register(HashTag)
class HashTagAdmin(admin.ModelAdmin):
pass
- 현재 관리자페이지에 나오는 것 처럼 여러 항목을 선택할 수 있는 HTML 요소는 multiple 속성이 추가된 select 태그임
- 여러 항목을 선택할 수는 있지만 체크박스 형태의 UI가 더 적합함
- admin에 formfield_overrides 옵션을 추가하면 선택할 항목을 checkbox로 표시할 수 있음
# post/admin.py
from django.db.models import ManyToManyField
from django.forms import CheckboxSelectMultiple
...
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = [
"id",
"content",
"created",
]
inlines = [
CommentInline,
PostImageInline,
]
# Post 변경화면에서 ManyToManyField를 Checkbox로 출력
formfield_overrides = {
ManyToManyField : {
"widget": CheckboxSelectMultiple,
}
}
Template 에 Post의 HashTag 표시
# templates/posts/feeds.html
<div class="post-content">
{{ post.content|linebreaksbr }}
<div class="post-tags">
{% for tag in post.tags.all %}
<span>#{{ tag.name }}</span>
{% endfor %}
</div>
</div>
- Post 모델에 tags라는 이름의 ManyToManyField를 선언했음
- ForeignKey를 역방향에서 참조할 때와 비슷하게 post.tags.all() 로 연결된 전체 HashTag 객체를 불러올 수 있음
- Template에서는 ()를 제외한 post.tags.all 을 사용
해시태그 검색
기본구조
- View : posts/views.py -> tags 함수
- URL : /posts/tags/{tag의 name}/
- Templates : templates/posts/tags.html
- View 함수에서 특정 해시태그를 찾기 위해 tag_name 이 매개변수로 전달될 수 있도록 함
# posts/views.py
def tags(request, tag_name):
return render(request, "posts/tags.html")
# posts/urls.py
app_name = "posts"
urlpatterns = [
path("feeds/", views.feeds, name = "feeds"),
path("comment_add/", views.comment_add, name = "comment_add"),
path("comment_delete/<int:comment_id>/" , views.comment_delete, name="comment_delete"),
path("post_add/", views.post_add, name = "post_add"),
path("tags/<str:tag_name>/", views.tags, name = "tags"),
]
# templates/posts/tags.html
{% extends 'base.html' %}
{% block content %}
<nav>
<h1>
<a href="{% url 'posts:feeds' %}">Pystagram</a>
</h1>
<a href="{% url 'posts:post_add' %}">Add post</a>
<a href="{% url 'users:logout' %}">Logout</a>
</nav>
<div id="tags">
<header class="tags-header">
<h2>#{{ tag_name }}</h2>
<div>게시물 {{ posts.count }}</div>
</header>
<div class="post-grid-container">
<div class="post-grid"></div>
</div>
</div>
{% endblock%}
View에서 해시태그를 찾고, 해당하는 Post 목록 돌려주기
# posts/views.py
def tags(request, tag_name):
tag = HashTag.objects.get(name=tag_name)
# tags(M2M 필드) 에 찾은 HashTag 객체가 있는 Post 들을 필터링
posts = Post.objects.filter(tags = tag)
# context로 Template에 필터링된 Post QuerySet을 넘겨주며
# 어떤 tag_name 으로 검색했는지도 넘겨줌
context = {
"tag_name" : tag_name,
"posts" : posts,
}
return render(request, "posts/tags.html", context)
Post 목록만큼 Grid 렌더링, tag_name 표시
# templates/posts/tags.html
<div id="tags">
<header class="tags-header">
<h2>#{{ tag_name }}</h2>
<div>게시물 {{ posts.count }}</div>
</header>
<div class="post-grid-container">
{% for post in posts %}
<div class="post-grid">{{ post.id }}</div>
{% endfor %}
</div>
</div>
각각의 Post가 가진 첫 번째 이미지 보여주기
# templates/posts/tags.html
{% for post in posts %}
<!-- Post에 연결된 PostImage가 있으며 -->
<!-- 연결된 첫 번째 PostImage의 photo가 비어있지 않은 경우 -->
{% if post.postimage_set.first and post.postimage_set.first.photo %}
<div class="post-grid">
<img src="{{ post.postimage_set.first.photo.url }}" alt="{{ post.id }}">
</div>
{% endif %}
{% endfor %}
- Post에 연결된 postimage가 없거나 PostImage의 photo 필드가 비어 있는 경우에는 .url로 이미지의 주소를 가져오는 로직에서 오류가 발생할 가능성이 있음
- 이미지를 렌더링하기 전에 {% if %} 태그 내에서 아래 두 가지 조건을 먼저 검사해야함
- Post에 연결된 PostImage가 있는지를 검사
- 연결된 PostImage가 있다면 해당 PostImage가 photo필드에 사진 파일을 갖고 있는지를 한번 더 검사
- 이미지를 렌더링하기 전에 {% if %} 태그 내에서 아래 두 가지 조건을 먼저 검사해야함
없는 해시태그로 검색했을 때 처리
- HashTag의 name속성으로 존재하는 문자열을 보내면 문제 없지만
- 없는 해시태그를 검색하면 오류 발생
- view에서 URL에서 입력된 문자열이 name속성인 HashTag를 찾지 못해 DoesNotExist에러가 발생
- HashTag에서 이름을 찾을 수 없는 경우에는 검색결과가 없다는 처리를 해야함
# posts/views.py
def tags(request, tag_name):
try:
tag = HashTag.objects.get(name=tag_name)
except HashTag.DoesNotExist:
# tag_name에 해당하는 HashTag를 찾지 못한 경우 빈 QuerySet을 돌려줌
posts = Post.objects.none()
else:
# tags(M2M 필드) 에 찾은 HashTag 객체가 있는 Post 들을 필터링
posts = Post.objects.filter(tags = tag)
# context로 Template에 필터링된 Post QuerySet을 넘겨주며
# 어떤 tag_name 으로 검색했는지도 넘겨줌
context = {
"tag_name" : tag_name,
"posts" : posts,
}
return render(request, "posts/tags.html", context)
# templates/posts/tags.html
{% for post in posts %}
<!-- Post에 연결된 PostImage가 있으며 -->
<!-- 연결된 첫 번째 PostImage의 photo가 비어있지 않은 경우 -->
{% if post.postimage_set.first and post.postimage_set.first.photo %}
<div class="post-grid">
<img src="{{ post.postimage_set.first.photo.url }}" alt="{{ post.id }}">
</div>
{% endif %}
{% empty %}
<p>검색된 게시물이 없습니다</p>
{% endfor %}
해시태그 생성
ManyToManyField 항목 추가 실습
- Post 에 정의된 tags ManyToManyField에 HashTag를 추가할 때는 add 함수를 사용
# templates/posts/post_add.html
<form method="POST" enctype="multipart/form-data">
{% csrf_token %}
<div>
<!-- label의 for속성에는 가리키는 input의 id값을 입력 -->
<label for="id_images">이미지</label>
<input id="id_images" name="images" type="file" multiple>
</div>
{{ form.as_p }}
<div>
<label for="id_tags">해시태그</label>
<input id="id_tags" name="tags" type="text" placeholder="쉼표(,)로 구분하여 여러 태그 입력" />
</div>
<button type="submit">게시</button>
</form>
# post/views.py
def post_add(request):
...
# 'tags'에 전달된 문자열을 분리해 HashTag 생성
tag_string = request.POST.get("tags")
if tag_string:
tag_name_list = [tag_name.strip() for tag_name in tag_string.split(",")]
for tag_name in tag_name_list:
# _ : 재활용할 생각이 없는 변수
tag, _ = HashTag.objects.get_or_create(
name = tag_name,
)
# get_or_create로 생성하거나 가져온 HashTag 객체를 Post의 tags에 추가
post.tags.add(tag)
# 모든 PostImage와 Post의 생성이 완료되면
# 피드 페이지로 이동하여 생성된 Post의 위치로 스크롤
# url = f"/posts/feeds/#post-{post.id}"
url = reverse("posts:feeds") + f"#post-{post.id}"
return redirect(url)
- request.POST 에 tags로 문자열이 전달되었을 경우에만 진행하며, 전달된 문자열을 쉼표 단위로 구분해서 HashTag.objects.get_or_create() 를 호출
- get_or_create() : 인수로 전달하는 값에 해당하는 객체가 이미 존재한다면 DB의 내용을 가져오고, 없다면 새로 DB에 생성
- 같은 이름을 가진 HashTag가 중복으로 존재할 필요 없으므로, create() 대신 get_or_create()를 사용
- get_or_create()의 결과는 2개의 아이템을 가진 튜플로 반환
- {DB에서 가져오거나 생성된 객체}, {생성 여부} = Model.objects.get_or_create(속성)
- 결과를 두 개의 변수에 할당하면, 첫 번째 변수에는 생성되거나 가져온 객체가 할당되며, 두 번째 변수에는 DB에 생성한 경우 True, 이미 존재하던 데이터를 가져오기만 했다면 False가 할당됨
- {DB에서 가져오거나 생성된 객체}, {생성 여부} = Model.objects.get_or_create(속성)
글 상세 페이지
- 기본구조
- View: posts/views.py -> post_detail
- URL: /posts/<post_id>/
- Template: templates/posts/post_detail.html
# posts/views.py
def post_detail(request, post_id):
post = Post.objects.get(id = post_id)
context = {
"post" : post,
}
return render(request, "posts/post_detail.html", context)
# posts/urls.py
app_name = "posts"
urlpatterns = [
path("feeds/", views.feeds, name = "feeds"),
path("comment_add/", views.comment_add, name = "comment_add"),
path("comment_delete/<int:comment_id>/" , views.comment_delete, name="comment_delete"),
path("post_add/", views.post_add, name = "post_add"),
path("tags/<str:tag_name>/", views.tags, name = "tags"),
path("<int:post_id>/", views.post_detail, name = "post_detail"),
]
# templates/posts/post_detail.html
{% extends 'base.html' %}
{% block content %}
<div id="post-container">
<h1>Post Detail</h1>
</div>
{% endblock %}
Template 내용 구현
- Post의 상세 화면은 피드 페이지의 각각의 글과 같음
- 피드 페이지 Template을 전체 복사한 후 for 태그만 삭제
※ 장고 템플릿 태그 주석법 {# #}
CommentForm 전달
- 존재하는 글의 상세화면에서는 대부분의 요소들이 정상적으로 출력되지만
- 하단의 댓글 입력 form은 게시 버튼 외에는 나타나지 않음
- feeds view함수에서는 각각의 Post하단의 댓글 입력에 사용할 CommentForm을 전달하지만, post_detail view함수에서는 CommentForm을 전달하지 않음
# posts/views.py
def post_detail(request, post_id):
post = Post.objects.get(id = post_id)
comment_form = CommentForm()
context = {
"post" : post,
"comment_form": comment_form,
}
return render(request, "posts/post_detail.html", context)
- 현재 feeds.html과 post_detail.html 은 for 태그를 제외한 article 태그의 내용을 중복으로 가지고 있음
- 이대로면 Post를 나타내는 방법이 달라질 때마다 두 Template을 함께 수정해야함
{% include %} 태그로 Template 재사용
article 태그를 post.html로 재사용
article 태그를 post.html로 재사용
- 다른 Template을 삽입할 때는 {% include %} 태그를 사용
- 피드 페이지와 글 상세 페이지의 article 태그와 nav태그 부분을 {% include %} 로 대체
해시태그 검색 결과에 링크 추가
- 피드 페이지나 글 상세 페이지에서 해시태그를 클릭하면 해시태그 검색결과로 올 수 있음
- 반대로 해시태그로 검색된 결과를 클릭했을 때 글 상세페이지로 갈 수 있도록 링크 추가
# templates/posts/tags.html
<div class="post-grid">
<a href="{% url 'posts:post_detail' post_id=post.id %}">
<img src="{{ post.postimage_set.first.photo.url }}" alt="">
</a>
</div>
글 작성 후 이동할 위치 지정
Post 상세 화면에서 댓글 작성 시 상세 화면으로 이동
기존의 댓글 작성 후 rediret 로직
- 댓글 추가를 처리하는 posts/views.py 의 comment_add view 함수를 보면, 댓글 작성 완료 후 피드 페이지로 이동하라느 응답을 돌려줌
- 하지만 댓글은 피드 페이지와 글 상세 페이지 양쪽에서 작성 가능
- 댓글 작성 완료 후 사용자를 이동시킬 페이지를 각각의 경우에 따라 다르게 지정하기
{% include %} 태그의 with 옵션
- 각각의 글을 나타내는 HTML 요소는 {% include 'post.html' %} 로 가져오고 있으며, 댓글을 작성하는 CommentForm은 post.html Template 내에서 사용하고 있음
- post.html을 {% include %} 로 가져오면서 댓글 작성 후 이동할 URL 값을 전달해야함
# templates/posts/post_detail.html
<div id="feeds" class="post-container">
{% url 'posts:post_detail' post.id as action_redirect_to %}
{% include 'posts/post.html' with action_redirect_url=action_redirect_to %}
</div>
- {% 태그명 as 변수명 %} : 태그로 만들어진 결괏값을 Template 내에서 사용할 변수에 할당
- {% url 'posts:post_detail' post.id as action_redirect_to %} 는 post.id의 상세 페이지 URL이 action_redirect_to 변수에 할당
- 이 변수에 할당된 상세페이지 URL은 댓글 작성이 완료된 후에 브라우저에서 이동해야 할 주소
- {% include 'Template명' with 변수명=값 %} : include로 가져올 Template에 변수명으로 값을 전달
- 이 코드에서는 post.html Template을 렌더할 때, action_redirect_url 이라는 변수를 추가적으로 사용할 수 있게 됨
# templates/posts/post.html
<form method="POST" action="{% url 'posts:comment_add' %}?next={{ action_redirect_url }}">
{% csrf_token %}
<!-- 사용자가 직접 입력하지 않는 고정된 데이터를 form 내부에 위치 -->
<input type="hidden" name="post" value="{{ post.id }}">
<!-- 전달된 CommentForm 의 필드들 중 "content" 필드만 렌더링 -->
{{ comment_form.content }}
<button type="submit">게시</button>
</form>
- 댓글을 생성하는 form의 action에 ?next= 값을 추가하여 댓글 작성 완료 후 다음으로 갈 URL 정보를 next 키로 전달
- 이 값은 댓글 작성 view 함수에서 사용
Template 중복 코드 제거
화면 단위 기능 정리
- 지금까지 만든 화면 단위 기능
- 로그인
- 회원가입
- 피드 페이지
- 글 상세 페이지
- 글 작성 페이지
- 비슷한 레이아웃을 가진 기능들을 묶으면
- 상단 네비게이션 바가 없는 레이아웃
- 로그인
- 회원가입
- 내비게이션 바가 있는 레이아웃
- 이미지 슬라이더 기능이 필요한 레이아웃
- 피드 페이지
- 글 상세 페이지
- 이미지 슬라이더가 없어도 되는 레이아웃
- 태그 페이지
- 글 작성 페이지
- 이미지 슬라이더 기능이 필요한 레이아웃
- 상단 네비게이션 바가 없는 레이아웃
- 위 목록에서 각 화면 단위 기능의 기반이 되는 레이아웃은 3가지
- 상단 내비게이션 바가 없는 레이아웃
- base.html
- 내비게이션 바가 있는 레이아웃
- base_nav.html
- 내비게이션 바가 있으며, 이미지 슬라이더 기능이 포함된 레이아웃
- base_slider.html
- 상단 내비게이션 바가 없는 레이아웃
base.html 분할
모든 기반 레이아웃의 최상단 Template
- extends 하는 기반 레이아웃들이 모두 extends할 최상단 Template을 구성
※ 직접적으로 호출하지 않는 함수는 _함수명.html 등으로 쓴다
# templates/_base.html
{% load static %}
<!DOCTYPE html>
<html lang="ko">
<head>
<link rel="stylesheet" href="{% static 'css/pystagram_style.css' %}">
<title>Pystagram</title>
{% block head %}{% endblock %}
</head>
<body>
{% block base_content %}{% endblock %}
</body>
</html>
로그인, 회원가입에서 사용할 base.html
- 다른 기능이 들어가 있지 않으므로 _base.html 의 내용을 그대로 사용하며 {% content %} 블록만 정의
# templates/base.html
{% extends '_base.html' %}
{% block base_content %}
{% block content %}{% endblock %}
{% endblock %}
글 작성에서 사용할 base_nav.html
# templates/base_nav.html
{% extends '_base.html' %}
{% block base_content %}
{% include 'nav.html' %}
{% block content %}{% endblock %}
{% endblock %}
피드, 글 상세에서 사용할 base_slider.html
- head 태그 내에서 슬라이더 동작을 위한 소스파일(js, css)를 가져오고, 하단에 script를 선언해 슬라이더 기능을 활성화
# templates/base_slider.html
{% extends '_base.html'%}
{% load static %}
{% block head %}
<link rel="stylesheet" href="{% static 'splide/splide.css' %}">
<script src="{% static 'splide/splide.js' %}"></script>
{% endblock %}
{% block base_content %}
{% include 'nav.html' %}
{% block content %}{% endblock %}
<script>
const elms = document.getElementsByClassName('splide')
for(let i = 0; i < elms.length; i++){
new Splide(elms[i]).mount();
}
</script>
{% endblock %}
분할한 Template을 사용하도록 코드 수정
좋아요 기능
- 좋아요 기능은 해시태그와 같은 다대다 관계를 사용
- 해시태그는 글 생성시 입력한 문자열을 쉼표 단위로 구분해서 생성했지만
- 좋아요는 form과 button으로 구성해서 언제든 추가/삭제 할 수 있는 토글방식으로 구현
좋아요 모델, 관리자 구성
ManyToManyField 추가
- 좋아요 기능은 해시태그와 같은 M2M DB 구조를 사용
- 한 사용자는 여러 개의 Post에 좋아요를 누를 수 있고
- 하나의 Post는 자신에게 좋아요를 누른 여러 사용자를 가질 수 있음
- 사용자가 좋아요를 누른 Post와 Post에 좋아요를 누른 사용자들의 관계는 사용자의 좋아요 액션으로 만들어짐
- 사용자쪽이 좀 더 주도적이므로 User 모델에 like_posts로 ManyToManyField를 정의하고, 좋아요 기능을 구현
# users/models.py
class User(AbstractUser):
profile_image = models.ImageField("프로필 이미지",
upload_to = "users/profile",
blank=True)
short_description = models.TextField("소개글", blank=True)
like_posts = models.ManyToManyField(
"posts.Post",
verbose_name = "좋아요 누른 Post목록",
related_name = "like_users",
blank = True,
)
- 이 필드에 정의한 related_name 속성은 역방향으로 Model을 참조할 때 사용하는 이름
- User 입장에서는 좋아요 한 목록을 user.like_posts.all() 로 불러올 수 있으며
- 반대로 Post 입장에서는 자신에게 좋아요 누른 User 목록을 post.like_users.all()로 불러올 수 있음
- related_name 을 별도로 지정하지 않으면 {모델명의 소문자}_set 으로 지정됨
- post.user_set 이라는 이름은 어떤 조건의 User 들과 연결된 것인지 알 수 없으므로, 이런 경우 의미를 명확히 나타내기 위해 related_name을 별도로 지정
admin 구성
UserAdmin에 like_posts 추가
- ManyToManyField를 선언한 모델에서는 fieldsets에 필드명을 추가
# users/admin.py
@admin.register(User)
class CustomUserAdmin(UserAdmin):
fieldsets = [
(None, {"fields" : ("username", "password")}),
("개인정보", {"fields" : ("first_name", "last_name", "email")}),
("추가필드", {"fields" : ("profile_image", "short_description")}),
("연관객체", {"fields" : ("like_posts",)}),
("권한", {"fields" : ("is_active", "is_staff", "is_superuser")}),
("중요한 일정", {"fields" : ("last_login", "date_joined")}),
]
PostAdmin 에 like_users 추가
- ManyToManyField를 정의한 모델에서는 fieldsets에 필드명을 추가하여 간단히 admin을 구성할 수 있지만, 역방향에서는 inline을 사용해야함
- Post에 역방향으로 접근하는 Comment와 PostImage를 연결할 때 사용한 것과 같이 Inline을 추가
# posts/admin.py
class LikeUserInline(admin.TabularInline):
model = Post.like_users.through
verbose_name = "좋아요 한 User"
verbose_name_plural = f"{verbose_name} 목록"
extra = 1
def has_change_permission(self, request, obj = None):
return False
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = [
"id",
"content",
"created",
]
inlines = [
CommentInline,
PostImageInline,
LikeUserInline,
]
# Post 변경화면에서 ManyToManyField를 Checkbox로 출력
formfield_overrides = {
ManyToManyField : {
"widget": CheckboxSelectMultiple,
}
}
- through 는 M2M 관계에서 중간 테이블
- has_change_permission 메서드는 보이는 객체의 수정 권한을 설정하는데 항상 False를 리턴하게 하여 객체의 수정을 막았음
- 이외에도 has_add_permission, has_delete_permission 메서드로 추가/삭제 조작에 제한을 줄 수 있음
좋아요 토글 액션
view 구현
- 좋아요를 누른 Post 목록을 위한 ManyToManyField는 User 에 있지만(like_posts), 좋아요를 누른는 액션 자체는 피드 페이지나 글 상세 페이지에서 이루어지므로 posts 앱에 정의하는 것이 더 타당함
- 별도의 Template은 만들지 않으며, posts/views.py 에 post_like View와 /posts/<post_id>/like/URL 을 사용
# posts/views.py
# URL에서 좋아요 처리할 Post의 id를 전달받음
def post_like(request, post_id):
post = Post.objects.get(id = post_id)
user = request.user
# 사용자가 "좋아요를 누른 Post목록"에 "좋아요 버튼을 누른 Post"가 존재한다면
if user.like_posts.filter(id = post.id).exists():
# 좋아요 목록에서 삭제
user.like_posts.remove(post)
# 존재하지 않는다면 좋아요 목록에 추가
else:
user.like_posts.add(post)
# next 값이 전달되었다면 해당 위치로
# 전달되지 않았다면 피드페이지에서 해당 Post 위치로 이동
url_next = request.GET.get("next") or reverse("posts:feeds") + f"#post-{post.id}"
return redirect(url_next)
- 좋아요 처리는 토글(toggle) 방식을 사용
- 이미 좋아요를 누른 상태라면 해제
- 그렇지 않다면 좋아요 상태로 만듦
- ManyToMany 연결을 제거하거나 추가하는 방식으로 구현할 수 있음
- 사용자가 좋아요를 누른 Post 목록에 좋아요 요청이 전달된 Post가 포함되어 있는지 판단하고
- 이미 존재한다면 연결을 삭제
- ManyToManyField 의 remove 메서드로 연결을 삭제할 수 있음
- 반대로 전달된 Post가 이미 좋아요를 누른 Post 목록에 속하지 않는다면
- add로 새로운 연결을 생성
- 생성이든 삭제든 로직이 실행된 후에는 댓글 작성할 때와 같이 next로 전달된 URL로 되돌아감
URLconf
- URL로 좋아요를 처리할 Post 정보를 받을 수 있도록 post_id를 포함
# posts/urls.py
app_name = "posts"
urlpatterns = [
path("feeds/", views.feeds, name = "feeds"),
path("comment_add/", views.comment_add, name = "comment_add"),
path("comment_delete/<int:comment_id>/" , views.comment_delete, name="comment_delete"),
path("post_add/", views.post_add, name = "post_add"),
path("tags/<str:tag_name>/", views.tags, name = "tags"),
path("<int:post_id>/", views.post_detail, name = "post_detail"),
path("<int:post_id>/like/", views.post_like, name ="post_like"),
]
Template의 좋아요 버튼에 form 추가
- 좋아요 액션을 처리할 form 을 추가
# templates/posts/post.html
<div class="post-buttons">
<form action="{% url 'posts:post_like' post_id=post.id %}?next={{ action_redirect_url }}"
method = "POST">
{% csrf_token %}
<button type="submit"
{% if user in post.like_users.all %}
style="color: red;"
{% endif %}>
<!-- Post의 좋아요를 누른 사용자 수를 가져옴 -->
Likes({{ post.like_users.count }})
</button>
</form>
<span>Comments({{ post.comment_set.count }})</span>
</div>
- form 의 action 주소는 방금 생성한 post_like View 함수로 연결되게 하며, DB데이터를 변경시키므로 method는 POST 방식을 사용
- 이 액션을 처리한 후 이동할 주소인 next 값은 댓글을 작성했을 때와 같으므로, post.html을 include할 때 전달되는 action_redirect_url을 재사용
- {% csrf_token %} 을 제외하면 form 내부에 아무런 input도 없음
- Post의 좋아요 토글 기능에는 특별히 전달할 데이터가 없고
- 이런 경우 내부 요소 없이 단순히 POST 요청만을 전달
- {% if user in post.like_users.all %} 태그로 post.like_users.all(이 Post에 좋아요를 누른 모든 User 목록)에 현재 로그인한 유저가 포함되는지 판단
- 좋아요를 누른 상태라면 button 태그의 style 속성에 color: red 값을 지정해 글자를 빨간색으로 바꾸어 사용자가 이 Post에 좋아요를 한 상태임을 표시
팔로우/팔로잉 기능
팔로우/팔로잉 모델, 관리자 구성
- 팔로우/팔로잉 관계는 해시태그, 좋아요와 같이 ManyToManyField를 사용해 다대다 관계로 구성되나 이들과는 다른 점이 있음
- 해시태그와 좋아요는 한쪽에서 연결은 반대쪽에서의 연결도 나타내는 대칭적(Symmetrical)인 관계
- 팔로우/팔로잉 관계는 한쪽에서의 연결과 반대쪽에서의 연결이 별도로 구분되는 비대칭적인 관계
- 따라서 팔로우/팔로잉 관계는 같은 테이블(User)에서의 관계를 나타내야 함
- 이 중개테이블의 데이터는 대칭적인 관계인 해시태그, 좋아요 기능과 달리 방향에 따라 나타내는 관계가 다른 비대칭적 관계를 나타냄
- From User에 있는 사용자는 To User에 있는 사용자를 팔로우
- 반면 To User의 사용자에게 From User에 있는 사용자는 자신을 팔로잉 하는 사용자로 취급
- 이 중개테이블의 데이터는 대칭적인 관계인 해시태그, 좋아요 기능과 달리 방향에 따라 나타내는 관계가 다른 비대칭적 관계를 나타냄
팔로우 관계 모델
- 좋아요, 해시태그에서는 다대다 연결의 중개 테이블이 자동으로 생성되었음
- 팔로우 관계에서는 중개 테이블을 직접 만들어서 사용할 예정
- 중개 테이블을 직접 생성하면 연결 관계 외에 다른 정보를 함께 저장할 수 있음
# users/models.py
class Relationship(models.Model):
from_user = models.ForeignKey(
"users.User",
verbose_name = "팔로우를 요청한 사용자",
related_name = "following_relationships",
on_delete = models.CASCADE,
)
to_user = models.ForeignKey(
"users.User",
verbose_name = "팔로우 요청의 대상",
related_name = "follower_relateionships",
on_delete = models.CASCADE,
)
created = models.DateTimeField(auto_now_add = True)
def __str__(self):
return f"관계 ({self.from_user} -> {self.to_user})"
- 이 Model 클래스의 from_user 는 팔로우/팔로잉 관계에서 팔로우를 요청한 사용자를 나타내며
- to_user는 팔로우 받은 사용자를 나타냄
- 직접 생성한 중개 테이블에는 추가 정보를 저장할 수 있음
- 관계가 형성된 시간을 created 필드에 저장
- User 에 팔로우/팔로잉 관계를 나타내는 following ManyToManyField를 추가
# users/models.py
class User(AbstractUser):
...
following = models.ManyToManyField(
"self",
verbose_name = "팔로우 중인 사용자들",
related_name = "followers",
symmetrical = False,
through = "users.Relationship",
)
- ManyToManyField의 첫 번째 인수로 self를 전달
- self는 같은 테이블로의 관계를 만들 때 사용
- symmetrical = False 로 이 관계가 비대칭적 관계임을 나타냄
- through = "users.Relationship" 로 Relationship 테이블을 중개 테이블로 사용해 관계를 형성하도록 함
팔로우관계 admin
- 같은 테이블로의 관계를 형성했기 때문에 하나의 User에서 자신을 팔로잉하는 사용자들과 자신이 팔로우한 사용자들의 두가지 관계를 동시에 볼 수 있어야 함
# users/admin.py
class FollowersInline(admin.TabularInline):
model = User.following.through
fk_name = "from_user"
verbose_name = "내가 팔로우하고 있는 사용자"
verbose_name_plural = f"{verbose_name} 목록"
class FollowingInline(admin.TabularInline):
model = User.following.through
fk_name = "to_user"
verbose_name = "나를 팔로우하고 있는 사용자"
verbose_name_plural = f"{verbose_name} 목록"
@admin.register(User)
class CustomUserAdmin(UserAdmin):
- User.following.through 는 User.following 필드의 다대다 연결을 구성하는 중개 테이블
- fk_name 은 해당 중개 테이블에서 한 쪽으로의 연결을 구성하는 ForeignKey 필드를 나타냄
- FollowersInline 은 내가 팔로우하는 사용자들을 나타냄
- 이 관계는 중개 테이블에서 from_user 필드로 알아낼 수 있음
- User 객체의 입장에서 자신이 from_user 인 데이터들은 자신이 팔로우하는 사용자들을 나타냄
- FollowingInline에서는 중개 테이블의 to_user 필드를 사용
- 자신이 to_user인 데이터들은 자신에게 팔로우하는 사용자들을 나타냄
# users/admin.py
@admin.register(User)
class CustomUserAdmin(UserAdmin):
fieldsets = [
(None, {"fields" : ("username", "password")}),
("개인정보", {"fields" : ("first_name", "last_name", "email")}),
("추가필드", {"fields" : ("profile_image", "short_description")}),
("연관객체", {"fields" : ("like_posts",)}),
("권한", {"fields" : ("is_active", "is_staff", "is_superuser")}),
("중요한 일정", {"fields" : ("last_login", "date_joined")}),
]
inlines = [
FollowersInline,
FollowingInline,
]
프로필 페이지
프로필 페이지 기본 구조 구성 및 연결
- View : users/views.py -> profile
- URL : /users/<int:user_id>/profile/
- Template : templates/Users/profile.html
# users/views.py
def profile(request, user_id):
return render(request, "users/profile.html")
# users/urls.py
app_name = "users"
urlpatterns = [
path("login/", views.login_view, name = "login"),
path("logout/", views.logout_view, name = "logout"),
path("signup/", views.signup, name = "signup"),
path("<int:user_id>/profile/", views.profile, name = "profile"),
]
#templates/users/profile.html
{% extends 'base_nav.html' %}
{% block content %}
<div id="profile">
<h1>Profile</h1>
</div>
{% endblock %}
프로필 Template에 정보 전달
# users/views.py
def profile(request, user_id):
user = get_object_or_404(User, id = user_id)
context = {
"user" : user,
}
return render(request, "users/profile.html", context)
- get_object_or_404 는 첫 번째 인수로 Model 클래스를 받고, 나머지 인수들에 해당 Model클래스를 찾을 조건을 지정
- 조건에 해당하는 객체가 있다면 리턴되며, 해당하는 객체가 없다면 브라우저에 404 Not Found 응답을 돌려줌
# templates/users/profile.html
{% extends 'base_nav.html' %}
{% block content %}
<div id="profile">
<div class="info">
<!-- 프로필 이미지 영역 -->
{% if user.profile_image %}
<img src="{{ user.profile_image.url }}">
{% endif %}
<!-- 사용자 정보 영역 -->
<div class="info-texts">
<h1>{{ user.username }}</h1>
<div class="counts">
<dl>
<dt>Posts</dt>
<dd>{{ user.post_set.count }}</dd>
<dt>Followers</dt>
<dd>{{ user.followers.count }}</dd>
<dt>Following</dt>
<dd>{{ user.following.count }}</dd>
</dl>
</div>
<p>{{ user.short_description }}</p>
</div>
</div>
<!-- 사용자가 작성한 Post 목록 -->
<div class="post-grid-container">
{% for post in user.post_set.all %}
{% if post.postimage_set.first %}
{% if post.postimage_set.first.photo %}
<div class="post-grid">
<a href="{% url 'posts:post_detail' post_id=post.id %}">
<img src="{{ post.postimage_set.first.photo.url }}" alt="">
</a>
</div>
{% endif %}
{% endif %}
{% endfor %}
</div>
</div>
{% endblock %}
- 프로필 정보는 전달받은 User 객체의 값과 역방향 관계들(post_set, followers, following)을 표시하며 사용자가 작성한 Post 목록은 해시태그 검색 결과의 표시 방법과 같음
팔로우/팔로잉 목록
중개 테이블의 데이터 가져오기
- 자신이 팔로우한 사용자 수와 자신을 팔로잉하는 사용자 수를 가져오는 것은 User 에 정의된 following ManyToManyField와 역방향 매니저명인 followers의 count 메서드로 쉽게 알아낼 수 있음
- 중개 테이블에 정의된 생성일시(created) 는 ManyToManyField명 대신 중개 테이블에 정의된 related_name을 사용해서 가져올 수 있음
base_profile.html 구성
- 프로필 페이지에서는 사용자 정보와 사용자의 Post 목록을 표시
- 프로필 페이지의 사용자 정보는 재사용하고
- Post목록 대신 팔로우/팔로잉 목록을 사용할 수 있도록
- 상단 사용자 정보를 공통으로 사용하는 기반 Template인 base_profile.html 을 구성
# templates/base_profile.html
{% extends 'base_nav.html' %}
{% block content %}
<div id="profile">
<div class="info">
<!-- 프로필 이미지 영역 -->
{% if user.profile_image %}
<img src="{{ user.profile_image.url }}">
{% endif %}
<!-- 사용자 정보 영역 -->
<div class="info-texts">
<h1>{{ user.username }}</h1>
<div class="counts">
<dl>
<dt>Posts</dt>
<dd>{{ user.post_set.count }}</dd>
<dt>Followers</dt>
<dd>{{ user.followers.count }}</dd>
<dt>Following</dt>
<dd>{{ user.following.count }}</dd>
</dl>
</div>
<p>{{ user.short_description }}</p>
</div>
</div>
{% block bottom_data %}{% endblock %}
</div>
{% endblock %}
# templates/users/profile.html
{% extends 'base_profile.html' %}
{% block bottom_data %}
<!-- 사용자가 작성한 Post 목록 -->
<div class="post-grid-container">
{% for post in user.post_set.all %}
{% if post.postimage_set.first %}
{% if post.postimage_set.first.photo %}
<div class="post-grid">
<a href="{% url 'posts:post_detail' post_id=post.id %}">
<img src="{{ post.postimage_set.first.photo.url }}" alt="">
</a>
</div>
{% endif %}
{% endif %}
{% endfor %}
</div>
{% endblock %}
팔로우/팔로잉 목록
자신을 팔로우하는 사용자 목록(followers)
- View : users/views.py -> followers
- URL : /users/<int:user_id>/followers/
- Template: templates/users/followers.html
자신이 팔로우하는 사용자목록(following)
- View : users/views.py -> following
- URL: /users/<int:user_id>/following/
- Template: templates/users/following.html
users/views.py
def followers(request, user_id):
user = get_object_or_404(User, id = user_id)
relationships = user.follower_relationships.all()
context = {
"user" : user,
"title" : "followers",
"relationships" : relationships,
}
return render(request, "users/followers.html" ,context)
def following(request, user_id):
user = get_object_or_404(User, id = user_id)
relationships = user.following_relationships.all()
context = {
"user" : user,
"title": "Following",
"relationships": relationships,
}
return render(request, "users/following.html", context)
#users/urls.py
app_name = "users"
urlpatterns = [
...
path("<int:user_id>/followers/", views.followers, name = "followers"),
path("<int:user_id>following/", views.following, name = "following"),
]
# templates/users/followers.html
{% extends 'base_profile.html' %}
{% block bottom_data %}
<div class="relationships">
<h3>{{ title }}</h3>
{% for relationship in relationships %}
<div class="relationship">
<a href="{% url 'users:profile' user_id=relationship.from_user.id %}">
{% if relationship.from_user.profile_image %}
<img src="{{ relationship.from_user.profile_image.url }}">
{% endif %}
<div class="relationship-info">
<span>{{ relationship.from_user.username }}</span>
<span>{{ relationship.created|date:"y.m.d" }}</span>
</div>
</a>
</div>
{% endfor %}
</div>
{% endblock %}
팔로우 버튼
팔로우 토글 View
- Post의 좋아요 기능과 같이 이미 팔로우되어 있다면 언팔로우를, 팔로우되어 있지 않다면 팔로우 목록에 추가하는 토글 기능을 사용
- View : users/views.py -> follow
- URL : /userw/<int:user_id>/follow/
- Template : 없음
# users/views.py
def follow(request, user_id):
# 로그인 한 유저
user = request.user
# 팔로우 하려는 유저
target_user = get_object_or_404(User, id = user_id)
# 팔로우 하려는 유저가 이미 자신의 팔로잉 목록에 있는 경우
if target_user in user.following.all():
# 팔로잉 목록에서 제거
user.following.remove(target_user)
# 팔로우하려는 유저가 자신의 팔로잉 목록에 없는 경우
else:
# 팔로잉 목록에 추가
user.following.add(target_user)
# 팔로우 토글 후 이동할 URL이 전달되었다면 해당 주소로
# 전달되지 않았다면 로그인 한 유저의 프로필 페이지로 이동
url_next = request.GET.get("next") or reverse("users:profile", args=[user.id])
return redirect(url_next)
# users/urls.py
app_name = "users"
urlpatterns = [
...
path("<int:user_id>/follow/", views.follow, name = "follow"),
]
# templates/posts/post.html
<header class="post-header">
<a href="{% url 'users:profile' user_id=post.user.id %}">
{% if post.user.profile_image %}
<img src = "{{ post.user.profile_image.url }}">
{% endif %}
<span>{{ post.user.username }}</span>
</a>
<!-- 글의 작성자가 로그인 한 사용자라면 팔로우 버튼을 표시하지 않음 -->
<!-- (자기 자신을 팔로우 하는 것을 방지) -->
<!-- 로그인한 유저 != 게시글의 작성자 -->
{% if user != post.user %}
<form
action="{%url 'users:follow' user_id=post.user.id %}?next={{ action_redirect_url }}"
method="POST">
{% csrf_token %}
<button type="submit" class="btn btn-primary">
<!-- 이 Post의 작성자가 이미 자신의 팔로잉 목록에 포함된 경우 -->
{% if post.user in user.following.all %}
Unfollow
<!-- 이 Post의 작성자를 아직 팔로잉 하지 않은 경우 -->
{% else %}
Follow
{% endif %}
</button>
</form>
{% endif %}
</header>
728x90
'03_Web' 카테고리의 다른 글
19_파일 관리 (0) | 2025.02.18 |
---|---|
18_ 블로그 Django 프로젝트 (1) | 2025.02.17 |
16_글과 댓글 모델(블로그) 구현 (4) | 2025.02.07 |
15_검색기능이 되는 화면 만들기 (2) | 2025.02.06 |
14_VIEW, URL, Template (1) | 2025.02.06 |