mirror of
https://github.com/TurTaskProject/TurTaskWeb.git
synced 2025-12-19 05:54:07 +01:00
Merge branch 'feature/kanban-board' of https://github.com/TurTaskProject/TurTaskWeb into feature/kanban-board
This commit is contained in:
commit
4e1580f7d1
40
.github/workflows/django.yml
vendored
40
.github/workflows/django.yml
vendored
@ -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
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -57,7 +57,6 @@ cover/
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
|
||||
@ -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')
|
||||
]
|
||||
@ -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)
|
||||
0
backend/boards/__init__.py
Normal file
0
backend/boards/__init__.py
Normal file
17
backend/boards/admin.py
Normal file
17
backend/boards/admin.py
Normal file
@ -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']
|
||||
9
backend/boards/apps.py
Normal file
9
backend/boards/apps.py
Normal file
@ -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
|
||||
35
backend/boards/migrations/0001_initial.py
Normal file
35
backend/boards/migrations/0001_initial.py
Normal file
@ -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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
23
backend/boards/migrations/0002_kanbantaskorder.py
Normal file
23
backend/boards/migrations/0002_kanbantaskorder.py
Normal file
@ -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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
backend/boards/migrations/__init__.py
Normal file
0
backend/boards/migrations/__init__.py
Normal file
56
backend/boards/models.py
Normal file
56
backend/boards/models.py
Normal file
@ -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}"
|
||||
|
||||
13
backend/boards/serializers.py
Normal file
13
backend/boards/serializers.py
Normal file
@ -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__'
|
||||
17
backend/boards/signals.py
Normal file
17
backend/boards/signals.py
Normal file
@ -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)
|
||||
3
backend/boards/tests.py
Normal file
3
backend/boards/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
11
backend/boards/urls.py
Normal file
11
backend/boards/urls.py
Normal file
@ -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)),
|
||||
]
|
||||
34
backend/boards/views.py
Normal file
34
backend/boards/views.py
Normal file
@ -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)
|
||||
|
||||
269
backend/core/local_settings.py
Normal file
269
backend/core/local_settings.py
Normal file
@ -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",
|
||||
},
|
||||
}
|
||||
267
backend/core/production_settings.py
Normal file
267
backend/core/production_settings.py
Normal file
@ -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",
|
||||
},
|
||||
}
|
||||
@ -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",
|
||||
},
|
||||
}
|
||||
if os.environ.get("DJANGO_ENV") == "PRODUCTION":
|
||||
from .production_settings import *
|
||||
else:
|
||||
from .local_settings import *
|
||||
@ -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')),
|
||||
]
|
||||
@ -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()
|
||||
|
||||
0
backend/dashboard/__init__.py
Normal file
0
backend/dashboard/__init__.py
Normal file
3
backend/dashboard/admin.py
Normal file
3
backend/dashboard/admin.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
backend/dashboard/apps.py
Normal file
6
backend/dashboard/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class DashboardConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'dashboard'
|
||||
0
backend/dashboard/migrations/__init__.py
Normal file
0
backend/dashboard/migrations/__init__.py
Normal file
7
backend/dashboard/serializers.py
Normal file
7
backend/dashboard/serializers.py
Normal file
@ -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']
|
||||
103
backend/dashboard/tests.py
Normal file
103
backend/dashboard/tests.py
Normal file
@ -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)
|
||||
11
backend/dashboard/urls.py
Normal file
11
backend/dashboard/urls.py
Normal file
@ -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)),
|
||||
]
|
||||
311
backend/dashboard/views.py
Normal file
311
backend/dashboard/views.py
Normal file
@ -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)
|
||||
11
backend/railway.json
Normal file
11
backend/railway.json
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
drf-spectacular>=0.26
|
||||
python-dateutil>=2.8
|
||||
gunicorn==21.2.0
|
||||
packaging==23.1
|
||||
@ -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
|
||||
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
|
||||
|
||||
@ -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']
|
||||
|
||||
@ -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)
|
||||
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)
|
||||
|
||||
39
backend/tasks/migrations/0012_habit.py
Normal file
39
backend/tasks/migrations/0012_habit.py
Normal file
@ -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,
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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(),
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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})"
|
||||
completed = models.BooleanField(default=False)
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
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(),
|
||||
)
|
||||
@ -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)
|
||||
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',)
|
||||
@ -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
|
||||
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
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -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)
|
||||
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)
|
||||
@ -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)]),
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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):
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@ -4,10 +4,11 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.16/dist/tailwind.min.css" rel="stylesheet"> -->
|
||||
<title>Vite + React</title>
|
||||
<title>TurTask</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
8
frontend/jsconfig.json
Normal file
8
frontend/jsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"src/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -10,35 +10,44 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@asseinfo/react-kanban": "^2.2.0",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.4.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.4.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@fullcalendar/core": "^6.1.9",
|
||||
"@fullcalendar/daygrid": "^6.1.9",
|
||||
"@fullcalendar/interaction": "^6.1.9",
|
||||
"@fullcalendar/react": "^6.1.9",
|
||||
"@fullcalendar/timegrid": "^6.1.9",
|
||||
"@heroicons/react": "1.0.6",
|
||||
"@mui/icons-material": "^5.14.16",
|
||||
"@mui/material": "^5.14.17",
|
||||
"@mui/system": "^5.14.17",
|
||||
"@react-oauth/google": "^0.11.1",
|
||||
"@syncfusion/ej2-base": "^23.1.41",
|
||||
"@syncfusion/ej2-kanban": "^23.1.36",
|
||||
"@tremor/react": "^3.11.1",
|
||||
"@wojtekmaj/react-daterange-picker": "^5.4.4",
|
||||
"axios": "^1.6.1",
|
||||
"bootstrap": "^5.3.2",
|
||||
"dotenv": "^16.3.1",
|
||||
"framer-motion": "^10.16.4",
|
||||
"gapi-script": "^1.2.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^18.2.0",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-bootstrap": "^2.9.1",
|
||||
"react-datetime-picker": "^5.5.3",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^4.11.0",
|
||||
"react-router-dom": "^6.18.0"
|
||||
"react-router-dom": "^6.18.0",
|
||||
"react-tsparticles": "^2.12.2",
|
||||
"tsparticles": "^2.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,41 +1,105 @@
|
||||
import "./App.css";
|
||||
import { Route, Routes, useLocation } from "react-router-dom";
|
||||
import axios from "axios";
|
||||
import { useEffect } from "react";
|
||||
import { Route, Routes, Navigate } from "react-router-dom";
|
||||
import { LoginPage } from "./components/authentication/LoginPage";
|
||||
import { SignUp } from "./components/authentication/SignUpPage";
|
||||
import { NavBar } from "./components/navigations/Navbar";
|
||||
import { Calendar } from "./components/calendar/calendar";
|
||||
import { KanbanPage } from "./components/kanbanBoard/kanbanPage";
|
||||
import { SideNav } from "./components/navigations/IconSideNav";
|
||||
import { Eisenhower } from "./components/EisenhowerMatrix/Eisenhower";
|
||||
import { PrivateRoute } from "./PrivateRoute";
|
||||
import { ProfileUpdatePage } from "./components/profile/profilePage";
|
||||
import { Dashboard } from "./components/dashboard/dashboard";
|
||||
import { LandingPage } from "./components/landingPage/LandingPage";
|
||||
import { PublicRoute } from "./PublicRoute";
|
||||
import { useAuth } from "./hooks/AuthHooks";
|
||||
|
||||
import TestAuth from "./components/testAuth";
|
||||
import LoginPage from "./components/authentication/LoginPage";
|
||||
import SignUpPage from "./components/authentication/SignUpPage";
|
||||
import NavBar from "./components/navigations/Navbar";
|
||||
import Home from "./components/Home";
|
||||
import ProfileUpdate from "./components/ProfileUpdatePage";
|
||||
import Calendar from "./components/calendar/calendar";
|
||||
import KanbanBoard from "./components/kanbanBoard/kanbanBoard";
|
||||
import IconSideNav from "./components/navigations/IconSideNav";
|
||||
import Eisenhower from "./components/eisenhowerMatrix/Eisenhower";
|
||||
const baseURL = import.meta.env.VITE_BASE_URL;
|
||||
|
||||
const App = () => {
|
||||
const location = useLocation();
|
||||
const prevention = ["/login", "/signup"];
|
||||
const isLoginPageOrSignUpPage = prevention.some(_ => location.pathname.includes(_));
|
||||
const { isAuthenticated, setIsAuthenticated } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const checkLoginStatus = async () => {
|
||||
const data = {
|
||||
access_token: localStorage.getItem("access_token"),
|
||||
refresh_token: localStorage.getItem("refresh_token"),
|
||||
};
|
||||
|
||||
await axios
|
||||
.post(`${baseURL}auth/status/`, data, {
|
||||
headers: {
|
||||
Authorization: "Bearer " + localStorage.getItem("access_token"),
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
if (response.data.access_token) {
|
||||
localStorage.setItem("access_token", response.data.access_token);
|
||||
setIsAuthenticated(true);
|
||||
} else {
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
} else {
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
})
|
||||
.catch((error) => {});
|
||||
};
|
||||
|
||||
checkLoginStatus();
|
||||
}, [setIsAuthenticated]);
|
||||
|
||||
return <div>{isAuthenticated ? <AuthenticatedComponents /> : <NonAuthenticatedComponents />}</div>;
|
||||
};
|
||||
|
||||
const NonAuthenticatedComponents = () => {
|
||||
return (
|
||||
<div className={isLoginPageOrSignUpPage ? "" : "display: flex"}>
|
||||
{!isLoginPageOrSignUpPage && <IconSideNav />}
|
||||
<div className={isLoginPageOrSignUpPage ? "" : "flex-1"}>
|
||||
<NavBar />
|
||||
<div className={isLoginPageOrSignUpPage ? "" : "flex items-center justify-center"}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/tasks" element={<KanbanBoard />} />
|
||||
<Route path="/testAuth" element={<TestAuth />} />
|
||||
<Route path="/update_profile" element={<ProfileUpdate />} />
|
||||
<Route path="/calendar" element={<Calendar />} />
|
||||
<Route path="/priority" element={<Eisenhower />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/signup" element={<SignUpPage />} />
|
||||
</Routes>
|
||||
</div>
|
||||
<div>
|
||||
<Routes>
|
||||
<Route exact path="/l" element={<PublicRoute />}>
|
||||
<Route exact path="/l" element={<LandingPage />} />
|
||||
</Route>
|
||||
<Route exact path="/login" element={<PublicRoute />}>
|
||||
<Route exact path="/login" element={<LoginPage />} />
|
||||
</Route>
|
||||
<Route exact path="/signup" element={<PublicRoute />}>
|
||||
<Route exact path="/signup" element={<SignUp />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/l" />} />
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AuthenticatedComponents = () => {
|
||||
return (
|
||||
<div className="display: flex">
|
||||
<SideNav />
|
||||
<div className="flex-1 ml-[76px] overflow-hidden">
|
||||
<NavBar />
|
||||
<div className="overflow-x-auto">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route exact path="/tasks" element={<PrivateRoute />}>
|
||||
<Route exact path="/tasks" element={<KanbanPage />} />
|
||||
</Route>
|
||||
<Route exact path="/profile" element={<PrivateRoute />}>
|
||||
<Route exact path="/profile" element={<ProfileUpdatePage />} />
|
||||
</Route>
|
||||
<Route exact path="/calendar" element={<PrivateRoute />}>
|
||||
<Route exact path="/calendar" element={<Calendar />} />
|
||||
</Route>
|
||||
<Route exact path="/priority" element={<PrivateRoute />}>
|
||||
<Route exact path="/priority" element={<Eisenhower />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
7
frontend/src/PrivateRoute.jsx
Normal file
7
frontend/src/PrivateRoute.jsx
Normal file
@ -0,0 +1,7 @@
|
||||
import { Navigate, Outlet } from "react-router-dom";
|
||||
import { useAuth } from "src/hooks/AuthHooks";
|
||||
|
||||
export const PrivateRoute = () => {
|
||||
const { isAuthenticated } = useAuth();
|
||||
return isAuthenticated ? <Outlet /> : <Navigate to="/" />;
|
||||
};
|
||||
7
frontend/src/PublicRoute.jsx
Normal file
7
frontend/src/PublicRoute.jsx
Normal file
@ -0,0 +1,7 @@
|
||||
import { Navigate, Outlet } from "react-router-dom";
|
||||
import { useAuth } from "src/hooks/AuthHooks";
|
||||
|
||||
export const PublicRoute = () => {
|
||||
const { isAuthenticated } = useAuth();
|
||||
return isAuthenticated ? <Navigate to="/d" /> : <Outlet />;
|
||||
};
|
||||
@ -1,72 +1,43 @@
|
||||
import axios from "axios";
|
||||
import axiosInstance from "./configs/AxiosConfig";
|
||||
import { axiosInstance } from "./AxiosConfig";
|
||||
|
||||
const baseURL = import.meta.env.VITE_BASE_URL;
|
||||
|
||||
// Function for user login
|
||||
const apiUserLogin = data => {
|
||||
export const apiUserLogin = (data) => {
|
||||
return axiosInstance
|
||||
.post("token/obtain/", data)
|
||||
.then(response => {
|
||||
console.log(response.statusText);
|
||||
return response;
|
||||
})
|
||||
.catch(error => {
|
||||
console.log("apiUserLogin error: ", error);
|
||||
return error;
|
||||
.then((response) => response)
|
||||
.catch((error) => {
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
// Function for user logout
|
||||
const apiUserLogout = () => {
|
||||
export const apiUserLogout = () => {
|
||||
axiosInstance.defaults.headers["Authorization"] = ""; // Clear authorization header
|
||||
localStorage.removeItem("access_token"); // Remove access token
|
||||
localStorage.removeItem("refresh_token"); // Remove refresh token
|
||||
};
|
||||
|
||||
// Function for Google login
|
||||
const googleLogin = async token => {
|
||||
export const googleLogin = async (token) => {
|
||||
axios.defaults.withCredentials = true;
|
||||
let res = await axios.post("http://localhost:8000/api/auth/google/", {
|
||||
let res = await axios.post(`${baseURL}auth/google/`, {
|
||||
code: token,
|
||||
});
|
||||
// console.log('service google login res: ', res);
|
||||
return await res;
|
||||
};
|
||||
|
||||
// Function to get 'hello' data
|
||||
const getGreeting = () => {
|
||||
return axiosInstance
|
||||
.get("hello")
|
||||
.then(response => {
|
||||
return response;
|
||||
})
|
||||
.catch(error => {
|
||||
return error;
|
||||
});
|
||||
};
|
||||
|
||||
const config = {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
};
|
||||
|
||||
// Function to register
|
||||
const createUser = async formData => {
|
||||
export const createUser = async (formData) => {
|
||||
try {
|
||||
axios.defaults.withCredentials = true;
|
||||
const resposne = axios.post("http://localhost:8000/api/user/create/", formData);
|
||||
// const response = await axiosInstance.post('/user/create/', formData);
|
||||
const response = await axios.post(`${baseURL}user/create/`, formData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} catch (e) {
|
||||
console.error("Error in createUser function:", e);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
// Export the functions and Axios instance
|
||||
export default {
|
||||
apiUserLogin,
|
||||
apiUserLogout,
|
||||
getGreeting: getGreeting,
|
||||
googleLogin,
|
||||
createUser,
|
||||
};
|
||||
|
||||
53
frontend/src/api/AxiosConfig.jsx
Normal file
53
frontend/src/api/AxiosConfig.jsx
Normal file
@ -0,0 +1,53 @@
|
||||
import axios from "axios";
|
||||
import { redirect } from "react-router-dom";
|
||||
|
||||
const baseURL = import.meta.env.VITE_BASE_URL;
|
||||
|
||||
export const axiosInstance = axios.create({
|
||||
baseURL: baseURL,
|
||||
timeout: 5000,
|
||||
headers: {
|
||||
Authorization: "Bearer " + localStorage.getItem("access_token"),
|
||||
"Content-Type": "application/json",
|
||||
accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
axiosInstance.interceptors.request.use((config) => {
|
||||
const access_token = localStorage.getItem("access_token");
|
||||
if (access_token) {
|
||||
config.headers.Authorization = `Bearer ${access_token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// handling token refresh on 401 Unauthorized errors
|
||||
axiosInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
const originalRequest = error.config;
|
||||
const refresh_token = localStorage.getItem("refresh_token");
|
||||
|
||||
// Check if the error is due to 401 and a refresh token is available
|
||||
if (error.response && error.response.status === 401) {
|
||||
if (refresh_token) {
|
||||
return axiosInstance
|
||||
.post("/token/refresh/", { refresh: refresh_token })
|
||||
.then((response) => {
|
||||
localStorage.setItem("access_token", response.data.access);
|
||||
|
||||
axiosInstance.defaults.headers["Authorization"] = "Bearer " + response.data.access;
|
||||
originalRequest.headers["Authorization"] = "Bearer " + response.data.access;
|
||||
|
||||
return axiosInstance(originalRequest);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("Interceptors error: ", err);
|
||||
});
|
||||
} else {
|
||||
redirect("/login");
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
@ -1,12 +1,8 @@
|
||||
import axiosInstance from "./configs/AxiosConfig";
|
||||
import { createTask, readTasks, readTaskByID, updateTask, deleteTask } from "./TaskApi";
|
||||
|
||||
export const fetchTags = () => {
|
||||
return axiosInstance
|
||||
.get("tags/")
|
||||
.then(response => {
|
||||
return response.data;
|
||||
})
|
||||
.catch(error => {
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
// CRUD functions for "tags" endpoint
|
||||
export const createTag = data => createTask("tags", data);
|
||||
export const readTags = () => readTasks("tags");
|
||||
export const readTagByID = id => readTaskByID("tags", id);
|
||||
export const updateTag = (id, data) => updateTask("tags", id, data);
|
||||
export const deleteTag = id => deleteTask("tags", id);
|
||||
|
||||
@ -1,23 +1,73 @@
|
||||
import axiosInstance from "./configs/AxiosConfig";
|
||||
import { axiosInstance } from "src/api/AxiosConfig";
|
||||
|
||||
export const fetchTodoTasks = () => {
|
||||
const baseURL = import.meta.env.VITE_BASE_URL;
|
||||
|
||||
export const createTask = (endpoint, data) => {
|
||||
return axiosInstance
|
||||
.get("todo/")
|
||||
.then(response => {
|
||||
return response.data;
|
||||
})
|
||||
.catch(error => {
|
||||
.post(`${baseURL}${endpoint}/`, data)
|
||||
.then((response) => response.data)
|
||||
.catch((error) => {
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchTodoTasksID = id => {
|
||||
export const readTasks = (endpoint) => {
|
||||
return axiosInstance
|
||||
.get(`todo/${id}/`)
|
||||
.then(response => {
|
||||
return response.data;
|
||||
})
|
||||
.catch(error => {
|
||||
.get(`${baseURL}${endpoint}/`)
|
||||
.then((response) => response.data)
|
||||
.catch((error) => {
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
export const readTaskByID = (endpoint, id) => {
|
||||
return axiosInstance
|
||||
.get(`${baseURL}${endpoint}/${id}/`)
|
||||
.then((response) => response.data)
|
||||
.catch((error) => {
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
export const updateTask = (endpoint, id, data) => {
|
||||
return axiosInstance
|
||||
.put(`${baseURL}${endpoint}/${id}/`, data)
|
||||
.then((response) => response.data)
|
||||
.catch((error) => {
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteTask = (endpoint, id) => {
|
||||
return axiosInstance
|
||||
.delete(`${baseURL}${endpoint}/${id}/`)
|
||||
.then((response) => response.data)
|
||||
.catch((error) => {
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
// Create
|
||||
export const createTodoTask = (data) => createTask("todo", data);
|
||||
export const createRecurrenceTask = (data) => createTask("daily", data);
|
||||
export const createHabitTask = (data) => createTask("habit", data);
|
||||
|
||||
// Read
|
||||
export const readTodoTasks = () => readTasks("todo");
|
||||
export const readRecurrenceTasks = () => readTasks("daily");
|
||||
export const readHabitTasks = () => readTasks("habit");
|
||||
|
||||
// Read by ID
|
||||
export const readTodoTaskByID = (id) => readTaskByID("todo", id);
|
||||
export const readRecurrenceTaskByID = (id) => readTaskByID("daily", id);
|
||||
export const readHabitTaskByID = (id) => readTaskByID("habit", id);
|
||||
|
||||
// Update
|
||||
export const updateTodoTask = (id, data) => updateTask("todo", id, data);
|
||||
export const updateRecurrenceTask = (id, data) => updateTask("daily", id, data);
|
||||
export const updateHabitTask = (id, data) => updateTask("habit", id, data);
|
||||
|
||||
// Delete
|
||||
export const deleteTodoTask = (id) => deleteTask("todo", id);
|
||||
export const deleteRecurrenceTask = (id) => deleteTask("daily", id);
|
||||
export const deleteHabitTask = (id) => deleteTask("habit", id);
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import axios from 'axios';
|
||||
import axios from "axios";
|
||||
|
||||
const baseURL = import.meta.env.VITE_BASE_URL;
|
||||
|
||||
const ApiUpdateUserProfile = async (formData) => {
|
||||
try {
|
||||
const response = await axios.post('http://127.0.1:8000/api/user/update/', formData, {
|
||||
const response = await axios.post(`${baseURL}user/update/`, formData, {
|
||||
headers: {
|
||||
'Authorization': "Bearer " + localStorage.getItem('access_token'),
|
||||
'Content-Type': 'multipart/form-data',
|
||||
Authorization: "Bearer " + localStorage.getItem("access_token"),
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
|
||||
@ -13,7 +15,7 @@ const ApiUpdateUserProfile = async (formData) => {
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error updating user profile:', error);
|
||||
console.error("Error updating user profile:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,42 +0,0 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: 'http://127.0.0.1:8000/api/',
|
||||
timeout: 5000,
|
||||
headers: {
|
||||
'Authorization': "Bearer " + localStorage.getItem('access_token'),
|
||||
'Content-Type': 'application/json',
|
||||
'accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// handling token refresh on 401 Unauthorized errors
|
||||
axiosInstance.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
const originalRequest = error.config;
|
||||
const refresh_token = localStorage.getItem('refresh_token');
|
||||
|
||||
// Check if the error is due to 401 and a refresh token is available
|
||||
if (error.response.status === 401 && error.response.statusText === "Unauthorized" && refresh_token !== "undefined") {
|
||||
return axiosInstance
|
||||
.post('/token/refresh/', { refresh: refresh_token })
|
||||
.then((response) => {
|
||||
|
||||
localStorage.setItem('access_token', response.data.access);
|
||||
|
||||
axiosInstance.defaults.headers['Authorization'] = "Bearer " + response.data.access;
|
||||
originalRequest.headers['Authorization'] = "Bearer " + response.data.access;
|
||||
|
||||
return axiosInstance(originalRequest);
|
||||
})
|
||||
.catch(err => {
|
||||
console.log('Interceptors error: ', err);
|
||||
});
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
export default axiosInstance;
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 6.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.6 KiB |
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
@ -1,30 +1,110 @@
|
||||
import React from 'react';
|
||||
import { useState, useEffect } from "react";
|
||||
import { FiAlertCircle, FiClock, FiXCircle, FiCheckCircle } from "react-icons/fi";
|
||||
import { readTodoTasks } from "../../api/TaskApi";
|
||||
import { axiosInstance } from "src/api/AxiosConfig";
|
||||
|
||||
function EachBlog({ name, colorCode, contentList, icon }) {
|
||||
const [tasks, setTasks] = useState(contentList);
|
||||
|
||||
const handleCheckboxChange = async (index) => {
|
||||
try {
|
||||
setTasks(contentList);
|
||||
|
||||
const updatedTasks = [...tasks];
|
||||
const taskId = updatedTasks[index].id;
|
||||
|
||||
const response = await axiosInstance.patch(`todo/${taskId}/`, {
|
||||
completed: !updatedTasks[index].completed,
|
||||
});
|
||||
|
||||
updatedTasks[index].completed = response.data.completed;
|
||||
|
||||
setTasks(updatedTasks);
|
||||
} catch (error) {
|
||||
console.error("Error updating task:", error);
|
||||
}
|
||||
};
|
||||
|
||||
function EachBlog({ name, colorCode }) {
|
||||
return (
|
||||
<div className={`grid grid-rows-2 gap-4 text-left p-4 rounded-lg bg-white border border-gray-300 shadow-md`}>
|
||||
<div className={`text-xl font-bold`} style={{ color: colorCode }}>
|
||||
{name}
|
||||
<div className={`h-full text-left p-4 rounded-lg bg-white border border-gray-300 overflow-y-auto`}>
|
||||
<div className="flex" style={{ color: colorCode }}>
|
||||
<span className="mx-2 mt-1">{icon}</span>
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
<div className='h-36'>
|
||||
Content goes here
|
||||
<hr className="my-3 h-0.5 border-t-0 bg-gray-300 opacity-100 dark:opacity-50" />
|
||||
<div className="space-y-2">
|
||||
{contentList && contentList.length > 0 ? (
|
||||
contentList.map((item, index) => (
|
||||
<div key={index} className="flex items-start">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.completed}
|
||||
className="checkbox mt-1 mr-2"
|
||||
onChange={() => handleCheckboxChange(index)}
|
||||
/>
|
||||
<label className={`cursor-pointer ${item.completed ? "line-through text-gray-500" : ""}`}>
|
||||
{item.title}
|
||||
</label>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-500 text-center">No tasks</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Eisenhower() {
|
||||
export function Eisenhower() {
|
||||
const [tasks, setTasks] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
readTodoTasks()
|
||||
.then((data) => {
|
||||
console.log(data);
|
||||
const contentList_ui = data.filter((task) => task.priority === 1);
|
||||
const contentList_uni = data.filter((task) => task.priority === 2);
|
||||
const contentList_nui = data.filter((task) => task.priority === 3);
|
||||
const contentList_nuni = data.filter((task) => task.priority === 4);
|
||||
|
||||
setTasks({
|
||||
contentList_ui,
|
||||
contentList_uni,
|
||||
contentList_nui,
|
||||
contentList_nuni,
|
||||
});
|
||||
})
|
||||
.catch((error) => console.error("Error fetching tasks:", error));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className='bg-slate-100 text-left p-4 m-auto'>
|
||||
<h1 className="text-3xl font-bold mb-4">The Eisenhower Matrix</h1>
|
||||
<div className='grid grid-rows-2 grid-cols-2 gap-2'>
|
||||
<EachBlog name="Urgent & Important" colorCode="#FF5733" />
|
||||
<EachBlog name="Urgent & Not important" colorCode="#FDDD5C" />
|
||||
<EachBlog name="Not urgent & Important" colorCode="#189AB4" />
|
||||
<EachBlog name="Not urgent & Not important" colorCode="#94FA92" />
|
||||
<div className="bg-slate-100 text-left p-4 w-full h-max">
|
||||
<div className="grid grid-rows-2 grid-cols-2 gap-2">
|
||||
<EachBlog
|
||||
name="Urgent & Important"
|
||||
colorCode="#ff5f68"
|
||||
icon={<FiAlertCircle />}
|
||||
contentList={tasks.contentList_ui}
|
||||
/>
|
||||
<EachBlog
|
||||
name="Urgent & Not important"
|
||||
colorCode="#ffb000"
|
||||
icon={<FiClock />}
|
||||
contentList={tasks.contentList_uni}
|
||||
/>
|
||||
<EachBlog
|
||||
name="Not urgent & Important"
|
||||
colorCode="#4772fa"
|
||||
icon={<FiCheckCircle />}
|
||||
contentList={tasks.contentList_nui}
|
||||
/>
|
||||
<EachBlog
|
||||
name="Not urgent & Not important"
|
||||
colorCode="#0cce9c"
|
||||
icon={<FiXCircle />}
|
||||
contentList={tasks.contentList_nuni}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Eisenhower;
|
||||
|
||||
84
frontend/src/components/FlaotingParticles.jsx
Normal file
84
frontend/src/components/FlaotingParticles.jsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { useCallback } from "react";
|
||||
import Particles from "react-tsparticles";
|
||||
import { loadFull } from "tsparticles";
|
||||
|
||||
export function FloatingParticles() {
|
||||
const particlesInit = useCallback(async (engine) => {
|
||||
await loadFull(engine);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ width: "0%", height: "100vh" }}>
|
||||
<Particles
|
||||
id="particles"
|
||||
init={particlesInit}
|
||||
className="-z-10"
|
||||
options={{
|
||||
fpsLimit: 240,
|
||||
smooth: true,
|
||||
interactivity: {
|
||||
events: {
|
||||
onClick: {
|
||||
enable: true,
|
||||
mode: "push",
|
||||
},
|
||||
onHover: {
|
||||
enable: true,
|
||||
mode: "repulse",
|
||||
},
|
||||
resize: true,
|
||||
},
|
||||
modes: {
|
||||
push: {
|
||||
quantity: 4,
|
||||
},
|
||||
repulse: {
|
||||
distance: 200,
|
||||
duration: 0.4,
|
||||
},
|
||||
},
|
||||
},
|
||||
particles: {
|
||||
color: {
|
||||
value: "#4f46e5",
|
||||
},
|
||||
links: {
|
||||
color: "#818cf8",
|
||||
distance: 150,
|
||||
enable: true,
|
||||
opacity: 0.5,
|
||||
width: 1,
|
||||
},
|
||||
move: {
|
||||
direction: "none",
|
||||
enable: true,
|
||||
outModes: {
|
||||
default: "bounce",
|
||||
},
|
||||
random: false,
|
||||
speed: 2,
|
||||
straight: false,
|
||||
},
|
||||
number: {
|
||||
density: {
|
||||
enable: true,
|
||||
area: 800,
|
||||
},
|
||||
value: 40,
|
||||
},
|
||||
opacity: {
|
||||
value: 0.5,
|
||||
},
|
||||
shape: {
|
||||
type: "square",
|
||||
},
|
||||
size: {
|
||||
value: { min: 4, max: 5 },
|
||||
},
|
||||
},
|
||||
detectRetina: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
function HomePage() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Welcome to My Website</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HomePage;
|
||||
@ -1,19 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
function IsAuthenticated() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const access_token = localStorage.getItem('access_token');
|
||||
|
||||
if (access_token) {
|
||||
setIsAuthenticated(true);
|
||||
} else {
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return isAuthenticated;
|
||||
}
|
||||
|
||||
export default IsAuthenticated;
|
||||
@ -1,141 +1,153 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, redirect } from "react-router-dom";
|
||||
import { useGoogleLogin } from "@react-oauth/google";
|
||||
import { FcGoogle } from "react-icons/fc";
|
||||
import { useAuth } from "src/hooks/AuthHooks";
|
||||
import { FloatingParticles } from "../FlaotingParticles";
|
||||
import { NavPreLogin } from "../navigations/NavPreLogin";
|
||||
import { apiUserLogin, googleLogin } from "src/api/AuthenticationApi";
|
||||
|
||||
import refreshAccessToken from "./refreshAcesstoken";
|
||||
import axiosapi from "../../api/AuthenticationApi";
|
||||
|
||||
function LoginPage() {
|
||||
export function LoginPage() {
|
||||
const { setIsAuthenticated } = useAuth();
|
||||
const Navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!refreshAccessToken()) {
|
||||
Navigate("/");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const handleEmailChange = event => {
|
||||
const handleEmailChange = (event) => {
|
||||
setEmail(event.target.value);
|
||||
};
|
||||
|
||||
const handlePasswordChange = event => {
|
||||
const handlePasswordChange = (event) => {
|
||||
setPassword(event.target.value);
|
||||
};
|
||||
|
||||
const handleSubmit = event => {
|
||||
const handleSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
// Send a POST request to the authentication API
|
||||
axiosapi
|
||||
.apiUserLogin({
|
||||
email: email,
|
||||
password: password,
|
||||
})
|
||||
.then(res => {
|
||||
// On successful login, store tokens and set the authorization header
|
||||
apiUserLogin({
|
||||
email: email,
|
||||
password: password,
|
||||
})
|
||||
.then((res) => {
|
||||
localStorage.setItem("access_token", res.data.access);
|
||||
localStorage.setItem("refresh_token", res.data.refresh);
|
||||
axiosapi.axiosInstance.defaults.headers["Authorization"] = "Bearer " + res.data.access;
|
||||
Navigate("/");
|
||||
setIsAuthenticated(true);
|
||||
redirect("/");
|
||||
})
|
||||
.catch(err => {
|
||||
console.log("Login failed");
|
||||
console.log(err);
|
||||
.catch((err) => {
|
||||
setError("Incorrect username or password");
|
||||
});
|
||||
};
|
||||
|
||||
const googleLoginImplicit = useGoogleLogin({
|
||||
flow: "auth-code",
|
||||
redirect_uri: "postmessage",
|
||||
onSuccess: async response => {
|
||||
onSuccess: async (response) => {
|
||||
try {
|
||||
const loginResponse = await axiosapi.googleLogin(response.code);
|
||||
const loginResponse = await googleLogin(response.code);
|
||||
if (loginResponse && loginResponse.data) {
|
||||
const { access_token, refresh_token } = loginResponse.data;
|
||||
|
||||
localStorage.setItem("access_token", access_token);
|
||||
localStorage.setItem("refresh_token", refresh_token);
|
||||
setIsAuthenticated(true);
|
||||
Navigate("/");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error with the POST request:", error);
|
||||
}
|
||||
},
|
||||
onError: errorResponse => console.log(errorResponse),
|
||||
onError: (errorResponse) => console.log(errorResponse),
|
||||
});
|
||||
|
||||
return (
|
||||
<div data-theme="night" className="min-h-screen flex">
|
||||
{/* Left Section (Login Box) */}
|
||||
<div className="w-1/2 flex items-center justify-center">
|
||||
<div className="w-96 bg-neutral rounded-lg p-8 shadow-md space-y-4">
|
||||
<h2 className="text-2xl font-semibold text-left">Log in to your account</h2>
|
||||
{/* Email Input */}
|
||||
<div className="form-control">
|
||||
<label className="label" htmlFor="email">
|
||||
<p className="text-bold">
|
||||
Email<span className="text-red-500 text-bold">*</span>
|
||||
</p>
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
type="email"
|
||||
id="email"
|
||||
placeholder="Enter your email"
|
||||
onChange={handleEmailChange}
|
||||
/>
|
||||
</div>
|
||||
{/* Password Input */}
|
||||
<div className="form-control">
|
||||
<label className="label" htmlFor="password">
|
||||
<p className="text-bold">
|
||||
Password<span className="text-red-500 text-bold">*</span>
|
||||
</p>
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
id="password"
|
||||
placeholder="Enter your password"
|
||||
onChange={handlePasswordChange}
|
||||
/>
|
||||
</div>
|
||||
{/* Login Button */}
|
||||
<button className="btn btn-primary w-full" onClick={handleSubmit}>
|
||||
Login
|
||||
</button>
|
||||
<div className="divider">OR</div>
|
||||
{/* Login with Google Button */}
|
||||
<button className="btn btn-outline btn-secondary w-full" onClick={() => googleLoginImplicit()}>
|
||||
Login with Google
|
||||
</button>
|
||||
{/* Forgot Password Link */}
|
||||
<div className="justify-left">
|
||||
<a href="#" className="text-blue-500 text-sm text-left">
|
||||
Forgot your password?
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<NavPreLogin
|
||||
text="Don't have account?"
|
||||
btn_text="Sign Up"
|
||||
link="/signup"
|
||||
/>
|
||||
<div className="h-screen flex items-center justify-center bg-gradient-to-r from-zinc-100 via-gray-200 to-zinc-100">
|
||||
{/* Particles Container */}
|
||||
|
||||
{/* Right Section (Blurred Image Background) */}
|
||||
<div className="w-1/2 relative">
|
||||
<div
|
||||
className="w-full h-full bg-cover bg-center"
|
||||
style={{
|
||||
backgroundImage: 'url("https://th.bing.com/th/id/OIG.9byG0pWUCcbGL7Kly9tA?pid=ImgGn&w=1024&h=1024&rs=1")',
|
||||
filter: "blur(2px) brightness(.5)",
|
||||
}}></div>
|
||||
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-white text-2xl font-semibold">
|
||||
Text Overlay
|
||||
<FloatingParticles />
|
||||
{/* Login Box */}
|
||||
<div className="flex items-center justify-center flex-1 z-50">
|
||||
<div className="w-100 bg-white border-solid rounded-lg p-8 shadow space-y-4">
|
||||
<h2 className="text-3xl font-bold">Log in to your account</h2>
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div role="alert" className="alert alert-error">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="stroke-current shrink-0 h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Email Input */}
|
||||
<div className="form-control ">
|
||||
<label className="label" htmlFor="email">
|
||||
<p className="text-bold">
|
||||
Email<span className="text-red-500 text-bold">*</span>
|
||||
</p>
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
type="email"
|
||||
id="email"
|
||||
placeholder="Enter your email"
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
/>
|
||||
</div>
|
||||
{/* Password Input */}
|
||||
<div className="form-control">
|
||||
<label className="label" htmlFor="password">
|
||||
<p className="text-bold">
|
||||
Password<span className="text-red-500 text-bold">*</span>
|
||||
</p>
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
id="password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
/>
|
||||
</div>
|
||||
{/* Login Button */}
|
||||
<button
|
||||
className="btn bg-blue-700 hover:bg-blue-900 w-full text-white font-bold"
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
<div className="divider">OR</div>
|
||||
{/* Login with Google Button */}
|
||||
<button
|
||||
className="btn bg-gray-200 btn-outline w-full "
|
||||
onClick={() => googleLoginImplicit()}
|
||||
>
|
||||
<FcGoogle className="rounded-full bg-white" />
|
||||
Login with Google
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoginPage;
|
||||
|
||||
@ -1,151 +1,162 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import axiosapi from '../../api/AuthenticationApi';
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { FcGoogle } from "react-icons/fc";
|
||||
import { useGoogleLogin } from "@react-oauth/google";
|
||||
import { NavPreLogin } from "../navigations/NavPreLogin";
|
||||
import { useAuth } from "src/hooks/AuthHooks";
|
||||
import { createUser, googleLogin } from "src/api/AuthenticationApi";
|
||||
import { FloatingParticles } from "../FlaotingParticles";
|
||||
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Button from '@mui/material/Button';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import Link from '@mui/material/Link';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Box from '@mui/material/Box';
|
||||
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Container from '@mui/material/Container';
|
||||
import { createTheme, ThemeProvider } from '@mui/material/styles';
|
||||
export function SignUp() {
|
||||
const Navigate = useNavigate();
|
||||
const { setIsAuthenticated } = useAuth();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
email: "",
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
const [error, setError] = useState(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const delay = (ms) => new Promise((res) => setTimeout(res, ms));
|
||||
|
||||
try {
|
||||
const data = await createUser(formData);
|
||||
localStorage.setItem("access_token", data.access_token);
|
||||
localStorage.setItem("refresh_token", data.refresh_token);
|
||||
await delay(200);
|
||||
setIsAuthenticated(true);
|
||||
Navigate("/");
|
||||
} catch (error) {
|
||||
console.error("Error creating user:", error);
|
||||
setError("Registration failed. Please try again.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmailChange = (e) => {
|
||||
setFormData({ ...formData, email: e.target.value });
|
||||
};
|
||||
|
||||
const handleUsernameChange = (e) => {
|
||||
setFormData({ ...formData, username: e.target.value });
|
||||
};
|
||||
|
||||
const handlePasswordChange = (e) => {
|
||||
setFormData({ ...formData, password: e.target.value });
|
||||
};
|
||||
|
||||
const googleLoginImplicit = useGoogleLogin({
|
||||
flow: "auth-code",
|
||||
redirect_uri: "postmessage",
|
||||
scope:
|
||||
"https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/calendar.acls.readonly https://www.googleapis.com/auth/calendar.events.readonly",
|
||||
onSuccess: async (response) => {
|
||||
try {
|
||||
const loginResponse = await googleLogin(response.code);
|
||||
if (loginResponse && loginResponse.data) {
|
||||
const { access_token, refresh_token } = loginResponse.data;
|
||||
|
||||
localStorage.setItem("access_token", access_token);
|
||||
localStorage.setItem("refresh_token", refresh_token);
|
||||
setIsAuthenticated(true);
|
||||
Navigate("/profile");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error with the POST request:", error);
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
},
|
||||
onError: (errorResponse) => console.log(errorResponse),
|
||||
});
|
||||
|
||||
function Copyright(props) {
|
||||
return (
|
||||
<Typography variant="body2" color="text.secondary" align="center" {...props}>
|
||||
{'Copyright © '}
|
||||
<Link color="inherit" href="https://github.com/TurTaskProject/TurTaskWeb">
|
||||
TurTask
|
||||
</Link>{' '}
|
||||
{new Date().getFullYear()}
|
||||
{'.'}
|
||||
</Typography>
|
||||
<div>
|
||||
<NavPreLogin
|
||||
text="Already have an account?"
|
||||
btn_text="Log In"
|
||||
link="/login"
|
||||
/>
|
||||
<div className="h-screen flex items-center justify-center bg-gradient-to-r from-zinc-100 via-gray-200 to-zinc-100">
|
||||
<FloatingParticles />
|
||||
<div className="w-1/4 h-1 flex items-center justify-center z-10">
|
||||
<div className="w-96 bg-white rounded-lg p-8 space-y-4 z-10">
|
||||
{/* Register Form */}
|
||||
<h2 className="text-3xl font-bold text-center">Signup</h2>
|
||||
{/* Email Input */}
|
||||
<div className="form-control ">
|
||||
<label className="label" htmlFor="email">
|
||||
<p className="text-bold">
|
||||
Email<span className="text-red-500 text-bold">*</span>
|
||||
</p>
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
type="email"
|
||||
id="email"
|
||||
placeholder="Enter your email"
|
||||
onChange={handleEmailChange}
|
||||
/>
|
||||
</div>
|
||||
{/* Username Input */}
|
||||
<div className="form-control">
|
||||
<label className="label" htmlFor="Username">
|
||||
<p className="text-bold">
|
||||
Username<span className="text-red-500 text-bold">*</span>
|
||||
</p>
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
id="Username"
|
||||
placeholder="Enter your username"
|
||||
onChange={handleUsernameChange}
|
||||
/>
|
||||
</div>
|
||||
{/* Password Input */}
|
||||
<div className="form-control">
|
||||
<label className="label" htmlFor="password">
|
||||
<p className="text-bold">
|
||||
Password<span className="text-red-500 text-bold">*</span>
|
||||
</p>
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
id="password"
|
||||
placeholder="Enter your password"
|
||||
onChange={handlePasswordChange}
|
||||
/>
|
||||
</div>
|
||||
<br></br>
|
||||
|
||||
{/* Signups Button */}
|
||||
<button className="btn btn-success w-full " onClick={handleSubmit}>
|
||||
Signup
|
||||
</button>
|
||||
<div className="divider">OR</div>
|
||||
{/* Login with Google Button */}
|
||||
<button
|
||||
className="btn btn-outline btn-secondary w-full "
|
||||
onClick={() => googleLoginImplicit()}
|
||||
>
|
||||
<FcGoogle className="rounded-full bg-white" />
|
||||
Login with Google
|
||||
</button>
|
||||
{/* Already have an account? */}
|
||||
<div className="text-blue-500 flex justify-center text-sm">
|
||||
<a href="login">Already have an account?</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const defaultTheme = createTheme();
|
||||
|
||||
export default function SignUp() {
|
||||
|
||||
const Navigate = useNavigate();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
const [error, setError] = useState(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
axiosapi.createUser(formData);
|
||||
} catch (error) {
|
||||
console.error('Error creating user:', error);
|
||||
setError('Registration failed. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
Navigate('/login');
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData({ ...formData, [name]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={defaultTheme}>
|
||||
<Container component="main" maxWidth="xs">
|
||||
<CssBaseline />
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Avatar sx={{ m: 1, bgcolor: 'secondary.main' }}>
|
||||
<LockOutlinedIcon />
|
||||
</Avatar>
|
||||
<Typography component="h1" variant="h5">
|
||||
Sign up
|
||||
</Typography>
|
||||
<Box component="form" noValidate onSubmit={handleSubmit} sx={{ mt: 3 }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
required
|
||||
fullWidth
|
||||
id="email"
|
||||
label="Email Address"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
autoComplete="username"
|
||||
name="Username"
|
||||
required
|
||||
fullWidth
|
||||
id="Username"
|
||||
label="Username"
|
||||
autoFocus
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
required
|
||||
fullWidth
|
||||
name="password"
|
||||
label="Password"
|
||||
type="password"
|
||||
id="password"
|
||||
autoComplete="new-password"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<FormControlLabel
|
||||
control={<Checkbox value="allowExtraEmails" color="primary" />}
|
||||
label="I want to receive inspiration, marketing promotions and updates via email."
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
>
|
||||
Sign Up
|
||||
</Button>
|
||||
<Grid container justifyContent="flex-end">
|
||||
<Grid item>
|
||||
<Link href="#" variant="body2">
|
||||
Already have an account? Sign in
|
||||
</Link>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
<Copyright sx={{ mt: 5 }} />
|
||||
</Container>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@ -1,37 +0,0 @@
|
||||
import axios from 'axios';
|
||||
|
||||
async function refreshAccessToken() {
|
||||
const refresh_token = localStorage.getItem('refresh_token');
|
||||
const access_token = localStorage.getItem('access_token');
|
||||
|
||||
if (access_token) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!refresh_token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const refreshUrl = 'http://127.0.0.1:8000/api/token/refresh/';
|
||||
|
||||
try {
|
||||
const response = await axios.post(refreshUrl, { refresh: refresh_token });
|
||||
|
||||
if (response.status === 200) {
|
||||
// Successful refresh - save the new access token and refresh token
|
||||
const newAccessToken = response.data.access;
|
||||
const newRefreshToken = response.data.refresh;
|
||||
|
||||
localStorage.setItem('access_token', newAccessToken);
|
||||
localStorage.setItem('refresh_token', newRefreshToken);
|
||||
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default refreshAccessToken;
|
||||
@ -1,42 +1,26 @@
|
||||
import { fetchTodoTasks } from '../../api/TaskApi';
|
||||
import { readTodoTasks } from "src/api/TaskApi";
|
||||
|
||||
let eventGuid = 0
|
||||
|
||||
// function getDateAndTime(dateString) {
|
||||
// const dateObject = new Date(dateString);
|
||||
|
||||
// const year = dateObject.getFullYear();
|
||||
// const month = (dateObject.getMonth() + 1).toString().padStart(2, '0');
|
||||
// const day = dateObject.getDate().toString().padStart(2, '0');
|
||||
// const dateFormatted = `${year}-${month}-${day}`;
|
||||
|
||||
// const hours = dateObject.getUTCHours().toString().padStart(2, '0');
|
||||
// const minutes = dateObject.getUTCMinutes().toString().padStart(2, '0');
|
||||
// const seconds = dateObject.getUTCSeconds().toString().padStart(2, '0');
|
||||
// const timeFormatted = `T${hours}:${minutes}:${seconds}`;
|
||||
|
||||
// return dateFormatted + timeFormatted;
|
||||
// }
|
||||
let eventGuid = 0;
|
||||
|
||||
const mapResponseToEvents = (response) => {
|
||||
return response.map(item => ({
|
||||
id: createEventId(),
|
||||
title: item.title,
|
||||
start: item.start_event,
|
||||
end: item.end_event,
|
||||
}));
|
||||
}
|
||||
return response.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
start: item.start_event,
|
||||
end: item.end_event,
|
||||
}));
|
||||
};
|
||||
|
||||
export async function getEvents() {
|
||||
try {
|
||||
const response = await fetchTodoTasks();
|
||||
return mapResponseToEvents(response);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const response = await readTodoTasks();
|
||||
return mapResponseToEvents(response);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function createEventId() {
|
||||
return String(eventGuid++);
|
||||
}
|
||||
return String(eventGuid++);
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState } from "react";
|
||||
import { formatDate } from "@fullcalendar/core";
|
||||
import FullCalendar from "@fullcalendar/react";
|
||||
import dayGridPlugin from "@fullcalendar/daygrid";
|
||||
@ -6,7 +6,7 @@ import timeGridPlugin from "@fullcalendar/timegrid";
|
||||
import interactionPlugin from "@fullcalendar/interaction";
|
||||
import { getEvents, createEventId } from "./TaskDataHandler";
|
||||
|
||||
export default class Calendar extends React.Component {
|
||||
export class Calendar extends React.Component {
|
||||
state = {
|
||||
weekendsVisible: true,
|
||||
currentEvents: [],
|
||||
@ -43,7 +43,8 @@ export default class Calendar extends React.Component {
|
||||
|
||||
renderSidebar() {
|
||||
return (
|
||||
<div className="w-72 bg-blue-100 border-r border-blue-200 p-8 flex-shrink-0">
|
||||
<div className="w-72 bg-blue-100 border-r border-blue-200 p-8 flex flex-col">
|
||||
{/* Description Zone */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-bold">Instructions</h2>
|
||||
<ul className="list-disc pl-4">
|
||||
@ -53,19 +54,24 @@ export default class Calendar extends React.Component {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Toggle */}
|
||||
<div className="mb-8">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={this.state.weekendsVisible}
|
||||
onChange={this.handleWeekendsToggle}
|
||||
className="mr-2"
|
||||
className="mr-2 mb-4"
|
||||
/>
|
||||
Toggle weekends
|
||||
</label>
|
||||
<button className="btn btn-info" onClick={() => alert("Commit soon🥺")}>
|
||||
Load Data from Google
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{/* Show all task */}
|
||||
<div className="overflow-y-auto">
|
||||
<h2 className="text-xl font-bold">All Events ({this.state.currentEvents.length})</h2>
|
||||
<ul>{this.state.currentEvents.map(renderSidebarEvent)}</ul>
|
||||
</div>
|
||||
@ -98,7 +104,14 @@ export default class Calendar extends React.Component {
|
||||
|
||||
handleEventClick = (clickInfo) => {
|
||||
if (confirm(`Are you sure you want to delete the event '${clickInfo.event.title}'`)) {
|
||||
clickInfo.event.remove();
|
||||
axiosInstance
|
||||
.delete(`todo/${clickInfo.event.id}/`)
|
||||
.then((response) => {
|
||||
clickInfo.event.remove();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error deleting Task:", error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
36
frontend/src/components/dashboard/Areachart.jsx
Normal file
36
frontend/src/components/dashboard/Areachart.jsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { AreaChart, Title } from "@tremor/react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { axiosInstance } from "src/api/AxiosConfig";
|
||||
|
||||
export const AreaChartGraph = () => {
|
||||
const [areaChartDataArray, setAreaChartDataArray] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAreaChartData = async () => {
|
||||
try {
|
||||
const response = await axiosInstance.get("/dashboard/weekly/");
|
||||
const areaChartData = response.data;
|
||||
setAreaChartDataArray(areaChartData);
|
||||
} catch (error) {
|
||||
console.error("Error fetching area chart data:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAreaChartData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title>Number of tasks statistics vs. last week</Title>
|
||||
<AreaChart
|
||||
className="mt-6"
|
||||
data={areaChartDataArray}
|
||||
index="date"
|
||||
categories={["This Week", "Last Week"]}
|
||||
colors={["neutral", "indigo"]}
|
||||
yAxisWidth={30}
|
||||
showAnimation
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
36
frontend/src/components/dashboard/Barchart.jsx
Normal file
36
frontend/src/components/dashboard/Barchart.jsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { BarChart, Title } from "@tremor/react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { axiosInstance } from "src/api/AxiosConfig";
|
||||
|
||||
export const BarChartGraph = () => {
|
||||
const [barchartDataArray, setBarChartDataArray] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAreaChartData = async () => {
|
||||
try {
|
||||
const response = await axiosInstance.get("/dashboard/weekly/");
|
||||
const barchartDataArray = response.data;
|
||||
setBarChartDataArray(barchartDataArray);
|
||||
} catch (error) {
|
||||
console.error("Error fetching area chart data:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAreaChartData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title>Task completed statistics vs. last week</Title>
|
||||
<BarChart
|
||||
className="mt-6"
|
||||
data={barchartDataArray}
|
||||
index="date"
|
||||
categories={["This Week", "Last Week"]}
|
||||
colors={["neutral", "indigo"]}
|
||||
yAxisWidth={30}
|
||||
showAnimation
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
39
frontend/src/components/dashboard/DonutChart.jsx
Normal file
39
frontend/src/components/dashboard/DonutChart.jsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { DonutChart } from "@tremor/react";
|
||||
import { axiosInstance } from "src/api/AxiosConfig";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function DonutChartGraph() {
|
||||
const [donutData, setDonutData] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDonutData = async () => {
|
||||
try {
|
||||
const response = await axiosInstance.get("/dashboard/stats/");
|
||||
const todoCount = response.data.todo_count || 0;
|
||||
const recurrenceCount = response.data.recurrence_count || 0;
|
||||
|
||||
const donutData = [
|
||||
{ name: "Todo", count: todoCount },
|
||||
{ name: "Recurrence", count: recurrenceCount },
|
||||
];
|
||||
|
||||
setDonutData(donutData);
|
||||
} catch (error) {
|
||||
console.error("Error fetching donut data:", error);
|
||||
}
|
||||
};
|
||||
fetchDonutData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DonutChart
|
||||
className="mt-6"
|
||||
data={donutData}
|
||||
category="count"
|
||||
index="name"
|
||||
colors={["rose", "yellow", "orange"]}
|
||||
showAnimation
|
||||
radius={25}
|
||||
/>
|
||||
);
|
||||
}
|
||||
57
frontend/src/components/dashboard/KpiCard.jsx
Normal file
57
frontend/src/components/dashboard/KpiCard.jsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { BadgeDelta, Card, Flex, Metric, ProgressBar, Text } from "@tremor/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { axiosInstance } from "src/api/AxiosConfig";
|
||||
|
||||
export function KpiCard() {
|
||||
const [kpiCardData, setKpiCardData] = useState({
|
||||
completedThisWeek: 0,
|
||||
completedLastWeek: 0,
|
||||
incOrdec: undefined,
|
||||
percentage: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchKpiCardData = async () => {
|
||||
try {
|
||||
const response = await axiosInstance.get("/dashboard/stats/");
|
||||
const completedThisWeek = response.data.completed_this_week || 0;
|
||||
const completedLastWeek = response.data.completed_last_week || 0;
|
||||
const percentage = (completedThisWeek / completedLastWeek) * 100;
|
||||
let incOrdec = undefined;
|
||||
|
||||
if (completedThisWeek <= completedLastWeek) {
|
||||
incOrdec = "moderateDecrease";
|
||||
}
|
||||
if (completedThisWeek > completedLastWeek) {
|
||||
incOrdec = "moderateIncrease";
|
||||
}
|
||||
|
||||
setKpiCardData({
|
||||
completedThisWeek,
|
||||
completedLastWeek,
|
||||
incOrdec,
|
||||
percentage,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching KPI card data:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchKpiCardData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card className="max-w-lg mx-auto">
|
||||
<Flex alignItems="start">
|
||||
<div>
|
||||
<Metric>{kpiCardData.completedThisWeek}</Metric>
|
||||
</div>
|
||||
<BadgeDelta deltaType={kpiCardData.incOrdec}>{kpiCardData.percentage.toFixed(0)}%</BadgeDelta>
|
||||
</Flex>
|
||||
<Flex className="mt-4">
|
||||
<Text className="truncate">vs. {kpiCardData.completedLastWeek} (last week)</Text>
|
||||
</Flex>
|
||||
<ProgressBar value={kpiCardData.percentage} className="mt-2" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
44
frontend/src/components/dashboard/ProgressCircle.jsx
Normal file
44
frontend/src/components/dashboard/ProgressCircle.jsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { Card, Flex, ProgressCircle } from "@tremor/react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { axiosInstance } from "src/api/AxiosConfig";
|
||||
|
||||
export function ProgressCircleChart() {
|
||||
const [progressData, setProgressData] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProgressData = async () => {
|
||||
try {
|
||||
const response = await axiosInstance.get("/dashboard/stats/");
|
||||
let completedLastWeek = response.data.completed_last_week || 0;
|
||||
let assignLastWeek = response.data.tasks_assigned_last_week || 0;
|
||||
|
||||
if (completedLastWeek === undefined) {
|
||||
completedLastWeek = 0;
|
||||
}
|
||||
if (assignLastWeek === undefined) {
|
||||
assignLastWeek = 0;
|
||||
}
|
||||
|
||||
const progress = (completedLastWeek / assignLastWeek) * 100;
|
||||
|
||||
setProgressData(progress);
|
||||
} catch (error) {
|
||||
console.error("Error fetching progress data:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchProgressData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card className="max-w-lg mx-auto">
|
||||
<Flex className="flex-col items-center">
|
||||
<ProgressCircle className="mt-6" value={progressData} size={200} strokeWidth={10} radius={60} color="indigo">
|
||||
<span className="h-12 w-12 rounded-full bg-indigo-100 flex items-center justify-center text-sm text-indigo-500 font-medium">
|
||||
{progressData.toFixed(0)} %
|
||||
</span>
|
||||
</ProgressCircle>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
72
frontend/src/components/dashboard/dashboard.jsx
Normal file
72
frontend/src/components/dashboard/dashboard.jsx
Normal file
@ -0,0 +1,72 @@
|
||||
import { Card, Grid, Tab, TabGroup, TabList, TabPanel, TabPanels, Text, Title, Legend } from "@tremor/react";
|
||||
import { KpiCard } from "./KpiCard";
|
||||
import { BarChartGraph } from "./Barchart";
|
||||
import { DonutChartGraph } from "./DonutChart";
|
||||
import { AreaChartGraph } from "./Areachart";
|
||||
import { ProgressCircleChart } from "./ProgressCircle";
|
||||
import { useState } from "react";
|
||||
|
||||
export function Dashboard() {
|
||||
const [value, setValue] = useState({
|
||||
from: new Date(2021, 0, 1),
|
||||
to: new Date(2023, 0, 7),
|
||||
});
|
||||
return (
|
||||
<div className="flex flex-col p-12">
|
||||
<div>
|
||||
<Title>Dashboard</Title>
|
||||
<Text>All of your progress will be shown right here.</Text>
|
||||
<br />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TabGroup className="mt-6">
|
||||
<TabList>
|
||||
<Tab>Weekly</Tab>
|
||||
<Tab>Overview</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
{/*Weekly Tab*/}
|
||||
<TabPanel>
|
||||
<Grid numItemsMd={2} numItemsLg={3} className="gap-6 mt-6">
|
||||
<Card>
|
||||
<Title>Highlights vs. last week</Title>
|
||||
<br />
|
||||
<KpiCard />
|
||||
<br />
|
||||
<Title>Last week progress rate</Title>
|
||||
<br />
|
||||
<ProgressCircleChart />
|
||||
<Legend
|
||||
className="mt-3 mx-auto w-1/2"
|
||||
categories={["Completed Tasks", "Assigned Tasks"]}
|
||||
colors={["indigo"]}></Legend>
|
||||
</Card>
|
||||
<Card>
|
||||
<BarChartGraph />
|
||||
</Card>
|
||||
<Card>
|
||||
<AreaChartGraph />
|
||||
</Card>
|
||||
</Grid>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<div className="h-31">
|
||||
<Card className="mx-auto h-full">
|
||||
<Title>Tasks</Title>
|
||||
<DonutChartGraph />
|
||||
<br />
|
||||
<Legend
|
||||
className="mt-3 mx-auto w-1/2"
|
||||
categories={["Todo Task", "Recurrence Task"]}
|
||||
colors={["rose", "yellow"]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
function PlusIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-6 h-6"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v6m3-3H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default PlusIcon;
|
||||
@ -1,23 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
function TrashIcon() {
|
||||
return (
|
||||
React.createElement(
|
||||
"svg",
|
||||
{
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
fill: "none",
|
||||
viewBox: "0 0 24 24",
|
||||
strokeWidth: 1.5,
|
||||
className: "w-6 h-6"
|
||||
},
|
||||
React.createElement("path", {
|
||||
strokeLinecap: "round",
|
||||
strokeLinejoin: "round",
|
||||
d: "M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default TrashIcon;
|
||||
@ -1,173 +1,64 @@
|
||||
import { SortableContext, useSortable } from "@dnd-kit/sortable";
|
||||
import TrashIcon from "../icons/trashIcon";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useMemo, useState } from "react";
|
||||
import PlusIcon from "../icons/plusIcon";
|
||||
import TaskCard from "./taskCard";
|
||||
|
||||
function ColumnContainer({
|
||||
column,
|
||||
deleteColumn,
|
||||
updateColumn,
|
||||
createTask,
|
||||
tasks,
|
||||
deleteTask,
|
||||
updateTask,
|
||||
}) {
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
import { AiOutlinePlusCircle } from "react-icons/ai";
|
||||
import { useMemo } from "react";
|
||||
import { TaskCard } from "./taskCard";
|
||||
|
||||
export function ColumnContainer({ column, createTask, tasks, deleteTask, updateTask }) {
|
||||
// Memoize task IDs to prevent unnecessary recalculations
|
||||
const tasksIds = useMemo(() => {
|
||||
return tasks.map((task) => task.id);
|
||||
}, [tasks]);
|
||||
|
||||
const {
|
||||
setNodeRef,
|
||||
attributes,
|
||||
listeners,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: column.id,
|
||||
data: {
|
||||
type: "Column",
|
||||
column,
|
||||
},
|
||||
disabled: editMode,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transition,
|
||||
transform: CSS.Transform.toString(transform),
|
||||
};
|
||||
|
||||
if (isDragging) {
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="
|
||||
bg-columnBackgroundColor
|
||||
w-[350px]
|
||||
h-[500px]
|
||||
max-h-[500px]
|
||||
rounded-md
|
||||
flex
|
||||
flex-col
|
||||
"
|
||||
></div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="
|
||||
bg-columnBackgroundColor
|
||||
w-[350px]
|
||||
h-[500px]
|
||||
max-h-[500px]
|
||||
bg-[#f1f2f4]
|
||||
w-[280px]
|
||||
max-h-[400px]
|
||||
rounded-md
|
||||
flex
|
||||
flex-col
|
||||
"
|
||||
>
|
||||
">
|
||||
{/* Column title */}
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onClick={() => {
|
||||
setEditMode(true);
|
||||
}}
|
||||
className="
|
||||
bg-mainBackgroundColor
|
||||
ml-3
|
||||
text-md
|
||||
h-[60px]
|
||||
cursor-grab
|
||||
rounded-md
|
||||
rounded-b-none
|
||||
p-3
|
||||
font-bold
|
||||
border-columnBackgroundColor
|
||||
border-4
|
||||
flex
|
||||
items-center
|
||||
justify-between
|
||||
"
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<div
|
||||
className="
|
||||
flex
|
||||
justify-center
|
||||
items-center
|
||||
bg-columnBackgroundColor
|
||||
px-2
|
||||
py-1
|
||||
text-sm
|
||||
rounded-full
|
||||
"
|
||||
></div>
|
||||
{!editMode && column.title}
|
||||
{editMode && (
|
||||
<input
|
||||
className="bg-white focus:border-rose-500 border rounded outline-none px-2"
|
||||
value={column.title}
|
||||
onChange={(e) => updateColumn(column.id, e.target.value)}
|
||||
autoFocus
|
||||
onBlur={() => {
|
||||
setEditMode(false);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
setEditMode(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
deleteColumn(column.id);
|
||||
}}
|
||||
className="
|
||||
stroke-gray-500
|
||||
hover:stroke-white
|
||||
hover:bg-columnBackgroundColor
|
||||
rounded
|
||||
px-1
|
||||
py-2
|
||||
"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
">
|
||||
<div className="flex gap-2">{column.title}</div>
|
||||
</div>
|
||||
|
||||
{/* Column task container */}
|
||||
<div className="flex flex-grow flex-col gap-4 p-2 overflow-x-hidden overflow-y-auto">
|
||||
<div className="flex flex-grow flex-col gap-2 p-1 overflow-x-hidden overflow-y-auto">
|
||||
{/* Provide a SortableContext for the tasks within the column */}
|
||||
<SortableContext items={tasksIds}>
|
||||
{/* Render TaskCard for each task in the column */}
|
||||
{tasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
deleteTask={deleteTask} // Pass deleteTask to TaskCard
|
||||
deleteTask={deleteTask}
|
||||
updateTask={updateTask}
|
||||
// Adjust the useSortable hook for tasks to enable dragging
|
||||
useSortable={(props) => useSortable({ ...props, disabled: false })}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</div>
|
||||
|
||||
{/* Column footer */}
|
||||
<button
|
||||
className="flex gap-2 items-center border-columnBackgroundColor border-2 rounded-md p-4 border-x-columnBackgroundColor hover:bg-mainBackgroundColor hover:text-rose-500 active:bg-white"
|
||||
className="flex gap-2 items-center rounded-md p-2 my-2 hover:bg-zinc-200 active:bg-zinc-400"
|
||||
onClick={() => {
|
||||
createTask(column.id);
|
||||
}}
|
||||
>
|
||||
<PlusIcon />
|
||||
}}>
|
||||
<AiOutlinePlusCircle />
|
||||
Add task
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ColumnContainer;
|
||||
|
||||
@ -0,0 +1,15 @@
|
||||
import { ColumnContainer } from "./columnContainer";
|
||||
|
||||
export function ColumnContainerCard({ column, createTask, tasks, deleteTask, updateTask }) {
|
||||
return (
|
||||
<div className="card bg-[#f1f2f4] shadow border p-1 my-2">
|
||||
<ColumnContainer
|
||||
column={column}
|
||||
createTask={createTask}
|
||||
tasks={tasks}
|
||||
deleteTask={deleteTask}
|
||||
updateTask={updateTask}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,110 +1,20 @@
|
||||
import PlusIcon from "../icons/plusIcon";
|
||||
import { useMemo, useState } from "react";
|
||||
import ColumnContainer from "./columnContainer";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import { ColumnContainerCard } from "./columnContainerWrapper";
|
||||
import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
|
||||
import { SortableContext, arrayMove } from "@dnd-kit/sortable";
|
||||
import { createPortal } from "react-dom";
|
||||
import TaskCard from "./taskCard";
|
||||
import { TaskCard } from "./taskCard";
|
||||
import { axiosInstance } from "src/api/AxiosConfig";
|
||||
|
||||
const defaultCols = [
|
||||
{
|
||||
id: "todo",
|
||||
title: "Todo",
|
||||
},
|
||||
{
|
||||
id: "doing",
|
||||
title: "Work in progress",
|
||||
},
|
||||
{
|
||||
id: "done",
|
||||
title: "Done",
|
||||
},
|
||||
];
|
||||
|
||||
const defaultTasks = [
|
||||
{
|
||||
id: "1",
|
||||
columnId: "todo",
|
||||
content: "List admin APIs for dashboard",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
columnId: "todo",
|
||||
content:
|
||||
"Develop user registration functionality with OTP delivered on SMS after email confirmation and phone number confirmation",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
columnId: "doing",
|
||||
content: "Conduct security testing",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
columnId: "doing",
|
||||
content: "Analyze competitors",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
columnId: "done",
|
||||
content: "Create UI kit documentation",
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
columnId: "done",
|
||||
content: "Dev meeting",
|
||||
},
|
||||
{
|
||||
id: "7",
|
||||
columnId: "done",
|
||||
content: "Deliver dashboard prototype",
|
||||
},
|
||||
{
|
||||
id: "8",
|
||||
columnId: "todo",
|
||||
content: "Optimize application performance",
|
||||
},
|
||||
{
|
||||
id: "9",
|
||||
columnId: "todo",
|
||||
content: "Implement data validation",
|
||||
},
|
||||
{
|
||||
id: "10",
|
||||
columnId: "todo",
|
||||
content: "Design database schema",
|
||||
},
|
||||
{
|
||||
id: "11",
|
||||
columnId: "todo",
|
||||
content: "Integrate SSL web certificates into workflow",
|
||||
},
|
||||
{
|
||||
id: "12",
|
||||
columnId: "doing",
|
||||
content: "Implement error logging and monitoring",
|
||||
},
|
||||
{
|
||||
id: "13",
|
||||
columnId: "doing",
|
||||
content: "Design and implement responsive UI",
|
||||
},
|
||||
];
|
||||
|
||||
function KanbanBoard() {
|
||||
const [columns, setColumns] = useState(defaultCols);
|
||||
export function KanbanBoard() {
|
||||
const [columns, setColumns] = useState([]);
|
||||
const [boardId, setBoardData] = useState();
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [activeTask, setActiveTask] = useState(null);
|
||||
const columnsId = useMemo(() => columns.map((col) => col.id), [columns]);
|
||||
|
||||
const [tasks, setTasks] = useState(defaultTasks);
|
||||
|
||||
const [activeColumn, setActiveColumn] = useState(null);
|
||||
|
||||
const [activeTask, setActiveTask] = useState(null);
|
||||
// ---------------- END STATE INITIATE ----------------
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
@ -114,88 +24,177 @@ function KanbanBoard() {
|
||||
})
|
||||
);
|
||||
|
||||
// ---------------- Task Handlers ----------------
|
||||
const handleTaskUpdate = (tasks, updatedTask) => {
|
||||
const updatedTasks = tasks.map((task) => (task.id === updatedTask.id ? updatedTask : task));
|
||||
setTasks(updatedTasks);
|
||||
};
|
||||
|
||||
const handleApiError = (error, action) => {
|
||||
console.error(`Error ${action}:`, error);
|
||||
};
|
||||
|
||||
const createTask = async (columnId) => {
|
||||
try {
|
||||
const response = await axiosInstance.post("todo/", {
|
||||
title: `New Task`,
|
||||
importance: 1,
|
||||
difficulty: 1,
|
||||
challenge: false,
|
||||
fromSystem: false,
|
||||
is_active: false,
|
||||
is_full_day_event: false,
|
||||
completed: false,
|
||||
priority: 1,
|
||||
list_board: columnId,
|
||||
});
|
||||
const newTask = {
|
||||
id: response.data.id,
|
||||
columnId,
|
||||
content: response.data.title,
|
||||
};
|
||||
|
||||
setTasks((prevTasks) => [...prevTasks, newTask]);
|
||||
} catch (error) {
|
||||
handleApiError(error, "creating task");
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTask = async (id) => {
|
||||
try {
|
||||
await axiosInstance.delete(`todo/${id}/`);
|
||||
const newTasks = tasks.filter((task) => task.id !== id);
|
||||
setTasks(newTasks);
|
||||
} catch (error) {
|
||||
handleApiError(error, "deleting task");
|
||||
}
|
||||
};
|
||||
|
||||
const updateTask = async (id, content, tasks) => {
|
||||
try {
|
||||
if (content === "") {
|
||||
await deleteTask(id);
|
||||
} else {
|
||||
const response = await axiosInstance.put(`todo/${id}/`, { content });
|
||||
|
||||
const updatedTask = {
|
||||
id,
|
||||
columnId: response.data.list_board,
|
||||
content: response.data.title,
|
||||
};
|
||||
|
||||
handleTaskUpdate(tasks, updatedTask);
|
||||
}
|
||||
} catch (error) {
|
||||
handleApiError(error, "updating task");
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------- END Task Handlers ----------------
|
||||
|
||||
// ---------------- Fetch Data ----------------
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const tasksResponse = await axiosInstance.get("/todo");
|
||||
|
||||
// Transform
|
||||
const transformedTasks = tasksResponse.data.map((task) => ({
|
||||
id: task.id,
|
||||
columnId: task.list_board,
|
||||
content: task.title,
|
||||
difficulty: task.difficulty,
|
||||
notes: task.notes,
|
||||
importance: task.importance,
|
||||
challenge: task.challenge,
|
||||
fromSystem: task.fromSystem,
|
||||
creation_date: task.creation_date,
|
||||
last_update: task.last_update,
|
||||
is_active: task.is_active,
|
||||
is_full_day_event: task.is_full_day_event,
|
||||
start_event: task.start_event,
|
||||
end_event: task.end_event,
|
||||
google_calendar_id: task.google_calendar_id,
|
||||
completed: task.completed,
|
||||
completion_date: task.completion_date,
|
||||
priority: task.priority,
|
||||
user: task.user,
|
||||
list_board: task.list_board,
|
||||
tags: task.tags,
|
||||
}));
|
||||
setTasks(transformedTasks);
|
||||
|
||||
const columnsResponse = await axiosInstance.get("/lists");
|
||||
|
||||
// Transform
|
||||
const transformedColumns = columnsResponse.data.map((column) => ({
|
||||
id: column.id,
|
||||
title: column.name,
|
||||
}));
|
||||
setColumns(transformedColumns);
|
||||
} catch (error) {
|
||||
console.error("Error fetching data from API:", error);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchBoardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axiosInstance.get("boards/");
|
||||
if (response.data && response.data.length > 0) {
|
||||
setBoardData(response.data[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching board data:", error);
|
||||
setLoading(false);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
fetchBoardData();
|
||||
}, []);
|
||||
|
||||
// ---------------- END Fetch Data ----------------
|
||||
|
||||
return (
|
||||
<div
|
||||
className="
|
||||
m-auto
|
||||
flex
|
||||
w-full
|
||||
items-center
|
||||
overflow-x-auto
|
||||
overflow-y-hidden
|
||||
"
|
||||
>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={onDragOver}
|
||||
>
|
||||
<div className="m-auto flex gap-4">
|
||||
<div className="flex gap-4">
|
||||
<SortableContext items={columnsId}>
|
||||
{columns.map((col) => (
|
||||
<ColumnContainer
|
||||
key={col.id}
|
||||
column={col}
|
||||
deleteColumn={deleteColumn}
|
||||
updateColumn={updateColumn}
|
||||
createTask={createTask}
|
||||
deleteTask={deleteTask}
|
||||
updateTask={updateTask}
|
||||
tasks={tasks.filter((task) => task.columnId === col.id)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</div>
|
||||
{/* create new column */}
|
||||
<button
|
||||
onClick={() => {
|
||||
createNewColumn();
|
||||
}}
|
||||
className="
|
||||
h-[60px]
|
||||
w-[350px]
|
||||
min-w-[350px]
|
||||
cursor-pointer
|
||||
rounded-lg
|
||||
bg-mainBackgroundColor
|
||||
border-2
|
||||
border-columnBackgroundColor
|
||||
p-4
|
||||
ring-rose-500
|
||||
hover:ring-2
|
||||
m-auto
|
||||
flex
|
||||
gap-2
|
||||
"
|
||||
>
|
||||
<PlusIcon />
|
||||
Add Column
|
||||
</button>
|
||||
w-full
|
||||
items-center
|
||||
justify-center
|
||||
overflow-x-auto
|
||||
overflow-y-hidden
|
||||
">
|
||||
<DndContext sensors={sensors} onDragStart={onDragStart} onDragEnd={onDragEnd} onDragOver={onDragOver}>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex gap-4">
|
||||
{!isLoading ? (
|
||||
<SortableContext items={columnsId}>
|
||||
{columns.map((col) => (
|
||||
<ColumnContainerCard
|
||||
key={col.id}
|
||||
column={col}
|
||||
createTask={createTask}
|
||||
deleteTask={deleteTask}
|
||||
updateTask={updateTask}
|
||||
tasks={(tasks || []).filter((task) => task.columnId === col.id)}
|
||||
/>
|
||||
))}{" "}
|
||||
</SortableContext>
|
||||
) : (
|
||||
<span className="loading loading-dots loading-lg"></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{createPortal(
|
||||
<DragOverlay>
|
||||
{activeColumn && (
|
||||
<ColumnContainer
|
||||
column={activeColumn}
|
||||
deleteColumn={deleteColumn}
|
||||
updateColumn={updateColumn}
|
||||
createTask={createTask}
|
||||
deleteTask={deleteTask}
|
||||
updateTask={updateTask}
|
||||
tasks={tasks.filter(
|
||||
(task) => task.columnId === activeColumn.id
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{activeTask && (
|
||||
<TaskCard
|
||||
task={activeTask}
|
||||
deleteTask={deleteTask}
|
||||
updateTask={updateTask}
|
||||
/>
|
||||
)}
|
||||
<DragOverlay className="bg-white" dropAnimation={null} zIndex={20}>
|
||||
{/* Render the active task as a draggable overlay */}
|
||||
<TaskCard task={activeTask} deleteTask={deleteTask} updateTask={updateTask} />
|
||||
</DragOverlay>,
|
||||
document.body
|
||||
)}
|
||||
@ -203,135 +202,122 @@ function KanbanBoard() {
|
||||
</div>
|
||||
);
|
||||
|
||||
function createTask(columnId) {
|
||||
const newTask = {
|
||||
id: generateId(),
|
||||
columnId,
|
||||
content: `Task ${tasks.length + 1}`,
|
||||
};
|
||||
|
||||
setTasks([...tasks, newTask]);
|
||||
}
|
||||
|
||||
function deleteTask(id) {
|
||||
const newTasks = tasks.filter((task) => task.id !== id);
|
||||
setTasks(newTasks);
|
||||
}
|
||||
|
||||
function updateTask(id, content) {
|
||||
const newTasks = tasks.map((task) => {
|
||||
if (task.id !== id) return task;
|
||||
return { ...task, content };
|
||||
});
|
||||
if (content === "") return deleteTask(id);
|
||||
setTasks(newTasks);
|
||||
}
|
||||
|
||||
function createNewColumn() {
|
||||
const columnToAdd = {
|
||||
id: generateId(),
|
||||
title: `Column ${columns.length + 1}`,
|
||||
};
|
||||
|
||||
setColumns([...columns, columnToAdd]);
|
||||
}
|
||||
|
||||
function deleteColumn(id) {
|
||||
const filteredColumns = columns.filter((col) => col.id !== id);
|
||||
setColumns(filteredColumns);
|
||||
|
||||
const newTasks = tasks.filter((t) => t.columnId !== id);
|
||||
setTasks(newTasks);
|
||||
}
|
||||
|
||||
function updateColumn(id, title) {
|
||||
const newColumns = columns.map((col) => {
|
||||
if (col.id !== id) return col;
|
||||
return { ...col, title };
|
||||
});
|
||||
|
||||
setColumns(newColumns);
|
||||
}
|
||||
|
||||
// Handle the start of a drag event
|
||||
function onDragStart(event) {
|
||||
if (event.active.data.current?.type === "Column") {
|
||||
setActiveColumn(event.active.data.current.column);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the dragged item is a Task
|
||||
if (event.active.data.current?.type === "Task") {
|
||||
setActiveTask(event.active.data.current.task);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle the end of a drag event
|
||||
function onDragEnd(event) {
|
||||
setActiveColumn(null);
|
||||
// Reset active column and task after the drag ends
|
||||
setActiveTask(null);
|
||||
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
if (!over) return; // If not dropped over anything, exit
|
||||
|
||||
const activeId = active.id;
|
||||
const overId = over.id;
|
||||
|
||||
if (activeId === overId) return;
|
||||
|
||||
const isActiveAColumn = active.data.current?.type === "Column";
|
||||
if (!isActiveAColumn) return;
|
||||
|
||||
setColumns((columns) => {
|
||||
const activeColumnIndex = columns.findIndex((col) => col.id === activeId);
|
||||
|
||||
const overColumnIndex = columns.findIndex((col) => col.id === overId);
|
||||
|
||||
return arrayMove(columns, activeColumnIndex, overColumnIndex);
|
||||
});
|
||||
}
|
||||
|
||||
function onDragOver(event) {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const activeId = active.id;
|
||||
const overId = over.id;
|
||||
|
||||
if (activeId === overId) return;
|
||||
|
||||
const isActiveATask = active.data.current?.type === "Task";
|
||||
const isOverATask = over.data.current?.type === "Task";
|
||||
|
||||
if (!isActiveATask) return;
|
||||
|
||||
if (isActiveATask && isOverATask) {
|
||||
setTasks((tasks) => {
|
||||
const activeIndex = tasks.findIndex((t) => t.id === activeId);
|
||||
const overIndex = tasks.findIndex((t) => t.id === overId);
|
||||
|
||||
if (tasks[activeIndex].columnId !== tasks[overIndex].columnId) {
|
||||
tasks[activeIndex].columnId = tasks[overIndex].columnId;
|
||||
return arrayMove(tasks, activeIndex, overIndex - 1);
|
||||
}
|
||||
|
||||
return arrayMove(tasks, activeIndex, overIndex);
|
||||
});
|
||||
}
|
||||
|
||||
const isOverAColumn = over.data.current?.type === "Column";
|
||||
|
||||
// Move tasks between columns and update columnId
|
||||
if (isActiveATask && isOverAColumn) {
|
||||
setTasks((tasks) => {
|
||||
const activeIndex = tasks.findIndex((t) => t.id === activeId);
|
||||
|
||||
tasks[activeIndex].columnId = overId;
|
||||
// Extract the column ID from overId
|
||||
const columnId = extractColumnId(overId);
|
||||
|
||||
tasks[activeIndex].columnId = columnId;
|
||||
|
||||
// API call to update task's columnId
|
||||
axiosInstance
|
||||
.put(`todo/change_task_list_board/`, {
|
||||
todo_id: activeId,
|
||||
new_list_board_id: over.data.current.task.columnId,
|
||||
new_index: 0,
|
||||
})
|
||||
.then((response) => {})
|
||||
.catch((error) => {
|
||||
console.error("Error updating task columnId:", error);
|
||||
});
|
||||
|
||||
return arrayMove(tasks, activeIndex, activeIndex);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function generateId() {
|
||||
return Math.floor(Math.random() * 10001);
|
||||
// Helper function to extract the column ID from the element ID
|
||||
function extractColumnId(elementId) {
|
||||
// Implement logic to extract the column ID from elementId
|
||||
// For example, if elementId is in the format "column-123", you can do:
|
||||
const parts = elementId.split("-");
|
||||
return parts.length === 2 ? parseInt(parts[1], 10) : null;
|
||||
}
|
||||
|
||||
// Handle the drag-over event
|
||||
function onDragOver(event) {
|
||||
const { active, over } = event;
|
||||
if (!over) return; // If not over anything, exit
|
||||
|
||||
const activeId = active.id;
|
||||
const overId = over.id;
|
||||
|
||||
if (activeId === overId) return; // If over the same element, exit
|
||||
|
||||
const isActiveATask = active.data.current?.type === "Task";
|
||||
const isOverATask = over.data.current?.type === "Task";
|
||||
|
||||
if (!isActiveATask) return; // If not dragging a Task, exit
|
||||
|
||||
// Reorder logic for Tasks within the same column
|
||||
if (isActiveATask && isOverATask) {
|
||||
setTasks((tasks) => {
|
||||
const activeIndex = tasks.findIndex((t) => t.id === activeId);
|
||||
const overIndex = tasks.findIndex((t) => t.id === overId);
|
||||
|
||||
// If moving to a different column, update columnId
|
||||
if (tasks[activeIndex].columnId !== tasks[overIndex].columnId) {
|
||||
tasks[activeIndex].columnId = tasks[overIndex].columnId;
|
||||
return arrayMove(tasks, activeIndex, overIndex - 1);
|
||||
}
|
||||
axiosInstance
|
||||
.put(`todo/change_task_list_board/`, {
|
||||
todo_id: activeId,
|
||||
new_list_board_id: over.data.current.task.columnId,
|
||||
new_index: 0,
|
||||
})
|
||||
.then((response) => {})
|
||||
.catch((error) => {
|
||||
console.error("Error updating task columnId:", error);
|
||||
});
|
||||
return arrayMove(tasks, activeIndex, overIndex);
|
||||
});
|
||||
}
|
||||
|
||||
const isOverAColumn = over.data.current?.type === "Column";
|
||||
// Move the Task to a different column and update columnId
|
||||
if (isActiveATask && isOverAColumn && tasks.some((task) => task.columnId !== overId)) {
|
||||
setTasks((tasks) => {
|
||||
const activeIndex = tasks.findIndex((t) => t.id === activeId);
|
||||
axiosInstance
|
||||
.put(`todo/change_task_list_board/`, {
|
||||
todo_id: activeId,
|
||||
new_list_board_id: over.data.current.task.columnId,
|
||||
new_index: 0,
|
||||
})
|
||||
.then((response) => {})
|
||||
.catch((error) => {
|
||||
console.error("Error updating task columnId:", error);
|
||||
});
|
||||
tasks[activeIndex].columnId = overId;
|
||||
return arrayMove(tasks, activeIndex, activeIndex);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default KanbanBoard;
|
||||
|
||||
34
frontend/src/components/kanbanBoard/kanbanPage.jsx
Normal file
34
frontend/src/components/kanbanBoard/kanbanPage.jsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { KanbanBoard } from "./kanbanBoard";
|
||||
import { useState } from "react";
|
||||
|
||||
export const KanbanPage = () => {
|
||||
const [activeTab, setActiveTab] = useState("kanban");
|
||||
|
||||
const handleTabClick = (tabId) => {
|
||||
setActiveTab(tabId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex justify-center border-2 py-3 mb-1">
|
||||
<div>
|
||||
<div className="tabs tabs-boxed">
|
||||
<a
|
||||
id="kanban"
|
||||
className={`tab ${activeTab === "kanban" ? "tab-active" : ""}`}
|
||||
onClick={() => handleTabClick("kanban")}>
|
||||
Kanban
|
||||
</a>
|
||||
{/* <a
|
||||
id="table"
|
||||
className={`tab ${activeTab === "table" ? "tab-active" : ""}`}
|
||||
onClick={() => handleTabClick("table")}>
|
||||
Table
|
||||
</a> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<KanbanBoard />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,26 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import TrashIcon from "../icons/trashIcon";
|
||||
import { BsFillTrashFill } from "react-icons/bs";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { TaskDetailModal } from "./taskDetailModal";
|
||||
|
||||
function TaskCard({ task, deleteTask, updateTask }) {
|
||||
export function TaskCard({ task, deleteTask, updateTask }) {
|
||||
const [mouseIsOver, setMouseIsOver] = useState(false);
|
||||
const [editMode, setEditMode] = useState(true);
|
||||
|
||||
const {
|
||||
setNodeRef,
|
||||
attributes,
|
||||
listeners,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
const { setNodeRef, attributes, listeners, transform, transition, isDragging } = useSortable({
|
||||
id: task.id,
|
||||
data: {
|
||||
type: "Task",
|
||||
task,
|
||||
},
|
||||
disabled: editMode,
|
||||
});
|
||||
|
||||
const style = {
|
||||
@ -28,11 +20,9 @@ function TaskCard({ task, deleteTask, updateTask }) {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
};
|
||||
|
||||
const toggleEditMode = () => {
|
||||
setEditMode((prev) => !prev);
|
||||
setMouseIsOver(false);
|
||||
};
|
||||
|
||||
{
|
||||
/* If card is dragged */
|
||||
}
|
||||
if (isDragging) {
|
||||
return (
|
||||
<div
|
||||
@ -40,72 +30,51 @@ function TaskCard({ task, deleteTask, updateTask }) {
|
||||
style={style}
|
||||
className="
|
||||
opacity-30
|
||||
bg-mainBackgroundColor p-2.5 h-[100px] min-h-[100px] items-center flex text-left rounded-xl border-2 border-rose-500 cursor-grab relative
|
||||
bg-mainBackgroundColor p-2.5 items-center flex text-left rounded-xl border-2 border-gray-400 cursor-grab relative
|
||||
"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (editMode) {
|
||||
return (
|
||||
return (
|
||||
<div>
|
||||
<TaskDetailModal
|
||||
taskId={task.id}
|
||||
title={task.content}
|
||||
description={task.description}
|
||||
tags={task.tags}
|
||||
difficulty={task.difficulty}
|
||||
f challenge={task.challenge}
|
||||
importance={task.importance}
|
||||
/>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="outline-double outline-1 outline-offset-2 hover:outline-double hover:outline-1 hover:outline-offset-2 bg-mainBackgroundColor p-2.5 h-[100px] min-h-[100px] items-center flex text-left rounded-xl hover:ring-2 hover:ring-inset hover:ring-rose-500 cursor-grab relative"
|
||||
>
|
||||
<textarea
|
||||
className="
|
||||
h-[90%]
|
||||
w-full resize-none border-none rounded bg-transparent text-black focus:outline-none
|
||||
"
|
||||
value={task.content}
|
||||
autoFocus
|
||||
placeholder="Task content here"
|
||||
onBlur={toggleEditMode}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && e.shiftKey) {
|
||||
toggleEditMode();
|
||||
}
|
||||
}}
|
||||
onChange={(e) => updateTask(task.id, e.target.value)}
|
||||
/>
|
||||
style={style}
|
||||
className="justify-center items-center flex text-left rounded-xl cursor-grab relative hover:border-2 hover:border-blue-400 shadow bg-white"
|
||||
onMouseEnter={() => {
|
||||
setMouseIsOver(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setMouseIsOver(false);
|
||||
}}>
|
||||
<p
|
||||
className="p-2.5 my-auto w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap rounded-xl shadow bg-white"
|
||||
onClick={() => document.getElementById(`task_detail_modal_${task.id}`).showModal()}>
|
||||
{task.content}
|
||||
</p>
|
||||
|
||||
{mouseIsOver && (
|
||||
<button
|
||||
onClick={() => {
|
||||
deleteTask(task.id);
|
||||
}}
|
||||
className="stroke-white absolute right-0 top-1/2 rounded-full bg-white -translate-y-1/2 bg-columnBackgroundColor p-2 hover:opacity-100 ">
|
||||
<BsFillTrashFill />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onClick={toggleEditMode}
|
||||
className="outline-double outline-1 outline-offset-2 hover:outline-double hover:outline-1 hover:outline-offset-2 sbg-mainBackgroundColor p-2.5 h-[100px] min-h-[100px] items-center flex text-left rounded-xl hover:ring-2 hover:ring-inset hover:ring-rose-500 cursor-grab relative task"
|
||||
onMouseEnter={() => {
|
||||
setMouseIsOver(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setMouseIsOver(false);
|
||||
}}
|
||||
>
|
||||
<p className="my-auto h-[90%] w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap">
|
||||
{task.content}
|
||||
</p>
|
||||
|
||||
{mouseIsOver && (
|
||||
<button
|
||||
onClick={() => {
|
||||
deleteTask(task.id);
|
||||
}}
|
||||
className="stroke-white absolute right-4 top-1/2 -translate-y-1/2 bg-columnBackgroundColor p-2 rounded opacity-60 hover:opacity-100"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TaskCard;
|
||||
|
||||
147
frontend/src/components/kanbanBoard/taskDetailModal.jsx
Normal file
147
frontend/src/components/kanbanBoard/taskDetailModal.jsx
Normal file
@ -0,0 +1,147 @@
|
||||
import { useState } from "react";
|
||||
import { FaTasks, FaRegListAlt } from "react-icons/fa";
|
||||
import { FaPlus } from "react-icons/fa6";
|
||||
import { TbChecklist } from "react-icons/tb";
|
||||
|
||||
export function TaskDetailModal({ title, description, tags, difficulty, challenge, importance, taskId }) {
|
||||
const [isChallengeChecked, setChallengeChecked] = useState(challenge);
|
||||
const [isImportantChecked, setImportantChecked] = useState(importance);
|
||||
const [currentDifficulty, setCurrentDifficulty] = useState(difficulty);
|
||||
|
||||
const handleChallengeChange = () => {
|
||||
setChallengeChecked(!isChallengeChecked);
|
||||
};
|
||||
|
||||
const handleImportantChange = () => {
|
||||
setImportantChecked(!isImportantChecked);
|
||||
};
|
||||
|
||||
const handleDifficultyChange = (event) => {
|
||||
setCurrentDifficulty(parseInt(event.target.value, 10));
|
||||
};
|
||||
|
||||
return (
|
||||
<dialog id={`task_detail_modal_${taskId}`} className="modal">
|
||||
<div className="modal-box w-4/5 max-w-3xl">
|
||||
{/* Title */}
|
||||
<div className="flex flex-col py-2">
|
||||
<div className="flex flex-col">
|
||||
<h3 className="font-bold text-lg">
|
||||
<span className="flex gap-2">
|
||||
{<FaTasks className="my-2" />}
|
||||
{title}
|
||||
</span>
|
||||
</h3>
|
||||
<p className="text-xs">{title}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex flex-col py-2 pb-4">
|
||||
<div className="flex flex-row space-x-5">
|
||||
<div className="dropdown">
|
||||
<label tabIndex={0} className="btn-md border-2 rounded-xl m-1 py-1">
|
||||
+ Add Tags
|
||||
</label>
|
||||
<ul tabIndex={0} className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
|
||||
<li>
|
||||
<a>
|
||||
<input type="checkbox" checked="checked" className="checkbox checkbox-sm" />
|
||||
Item 2
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-nowrap overflow-x-auto"></div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="font-bold">
|
||||
<span className="flex gap-2">
|
||||
<FaRegListAlt className="my-1" />
|
||||
Description
|
||||
</span>
|
||||
</h2>
|
||||
<textarea className="textarea w-full" disabled>
|
||||
{description}
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
{/* Difficulty, Challenge, and Importance */}
|
||||
<div className="flex flex-row space-x-3 my-4">
|
||||
<div className="flex-1 card shadow border-2 p-2">
|
||||
<input
|
||||
type="range"
|
||||
id="difficultySelector"
|
||||
min={0}
|
||||
max="100"
|
||||
value={currentDifficulty}
|
||||
className="range"
|
||||
step="25"
|
||||
onChange={handleDifficultyChange}
|
||||
/>
|
||||
<div className="w-full flex justify-between text-xs px-2 space-x-2">
|
||||
<span>Easy</span>
|
||||
<span>Normal</span>
|
||||
<span>Hard</span>
|
||||
<span>Very Hard</span>
|
||||
<span>Devil</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Challenge Checkbox */}
|
||||
<div className="card shadow border-2 p-2">
|
||||
<div className="form-control">
|
||||
<label className="label cursor-pointer space-x-2">
|
||||
<span className="label-text">Challenge</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChallengeChecked}
|
||||
className="checkbox"
|
||||
onChange={handleChallengeChange}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Important Checkbox */}
|
||||
<div className="card shadow border-2 p-2">
|
||||
<div className="form-control">
|
||||
<label className="label cursor-pointer space-x-2">
|
||||
<span className="label-text">Important</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isImportantChecked}
|
||||
className="checkbox"
|
||||
onChange={handleImportantChange}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtask */}
|
||||
<div className="flex flex-col pt-2">
|
||||
<h2 className="font-bold">
|
||||
<span className="flex gap-1">
|
||||
<TbChecklist className="my-1" />
|
||||
Subtasks
|
||||
</span>
|
||||
</h2>
|
||||
<div className="flex space-x-3 pt-2">
|
||||
<input type="text" placeholder="subtask topic" className="input input-bordered flex-1 w-full" />
|
||||
<button className="btn">
|
||||
<FaPlus />
|
||||
Add Subtask
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="dialog">
|
||||
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">X</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
44
frontend/src/components/landingPage/LandingPage.jsx
Normal file
44
frontend/src/components/landingPage/LandingPage.jsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { FloatingParticles } from "../FlaotingParticles";
|
||||
|
||||
export function LandingPage() {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-gradient-to-r from-zinc-100 via-gray-200 to-zinc-100">
|
||||
{/* Particles Container */}
|
||||
<FloatingParticles />
|
||||
{/* Navbar */}
|
||||
<div className="relative" id="home">
|
||||
<div className="max-w-7xl mx-auto px-6 md:px-12 xl:px-6">
|
||||
<div className="relative pt-36 ml-auto">
|
||||
<div className="lg:w-2/3 text-center mx-auto">
|
||||
<h1 className="text-#143D6C font-bold text-5xl md:text-6xl xl:text-7xl">
|
||||
Manage your task with{" "}
|
||||
<span className="text-primary">
|
||||
TurTask
|
||||
<label className="swap swap-flip text-6xl">
|
||||
<input type="checkbox" />
|
||||
<div className="swap-on">😇</div>
|
||||
<div className="swap-off">🥳</div>
|
||||
</label>
|
||||
</span>
|
||||
</h1>
|
||||
<p className="mt-8 text-#143D6C">
|
||||
Unleash productivity with our personal task and project
|
||||
management.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap justify-center gap-y-4 gap-x-6">
|
||||
<a
|
||||
href="/login"
|
||||
className="relative flex h-11 w-full items-center justify-center px-6 before:absolute before:inset-0 before:rounded-full before:bg-primary before:transition before:duration-300 hover:before:scale-105 active:duration-75 active:before:scale-95 sm:w-max"
|
||||
>
|
||||
<span className="relative text-base font-semibold text-white">
|
||||
Get started
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,36 +1,24 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
AiOutlineHome,
|
||||
AiOutlineSchedule,
|
||||
AiOutlineUnorderedList,
|
||||
AiOutlinePieChart,
|
||||
AiOutlinePlus,
|
||||
} from "react-icons/ai";
|
||||
import { AiOutlineHome, AiOutlineSchedule, AiOutlineUnorderedList } from "react-icons/ai";
|
||||
import { PiStepsDuotone } from "react-icons/pi";
|
||||
import { IoSettingsOutline } from "react-icons/io5";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
const menuItems = [
|
||||
{ id: 0, path: "/", icon: <AiOutlineHome /> },
|
||||
{ id: 1, path: "/tasks", icon: <AiOutlineUnorderedList /> },
|
||||
{ id: 2, path: "/calendar", icon: <AiOutlineSchedule /> },
|
||||
{ id: 3, path: "/analytic", icon: <AiOutlinePieChart /> },
|
||||
{ id: 4, path: "/priority", icon: <AiOutlinePlus /> },
|
||||
{ id: 3, path: "/priority", icon: <PiStepsDuotone /> },
|
||||
];
|
||||
// { id: 3, path: "/settings", icon: <IoSettingsOutline /> },
|
||||
|
||||
const IconSideNav = () => {
|
||||
return (
|
||||
<div className="bg-slate-900 text-slate-100 flex">
|
||||
<SideNav />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SideNav = () => {
|
||||
export const SideNav = () => {
|
||||
const [selected, setSelected] = useState(0);
|
||||
|
||||
return (
|
||||
<nav className="bg-slate-950 p-4 flex flex-col items-center gap-2 h-screen">
|
||||
{menuItems.map(item => (
|
||||
<nav className="bg-slate-950 p-4 flex flex-col items-center gap-2 h-full fixed top-0 left-0 z-50">
|
||||
{menuItems.map((item) => (
|
||||
<NavItem
|
||||
key={item.id}
|
||||
icon={item.icon}
|
||||
@ -44,12 +32,12 @@ const SideNav = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const NavItem = ({ icon, selected, id, setSelected, logo, path }) => {
|
||||
const NavItem = ({ icon, selected, id, setSelected, path }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
className="p-3 text-xl bg-slate-800 hover-bg-slate-700 rounded-md transition-colors relative"
|
||||
className="p-3 text-xl text-white bg-slate-800 hover-bg-slate-700 rounded-md transition-colors relative"
|
||||
onClick={() => {
|
||||
setSelected(id);
|
||||
navigate(path);
|
||||
@ -69,5 +57,3 @@ const NavItem = ({ icon, selected, id, setSelected, logo, path }) => {
|
||||
</motion.button>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconSideNav;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user