From 8b869651ee0ae14873805490920d908932af366b Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Mon, 1 Apr 2024 20:54:36 +0900 Subject: [PATCH 01/41] =?UTF-8?q?feat:=20memo=20app=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- memo/__init__.py | 0 memo/admin.py | 3 +++ memo/apps.py | 6 ++++++ memo/migrations/__init__.py | 0 memo/models.py | 3 +++ memo/tests.py | 3 +++ memo/views.py | 3 +++ 7 files changed, 18 insertions(+) create mode 100644 memo/__init__.py create mode 100644 memo/admin.py create mode 100644 memo/apps.py create mode 100644 memo/migrations/__init__.py create mode 100644 memo/models.py create mode 100644 memo/tests.py create mode 100644 memo/views.py diff --git a/memo/__init__.py b/memo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/memo/admin.py b/memo/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/memo/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/memo/apps.py b/memo/apps.py new file mode 100644 index 0000000..abd08f6 --- /dev/null +++ b/memo/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class MemoConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "memo" diff --git a/memo/migrations/__init__.py b/memo/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/memo/models.py b/memo/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/memo/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/memo/tests.py b/memo/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/memo/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/memo/views.py b/memo/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/memo/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. From 18271e3b946bdd9be55767fb3cffa16ec3646201 Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Mon, 1 Apr 2024 21:02:54 +0900 Subject: [PATCH 02/41] =?UTF-8?q?feat:=20memo=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- memo/models.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/memo/models.py b/memo/models.py index 71a8362..e253847 100644 --- a/memo/models.py +++ b/memo/models.py @@ -1,3 +1,14 @@ from django.db import models +from django.conf import settings +from django.utils import timezone -# Create your models here. + +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 From c330c17b0e2ae5f63a65c5bb47dfef5642bbad17 Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Tue, 2 Apr 2024 00:46:24 +0900 Subject: [PATCH 03/41] =?UTF-8?q?fix:=20app=20=EC=9D=B4=EB=A6=84=EC=9D=84?= =?UTF-8?q?=20memos=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20urls=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- memo/views.py | 3 --- {memo => memos}/__init__.py | 0 {memo => memos}/admin.py | 0 {memo => memos}/apps.py | 2 +- {memo => memos}/migrations/__init__.py | 0 {memo => memos}/models.py | 0 {memo => memos}/tests.py | 0 memos/views.py | 10 ++++++++++ resumai/settings/base.py | 1 + resumai/urls.py | 1 + 10 files changed, 13 insertions(+), 4 deletions(-) delete mode 100644 memo/views.py rename {memo => memos}/__init__.py (100%) rename {memo => memos}/admin.py (100%) rename {memo => memos}/apps.py (86%) rename {memo => memos}/migrations/__init__.py (100%) rename {memo => memos}/models.py (100%) rename {memo => memos}/tests.py (100%) create mode 100644 memos/views.py diff --git a/memo/views.py b/memo/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/memo/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/memo/__init__.py b/memos/__init__.py similarity index 100% rename from memo/__init__.py rename to memos/__init__.py diff --git a/memo/admin.py b/memos/admin.py similarity index 100% rename from memo/admin.py rename to memos/admin.py diff --git a/memo/apps.py b/memos/apps.py similarity index 86% rename from memo/apps.py rename to memos/apps.py index abd08f6..96d5d04 100644 --- a/memo/apps.py +++ b/memos/apps.py @@ -3,4 +3,4 @@ class MemoConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" - name = "memo" + name = "memos" diff --git a/memo/migrations/__init__.py b/memos/migrations/__init__.py similarity index 100% rename from memo/migrations/__init__.py rename to memos/migrations/__init__.py diff --git a/memo/models.py b/memos/models.py similarity index 100% rename from memo/models.py rename to memos/models.py diff --git a/memo/tests.py b/memos/tests.py similarity index 100% rename from memo/tests.py rename to memos/tests.py diff --git a/memos/views.py b/memos/views.py new file mode 100644 index 0000000..b5142ba --- /dev/null +++ b/memos/views.py @@ -0,0 +1,10 @@ +from django.shortcuts import render + +# Create your views here. +from django.urls import path +from memos import views + +# TODO swagger에 뜨는 api 관리 +urlpatterns = [ + path("kakao/login/", views.kakao_login, name="kakao_login"), +] diff --git a/resumai/settings/base.py b/resumai/settings/base.py index 12d91b4..faf78b2 100644 --- a/resumai/settings/base.py +++ b/resumai/settings/base.py @@ -35,6 +35,7 @@ "django.contrib.staticfiles", "resumai", "accounts", + "memos", # django-rest-auth "rest_framework", "rest_framework_simplejwt", diff --git a/resumai/urls.py b/resumai/urls.py index c9ae0f3..7e1acbe 100644 --- a/resumai/urls.py +++ b/resumai/urls.py @@ -40,6 +40,7 @@ def preprocessing_filter_spec(endpoints): # path('accounts/', include('allauth.urls')), path("accounts/", include("accounts.urls")), path("registration/", include("dj_rest_auth.registration.urls")), + path("memos/", include("views.urls")), # swagger 관련 path("api/schema/", SpectacularAPIView.as_view(), name="schema"), path( From c7b2d5f9672651ce4e7fb81072f7a8d0a987090b Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Tue, 2 Apr 2024 02:00:17 +0900 Subject: [PATCH 04/41] =?UTF-8?q?feat:=20memo=20=EC=9E=91=EC=84=B1=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- memos/serializers.py | 9 +++++++++ memos/urls.py | 7 +++++++ memos/views.py | 48 +++++++++++++++++++++++++++++++++++++------- resumai/urls.py | 2 +- 4 files changed, 58 insertions(+), 8 deletions(-) create mode 100644 memos/serializers.py create mode 100644 memos/urls.py diff --git a/memos/serializers.py b/memos/serializers.py new file mode 100644 index 0000000..62fcae8 --- /dev/null +++ b/memos/serializers.py @@ -0,0 +1,9 @@ +from rest_framework import serializers +from .models import Memo + + +class PostMemoSerializer(serializers.ModelSerializer): + class Meta: + model = Memo + fields = ("id", "title", "content", "created_at", "updated_at") + read_only_fields = ("id", "created_at", "updated_at") \ No newline at end of file diff --git a/memos/urls.py b/memos/urls.py new file mode 100644 index 0000000..5532393 --- /dev/null +++ b/memos/urls.py @@ -0,0 +1,7 @@ +from django.urls import path +from memos import views + + +urlpatterns = [ + path("", views.PostMemoView.as_view(), name="post_memo"), +] diff --git a/memos/views.py b/memos/views.py index b5142ba..81f82b0 100644 --- a/memos/views.py +++ b/memos/views.py @@ -1,10 +1,44 @@ from django.shortcuts import render +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response +from rest_framework import status +from rest_framework.views import APIView -# Create your views here. -from django.urls import path -from memos import views +from drf_spectacular.utils import ( + extend_schema, + OpenApiResponse, + OpenApiExample, + OpenApiParameter, +) -# TODO swagger에 뜨는 api 관리 -urlpatterns = [ - path("kakao/login/", views.kakao_login, name="kakao_login"), -] +from .serializers import PostMemoSerializer + +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) \ No newline at end of file diff --git a/resumai/urls.py b/resumai/urls.py index 7e1acbe..488c46b 100644 --- a/resumai/urls.py +++ b/resumai/urls.py @@ -40,7 +40,7 @@ def preprocessing_filter_spec(endpoints): # path('accounts/', include('allauth.urls')), path("accounts/", include("accounts.urls")), path("registration/", include("dj_rest_auth.registration.urls")), - path("memos/", include("views.urls")), + path("memos/", include("memos.urls")), # swagger 관련 path("api/schema/", SpectacularAPIView.as_view(), name="schema"), path( From 1bd05edd51fcb281bda9ff5a0c084d42bed1c650 Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Tue, 2 Apr 2024 02:21:07 +0900 Subject: [PATCH 05/41] =?UTF-8?q?fix:=20api=20schema=20detail=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- accounts/views.py | 14 ++++++-------- requirements.txt | 1 + resumai/settings/base.py | 4 ++++ resumai/urls.py | 6 ++---- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/accounts/views.py b/accounts/views.py index 64a9bb3..774ce35 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -48,13 +48,12 @@ CLIENT_SECRET = env("KAKAO_CLIENT_SECRET_KEY") -@extend_schema( - summary="카카오 로그인", - description="카카오 로그인 페이지로 리다이렉트하여, 정보를 입력하면 카카오 **access_token, code**를 반환합니다.", - responses={200: KakaoTokenSerializer}, -) -@api_view(["GET"]) -@permission_classes([AllowAny]) +# @extend_schema( +# summary="카카오 로그인", +# description="카카오 로그인 페이지로 리다이렉트하여, 정보를 입력하면 카카오 **access_token, code**를 반환합니다.", +# responses={200: KakaoTokenSerializer}, +# ) +@extend_schema(exclude=True) def kakao_login(request): return redirect( f"https://kauth.kakao.com/oauth/authorize?client_id={REST_API_KEY}&redirect_uri={KAKAO_CALLBACK_URI}&response_type=code" @@ -144,7 +143,6 @@ def kakao_callback(request): # return JsonResponse(accept_json) -# @extend_schema(exclude=True) @extend_schema( summary="카카오 로그인 마무리", description="access token, code를 post 요청으로 보내면 access token, 유저 정보를 반환합니다. **(id_token은 불필요합니다.)**", diff --git a/requirements.txt b/requirements.txt index eab1e9c..6c34b21 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,7 @@ defusedxml==0.7.1 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 diff --git a/resumai/settings/base.py b/resumai/settings/base.py index faf78b2..8400958 100644 --- a/resumai/settings/base.py +++ b/resumai/settings/base.py @@ -33,6 +33,7 @@ "django.contrib.messages", "django.contrib.sites", "django.contrib.staticfiles", + "corsheaders", "resumai", "accounts", "memos", @@ -52,6 +53,7 @@ MIDDLEWARE = [ "resumai.middleware.HealthCheckMiddleware", + "corsheaders.middleware.CorsMiddleware", "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", @@ -154,6 +156,8 @@ }, ] +CORS_ALLOW_ALL_ORIGINS = True + # Internationalization # https://docs.djangoproject.com/en/5.0/topics/i18n/ diff --git a/resumai/urls.py b/resumai/urls.py index 488c46b..d4dddfc 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,8 +36,6 @@ 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("registration/", include("dj_rest_auth.registration.urls")), path("memos/", include("memos.urls")), From db462b1a759352ce644d2d23d58d2da46ffacec7 Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Tue, 2 Apr 2024 02:26:09 +0900 Subject: [PATCH 06/41] =?UTF-8?q?fix:=20memo=20model=20migrate=20=ED=9B=84?= =?UTF-8?q?=20docker-compose,=20sh=20=ED=8C=8C=EC=9D=BC=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=EC=9C=BC=EB=A1=9C=20migration=20=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/docker/entrypoint.prod.sh | 4 ++- docker-compose.yml | 2 +- memos/migrations/0001_initial.py | 42 ++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 memos/migrations/0001_initial.py 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/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, + ), + ), + ], + ), + ] From 07a7b9ce9ee400a3739c27a1e992ff00bb154123 Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Tue, 2 Apr 2024 02:57:14 +0900 Subject: [PATCH 07/41] =?UTF-8?q?feat:=20memo=20models=EC=97=90=20is=5Fscr?= =?UTF-8?q?apped,=20is=5Ffinished=20=EC=84=A4=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4=EC=9D=B4=EC=85=98=EC=9D=84?= =?UTF-8?q?=20=ED=99=9C=EC=9A=A9=ED=95=9C=20=EC=A0=84=EC=B2=B4=20=EB=A9=94?= =?UTF-8?q?=EB=AA=A8=20=EA=B0=80=EC=A0=B8=EC=98=A4=EA=B8=B0=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../0002_memo_is_finished_memo_is_scrapped.py | 25 +++++++++++++++++ memos/models.py | 4 ++- memos/serializers.py | 7 ++++- memos/urls.py | 1 + memos/views.py | 28 +++++++++++++++++-- resumai/settings/base.py | 2 ++ 6 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 memos/migrations/0002_memo_is_finished_memo_is_scrapped.py 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/models.py b/memos/models.py index e253847..f9c4590 100644 --- a/memos/models.py +++ b/memos/models.py @@ -9,6 +9,8 @@ class Memo(models.Model): title = models.CharField(max_length=255) content = models.TextField() updated_at = models.DateTimeField(auto_now=True) - + is_scrapped = models.BooleanField() + is_finished = models.BooleanField() + # TODO: 메모 스크랩 가능해야 하고, 작성중인지 여부도 필요 def __str__(self): return self.title diff --git a/memos/serializers.py b/memos/serializers.py index 62fcae8..00225fd 100644 --- a/memos/serializers.py +++ b/memos/serializers.py @@ -2,8 +2,13 @@ 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") + fields = ("id", "title", "content", "created_at", "updated_at", "is_scrapped", "is_finished") read_only_fields = ("id", "created_at", "updated_at") \ No newline at end of file diff --git a/memos/urls.py b/memos/urls.py index 5532393..10f146b 100644 --- a/memos/urls.py +++ b/memos/urls.py @@ -4,4 +4,5 @@ urlpatterns = [ path("", views.PostMemoView.as_view(), name="post_memo"), + path("all", views.GetAllMemoView.as_view(), name="get_all_memos") ] diff --git a/memos/views.py b/memos/views.py index 81f82b0..64f03e2 100644 --- a/memos/views.py +++ b/memos/views.py @@ -4,6 +4,8 @@ from rest_framework.response import Response from rest_framework import status from rest_framework.views import APIView +from rest_framework.pagination import PageNumberPagination + from drf_spectacular.utils import ( extend_schema, @@ -12,7 +14,9 @@ OpenApiParameter, ) -from .serializers import PostMemoSerializer +from .models import Memo +from .serializers import PostMemoSerializer, MemoSerializer + class PostMemoView(APIView): permission_classes = [IsAuthenticated] @@ -41,4 +45,24 @@ def post(self, request): return Response(serializer.data, status=status.HTTP_201_CREATED) else: # 데이터가 유효하지 않은 경우, 에러 메시지 반환 - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) \ No newline at end of file + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + + + +class GetAllMemoView(APIView, PageNumberPagination): + permission_classes = [IsAuthenticated] + + 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) \ No newline at end of file diff --git a/resumai/settings/base.py b/resumai/settings/base.py index 8400958..4ed8980 100644 --- a/resumai/settings/base.py +++ b/resumai/settings/base.py @@ -80,6 +80,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 = { From b8ec11107c8fef167fbb8ced096e475eee946a5f Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Tue, 2 Apr 2024 03:04:20 +0900 Subject: [PATCH 08/41] =?UTF-8?q?feat:=20=ED=8A=B9=EC=A0=95=20=EB=A9=94?= =?UTF-8?q?=EB=AA=A8=EC=9D=98=20detail=20=EA=B0=80=EC=A0=B8=EC=98=A4?= =?UTF-8?q?=EB=8A=94=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- memos/urls.py | 3 ++- memos/views.py | 24 +++++++++++++++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/memos/urls.py b/memos/urls.py index 10f146b..b77b2d2 100644 --- a/memos/urls.py +++ b/memos/urls.py @@ -4,5 +4,6 @@ urlpatterns = [ path("", views.PostMemoView.as_view(), name="post_memo"), - path("all", views.GetAllMemoView.as_view(), name="get_all_memos") + path("all", views.GetAllMemoView.as_view(), name="get_all_memos"), + path('memo//', views.GetMemoDetailView.as_view(), name='memo-detail') ] diff --git a/memos/views.py b/memos/views.py index 64f03e2..43838fc 100644 --- a/memos/views.py +++ b/memos/views.py @@ -1,5 +1,6 @@ from django.shortcuts import render from rest_framework.decorators import api_view, permission_classes +from django.http import Http404 from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework import status @@ -48,8 +49,6 @@ def post(self, request): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - class GetAllMemoView(APIView, PageNumberPagination): permission_classes = [IsAuthenticated] @@ -65,4 +64,23 @@ def get(self, request): # Pagination이 적용되지 않은 경우(선택적) serializer = MemoSerializer(memos, many=True) - return Response(serializer.data) \ No newline at end of file + 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 + + 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) \ No newline at end of file From 649f8919c77c289e679e55f21d032d8df792c160 Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Tue, 2 Apr 2024 03:13:08 +0900 Subject: [PATCH 09/41] feat: memo delete api --- memos/urls.py | 4 +++- memos/views.py | 41 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/memos/urls.py b/memos/urls.py index b77b2d2..8dfe08a 100644 --- a/memos/urls.py +++ b/memos/urls.py @@ -5,5 +5,7 @@ urlpatterns = [ path("", views.PostMemoView.as_view(), name="post_memo"), path("all", views.GetAllMemoView.as_view(), name="get_all_memos"), - path('memo//', views.GetMemoDetailView.as_view(), name='memo-detail') + path('memo//', views.GetMemoDetailView.as_view(), name='memo-detail'), + # path('scrap//', views.ScrapMemoView.as_view(), name='scrap-memo'), + path('delete//', views.DeleteMemoView.as_view(), name='delete-memo'), ] diff --git a/memos/views.py b/memos/views.py index 43838fc..de8f56e 100644 --- a/memos/views.py +++ b/memos/views.py @@ -83,4 +83,43 @@ def get_object(self, pk, user): 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) \ No newline at end of file + return Response(serializer.data, status=status.HTTP_200_OK) + + +# class ScrapMemoView(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 +# +# def get(self, request, pk, format=None): +# memo = self.get_object(pk, request.user) +# # is_scrapped 값 토글 +# memo.is_scrapped = not memo.is_scrapped +# memo.save(update_fields=['is_scrapped']) +# return Response({'status': 'success', 'is_scrapped': memo.is_scrapped}, 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("You do not have permission to delete this memo.") + return memo + except Memo.DoesNotExist: + raise Http404 + + def delete(self, request, pk, format=None): + memo = self.get_object(pk, request.user) + memo.delete() + return Response({'status': 'success', 'message': 'Memo deleted successfully.'}, status=status.HTTP_204_NO_CONTENT) \ No newline at end of file From 34636184b5756349e5e0447fda1e08b0037b33ad Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Tue, 2 Apr 2024 13:46:59 +0900 Subject: [PATCH 10/41] =?UTF-8?q?feat:=20memo=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20swagger?= =?UTF-8?q?=20detail=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- memos/urls.py | 1 + memos/views.py | 97 ++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 91 insertions(+), 7 deletions(-) diff --git a/memos/urls.py b/memos/urls.py index 8dfe08a..2c11721 100644 --- a/memos/urls.py +++ b/memos/urls.py @@ -8,4 +8,5 @@ path('memo//', views.GetMemoDetailView.as_view(), name='memo-detail'), # path('scrap//', views.ScrapMemoView.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 index de8f56e..9f1d340 100644 --- a/memos/views.py +++ b/memos/views.py @@ -1,17 +1,16 @@ -from django.shortcuts import render -from rest_framework.decorators import api_view, permission_classes from django.http import Http404 +from django.db.models import Q from rest_framework.permissions import AllowAny, 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, - OpenApiResponse, - OpenApiExample, + inline_serializer, OpenApiParameter, ) @@ -52,6 +51,21 @@ def post(self, 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) @@ -80,6 +94,14 @@ def get_object(self, pk, user): 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) @@ -114,7 +136,7 @@ def get_object(self, pk, user): memo = Memo.objects.get(pk=pk) # 메모를 작성한 유저와 현재 요청 유저가 동일한지 확인 if memo.user != user: - raise Http404("You do not have permission to delete this memo.") + raise Http404("해당 메모를 제거할 자격이 없습니다.") return memo except Memo.DoesNotExist: raise Http404 @@ -122,4 +144,65 @@ def get_object(self, pk, user): def delete(self, request, pk, format=None): memo = self.get_object(pk, request.user) memo.delete() - return Response({'status': 'success', 'message': 'Memo deleted successfully.'}, status=status.HTTP_204_NO_CONTENT) \ No newline at end of file + 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 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) + From 9e23d3b17751a435504f19cdb99334d7ee1705b0 Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Tue, 2 Apr 2024 13:49:27 +0900 Subject: [PATCH 11/41] =?UTF-8?q?fix:=20memo=20model=EC=97=90=EC=84=9C=20i?= =?UTF-8?q?s=5Fscrapped,=20is=5Ffinished=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...emo_is_finished_remove_memo_is_scrapped.py | 21 +++++++++++++++++++ memos/models.py | 4 +--- memos/serializers.py | 2 +- 3 files changed, 23 insertions(+), 4 deletions(-) create mode 100644 memos/migrations/0003_remove_memo_is_finished_remove_memo_is_scrapped.py 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/models.py b/memos/models.py index f9c4590..e253847 100644 --- a/memos/models.py +++ b/memos/models.py @@ -9,8 +9,6 @@ class Memo(models.Model): title = models.CharField(max_length=255) content = models.TextField() updated_at = models.DateTimeField(auto_now=True) - is_scrapped = models.BooleanField() - is_finished = models.BooleanField() - # TODO: 메모 스크랩 가능해야 하고, 작성중인지 여부도 필요 + def __str__(self): return self.title diff --git a/memos/serializers.py b/memos/serializers.py index 00225fd..51f6c62 100644 --- a/memos/serializers.py +++ b/memos/serializers.py @@ -10,5 +10,5 @@ class Meta: class PostMemoSerializer(serializers.ModelSerializer): class Meta: model = Memo - fields = ("id", "title", "content", "created_at", "updated_at", "is_scrapped", "is_finished") + fields = ("id", "title", "content", "created_at", "updated_at") read_only_fields = ("id", "created_at", "updated_at") \ No newline at end of file From 0ffa40486cf591bb67d015b1b9b6a26ab244f350 Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Tue, 2 Apr 2024 13:54:04 +0900 Subject: [PATCH 12/41] fix: delete api swagger update --- memos/views.py | 31 +++++++++---------------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/memos/views.py b/memos/views.py index 9f1d340..d378269 100644 --- a/memos/views.py +++ b/memos/views.py @@ -1,6 +1,6 @@ from django.http import Http404 from django.db.models import Q -from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework import status from rest_framework.views import APIView @@ -107,27 +107,6 @@ def get(self, request, pk, format=None): serializer = MemoSerializer(memo) return Response(serializer.data, status=status.HTTP_200_OK) - -# class ScrapMemoView(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 -# -# def get(self, request, pk, format=None): -# memo = self.get_object(pk, request.user) -# # is_scrapped 값 토글 -# memo.is_scrapped = not memo.is_scrapped -# memo.save(update_fields=['is_scrapped']) -# return Response({'status': 'success', 'is_scrapped': memo.is_scrapped}, status=status.HTTP_200_OK) - class DeleteMemoView(APIView): permission_classes = [IsAuthenticated] @@ -141,6 +120,14 @@ def get_object(self, pk, user): except Memo.DoesNotExist: raise Http404 + @extend_schema( + summary="메모 삭제", + description="특정 메모를 삭제합니다. 메모를 작성한 사용자만 해당 메모를 삭제할 수 있습니다.", + responses={ + 204: None, # 성공적으로 삭제되었을 때, 특별한 응답 본문은 없음 + 404: {'description': '해당 메모를 찾을 수 없거나 삭제할 권한이 없습니다.'} + }, + ) def delete(self, request, pk, format=None): memo = self.get_object(pk, request.user) memo.delete() From 886dbc3174c6f824a3a77511a8e77e8621bb0b9e Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Tue, 2 Apr 2024 13:54:29 +0900 Subject: [PATCH 13/41] =?UTF-8?q?reformat:=20black=EC=9D=84=20=ED=99=9C?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EC=97=AC=20code=20reformat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- memos/serializers.py | 5 ++-- memos/urls.py | 6 ++-- memos/views.py | 62 ++++++++++++++++++++-------------------- resumai/settings/base.py | 4 +-- 4 files changed, 39 insertions(+), 38 deletions(-) diff --git a/memos/serializers.py b/memos/serializers.py index 51f6c62..fddc6f9 100644 --- a/memos/serializers.py +++ b/memos/serializers.py @@ -5,10 +5,11 @@ class MemoSerializer(serializers.ModelSerializer): class Meta: model = Memo - fields = '__all__' # 모든 필드를 포함 + 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") \ No newline at end of file + read_only_fields = ("id", "created_at", "updated_at") diff --git a/memos/urls.py b/memos/urls.py index 2c11721..aab65f3 100644 --- a/memos/urls.py +++ b/memos/urls.py @@ -5,8 +5,8 @@ urlpatterns = [ path("", views.PostMemoView.as_view(), name="post_memo"), path("all", views.GetAllMemoView.as_view(), name="get_all_memos"), - path('memo//', views.GetMemoDetailView.as_view(), name='memo-detail'), + path("memo//", views.GetMemoDetailView.as_view(), name="memo-detail"), # path('scrap//', views.ScrapMemoView.as_view(), name='scrap-memo'), - path('delete//', views.DeleteMemoView.as_view(), name='delete-memo'), - path('search/', views.SearchMemoView.as_view(), name='search-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 index d378269..13f77a5 100644 --- a/memos/views.py +++ b/memos/views.py @@ -41,7 +41,9 @@ def post(self, request): # 데이터 유효성 검사 if serializer.is_valid(): # 유효한 데이터의 경우, 메모 저장 - serializer.save(user=request.user) # 현재 로그인한 사용자를 메모의 user 필드에 저장 + serializer.save( + user=request.user + ) # 현재 로그인한 사용자를 메모의 user 필드에 저장 return Response(serializer.data, status=status.HTTP_201_CREATED) else: # 데이터가 유효하지 않은 경우, 에러 메시지 반환 @@ -56,15 +58,15 @@ class GetAllMemoView(APIView, PageNumberPagination): description="사용자가 작성한 전체 메모를 받아옵니다.", responses={ 200: inline_serializer( - name='GetAllMemoResponse', + name="GetAllMemoResponse", fields={ - 'count': serializers.IntegerField(), - 'next': serializers.URLField(), - 'previous': serializers.URLField(), - 'results': MemoSerializer(many=True) - } + "count": serializers.IntegerField(), + "next": serializers.URLField(), + "previous": serializers.URLField(), + "results": MemoSerializer(many=True), + }, ) - } + }, ) def get(self, request): # 현재 인증된 유저에게 속한 메모들을 조회 @@ -94,19 +96,17 @@ def get_object(self, pk, user): except Memo.DoesNotExist: raise Http404 - @extend_schema( summary="특정 메모를 받아옵니다.", description="사용자가 작성한 특정 메모의 디테일 받아옵니다.", - responses={ - 200: MemoSerializer - } + 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] @@ -125,22 +125,27 @@ def get_object(self, pk, user): description="특정 메모를 삭제합니다. 메모를 작성한 사용자만 해당 메모를 삭제할 수 있습니다.", responses={ 204: None, # 성공적으로 삭제되었을 때, 특별한 응답 본문은 없음 - 404: {'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) + return JsonResponse( + {"status": "success", "message": "Memo deleted successfully."}, + status=status.HTTP_204_NO_CONTENT, + ) + class CustomPagination(PageNumberPagination): # 클라이언트로부터 'size' 파라미터를 받아 페이지 크기를 결정 - page_size_query_param = 'size' + page_size_query_param = "size" # 'size' 파라미터가 제공되지 않은 경우 기본 페이지 크기 def get_page_size(self, request): return super().get_page_size(request) + class SearchMemoView(APIView, CustomPagination): permission_classes = [IsAuthenticated] @@ -149,13 +154,13 @@ class SearchMemoView(APIView, CustomPagination): description="키워드를 기반으로 메모를 검색합니다.", responses={ 200: inline_serializer( - name='SearchResponse', + name="SearchResponse", fields={ - 'count': serializers.IntegerField(), - 'next': serializers.URLField(), - 'previous': serializers.URLField(), - 'results': PostMemoSerializer(many=True) - } + "count": serializers.IntegerField(), + "next": serializers.URLField(), + "previous": serializers.URLField(), + "results": PostMemoSerializer(many=True), + }, ) }, parameters=[ @@ -165,23 +170,19 @@ class SearchMemoView(APIView, CustomPagination): description="검색할 키워드입니다.", ), OpenApiParameter( - name="page", - type=int, - description="한번에 요청할 page 사이즈 입니다." + name="page", type=int, description="한번에 요청할 page 사이즈 입니다." ), OpenApiParameter( - name="size", - type=int, - description="한 화면에 표시할 메모의 개수입니다." + name="size", type=int, description="한 화면에 표시할 메모의 개수입니다." ), ], ) def get(self, request): - keyword = request.query_params.get('keyword', '') + keyword = request.query_params.get("keyword", "") query_set = Memo.objects.filter( - Q(user=request.user) & - (Q(title__icontains=keyword) | Q(content__icontains=keyword)) + Q(user=request.user) + & (Q(title__icontains=keyword) | Q(content__icontains=keyword)) ) page = self.paginate_queryset(query_set, request, view=self) @@ -192,4 +193,3 @@ def get(self, request): # Pagination이 적용되지 않는 경우의 대비책(예: size 파라미터 누락 등) serializer = MemoSerializer(query_set, many=True) return Response(serializer.data) - diff --git a/resumai/settings/base.py b/resumai/settings/base.py index 4ed8980..61fa000 100644 --- a/resumai/settings/base.py +++ b/resumai/settings/base.py @@ -80,8 +80,8 @@ "rest_framework_simplejwt.authentication.JWTAuthentication", ), "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", - 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', - 'PAGE_SIZE': 5, + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "PAGE_SIZE": 5, } REST_AUTH = { From 6c686ed13141aa3fcde829f71247e84576bd2a48 Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Tue, 2 Apr 2024 20:07:07 +0900 Subject: [PATCH 14/41] =?UTF-8?q?fix:=20client=EC=97=90=EC=84=9C=20code=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EB=B3=B4=EB=82=B4=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- accounts/urls.py | 4 +-- accounts/views.py | 65 ++++++------------------------------- resumai/templates/home.html | 12 +------ 3 files changed, 13 insertions(+), 68 deletions(-) diff --git a/accounts/urls.py b/accounts/urls.py index 7452ca5..9dd5c7e 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -3,8 +3,8 @@ # 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/", views.kakao_callback, name="kakao_callback"), path( "kakao/login/finish/", views.KakaoLoginView.as_view(), diff --git a/accounts/views.py b/accounts/views.py index 774ce35..ea5ec22 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -47,20 +47,23 @@ REST_API_KEY = env("KAKAO_REST_API_KEY") CLIENT_SECRET = env("KAKAO_CLIENT_SECRET_KEY") - -# @extend_schema( -# summary="카카오 로그인", -# description="카카오 로그인 페이지로 리다이렉트하여, 정보를 입력하면 카카오 **access_token, code**를 반환합니다.", -# responses={200: KakaoTokenSerializer}, -# ) @extend_schema(exclude=True) def kakao_login(request): return redirect( f"https://kauth.kakao.com/oauth/authorize?client_id={REST_API_KEY}&redirect_uri={KAKAO_CALLBACK_URI}&response_type=code" ) - +@extend_schema( + summary="카카오 로그인", + description="code를 GET 요청으로 보내면 access token, code를 반환합니다.", + parameters=[ + OpenApiParameter( + name="code", type=str, description="발급받은 카카오의 code 입니다." + ), + ], +) @permission_classes([AllowAny]) +@api_view(["GET"]) def kakao_callback(request): code = request.GET.get("code") @@ -94,54 +97,6 @@ def kakao_callback(request): # 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( summary="카카오 로그인 마무리", 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

From c086d1215d33c8d3ce5a9649f23406d36b46c5b6 Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Tue, 2 Apr 2024 20:29:19 +0900 Subject: [PATCH 15/41] =?UTF-8?q?feat:=20resume=20app=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EB=B0=8F=20=EB=AA=A8=EB=8D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resumai/settings/base.py | 1 + resumai/urls.py | 1 + resume/__init__.py | 0 resume/admin.py | 3 +++ resume/apps.py | 6 +++++ resume/migrations/0001_initial.py | 45 +++++++++++++++++++++++++++++++ resume/migrations/__init__.py | 0 resume/models.py | 16 +++++++++++ resume/tests.py | 3 +++ resume/urls.py | 7 +++++ resume/views.py | 3 +++ 11 files changed, 85 insertions(+) create mode 100644 resume/__init__.py create mode 100644 resume/admin.py create mode 100644 resume/apps.py create mode 100644 resume/migrations/0001_initial.py create mode 100644 resume/migrations/__init__.py create mode 100644 resume/models.py create mode 100644 resume/tests.py create mode 100644 resume/urls.py create mode 100644 resume/views.py diff --git a/resumai/settings/base.py b/resumai/settings/base.py index 61fa000..cdcd0c3 100644 --- a/resumai/settings/base.py +++ b/resumai/settings/base.py @@ -37,6 +37,7 @@ "resumai", "accounts", "memos", + "resume", # django-rest-auth "rest_framework", "rest_framework_simplejwt", diff --git a/resumai/urls.py b/resumai/urls.py index d4dddfc..ef1fff7 100644 --- a/resumai/urls.py +++ b/resumai/urls.py @@ -39,6 +39,7 @@ def preprocessing_filter_spec(endpoints): 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/__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..4c68598 --- /dev/null +++ b/resume/models.py @@ -0,0 +1,16 @@ +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) # 제목이 곧 지원하려는 기업명과 동일함 + 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) + + def __str__(self): + return self.title 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..428b473 --- /dev/null +++ b/resume/urls.py @@ -0,0 +1,7 @@ +from django.urls import path +from resume import views + + +urlpatterns = [ + # path("", views.PostMemoView.as_view(), name="post_memo"), +] diff --git a/resume/views.py b/resume/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/resume/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. From 9fa886b9310146db1a9a68c6a1c72fbdade851d3 Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Tue, 2 Apr 2024 20:57:25 +0900 Subject: [PATCH 16/41] =?UTF-8?q?feat:=20openai=20call=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20utils=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20guidline?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20api=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements.txt | 12 ++++++++++ resumai/settings/base.py | 3 --- resume/urls.py | 2 +- resume/views.py | 49 +++++++++++++++++++++++++++++++++++++++- utils/openai_call.py | 27 ++++++++++++++++++++++ utils/prompts.py | 42 ++++++++++++++++++++++++++++++++++ 6 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 utils/openai_call.py create mode 100644 utils/prompts.py diff --git a/requirements.txt b/requirements.txt index 6c34b21..4b9f53d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ +annotated-types==0.6.0 +anyio==4.3.0 asgiref==3.7.2 attrs==23.2.0 black==24.3.0 @@ -7,6 +9,7 @@ charset-normalizer==3.3.2 click==8.1.7 cryptography==42.0.5 defusedxml==0.7.1 +distro==1.9.0 dj-rest-auth==5.0.2 Django==5.0.3 django-allauth==0.61.1 @@ -17,7 +20,11 @@ djangorestframework==3.14.0 djangorestframework-simplejwt==5.3.1 drf-spectacular==0.27.1 drf-yasg==1.21.7 +exceptiongroup==1.2.0 gunicorn==21.2.0 +h11==0.14.0 +httpcore==1.0.5 +httpx==0.27.0 idna==3.6 inflection==0.5.1 jsonschema==4.21.1 @@ -25,10 +32,13 @@ jsonschema-specifications==2023.12.1 mypy-extensions==1.0.0 mysqlclient==2.2.4 oauthlib==3.2.2 +openai==1.16.0 packaging==23.2 pathspec==0.12.1 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 @@ -38,8 +48,10 @@ referencing==0.33.0 requests==2.31.0 requests-oauthlib==1.4.0 rpds-py==0.18.0 +sniffio==1.3.1 sqlparse==0.4.4 tomli==2.0.1 +tqdm==4.66.2 typing_extensions==4.10.0 uritemplate==4.1.1 urllib3==2.2.1 diff --git a/resumai/settings/base.py b/resumai/settings/base.py index cdcd0c3..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 diff --git a/resume/urls.py b/resume/urls.py index 428b473..4acee28 100644 --- a/resume/urls.py +++ b/resume/urls.py @@ -3,5 +3,5 @@ urlpatterns = [ - # path("", views.PostMemoView.as_view(), name="post_memo"), + path("guidelines/", views.GetGuidelinesView.as_view(), name="get_guidelines"), ] diff --git a/resume/views.py b/resume/views.py index 91ea44a..c39d194 100644 --- a/resume/views.py +++ b/resume/views.py @@ -1,3 +1,50 @@ -from django.shortcuts import render +import json + +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 utils.openai_call import get_chat_openai +from utils.prompts import GUIDELINE_PROMPT + # Create your views here. +class GetGuidelinesView(APIView): + @extend_schema( + summary="메모 검색", + description="키워드를 기반으로 메모를 검색합니다.", + responses={ + 200: ["가이드라인1", "가이드라인2"] + }, + 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_json = json.loads(guideline_string.replace("'", '"')) + print(guideline_json) + return JsonResponse(guideline_json, safe=False) + except Exception as e: + print(e) + error_message = {'error': '가이드라인 생성 중 오류가 발생했습니다.'} + return JsonResponse(error_message, status=500) diff --git a/utils/openai_call.py b/utils/openai_call.py new file mode 100644 index 0000000..a6b599b --- /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..ed2cc2c --- /dev/null +++ b/utils/prompts.py @@ -0,0 +1,42 @@ +# TODO: 프롬프트에 강조되어야 할 부분 추가 +GENERATE_SELF_INTRODUCTION_PROMPT = f""" +당신은 자기소개서 컨설턴트입니다. +당신은 기업 우대사항과 예시들을 활용하여 주어진 질문에 대한 고객의 답변 작성을 첨삭해 주서야 합니다. + +다음은 답변해야 하는 질문과 해당 질문에 대한 고객의 답변입니다. +Q: {{question}} \n +A: {{context}} + +다음은 해당하는 기업의 조직 소개 및 우대사항입니다. +{{favor_info}} + +아래는 잘 작성된 몇 가지 자기소개서 예시입니다. +아래 예시들을 **참고만 하고**, 고객의 답변과 우대사항을 최대한 반영하여 첨삭된 자기소개서를 작성해 주세요. +{{examples}} +""" + +# TODO: 가이드라인 예시 몇개 더 +GUIDELINE_PROMPT = f""" +당신은 자기소개서 컨설턴트입니다. + +당신은 주어진 질문에 대한 고객의 답변 작성을 돕기 위해 가이드라인을 만들어 주어야 합니다. 가이드의 개수는 **정확히 3개**이어야 합니다. + +## 규칙 +- 반드시 생성한 가이드라인을 list 형태로 반환해 주세요. +- 각 문장의 끝은 반드시 '작성해 주세요' 또는 '서술해 주세요'로 끝나야 합니다. + +예시) +Q: 당신의 '지원동기'에 대해서 소개해주세요. +A: ['왜 이 회사여야만 하는가에 대해서 작성해 주세요.', '회사-직무-본인과의 적합성에 대해 서술해 주세요.', '실현가능한 목표와 비전에 대해 서술해 주세요.'] + +Q: 당신이 지원한 직무에 대한 '직무 관심 계기'에 대해서 소개해주세요. +A: ['해당 직무에 관심을 가지게 된 구체적인 사건이나 경험을 작성해 주세요.', '직무에 대한 당신의 열정과 관심이 어떻게 발전해 왔는지 서술해 주세요.', '이 직무를 통해 달성하고자 하는 개인적 또는 전문적 목표에 대해 작성해 주세요.'] + +Q: 당신이 이전에 근무했던 회사의 '회사 경력'에 대해서 소개해주세요. +A: ['회사에서의 주요 업무와 책임에 대해 작성해 주세요.', '경력 동안 달성한 주요 성과와 그 성과가 어떻게 당신의 전문성을 반영하는지 서술해 주세요.', '직무와 관련된 중요한 배움이나 성장의 경험에 대해 작성해 주세요.'] + +------------- + +Q: 당신의 '{{question}}'에 대해서 소개해주세요. +A: +""" From d8a990a0e58d89ff03662aac841b5bd773ce4980 Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Thu, 4 Apr 2024 15:33:29 +0900 Subject: [PATCH 17/41] =?UTF-8?q?feat:=20=EC=9E=90=EA=B8=B0=EC=86=8C?= =?UTF-8?q?=EA=B0=9C=EC=84=9C=20=EC=83=9D=EC=84=B1=20api=20=EC=9E=91?= =?UTF-8?q?=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements.txt | 1 + resume/serializers.py | 8 +++ resume/urls.py | 1 + resume/utils.py | 24 +++++++++ resume/views.py | 115 ++++++++++++++++++++++++++++++++++++++---- utils/prompts.py | 5 +- 6 files changed, 143 insertions(+), 11 deletions(-) create mode 100644 resume/serializers.py create mode 100644 resume/utils.py diff --git a/requirements.txt b/requirements.txt index 4b9f53d..62d6779 100644 --- a/requirements.txt +++ b/requirements.txt @@ -35,6 +35,7 @@ oauthlib==3.2.2 openai==1.16.0 packaging==23.2 pathspec==0.12.1 +pinecone-client==3.2.2 platformdirs==4.2.0 pycparser==2.21 pydantic==2.6.4 diff --git a/resume/serializers.py b/resume/serializers.py new file mode 100644 index 0000000..88b047e --- /dev/null +++ b/resume/serializers.py @@ -0,0 +1,8 @@ +from rest_framework import serializers + +class GenerateResumeSerializer(serializers.Serializer): + question = serializers.CharField() + guidelines = serializers.CharField() + answers = serializers.CharField() + free_answer = serializers.CharField() + favor_info = serializers.CharField() diff --git a/resume/urls.py b/resume/urls.py index 4acee28..6a25786 100644 --- a/resume/urls.py +++ b/resume/urls.py @@ -4,4 +4,5 @@ urlpatterns = [ path("guidelines/", views.GetGuidelinesView.as_view(), name="get_guidelines"), + path("generate/", views.GenerateResumeView.as_view(), name="generate_resume"), ] diff --git a/resume/utils.py b/resume/utils.py new file mode 100644 index 0000000..c9e1272 --- /dev/null +++ b/resume/utils.py @@ -0,0 +1,24 @@ +import os +from pathlib import Path + +import pinecone + +from utils.openai_call import get_embedding + +env = os.environ.Env(DEBUG=(bool, False)) +BASE_DIR = Path(__file__).resolve().parent.parent.parent +os.environ.Env.read_env(os.path.join(BASE_DIR, ".env")) + + +def retrieve_similar_answers(user_qa): + try: + pinecone.init(api_key=os.environ.get('PINECONE_API_KEY'), environment="gcp-starter") + index = pinecone.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: + return [] \ No newline at end of file diff --git a/resume/views.py b/resume/views.py index c39d194..de1c16f 100644 --- a/resume/views.py +++ b/resume/views.py @@ -16,18 +16,27 @@ OpenApiParameter, ) +from resume.serializers import GenerateResumeSerializer +from resume.utils import retrieve_similar_answers from utils.openai_call import get_chat_openai -from utils.prompts import GUIDELINE_PROMPT +from utils.prompts import GUIDELINE_PROMPT, GENERATE_SELF_INTRODUCTION_PROMPT # Create your views here. class GetGuidelinesView(APIView): + # permission_classes = [IsAuthenticated] + @extend_schema( - summary="메모 검색", - description="키워드를 기반으로 메모를 검색합니다.", - responses={ - 200: ["가이드라인1", "가이드라인2"] - }, + summary="가이드라인 생성", + description="질문을 기반으로 가이드라인을 생성합니다.", + # responses={ + # 200: inline_serializer( + # name="GetGuidelineResponse", + # fields={ + # "results": ['guide1', 'guide2', 'guide3'] + # } + # ) + # }, parameters=[ OpenApiParameter( name="question", @@ -41,10 +50,98 @@ def get(self, request): try: prompt = GUIDELINE_PROMPT.format(question=question) guideline_string = get_chat_openai(prompt) - guideline_json = json.loads(guideline_string.replace("'", '"')) - print(guideline_json) - return JsonResponse(guideline_json, safe=False) + guideline_list = json.loads(guideline_string.replace("'", '"')) + guideline_json = { + "result": guideline_list + } + return JsonResponse(guideline_json) except Exception as e: print(e) error_message = {'error': '가이드라인 생성 중 오류가 발생했습니다.'} return JsonResponse(error_message, status=500) + +class GenerateResumeView(APIView): + # permission_classes = [IsAuthenticated] + + @extend_schema( + summary="자기소개서 생성", + description="답변을 기반으로 자기소개서를 생성합니다.", + responses={ + 200: inline_serializer( + name="GenerateResumeResponse", + fields={ + "result": serializers.CharField(), + }, + ) + }, + parameters=[ + OpenApiParameter( + name="question", + type=str, + description="기업이 제시한 질문을 입력합니다.", + ), + OpenApiParameter( + name="guidelines", + type=str, + description="가이드라인 리스트를 입력합니다.", + ), + OpenApiParameter( + name="answers", + type=str, + description="각 가이드라인에 작성한 답안을 리스트로 전달합니다. 답변이 없을 경우, 공백을 담아 전달합니다.", + ), + OpenApiParameter( + name="free_answer", + type=str, + description="자유 작성란에 작성한 내용을 전달합니다.", + ), + OpenApiParameter( + name="favor_info", + type=str, + description="우대 공고에 작성한 내용을 전달합니다.", + ), + ], + ) + def get(self, request): + question = request.GET.get("question") + guidelines = request.GET.get("guidelines") + answers = request.GET.get("answers") + free_answer = request.GET.get("free_answer") + favor_info = request.GET.get("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') + total_answer += free_answer + + # 예시 retrieve + examples = retrieve_similar_answers(total_answer) + 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, + answers=total_answer, + favor_info=favor_info, + examples=examples_str + ) + + # 자소서 생성 + generated_self_introduction = get_chat_openai(prompt) + generated_self_introduction_json = { + "result": generated_self_introduction + } + + return JsonResponse(generated_self_introduction_json) + + + diff --git a/utils/prompts.py b/utils/prompts.py index ed2cc2c..3105b44 100644 --- a/utils/prompts.py +++ b/utils/prompts.py @@ -3,9 +3,10 @@ 당신은 자기소개서 컨설턴트입니다. 당신은 기업 우대사항과 예시들을 활용하여 주어진 질문에 대한 고객의 답변 작성을 첨삭해 주서야 합니다. -다음은 답변해야 하는 질문과 해당 질문에 대한 고객의 답변입니다. +다음은 답변해야 하는 질문과 해당 질문에 대한 고객의 답변입니다. +고객의 답변은 제공된 '가이드라인 + 답변' 쌍으로 구성되어 있습니다. Q: {{question}} \n -A: {{context}} +A: {{answer}} 다음은 해당하는 기업의 조직 소개 및 우대사항입니다. {{favor_info}} From 123a6f5c7d9004c6932f72af5e39a9c368e267c6 Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Fri, 5 Apr 2024 22:49:38 +0900 Subject: [PATCH 18/41] =?UTF-8?q?feat:=20=EC=9E=90=EA=B8=B0=EC=86=8C?= =?UTF-8?q?=EA=B0=9C=EC=84=9C=20=EC=83=9D=EC=84=B1=20api=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- accounts/views.py | 11 +++++++++++ resume/utils.py | 14 +++++++++----- resume/views.py | 48 ++++++++++++++++++++++++++++++++++------------- 3 files changed, 55 insertions(+), 18 deletions(-) diff --git a/accounts/views.py b/accounts/views.py index ea5ec22..94e4166 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -61,6 +61,17 @@ def kakao_login(request): name="code", type=str, description="발급받은 카카오의 code 입니다." ), ], + examples=[ + OpenApiExample( + response_only=True, + summary="Response Body Example입니다.", + name="success_example", + value={ + "access_token": "string", + "code": "string", + }, + ), + ] ) @permission_classes([AllowAny]) @api_view(["GET"]) diff --git a/resume/utils.py b/resume/utils.py index c9e1272..2277f48 100644 --- a/resume/utils.py +++ b/resume/utils.py @@ -1,19 +1,22 @@ import os from pathlib import Path +import environ -import pinecone +from pinecone import Pinecone + +pc = Pinecone(api_key=os.environ.get('PINECONE_API_KEY')) from utils.openai_call import get_embedding -env = os.environ.Env(DEBUG=(bool, False)) +env = environ.Env(DEBUG=(bool, False)) BASE_DIR = Path(__file__).resolve().parent.parent.parent -os.environ.Env.read_env(os.path.join(BASE_DIR, ".env")) +environ.Env.read_env(os.path.join(BASE_DIR, ".env")) def retrieve_similar_answers(user_qa): try: - pinecone.init(api_key=os.environ.get('PINECONE_API_KEY'), environment="gcp-starter") - index = pinecone.Index("resumai-self-introduction-index") + 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 @@ -21,4 +24,5 @@ def retrieve_similar_answers(user_qa): return retrieved_data["matches"] except Exception as e: + print(e) return [] \ No newline at end of file diff --git a/resume/views.py b/resume/views.py index de1c16f..f8580a3 100644 --- a/resume/views.py +++ b/resume/views.py @@ -1,4 +1,5 @@ import json +import logging from django.http import Http404 from django.db.models import Q @@ -9,6 +10,7 @@ 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, @@ -78,29 +80,47 @@ class GenerateResumeView(APIView): OpenApiParameter( name="question", type=str, - description="기업이 제시한 질문을 입력합니다.", + description="기업이 제시한 질문", ), OpenApiParameter( - name="guidelines", - type=str, - description="가이드라인 리스트를 입력합니다.", + name='guidelines', + type={ + 'type': 'array', + 'items': { + 'type': 'string' + } + }, + location=OpenApiParameter.QUERY, + required=False, + style='form', + explode=False, + description="제공된 가이드라인", ), OpenApiParameter( - name="answers", - type=str, - description="각 가이드라인에 작성한 답안을 리스트로 전달합니다. 답변이 없을 경우, 공백을 담아 전달합니다.", + 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="자유 작성란에 작성한 내용을 전달합니다.", + description="자유 작성란에 작성한 답변", ), OpenApiParameter( name="favor_info", type=str, - description="우대 공고에 작성한 내용을 전달합니다.", + description="우대사항", ), - ], + ] ) def get(self, request): question = request.GET.get("question") @@ -108,15 +128,17 @@ def get(self, request): answers = request.GET.get("answers") free_answer = request.GET.get("free_answer") favor_info = request.GET.get("favor_info") + print(question) # 답변을 guideline + answer + free_answer로 구성 total_answer = '' - + print(answers) for index, answer in enumerate(answers): # answer 값이 존재하는 경우에만 처리 if answer: total_answer += (guidelines[index] + '\n' + answer + '\n\n') - total_answer += free_answer + if free_answer: + total_answer += free_answer # 예시 retrieve examples = retrieve_similar_answers(total_answer) @@ -130,7 +152,7 @@ def get(self, request): # 프롬프트 작성 prompt = GENERATE_SELF_INTRODUCTION_PROMPT.format( question=question, - answers=total_answer, + answer=total_answer, favor_info=favor_info, examples=examples_str ) From 668d3aa1a6c96e38f01d3482595e64c612c28ece Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Sat, 6 Apr 2024 16:06:44 +0900 Subject: [PATCH 19/41] =?UTF-8?q?refactor:=20black=EC=9C=BC=EB=A1=9C=20ref?= =?UTF-8?q?ormatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- accounts/views.py | 4 +++- resume/models.py | 2 +- resume/serializers.py | 1 + resume/utils.py | 4 ++-- resume/views.py | 48 +++++++++++++++---------------------------- utils/openai_call.py | 2 +- 6 files changed, 24 insertions(+), 37 deletions(-) diff --git a/accounts/views.py b/accounts/views.py index 94e4166..79c7ddf 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -47,12 +47,14 @@ REST_API_KEY = env("KAKAO_REST_API_KEY") CLIENT_SECRET = env("KAKAO_CLIENT_SECRET_KEY") + @extend_schema(exclude=True) def kakao_login(request): return redirect( f"https://kauth.kakao.com/oauth/authorize?client_id={REST_API_KEY}&redirect_uri={KAKAO_CALLBACK_URI}&response_type=code" ) + @extend_schema( summary="카카오 로그인", description="code를 GET 요청으로 보내면 access token, code를 반환합니다.", @@ -71,7 +73,7 @@ def kakao_login(request): "code": "string", }, ), - ] + ], ) @permission_classes([AllowAny]) @api_view(["GET"]) diff --git a/resume/models.py b/resume/models.py index 4c68598..370c75c 100644 --- a/resume/models.py +++ b/resume/models.py @@ -4,7 +4,7 @@ class Resume(models.Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - title = models.CharField(max_length=255) # 제목이 곧 지원하려는 기업명과 동일함 + title = models.CharField(max_length=255) # 제목이 곧 지원하려는 기업명과 동일함 content = models.TextField() due_date = models.DateTimeField(null=True) created_at = models.DateTimeField(auto_now_add=True) diff --git a/resume/serializers.py b/resume/serializers.py index 88b047e..0b78730 100644 --- a/resume/serializers.py +++ b/resume/serializers.py @@ -1,5 +1,6 @@ from rest_framework import serializers + class GenerateResumeSerializer(serializers.Serializer): question = serializers.CharField() guidelines = serializers.CharField() diff --git a/resume/utils.py b/resume/utils.py index 2277f48..7f28701 100644 --- a/resume/utils.py +++ b/resume/utils.py @@ -4,7 +4,7 @@ from pinecone import Pinecone -pc = Pinecone(api_key=os.environ.get('PINECONE_API_KEY')) +pc = Pinecone(api_key=os.environ.get("PINECONE_API_KEY")) from utils.openai_call import get_embedding @@ -25,4 +25,4 @@ def retrieve_similar_answers(user_qa): except Exception as e: print(e) - return [] \ No newline at end of file + return [] diff --git a/resume/views.py b/resume/views.py index f8580a3..b5d0c65 100644 --- a/resume/views.py +++ b/resume/views.py @@ -26,7 +26,7 @@ # Create your views here. class GetGuidelinesView(APIView): - # permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated] @extend_schema( summary="가이드라인 생성", @@ -53,17 +53,16 @@ def get(self, request): prompt = GUIDELINE_PROMPT.format(question=question) guideline_string = get_chat_openai(prompt) guideline_list = json.loads(guideline_string.replace("'", '"')) - guideline_json = { - "result": guideline_list - } + guideline_json = {"result": guideline_list} return JsonResponse(guideline_json) except Exception as e: print(e) - error_message = {'error': '가이드라인 생성 중 오류가 발생했습니다.'} + error_message = {"error": "가이드라인 생성 중 오류가 발생했습니다."} return JsonResponse(error_message, status=500) + class GenerateResumeView(APIView): - # permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated] @extend_schema( summary="자기소개서 생성", @@ -83,30 +82,20 @@ class GenerateResumeView(APIView): description="기업이 제시한 질문", ), OpenApiParameter( - name='guidelines', - type={ - 'type': 'array', - 'items': { - 'type': 'string' - } - }, + name="guidelines", + type={"type": "array", "items": {"type": "string"}}, location=OpenApiParameter.QUERY, required=False, - style='form', + style="form", explode=False, description="제공된 가이드라인", ), OpenApiParameter( - name='answers', - type={ - 'type': 'array', - 'items': { - 'type': 'string' - } - }, + name="answers", + type={"type": "array", "items": {"type": "string"}}, location=OpenApiParameter.QUERY, required=False, - style='form', + style="form", explode=False, description="제공된 가이드라인에 대한 답변", ), @@ -120,7 +109,7 @@ class GenerateResumeView(APIView): type=str, description="우대사항", ), - ] + ], ) def get(self, request): question = request.GET.get("question") @@ -131,12 +120,12 @@ def get(self, request): print(question) # 답변을 guideline + answer + free_answer로 구성 - total_answer = '' + total_answer = "" print(answers) for index, answer in enumerate(answers): # answer 값이 존재하는 경우에만 처리 if answer: - total_answer += (guidelines[index] + '\n' + answer + '\n\n') + total_answer += guidelines[index] + "\n" + answer + "\n\n" if free_answer: total_answer += free_answer @@ -154,16 +143,11 @@ def get(self, request): question=question, answer=total_answer, favor_info=favor_info, - examples=examples_str + examples=examples_str, ) # 자소서 생성 generated_self_introduction = get_chat_openai(prompt) - generated_self_introduction_json = { - "result": generated_self_introduction - } + generated_self_introduction_json = {"result": generated_self_introduction} return JsonResponse(generated_self_introduction_json) - - - diff --git a/utils/openai_call.py b/utils/openai_call.py index a6b599b..4d8a791 100644 --- a/utils/openai_call.py +++ b/utils/openai_call.py @@ -7,7 +7,7 @@ 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')) +client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY")) def get_chat_openai(prompt, model="gpt-4"): From 7b2dc8e91935ae7cfef7261f5ba3193e0d3b6fa7 Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Sat, 6 Apr 2024 16:35:35 +0900 Subject: [PATCH 20/41] =?UTF-8?q?feat:=20resume=20model=EC=97=90=20positio?= =?UTF-8?q?n=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=9E=90=EA=B8=B0?= =?UTF-8?q?=EC=86=8C=EA=B0=9C=EC=84=9C=20post=20api=20=EC=99=84=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resume/migrations/0002_resume_position.py | 19 ++++++++++++ resume/models.py | 1 + resume/serializers.py | 8 +++++ resume/urls.py | 1 + resume/views.py | 37 ++++++++++++++++++++++- 5 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 resume/migrations/0002_resume_position.py 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/models.py b/resume/models.py index 370c75c..1c80fba 100644 --- a/resume/models.py +++ b/resume/models.py @@ -5,6 +5,7 @@ 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) # 지원하려는 기업의 지원하려는 직무 content = models.TextField() due_date = models.DateTimeField(null=True) created_at = models.DateTimeField(auto_now_add=True) diff --git a/resume/serializers.py b/resume/serializers.py index 0b78730..f43f7b3 100644 --- a/resume/serializers.py +++ b/resume/serializers.py @@ -1,5 +1,7 @@ from rest_framework import serializers +from resume.models import Resume + class GenerateResumeSerializer(serializers.Serializer): question = serializers.CharField() @@ -7,3 +9,9 @@ class GenerateResumeSerializer(serializers.Serializer): answers = serializers.CharField() free_answer = serializers.CharField() favor_info = serializers.CharField() + +class PostResumeSerializer(serializers.ModelSerializer): + class Meta: + model = Resume + fields = ("id", "title", "position", "content", "due_date", "created_at", "updated_at", "is_finished", "is_liked") + read_only_fields = ("id", "created_at", "updated_at") \ No newline at end of file diff --git a/resume/urls.py b/resume/urls.py index 6a25786..5676569 100644 --- a/resume/urls.py +++ b/resume/urls.py @@ -5,4 +5,5 @@ urlpatterns = [ 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"), ] diff --git a/resume/views.py b/resume/views.py index b5d0c65..2774dc1 100644 --- a/resume/views.py +++ b/resume/views.py @@ -18,7 +18,7 @@ OpenApiParameter, ) -from resume.serializers import GenerateResumeSerializer +from resume.serializers import GenerateResumeSerializer, PostResumeSerializer from resume.utils import retrieve_similar_answers from utils.openai_call import get_chat_openai from utils.prompts import GUIDELINE_PROMPT, GENERATE_SELF_INTRODUCTION_PROMPT @@ -151,3 +151,38 @@ def get(self, request): generated_self_introduction_json = {"result": generated_self_introduction} return JsonResponse(generated_self_introduction_json) + +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): + print(request.user) + 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) + From f2be78f6f30e9d812caa6a9989015588a027bfcf Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Sat, 6 Apr 2024 16:55:49 +0900 Subject: [PATCH 21/41] =?UTF-8?q?feat:=20=EC=9E=90=EA=B8=B0=EC=86=8C?= =?UTF-8?q?=EA=B0=9C=EC=84=9C=20=EC=83=9D=EC=84=B1=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resume/serializers.py | 8 +++++++- resume/urls.py | 1 + resume/views.py | 30 +++++++++++++++++++++++++++++- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/resume/serializers.py b/resume/serializers.py index f43f7b3..262e914 100644 --- a/resume/serializers.py +++ b/resume/serializers.py @@ -11,7 +11,13 @@ class GenerateResumeSerializer(serializers.Serializer): favor_info = serializers.CharField() class PostResumeSerializer(serializers.ModelSerializer): + class Meta: + model = Resume + fields = ("title", "position", "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 = ("id", "created_at", "updated_at") \ No newline at end of file + read_only_fields = ("created_at", "updated_at") \ No newline at end of file diff --git a/resume/urls.py b/resume/urls.py index 5676569..e18ddfa 100644 --- a/resume/urls.py +++ b/resume/urls.py @@ -6,4 +6,5 @@ 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") ] diff --git a/resume/views.py b/resume/views.py index 2774dc1..ee9b473 100644 --- a/resume/views.py +++ b/resume/views.py @@ -3,6 +3,7 @@ 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 @@ -18,7 +19,8 @@ OpenApiParameter, ) -from resume.serializers import GenerateResumeSerializer, PostResumeSerializer +from resume.models import Resume +from resume.serializers import GenerateResumeSerializer, PostResumeSerializer, UpdateResumeSerializer from resume.utils import retrieve_similar_answers from utils.openai_call import get_chat_openai from utils.prompts import GUIDELINE_PROMPT, GENERATE_SELF_INTRODUCTION_PROMPT @@ -186,3 +188,29 @@ def post(self, request): # 데이터가 유효하지 않은 경우, 에러 메시지 반환 return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) +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) \ No newline at end of file From 7a466c6a78afbeabbafe02718ca8872be119e4d4 Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Sat, 6 Apr 2024 17:14:19 +0900 Subject: [PATCH 22/41] =?UTF-8?q?feat:=20=EC=9E=90=EA=B8=B0=EC=86=8C?= =?UTF-8?q?=EA=B0=9C=EC=84=9C=20=EC=8A=A4=ED=81=AC=EB=9E=A9=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resume/urls.py | 3 ++- resume/views.py | 20 +++++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/resume/urls.py b/resume/urls.py index e18ddfa..e91b368 100644 --- a/resume/urls.py +++ b/resume/urls.py @@ -6,5 +6,6 @@ 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("update/", views.UpdateResumeView.as_view(), name="update_resume"), + path("scrap/", views.ScrapResumeView.as_view(), name="scrap_resume") ] diff --git a/resume/views.py b/resume/views.py index ee9b473..9d7d560 100644 --- a/resume/views.py +++ b/resume/views.py @@ -213,4 +213,22 @@ def put(self, request, *args, **kwargs): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) else: - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) \ No newline at end of file + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + +class ScrapResumeView(APIView): + permission_classes = [IsAuthenticated] + + 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) \ No newline at end of file From 9e809b9b60b001bf2ab39bedd479bf7618a7ea05 Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Sat, 6 Apr 2024 17:22:16 +0900 Subject: [PATCH 23/41] =?UTF-8?q?feat:=20memo=20=EC=88=98=EC=A0=95=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- memos/urls.py | 2 +- memos/views.py | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/memos/urls.py b/memos/urls.py index aab65f3..5c52e1a 100644 --- a/memos/urls.py +++ b/memos/urls.py @@ -6,7 +6,7 @@ path("", views.PostMemoView.as_view(), name="post_memo"), path("all", views.GetAllMemoView.as_view(), name="get_all_memos"), path("memo//", views.GetMemoDetailView.as_view(), name="memo-detail"), - # path('scrap//', views.ScrapMemoView.as_view(), name='scrap-memo'), + 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 index 13f77a5..0ee5a62 100644 --- a/memos/views.py +++ b/memos/views.py @@ -145,6 +145,32 @@ class CustomPagination(PageNumberPagination): 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] From 08e268f3b5cd4a896755b09b42eb8c77378f2728 Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Sun, 7 Apr 2024 02:59:59 +0900 Subject: [PATCH 24/41] fix: add logger --- accounts/views.py | 3 +++ resume/urls.py | 1 + resume/views.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/accounts/views.py b/accounts/views.py index 79c7ddf..33b3c61 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -79,6 +79,7 @@ def kakao_login(request): @api_view(["GET"]) def kakao_callback(request): code = request.GET.get("code") + logger.info(code) # Access Token Request token_req = requests.get( @@ -86,12 +87,14 @@ def kakao_callback(request): ) token_req_json = token_req.json() + logger.info(token_req_json) error = token_req_json.get("error") if error is not None: raise JSONDecodeError(error) access_token = token_req_json.get("access_token") + logger.info(access_token) # Email Request profile_request = requests.get( diff --git a/resume/urls.py b/resume/urls.py index e91b368..2a90457 100644 --- a/resume/urls.py +++ b/resume/urls.py @@ -3,6 +3,7 @@ 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"), diff --git a/resume/views.py b/resume/views.py index 9d7d560..8844b96 100644 --- a/resume/views.py +++ b/resume/views.py @@ -26,6 +26,38 @@ from utils.prompts import GUIDELINE_PROMPT, GENERATE_SELF_INTRODUCTION_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] From 66fe75d644119be02df69c6fa58aeaa86cd7ef5e Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Sun, 7 Apr 2024 03:09:20 +0900 Subject: [PATCH 25/41] add logger.fata --- accounts/views.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/accounts/views.py b/accounts/views.py index 33b3c61..83367f8 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -50,6 +50,7 @@ @extend_schema(exclude=True) def kakao_login(request): + logger.fatal(f"https://kauth.kakao.com/oauth/authorize?client_id={REST_API_KEY}&redirect_uri={KAKAO_CALLBACK_URI}&response_type=code") return redirect( f"https://kauth.kakao.com/oauth/authorize?client_id={REST_API_KEY}&redirect_uri={KAKAO_CALLBACK_URI}&response_type=code" ) @@ -79,7 +80,7 @@ def kakao_login(request): @api_view(["GET"]) def kakao_callback(request): code = request.GET.get("code") - logger.info(code) + logger.fatal(code) # Access Token Request token_req = requests.get( @@ -87,14 +88,14 @@ def kakao_callback(request): ) token_req_json = token_req.json() - logger.info(token_req_json) + logger.fatal(token_req_json) error = token_req_json.get("error") if error is not None: raise JSONDecodeError(error) access_token = token_req_json.get("access_token") - logger.info(access_token) + logger.fatal(access_token) # Email Request profile_request = requests.get( From 37fe8b902df4bfd49168ef8154a133d348821306 Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Sun, 7 Apr 2024 03:18:39 +0900 Subject: [PATCH 26/41] =?UTF-8?q?fix:=20callback=20uri=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- accounts/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/accounts/views.py b/accounts/views.py index 83367f8..eafd6c6 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -42,7 +42,7 @@ 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/login/" # 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") From 99bd6f21a4069e2bcec53988defc179e138714fe Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Sun, 7 Apr 2024 04:46:54 +0900 Subject: [PATCH 27/41] =?UTF-8?q?fix:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EC=9D=B8=EA=B0=80=EC=BD=94=EB=93=9C=20=EC=A0=84=EC=86=A1=20?= =?UTF-8?q?=EC=8B=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B0=80=EC=9E=85=20?= =?UTF-8?q?=ED=9B=84=20=EB=AA=A8=EB=93=A0=20=EC=A0=95=EB=B3=B4=20=EB=B0=98?= =?UTF-8?q?=ED=99=98=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- accounts/serializers.py | 2 +- accounts/urls.py | 3 +- accounts/views.py | 174 +++++++++++++++++++--------------------- 3 files changed, 83 insertions(+), 96 deletions(-) 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 9dd5c7e..4e542ae 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -4,9 +4,8 @@ # TODO swagger에 뜨는 api 관리 urlpatterns = [ path("kakao/", views.kakao_login, name="kakao_login"), - path("kakao/login/", views.kakao_callback, name="kakao_callback"), path( - "kakao/login/finish/", + "kakao/login/", views.KakaoLoginView.as_view(), name="kakao_login_todjango", ), diff --git a/accounts/views.py b/accounts/views.py index eafd6c6..414c56a 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -10,15 +10,18 @@ OpenApiParameter, ) from django.http import JsonResponse +from rest_framework_simplejwt.tokens import RefreshToken import requests from django.shortcuts import redirect +from django.contrib.auth import get_user_model from json.decoder import JSONDecodeError from rest_framework.decorators import api_view, permission_classes 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 rest_framework.authtoken.models import Token from allauth.socialaccount.providers.oauth2.client import OAuth2Client from dj_rest_auth.registration.views import SocialLoginView @@ -42,11 +45,12 @@ env.read_env(env_file) BASE_URL = env("BASE_URL") -KAKAO_CALLBACK_URI = BASE_URL + "accounts/kakao/login/" +KAKAO_CALLBACK_URI = BASE_URL + "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(exclude=True) def kakao_login(request): @@ -55,105 +59,89 @@ def kakao_login(request): f"https://kauth.kakao.com/oauth/authorize?client_id={REST_API_KEY}&redirect_uri={KAKAO_CALLBACK_URI}&response_type=code" ) +class KakaoLoginView(SocialLoginView): -@extend_schema( - summary="카카오 로그인", - description="code를 GET 요청으로 보내면 access token, code를 반환합니다.", - parameters=[ - OpenApiParameter( - name="code", type=str, description="발급받은 카카오의 code 입니다." - ), - ], - examples=[ - OpenApiExample( - response_only=True, - summary="Response Body Example입니다.", - name="success_example", - value={ - "access_token": "string", - "code": "string", + @extend_schema( + summary="카카오 로그인 마무리", + description="code (인가 코드)를 post 요청으로 보내면 access token, 유저 정보를 반환합니다. **(id_token은 불필요합니다.)**", + request={ + "application/json": { + "type": "object", + "properties": { + "code": {"type": "string"}, + }, }, - ), - ], -) -@permission_classes([AllowAny]) -@api_view(["GET"]) -def kakao_callback(request): - code = request.GET.get("code") - logger.fatal(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}" + }, + 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", + "created": False + } + }, + ), + ], ) + def post(self, request, *args, **kwargs): + code = request.data.get("code") + print(code) - token_req_json = token_req.json() - logger.fatal(token_req_json) + if not code: + return Response({"error": "Code is required"}, status=status.HTTP_400_BAD_REQUEST) - error = token_req_json.get("error") - if error is not None: - raise JSONDecodeError(error) + # 카카오 인가코드를 사용해 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}") + + if token_res.status_code != 200: + 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}"}) + + # 사용자에 대한 토큰 생성 + 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) - access_token = token_req_json.get("access_token") - logger.fatal(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) - - -@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"}, - }, - }, - }, - 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"}, - }, - ), - ], -) -class KakaoLoginView(SocialLoginView): - adapter_class = kakao_view.KakaoOAuth2Adapter - client_class = OAuth2Client - callback_url = KAKAO_CALLBACK_URI class UpdateUserInfoView(APIView): From 0edb5658b825fc9fec657377be22cc5116a86505 Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Sun, 7 Apr 2024 04:51:53 +0900 Subject: [PATCH 28/41] =?UTF-8?q?fix:=20redirect=20uri=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20logger=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- accounts/views.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/accounts/views.py b/accounts/views.py index 414c56a..8a1f92f 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -45,7 +45,8 @@ 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") @@ -93,15 +94,16 @@ class KakaoLoginView(SocialLoginView): ) def post(self, request, *args, **kwargs): code = request.data.get("code") - print(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() From 3c345729921b8648f920c467deb2e450171e63cb Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Sun, 7 Apr 2024 04:58:38 +0900 Subject: [PATCH 29/41] =?UTF-8?q?reformat:=20black=EC=9D=84=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EC=97=AC=20code=20reformatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- accounts/views.py | 64 ++++++++++++++++++++++++------------------- memos/urls.py | 2 +- memos/views.py | 12 ++++++-- resume/models.py | 2 +- resume/serializers.py | 27 ++++++++++++++++-- resume/urls.py | 2 +- resume/views.py | 39 ++++++++++++++++++++------ 7 files changed, 102 insertions(+), 46 deletions(-) diff --git a/accounts/views.py b/accounts/views.py index 8a1f92f..70e981d 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -2,35 +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 django.contrib.auth import get_user_model -from json.decoder import JSONDecodeError -from rest_framework.decorators import api_view, permission_classes 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 rest_framework.authtoken.models import Token -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 @@ -46,20 +37,24 @@ BASE_URL = env("BASE_URL") # KAKAO_CALLBACK_URI = BASE_URL + "accounts/kakao/callback/" -KAKAO_CALLBACK_URI="http://localhost:5173/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(exclude=True) def kakao_login(request): - logger.fatal(f"https://kauth.kakao.com/oauth/authorize?client_id={REST_API_KEY}&redirect_uri={KAKAO_CALLBACK_URI}&response_type=code") + logger.fatal( + f"https://kauth.kakao.com/oauth/authorize?client_id={REST_API_KEY}&redirect_uri={KAKAO_CALLBACK_URI}&response_type=code" + ) return redirect( f"https://kauth.kakao.com/oauth/authorize?client_id={REST_API_KEY}&redirect_uri={KAKAO_CALLBACK_URI}&response_type=code" ) + class KakaoLoginView(SocialLoginView): @extend_schema( @@ -79,15 +74,15 @@ class KakaoLoginView(SocialLoginView): summary="Response Body Example입니다.", name="success_example", value={ - "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b", - "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0", - "user_info": { + "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", - "created": False - } + "is_created": False, + }, }, ), ], @@ -96,27 +91,35 @@ 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) + 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}") + 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) + 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}" - } + 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) + return Response( + {"error": "Failed to obtain user information"}, + status=status.HTTP_400_BAD_REQUEST, + ) profile_json = profile_res.json() @@ -125,7 +128,14 @@ def post(self, request, *args, **kwargs): 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}"}) + user, created = User.objects.get_or_create( + email=email, + defaults={ + "username": f"{nickname}", + "kakao_oid": kakao_oid, + "profile_image": f"{profile_image}", + }, + ) # 사용자에 대한 토큰 생성 refresh = RefreshToken.for_user(user) @@ -137,15 +147,13 @@ def post(self, request, *args, **kwargs): "email": user.email, "username": user.username, "profile_image": user.profile_image, - "is_created": created - } + "is_created": created, + }, } return Response(data, status=status.HTTP_200_OK) - - class UpdateUserInfoView(APIView): permission_classes = [IsAuthenticated] # 인증된 사용자만 접근 가능하도록 설정 diff --git a/memos/urls.py b/memos/urls.py index 5c52e1a..1220c85 100644 --- a/memos/urls.py +++ b/memos/urls.py @@ -6,7 +6,7 @@ path("", views.PostMemoView.as_view(), name="post_memo"), path("all", views.GetAllMemoView.as_view(), name="get_all_memos"), path("memo//", views.GetMemoDetailView.as_view(), name="memo-detail"), - path('update//', views.UpdateMemoView.as_view(), name='scrap-memo'), + 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 index 0ee5a62..fc2208b 100644 --- a/memos/views.py +++ b/memos/views.py @@ -145,6 +145,7 @@ class CustomPagination(PageNumberPagination): def get_page_size(self, request): return super().get_page_size(request) + class UpdateMemoView(APIView): permission_classes = [IsAuthenticated] # 인증된 사용자만 접근 가능하도록 설정 @@ -155,12 +156,16 @@ class UpdateMemoView(APIView): ) def put(self, request, *args, **kwargs): user = request.user - resume_id = kwargs.get('id') # URL에서 resume의 id를 가져옵니다. + resume_id = kwargs.get("id") # URL에서 resume의 id를 가져옵니다. try: - resume = Memo.objects.get(id=resume_id, user=user) # 요청한 사용자의 resume만 선택 + 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) + return Response( + {"error": "Resume not found"}, status=status.HTTP_404_NOT_FOUND + ) serializer = PostMemoSerializer( resume, data=request.data, partial=True @@ -172,6 +177,7 @@ def put(self, request, *args, **kwargs): else: return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + class SearchMemoView(APIView, CustomPagination): permission_classes = [IsAuthenticated] diff --git a/resume/models.py b/resume/models.py index 1c80fba..fca8de2 100644 --- a/resume/models.py +++ b/resume/models.py @@ -5,7 +5,7 @@ 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) # 지원하려는 기업의 지원하려는 직무 + position = models.CharField(max_length=255) # 지원하려는 기업의 지원하려는 직무 content = models.TextField() due_date = models.DateTimeField(null=True) created_at = models.DateTimeField(auto_now_add=True) diff --git a/resume/serializers.py b/resume/serializers.py index 262e914..17cc067 100644 --- a/resume/serializers.py +++ b/resume/serializers.py @@ -10,14 +10,35 @@ class GenerateResumeSerializer(serializers.Serializer): free_answer = serializers.CharField() favor_info = serializers.CharField() + class PostResumeSerializer(serializers.ModelSerializer): class Meta: model = Resume - fields = ("title", "position", "content", "due_date", "created_at", "updated_at", "is_finished", "is_liked") + fields = ( + "title", + "position", + "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") \ No newline at end of file + fields = ( + "id", + "title", + "position", + "content", + "due_date", + "created_at", + "updated_at", + "is_finished", + "is_liked", + ) + read_only_fields = ("created_at", "updated_at") diff --git a/resume/urls.py b/resume/urls.py index 2a90457..0b4cb1d 100644 --- a/resume/urls.py +++ b/resume/urls.py @@ -8,5 +8,5 @@ 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("scrap/", views.ScrapResumeView.as_view(), name="scrap_resume"), ] diff --git a/resume/views.py b/resume/views.py index 8844b96..cf16e26 100644 --- a/resume/views.py +++ b/resume/views.py @@ -20,7 +20,11 @@ ) from resume.models import Resume -from resume.serializers import GenerateResumeSerializer, PostResumeSerializer, UpdateResumeSerializer +from resume.serializers import ( + GenerateResumeSerializer, + PostResumeSerializer, + UpdateResumeSerializer, +) from resume.utils import retrieve_similar_answers from utils.openai_call import get_chat_openai from utils.prompts import GUIDELINE_PROMPT, GENERATE_SELF_INTRODUCTION_PROMPT @@ -58,6 +62,7 @@ def get(self, request): serializer = PostResumeSerializer(resumes, many=True) return Response(serializer.data) + # Create your views here. class GetGuidelinesView(APIView): permission_classes = [IsAuthenticated] @@ -186,6 +191,7 @@ def get(self, request): return JsonResponse(generated_self_introduction_json) + class PostResumeView(APIView): permission_classes = [IsAuthenticated] @@ -220,6 +226,7 @@ def post(self, request): # 데이터가 유효하지 않은 경우, 에러 메시지 반환 return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + class UpdateResumeView(APIView): permission_classes = [IsAuthenticated] # 인증된 사용자만 접근 가능하도록 설정 @@ -230,12 +237,16 @@ class UpdateResumeView(APIView): ) def put(self, request, *args, **kwargs): user = request.user - resume_id = kwargs.get('id') # URL에서 resume의 id를 가져옵니다. + resume_id = kwargs.get("id") # URL에서 resume의 id를 가져옵니다. try: - resume = Resume.objects.get(id=resume_id, user=user) # 요청한 사용자의 resume만 선택 + 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) + return Response( + {"error": "Resume not found"}, status=status.HTTP_404_NOT_FOUND + ) serializer = PostResumeSerializer( resume, data=request.data, partial=True @@ -247,20 +258,30 @@ def put(self, request, *args, **kwargs): else: return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + class ScrapResumeView(APIView): permission_classes = [IsAuthenticated] def get(self, request, *args, **kwargs): - resume_id = kwargs.get('id', None) # URL로부터 자기소개서 id를 받아옵니다. + 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) + 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']) # 업데이트할 필드를 명시적으로 지정합니다. + resume.save( + update_fields=["is_liked"] + ) # 업데이트할 필드를 명시적으로 지정합니다. - return Response({'id': resume_id, 'is_liked': resume.is_liked}, status=status.HTTP_200_OK) + 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) \ No newline at end of file + return Response( + {"error": "Resume not found"}, status=status.HTTP_404_NOT_FOUND + ) From caee917e201f6eb32e45585d018bfce969393b63 Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Sun, 7 Apr 2024 05:37:03 +0900 Subject: [PATCH 30/41] =?UTF-8?q?fix:=20SocialLoginView=EC=97=90=EC=84=9C?= =?UTF-8?q?=20APIView=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- accounts/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/accounts/views.py b/accounts/views.py index 70e981d..bcab9dc 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -55,7 +55,7 @@ def kakao_login(request): ) -class KakaoLoginView(SocialLoginView): +class KakaoLoginView(APIView): @extend_schema( summary="카카오 로그인 마무리", From 358b4a6c6782e057c477f6c7592b6ee3c5c3afa2 Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Tue, 9 Apr 2024 17:11:45 +0900 Subject: [PATCH 31/41] =?UTF-8?q?feat:=20chat=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements.txt | 20 ++++++++++++++++++++ resume/urls.py | 1 + resume/utils.py | 17 +++++++++++++---- resume/views.py | 41 ++++++++++++++++++++++++++++++++++------- 4 files changed, 68 insertions(+), 11 deletions(-) diff --git a/requirements.txt b/requirements.txt index 62d6779..67e6532 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +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 @@ -8,6 +11,7 @@ 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 @@ -21,18 +25,30 @@ 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.40 +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 @@ -50,9 +66,13 @@ 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 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/resume/urls.py b/resume/urls.py index 0b4cb1d..ea3ac85 100644 --- a/resume/urls.py +++ b/resume/urls.py @@ -9,4 +9,5 @@ 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"), ] diff --git a/resume/utils.py b/resume/utils.py index 7f28701..a7a3630 100644 --- a/resume/utils.py +++ b/resume/utils.py @@ -3,15 +3,16 @@ import environ from pinecone import Pinecone +# from utils.openai_call import get_embedding -pc = Pinecone(api_key=os.environ.get("PINECONE_API_KEY")) - -from utils.openai_call import get_embedding +from langchain_community.chat_models import ChatOpenAI +from langchain.chains import ConversationChain env = environ.Env(DEBUG=(bool, False)) -BASE_DIR = Path(__file__).resolve().parent.parent.parent +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: @@ -26,3 +27,11 @@ def retrieve_similar_answers(user_qa): except Exception as e: print(e) return [] + +def run_llm(query: str, chat_history: list[dict[str, any]]) -> any: + chat = ChatOpenAI(verbose=True, temperature=0, model_name="gpt-4") + conversation = ConversationChain( + llm=chat, + verbose=True, + ) + return conversation.predict(input=query) diff --git a/resume/views.py b/resume/views.py index cf16e26..f5cf03a 100644 --- a/resume/views.py +++ b/resume/views.py @@ -25,7 +25,7 @@ PostResumeSerializer, UpdateResumeSerializer, ) -from resume.utils import retrieve_similar_answers +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 @@ -95,8 +95,7 @@ def get(self, request): guideline_json = {"result": guideline_list} return JsonResponse(guideline_json) except Exception as e: - print(e) - error_message = {"error": "가이드라인 생성 중 오류가 발생했습니다."} + error_message = {"error": "가이드라인 생성 중 오류가 발생했습니다. 질문을 올바르게 입력해 주세요."} return JsonResponse(error_message, status=500) @@ -156,11 +155,9 @@ def get(self, request): answers = request.GET.get("answers") free_answer = request.GET.get("free_answer") favor_info = request.GET.get("favor_info") - print(question) # 답변을 guideline + answer + free_answer로 구성 total_answer = "" - print(answers) for index, answer in enumerate(answers): # answer 값이 존재하는 경우에만 처리 if answer: @@ -170,6 +167,10 @@ def get(self, request): # 예시 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']}" @@ -189,7 +190,7 @@ def get(self, request): generated_self_introduction = get_chat_openai(prompt) generated_self_introduction_json = {"result": generated_self_introduction} - return JsonResponse(generated_self_introduction_json) + return JsonResponse(generated_self_introduction_json, status=200) class PostResumeView(APIView): @@ -212,7 +213,6 @@ class PostResumeView(APIView): }, ) def post(self, request): - print(request.user) serializer = PostResumeSerializer(data=request.data) # 데이터 유효성 검사 @@ -285,3 +285,30 @@ def get(self, request, *args, **kwargs): return Response( {"error": "Resume not found"}, status=status.HTTP_404_NOT_FOUND ) + +class ChatView(APIView): + + @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): + query = request.data.query + langchain_answer = run_llm(query=query) + + return Response( + {"answer": langchain_answer}, + status=status.HTTP_200_OK, + ) \ No newline at end of file From 73d9dbb383ae9dde8d0637820405965bcc2e4d44 Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Tue, 9 Apr 2024 19:01:38 +0900 Subject: [PATCH 32/41] =?UTF-8?q?feat:=20=EC=B1=97=EB=B4=87=20api=201?= =?UTF-8?q?=EC=B0=A8=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resume/models.py | 6 ++++++ resume/utils.py | 1 + resume/views.py | 28 ++++++++++++++++++++-------- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/resume/models.py b/resume/models.py index fca8de2..2bef061 100644 --- a/resume/models.py +++ b/resume/models.py @@ -15,3 +15,9 @@ class Resume(models.Model): def __str__(self): return self.title + +class ChatHistory(models.Model): + resume = models.ForeignKey(Resume, on_delete=models.CASCADE) + # history = + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) diff --git a/resume/utils.py b/resume/utils.py index a7a3630..5e0ffa5 100644 --- a/resume/utils.py +++ b/resume/utils.py @@ -33,5 +33,6 @@ def run_llm(query: str, chat_history: list[dict[str, any]]) -> any: conversation = ConversationChain( llm=chat, verbose=True, + chat_history=chat_history ) return conversation.predict(input=query) diff --git a/resume/views.py b/resume/views.py index f5cf03a..be94255 100644 --- a/resume/views.py +++ b/resume/views.py @@ -149,7 +149,20 @@ class GenerateResumeView(APIView): ), ], ) - def get(self, request): + 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) + question = request.GET.get("question") guidelines = request.GET.get("guidelines") answers = request.GET.get("answers") @@ -291,24 +304,23 @@ class ChatView(APIView): @extend_schema( summary="챗봇 대화", description="챗봇과의 대화를 통해 자기소개서를 업데이트합니다.", - responses={200: PostResumeSerializer}, + responses={200: { + "answer": "string" + }}, request={ "application/json": { "type": "object", "properties": { - "title": {"type": "string"}, - "position": {"type": "string"}, - "content": {"type": "string"}, - "due_date": {"type": "string"}, + "query": {"type": "string"}, }, }, }, ) def post(self, request): query = request.data.query - langchain_answer = run_llm(query=query) + chatbot_answer = run_llm(query=query) return Response( - {"answer": langchain_answer}, + {"answer": chatbot_answer}, status=status.HTTP_200_OK, ) \ No newline at end of file From bc305a22822c3a469c13ce1735b717aa76455a75 Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Tue, 9 Apr 2024 19:03:43 +0900 Subject: [PATCH 33/41] =?UTF-8?q?fix:=20=EB=AA=A8=EB=93=A0=20api=20url?= =?UTF-8?q?=EC=97=90=20/=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- accounts/urls.py | 6 +++--- memos/urls.py | 8 ++++---- resume/urls.py | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/accounts/urls.py b/accounts/urls.py index 4e542ae..569bb27 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -3,12 +3,12 @@ # TODO swagger에 뜨는 api 관리 urlpatterns = [ - path("kakao/", views.kakao_login, name="kakao_login"), + path("kakao", views.kakao_login, name="kakao_login"), path( - "kakao/login/", + "kakao/login", views.KakaoLoginView.as_view(), name="kakao_login_todjango", ), - path("update/", views.UpdateUserInfoView.as_view(), name="update_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/memos/urls.py b/memos/urls.py index 1220c85..c04ef75 100644 --- a/memos/urls.py +++ b/memos/urls.py @@ -5,8 +5,8 @@ urlpatterns = [ path("", views.PostMemoView.as_view(), name="post_memo"), path("all", views.GetAllMemoView.as_view(), name="get_all_memos"), - path("memo//", 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"), + path("memo/", 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/resume/urls.py b/resume/urls.py index ea3ac85..c06c361 100644 --- a/resume/urls.py +++ b/resume/urls.py @@ -3,9 +3,9 @@ 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("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"), From d0e194e58fa6bd65a3a398df7e37aaf8b6af1580 Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Wed, 10 Apr 2024 16:39:16 +0900 Subject: [PATCH 34/41] =?UTF-8?q?feat:=20=EC=9E=90=EC=86=8C=EC=84=9C=20mod?= =?UTF-8?q?el=EC=97=90=20question=20=EC=A0=80=EC=9E=A5=EB=90=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=84=A4=EC=A0=95=20=ED=9B=84=20generate=20?= =?UTF-8?q?=EC=8B=9C=20=EC=9E=90=EC=86=8C=EC=84=9C=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../0003_resume_question_chathistory.py | 42 +++++++++++ .../migrations/0004_alter_resume_due_date.py | 18 +++++ .../migrations/0005_alter_resume_due_date.py | 18 +++++ resume/models.py | 5 +- resume/serializers.py | 1 + resume/utils.py | 2 +- resume/views.py | 73 ++++++++++++------- 7 files changed, 129 insertions(+), 30 deletions(-) create mode 100644 resume/migrations/0003_resume_question_chathistory.py create mode 100644 resume/migrations/0004_alter_resume_due_date.py create mode 100644 resume/migrations/0005_alter_resume_due_date.py 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/models.py b/resume/models.py index 2bef061..60215e1 100644 --- a/resume/models.py +++ b/resume/models.py @@ -6,8 +6,9 @@ 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) # 지원하려는 기업의 지원하려는 직무 - content = models.TextField() - due_date = models.DateTimeField(null=True) + 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) diff --git a/resume/serializers.py b/resume/serializers.py index 17cc067..6c6867b 100644 --- a/resume/serializers.py +++ b/resume/serializers.py @@ -17,6 +17,7 @@ class Meta: fields = ( "title", "position", + "question", "content", "due_date", "created_at", diff --git a/resume/utils.py b/resume/utils.py index 5e0ffa5..844f613 100644 --- a/resume/utils.py +++ b/resume/utils.py @@ -3,7 +3,7 @@ import environ from pinecone import Pinecone -# from utils.openai_call import get_embedding +from utils.openai_call import get_embedding from langchain_community.chat_models import ChatOpenAI from langchain.chains import ConversationChain diff --git a/resume/views.py b/resume/views.py index be94255..a56119b 100644 --- a/resume/views.py +++ b/resume/views.py @@ -106,14 +106,25 @@ class GenerateResumeView(APIView): summary="자기소개서 생성", description="답변을 기반으로 자기소개서를 생성합니다.", responses={ - 200: inline_serializer( - name="GenerateResumeResponse", - fields={ - "result": serializers.CharField(), - }, - ) + 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, @@ -150,24 +161,14 @@ class GenerateResumeView(APIView): ], ) 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) - - question = request.GET.get("question") - guidelines = request.GET.get("guidelines") - answers = request.GET.get("answers") - free_answer = request.GET.get("free_answer") - favor_info = request.GET.get("favor_info") + 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 = "" @@ -201,9 +202,27 @@ def post(self, request): # 자소서 생성 generated_self_introduction = get_chat_openai(prompt) - generated_self_introduction_json = {"result": generated_self_introduction} - return JsonResponse(generated_self_introduction_json, status=200) + 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(): + # 유효한 데이터의 경우, 자소서 저장 + 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 PostResumeView(APIView): @@ -320,7 +339,7 @@ def post(self, request): query = request.data.query chatbot_answer = run_llm(query=query) - return Response( + return JsonResponse( {"answer": chatbot_answer}, status=status.HTTP_200_OK, ) \ No newline at end of file From c2127268765c91b09ea14c9592e68cf1f67e1ee2 Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Wed, 10 Apr 2024 16:55:52 +0900 Subject: [PATCH 35/41] =?UTF-8?q?feat:=20=ED=8A=B9=EC=A0=95=20=EC=9E=90?= =?UTF-8?q?=EC=86=8C=EC=84=9C=20=EB=B0=9B=EC=95=84=EC=98=A4=EB=8A=94=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- memos/urls.py | 2 +- resume/urls.py | 3 +- resume/views.py | 87 +++++++++++++++++++++++++++++++------------------ 3 files changed, 59 insertions(+), 33 deletions(-) diff --git a/memos/urls.py b/memos/urls.py index c04ef75..d91bddf 100644 --- a/memos/urls.py +++ b/memos/urls.py @@ -5,7 +5,7 @@ urlpatterns = [ path("", views.PostMemoView.as_view(), name="post_memo"), path("all", views.GetAllMemoView.as_view(), name="get_all_memos"), - path("memo/", views.GetMemoDetailView.as_view(), name="memo-detail"), + 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/resume/urls.py b/resume/urls.py index c06c361..34834bf 100644 --- a/resume/urls.py +++ b/resume/urls.py @@ -6,8 +6,9 @@ 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("", 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"), ] diff --git a/resume/views.py b/resume/views.py index a56119b..9804b36 100644 --- a/resume/views.py +++ b/resume/views.py @@ -216,51 +216,75 @@ def post(self, request): # 데이터 유효성 검사 if serializer.is_valid(): # 유효한 데이터의 경우, 자소서 저장 - serializer.save( + saved_instance = serializer.save( user=request.user ) # 현재 로그인한 사용자를 자소서의 user 필드에 저장 - return Response(serializer.data, status=status.HTTP_201_CREATED) + 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): +# 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="자기소개서를 등록합니다.", + 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) + def get(self, request, pk, format=None): + print(pk) + 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] # 인증된 사용자만 접근 가능하도록 설정 + permission_classes = [IsAuthenticated] @extend_schema( summary="자기소개서 업데이트", @@ -342,4 +366,5 @@ def post(self, request): return JsonResponse( {"answer": chatbot_answer}, status=status.HTTP_200_OK, - ) \ No newline at end of file + ) + From ee7020e73bef72dec6e6eaf7384003c42d2ae6cc Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Wed, 10 Apr 2024 17:00:21 +0900 Subject: [PATCH 36/41] =?UTF-8?q?reformat:=20black=EC=9C=BC=EB=A1=9C=20cod?= =?UTF-8?q?e=20reformat=20=EB=B0=8F=20api=20uri=20=EB=A7=88=EC=A7=80?= =?UTF-8?q?=EB=A7=89=EC=97=90=20/=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- accounts/urls.py | 8 ++++---- memos/urls.py | 10 +++++----- resumai/urls.py | 6 +++--- resume/models.py | 5 +++-- resume/urls.py | 14 +++++++------- resume/utils.py | 8 +++----- resume/views.py | 41 ++++++++++++++++++++++------------------- 7 files changed, 47 insertions(+), 45 deletions(-) diff --git a/accounts/urls.py b/accounts/urls.py index 569bb27..3687ea6 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -3,12 +3,12 @@ # TODO swagger에 뜨는 api 관리 urlpatterns = [ - path("kakao", views.kakao_login, name="kakao_login"), + path("/kakao", views.kakao_login, name="kakao_login"), path( - "kakao/login", + "/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/memos/urls.py b/memos/urls.py index d91bddf..b06b164 100644 --- a/memos/urls.py +++ b/memos/urls.py @@ -4,9 +4,9 @@ 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"), + 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/resumai/urls.py b/resumai/urls.py index ef1fff7..f0858c8 100644 --- a/resumai/urls.py +++ b/resumai/urls.py @@ -36,10 +36,10 @@ def preprocessing_filter_spec(endpoints): urlpatterns = [ path("", kakao_login_page, name="home"), path("admin/", admin.site.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")), + path("memos", include("memos.urls")), + path("resume", include("resume.urls")), # swagger 관련 path("api/schema/", SpectacularAPIView.as_view(), name="schema"), path( diff --git a/resume/models.py b/resume/models.py index 60215e1..d3b19d5 100644 --- a/resume/models.py +++ b/resume/models.py @@ -6,8 +6,8 @@ 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 + 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) @@ -17,6 +17,7 @@ class Resume(models.Model): def __str__(self): return self.title + class ChatHistory(models.Model): resume = models.ForeignKey(Resume, on_delete=models.CASCADE) # history = diff --git a/resume/urls.py b/resume/urls.py index 34834bf..afa1801 100644 --- a/resume/urls.py +++ b/resume/urls.py @@ -3,12 +3,12 @@ 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("/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("/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"), ] diff --git a/resume/utils.py b/resume/utils.py index 844f613..d72b08d 100644 --- a/resume/utils.py +++ b/resume/utils.py @@ -14,6 +14,7 @@ pc = Pinecone(api_key=os.environ.get("PINECONE_API_KEY")) + def retrieve_similar_answers(user_qa): try: pc = Pinecone() @@ -28,11 +29,8 @@ def retrieve_similar_answers(user_qa): print(e) return [] + def run_llm(query: str, chat_history: list[dict[str, any]]) -> any: chat = ChatOpenAI(verbose=True, temperature=0, model_name="gpt-4") - conversation = ConversationChain( - llm=chat, - verbose=True, - chat_history=chat_history - ) + conversation = ConversationChain(llm=chat, verbose=True, chat_history=chat_history) return conversation.predict(input=query) diff --git a/resume/views.py b/resume/views.py index 9804b36..457ae20 100644 --- a/resume/views.py +++ b/resume/views.py @@ -95,7 +95,9 @@ def get(self, request): guideline_json = {"result": guideline_list} return JsonResponse(guideline_json) except Exception as e: - error_message = {"error": "가이드라인 생성 중 오류가 발생했습니다. 질문을 올바르게 입력해 주세요."} + error_message = { + "error": "가이드라인 생성 중 오류가 발생했습니다. 질문을 올바르게 입력해 주세요." + } return JsonResponse(error_message, status=500) @@ -105,9 +107,7 @@ class GenerateResumeView(APIView): @extend_schema( summary="자기소개서 생성", description="답변을 기반으로 자기소개서를 생성합니다.", - responses={ - 200: PostResumeSerializer - }, + responses={200: PostResumeSerializer}, parameters=[ OpenApiParameter( name="title", @@ -123,7 +123,7 @@ class GenerateResumeView(APIView): name="due_date", type=str, style="date", - description="공고 마감기한. 그냥 str 형식으로 \"2024-04-10\" 이렇게 보내주삼", + description='공고 마감기한. 그냥 str 형식으로 "2024-04-10" 이렇게 보내주삼', ), OpenApiParameter( name="question", @@ -182,7 +182,9 @@ def post(self, request): # 예시 retrieve examples = retrieve_similar_answers(total_answer) if len(examples) == 0: - error_message = {"error": "유사한 질문을 가져오는 도중 문제가 발생했습니다. 다시 시도해 주세요."} + error_message = { + "error": "유사한 질문을 가져오는 도중 문제가 발생했습니다. 다시 시도해 주세요." + } return JsonResponse(error_message, status=500) examples_str = "\n\n".join( @@ -203,15 +205,17 @@ def post(self, request): # 자소서 생성 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 - }) + 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(): @@ -258,6 +262,7 @@ def post(self, request): # # 데이터가 유효하지 않은 경우, 에러 메시지 반환 # return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + class GetResumeView(APIView): permission_classes = [IsAuthenticated] @@ -342,14 +347,13 @@ def get(self, request, *args, **kwargs): {"error": "Resume not found"}, status=status.HTTP_404_NOT_FOUND ) + class ChatView(APIView): @extend_schema( summary="챗봇 대화", description="챗봇과의 대화를 통해 자기소개서를 업데이트합니다.", - responses={200: { - "answer": "string" - }}, + responses={200: {"answer": "string"}}, request={ "application/json": { "type": "object", @@ -367,4 +371,3 @@ def post(self, request): {"answer": chatbot_answer}, status=status.HTTP_200_OK, ) - From b7d43c970fc081bbaa6d1fe56e9ce68a2b2bb859 Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Thu, 11 Apr 2024 01:39:33 +0900 Subject: [PATCH 37/41] =?UTF-8?q?feat:=20memory=EB=A5=BC=20=ED=99=9C?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EC=97=AC=20=EC=B1=84=ED=8C=85=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=ED=95=98=EB=8F=84=EB=A1=9D=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- memos/views.py | 6 +- requirements.txt | 5 +- ..._chathistory_query_chathistory_response.py | 23 +++++++ resume/models.py | 3 +- resume/urls.py | 2 +- resume/utils.py | 13 +++- resume/views.py | 64 ++++++++++++++----- utils/prompts.py | 10 +++ 8 files changed, 101 insertions(+), 25 deletions(-) create mode 100644 resume/migrations/0006_chathistory_query_chathistory_response.py diff --git a/memos/views.py b/memos/views.py index fc2208b..53c8aaa 100644 --- a/memos/views.py +++ b/memos/views.py @@ -54,7 +54,7 @@ class GetAllMemoView(APIView, PageNumberPagination): permission_classes = [IsAuthenticated] @extend_schema( - summary="전체 메모를 받아옵니다.", + summary="전체 메모 조회", description="사용자가 작성한 전체 메모를 받아옵니다.", responses={ 200: inline_serializer( @@ -97,7 +97,7 @@ def get_object(self, pk, user): raise Http404 @extend_schema( - summary="특정 메모를 받아옵니다.", + summary="특정 메모 조회", description="사용자가 작성한 특정 메모의 디테일 받아옵니다.", responses={200: MemoSerializer}, ) @@ -150,7 +150,7 @@ class UpdateMemoView(APIView): permission_classes = [IsAuthenticated] # 인증된 사용자만 접근 가능하도록 설정 @extend_schema( - summary="자기소개서 업데이트", + summary="메모 업데이트", request=PostMemoSerializer, responses={200: PostMemoSerializer}, ) diff --git a/requirements.txt b/requirements.txt index 67e6532..af2e3e9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -38,7 +38,8 @@ jsonschema==4.21.1 jsonschema-specifications==2023.12.1 langchain==0.1.14 langchain-community==0.0.31 -langchain-core==0.1.40 +langchain-core==0.1.41 +langchain-openai==0.1.2 langchain-text-splitters==0.0.1 langsmith==0.1.41 marshmallow==3.21.1 @@ -62,6 +63,7 @@ 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 @@ -69,6 +71,7 @@ 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 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/models.py b/resume/models.py index d3b19d5..183a521 100644 --- a/resume/models.py +++ b/resume/models.py @@ -20,6 +20,7 @@ def __str__(self): class ChatHistory(models.Model): resume = models.ForeignKey(Resume, on_delete=models.CASCADE) - # history = + 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/urls.py b/resume/urls.py index afa1801..8de472e 100644 --- a/resume/urls.py +++ b/resume/urls.py @@ -9,6 +9,6 @@ # 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("//chat", views.ChatView.as_view(), name="chat"), path("/", views.GetResumeView.as_view(), name="update_resume"), ] diff --git a/resume/utils.py b/resume/utils.py index d72b08d..b53775f 100644 --- a/resume/utils.py +++ b/resume/utils.py @@ -5,8 +5,9 @@ from pinecone import Pinecone from utils.openai_call import get_embedding -from langchain_community.chat_models import ChatOpenAI +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 @@ -30,7 +31,13 @@ def retrieve_similar_answers(user_qa): 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: - chat = ChatOpenAI(verbose=True, temperature=0, model_name="gpt-4") - conversation = ConversationChain(llm=chat, verbose=True, chat_history=chat_history) + 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 index 457ae20..6c36281 100644 --- a/resume/views.py +++ b/resume/views.py @@ -19,7 +19,7 @@ OpenApiParameter, ) -from resume.models import Resume +from resume.models import Resume, ChatHistory from resume.serializers import ( GenerateResumeSerializer, PostResumeSerializer, @@ -27,14 +27,14 @@ ) 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 +from utils.prompts import GUIDELINE_PROMPT, GENERATE_SELF_INTRODUCTION_PROMPT, CHAT_PROMPT class GetAllResumeView(APIView, PageNumberPagination): permission_classes = [IsAuthenticated] @extend_schema( - summary="전체 자기소개서를 받아옵니다.", + summary="전체 자소서 조회", description="사용자가 작성한 전체 자기소개서를 받아옵니다.", responses={ 200: inline_serializer( @@ -105,7 +105,7 @@ class GenerateResumeView(APIView): permission_classes = [IsAuthenticated] @extend_schema( - summary="자기소개서 생성", + summary="자소서 생성", description="답변을 기반으로 자기소개서를 생성합니다.", responses={200: PostResumeSerializer}, parameters=[ @@ -222,7 +222,11 @@ def post(self, request): # 유효한 데이터의 경우, 자소서 저장 saved_instance = serializer.save( user=request.user - ) # 현재 로그인한 사용자를 자소서의 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: # 데이터가 유효하지 않은 경우, 에러 메시지 반환 @@ -277,12 +281,11 @@ def get_object(self, pk, user): raise Http404 @extend_schema( - summary="특정 자소서를 받아옵니다.", + summary="특정 자소서 조회", description="사용자가 작성한 특정 자소서의 디테일을 받아옵니다.", responses={200: PostResumeSerializer}, ) def get(self, request, pk, format=None): - print(pk) resume = self.get_object(pk, request.user) serializer = PostResumeSerializer(resume) return Response(serializer.data, status=status.HTTP_200_OK) @@ -292,7 +295,7 @@ class UpdateResumeView(APIView): permission_classes = [IsAuthenticated] @extend_schema( - summary="자기소개서 업데이트", + summary="자소서 업데이트", request=UpdateResumeSerializer, responses={200: PostResumeSerializer}, ) @@ -323,6 +326,19 @@ def put(self, request, *args, **kwargs): 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: @@ -349,10 +365,11 @@ def get(self, request, *args, **kwargs): class ChatView(APIView): + permission_classes = [IsAuthenticated] @extend_schema( summary="챗봇 대화", - description="챗봇과의 대화를 통해 자기소개서를 업데이트합니다.", + description="챗봇과의 대화를 통해 자기소개서를 첨삭 받습니다..", responses={200: {"answer": "string"}}, request={ "application/json": { @@ -363,11 +380,26 @@ class ChatView(APIView): }, }, ) - def post(self, request): - query = request.data.query - chatbot_answer = run_llm(query=query) + def post(self, request, id): + query = request.data.get("query", "") - return JsonResponse( - {"answer": chatbot_answer}, - status=status.HTTP_200_OK, - ) + 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) diff --git a/utils/prompts.py b/utils/prompts.py index 3105b44..755ac12 100644 --- a/utils/prompts.py +++ b/utils/prompts.py @@ -41,3 +41,13 @@ Q: 당신의 '{{question}}'에 대해서 소개해주세요. A: """ + +CHAT_PROMPT = f""" +당신은 자기소개서 컨설턴트입니다. +당신은 이전 대화에서 생성된 자기소개서를 보고, 고객의 요구사항과 공고 우대사항을 반영하여 유용한 자기소개서를 생성해야 합니다. + +고객의 요구사항은 다음과 같습니다. +{{query}} + +당신은 **오직 첨삭된 자기소개서만으로 대답할 수 있습니다**. +""" \ No newline at end of file From 7a34d841865c85220629073d233cb708f67ed924 Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Thu, 11 Apr 2024 01:40:08 +0900 Subject: [PATCH 38/41] =?UTF-8?q?reformat:=20black=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20reformatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resume/utils.py | 5 +++-- resume/views.py | 27 +++++++++++++++++---------- utils/prompts.py | 2 +- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/resume/utils.py b/resume/utils.py index b53775f..9d09936 100644 --- a/resume/utils.py +++ b/resume/utils.py @@ -33,11 +33,12 @@ def retrieve_similar_answers(user_qa): 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"]} + 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 index 6c36281..a8a4e34 100644 --- a/resume/views.py +++ b/resume/views.py @@ -27,7 +27,11 @@ ) 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 +from utils.prompts import ( + GUIDELINE_PROMPT, + GENERATE_SELF_INTRODUCTION_PROMPT, + CHAT_PROMPT, +) class GetAllResumeView(APIView, PageNumberPagination): @@ -220,11 +224,11 @@ def post(self, request): # 데이터 유효성 검사 if serializer.is_valid(): # 유효한 데이터의 경우, 자소서 저장 - saved_instance = serializer.save( - user=request.user - ) + 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 = ChatHistory( + resume=resume, query=prompt, response=generated_self_introduction + ) new_chat_history.save() return Response({"id": saved_instance.id}, status=status.HTTP_201_CREATED) @@ -387,18 +391,21 @@ def post(self, request, id): # 해당 resume에 대한 이전 대화 내역을 가져옴 chat_history_instances = ChatHistory.objects.filter(resume=resume) - chat_history = [{"query": instance.query, "response": instance.response} for instance in chat_history_instances] + 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 - ) + 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 = ChatHistory( + resume=resume, query=query, response=chatbot_response + ) new_chat_history.save() # 챗봇의 응답을 반환 diff --git a/utils/prompts.py b/utils/prompts.py index 755ac12..bf40caa 100644 --- a/utils/prompts.py +++ b/utils/prompts.py @@ -50,4 +50,4 @@ {{query}} 당신은 **오직 첨삭된 자기소개서만으로 대답할 수 있습니다**. -""" \ No newline at end of file +""" From 1068942367dc205e3cd721507220d4504affca53 Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Thu, 11 Apr 2024 12:47:37 +0900 Subject: [PATCH 39/41] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20=EB=B0=98=ED=99=98=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resume/serializers.py | 11 ++++++++++- resume/urls.py | 1 + resume/views.py | 35 ++++++++++++++++++++++++++++++++++- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/resume/serializers.py b/resume/serializers.py index 6c6867b..fe9da01 100644 --- a/resume/serializers.py +++ b/resume/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from resume.models import Resume +from resume.models import Resume, ChatHistory class GenerateResumeSerializer(serializers.Serializer): @@ -43,3 +43,12 @@ class Meta: "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/urls.py b/resume/urls.py index 8de472e..2768b83 100644 --- a/resume/urls.py +++ b/resume/urls.py @@ -11,4 +11,5 @@ 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="update_resume"), ] diff --git a/resume/views.py b/resume/views.py index a8a4e34..6a82dec 100644 --- a/resume/views.py +++ b/resume/views.py @@ -23,7 +23,7 @@ from resume.serializers import ( GenerateResumeSerializer, PostResumeSerializer, - UpdateResumeSerializer, + UpdateResumeSerializer, ChatHistorySerializer, ) from resume.utils import retrieve_similar_answers, run_llm from utils.openai_call import get_chat_openai @@ -410,3 +410,36 @@ def post(self, request, id): # 챗봇의 응답을 반환 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) From d29732201c47a619ec4e9d6ac0b4fd40fa2dbf3b Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Thu, 11 Apr 2024 12:59:48 +0900 Subject: [PATCH 40/41] =?UTF-8?q?feat:=20=EC=9E=90=EA=B8=B0=EC=86=8C?= =?UTF-8?q?=EA=B0=9C=EC=84=9C=20=EC=82=AD=EC=A0=9C=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- memos/urls.py | 2 +- memos/views.py | 2 +- resume/urls.py | 3 ++- resume/views.py | 29 +++++++++++++++++++++++++++++ 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/memos/urls.py b/memos/urls.py index b06b164..59ed34c 100644 --- a/memos/urls.py +++ b/memos/urls.py @@ -8,5 +8,5 @@ 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"), + path("/search", views.SearchMemoView.as_view(), name="search-memo") ] diff --git a/memos/views.py b/memos/views.py index 53c8aaa..e5897c4 100644 --- a/memos/views.py +++ b/memos/views.py @@ -124,7 +124,7 @@ def get_object(self, pk, user): summary="메모 삭제", description="특정 메모를 삭제합니다. 메모를 작성한 사용자만 해당 메모를 삭제할 수 있습니다.", responses={ - 204: None, # 성공적으로 삭제되었을 때, 특별한 응답 본문은 없음 + 204: {"description": "메모가 성공적으로 삭제되었습니다."} 404: {"description": "해당 메모를 찾을 수 없거나 삭제할 권한이 없습니다."}, }, ) diff --git a/resume/urls.py b/resume/urls.py index 2768b83..9d7803e 100644 --- a/resume/urls.py +++ b/resume/urls.py @@ -11,5 +11,6 @@ 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="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/views.py b/resume/views.py index 6a82dec..5924a4a 100644 --- a/resume/views.py +++ b/resume/views.py @@ -443,3 +443,32 @@ def get(self, request, pk): 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, + ) \ No newline at end of file From 7a3aebc3a4a696f9e044f66444dd47e0026036da Mon Sep 17 00:00:00 2001 From: yjoonjang Date: Thu, 11 Apr 2024 13:01:23 +0900 Subject: [PATCH 41/41] =?UTF-8?q?reformat:=20=EC=89=BC=ED=91=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20black=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20reformatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- memos/urls.py | 2 +- memos/views.py | 2 +- resume/serializers.py | 7 ++----- resume/urls.py | 8 ++++++-- resume/views.py | 21 +++++++++++++++------ 5 files changed, 25 insertions(+), 15 deletions(-) diff --git a/memos/urls.py b/memos/urls.py index 59ed34c..b06b164 100644 --- a/memos/urls.py +++ b/memos/urls.py @@ -8,5 +8,5 @@ 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") + path("/search", views.SearchMemoView.as_view(), name="search-memo"), ] diff --git a/memos/views.py b/memos/views.py index e5897c4..732386a 100644 --- a/memos/views.py +++ b/memos/views.py @@ -124,7 +124,7 @@ def get_object(self, pk, user): summary="메모 삭제", description="특정 메모를 삭제합니다. 메모를 작성한 사용자만 해당 메모를 삭제할 수 있습니다.", responses={ - 204: {"description": "메모가 성공적으로 삭제되었습니다."} + 204: {"description": "메모가 성공적으로 삭제되었습니다."}, 404: {"description": "해당 메모를 찾을 수 없거나 삭제할 권한이 없습니다."}, }, ) diff --git a/resume/serializers.py b/resume/serializers.py index fe9da01..a1d2bdf 100644 --- a/resume/serializers.py +++ b/resume/serializers.py @@ -44,11 +44,8 @@ class Meta: ) read_only_fields = ("created_at", "updated_at") + class ChatHistorySerializer(serializers.ModelSerializer): class Meta: model = ChatHistory - fields = ( - "query", - "response", - "created_at" - ) + fields = ("query", "response", "created_at") diff --git a/resume/urls.py b/resume/urls.py index 9d7803e..ee1715f 100644 --- a/resume/urls.py +++ b/resume/urls.py @@ -11,6 +11,10 @@ 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") + path( + "//chatHistory", + views.GetChatHistoryView.as_view(), + name="get_chat_history", + ), + path("/delete/", views.DeleteResumeView.as_view(), name="delete_resume"), ] diff --git a/resume/views.py b/resume/views.py index 5924a4a..550bca4 100644 --- a/resume/views.py +++ b/resume/views.py @@ -23,7 +23,8 @@ from resume.serializers import ( GenerateResumeSerializer, PostResumeSerializer, - UpdateResumeSerializer, ChatHistorySerializer, + UpdateResumeSerializer, + ChatHistorySerializer, ) from resume.utils import retrieve_similar_answers, run_llm from utils.openai_call import get_chat_openai @@ -411,6 +412,7 @@ def post(self, request, id): # 챗봇의 응답을 반환 return JsonResponse({"answer": chatbot_response}, status=status.HTTP_200_OK) + class GetChatHistoryView(APIView): permission_classes = [IsAuthenticated] @@ -434,16 +436,21 @@ class GetChatHistoryView(APIView): 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() + 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}) + serializer = ChatHistorySerializer( + result_page, many=True, context={"request": request} + ) return paginator.get_paginated_response(serializer.data) + class DeleteResumeView(APIView): permission_classes = [IsAuthenticated] @@ -462,7 +469,9 @@ def get_object(self, pk, user): description="특정 자소서를 삭제합니다. 자소서를 작성한 사용자만 해당 자소서를 삭제할 수 있습니다.", responses={ 204: {"description": "자기소개서가 성공적으로 삭제되었습니다."}, - 404: {"description": "해당 자소서를 찾을 수 없거나 삭제할 권한이 없습니다."}, + 404: { + "description": "해당 자소서를 찾을 수 없거나 삭제할 권한이 없습니다." + }, }, ) def delete(self, request, pk, format=None): @@ -471,4 +480,4 @@ def delete(self, request, pk, format=None): return JsonResponse( {"status": "success", "message": "Resume deleted successfully."}, status=status.HTTP_204_NO_CONTENT, - ) \ No newline at end of file + )