WeniVooks

검색

Django 베이스캠프

accounts 앱 구현

이전 시간에는 도서관 관리 시스템(Library Management System, LMS)의 템플릿을 구현하였습니다.

이번 시간부터는 각 도메인(앱) 별로 CRUD(Create, Read, Update, Delete) 기능을 구현해보겠습니다.

먼저, accounts 앱의 기능을 구현해보겠습니다.

1. accounts 모델 내 메서드 추가

accounts 앱의 모델에 메서드를 추가하여, 비지니스 로직을 수행할 수 있도록 합니다.

 
    #... 생략
 
    def is_librarian(self):
        """사서 여부 확인"""
        return self.role == 'LIBRARIAN'
 
    def can_borrow(self):
        """대출 가능 여부 확인"""
        from books.models import Loan
        active_loans = Loan.objects.filter( # 대출 중인 도서 수
            user=self, # 현재 사용자
            status='ACTIVE' # 대출 중인 도서
        ).count() # 개수
        return active_loans < 3 # 3권 이하인 경우 대출 가능
 
    def has_overdue_books(self):
        """연체 도서 여부 확인"""
        from books.models import Loan
        return Loan.objects.filter( # 연체 중인 도서 확인
            user=self, # 현재 사용자
            status='ACTIVE', # 대출 중인 도서
            due_date__lt=timezone.now() # 현재 시간보다 이전
        ).exists() # 존재 여부
 
    def get_active_loans(self):
        """현재 대출 중인 도서 목록"""
        return self.loan_set.filter(status='ACTIVE')
 
    def get_active_reservations(self):
        """현재 예약 중인 도서 목록"""
        return self.reservation_set.filter(status='WAITING')
 
    #... 생략
 
    def is_librarian(self):
        """사서 여부 확인"""
        return self.role == 'LIBRARIAN'
 
    def can_borrow(self):
        """대출 가능 여부 확인"""
        from books.models import Loan
        active_loans = Loan.objects.filter( # 대출 중인 도서 수
            user=self, # 현재 사용자
            status='ACTIVE' # 대출 중인 도서
        ).count() # 개수
        return active_loans < 3 # 3권 이하인 경우 대출 가능
 
    def has_overdue_books(self):
        """연체 도서 여부 확인"""
        from books.models import Loan
        return Loan.objects.filter( # 연체 중인 도서 확인
            user=self, # 현재 사용자
            status='ACTIVE', # 대출 중인 도서
            due_date__lt=timezone.now() # 현재 시간보다 이전
        ).exists() # 존재 여부
 
    def get_active_loans(self):
        """현재 대출 중인 도서 목록"""
        return self.loan_set.filter(status='ACTIVE')
 
    def get_active_reservations(self):
        """현재 예약 중인 도서 목록"""
        return self.reservation_set.filter(status='WAITING')
  • is_librarian(): 사서 권한 확인 메서드
  • can_borrow(): 추가 대출 가능 여부 확인 (3권 제한)
  • has_overdue_books(): 연체 도서 여부 확인
  • get_active_loans(): 현재 대출 목록 조회
  • get_active_reservations(): 현재 예약 목록 조회

1.1 사서 권한을 체크하는 데코레이터 추가 작성

# accounts/decorators.py
from django.shortcuts import redirect 
from django.contrib import messages
from functools import wraps # wraps 데코레이터를 사용하여 데코레이터를 작성
 
def librarian_required(view_func): # 뷰 함수를 인자로 받는 데코레이터 이뜻은 뷰 함수를 인자로 받아서 뷰 함수를 반환한다는 뜻
    """사서 권한 필요한 뷰의 데코레이터"""
    @wraps(view_func) # wraps 데코레이터를 사용하여 데코레이터를 작성 즉, view_func 함수를 래핑하여 사용 -> 결국 view_func 함수를 반환
    def _wrapped_view(request, *args, **kwargs): # 뷰 함수의 인자를 받아서 처리
        if not request.user.is_authenticated: # 로그인이 되어 있지 않은 경우
            messages.error(request, '로그인이 필요합니다.') # 에러 메시지 출력
            return redirect('accounts:login') # 로그인 페이지로 이동
            
        if not request.user.is_librarian(): # 사서 권한이 없는 경우
            messages.error(request, '사서 권한이 필요합니다.') # 에러 메시지 출력
            return redirect('accounts:profile') # 프로필 페이지로 이동
            
        return view_func(request, *args, **kwargs) # 뷰 함수 실행
    return _wrapped_view # 래핑된 뷰 함수 반환
# accounts/decorators.py
from django.shortcuts import redirect 
from django.contrib import messages
from functools import wraps # wraps 데코레이터를 사용하여 데코레이터를 작성
 
def librarian_required(view_func): # 뷰 함수를 인자로 받는 데코레이터 이뜻은 뷰 함수를 인자로 받아서 뷰 함수를 반환한다는 뜻
    """사서 권한 필요한 뷰의 데코레이터"""
    @wraps(view_func) # wraps 데코레이터를 사용하여 데코레이터를 작성 즉, view_func 함수를 래핑하여 사용 -> 결국 view_func 함수를 반환
    def _wrapped_view(request, *args, **kwargs): # 뷰 함수의 인자를 받아서 처리
        if not request.user.is_authenticated: # 로그인이 되어 있지 않은 경우
            messages.error(request, '로그인이 필요합니다.') # 에러 메시지 출력
            return redirect('accounts:login') # 로그인 페이지로 이동
            
        if not request.user.is_librarian(): # 사서 권한이 없는 경우
            messages.error(request, '사서 권한이 필요합니다.') # 에러 메시지 출력
            return redirect('accounts:profile') # 프로필 페이지로 이동
            
        return view_func(request, *args, **kwargs) # 뷰 함수 실행
    return _wrapped_view # 래핑된 뷰 함수 반환

이 데코레이터의 흐름을 자세히 살펴보겠습니다.

  1. librarian_required 데코레이터는 view_func 함수를 인자로 받습니다.
    • view_func: 뷰 함수 (예: book_create, book_update, book_delete)
  2. @wraps(view_func) 데코레이터를 사용하여 데코레이터를 작성합니다.
    • wraps 데코레이터는 데코레이터를 작성할 때 사용합니다.
  3. _wrapped_view 함수는 뷰 함수의 인자를 받아서 처리합니다.
    • request: 요청 객체
    • *args: 가변 인자
    • **kwargs: 키워드 인자
  4. if not request.user.is_authenticated:: 로그인이 되어 있지 않은 경우
    • messages.error(request, '로그인이 필요합니다.'): 에러 메시지 출력
    • return redirect('accounts:login'): 로그인 페이지로 이동
  5. if not request.user.is_librarian():: 사서 권한이 없는 경우
    • messages.error(request, '사서 권한이 필요합니다.'): 에러 메시지 출력
    • return redirect('accounts:profile'): 프로필 페이지로 이동
  6. return view_func(request, *args, **kwargs): 뷰 함수 실행 (예: book_create(request), book_update(request, pk=1), book_delete(request, pk=1))
  7. _wrapped_view 함수를 반환합니다.

1.2 데코레이터 적용 예시

이제 우리는 librarian_required 데코레이터를 사용하여 사서 권한이 필요한 뷰에 적용할 수 있습니다.

@librarian_required
def some_librarian_view(request):
    # 사서만 접근 가능한 뷰 로직
    pass
@librarian_required
def some_librarian_view(request):
    # 사서만 접근 가능한 뷰 로직
    pass

2. forms.py 작성

accounts 앱의 폼을 작성하여, 사용자 입력을 처리할 수 있도록 합니다.

# accounts/forms.py
from django import forms
from django.contrib.auth.forms import UserCreationForm, AuthenticationForm
from .models import User
 
class UserRegistrationForm(UserCreationForm): # UserCreationForm은 기본적으로 제공되는 회원가입 폼
    """회원가입 폼"""
    phone = forms.CharField(
        max_length=11, 
        required=False, # 필수 입력이 아님
        widget=forms.TextInput(attrs={'class': 'form-control'}) # Bootstrap 스타일 적용
    )
    
    class Meta:
        model = User
        fields = ('username', 'email', 'phone', 'password1', 'password2') # 필드 순서 지정
        
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # Bootstrap 스타일 적용
        for field in self.fields:
            self.fields[field].widget.attrs['class'] = 'form-control'
 
class LoginForm(AuthenticationForm): # AuthenticationForm은 기본적으로 제공되는 로그인 폼
    """로그인 폼"""
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # Bootstrap 스타일 적용
        for field in self.fields:
            self.fields[field].widget.attrs['class'] = 'form-control'
            
class UserUpdateForm(forms.ModelForm):
    """사용자 정보 수정 폼"""
    class Meta:
        model = User
        fields = ['email', 'phone']
        widgets = {
            'email': forms.EmailInput(attrs={'class': 'form-control'}),
            'phone': forms.TextInput(attrs={'class': 'form-control'})
        }
# accounts/forms.py
from django import forms
from django.contrib.auth.forms import UserCreationForm, AuthenticationForm
from .models import User
 
class UserRegistrationForm(UserCreationForm): # UserCreationForm은 기본적으로 제공되는 회원가입 폼
    """회원가입 폼"""
    phone = forms.CharField(
        max_length=11, 
        required=False, # 필수 입력이 아님
        widget=forms.TextInput(attrs={'class': 'form-control'}) # Bootstrap 스타일 적용
    )
    
    class Meta:
        model = User
        fields = ('username', 'email', 'phone', 'password1', 'password2') # 필드 순서 지정
        
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # Bootstrap 스타일 적용
        for field in self.fields:
            self.fields[field].widget.attrs['class'] = 'form-control'
 
class LoginForm(AuthenticationForm): # AuthenticationForm은 기본적으로 제공되는 로그인 폼
    """로그인 폼"""
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # Bootstrap 스타일 적용
        for field in self.fields:
            self.fields[field].widget.attrs['class'] = 'form-control'
            
class UserUpdateForm(forms.ModelForm):
    """사용자 정보 수정 폼"""
    class Meta:
        model = User
        fields = ['email', 'phone']
        widgets = {
            'email': forms.EmailInput(attrs={'class': 'form-control'}),
            'phone': forms.TextInput(attrs={'class': 'form-control'})
        }

3. views.py 작성

# accounts/views.py
from django.shortcuts import render, redirect
from django.contrib.auth import login, logout
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from .forms import UserRegistrationForm, LoginForm
 
def register_view(request): # rqeuest: 요청 객체
    """회원가입 뷰"""
    if request.user.is_authenticated: # 로그인 상태인 경우
        return redirect('accounts:profile') # 프로필 페이지로 이동
        
    if request.method == 'POST': # POST 요청인 경우
        form = UserRegistrationForm(request.POST) # 회원가입 폼 생성
        if form.is_valid(): # 유효성 검사
            user = form.save() # 회원가입
            login(request, user) # 로그인
            messages.success(request, '회원가입이 완료되었습니다.') # 메시지 출력
            return redirect('accounts:profile') # 프로필 페이지로 이동
    else: # GET 요청인 경우
        form = UserRegistrationForm() # 회원가입 폼 생성
    
    return render(request, 'accounts/register.html', {'form': form}) # 회원가입 페이지 렌더링
 
def login_view(request):
    """로그인 뷰"""
    if request.user.is_authenticated: # 로그인 상태인 경우
        return redirect(reverse_lazy('accounts:profile')) # 프로필 페이지로 이동
        
    if request.method == 'POST':
        form = LoginForm(request, request.POST) # 로그인 폼 생성
        if form.is_valid(): # 유효성 검사
            login(request, form.get_user()) # 로그인
            # next 파라미터가 있으면 해당 URL로 리다이렉트 아니면 프로필 페이지로 이동
            next_url = request.GET.get('next', reverse_lazy('accounts:profile'))
            return redirect(next_url)
    else: # GET 요청인 경우
        form = LoginForm() # 로그인 폼 생성
    
    return render(request, 'accounts/login.html', {'form': form}) # 로그인 페이지 렌더링
 
def logout_view(request):
    """로그아웃 뷰"""
    if request.method == 'POST':
        logout(request) # 로그아웃
        messages.success(request, '로그아웃되었습니다.') # 메시지 출력
     return redirect(reverse_lazy('accounts:login')) # 로그인 페이지로 이동
 
@login_required # 로그인 필요 이 데코레이터는 로그인이 필요한 경우에만 접근 가능하도록 설정
def profile_view(request):
    """프로필 뷰"""
    return render(request, 'accounts/profile.html', { # 프로필 페이지 렌더링
        'active_loans': request.user.get_active_loans(), # 현재 대출 중인 도서 목록
        'active_reservations': request.user.get_active_reservations() # 현재 예약 중인 도서 목록
    })
# accounts/views.py
from django.shortcuts import render, redirect
from django.contrib.auth import login, logout
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from .forms import UserRegistrationForm, LoginForm
 
def register_view(request): # rqeuest: 요청 객체
    """회원가입 뷰"""
    if request.user.is_authenticated: # 로그인 상태인 경우
        return redirect('accounts:profile') # 프로필 페이지로 이동
        
    if request.method == 'POST': # POST 요청인 경우
        form = UserRegistrationForm(request.POST) # 회원가입 폼 생성
        if form.is_valid(): # 유효성 검사
            user = form.save() # 회원가입
            login(request, user) # 로그인
            messages.success(request, '회원가입이 완료되었습니다.') # 메시지 출력
            return redirect('accounts:profile') # 프로필 페이지로 이동
    else: # GET 요청인 경우
        form = UserRegistrationForm() # 회원가입 폼 생성
    
    return render(request, 'accounts/register.html', {'form': form}) # 회원가입 페이지 렌더링
 
def login_view(request):
    """로그인 뷰"""
    if request.user.is_authenticated: # 로그인 상태인 경우
        return redirect(reverse_lazy('accounts:profile')) # 프로필 페이지로 이동
        
    if request.method == 'POST':
        form = LoginForm(request, request.POST) # 로그인 폼 생성
        if form.is_valid(): # 유효성 검사
            login(request, form.get_user()) # 로그인
            # next 파라미터가 있으면 해당 URL로 리다이렉트 아니면 프로필 페이지로 이동
            next_url = request.GET.get('next', reverse_lazy('accounts:profile'))
            return redirect(next_url)
    else: # GET 요청인 경우
        form = LoginForm() # 로그인 폼 생성
    
    return render(request, 'accounts/login.html', {'form': form}) # 로그인 페이지 렌더링
 
def logout_view(request):
    """로그아웃 뷰"""
    if request.method == 'POST':
        logout(request) # 로그아웃
        messages.success(request, '로그아웃되었습니다.') # 메시지 출력
     return redirect(reverse_lazy('accounts:login')) # 로그인 페이지로 이동
 
@login_required # 로그인 필요 이 데코레이터는 로그인이 필요한 경우에만 접근 가능하도록 설정
def profile_view(request):
    """프로필 뷰"""
    return render(request, 'accounts/profile.html', { # 프로필 페이지 렌더링
        'active_loans': request.user.get_active_loans(), # 현재 대출 중인 도서 목록
        'active_reservations': request.user.get_active_reservations() # 현재 예약 중인 도서 목록
    })

4. 로그인/ 로그아웃 URL 설정

settings.py에 로그인/로그아웃 관련 설정을 추가합니다.

# config/settings.py
 
# 로그인/로그아웃 관련 설정
LOGIN_URL = 'accounts:login'  # 로그인이 필요할 때 리다이렉트할 URL
LOGIN_REDIRECT_URL = 'accounts:profile'  # 로그인 성공 후 리다이렉트할 URL
LOGOUT_REDIRECT_URL = 'accounts:login'  # 로그아웃 후 리다이렉트할 URL
# config/settings.py
 
# 로그인/로그아웃 관련 설정
LOGIN_URL = 'accounts:login'  # 로그인이 필요할 때 리다이렉트할 URL
LOGIN_REDIRECT_URL = 'accounts:profile'  # 로그인 성공 후 리다이렉트할 URL
LOGOUT_REDIRECT_URL = 'accounts:login'  # 로그아웃 후 리다이렉트할 URL

결국 이 설정은 로그인이 필요한 경우에만 접근 가능하도록 설정합니다. 리다이렉트 URL은 accounts으로 설정합니다.

특히, @login_required 데코레이터를 사용하여 로그인이 필요한 경우에만 접근 가능하도록 설정합니다. 리다이렉트 URL은 accounts으로 설정합니다.

예시를 보여드리면,

 
# settings.py 설정이 없는 경우
@login_required  # 기본값으로 '/accounts/login/'으로 리다이렉트
def profile_view(request):
    ...
 
# settings.py 설정이 있는 경우
LOGIN_URL = 'accounts:login'  # 우리가 만든 커스텀 로그인 URL로 리다이렉트
 
# settings.py 설정이 없는 경우
@login_required  # 기본값으로 '/accounts/login/'으로 리다이렉트
def profile_view(request):
    ...
 
# settings.py 설정이 있는 경우
LOGIN_URL = 'accounts:login'  # 우리가 만든 커스텀 로그인 URL로 리다이렉트

settings.py 설정이 없는 경우 그리고 있는 경우의 차이는 결국 리다이렉트 URL이 다르다는 것입니다.

예시를 들면, settings.py 설정이 없는 경우에는 기본값인 '/accounts/login/'으로 리다이렉트되지만, (Django의 기본 로그인 URL)

설정이 있는 경우에는 우리가 만든 커스텀 로그인 URL로 리다이렉트됩니다.

7.4 템플릿 구현7.6 books 앱 구현