79 Django 소셜로그인 인증활용하여 댓글기능 만들기
79.1 Social login , comment
이전 까지 댓글 등록/수정/삭제 기능과 소셜 로그인 기능을 만들었습니다.
이번에는 소셜로그인을 성공했을때 인증정보를 세션에 저장하고
저장된 인증정보를 가지고 등록/수정/삭제가 가능하도록 작업하겠습니다.
댓글기능에 인증정보가 추가하면서 처음 생각한것보다 소스가 많이 변경되었습니다.
76 Django CBV 댓글 기능 - Ajax 등록
77 Django CBV 댓글기능-Ajax 수정/삭제
78 Django 소셜로그인 - NAVER LOGIN
79.2 설정정보 추가 - settings.py
클라이언트 아이디와 시크릿 키만 있으면 프로바이더를 쉽게 추가할 수 있는게 allauth의 장점이죠.
일단 프로바이더는 네이버, 카카오, 구글로 하였습니다.
DJANGO_BASE_APP = [
...
]
AUTHENTICATION_BACKENDS = [
...
]
SOCIALACCOUNT_PROVIDERS = {
'naver': {'APP': { ... }},
'kakao': {'APP': {...}},
'google': {'APP': {... },
'SCOPE': ['profile','email',
],},
}
LOGIN_REDIRECT_URL = '/' # 로그인 후 리디렉션할 페이지
ACCOUNT_USERNAME_REQUIRED = False # 사용자 이름입력 기본 True
ACCOUNT_AUTHENTICATED_LOGIN_REDIRECTS=False #이미 인증된 사용자가 인증시도시
# SocialAccount.extra_data https://dev.twitch.tv/docs/api/reference/#get-users
SOCIALACCOUNT_ADAPTER = "myapp.common.socialAdapter.CustomSocialAccountAdapter" #socialAdapter 상속
SOCIALACCOUNT_LOGIN_ON_GET = True #GET POST 0.47.0 (2021-12-09)
- LOGIN_REDIRECT_URL, LOGOUT_REDIRECT_URL|LOGIN_REDIRECT_URL, LOGOUT_REDIRECT_URL 등은 주석으로 적어놓은 그대로 입니다.
- SOCIALACCOUNT_ADAPTER : 소셜로그인의 인증하는 기능을 하는 어댑터를 상속 받아 일부 과정에 참여할수 있는 기능을 제공합니다.
- SOCIALACCOUNT_LOGIN_ON_GET :
1) 소셜 로그인을 시도할때 인증데이터 post, get 방식 설정(기본 False)
2) 댓글창에서 바로 인증으로 넘어가야 되는데 로그인버튼을 클릭하면 위 그림처럼 /accouts/login/ 계속 이동되서 찾느라 시간이 좀 걸렸습니다.
79.3 socialAdapter.py 상속 - custom
settings.py 에 SOCIALACCOUNT_ADAPTER 설정을 통해 커스텀 adapter.py를 상속 받아 인증단계에 관여 할수 있다고 했습니다.
소셜로그인 인증 직후 정보를 세션에 담기 위해 socialAdapter.py를 만들고 DefaultSocialAccountAdapter 클래스를 상속 받겠습니다.
#myapp/common/CustomAppSocialAccountAdapter.py
from django.conf import settings
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
class CustomSocialAccountAdapter(DefaultSocialAccountAdapter):
# 로그인후 사용자가 다음을 통해 성공적으로 인증한 직후에 호출
def pre_social_login(self, request, sociallogin):
login = sociallogin.serialize()
account = login.get('account')
extra_data = account.get('extra_data')
self.request.session['user_uid'] = account.get('uid')
provider = account.get('provider')
self.request.session['user_provider'] = provider
user_name = user_profile_imag = ''
if provider in ('naver','google') :
user_name = extra_data.get('email') or extra_data.get('name')
user_profile_imag = extra_data.get('profile_image') or extra_data.get('picture')
elif provider == 'kakao':
kakao_account = extra_data.get('kakao_account')
user_name = kakao_account.get('email') or kakao_account.get('profile').get('nickname')
user_profile_imag = kakao_account.get('profile').get('thumbnail_image_url')
if user_name != None:
user_name = f'{user_name[:1]}**{user_name[3:]}'
self.request.session['user_profile_imag'] = user_profile_imag or 'https://res.cloudinary.com/dtfub5xym/image/upload/v1669638871/pb_comment_logo.png'
self.request.session['user_name'] = user_name
return settings.LOGIN_REDIRECT_URL
# 로그인 전 사용자 인스턴스를 추가로 채우는 데 사용
def populate_user(self, request, sociallogin, data):
return settings.LOGIN_REDIRECT_URL
- pre_social_login :
1) 인증 후 로그인 한 사용자의 정보가 sociallogin에 담겨있습니다.
2) account는 프로바이더 정보가 공통으로 들어있습니다. (uid, provider 등)
3) extra_data 는 프로바이더별로 사용자의 추가 정보가 담겨있음을 확인할 수 있습니다.
3) 로그인한 유저의 key로 uid를 사용하고 정보를 세션에 담아 인증 확인시 사용합니다.
79.4 댓글 테이블 수정 - models.py
댓글 models.py 필드 변경 및 추가가 많았습니다.
소셜 정보를 받아보니 추가할 필드(uid)도 생겼고, 비슷한 필드명을 사용하기 위해 변경을 진행했습니다.
변경전 모델 : 76.02 모델 만들기 - models.py
변경 후 : 아래 소스
#myapp/common/common_models.py
from django.db import models
from django.core.exceptions import ValidationError
class PyComment(models.Model):
id = models.AutoField(primary_key=True)
app_id = models.IntegerField( blank=False, null=False)
app_name = models.CharField(max_length=15, blank=False, null=False)
provider_name = models.CharField(max_length=35, blank=False, null=False)
uid = models.CharField(max_length=200, blank=False, null=False)
comment_body = models.TextField()
profile_image = models.CharField(max_length=150, blank=False, null=False)
use_yn = models.CharField(max_length=1, default='Y', blank=False, null=False)
regist_nm = models.CharField(max_length=30, blank=False, null=False)
regist_dt = models.DateTimeField(auto_now=True, blank=False, null=False)
class Meta:
ordering = ['-regist_dt']
db_table = 'py_comment'
def to_dict(self):
return {'comment_id':self.id,
'profile_image': self.profile_image,
'comment_body':self.comment_body,
'regist_nm':self.regist_nm,
'regist_dt':self.regist_dt.strftime('%Y.%m.%d %H:%M:%S'),
}
- to_dict : 댓글을 등록 후 결과 데이터를 JSON으로 시리얼라이즈 하려고 추가하였습니다.
79.5 댓글 폼 수정 - form.py
변경 전에는 단순하게 modelfrom을 상속 받아 사용필드만 정의해서 사용했었습니다.
현재는 save(), init등 view에서 처리하던 로직을 form.py에서 처리하도록 변경했습니다.
변경 전: 76.04 댓글 폼 만들기 - form.py
변경 후: 아래소스
#myapp/common/common_forms.py
from django import forms
from .common_models import PyComment
# https://docs.djangoproject.com/en/4.1/ref/forms/api/
class CommentForm(forms.ModelForm):
class Meta:
model = PyComment
fields = ('comment_body','app_name')
labels = {"comment_body": "",}
widgets= {'app_name': forms.TextInput(attrs={'type':'hidden'}),
'comment_body': forms.Textarea(attrs={'class':'ml-2 mr-5','style':'width:100%','rows':'2','cols':'90','oninput':'auto_height(this)','placeholder':'Join the discussion and leave a comment!'}),
}
def __init__(self, *args, **kwargs):
self.app_id = kwargs.pop('app_id', None)
self.uid = kwargs.pop('uid', None)
self.provider_name = kwargs.pop('provider_name', None)
self.profile_image = kwargs.pop('profile_image', None)
self.regist_nm = kwargs.pop('regist_nm', None)
super().__init__(*args, **kwargs)
def clean_comment_body(self):
return self.cleaned_data['comment_body'].replace('<','<').replace('>','>')
def save(self, commit=True, comment_id=None, update_fields=None, *args, **kwargs):
instance = super().save(commit=False)
instance.comment_body = self.cleaned_data['comment_body']
instance.app_id = self.app_id
instance.provider_name = self.provider_name
instance.uid = self.uid
instance.profile_image = self.profile_image
instance.regist_nm = self.regist_nm
instance.id = comment_id
if commit:
instance.save(update_fields=update_fields)
return instance
- meta : 기본적으로 form에 사용할 필드 정의합니다.
- init :
1) fileds에 정의하지 않아 form에 생성되지 않고 사용자에게 입력받지 않지만 db저장에 필요한 필드를 초기화 합니다.
2) db 저장시점에 get_form_kwargs(self)을 오버라이딩하여 해당 변수(app_id, uid 등)들에 값을 할당하여 사용합니다. - def clean_comment_body(self) :
1) clean_필드명 으로 함수를 만들어 해당 필드만 지정할 수 있습니다.
2) 추가적인 유효성 검사나 로직을 추가할 수 있습니다. 여기서는 스크립트 실행 방지를 위해 replace을 추가하였습니다.
3) clean(self) 로 만들어 모든 필드에 대해 추가적인 유효성 검사도 가능합니다. - save : db 저장
79.6 LogoutView, urls.py
DJango auth에 내장되어 있는 LogoutView을 이용하여 따로 logout 기능을 만들지 않아도 됩니다.
해당 기능을 이용하여 간단하게 로그인 세션정보를 삭제할 수 있는 편리한 기능입니다.
from django.contrib.auth.views import LogoutView
app_name = "common"
urlpatterns = [
...
...
path('comment/<int:app_id>/add', views.AddComment.as_view(), name='add_comment'),
path('comment/<int:comment_id>/edit',views.EditComment.as_view(), name='edit_comment'),
path('comment/<int:comment_id>/del', views.DelComment.as_view(), name='del_comment'),
path('comment/<int:comment_id>/<int:app_id>/mod', views.ModComment.as_view(), name='mod_comment'),
path('logout/', LogoutView.as_view(), name='logout'),
]
79.7 댓글 등록 / 수정 / 삭제- view.py
소스 가독성을 높이기 위해 기존 불필요한 함수들 제거, 결과 메세지, 인증 체크, 인증 데이터 바인딩 등 변경점이 많았습니다.
변경 전 : 댓글 등록 view.py(/blog/82/#333){target=_blank}
변경 후 : 아래소스
class AddComment(generic.FormView):
form_class = CommentForm
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['app_id'] = self.kwargs.get('app_id')
kwargs['uid'] =self.request.session.get('user_uid',None)
kwargs['provider_name'] =self.request.session.get('user_provider',None)
kwargs['profile_image'] =self.request.session.get('user_profile_imag',None)
kwargs['regist_nm'] =self.request.session.get('user_name',None)
return kwargs
def post(self, request, *args, **kwargs):
if not self.request.user.is_authenticated:
return JsonResponse({'isSuccess':False,'error_msg':"로그인이 필요합니다."})
form = self.get_form()
if form.is_valid():
comment = form.save()
context = {'isSuccess':True}
context.update(comment.to_dict())
return JsonResponse(context)
return JsonResponse({'isSuccess':False,'error_msg': [(k, v[0]) for k, v in form.errors.items()]})
- get_form_kwargs(self): 댓글 등록이 요청이 들어오면 db저장시 필요한 데이터를 셋팅합니다.
변경 전 : 댓글 수정/삭제
변경 후 : 아래소스
class EditComment(generic.FormView):
form_class = CommentForm
model = PyComment
def get_object(self):
return PyComment.objects.get(id=self.kwargs.get('comment_id'), use_yn = 'Y')
def get_initial(self):
PyComment = self.get_object()
return {'comment_body':self.get_object().comment_body,'app_name':self.get_object().app_name}
def get(self, request, *args, **kwargs):
return JsonResponse({'comment_id':self.get_object().id,'form':self.get_form().as_p()})
class ModComment(generic.FormView):
form_class = CommentForm
def post(self, request, *args, **kwargs):
if not self.request.user.is_authenticated:
return JsonResponse({'isSuccess':False,'error_msg':"로그인이 필요합니다."})
form = self.get_form()
if form.is_valid():
comment = form.save(comment_id=self.kwargs.get('comment_id'), update_fields=['comment_body'] )
return JsonResponse({'isSuccess':True,'comment_id':self.kwargs.get('comment_id'),'comment_body':comment.comment_body})
return JsonResponse({'isSuccess':False,'error_msg': [(k, v[0]) for k, v in form.errors.items()]})
class DelComment(generic.View):
def post(self, request, *args, **kwargs):
if not self.request.user.is_authenticated:
return JsonResponse({'isSuccess':False,'error_msg':"로그인이 필요합니다."})
comment_queryset = PyComment.objects.filter(id =self.kwargs.get('comment_id'),
uid = self.request.session.get('user_uid',None),
provider_name = self.request.session.get('user_provider',None),
use_yn = 'Y')
if comment_queryset.count() == 1:
cmt = comment_queryset.update(use_yn='N')
return JsonResponse({'isSuccess':True,'error_msg':'','comment_id':self.kwargs.get('comment_id')})
return JsonResponse({'isSuccess':False,'error_msg':'삭제를 실패하였습니다.'})
79.8 화면 상세/등록/수정/삭제- comment.html
Ajax를 이용해 만들려고 하니 동적으로 태그를 컨트롤하는 부분이 많이 추가되었습니다.
그냥 수정이든 삭제든 리다이렉트 하면 편할 텐데 말이죠.
load socialaccount 하고 socialaccount_providers를 for문으로 돌리고
provider_login_url에 프로바이더(settings.py SOCIALACCOUNT_PROVIDERS)를 바인딩하여 소셜로그인 가능한 url을 받습니다.
next= 변수로 로그인 이후 돌아올 페이지를 지정할 수 있습니다.
나머지는 ajax, jquery 를 이용하여 만들었습니다.
<!--myapp>templates/base>comment.html -->
{% if pageInfo.id %}
{% load socialaccount %}
{% get_providers as socialaccount_providers %}
<div class="row">
<div class="col-lg-8 mb-1">
<div class="card bg-light">
<div id="comment_header" class="card-header h5 ">Comments
<div class="float-right d-inline-block text-right">
{% if user.is_authenticated %}
<span class="text-sm ">{{ request.session.user_name }}<br/>
<a href="{% url 'common:logout' %}?next={{ request.path }}" id="comment_login_btn" class="">로그아웃</a>
</span>
{% else %}
{% for provider in socialaccount_providers %}
<button type="button" class="login-btn-icon ml-1" >
<a href="{% provider_login_url provider.id %}?next={{ request.path }}#commentForm" class="{{provider.id}}-icon width-height-100 dis_block"
data-toggle="tooltip" data-placement="top" title="" rel="noopener" aria-label="{{provider.id}}" data-original-title="{{provider.id}} Login"
></a>
<span class="toast hide">{{provider.id}} Login</span>
{% endfor %}
{% endif %}
</div>
{% if not user.is_authenticated %}
<div class="float-right font-weight-bold font-weight-bolder text-sm">Login:</div>
{% endif %}
</div>
<div class="card-body">
<form id="commentForm" class="form-inline" method="POST">
{% csrf_token %}
{{form.as_p}}
<div class="form-group row">
<button id="comment_submit_btn" class="btn btn-primary ml-4 " type="button" >등록</button>
</div>
</form>
<div id="singleComment">
{% for comment in comments %}
<div id="comment_box_{{comment.id}}" class="d-flex mb-4">
<div class=" flex-sm-shrink-0">
<img class="rounded-circle" src="{{comment.profile_image}}" alt="{{comment.regist_nm}} {{comment.provider_name}}" width="50" height="50" style="width:50px !important" />
</div>
<div class="ms-3">
<div class="fw-bold">
<span class="text-gray-900 font-weight-bolder text-lg">{{ comment.regist_nm }}</span> <span class="text-xs"> {{comment.regist_dt | date:'Y.m.d H:i:s' }}</span>
{% if user.is_authenticated and comment.uid == request.session.user_uid %}
<span class="edit btn text-info btn-ssm" data-comment-id="{{comment.id}}" data-action-edit="{% url "common:edit_comment" comment.id %}">update</span>
<span class="del btn text-info btn-ssm" data-comment-id="{{comment.id}}" data-action-del= "{% url "common:del_comment" comment.id %}" >delete</span>
{% endif %}
</div>
<span id="comment_body_{{comment.id}}">
{{ comment.comment_body | linebreaks }}
</span>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
<!--Confirm Modal -->
{% comment %} <div class="modal" id="msg_popup" tabindex="-1" role="dialog">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-body">
<!-- MSG Space-->
</div>
<div class="modal-footer" id="btn_confirm">
<button type="button" id="confirm_yes" class="btn btn-primary" data-dismiss="modal" >YES</button>
<button type="button" id="confirm_no"class="btn btn-secondary" data-dismiss="modal">NO</button>
</div>
<div class="modal-footer" id="btn_alert">
<button type="button" id="alert_ok"class="btn btn-primary" data-dismiss="modal" >OK</button>
</div>
</div>
</div>
</div> {% endcomment %}
{% block js %}
<script src="https://cdn.jsdelivr.net/npm/jquery@3.5.1/dist/jquery.min.js"></script>
<script>
$(function() {
var header_lenght = $(".card-header").length
$("#comment_header").addClass('card-header-idx'+header_lenght) //우측 메뉴에서 사용
$('#comment_submit_btn').click(function() {
if('{{ user.is_authenticated }}' != 'True'){
noticefyAlert('danger', "댓글등록은 로그인이 필요한 서비스입니다.");
return;
}
if($('#id_comment_body').val() == ''){
noticefyAlert('danger', "댓글을 입력하세요.");
return "";
}
$('#comment_submit_btn').attr('disabled',true)
$.ajax({
url: '{% url "common:add_comment" pageInfo.id %}',
method:"POST",
data:$('#commentForm').serialize()
}).done(function(data) {
if(data != null){
if(data.isSuccess){
new_comment = '<div id="comment_box_'+data.comment_id+'"class="d-flex mb-4">';
new_comment += ' <div class=" flex-sm-shrink-0"><img class="rounded-circle" src="'+data.profile_image+'" alt="'+data.regist_nm+' '+data.provider_name+' Profile Image" width="50" height="50" style="width:50px !important" /></div>';
new_comment += ' <div class="ms-3">';
new_comment += ' <div class="fw-bold">';
new_comment += ' <span class="text-gray-900 font-weight-bolder text-lg">'+data.regist_nm+'</span>';
new_comment += ' <span class="text-xs"> '+data.regist_dt+'</span>';
new_comment += ' <span class="edit btn text-info btn-ssm" data-action-edit="/comment/'+data.comment_id+'/edit">update</span>';
new_comment += ' <span class="del btn text-info btn-ssm" data-action-del="/comment/'+data.comment_id+'/del" >delete</span>';
new_comment += ' </div>';
new_comment += ' <span id="comment_body_'+data.comment_id+'"> <pre>'+data.comment_body+'</pre></span>';
new_comment += ' </div>';
new_comment += '</div>';
$('#id_comment_body').val('')
$('#singleComment').prepend(new_comment); // new comment 끼워넣기
$("#comment_box_"+data.comment_id).fadeOut(100).fadeIn(2000);
noticefyAlert("success", "success", 'comment_submit_btn');
}else{noticefyAlert('danger', data.error_msg, 'comment_submit_btn');}
}else{noticefyAlert('danger', "transaction fail", 'comment_submit_btn');}
});
});
$(document).on("click", ".edit", function() {
noticefyAlert("info","request waiting for editor");
$.ajax({
url: $(this).attr('data-action-edit'),
type: "GET",
beforeSend : function(xhr){xhr.setRequestHeader('csrfmiddlewaretoken','{{ csrf_token }}');},
success: function(data){
tForm = '';
tForm +='<form id="commentFormMod_'+data.comment_id+'" class="form-inline" method="POST">';
tForm += data.form
tForm +=' <div class="form-group row">';
tForm +=' <button class="mod btn btn-primary ml-4" type="button" data-comment-id="'+data.comment_id+'" data-action-mod="/comment/'+data.comment_id+'/{{pageInfo.id}}/mod" >수정</button>';
tForm +=' </div>';
tForm +='</form>';
$('#comment_body_'+data.comment_id).empty();
$('#comment_body_'+data.comment_id).append(tForm);
},
error: function(request, status, error){noticefyAlert('danger', "transaction fail")},
});
});
$(document).on("click", ".mod", function() {
$.ajax({
url: $(this).attr('data-action-mod'),
method:"POST",
beforeSend : function(xhr){xhr.setRequestHeader('X-CSRFToken','{{ csrf_token }}');},
data:$('#commentFormMod_'+$(this).attr('data-comment-id')).serialize()
}).done(function(data) {
if(data.isSuccess){
noticefyAlert("success","success");
$('#comment_body_'+data.comment_id).empty();
$('#comment_body_'+data.comment_id).append(data.comment_body);
}else{noticefyAlert("danger",data.error_msg);}
});
return;
});
$(document).on("click", ".del", function() {
$.ajax({
url:$(this).attr('data-action-del'),
method:"POST",
beforeSend : function(xhr){xhr.setRequestHeader('X-CSRFToken','{{ csrf_token }}');},
}).done(function(data) {
if(data.isSuccess){
$('#comment_box_'+data.comment_id).remove();
noticefyAlert("success","success");
}else{noticefyAlert("danger",data.error_msg);}
});
return;
});
});
</script>
{% endblock js %}
{% endif %}
79.9 적용확인
구글 로그인 후 댓글입력
네이버 로그인 후 댓글입력입니다.