diff --git a/backend/tasks/api.py b/backend/tasks/api.py index 5352f9a..7669d76 100644 --- a/backend/tasks/api.py +++ b/backend/tasks/api.py @@ -31,6 +31,10 @@ class GoogleCalendarEventViewset(viewsets.ViewSet): 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) diff --git a/backend/tasks/tasks/views.py b/backend/tasks/tasks/views.py index 4077315..d884bbe 100644 --- a/backend/tasks/tasks/views.py +++ b/backend/tasks/tasks/views.py @@ -1,37 +1,16 @@ -from rest_framework import status -from rest_framework.response import Response -from rest_framework.generics import CreateAPIView, RetrieveAPIView, RetrieveUpdateAPIView, DestroyAPIView +from rest_framework import viewsets from rest_framework.permissions import IsAuthenticated -from ..models import Todo +from tasks.models import Todo from .serializers import TaskCreateSerializer, TaskGeneralSerializer -class TaskCreateView(CreateAPIView): - queryset = Todo.objects.all() - serializer_class = TaskCreateSerializer - permission_classes = [IsAuthenticated] - def create(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) - - if serializer.is_valid(): - self.perform_create(serializer) - return Response(serializer.data, status=status.HTTP_201_CREATED) - - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - -class TaskRetrieveView(RetrieveAPIView): +class TodoViewSet(viewsets.ModelViewSet): queryset = Todo.objects.all() serializer_class = TaskGeneralSerializer permission_classes = [IsAuthenticated] - -class TaskUpdateView(RetrieveUpdateAPIView): - queryset = Todo.objects.all() - serializer_class = TaskGeneralSerializer - permission_classes = [IsAuthenticated] - - -class TaskDeleteView(DestroyAPIView): - queryset = Todo.objects.all() - permission_classes = [IsAuthenticated] \ No newline at end of file + def get_serializer_class(self): + # Can't add ManytoMany at creation time (Tags) + if self.action == 'create': + return TaskCreateSerializer + return TaskGeneralSerializer \ No newline at end of file diff --git a/backend/tasks/tests/test_todo_creation.py b/backend/tasks/tests/test_todo_creation.py index 0852fbe..9912126 100644 --- a/backend/tasks/tests/test_todo_creation.py +++ b/backend/tasks/tests/test_todo_creation.py @@ -1,21 +1,18 @@ from datetime import datetime, timedelta - from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase - from tasks.tests.utils import create_test_user, login_user -from ..models import Todo +from tasks.models import Todo -class TodoCreateViewTests(APITestCase): + +class TodoViewSetTests(APITestCase): def setUp(self): - self.user = create_test_user() self.client = login_user(self.user) - self.url = reverse("add-task") + 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. @@ -28,7 +25,7 @@ class TodoCreateViewTests(APITestCase): 'priority': 1, 'difficulty': 1, 'user': self.user.id, - 'end_event': self.due_date, + '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) @@ -42,7 +39,6 @@ class TodoCreateViewTests(APITestCase): 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 @@ -55,7 +51,6 @@ class TodoCreateViewTests(APITestCase): '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 @@ -71,9 +66,8 @@ class TodoCreateViewTests(APITestCase): 'priority': 1, 'difficulty': 1, 'user': 999, # Invalid user ID - 'end_event': self.due_date, + 'end_event': self.due_date.strftime('%Y-%m-%dT%H:%M:%S'), } - response = self.client.post(self.url, data, format='json') self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(Todo.objects.count(), 0) # No task should be created diff --git a/backend/tasks/urls.py b/backend/tasks/urls.py index c04bd68..b44ddd9 100644 --- a/backend/tasks/urls.py +++ b/backend/tasks/urls.py @@ -1,17 +1,17 @@ from django.urls import path, include + from rest_framework.routers import DefaultRouter -from .api import GoogleCalendarEventViewset -from .tasks.views import TaskCreateView, TaskRetrieveView, TaskUpdateView, TaskDeleteView -from .misc.views import TagViewSet + +from tasks.api import GoogleCalendarEventViewset +from tasks.tasks.views import TodoViewSet +from tasks.misc.views import TagViewSet + router = DefaultRouter() +router.register(r'todo', TodoViewSet) router.register(r'tags', TagViewSet) router.register(r'calendar-events', GoogleCalendarEventViewset, basename='calendar-events') urlpatterns = [ path('', include(router.urls)), - path('tasks/create/', TaskCreateView.as_view(), name="add-task"), - path('tasks//', TaskRetrieveView.as_view(), name='retrieve-task'), - path('tasks//update/', TaskUpdateView.as_view(), name='update-task'), - path('tasks//delete/', TaskDeleteView.as_view(), name='delete-task'), ] \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 793cfe0..a7f5f04 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,11 @@ "dependencies": { "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.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", "@mui/icons-material": "^5.14.15", "@mui/material": "^5.14.15", "@mui/system": "^5.14.15", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 67325cd..a23ba2c 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -11,6 +11,21 @@ dependencies: '@emotion/styled': specifier: ^11.11.0 version: 11.11.0(@emotion/react@11.11.1)(@types/react@18.2.33)(react@18.2.0) + '@fullcalendar/core': + specifier: ^6.1.9 + version: 6.1.9 + '@fullcalendar/daygrid': + specifier: ^6.1.9 + version: 6.1.9(@fullcalendar/core@6.1.9) + '@fullcalendar/interaction': + specifier: ^6.1.9 + version: 6.1.9(@fullcalendar/core@6.1.9) + '@fullcalendar/react': + specifier: ^6.1.9 + version: 6.1.9(@fullcalendar/core@6.1.9)(react-dom@18.2.0)(react@18.2.0) + '@fullcalendar/timegrid': + specifier: ^6.1.9 + version: 6.1.9(@fullcalendar/core@6.1.9) '@mui/icons-material': specifier: ^5.14.15 version: 5.14.15(@mui/material@5.14.15)(@types/react@18.2.33)(react@18.2.0) @@ -728,6 +743,49 @@ packages: resolution: {integrity: sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==} dev: false + /@fullcalendar/core@6.1.9: + resolution: {integrity: sha512-eeG+z9BWerdsU9Ac6j16rpYpPnE0wxtnEHiHrh/u/ADbGTR3hCOjCD9PxQOfhOTHbWOVs7JQunGcksSPu5WZBQ==} + dependencies: + preact: 10.12.1 + dev: false + + /@fullcalendar/daygrid@6.1.9(@fullcalendar/core@6.1.9): + resolution: {integrity: sha512-o/6joH/7lmVHXAkbaa/tUbzWYnGp/LgfdiFyYPkqQbjKEeivNZWF1WhHqFbhx0zbFONSHtrvkjY2bjr+Ef2quQ==} + peerDependencies: + '@fullcalendar/core': ~6.1.9 + dependencies: + '@fullcalendar/core': 6.1.9 + dev: false + + /@fullcalendar/interaction@6.1.9(@fullcalendar/core@6.1.9): + resolution: {integrity: sha512-I3FGnv0kKZpIwujg3HllbKrciNjTqeTYy3oJG226oAn7lV6wnrrDYMmuGmA0jPJAGN46HKrQqKN7ItxQRDec4Q==} + peerDependencies: + '@fullcalendar/core': ~6.1.9 + dependencies: + '@fullcalendar/core': 6.1.9 + dev: false + + /@fullcalendar/react@6.1.9(@fullcalendar/core@6.1.9)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-ioxu0V++pYz2u/N1LL1V8DkMyiKGRun0gMAll2tQz3Kzi3r9pTwncGKRb1zO8h0e+TrInU08ywk/l5lBwp7eog==} + peerDependencies: + '@fullcalendar/core': ~6.1.9 + react: ^16.7.0 || ^17 || ^18 + react-dom: ^16.7.0 || ^17 || ^18 + dependencies: + '@fullcalendar/core': 6.1.9 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@fullcalendar/timegrid@6.1.9(@fullcalendar/core@6.1.9): + resolution: {integrity: sha512-le7UV05wVE1Trdr054kgJXTwa+A1pEI8nlCBnPWdcyrL+dTLoPvQ4AWEVCnV7So+4zRYaCqnqGXfCJsj0RQa0g==} + peerDependencies: + '@fullcalendar/core': ~6.1.9 + dependencies: + '@fullcalendar/core': 6.1.9 + '@fullcalendar/daygrid': 6.1.9(@fullcalendar/core@6.1.9) + dev: false + /@humanwhocodes/config-array@0.11.13: resolution: {integrity: sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==} engines: {node: '>=10.10.0'} @@ -2779,6 +2837,10 @@ packages: source-map-js: 1.0.2 dev: true + /preact@10.12.1: + resolution: {integrity: sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==} + dev: false + /prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e9bb13b..67360d3 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -4,9 +4,10 @@ import { BrowserRouter, Route, Routes, Link } from 'react-router-dom'; import TestAuth from './components/testAuth'; import LoginPage from './components/authentication/LoginPage'; import SignUpPage from './components/authentication/SignUpPage'; -import NavBar from './components/Nav/Navbar'; +import NavBar from './components/nav/Navbar'; import Home from './components/Home'; -import ProfileUpdate from './components/ProfileUpdatePage' +import ProfileUpdate from './components/ProfileUpdatePage'; +import Calendar from './components/calendar/calendar'; const App = () => { return ( @@ -19,6 +20,7 @@ const App = () => { }/> }/> }/> + }/> diff --git a/frontend/src/api/axiosapi.jsx b/frontend/src/api/AuthenticationApi.jsx similarity index 100% rename from frontend/src/api/axiosapi.jsx rename to frontend/src/api/AuthenticationApi.jsx diff --git a/frontend/src/api/TaskApi.jsx b/frontend/src/api/TaskApi.jsx new file mode 100644 index 0000000..e56662b --- /dev/null +++ b/frontend/src/api/TaskApi.jsx @@ -0,0 +1,23 @@ +import axios from 'axios'; + +// Create an Axios instance with common configurations +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', + } +}); + +export const fetchTodoTasks = () => { + return axiosInstance + .get('todo/') + .then((response) => { + return response.data; + }) + .catch(error => { + throw error; + }); +}; \ No newline at end of file diff --git a/frontend/src/components/Nav/Navbar.jsx b/frontend/src/components/Nav/Navbar.jsx index f88d821..5fee6cf 100644 --- a/frontend/src/components/Nav/Navbar.jsx +++ b/frontend/src/components/Nav/Navbar.jsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { Link, useNavigate } from 'react-router-dom'; import IsAuthenticated from '../authentication/IsAuthenticated'; -import axiosapi from '../../api/axiosapi'; +import axiosapi from '../../api/AuthenticationApi'; import AppBar from '@mui/material/AppBar'; import Box from '@mui/material/Box'; import Toolbar from '@mui/material/Toolbar'; diff --git a/frontend/src/components/authentication/LoginPage.jsx b/frontend/src/components/authentication/LoginPage.jsx index 242a9f1..b28e274 100644 --- a/frontend/src/components/authentication/LoginPage.jsx +++ b/frontend/src/components/authentication/LoginPage.jsx @@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom"; import { useGoogleLogin } from "@react-oauth/google" import refreshAccessToken from './refreshAcesstoken'; -import axiosapi from '../../api/axiosapi'; +import axiosapi from '../../api/AuthenticationApi'; function LoginPage() { const Navigate = useNavigate(); diff --git a/frontend/src/components/authentication/SignUpPage.jsx b/frontend/src/components/authentication/SignUpPage.jsx index 7739016..2712f97 100644 --- a/frontend/src/components/authentication/SignUpPage.jsx +++ b/frontend/src/components/authentication/SignUpPage.jsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import axiosapi from '../../api/axiosapi'; +import axiosapi from '../../api/AuthenticationApi'; import Avatar from '@mui/material/Avatar'; import Button from '@mui/material/Button'; diff --git a/frontend/src/components/calendar/TaskDataHandler.jsx b/frontend/src/components/calendar/TaskDataHandler.jsx new file mode 100644 index 0000000..3c123e9 --- /dev/null +++ b/frontend/src/components/calendar/TaskDataHandler.jsx @@ -0,0 +1,42 @@ +import { fetchTodoTasks } from '../../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; +// } + +const mapResponseToEvents = (response) => { + return response.map(item => ({ + id: createEventId(), + 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 []; + } +} + +export function createEventId() { + return String(eventGuid++); +} \ No newline at end of file diff --git a/frontend/src/components/calendar/calendar.jsx b/frontend/src/components/calendar/calendar.jsx new file mode 100644 index 0000000..443d4bc --- /dev/null +++ b/frontend/src/components/calendar/calendar.jsx @@ -0,0 +1,127 @@ +import React, { useState } from 'react'; +import { formatDate } from "@fullcalendar/core"; +import FullCalendar from "@fullcalendar/react"; +import dayGridPlugin from "@fullcalendar/daygrid"; +import timeGridPlugin from "@fullcalendar/timegrid"; +import interactionPlugin from "@fullcalendar/interaction"; +import { getEvents, createEventId } from "./TaskDataHandler"; +import './index.css' + +export default class Calendar extends React.Component { + state = { + weekendsVisible: true, + currentEvents: [], + }; + + render() { + return ( +
+ {this.renderSidebar()} +
+ +
+
+ ); + } + + renderSidebar() { + return ( +
+
+

Instructions

+
    +
  • Select dates and you will be prompted to create a new event
  • +
  • Drag, drop, and resize events
  • +
  • Click an event to delete it
  • +
+
+
+ +
+
+

All Events ({this.state.currentEvents.length})

+
    {this.state.currentEvents.map(renderSidebarEvent)}
+
+
+ ); + } + + handleWeekendsToggle = () => { + this.setState({ + weekendsVisible: !this.state.weekendsVisible, + }); + }; + + handleDateSelect = selectInfo => { + let title = prompt("Please enter a new title for your event"); + let calendarApi = selectInfo.view.calendar; + + calendarApi.unselect(); // clear date selection + + if (title) { + calendarApi.addEvent({ + id: createEventId(), + title, + start: selectInfo.startStr, + end: selectInfo.endStr, + allDay: selectInfo.allDay, + }); + } + }; + + handleEventClick = clickInfo => { + if (confirm(`Are you sure you want to delete the event '${clickInfo.event.title}'`)) { + clickInfo.event.remove(); + } + }; + + handleEvents = events => { + this.setState({ + currentEvents: events, + }); + }; +} + +function renderEventContent(eventInfo) { + return ( + <> + {eventInfo.timeText} + {eventInfo.event.title} + + ); +} + +function renderSidebarEvent(event) { + return ( +
  • + {formatDate(event.start, { year: "numeric", month: "short", day: "numeric" })} + {event.title} +
  • + ); +} diff --git a/frontend/src/components/calendar/index.css b/frontend/src/components/calendar/index.css new file mode 100644 index 0000000..e5086bd --- /dev/null +++ b/frontend/src/components/calendar/index.css @@ -0,0 +1,55 @@ + +html, +body, +body > div { /* the react root */ + margin: 0; + padding: 0; + height: 100%; +} + +h2 { + margin: 0; + font-size: 16px; +} + +ul { + margin: 0; + padding: 0 0 0 1.5em; +} + +li { + margin: 1.5em 0; + padding: 0; +} + +b { /* used for event dates/times */ + margin-right: 3px; +} + +.demo-app { + display: flex; + min-height: 100%; + font-family: Arial, Helvetica Neue, Helvetica, sans-serif; + font-size: 14px; +} + +.demo-app-sidebar { + width: 300px; + line-height: 1.5; + background: #eaf9ff; + border-right: 1px solid #d3e2e8; +} + +.demo-app-sidebar-section { + padding: 2em; +} + +.demo-app-main { + flex-grow: 1; + padding: 3em; +} + +.fc { /* the calendar root */ + max-width: 1100px; + margin: 0 auto; +} diff --git a/frontend/src/components/testAuth.jsx b/frontend/src/components/testAuth.jsx index 9a822c7..abd7ba1 100644 --- a/frontend/src/components/testAuth.jsx +++ b/frontend/src/components/testAuth.jsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import axiosapi from '../api/axiosapi'; +import axiosapi from '../api/AuthenticationApi'; import { Button } from '@mui/material'; import { useNavigate } from 'react-router-dom';