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 }} 님 환영합니다!
+
+ 저희 서비스에 가입해 주셔서 진심으로 감사드립니다. 귀하의 선택을
+ 환영하며, 함께 멋진 경험을 만들어 나가길 기대합니다.
+
+
저희 서비스의 주요 기능:
+
+ - 큰 목표를 작은 걸음으로 나누어, 한 발씩 쉽게 나아가세요
+ - AI 지원 서비스
+ - 매일매일 피드백을 체크합니다.
+
+
+ 궁금한 점이 있으시면 언제든 문의해 주세요. 저희 팀이 항상 도와드릴
+ 준비가 되어 있습니다.
+
+
감사합니다!
+
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