[DRF Project] 당근마켓 클론#1

Gyubster·2022년 1월 21일
3

당근마켓클론

목록 보기
1/4

간단한 CRUD 게시판 프로젝트를 통해서 감을 다시 잡았다..!(아마도..?) 원래 진행했던 당근마켓 클로닝 프로젝트를 다시 진행해보려고한다. 간략한 부분은 이미 모두 다 진행을 했었기에, 현재 당근마켓이 가지고 있지 않았던 기능을 추가하는 부분을 다시 손보려고한다.

프로젝트 개요

1. (클로닝) 프로젝트 대상 및 목표: "당근마켓 (앱)"

: DRF를 이용한 당근 마켓 앱을 클로닝
: 개선되거나 추가되었으면 하는 기능 추가아이디어 구현 (새상품 최저가 검색 기능)
: 전화번호 본인인증 및 위치기반 관련 기능 적용 및 구현

2. 팀 구성: 프론트엔드 1명, 백엔드 1명

3. 프로젝트 기간: 2021.03.17 ~ 2021.04.02

4. 프로젝트 범위:

진입화면(3)
휴대폰 본인인증 화면(4, 5) : firebase 연결
동네인증(6): 위치인식 및 키워드 검색 필터링, 주소 API 연결
메인화면(1): Top 메뉴 및 Bottom Navigation
상세화면(2): 이미지 슬라이드, 동적 라우팅
동네 선택 모달창(7)
키워드 검색 화면(8)
카테고리 선택 화면 (9)
채팅화면(10)

Add) 추가 아이디어 화면


아이디어 배경
: 당근마켓 앱을 사용하다보면, 중고제품의 가격이 적정 가격인지를 판단하기 위해서 보통 앱을 빠져나가 온라인 쇼핑몰에서 새상품 최저가 가격을 알아본 후, 중고제품의 가격 적정여부를 판단하고 구매결정하고 있음. 따라서, 이에 대한 기능을 앱 내에서 직접 제공한다면 사용자의 편의 향상에 도움될것
구현 내용
: 위 사진에서 보는 바와 같이, 매 상품박스 안에는 "새상품 최저가 검색"이라는 버튼이 있고, 이를 클릭하면, 제품명(모델명)기반 웹 크롤링 결과 중 최저가순 내림차순으로 2~3개 정도의 결과를 하단 공간이 열리면서 보여지도록 구현해보았음

5. 업무진행 방법

백엔드-프론트엔드 간의 원활한 내용 공유 및 일정 관리를 위하여, Notion과 Slack을 적극 활용하였음

6. 사용기술 스택

FrontEnd
: React Native, Hooks, State Management(Context, Redux, MobX), Navigatore, Expo
BackEnd
: DRF, Mysql, Scheduler
기타 Tools
: Figma, diagrams, Notion, Slack, Git

팀원이 정리해놓은 개요를 참고하여 작성함.(https://velog.io/@mementomori/Cloning-Project-%EB%8B%B9%EA%B7%BC%EB%A7%88%EC%BC%93)

백엔드 구성

Git 주소: https://github.com/Gyubster/we_market

총 앱은 Post, User로 구성되어 있다.

User

당근 마켓은 특이하게 핸드폰 번호를 통해서 가입을 진행하고 로그인을 하게된다. 따라서, 핸드폰 번호로 회원가입 후 자동로그인 기능과 db에 입력한 핸드폰 번호가 있는 경우 자동으로 로그인 하도록 하였다. 또한, 로그인한 유저의 위치, 즐겨찾기가 자동으로 불러질 수 있도록 하였다.

  1. models.py
/user/models.py

from django.db                  import models
from django.contrib.auth.models import BaseUserManager, AbstractBaseUser, PermissionsMixin
from django.utils               import timezone
from django.utils.translation   import ugettext_lazy as _

class UserManager(BaseUserManager):    
    use_in_migrations = True
    
    def _create_user(self, phone_number, **extra_fields):
        if not phone_number:
            raise ValueError('Phone Number must be given')
        user = self.model(phone_number=phone_number, **extra_fields)
        user.save(using=self._db)
        return user
    
    def create_user(self, phone_number, **extra_fields):
        extra_fields.setdefault('is_active', True)
        extra_fields.setdefault('is_staff', False)
        extra_fields.setdefault('is_superuser', False)
        return self._create_user(phone_number, **extra_fields)

    def create_superuser(self, phone_number, **extra_fields):
        extra_fields.setdefault('is_active', True)
        extra_fields.setdefault('is_staff', True)
        extra_fields.setdefault('is_superuser', True)
        return self._create_user(phone_number, **extra_fields)

class User(AbstractBaseUser, PermissionsMixin):
    phone_number = models.CharField(
            verbose_name    = _('phone number'),
            max_length      = 64,
            unique          = True,
            )
    nickname = models.CharField(
            verbose_name    = _('nickname'),
            max_length      = 64,
            default         = 'Default Nickname',
            )
    profile_image = models.URLField(
            verbose_name    = _('profile image url'),
            max_length      = 2000,
            default         = 'https://png.pngtree.com/element_our/20200610/ourlarge/pngtree-character-default-avatar-image_2237203.jpg'
            )
    is_active = models.BooleanField(
            verbose_name    = _('is active'),
            default         = True,
            )
    is_staff = models.BooleanField(
            verbose_name    = _('is staff'),
            default         = False,
            )
    is_superuser = models.BooleanField(
            verbose_name    = _('is superuser'),
            default         = False,
            )

    USERNAME_FIELD = 'phone_number'

    objects = UserManager()
    
    class Meta:
        db_table = 'users'

class Address(models.Model):
    user = models.ForeignKey(
            'User',
            related_name    = 'addresses',
            on_delete       = models.CASCADE,
            )
    name = models.CharField(
            verbose_name    = _('location'),
            max_length      = 64,
            null            = True,
            blank           = True,
            default         = None,
            )
    is_main = models.BooleanField(
            verbose_name    = _('is main'),
            default         = False,
            )
    
    class Meta:
        db_table = 'addresses'

class Filter(models.Model):
    user = models.ForeignKey(
            'User',
            related_name    = 'filters',
            on_delete       = models.CASCADE,
            )
    subcategory = models.ForeignKey(
            'post.Subcategory',
            on_delete       = models.CASCADE,
            )
    is_active = models.BooleanField(
            verbose_name    = _('is_main'),
            default         = True,
            )

    class Meta:
        db_table = 'filters'
  1. serializers.py
/api/user/serializers.py

from django.contrib.auth            import get_user_model
from django.contrib.auth.models     import update_last_login
from rest_framework                 import serializers
from rest_framework_jwt.settings    import api_settings

from user.models            import User, Address, Filter
from post.models            import Subcategory

from api.post.serializers   import PostSerializer

User                = get_user_model()
JWT_PAYLOAD_HANDLER = api_settings.JWT_PAYLOAD_HANDLER
JWT_ENCODE_HANDLER  = api_settings.JWT_ENCODE_HANDLER

class AddressSerializer(serializers.ModelSerializer):
    class Meta:
        model   = Address
        fields  = '__all__'
        extra_kwargs    = {
                    'addresses' : {'required': False},
                    'user'      : {'required': False},
                    'is_main'   : {'required': False}
                }
    def create(self, validated_data):
        return super().create(validated_data)

class FilterSerializer(serializers.ModelSerializer):
    class Meta:
        model   = Filter 
        fields  = '__all__'

class UserAddressSerialzier(serializers.ModelSerializer):
    addresses   = AddressSerializer(many=True)
    
    class Meta:
        model           = User
        fields          = ['addresses']

    def create(self, validated_data):
        return super().create(validated_data)

class UserSerializer(serializers.ModelSerializer):
    addresses   = AddressSerializer(many=True, read_only=True)
    filters     = FilterSerializer(many=True, read_only=True)
    posts       = PostSerializer(many=True, read_only=True)

    class Meta:
        model        = User
        fields       = ['phone_number', 'nickname', 'profile_image', 'addresses', 'filters', 'posts']
        extra_kwargs = {
            'phone_number': {'validators': []},
        }

    def validate(self, data):
        user, is_created = User.objects.get_or_create(phone_number=data["phone_number"])
        
        if is_created:
            subcategory_ids = Subcategory.objects.filter(category_id=1).values_list('id', flat=True)
            Filter.objects.bulk_create(
                [Filter(user_id = user.id, subcategory_id = id) for id in subcategory_ids]
            )
        
        payload   = JWT_PAYLOAD_HANDLER(user)
        jwt_token = JWT_ENCODE_HANDLER(payload)
        
        update_last_login(None, user)
        
        results = {
                'access_token' : jwt_token
            }

        return results
  1. views.py
/api/user/views.py

from rest_framework                 import status, generics, mixins
from rest_framework.response        import Response
from rest_framework.permissions     import IsAdminUser,IsAuthenticated, IsAuthenticatedOrReadOnly, AllowAny

from user.models    import User, Filter, Address

from .serializers   import AddressSerializer, UserSerializer

class AddressDetailGenericAPIView(generics.ListAPIView):
    queryset            = Address.objects.all()
    serializer_class    = AddressSerializer
    permission_classes  = [AllowAny]

    def get_queryset(self):
        return Address.objects.filter(user_id=self.request.user.id)

    def post(self, request):
        serializer  = AddressSerializer(data=request.data)
        user        = User.objects.get(id=request.user.id)

        if not serializer.is_valid(raise_exception=True):
            return Response({"message": "Request Body Error."}, status=status.HTTP_409_CONFLICT)

        name = serializer.validated_data['name']
        if name == "None":
            return Response({'message': 'fail'}, status=status.HTTP_200_OK)
        
        try:
            former_address  = Address.objects.get(user_id=user.id, name=name, is_main=False)
            present_address = Address.objects.get(user_id=user.id, is_main=True)
            
            present_address.is_main = False
            former_address.is_main  = True
            
            present_address.save()
            former_address.save()
            
        except Address.DoesNotExist:
            try :
                former_address = Address.objects.get(user_id=user.id, is_main=True)
                former_address.is_main = False
                former_address.save()
                Address.objects.create(
                    user_id = user.id,
                    name    = name,
                    is_main = True
                )
            except Address.DoesNotExist:
                Address.objects.create(
                    user_id = user.id,
                    name    = name,
                    is_main = True
                )

        return Response({'message':'success'}, status=status.HTTP_200_OK)

class UserSignInGenericAPIView(generics.GenericAPIView):
    queryset            = User.objects.all()
    serializer_class    = UserSerializer
    permission_classes  = [AllowAny]

    def post(self, request):
        serializer = UserSerializer(data=request.data)

        if not serializer.is_valid(raise_exception=True):
            return Response({"message": "Request Body Error."}, status=status.HTTP_409_CONFLICT)

        response = {           
            'access_token'  : serializer.validated_data['access_token']
        }

        return Response(response, status=status.HTTP_200_OK)
  1. urls.py
/we_market/ursl.py

from django.contrib import admin
from django.urls    import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/user', include('api.user.urls'), name='user-api'),
]

/api/user/urls.py

from django.urls    import path

from .views import UserSignInGenericAPIView, AddressDetailGenericAPIView

app_name= 'users'

urlpatterns = [
    path('/signin', UserSignInGenericAPIView.as_view(), name='user-signin'),
    path('/address', AddressDetailGenericAPIView.as_view(), name='user-address'),
]

정리하자면 아래와 같다.

1. 핸드폰 번호를 통한 회원가입 및 로그인
2. 로그인시, 유저의 위치 및 즐겨찾기 정보 자동 불러오기

Post

당근 마켓의 게시글의 경우, 일반적인 중고사이트에서 올리게 되는 형식을 지키면 된다. 제목, 제품, 소개글, 가격, 좋아요 수, 조회 수, 채팅 수, 주소, 작성 시간, 수정 시간을 항목으로 추가하였다. 게시글에 들어갈 사진들 또한 참조 될 수 있도록 항목으로 추가하였다.
또한, 현재의 당근 마켓에 들어가면 좋겠을 부분인 새 제품의 현 가격을 추가하기로 계획하였었다. 다음 포스팅 부터 이 부분을 중점적으로 다룰 것이다!

  1. models.py
/post/models.py

from django.db                  import models
from django.utils.translation   import ugettext_lazy as _

class Post(models.Model):
    user = models.ForeignKey(
            'user.User',
            related_name = 'users',
            on_delete = models.CASCADE,
            )
    subcategory = models.ForeignKey(
            'Subcategory',
            on_delete = models.CASCADE,
            )
    status = models.ForeignKey(
            'Status',
            on_delete = models.CASCADE,
            )
    title = models.CharField(
            verbose_name    = _('title'),
            max_length      = 64,
            )
    product = models.CharField(
            verbose_name    = _('product name'),
            max_length      = 64,
            null            = True,
            blank           = True,
            )
    introduction = models.CharField(
            verbose_name    = _('introduction'),
            max_length      = 2000,
            )
    price = models.DecimalField(
            verbose_name    = _('price'),
            max_digits      = 10,
            decimal_places  = 2,
            )
    like_count = models.IntegerField(
            verbose_name    = _('like count'),
            default         = 0,
            )
    view_count = models.IntegerField(
            verbose_name    = _('view count'),
            default         = 0,
            )
    chat_count  = models.IntegerField(
            verbose_name    = _('chat count'),
            default         = 0,
            )
    possible_discount = models.BooleanField(
            verbose_name    = _('is discount'),
            default         = False,
            )
    address = models.CharField(
            verbose_name    = _('address'),
            max_length      = 64,
            )
    created_at = models.DateTimeField(
            verbose_name    = _('created at'),
            auto_now_add    = True,
            )
    updated_at = models.DateTimeField(
            verbose_name    = _('updated_at'),
            auto_now        = True,
            )

    class Meta:
        db_table = 'posts'

    @property
    def first_image(self):
        return self.images.first()

class Subcategory(models.Model):
    category = models.ForeignKey(
            'Category',
            on_delete   = models.CASCADE,
            )
    name = models.CharField(
            verbose_name    = _('name'),
            max_length      = 64,
            )

    class Meta:
        db_table = 'subcategories'

class Category(models.Model):
    name = models.CharField(
            verbose_name    = _('name'),
            max_length      = 64,
            )

    class Meta:
        db_table = 'categories'

class Status(models.Model):
    name = models.CharField(
            verbose_name    = _('name'),
            max_length      = 64,
            )

    class Meta:
        db_table = 'statuses'

class PostImage(models.Model):
    post = models.ForeignKey(
            'Post',
            related_name='images',
            on_delete=models.CASCADE,
            )
    url = models.URLField(
            verbose_name    = _('url'),
            max_length      = 2000,
            null            = True,
            )

    class Meta:
        db_table = 'images'

class CrawlImage(models.Model):
    post = models.ForeignKey(
            'Post',
            related_name = 'crawl_images',
            on_delete    = models.CASCADE,
            )
    url  = models.URLField(
            verbose_name    = _('url'),
            max_length      = 2000,
            null            = True,
            )

    class Meta:
        db_table = 'crawl_images'

class CrawlMall(models.Model):
    post = models.ForeignKey(
            'Post',
            related_name = 'crawl_malls',
            on_delete    = models.CASCADE,
            )
    name = models.URLField(
            verbose_name    = _('name'),
            null            = True,
            )
    price = models.DecimalField(
            verbose_name    = _('price'),
            max_digits      = 10,
            decimal_places  = 2,
            )
    
    class Meta:
        db_table = 'crawl_malls'

class SearchHistroy(models.Model):
    user = models.ForeignKey(
            'user.User',
            on_delete = models.CASCADE,
            )
    post = models.ForeignKey(
            'Post',
            on_delete = models.CASCADE,
            )
    created_at = models.DateTimeField(
            verbose_name    = _('created at'),
            auto_now_add    = True,
            )
  1. serializers.py
/api/post/serializers.py

from rest_framework     import serializers

from user.models    import User
from post.models    import Post, PostImage

class ImageSerializer(serializers.ModelSerializer):
    class Meta:
        model  = PostImage
        fields = ['url']

class PostSerializer(serializers.ModelSerializer):
    images = ImageSerializer(many=True)
    
    class Meta:
        model        = Post
        fields       = ['title', 'subcategory', 'status', 'title', 'product', 'product', 'introduction', 'price', 'like_count', 'view_count', 'chat_count', 'possible_discount', 'address', 'created_at', 'updated_at', 'images']
        extra_kwargs = {
                'address'   : {'required': False},
                'images'    : {'required': False},
                }

class PostListSerializer(serializers.ModelSerializer):
    image = ImageSerializer(source='first_image', read_only=True)

    class Meta:
        model   = Post
        fields  = ['title', 'image','address', 'created_at', 'price', 'like_count', 'chat_count', 'product']

class PostDetailSerializer(serializers.ModelSerializer):
    images      = ImageSerializer(many=True, read_only=True)
    subcategory = serializers.ReadOnlyField(source='subcategory.name')
    writer      = serializers.SerializerMethodField()

    class Meta:
        model       = Post
        fields      = ['id', 'title', 'address', 'created_at', 'price', 'like_count', 'chat_count', 'images', 'subcategory', 'possible_discount', 'view_count', 'introduction', 'writer']

    def get_writer(self, obj):
        user = User.objects.get(id=obj.user_id)
        writer = {
                'writer_nickname'       : user.nickname,
                'wirter_profile_image'  : user.profile_image,
                'writer_posts'          : [
                    {
                        'title'         : post.title,
                        'post_image'    : post.images.first().url,
                        'price'         : post.price
                    } for post in Post.objects.filter(user_id=user.id)]
            }
        return writer   
  1. views.py
/api/post/views.py

from rest_framework             import generics, status
from rest_framework.response    import Response
from rest_framework.permissions import AllowAny, IsAdminUser, IsAuthenticated, IsAuthenticatedOrReadOnly

from user.models    import Address, Filter, User
from post.models    import Post, Subcategory, PostImage, Status
from .serializers   import PostSerializer, PostDetailSerializer, PostListSerializer

class PostListGenericsAPIView(generics.ListAPIView):
    serializer_class    = PostListSerializer
    permission_classes  = [IsAuthenticatedOrReadOnly]

    def get_queryset(self):
        address = Address.objects.get(user_id=self.request.user.id, is_main=True)
        
        exclude_filters         = Filter.objects.filter(user_id=self.request.user.id, is_active=False)
        excluded_subcategories  = [exclude_filter.subcategory_id for exclude_filter in exclude_filters]

        posts   = Post.objects.filter(address=address.name).exclude(subcategory_id__in = excluded_subcategories)

        return posts

class PostDetailGenericsAPIView(generics.RetrieveUpdateDestroyAPIView):
    queryset            = Post.objects.all()
    serializer_class    = PostDetailSerializer
    permission_classes  = [IsAuthenticatedOrReadOnly]

class PostCreateGenericsAPIView(generics.CreateAPIView):
    queryset            = Post.objects.all()
    serializer_class    = PostSerializer
    permission_classes  = [IsAuthenticated]

    def post(self, request, *args, **kwargs):
        serializer = PostSerializer(data=request.data)
        if serializer.is_valid():
            post = Post.objects.create(
                    user_id             = request.user.id,
                    subcategory_id      = request.data['subcategory'],
                    status_id           = request.data['status'],
                    product             = request.data['product'],
                    address             = Address.objects.get(user_id=request.user.id, is_main=True).name,
                    like_count          = 0,
                    view_count          = 0,
                    chat_count          = 0,
                    possible_discount   = request.data['possible_discount'],
                    introduction        = request.data['introduction'],
                    price               = request.data['price'],
                    )
            
            for image_dict in request.data['images']:
                for key, value in image_dict.items():
                    PostImage.objects.create(
                        post_id         = post.id,
                        url             = value,
                        )
            
            return Response(serializer.data, status=status.HTTP_200_OK)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
  1. urls.py
/we_market/urls.py

from django.contrib import admin
from django.urls    import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/user', include('api.user.urls'), name='user-api'),
    path('api/post', include('api.post.urls'), name='post-api'),
]

/api/post/urls.py

from django.urls    import path

from api.post.views     import PostCreateGenericsAPIView, PostDetailGenericsAPIView, PostListGenericsAPIView

app_name='post'

urlpatterns = [
    path('/', PostListGenericsAPIView.as_view(), name='post-list'),
    path('/<int:pk>', PostDetailGenericsAPIView.as_view(), name='post-detail'),
    path('/create', PostCreateGenericsAPIView.as_view(), name='post-create')
]
profile
공부하는 예비 개발자

0개의 댓글