diff --git a/accounts/serializers.py b/accounts/serializers.py index f2aac45..ee844f5 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -27,7 +27,7 @@ class KakaoTokenSerializer(serializers.Serializer): class UserInfoUpdateSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ("username", "position", "profile_image") + fields = ("id", "username", "position", "profile_image") class GetUserInfoSerializer(serializers.ModelSerializer): diff --git a/accounts/urls.py b/accounts/urls.py index 7452ca5..3687ea6 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -3,13 +3,12 @@ # TODO swagger에 뜨는 api 관리 urlpatterns = [ - path("kakao/login/", views.kakao_login, name="kakao_login"), - path("kakao/callback/", views.kakao_callback, name="kakao_callback"), + path("/kakao", views.kakao_login, name="kakao_login"), path( - "kakao/login/finish/", + "/kakao/login", views.KakaoLoginView.as_view(), name="kakao_login_todjango", ), - path("update/", views.UpdateUserInfoView.as_view(), name="update_user_info"), - path("user/me", views.GetUserInfoView.as_view(), name="get_user_info"), + path("/update", views.UpdateUserInfoView.as_view(), name="update_user_info"), + path("/user/me", views.GetUserInfoView.as_view(), name="get_user_info"), ] diff --git a/accounts/views.py b/accounts/views.py index 64a9bb3..bcab9dc 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -2,32 +2,26 @@ import environ from pathlib import Path -from django.db import transaction from drf_spectacular.utils import ( extend_schema, OpenApiResponse, OpenApiExample, OpenApiParameter, ) -from django.http import JsonResponse +from rest_framework_simplejwt.tokens import RefreshToken import requests from django.shortcuts import redirect -from json.decoder import JSONDecodeError -from rest_framework.decorators import api_view, permission_classes +from django.contrib.auth import get_user_model from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from rest_framework import status -from allauth.socialaccount.providers.oauth2.client import OAuth2Client from dj_rest_auth.registration.views import SocialLoginView -from allauth.socialaccount.providers.kakao import views as kakao_view -from .models import CustomUser from .serializers import ( UserInfoUpdateSerializer, GetUserInfoSerializer, - KakaoTokenSerializer, ) import logging @@ -42,148 +36,122 @@ env.read_env(env_file) BASE_URL = env("BASE_URL") -KAKAO_CALLBACK_URI = BASE_URL + "accounts/kakao/callback/" +# KAKAO_CALLBACK_URI = BASE_URL + "accounts/kakao/callback/" +KAKAO_CALLBACK_URI = "http://localhost:5173/accounts/kakao/callback" # KAKAO_CALLBACK_URI = "http://api.resumai.kr/accounts/kakao/callback/" REST_API_KEY = env("KAKAO_REST_API_KEY") CLIENT_SECRET = env("KAKAO_CLIENT_SECRET_KEY") +User = get_user_model() -@extend_schema( - summary="카카오 로그인", - description="카카오 로그인 페이지로 리다이렉트하여, 정보를 입력하면 카카오 **access_token, code**를 반환합니다.", - responses={200: KakaoTokenSerializer}, -) -@api_view(["GET"]) -@permission_classes([AllowAny]) + +@extend_schema(exclude=True) def kakao_login(request): - return redirect( + logger.fatal( f"https://kauth.kakao.com/oauth/authorize?client_id={REST_API_KEY}&redirect_uri={KAKAO_CALLBACK_URI}&response_type=code" ) - - -@permission_classes([AllowAny]) -def kakao_callback(request): - code = request.GET.get("code") - - # Access Token Request - token_req = requests.get( - f"https://kauth.kakao.com/oauth/token?grant_type=authorization_code&client_id={REST_API_KEY}&client_secret={CLIENT_SECRET}&redirect_uri={KAKAO_CALLBACK_URI}&code={code}" + return redirect( + f"https://kauth.kakao.com/oauth/authorize?client_id={REST_API_KEY}&redirect_uri={KAKAO_CALLBACK_URI}&response_type=code" ) - token_req_json = token_req.json() - error = token_req_json.get("error") - if error is not None: - raise JSONDecodeError(error) +class KakaoLoginView(APIView): - access_token = token_req_json.get("access_token") - - # Email Request - profile_request = requests.get( - "https://kapi.kakao.com/v2/user/me", - headers={"Authorization": f"Bearer {access_token}"}, - ) - profile_data = profile_request.json() - - kakao_oid = profile_data.get("id") - kakao_account = profile_data.get("kakao_account") - username = kakao_account["profile"]["nickname"] - profile_image_url = kakao_account["profile"]["profile_image_url"] - email = kakao_account.get("email") - - data = {"access_token": access_token, "code": code} - # TODO 유저 프로필 이미지 저장하도록 - return JsonResponse(data) - - # try: - # user = CustomUser.objects.get(email=email) - # # 유저가 존재하는 경우 - # logger.warning(f"user: {user}") - # logger.warning("유저 존재") - # accept = requests.post("https://api.resumai.kr/accounts/kakao/login/finish/", data=data) - # logger.warning(f"accept: {accept}") - # logger.warning(f"accept.reason: {accept.reason}") - # logger.warning(f"accept.history: {accept.history}") - # logger.warning(accept.content) - # accept_status = accept.status_code - # logger.warning(accept_status) - # - # if accept_status != 200: - # return Response({"err_msg": "failed to signin"}, status=accept_status) - # - # accept_json = accept.json() - # logger.warning(f"accept_json, {accept_json}") - # # key 이름 변경 - # accept_json["accessToken"] = accept_json.pop("access") - # accept_json["refreshToken"] = accept_json.pop("refresh") - # accept_json["userProfile"] = accept_json.pop("user") - # accept_json["userProfile"]["id"] = accept_json["userProfile"].pop("pk") - # return JsonResponse(accept_json) - # - # except CustomUser.DoesNotExist: - # # 기존에 가입된 유저가 없으면 새로 가입 - # logger.warning("유저 미존재") - # accept = requests.post("http://localhost:8000/accounts/kakao/login/finish/", data=data) - # logger.warning(f"accept: {accept}") - # logger.warning(f"accept.reason: {accept.reason}") - # logger.warning(f"accept.request: {accept.request}") - # logger.warning(f"accept.raw: {accept.raw}") - # accept_status = accept.status_code - # logger.warning(accept_status) - # if accept_status != 200: - # return Response({"err_msg": "failed to signup"}, status=accept_status) - # - # # user의 pk, email, first name, last name과 Access Token, Refresh token 가져옴 - # accept_json = accept.json() - # logger.warning(f"accept_json, {accept_json}") - # # key 이름 변경 - # accept_json["accessToken"] = accept_json.pop("access") - # accept_json["refreshToken"] = accept_json.pop("refresh") - # accept_json["userProfile"] = accept_json.pop("user") - # accept_json["userProfile"]["id"] = accept_json["userProfile"].pop("pk") - # return JsonResponse(accept_json) - - -# @extend_schema(exclude=True) -@extend_schema( - summary="카카오 로그인 마무리", - description="access token, code를 post 요청으로 보내면 access token, 유저 정보를 반환합니다. **(id_token은 불필요합니다.)**", - parameters=[ - OpenApiParameter( - name="access_token", - type=str, - description="발급받은 카카오의 access_token 입니다.", - ), - OpenApiParameter( - name="code", type=str, description="발급받은 카카오의 code 입니다." - ), - ], - request={ - "application/json": { - "type": "object", - "properties": { - "access_token": {"type": "string"}, - "code": {"type": "string"}, + @extend_schema( + summary="카카오 로그인 마무리", + description="code (인가 코드)를 post 요청으로 보내면 access token, 유저 정보를 반환합니다. **(id_token은 불필요합니다.)**", + request={ + "application/json": { + "type": "object", + "properties": { + "code": {"type": "string"}, + }, }, }, - }, - examples=[ - OpenApiExample( - response_only=True, - summary="Response Body Example입니다.", - name="success_example", - value={ - "access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlI", - "refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBl", - "user": {"pk": 6, "email": "yjoonjang@naver.com"}, + examples=[ + OpenApiExample( + response_only=True, + summary="Response Body Example입니다.", + name="success_example", + value={ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0", + "user_info": { + "id": 6, + "email": "yjoonjang@naver.com", + "username": "장영준", + "profile_image": "https://k.kakaocdn.net/dn/cI6qGf/btsCovDyklV/ydaQojxohw6VnLxtcdKwuk/img_640x640.jpg", + "is_created": False, + }, + }, + ), + ], + ) + def post(self, request, *args, **kwargs): + code = request.data.get("code") + + if not code: + return Response( + {"error": "Code is required"}, status=status.HTTP_400_BAD_REQUEST + ) + + # 카카오 인가코드를 사용해 access_token 획득 + token_res = requests.get( + f"https://kauth.kakao.com/oauth/token?grant_type=authorization_code&client_id={REST_API_KEY}&client_secret={CLIENT_SECRET}&redirect_uri={KAKAO_CALLBACK_URI}&code={code}" + ) + logger.fatal(token_res) + + if token_res.status_code != 200: + logger.fatal(token_res.json()) + return Response( + {"error": "Failed to obtain access token"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + token_json = token_res.json() + access_token = token_json.get("access_token") + + # 카카오 access_token으로부터 사용자 정보 획득 + headers = {"Authorization": f"Bearer {access_token}"} + profile_res = requests.get("https://kapi.kakao.com/v2/user/me", headers=headers) + + if profile_res.status_code != 200: + return Response( + {"error": "Failed to obtain user information"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + profile_json = profile_res.json() + + kakao_oid = profile_json.get("id") + nickname = profile_json.get("properties")["nickname"] + profile_image = profile_json.get("properties")["profile_image"] + email = profile_json.get("kakao_account")["email"] + + user, created = User.objects.get_or_create( + email=email, + defaults={ + "username": f"{nickname}", + "kakao_oid": kakao_oid, + "profile_image": f"{profile_image}", }, - ), - ], -) -class KakaoLoginView(SocialLoginView): - adapter_class = kakao_view.KakaoOAuth2Adapter - client_class = OAuth2Client - callback_url = KAKAO_CALLBACK_URI + ) + + # 사용자에 대한 토큰 생성 + refresh = RefreshToken.for_user(user) + data = { + "access_token": str(refresh.access_token), + "refresh_token": str(refresh), + "user_info": { + "id": user.id, + "email": user.email, + "username": user.username, + "profile_image": user.profile_image, + "is_created": created, + }, + } + + return Response(data, status=status.HTTP_200_OK) class UpdateUserInfoView(APIView): diff --git a/config/docker/entrypoint.prod.sh b/config/docker/entrypoint.prod.sh index 6097940..354633d 100644 --- a/config/docker/entrypoint.prod.sh +++ b/config/docker/entrypoint.prod.sh @@ -1,6 +1,8 @@ #!/bin/sh -python manage.py migrate --noinput +python manage.py makemigrations --no-input + +python manage.py migrate --no-input python manage.py collectstatic --no-input diff --git a/docker-compose.yml b/docker-compose.yml index 118db83..687e326 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,7 +19,7 @@ services: build: context: ./ dockerfile: Dockerfile - command: sh -c "python manage.py migrate && python manage.py runserver 0.0.0.0:8000 --settings=resumai.settings.dev" + command: sh -c "python manage.py makemigrations && python manage.py migrate && python manage.py runserver 0.0.0.0:8000 --settings=resumai.settings.dev" environment: DJANGO_ENV: development env_file: diff --git a/memos/__init__.py b/memos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/memos/admin.py b/memos/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/memos/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/memos/apps.py b/memos/apps.py new file mode 100644 index 0000000..96d5d04 --- /dev/null +++ b/memos/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class MemoConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "memos" diff --git a/memos/migrations/0001_initial.py b/memos/migrations/0001_initial.py new file mode 100644 index 0000000..d0753d1 --- /dev/null +++ b/memos/migrations/0001_initial.py @@ -0,0 +1,42 @@ +# Generated by Django 5.0.3 on 2024-04-01 17:21 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Memo", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("title", models.CharField(max_length=255)), + ("content", models.TextField()), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/memos/migrations/0002_memo_is_finished_memo_is_scrapped.py b/memos/migrations/0002_memo_is_finished_memo_is_scrapped.py new file mode 100644 index 0000000..44399d9 --- /dev/null +++ b/memos/migrations/0002_memo_is_finished_memo_is_scrapped.py @@ -0,0 +1,25 @@ +# Generated by Django 5.0.3 on 2024-04-01 17:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("memos", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="memo", + name="is_finished", + field=models.BooleanField(default=True), + preserve_default=False, + ), + migrations.AddField( + model_name="memo", + name="is_scrapped", + field=models.BooleanField(default=True), + preserve_default=False, + ), + ] diff --git a/memos/migrations/0003_remove_memo_is_finished_remove_memo_is_scrapped.py b/memos/migrations/0003_remove_memo_is_finished_remove_memo_is_scrapped.py new file mode 100644 index 0000000..4c9af07 --- /dev/null +++ b/memos/migrations/0003_remove_memo_is_finished_remove_memo_is_scrapped.py @@ -0,0 +1,21 @@ +# Generated by Django 5.0.3 on 2024-04-02 04:47 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("memos", "0002_memo_is_finished_memo_is_scrapped"), + ] + + operations = [ + migrations.RemoveField( + model_name="memo", + name="is_finished", + ), + migrations.RemoveField( + model_name="memo", + name="is_scrapped", + ), + ] diff --git a/memos/migrations/__init__.py b/memos/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/memos/models.py b/memos/models.py new file mode 100644 index 0000000..e253847 --- /dev/null +++ b/memos/models.py @@ -0,0 +1,14 @@ +from django.db import models +from django.conf import settings +from django.utils import timezone + + +class Memo(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True) + title = models.CharField(max_length=255) + content = models.TextField() + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return self.title diff --git a/memos/serializers.py b/memos/serializers.py new file mode 100644 index 0000000..fddc6f9 --- /dev/null +++ b/memos/serializers.py @@ -0,0 +1,15 @@ +from rest_framework import serializers +from .models import Memo + + +class MemoSerializer(serializers.ModelSerializer): + class Meta: + model = Memo + fields = "__all__" # 모든 필드를 포함 + + +class PostMemoSerializer(serializers.ModelSerializer): + class Meta: + model = Memo + fields = ("id", "title", "content", "created_at", "updated_at") + read_only_fields = ("id", "created_at", "updated_at") diff --git a/memos/tests.py b/memos/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/memos/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/memos/urls.py b/memos/urls.py new file mode 100644 index 0000000..b06b164 --- /dev/null +++ b/memos/urls.py @@ -0,0 +1,12 @@ +from django.urls import path +from memos import views + + +urlpatterns = [ + path("", views.PostMemoView.as_view(), name="post_memo"), + path("/all", views.GetAllMemoView.as_view(), name="get_all_memos"), + path("/", views.GetMemoDetailView.as_view(), name="memo-detail"), + path("/update/", views.UpdateMemoView.as_view(), name="scrap-memo"), + path("/delete/", views.DeleteMemoView.as_view(), name="delete-memo"), + path("/search", views.SearchMemoView.as_view(), name="search-memo"), +] diff --git a/memos/views.py b/memos/views.py new file mode 100644 index 0000000..732386a --- /dev/null +++ b/memos/views.py @@ -0,0 +1,227 @@ +from django.http import Http404 +from django.db.models import Q +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework import status +from rest_framework.views import APIView +from rest_framework.pagination import PageNumberPagination +from rest_framework import serializers +from django.http import JsonResponse + +from drf_spectacular.utils import ( + extend_schema, + inline_serializer, + OpenApiParameter, +) + +from .models import Memo +from .serializers import PostMemoSerializer, MemoSerializer + + +class PostMemoView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema( + summary="메모 등록", + description="메모를 등록합니다.", + responses={200: PostMemoSerializer}, + request={ + "application/json": { + "type": "object", + "properties": { + "title": {"type": "string"}, + "content": {"type": "string"}, + }, + }, + }, + ) + def post(self, request): + serializer = PostMemoSerializer(data=request.data) + + # 데이터 유효성 검사 + if serializer.is_valid(): + # 유효한 데이터의 경우, 메모 저장 + serializer.save( + user=request.user + ) # 현재 로그인한 사용자를 메모의 user 필드에 저장 + return Response(serializer.data, status=status.HTTP_201_CREATED) + else: + # 데이터가 유효하지 않은 경우, 에러 메시지 반환 + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class GetAllMemoView(APIView, PageNumberPagination): + permission_classes = [IsAuthenticated] + + @extend_schema( + summary="전체 메모 조회", + description="사용자가 작성한 전체 메모를 받아옵니다.", + responses={ + 200: inline_serializer( + name="GetAllMemoResponse", + fields={ + "count": serializers.IntegerField(), + "next": serializers.URLField(), + "previous": serializers.URLField(), + "results": MemoSerializer(many=True), + }, + ) + }, + ) + def get(self, request): + # 현재 인증된 유저에게 속한 메모들을 조회 + memos = Memo.objects.filter(user=request.user) + + # Pagination 적용 + page = self.paginate_queryset(memos, request, view=self) + if page is not None: + serializer = MemoSerializer(page, many=True) + return self.get_paginated_response(serializer.data) + + # Pagination이 적용되지 않은 경우(선택적) + serializer = MemoSerializer(memos, many=True) + return Response(serializer.data) + + +class GetMemoDetailView(APIView): + permission_classes = [IsAuthenticated] + + def get_object(self, pk, user): + try: + memo = Memo.objects.get(pk=pk) + # 메모를 작성한 유저와 현재 요청 유저가 동일한지 확인 + if memo.user != user: + raise Http404("해당 메모에 접근할 권한이 없습니다.") + return memo + except Memo.DoesNotExist: + raise Http404 + + @extend_schema( + summary="특정 메모 조회", + description="사용자가 작성한 특정 메모의 디테일 받아옵니다.", + responses={200: MemoSerializer}, + ) + def get(self, request, pk, format=None): + memo = self.get_object(pk, request.user) + serializer = MemoSerializer(memo) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class DeleteMemoView(APIView): + permission_classes = [IsAuthenticated] + + def get_object(self, pk, user): + try: + memo = Memo.objects.get(pk=pk) + # 메모를 작성한 유저와 현재 요청 유저가 동일한지 확인 + if memo.user != user: + raise Http404("해당 메모를 제거할 자격이 없습니다.") + return memo + except Memo.DoesNotExist: + raise Http404 + + @extend_schema( + summary="메모 삭제", + description="특정 메모를 삭제합니다. 메모를 작성한 사용자만 해당 메모를 삭제할 수 있습니다.", + responses={ + 204: {"description": "메모가 성공적으로 삭제되었습니다."}, + 404: {"description": "해당 메모를 찾을 수 없거나 삭제할 권한이 없습니다."}, + }, + ) + def delete(self, request, pk, format=None): + memo = self.get_object(pk, request.user) + memo.delete() + return JsonResponse( + {"status": "success", "message": "Memo deleted successfully."}, + status=status.HTTP_204_NO_CONTENT, + ) + + +class CustomPagination(PageNumberPagination): + # 클라이언트로부터 'size' 파라미터를 받아 페이지 크기를 결정 + page_size_query_param = "size" + + # 'size' 파라미터가 제공되지 않은 경우 기본 페이지 크기 + def get_page_size(self, request): + return super().get_page_size(request) + + +class UpdateMemoView(APIView): + permission_classes = [IsAuthenticated] # 인증된 사용자만 접근 가능하도록 설정 + + @extend_schema( + summary="메모 업데이트", + request=PostMemoSerializer, + responses={200: PostMemoSerializer}, + ) + def put(self, request, *args, **kwargs): + user = request.user + resume_id = kwargs.get("id") # URL에서 resume의 id를 가져옵니다. + + try: + resume = Memo.objects.get( + id=resume_id, user=user + ) # 요청한 사용자의 resume만 선택 + except Memo.DoesNotExist: + return Response( + {"error": "Resume not found"}, status=status.HTTP_404_NOT_FOUND + ) + + serializer = PostMemoSerializer( + resume, data=request.data, partial=True + ) # 업데이트 대상 인스턴스를 지정하고 부분 업데이트 가능 + + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class SearchMemoView(APIView, CustomPagination): + permission_classes = [IsAuthenticated] + + @extend_schema( + summary="메모 검색", + description="키워드를 기반으로 메모를 검색합니다.", + responses={ + 200: inline_serializer( + name="SearchResponse", + fields={ + "count": serializers.IntegerField(), + "next": serializers.URLField(), + "previous": serializers.URLField(), + "results": PostMemoSerializer(many=True), + }, + ) + }, + parameters=[ + OpenApiParameter( + name="keyword", + type=str, + description="검색할 키워드입니다.", + ), + OpenApiParameter( + name="page", type=int, description="한번에 요청할 page 사이즈 입니다." + ), + OpenApiParameter( + name="size", type=int, description="한 화면에 표시할 메모의 개수입니다." + ), + ], + ) + def get(self, request): + keyword = request.query_params.get("keyword", "") + + query_set = Memo.objects.filter( + Q(user=request.user) + & (Q(title__icontains=keyword) | Q(content__icontains=keyword)) + ) + + page = self.paginate_queryset(query_set, request, view=self) + if page is not None: + serializer = MemoSerializer(page, many=True) + return self.get_paginated_response(serializer.data) + + # Pagination이 적용되지 않는 경우의 대비책(예: size 파라미터 누락 등) + serializer = MemoSerializer(query_set, many=True) + return Response(serializer.data) diff --git a/requirements.txt b/requirements.txt index eab1e9c..af2e3e9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,9 @@ +aiohttp==3.9.3 +aiosignal==1.3.1 +annotated-types==0.6.0 +anyio==4.3.0 asgiref==3.7.2 +async-timeout==4.0.3 attrs==23.2.0 black==24.3.0 certifi==2024.2.2 @@ -6,39 +11,71 @@ cffi==1.16.0 charset-normalizer==3.3.2 click==8.1.7 cryptography==42.0.5 +dataclasses-json==0.6.4 defusedxml==0.7.1 +distro==1.9.0 dj-rest-auth==5.0.2 Django==5.0.3 django-allauth==0.61.1 +django-cors-headers==4.3.1 django-ebhealthcheck==2.0.2 django-environ==0.11.2 djangorestframework==3.14.0 djangorestframework-simplejwt==5.3.1 drf-spectacular==0.27.1 drf-yasg==1.21.7 +exceptiongroup==1.2.0 +frozenlist==1.4.1 gunicorn==21.2.0 +h11==0.14.0 +httpcore==1.0.5 +httpx==0.27.0 idna==3.6 inflection==0.5.1 +jsonpatch==1.33 +jsonpointer==2.4 jsonschema==4.21.1 jsonschema-specifications==2023.12.1 +langchain==0.1.14 +langchain-community==0.0.31 +langchain-core==0.1.41 +langchain-openai==0.1.2 +langchain-text-splitters==0.0.1 +langsmith==0.1.41 +marshmallow==3.21.1 +multidict==6.0.5 mypy-extensions==1.0.0 mysqlclient==2.2.4 +numpy==1.26.4 oauthlib==3.2.2 +openai==1.16.0 +orjson==3.10.0 packaging==23.2 pathspec==0.12.1 +pinecone-client==3.2.2 platformdirs==4.2.0 pycparser==2.21 +pydantic==2.6.4 +pydantic_core==2.16.3 PyJWT==2.8.0 PyMySQL==1.1.0 python3-openid==3.2.0 pytz==2024.1 PyYAML==6.0.1 referencing==0.33.0 +regex==2023.12.25 requests==2.31.0 requests-oauthlib==1.4.0 rpds-py==0.18.0 +sniffio==1.3.1 +SQLAlchemy==2.0.29 sqlparse==0.4.4 +tenacity==8.2.3 +tiktoken==0.6.0 tomli==2.0.1 +tqdm==4.66.2 +typing-inspect==0.9.0 typing_extensions==4.10.0 uritemplate==4.1.1 urllib3==2.2.1 +yarl==1.9.4 diff --git a/resumai/settings/base.py b/resumai/settings/base.py index 12d91b4..607396f 100644 --- a/resumai/settings/base.py +++ b/resumai/settings/base.py @@ -10,10 +10,7 @@ env = environ.Env(DEBUG=(bool, False)) - BASE_DIR = Path(__file__).resolve().parent.parent.parent -print(BASE_DIR) - environ.Env.read_env(os.path.join(BASE_DIR, ".env")) # Quick-start development settings - unsuitable for production @@ -33,8 +30,11 @@ "django.contrib.messages", "django.contrib.sites", "django.contrib.staticfiles", + "corsheaders", "resumai", "accounts", + "memos", + "resume", # django-rest-auth "rest_framework", "rest_framework_simplejwt", @@ -51,6 +51,7 @@ MIDDLEWARE = [ "resumai.middleware.HealthCheckMiddleware", + "corsheaders.middleware.CorsMiddleware", "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", @@ -77,6 +78,8 @@ "rest_framework_simplejwt.authentication.JWTAuthentication", ), "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "PAGE_SIZE": 5, } REST_AUTH = { @@ -153,6 +156,8 @@ }, ] +CORS_ALLOW_ALL_ORIGINS = True + # Internationalization # https://docs.djangoproject.com/en/5.0/topics/i18n/ diff --git a/resumai/templates/home.html b/resumai/templates/home.html index 8508464..471734b 100644 --- a/resumai/templates/home.html +++ b/resumai/templates/home.html @@ -1,16 +1,6 @@ - - - Kakao Login - -

Welcome to the Kakao Login Page

-

- Click the button below to log in using your Kakao account. -

- - - +

RESUMAI API

diff --git a/resumai/urls.py b/resumai/urls.py index c9ae0f3..f0858c8 100644 --- a/resumai/urls.py +++ b/resumai/urls.py @@ -14,9 +14,9 @@ schema_view = get_schema_view( openapi.Info( - title="Break-Magazine API", + title="RESUMAI API", default_version="v1", - description="API documentation for Break-Magazine Webzine project", + description="API documentation for RESUMAI project", ), public=True, patterns=accounts_urlpatterns, @@ -36,10 +36,10 @@ def preprocessing_filter_spec(endpoints): urlpatterns = [ path("", kakao_login_page, name="home"), path("admin/", admin.site.urls), - # path("accounts/", include("dj_rest_auth.urls")), - # path('accounts/', include('allauth.urls')), - path("accounts/", include("accounts.urls")), + path("accounts", include("accounts.urls")), path("registration/", include("dj_rest_auth.registration.urls")), + path("memos", include("memos.urls")), + path("resume", include("resume.urls")), # swagger 관련 path("api/schema/", SpectacularAPIView.as_view(), name="schema"), path( diff --git a/resume/__init__.py b/resume/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/resume/admin.py b/resume/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/resume/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/resume/apps.py b/resume/apps.py new file mode 100644 index 0000000..4511e1d --- /dev/null +++ b/resume/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ResumeConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "resume" diff --git a/resume/migrations/0001_initial.py b/resume/migrations/0001_initial.py new file mode 100644 index 0000000..5da0007 --- /dev/null +++ b/resume/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# Generated by Django 5.0.3 on 2024-04-02 11:28 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Resume", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=255)), + ("content", models.TextField()), + ("due_date", models.DateTimeField(null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("is_finished", models.BooleanField(default=False)), + ("is_liked", models.BooleanField(default=False)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/resume/migrations/0002_resume_position.py b/resume/migrations/0002_resume_position.py new file mode 100644 index 0000000..cdfb374 --- /dev/null +++ b/resume/migrations/0002_resume_position.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.3 on 2024-04-06 07:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("resume", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="resume", + name="position", + field=models.CharField(default="ML Engineer", max_length=255), + preserve_default=False, + ), + ] diff --git a/resume/migrations/0003_resume_question_chathistory.py b/resume/migrations/0003_resume_question_chathistory.py new file mode 100644 index 0000000..f8c3d63 --- /dev/null +++ b/resume/migrations/0003_resume_question_chathistory.py @@ -0,0 +1,42 @@ +# Generated by Django 5.0.3 on 2024-04-10 06:52 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("resume", "0002_resume_position"), + ] + + operations = [ + migrations.AddField( + model_name="resume", + name="question", + field=models.TextField(default="테스트질문"), + preserve_default=False, + ), + migrations.CreateModel( + name="ChatHistory", + 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)), + ( + "resume", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="resume.resume" + ), + ), + ], + ), + ] diff --git a/resume/migrations/0004_alter_resume_due_date.py b/resume/migrations/0004_alter_resume_due_date.py new file mode 100644 index 0000000..0ac403d --- /dev/null +++ b/resume/migrations/0004_alter_resume_due_date.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.3 on 2024-04-10 07:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("resume", "0003_resume_question_chathistory"), + ] + + operations = [ + migrations.AlterField( + model_name="resume", + name="due_date", + field=models.CharField(max_length=32, null=True), + ), + ] diff --git a/resume/migrations/0005_alter_resume_due_date.py b/resume/migrations/0005_alter_resume_due_date.py new file mode 100644 index 0000000..4b20200 --- /dev/null +++ b/resume/migrations/0005_alter_resume_due_date.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.3 on 2024-04-10 07:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("resume", "0004_alter_resume_due_date"), + ] + + operations = [ + migrations.AlterField( + model_name="resume", + name="due_date", + field=models.DateField(null=True), + ), + ] diff --git a/resume/migrations/0006_chathistory_query_chathistory_response.py b/resume/migrations/0006_chathistory_query_chathistory_response.py new file mode 100644 index 0000000..e4a403b --- /dev/null +++ b/resume/migrations/0006_chathistory_query_chathistory_response.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.3 on 2024-04-10 08:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("resume", "0005_alter_resume_due_date"), + ] + + operations = [ + migrations.AddField( + model_name="chathistory", + name="query", + field=models.TextField(null=True), + ), + migrations.AddField( + model_name="chathistory", + name="response", + field=models.TextField(null=True), + ), + ] diff --git a/resume/migrations/__init__.py b/resume/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/resume/models.py b/resume/models.py new file mode 100644 index 0000000..183a521 --- /dev/null +++ b/resume/models.py @@ -0,0 +1,26 @@ +from django.db import models +from django.conf import settings + + +class Resume(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + title = models.CharField(max_length=255) # 제목이 곧 지원하려는 기업명과 동일함 + position = models.CharField(max_length=255) # 지원하려는 기업의 지원하려는 직무 + question = models.TextField() # 작성하려는 자소서에서 답변할 질문 + content = models.TextField() # 질문에 대한 답변 = contents + due_date = models.DateField(null=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + is_finished = models.BooleanField(default=False) + is_liked = models.BooleanField(default=False) + + def __str__(self): + return self.title + + +class ChatHistory(models.Model): + resume = models.ForeignKey(Resume, on_delete=models.CASCADE) + query = models.TextField(null=True) # 사용자의 질문 + response = models.TextField(null=True) # 챗봇의 응답 + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) diff --git a/resume/serializers.py b/resume/serializers.py new file mode 100644 index 0000000..a1d2bdf --- /dev/null +++ b/resume/serializers.py @@ -0,0 +1,51 @@ +from rest_framework import serializers + +from resume.models import Resume, ChatHistory + + +class GenerateResumeSerializer(serializers.Serializer): + question = serializers.CharField() + guidelines = serializers.CharField() + answers = serializers.CharField() + free_answer = serializers.CharField() + favor_info = serializers.CharField() + + +class PostResumeSerializer(serializers.ModelSerializer): + class Meta: + model = Resume + fields = ( + "title", + "position", + "question", + "content", + "due_date", + "created_at", + "updated_at", + "is_finished", + "is_liked", + ) + read_only_fields = ("id", "created_at", "updated_at") + + +class UpdateResumeSerializer(serializers.ModelSerializer): + class Meta: + model = Resume + fields = ( + "id", + "title", + "position", + "content", + "due_date", + "created_at", + "updated_at", + "is_finished", + "is_liked", + ) + read_only_fields = ("created_at", "updated_at") + + +class ChatHistorySerializer(serializers.ModelSerializer): + class Meta: + model = ChatHistory + fields = ("query", "response", "created_at") diff --git a/resume/tests.py b/resume/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/resume/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/resume/urls.py b/resume/urls.py new file mode 100644 index 0000000..ee1715f --- /dev/null +++ b/resume/urls.py @@ -0,0 +1,20 @@ +from django.urls import path +from resume import views + + +urlpatterns = [ + path("/all", views.GetAllResumeView.as_view(), name="get_all_resume"), + path("/guidelines", views.GetGuidelinesView.as_view(), name="get_guidelines"), + path("/generate", views.GenerateResumeView.as_view(), name="generate_resume"), + # path("", views.PostResumeView.as_view(), name="post_resume"), + path("/update/", views.UpdateResumeView.as_view(), name="update_resume"), + path("/scrap/", views.ScrapResumeView.as_view(), name="scrap_resume"), + path("//chat", views.ChatView.as_view(), name="chat"), + path("/", views.GetResumeView.as_view(), name="update_resume"), + path( + "//chatHistory", + views.GetChatHistoryView.as_view(), + name="get_chat_history", + ), + path("/delete/", views.DeleteResumeView.as_view(), name="delete_resume"), +] diff --git a/resume/utils.py b/resume/utils.py new file mode 100644 index 0000000..9d09936 --- /dev/null +++ b/resume/utils.py @@ -0,0 +1,44 @@ +import os +from pathlib import Path +import environ + +from pinecone import Pinecone +from utils.openai_call import get_embedding + +from langchain_openai import ChatOpenAI +from langchain.chains import ConversationChain +from langchain.memory import ConversationBufferMemory + +env = environ.Env(DEBUG=(bool, False)) +BASE_DIR = Path(__file__).resolve().parent.parent +environ.Env.read_env(os.path.join(BASE_DIR, ".env")) + +pc = Pinecone(api_key=os.environ.get("PINECONE_API_KEY")) + + +def retrieve_similar_answers(user_qa): + try: + pc = Pinecone() + index = pc.Index("resumai-self-introduction-index") + query_embedding = get_embedding(user_qa) + retrieved_data = index.query( + vector=query_embedding, top_k=3, include_metadata=True + ) + return retrieved_data["matches"] + + except Exception as e: + print(e) + return [] + + +llm = ChatOpenAI(verbose=True, temperature=0, model_name="gpt-4") +memory = ConversationBufferMemory() + + +def run_llm(query: str, chat_history: list[dict[str, any]]) -> any: + for chat in chat_history: + memory.save_context( + inputs={"human": chat["query"]}, outputs={"ai": chat["response"]} + ) + conversation = ConversationChain(llm=llm, verbose=True, memory=memory) + return conversation.predict(input=query) diff --git a/resume/views.py b/resume/views.py new file mode 100644 index 0000000..550bca4 --- /dev/null +++ b/resume/views.py @@ -0,0 +1,483 @@ +import json +import logging + +from django.http import Http404 +from django.db.models import Q +from django.shortcuts import get_object_or_404 +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework import status +from rest_framework.views import APIView +from rest_framework.pagination import PageNumberPagination +from rest_framework import serializers +from django.http import JsonResponse +from typing import List + +from drf_spectacular.utils import ( + extend_schema, + inline_serializer, + OpenApiParameter, +) + +from resume.models import Resume, ChatHistory +from resume.serializers import ( + GenerateResumeSerializer, + PostResumeSerializer, + UpdateResumeSerializer, + ChatHistorySerializer, +) +from resume.utils import retrieve_similar_answers, run_llm +from utils.openai_call import get_chat_openai +from utils.prompts import ( + GUIDELINE_PROMPT, + GENERATE_SELF_INTRODUCTION_PROMPT, + CHAT_PROMPT, +) + + +class GetAllResumeView(APIView, PageNumberPagination): + permission_classes = [IsAuthenticated] + + @extend_schema( + summary="전체 자소서 조회", + description="사용자가 작성한 전체 자기소개서를 받아옵니다.", + responses={ + 200: inline_serializer( + name="GetAllResumeResponse", + fields={ + "count": serializers.IntegerField(), + "next": serializers.URLField(), + "previous": serializers.URLField(), + "results": PostResumeSerializer(many=True), + }, + ) + }, + ) + def get(self, request): + # 현재 인증된 유저에게 속한 메모들을 조회 + resumes = Resume.objects.filter(user=request.user) + + # Pagination 적용 + page = self.paginate_queryset(resumes, request, view=self) + if page is not None: + serializer = PostResumeSerializer(page, many=True) + return self.get_paginated_response(serializer.data) + + # Pagination이 적용되지 않은 경우(선택적) + serializer = PostResumeSerializer(resumes, many=True) + return Response(serializer.data) + + +# Create your views here. +class GetGuidelinesView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema( + summary="가이드라인 생성", + description="질문을 기반으로 가이드라인을 생성합니다.", + # responses={ + # 200: inline_serializer( + # name="GetGuidelineResponse", + # fields={ + # "results": ['guide1', 'guide2', 'guide3'] + # } + # ) + # }, + parameters=[ + OpenApiParameter( + name="question", + type=str, + description="기업이 제시한 질문을 입력합니다.", + ) + ], + ) + def get(self, request): + question = request.GET.get("question") + try: + prompt = GUIDELINE_PROMPT.format(question=question) + guideline_string = get_chat_openai(prompt) + guideline_list = json.loads(guideline_string.replace("'", '"')) + guideline_json = {"result": guideline_list} + return JsonResponse(guideline_json) + except Exception as e: + error_message = { + "error": "가이드라인 생성 중 오류가 발생했습니다. 질문을 올바르게 입력해 주세요." + } + return JsonResponse(error_message, status=500) + + +class GenerateResumeView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema( + summary="자소서 생성", + description="답변을 기반으로 자기소개서를 생성합니다.", + responses={200: PostResumeSerializer}, + parameters=[ + OpenApiParameter( + name="title", + type=str, + description="자소서 제목", + ), + OpenApiParameter( + name="position", + type=str, + description="지원하려는 직무", + ), + OpenApiParameter( + name="due_date", + type=str, + style="date", + description='공고 마감기한. 그냥 str 형식으로 "2024-04-10" 이렇게 보내주삼', + ), + OpenApiParameter( + name="question", + type=str, + description="기업이 제시한 질문", + ), + OpenApiParameter( + name="guidelines", + type={"type": "array", "items": {"type": "string"}}, + location=OpenApiParameter.QUERY, + required=False, + style="form", + explode=False, + description="제공된 가이드라인", + ), + OpenApiParameter( + name="answers", + type={"type": "array", "items": {"type": "string"}}, + location=OpenApiParameter.QUERY, + required=False, + style="form", + explode=False, + description="제공된 가이드라인에 대한 답변", + ), + OpenApiParameter( + name="free_answer", + type=str, + description="자유 작성란에 작성한 답변", + ), + OpenApiParameter( + name="favor_info", + type=str, + description="우대사항", + ), + ], + ) + def post(self, request): + title = request.data["title"] + position = request.data["position"] + due_date = request.data["due_date"] + question = request.data["question"] + guidelines = request.data["guidelines"] + answers = request.data["answers"] + free_answer = request.data["free_answer"] + favor_info = request.data["favor_info"] + + # 답변을 guideline + answer + free_answer로 구성 + total_answer = "" + for index, answer in enumerate(answers): + # answer 값이 존재하는 경우에만 처리 + if answer: + total_answer += guidelines[index] + "\n" + answer + "\n\n" + if free_answer: + total_answer += free_answer + + # 예시 retrieve + examples = retrieve_similar_answers(total_answer) + if len(examples) == 0: + error_message = { + "error": "유사한 질문을 가져오는 도중 문제가 발생했습니다. 다시 시도해 주세요." + } + return JsonResponse(error_message, status=500) + + examples_str = "\n\n".join( + [ + f"예시{i}) \nQuestion: {ex['metadata']['question']} \nAnswer: {ex['metadata']['answer']}" + for i, ex in enumerate(examples, start=1) + ] + ) + + # 프롬프트 작성 + prompt = GENERATE_SELF_INTRODUCTION_PROMPT.format( + question=question, + answer=total_answer, + favor_info=favor_info, + examples=examples_str, + ) + + # 자소서 생성 + generated_self_introduction = get_chat_openai(prompt) + + serializer = PostResumeSerializer( + data={ + "title": title, + "position": position, + "question": question, + "content": generated_self_introduction, + "due_date": due_date, + "is_finished": False, + "is_liked": False, + } + ) + + # 데이터 유효성 검사 + if serializer.is_valid(): + # 유효한 데이터의 경우, 자소서 저장 + saved_instance = serializer.save(user=request.user) + resume = get_object_or_404(Resume, pk=saved_instance.id) + new_chat_history = ChatHistory( + resume=resume, query=prompt, response=generated_self_introduction + ) + new_chat_history.save() + + return Response({"id": saved_instance.id}, status=status.HTTP_201_CREATED) + else: + # 데이터가 유효하지 않은 경우, 에러 메시지 반환 + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +# class PostResumeView(APIView): +# permission_classes = [IsAuthenticated] +# +# @extend_schema( +# summary="자기소개서 등록", +# description="자기소개서를 등록합니다.", +# responses={200: PostResumeSerializer}, +# request={ +# "application/json": { +# "type": "object", +# "properties": { +# "title": {"type": "string"}, +# "position": {"type": "string"}, +# "content": {"type": "string"}, +# "due_date": {"type": "string"}, +# }, +# }, +# }, +# ) +# def post(self, request): +# serializer = PostResumeSerializer(data=request.data) +# +# # 데이터 유효성 검사 +# if serializer.is_valid(): +# # 유효한 데이터의 경우, 자소서 저장 +# serializer.save( +# user=request.user +# ) # 현재 로그인한 사용자를 메모의 user 필드에 저장 +# return Response(serializer.data, status=status.HTTP_201_CREATED) +# else: +# # 데이터가 유효하지 않은 경우, 에러 메시지 반환 +# return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class GetResumeView(APIView): + permission_classes = [IsAuthenticated] + + def get_object(self, pk, user): + try: + resume = Resume.objects.get(pk=pk) + # 메모를 작성한 유저와 현재 요청 유저가 동일한지 확인 + if resume.user != user: + raise Http404("해당 메모에 접근할 권한이 없습니다.") + return resume + except resume.DoesNotExist: + raise Http404 + + @extend_schema( + summary="특정 자소서 조회", + description="사용자가 작성한 특정 자소서의 디테일을 받아옵니다.", + responses={200: PostResumeSerializer}, + ) + def get(self, request, pk, format=None): + resume = self.get_object(pk, request.user) + serializer = PostResumeSerializer(resume) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class UpdateResumeView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema( + summary="자소서 업데이트", + request=UpdateResumeSerializer, + responses={200: PostResumeSerializer}, + ) + def put(self, request, *args, **kwargs): + user = request.user + resume_id = kwargs.get("id") # URL에서 resume의 id를 가져옵니다. + + try: + resume = Resume.objects.get( + id=resume_id, user=user + ) # 요청한 사용자의 resume만 선택 + except Resume.DoesNotExist: + return Response( + {"error": "Resume not found"}, status=status.HTTP_404_NOT_FOUND + ) + + serializer = PostResumeSerializer( + resume, data=request.data, partial=True + ) # 업데이트 대상 인스턴스를 지정하고 부분 업데이트 가능 + + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class ScrapResumeView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema( + summary="자소서 스크랩", + description="특정 자기소개서를 스크랩합니다.", + responses={ + 200: inline_serializer( + name="ScrapResumeResponse", + fields={ + "id": serializers.IntegerField(), + "is_liked": serializers.BooleanField(), + }, + ) + }, + ) + def get(self, request, *args, **kwargs): + resume_id = kwargs.get("id", None) # URL로부터 자기소개서 id를 받아옵니다. + if not resume_id: + return Response( + {"error": "Resume ID is required"}, status=status.HTTP_400_BAD_REQUEST + ) + + try: + resume = Resume.objects.get(id=resume_id) + # is_liked 필드의 값을 반전시킵니다. + resume.is_liked = not resume.is_liked + resume.save( + update_fields=["is_liked"] + ) # 업데이트할 필드를 명시적으로 지정합니다. + + return Response( + {"id": resume_id, "is_liked": resume.is_liked}, + status=status.HTTP_200_OK, + ) + except Resume.DoesNotExist: + return Response( + {"error": "Resume not found"}, status=status.HTTP_404_NOT_FOUND + ) + + +class ChatView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema( + summary="챗봇 대화", + description="챗봇과의 대화를 통해 자기소개서를 첨삭 받습니다..", + responses={200: {"answer": "string"}}, + request={ + "application/json": { + "type": "object", + "properties": { + "query": {"type": "string"}, + }, + }, + }, + ) + def post(self, request, id): + query = request.data.get("query", "") + + resume = get_object_or_404(Resume, pk=id) + + # 해당 resume에 대한 이전 대화 내역을 가져옴 + chat_history_instances = ChatHistory.objects.filter(resume=resume) + chat_history = [ + {"query": instance.query, "response": instance.response} + for instance in chat_history_instances + ] + + if len(chat_history) == 1: + query = CHAT_PROMPT.format(query=query) + + # 챗봇으로부터 응답을 받음 + chatbot_response = run_llm(query=query, chat_history=chat_history) + + # 새로운 대화 기록을 생성하고 저장 + new_chat_history = ChatHistory( + resume=resume, query=query, response=chatbot_response + ) + new_chat_history.save() + + # 챗봇의 응답을 반환 + return JsonResponse({"answer": chatbot_response}, status=status.HTTP_200_OK) + + +class GetChatHistoryView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema( + summary="채팅 내역 조회", + description="채팅 내역을 반환합니다.", + responses={ + 200: inline_serializer( + name="GetChatHistoryResponse", + fields={ + "count": serializers.IntegerField(), + "next": serializers.URLField(), + "previous": serializers.URLField(), + "results": ChatHistorySerializer(many=True), + }, + ) + }, + parameters=[ + OpenApiParameter( + name="page", + type=int, + description="페이지 수", + ), + ], + ) + def get(self, request, pk): + resume = get_object_or_404(Resume, pk=pk) + queryset = ( + ChatHistory.objects.filter(resume=resume).order_by("-created_at").reverse() + ) + paginator = PageNumberPagination() + result_page = paginator.paginate_queryset(queryset, request) + serializer = ChatHistorySerializer( + result_page, many=True, context={"request": request} + ) + return paginator.get_paginated_response(serializer.data) + + +class DeleteResumeView(APIView): + permission_classes = [IsAuthenticated] + + def get_object(self, pk, user): + try: + resume = Resume.objects.get(pk=pk) + # 메모를 작성한 유저와 현재 요청 유저가 동일한지 확인 + if resume.user != user: + raise Http404("해당 메모를 제거할 자격이 없습니다.") + return resume + except Resume.DoesNotExist: + raise Http404 + + @extend_schema( + summary="자소서 삭제", + description="특정 자소서를 삭제합니다. 자소서를 작성한 사용자만 해당 자소서를 삭제할 수 있습니다.", + responses={ + 204: {"description": "자기소개서가 성공적으로 삭제되었습니다."}, + 404: { + "description": "해당 자소서를 찾을 수 없거나 삭제할 권한이 없습니다." + }, + }, + ) + def delete(self, request, pk, format=None): + resume = self.get_object(pk, request.user) + resume.delete() + return JsonResponse( + {"status": "success", "message": "Resume deleted successfully."}, + status=status.HTTP_204_NO_CONTENT, + ) diff --git a/utils/openai_call.py b/utils/openai_call.py new file mode 100644 index 0000000..4d8a791 --- /dev/null +++ b/utils/openai_call.py @@ -0,0 +1,27 @@ +from openai import OpenAI +import environ +from pathlib import Path +import os + +env = environ.Env(DEBUG=(bool, False)) +BASE_DIR = Path(__file__).resolve().parent.parent +environ.Env.read_env(os.path.join(BASE_DIR, ".env")) + +client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY")) + + +def get_chat_openai(prompt, model="gpt-4"): + response = client.chat.completions.create( + model=model, + messages=[{"role": "user", "content": prompt}], + temperature=0, + ) + output = response.choices[0].message.content + return output + + +def get_embedding(text, model="text-embedding-3-small"): + # text = text.replace("\n", " ") + response = client.embeddings.create(input=[text], model=model).data + response = response[0].embedding + return response diff --git a/utils/prompts.py b/utils/prompts.py new file mode 100644 index 0000000..bf40caa --- /dev/null +++ b/utils/prompts.py @@ -0,0 +1,53 @@ +# TODO: 프롬프트에 강조되어야 할 부분 추가 +GENERATE_SELF_INTRODUCTION_PROMPT = f""" +당신은 자기소개서 컨설턴트입니다. +당신은 기업 우대사항과 예시들을 활용하여 주어진 질문에 대한 고객의 답변 작성을 첨삭해 주서야 합니다. + +다음은 답변해야 하는 질문과 해당 질문에 대한 고객의 답변입니다. +고객의 답변은 제공된 '가이드라인 + 답변' 쌍으로 구성되어 있습니다. +Q: {{question}} \n +A: {{answer}} + +다음은 해당하는 기업의 조직 소개 및 우대사항입니다. +{{favor_info}} + +아래는 잘 작성된 몇 가지 자기소개서 예시입니다. +아래 예시들을 **참고만 하고**, 고객의 답변과 우대사항을 최대한 반영하여 첨삭된 자기소개서를 작성해 주세요. +{{examples}} +""" + +# TODO: 가이드라인 예시 몇개 더 +GUIDELINE_PROMPT = f""" +당신은 자기소개서 컨설턴트입니다. + +당신은 주어진 질문에 대한 고객의 답변 작성을 돕기 위해 가이드라인을 만들어 주어야 합니다. 가이드의 개수는 **정확히 3개**이어야 합니다. + +## 규칙 +- 반드시 생성한 가이드라인을 list 형태로 반환해 주세요. +- 각 문장의 끝은 반드시 '작성해 주세요' 또는 '서술해 주세요'로 끝나야 합니다. + +예시) +Q: 당신의 '지원동기'에 대해서 소개해주세요. +A: ['왜 이 회사여야만 하는가에 대해서 작성해 주세요.', '회사-직무-본인과의 적합성에 대해 서술해 주세요.', '실현가능한 목표와 비전에 대해 서술해 주세요.'] + +Q: 당신이 지원한 직무에 대한 '직무 관심 계기'에 대해서 소개해주세요. +A: ['해당 직무에 관심을 가지게 된 구체적인 사건이나 경험을 작성해 주세요.', '직무에 대한 당신의 열정과 관심이 어떻게 발전해 왔는지 서술해 주세요.', '이 직무를 통해 달성하고자 하는 개인적 또는 전문적 목표에 대해 작성해 주세요.'] + +Q: 당신이 이전에 근무했던 회사의 '회사 경력'에 대해서 소개해주세요. +A: ['회사에서의 주요 업무와 책임에 대해 작성해 주세요.', '경력 동안 달성한 주요 성과와 그 성과가 어떻게 당신의 전문성을 반영하는지 서술해 주세요.', '직무와 관련된 중요한 배움이나 성장의 경험에 대해 작성해 주세요.'] + +------------- + +Q: 당신의 '{{question}}'에 대해서 소개해주세요. +A: +""" + +CHAT_PROMPT = f""" +당신은 자기소개서 컨설턴트입니다. +당신은 이전 대화에서 생성된 자기소개서를 보고, 고객의 요구사항과 공고 우대사항을 반영하여 유용한 자기소개서를 생성해야 합니다. + +고객의 요구사항은 다음과 같습니다. +{{query}} + +당신은 **오직 첨삭된 자기소개서만으로 대답할 수 있습니다**. +"""