Skip to content

Commit

Permalink
gh-2: 사용자 회원가입(API) 구현 (#13)
Browse files Browse the repository at this point in the history
* Feat: User 모델 작성 #1 #2

* Test: 회원가입API, 유저 서비스 및 선택자 테스트 코드 작성 #2

* Feat: 회원가입 서비스 로직 및 API 작성 #2
  • Loading branch information
JaeHyuckSa authored Nov 9, 2023
1 parent 07b0ba8 commit 788ee86
Show file tree
Hide file tree
Showing 16 changed files with 286 additions and 7 deletions.
6 changes: 3 additions & 3 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ jobs:
run: docker compose up -d --build

- name: Run mypy
run: docker compose run django poetry run mypy --config mypy.ini restaurants_recommendation/
run: docker compose run django poetry run mypy --config mypy.ini budget_management/

- name: Run isort
run: docker compose run django poetry run isort restaurants_recommendation/ --check
run: docker compose run django poetry run isort budget_management/ --check

- name: Run black
run: docker compose run django poetry run black restaurants_recommendation/ --check
run: docker compose run django poetry run black budget_management/ --check

- name: Run flake8
run: docker compose run django poetry run flake8
Expand Down
6 changes: 6 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ repos:
hooks:
- id: isort
args: ['--profile', 'black', '--filter-files', 'true']
# mypy
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v0.940
hooks:
- id: mypy
args: ['--config', 'mypy.ini', 'budget_management/']

default_language_version:
python: python3.11
8 changes: 8 additions & 0 deletions budget_management/common/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from rest_framework import status
from rest_framework.exceptions import APIException


class ValidationError(APIException):
status_code = status.HTTP_400_BAD_REQUEST
default_detail = "Invalid value."
default_code = "invalid"
10 changes: 9 additions & 1 deletion budget_management/common/models.py
Original file line number Diff line number Diff line change
@@ -1 +1,9 @@
# Create your models here.
from django.db import models


class BaseModel(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

class Meta:
abstract = True
16 changes: 16 additions & 0 deletions budget_management/users/managers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from django.contrib.auth.base_user import BaseUserManager


class UserManager(BaseUserManager):
def create_user(self, username, password, **extra_fields):
if not username:
raise ValueError("username is required")

user = self.model(username=username, **extra_fields)
user.set_password(password)
user.save()
return user

def create_superuser(self, username, password, **extra_fields):
extra_fields.setdefault("is_admin", True)
return self.create_user(username, password, **extra_fields)
30 changes: 30 additions & 0 deletions budget_management/users/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Generated by Django 4.2.7 on 2023-11-09 15:25

from django.db import migrations, models


class Migration(migrations.Migration):

initial = True

dependencies = [
]

operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('username', models.CharField(max_length=16, unique=True)),
('is_active', models.BooleanField(default=True)),
('is_admin', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'users',
},
),
]
26 changes: 25 additions & 1 deletion budget_management/users/models.py
Original file line number Diff line number Diff line change
@@ -1 +1,25 @@
# Create your models here.
from django.contrib.auth.models import AbstractBaseUser
from django.db import models

from budget_management.common.models import BaseModel
from budget_management.users.managers import UserManager


class User(AbstractBaseUser, BaseModel):
username = models.CharField(max_length=16, unique=True)
is_active = models.BooleanField(default=True)
is_admin = models.BooleanField(default=False)

objects: UserManager = UserManager()

USERNAME_FIELD = "username"

class Meta:
db_table = "users"

def __str__(self):
return self.username

@property
def is_staff(self):
return self.is_admin
7 changes: 7 additions & 0 deletions budget_management/users/selectors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from typing import Optional

from budget_management.users.models import User


def get_user_by_username(username: str) -> Optional[User]:
return User.objects.filter(username=username).first()
25 changes: 25 additions & 0 deletions budget_management/users/services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from django.db import transaction

from budget_management.common.exceptions import ValidationError
from budget_management.users.models import User
from budget_management.users.selectors import get_user_by_username


class UserService:
def validate_unique_usernmae(self, username: str):
if username:
username_exists = get_user_by_username(username)
if username_exists:
raise ValidationError("Username already exists.")

def validate_password(self, password: str):
if password:
if len(password) < 8:
raise ValidationError("Password must be at least 8 characters long.")

@transaction.atomic
def create(self, username: str, password: str) -> User:
self.validate_unique_usernmae(username)
self.validate_password(password)
user = User.objects.create_user(username=username, password=password)
return user
68 changes: 68 additions & 0 deletions budget_management/users/tests/test_signup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import json

from django.urls import reverse
from rest_framework.test import APITestCase

from budget_management.users.models import User


class SingupViewTest(APITestCase):
@classmethod
def setUpTestData(self):
self.user = User.objects.create_user(username="test2", password="test1234!")

def test_signup_success(self):
response = self.client.post(
path=reverse("signup"),
data=json.dumps({"username": "test", "password": "test1234!"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 201)

def test_signup_fail_unique_username(self):
response = self.client.post(
path=reverse("signup"),
data=json.dumps({"username": "test2", "password": "test1234!"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)

def test_signup_fail_password_validation(self):
response = self.client.post(
path=reverse("signup"),
data=json.dumps({"username": "test", "password": "test"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)

def test_signup_fail_blank_username(self):
response = self.client.post(
path=reverse("signup"),
data=json.dumps({"username": "", "password": "test1234!"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)

def test_signup_fail_blank_password(self):
response = self.client.post(
path=reverse("signup"),
data=json.dumps({"username": "test", "password": ""}),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)

def test_signup_fail_required_username(self):
response = self.client.post(
path=reverse("signup"),
data=json.dumps({"password": "test1234!"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)

def test_signup_fail_required_password(self):
response = self.client.post(
path=reverse("signup"),
data=json.dumps({"username": "test"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
17 changes: 17 additions & 0 deletions budget_management/users/tests/test_user_selectors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from django.test import TestCase

from budget_management.users.models import User
from budget_management.users.selectors import get_user_by_username


class UserSelectorsTest(TestCase):
def setUp(self):
User.objects.create_user(username="test", password="test1234!")
self.user = get_user_by_username("test")

def test_get_user_by_username(self):
self.assertEqual(self.user.username, "test")

def test_get_user_by_username_not_found(self):
user = get_user_by_username("test2")
self.assertIsNone(user)
22 changes: 22 additions & 0 deletions budget_management/users/tests/test_user_services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from django.test import TestCase

from budget_management.users.services import UserService


class UserServicesTest(TestCase):
def setUp(self):
self.user_service = UserService()

def test_create(self):
user = self.user_service.create(username="test", password="test1234!")
self.assertEqual(user.username, "test")
self.assertTrue(user.check_password("test1234!"))

def test_create_with_duplicated_username(self):
self.user_service.create(username="test", password="test1234!")
with self.assertRaises(Exception):
self.user_service.create(username="test", password="test1234!")

def test_create_with_short_password(self):
with self.assertRaises(Exception):
self.user_service.create(username="test", password="test")
7 changes: 7 additions & 0 deletions budget_management/users/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from django.urls import path

from budget_management.users.views import SignupView

urlpatterns = [
path("signup", SignupView.as_view(), name="signup"),
]
36 changes: 36 additions & 0 deletions budget_management/users/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from drf_yasg.utils import swagger_auto_schema
from rest_framework import serializers, status
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView

from budget_management.users.services import UserService


class SignupView(APIView):
permission_classes = [AllowAny]

class InputSerializer(serializers.Serializer):
username = serializers.CharField()
password = serializers.CharField()

class OuputSerializer(serializers.Serializer):
username = serializers.CharField()

@swagger_auto_schema(
request_body=InputSerializer,
responses={
status.HTTP_201_CREATED: OuputSerializer,
},
)
def post(self, request):
"""
계정명과 비밀번호를 받아서 새로운 유저를 생성합니다.
url: /api/users/signup
"""
user_service = UserService()
serializer = self.InputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = user_service.create(**serializer.validated_data)
return Response(self.OuputSerializer(user).data, status=status.HTTP_201_CREATED)
3 changes: 2 additions & 1 deletion config/root_urls.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import path
from django.urls import path, include
from drf_yasg import openapi
from drf_yasg.views import get_schema_view
from rest_framework import permissions
Expand All @@ -21,6 +21,7 @@
# Admin
path("admin/", admin.site.urls),
# API
path("api/users/", include("budget_management.users.urls")),
# Swagger
path("swagger/docs/", schema_view.with_ui("swagger", cache_timeout=0), name="schema-swagger-ui"),
]
Expand Down
6 changes: 5 additions & 1 deletion mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@ django_settings_module = "config.settings"
# Ignore everything related to Django config
ignore_errors = true

[mypy-restaurants_recommendation.*.migrations.*]
[mypy-budget_management.*.migrations.*]
# Ignore Django migrations
ignore_errors = true

[mypy-budget_management.*.managers]
# Ignore Django models
ignore_errors = true

[mypy-rest_framework_simplejwt.*]
ignore_missing_imports = true

0 comments on commit 788ee86

Please sign in to comment.