diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 044cdd1..1944c53 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,7 +3,7 @@ name: Deploy to EC2 on: push: branches: - - dev + - master jobs: diff --git a/.gitignore b/.gitignore index 0bc3174..a8fec23 100644 --- a/.gitignore +++ b/.gitignore @@ -170,5 +170,6 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. .idea/ - +.env +.env.prod # End of https://www.toptal.com/developers/gitignore/api/django \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index b2d0d07..1e208f1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,8 @@ +# pyhton 3.8.3 버전을 담은 이미지를 개조해서 사용할 예정 FROM python:3.8.3-alpine ENV PYTHONUNBUFFERED 1 +# 이미지 내에서 명령어를 실행할 디렉토리 설정 RUN mkdir /app WORKDIR /app diff --git a/README.md b/README.md index 8c0caf2..b970c3a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,763 @@ # CEOS 17기 백엔드 스터디 +### 에브리타임 서비스 설명 w/ ERD +* 데이터 모델링 분류를 크게 **account**, **board**, **timetable** 세가지로 나누었다. +* account 기능: User, School + * 사용자는 가입할 때, 학교를 선택하여야 한다. + * 이후 사용자는 개인 정보(아이디, 비밀번호, 이메일, 닉네임, 입학연도 등)를 입력하여 에브리타임에 가입한다. +* board 기능(커뮤니티 기능): Board, Post, Message, My_board, Post_media, Comment, Scrap + * 사용자는 게시판을 즐겨찾기를 통해 고정할 수 있다. + * 사용자는 게시물을 작성할 수 있으며, 게시물을 스크랩, 공감할 수 있고, 댓글과 대댓글을 작성할 수 있다. + * 사용자는 스크랩한 게시물을 따로 모아서 볼 수 있다. + * 사용자는 게시글을 쓴 사람 혹은 댓글을 단 사람과 쪽지를 주고 받을 수 있다. +* timetable 기능: Friend, Timetable, Lecture, My_lecture, Review + * 사용자는 강의를 선택하여 시간표에 넣을 수 있다. + * 사용자는 선택한 강의에 대해 강의평을 작성할 수 있다. + * 사용자는 친구맺기 기능을 통해 친구와 시간표를 공유할 수 있다. +![img_6.png](img_6.png) + +### ORM 이용해보기 +* ForeignKey 필드를 포함하는 모델로 **Board**을 선택하였다. +1. 데이터베이스에 해당 모델 객체 3개 이상 넣기 +```angular2html +from account.models import School +school1 = School(school_name = '홍익대학교') +school1 = School(school_name = '이화여자대학교') +school1 = School(school_name = '서강대학교') +school1 = School(school_name = '연세대학교') +``` +![img.png](img.png) +```angular2html +from board.models import Board +board1 = Board(category = '학과', name = '홍익대컴퓨터공학과', school_id_id = 1) +board1 = Board(category = '학과', name = '컴퓨터공학과', school_id_id = 1) +board2 = Board(category = '진로', name = '진로게시판', school_id_id = 2) +board3 = Board(category = '홍보', name = '홍보게시판', school_id_id = 3) +board4 = Board(category = '단체', name = '총학생회', school_id_id = 4) +``` +![img_1.png](img_1.png) +2. 삽입한 객체들을 쿼리셋으로 조회해보기 +```angular2html +Board.objects.all() +``` +![img_2.png](img_2.png) +3. filter 함수 사용해보기 +```angular2html +Board.objects.filter(category= '단체') +``` +![img_3.png](img_3.png) + +### 겪은 오류와 해결 과정 +* 메세지에서 두 속성이 같은 유저를 참고할 때 related_name을 설정하라는 오류가 발생했다. +* User 입장에서는 Message에서 두개의 필드가 참조를 하고 있기 때문에 역참조하는 입장에서 생각해봤을 때 이를 구분해달라는 오류인 것 같다고 생각했다. +```angular2html +user = models.ForeignKey(User, on_delete=models.PROTECT) +sender = models.ForeignKey(User, related_name='sender',on_delete=models.PROTECT) +``` +related_name 역할? +* User 인스턴스와 연결되어 있는 Message를 거꾸로 불러올 때, related_name='sender' 라는 이름으로 부르겠다고 지정해 준 것이다. +* ralated_name이 필수는 아니지만 위 경우처럼 한 테이블에서 서로 다른 두 속성이 같은 테이블을 참조할 때는 필수로 지정해주어야 한다. + +### 새롭게 알게 된점 +1. 커스텀 User 모델 +* 커스텀 User 모델을 작성하는 세 가지 방법 + * 표준 User 모델과 1대 1 관계를 가지는 모델을 만드는 방법 + * AbstractUser을 상속받는 모델을 만드는 방법 + * AbstractBaseUser을 상속받는 모델을 만드는 방법 +* 세가지 중, AbstractBaseUser을 상속받아 구현하였다. +* 커스터마이즈 유연성이 세가지 중 가장 높다.(=최소한의 필드만 제공) +2. 생성시각, 수정시각 +```angular2html +class TimestampedModel(models.Model): + # 생성된 날짜를 기록 + created_at = models.DateTimeField(auto_now_add=True) + # 수정된 날짜를 기록 + updated_at = models.DateTimeField(auto_now=True) +``` +언제 만들어졌고 수정되었는지는 향후 유지보수에 있어서 굉장히 중요한 정보이기 때문에 TimestampedModel 클래스를 따로 만들어 모든 클래스가 이를 상속받도록 하였다. +3. UUID +* 중복되지 않는 ID를 만드는 표준 규약 +* 계속해서 생성하여도 중복될 확률이 0에 가깝다고 한다. +* 사용자의 기본키를 UUIDField로 지정하였다. +* 기본키가 연속성의 규칙을 가지면 보안상의 문제도 무시할 수 없을 것이다. +```angular2html +user_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) +``` +4. 대댓글 자기 참조 +* 대댓글도 본질은 댓글이기 때문에 따로 테이블을 만들지 않고 'self'로 자기 참조를 통해 구현하였다. +* 이 필드에 값이 있으면 대댓글, 없으면 댓글이다. +```angular2html +parent_comment = models.ForeignKey('self', on_delete=models.CASCADE, null=True) +``` + +### 회고 +작년에 데이터베이스 강의를 들었을 때 교수님께서 현실에 있는 데이터를 추상화하는 과정(모델링)이 데이터베이스를 다룰 때 가장 중요한 부분이라고 강조하셨던 기억이 있어서 이번 과제를 하는데 있어서 ERD를 짜는 데 가장 많은 시간을 할애하였다. +과제를 하면서 가장 헷갈렸던 부분 중 하나가 테이블을 어디까지 세세하게 나누어야하고, 어떤 정보를 담아야할지였는데 내가 에브리타임을 사용하는 사람일 경우를 기준으로 생각해보니 필요한 정보만을 추출하여 ERD를 짤 수 있었다. +그리고 우리가 실제로 자주 사용하는 어플을 가지고 데이터 모델링을 해볼 수 있어서 재미있었고, Django와 조금은(?) 더 친해진 느낌이다.....ㅎ + + +# CEOS 17기 백엔드 3주차 스터디 + +## 2주차 데이터 모델링 피드백 반영 + +### CharField를 TextField로 수정 +```python +content = models.TextField(blank=False) +``` +- MySQL에서 char type의 최댓값은 255이기 때문에, 이보다 더 큰 값을 넣어줘야 하는 필드는 TextField로 바꿔주었다. + +### class naming 규칙 반영 +- My_board 를 MyBoard 로 변경하였다. +- My_Lecture 를 MyLecture 로 변경하였다. + +## 3주차 미션: DRF1 - Serializer, API View & Filter + +### Serializer + +- Serializer는 Django가 다룰 수 있는 객체를 외부에서 받는 JSON 등의 데이터 형태로 변환한다는 것을 의미한다. +- Deserialize는 Serializer와 반대되는 개념이다. +- 요청 JSON 데이터를 Deserialize 하여 Django 객체에 저장하고, +- Django 객체를 Serializer 하여 응답 JSON 데이터로 바꿔주는 것이다. + +### Nested Serializer + +- 두 테이블 간의 관계를 표현하기 위해서 Nested Serializer 를 사용하였다. +```python +class BoardSerializer(serializers.ModelSerializer): + school_id = SchoolSerializer + + class Meta: + model = Board # models.py의 board 사용 + fields = '__all__' # 모든 필드 포함 +``` + +- School 과 Board 는 1:N 의 관계를 가지므로, Board 에 관련된 School 의 정보를 함께 가져오기 위해 Nested Serializer 를 사용하였다. + +### 의문점 + +- 저번 과제에서 models.py를 구현할 때, 대댓글 테이블을 따로 만들지 않고 댓글 테이블을 자기 참조하여 만들었는데 이를 Serializer로 구현하려니 코드가 이상해졌다. +```python +parent_comment = CommentSerializer() +``` +- 위 코드를 CommentSerializer 에 넣으니 에러가 나는 것을 보고, Serializer 는 자기 참조가 안 되는 듯 보였다. +- 그래서 아래와 같이 코드를 InCommentSerializer 를 따로 만들어 코드를 리팩토링 해보았다. +```python +class CommentSerializer(serializers.ModelSerializer): + post = PostSerializer + user = UserSerializer + + # parent_comment = CommentSerializer() + + class Meta: + model = Comment + fields = '__all__' # 모든 필드 포함 + + +class InCommentSerializer(serializers.ModelSerializer): + parent_comment = CommentSerializer + + class Meta: + model = Comment + fields = '__all__' # 모든 필드 포함 +``` +- InCommentSerializer 는 Comment 클래스에 있는 모드 필드를 포함하되, **parent_comment = CommentSerializer** 를 설정하여 CommentSerializer 와 관계를 맺도록 하였다. +- 이와 관련해서 구글링을 해보았지만 참고할 만 한 레퍼런스가 딱히 없어서 이렇게 구현을 했는데 이런 방법이 맞는 것인지 궁금합니다.. + +### CBV (Class-Based View) + +```python +class BoardList(APIView): + + def get(self, request, format=None): + try: + board_list = Board.objects.all() + serializer = BoardSerializer(board_list, many=True) + return Response(serializer.data) + except AttributeError as e: + print(e) + return Response("message: error") + + def post(self, request): + serializer = BoardSerializer(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=201) + return Response(serializer.errors, status=400) + + +class BoardDetail(APIView): + def get(self, request, pk): + try: + board = Board.objects.get(id=pk) + serializer = BoardSerializer(board) + return Response(serializer.data, status=201) + except ObjectDoesNotExist as e: + print(e) + return Response({"message: error"}) + + def delete(self, request, pk): + try: + board = Board.objects.get(id=pk) + board.delete() + return Response(status=200) + except ObjectDoesNotExist as e: + print(e) + return Response({"message: not exist"}) +``` +- BoardList 는 APIView 를 상속받고 있다. +- APIView는 클래스로 정의된다. +- APIView를 상속받은 클래스 안에 request method에 맞는 함수들을 정의해주면 각각의 요청은 request method 이름에 맞게 구분되어 그에 맞는 결과를 반환하게 된다. + +### ViewSet 으로 리팩토링 + +- 여러가지 API 기능을 통합해서 하나의 API set 으로 제공하는 것이다. +- CBV 로 작성한 코드를 보면 BoardList, BoardDetail 각각의 api 가 중복되는 경우가 있다. +- 이럴때 ViewSet 을 쓰게 되면 중복되는 로직의 코드를 줄일 수 있어 코드의 효율성을 높일 수 있다. +- ViewSet은 .get(), .post() 대신 .list(), .create() 같은 액션을 제공한다. +```python +class BoardViewSet(viewsets.ModelViewSet): + serializer_class = BoardSerializer + queryset = Board.objects.all() + filter_backends = [DjangoFilterBackend] + filterset_class = BoardFilter +``` + +### URL 매핑 with Router +```python +router = routers.DefaultRouter() +router.register('board', BoardViewSet) + +urlpatterns = [ + path('', include(router.urls)), +] +``` +- viewset들의 view를 명시적으로 등록하는 것보다 router 클래스를 사용해 viewset을 등록하였다. + +### Filtering 기능 구현하기 +```python +class BoardFilter(FilterSet): + name = filters.CharFilter(field_name='name') + school_id = filters.NumberFilter(method='filter_school_id') + + def filter_school_id(self, queryset, name, value): + return queryset.filter(**{ + name: value, + }) + + class Meta: + model = Board + fields = ['name', 'school_id'] +``` +- Board 클래스 내의 name, school_id_id 에 필터를 걸어 줄 BoardFilter 클래스를 FilterSet 을 상속해 선언해주었다. + +### 모든 데이터를 가져오는 API 만들기 + +- Board의 모든 list를 가져오는 API 요청 결과 + + - url : board/ + - method: GET +``` +{ + "id": 3, + "created_at": "2023-04-01T01:33:26.211450+09:00", + "updated_at": "2023-04-01T01:33:26.211450+09:00", + "category": "진로", + "name": "진로게시판", + "is_deleted": false, + "school_id": 2 + }, + { + "id": 2, + "created_at": "2023-04-01T01:33:18.361955+09:00", + "updated_at": "2023-04-01T01:33:18.362962+09:00", + "category": "학과", + "name": "컴퓨터공학과", + "is_deleted": false, + "school_id": 1 + }, + { + "id": 1, + "created_at": "2023-04-01T01:25:16.489555+09:00", + "updated_at": "2023-04-01T01:25:16.489555+09:00", + "category": "학과", + "name": "홍익대컴퓨터공학과", + "is_deleted": false, + "school_id": 1 + } +``` +![BoardList](https://user-images.githubusercontent.com/81136546/230720061-8c31293c-e3b4-410c-abb9-4613d71344dc.png) + +### 특정 데이터를 가져오는 API 만들기 + +- 3번째 Board를 가져오는 API 요청 결과 + - url: board/3/ + - method: GET +``` +{ + "id": 3, + "created_at": "2023-04-01T01:33:26.211450+09:00", + "updated_at": "2023-04-01T01:33:26.211450+09:00", + "category": "진로", + "name": "진로게시판", + "is_deleted": false, + "school_id": 2 +} +``` +![BoardDetail](https://user-images.githubusercontent.com/81136546/230720765-d1c30990-8926-4100-9cfc-8d054253fd66.png) + +### 새로운 데이터를 create하도록 요청하는 API 만들기 + +- Board를 추가하는 API 요청 결과 + - url: board/ + - method: POST + +![POST](https://user-images.githubusercontent.com/81136546/230721062-e08f08f2-f2f3-401c-8a67-6a562794989f.png) + +![POST1](https://user-images.githubusercontent.com/81136546/230721095-7a5f4b35-57eb-403b-bd38-e9ee5c2903c5.png) + +### 특정 데이터를 삭제 또는 업데이트하는 API + +- 특정 Board 를 삭제하는 API 요청 결과 + - url: board/4/ + - method: DELETE +- id가 4인 board 를 삭제한 후 다시 /board/4 로 GET 요청을 하면 아래와 같이 뜬다. +```python +{ + "detail": "찾을 수 없습니다." +} +``` + +### 겪은 오류와 해결 과정 +1. many = True 추가 +```python +class BoardList(APIView): + + def get(self, request, format=None): + try: + board_list = Board.objects.all() + serializer = BoardSerializer(board_list, many = True) + return Response(serializer.data) + except AttributeError as e: + print(e) + return Response("message: error") +``` +- Serializer 에 해당 필드가 있는데 자꾸 없다는 오류가 떴다. +- serializer로 보내주는 데이터가 여러 개의 object인 queryset 인 경우, +- queryset을 넘겨주기 위해서는 **many=True** 를 추가로 작성해줘야 한다고 한다. +- 나와 같은 오류를 해결한 블로그를 첨부하겠다. + [many=True](https://dongza.tistory.com/20) + +2. Nested Serializer + +- Nested Serializer 를 사용하는 이유는 두 테이블 간의 관계를 연결시켜, 외래키가 포함된 테이블의 정보까지 함께 보기 위함이다. +- 하지만 외래키인 school_id_id 필드를 넣어줬음에도 'school_id_id cannot be null' 이라는 에러가 뜨며 api가 돌아가지 않았다. +- 그래서 아래와 같이 SchoolSerializer 뒤에 괄호를 없앴더니 정상적으로 돌아가긴 했다. +- 결과적으로는 돌아가지만 내가 구현한 것은 사실 Nested Serializer 는 아닌 것이다.. +- 이 부분은 추후에 수정해야겠다고 생각했다..! +```python +class BoardSerializer(serializers.ModelSerializer): + school_id = SchoolSerializer + + class Meta: + model = Board # models.py의 board 사용 + fields = '__all__' # 모든 필드 포함 +``` + +### 회고 + +- ViewSet 을 사용하니 확실히 따로 api 를 구현할 때 보다 코드 길이가 줄어드는게 너무 신기했고 개발자 입장에서 너무 편리하다고 생각이 들었다. +- 직접 API 를 만들고 값을 넣어가며 눈으로 보이는 코딩을 할 수 있어서 확실히 지난 과제보다 재미있었다ㅎㅎㅎ!! +- nested serializer에서 미흡한 점이 있었지만 이번 과제를 함으로써 django 에서 쓰이는 다양한 기능을 써볼 수 있어서 정말 유익했다!! + +## 4주차: DRF2 - Simple JWT + +### Q1. 로그인 인증은 어떻게 하나요? + +#### Session과 Cookie를 이용한 로그인 인증 방식 + +- Session: 방문자가 웹 서버에 접속해 있는 일련의 상태 +- Cookie(Session ID): 웹 사이트에 접속할 때 생성되는 정보를 담은 데이터 = 세션을 발급받기 위한 도구 + +![세션과 쿠키](https://user-images.githubusercontent.com/81136546/236402787-01db8b2b-5ab9-4525-925d-f3f724b41b96.png) +- 사용자가 로그인하면 +- 서버 측에서 사용자의 인증 정보를 저장하고 +- 클라이언트 측에 쿠키(세션 ID)를 전송하여 인증을 유지한다. +- 그 후, 클라이언트가 서버에 작업을 요청할 때 +- 요청 헤더에 쿠키가 같이 전달된다 +- 서버는 클라이언트가 보낸 쿠키와 기존 정보를 비교하여 인증한다. + +#### OAuth를 이용한 로그인 인증 방식 + +- OAuth란? + : 사용자의 인증 및 권한 부여를 위한 표준 프로토콜 + : 쉽게 말해, 우리의 서비스가 우리 서비스를 이용하는 유저의 타사 플랫폼 정보에 접근하기 위해서 권한을 타사 플랫폼으로부터 위임 받는 것 이다. + +- 장점 + - 사용자는 ID와 Password를 공유하지 않으면서 여러 애플리케이션에서의 로그인 및 접근을 간편하게 할 수 있습니다 +- 단점 + - OAuth를 사용하는 서비스가 중단되면 다른 서비스와 연동하는 데 어려움이 있을 수 있다. + - 과정이 복잡하고 개발이 어렵다... + +#### JWT를 이용한 로그인 인증 방식 + + - 서버에서 JWT를 발급하여 클라이언트에게 전달하고, + - 클라이언트는 이를 저장해두고 인증이 필요한 요청을 보낼 때마다 + - JWT를 함께 전송하여 인증하는 방식 + +### Q2. JWT는 무엇인가요? + +JSON WEB TOKEN 의 약자 +: 웹 애플리케이션 간 정보를 안전하게 전송하기 위한 오픈 스탠다드 + +- JWT는 세가지 부분으로 이루어져 있다. + ![JWT구조](https://user-images.githubusercontent.com/81136546/236397384-7b3ec663-9991-4313-b629-7c2e2c40bc8e.png) + - Header - 토큰의 유형과 해싱 알고리즘 정보가 담겨 있다. + - Payload - 서버와 클라이언트 간 주고받을 **정보**가 JSON 형태로 인코딩되어 있다. + - Payload에는 사용자 정보, 권한, 토큰 만료 시간 등을 포함할 수 있습니다. + - Signature - 헤더와 페이로드를 인코딩하여 생성된 서명 값이다. + +- 장점 + - 토큰 자체에 정보가 담겨 있어 별도의 세션 상태를 유지할 필요가 없다. + - 따라서, 서버는 상태를 유지할 필요 없이 각 요청마다 **JWT 를 검증**하여 사용자 인증 및 권한 부여를 한다. + - URL 파라미터나 HTTP 헤더 등으로 전송할 수 있다. + - 다양한 플랫폼과 프로그래밍 언어에서 지원되기 때문에 유연하게 사용된다. +- 단점/주의할 점 + - 보안성이 떨어질 수 있다. + - Signature 값은 secret key 를 사용하여 생성되기 때문에 노출되면 토큰이 위조될 가능성이 있다. + - secret key 를 안전하게 보관하고, 토큰의 만료 시간을 적절하게 설정해야 한다. + - 토큰의 크기가 커질 수 있다. + - 토큰 자체에 정보가 담기기 때문에, Payload의 크기가 커지면 네트워크 부하가 생길 수 있다. + - Payload 자체는 암호화되지 않기 때문에 정보가 노출될 수 있다. + - 토큰을 탈취당하면 대처하기 어렵고, 토큰 만료에 대한 처리가 어렵다. + - JWT는 토큰을 서명한 발급자만 토큰을 무효화할 수 있기 때문에 토큰 만료 처리를 위해서는 토큰을 강제로 만료시키는 방식을 채택해야 합니다. + +### +) Access Token/ Refresh token에 대해 알아보자 + +1. Access Token + : 사용자가 인증을 거친 후, 서비스에 접근할 때 해당 사용자를 식별하는데 사용하는 문자열 + + - Access token은 **인증된 사용자의 권한 정보**를 포함할 수 있다. + - 일반적으로 짧은 유효 기간을 가지며, 만료되면 다시 발급해야 한다. + +2. Refresh Token + : Access token의 만료 기간이 지난 후 새로운 Access token을 발급받을 때 사용되는 문자열 + + - Refresh token은 Access token과 마찬가지로 일정 시간 동안 유효하며, 만료 시간이 지난 후에는 사용할 수 없다. + - Access Token보다 상대적으로 **긴 유효 기간**을 갖는다. + - Refresh token은 주로 **로그인한 사용자를 식별**하고, **유효한 Refresh token이 있는 경우에만 새로운 Access token을 발급**하는 인증 서비스에서 사용된다. +- Refresh Token은 Access Token보다 보안에 더욱 신경써야 한다. + + 그 이유는? + - Refresh token은 Access token보다 더 오랜 시간 동안 유효하기 때문에 만약 Refresh token이 탈취된다면 해커는 긴 시간 동안 인증된 사용자처럼 서비스에 접근할 수 있기 때문! + +### Q3. JWT 로그인 구현하기 + +#### 1. 커스텀 User 모델 사용하기 +- AbstractBaseUser를 상속받아 커스텀 User 모델 생성 +- 데이터 모델링 미션 때 미리 만들어 놨기 때문에 따로 생성하지는 않았다. + +#### 2. 회원가입 구현하기 +![signup](https://user-images.githubusercontent.com/81136546/236418700-6e793e0b-d5ce-4c4b-98fe-ea6e73f24c33.png) + +#### 3. Login 구현하기 +- URL: http://127.0.0.1:8000/account/login/ +- Method: POST +- 로그인 성공 화면 +![login](https://user-images.githubusercontent.com/81136546/236411881-f954fd26-02b6-428b-bd9e-23787be4e9ef.png) +- JSON형식으로 데이터를 넣어주고 로그인을 하게 되면 +- HTTP RESPONSE로 사용자 아이디와 로그인 성공 메시지 그리고 access_token과 refresh_token이 함께 발급된다. +- 로그인 실패 화면 +- ![스크린샷 2023-05-05 173948](https://user-images.githubusercontent.com/81136546/236413568-fd480e32-5a02-486e-97d4-d24ecaa2a03c.png) +- "user account not exist"라는 error message 도출 + +#### 4. Refresh Token 발급 +```python +class LoginSerializer(serializers.ModelSerializer): + ... + def validate(self, data): + ... + refresh = RefreshToken.for_user(user) + return { + 'user' : user, + 'id' : id, + 'access_token': str(refresh.access_token), + 'refresh_token': str(refresh) + } +``` + +#### 5. JWT Logout 은 어떻게 이루어질까요? +- access token과 refresh token을 삭제하거나 만료시킴으로써 로그아웃 구현 +```python +class LogoutView(APIView): + + def post(self, request): + response = Response(status=status.HTTP_204_NO_CONTENT) + response.delete_cookie('access_token') + response.delete_cookie('refresh_token') + return response +``` +#### 6. permission_classes를 통한 권한 설정 +```python +REST_FRAMEWORK = { + 'DEFAULT_PERMISSION_CLASSES': ( + # 'rest_framework.permissions.IsAuthenticated', # 인증된 사용자만 접근 + # 'rest_framework.permissions.IsAdminUser', # 관리자만 접근 + 'rest_framework.permissions.AllowAny', # 누구나 접근 + ), + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ) +} +``` +#### 7. AuthView + +- 로그인 한 사용자의 토큰 정보를 확인하고 유효 검증을 한 뒤 정보를 반환하는 뷰 +```python +class AuthView(APIView): + def get(self, request): + # "Bearer " 형식으로 반환되기 때문에, 분리한 후 access_token만 추출 + access_token = request.META['HTTP_AUTHORIZATION'].split()[1] + # access_token이 없다면 + if not access_token: + return Response({"message": "access token 없음"}, status=status.HTTP_401_UNAUTHORIZED) + # access_token이 존재한다면 + # payload에서 사용자 id를 추출하여 + # UserSerializer에서 사용자 정보를 가져와 반환 + try: + # payload에서 user_id(고유한 식별자)를 추출 + # payload={'user_id:1'} + payload = jwt.decode(access_token, SECRET_KEY, algorithms=['HS256']) # accesstoken 번호 + id = payload.get('user_id') + #해당 유저 아이디를 가지는 객체 user을 가져와 + user = get_object_or_404(User, id=id) + #UserSerializer로 JSON화 시켜준 뒤, + serializer = UserSerializer(instance=user) + #프론트로 200과 함께 재전송 + return Response(serializer.data, status=status.HTTP_200_OK) + + # Access token 유효하지 않을 때 + except jwt.exceptions.InvalidSignatureError: + return Response({"message": "유효하지 않은 access token"}, status=status.HTTP_401_UNAUTHORIZED) + # Access token이 만료되었을 때 + except jwt.exceptions.ExpiredSignatureError: + refresh_token = request.COOKIES.get('refresh_token') + + #refresh_token이 없다면 에러 발생 + if not refresh_token: + return Response({"message": "refresh token 없음"}, status=status.HTTP_401_UNAUTHORIZED) + + try: + #refresh_token 디코딩 + payload = jwt.decode(refresh_token, REFRESH_TOKEN_SECRET_KEY, algorithms=['HS256']) + id = payload.get('id') + user = get_object_or_404(pk=id) + + #새로운 access_toke + access_token = jwt.encode({"id": user.pk}, SECRET_KEY, algorithm='HS256') + + #access_token을 쿠키에 저장하여 프론트로 전송 + response = Response(UserSerializer(instance=user).data, status=status.HTTP_200_OK) + response.set_cookie(key='access_token', value=access_token, httponly=True, samesite='None', secure=True) + + return response + + # refresh_toke + except jwt.exceptions.InvalidSignatureError: + # refresh_token 유효하지 않음 + return Response({"message": "유효하지 않은 refresh token"}, status=status.HTTP_401_UNAUTHORIZED) + + except jwt.exceptions.ExpiredSignatureError: + # refresh_token 만료 기간 다 됨 => 이경우에는, 사용자가 로그아웃 후 재로그인하도록 유인 => 리다이렉트 + return Response({"message": "refresh token 기간 만료"}, status=status.HTTP_401_UNAUTHORIZED) +``` +- 토큰이 유효한지 여부를 확인하고 +- 만약 access_token이 유효하다면, +- 이를 이용해, 해당 사용자의 정보를 반환하고, +- 만약 access_token이 유효하지 않으면, +- refresh_token을 이용해 새로운 access_token을 발급해준다. + +### 겪은 오류와 해결 과정 +1. ERROR: 'Manager' object has no attribute 'create_user' +- 'create_user' 관련 오류길래 models.py에서 커스텀 유저 생성 관련 코드를 잘 살펴봤다. + +- class UserManager에서 처음에 BaseUserManager를 상속받지 않아서였다. +- 그래서, ```class UserManager(BaseUserManager):```로 고쳐줬더니 잘 돌아갔다,,,, + +2. access_token을 기반으로 사용자 정보 가져올 때 +- 분명 로그인을 정상적으로 하고 유효한 access_token으로 사용자 정보를 가져오려고 하는데, +- {"message": "access token 없음"} 이라는 에러가 자꾸 떴다. +- request.META['HTTP_AUTHORIZATION']에서 반환되는 값은 일반적으로 "Bearer "과 같은 형식으로 반환되기 때문에 +- 이를 split으로 분리해 만을 가져와야 한다는 것을 알았다. +- ```access_token = request.META['HTTP_AUTHORIZATION'].split()[1]``` +- 이렇게 코드를 고치고 access_token을 입력하고 실행해보니 드디어 사용자의 정보가 알맞게 나왔다. +![image](https://user-images.githubusercontent.com/81136546/236425575-8ab7d72a-afba-46de-be09-f4fb3481fa14.png) +- 여기서 user_id가 이상한 문자열인 이유는 기본키 타입을 UUID로 해놨기 때문이다. + +## 5주차: AWS : EC2, RDS & Docker & Github Action + +### 실 환경 배포 + +- 회원가입 API +- `POST http://ec2-52-79-177-143.ap-northeast-2.compute.amazonaws.com/account/signup/` +![SIGNUP](https://github.com/CEOS-Developers/django_rest_framework_17th/assets/81136546/6f8fa822-6aae-4ead-ab34-1506d00992bd) + +- 로그인 API +- `POST ec2-52-79-177-143.ap-northeast-2.compute.amazonaws.com/account/login/` +![LOGIN](https://github.com/CEOS-Developers/django_rest_framework_17th/assets/81136546/74930028-935b-4623-a94a-8b1e9118f2dd) +- 과제는 성공적으로 구현했다! + +### GitHub Actions 사용해보기 + +- Github Secrets에 각각 알맞은 값을 넣어준다. -> 배포할 때 여기에 있는 정보를 활용 + - 이때, Secrets를 사용하는 이유는 **민감한 정보를 안전하게 관리**해주기 때문이다. + - 워크플로우 파일에서는 이 시크릿 값을 $ 기호를 사용해서, ${{ secrets.MY_SECRET }}와 같이 작성해주면 된다. +![secret](https://github.com/CEOS-Developers/django_rest_framework_17th/assets/81136546/210b174b-4428-4a6e-85c8-d71d28ef907d) +- ENV_VARS: `.env.prod` 파일을 복붙 +```python +DATABASE_HOST={RDS db 주소} +DATABASE_DB=mysql +DATABASE_NAME={RDS 기본 database 이름} +DATABASE_USER={RDS User 이름} +DATABASE_PASSWORD={RDS master 비밀번호} +DATABASE_PORT=3306 +DEBUG=False +DJANGO_ALLOWED_HOSTS={EC2 서버 ip 주소} +DJANGO_SECRET_KEY={django secret key} +``` +- 깃헙에 코드를 push하면, EC2 인스턴스로 사용하여 자동으로 배포해준다. + +### 회고 + +#### RDS와 로컬 MySQL 연결 +ERROR: `Cannot Connect to Database Server` + + - RDS 데이터베이스 생성할 때 마스터 사용자 이름을 'admin'으로 해놓고 + - MySQL에서는 username을 'root'로 지정해서 생긴 에러였다. + - 마찬가지로 이름을 'admin'으로 바꿔주니 연결이 잘 된 것을 확인할 수 있었다. + - 내가 입력한 정보를 잘 기억해야겠다고 다시한번 다짐했다...ㅎㅎ + +#### docker란? +- 개발용 컴퓨터와 서버용 컴퓨터에 같은 환경을 만들어주는 도구이다. +- 여기서 같은 환경이란? + - 컨테이너화 시킨다는 의미 => 서로 다른 환경을 분리할 수 있음 + - 각각의 컨테이너를 도커 컨테이너라고 부른다. +- 도커 컨테이너는 도커에서 실행되는 하나의 유닛이다. +- docker는 Dockerfile를 실행시킨다. +- +#### Dockerfile이란? +- 이미지를 만들기 위한 설계도 <- 이미지란, 컴퓨터의 특정 상태를 캡쳐해서 박제해놨다는 뜻 +- 도커에서 어떻게 실행될지 설정이 되어있다. +```python +# pyhton 3.8.3 버전을 담은 이미지를 개조해서 사용할 예정 +FROM python:3.8.3-alpine +ENV PYTHONUNBUFFERED 1 +``` + +#### docker-compose.yml +- 거시적 설계도 -> 여러개의 컨테이너를 정의하고 구성함 +- ```service```섹션을 사용해서 각각 하나의 컨테이너를 정의할 수 있다! +```python +services: + + db: + container_name: db + image: mysql:5.7 #window + ... +``` +- MySQL 5.7 이미지를 사용하여 컨테이너를 생성한다는 의미이다. +- 이 파일은 개발자는 로컬 환경에서 docker-compose.yml 파일을 사용하여 개발 및 테스트를 수행할 때 사용된다. +- 어떻게 실행? +- 이 파일은 터미널에서 우리가 `docker-compose -f docker-compose.yml up --build` 명령어를 통해 실행해주어야 한다! + +#### docker-compose.prod.yml +- docker-compose.prod.yml 파일은 배포 환경에서 사용되는 Docker Compose 설정 파일이다. +- 실제 운영 환경에서 애플리케이션을 **배포하고 실행할 때 사용**된다. +- 어떻게 실행? +- 이 파일은 Github Actions가 실행시켜준다! +- `config/scripts/deploy.sh` 의 맨 아래에 있는 결국 우리가 실행시켜야하는 명령어인 +- `sudo docker-compose -f /home/ubuntu/srv/ubuntu/docker-compose.prod.yml up --build -d`로부터 실행이 된다. + +#### 이 둘은 어떤 연관이 있을까? +- 이름이 비슷해서 뭔가 연관이 있을 줄 알았지만, 사실 직접적인 연관은 없다. +- 위 언급대로, docker-compose.yml은 로컬환경에서 +- docker-compose.prod.yml은 배포환경에 사용된다. + +#### django에서의 서버 배포 흐름에 대하여 +![server](https://github.com/CEOS-Developers/django_rest_framework_17th/assets/81136546/755cbb8e-0cc7-4fba-8586-72d219553d3b) +- 클라이언트가 Nginx 서버로 HTTP 요청을 전송하면, +- Nginx 서버는 수신한 요청을 받아들이고, 그 요청을 Gunicorn으로 전달한다. +- Gunicorn은 Django 애플리케이션을 실행하는 역할을 한다. +- Django 애플리케이션은 Gunicorn에서 요청을 받아들이고, 요청을 처리하여 필요한 응답을 생성한 다음 +- Gunicorn은 Django 애플리케이션에서 받은 응답을 다시 Nginx로 전달한다. +- Nginx는 최종적으로 응답을 클라이언트에게 반환하여 요청에 대한 응답을 제공합니다. +- 이러한 과정은 **AWS EC2 인스턴스** 내에서 일어난다. + +#### gunicorn과 nginx +1. gunicorn +- Github Actions이 실행해주는 docker-compose.prod.yml파일에 있는 +- `command: gunicorn django_docker.wsgi:application --bind 0.0.0.0:8000` 에 의해 +- Gunicorn이 Django 애플리케이션을 실행하고 +- 웹 서버(nginx)와 애플리케이션(django) 사이의 표준화된 통신 프로토콜인 WSGI를 사용하여 원활한 상호작용이 가능을 하게 해준다. + +2. nginx +- 웹 서버로서 역할 + +#### 느낀 점 + +- 처음 과제를 읽었을 때 모르는 용어가 대부분이라 어디서부터 어떻게 해야할지 너무 막막했다... +- 그래도 과제를 끝내고 리드미를 쓰면서 다시 배포 흐름에 대해서 공부를 하다보니 아주 조금은 알 것 같기도 하다ㅎㅎㅎ +- 어차피 프로젝트할 때 배포해야하니 앞으로 더 자세히 알아보고 공부해야겠다고 생각했당 + +## 6주차 : AWS : https 인증 + +### HTTP와 HTTPS +- HTTP는 인터넷에서 웹 브라우저와 웹 서버 간의 데이터 전송을 위한 프로토콜 +- 데이터를 전송할 때, 기본적으로 평문으로 데이터를 전송하므로 보안성이 보장되지 않는다. +- 이러한 문제를 HTTPS가 보완한다. +- 기본적인 HTTP에 더해 **데이터의 암호화와 인증**을 추가로 제공한다. + +### HTTPS 구현 과정 + +1. SSL/TLS 인증서 준비: AWS의 Certificate Manager에서 원하는 도메인에 대한 SSL 인증서를 받는다. +2. ALB에 SSL/TLS 인증서 연결: 위에서 연결한 인증서를 ALB에 연결한다. +3. 리스너 구성: 클라이언트의 요청을 받을 포트 (443 포트) 및 프로토콜 (HTTPS)를 지정한다. +4. 대상 그룹 설정: 리스너가 수신한 요청을 처리할 대상 그룹을 설정한다. + +- HTTP 요청은 포트 80을 통해 전달될 수 있으며, HTTPS 요청은 **포트 443**을 통해 전달된다. +- ALB를 통해 SSL/TLS 인증서를 추가함으로써 HTTPS 요청을 처리할 수 있게 되는 것! + +#### SSL인증서란? +- 웹 사이트의 신원을 확인하고 웹 사이트와 사용자 간의 안전한 통신을 제공하는 디지털 인증서이다. +- 사용자에게 웹 사이트의 신뢰성을 보장하고 개인 정보를 안전하게 전송할 수 있도록 도와준다. +- 작은 자물쇠 아이콘을 통해서 사용자는 안전함을 인식할 수 있다! + +#### TLS인증서란? +- 데이터의 안전한 전송을 보장하기 위해 SSL(Secure Sockets Layer) 프로토콜을 대체하는 역할을 한다. +- 주로 HTTPS에서 사용된다. + +#### ALB란? + +- 여러 대상에 대한 트래픽을 분산시키는 역할 +- 목적: 웹 사이트에 접속하는 사용자들의 요청을 **여러 대의 서버로 분산하여 부하를 분담**하고, 정상적인 서버에만 트래픽을 전달하여 웹 서비스의 안정성을 유지한다. + +### 이번 과제를 하며 +- 일단 첫번째로 도메인을 생성해주었습니다. +![domain](https://github.com/CEOS-Developers/django_rest_framework_17th/assets/81136546/0d50499c-af9f-4011-b931-49bd59ded2b8) +- 그 후 과제 노션에 나와있는대로 SSL인증서 요청받고, +- 대상 그룹, 로드 밸런서 등등 잘 따라하며 생성 및 연결도 완료했습니다. +- 정말 이때까지는 노션만 잘 따라하면 과제를 성공적으로 마칠 수 있을 줄 알았습니다.. +- 그런데, 깃헙 액션으로 내가 만든 도메인으로 배포를 해보니.... +![error](https://github.com/CEOS-Developers/django_rest_framework_17th/assets/81136546/4967a8f7-793c-4280-8ba2-74a72ed48e93) +- 정말 다시는 보기도 싫은 이 에러를 마주쳤고, 본격적으로 에러를 해결해보려 했습니다ㅜㅡㅜ +![proxy](https://github.com/CEOS-Developers/django_rest_framework_17th/assets/81136546/c2489be5-e057-465c-8992-166e33422024) +- 과제에 [선택]이라고 쓰여있던 nginx 리다이렉션 로직도 추가하고 +- 프록시 설정도 다시 해줬습니다. +- 그런데 에러는 똑같았습니다. +- 계속 구글링을 하다가 gunicorn 시간 초과 관련일수도 있다고 해서, gunicorn timeout 설정도 추가했습니다. +- ```command: gunicorn django_rest_framework_17th.wsgi:application --bind 0.0.0.0:8000 --timeout=120``` +- 그런데도 계속 에러는 같았습니다.........ㅎ +- 정확한 에러 로그를 알려주지 않아서 정말 어디서부터 어디까지 잘못된건지(?) 모르는 상태에서 구글링만으로 에러를 해결하려니 정말 막막했습니다ㅠ +- 깃 시크릿에 들어가있는 정보도 다시 한번 확인해주며 제대로 넣고 돌렸는데도 에러는 똑같았습니다ㅜ^ㅜ +- 회고 + - 장고의 문제인지 도커의 문제인지 서버의 문제인지 모르지만, 이 알 수 없는 에러를 고쳐보려고 정말 많은 시행착오를 겪었습니다. + - 프로젝트에서는 스프링을 사용하니 앞으로 남은 기말고사 기간동안 이 부분을 더 열심히 공부해야겠다고 다짐했습니다,,, \ No newline at end of file diff --git a/account/__init__.py b/account/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/account/admin.py b/account/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/account/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/account/apps.py b/account/apps.py new file mode 100644 index 0000000..2b08f1a --- /dev/null +++ b/account/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'account' diff --git a/account/forms.py b/account/forms.py new file mode 100644 index 0000000..97ede17 --- /dev/null +++ b/account/forms.py @@ -0,0 +1,70 @@ +from django import forms +from django.contrib.auth.forms import ReadOnlyPasswordHashField +from django.utils.translation import ugettext_lazy as _ +from .models import User,UserManager + + +# 회원가입 시 필요한 아이디, 이메일, 비밀번호 +class UserCreateForm(forms.ModelForm): + user_id=forms.CharField( + label=('User ID'), + required=True, + widget=forms.TextInput( + attrs={ + 'class': 'form-control', + 'placeholder':_('User ID'), + 'required': 'True', + } + ) + ) + email = forms.EmailField( + label=('Email'), + required=True, + widget=forms.EmailInput( + attrs={ + 'class': 'form-control', + 'placeholder':_('Email address'), + 'required': 'True', + } + ) + ) + password1 = forms.CharField( + label=_('Password'), + widget=forms.PasswordInput( + attrs={ + 'class': 'form-control', + 'placeholder': _('Password'), + 'required': 'True', + } + ) + ) + password2 = forms.CharField( + label=_('Password confirmation'), + widget=forms.PasswordInput( + attrs={ + 'class': 'form-control', + 'placeholder': _('Password confirmation'), + 'required': 'True', + } + ) + ) + + class Meta: + model = User + fields = ('login_id', 'email') + + def clean_password2(self): + password1 = self.cleaned_data.get("password1") + password2 = self.cleaned_data.get("password2") + if password1 and password2 and password1 != password2: + raise forms.ValidationError("Passwords don't match") + return password2 + + def save(self, commit=True): + # Save the provided password in hashed format + user = super(UserCreateForm, self).save(commit=False) + user.user_id = UserManager.normalize_email(self.cleaned_data['login_id']) + user.set_password(self.cleaned_data["password1"]) + if commit: + user.save() + return user \ No newline at end of file diff --git a/account/migrations/0001_initial.py b/account/migrations/0001_initial.py new file mode 100644 index 0000000..6706a7f --- /dev/null +++ b/account/migrations/0001_initial.py @@ -0,0 +1,63 @@ +# Generated by Django 3.2.16 on 2023-03-31 15:00 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='School', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('school_name', models.CharField(max_length=100)), + ], + options={ + 'ordering': ['-created_at', '-updated_at'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='UserManager', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ['-created_at', '-updated_at'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='User', + fields=[ + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('id', models.CharField(max_length=20, verbose_name='아이디')), + ('password', models.CharField(max_length=128, verbose_name='비밀번호')), + ('email', models.EmailField(max_length=128, unique=True, verbose_name='이메일')), + ('nickname', models.CharField(max_length=20, unique=True, verbose_name='닉네임')), + ('class_of', models.IntegerField(verbose_name='입학연도')), + ('name', models.CharField(max_length=20, verbose_name='이름')), + ('join_date', models.DateField(auto_now_add=True, verbose_name='가입일')), + ('is_active', models.BooleanField(default=True)), + ('is_admin', models.BooleanField(default=False)), + ('school_id', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='account.school')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/account/migrations/0002_delete_usermanager.py b/account/migrations/0002_delete_usermanager.py new file mode 100644 index 0000000..65bef25 --- /dev/null +++ b/account/migrations/0002_delete_usermanager.py @@ -0,0 +1,16 @@ +# Generated by Django 3.2.16 on 2023-05-02 14:11 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0001_initial'), + ] + + operations = [ + migrations.DeleteModel( + name='UserManager', + ), + ] diff --git a/account/migrations/0003_alter_user_class_of.py b/account/migrations/0003_alter_user_class_of.py new file mode 100644 index 0000000..f63b674 --- /dev/null +++ b/account/migrations/0003_alter_user_class_of.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.16 on 2023-05-02 14:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0002_delete_usermanager'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='class_of', + field=models.IntegerField(null=True, verbose_name='입학연도'), + ), + ] diff --git a/account/migrations/0004_auto_20230502_2316.py b/account/migrations/0004_auto_20230502_2316.py new file mode 100644 index 0000000..6f7104d --- /dev/null +++ b/account/migrations/0004_auto_20230502_2316.py @@ -0,0 +1,29 @@ +# Generated by Django 3.2.16 on 2023-05-02 14:16 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0003_alter_user_class_of'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='join_date', + field=models.DateField(auto_now_add=True, null=True, verbose_name='가입일'), + ), + migrations.AlterField( + model_name='user', + name='name', + field=models.CharField(max_length=20, null=True, verbose_name='이름'), + ), + migrations.AlterField( + model_name='user', + name='school_id', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='account.school'), + ), + ] diff --git a/account/migrations/__init__.py b/account/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/account/models.py b/account/models.py new file mode 100644 index 0000000..1d16a59 --- /dev/null +++ b/account/models.py @@ -0,0 +1,68 @@ +from django.db import models +from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin +from core.models import TimestampedModel +import uuid + + +class School(TimestampedModel): + school_name = models.CharField(max_length=100) + + def __str__(self): + return self.school_name + + +class UserManager(BaseUserManager): + # 필수로 필요한 데이터를 선언 + def create_user(self, id, email, password, nickname): + if not id: + raise ValueError('Users must have an user_id') + if not password: + raise ValueError('Password must have and password') + print("usermanager 디버깅") + user = self.model( + id=id, + email=email, + nickname=nickname, + ) + user.set_password(password) + user.save(using=self._db) + return user + + # python manage.py createsuperuser 사용 시 해당 함수가 사용됨 + def create_superuser(self, id, email, password=None): + user = self.create_user( + email, + id=id, + password=password + ) + user.is_admin = True + user.save(using=self._db) + return user + + +class User(AbstractBaseUser,TimestampedModel): + user_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + + id = models.CharField("아이디", max_length= 20) + password = models.CharField("비밀번호", max_length=128) + email = models.EmailField("이메일", max_length=128, unique=True) + nickname = models.CharField("닉네임",max_length=20, unique= True) + class_of = models.IntegerField("입학연도", null=True) + name = models.CharField("이름",max_length=20, null=True) + join_date = models.DateField("가입일", auto_now_add=True, null=True) + + is_active = models.BooleanField(default=True) + is_admin = models.BooleanField(default=False) + + school_id = models.ForeignKey(School, on_delete=models.PROTECT, null=True) + + REQUIRED_FIELDS = ['password'] # 필수로 값을 받아야하는 필드 + USERNAME_FIELD = 'email' + + # custom user 생성 시 필요 + objects = UserManager() + + def __str__(self): + return self.name + + diff --git a/account/serializers.py b/account/serializers.py new file mode 100644 index 0000000..e923496 --- /dev/null +++ b/account/serializers.py @@ -0,0 +1,101 @@ +from rest_framework import serializers +from django.contrib.auth.models import User +from .models import * +from rest_framework_simplejwt.tokens import RefreshToken + + +class SchoolSerializer(serializers.ModelSerializer): + class Meta: + model = School # models.py의 school 사용 + fields = '__all__' # 모든 필드 포함 + + +class UserSerializer(serializers.ModelSerializer): + school = SchoolSerializer # nested_serializer 사용해서 관계 생성 + + class Meta: + model = User # models.py의 User 사용 + fields = '__all__' # 모든 필드 포함 + + +class SignUpSerializer(serializers.ModelSerializer): + id = serializers.CharField( + required=True, + write_only=True, + max_length=30 + ) + + password = serializers.CharField( + required=True, + write_only=True, + style={'input_type': 'password'} + ) + + email = serializers.CharField( + required=True, + write_only=True, + max_length=255 + ) + + nickname = serializers.CharField( + required=True, + write_only=True, + max_length=255 + ) + + class Meta: + model = User + fields = ('id','email','password','nickname') + + def save(self, request): + user = User.objects.create_user( + id=self.validated_data['id'], + email=self.validated_data['email'], + password=self.validated_data['password'], + nickname=self.validated_data['nickname'] + ) + user.save() + + return user + + +class LoginSerializer(serializers.ModelSerializer): + id = serializers.CharField( + required=True, + write_only=True, + ) + + password = serializers.CharField( + required=True, + write_only=True, + style={'input_type': 'password'} + ) + + class Meta: + model = User + fields = ['id', 'password'] + + def validate(self, data): + id = data.get('id', None) + password = data.get('password', None) + + if User.objects.filter(id=id).exists(): + user = User.objects.get(id=id) + + if not user.check_password(password): + raise serializers.ValidationError("wrong password") + else: + raise serializers.ValidationError("user account not exist") + + # RefreshToken 클래스를 사용하여 access token과 refresh token을 발급 + refresh = RefreshToken.for_user(user) + + return { + 'user' : user, + 'id' : id, + 'access_token': str(refresh.access_token), + 'refresh_token': str(refresh) + } + + + diff --git a/account/tests.py b/account/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/account/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/account/urls.py b/account/urls.py new file mode 100644 index 0000000..6345498 --- /dev/null +++ b/account/urls.py @@ -0,0 +1,11 @@ +from rest_framework import routers +from .views import * +from django.urls import path +app_name = 'account' + +urlpatterns = [ + path('signup/', SignupView.as_view()), # 회원 가입 + path('login/', LoginView.as_view()), # 로그인 + path('logout/', LogoutView.as_view()), # 로그 아웃 + path('auth/', AuthView.as_view()), # 인가 +]; diff --git a/account/views.py b/account/views.py new file mode 100644 index 0000000..87b5605 --- /dev/null +++ b/account/views.py @@ -0,0 +1,128 @@ +import jwt +from django.shortcuts import get_object_or_404 +from rest_framework import status +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework_simplejwt.tokens import RefreshToken + +from .models import User +from .serializers import UserSerializer, SignUpSerializer, LoginSerializer +from django_rest_framework_17th.settings import SECRET_KEY, REFRESH_TOKEN_SECRET_KEY + + +class SignupView(APIView): + def post(self, request): + serializer = SignUpSerializer(data=request.data) + + if serializer.is_valid(raise_exception=False): + user = serializer.save(request) + response = Response( + { + "id": user.id, + "message": "회원가입 성공", + }, + status=status.HTTP_200_OK, + ) + return response + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class LoginView(APIView): + def post(self, request): + serializer = LoginSerializer(data=request.data) + if serializer.is_valid(raise_exception=False): + #유효성 검사를 통과한 경우 토큰 확인 + #serializer.validated_data는 프론트에서 전송한 request.data에서 추출됨 + id=serializer.validated_data.get("id") + + access_token = serializer.validated_data['access_token'] + refresh_token = serializer.validated_data['refresh_token'] + + response = Response({ + "id": id, + "message": "로그인 성공", + "token":{ + "access_token": access_token.__str__(), + "refresh_token": refresh_token.__str__(), + }}, + status=status.HTTP_200_OK, ) + + response.set_cookie("access_token", access_token.__str__(), httponly=True, secure=True, + max_age=60 * 60 * 1) # 쿠키 만료 시간을 1시간으로 설정 + response.set_cookie("refresh_token", access_token.__str__(), httponly=True, secure=True, + max_age=60 * 60 * 24) # 쿠키 만료 시간을 24시간으로 설정 + return response + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class LogoutView(APIView): + def post(self, request): + response = Response(status=status.HTTP_204_NO_CONTENT) + response.delete_cookie('access_token') + response.delete_cookie('refresh_token') + return response + + + +class AuthView(APIView): + def get(self, request): + # "Bearer " 형식으로 반환되기 때문에, 분리한 후 access_token만 추출 + access_token = request.META['HTTP_AUTHORIZATION'].split()[1] + + if not access_token: + return Response({"message": "access token 없음"}, status=status.HTTP_401_UNAUTHORIZED) + + try: + user = request.user + if user.is_authenticated: + serializer = UserSerializer(instance=user) + return Response(serializer.data, status=status.HTTP_200_OK) + + # # payload에서 user_id(고유한 식별자)를 추출 + # # payload={'user_id:1'} + # payload = jwt.decode(access_token, SECRET_KEY, algorithms=['HS256']) # accesstoken 번호 + # id = payload.get('user_id') + # #해당 유저 아이디를 가지는 객체 user을 가져와 + # user = get_object_or_404(User, id=id) + # #UserSerializer로 JSON화 시켜준 뒤, + # serializer = UserSerializer(instance=user) + # #프론트로 200과 함께 재전송 + # return Response(serializer.data, status=status.HTTP_200_OK) + + #Access token 예외 처리 + except jwt.exceptions.InvalidSignatureError: + #access_token 유효하지 않음 + return Response({"message": "유효하지 않은 access token"}, status=status.HTTP_401_UNAUTHORIZED) + + except jwt.exceptions.ExpiredSignatureError: + # access_token 만료 기간 다 됨 + refresh_token = request.COOKIES.get('refresh_token') + + #refresh_token이 없다면 에러 발생 + if not refresh_token: + return Response({"message": "refresh token 없음"}, status=status.HTTP_401_UNAUTHORIZED) + + try: + #refresh_token 디코딩 + payload = jwt.decode(refresh_token, REFRESH_TOKEN_SECRET_KEY, algorithms=['HS256']) + id = payload.get('id') + user = get_object_or_404(pk=id) + + #새로운 access_token 발급 + access_token = jwt.encode({"id": user.pk}, SECRET_KEY, algorithm='HS256') + + #access_token을 쿠키에 저장하여 프론트로 전송 + response = Response(UserSerializer(instance=user).data, status=status.HTTP_200_OK) + response.set_cookie(key='access_token', value=access_token, httponly=True, samesite='None', secure=True) + + return response + + # refresh_token 예외 처리 + except jwt.exceptions.InvalidSignatureError: + # refresh_token 유효하지 않음 + return Response({"message": "유효하지 않은 refresh token"}, status=status.HTTP_401_UNAUTHORIZED) + + except jwt.exceptions.ExpiredSignatureError: + # refresh_token 만료 기간 다 됨 => 이경우에는, 사용자가 로그아웃 후 재로그인하도록 유인 => 리다이렉트 + return Response({"message": "refresh token 기간 만료"}, status=status.HTTP_401_UNAUTHORIZED) diff --git a/api/models.py b/api/models.py index 71a8362..beeb308 100644 --- a/api/models.py +++ b/api/models.py @@ -1,3 +1,2 @@ from django.db import models -# Create your models here. diff --git a/board/__init__.py b/board/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/board/admin.py b/board/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/board/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/board/apps.py b/board/apps.py new file mode 100644 index 0000000..8e13c06 --- /dev/null +++ b/board/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BoardConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'board' diff --git a/board/migrations/0001_initial.py b/board/migrations/0001_initial.py new file mode 100644 index 0000000..bfdccce --- /dev/null +++ b/board/migrations/0001_initial.py @@ -0,0 +1,130 @@ +# Generated by Django 3.2.16 on 2023-03-31 15:00 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('account', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Board', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('category', models.CharField(max_length=20)), + ('name', models.CharField(max_length=50)), + ('is_deleted', models.BooleanField(default=False)), + ('school_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='account.school')), + ], + options={ + 'ordering': ['-created_at', '-updated_at'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Post', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('title', models.CharField(max_length=100)), + ('content', models.CharField(max_length=500)), + ('likeCnt', models.IntegerField(default=0)), + ('scrabCnt', models.IntegerField(default=0)), + ('commentCnt', models.IntegerField(default=0)), + ('is_anonymous', models.BooleanField(default=True)), + ('is_question', models.BooleanField(default=False)), + ('is_delete', models.BooleanField(default=False)), + ('board', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='board.board')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='account.user')), + ], + options={ + 'ordering': ['-created_at', '-updated_at'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Scrap', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='board.post')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='account.user')), + ], + options={ + 'ordering': ['-created_at', '-updated_at'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Post_media', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='board.post')), + ], + options={ + 'ordering': ['-created_at', '-updated_at'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='My_board', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('board', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='board.board')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='account.user')), + ], + options={ + 'ordering': ['-created_at', '-updated_at'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Message', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('content', models.CharField(max_length=500)), + ('post', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='board.post')), + ('sender', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sender', to='account.user')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='account.user')), + ], + options={ + 'ordering': ['-created_at', '-updated_at'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Comment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('content', models.CharField(max_length=500)), + ('likeCnt', models.IntegerField(default=0)), + ('is_anonymous', models.BooleanField(default=True)), + ('is_delete', models.BooleanField(default=False)), + ('parent_comment', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='board.comment')), + ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='board.post')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='account.user')), + ], + options={ + 'ordering': ['-created_at', '-updated_at'], + 'abstract': False, + }, + ), + ] diff --git a/board/migrations/0002_auto_20230404_2048.py b/board/migrations/0002_auto_20230404_2048.py new file mode 100644 index 0000000..16ec4cb --- /dev/null +++ b/board/migrations/0002_auto_20230404_2048.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.16 on 2023-04-04 11:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('board', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='post_media', + name='post', + ), + migrations.AlterField( + model_name='comment', + name='content', + field=models.TextField(), + ), + migrations.AlterField( + model_name='post', + name='content', + field=models.TextField(), + ), + migrations.DeleteModel( + name='Message', + ), + migrations.DeleteModel( + name='Post_media', + ), + ] diff --git a/board/migrations/0003_rename_my_board_myboard.py b/board/migrations/0003_rename_my_board_myboard.py new file mode 100644 index 0000000..49cc27f --- /dev/null +++ b/board/migrations/0003_rename_my_board_myboard.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.16 on 2023-04-04 11:59 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0001_initial'), + ('board', '0002_auto_20230404_2048'), + ] + + operations = [ + migrations.RenameModel( + old_name='My_board', + new_name='MyBoard', + ), + ] diff --git a/board/migrations/__init__.py b/board/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/board/models.py b/board/models.py new file mode 100644 index 0000000..880bb54 --- /dev/null +++ b/board/models.py @@ -0,0 +1,60 @@ +from django.db import models +from account.models import School, User +from core.models import TimestampedModel + +class Board(TimestampedModel): + school_id = models.ForeignKey(School, on_delete=models.CASCADE) + + category = models.CharField(max_length=20) + name = models.CharField(max_length=50) + is_deleted = models.BooleanField(default=False) + + def __str__(self): + return self.name + +class Post(TimestampedModel): + board = models.ForeignKey(Board, on_delete=models.CASCADE) + user = models.ForeignKey(User, on_delete=models.CASCADE) + + title = models.CharField(max_length= 100, null= False) + content = models.TextField(blank=False) + likeCnt = models.IntegerField(default=0) + scrabCnt = models.IntegerField(default=0) + commentCnt = models.IntegerField(default=0) + is_anonymous = models.BooleanField(default=True) + is_question = models.BooleanField(default=False) + is_delete = models.BooleanField(default=False) + + def __str__(self): + return self.title + +class MyBoard(TimestampedModel): + user = models.ForeignKey(User, on_delete=models.CASCADE) + board = models.ForeignKey(Board, on_delete=models.CASCADE) + + def __str__(self): + return self.board.name + +class Comment(TimestampedModel): + post = models.ForeignKey(Post,on_delete=models.CASCADE) + parent_comment = models.ForeignKey('self', on_delete=models.CASCADE, null=True) + user = models.ForeignKey(User, on_delete=models.CASCADE) + + content = models.TextField(blank=False) + likeCnt = models.IntegerField(default=0) + is_anonymous = models.BooleanField(default=True) + is_delete = models.BooleanField(default=False) + + def __str__(self): + return self.content + +class Scrap(TimestampedModel): + user = models.ForeignKey(User,on_delete=models.CASCADE) + post = models.ForeignKey(Post,on_delete=models.CASCADE) + + def __str__(self): + return self.post.title + + + + diff --git a/board/serializers.py b/board/serializers.py new file mode 100644 index 0000000..ed026fd --- /dev/null +++ b/board/serializers.py @@ -0,0 +1,54 @@ +from rest_framework import serializers +from .models import * +from account.serializers import * + + +class BoardSerializer(serializers.ModelSerializer): + school_id = SchoolSerializer + + class Meta: + model = Board # models.py의 board 사용 + fields = '__all__' # 모든 필드 포함 + + +class PostSerializer(serializers.ModelSerializer): + board = BoardSerializer + user = UserSerializer + + class Meta: + model = Post # models.py의 post 사용 + fields = '__all__' # 모든 필드 포함 + + +class MyBoardSerializer(serializers.ModelSerializer): + board = BoardSerializer + user = UserSerializer + + class Meta: + model = MyBoard # models.py의 myBoard 사용 + fields = '__all__' # 모든 필드 포함 + + +class CommentSerializer(serializers.ModelSerializer): + post = PostSerializer + user = UserSerializer + + # parent_comment = CommentSerializer() + + class Meta: + model = Comment # models.py의 comment 사용 + fields = '__all__' # 모든 필드 포함 + + +class InCommentSerializer(serializers.ModelSerializer): + parent_comment = CommentSerializer + + class Meta: + model = Comment + fields = '__all__' # 모든 필드 포함 + + +class ScrapSerializer(serializers.ModelSerializer): + class Meta: + model = Scrap # models.py의 scrap 사용 + fields = '__all__' # 모든 필드 포함 diff --git a/board/tests.py b/board/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/board/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/board/urls.py b/board/urls.py new file mode 100644 index 0000000..9eb7f2f --- /dev/null +++ b/board/urls.py @@ -0,0 +1,12 @@ +from django.urls import path,include +from rest_framework import routers +from .views import BoardViewSet + + +router = routers.DefaultRouter() +router.register('board', BoardViewSet) + +urlpatterns = [ + path('', include(router.urls)), +] + diff --git a/board/views.py b/board/views.py new file mode 100644 index 0000000..29d1e83 --- /dev/null +++ b/board/views.py @@ -0,0 +1,73 @@ +from django.shortcuts import render +from django.http import HttpResponse +from rest_framework import status +from rest_framework.views import APIView +from django.core.exceptions import ObjectDoesNotExist +from django.http import JsonResponse +from rest_framework.response import Response +from .serializers import BoardSerializer +from .models import Board +from rest_framework import viewsets +from django_filters.rest_framework import FilterSet, filters +from django_filters.rest_framework import DjangoFilterBackend + +''' +class BoardList(APIView): + + def get(self, request, format=None): + try: + board_list = Board.objects.all() + serializer = BoardSerializer(board_list, many=True) + return Response(serializer.data) + except AttributeError as e: + print(e). + return Response("message: error") + + def post(self, request): + serializer = BoardSerializer(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=201) + return Response(serializer.errors, status=400) + + +class BoardDetail(APIView): + def get(self, request, pk): + try: + board = Board.objects.get(id=pk) + serializer = BoardSerializer(board) + return Response(serializer.data, status=201) + except ObjectDoesNotExist as e: + print(e) + return Response({"message: error"}) + + def delete(self, request, pk): + try: + board = Board.objects.get(id=pk) + board.delete() + return Response(status=200) + except ObjectDoesNotExist as e: + print(e) + return Response({"message: not exist"}) +''' + + +class BoardFilter(FilterSet): + name = filters.CharFilter(field_name='name') + school_id = filters.NumberFilter(method='filter_school_id') + + def filter_school_id(self, queryset, name, value): + return queryset.filter(**{ + name: value, + }) + + class Meta: + model = Board + fields = ['name', 'school_id'] + + +class BoardViewSet(viewsets.ModelViewSet): + serializer_class = BoardSerializer + queryset = Board.objects.all() + filter_backends = [DjangoFilterBackend] + filterset_class = BoardFilter diff --git a/board/viewset.py b/board/viewset.py new file mode 100644 index 0000000..b3ffda1 --- /dev/null +++ b/board/viewset.py @@ -0,0 +1,20 @@ +from django.urls import path +from .views import BoardViewSet + +# board 목록 보여주기 +board_list = BoardViewSet.as_view({ + 'get': 'list', + 'post': 'create' +}) + +# board detail 보여주기 + 수정 + 삭제 +board_detail = BoardViewSet.as_view({ + 'get': 'retrieve', + 'put': 'update', + 'delete': 'destroy' +}) + +urlpatterns =[ + path('board/', board_list), + path('board//', board_detail), +] \ No newline at end of file diff --git a/config/nginx/nginx.conf b/config/nginx/nginx.conf index fb084b1..063aebb 100644 --- a/config/nginx/nginx.conf +++ b/config/nginx/nginx.conf @@ -6,11 +6,15 @@ server { listen 80; + location / { proxy_pass http://django_rest_framework_17th; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $host; proxy_redirect off; + proxy_buffer_size 128k; + proxy_buffers 4 256k; + proxy_busy_buffers_size 256k; } location /static/ { diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/models.py b/core/models.py new file mode 100644 index 0000000..cc8ba08 --- /dev/null +++ b/core/models.py @@ -0,0 +1,11 @@ +from django.db import models + +class TimestampedModel(models.Model): + # 생성된 날짜를 기록 + created_at = models.DateTimeField(auto_now_add=True) + # 수정된 날짜를 기록 + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True + ordering = ['-created_at', '-updated_at'] \ No newline at end of file diff --git a/django_rest_framework_17th/settings/base.py b/django_rest_framework_17th/settings/base.py index 2a2381c..064e7ad 100644 --- a/django_rest_framework_17th/settings/base.py +++ b/django_rest_framework_17th/settings/base.py @@ -11,6 +11,8 @@ """ import os +from datetime import timedelta + import environ # Build paths inside the project like this: os.path.join(BASE_DIR, ...) @@ -28,7 +30,7 @@ SECRET_KEY = env('DJANGO_SECRET_KEY') DEBUG = env('DEBUG') -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = ['0.0.0.0'] # Application definition @@ -41,8 +43,29 @@ 'django.contrib.messages', 'django.contrib.staticfiles', 'api', + 'board', + 'timetable', + 'account', + 'rest_framework', + 'rest_framework_simplejwt', + 'django_filters', ] +AUTH_USER_MODEL = 'account.User' + +REST_FRAMEWORK = { + 'DEFAULT_PERMISSION_CLASSES': ( + # 'rest_framework.permissions.IsAuthenticated', # 인증된 사용자만 접근 + # 'rest_framework.permissions.IsAdminUser', # 관리자만 접근 + 'rest_framework.permissions.AllowAny', # 누구나 접근 + ), + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ) +} + +REFRESH_TOKEN_SECRET_KEY=os.environ.get('REFRESH_TOKEN_SECRET_KEY') + MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', diff --git a/django_rest_framework_17th/urls.py b/django_rest_framework_17th/urls.py index cf9bedd..d1185f9 100644 --- a/django_rest_framework_17th/urls.py +++ b/django_rest_framework_17th/urls.py @@ -13,9 +13,13 @@ 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ + + from django.contrib import admin -from django.urls import path +from django.urls import path, include urlpatterns = [ + path('', include('board.urls')), path('admin/', admin.site.urls), + path('account/', include('account.urls')), ] diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index cf94cd6..4ced727 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,22 +1,34 @@ +#docker-compose.prod.yml version: '3' services: web: container_name: web - #작성 + build: + context: ./ + dockerfile: Dockerfile + command: gunicorn django_rest_framework_17th.wsgi:application --bind 0.0.0.0:8000 --timeout=120 + environment: + DJANGO_SETTINGS_MODULE: django_rest_framework_17th.settings.prod + env_file: + - .env volumes: - static:/home/app/web/static - media:/home/app/web/media + expose: + - 8000 # 도커 포트 번호 entrypoint: - sh - config/docker/entrypoint.prod.sh nginx: container_name: nginx - #작성 + build: ./config/nginx volumes: - static:/home/app/web/static - media:/home/app/web/media + ports: + - "80:80" depends_on: - web diff --git a/docker-compose.yml b/docker-compose.yml index 44008b5..ba87f58 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,17 +3,39 @@ services: db: container_name: db - #작성 + image: mysql:5.7 #window + restart: always + environment: + MYSQL_ROOT_HOST: '%' + MYSQL_ROOT_PASSWORD: mysql + expose: + - 3306 + ports: + - "3307:3306" //외부에 개방할 포트 + env_file: + - .env volumes: - dbdata:/var/lib/mysql web: container_name: web - #작성 + build: . + command: sh -c "python manage.py migrate && python manage.py runserver 0.0.0.0:8000" + environment: + MYSQL_ROOT_PASSWORD: mysql + DATABASE_NAME: mysql + DATABASE_USER: 'root' + DATABASE_PASSWORD: mysql + DATABASE_PORT: 3306 + DATABASE_HOST: db + DJANGO_SETTINGS_MODULE: django_rest_framework_17th.settings.dev + restart: always + ports: + - "8000:8000" volumes: - .:/app depends_on: - db volumes: app: - dbdata: + dbdata: \ No newline at end of file diff --git a/img.png b/img.png new file mode 100644 index 0000000..4034215 Binary files /dev/null and b/img.png differ diff --git a/img_1.png b/img_1.png new file mode 100644 index 0000000..9f08081 Binary files /dev/null and b/img_1.png differ diff --git a/img_2.png b/img_2.png new file mode 100644 index 0000000..d021578 Binary files /dev/null and b/img_2.png differ diff --git a/img_3.png b/img_3.png new file mode 100644 index 0000000..1b764d6 Binary files /dev/null and b/img_3.png differ diff --git a/img_4.png b/img_4.png new file mode 100644 index 0000000..f2f1269 Binary files /dev/null and b/img_4.png differ diff --git a/img_5.png b/img_5.png new file mode 100644 index 0000000..1a6c809 Binary files /dev/null and b/img_5.png differ diff --git a/img_6.png b/img_6.png new file mode 100644 index 0000000..1a6c809 Binary files /dev/null and b/img_6.png differ diff --git a/requirements.txt b/requirements.txt index 31cf4e5..266a5f3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,12 @@ -asgiref==3.2.10 +asgiref==3.6.0 Django==3.2.16 -django-environ==0.4.5 -gunicorn==20.0.4 -pytz==2020.1 -sqlparse==0.3.1 +django-environ==0.9.0 +gunicorn==20.1.0 +pytz==2022.7.1 +sqlparse==0.4.3 + +djangorestframework~=3.14.0 +django-filter~=23.1 + +djangorestframework-simplejwt==5.2.2 +PyMySQL==1.0.2 \ No newline at end of file diff --git a/timetable/__init__.py b/timetable/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/timetable/admin.py b/timetable/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/timetable/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/timetable/apps.py b/timetable/apps.py new file mode 100644 index 0000000..f3abe50 --- /dev/null +++ b/timetable/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TimetableConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'timetable' diff --git a/timetable/migrations/0001_initial.py b/timetable/migrations/0001_initial.py new file mode 100644 index 0000000..4f07bb3 --- /dev/null +++ b/timetable/migrations/0001_initial.py @@ -0,0 +1,97 @@ +# Generated by Django 3.2.16 on 2023-03-31 15:02 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('account', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Lecture', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=30)), + ('lecture_code', models.CharField(max_length=10)), + ('professor', models.CharField(max_length=30)), + ('lecture_time', models.CharField(max_length=30)), + ('lecture_room', models.CharField(max_length=20)), + ('category', models.CharField(max_length=20)), + ('semester', models.CharField(max_length=20)), + ('school', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='account.school')), + ], + options={ + 'ordering': ['-created_at', '-updated_at'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Timetable', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(default='시간표', max_length=20)), + ('semester', models.CharField(max_length=20)), + ('is_public', models.CharField(max_length=10)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='account.user')), + ], + options={ + 'ordering': ['-created_at', '-updated_at'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Review', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('semester', models.CharField(max_length=20)), + ('content', models.CharField(max_length=500)), + ('lecture', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='timetable.lecture')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='account.user')), + ], + options={ + 'ordering': ['-created_at', '-updated_at'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='My_lecture', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('lecture', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='timetable.lecture')), + ('timetable', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='timetable.timetable')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='account.user')), + ], + options={ + 'ordering': ['-created_at', '-updated_at'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Friend', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=20)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='account.user')), + ], + options={ + 'ordering': ['-created_at', '-updated_at'], + 'abstract': False, + }, + ), + ] diff --git a/timetable/migrations/0002_auto_20230404_2100.py b/timetable/migrations/0002_auto_20230404_2100.py new file mode 100644 index 0000000..4053ea4 --- /dev/null +++ b/timetable/migrations/0002_auto_20230404_2100.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.16 on 2023-04-04 12:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0001_initial'), + ('timetable', '0001_initial'), + ] + + operations = [ + migrations.RenameModel( + old_name='My_lecture', + new_name='MyLecture', + ), + migrations.AlterField( + model_name='review', + name='content', + field=models.TextField(), + ), + ] diff --git a/timetable/migrations/__init__.py b/timetable/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/timetable/models.py b/timetable/models.py new file mode 100644 index 0000000..3b34569 --- /dev/null +++ b/timetable/models.py @@ -0,0 +1,62 @@ +from django.db import models +from board.models import User, School +from core.models import TimestampedModel + +class Friend(TimestampedModel): + user = models.ForeignKey(User, on_delete=models.CASCADE) + name = models.CharField(max_length=20) + + def __str__(self): + return self.name + +class Timetable(TimestampedModel): + user = models.ForeignKey(User, on_delete=models.CASCADE) + + name = models.CharField(max_length=20,default= "시간표") + semester = models.CharField(max_length=20) + is_public = models.CharField(max_length= 10) + + def __str__(self): + return self.name + +class Lecture(TimestampedModel): + school = models.ForeignKey(School, on_delete=models.CASCADE) + + name = models.CharField(max_length=30) + lecture_code = models.CharField(max_length=10) + professor = models.CharField(max_length=30) + lecture_time = models.CharField(max_length=30) + lecture_room = models.CharField(max_length=20) + category = models.CharField(max_length=20) + semester = models.CharField(max_length=20) + grade = models.IntegerField + credit = models.IntegerField + capacity = models.IntegerField + + def __str__(self): + return self.name + +class MyLecture(TimestampedModel): + lecture = models.ForeignKey(Lecture,on_delete=models.CASCADE) + user = models.ForeignKey(User, on_delete=models.CASCADE) + timetable = models.ForeignKey(Timetable, on_delete=models.CASCADE) + + def __str__(self): + return f'{self.user.name}:{self.lecture.name}' + +class Review(TimestampedModel): + lecture = models.ForeignKey(Lecture, on_delete=models.CASCADE) + user = models.ForeignKey(User, on_delete=models.PROTECT) + + semester = models.CharField(max_length=20) + content = models.TextField(blank=False) + star = models.IntegerField + likeCnt = models.IntegerField + + def __str__(self): + return f'{self.star}:{self.content}' + + + + + diff --git a/timetable/serializers.py b/timetable/serializers.py new file mode 100644 index 0000000..67c0b15 --- /dev/null +++ b/timetable/serializers.py @@ -0,0 +1,46 @@ +from rest_framework import serializers +from .models import * +from account.serializers import * + + +class FriendSerializer(serializers.ModelSerializer): + user = UserSerializer() + + class Meta: + model = Friend + fields = '__all__' # 모든 필드 포함 + + +class TimetableSerializer(serializers.ModelSerializer): + user = UserSerializer() + + class Meta: + model = Timetable + fields = '__all__' # 모든 필드 포함 + + +class LectureSerializer(serializers.ModelSerializer): + school = SchoolSerializer() + + class Meta: + model = Lecture + fields = '__all__' # 모든 필드 포함 + + +class MyLectureSerializer(serializers.ModelSerializer): + lecture = LectureSerializer() + user = UserSerializer() + timetable = TimetableSerializer() + + class Meta: + model = MyLecture + fields = '__all__' # 모든 필드 포함 + + +class ReviewSerializer(serializers.ModelSerializer): + lecture = LectureSerializer() + user = UserSerializer() + + class Meta: + model = Review + fields = '__all__' # 모든 필드 포함 \ No newline at end of file diff --git a/timetable/tests.py b/timetable/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/timetable/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/timetable/views.py b/timetable/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/timetable/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here.