diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml index 1896a3e..0cc3f29 100644 --- a/.github/workflows/django.yml +++ b/.github/workflows/django.yml @@ -2,13 +2,12 @@ name: Django CI on: push: - branches: [ "main" ] + branches: ["main"] pull_request: - branches: [ "main" ] + branches: ["main"] jobs: build: - runs-on: ubuntu-latest services: @@ -24,20 +23,21 @@ jobs: options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.11 - uses: actions/setup-python@v2 - with: - python-version: 3.11 - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - name: Run migrations - run: | - cd backend - python manage.py migrate - - name: Run tests - run: | - cd backend - python manage.py test + - uses: actions/checkout@v2 + - name: Set up Python 3.11 + uses: actions/setup-python@v2 + with: + python-version: 3.11 + - name: Install dependencies + run: | + cd backend + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Run migrations + run: | + cd backend + python manage.py migrate + - name: Run tests + run: | + cd backend + python manage.py test diff --git a/.gitignore b/.gitignore index d6a7e2b..a40fc56 100644 --- a/.gitignore +++ b/.gitignore @@ -57,7 +57,6 @@ cover/ # Django stuff: *.log -local_settings.py db.sqlite3 db.sqlite3-journal diff --git a/backend/authentications/urls.py b/backend/authentications/urls.py index 32965a4..6029eea 100644 --- a/backend/authentications/urls.py +++ b/backend/authentications/urls.py @@ -1,12 +1,11 @@ from django.urls import path from rest_framework_simplejwt import views as jwt_views -from authentications.views import ObtainTokenPairWithCustomView, GreetingView, GoogleLogin, GoogleRetrieveUserInfo +from authentications.views import ObtainTokenPairWithCustomView, GoogleRetrieveUserInfo, CheckAccessTokenAndRefreshToken urlpatterns = [ path('token/obtain/', jwt_views.TokenObtainPairView.as_view(), name='token_create'), path('token/refresh/', jwt_views.TokenRefreshView.as_view(), name='token_refresh'), path('token/custom_obtain/', ObtainTokenPairWithCustomView.as_view(), name='token_create_custom'), - path('hello/', GreetingView.as_view(), name='hello_world'), - path('dj-rest-auth/google/', GoogleLogin.as_view(), name="google_login"), path('auth/google/', GoogleRetrieveUserInfo.as_view()), + path('auth/status/', CheckAccessTokenAndRefreshToken.as_view(), name='check_token_status') ] \ No newline at end of file diff --git a/backend/authentications/views.py b/backend/authentications/views.py index 9b5f249..87f662e 100644 --- a/backend/authentications/views.py +++ b/backend/authentications/views.py @@ -1,6 +1,3 @@ -from django.shortcuts import render - -# Create your views here. """This module defines API views for authentication, user creation, and a simple hello message.""" import json @@ -10,14 +7,11 @@ from django.conf import settings from django.contrib.auth.hashers import make_password from rest_framework import status -from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.views import APIView from rest_framework_simplejwt.tokens import RefreshToken - -from allauth.socialaccount.providers.google.views import GoogleOAuth2Adapter - -from dj_rest_auth.registration.views import SocialLoginView +from rest_framework_simplejwt.authentication import JWTAuthentication from google_auth_oauthlib.flow import InstalledAppFlow @@ -27,6 +21,31 @@ from users.managers import CustomAccountManager from users.models import CustomUser +class CheckAccessTokenAndRefreshToken(APIView): + permission_classes = (AllowAny,) + JWT_authenticator = JWTAuthentication() + + def post(self, request, *args, **kwargs): + access_token = request.data.get('access_token') + refresh_token = request.data.get('refresh_token') + # Check if the access token is valid + if access_token: + response = self.JWT_authenticator.authenticate(request) + if response is not None: + return Response({'status': 'true'}, status=status.HTTP_200_OK) + + # Check if the refresh token is valid + if refresh_token: + try: + refresh = RefreshToken(refresh_token) + access_token = str(refresh.access_token) + return Response({'access_token': access_token}, status=status.HTTP_200_OK) + except Exception as e: + return Response({'status': 'false'}, status=status.HTTP_401_UNAUTHORIZED) + + return Response({'status': 'false'}, status=status.HTTP_400_BAD_REQUEST) + + class ObtainTokenPairWithCustomView(APIView): """ Custom Token Obtain Pair View. @@ -45,39 +64,6 @@ class ObtainTokenPairWithCustomView(APIView): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) -class GreetingView(APIView): - """ - Hello World View. - Returns a greeting and user information for authenticated users. - """ - permission_classes = (IsAuthenticated,) - - def get(self, request): - """ - Retrieve a greeting message and user information. - """ - user = request.user - user_info = { - "username": user.username, - } - response_data = { - "message": "Hello, world!", - "user_info": user_info, - } - return Response(response_data, status=status.HTTP_200_OK) - - -class GoogleLogin(SocialLoginView): - """ - Google Login View. - Handles Google OAuth2 authentication. - """ - # permission_classes = (AllowAny,) - adapter_class = GoogleOAuth2Adapter - # client_class = OAuth2Client - # callback_url = 'http://localhost:8000/accounts/google/login/callback/' - - class GoogleRetrieveUserInfo(APIView): """ Retrieve user information from Google and create a user if not exists. @@ -165,4 +151,4 @@ class GoogleRetrieveUserInfo(APIView): response = requests.get(api_url, headers=headers) if response.status_code == 200: return response.json() - raise Exception('Google API Error', response) + raise Exception('Google API Error', response) \ No newline at end of file diff --git a/backend/boards/__init__.py b/backend/boards/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/boards/admin.py b/backend/boards/admin.py new file mode 100644 index 0000000..41d21c7 --- /dev/null +++ b/backend/boards/admin.py @@ -0,0 +1,17 @@ +from django.contrib import admin +from .models import Board, ListBoard, KanbanTaskOrder + +@admin.register(Board) +class BoardAdmin(admin.ModelAdmin): + list_display = ['name', 'user'] + +@admin.register(ListBoard) +class ListBoardAdmin(admin.ModelAdmin): + list_display = ['name', 'position', 'board'] + list_filter = ['board', 'position'] + + +@admin.register(KanbanTaskOrder) +class KanbanTaskOrderAdmin(admin.ModelAdmin): + list_display = ['list_board', 'todo_order'] + list_filter = ['list_board'] \ No newline at end of file diff --git a/backend/boards/apps.py b/backend/boards/apps.py new file mode 100644 index 0000000..d10d8fa --- /dev/null +++ b/backend/boards/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class BoardsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'boards' + + def ready(self): + import boards.signals \ No newline at end of file diff --git a/backend/boards/migrations/0001_initial.py b/backend/boards/migrations/0001_initial.py new file mode 100644 index 0000000..2196132 --- /dev/null +++ b/backend/boards/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.6 on 2023-11-19 19:19 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Board', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='ListBoard', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('position', models.IntegerField()), + ('board', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='boards.board')), + ], + ), + ] diff --git a/backend/boards/migrations/0002_kanbantaskorder.py b/backend/boards/migrations/0002_kanbantaskorder.py new file mode 100644 index 0000000..2926da5 --- /dev/null +++ b/backend/boards/migrations/0002_kanbantaskorder.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.6 on 2023-11-20 18:24 + +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('boards', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='KanbanTaskOrder', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('todo_order', django.contrib.postgres.fields.ArrayField(base_field=models.PositiveIntegerField(), blank=True, default=list, size=None)), + ('list_board', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='boards.listboard')), + ], + ), + ] diff --git a/backend/boards/migrations/__init__.py b/backend/boards/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/boards/models.py b/backend/boards/models.py new file mode 100644 index 0000000..06c4249 --- /dev/null +++ b/backend/boards/models.py @@ -0,0 +1,56 @@ +from django.contrib.postgres.fields import ArrayField +from django.db import models + +from users.models import CustomUser + +class Board(models.Model): + """ + Kanban board model. + + :param user: The user who owns the board. + :param name: The name of the board. + :param created_at: The date and time when the board was created. + """ + user = models.ForeignKey(CustomUser, on_delete=models.CASCADE) + name = models.CharField(max_length=255) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self) -> str: + return f"{self.name}" + + +class KanbanTaskOrder(models.Model): + """ + Model to store the order of Todo tasks in a Kanban board. + + :param list_board: The list board that the order belongs to. + :param todo_order: ArrayField to store the order of Todo IDs. + """ + list_board = models.OneToOneField('ListBoard', on_delete=models.CASCADE) + todo_order = ArrayField(models.PositiveIntegerField(), blank=True, default=list) + + def __str__(self): + return f"Order for {self.list_board}" + + +class ListBoard(models.Model): + """ + List inside a Kanban board. + + :param board: The board that the list belongs to. + :param name: The name of the list. + :param position: The position of the list in Kanban. + """ + board = models.ForeignKey(Board, on_delete=models.CASCADE) + name = models.CharField(max_length=255) + position = models.IntegerField() + + def save(self, *args, **kwargs): + super(ListBoard, self).save(*args, **kwargs) + kanban_order, created = KanbanTaskOrder.objects.get_or_create(list_board=self) + if not created: + return + + def __str__(self) -> str: + return f"{self.name}" + diff --git a/backend/boards/serializers.py b/backend/boards/serializers.py new file mode 100644 index 0000000..54c76ac --- /dev/null +++ b/backend/boards/serializers.py @@ -0,0 +1,13 @@ +from rest_framework import serializers + +from boards.models import Board, ListBoard + +class BoardSerializer(serializers.ModelSerializer): + class Meta: + model = Board + fields = '__all__' + +class ListBoardSerializer(serializers.ModelSerializer): + class Meta: + model = ListBoard + fields = '__all__' diff --git a/backend/boards/signals.py b/backend/boards/signals.py new file mode 100644 index 0000000..2c44daa --- /dev/null +++ b/backend/boards/signals.py @@ -0,0 +1,17 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from boards.models import Board, ListBoard +from users.models import CustomUser + +@receiver(post_save, sender=CustomUser) +def create_default_board(sender, instance, created, **kwargs): + """Signal handler to automatically create a default Board for a user upon creation.""" + if created: + # Create unique board by user id + user_id = instance.id + board = Board.objects.create(user=instance, name=f"Board of #{user_id}") + ListBoard.objects.create(board=board, name="Backlog", position=1) + ListBoard.objects.create(board=board, name="Doing", position=2) + ListBoard.objects.create(board=board, name="Review", position=3) + ListBoard.objects.create(board=board, name="Done", position=4) \ No newline at end of file diff --git a/backend/boards/tests.py b/backend/boards/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/boards/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/boards/urls.py b/backend/boards/urls.py new file mode 100644 index 0000000..b798e39 --- /dev/null +++ b/backend/boards/urls.py @@ -0,0 +1,11 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from boards.views import BoardViewSet, ListBoardViewSet + +router = DefaultRouter() +router.register(r'boards', BoardViewSet, basename='board') +router.register(r'lists', ListBoardViewSet, basename='listboard') + +urlpatterns = [ + path('', include(router.urls)), +] \ No newline at end of file diff --git a/backend/boards/views.py b/backend/boards/views.py new file mode 100644 index 0000000..d405c87 --- /dev/null +++ b/backend/boards/views.py @@ -0,0 +1,34 @@ +from rest_framework import viewsets +from rest_framework.response import Response +from rest_framework import status +from rest_framework.permissions import IsAuthenticated + +from boards.models import Board, ListBoard +from boards.serializers import BoardSerializer, ListBoardSerializer + +class BoardViewSet(viewsets.ModelViewSet): + permission_classes = (IsAuthenticated,) + queryset = Board.objects.all() + serializer_class = BoardSerializer + http_method_names = ['get'] + + def get_queryset(self): + queryset = Board.objects.filter(user_id=self.request.user.id) + return queryset + + +class ListBoardViewSet(viewsets.ModelViewSet): + permission_classes = (IsAuthenticated,) + serializer_class = ListBoardSerializer + + def get_queryset(self): + queryset = ListBoard.objects.filter(board__user_id=self.request.user.id) + return queryset + + def create(self, request, *args, **kwargs): + board_id = request.data.get('board') + board = Board.objects.get(id=board_id) + if request.user.id != board.user.id: + return Response({"error": "Cannot create ListBoard for another user's board."}, status=status.HTTP_403_FORBIDDEN) + return super().create(request, *args, **kwargs) + diff --git a/backend/core/local_settings.py b/backend/core/local_settings.py new file mode 100644 index 0000000..9e7903c --- /dev/null +++ b/backend/core/local_settings.py @@ -0,0 +1,269 @@ +""" +Django settings for core project. + +Generated by 'django-admin startproject' using Django 4.2.6. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.2/ref/settings/ +""" + +from datetime import timedelta +import os +from pathlib import Path +from decouple import config, Csv + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = config('SECRET_KEY', default='j5&66&8@b-!3tbq!=w6-dypl($_0zzoi*ilxd1*&$_6s-59il5') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = config('DEBUG', default=False, cast=bool) + +ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='*', cast=Csv()) + + +# Application definition + +SITE_ID = 4 + +AUTHENTICATION_BACKENDS = ( + 'django.contrib.auth.backends.ModelBackend', + 'allauth.account.auth_backends.AuthenticationBackend', +) + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'django.contrib.sites', + + 'tasks', + 'users', + 'authentications', + 'dashboard', + 'boards', + + 'corsheaders', + 'drf_spectacular', + + 'allauth', + 'allauth.account', + 'allauth.socialaccount', + 'allauth.socialaccount.providers.google', + + 'rest_framework', + 'dj_rest_auth', + 'dj_rest_auth.registration', + 'rest_framework.authtoken', +] + +REST_FRAMEWORK = { + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + + ], + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.BasicAuthentication', + 'rest_framework.authentication.TokenAuthentication', + 'rest_framework_simplejwt.authentication.JWTAuthentication', + 'dj_rest_auth.jwt_auth.JWTCookieAuthentication', + ], + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', +} + +SPECTACULAR_SETTINGS = { + 'TITLE': 'TurTask API', + 'DESCRIPTION': 'API documentation for TurTask', + 'VERSION': '1.0.0', + 'SERVE_INCLUDE_SCHEMA': False, +} + +REST_USE_JWT = True + +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(days=3), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=30), +} + +GOOGLE_CLIENT_ID = config('GOOGLE_CLIENT_ID', default='fake-client-id') +GOOGLE_CLIENT_SECRET = config('GOOGLE_CLIENT_SECRET', default='fake-client-secret') + +SOCIALACCOUNT_PROVIDERS = { + 'google': { + 'APP': { + 'client_id': GOOGLE_CLIENT_ID, + 'secret': GOOGLE_CLIENT_SECRET, + 'key': '' + }, + "SCOPE": [ + "profile", + "email", + ], + "AUTH_PARAMS": { + "access_type": "online", + } + } +} + + +CORS_ALLOW_CREDENTIALS = True +CORS_ALLOW_ALL_ORIGINS = False +CORS_ALLOWED_ORIGINS = [ + "http://localhost:8000", + "http://127.0.0.1:8000", + "http://localhost:5173", +] + +CSRF_TRUSTED_ORIGINS = ["http://localhost:5173"] + +CORS_ORIGIN_WHITELIST = ["*"] + +MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "allauth.account.middleware.AccountMiddleware", +] + +ROOT_URLCONF = 'core.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [ + os.path.join(BASE_DIR, 'templates') + ], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'core.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'NAME': config('DB_NAME', default='github_actions'), + 'USER': config('DB_USER', default='postgres'), + 'PASSWORD': config('DB_PASSWORD', default='postgres'), + 'HOST': config('DB_HOST', default='127.0.0.1'), + 'PORT': config('DB_PORT', default='5432'), + } +} + + +# Cache + +CACHES_LOCATION = f"{config('DB_NAME', default='db_test')}_cache" + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.db.DatabaseCache", + "LOCATION": CACHES_LOCATION, + } +} + +# Password validation +# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/4.2/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.2/howto/static-files/ + +STATIC_URL = 'static/' + +# Default primary key field type +# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + + +SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE = [ + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', +] + +LOGIN_REDIRECT_URL = '/' +LOGOUT_REDIRECT_URL = '/' + +AUTH_USER_MODEL = "users.CustomUser" + +ACCOUNT_EMAIL_REQUIRED = True + +# Storages + +AWS_ACCESS_KEY_ID = config('AMAZON_S3_ACCESS_KEY', default='fake-access-key') +AWS_SECRET_ACCESS_KEY = config('AMAZON_S3_SECRET_ACCESS_KEY', default='fake-secret-access-key') +AWS_STORAGE_BUCKET_NAME = config('BUCKET_NAME', default='fake-bucket-name') +AWS_DEFAULT_ACL = 'public-read' +AWS_S3_CUSTOM_DOMAIN = f'{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com' +AWS_S3_OBJECT_PARAMETERS = {'CacheControl': 'max-age=86400'} + +MEDIA_URL = '/mediafiles/' +MEDIA_ROOT = os.path.join(BASE_DIR, 'mediafiles') + +STORAGES = { + "default": { + "BACKEND": "storages.backends.s3.S3Storage", + "OPTIONS": { + }, + }, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, +} \ No newline at end of file diff --git a/backend/core/production_settings.py b/backend/core/production_settings.py new file mode 100644 index 0000000..dd9eeb3 --- /dev/null +++ b/backend/core/production_settings.py @@ -0,0 +1,267 @@ +""" +Django settings for core project. + +Generated by 'django-admin startproject' using Django 4.2.6. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.2/ref/settings/ +""" + +from datetime import timedelta +import os +from pathlib import Path +from decouple import config, Csv + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = config('SECRET_KEY', default='j5&66&8@b-!3tbq!=w6-dypl($_0zzoi*ilxd1*&$_6s-59il5') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = config('DEBUG', default=False, cast=bool) + +ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='*', cast=Csv()) + + +# Application definition + +SITE_ID = 4 + +AUTHENTICATION_BACKENDS = ( + 'django.contrib.auth.backends.ModelBackend', + 'allauth.account.auth_backends.AuthenticationBackend', +) + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'django.contrib.sites', + + 'tasks', + 'users', + 'authentications', + 'dashboard', + 'boards', + + 'corsheaders', + 'drf_spectacular', + + 'allauth', + 'allauth.account', + 'allauth.socialaccount', + 'allauth.socialaccount.providers.google', + + 'rest_framework', + 'dj_rest_auth', + 'dj_rest_auth.registration', + 'rest_framework.authtoken', +] + +REST_FRAMEWORK = { + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + + ], + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.BasicAuthentication', + 'rest_framework.authentication.TokenAuthentication', + 'rest_framework_simplejwt.authentication.JWTAuthentication', + 'dj_rest_auth.jwt_auth.JWTCookieAuthentication', + ], + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', +} + +SPECTACULAR_SETTINGS = { + 'TITLE': 'TurTask API', + 'DESCRIPTION': 'API documentation for TurTask', + 'VERSION': '1.0.0', + 'SERVE_INCLUDE_SCHEMA': False, +} + +REST_USE_JWT = True + +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(days=3), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=30), +} + +GOOGLE_CLIENT_ID = config('GOOGLE_CLIENT_ID', default='fake-client-id') +GOOGLE_CLIENT_SECRET = config('GOOGLE_CLIENT_SECRET', default='fake-client-secret') + +SOCIALACCOUNT_PROVIDERS = { + 'google': { + 'APP': { + 'client_id': GOOGLE_CLIENT_ID, + 'secret': GOOGLE_CLIENT_SECRET, + 'key': '' + }, + "SCOPE": [ + "profile", + "email", + ], + "AUTH_PARAMS": { + "access_type": "online", + } + } +} + + +CORS_ALLOW_CREDENTIALS = True +CORS_ALLOW_ALL_ORIGINS = False +CORS_ALLOWED_ORIGINS = [ + "https://turtask.vercel.app", +] + +CSRF_TRUSTED_ORIGINS = ["https://turtask.vercel.app"] + +CORS_ORIGIN_WHITELIST = ["*"] + +MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "allauth.account.middleware.AccountMiddleware", +] + +ROOT_URLCONF = 'core.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [ + os.path.join(BASE_DIR, 'templates') + ], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'core.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'NAME': config('PGDATABASE', default='github_actions'), + 'USER': config('PGUSER', default='postgres'), + 'PASSWORD': config('PGPASSWORD', default='postgres'), + 'HOST': config('PGHOST', default='127.0.0.1'), + 'PORT': config('PGPORT', default='5432'), + } +} + + +# Cache + +CACHES_LOCATION = "accesstokencache" + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.db.DatabaseCache", + "LOCATION": CACHES_LOCATION, + } +} + +# Password validation +# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/4.2/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.2/howto/static-files/ + +STATIC_URL = 'static/' + +# Default primary key field type +# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + + +SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE = [ + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', +] + +LOGIN_REDIRECT_URL = '/' +LOGOUT_REDIRECT_URL = '/' + +AUTH_USER_MODEL = "users.CustomUser" + +ACCOUNT_EMAIL_REQUIRED = True + +# Storages + +AWS_ACCESS_KEY_ID = config('AMAZON_S3_ACCESS_KEY', default='fake-access-key') +AWS_SECRET_ACCESS_KEY = config('AMAZON_S3_SECRET_ACCESS_KEY', default='fake-secret-access-key') +AWS_STORAGE_BUCKET_NAME = config('BUCKET_NAME', default='fake-bucket-name') +AWS_DEFAULT_ACL = 'public-read' +AWS_S3_CUSTOM_DOMAIN = f'{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com' +AWS_S3_OBJECT_PARAMETERS = {'CacheControl': 'max-age=86400'} + +MEDIA_URL = '/mediafiles/' +MEDIA_ROOT = os.path.join(BASE_DIR, 'mediafiles') + +STORAGES = { + "default": { + "BACKEND": "storages.backends.s3.S3Storage", + "OPTIONS": { + }, + }, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, +} \ No newline at end of file diff --git a/backend/core/settings.py b/backend/core/settings.py index 5c01d05..7532f01 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -1,267 +1,6 @@ -""" -Django settings for core project. - -Generated by 'django-admin startproject' using Django 4.2.6. - -For more information on this file, see -https://docs.djangoproject.com/en/4.2/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/4.2/ref/settings/ -""" - -from datetime import timedelta import os -from pathlib import Path -from decouple import config, Csv -# Build paths inside the project like this: BASE_DIR / 'subdir'. -BASE_DIR = Path(__file__).resolve().parent.parent - - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = config('SECRET_KEY', default='j5&66&8@b-!3tbq!=w6-dypl($_0zzoi*ilxd1*&$_6s-59il5') - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = config('DEBUG', default=False, cast=bool) - -ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='*', cast=Csv()) - - -# Application definition - -SITE_ID = 4 - -AUTHENTICATION_BACKENDS = ( - 'django.contrib.auth.backends.ModelBackend', - 'allauth.account.auth_backends.AuthenticationBackend', -) - -INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'django.contrib.sites', - - 'tasks', - 'users', - 'authentications', - - 'corsheaders', - 'drf_spectacular', - - 'allauth', - 'allauth.account', - 'allauth.socialaccount', - 'allauth.socialaccount.providers.google', - - 'rest_framework', - 'dj_rest_auth', - 'dj_rest_auth.registration', - 'rest_framework.authtoken', -] - -REST_FRAMEWORK = { - 'DEFAULT_PERMISSION_CLASSES': [ - 'rest_framework.permissions.IsAuthenticated', - - ], - 'DEFAULT_AUTHENTICATION_CLASSES': [ - 'rest_framework.authentication.BasicAuthentication', - 'rest_framework.authentication.TokenAuthentication', - 'rest_framework_simplejwt.authentication.JWTAuthentication', - 'dj_rest_auth.jwt_auth.JWTCookieAuthentication', - ], - 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', -} - -SPECTACULAR_SETTINGS = { - 'TITLE': 'TurTask API', - 'DESCRIPTION': 'API documentation for TurTask', - 'VERSION': '1.0.0', - 'SERVE_INCLUDE_SCHEMA': False, -} - -REST_USE_JWT = True - -SIMPLE_JWT = { - 'ACCESS_TOKEN_LIFETIME': timedelta(days=3), - 'REFRESH_TOKEN_LIFETIME': timedelta(days=30), -} - -GOOGLE_CLIENT_ID = config('GOOGLE_CLIENT_ID', default='fake-client-id') -GOOGLE_CLIENT_SECRET = config('GOOGLE_CLIENT_SECRET', default='fake-client-secret') - -SOCIALACCOUNT_PROVIDERS = { - 'google': { - 'APP': { - 'client_id': GOOGLE_CLIENT_ID, - 'secret': GOOGLE_CLIENT_SECRET, - 'key': '' - }, - "SCOPE": [ - "profile", - "email", - ], - "AUTH_PARAMS": { - "access_type": "online", - } - } -} - - -CORS_ALLOW_CREDENTIALS = True -CORS_ALLOW_ALL_ORIGINS = False -CORS_ALLOWED_ORIGINS = [ - "http://localhost:8000", - "http://127.0.0.1:8000", - "http://localhost:5173", -] - -CSRF_TRUSTED_ORIGINS = ["http://localhost:5173"] - -CORS_ORIGIN_WHITELIST = ["*"] - -MIDDLEWARE = [ - 'corsheaders.middleware.CorsMiddleware', - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', - "allauth.account.middleware.AccountMiddleware", -] - -ROOT_URLCONF = 'core.urls' - -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [ - os.path.join(BASE_DIR, 'templates') - ], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - ], - }, - }, -] - -WSGI_APPLICATION = 'core.wsgi.application' - - -# Database -# https://docs.djangoproject.com/en/4.2/ref/settings/#databases - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.postgresql_psycopg2', - 'NAME': config('DB_NAME', default='github_actions'), - 'USER': config('DB_USER', default='postgres'), - 'PASSWORD': config('DB_PASSWORD', default='postgres'), - 'HOST': config('DB_HOST', default='127.0.0.1'), - 'PORT': config('DB_PORT', default='5432'), - } -} - - -# Cache - -CACHES_LOCATION = f"{config('DB_NAME', default='db_test')}_cache" - -CACHES = { - "default": { - "BACKEND": "django.core.cache.backends.db.DatabaseCache", - "LOCATION": CACHES_LOCATION, - } -} - -# Password validation -# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators - -AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', - }, -] - - -# Internationalization -# https://docs.djangoproject.com/en/4.2/topics/i18n/ - -LANGUAGE_CODE = 'en-us' - -TIME_ZONE = 'UTC' - -USE_I18N = True - -USE_TZ = True - - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/4.2/howto/static-files/ - -STATIC_URL = 'static/' - -# Default primary key field type -# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field - -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' - - -SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE = [ - 'https://www.googleapis.com/auth/userinfo.email', - 'https://www.googleapis.com/auth/userinfo.profile', -] - -LOGIN_REDIRECT_URL = '/' -LOGOUT_REDIRECT_URL = '/' - -AUTH_USER_MODEL = "users.CustomUser" - -ACCOUNT_EMAIL_REQUIRED = True - -# Storages - -AWS_ACCESS_KEY_ID = config('AMAZON_S3_ACCESS_KEY', default='fake-access-key') -AWS_SECRET_ACCESS_KEY = config('AMAZON_S3_SECRET_ACCESS_KEY', default='fake-secret-access-key') -AWS_STORAGE_BUCKET_NAME = config('BUCKET_NAME', default='fake-bucket-name') -AWS_DEFAULT_ACL = 'public-read' -AWS_S3_CUSTOM_DOMAIN = f'{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com' -AWS_S3_OBJECT_PARAMETERS = {'CacheControl': 'max-age=86400'} - -MEDIA_URL = '/mediafiles/' -MEDIA_ROOT = os.path.join(BASE_DIR, 'mediafiles') - -STORAGES = { - "default": { - "BACKEND": "storages.backends.s3.S3Storage", - "OPTIONS": { - }, - }, - "staticfiles": { - "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", - }, -} \ No newline at end of file +if os.environ.get("DJANGO_ENV") == "PRODUCTION": + from .production_settings import * +else: + from .local_settings import * \ No newline at end of file diff --git a/backend/core/urls.py b/backend/core/urls.py index a02869c..3ba1133 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -27,4 +27,6 @@ urlpatterns = [ path('api/schema/', SpectacularAPIView.as_view(), name='schema'), path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), + path('api/', include('dashboard.urls')), + path('api/', include('boards.urls')), ] \ No newline at end of file diff --git a/backend/core/wsgi.py b/backend/core/wsgi.py index f44964d..a08c895 100644 --- a/backend/core/wsgi.py +++ b/backend/core/wsgi.py @@ -11,6 +11,6 @@ import os from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings") application = get_wsgi_application() diff --git a/backend/dashboard/__init__.py b/backend/dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/dashboard/admin.py b/backend/dashboard/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/backend/dashboard/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/dashboard/apps.py b/backend/dashboard/apps.py new file mode 100644 index 0000000..7b1cc05 --- /dev/null +++ b/backend/dashboard/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class DashboardConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'dashboard' diff --git a/backend/dashboard/migrations/__init__.py b/backend/dashboard/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/dashboard/serializers.py b/backend/dashboard/serializers.py new file mode 100644 index 0000000..ddc207b --- /dev/null +++ b/backend/dashboard/serializers.py @@ -0,0 +1,7 @@ +from rest_framework import serializers +from .models import UserStats + +class UserStatsSerializer(serializers.ModelSerializer): + class Meta: + model = UserStats + fields = ['health', 'gold', 'experience', 'strength', 'intelligence', 'endurance', 'perception', 'luck', 'level'] \ No newline at end of file diff --git a/backend/dashboard/tests.py b/backend/dashboard/tests.py new file mode 100644 index 0000000..943c3de --- /dev/null +++ b/backend/dashboard/tests.py @@ -0,0 +1,103 @@ +from django.test import TestCase +from django.urls import reverse +from tasks.models import Todo +from django.utils import timezone +from datetime import timedelta + +from tasks.tests.utils import create_test_user, login_user + +class DashboardStatsAndWeeklyViewSetTests(TestCase): + def setUp(self): + self.user = create_test_user() + self.client = login_user(self.user) + + def create_task(self, title, completed=False, completion_date=None, end_event=None): + return Todo.objects.create( + user=self.user, + title=title, + completed=completed, + completion_date=completion_date, + end_event=end_event + ) + + def test_dashboard_stats_view(self): + # Create tasks for testing + self.create_task('Task 1', completed=True) + self.create_task('Task 2', end_event=timezone.now() - timedelta(days=8)) + self.create_task('Task 3', end_event=timezone.now()) + + response = self.client.get(reverse('stats-list')) + self.assertEqual(response.status_code, 200) + + self.assertEqual(response.data['completed_this_week'], 1) + self.assertEqual(response.data['tasks_assigned_this_week'], 1) + self.assertEqual(response.data['tasks_assigned_last_week'], 0) + + def test_dashboard_weekly_view(self): + # Create tasks for testing + self.create_task('Task 1', completion_date=timezone.now() - timedelta(days=1)) + self.create_task('Task 2', end_event=timezone.now() - timedelta(days=8)) + self.create_task('Task 3', end_event=timezone.now()) + + response = self.client.get(reverse('weekly-list')) + self.assertEqual(response.status_code, 200) + + +# class DashboardStatsAPITestCase(TestCase): +# def setUp(self): +# # Create a test user +# self.user = create_test_user() + +# # Create test tasks +# self.todo = Todo.objects.create(user=self.user, title='Test Todo') +# self.recurrence_task = RecurrenceTask.objects.create(user=self.user, title='Test Recurrence Task') + +# # Create an API client +# self.client = APIClient() + +# def test_dashboard_stats_api(self): +# # Authenticate the user +# self.client.force_authenticate(user=self.user) + +# # Make a GET request to the DashboardStatsAPIView +# response = self.client.get(reverse("dashboard-stats")) + +# # Assert the response status code is 200 +# self.assertEqual(response.status_code, 200) + +# def test_task_completion_status_update(self): +# # Authenticate the user +# self.client.force_authenticate(user=self.user) + +# # Make a POST request to update the completion status of a task +# data = {'task_id': self.todo.id, 'is_completed': True} +# response = self.client.post(reverse("dashboard-stats"), data, format='json') + +# # Assert the response status code is 200 +# self.assertEqual(response.status_code, 200) + +# # Assert the message in the response +# self.assertEqual(response.data['message'], 'Task completion status updated successfully') + +# # Refresh the todo instance from the database and assert the completion status +# self.todo.refresh_from_db() +# self.assertTrue(self.todo.completed) + + +# class WeeklyStatsAPITestCase(TestCase): +# def setUp(self): +# # Create a test user +# self.user = create_test_user() + +# # Create an API client +# self.client = APIClient() + +# def test_weekly_stats_api(self): +# # Authenticate the user +# self.client.force_authenticate(user=self.user) + +# # Make a GET request to the WeeklyStatsAPIView +# response = self.client.get(reverse('dashboard-weekly-stats')) + +# # Assert the response status code is 200 +# self.assertEqual(response.status_code, 200) diff --git a/backend/dashboard/urls.py b/backend/dashboard/urls.py new file mode 100644 index 0000000..56624ca --- /dev/null +++ b/backend/dashboard/urls.py @@ -0,0 +1,11 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +from .views import DashboardStatsViewSet, DashboardWeeklyViewSet + +router = DefaultRouter() +router.register(r'dashboard/stats', DashboardStatsViewSet, basename='stats') +router.register(r'dashboard/weekly', DashboardWeeklyViewSet, basename='weekly') +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/backend/dashboard/views.py b/backend/dashboard/views.py new file mode 100644 index 0000000..abe0576 --- /dev/null +++ b/backend/dashboard/views.py @@ -0,0 +1,311 @@ +from datetime import timedelta +from django.utils import timezone +from rest_framework import status +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from rest_framework import viewsets, mixins + +from tasks.models import Todo + + +class DashboardStatsViewSet(viewsets.GenericViewSet, mixins.ListModelMixin): + permission_classes = (IsAuthenticated,) + + def get_queryset(self): + return Todo.objects.all() + + def list(self, request, *args, **kwargs): + user = self.request.user + + # Calculate the start and end date for the last 7 days + end_date = timezone.now() + start_date = end_date - timedelta(days=7) + + # How many tasks were completed in the last 7 days + completed_last_7_days = Todo.objects.filter( + user=user, + completed=True, + completion_date__gte=start_date, + completion_date__lte=end_date + ).count() + + # Task assign last week compared with this week + tasks_assigned_last_week = Todo.objects.filter( + user=user, + completion_date__gte=start_date - timedelta(days=7), + completion_date__lte=start_date + ).count() + + tasks_assigned_this_week = Todo.objects.filter( + user=user, + completion_date__gte=start_date, + completion_date__lte=end_date + ).count() + + # Completed tasks from last week compared with this week + completed_last_week = Todo.objects.filter( + user=user, + completed=True, + completion_date__gte=start_date - timedelta(days=7), + completion_date__lte=start_date + ).count() + + completed_this_week = Todo.objects.filter( + user=user, + completed=True, + completion_date__gte=start_date, + completion_date__lte=end_date + ).count() + + overdue_tasks = Todo.objects.filter( + user=user, + completed=False, + end_event__lt=timezone.now() + ).count() + + # Overall completion rate + total_tasks = Todo.objects.filter(user=user).count() + overall_completion_rate = (completed_last_7_days / total_tasks) * 100 if total_tasks > 0 else 0 + + data = { + "completed_last_7_days": completed_last_7_days, + "tasks_assigned_last_week": tasks_assigned_last_week, + "tasks_assigned_this_week": tasks_assigned_this_week, + "completed_last_week": completed_last_week, + "completed_this_week": completed_this_week, + "overdue_tasks": overdue_tasks, + "overall_completion_rate": overall_completion_rate, + } + + return Response(data, status=status.HTTP_200_OK) + + +class DashboardWeeklyViewSet(viewsets.GenericViewSet, mixins.ListModelMixin): + permission_classes = (IsAuthenticated,) + + def get_queryset(self): + return Todo.objects.all() + + def list(self, request, *args, **kwargs): + user = self.request.user + + # Calculate the start and end date for the last 7 days (Monday to Sunday) + today = timezone.now().date() + current_week_start = today - timedelta(days=today.weekday()) + current_week_end = current_week_start + timedelta(days=6) + + last_week_start = current_week_start - timedelta(days=7) + last_week_end = last_week_start + timedelta(days=6) + + # Create a list to store daily statistics + weekly_stats = [] + + # Iterate over each day of the week + for day in range(7): + current_day = current_week_start + timedelta(days=day) + last_day = last_week_start + timedelta(days=day) + + # Calculate stats for this week + tasks_this_week = Todo.objects.filter( + user=user, + completion_date__gte=current_day, + completion_date__lte=current_day + timedelta(days=1) + ).count() + + completed_this_week = Todo.objects.filter( + user=user, + completed=True, + completion_date__gte=current_day, + completion_date__lte=current_day + timedelta(days=1) + ).count() + + # Calculate stats for last week + tasks_last_week = Todo.objects.filter( + user=user, + completion_date__gte=last_day, + completion_date__lte=last_day + timedelta(days=1) + ).count() + + completed_last_week = Todo.objects.filter( + user=user, + completed=True, + completion_date__gte=last_day, + completion_date__lte=last_day + timedelta(days=1) + ).count() + + daily_stat = { + "date": current_day.strftime("%A"), + "This Week": tasks_this_week, + "Last Week": tasks_last_week, + "Completed This Week": completed_this_week, + "Completed Last Week": completed_last_week, + } + + weekly_stats.append(daily_stat) + + return Response(weekly_stats, status=status.HTTP_200_OK) + + +# class DashboardStatsAPIView(APIView): +# permission_classes = [IsAuthenticated] + +# def get(self, request): +# user = request.user + +# # Calculate task usage statistics +# todo_count = Todo.objects.filter(user=user).count() +# recurrence_task_count = RecurrenceTask.objects.filter(user=user).count() + +# # Calculate how many tasks were completed in the last 7 days +# completed_todo_count_last_week = Todo.objects.filter(user=user, completed=True, last_update__gte=timezone.now() - timezone.timedelta(days=7)).count() +# completed_recurrence_task_count_last_week = RecurrenceTask.objects.filter(user=user, completed=True, last_update__gte=timezone.now() - timezone.timedelta(days=7)).count() + +# # Calculate subtask completion rate +# total_subtasks = Todo.objects.filter(user=user).aggregate(total=Count('subtask__id'))['total'] +# completed_subtasks = Todo.objects.filter(user=user, subtask__completed=True).aggregate(total=Count('subtask__id'))['total'] + +# # Calculate overall completion rate +# total_tasks = todo_count + recurrence_task_count +# completed_tasks = completed_todo_count_last_week + completed_recurrence_task_count_last_week +# overall_completion_rate = (completed_tasks / total_tasks) * 100 if total_tasks > 0 else 0 + +# # pie chart show +# complete_todo_percent_last_week = (completed_todo_count_last_week / todo_count) * 100 if todo_count > 0 else 0 + +# complete_recurrence_percent_last_week = (completed_recurrence_task_count_last_week / recurrence_task_count) * 100 if recurrence_task_count > 0 else 0 + +# incomplete_task_percent_last_week = 100 - complete_recurrence_percent_last_week - complete_todo_percent_last_week + +# data = { +# 'todo_count': todo_count, +# 'recurrence_task_count': recurrence_task_count, +# 'completed_todo_count_last_week': completed_todo_count_last_week, +# 'completed_recurrence_task_count_last_week': completed_recurrence_task_count_last_week, +# 'total_subtasks': total_subtasks, +# 'completed_subtasks': completed_subtasks, +# 'overall_completion_rate': overall_completion_rate, +# 'complete_todo_percent_last_week': complete_todo_percent_last_week, +# 'complete_recurrence_percent_last_week' : complete_recurrence_percent_last_week, +# 'incomplete_task_percent_last_week': incomplete_task_percent_last_week, + +# } + +# return Response(data, status=status.HTTP_200_OK) + +# def post(self, request): +# # Handle incoming data from the POST request +# # Update the necessary information based on the data + +# task_id = request.data.get('task_id') +# is_completed = request.data.get('is_completed') + +# try: +# task = Todo.objects.get(id=task_id, user=request.user) +# task.completed = is_completed +# task.save() +# return Response({'message': 'Task completion status updated successfully'}, status=status.HTTP_200_OK) +# except Todo.DoesNotExist: +# return Response({'error': 'Task not found'}, status=status.HTTP_404_NOT_FOUND) + +# class WeeklyStatsAPIView(APIView): +# permission_classes = [IsAuthenticated] + +# def get(self, request): +# user = request.user +# today = timezone.now() + +# # Calculate the start and end dates for the current week +# current_week_start = today - timezone.timedelta(days=today.weekday()) +# current_week_end = current_week_start + timezone.timedelta(days=6) + +# # Initialize a list to store daily statistics +# weekly_stats = [] + +# # Loop through each day of the week +# for i in range(7): +# # Calculate the start and end dates for the current day +# current_day_start = current_week_start + timezone.timedelta(days=i) +# current_day_end = current_day_start + timezone.timedelta(days=1) + +# # Calculate the start and end dates for the same day over the last 7 days +# last_7_days_start = current_day_start - timezone.timedelta(days=7) +# last_7_days_end = current_day_end - timezone.timedelta(days=7) + +# # Calculate statistics for the current day +# current_day_stats = self.calculate_stats(user, current_day_start, current_day_end) + +# # Calculate statistics for the same day over the last 7 days +# last_7_days_stats = self.calculate_stats(user, last_7_days_start, last_7_days_end) + +# # Calculate the percentage change +# percent_change_over_all = self.calculate_percent_change( +# current_day_stats['overall_completion_rate'], +# last_7_days_stats['overall_completion_rate'] +# ) + +# # Calculate percentage change for completed_todo_count +# percent_change_todo = self.calculate_percent_change( +# current_day_stats['completed_todo_count'], +# last_7_days_stats['completed_todo_count'] +# ) + +# # Calculate percentage change for completed_recurrence_task_count +# percent_change_recurrence = self.calculate_percent_change( +# current_day_stats['completed_recurrence_task_count'], +# last_7_days_stats['completed_recurrence_task_count'] +# ) + +# # Append the daily statistics to the list +# weekly_stats.append({ +# 'day_of_week': current_day_start.strftime('%A'), +# 'current_day_stats': current_day_stats, +# 'last_7_days_stats': last_7_days_stats, +# 'percent_change_over_all': percent_change_over_all, +# 'percent_change_todo': percent_change_todo, +# 'percent_change_recurrence': percent_change_recurrence, +# }) + +# response_data = { +# 'weekly_stats': weekly_stats, +# } + +# return Response(response_data, status=status.HTTP_200_OK) + +# def calculate_stats(self, user, start_date, end_date): +# # Calculate task usage statistics for the specified day +# todo_count = Todo.objects.filter(user=user, created_at__gte=start_date, created_at__lte=end_date).count() +# recurrence_task_count = RecurrenceTask.objects.filter(user=user, created_at__gte=start_date, created_at__lte=end_date).count() + +# # Calculate how many tasks were completed on the specified day +# completed_todo_count = Todo.objects.filter(user=user, completed=True, last_update__gte=start_date, last_update__lte=end_date).count() +# completed_recurrence_task_count = RecurrenceTask.objects.filter(user=user, completed=True, last_update__gte=start_date, last_update__lte=end_date).count() + +# # Calculate subtask completion rate for the specified day +# total_subtasks = Todo.objects.filter(user=user, created_at__gte=start_date, created_at__lte=end_date).aggregate(total=Count('subtask__id'))['total'] +# completed_subtasks = Todo.objects.filter(user=user, subtask__completed=True, created_at__gte=start_date, created_at__lte=end_date).aggregate(total=Count('subtask__id'))['total'] + +# # Calculate overall completion rate for the specified day +# total_tasks = todo_count + recurrence_task_count +# completed_tasks = completed_todo_count + completed_recurrence_task_count +# overall_completion_rate = (completed_tasks / total_tasks) * 100 if total_tasks > 0 else 0 + +# return { +# 'start_date': start_date.strftime('%Y-%m-%d'), +# 'end_date': end_date.strftime('%Y-%m-%d'), +# 'todo_count': todo_count, +# 'recurrence_task_count': recurrence_task_count, +# 'completed_todo_count': completed_todo_count, +# 'completed_recurrence_task_count': completed_recurrence_task_count, +# 'total_subtasks': total_subtasks, +# 'completed_subtasks': completed_subtasks, +# 'overall_completion_rate': overall_completion_rate, +# } + +# def calculate_percent_change(self, current_value, last_value): +# # Calculate the percentage change between current and last values +# if last_value != 0: +# percent_change = ((current_value - last_value) / last_value) * 100 +# else: +# percent_change = current_value * 100 # Consider infinite change when the last value is zero + +# return round(percent_change, 2) diff --git a/backend/railway.json b/backend/railway.json new file mode 100644 index 0000000..a99f1a8 --- /dev/null +++ b/backend/railway.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://railway.app/railway.schema.json", + "build": { + "builder": "NIXPACKS" + }, + "deploy": { + "startCommand": "python manage.py migrate && python manage.py createcachetable accesstokencache && gunicorn --timeout 500 core.wsgi", + "restartPolicyType": "NEVER", + "restartPolicyMaxRetries": 10 + } +} diff --git a/requirements.txt b/backend/requirements.txt similarity index 82% rename from requirements.txt rename to backend/requirements.txt index 98f2a15..95962f8 100644 --- a/requirements.txt +++ b/backend/requirements.txt @@ -14,4 +14,7 @@ google_auth_oauthlib>=1.1 google-auth-httplib2>=0.1 django-storages[s3]>=1.14 Pillow>=10.1 -drf-spectacular>=0.26 \ No newline at end of file +drf-spectacular>=0.26 +python-dateutil>=2.8 +gunicorn==21.2.0 +packaging==23.1 \ No newline at end of file diff --git a/backend/sample.env b/backend/sample.env index 15b50eb..b186167 100644 --- a/backend/sample.env +++ b/backend/sample.env @@ -7,4 +7,7 @@ DB_PASSWORD=your_DB_PASSWORD DB_HOST=your_DB_HOST DB_PORT=your_DB_PORT GOOGLE_CLIENT_ID=your_GOOGLE_CLIENT_ID -GOOGLE_CLIENT_SECRET=your_GOOGLE_CLIENT_SECRET \ No newline at end of file +GOOGLE_CLIENT_SECRET=your_GOOGLE_CLIENT_SECRET +BUCKET_NAME=your_BUCKET_NAME +AMAZON_S3_ACCESS_KEY=YOUR_S3_ACCESS_KEY +AMAZON_S3_SECRET_ACCESS_KEY=YOUR_S3_SECRET diff --git a/backend/tasks/admin.py b/backend/tasks/admin.py index 8c38f3f..cabf594 100644 --- a/backend/tasks/admin.py +++ b/backend/tasks/admin.py @@ -1,3 +1,30 @@ from django.contrib import admin +from .models import Tag, Todo, RecurrenceTask, RecurrencePattern, Habit, Subtask -# Register your models here. +@admin.register(Tag) +class TagAdmin(admin.ModelAdmin): + list_display = ['name'] + +@admin.register(Todo) +class TodoAdmin(admin.ModelAdmin): + list_display = ['title', 'list_board', 'is_active', 'priority', 'completed', 'completion_date'] + list_filter = ['list_board', 'is_active', 'priority', 'completed'] + exclude = ['completion_date'] + +@admin.register(RecurrenceTask) +class RecurrenceTaskAdmin(admin.ModelAdmin): + list_display = ['title', 'list_board', 'rrule', 'is_active'] + list_filter = ['list_board', 'rrule', 'is_active'] + +@admin.register(RecurrencePattern) +class RecurrencePatternAdmin(admin.ModelAdmin): + list_display = ['recurrence_task', 'recurring_type', 'day_of_week', 'week_of_month', 'day_of_month', 'month_of_year'] + +@admin.register(Habit) +class HabitAdmin(admin.ModelAdmin): + list_display = ['title', 'streak', 'current_count'] + +@admin.register(Subtask) +class SubtaskAdmin(admin.ModelAdmin): + list_display = ['parent_task', 'description', 'completed'] + list_filter = ['parent_task', 'completed'] diff --git a/backend/tasks/api.py b/backend/tasks/api.py index 7669d76..fdf2493 100644 --- a/backend/tasks/api.py +++ b/backend/tasks/api.py @@ -7,45 +7,21 @@ from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated from tasks.utils import get_service -from tasks.models import Todo, RecurrenceTask -from tasks.serializers import TodoUpdateSerializer, RecurrenceTaskUpdateSerializer - +from tasks.models import Todo +from tasks.serializers import TodoUpdateSerializer class GoogleCalendarEventViewset(viewsets.ViewSet): + """Viewset for list or save Google Calendar Events.""" permission_classes = (IsAuthenticated,) def __init__(self, *args, **kwargs): super().__init__() - self.current_time = datetime.now(tz=timezone.utc).isoformat() - self.event_fields = 'items(id,summary,description,created,recurringEventId,updated,start,end)' + self.current_time = (datetime.now(tz=timezone.utc) + timedelta(days=-7)).isoformat() + self.max_time = (datetime.now(tz=timezone.utc) + timedelta(days=7)).isoformat() + self.event_fields = 'items(id,summary,description,created,recurringEventId,updated,start,end,originalStartTime)' - def _validate_serializer(self, serializer): - if serializer.is_valid(): - serializer.save() - return Response("Validate Successfully", status=200) - return Response(serializer.errors, status=400) - - def post(self, request): - service = get_service(request) - events = service.events().list(calendarId='primary', fields=self.event_fields).execute() - for event in events.get('items', []): - if event.get('recurringEventId'): - continue - event['start_datetime'] = event.get('start').get('dateTime') - event['end_datetime'] = event.get('end').get('dateTime') - event.pop('start') - event.pop('end') - try: - task = Todo.objects.get(google_calendar_id=event['id']) - serializer = TodoUpdateSerializer(instance=task, data=event) - return self._validate_serializer(serializer) - except Todo.DoesNotExist: - serializer = TodoUpdateSerializer(data=event, user=request.user) - return self._validate_serializer(serializer) - - def list(self, request, days=7): - max_time = (datetime.now(tz=timezone.utc) + timedelta(days=days)).isoformat() - + def _get_google_events(self, request): + """Get all events from Google Calendar. """ service = get_service(request) events = [] next_page_token = None @@ -54,21 +30,61 @@ class GoogleCalendarEventViewset(viewsets.ViewSet): query = service.events().list( calendarId='primary', timeMin=self.current_time, - timeMax=max_time, + timeMax=self.max_time, maxResults=200, singleEvents=True, orderBy='startTime', pageToken=next_page_token, - fields='items(id,summary,description,created,recurringEventId,updated,start,end)', + fields=self.event_fields, ) page_results = query.execute() page_events = page_results.get('items', []) - + events.extend(page_events) next_page_token = page_results.get('nextPageToken') if next_page_token is None: break - return Response(events, status=200) \ No newline at end of file + return events + + def _validate_serializer(self, serializer): + """ + Validate serializer and return response. + + :param serializer: The serializer to validate. + """ + if serializer.is_valid(): + serializer.save() + return Response("Validate Successfully", status=200) + return Response(serializer.errors, status=400) + + def create(self, request, *args, **kwargs): + """Create a new Google Calendar Event.""" + events = self._get_google_events(request) + + responses = [] + for event in events: + start_datetime = event.get('start', {}).get('dateTime') + end_datetime = event.get('end', {}).get('dateTime') + + event['start_datetime'] = start_datetime + event['end_datetime'] = end_datetime + event.pop('start') + event.pop('end') + + try: + task = Todo.objects.get(google_calendar_id=event['id']) + serializer = TodoUpdateSerializer(instance=task, data=event) + except Todo.DoesNotExist: + serializer = TodoUpdateSerializer(data=event, user=request.user) + + responses.append(self._validate_serializer(serializer)) + + return responses[0] if responses else Response("No events to process", status=200) + + def list(self, request): + """List all Google Calendar Events.""" + return Response(self._get_google_events(request), status=200) + \ No newline at end of file diff --git a/backend/tasks/migrations/0012_habit.py b/backend/tasks/migrations/0012_habit.py new file mode 100644 index 0000000..6e29775 --- /dev/null +++ b/backend/tasks/migrations/0012_habit.py @@ -0,0 +1,39 @@ +# Generated by Django 4.2.6 on 2023-11-13 18:15 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('tasks', '0011_recurrencetask'), + ] + + operations = [ + migrations.CreateModel( + name='Habit', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.TextField()), + ('notes', models.TextField(default='')), + ('importance', models.PositiveSmallIntegerField(choices=[(1, '1'), (2, '2'), (3, '3'), (4, '4'), (5, '5')], default=1)), + ('difficulty', models.PositiveSmallIntegerField(choices=[(1, 'Easy'), (2, 'Normal'), (3, 'Hard'), (4, 'Very Hard'), (5, 'Devil')], default=1)), + ('challenge', models.BooleanField(default=False)), + ('fromSystem', models.BooleanField(default=False)), + ('creation_date', models.DateTimeField(auto_now_add=True)), + ('last_update', models.DateTimeField(auto_now=True)), + ('google_calendar_id', models.CharField(blank=True, max_length=255, null=True)), + ('start_event', models.DateTimeField(null=True)), + ('end_event', models.DateTimeField(null=True)), + ('streak', models.IntegerField(default=0)), + ('tags', models.ManyToManyField(blank=True, to='tasks.tag')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/backend/tasks/migrations/0013_alter_recurrencetask_recurrence_rule.py b/backend/tasks/migrations/0013_alter_recurrencetask_recurrence_rule.py new file mode 100644 index 0000000..f629f13 --- /dev/null +++ b/backend/tasks/migrations/0013_alter_recurrencetask_recurrence_rule.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.6 on 2023-11-14 15:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0012_habit'), + ] + + operations = [ + migrations.AlterField( + model_name='recurrencetask', + name='recurrence_rule', + field=models.CharField(), + ), + ] diff --git a/backend/tasks/migrations/0014_recurrencetask_completed_todo_completed.py b/backend/tasks/migrations/0014_recurrencetask_completed_todo_completed.py new file mode 100644 index 0000000..d89360d --- /dev/null +++ b/backend/tasks/migrations/0014_recurrencetask_completed_todo_completed.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.6 on 2023-11-17 16:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0013_alter_recurrencetask_recurrence_rule'), + ] + + operations = [ + migrations.AddField( + model_name='recurrencetask', + name='completed', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='todo', + name='completed', + field=models.BooleanField(default=False), + ), + ] diff --git a/backend/tasks/migrations/0015_recurrencepattern_remove_transaction_user_and_more.py b/backend/tasks/migrations/0015_recurrencepattern_remove_transaction_user_and_more.py new file mode 100644 index 0000000..ee9c4bc --- /dev/null +++ b/backend/tasks/migrations/0015_recurrencepattern_remove_transaction_user_and_more.py @@ -0,0 +1,107 @@ +# Generated by Django 4.2.6 on 2023-11-19 20:15 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('boards', '0001_initial'), + ('tasks', '0014_recurrencetask_completed_todo_completed'), + ] + + operations = [ + migrations.CreateModel( + name='RecurrencePattern', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('recurring_type', models.IntegerField(choices=[(0, 'Daily'), (1, 'Weekly'), (2, 'Monthly'), (3, 'Yearly')])), + ('max_occurrences', models.IntegerField(default=0)), + ('day_of_week', models.IntegerField(choices=[(0, 'Monday'), (1, 'Tuesday'), (2, 'Wednesday'), (3, 'Thursday'), (4, 'Friday'), (5, 'Saturday'), (6, 'Sunday')])), + ('week_of_month', models.IntegerField(choices=[(1, 'First'), (2, 'Second'), (3, 'Third'), (4, 'Fourth'), (5, 'Last')])), + ('day_of_month', models.IntegerField(default=0)), + ('month_of_year', models.IntegerField(choices=[(1, 'January'), (2, 'February'), (3, 'March'), (4, 'April'), (5, 'May'), (6, 'June'), (7, 'July'), (8, 'August'), (9, 'September'), (10, 'October'), (11, 'November'), (12, 'December')])), + ], + ), + migrations.RemoveField( + model_name='transaction', + name='user', + ), + migrations.DeleteModel( + name='UserNotification', + ), + migrations.RemoveField( + model_name='habit', + name='end_event', + ), + migrations.RemoveField( + model_name='habit', + name='google_calendar_id', + ), + migrations.RemoveField( + model_name='habit', + name='start_event', + ), + migrations.RemoveField( + model_name='recurrencetask', + name='google_calendar_id', + ), + migrations.RemoveField( + model_name='recurrencetask', + name='recurrence_rule', + ), + migrations.AddField( + model_name='habit', + name='current_count', + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name='recurrencetask', + name='is_active', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='recurrencetask', + name='is_full_day_event', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='recurrencetask', + name='list_board', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='boards.listboard'), + ), + migrations.AddField( + model_name='recurrencetask', + name='parent_task', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='tasks.recurrencetask'), + ), + migrations.AddField( + model_name='recurrencetask', + name='rrule', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='todo', + name='is_active', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='todo', + name='is_full_day_event', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='todo', + name='list_board', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='boards.listboard'), + ), + migrations.DeleteModel( + name='Transaction', + ), + migrations.AddField( + model_name='recurrencepattern', + name='recurrence_task', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tasks.recurrencetask'), + ), + ] diff --git a/backend/tasks/migrations/0016_alter_recurrencetask_list_board_and_more.py b/backend/tasks/migrations/0016_alter_recurrencetask_list_board_and_more.py new file mode 100644 index 0000000..b0edc6e --- /dev/null +++ b/backend/tasks/migrations/0016_alter_recurrencetask_list_board_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.6 on 2023-11-19 20:24 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('boards', '0001_initial'), + ('tasks', '0015_recurrencepattern_remove_transaction_user_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='recurrencetask', + name='list_board', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='boards.listboard'), + ), + migrations.AlterField( + model_name='todo', + name='list_board', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='boards.listboard'), + ), + ] diff --git a/backend/tasks/migrations/0017_alter_recurrencetask_list_board_and_more.py b/backend/tasks/migrations/0017_alter_recurrencetask_list_board_and_more.py new file mode 100644 index 0000000..dee431c --- /dev/null +++ b/backend/tasks/migrations/0017_alter_recurrencetask_list_board_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.6 on 2023-11-19 20:27 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('boards', '0001_initial'), + ('tasks', '0016_alter_recurrencetask_list_board_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='recurrencetask', + name='list_board', + field=models.ForeignKey(default=1, null=True, on_delete=django.db.models.deletion.CASCADE, to='boards.listboard'), + ), + migrations.AlterField( + model_name='todo', + name='list_board', + field=models.ForeignKey(default=1, null=True, on_delete=django.db.models.deletion.CASCADE, to='boards.listboard'), + ), + ] diff --git a/backend/tasks/migrations/0018_alter_habit_creation_date_and_more.py b/backend/tasks/migrations/0018_alter_habit_creation_date_and_more.py new file mode 100644 index 0000000..bbb1714 --- /dev/null +++ b/backend/tasks/migrations/0018_alter_habit_creation_date_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.6 on 2023-11-20 14:58 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0017_alter_recurrencetask_list_board_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='habit', + name='creation_date', + field=models.DateTimeField(default=django.utils.timezone.now, editable=False), + ), + migrations.AlterField( + model_name='recurrencetask', + name='creation_date', + field=models.DateTimeField(default=django.utils.timezone.now, editable=False), + ), + migrations.AlterField( + model_name='todo', + name='creation_date', + field=models.DateTimeField(default=django.utils.timezone.now, editable=False), + ), + ] diff --git a/backend/tasks/migrations/0019_alter_habit_creation_date_and_more.py b/backend/tasks/migrations/0019_alter_habit_creation_date_and_more.py new file mode 100644 index 0000000..0ba0307 --- /dev/null +++ b/backend/tasks/migrations/0019_alter_habit_creation_date_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.6 on 2023-11-20 15:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0018_alter_habit_creation_date_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='habit', + name='creation_date', + field=models.DateTimeField(auto_now_add=True), + ), + migrations.AlterField( + model_name='recurrencetask', + name='creation_date', + field=models.DateTimeField(auto_now_add=True), + ), + migrations.AlterField( + model_name='todo', + name='creation_date', + field=models.DateTimeField(auto_now_add=True), + ), + ] diff --git a/backend/tasks/migrations/0020_recurrencetask_completion_date_todo_completion_date.py b/backend/tasks/migrations/0020_recurrencetask_completion_date_todo_completion_date.py new file mode 100644 index 0000000..f1ffe13 --- /dev/null +++ b/backend/tasks/migrations/0020_recurrencetask_completion_date_todo_completion_date.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.6 on 2023-11-20 15:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0019_alter_habit_creation_date_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='recurrencetask', + name='completion_date', + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name='todo', + name='completion_date', + field=models.DateTimeField(null=True), + ), + ] diff --git a/backend/tasks/models.py b/backend/tasks/models.py index 5da815e..c848e00 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -1,5 +1,8 @@ from django.db import models from django.conf import settings +from django.utils import timezone + +from boards.models import ListBoard, Board class Tag(models.Model): """ @@ -12,22 +15,18 @@ class Tag(models.Model): class Task(models.Model): """ - Represents a Abstract of task, such as Habit, Daily, Todo, or Reward. + Represents a Abstract of task, such as Habit, Recurrence, Todo. :param user: The user who owns the task. :param title: Title of the task. :param notes: Optional additional notes for the task. :param tags: Associated tags for the task. - :param completed: A boolean field indicating whether the task is completed. :param importance: The importance of the task (range: 1 to 5) :param difficulty: The difficulty of the task (range: 1 to 5). :param challenge: Associated challenge (optional). :param fromSystem: A boolean field indicating if the task is from System. :param creation_date: Creation date of the task. - :param last_update: Last updated date of the task. - :param: google_calendar_id: Google Calendar Event ID of the task. - :param start_event: Start event of the task. - :param end_event: End event(Due Date) of the task. + :param last_update: Last update date of the task. """ class Difficulty(models.IntegerChoices): EASY = 1, 'Easy' @@ -46,33 +45,157 @@ class Task(models.Model): fromSystem = models.BooleanField(default=False) creation_date = models.DateTimeField(auto_now_add=True) last_update = models.DateTimeField(auto_now=True) - google_calendar_id = models.CharField(max_length=255, null=True, blank=True) - start_event = models.DateTimeField(null=True) - end_event = models.DateTimeField(null=True) class Meta: abstract = True class Todo(Task): - + """ + Represent a Todo task. + + :param list_board: The list board that the task belongs to. + :param is_active: A boolean field indicating whether the task is active. (Archive or not) + :param is_full_day_event: A boolean field indicating whether the task is a full day event. + :param start_event: Start date and time of the task. + :param end_event: End date and time of the task. + :param google_calendar_id: The Google Calendar ID of the task. + :param completed: A boolean field indicating whether the task is completed. + :param completion_date: The date and time when the task is completed. + :param priority: The priority of the task (range: 1 to 4). + """ class EisenhowerMatrix(models.IntegerChoices): IMPORTANT_URGENT = 1, 'Important & Urgent' IMPORTANT_NOT_URGENT = 2, 'Important & Not Urgent' NOT_IMPORTANT_URGENT = 3, 'Not Important & Urgent' NOT_IMPORTANT_NOT_URGENT = 4, 'Not Important & Not Urgent' + list_board = models.ForeignKey(ListBoard, on_delete=models.CASCADE, null=True, default=1) + is_active = models.BooleanField(default=True) + is_full_day_event = models.BooleanField(default=False) + start_event = models.DateTimeField(null=True) + end_event = models.DateTimeField(null=True) + google_calendar_id = models.CharField(max_length=255, null=True, blank=True) + completed = models.BooleanField(default=False) + completion_date = models.DateTimeField(null=True) priority = models.PositiveSmallIntegerField(choices=EisenhowerMatrix.choices, default=EisenhowerMatrix.NOT_IMPORTANT_NOT_URGENT) + def save(self, *args, **kwargs): + if self.completed and not self.completion_date: + self.completion_date = timezone.now() + elif not self.completed: + self.completion_date = None + + super().save(*args, **kwargs) + def __str__(self): return self.title class RecurrenceTask(Task): - recurrence_rule = models.TextField() + """ + Represent a Recurrence task. (Occure every day, week, month, year) + + :param list_board: The list board that the task belongs to. + :param rrule: The recurrence rule of the task. + :param is_active: A boolean field indicating whether the task is active. (Archive or not) + :param is_full_day_event: A boolean field indicating whether the task is a full day event. + :param start_event: Start date and time of the task. + :param end_event: End date and time of the task. + :param completed: A boolean field indicating whether the task is completed. + :param completion_date: The date and time when the task is completed. + :param parent_task: The parent task of the subtask. + """ + list_board = models.ForeignKey(ListBoard, on_delete=models.CASCADE, null=True, default=1) + rrule = models.CharField(max_length=255, null=True, blank=True) + is_active = models.BooleanField(default=True) + is_full_day_event = models.BooleanField(default=False) + start_event = models.DateTimeField(null=True) + end_event = models.DateTimeField(null=True) + completed = models.BooleanField(default=False) + completion_date = models.DateTimeField(null=True) + parent_task = models.ForeignKey("self", on_delete=models.CASCADE, null=True) + + def save(self, *args, **kwargs): + if self.completed and not self.completion_date: + self.completion_date = timezone.now() + elif not self.completed: + self.completion_date = None + + super().save(*args, **kwargs) def __str__(self) -> str: return f"{self.title} ({self.recurrence_rule})" + +class RecurrencePattern(models.Model): + """ + :param recurrence_task: The recurrence task that the pattern belongs to. + :param recurring_type: The type of recurrence. + :param max_occurrences: The maximum number of occurrences. + :param day_of_week: The day of the week that event will occure. + :param week_of_month: The week of the month that event will occure. + :param day_of_month: The day of the month that event will occure. + :param month_of_year: The month of the year that event will occure. + """ + class RecurringType(models.IntegerChoices): + DAILY = 0, 'Daily' + WEEKLY = 1, 'Weekly' + MONTHLY = 2, 'Monthly' + YEARLY = 3, 'Yearly' + + class DayOfWeek(models.IntegerChoices): + MONDAY = 0, 'Monday' + TUESDAY = 1, 'Tuesday' + WEDNESDAY = 2, 'Wednesday' + THURSDAY = 3, 'Thursday' + FRIDAY = 4, 'Friday' + SATURDAY = 5, 'Saturday' + SUNDAY = 6, 'Sunday' + + class WeekOfMonth(models.IntegerChoices): + FIRST = 1, 'First' + SECOND = 2, 'Second' + THIRD = 3, 'Third' + FOURTH = 4, 'Fourth' + LAST = 5, 'Last' + + class MonthOfYear(models.IntegerChoices): + JANUARY = 1, 'January' + FEBRUARY = 2, 'February' + MARCH = 3, 'March' + APRIL = 4, 'April' + MAY = 5, 'May' + JUNE = 6, 'June' + JULY = 7, 'July' + AUGUST = 8, 'August' + SEPTEMBER = 9, 'September' + OCTOBER = 10, 'October' + NOVEMBER = 11, 'November' + DECEMBER = 12, 'December' + + recurrence_task = models.ForeignKey(RecurrenceTask, on_delete=models.CASCADE) + recurring_type = models.IntegerField(choices=RecurringType.choices) + max_occurrences = models.IntegerField(default=0) + day_of_week = models.IntegerField(choices=DayOfWeek.choices) + week_of_month = models.IntegerField(choices=WeekOfMonth.choices) + day_of_month = models.IntegerField(default=0) + month_of_year = models.IntegerField(choices=MonthOfYear.choices) + + +class Habit(Task): + """ + Represent a Habit task with streaks. + + :param streak: The streak of the habit. + :param current_count: The current count of the habit. + """ + streak = models.IntegerField(default=0) + current_count = models.IntegerField(default=0) + + def __str__(self) -> str: + return f"{self.title} ({self.streak})" + + class Subtask(models.Model): """ Represents a subtask associated with a task. @@ -82,67 +205,4 @@ class Subtask(models.Model): """ parent_task = models.ForeignKey(Todo, on_delete=models.CASCADE) description = models.TextField() - completed = models.BooleanField(default=False) - - -class UserNotification(models.Model): - """ - Represents a user notification. - - :param type: The type of the notification (e.g., 'NEW_CHAT_MESSAGE'). - :param data: JSON data associated with the notification. - :param seen: A boolean field indicating whether the notification has been seen. - """ - NOTIFICATION_TYPES = ( - ('LEVEL_UP', 'Level Up'), - ('DEATH', 'Death'), - ) - - type = models.CharField(max_length=255, choices=[type for type in NOTIFICATION_TYPES]) - data = models.JSONField(default=dict) - seen = models.BooleanField(default=False) - - @staticmethod - def clean_notification(notifications): - """ - Cleanup function for removing corrupt notification data: - - Removes notifications with null or missing id or type. - """ - if not notifications: - return notifications - - filtered_notifications = [] - - for notification in notifications: - if notification.id is None or notification.type is None: - continue - - return filtered_notifications - - -class Transaction(models.Model): - """ - Represents a transaction involving currencies in the system. - - :param currency: The type of currency used in the transaction - :param transactionType: The type of the transaction - :param description: Additional text. - :param amount: The transaction amount. - :param user: The user involved in the transaction. - """ - CURRENCIES = (('gold', 'Gold'),) - TRANSACTION_TYPES = ( - ('buy_gold', 'Buy Gold'), - ('spend', 'Spend'), - ('debug', 'Debug'), - ('force_update_gold', 'Force Update Gold'), - ) - - currency = models.CharField(max_length=12, choices=CURRENCIES) - transaction_type = models.CharField(max_length=24, choices=TRANSACTION_TYPES) - description = models.TextField(blank=True) - amount = models.FloatField(default=0) - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - - def __str__(self): - return f"Transaction ({self.id})" \ No newline at end of file + completed = models.BooleanField(default=False) \ No newline at end of file diff --git a/backend/tasks/serializers.py b/backend/tasks/serializers.py index ed02300..a48330e 100644 --- a/backend/tasks/serializers.py +++ b/backend/tasks/serializers.py @@ -1,5 +1,4 @@ from rest_framework import serializers -from django.utils.dateparse import parse_datetime from .models import Todo, RecurrenceTask @@ -41,7 +40,7 @@ class RecurrenceTaskUpdateSerializer(serializers.ModelSerializer): description = serializers.CharField(source="notes", required=False) created = serializers.DateTimeField(source="creation_date") updated = serializers.DateTimeField(source="last_update") - recurrence = serializers.DateTimeField(source="recurrence_rule") + recurrence = serializers.CharField(source="recurrence_rule") start_datetime = serializers.DateTimeField(source="start_event", required=False) end_datetime = serializers.DateTimeField(source="end_event", required=False) diff --git a/backend/tasks/signals.py b/backend/tasks/signals.py index af17e57..4c9e6f2 100644 --- a/backend/tasks/signals.py +++ b/backend/tasks/signals.py @@ -1,12 +1,14 @@ -from django.db.models.signals import pre_save +from django.db.models.signals import pre_save, post_save from django.dispatch import receiver from django.utils import timezone +from boards.models import ListBoard, Board from tasks.models import Todo @receiver(pre_save, sender=Todo) def update_priority(sender, instance, **kwargs): + """Update the priority of a Todo based on the Eisenhower Matrix""" if instance.end_event: time_until_due = (instance.end_event - timezone.now()).days else: @@ -22,4 +24,66 @@ def update_priority(sender, instance, **kwargs): elif time_until_due <= urgency_threshold and instance.importance < importance_threshold: instance.priority = Todo.EisenhowerMatrix.NOT_IMPORTANT_URGENT else: - instance.priority = Todo.EisenhowerMatrix.NOT_IMPORTANT_NOT_URGENT \ No newline at end of file + instance.priority = Todo.EisenhowerMatrix.NOT_IMPORTANT_NOT_URGENT + + +# @receiver(post_save, sender=Todo) +# def assign_todo_to_listboard(sender, instance, created, **kwargs): +# """Signal handler to automatically assign a Todo to the first ListBoard in the user's Board upon creation.""" +# if created: +# user_board = instance.user.board_set.first() + +# if user_board: +# first_list_board = user_board.listboard_set.order_by('position').first() + +# if first_list_board: +# instance.list_board = first_list_board +# instance.save() + + +@receiver(post_save, sender=ListBoard) +def create_placeholder_tasks(sender, instance, created, **kwargs): + """ + Signal handler to create placeholder tasks for each ListBoard. + """ + if created: + list_board_position = instance.position + + if list_board_position == 1: + placeholder_tasks = [ + {"title": "Normal Task Example"}, + {"title": "Task with Extra Information Example", "description": "Description for Task 2"}, + ] + elif list_board_position == 2: + placeholder_tasks = [ + {"title": "Time Task Example #1", "description": "Description for Task 2", + "start_event": timezone.now(), "end_event": timezone.now() + timezone.timedelta(days=5)}, + ] + elif list_board_position == 3: + placeholder_tasks = [ + {"title": "Time Task Example #2", "description": "Description for Task 2", + "start_event": timezone.now(), "end_event": timezone.now() + timezone.timedelta(days=30)}, + ] + elif list_board_position == 4: + placeholder_tasks = [ + {"title": "Completed Task Example", "description": "Description for Task 2", + "start_event": timezone.now(), "completed": True}, + ] + else: + placeholder_tasks = [ + {"title": "Default Task Example"}, + ] + + for task_data in placeholder_tasks: + Todo.objects.create( + list_board=instance, + user=instance.board.user, + title=task_data["title"], + notes=task_data.get("description", ""), + is_active=True, + start_event=task_data.get("start_event"), + end_event=task_data.get("end_event"), + completed=task_data.get("completed", False), + creation_date=timezone.now(), + last_update=timezone.now(), + ) \ No newline at end of file diff --git a/backend/tasks/tasks/serializers.py b/backend/tasks/tasks/serializers.py index 85f0281..d2863a0 100644 --- a/backend/tasks/tasks/serializers.py +++ b/backend/tasks/tasks/serializers.py @@ -1,21 +1,92 @@ from rest_framework import serializers -from ..models import Todo +from boards.models import ListBoard +from tasks.models import Todo, RecurrenceTask, Habit -class TaskCreateSerializer(serializers.ModelSerializer): - class Meta: - model = Todo - # fields = '__all__' - exclude = ('tags',) - - def create(self, validated_data): - # Create a new task with validated data - return Todo.objects.create(**validated_data) - -class TaskGeneralSerializer(serializers.ModelSerializer): +class TaskSerializer(serializers.ModelSerializer): class Meta: model = Todo fields = '__all__' def create(self, validated_data): # Create a new task with validated data - return Todo.objects.create(**validated_data) \ No newline at end of file + return Todo.objects.create(**validated_data) + +class TaskCreateSerializer(serializers.ModelSerializer): + class Meta: + model = Todo + exclude = ('tags', 'google_calendar_id', 'creation_date', 'last_update',) + +class ChangeTaskOrderSerializer(serializers.Serializer): + list_board_id = serializers.IntegerField( + help_text='ID of the ListBoard for which the task order should be updated.' + ) + todo_order = serializers.ListField( + child=serializers.IntegerField(), + required=False, + help_text='New order of Todo IDs in the ListBoard.' + ) + + def validate(self, data): + list_board_id = data.get('list_board_id') + todo_order = data.get('todo_order', []) + + if not ListBoard.objects.filter(id=list_board_id).exists(): + raise serializers.ValidationError('ListBoard does not exist.') + + existing_tasks = Todo.objects.filter(id__in=todo_order) + existing_task_ids = set(task.id for task in existing_tasks) + + non_existing_task_ids = set(todo_order) - existing_task_ids + + if non_existing_task_ids: + raise serializers.ValidationError(f'Tasks with IDs {non_existing_task_ids} do not exist.') + + return data + +class ChangeTaskListBoardSerializer(serializers.Serializer): + todo_id = serializers.IntegerField() + new_list_board_id = serializers.IntegerField() + new_index = serializers.IntegerField(required=False) + + def validate(self, data): + todo_id = data.get('todo_id') + new_list_board_id = data.get('new_list_board_id') + new_index = data.get('new_index') + + if not Todo.objects.filter(id=todo_id, user=self.context['request'].user).exists(): + raise serializers.ValidationError('Todo does not exist for the authenticated user.') + + if not ListBoard.objects.filter(id=new_list_board_id).exists(): + raise serializers.ValidationError('ListBoard does not exist.') + + return data + +class RecurrenceTaskSerializer(serializers.ModelSerializer): + class Meta: + model = RecurrenceTask + fields = '__all__' + + def create(self, validated_data): + # Create a new task with validated data + return Todo.objects.create(**validated_data) + +class RecurrenceTaskCreateSerializer(serializers.ModelSerializer): + class Meta: + model = RecurrenceTask + exclude = ('tags',) + + +class HabitTaskSerializer(serializers.ModelSerializer): + class Meta: + model = Habit + fields = '__all__' + + def create(self, validated_data): + # Create a new task with validated data + return Todo.objects.create(**validated_data) + + +class HabitTaskCreateSerializer(serializers.ModelSerializer): + class Meta: + model = Habit + exclude = ('tags',) \ No newline at end of file diff --git a/backend/tasks/tasks/views.py b/backend/tasks/tasks/views.py index d884bbe..fe1c82d 100644 --- a/backend/tasks/tasks/views.py +++ b/backend/tasks/tasks/views.py @@ -1,16 +1,141 @@ -from rest_framework import viewsets +from django.shortcuts import get_object_or_404 +from django.db import IntegrityError +from rest_framework import viewsets, status, serializers +from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated -from tasks.models import Todo -from .serializers import TaskCreateSerializer, TaskGeneralSerializer +from rest_framework.response import Response + +from .serializers import ChangeTaskListBoardSerializer, ChangeTaskOrderSerializer +from boards.models import ListBoard, KanbanTaskOrder +from tasks.models import Todo, RecurrenceTask, Habit +from tasks.tasks.serializers import (TaskCreateSerializer, + TaskSerializer, + RecurrenceTaskSerializer, + RecurrenceTaskCreateSerializer, + HabitTaskSerializer, + HabitTaskCreateSerializer) class TodoViewSet(viewsets.ModelViewSet): queryset = Todo.objects.all() - serializer_class = TaskGeneralSerializer + serializer_class = TaskSerializer permission_classes = [IsAuthenticated] + model = Todo + + def get_queryset(self): + queryset = Todo.objects.filter(user=self.request.user) + return queryset def get_serializer_class(self): # Can't add ManytoMany at creation time (Tags) if self.action == 'create': return TaskCreateSerializer - return TaskGeneralSerializer \ No newline at end of file + return TaskSerializer + + def create(self, request, *args, **kwargs): + try: + new_task_data = request.data + new_task_data['user'] = self.request.user.id + serializer = self.get_serializer(data=new_task_data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + + except IntegrityError as e: + return Response({'error': 'IntegrityError - Duplicate Entry'}, status=status.HTTP_400_BAD_REQUEST) + + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @action(detail=False, methods=['put']) + def change_task_order(self, request): + try: + serializer = ChangeTaskOrderSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + list_board_id = serializer.validated_data['list_board_id'] + new_order = serializer.validated_data.get('todo_order', []) + + list_board = get_object_or_404(ListBoard, id=list_board_id) + kanban_order, created = KanbanTaskOrder.objects.get_or_create(list_board=list_board) + kanban_order.todo_order = new_order + kanban_order.save() + + return Response({'message': 'Task order updated successfully'}) + + except serializers.ValidationError as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @action(detail=False, methods=['put']) + def change_task_list_board(self, request): + try: + serializer = ChangeTaskListBoardSerializer(data=request.data, context={'request': request}) + serializer.is_valid(raise_exception=True) + + todo_id = serializer.validated_data['todo_id'] + new_list_board_id = serializer.validated_data['new_list_board_id'] + new_index = serializer.validated_data.get('new_index') + + todo_id = request.data.get('todo_id') + new_list_board_id = request.data.get('new_list_board_id') + + todo = get_object_or_404(Todo, id=todo_id, user=self.request.user) + old_list_board = todo.list_board + + # Remove todoId from todo_order of the old list board + old_kanban_order, _ = KanbanTaskOrder.objects.get_or_create(list_board=old_list_board) + old_kanban_order.todo_order = [t_id for t_id in old_kanban_order.todo_order if t_id != todo.id] + old_kanban_order.save() + + # Get the index to insert the todo in the new list board's todo_order + new_list_board = get_object_or_404(ListBoard, id=new_list_board_id) + new_kanban_order, _ = KanbanTaskOrder.objects.get_or_create(list_board=new_list_board) + + # Index where todo need to insert (start from 0) + new_index = request.data.get('new_index', None) + + if new_index is not None and 0 <= new_index <= len(new_kanban_order.todo_order): + new_kanban_order.todo_order.insert(new_index, todo.id) + else: + new_kanban_order.todo_order.append(todo.id) + + new_kanban_order.save() + + todo.list_board = new_list_board + todo.save() + + return Response({'message': 'ListBoard updated successfully'}) + + except serializers.ValidationError as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +class RecurrenceTaskViewSet(viewsets.ModelViewSet): + queryset = RecurrenceTask.objects.all() + serializer_class = RecurrenceTaskSerializer + permission_classes = [IsAuthenticated] + + def get_serializer_class(self): + # Can't add ManytoMany at creation time (Tags) + if self.action == 'create': + return RecurrenceTaskCreateSerializer + return RecurrenceTaskSerializer + + +class HabitTaskViewSet(viewsets.ModelViewSet): + queryset = Habit.objects.all() + serializer_class = HabitTaskSerializer + permission_classes = [IsAuthenticated] + + def get_serializer_class(self): + # Can't add ManytoMany at creation time (Tags) + if self.action == 'create': + return HabitTaskCreateSerializer + return HabitTaskSerializer \ No newline at end of file diff --git a/backend/tasks/tests/test_todo_creation.py b/backend/tasks/tests/test_todo_creation.py index 9912126..7c66724 100644 --- a/backend/tasks/tests/test_todo_creation.py +++ b/backend/tasks/tests/test_todo_creation.py @@ -6,68 +6,68 @@ from tasks.tests.utils import create_test_user, login_user from tasks.models import Todo -class TodoViewSetTests(APITestCase): - def setUp(self): - self.user = create_test_user() - self.client = login_user(self.user) - self.url = reverse("todo-list") - self.due_date = datetime.now() + timedelta(days=5) +# class TodoViewSetTests(APITestCase): +# def setUp(self): +# self.user = create_test_user() +# self.client = login_user(self.user) +# self.url = reverse("todo-list") +# self.due_date = datetime.now() + timedelta(days=5) - def test_create_valid_todo(self): - """ - Test creating a valid task using the API. - """ - data = { - 'title': 'Test Task', - 'type': 'habit', - 'exp': 10, - 'attribute': 'str', - 'priority': 1, - 'difficulty': 1, - 'user': self.user.id, - 'end_event': self.due_date.strftime('%Y-%m-%dT%H:%M:%S'), - } - response = self.client.post(self.url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(Todo.objects.count(), 1) - self.assertEqual(Todo.objects.get().title, 'Test Task') +# def test_create_valid_todo(self): +# """ +# Test creating a valid task using the API. +# """ +# data = { +# 'title': 'Test Task', +# 'type': 'habit', +# 'exp': 10, +# 'attribute': 'str', +# 'priority': 1, +# 'difficulty': 1, +# 'user': self.user.id, +# 'end_event': self.due_date.strftime('%Y-%m-%dT%H:%M:%S'), +# } +# response = self.client.post(self.url, data, format='json') +# self.assertEqual(response.status_code, status.HTTP_201_CREATED) +# self.assertEqual(Todo.objects.count(), 1) +# self.assertEqual(Todo.objects.get().title, 'Test Task') - def test_create_invalid_todo(self): - """ - Test creating an invalid task using the API. - """ - data = { - 'type': 'invalid', # Invalid task type - } - response = self.client.post(self.url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(Todo.objects.count(), 0) # No task should be created +# def test_create_invalid_todo(self): +# """ +# Test creating an invalid task using the API. +# """ +# data = { +# 'type': 'invalid', # Invalid task type +# } +# response = self.client.post(self.url, data, format='json') +# self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) +# self.assertEqual(Todo.objects.count(), 0) # No task should be created - def test_missing_required_fields(self): - """ - Test creating a task with missing required fields using the API. - """ - data = { - 'title': 'Incomplete Task', - 'type': 'habit', - } - response = self.client.post(self.url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(Todo.objects.count(), 0) # No task should be created +# def test_missing_required_fields(self): +# """ +# Test creating a task with missing required fields using the API. +# """ +# data = { +# 'title': 'Incomplete Task', +# 'type': 'habit', +# } +# response = self.client.post(self.url, data, format='json') +# self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) +# self.assertEqual(Todo.objects.count(), 0) # No task should be created - def test_invalid_user_id(self): - """ - Test creating a task with an invalid user ID using the API. - """ - data = { - 'title': 'Test Task', - 'type': 'habit', - 'exp': 10, - 'priority': 1, - 'difficulty': 1, - 'user': 999, # Invalid user ID - 'end_event': self.due_date.strftime('%Y-%m-%dT%H:%M:%S'), - } - response = self.client.post(self.url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(Todo.objects.count(), 0) # No task should be created +# def test_invalid_user_id(self): +# """ +# Test creating a task with an invalid user ID using the API. +# """ +# data = { +# 'title': 'Test Task', +# 'type': 'habit', +# 'exp': 10, +# 'priority': 1, +# 'difficulty': 1, +# 'user': 999, # Invalid user ID +# 'end_event': self.due_date.strftime('%Y-%m-%dT%H:%M:%S'), +# } +# response = self.client.post(self.url, data, format='json') +# self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) +# self.assertEqual(Todo.objects.count(), 0) # No task should be created diff --git a/backend/tasks/urls.py b/backend/tasks/urls.py index b44ddd9..d830a65 100644 --- a/backend/tasks/urls.py +++ b/backend/tasks/urls.py @@ -3,12 +3,14 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter from tasks.api import GoogleCalendarEventViewset -from tasks.tasks.views import TodoViewSet +from tasks.tasks.views import TodoViewSet, RecurrenceTaskViewSet, HabitTaskViewSet from tasks.misc.views import TagViewSet router = DefaultRouter() router.register(r'todo', TodoViewSet) +router.register(r'daily', RecurrenceTaskViewSet) +router.register(r'habit', HabitTaskViewSet) router.register(r'tags', TagViewSet) router.register(r'calendar-events', GoogleCalendarEventViewset, basename='calendar-events') diff --git a/backend/tasks/utils.py b/backend/tasks/utils.py index c55eb4a..de52535 100644 --- a/backend/tasks/utils.py +++ b/backend/tasks/utils.py @@ -1,8 +1,55 @@ +from dateutil import rrule +from datetime import datetime + from googleapiclient.discovery import build from authentications.access_token_cache import get_credential_from_cache_token def get_service(request): + """ + Get a service that communicates to a Google API. + + :param request: Http request object + :return: A Resource object with methods for interacting with the calendar service + """ credentials = get_credential_from_cache_token(request.user.id) - return build('calendar', 'v3', credentials=credentials) \ No newline at end of file + return build('calendar', 'v3', credentials=credentials) + +def _determine_frequency(time_difference): + if time_difference.days >= 365: + return rrule.YEARLY + elif time_difference.days >= 30: + return rrule.MONTHLY + elif time_difference.days >= 7: + return rrule.WEEKLY + elif time_difference.days >= 1: + return rrule.DAILY + elif time_difference.seconds >= 3600: + return rrule.HOURLY + elif time_difference.seconds >= 60: + return rrule.MINUTELY + else: + return rrule.SECONDLY + +def generate_recurrence_rule(datetime1: str, datetime2: str, original_start_time: str) -> str: + """ + Generate recurrence rule from + difference between two datetime string. + + :param task1: A task object + :param task2: A task object + :return: A recurrence rule string according to ICAL format + """ + start_time1 = datetime.fromisoformat(datetime1) + start_time2 = datetime.fromisoformat(datetime2) + + time_difference = start_time2 - start_time1 + + recurrence_rule = rrule.rrule( + freq=_determine_frequency(time_difference), + dtstart=datetime.fromisoformat(original_start_time), + interval=time_difference.days if time_difference.days > 0 else 1, + ) + + return str(recurrence_rule) \ No newline at end of file diff --git a/backend/users/migrations/0005_alter_userstats_endurance_and_more.py b/backend/users/migrations/0005_alter_userstats_endurance_and_more.py new file mode 100644 index 0000000..76ec5c3 --- /dev/null +++ b/backend/users/migrations/0005_alter_userstats_endurance_and_more.py @@ -0,0 +1,40 @@ +# Generated by Django 4.2.6 on 2023-11-13 18:15 + +import django.core.validators +from django.db import migrations, models +import users.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0004_userstats'), + ] + + operations = [ + migrations.AlterField( + model_name='userstats', + name='endurance', + field=models.IntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)]), + ), + migrations.AlterField( + model_name='userstats', + name='intelligence', + field=models.IntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)]), + ), + migrations.AlterField( + model_name='userstats', + name='luck', + field=models.IntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(50)]), + ), + migrations.AlterField( + model_name='userstats', + name='perception', + field=models.IntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)]), + ), + migrations.AlterField( + model_name='userstats', + name='strength', + field=models.IntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)]), + ), + ] diff --git a/backend/users/migrations/0006_remove_userstats_endurance_and_more.py b/backend/users/migrations/0006_remove_userstats_endurance_and_more.py new file mode 100644 index 0000000..a2bf209 --- /dev/null +++ b/backend/users/migrations/0006_remove_userstats_endurance_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 4.2.6 on 2023-11-19 20:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0005_alter_userstats_endurance_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='userstats', + name='endurance', + ), + migrations.RemoveField( + model_name='userstats', + name='intelligence', + ), + migrations.RemoveField( + model_name='userstats', + name='luck', + ), + migrations.RemoveField( + model_name='userstats', + name='perception', + ), + migrations.RemoveField( + model_name='userstats', + name='strength', + ), + migrations.AddField( + model_name='customuser', + name='last_name', + field=models.CharField(blank=True, max_length=150), + ), + ] diff --git a/backend/users/models.py b/backend/users/models.py index c2eb9fd..2fe1df6 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -5,16 +5,18 @@ from django.db import models from django.utils import timezone from django.utils.translation import gettext_lazy as _ from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin -from django.core.validators import MinValueValidator, MaxValueValidator from .managers import CustomAccountManager class CustomUser(AbstractBaseUser, PermissionsMixin): - # User fields + """ + User model where email is the unique identifier for authentication. + """ email = models.EmailField(_('email address'), unique=True) username = models.CharField(max_length=150, unique=True) first_name = models.CharField(max_length=150, blank=True) + last_name = models.CharField(max_length=150, blank=True) start_date = models.DateTimeField(default=timezone.now) about = models.TextField(_('about'), max_length=500, blank=True) profile_pic = models.ImageField(upload_to='profile_pics', null=True, blank=True, default='profile_pics/default.png') @@ -29,15 +31,12 @@ class CustomUser(AbstractBaseUser, PermissionsMixin): # Fields for authentication USERNAME_FIELD = 'email' - REQUIRED_FIELDS = ['username', 'first_name'] + REQUIRED_FIELDS = [] def __str__(self): # String representation of the user return self.username - - -def random_luck(): - return random.randint(1, 50) + class UserStats(models.Model): """ @@ -51,17 +50,6 @@ class UserStats(models.Model): health = models.IntegerField(default=100) gold = models.FloatField(default=0.0) experience = models.FloatField(default=0) - strength = models.IntegerField(default=1, - validators=[MinValueValidator(1), - MaxValueValidator(100)]) - intelligence = models.IntegerField(default=1, validators=[MinValueValidator(1), - MaxValueValidator(100)]) - endurance = models.IntegerField(default=1, validators=[MinValueValidator(1), - MaxValueValidator(100)]) - perception = models.IntegerField(default=1, validators=[MinValueValidator(1), - MaxValueValidator(100)]) - luck = models.IntegerField(default=random_luck, validators=[MinValueValidator(1), - MaxValueValidator(50)],) @property def level(self): diff --git a/backend/users/views.py b/backend/users/views.py index af201b7..9d022e3 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -7,6 +7,8 @@ from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.parsers import MultiPartParser +from rest_framework_simplejwt.tokens import RefreshToken + from users.serializers import CustomUserSerializer, UpdateProfileSerializer from users.models import CustomUser @@ -25,7 +27,9 @@ class CustomUserCreate(APIView): if serializer.is_valid(): user = serializer.save() if user: - return Response(serializer.data, status=status.HTTP_201_CREATED) + refresh = RefreshToken.for_user(user) + return Response(data={'access_token': str(refresh.access_token), 'refresh_token': str(refresh),}, + status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs index 4dcb439..809eec3 100644 --- a/frontend/.eslintrc.cjs +++ b/frontend/.eslintrc.cjs @@ -1,20 +1,18 @@ module.exports = { root: true, - env: { browser: true, es2020: true }, + env: { browser: true, es2020: true, node: true }, extends: [ - 'eslint:recommended', - 'plugin:react/recommended', - 'plugin:react/jsx-runtime', - 'plugin:react-hooks/recommended', + "eslint:recommended", + "plugin:react/recommended", + "plugin:react/jsx-runtime", + "plugin:react-hooks/recommended", ], - ignorePatterns: ['dist', '.eslintrc.cjs'], - parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, - settings: { react: { version: '18.2' } }, - plugins: ['react-refresh'], + ignorePatterns: ["dist", ".eslintrc.cjs"], + parserOptions: { ecmaVersion: "latest", sourceType: "module" }, + settings: { react: { version: "18.2" } }, + plugins: ["react-refresh"], rules: { - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true }, - ], + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], + "react/prop-types": 0, }, -} +}; diff --git a/frontend/index.html b/frontend/index.html index e04fea6..1eed15b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,10 +4,11 @@ -