diff --git a/.github/workflows/deploy_to_ecs.yml b/.github/workflows/deploy_to_ecs.yml index a66e7ef..71dba50 100644 --- a/.github/workflows/deploy_to_ecs.yml +++ b/.github/workflows/deploy_to_ecs.yml @@ -12,6 +12,14 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v2 + with: + token: ${{ secrets.ACCESS_TOKEN }} + submodules: true + + - name: Update version.txt + run: | + VERSION=$(TZ=Asia/Seoul date +'%Y.%m.%d.%H.%M.%S') # 한국 시간대 사용 + echo "onestep_dev@${VERSION}" > version.txt - name: Set Dockerfile Path id: dockerfile-path diff --git a/.github/workflows/deploy_to_ecs_prod.yml b/.github/workflows/deploy_to_ecs_prod.yml index 6330cbd..523c3bc 100644 --- a/.github/workflows/deploy_to_ecs_prod.yml +++ b/.github/workflows/deploy_to_ecs_prod.yml @@ -12,6 +12,14 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v2 + with: + token: ${{ secrets.ACCESS_TOKEN }} + submodules: true + + - name: Update version.txt + run: | + VERSION=$(TZ=Asia/Seoul date +'%Y.%m.%d.%H.%M.%S') # 한국 시간대 사용 + echo "onestep_prod@${VERSION}" > version.txt - name: Set Dockerfile Path id: dockerfile-path diff --git a/.github/workflows/deploy_to_ecs_test.yml b/.github/workflows/deploy_to_ecs_test.yml index dd3cb67..bbdfff0 100644 --- a/.github/workflows/deploy_to_ecs_test.yml +++ b/.github/workflows/deploy_to_ecs_test.yml @@ -12,6 +12,9 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v2 + with: + token: ${{ secrets.ACCESS_TOKEN }} + submodules: true - name: Set Dockerfile Path id: dockerfile-path diff --git a/.gitignore b/.gitignore index 499059d..8dbc09d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ # Icon must end with two \r Icon +# example.py +example_1.py + onestep_dev/ # Thumbnails ._* @@ -91,6 +94,7 @@ db.sqlite3 /pyenv /.ruff_cache +/patch_note # debug .vscode/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..e844430 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "Lexorank"] + path = Lexorank + url = ../Lexorank diff --git a/Dockerfile.dev b/Dockerfile.dev index d977c6c..1bd1e3e 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -15,5 +15,6 @@ COPY . /app/ EXPOSE 8000 # 서버 실행 명령 -CMD ["sh", "-c", "python manage.py makemigrations && python manage.py migrate && DJANGO_SETTINGS_MODULE=onestep_be.setting.dev gunicorn -w 2 --timeout 300 -b 0.0.0.0:8000 onestep_be.wsgi:application"] +CMD ["sh", "-c", "python manage.py makemigrations && python manage.py migrate && DJANGO_SETTINGS_MODULE=onestep_be.setting.dev python -m gunicorn -w 2 -b 0.0.0.0:8000 onestep_be.asgi:application -k uvicorn.workers.UvicornWorker"] +# CMD ["sh", "-c", "python manage.py makemigrations && python manage.py migrate && DJANGO_SETTINGS_MODULE=onestep_be.setting.dev gunicorn -w 2 --timeout 300 -b 0.0.0.0:8000 onestep_be.wsgi:application"] # CMD ["sh", "-c", "python manage.py migrate && python manage.py runserver 0.0.0.0:8000 --settings=onestep_be.setting.dev"] \ No newline at end of file diff --git a/Dockerfile.prod b/Dockerfile.prod index 0b7586c..62e1f3c 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -15,5 +15,6 @@ COPY . /app/ EXPOSE 8000 # 서버 실행 명령 -CMD ["sh", "-c", "python manage.py makemigrations && python manage.py migrate && DJANGO_SETTINGS_MODULE=onestep_be.setting.prod gunicorn -w 2 --timeout 300 -b 0.0.0.0:8000 onestep_be.wsgi:application"] +CMD ["sh", "-c", "python manage.py makemigrations && python manage.py migrate && DJANGO_SETTINGS_MODULE=onestep_be.setting.prod python -m gunicorn -w 2 -b 0.0.0.0:8000 onestep_be.asgi:application -k uvicorn.workers.UvicornWorker"] +# CMD ["sh", "-c", "python manage.py makemigrations && python manage.py migrate && DJANGO_SETTINGS_MODULE=onestep_be.setting.prod gunicorn -w 2 --timeout 300 -b 0.0.0.0:8000 onestep_be.wsgi:application"] # CMD ["sh", "-c", "python manage.py migrate && python manage.py runserver 0.0.0.0:8000 --settings=onestep_be.setting.prod"] diff --git a/Dockerfile.test b/Dockerfile.test index 90d5082..7f1ab2a 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -15,5 +15,6 @@ COPY . /app/ EXPOSE 8000 # 서버 실행 명령 -CMD ["sh", "-c", "python manage.py makemigrations && python manage.py migrate && DJANGO_SETTINGS_MODULE=onestep_be.setting.test gunicorn -w 2 --timeout 300 -b 0.0.0.0:8000 onestep_be.wsgi:application"] +CMD ["sh", "-c", "python manage.py makemigrations && python manage.py migrate && DJANGO_SETTINGS_MODULE=onestep_be.setting.test python -m gunicorn -w 2 -b 0.0.0.0:8000 onestep_be.asgi:application -k uvicorn.workers.UvicornWorker"] +# CMD ["sh", "-c", "python manage.py makemigrations && python manage.py migrate && DJANGO_SETTINGS_MODULE=onestep_be.setting.test gunicorn -w 2 --timeout 300 -b 0.0.0.0:8000 onestep_be.wsgi:application"] # CMD ["sh", "-c", "python manage.py migrate && python manage.py runserver 0.0.0.0:8000 --settings=onestep_be.setting.test"] diff --git a/Lexorank b/Lexorank new file mode 160000 index 0000000..19f5317 --- /dev/null +++ b/Lexorank @@ -0,0 +1 @@ +Subproject commit 19f5317c6ab232eab0f1a7707d7e6462f84218cb diff --git a/accounts/admin.py b/accounts/admin.py index 846f6b4..abc5ef3 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1 +1,63 @@ # Register your models here. +from django.contrib import admin + +from .models import Device, PatchNote, User + + +class UserAdmin(admin.ModelAdmin): + readonly_fields = [ + "id", + "social_provider", + "username", + "created_at", + "updated_at", + ] + list_display = [ + "id", + "username", + "social_provider", + "created_at", + "deleted_at", + "updated_at", + "is_active", + "is_staff", + "is_superuser", + ] + fieldsets = [ + ( + None, + { + "fields": [ + "id", + "username", + "social_provider", + "created_at", + "deleted_at", + "updated_at", + "is_active", + "is_staff", + "is_superuser", + ] + }, + ), + ] + + +class DeviceAdmin(admin.ModelAdmin): + readonly_fields = ["id", "created_at"] + list_display = ["id", "user_id", "created_at", "deleted_at"] + fieldsets = [ + ( + None, + {"fields": ["id", "user_id", "created_at", "deleted_at"]}, + ), + ] + + +class PatchNoteAdmin(admin.ModelAdmin): + list_display = ("title", "created_at") + + +admin.site.register(User, UserAdmin) +admin.site.register(Device, DeviceAdmin) +admin.site.register(PatchNote, PatchNoteAdmin) diff --git a/accounts/authentication.py b/accounts/authentication.py new file mode 100644 index 0000000..6e780ce --- /dev/null +++ b/accounts/authentication.py @@ -0,0 +1,29 @@ +from rest_framework_simplejwt.authentication import JWTAuthentication +from rest_framework_simplejwt.exceptions import InvalidToken +from rest_framework_simplejwt.settings import api_settings +from jwt import DecodeError, ExpiredSignatureError +import jwt + + +class CustomJWTAuthentication(JWTAuthentication): + def authenticate(self, request): + header = self.get_header(request) + if header is None: + return None + + raw_token = self.get_raw_token(header) + if raw_token is None: + return None + + try: + validated_token = self.get_validated_token(raw_token) + token_payload = jwt.decode( + raw_token, + api_settings.SIGNING_KEY, + algorithms=[api_settings.ALGORITHM] + ) + request.auth = token_payload + except (InvalidToken, DecodeError, ExpiredSignatureError) as e: + raise InvalidToken(e) + + return self.get_user(validated_token), token_payload diff --git a/accounts/aws.py b/accounts/aws.py index 0b97aaa..67b53bf 100644 --- a/accounts/aws.py +++ b/accounts/aws.py @@ -1,5 +1,4 @@ import os - import boto3 from dotenv import load_dotenv diff --git a/accounts/exceptions.py b/accounts/exceptions.py new file mode 100644 index 0000000..2c8cf72 --- /dev/null +++ b/accounts/exceptions.py @@ -0,0 +1,16 @@ +from rest_framework import exceptions, status + + +class LoginException(exceptions.APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = "device type and token is required" + default_code = "error" + + def __init__(self, detail=None, code=None): + if detail is not None: + self.detail = detail + else: + self.detail = self.default_detail + if code is not None: + self.default_code = code + super().__init__(self.detail, self.default_code) diff --git a/accounts/migrations/0010_patchnote.py b/accounts/migrations/0010_patchnote.py new file mode 100644 index 0000000..b5c2bba --- /dev/null +++ b/accounts/migrations/0010_patchnote.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.6 on 2024-09-22 05:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0009_merge_20240719_1133'), + ] + + operations = [ + migrations.CreateModel( + name='PatchNote', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255)), + ('html_file', models.FileField(upload_to='patch_note/')), + ('created_at', models.DateTimeField(auto_now_add=True, null=True)), + ('email_sent', models.BooleanField(default=False)), + ], + ), + ] diff --git a/accounts/migrations/0011_alter_patchnote_email_sent.py b/accounts/migrations/0011_alter_patchnote_email_sent.py new file mode 100644 index 0000000..7dee1d8 --- /dev/null +++ b/accounts/migrations/0011_alter_patchnote_email_sent.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2024-09-22 06:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0010_patchnote'), + ] + + operations = [ + migrations.AlterField( + model_name='patchnote', + name='email_sent', + field=models.BooleanField(default=True), + ), + ] diff --git a/accounts/migrations/0012_patchnote_email_list_alter_patchnote_email_sent.py b/accounts/migrations/0012_patchnote_email_list_alter_patchnote_email_sent.py new file mode 100644 index 0000000..001b300 --- /dev/null +++ b/accounts/migrations/0012_patchnote_email_list_alter_patchnote_email_sent.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.6 on 2024-09-23 06:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0011_alter_patchnote_email_sent'), + ] + + operations = [ + migrations.AddField( + model_name='patchnote', + name='email_list', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='patchnote', + name='email_sent', + field=models.BooleanField(default=False), + ), + ] diff --git a/accounts/migrations/0013_user_is_subscribed.py b/accounts/migrations/0013_user_is_subscribed.py new file mode 100644 index 0000000..f9493e9 --- /dev/null +++ b/accounts/migrations/0013_user_is_subscribed.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2024-09-23 11:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0012_patchnote_email_list_alter_patchnote_email_sent'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='is_subscribed', + field=models.BooleanField(default=False), + ), + ] diff --git a/accounts/migrations/0014_user_is_premium.py b/accounts/migrations/0014_user_is_premium.py new file mode 100644 index 0000000..9b97f85 --- /dev/null +++ b/accounts/migrations/0014_user_is_premium.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2024-09-25 08:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0013_user_is_subscribed'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='is_premium', + field=models.BooleanField(default=False), + ), + ] diff --git a/accounts/migrations/0015_alter_user_social_provider.py b/accounts/migrations/0015_alter_user_social_provider.py new file mode 100644 index 0000000..93d61df --- /dev/null +++ b/accounts/migrations/0015_alter_user_social_provider.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-10-17 07:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0014_user_is_premium'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='social_provider', + field=models.CharField(choices=[('GOOGLE', 'Google'), ('APPLE', 'Apple')], default='GOOGLE', max_length=30), + ), + ] diff --git a/accounts/models.py b/accounts/models.py index 324a603..70b3627 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -1,8 +1,12 @@ +import sentry_sdk from django.contrib.auth.models import AbstractUser from django.contrib.auth.validators import UnicodeUsernameValidator from django.db import models +from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from accounts.utils import send_email, send_welcome_email + class TimeStamp(models.Model): created_at = models.DateTimeField(null=True, auto_now_add=True) @@ -16,8 +20,7 @@ class Meta: class User(AbstractUser, TimeStamp): class SocialProvider(models.TextChoices): GOOGLE = "GOOGLE" - KAKAO = "KAKAO" - NAVER = "NAVER" + APPLE = "APPLE" username_validator = UnicodeUsernameValidator() username = models.CharField( @@ -38,6 +41,35 @@ class SocialProvider(models.TextChoices): choices=SocialProvider.choices, default=SocialProvider.GOOGLE, ) + is_subscribed = models.BooleanField(default=False) + is_premium = models.BooleanField(default=False) + + @classmethod + def get_or_create_user(self, email): + try: + is_new = False + user = User.objects.get(username=email) + if user.deleted_at is not None: + user.deleted_at = None + user.save(update_fields=["deleted_at"]) + is_new = True + except User.DoesNotExist: + user = User.objects.create(username=email, password="") + send_welcome_email( + email, + user.username, + ) + is_new = True + except Exception as e: + sentry_sdk.capture_exception(e) + raise e + + return user, is_new + + def delete_user(instance): + instance.deleted_at = timezone.now() + instance.save(update_fields=["deleted_at"]) + return instance class Device(models.Model): @@ -45,3 +77,43 @@ class Device(models.Model): token = models.CharField(max_length=255, null=True) created_at = models.DateTimeField(null=True, auto_now_add=True) deleted_at = models.DateTimeField(blank=True, null=True) + + +class PatchNote(models.Model): + title = models.CharField(max_length=255) + html_file = models.FileField(upload_to="patch_note/") + created_at = models.DateTimeField(null=True, auto_now_add=True) + email_sent = models.BooleanField(default=False) + email_list = models.TextField(null=True, blank=True) + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + + # 모든 사용자에게 이메일 보내기 + if not self.email_sent: # 이메일이 아직 보내지지 않은 경우 + user_email_list = self.send_email_to_all_users() + self.email_sent = True # 이메일을 보냈으므로 True로 변경 + self.email_list = ", ".join(user_email_list) + self.save(update_fields=["email_sent", "email_list"]) + + def send_email_to_all_users(self): + # 모든 사용자에게 이메일 보내기 + user_email_list = User.objects.filter( + deleted_at__isnull=True, is_staff=False + ).values_list("username", flat=True) + subject = f"OneStep's New Patch Note: {self.title}" + sentry_sdk.capture_message( + f"Patch note title : {self.title}, Sending email to users" + ) + try: + # HTML 파일 내용 읽기 + with open(self.html_file.path, "r") as f: + message = f.read() + send_email( + to_email_address=list(user_email_list), + subject=subject, + message=message, + ) + return user_email_list + except Exception as e: + sentry_sdk.capture_exception(e) diff --git a/accounts/serializers.py b/accounts/serializers.py index 483211b..da64040 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -37,4 +37,11 @@ def is_valid(self, *, raise_exception=False): class UserSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ["id", "email", "username", "social_provider"] + fields = [ + "id", + "email", + "username", + "social_provider", + "is_subscribed", + "is_premium", + ] diff --git a/accounts/templates/welcome_email.html b/accounts/templates/welcome_email.html new file mode 100644 index 0000000..dfa627a --- /dev/null +++ b/accounts/templates/welcome_email.html @@ -0,0 +1,74 @@ + + + + + + 환영합니다! + + + +
+

환영합니다!

+
+
+

안녕하세요, {{ username }} 님 환영합니다!

+

+ 저희 서비스에 가입해 주셔서 진심으로 감사드립니다. 귀하의 선택을 + 환영하며, 함께 멋진 경험을 만들어 나가길 기대합니다. +

+

저희 서비스의 주요 기능:

+ +

+ 궁금한 점이 있으시면 언제든 문의해 주세요. 저희 팀이 항상 도와드릴 + 준비가 되어 있습니다. +

+

감사합니다!

+

Onestep 팀 드림

+
+ + + diff --git a/accounts/tests.py b/accounts/tests.py index 269ce49..e242ddc 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -3,6 +3,8 @@ import pytest from django.contrib.auth import get_user_model from django.urls import reverse +from fcm_django.models import FCMDevice +from rest_framework import status from rest_framework.test import ( APIClient, APIRequestFactory, @@ -28,10 +30,200 @@ def test_user_info(create_user): "email": create_user.email, "username": create_user.username, "social_provider": "GOOGLE", + "is_subscribed": False, + "is_premium": False, } +@pytest.mark.django_db +def test_update_user_is_subscribed( + create_user, + authenticated_client, +): + url = reverse("user") # URL name for the categoryView patch method + data = { + "is_subscribed": True, + } + response = authenticated_client.patch(url, data, format="json") + assert response.status_code == 200 + assert response.data["is_subscribed"] + + +@pytest.mark.django_db +def test_update_user_is_premium( + create_user, + authenticated_client, +): + url = reverse("user") # URL name for the categoryView patch method + data = { + "is_premium": True, + } + response = authenticated_client.patch(url, data, format="json") + assert response.status_code == 200 + assert response.data["is_premium"] + + +@pytest.mark.django_db +def test_delete_user( + create_user, + authenticated_client, +): + url = reverse("user") # URL name for the categoryView patch method + response = authenticated_client.delete(url, {}, format="json") + assert response.status_code == 200 + + assert ( + User.objects.filter( + id=create_user.id, deleted_at__isnull=True + ).exists() + is False + ) + + +@pytest.mark.django_db def test_google_login(invalid_token): client = APIClient() response = client.post(reverse("google_login"), data=invalid_token) assert response.status_code == 400 + + +@pytest.mark.django_db +def test_google_login_missing_device_token(): + client = APIClient() + response = client.post(reverse("google_login")) + assert response.status_code == 400 + + +@pytest.mark.django_db +class TestGoogleLogin: + @pytest.fixture + def api_client(self): + return APIClient() + + @patch("accounts.views.id_token.verify_oauth2_token") + @patch("accounts.models.send_welcome_email") + def test_google_login_android_new_user( + self, mock_send_welcome_email, mock_verify_oauth2_token, api_client + ): + # Mock the token verification response + mock_verify_oauth2_token.return_value = { + "iss": "accounts.google.com", + "email": "testuser@example.com", + } + + # Define the URL for the GoogleLogin view + url = reverse("google_login") + + # Create a mock request + data = { + "token": "mock_token", + "device_token": "mock_device_token", + "type": 0, + } + + # Call the view + response = api_client.post(url, data, format="json") + + # Check that the user was created + user = User.objects.get(username="testuser@example.com") + assert user is not None + + # Check that the device was created + device = FCMDevice.objects.get( + user_id=user.id, registration_id="mock_device_token" + ) + assert device is not None + + # Check that the email was sent + mock_send_welcome_email.assert_called_once_with( + user.username, + user.username, + ) + + # Check the response status + assert response.status_code == status.HTTP_200_OK + assert "refresh" in response.data + assert "access" in response.data + + @patch("accounts.views.id_token.verify_oauth2_token") + @patch("accounts.models.send_welcome_email") + def test_google_login_ios_new_user( + self, mock_send_welcome_email, mock_verify_oauth2_token, api_client + ): + # Mock the token verification response + mock_verify_oauth2_token.return_value = { + "iss": "accounts.google.com", + "email": "testuser@example.com", + } + + # Define the URL for the GoogleLogin view + url = reverse("google_login") + + # Create a mock request + data = { + "token": "mock_token", + "type": 1, + } + + # Call the view + response = api_client.post(url, data, format="json") + + # Check that the user was created + user = User.objects.get(username="testuser@example.com") + assert user is not None + + # Check that the email was sent + mock_send_welcome_email.assert_called_once_with( + user.username, + user.username, + ) + + # Check the response status + assert response.status_code == status.HTTP_200_OK + assert "refresh" in response.data + assert "access" in response.data + + @patch("accounts.views.id_token.verify_oauth2_token") + @patch("accounts.models.send_welcome_email") + def test_google_login_ios_new_user_with_device( + self, mock_send_welcome_email, mock_verify_oauth2_token, api_client + ): + # Mock the token verification response + mock_verify_oauth2_token.return_value = { + "iss": "accounts.google.com", + "email": "testuser@example.com", + } + + # Define the URL for the GoogleLogin view + url = reverse("google_login") + + # Create a mock request + data = { + "token": "mock_token", + "device_token": "mock_device_token", + "type": 1, + } + + # Call the view + response = api_client.post(url, data, format="json") + + # Check that the user was created + user = User.objects.get(username="testuser@example.com") + assert user is not None + + # Check that the device was created + device = FCMDevice.objects.get( + user_id=user.id, registration_id="mock_device_token" + ) + assert device is not None + + # Check that the email was sent + mock_send_welcome_email.assert_called_once_with( + user.username, + user.username, + ) + + # Check the response status + assert response.status_code == status.HTTP_200_OK + assert "refresh" in response.data + assert "access" in response.data diff --git a/accounts/tokens.py b/accounts/tokens.py index a4713a1..10e6dcc 100644 --- a/accounts/tokens.py +++ b/accounts/tokens.py @@ -7,3 +7,8 @@ def for_user(cls, user, device_token): token = super().for_user(user) token["device"] = device_token return token + + @classmethod + def for_user_without_device(cls, user): + token = super().for_user(user) + return token diff --git a/accounts/urls.py b/accounts/urls.py index 35de135..636d80d 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -6,8 +6,9 @@ from accounts.views import ( AndroidClientView, + AppleLogin, GoogleLogin, - TestView, + IOSClientView, UserRetrieveView, ) @@ -15,7 +16,8 @@ path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), path("api/token/verify/", TokenVerifyView.as_view(), name="token_verify"), path("login/google/", GoogleLogin.as_view(), name="google_login"), - path("test/", TestView.as_view(), name="test"), + path("login/apple/", AppleLogin.as_view(), name="apple_login"), path("user/", UserRetrieveView.as_view(), name="user"), path("android/", AndroidClientView.as_view(), name="android"), + path("ios/", IOSClientView.as_view(), name="ios"), ] diff --git a/accounts/utils.py b/accounts/utils.py new file mode 100644 index 0000000..7b9388b --- /dev/null +++ b/accounts/utils.py @@ -0,0 +1,58 @@ +from dataclasses import dataclass +from typing import List, Union + +import resend +from django.template.loader import render_to_string + + +@dataclass +class SendParams: + def __init__( + self, + from_email_address: str, + to_email_address: Union[str, List[str]], + subject: str, + message: str, + ): + self.from_address = from_email_address + self.to_email_address = to_email_address + self.subject = subject + self.message = message + + def to_dict(self): + return { + "from": self.from_address, + "to": self.to_email_address, + "subject": self.subject, + "html": self.message, + } + + +def send_email( + to_email_address: Union[str, List[str]], subject: str, message: str +): + params = SendParams( + from_email_address="developers@stepby.one", + to_email_address=to_email_address, + subject=subject, + message=message, + ) + + email = resend.Emails.send(params.to_dict()) + return email + + +def send_welcome_email( + to_email_address: Union[str, List[str]], user_name: str +): + html_message = render_to_string( + "welcome_email.html", {"username": user_name} + ) + send_email(to_email_address, "Welcome to join us", html_message) + + +def get_welcome_email(user_name): + html_message = render_to_string( + "welcome_email.html", {"username": user_name} + ) + return html_message diff --git a/accounts/views.py b/accounts/views.py index b3f614e..a4887f8 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,13 +1,18 @@ +import jwt +import requests +import sentry_sdk +import urllib3 from django.conf import settings from django.contrib.auth import get_user_model -from google.auth.transport import requests +from fcm_django.models import FCMDevice +from google.auth.transport import requests as google_requests from google.oauth2 import id_token from rest_framework import status -from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from accounts.models import Device +from accounts.exceptions import LoginException from accounts.serializers import UserSerializer from accounts.tokens import CustomRefreshToken @@ -15,45 +20,117 @@ JWT_SECRET_KEY = settings.SECRETS.get("JWT_SECRET_KEY") -GOOGLE_CLIENT_ID = settings.SECRETS.get("GCID") +GOOGLE_ANDROID_CLIENT_ID = settings.SECRETS.get("GCID") +GOOGLE_IOS_CLIENT_ID = settings.SECRETS.get("GOOGLE_IOS_CLIENT_ID") +DEVICE_TYPE_ANDROID = 0 +DEVICE_TYPE_IOS = 1 -class TestView(APIView): - permission_classes = [IsAuthenticated] - - def get(self, request): - return Response({"message": "Hello, World!"}) - - -class GoogleLogin(APIView): - google_client_id = settings.SECRETS.get("GCID") +class BaseLogin(APIView): authentication_classes = [] permission_classes = [AllowAny] def post(self, request): + try: + device_type, token, device_token = self.validate_request(request) + email = self.verify_token(device_type, token) + user, is_new = User.get_or_create_user(email) + refresh = self.handle_device_token(user, device_token) + return Response( + { + "refresh": str(refresh), + "access": str(refresh.access_token), + "is_new": is_new, + "email": email, + }, + status=status.HTTP_200_OK, + ) + except Exception as e: + sentry_sdk.capture_exception(e) + return Response( + {"error": str(e)}, status=status.HTTP_400_BAD_REQUEST + ) + + def validate_request(self, request): + device_type = request.data.get("type", None) token = request.data.get("token") - device_token = request.data.get("device_token") - if not device_token or not token: - raise Exception("device token and token is required") + device_token = request.data.get("device_token", None) + if not token or device_type is None: + raise LoginException() + return device_type, token, device_token + + def handle_device_token(self, user, device_token): + if device_token: + FCMDevice.objects.get_or_create( + user=user, registration_id=device_token + ) + return CustomRefreshToken.for_user(user, device_token) + else: + return CustomRefreshToken.for_user_without_device(user) + + +class GoogleLogin(BaseLogin): + def verify_token(self, device_type, token): + audience = self.get_audience(device_type) + idinfo = id_token.verify_oauth2_token( + token, + google_requests.Request(), + audience=audience, + ) + self.validate_issuer(idinfo) + return idinfo["email"] + + def get_audience(self, device_type): + if device_type == DEVICE_TYPE_ANDROID: + return GOOGLE_ANDROID_CLIENT_ID + elif device_type == DEVICE_TYPE_IOS: + return GOOGLE_IOS_CLIENT_ID + else: + raise LoginException("Invalid device type") + + def validate_issuer(self, idinfo): + if "accounts.google.com" not in idinfo["iss"]: + raise LoginException("Invalid token") + + +class AppleLogin(BaseLogin): + APPLE_APP_ID = settings.SECRETS.get("APPLE_APP_ID") + APPLE_PUBLIC_KEYS_URL = "https://appleid.apple.com/auth/keys" + + def verify_token(self, device_type, identity_token): + if device_type != DEVICE_TYPE_IOS: + raise LoginException("Invalid device type") try: - idinfo = id_token.verify_oauth2_token( - token, requests.Request(), audience=GOOGLE_CLIENT_ID - ) - if "accounts.google.com" in idinfo["iss"]: - email = idinfo["email"] - user, _ = User.objects.get_or_create( - username=email, password="" - ) - Device.objects.get_or_create(user_id=user, token=device_token) - refresh = CustomRefreshToken.for_user(user, device_token) - return Response( - { - "refresh": str(refresh), - "access": str(refresh.access_token), - } - ) - except Exception: - return Response(status=status.HTTP_400_BAD_REQUEST) + unverified_header = jwt.get_unverified_header(identity_token) + kid = unverified_header["kid"] + public_key = self.get_apple_public_key(kid) + decoded_token = jwt.decode( + identity_token, + public_key, + algorithms=["RS256"], + audience=self.APPLE_APP_ID, + issuer="https://appleid.apple.com", + ) + email = decoded_token.get("email", None) + return email + except jwt.ExpiredSignatureError as e: + sentry_sdk.capture_exception(e) + raise LoginException("Token has expired") + except jwt.InvalidTokenError as e: + sentry_sdk.capture_exception(e) + raise LoginException("Invalid token") + except Exception as e: + sentry_sdk.capture_exception(e) + raise LoginException(f"An unexpected error occurred: {e}") + + def get_apple_public_key(self, kid): + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + response = requests.get(self.APPLE_PUBLIC_KEYS_URL, verify=False) + keys = response.json().get("keys", []) + for key in keys: + if key["kid"] == kid: + return jwt.algorithms.RSAAlgorithm.from_jwk(key) + raise ValueError("Matching key not found") class UserRetrieveView(APIView): @@ -62,14 +139,102 @@ class UserRetrieveView(APIView): permission_classes = [IsAuthenticated] def get(self, request): - user = User.objects.get(username=request.user.username) - serializer = UserSerializer(user) - return Response(serializer.data) + try: + sentry_sdk.set_user( + { + "id": request.user.id, + "username": request.user.username, + } + ) + user = User.objects.get( + username=request.user.username, deleted_at__isnull=True + ) + serializer = UserSerializer(user) + return Response(serializer.data, status=status.HTTP_200_OK) + except User.DoesNotExist as e: + sentry_sdk.capture_exception(e) + return Response( + {"error": "User not found"}, status=status.HTTP_404_NOT_FOUND + ) + + except Exception as e: + sentry_sdk.capture_exception(e) + return Response( + {"error": "An unexpected error occurred"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + def patch(self, request): + """ + 입력 : is_subscribe (Boolean), is_premium (Boolean) + """ + try: + user = request.user + sentry_sdk.set_user( + { + "id": request.user.id, + "username": request.user.username, + } + ) + if request.data.get("is_premium"): + user.is_premium = request.data.get("is_premium") + if request.data.get("is_subscribed"): + user.is_subscribed = request.data.get("is_subscribed") + user.save() + serializer = UserSerializer(user) + return Response(serializer.data, status=status.HTTP_200_OK) + except User.DoesNotExist as e: + sentry_sdk.capture_exception(e) + return Response( + {"error": "User not found"}, status=status.HTTP_404_NOT_FOUND + ) + except Exception as e: + sentry_sdk.capture_exception(e) + return Response( + {"error": "An unexpected error occurred"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + def delete(self, request): + try: + user = request.user + user = User.delete_user(instance=user) + return Response( + {"message": "User deleted"}, status=status.HTTP_200_OK + ) + except Exception as e: + sentry_sdk.capture_exception(e) + return Response( + {"error": "An unexpected error occurred"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) class AndroidClientView(APIView): def get(self, request): - ANDROID_CLIENT_ID = settings.SECRETS.get("ANDROID_CLIENT_ID") - return Response( - {"android_client_id": ANDROID_CLIENT_ID}, status=status.HTTP_200_OK - ) + try: + return Response( + {"android_client_id": GOOGLE_ANDROID_CLIENT_ID}, + status=status.HTTP_200_OK, + ) + except Exception as e: + sentry_sdk.capture_exception(e) + return Response( + {"error": "Android client id not found"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class IOSClientView(APIView): + def get(self, request): + try: + return Response( + {"ios_client_id": GOOGLE_IOS_CLIENT_ID}, + status=status.HTTP_200_OK, + ) + except Exception as e: + sentry_sdk.capture_exception(e) + return Response( + {"error": "IOS client id not found"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/conftest.py b/conftest.py index 4121306..a23e54f 100644 --- a/conftest.py +++ b/conftest.py @@ -1,6 +1,9 @@ # This file is for Pytest Configuration # This file is used to define fixtures that can be used in multiple test files +import random +from unittest.mock import Mock + import pytest from faker import Faker from rest_framework.test import APIClient @@ -14,7 +17,11 @@ @pytest.fixture(scope="module") def invalid_token(): - response = {"token": "token", "deviceToken": "device_token"} + response = { + "token": "token", + "deviceToken": "device_token", + "type": 0, + } return response @@ -30,44 +37,68 @@ def create_user(db): @pytest.fixture def authenticated_client(create_user): - client.force_authenticate(user=create_user) + client.force_authenticate(user=create_user, token={"device": None}) yield client client.force_authenticate(user=None) # logout @pytest.fixture -def create_category(db, create_user): +def create_category( + db, + create_user, + title="Test Category", + color=1, + rank="0|hzzzzz:", +): category = Category.objects.create( user_id=create_user, - title="Test Category", - color="#FFFFFF", - order="0|hzzzzz:", + title=title, + color=color, + rank=rank, ) return category @pytest.fixture -def create_todo(db, create_user, create_category): +def create_todo( + db, + create_user, + create_category, + date="2024-08-01", + due_time=None, + content="Test Todo", + is_completed=False, + rank="0|hzzzzz:", +): todo = Todo.objects.create( user_id=create_user, - start_date="2024-08-01", - end_date="2024-08-30", + date=date, + due_time=due_time, category_id=create_category, - content="Test Todo", - order="0|hzzzzz:", - is_completed=False, + content=content, + is_completed=is_completed, + rank=rank, ) return todo @pytest.fixture -def create_subtodo(db, create_user, create_todo): +def create_subtodo( + db, + create_todo, + content="Test SubTodo", + date="2024-08-01", + due_time=None, + is_completed=False, + rank="0|hzzzzz:", +): subtodo = SubTodo.objects.create( - content="Test SubTodo", - date="2024-08-01", + content=content, + date=date, + due_time=due_time, todo=create_todo, - order="0|hzzzzz:", - is_completed=False, + is_completed=is_completed, + rank=rank, ) return subtodo @@ -83,15 +114,58 @@ def content(): @pytest.fixture -def order(): - orders = ["0|azzzzz:", "0|hzzzzz:", "0|lzzzzz:"] +def rank(): + orders = ["0|hzzzzz:", "0|i00007:", "0|i0000f:"] + return orders - def get_order(index): - return orders[index] - return get_order +@pytest.fixture +def color(): + return fake.random_int(min=0, max=8) @pytest.fixture -def color(): - return fake.color() +def title(): + return fake.sentence(nb_words=3) + + +@pytest.fixture +def category(): + CATEGORY_CHOICES = [ + ("bug", "버그"), + ("feature", "기능 요청"), + ("feedback", "일반 피드백"), + ] + return random.choice(CATEGORY_CHOICES)[0] + + +@pytest.fixture +def due_time(): + return fake.time(pattern="%H:%M:%S") + + +@pytest.fixture +def recommend_result(): + mock_response = Mock() + mock_response.choices = [ + Mock( + message=Mock( + content=( + '{"id": 1, "content": "study algebra", "date": "2024-09-01", ' # noqa: E501 + '"due_time": "None", "category_id": 1, ' + '"is_completed": false, "children": []}' + ) + ) + ) + ] + + return mock_response + + +# FCM 알림 함수를 기본적으로 disable하는 fixture +@pytest.fixture(autouse=True) +def patch_send_push_notification_device(monkeypatch): + monkeypatch.setattr( + "todos.firebase_messaging.send_push_notification_device", + lambda *args, **kwargs: None, + ) diff --git a/feedback/__init__.py b/feedback/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/feedback/admin.py b/feedback/admin.py new file mode 100644 index 0000000..5ec822a --- /dev/null +++ b/feedback/admin.py @@ -0,0 +1,34 @@ +from django.contrib import admin + +from .models import Feedback + + +class FeedbackAdmin(admin.ModelAdmin): + readonly_fields = ["id", "user_id", "created_at"] + list_display = [ + "id", + "user_id", + "title", + "category", + "status", + "created_at", + ] + fieldsets = [ + ( + None, + { + "fields": [ + "id", + "user_id", + "title", + "category", + "description", + "created_at", + "status", + ] + }, + ), + ] + + +admin.site.register(Feedback, FeedbackAdmin) diff --git a/feedback/apps.py b/feedback/apps.py new file mode 100644 index 0000000..d26ac3c --- /dev/null +++ b/feedback/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class FeedbackConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "feedback" diff --git a/feedback/migrations/0001_initial.py b/feedback/migrations/0001_initial.py new file mode 100644 index 0000000..0550b50 --- /dev/null +++ b/feedback/migrations/0001_initial.py @@ -0,0 +1,63 @@ +# Generated by Django 5.0.6 on 2024-09-10 06:04 + +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="Feedback", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=200)), + ( + "category", + models.CharField( + choices=[ + ("bug", "버그"), + ("feature", "기능 요청"), + ("feedback", "일반 피드백"), + ], + max_length=20, + ), + ), + ("description", models.TextField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "status", + models.CharField( + choices=[ + ("pending", "대기 중"), + ("processing", "처리 중"), + ("completed", "완료"), + ], + default="pending", + max_length=20, + ), + ), + ( + "user_id", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/feedback/migrations/__init__.py b/feedback/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/feedback/models.py b/feedback/models.py new file mode 100644 index 0000000..a89ac8a --- /dev/null +++ b/feedback/models.py @@ -0,0 +1,29 @@ +from django.db import models + +from accounts.models import User + + +# Create your models here. +class Feedback(models.Model): + class CategoryProvider(models.TextChoices): + Bug = "bug", "버그" + Feature = "feature", "기능 요청" + Feedback = "feedback", "일반 피드백" + + class StatusProvider(models.TextChoices): + Pending = "pending", "대기 중" + Processing = "processing", "처리 중" + Completed = "completed", "완료" + + user_id = models.ForeignKey(User, on_delete=models.CASCADE) + title = models.CharField(max_length=200) + category = models.CharField( + max_length=20, choices=CategoryProvider.choices + ) + description = models.TextField() + created_at = models.DateTimeField(auto_now_add=True) + status = models.CharField( + max_length=20, + choices=StatusProvider.choices, + default=StatusProvider.Pending, + ) diff --git a/feedback/serializers.py b/feedback/serializers.py new file mode 100644 index 0000000..8be5d16 --- /dev/null +++ b/feedback/serializers.py @@ -0,0 +1,19 @@ +from rest_framework import serializers + +from accounts.models import User +from feedback.models import Feedback + + +class FeedbackSerializer(serializers.ModelSerializer): + user_id = serializers.PrimaryKeyRelatedField( + queryset=User.objects.all(), required=True + ) + title = serializers.CharField(max_length=200, required=True) + category = serializers.ChoiceField( + choices=Feedback.CategoryProvider.choices, required=True + ) + description = serializers.CharField(required=True) + + class Meta: + model = Feedback + fields = "__all__" diff --git a/feedback/tests.py b/feedback/tests.py new file mode 100644 index 0000000..a4e4b48 --- /dev/null +++ b/feedback/tests.py @@ -0,0 +1,59 @@ +import pytest +from django.urls import reverse + +""" +====================================== +# category Get checklist # +- correct test +- invalid user_id +- invalid category +====================================== +""" + + +@pytest.mark.django_db +def test_create_feedback_success( + create_user, authenticated_client, title, category, content +): + url = reverse("feedback") + data = { + "user_id": create_user.id, + "title": title, + "category": category, + "description": content, + } + + response = authenticated_client.post(url, data, format="json") + assert response.status_code == 201 + + +@pytest.mark.django_db +def test_create_feedback_invalid_user_id( + authenticated_client, title, category, content +): + url = reverse("feedback") + data = { + "user_id": 999, + "title": title, + "category": category, + "description": content, + } + + response = authenticated_client.post(url, data, format="json") + assert response.status_code == 400 + + +@pytest.mark.django_db +def test_create_feedback_invalid_category( + create_user, authenticated_client, title, category, content +): + url = reverse("feedback") + data = { + "user_id": create_user.id, + "title": title, + "category": "invalid Category", + "description": content, + } + + response = authenticated_client.post(url, data, format="json") + assert response.status_code == 400 diff --git a/feedback/urls.py b/feedback/urls.py new file mode 100644 index 0000000..7e1fd11 --- /dev/null +++ b/feedback/urls.py @@ -0,0 +1,10 @@ +# todos/urls.py +from django.urls import path + +from feedback.views import ( + FeedbackView, +) + +urlpatterns = [ + path("", FeedbackView.as_view(), name="feedback"), +] diff --git a/feedback/views.py b/feedback/views.py new file mode 100644 index 0000000..a34f8b0 --- /dev/null +++ b/feedback/views.py @@ -0,0 +1,38 @@ +# Create your views here.# todos/views.py + +from drf_yasg.utils import swagger_auto_schema +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from feedback.serializers import ( + FeedbackSerializer, +) + + +class FeedbackView(APIView): + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + tags=["Feedback"], + request_body=FeedbackSerializer, + operation_summary="Create a Feedback", + responses={201: FeedbackSerializer}, + ) + def post(self, request): + """ + - 이 함수는 Feedback을 생성하는 함수입니다. + - 입력 : user_id, title, category, description + """ + data = request.data + # category_id validation + serializer = FeedbackSerializer( + context={"request": request}, data=data + ) + if serializer.is_valid(raise_exception=True): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response( + {"error": serializer.errors}, status=status.HTTP_400_BAD_REQUEST + ) diff --git a/onestep_be/__init__.py b/onestep_be/__init__.py index cc9e574..e69de29 100644 --- a/onestep_be/__init__.py +++ b/onestep_be/__init__.py @@ -1,5 +0,0 @@ -import datetime - -# 날짜와 시간을 "YYYY.MM.DD.HHMMSS" 형식으로 포맷 -__version__ = datetime.datetime.now().strftime("%Y.%m.%d.%H%M%S") -VERSION = __version__ diff --git a/onestep_be/setting/dev.py b/onestep_be/setting/dev.py index a6f9eca..76b8b7f 100644 --- a/onestep_be/setting/dev.py +++ b/onestep_be/setting/dev.py @@ -1,6 +1,6 @@ -from onestep_be.settings import * - +from accounts.aws import get_secret +from onestep_be.settings import * SECRETS = eval(get_secret()) DATABASES = { @@ -13,4 +13,3 @@ "PORT": SECRETS.get("DB_PORT"), } } - diff --git a/onestep_be/setting/prod.py b/onestep_be/setting/prod.py index f530887..dfacafe 100644 --- a/onestep_be/setting/prod.py +++ b/onestep_be/setting/prod.py @@ -1,6 +1,7 @@ -from onestep_be.settings import * from accounts.aws import get_secret +from onestep_be.settings import * + ALLOWED_HOSTS = ["*"] DEBUG = False @@ -17,3 +18,4 @@ } } + diff --git a/onestep_be/setting/test.py b/onestep_be/setting/test.py index fc04bf6..5a4e577 100644 --- a/onestep_be/setting/test.py +++ b/onestep_be/setting/test.py @@ -1,7 +1,6 @@ from onestep_be.settings import * - SKIP_AUTHENTICATION = True if SKIP_AUTHENTICATION: - MIDDLEWARE.insert(0, "accounts.middleware.SkipAuthMiddleware") \ No newline at end of file + MIDDLEWARE.insert(0, "accounts.middleware.SkipAuthMiddleware") diff --git a/onestep_be/settings.py b/onestep_be/settings.py index a5df2f9..076983b 100644 --- a/onestep_be/settings.py +++ b/onestep_be/settings.py @@ -12,19 +12,26 @@ from datetime import timedelta from pathlib import Path +from urllib.parse import urlparse import django.db.models.signals +import pymysql +import resend import sentry_sdk from openai import OpenAI +from sentry_sdk.integrations.asyncio import AsyncioIntegration from sentry_sdk.integrations.django import DjangoIntegration from accounts.aws import get_secret +CSRF_TRUSTED_ORIGINS = ["https://*.stepby.one", "https://stepby.one"] # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent SKIP_AUTHENTICATION = False +pymysql.install_as_MySQLdb() + # Load all secret variables stored in AWS secret manager SECRETS = eval(get_secret()) @@ -55,11 +62,10 @@ "rest_framework", "corsheaders", "todos", + "feedback", "rest_framework_simplejwt", - "allauth", - "allauth.account", - "allauth.socialaccount", - "allauth.socialaccount.providers.google", + "fcm_django", + "django_crontab", ] MIDDLEWARE = [ @@ -71,33 +77,35 @@ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", - "allauth.account.middleware.AccountMiddleware", "djangorestframework_camel_case.middleware.CamelCaseMiddleWare", ] +CRONJOBS = [ + ("0 8 * * *", "todos.jobs.send_morning_alarm"), + ("0 14 * * *", "todos.jobs.send_afternoon_alarm"), + ("0 20 * * *", "todos.jobs.send_evening_alarm"), +] + REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": ( - "rest_framework_simplejwt.authentication.JWTAuthentication", + "accounts.authentication.CustomJWTAuthentication", ), "DEFAULT_RENDERER_CLASSES": ( "djangorestframework_camel_case.render.CamelCaseJSONRenderer", "djangorestframework_camel_case.render.CamelCaseBrowsableAPIRenderer", - # Any other renders ), "DEFAULT_PARSER_CLASSES": ( - # If you use MultiPartFormParser or FormParser, we also have a camel case version # noqa : E501 "djangorestframework_camel_case.parser.CamelCaseFormParser", "djangorestframework_camel_case.parser.CamelCaseMultiPartParser", "djangorestframework_camel_case.parser.CamelCaseJSONParser", - # Any other parsers ), } # SimpleJWT settings SIMPLE_JWT = { - "ACCESS_TOKEN_LIFETIME": timedelta(minutes=30), - "REFRESH_TOKEN_LIFETIME": timedelta(days=1), + "ACCESS_TOKEN_LIFETIME": timedelta(days=7), + "REFRESH_TOKEN_LIFETIME": timedelta(days=30), "ROTATE_REFRESH_TOKENS": False, "BLACKLIST_AFTER_ROTATION": True, "UPDATE_LAST_LOGIN": False, @@ -111,7 +119,10 @@ "USER_ID_FIELD": "id", "USER_ID_CLAIM": "user_id", "USER_AUTHENTICATION_RULE": "rest_framework_simplejwt.authentication.default_user_authentication_rule", # noqa : E501 - "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), + "AUTH_TOKEN_CLASSES": ( + "rest_framework_simplejwt.tokens.AccessToken", + "accounts.tokens.CustomRefreshToken", + ), "TOKEN_TYPE_CLAIM": "token_type", "JTI_CLAIM": "jti", "SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp", @@ -178,7 +189,7 @@ # Add OpenAI API Key OPENAI_API_KEY = SECRETS.get("OPENAI_API_KEY") -client = OpenAI(api_key=OPENAI_API_KEY) +openai_client = OpenAI(api_key=OPENAI_API_KEY) # Password validation # https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators @@ -221,17 +232,45 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# BASE_DIR은 Django 프로젝트의 루트 디렉토리를 가리킵니다. +# version.txt 파일이 BASE_DIR에 있다고 가정합니다. +VERSION_FILE_PATH = BASE_DIR.parent / "version.txt" + +# 파일 읽기 +try: + with VERSION_FILE_PATH.open("r") as file: + # 파일의 내용을 읽어서 변수에 저장 + PROJECT_VERSION = file.read().strip() + if PROJECT_VERSION == "": + PROJECT_VERSION = "Unknown" + SENTRY_ENVIRONMENT = "localhost" + else: + SENTRY_ENVIRONMENT = PROJECT_VERSION.split("@")[0] +except FileNotFoundError: + PROJECT_VERSION = "Unknown" # 파일이 없을 경우 기본값 + # sentry settings +SENTRY_DSN = SECRETS.get("SENTRY_DSN") + + +# sentry Filtering +def setnry_filter_transactions(event, hint): + url_string = event["request"]["url"] + parsed_url = urlparse(url_string) + + if parsed_url.path == "/auth/android/" or parsed_url.path == "/swagger/": + return None + return event + sentry_sdk.init( - dsn="https://9425334e0e90c405218fa9613cea9a03@o4507736964136960.ingest.us.sentry.io/4507763025117184", - # Set traces_sample_rate to 1.0 to capture 100% - # of transactions for performance monitoring. - traces_sample_rate=0.5, - # Set profiles_sample_rate to 1.0 to profile 100% - # of sampled transactions. - # We recommend adjusting this value in production. - profiles_sample_rate=0.5, + dsn=SENTRY_DSN, + traces_sample_rate=1.0, + release=PROJECT_VERSION, + profiles_sample_rate=1.0, + # environment=SENTRY_ENVIRONMENT, + environment="Testing", integrations=[ DjangoIntegration( transaction_style="url", @@ -243,18 +282,9 @@ ], cache_spans=False, ), + AsyncioIntegration(), ], + before_send_transaction=setnry_filter_transactions, ) -sentry_sdk.metrics.incr(key="api_calls", value=1) - -sentry_sdk.metrics.distribution( - key="processing_time", - value=0.002, - unit="second", -) -sentry_sdk.metrics.gauge( - key="cpu_usage", - value=94, - unit="percent", -) +resend.api_key = SECRETS.get("RESEND") diff --git a/onestep_be/urls.py b/onestep_be/urls.py index e24d37b..767fbb4 100644 --- a/onestep_be/urls.py +++ b/onestep_be/urls.py @@ -1,8 +1,9 @@ from django.contrib import admin -from django.urls import path, include -from rest_framework.permissions import AllowAny -from drf_yasg.views import get_schema_view +from django.urls import include, path from drf_yasg import openapi +from drf_yasg.views import get_schema_view +from rest_framework.permissions import AllowAny +from django.contrib.staticfiles.urls import staticfiles_urlpatterns schema_view = get_schema_view( openapi.Info( @@ -26,6 +27,13 @@ schema_view.with_ui("swagger", cache_timeout=0), name="schema-swagger-ui", ), - path("redoc/", schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc"), - path('todos/', include('todos.urls')), + path( + "redoc/", + schema_view.with_ui("redoc", cache_timeout=0), + name="schema-redoc", + ), + path("todos/", include("todos.urls")), + path("feedback/", include("feedback.urls")), ] + +urlpatterns += staticfiles_urlpatterns() diff --git a/pyproject.toml b/pyproject.toml index b6b53b7..c8e141a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,10 +5,11 @@ requires-python = ">=3.10" [tool.ruff] # Set the maximum line length to 79. line-length = 79 +exclude = ["*/migrations", "onestep_be/setting"] [tool.ruff.lint] # Add the `line-too-long` rule to the enforced rule set. extend-select = ["E501"] [tool.ruff.lint.pycodestyle] -ignore-overlong-task-comments = true \ No newline at end of file +ignore-overlong-task-comments = true diff --git a/requirements.txt b/requirements.txt index 317dedb..cc6c79d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,6 +27,7 @@ djangorestframework-camel-case==1.4.2 djangorestframework-simplejwt==5.3.1 docutils==0.16 drf-yasg==1.21.7 +Faker==27.0.0 Flask==3.0.3 Flask-Cors==4.0.1 Flask-Login==0.6.3 @@ -71,7 +72,7 @@ pyzmq==26.1.0 requests==2.32.3 rsa==4.7.2 s3transfer==0.10.2 -sentry-sdk==2.13.0 +sentry-sdk==2.17.0 setuptools==69.5.1 simplejson==3.19.2 six==1.16.0 @@ -88,3 +89,29 @@ Werkzeug==3.0.3 wheel==0.43.0 zope.event==5.0 zope.interface==7.0.1 +resend==2.4.0 +cachecontrol==0.14.0 +fcm_django==2.2.1 +firebase-admin==6.5.0 +google-api-core==2.20.0 +google-api-python-client==2.147.0 +google-auth-httplib2==0.2.0 +google-cloud-core==2.4.1 +google-cloud-firestore==2.18.0 +google-cloud-storage==2.18.2 +google-crc32c==1.6.0 +google-resumable-media==2.7.2 +grpcio==1.66.1 +grpcio-status==1.66.1 +httplib2==0.22.0 +proto-plus==1.24.0 +protobuf==5.28.2 +swapper==1.4.0 +django_crontab==0.7.1 +Faker==27.0.0 +pymysql==1.1.1 +py-cpuinfo==9.0.0 +pytest-benchmark==4.0.0 +pytest_asyncio==0.24.0 +debugpy==1.8.7 +time-machine==2.16.0 diff --git a/todos/admin.py b/todos/admin.py index 8b13789..88ca544 100644 --- a/todos/admin.py +++ b/todos/admin.py @@ -1 +1,81 @@ +# Register your models here. +from django.contrib import admin +from .models import Category, SubTodo, Todo + + +class TodoAdmin(admin.ModelAdmin): + readonly_fields = ["id"] + list_display = [ + "id", + "content", + "user_id", + "category_id", + "due_time", + "date", + "is_completed", + ] + fieldsets = [ + ( + None, + { + "fields": [ + "id", + "content", + "user_id", + "category_id", + "due_time", + "is_completed", + ] + }, + ), + ( + "Date information", + {"fields": ["date"], "classes": ["collapse"]}, + ), + ] + + +class SubTodoAdmin(admin.ModelAdmin): + readonly_fields = ["id"] + list_display = [ + "id", + "content", + "todo_id", + "due_time", + "is_completed", + ] + fieldsets = [ + ( + None, + { + "fields": [ + "id", + "content", + "todo_id", + "due_time", + "is_completed", + ] + }, + ), + ( + "Date information", + {"fields": ["date"], "classes": ["collapse"]}, + ), + ] + + +class CategoryAdmin(admin.ModelAdmin): + readonly_fields = ["id"] + list_display = ["id", "title", "user_id", "color"] + fieldsets = [ + ( + None, + {"fields": ["id", "title", "user_id", "color"]}, + ), + ] + + +admin.site.register(Todo, TodoAdmin) +admin.site.register(SubTodo, SubTodoAdmin) +admin.site.register(Category, CategoryAdmin) diff --git a/todos/firebase_messaging.py b/todos/firebase_messaging.py new file mode 100644 index 0000000..3cc792b --- /dev/null +++ b/todos/firebase_messaging.py @@ -0,0 +1,53 @@ +import firebase_admin +import os +from firebase_admin import credentials, messaging +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'onestep_be.settings') +from django.conf import settings +from dataclasses import dataclass +from fcm_django.models import FCMDevice +from django.contrib.auth import get_user_model + + +User = get_user_model() + + +@dataclass +class PushNotificationStatus: + status: str + + +PUSH_NOTIFICATION_SUCCESS = PushNotificationStatus("success") +PUSH_NOTIFICATION_ERROR = PushNotificationStatus("error") + + +def send_push_notification_device(token, target_user, title, body): + target_device = FCMDevice.objects.filter(user=target_user).exclude(registration_id=token) + if target_device.exists(): + target_device = target_device.first() + try: + target_device.send_message( + messaging.Message( + notification=messaging.Notification( + title=title, + body=body, + ), + ) + ) + except Exception: + pass + + +def send_push_notification(token, title, body): + device = FCMDevice.objects.filter(registration_id=token).first() + try: + device.send_message( + messaging.Message( + notification=messaging.Notification( + title=title, + body=body, + ), + ) + ) + except Exception: + return PUSH_NOTIFICATION_ERROR + return PUSH_NOTIFICATION_SUCCESS \ No newline at end of file diff --git a/todos/jobs.py b/todos/jobs.py new file mode 100644 index 0000000..cb6ebd3 --- /dev/null +++ b/todos/jobs.py @@ -0,0 +1,50 @@ +import firebase_admin +import os +from firebase_admin import credentials, messaging +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'onestep_be.settings') +from django.conf import settings +from fcm_django.models import FCMDevice +from django.db.models import Prefetch +from todos.models import Todo + + +firebase_info = eval(settings.SECRETS.get("FIREBASE")) +cred = credentials.Certificate(firebase_info) +firebase_admin.initialize_app(cred) + + +MORNING_ALARM_TITLE = "오늘의 할 일을 확인해보세요" +AFTERNOON_ALARM_TITLE = "지금 할 일을 확인해보세요" +EVENING_ALARM_TITLE = "오늘의 남은 할 일을 확인해보세요" + + +def send_morning_alarm(): + send_day_alarm(MORNING_ALARM_TITLE) + + +def send_afternoon_alarm(): + send_day_alarm(AFTERNOON_ALARM_TITLE) + + +def send_evening_alarm(): + send_day_alarm(EVENING_ALARM_TITLE) + + +def send_day_alarm(alarm_title): + users_prefetch = Prefetch('user__todo_set', queryset=Todo.objects.filter(is_completed=False)) + devices = FCMDevice.objects.all().select_related('user').prefetch_related(users_prefetch) + try: + for device in devices: + todos_queryset = device.user.todo_set.filter(is_completed=False).values_list("content", flat=True) + todos_list = "\n".join(todos_queryset) + device.send_message( + messaging.Message( + notification=messaging.Notification( + title=alarm_title, + body=todos_list, + ), + ) + ) + except Exception: + pass + diff --git a/todos/migrations/0011_userlastusage.py b/todos/migrations/0011_userlastusage.py new file mode 100644 index 0000000..96a0baf --- /dev/null +++ b/todos/migrations/0011_userlastusage.py @@ -0,0 +1,24 @@ +# Generated by Django 5.0.6 on 2024-09-25 09:20 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('todos', '0010_alter_subtodo_todo_alter_todo_category_id'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='UserLastUsage', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('last_used_at', models.DateTimeField(null=True)), + ('user_id', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/todos/migrations/0012_rename_end_date_todo_date_remove_todo_start_date_and_more.py b/todos/migrations/0012_rename_end_date_todo_date_remove_todo_start_date_and_more.py new file mode 100644 index 0000000..038bd11 --- /dev/null +++ b/todos/migrations/0012_rename_end_date_todo_date_remove_todo_start_date_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 5.0.6 on 2024-10-08 06:51 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('todos', '0011_userlastusage'), + ] + + operations = [ + migrations.RenameField( + model_name='todo', + old_name='end_date', + new_name='date', + ), + migrations.RemoveField( + model_name='todo', + name='start_date', + ), + migrations.AddField( + model_name='subtodo', + name='due_time', + field=models.TimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='todo', + name='due_time', + field=models.TimeField(null=True), + ), + migrations.AlterField( + model_name='userlastusage', + name='last_used_at', + field=models.DateTimeField(default=django.utils.timezone.now), + preserve_default=False, + ), + ] diff --git a/todos/migrations/0013_rename_todo_subtodo_todo_id.py b/todos/migrations/0013_rename_todo_subtodo_todo_id.py new file mode 100644 index 0000000..b1b2e00 --- /dev/null +++ b/todos/migrations/0013_rename_todo_subtodo_todo_id.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2024-10-08 07:49 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('todos', '0012_rename_end_date_todo_date_remove_todo_start_date_and_more'), + ] + + operations = [ + migrations.RenameField( + model_name='subtodo', + old_name='todo', + new_name='todo_id', + ), + ] diff --git a/todos/migrations/0014_rename_order_category_rank_rename_order_subtodo_rank_and_more.py b/todos/migrations/0014_rename_order_category_rank_rename_order_subtodo_rank_and_more.py new file mode 100644 index 0000000..ace734d --- /dev/null +++ b/todos/migrations/0014_rename_order_category_rank_rename_order_subtodo_rank_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.16 on 2024-10-11 06:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('todos', '0013_rename_todo_subtodo_todo_id'), + ] + + operations = [ + migrations.RenameField( + model_name='category', + old_name='order', + new_name='rank', + ), + migrations.RenameField( + model_name='subtodo', + old_name='order', + new_name='rank', + ), + migrations.RenameField( + model_name='todo', + old_name='order', + new_name='rank', + ), + ] diff --git a/todos/migrations/0015_alter_category_color_alter_category_rank_and_more.py b/todos/migrations/0015_alter_category_color_alter_category_rank_and_more.py new file mode 100644 index 0000000..bd352ee --- /dev/null +++ b/todos/migrations/0015_alter_category_color_alter_category_rank_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.16 on 2024-11-05 05:13 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('todos', '0014_rename_order_category_rank_rename_order_subtodo_rank_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='category', + name='color', + field=models.SmallIntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(8)]), + ), + migrations.AlterField( + model_name='category', + name='rank', + field=models.CharField(default='0|hzzzzz:', max_length=255), + ), + migrations.AlterField( + model_name='subtodo', + name='rank', + field=models.CharField(default='0|hzzzzz:', max_length=255), + ), + migrations.AlterField( + model_name='todo', + name='rank', + field=models.CharField(default='0|hzzzzz:', max_length=255), + ), + ] diff --git a/todos/models.py b/todos/models.py index 901298c..1f1bb63 100644 --- a/todos/models.py +++ b/todos/models.py @@ -1,8 +1,11 @@ +from django.core.exceptions import ObjectDoesNotExist +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import Count, Prefetch, Q from django.utils import timezone from accounts.models import User +from Lexorank.src.lexo_rank import LexoRank class TodosManager(models.Manager): @@ -17,17 +20,65 @@ def delete_many(self, instances): instance.save() return instances + def get_next_rank_subtodo(self, user_id): + get_list = ( + SubTodo.objects.filter(todo_id__user_id=user_id) + .select_related("todo_id") + .last() + ) + if get_list is None: + return str(LexoRank.middle()) + return str((LexoRank.parse(get_list.rank)).gen_next()) + + def gen_next_rank(self, prev_rank): + return str(LexoRank.parse(prev_rank).gen_next()) + + def get_next_rank(self, user_id): + get_list = ( + self.get_queryset().filter(user_id=user_id).order_by("rank").last() + ) + if get_list is None: + return str(LexoRank.middle()) + return str(LexoRank.parse(get_list.rank).gen_next()) + + def get_update_rank(self, instance, prev_id, next_id): + if prev_id is None and next_id is None: + return instance.rank + elif prev_id is None: # Move to the top + get_next_rank = self.get_queryset().get(id=next_id).rank + get_rank = str(LexoRank.parse(get_next_rank).gen_prev()) + return get_rank + elif next_id is None: # Move to the bottom + get_prev_rank = self.get_queryset().get(id=prev_id).rank + get_rank = str(LexoRank.parse(get_prev_rank).gen_next()) + return get_rank + else: # Move to after prev_id + prev_rank = self.get_queryset().get(id=prev_id).rank + prev_lexo = LexoRank.parse(prev_rank) + next_rank = self.get_queryset().get(id=next_id).rank + next_instance = LexoRank.parse(next_rank) + return str(prev_lexo.between(next_instance)) + def get_queryset(self): return super().get_queryset().filter(deleted_at__isnull=True) def get_with_id(self, id): - return self.get_queryset().filter(id=id).first() + instance = self.get_queryset().filter(id=id).first() + if instance is None: + raise ObjectDoesNotExist(f"No object found with id {id}") + return instance def get_with_user_id(self, user_id): - return self.get_queryset().filter(user_id=user_id).order_by("order") + instance = self.get_queryset().filter(user_id=user_id).order_by("rank") + if instance is None: + raise ObjectDoesNotExist(f"No object found with user_id {user_id}") + return instance def get_subtodos(self, todo_id): - return self.get_queryset().filter(todo=todo_id).order_by("order") + instance = self.get_queryset().filter(todo_id=todo_id).order_by("rank") + if instance is None: + raise ObjectDoesNotExist(f"No object found with todo_id {todo_id}") + return instance def get_inbox(self, user_id): return ( @@ -41,43 +92,30 @@ def get_inbox(self, user_id): ), ) ) - .filter( - Q(end_date__isnull=True, start_date__isnull=True) - | Q(subtodos_count__gt=0) - ) + .filter(Q(date__isnull=True) | Q(subtodos_count__gt=0)) .prefetch_related( Prefetch( "subtodos", queryset=SubTodo.objects.filter( deleted_at__isnull=True, date__isnull=True - ).order_by("order"), + ).order_by("rank"), ) ) - .order_by("order") + .order_by("rank") ) def get_daily_with_date(self, user_id, start_date, end_date): return ( Todo.objects.filter(user_id=user_id, deleted_at__isnull=True) - .filter( - ( - Q(start_date__isnull=True) - | Q(start_date__lte=end_date, start_date__gte=start_date) - ) - | ( - Q(end_date__isnull=True) - | Q(end_date__lte=end_date, end_date__gte=start_date) - ) - | (Q(start_date__lte=start_date, end_date__gte=end_date)) - ) - .exclude(start_date__isnull=True, end_date__isnull=True) - .order_by("order") + .filter(Q(date__gte=start_date, date__lte=end_date)) + .exclude(date__isnull=True) + .order_by("rank") .prefetch_related( Prefetch( "subtodos", queryset=SubTodo.objects.filter( deleted_at__isnull=True, date__isnull=False - ).order_by("order"), + ).order_by("rank"), ) ) ) @@ -85,14 +123,14 @@ def get_daily_with_date(self, user_id, start_date, end_date): def get_daily(self, user_id): return ( Todo.objects.filter(user_id=user_id, deleted_at__isnull=True) - .filter(Q(end_date__isnull=False) | Q(start_date__isnull=False)) + .filter(date__isnull=False) .order_by("order") .prefetch_related( Prefetch( "subtodos", queryset=SubTodo.objects.filter( deleted_at__isnull=True, date__isnull=False - ).order_by("order"), + ).order_by("rank"), ) ) ) @@ -111,12 +149,13 @@ class Todo(TimeStamp): id = models.AutoField(primary_key=True) content = models.CharField(max_length=255) category_id = models.ForeignKey("Category", on_delete=models.CASCADE) - start_date = models.DateField(null=True) - end_date = models.DateField(null=True) + due_time = models.TimeField(null=True) + date = models.DateField(null=True) user_id = models.ForeignKey(User, on_delete=models.CASCADE) - order = models.CharField(max_length=255) is_completed = models.BooleanField(default=False) + rank = models.CharField(max_length=255, default="0|hzzzzz:") + objects = TodosManager() def __str__(self): @@ -126,13 +165,15 @@ def __str__(self): class SubTodo(TimeStamp): id = models.AutoField(primary_key=True) content = models.CharField(max_length=255) - todo = models.ForeignKey( + todo_id = models.ForeignKey( "Todo", on_delete=models.CASCADE, related_name="subtodos" ) + due_time = models.TimeField(null=True, blank=True) date = models.DateField(null=True) - order = models.CharField(max_length=255, null=True) is_completed = models.BooleanField(default=False) + rank = models.CharField(max_length=255, default="0|hzzzzz:") + objects = TodosManager() def __str__(self): @@ -142,8 +183,47 @@ def __str__(self): class Category(TimeStamp): id = models.AutoField(primary_key=True) user_id = models.ForeignKey(User, on_delete=models.CASCADE, default=1) - color = models.CharField(max_length=7) + color = models.SmallIntegerField( + validators=[MinValueValidator(0), MaxValueValidator(8)] + ) title = models.CharField(max_length=100, null=True) - order = models.CharField(max_length=255, null=True) + + rank = models.CharField(max_length=255, default="0|hzzzzz:") objects = TodosManager() + + +class UserLastUsage(models.Model): + id = models.AutoField(primary_key=True) + user_id = models.ForeignKey(User, on_delete=models.PROTECT) + last_used_at = models.DateTimeField(null=False) + + @classmethod + def check_rate_limit(cls, user_id: int, RATE_LIMIT_SECONDS: int): + try: + user = User.objects.get(id=user_id) + user_last_usage, created = cls.objects.get_or_create( + user_id=user, defaults={"last_used_at": timezone.now()} + ) + if user.is_premium: + user_last_usage.last_used_at = timezone.now() + user_last_usage.save(update_fields=["last_used_at"]) + return True, "Premium user" + if not created: + now = timezone.now() + if ( + user_last_usage.last_used_at + + timezone.timedelta(seconds=RATE_LIMIT_SECONDS) + > now + ): + return False, "Rate limit exceeded" + else: + user_last_usage.last_used_at = now + user_last_usage.save(update_fields=["last_used_at"]) + return True, "Updated" + else: + return True, "Created" + except User.DoesNotExist: + return False, "User does not exist" + except Exception as e: + return False, f"An error occurred: {str(e)}" diff --git a/todos/serializers.py b/todos/serializers.py index 527d930..52efa32 100644 --- a/todos/serializers.py +++ b/todos/serializers.py @@ -1,23 +1,16 @@ # todos/serializers.py -import re import django.utils.timezone as timezone from rest_framework import serializers from accounts.models import User -from todos.utils import validate_lexo_order from .models import Category, SubTodo, Todo -class PatchOrderSerializer(serializers.Serializer): - prev_id = serializers.PrimaryKeyRelatedField( - queryset=Todo.objects.all(), required=False, allow_null=True - ) - next_id = serializers.PrimaryKeyRelatedField( - queryset=Todo.objects.all(), required=False, allow_null=True - ) - updated_order = serializers.CharField(max_length=255, required=True) +class PatchRankSerializer(serializers.Serializer): + prev_id = serializers.IntegerField(allow_null=True) + next_id = serializers.IntegerField(allow_null=True) class CategorySerializer(serializers.ModelSerializer): @@ -32,56 +25,29 @@ def validate_user_id(self, data): raise serializers.ValidationError("User does not exist") return data - def validate_color(self, data): - hex_color_pattern = r"^#([A-Fa-f0-9]{6})$" - match = re.match(hex_color_pattern, data) - if bool(match) is False: - raise serializers.ValidationError("Color code is invalid") - return data - def validate(self, data): request = self.context["request"] if request.method == "PATCH": if not any( - data.get(field) for field in ["color", "title", "order"] + data.get(field) for field in ["color", "title", "rank"] ): raise serializers.ValidationError( - "At least one of color, title, order must be provided" + "At least one of color, title, rank must be provided" ) if data.get("user_id"): raise serializers.ValidationError("User cannot be updated") - return data - - elif request.method == "POST": - user_id = data.get("user_id") - last_category = ( - Category.objects.filter( - user_id=user_id, deleted_at__isnull=True - ) - .order_by("-order") - .first() - ) - if last_category is not None: - last_order = last_category.order - if ( - validate_lexo_order( - prev=last_order, next=None, updated=data["order"] - ) - is False - ): - raise serializers.ValidationError("Order is invalid") return data class SubTodoSerializer(serializers.ModelSerializer): content = serializers.CharField(max_length=255) - todo = serializers.PrimaryKeyRelatedField( + todo_id = serializers.PrimaryKeyRelatedField( queryset=Todo.objects.all(), required=True ) date = serializers.DateField(required=False, allow_null=True) - order = serializers.CharField(max_length=255) + rank = serializers.CharField(max_length=255, required=False) is_completed = serializers.BooleanField(default=False) - patch_order = PatchOrderSerializer(required=False) + patch_rank = PatchRankSerializer(required=False) class Meta: model = SubTodo @@ -94,50 +60,32 @@ def validate_todo(self, data): raise serializers.ValidationError("Todo does not exist") return data - def validate_patch_order(self, data): - request = self.context["request"] - if request.method == "PATCH": - updated_order = data.get("updated_order") - prev = data.get("prev_id").order if data.get("prev_id") else None - next = data.get("next_id").order if data.get("next_id") else None - if not validate_lexo_order( - prev=prev, next=next, updated=updated_order - ): - raise serializers.ValidationError("Order is invalid") - return data - def validate(self, data): request = self.context["request"] if request.method == "PATCH": if not any( - data.get(field) - for field in ["content", "date", "is_completed", "order"] + data.get(field) is not None + for field in ["content", "date", "is_completed", "patch_rank"] ): raise serializers.ValidationError( "At least one of content, date, \ - is_completed, order must be provided" + is_completed, rank must be provided" ) return data + return data - elif request.method == "POST": - todo_id = data.get("todo").id - last_subtodo = ( - SubTodo.objects.filter( - todo_id=todo_id, deleted_at__isnull=True + def update(self, instance, validated_data): + for attr, value in validated_data.items(): + if attr == "patch_rank": + rank = SubTodo.objects.get_update_rank( + instance, value.get("prev_id"), value.get("next_id") ) - .order_by("-order") - .first() - ) - if last_subtodo is not None: - last_order = last_subtodo.order - if ( - validate_lexo_order( - prev=last_order, next=None, updated=data["order"] - ) - is False - ): - raise serializers.ValidationError("Order is invalid") - return data + setattr(instance, "rank", rank) + else: + setattr(instance, attr, value) + instance.updated_at = timezone.now() + instance.save() + return instance class GetTodoSerializer(serializers.ModelSerializer): @@ -149,10 +97,10 @@ class Meta: "id", "content", "category_id", - "start_date", - "end_date", + "date", + "due_time", "user_id", - "order", + "rank", "is_completed", "children", ] @@ -164,13 +112,13 @@ class TodoSerializer(serializers.ModelSerializer): queryset=Category.objects.all(), required=True ) user_id = serializers.PrimaryKeyRelatedField( - queryset=User.objects.all(), required=True + queryset=User.objects.all(), required=False ) - start_date = serializers.DateField(allow_null=True, required=False) - end_date = serializers.DateField(allow_null=True, required=False) - order = serializers.CharField(max_length=255) + date = serializers.DateField(allow_null=True, required=False) + due_time = serializers.TimeField(allow_null=True, required=False) + rank = serializers.CharField(max_length=255, required=False) is_completed = serializers.BooleanField(default=False, required=False) - patch_order = PatchOrderSerializer(required=False) + patch_rank = PatchRankSerializer(required=False) class Meta: model = Todo @@ -178,12 +126,12 @@ class Meta: "id", "content", "category_id", - "start_date", - "end_date", + "date", + "due_time", "user_id", - "order", + "rank", "is_completed", - "patch_order", + "patch_rank", ] def validate_category_id(self, data): @@ -196,63 +144,38 @@ def validate_user_id(self, data): raise serializers.ValidationError("User does not exist") return data - def validate_patch_order(self, data): - request = self.context["request"] - if request.method == "PATCH": - updated_order = data.get("updated_order") - prev = data.get("prev_id").order if data.get("prev_id") else None - next = data.get("next_id").order if data.get("next_id") else None - if not validate_lexo_order( - prev=prev, next=next, updated=updated_order - ): - raise serializers.ValidationError("Order is invalid") - return data - def validate(self, data): request = self.context["request"] - start_date = data.get("start_date") - end_date = data.get("end_date") - - if start_date and end_date and start_date > end_date: - raise serializers.ValidationError( - "Start date should be less than end date" - ) if request.method == "PATCH": if not any( - data.get(field) + data.get(field) is not None for field in [ "content", "category_id", - "start_date", - "end_date", + "date", "is_completed", - "order", + "patch_rank", ] ): raise serializers.ValidationError( - "At least one of content, category_id, start_date, end_date, is_completed must be provided" # noqa : E501 + "At least one of content, category_id, date, is_completed must be provided" # noqa : E501 ) if data.get("user_id"): raise serializers.ValidationError("User cannot be updated") - elif request.method == "POST": - user_id = data.get("user_id") - last_todo = ( - Todo.objects.filter(user_id=user_id, deleted_at__isnull=True) - .order_by("-order") - .first() - ) - if last_todo and not validate_lexo_order( - prev=last_todo.order, next=None, updated=data["order"] - ): - raise serializers.ValidationError("Order is invalid") - return data def update(self, instance, validated_data): # Update the fields as usual for attr, value in validated_data.items(): - setattr(instance, attr, value) + if attr == "patch_rank": + # If the rank field is provided, update the rank field + rank = Todo.objects.get_update_rank( + instance, value.get("prev_id"), value.get("next_id") + ) + setattr(instance, "rank", rank) + else: + setattr(instance, attr, value) # Set the updated_at field to the current time instance.updated_at = timezone.now() diff --git a/todos/swagger_serializers.py b/todos/swagger_serializers.py index 036932a..d76c6fe 100644 --- a/todos/swagger_serializers.py +++ b/todos/swagger_serializers.py @@ -4,21 +4,77 @@ from .models import Category, SubTodo, Todo -class SwaggerOrderserializer(serializers.ModelSerializer): +class SwaggerTodoSerializer(serializers.ModelSerializer): + content = serializers.CharField(max_length=255) + category_id = serializers.PrimaryKeyRelatedField( + queryset=Category.objects.all(), required=True + ) + date = serializers.DateField(allow_null=True, required=False) + due_time = serializers.TimeField(allow_null=True, required=False) + is_completed = serializers.BooleanField(default=False, required=False) + + class Meta: + model = Todo + fields = [ + "id", + "content", + "category_id", + "date", + "due_time", + "user_id", + "is_completed", + "rank", + ] + read_only_fields = ["user_id", "rank", "id"] + + +class SwaggerSubTodoSerializer(serializers.ModelSerializer): + content = serializers.CharField(max_length=255) + todo_id = serializers.PrimaryKeyRelatedField( + queryset=Todo.objects.all(), required=True + ) + date = serializers.DateField(required=False, allow_null=True) + due_time = serializers.TimeField(required=False, allow_null=True) + is_completed = serializers.BooleanField(default=False) + + class Meta: + model = Todo + fields = [ + "id", + "todo_id", + "content", + "date", + "due_time", + "is_completed", + "rank", + ] + read_only_fields = ["rank", "id"] + + +class SwaggerCategorySerializer(serializers.ModelSerializer): + color = serializers.IntegerField(min_value=0, max_value=8) + title = serializers.CharField(max_length=100) + + class Meta: + model = Category + fields = ["id", "user_id", "color", "title", "rank"] + read_only_fields = ["rank", "id", "user_id"] + + +class SwaggerRankSerializer(serializers.ModelSerializer): prev_id = serializers.IntegerField() next_id = serializers.IntegerField() - updated_order = serializers.CharField(max_length=255) class Meta: model = Todo - fields = ["prev_id", "next_id", "updated_order"] + fields = ["prev_id", "next_id"] class SwaggerCategoryPatchSerializer(serializers.ModelSerializer): category_id = serializers.IntegerField() - color = serializers.CharField(max_length=7, required=False) + color = serializers.IntegerField(min_value=0, max_value=8) title = serializers.CharField(max_length=100, required=False) - order = SwaggerOrderserializer(required=False) + rank = SwaggerRankSerializer(required=False) class Meta: model = Category @@ -31,9 +87,9 @@ class SwaggerTodoPatchSerializer(serializers.ModelSerializer): category_id = serializers.PrimaryKeyRelatedField( queryset=Category.objects.all(), required=False ) - start_date = serializers.DateField(allow_null=True, required=False) - end_date = serializers.DateField(allow_null=True, required=False) - order = SwaggerOrderserializer(required=False) + date = serializers.DateField(allow_null=True, required=False) + due_time = serializers.TimeField(allow_null=True, required=False) + rank = SwaggerRankSerializer(required=False) is_completed = serializers.BooleanField(default=False, required=False) class Meta: @@ -42,9 +98,9 @@ class Meta: "todo_id", "content", "category_id", - "start_date", - "end_date", - "order", + "date", + "due_time", + "rank", "is_completed", ] @@ -52,11 +108,11 @@ class Meta: class SwaggerSubTodoPatchSerializer(serializers.ModelSerializer): subtodo_id = serializers.IntegerField() content = serializers.CharField(max_length=255, required=False) - todo = serializers.PrimaryKeyRelatedField( + todo_id = serializers.PrimaryKeyRelatedField( queryset=Todo.objects.all(), required=False ) date = serializers.DateField(allow_null=True, required=False) - order = SwaggerOrderserializer(required=False) + rank = SwaggerRankSerializer(required=False) is_completed = serializers.BooleanField(default=False, required=False) class Meta: @@ -64,8 +120,9 @@ class Meta: fields = [ "subtodo_id", "content", - "todo", + "todo_id", "date", - "order", + "due_time", + "rank", "is_completed", ] diff --git a/todos/tests/test_category_delete.py b/todos/tests/test_category_delete.py index 1535417..f1b611a 100644 --- a/todos/tests/test_category_delete.py +++ b/todos/tests/test_category_delete.py @@ -14,13 +14,12 @@ @pytest.mark.django_db def test_delete_category_success( - create_user, authenticated_client, content, order, color + create_user, authenticated_client, content, color ): category = Category.objects.create( user_id=create_user, title=content, color=color, - order=order(0), ) url = reverse("category") data = {"category_id": category.id} @@ -37,7 +36,7 @@ def test_delete_category_success( @pytest.mark.django_db def test_delete_category_invalid_id( - authenticated_client, content, order, color + authenticated_client, ): url = reverse("category") data = {"category_id": 999} diff --git a/todos/tests/test_category_get.py b/todos/tests/test_category_get.py index 808edec..8763b6e 100644 --- a/todos/tests/test_category_get.py +++ b/todos/tests/test_category_get.py @@ -13,21 +13,17 @@ @pytest.mark.django_db -def test_get_category( - create_user, authenticated_client, content, order, color -): +def test_get_category(create_user, authenticated_client, content, color): url = reverse("category") Category.objects.create( user_id=create_user, title=content, color=color, - order=order(0), ) Category.objects.create( user_id=create_user, title=content, color=color, - order=order(1), ) response = authenticated_client.get( url, {"user_id": create_user.id}, format="json" @@ -38,31 +34,28 @@ def test_get_category( @pytest.mark.django_db def test_get_category_ordering( - create_user, authenticated_client, content, order, color + create_user, authenticated_client, content, color ): url = reverse("category") Category.objects.create( user_id=create_user, - title=content, + title="1", color=color, - order=order(2), ) Category.objects.create( user_id=create_user, - title=content, + title="2", color=color, - order=order(1), ) Category.objects.create( user_id=create_user, - title=content, + title="3", color=color, - order=order(0), ) response = authenticated_client.get( url, {"user_id": create_user.id}, format="json" ) assert response.status_code == 200 - assert response.data[0]["order"] == order(0) - assert response.data[1]["order"] == order(1) - assert response.data[2]["order"] == order(2) + assert response.data[0]["title"] == "1" + assert response.data[1]["title"] == "2" + assert response.data[2]["title"] == "3" diff --git a/todos/tests/test_category_patch.py b/todos/tests/test_category_patch.py index 60f8b79..e861399 100644 --- a/todos/tests/test_category_patch.py +++ b/todos/tests/test_category_patch.py @@ -16,13 +16,12 @@ @pytest.mark.django_db def test_update_category_success( - create_user, authenticated_client, content, order, color + create_user, authenticated_client, content, color ): category = Category.objects.create( user_id=create_user, title=content, color=color, - order=order(0), ) url = reverse("category") # URL name for the categoryView patch method data = { @@ -36,13 +35,12 @@ def test_update_category_success( @pytest.mark.django_db def test_update_category_invalid_user_id( - create_user, authenticated_client, content, order, color + create_user, authenticated_client, content, color ): category = Category.objects.create( user_id=create_user, title=content, color=color, - order=order(0), ) url = reverse("category") data = {"category_id": category.id, "user_id": 999} diff --git a/todos/tests/test_category_post.py b/todos/tests/test_category_post.py index 4a3e991..3dafa49 100644 --- a/todos/tests/test_category_post.py +++ b/todos/tests/test_category_post.py @@ -1,8 +1,6 @@ import pytest from django.urls import reverse -from todos.models import Category - """ ====================================== # Todo Post checklist # @@ -15,51 +13,13 @@ @pytest.mark.django_db def test_create_category_success( - create_user, authenticated_client, content, order, color + create_user, authenticated_client, content, color ): url = reverse("category") data = { "user_id": create_user.id, "title": content, "color": color, - "order": order(0), } response = authenticated_client.post(url, data, format="json") assert response.status_code == 201 - assert "id" in response.data - - -@pytest.mark.django_db -def test_create_category_invalid_order( - create_user, authenticated_client, content, order, color -): - url = reverse("category") - Category.objects.create( - user_id=create_user, - title=content, - color=color, - order=order(0), - ) - data = { - "user_id": create_user.id, - "title": content, - "color": color, - "order": order(0), - } - response = authenticated_client.post(url, data, format="json") - assert response.status_code == 400 - - -@pytest.mark.django_db -def test_create_category_invalid_user_id( - authenticated_client, content, order, color -): - url = reverse("category") - data = { - "user_id": 999, - "title": content, - "color": color, - "order": order(0), - } - response = authenticated_client.post(url, data, format="json") - assert response.status_code == 400 diff --git a/todos/tests/test_firebase_alarm.py b/todos/tests/test_firebase_alarm.py new file mode 100644 index 0000000..4d768e3 --- /dev/null +++ b/todos/tests/test_firebase_alarm.py @@ -0,0 +1,23 @@ +import unittest +from unittest.mock import patch + +from django.test import TestCase + +from todos.firebase_messaging import ( + PUSH_NOTIFICATION_SUCCESS, + send_push_notification, +) + + +class FCMNotificationTest(TestCase): + @patch("todos.firebase_messaging.send_push_notification") + def test_send_push_notification(self, mock_send_fcm): + mock_send_fcm.return_value = PUSH_NOTIFICATION_SUCCESS + mock_send_fcm() + response = send_push_notification("device_token", "title", "message") + mock_send_fcm.assert_called_once() + self.assertEqual(response, PUSH_NOTIFICATION_SUCCESS) + + +if __name__ == "__main__": + unittest.main() diff --git a/todos/tests/test_recommend_check_rate_limit.py b/todos/tests/test_recommend_check_rate_limit.py new file mode 100644 index 0000000..b3e2b12 --- /dev/null +++ b/todos/tests/test_recommend_check_rate_limit.py @@ -0,0 +1,66 @@ +# import asyncio +import datetime as dt +from unittest.mock import AsyncMock, patch + +import pytest +from django.urls import reverse +from rest_framework import status + + +@pytest.mark.django_db +@pytest.mark.asyncio +@patch( + "todos.views.openai_client.chat.completions.create", new_callable=AsyncMock +) +def test_rate_limit_exceeded( + mock_llm, authenticated_client, create_todo, recommend_result +): + mock_llm.return_value = recommend_result + url = reverse("recommend") + + response = authenticated_client.get(url, {"todo_id": create_todo.id}) + assert response.status_code == status.HTTP_200_OK + + response = authenticated_client.get(url, {"todo_id": create_todo.id}) + assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS + assert response.json()["error"] == "Rate limit exceeded" + + +@pytest.mark.django_db +@patch( + "todos.views.openai_client.chat.completions.create", new_callable=AsyncMock +) +def test_rate_limit_passed( + mock_llm, authenticated_client, create_todo, recommend_result, time_machine +): + url = reverse("recommend") + mock_llm.return_value = recommend_result + time_machine.move_to(dt.datetime(2024, 3, 3)) + + response = authenticated_client.get(url, {"todo_id": create_todo.id}) + assert response.status_code == status.HTTP_200_OK + + time_machine.shift(dt.timedelta(seconds=10)) + + response = authenticated_client.get(url, {"todo_id": create_todo.id}) + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +@patch( + "todos.views.openai_client.chat.completions.create", new_callable=AsyncMock +) +def test_rate_limit_premium( + mock_llm, authenticated_client, create_user, create_todo, recommend_result +): + create_user.is_premium = True + create_user.save() + + url = reverse("recommend") + mock_llm.return_value = recommend_result + + response = authenticated_client.get(url, {"todo_id": create_todo.id}) + assert response.status_code == status.HTTP_200_OK + + response = authenticated_client.get(url, {"todo_id": create_todo.id}) + assert response.status_code == status.HTTP_200_OK diff --git a/todos/tests/test_subtodo_delete.py b/todos/tests/test_subtodo_delete.py index 61688a8..cfa475d 100644 --- a/todos/tests/test_subtodo_delete.py +++ b/todos/tests/test_subtodo_delete.py @@ -14,14 +14,14 @@ @pytest.mark.django_db def test_delete_subtodo_success( - create_todo, authenticated_client, content, date, order + create_todo, authenticated_client, content, date, rank ): subtodo = SubTodo.objects.create( content=content, date=date, - todo=create_todo, - order=order(0), + todo_id=create_todo, is_completed=False, + rank=rank[0], ) url = reverse("subtodos") data = {"subtodo_id": subtodo.id} @@ -37,7 +37,7 @@ def test_delete_subtodo_success( @pytest.mark.django_db -def test_delete_subtodo_invalid_id(authenticated_client, content, date, order): +def test_delete_subtodo_invalid_id(authenticated_client): url = reverse("subtodos") data = { "subtodo_id": 999 # Invalid subtodo id diff --git a/todos/tests/test_subtodo_get.py b/todos/tests/test_subtodo_get.py index 34dcc9a..ea423d1 100644 --- a/todos/tests/test_subtodo_get.py +++ b/todos/tests/test_subtodo_get.py @@ -14,21 +14,21 @@ @pytest.mark.django_db -def test_get_subtodos(create_todo, authenticated_client, content, date, order): +def test_get_subtodos(create_todo, authenticated_client, content, date, rank): url = reverse("subtodos") SubTodo.objects.create( content=content, date=date, - todo=create_todo, - order=order(0), + todo_id=create_todo, is_completed=False, + rank=rank[0], ) SubTodo.objects.create( content=content, date=date, - todo=create_todo, - order=order(1), + todo_id=create_todo, is_completed=False, + rank=rank[1], ) response = authenticated_client.get( url, {"todo_id": create_todo.id}, format="json" @@ -39,64 +39,64 @@ def test_get_subtodos(create_todo, authenticated_client, content, date, order): @pytest.mark.django_db def test_get_subtodos_ordering( - create_todo, authenticated_client, content, date, order + create_todo, authenticated_client, content, date, rank ): url = reverse("subtodos") SubTodo.objects.create( - content=content, + content="1", date=date, - todo=create_todo, - order=order(2), + todo_id=create_todo, is_completed=False, + rank=rank[0], ) SubTodo.objects.create( - content=content, + content="2", date=date, - todo=create_todo, - order=order(1), + todo_id=create_todo, is_completed=False, + rank=rank[1], ) SubTodo.objects.create( - content=content, + content="3", date=date, - todo=create_todo, - order=order(0), + todo_id=create_todo, is_completed=False, + rank=rank[2], ) response = authenticated_client.get( url, {"todo_id": create_todo.id}, format="json" ) assert response.status_code == 200 - assert response.data[0]["order"] == order(0) - assert response.data[1]["order"] == order(1) - assert response.data[2]["order"] == order(2) + assert response.data[0]["content"] == "1" + assert response.data[1]["content"] == "2" + assert response.data[2]["content"] == "3" @pytest.mark.django_db def test_get_subtodos_between_dates( - create_todo, authenticated_client, content, date, order + create_todo, authenticated_client, content, rank ): url = reverse("subtodos") SubTodo.objects.create( content=content, date="2024-08-02", - todo=create_todo, - order=order(0), + todo_id=create_todo, is_completed=False, + rank=rank[0], ) SubTodo.objects.create( content=content, date="2024-08-04", - todo=create_todo, - order=order(1), + todo_id=create_todo, is_completed=False, + rank=rank[1], ) SubTodo.objects.create( content=content, date="2024-08-06", - todo=create_todo, - order=order(2), + todo_id=create_todo, is_completed=False, + rank=rank[2], ) response = authenticated_client.get( url, diff --git a/todos/tests/test_subtodo_patch.py b/todos/tests/test_subtodo_patch.py index 3eac9e3..c41ed72 100644 --- a/todos/tests/test_subtodo_patch.py +++ b/todos/tests/test_subtodo_patch.py @@ -17,14 +17,14 @@ @pytest.mark.django_db def test_update_subtodo_success( - create_todo, authenticated_client, content, date, order + create_todo, authenticated_client, content, date, rank ): subtodo = SubTodo.objects.create( content=content, date=date, - todo=create_todo, - order=order(0), + todo_id=create_todo, is_completed=False, + rank=rank[0], ) url = reverse("subtodos") # URL name for the SubTodoView patch method data = { @@ -39,55 +39,132 @@ def test_update_subtodo_success( @pytest.mark.django_db -def test_update_subtodo_invalid_order( - create_todo, authenticated_client, content, date, order +def test_update_subtodo_success_top_rank( + create_todo, authenticated_client, content, date, rank ): subtodo = SubTodo.objects.create( content=content, date=date, - todo=create_todo, - order=order(0), + todo_id=create_todo, is_completed=False, + rank=rank[1], ) subtodo2 = SubTodo.objects.create( content=content, date=date, - todo=create_todo, - order=order(1), + todo_id=create_todo, is_completed=False, + rank=rank[1], ) url = reverse("subtodos") data = { - "subtodo_id": subtodo.id, + "subtodo_id": subtodo2.id, "content": "Updated SubTodo", "date": "2024-08-03", - "order": { + "patch_rank": { "prev_id": None, - "next_id": subtodo2.id, - "updated_order": order(2), + "next_id": subtodo.id, }, } response = authenticated_client.patch(url, data, format="json") - assert response.status_code == 400 + assert response.status_code == 200 + assert response.data["content"] == "Updated SubTodo" + assert response.data["rank"] < subtodo.rank + + +@pytest.mark.django_db +def test_update_subtodo_success_bottom_rank( + create_todo, authenticated_client, content, date, rank +): + subtodo = SubTodo.objects.create( + content=content, + date=date, + todo_id=create_todo, + is_completed=False, + rank=rank[0], + ) + subtodo2 = SubTodo.objects.create( + content=content, + date=date, + todo_id=create_todo, + is_completed=False, + rank=rank[1], + ) + url = reverse("subtodos") + data = { + "subtodo_id": subtodo.id, + "content": "Updated SubTodo", + "date": "2024-08-03", + "patch_rank": { + "prev_id": subtodo2.id, + "next_id": None, + }, + } + response = authenticated_client.patch(url, data, format="json") + assert response.status_code == 200 + assert response.data["content"] == "Updated SubTodo" + assert response.data["rank"] > subtodo2.rank + + +@pytest.mark.django_db +def test_update_subtodo_success_between_rank( + create_todo, authenticated_client, content, date, rank +): + subtodo = SubTodo.objects.create( + content=content, + date=date, + todo_id=create_todo, + is_completed=False, + rank=rank[0], + ) + subtodo2 = SubTodo.objects.create( + content=content, + date=date, + todo_id=create_todo, + is_completed=False, + rank=rank[1], + ) + subtodo3 = SubTodo.objects.create( + content=content, + date=date, + todo_id=create_todo, + is_completed=False, + rank=rank[2], + ) + url = reverse("subtodos") + data = { + "subtodo_id": subtodo.id, + "content": "Updated SubTodo", + "date": "2024-08-03", + "patch_rank": { + "prev_id": subtodo2.id, + "next_id": subtodo3.id, + }, + } + response = authenticated_client.patch(url, data, format="json") + assert response.status_code == 200 + assert response.data["content"] == "Updated SubTodo" + assert response.data["rank"] < subtodo3.rank + assert response.data["rank"] > subtodo2.rank @pytest.mark.django_db def test_update_subtodo_invalid_todo_id( - create_todo, authenticated_client, content, date, order + create_todo, authenticated_client, content, date, rank ): subtodo = SubTodo.objects.create( content=content, date=date, - todo=create_todo, - order=order(0), + todo_id=create_todo, is_completed=False, + rank=rank[0], ) url = reverse("subtodos") data = { "subtodo_id": subtodo.id, "content": "Updated SubTodo", "date": "2024-08-03", - "todo": 999, # Invalid todo id + "todo_id": 999, # Invalid todo id } response = authenticated_client.patch(url, data, format="json") assert response.status_code == 400 diff --git a/todos/tests/test_subtodo_post.py b/todos/tests/test_subtodo_post.py index 538ce1d..294ae4a 100644 --- a/todos/tests/test_subtodo_post.py +++ b/todos/tests/test_subtodo_post.py @@ -1,8 +1,6 @@ import pytest from django.urls import reverse -from todos.models import SubTodo - """ ====================================== # SubTodo Post checklist # @@ -17,60 +15,60 @@ @pytest.mark.django_db def test_create_subtodo_success( - create_todo, authenticated_client, content, date, order + create_todo, authenticated_client, content, date ): url = reverse("subtodos") data = [ { "content": content, "date": date, - "todo": create_todo.id, - "order": order(0), + "due_time": None, + "todo_id": create_todo.id, "is_completed": False, } ] response = authenticated_client.post(url, data, format="json") assert response.status_code == 201 - response_data = response.data[0] # 리스트의 첫 번째 항목 접근 - assert "id" in response_data + assert response.data[0]["content"] == content @pytest.mark.django_db -def test_create_subtodo_invalid_order( - create_todo, authenticated_client, content, date, order +def test_create_subtodo_success_many( + create_todo, authenticated_client, content, date ): - SubTodo.objects.create( - content=content, - date=date, - todo=create_todo, - order=order(0), - is_completed=False, - ) url = reverse("subtodos") data = [ { "content": content, "date": date, - "todo": create_todo.id, - "order": order(0), + "due_time": None, + "todo_id": create_todo.id, "is_completed": False, - } + }, + { + "content": content + "2", + "date": date, + "due_time": None, + "todo_id": create_todo.id, + "is_completed": False, + }, ] response = authenticated_client.post(url, data, format="json") - assert response.status_code == 400 + assert response.status_code == 201 + assert response.data[0]["content"] == content + assert response.data[1]["content"] == content + "2" + assert response.data[0]["rank"] < response.data[1]["rank"] @pytest.mark.django_db -def test_create_subtodo_invalid_todo_id( - authenticated_client, content, date, order -): +def test_create_subtodo_invalid_todo_id(authenticated_client, content, date): url = reverse("subtodos") data = [ { "content": content, "date": date, - "todo": 999, # Invalid todo id - "order": order(0), + "due_time": None, + "todo_id": 999, # Invalid todo id "is_completed": False, } ] diff --git a/todos/tests/test_todo_delete.py b/todos/tests/test_todo_delete.py index 2ee377d..5c3a34d 100644 --- a/todos/tests/test_todo_delete.py +++ b/todos/tests/test_todo_delete.py @@ -1,5 +1,3 @@ -from datetime import timedelta - import pytest from django.urls import reverse @@ -16,15 +14,15 @@ @pytest.mark.django_db def test_delete_todo_success( - authenticated_client, create_category, create_user, date, content, order + authenticated_client, create_category, create_user, date, content, rank ): todo = Todo.objects.create( user_id=create_user, - start_date=date + timedelta(days=1), - end_date=date + timedelta(days=2), + date=date, + due_time=None, content=content, category_id=create_category, - order=order(0), + rank=rank[0], ) url = reverse("todos") data = {"todo_id": todo.id} @@ -40,7 +38,7 @@ def test_delete_todo_success( @pytest.mark.django_db -def test_delete_todo_invalid_id(authenticated_client, order): +def test_delete_todo_invalid_id(authenticated_client): url = reverse("todos") data = {"todo_id": 999} response = authenticated_client.delete(url, data, format="json") diff --git a/todos/tests/test_todo_get.py b/todos/tests/test_todo_get.py index 46f4a7a..c1b8507 100644 --- a/todos/tests/test_todo_get.py +++ b/todos/tests/test_todo_get.py @@ -19,24 +19,24 @@ @pytest.mark.django_db def test_get_todos( - create_user, create_category, authenticated_client, date, content, order + create_user, date, content, create_category, authenticated_client, rank ): url = reverse("todos") Todo.objects.create( user_id=create_user, - start_date=date + timedelta(days=1), - end_date=date + timedelta(days=2), + date=date, + due_time=None, content=content, category_id=create_category, - order=order(0), + rank=rank[0], ) Todo.objects.create( user_id=create_user, - start_date=date + timedelta(days=1), - end_date=date + timedelta(days=2), + date=date, + due_time=None, content=content, category_id=create_category, - order=order(1), + rank=rank[1], ) response = authenticated_client.get( url, {"user_id": create_user.id}, format="json" @@ -47,122 +47,31 @@ def test_get_todos( @pytest.mark.django_db def test_get_todos_ordering( - create_user, create_category, authenticated_client, date, content, order + create_user, create_category, authenticated_client, date, content ): url = reverse("todos") Todo.objects.create( user_id=create_user, - start_date=date + timedelta(days=1), - end_date=date + timedelta(days=2), + date=date, + due_time=None, content=content, category_id=create_category, - order=order(2), ) Todo.objects.create( user_id=create_user, - start_date=date + timedelta(days=1), - end_date=date + timedelta(days=2), + date=date + timedelta(days=1), + due_time=None, content=content, category_id=create_category, - order=order(1), ) Todo.objects.create( user_id=create_user, - start_date=date + timedelta(days=1), - end_date=date + timedelta(days=2), + date=date, + due_time=None, content=content, category_id=create_category, - order=order(0), ) response = authenticated_client.get( url, {"user_id": create_user.id}, format="json" ) assert response.status_code == 200 - - assert response.data[0]["order"] == order(0) - assert response.data[1]["order"] == order(1) - assert response.data[2]["order"] == order(2) - - -@pytest.mark.django_db -def test_get_todos_between_dates( - create_user, create_category, authenticated_client, date, content, order -): - url = reverse("todos") - Todo.objects.create( - user_id=create_user, - start_date=date + timedelta(days=2), - end_date=date + timedelta(days=4), - content=content, - category_id=create_category, - order=order(0), - ) - Todo.objects.create( - user_id=create_user, - start_date=date + timedelta(days=4), - end_date=date + timedelta(days=6), - content=content, - category_id=create_category, - order=order(1), - ) - Todo.objects.create( - user_id=create_user, - start_date=date + timedelta(days=6), - end_date=date + timedelta(days=8), - content=content, - category_id=create_category, - order=order(2), - ) - response = authenticated_client.get( - url, - { - "user_id": create_user.id, - "start_date": date + timedelta(days=3), - "end_date": date + timedelta(days=6), - }, - format="json", - ) - assert response.status_code == 200 - assert len(response.data) == 3 - - -@pytest.mark.django_db -def test_get_todos_between_dates2( - create_user, create_category, authenticated_client, date, content, order -): - url = reverse("todos") - Todo.objects.create( - user_id=create_user, - start_date=date + timedelta(days=2), - end_date=date + timedelta(days=4), - content=content, - category_id=create_category, - order=order(0), - ) - Todo.objects.create( - user_id=create_user, - start_date=date + timedelta(days=4), - end_date=date + timedelta(days=6), - content=content, - category_id=create_category, - order=order(1), - ) - Todo.objects.create( - user_id=create_user, - start_date=date + timedelta(days=7), - end_date=date + timedelta(days=8), - content=content, - category_id=create_category, - order=order(2), - ) - response = authenticated_client.get( - url, - { - "user_id": create_user.id, - "start_date": date + timedelta(days=3), - "end_date": date + timedelta(days=6), - }, - format="json", - ) - assert response.status_code == 200 - assert len(response.data) == 2 diff --git a/todos/tests/test_todo_patch.py b/todos/tests/test_todo_patch.py index 4c31924..75796c2 100644 --- a/todos/tests/test_todo_patch.py +++ b/todos/tests/test_todo_patch.py @@ -3,6 +3,7 @@ import pytest from django.urls import reverse +from Lexorank.src.lexo_rank import LexoRank from todos.models import Todo """ @@ -19,140 +20,188 @@ @pytest.mark.django_db def test_update_todo_success( - authenticated_client, create_category, create_user, date, content, order + authenticated_client, + create_category, + create_user, + date, + content, + due_time, + rank, ): todo = Todo.objects.create( user_id=create_user, - start_date=date + timedelta(days=1), - end_date=date + timedelta(days=2), + date=date, + due_time=None, content=content, category_id=create_category, - order=order(0), + rank=rank[0], ) url = reverse("todos") data = { "todo_id": todo.id, "content": "Updated Todo", - "start_date": date + timedelta(days=1), - "end_date": date + timedelta(days=3), + "date": date + timedelta(days=1), + "due_time": due_time, } response = authenticated_client.patch(url, data, format="json") assert response.status_code == 200 assert response.data["content"] == "Updated Todo" - assert response.data["end_date"] == str(data["end_date"]) + assert response.data["date"] == str(data["date"]) @pytest.mark.django_db -def test_update_todo_success_order( - create_user, create_category, authenticated_client, date, content, order +def test_update_todo_success_bottom_order( + create_user, create_category, authenticated_client, date, content, rank ): todo = Todo.objects.create( user_id=create_user, - start_date=date + timedelta(days=1), - end_date=date + timedelta(days=2), + date=date, + due_time=None, content=content, category_id=create_category, - order=order(0), + rank=rank[0], ) todo2 = Todo.objects.create( user_id=create_user, - start_date=date + timedelta(days=1), - end_date=date + timedelta(days=2), + date=date, + due_time=None, content=content, category_id=create_category, - order=order(1), + rank=rank[1], ) url = reverse("todos") data = { "todo_id": todo.id, - "order": { + "patch_rank": { "prev_id": todo2.id, "next_id": None, - "updated_order": order(2), }, } response = authenticated_client.patch(url, data, format="json") assert response.status_code == 200 - assert response.data["order"] == order(2) + assert response.data["rank"] > todo2.rank @pytest.mark.django_db -def test_update_todo_invalid_order( - authenticated_client, create_category, create_user, date, content, order +def test_update_todo_success_top_order( + create_user, create_category, authenticated_client, date, content, rank ): todo = Todo.objects.create( user_id=create_user, - start_date=date + timedelta(days=1), - end_date=date + timedelta(days=2), + date=date, + due_time=None, content=content, category_id=create_category, - order=order(0), + rank=rank[0], ) todo2 = Todo.objects.create( user_id=create_user, - start_date=date + timedelta(days=1), - end_date=date + timedelta(days=2), + date=date, + due_time=None, content=content, category_id=create_category, - order=order(1), + rank=rank[1], ) url = reverse("todos") + data = { + "todo_id": todo2.id, + "patch_rank": { + "prev_id": None, + "next_id": todo.id, + }, + } + response = authenticated_client.patch(url, data, format="json") + assert response.status_code == 200 + assert LexoRank.parse(response.data["rank"]) < LexoRank.parse(todo.rank) + + +@pytest.mark.django_db +def test_update_todo_success_None_order( + create_user, create_category, authenticated_client, date, content, rank +): + todo = Todo.objects.create( + user_id=create_user, + date=date, + due_time=None, + content=content, + category_id=create_category, + rank=rank[0], + ) + before_rank = todo.rank + url = reverse("todos") data = { "todo_id": todo.id, - "content": "Updated Todo", - "start_date": date + timedelta(days=1), - "end_date": date + timedelta(days=3), - "order": { + "patch_rank": { "prev_id": None, - "next_id": todo2.id, - "updated_order": order(2), + "next_id": None, }, } response = authenticated_client.patch(url, data, format="json") - assert response.status_code == 400 + assert response.status_code == 200 + assert response.data["rank"] == before_rank @pytest.mark.django_db -def test_update_todo_invalid_start_date( - authenticated_client, create_category, create_user, date, content, order +def test_update_todo_success_between_order( + create_user, create_category, authenticated_client, date, content, rank ): todo = Todo.objects.create( user_id=create_user, - start_date=date + timedelta(days=1), - end_date=date + timedelta(days=2), + date=date, + due_time=None, content=content, category_id=create_category, - order=order(0), + rank=rank[0], + ) + todo2 = Todo.objects.create( + user_id=create_user, + date=date, + due_time=None, + content=content, + category_id=create_category, + rank=rank[1], + ) + todo3 = Todo.objects.create( + user_id=create_user, + date=date, + due_time=None, + content=content, + category_id=create_category, + rank=rank[2], ) url = reverse("todos") data = { "todo_id": todo.id, - "content": "Updated Todo", - "start_date": date + timedelta(days=2), - "end_date": date + timedelta(days=1), + "patch_rank": { + "prev_id": todo2.id, + "next_id": todo3.id, + }, } response = authenticated_client.patch(url, data, format="json") - assert response.status_code == 400 + assert response.status_code == 200 + assert response.data["rank"] > todo2.rank + assert response.data["rank"] < todo3.rank @pytest.mark.django_db def test_update_todo_invalid_category_id( - authenticated_client, create_category, create_user, date, content, order + authenticated_client, + create_category, + create_user, + date, + content, ): todo = Todo.objects.create( user_id=create_user, - start_date=date + timedelta(days=1), - end_date=date + timedelta(days=2), + date=date, + due_time=None, content=content, category_id=create_category, - order=order(0), ) url = reverse("todos") data = { "todo_id": todo.id, "content": "Updated Todo", - "start_date": date + timedelta(days=1), - "end_date": date + timedelta(days=3), "category_id": 999, } response = authenticated_client.patch(url, data, format="json") @@ -161,22 +210,23 @@ def test_update_todo_invalid_category_id( @pytest.mark.django_db def test_update_todo_invalid_user_id( - authenticated_client, create_category, create_user, date, content, order + authenticated_client, + create_category, + create_user, + date, + content, ): todo = Todo.objects.create( user_id=create_user, - start_date=date + timedelta(days=1), - end_date=date + timedelta(days=2), + date=date, + due_time=None, content=content, category_id=create_category, - order=order(0), ) url = reverse("todos") data = { "todo_id": todo.id, "content": "Updated Todo", - "start_date": date + timedelta(days=1), - "end_date": date + timedelta(days=3), "user_id": 999, } response = authenticated_client.patch(url, data, format="json") diff --git a/todos/tests/test_todo_post.py b/todos/tests/test_todo_post.py index 2704644..a8299bb 100644 --- a/todos/tests/test_todo_post.py +++ b/todos/tests/test_todo_post.py @@ -1,10 +1,6 @@ -from datetime import timedelta - import pytest from django.urls import reverse -from todos.models import Todo - """ ====================================== # SubTodo Post checklist # @@ -19,110 +15,35 @@ @pytest.mark.django_db def test_create_todo_success( - authenticated_client, create_category, create_user, date, content, order -): - url = reverse("todos") - data = { - "user_id": create_user.id, - "start_date": date, - "end_date": date + timedelta(days=1), - "content": content, - "category_id": create_category.id, - "order": order(0), - } - response = authenticated_client.post(url, data, format="json") - assert response.status_code == 201 - assert "id" in response.data - - -@pytest.mark.django_db -def test_create_todo_invalid_order( - create_user, create_category, authenticated_client, date, content, order -): - url = reverse("todos") - Todo.objects.create( - user_id=create_user, - start_date=date, - end_date=date + timedelta(days=1), - content=content, - category_id=create_category, - order=order(1), - ) - data = { - "user_id": create_user.id, - "start_date": date + timedelta(days=2), - "end_date": date + timedelta(days=3), - "content": content, - "category_id": create_category.id, - "order": order(0), - } - response = authenticated_client.post(url, data, format="json") - assert response.status_code == 400 - assert response.data["non_field_errors"][0] == "Order is invalid" - - -@pytest.mark.django_db -def test_create_todo_invalid_start_date( - create_user, create_category, authenticated_client, date, content, order + authenticated_client, + create_category, + create_user, + date, + content, ): url = reverse("todos") data = { - "user_id": create_user.id, - "start_date": date + timedelta(days=2), - "end_date": date + timedelta(days=1), + "date": date, + "due_time": None, "content": content, "category_id": create_category.id, - "order": order(0), - } - response = authenticated_client.post(url, data, format="json") - assert response.status_code == 400 - - -@pytest.mark.django_db -def test_create_todo_valid_start_date( - create_user, create_category, authenticated_client, date, content, order -): - url = reverse("todos") - data = { - "user_id": create_user.id, - "start_date": date + timedelta(days=2), - "content": content, - "category_id": create_category.id, - "order": order(0), } response = authenticated_client.post(url, data, format="json") assert response.status_code == 201 + assert response.data["rank"] == "0|hzzzzz:" @pytest.mark.django_db def test_create_todo_invalid_category_id( - create_user, authenticated_client, date, content, order + create_user, authenticated_client, date, content ): url = reverse("todos") data = { "user_id": create_user.id, - "start_date": date + timedelta(days=1), - "end_date": date + timedelta(days=2), + "date": date, + "due_time": None, "content": content, "category_id": 999, - "order": order(0), - } - response = authenticated_client.post(url, data, format="json") - assert response.status_code == 400 - - -@pytest.mark.django_db -def test_create_todo_invalid_user_id( - authenticated_client, create_category, date, content, order -): - url = reverse("todos") - data = { - "user_id": 999, - "start_date": date + timedelta(days=1), - "end_date": date + timedelta(days=2), - "content": content, - "category_id": create_category.id, - "order": order(0), } response = authenticated_client.post(url, data, format="json") assert response.status_code == 400 diff --git a/todos/urls.py b/todos/urls.py index 65fdb65..cb9b4ce 100644 --- a/todos/urls.py +++ b/todos/urls.py @@ -14,5 +14,5 @@ path("sub/", SubTodoView.as_view(), name="subtodos"), path("category/", CategoryView.as_view(), name="category"), path("inbox/", InboxView.as_view(), name="inbox"), - path("recommend/", RecommendSubTodo.as_view(), name="Recommend"), + path("recommend/", RecommendSubTodo.as_view(), name="recommend"), ] diff --git a/todos/utils.py b/todos/utils.py index b3a390c..afa5c10 100644 --- a/todos/utils.py +++ b/todos/utils.py @@ -1,24 +1,18 @@ -from todos.lexorank import LexoRank +import sentry_sdk -def validate_lexo_order(prev, next, updated): - updated_lexo = LexoRank(updated) - if prev is None and next is None: - return True - if prev is None: - next_lexo = LexoRank(next) - if next_lexo.compare_to(updated_lexo) <= 0: - return False - elif next is None: - prev_lexo = LexoRank(prev) - if prev_lexo.compare_to(updated_lexo) >= 0: - return False - else: - prev_lexo = LexoRank(prev) - next_lexo = LexoRank(next) - if ( - prev_lexo.compare_to(updated_lexo) >= 0 - or next_lexo.compare_to(updated_lexo) <= 0 - ): - return False - return True +def set_sentry_user(user): + sentry_sdk.set_user( + { + "id": user.id, + "username": user.username, + } + ) + + +def sentry_validation_error(where: str, error, user_id): + sentry_sdk.capture_message( + "Validation Error in" + where, + level="error", + extra={"error": error, "user_id": user_id}, + ) diff --git a/todos/views.py b/todos/views.py index 8e43194..155c0b7 100644 --- a/todos/views.py +++ b/todos/views.py @@ -1,6 +1,9 @@ # todos/views.py + + import json +import sentry_sdk from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema from rest_framework import status @@ -8,8 +11,9 @@ from rest_framework.response import Response from rest_framework.views import APIView -from onestep_be.settings import client -from todos.models import Category, SubTodo, Todo +from onestep_be.settings import openai_client +from todos.firebase_messaging import send_push_notification_device +from todos.models import Category, SubTodo, Todo, UserLastUsage from todos.serializers import ( CategorySerializer, GetTodoSerializer, @@ -18,9 +22,22 @@ ) from todos.swagger_serializers import ( SwaggerCategoryPatchSerializer, + SwaggerCategorySerializer, SwaggerSubTodoPatchSerializer, + SwaggerSubTodoSerializer, SwaggerTodoPatchSerializer, + SwaggerTodoSerializer, ) +from todos.utils import sentry_validation_error, set_sentry_user + +TODO_FCM_MESSAGE_TITLE = "Todo" +TODO_FCM_MESSAGE_BODY = "Todo가 변경되었습니다." +SUBTODO_FCM_MESSAGE_TITLE = "SubTodo" +SUBTODO_FCM_MESSAGE_BODY = "SubTodo가 변경되었습니다." +CATEGORY_FCM_MESSAGE_TITLE = "Category" +CATEGORY_FCM_MESSAGE_BODY = "Category가 변경되었습니다." + +RATE_LIMIT_SECONDS = 10 class TodoView(APIView): @@ -29,47 +46,61 @@ class TodoView(APIView): @swagger_auto_schema( tags=["Todo"], - request_body=TodoSerializer, + request_body=SwaggerTodoSerializer, operation_summary="Create a todo", - responses={201: TodoSerializer}, + responses={201: SwaggerTodoSerializer}, ) def post(self, request): """ - 이 함수는 todo를 생성하는 함수입니다. - - 입력 : user_id, start_date, deadline, content, category, parent_id + - 입력 : date, due_time, content, category, parent_id - content 는 암호화 되어야 합니다. - - deadline 은 항상 start_date 와 같은 날이거나 그 이후여야합니다 - category_id 는 category에 존재해야합니다. - content는 1자 이상 50자 이하여야합니다. - - user_id 는 user 테이블에 존재해야합니다. - - parent_id는 todo 테이블에 이미 존재해야합니다. - - parent_id가 없는 경우 null로 처리합니다. - - parent_id는 자기 자신을 참조할 수 없습니다. 구현해야할 내용 - order 순서 정리 - 암호화 """ - data = request.data - # category_id validation - serializer = TodoSerializer(context={"request": request}, data=data) - if serializer.is_valid(raise_exception=True): - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response( - {"error": serializer.errors}, status=status.HTTP_400_BAD_REQUEST - ) + + try: + data = request.data.copy() + data["user_id"] = request.user.id + data["rank"] = Todo.objects.get_next_rank(request.user.id) + + set_sentry_user(request.user) + serializer = TodoSerializer( + context={"request": request}, data=data + ) + if serializer.is_valid(raise_exception=True): + serializer.save() + send_push_notification_device( + request.auth.get("device"), + request.user, + TODO_FCM_MESSAGE_TITLE, + TODO_FCM_MESSAGE_BODY, + ) + return Response( + serializer.data, status=status.HTTP_201_CREATED + ) + else: + sentry_validation_error( + "TodoCreate", serializer.errors, request.user.id + ) + return Response( + {"error": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + except Exception as e: + sentry_sdk.capture_exception(e) + return Response( + {"error": str(e)}, + status=status.HTTP_400_BAD_REQUEST, + ) @swagger_auto_schema( tags=["Todo"], manual_parameters=[ - openapi.Parameter( - "user_id", - openapi.IN_QUERY, - type=openapi.TYPE_INTEGER, - description="user_id", - required=True, - ), openapi.Parameter( "start_date", openapi.IN_QUERY, @@ -93,16 +124,17 @@ def post(self, request): def get(self, request): """ - 이 함수는 daily todo list를 불러오는 함수입니다. - - 입력 : user_id(필수), start_date, end_date + - 입력 : start_date, end_date - start_date와 end_date가 없는 경우 user_id에 해당하는 모든 todo를 불러옵니다. - start_date와 end_date가 있는 경우 user_id에 해당하는 todo 중 start_date와 end_date 사이에 있는 todo를 불러옵니다. - order 의 순서로 정렬합니다. """ # noqa: E501 start_date = request.GET.get("start_date") end_date = request.GET.get("end_date") - user_id = request.GET.get("user_id") - + user_id = request.user.id + set_sentry_user(request.user) if user_id is None: + sentry_sdk.capture_message("User_id not provided", level="error") return Response( {"error": "user_id must be provided"}, status=status.HTTP_400_BAD_REQUEST, @@ -114,11 +146,12 @@ def get(self, request): todos = Todo.objects.get_daily_with_date( user_id=user_id, start_date=start_date, end_date=end_date ) - else: # start_date and end_date are None + else: todos = Todo.objects.get_with_user_id( user_id=user_id - ).order_by("order") - except Todo.DoesNotExist: + ).order_by("rank") + except Todo.DoesNotExist as e: + sentry_sdk.capture_exception(e) return Response( {"error": "Todo not found"}, status=status.HTTP_404_NOT_FOUND ) @@ -129,31 +162,34 @@ def get(self, request): tags=["Todo"], request_body=SwaggerTodoPatchSerializer, operation_summary="Update a todo", - responses={200: TodoSerializer}, + responses={200: SwaggerTodoSerializer}, ) def patch(self, request): """ - 이 함수는 todo를 수정하는 함수입니다. - 입력 : todo_id, 수정 내용 - - 수정 내용은 content, category, start_date, end_date 중 하나 이상이어야 합니다. - - order 의 경우 아래와 같이 제시된다. - "order" : { + - 수정 내용은 content, category, date, due_time 중 하나 이상이어야 합니다. + - rank 의 경우 아래와 같이 제시된다. + "rank" : { "prev_id" : 1, "next_id" : 3, - "updated_order" : "0|asdf:" } """ # noqa: E501 + set_sentry_user(request.user) todo_id = request.data.get("todo_id") + if todo_id is None: + sentry_sdk.capture_message("Todo_id not provided", level="error") + return Response( + {"error": "todo_id must be provided"}, + status=status.HTTP_400_BAD_REQUEST, + ) try: todo = Todo.objects.get(id=todo_id, deleted_at__isnull=True) - except Todo.DoesNotExist: + except Todo.DoesNotExist as e: + sentry_sdk.capture_exception(e) return Response( {"error": "Todo not found"}, status=status.HTTP_404_NOT_FOUND ) - if "order" in request.data: - nested_order = request.data.get("order") - request.data["order"] = nested_order.get("updated_order") - request.data["patch_order"] = nested_order serializer = TodoSerializer( context={"request": request}, instance=todo, @@ -163,10 +199,21 @@ def patch(self, request): if serializer.is_valid(raise_exception=True): serializer.save() + send_push_notification_device( + request.auth.get("device"), + request.user, + TODO_FCM_MESSAGE_TITLE, + TODO_FCM_MESSAGE_BODY, + ) return Response(serializer.data, status=status.HTTP_200_OK) - return Response( - {"error": serializer.errors}, status=status.HTTP_400_BAD_REQUEST - ) + else: + sentry_validation_error( + "TodoPatch", serializer.errors, request.user.id + ) + return Response( + {"error": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) @swagger_auto_schema( tags=["Todo"], @@ -179,7 +226,7 @@ def patch(self, request): }, ), operation_summary="Delete a todo", - responses={200: TodoSerializer}, + responses={200: SwaggerTodoSerializer}, ) def delete(self, request): """ @@ -189,56 +236,84 @@ def delete(self, request): - deleted_at 필드가 null이 아닌 경우 이미 삭제된 todo입니다. - 해당 todo 에 속한 subtodo 도 전부 delete 를 해야함 """ # noqa: E501 + set_sentry_user(request.user) todo_id = request.data.get("todo_id") - + if todo_id is None: + sentry_sdk.capture_message("Todo_id not provided", level="error") + return Response( + {"error": "todo_id must be provided"}, + status=status.HTTP_400_BAD_REQUEST, + ) try: todo = Todo.objects.get_with_id(id=todo_id) subtodos = SubTodo.objects.get_subtodos(todo.id) SubTodo.objects.delete_many(subtodos) Todo.objects.delete_instance(todo) - except Todo.DoesNotExist: + send_push_notification_device( + request.auth.get("device"), + request.user, + TODO_FCM_MESSAGE_TITLE, + TODO_FCM_MESSAGE_BODY, + ) return Response( - {"error": "Todo not found"}, status=status.HTTP_404_NOT_FOUND + {"todo_id": todo.id, "message": "Todo deleted successfully"}, + status=status.HTTP_200_OK, + ) + except Todo.DoesNotExist as e: + sentry_sdk.capture_exception(e) + return Response( + {"error": "Todo not found"}, status=status.HTTP_400_BAD_REQUEST ) except Exception as e: + sentry_sdk.capture_exception(e) return Response( {"error": str(e)}, status=status.HTTP_400_BAD_REQUEST ) - return Response( - {"todo_id": todo.id, "message": "Todo deleted successfully"}, - status=status.HTTP_200_OK, - ) - class SubTodoView(APIView): permission_classes = [IsAuthenticated] @swagger_auto_schema( tags=["SubTodo"], - request_body=SubTodoSerializer(many=True), + request_body=SwaggerSubTodoSerializer(many=True), operation_summary="Create a subtodo", - responses={201: SubTodoSerializer}, + responses={201: SwaggerSubTodoSerializer}, ) def post(self, request): """ - 이 함수는 sub todo를 생성하는 함수입니다. - - 입력 : todo, date, content, order + - 입력 : todo, date, content - subtodo 는 리스트에 여러 객체가 들어간 형태를 가집니다. - content 는 암호화 되어야 합니다(// 미정) - - date 는 parent의 start_date와 end_date의 사이여야 합니다. """ - data = request.data + set_sentry_user(request.user) + data = request.data.copy() + rank = SubTodo.objects.get_next_rank_subtodo(request.user.id) + for i in range(len(data)): + data[i]["rank"] = rank + rank = SubTodo.objects.gen_next_rank(rank) serializer = SubTodoSerializer( context={"request": request}, data=data, many=True ) - if serializer.is_valid(raise_exception=True): serializer.save() + send_push_notification_device( + request.auth.get("device"), + request.user, + SUBTODO_FCM_MESSAGE_TITLE, + SUBTODO_FCM_MESSAGE_BODY, + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response( - {"error": serializer.errors}, status=status.HTTP_400_BAD_REQUEST - ) + else: + sentry_validation_error( + "SubTodoCreate", serializer.errors, request.user.id + ) + return Response( + {"error": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) @swagger_auto_schema( tags=["SubTodo"], @@ -252,7 +327,7 @@ def post(self, request): ) ], operation_summary="Get a subtodo", - responses={200: SubTodoSerializer}, + responses={200: SwaggerSubTodoSerializer}, ) def get(self, request): """ @@ -260,37 +335,57 @@ def get(self, request): - 입력 : todo_id - parent_id에 해당하는 sub todo list를 불러옵니다. """ + set_sentry_user(request.user) todo_id = request.GET.get("todo_id") + if todo_id is None: + sentry_sdk.capture_message("Todo_id not provided", level="error") + return Response( + {"error": "todo_id must be provided"}, + status=status.HTTP_400_BAD_REQUEST, + ) try: sub_todos = SubTodo.objects.get_subtodos(todo_id=todo_id) - except SubTodo.DoesNotExist: + serializer = SubTodoSerializer(sub_todos, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + except SubTodo.DoesNotExist as e: + sentry_sdk.capture_exception(e) return Response( {"error": "SubTodo not found"}, status=status.HTTP_404_NOT_FOUND, ) - - serializer = SubTodoSerializer(sub_todos, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) + except Exception as e: + sentry_sdk.capture_exception(e) + return Response( + {"error": str(e)}, status=status.HTTP_400_BAD_REQUEST + ) @swagger_auto_schema( tags=["SubTodo"], request_body=SwaggerSubTodoPatchSerializer, operation_summary="Update a subtodo", - responses={200: SubTodoSerializer}, + responses={200: SwaggerSubTodoSerializer}, ) def patch(self, request): """ - 이 함수는 sub todo를 수정하는 함수입니다. - 입력 : subtodo_id, 수정 내용 - - 수정 내용은 content, date, parent_id 중 하나 이상이어야 합니다. - - order 의 경우 아래와 같이 수신됨 - "order" : { + - 수정 내용은 content, date, parent_id, rank 중 하나 이상이어야 합니다. + - rank 의 경우 아래와 같이 수신됨 + "rank" : { "prev_id" : 1, "next_id" : 3, - "updated_order" : "0|asdf:" } """ + set_sentry_user(request.user) subtodo_id = request.data.get("subtodo_id") + if subtodo_id is None: + sentry_sdk.capture_message( + "SubTodo_id not provided", level="error" + ) + return Response( + {"error": "subtodo_id must be provided"}, + status=status.HTTP_400_BAD_REQUEST, + ) try: sub_todo = SubTodo.objects.get( id=subtodo_id, deleted_at__isnull=True @@ -300,10 +395,6 @@ def patch(self, request): {"error": "SubTodo not found"}, status=status.HTTP_404_NOT_FOUND, ) - if "order" in request.data: - nested_order = request.data.get("order") - request.data["order"] = nested_order.get("updated_order") - request.data["patch_order"] = nested_order serializer = SubTodoSerializer( context={"request": request}, instance=sub_todo, @@ -313,11 +404,22 @@ def patch(self, request): if serializer.is_valid(raise_exception=True): serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) + send_push_notification_device( + request.auth.get("device"), + request.user, + SUBTODO_FCM_MESSAGE_TITLE, + SUBTODO_FCM_MESSAGE_BODY, + ) - return Response( - {"error": serializer.errors}, status=status.HTTP_400_BAD_REQUEST - ) + return Response(serializer.data, status=status.HTTP_200_OK) + else: + sentry_validation_error( + "SubTodoPatch", serializer.errors, request.user.id + ) + return Response( + {"error": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) @swagger_auto_schema( tags=["SubTodo"], @@ -330,7 +432,7 @@ def patch(self, request): }, ), operation_summary="Delete a subtodo", - responses={200: SubTodoSerializer}, + responses={200: SwaggerSubTodoSerializer}, ) def delete(self, request): """ @@ -339,77 +441,126 @@ def delete(self, request): - subtodo_id에 해당하는 sub todo의 deleted_at 필드를 현재 시간으로 업데이트합니다. - deleted_at 필드가 null이 아닌 경우 이미 삭제된 sub todo입니다. """ # noqa: E501 + set_sentry_user(request.user) subtodo_id = request.data.get("subtodo_id") + if subtodo_id is None: + sentry_sdk.capture_message( + "SubTodo_id not provided", level="error" + ) + return Response( + {"error": "subtodo_id must be provided"}, + status=status.HTTP_400_BAD_REQUEST, + ) try: sub_todo = SubTodo.objects.get_with_id(id=subtodo_id) SubTodo.objects.delete_instance(sub_todo) - except SubTodo.DoesNotExist: + + send_push_notification_device( + request.auth.get("device"), + request.user, + SUBTODO_FCM_MESSAGE_TITLE, + SUBTODO_FCM_MESSAGE_BODY, + ) + return Response( + { + "subtodo_id": sub_todo.id, + "message": "SubTodo deleted successfully", + }, + status=status.HTTP_200_OK, + ) + except SubTodo.DoesNotExist as e: + sentry_sdk.capture_exception(e) return Response( {"error": "SubTodo not found"}, status=status.HTTP_404_NOT_FOUND, ) except Exception as e: + sentry_sdk.capture_exception(e) return Response( {"error": str(e)}, status=status.HTTP_400_BAD_REQUEST ) - return Response( - { - "subtodo_id": sub_todo.id, - "message": "SubTodo deleted successfully", - }, - status=status.HTTP_200_OK, - ) - class CategoryView(APIView): permission_classes = [IsAuthenticated] @swagger_auto_schema( tags=["Category"], - request_body=CategorySerializer, + request_body=SwaggerCategorySerializer, operation_summary="Create a category", - responses={201: CategorySerializer}, + responses={201: SwaggerCategorySerializer}, ) def post(self, request): """ - 이 함수는 category를 생성하는 함수입니다. - - 입력 : user_id, title, color + - 입력 : title, color - title은 1자 이상 50자 이하여야합니다. - color는 7자여야합니다. """ - data = request.data + set_sentry_user(request.user) + try: + data = request.data.copy() + data["user_id"] = request.user.id + data["rank"] = Category.objects.get_next_rank(request.user.id) - serializer = CategorySerializer( - context={"request": request}, data=data - ) + serializer = CategorySerializer( + context={"request": request}, data=data + ) - if serializer.is_valid(raise_exception=True): - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response( - {"error": serializer.errors}, status=status.HTTP_400_BAD_REQUEST - ) + if serializer.is_valid(raise_exception=True): + serializer.save() + send_push_notification_device( + request.auth.get("device"), + request.user, + CATEGORY_FCM_MESSAGE_TITLE, + CATEGORY_FCM_MESSAGE_BODY, + ) + + return Response( + serializer.data, status=status.HTTP_201_CREATED + ) + else: + sentry_validation_error( + "CategoryCreate", serializer.errors, request.user.id + ) + return Response( + {"error": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + except Exception as e: + sentry_sdk.capture_exception(e) + return Response( + {"error": str(e)}, + status=status.HTTP_400_BAD_REQUEST, + ) @swagger_auto_schema( tags=["Category"], request_body=SwaggerCategoryPatchSerializer, operation_summary="Update a category", - responses={200: CategorySerializer}, + responses={200: SwaggerCategorySerializer}, ) def patch(self, request): """ - 이 함수는 category를 수정하는 함수입니다. - 입력 : category_id, 수정 내용 - 수정 내용은 title, color 중 하나 이상이어야 합니다. - - order 의 경우 아래와 같이 수신됨 - "order" : { + - rank 의 경우 아래와 같이 수신됨 + "rank" : { "prev_id" : 1, "next_id" : 3, - "updated_order" : "0|asdf:" } """ + set_sentry_user(request.user) category_id = request.data.get("category_id") + if category_id is None: + sentry_sdk.capture_message( + "Category_id not provided", level="error" + ) + return Response( + {"error": "category_id must be provided"}, + status=status.HTTP_400_BAD_REQUEST, + ) if "user_id" in request.data: return Response( {"error": "user_id cannot be updated"}, @@ -420,17 +571,17 @@ def patch(self, request): category = Category.objects.get( id=category_id, deleted_at__isnull=True ) - except Category.DoesNotExist: + except Category.DoesNotExist as e: + sentry_sdk.capture_exception(e) return Response( {"error": "Category not found"}, status=status.HTTP_404_NOT_FOUND, ) - - if "order" in request.data: - nested_order = request.data.get("order") - request.data["order"] = nested_order.get("updated_order") - request.data["patch_order"] = nested_order - + except Exception as e: + sentry_sdk.capture_exception(e) + return Response( + {"error": str(e)}, status=status.HTTP_400_BAD_REQUEST + ) serializer = CategorySerializer( context={"request": request}, instance=category, @@ -439,47 +590,59 @@ def patch(self, request): ) if serializer.is_valid(raise_exception=True): serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) + send_push_notification_device( + request.auth.get("device"), + request.user, + CATEGORY_FCM_MESSAGE_TITLE, + CATEGORY_FCM_MESSAGE_BODY, + ) - return Response( - {"error": serializer.errors}, status=status.HTTP_400_BAD_REQUEST - ) + return Response(serializer.data, status=status.HTTP_200_OK) + else: + sentry_validation_error( + "CategoryPatch", serializer.errors, request.user.id + ) + return Response( + {"error": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) @swagger_auto_schema( tags=["Category"], - manual_parameters=[ - openapi.Parameter( - "user_id", - openapi.IN_QUERY, - type=openapi.TYPE_INTEGER, - description="user_id", - required=True, - ) - ], operation_summary="Get a category", - responses={200: CategorySerializer}, + responses={200: SwaggerCategorySerializer}, ) def get(self, request): """ - 이 함수는 category list를 불러오는 함수입니다. - - 입력 : user_id(필수) + - 입력 : 없음 - user_id에 해당하는 category list를 불러옵니다. """ - user_id = request.GET.get("user_id") - if user_id is None: - return Response( - {"error": "user_id must be provided"}, - status=status.HTTP_400_BAD_REQUEST, - ) + set_sentry_user(request.user) try: + user_id = request.user.id + if user_id is None: + sentry_sdk.capture_message( + "User_id not provided", level="error" + ) + return Response( + {"error": "user_id must be provided"}, + status=status.HTTP_400_BAD_REQUEST, + ) categories = Category.objects.get_with_user_id(user_id=user_id) - except Category.DoesNotExist: + serializer = CategorySerializer(categories, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + except Category.DoesNotExist as e: + sentry_sdk.capture_exception(e) return Response( {"error": "Category not found"}, status=status.HTTP_404_NOT_FOUND, ) - serializer = CategorySerializer(categories, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) + except Exception as e: + sentry_sdk.capture_exception(e) + return Response( + {"error": str(e)}, status=status.HTTP_400_BAD_REQUEST + ) @swagger_auto_schema( tags=["Category"], @@ -492,7 +655,7 @@ def get(self, request): }, ), operation_summary="Delete a category", - responses={200: CategorySerializer}, + responses={200: SwaggerCategorySerializer}, ) def delete(self, request): """ @@ -501,10 +664,25 @@ def delete(self, request): - category_id에 해당하는 category의 deleted_at 필드를 현재 시간으로 업데이트합니다. - deleted_at 필드가 null이 아닌 경우 이미 삭제된 category입니다. """ # noqa: E501 - category_id = request.data.get("category_id") try: + set_sentry_user(request.user) + category_id = request.data.get("category_id") + if category_id is None: + sentry_sdk.capture_message( + "Category_id not provided", level="error" + ) + return Response( + {"error": "category_id must be provided"}, + status=status.HTTP_400_BAD_REQUEST, + ) category = Category.objects.get_with_id(id=category_id) Category.objects.delete_instance(category) + send_push_notification_device( + request.auth.get("device"), + request.user, + CATEGORY_FCM_MESSAGE_TITLE, + CATEGORY_FCM_MESSAGE_BODY, + ) return Response( { "category_id": category.id, @@ -512,12 +690,14 @@ def delete(self, request): }, status=status.HTTP_200_OK, ) - except Category.DoesNotExist: + except Category.DoesNotExist as e: + sentry_sdk.capture_exception(e) return Response( {"error": "Category not found"}, status=status.HTTP_404_NOT_FOUND, ) except Exception as e: + sentry_sdk.capture_exception(e) return Response( {"error": str(e)}, status=status.HTTP_400_BAD_REQUEST ) @@ -543,24 +723,33 @@ class InboxView(APIView): def get(self, request): """ - 이 함수는 daily todo list를 불러오는 함수입니다. - - 입력 : user_id(필수) + - 입력 : 없음 - order 의 순서로 정렬합니다. """ - user_id = request.GET.get("user_id") - - if user_id is None: - return Response( - {"error": "user_id must be provided"}, - status=status.HTTP_400_BAD_REQUEST, - ) try: + set_sentry_user(request.user) + user_id = request.user.id + if user_id is None: + sentry_sdk.capture_message( + "User_id not provided", level="error" + ) + return Response( + {"error": "user_id must be provided"}, + status=status.HTTP_400_BAD_REQUEST, + ) todos = Todo.objects.get_inbox(user_id=user_id) - except Todo.DoesNotExist: + serializer = GetTodoSerializer(todos, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + except Todo.DoesNotExist as e: + sentry_sdk.capture_exception(e) return Response( {"error": "Inbox is Empty"}, status=status.HTTP_404_NOT_FOUND ) - serializer = GetTodoSerializer(todos, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) + except Exception as e: + sentry_sdk.capture_exception(e) + return Response( + {"error": str(e)}, status=status.HTTP_400_BAD_REQUEST + ) class RecommendSubTodo(APIView): @@ -583,57 +772,82 @@ class RecommendSubTodo(APIView): def get(self, request): """ - 이 함수는 sub todo를 추천하는 함수입니다. - - 입력 : todo_id, recommend_category - - todo_id에 해당하는 todo_id 의 Contents 를 바탕으로 sub todo를 추천합니다. - - 커스텀의 경우 사용자의 이전 기록들을 바탕으로 추천합니다. - - 추천할 때의 subtodo 는 약 1시간의 작업으로 openAI 의 api를 통해 추천합니다. - """ # noqa: E501 - todo_id = request.GET.get("todo_id") + """ + set_sentry_user(request.user) + + user_id = request.user.id try: + flag, message = UserLastUsage.check_rate_limit( + user_id=user_id, RATE_LIMIT_SECONDS=RATE_LIMIT_SECONDS + ) + + if flag is False: + return Response( + {"error": message}, + status=status.HTTP_429_TOO_MANY_REQUESTS, + ) + todo_id = request.GET.get("todo_id") + if todo_id is None: + sentry_sdk.capture_message( + "Todo_id not provided", level="error" + ) + return Response( + {"error": "todo_id must be provided"}, + status=status.HTTP_400_BAD_REQUEST, + ) + # 비동기적으로 OpenAI API 호출 처리 todo = Todo.objects.get_with_id(id=todo_id) - completion = client.chat.completions.create( - model="gpt-4o-mini", - messages=[ - { - "role": "system", - "content": """너는 퍼스널 매니저야. - 너가 하는 일은 이 사람이 할 이야기를 듣고 약 1시간 정도면 끝낼 수 있도록 작업을 나눠주는 식으로 진행할 거야. - 아래는 너가 나눠줄 작업 형식이야. - { id : 1, content: "3학년 2학기 운영체제 중간고사 준비", start_date="2024-09-01", end_date="2024-09-24"} - 이런 형식으로 작성된 작업을 받았을 때 너는 이 작업을 어떻게 나눠줄 것인지를 알려주면 돼. - Output a JSON object structured like: - {id, content, start_date, end_date, category_id, order, is_completed, children : [ - {content, date, todo(parent todo id)}, ... ,{content, date, todo(parent todo id)}]} - [조건] - - date 는 부모의 start_date를 따를 것 - - 작업은 한 서브투두를 해결하는데 1시간 정도로 이루어지도록 제시할 것 - - 언어는 주어진 todo content의 언어에 따를 것 - """, # noqa: E501 - }, - { - "role": "user", - "content": f"id: {todo.id}, \ - content: {todo.content}, \ - start_date: {todo.start_date}, \ - end_date: {todo.end_date}, \ - category_id: {todo.category_id}, \ - order: {todo.order}, \ - is_completed: {todo.is_completed}", - }, - ], - response_format={"type": "json_object"}, - ) - - except Todo.DoesNotExist: + todo_data = { + "id": todo.id, + "content": todo.content, + "date": todo.date, + "due_time": todo.due_time, + "category_id": todo.category_id, + } + completion = self.get_openai_completion(todo_data) + return Response( + json.loads(completion.choices[0].message.content), + status=status.HTTP_200_OK, + ) + except Todo.DoesNotExist as e: + sentry_sdk.capture_exception(e) return Response( {"error": "Todo not found"}, status=status.HTTP_404_NOT_FOUND ) except Exception as e: + sentry_sdk.capture_exception(e) return Response( {"error": str(e)}, status=status.HTTP_400_BAD_REQUEST ) - return Response( - json.loads(completion.choices[0].message.content), - status=status.HTTP_200_OK, + # 비동기적으로 OpenAI API를 호출하는 함수 + def get_openai_completion(self, todo): + return openai_client.chat.completions.create( + model="gpt-4o-mini", + messages=[ + { + "role": "system", + "content": """너는 퍼스널 매니저야. + 너가 하는 일은 이 사람이 할 이야기를 듣고 작업을 나눠줘야 해. + 아래는 너가 나눠줄 작업 형식이야. + { id : 1, content: "운영체제 중간고사 준비", date="2024-09-01", due_time=None} + 이런 형식으로 작성된 작업을 받았을 때 너는 이 작업을 어떻게 나눠줄 것인지를 알려주면 돼. + Output a JSON object structured like: + {id, content, date, due_time, category_id, children : [ + {content, todo(parent todo id)}, ...}]} + [조건] + - 작업은 한 서브투두를 해결하는데 1시간 정도로 이루어지도록 제시할 것 + - 언어는 주어진 todo content의 언어에 따를 것 + """, # noqa: E501 + }, + { + "role": "user", + "content": f"id: {todo['id']}, " + f"content: {todo['content']}, " + f"date: {todo['date']}, " + f"due_time: {todo['due_time']}, " + f"category_id: {todo['category_id']}", + }, + ], + response_format={"type": "json_object"}, ) diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..e69de29