diff --git a/README.md b/README.md
index 836d013..564e97b 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,5 @@
-# Planty
-
-
+
> "I have plenty things to do!"
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
index d20191f..b86e3b9 100644
--- a/frontend/app/layout.tsx
+++ b/frontend/app/layout.tsx
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
+import Head from "next/head";
import "./globals.css";
export const metadata: Metadata = {
@@ -13,6 +14,26 @@ export default function RootLayout({
}>) {
return (
+
+
+
+
+
+
{children}
);
diff --git a/frontend/components/app-sidebar.tsx b/frontend/components/app-sidebar.tsx
index 1b6e9c8..fc8b06b 100644
--- a/frontend/components/app-sidebar.tsx
+++ b/frontend/components/app-sidebar.tsx
@@ -60,56 +60,6 @@ const data = {
icon: () => ,
},
],
- favorites: [
- {
- name: "Inbox",
- url: "#",
- emoji: "๐ฉ",
- },
- {
- name: "Current tasks",
- url: "#",
- emoji: "๐",
- },
- {
- name: "Projects",
- url: "#",
- emoji: "๐ฏ",
- },
- {
- name: "Sometime later",
- url: "#",
- emoji: "๐",
- children: [
- {
- name: "Duties",
- url: "#",
- emoji: "๐ผ",
- },
- {
- name: "Programming",
- url: "#",
- emoji: "๐ป",
- },
- {
- name: "Music",
- url: "#",
- emoji: "๐ธ",
- },
- {
- name: "Would be great to do",
- url: "#",
- emoji: "๐ฆ",
- },
- ],
- },
- {
- name: "Waiting for others",
- url: "#",
- emoji: "โ",
- },
- ],
- workspaces: [],
};
export function AppSidebar({ ...props }: React.ComponentProps) {
diff --git a/frontend/components/logo.tsx b/frontend/components/logo.tsx
index 3be21db..c14b3b0 100644
--- a/frontend/components/logo.tsx
+++ b/frontend/components/logo.tsx
@@ -3,9 +3,9 @@ import Image from "next/image";
export function Logo() {
return (
-
+
Loading sections...;
+ const { sections, rootSectionId, isLoading, isError } = useSections();
+
if (isError) return Failed to load sections.
;
return (
-
+
Sections
- {sections.map((item) => (
-
- ))}
+ {isLoading ? (
+
+ ) : (
+ sections.map((item) => )
+ )}
);
}
-function Tree({ item }) {
- const hasChildren = item.children && item.children.length > 0;
-
+function Tree({ item, noIndent = false }) {
+ const hasChildren = item.subsections && item.subsections.length > 0;
if (!hasChildren) {
return (
-
-
+
+
);
}
+ const anyChildHasChildren = item.subsections.some(
+ (child) => child.subsections.length > 0
+ );
return (
-
+
-
- {item.emoji}
- {item.title}
+
- {item.children.map((child) => (
-
+ {item.subsections.map((child) => (
+
))}
@@ -75,8 +84,107 @@ function Tree({ item }) {
);
}
-const TreeElement = React.forwardRef(({ item, ...props }, ref) => (
-
- {item.title}
-
-));
+const TreeElement = React.forwardRef<
+ HTMLAnchorElement,
+ {
+ item: { id: string; title: string };
+ withChevron?: boolean;
+ clickable?: boolean;
+ withIdentIfNoChevron?: boolean;
+ } & React.HTMLAttributes
+>(
+ (
+ {
+ item,
+ clickable = true,
+ withChevron = false,
+ withIdentIfNoChevron = true,
+ ...props
+ },
+ ref
+ ) => {
+ const displayFullIdent = !withChevron && withIdentIfNoChevron;
+ const displayMiniIdent = !withChevron && !withIdentIfNoChevron;
+ const mainContent = (
+
+ {displayFullIdent &&
}
+ {displayMiniIdent &&
}
+ {withChevron && (
+
+
+
+ )}
+
+ {item.title}
+
+
+ );
+ if (!clickable) {
+ return mainContent;
+ } else {
+ return (
+
+ {mainContent}
+
+ );
+ }
+ }
+);
+
+function SectionsSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/hooks/use-sections.tsx b/frontend/hooks/use-sections.tsx
index 4b5926f..36d1ea8 100644
--- a/frontend/hooks/use-sections.tsx
+++ b/frontend/hooks/use-sections.tsx
@@ -3,9 +3,10 @@ import { fetcher } from "@/hooks/fetcher";
export const useSections = () => {
const { data, error, isLoading } = useSWR("/api/sections", fetcher);
-
+ let rootSectionId = data?.[0]?.id;
return {
- sections: data,
+ sections: data?.[0]?.subsections,
+ rootSectionId: data?.[0].id,
isLoading,
isError: error,
};
diff --git a/frontend/public/android-chrome-192x192.png b/frontend/public/android-chrome-192x192.png
new file mode 100644
index 0000000..7543138
Binary files /dev/null and b/frontend/public/android-chrome-192x192.png differ
diff --git a/frontend/public/android-chrome-512x512.png b/frontend/public/android-chrome-512x512.png
new file mode 100644
index 0000000..b202659
Binary files /dev/null and b/frontend/public/android-chrome-512x512.png differ
diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png
new file mode 100644
index 0000000..336e3c2
Binary files /dev/null and b/frontend/public/apple-touch-icon.png differ
diff --git a/frontend/public/favicon-16x16.png b/frontend/public/favicon-16x16.png
new file mode 100644
index 0000000..8a1b0af
Binary files /dev/null and b/frontend/public/favicon-16x16.png differ
diff --git a/frontend/public/favicon-32x32.png b/frontend/public/favicon-32x32.png
new file mode 100644
index 0000000..898b32a
Binary files /dev/null and b/frontend/public/favicon-32x32.png differ
diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico
new file mode 100644
index 0000000..552efb8
Binary files /dev/null and b/frontend/public/favicon.ico differ
diff --git a/frontend/public/logo.png b/frontend/public/logo.png
deleted file mode 100644
index 4801069..0000000
Binary files a/frontend/public/logo.png and /dev/null differ
diff --git a/frontend/public/logo_hor.png b/frontend/public/logo_hor.png
new file mode 100644
index 0000000..58dfc3f
Binary files /dev/null and b/frontend/public/logo_hor.png differ
diff --git a/frontend/public/site.webmanifest b/frontend/public/site.webmanifest
new file mode 100644
index 0000000..45dc8a2
--- /dev/null
+++ b/frontend/public/site.webmanifest
@@ -0,0 +1 @@
+{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
\ No newline at end of file
diff --git a/imgs/logo_hor.png b/imgs/logo_hor.png
new file mode 100644
index 0000000..58dfc3f
Binary files /dev/null and b/imgs/logo_hor.png differ
diff --git a/imgs/logo_hor_black.png b/imgs/logo_hor_black.png
new file mode 100644
index 0000000..c593a08
Binary files /dev/null and b/imgs/logo_hor_black.png differ
diff --git a/imgs/logo_hor_black_stroke.png b/imgs/logo_hor_black_stroke.png
new file mode 100644
index 0000000..8be4639
Binary files /dev/null and b/imgs/logo_hor_black_stroke.png differ
diff --git a/imgs/logo_hor_stroke.png b/imgs/logo_hor_stroke.png
new file mode 100644
index 0000000..204e73a
Binary files /dev/null and b/imgs/logo_hor_stroke.png differ
diff --git a/imgs/logo_hor_white.png b/imgs/logo_hor_white.png
new file mode 100644
index 0000000..aa0176a
Binary files /dev/null and b/imgs/logo_hor_white.png differ
diff --git a/imgs/logo_vert.png b/imgs/logo_vert.png
new file mode 100644
index 0000000..4aa2bf7
Binary files /dev/null and b/imgs/logo_vert.png differ
diff --git a/imgs/logo_vert_black.png b/imgs/logo_vert_black.png
new file mode 100644
index 0000000..0774efa
Binary files /dev/null and b/imgs/logo_vert_black.png differ
diff --git a/imgs/logo_vert_black_stroke.png b/imgs/logo_vert_black_stroke.png
new file mode 100644
index 0000000..98b43ad
Binary files /dev/null and b/imgs/logo_vert_black_stroke.png differ
diff --git a/imgs/logo_vert_stroke.png b/imgs/logo_vert_stroke.png
new file mode 100644
index 0000000..a035d48
Binary files /dev/null and b/imgs/logo_vert_stroke.png differ
diff --git a/imgs/logo_vert_white.png b/imgs/logo_vert_white.png
new file mode 100644
index 0000000..8f77186
Binary files /dev/null and b/imgs/logo_vert_white.png differ
diff --git a/imgs/planty.png b/imgs/planty.png
deleted file mode 100644
index 8b32ae6..0000000
Binary files a/imgs/planty.png and /dev/null differ
diff --git a/imgs/planty_small.png b/imgs/planty_small.png
deleted file mode 100644
index 5bff3e1..0000000
Binary files a/imgs/planty_small.png and /dev/null differ
diff --git a/imgs/sign.png b/imgs/sign.png
new file mode 100644
index 0000000..59c9d97
Binary files /dev/null and b/imgs/sign.png differ
diff --git a/imgs/sign_black_stroke.png b/imgs/sign_black_stroke.png
new file mode 100644
index 0000000..2f7a371
Binary files /dev/null and b/imgs/sign_black_stroke.png differ
diff --git a/imgs/sign_stroke.png b/imgs/sign_stroke.png
new file mode 100644
index 0000000..be5ef11
Binary files /dev/null and b/imgs/sign_stroke.png differ
diff --git a/imgs/sign_white.png b/imgs/sign_white.png
new file mode 100644
index 0000000..a667072
Binary files /dev/null and b/imgs/sign_white.png differ
diff --git a/planty/application/auth.py b/planty/application/auth.py
index 1fc8cdb..e588782 100644
--- a/planty/application/auth.py
+++ b/planty/application/auth.py
@@ -22,6 +22,15 @@
from planty.infrastructure.database import get_async_session
from planty.infrastructure.models import AccessTokenModel, UserModel
+# Cookie/token lifetime is 10 years. This is intentional to avoid relogin
+# torture when the token expires. Token can be invalidated in case of
+# passord/cookie leakage, cause it's stored in the db.
+#
+# (TODO: consider using refresh tokens when they will be supported by
+# fastapi-users: https://github.com/fastapi-users/fastapi-users/discussions/350
+# and set lifetime to a shorter period without requiring user to relogin)
+TOKEN_LIFETIME = 60 * 60 * 24 * 365 * 10
+
async def get_user_db(
session: AsyncSession = Depends(get_async_session),
@@ -40,10 +49,10 @@ def get_database_strategy(
get_access_token_db
),
) -> DatabaseStrategy[Any, Any, Any]:
- return DatabaseStrategy(access_token_db, lifetime_seconds=3600)
+ return DatabaseStrategy(access_token_db, lifetime_seconds=TOKEN_LIFETIME)
-cookie_transport = CookieTransport(cookie_max_age=3600)
+cookie_transport = CookieTransport(cookie_max_age=TOKEN_LIFETIME)
cookie_auth_backend = AuthenticationBackend(
diff --git a/planty/application/router.py b/planty/application/router.py
index 4515155..36565d9 100644
--- a/planty/application/router.py
+++ b/planty/application/router.py
@@ -9,6 +9,7 @@
AttachmentUploadInfo,
SectionCreateRequest,
SectionCreateResponse,
+ SectionMoveRequest,
SectionResponse,
ShuffleSectionRequest,
TaskCreateRequest,
@@ -191,6 +192,16 @@ async def get_section(
return section
+@router.post("/section/move")
+async def move_section(
+ request: SectionMoveRequest, user: User = Depends(current_user)
+) -> None:
+ async with SqlAlchemyUnitOfWork() as uow:
+ section_service = SectionService(uow=uow)
+ await section_service.move_section(user.id, request)
+ await uow.commit()
+
+
@router.get("/sections")
async def get_sections(user: User = Depends(current_user)) -> SectionsListResponse:
async with SqlAlchemyUnitOfWork() as uow:
diff --git a/planty/application/schemas.py b/planty/application/schemas.py
index ad89cb2..fd22266 100644
--- a/planty/application/schemas.py
+++ b/planty/application/schemas.py
@@ -10,52 +10,46 @@
from fastapi_users import schemas as fastapi_users_schemas
-class TaskCreateRequest(BaseModel):
+class Schema(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+
+class TaskCreateRequest(Schema):
section_id: UUID
title: str
description: Optional[str] = None
due_to: Optional[date] = None
recurrence: Optional[RecurrenceInfo] = None
- model_config = ConfigDict(extra="forbid")
-
-class TaskCreateResponse(BaseModel):
+class TaskCreateResponse(Schema):
id: UUID
- model_config = ConfigDict(extra="forbid")
-
-class TaskRemoveRequest(BaseModel):
+class TaskRemoveRequest(Schema):
task_id: UUID
-class TaskMoveRequest(BaseModel):
+class TaskMoveRequest(Schema):
task_id: UUID
section_to_id: UUID
index: NonNegativeInt
- model_config = ConfigDict(
- extra="forbid",
- )
+
+class SectionMoveRequest(Schema):
+ section_id: UUID
+ to_parent_id: UUID
+ index: NonNegativeInt
-class TaskToggleCompletedRequest(BaseModel):
+class TaskToggleCompletedRequest(Schema):
task_id: UUID
auto_archive: bool = True
- model_config = ConfigDict(
- extra="forbid",
- )
-
-class ShuffleSectionRequest(BaseModel):
+class ShuffleSectionRequest(Schema):
section_id: UUID
- model_config = ConfigDict(
- extra="forbid",
- )
-
"""
[!!!]
@@ -71,44 +65,39 @@ class ShuffleSectionRequest(BaseModel):
"""
-class TaskUpdateRequest(BaseModel):
+class TaskUpdateRequest(Schema):
id: UUID
- section_id: UUID = None # type: ignore
- title: str = None # type: ignore
+ title: str = None # type: ignore
description: Optional[str] = None
due_to_next: Optional[date] = None
due_to_days_period: Optional[int] = None
- model_config = ConfigDict(extra="forbid")
-
-class TaskUpdateResponse(BaseModel):
+class TaskUpdateResponse(Schema):
task: "TaskResponse"
-class SectionCreateRequest(BaseModel):
+class SectionCreateRequest(Schema):
title: str
- parent_id: Optional[UUID] = None
-
- model_config = ConfigDict(extra="forbid")
+ parent_id: UUID
-class SectionCreateResponse(BaseModel):
+class SectionCreateResponse(Schema):
id: UUID
-class RequestAttachmentUpload(BaseModel):
+class RequestAttachmentUpload(Schema):
task_id: UUID
aes_key_b64: str
aes_iv_b64: str
-class AttachmentUploadInfo(BaseModel):
+class AttachmentUploadInfo(Schema):
post_url: str
post_fields: dict[str, Any]
-class RequestAttachmentRemove(BaseModel):
+class RequestAttachmentRemove(Schema):
task_id: UUID
attachment_id: UUID
@@ -123,7 +112,7 @@ class AttachmentResponse(Attachment):
url: str
-class TaskResponse(BaseModel):
+class TaskResponse(Schema):
id: UUID
section_id: UUID
title: str
@@ -138,12 +127,13 @@ class TaskResponse(BaseModel):
attachments: list[AttachmentResponse]
-class SectionResponse(BaseModel):
+class SectionResponse(Schema):
id: UUID
title: str
parent_id: Optional[UUID]
added_at: datetime
+ subsections: list["SectionResponse"]
tasks: list[TaskResponse]
diff --git a/planty/application/services/responses_converter.py b/planty/application/services/responses_converter.py
index bfb2016..ba7f601 100644
--- a/planty/application/services/responses_converter.py
+++ b/planty/application/services/responses_converter.py
@@ -67,8 +67,6 @@ def convert_to_response(obj: possible_in_types) -> possible_out_types:
obj = cast(Section, obj)
section_data = obj.model_dump()
_adjust_section_dict(section_data)
- for task in section_data.get("tasks", []):
- _adjust_task_dict(task)
return SectionResponse(**section_data)
elif _satisfies(obj, list[Task]):
obj = cast(list[Task], obj)
@@ -98,6 +96,10 @@ def _adjust_task_dict(task: dict[str, Any]) -> None:
def _adjust_section_dict(section: dict[str, Any]) -> None:
section.pop("user_id")
+ for task in section.get("tasks", []):
+ _adjust_task_dict(task)
+ for subsection in section.get("subsections", []):
+ _adjust_section_dict(subsection)
def _satisfies(obj: Any, type_hint: Any) -> bool:
diff --git a/planty/application/services/tasks.py b/planty/application/services/tasks.py
index bde9f3d..f609bd7 100644
--- a/planty/application/services/tasks.py
+++ b/planty/application/services/tasks.py
@@ -15,6 +15,7 @@
RequestAttachmentUpload,
ArchivedTasksResponse,
SectionCreateRequest,
+ SectionMoveRequest,
SectionResponse,
ShuffleSectionRequest,
TaskCreateRequest,
@@ -34,6 +35,7 @@
)
from planty.application.uow import IUnitOfWork
from planty.domain.calendar import multiply_tasks_with_recurrences
+from planty.domain.exceptions import ChangingRootSectionError
from planty.domain.task import Attachment, Section, Task
@@ -125,11 +127,24 @@ def __init__(self, uow: IUnitOfWork):
self._section_repo = uow.section_repo
self._task_repo = uow.task_repo
+ # TODO: when writing "update_section" don't forget to raise
+ # ChangingRootSectionError if root section is being updated
+
async def add(self, user_id: UUID, section_data: SectionCreateRequest) -> Section:
+ if parent_id := section_data.parent_id:
+ parent_section = await self.get_section(user_id, parent_id)
+ # add to the end of parent section:
+ index = await self._section_repo.count_subsections(parent_section.id)
+ else:
+ index = 0
section = Section(
- user_id=user_id, title=section_data.title, parent_id=None, tasks=[]
+ user_id=user_id,
+ title=section_data.title,
+ parent_id=section_data.parent_id,
+ tasks=[],
+ subsections=[],
)
- await self._section_repo.add(section)
+ await self._section_repo.add(section, index=index)
return section
async def get_section(self, user_id: UUID, section_id: UUID) -> SectionResponse:
@@ -139,7 +154,11 @@ async def get_section(self, user_id: UUID, section_id: UUID) -> SectionResponse:
return convert_to_response(section)
async def get_all_sections(self, user_id: UUID) -> SectionsListResponse:
- sections: list[Section] = await self._section_repo.get_all(user_id)
+ sections: list[Section] = await self._section_repo.get_all_without_tasks(
+ user_id
+ )
+ # TODO: remove tasks=[] from this schema to avoid confusion! use new
+ # schema, e.g. "SectionSummary"
return convert_to_response(sections)
async def create_task(self, user_id: UUID, task: TaskCreateRequest) -> UUID:
@@ -184,6 +203,34 @@ async def move_task(self, user_id: UUID, request: TaskMoveRequest) -> None:
await self._section_repo.update(section_from)
await self._section_repo.update(section_to)
+ async def move_section(self, user_id: UUID, request: SectionMoveRequest) -> None:
+ # move `section` from `section_from` to `section to` with given `index``
+ section = await self._section_repo.get(request.section_id)
+ if section.user_id != user_id:
+ raise ForbiddenException()
+ if section.is_root():
+ raise ChangingRootSectionError()
+ assert section.parent_id # (because it's not root, assert for type checking)
+ section_to = await self._section_repo.get(
+ request.to_parent_id, with_direct_subsections=True
+ )
+ if section_to.user_id != user_id:
+ raise ForbiddenException()
+ same_section = request.to_parent_id == section.parent_id
+ if same_section:
+ section_from = section_to
+ else:
+ section_from = await self._section_repo.get(section.parent_id)
+
+ Section.move_section(section, section_from, section_to, request.index)
+
+ await self._section_repo.update(section)
+ if same_section:
+ await self._section_repo.update(section_from)
+ else:
+ await self._section_repo.update(section_from)
+ await self._section_repo.update(section_to)
+
async def toggle_task_completed(
self, user_id: UUID, task_id: UUID, auto_archive: bool
) -> SectionResponse:
@@ -211,3 +258,8 @@ async def shuffle(
section.shuffle_tasks()
await self._section_repo.update(section)
return convert_to_response(section)
+
+ async def create_root_section(self, user_id: UUID) -> Section:
+ section = Section.create_root_section(user_id)
+ await self._section_repo.add(section, index=0)
+ return section
diff --git a/planty/application/services/user_manager.py b/planty/application/services/user_manager.py
index c5825d1..79e2f6e 100644
--- a/planty/application/services/user_manager.py
+++ b/planty/application/services/user_manager.py
@@ -2,6 +2,8 @@
from typing import Optional
from loguru import logger
from fastapi import Request
+from planty.application.services.tasks import SectionService
+from planty.application.uow import SqlAlchemyUnitOfWork
from planty.config import settings
from fastapi_users import BaseUserManager, UUIDIDMixin
@@ -16,6 +18,13 @@ async def on_after_register(
self, user: UserModel, request: Optional[Request] = None
) -> None:
logger.info(f"User {user.id} has registered.")
+ async with SqlAlchemyUnitOfWork() as uow:
+ # TODO: this transaction is performed after the used has registred.
+ # If it fails, it's bad.
+ section_service = SectionService(uow)
+ root_section = await section_service.create_root_section(user.id)
+ logger.info(f"Created root section {root_section.id}")
+ await uow.commit()
async def on_after_forgot_password(
self, user: UserModel, token: str, request: Optional[Request] = None
diff --git a/planty/application/tests/test_endpoints.py b/planty/application/tests/test_endpoints.py
index 11794df..cda0fb5 100644
--- a/planty/application/tests/test_endpoints.py
+++ b/planty/application/tests/test_endpoints.py
@@ -112,6 +112,7 @@ async def test_create_section(
) -> None:
section_data = {
"title": str(additional_test_data["sections"][0]["title"]),
+ "parent_id": "0d966845-254b-4b5c-b8a7-8d34dcd3d527",
}
response = await ac.post("/api/section", json=section_data)
assert response.status_code == 201
@@ -197,6 +198,61 @@ async def test_move_task(
return
+@pytest.mark.parametrize(
+ "section_id,section_to,index,status_code,error_detail",
+ [
+ (
+ "090eda97-dd2d-45bb-baa0-7814313e5a38",
+ "36ea0a4f-0334-464d-8066-aa359ecfdcba",
+ 0, # move to the beginning
+ 200,
+ None,
+ ),
+ (
+ "090eda97-dd2d-45bb-baa0-7814313e5a38",
+ "6ff6e896-5da3-46ec-bf66-0a317c5496fa",
+ 123, # move to an incorrect index
+ 422,
+ "The section can't be moved to the specified index",
+ ),
+ (
+ "7e98e010-9d89-4dd2-be8e-773808e1ad85",
+ "0d966845-254b-4b5c-b8a7-8d34dcd3d527", # move inside the root section
+ 0,
+ 200,
+ None,
+ ),
+ (
+ "0d966845-254b-4b5c-b8a7-8d34dcd3d527", # move the root section itself
+ "7e98e010-9d89-4dd2-be8e-773808e1ad85",
+ 2,
+ 422,
+ "The root section can't be modified",
+ ),
+ ],
+)
+async def test_move_section(
+ section_id: str,
+ section_to: str,
+ index: int,
+ status_code: int,
+ error_detail: Optional[str],
+ ac: AsyncClient,
+) -> None:
+ response = await ac.post(
+ "/api/section/move",
+ json={
+ "section_id": section_id,
+ "to_parent_id": section_to,
+ "index": index,
+ },
+ )
+ assert response.status_code == status_code
+ if not response.is_success:
+ assert response.json()["detail"] == error_detail
+ return
+
+
async def test_move_another_user_task(
ac_another_user: AsyncClient,
) -> None:
@@ -457,3 +513,13 @@ async def test_get_tasks_by_search(
assert response.status_code == 200
tasks = response.json()
assert len(tasks) == n_tasks_expected
+
+
+async def test_root_section_is_created(ac: AsyncClient) -> None:
+ # this test ensures that `UserManager.on_after_register` doesn't crash
+ user_data = {
+ "email": "new_user@example.com",
+ "password": "string",
+ }
+ response = await ac.post("/auth/register", json=user_data)
+ assert response.is_success
diff --git a/planty/conftest.py b/planty/conftest.py
index 69c753d..2f575cc 100644
--- a/planty/conftest.py
+++ b/planty/conftest.py
@@ -1,3 +1,4 @@
+from copy import deepcopy
import os
import json
from uuid import UUID
@@ -11,6 +12,7 @@
os.environ["PLANTY_MODE"] = "TEST"
from planty.domain.task import Attachment, Section, Task, User # noqa: E402
+from planty.infrastructure.repositories import SQLAlchemySectionRepository # noqa: E402
from planty.infrastructure.models import ( # noqa: E402
AttachmentModel,
SectionModel,
@@ -51,9 +53,10 @@ def _load_json_with_data(filename: str) -> dict[str, Any]:
if column == "index":
# Prevent forgetting to change index in json
idx = row[column]
- assert (
- idx == last_idx + 1 or idx == 0
- ), f"Unexpected index in entity with id {row['id']}"
+ assert idx == last_idx + 1 or idx == 0, (
+ f"Unexpected index in entity with id {row['id']} "
+ "(NOTE: this is just a heuristic)"
+ )
last_idx = idx
# TODO: make this dict immutable to prevent accidental modification
@@ -88,13 +91,26 @@ def attachments_data(
return test_data["attachments"]
+def _patch_models_data(models_data: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ # models_data must not be modified
+ models_data = deepcopy(models_data)
+
+ for data in models_data:
+ for key in data:
+ if key == "id" or key.endswith("_id") and data[key]:
+ data[key] = UUID(data[key])
+ return models_data
+
+
@pytest.fixture(scope="session")
def all_users(users_data: list[dict[str, Any]]) -> list[User]:
+ users_data = _patch_models_data(users_data)
return [UserModel(**user).to_entity() for user in users_data]
@pytest.fixture(scope="session")
def all_attachments(attachments_data: list[dict[str, Any]]) -> list[Attachment]:
+ attachments_data = _patch_models_data(attachments_data)
return [
AttachmentModel(**attachment).to_entity() for attachment in attachments_data
]
@@ -104,6 +120,7 @@ def all_attachments(attachments_data: list[dict[str, Any]]) -> list[Attachment]:
def all_tasks(
tasks_data: list[dict[str, Any]], all_attachments: list[Attachment]
) -> list[Task]:
+ tasks_data = _patch_models_data(tasks_data)
tasks = []
for task_data in tasks_data:
task = TaskModel(**task_data).to_entity(
@@ -119,14 +136,16 @@ def all_tasks(
def all_sections(
sections_data: list[dict[str, Any]], all_tasks: list[Task]
) -> list[Section]:
- sections = []
- for section_data in sections_data:
- section = SectionModel(**section_data).to_entity(
- tasks=[
- task for task in all_tasks if str(task.section_id) == section_data["id"]
- ]
- )
- sections.append(section)
+ sections_data = _patch_models_data(sections_data)
+ models = [SectionModel(**section_data) for section_data in sections_data]
+
+ sections = SQLAlchemySectionRepository.get_sections_tree(
+ models, return_as_tree=False
+ )
+
+ for section in sections:
+ section.tasks = [task for task in all_tasks if task.section_id == section.id]
+
return sections
@@ -172,7 +191,12 @@ def nonempty_section(all_sections: list[Section]) -> Section:
@pytest.fixture
-def another_nonempty_section(all_sections: list[Section]) -> Section:
+def section_sometimes_later(all_sections: list[Section]) -> Section:
+ return _find_by_id(all_sections, UUID("36ea0a4f-0334-464d-8066-aa359ecfdcba"))
+
+
+@pytest.fixture
+def section_current_tasks(all_sections: list[Section]) -> Section:
return _find_by_id(all_sections, UUID("6ff6e896-5da3-46ec-bf66-0a317c5496fa"))
diff --git a/planty/domain/exceptions.py b/planty/domain/exceptions.py
index b71028e..646ecc6 100644
--- a/planty/domain/exceptions.py
+++ b/planty/domain/exceptions.py
@@ -10,9 +10,33 @@ def _detail(self) -> str:
return "The task can't be moved to the specified index"
-class RemovingFromWrongSectionError(PlantyException):
+class MovingSectionIndexError(PlantyException):
+ status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
+
+ @property
+ def _detail(self) -> str:
+ return "The section can't be moved to the specified index"
+
+
+class RemovingTaskFromWrongSectionError(PlantyException):
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
@property
def _detail(self) -> str:
return "This task doesn't belong to this section"
+
+
+class RemovingSectionFromWrongSectionError(PlantyException):
+ status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
+
+ @property
+ def _detail(self) -> str:
+ return "This section doesn't belong to this section"
+
+
+class ChangingRootSectionError(PlantyException):
+ status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
+
+ @property
+ def _detail(self) -> str:
+ return "The root section can't be modified"
diff --git a/planty/domain/task.py b/planty/domain/task.py
index a78f366..4c45fda 100644
--- a/planty/domain/task.py
+++ b/planty/domain/task.py
@@ -5,6 +5,7 @@
from pydantic import (
BaseModel,
+ ConfigDict,
Field,
NonNegativeInt,
model_validator,
@@ -12,22 +13,33 @@
from planty.domain.types import RecurrencePeriodType
from planty.utils import generate_uuid, get_datetime_now
-from planty.domain.exceptions import RemovingFromWrongSectionError, MovingTaskIndexError
+from planty.domain.exceptions import (
+ MovingSectionIndexError,
+ RemovingSectionFromWrongSectionError,
+ RemovingTaskFromWrongSectionError,
+ MovingTaskIndexError,
+)
+
+class Entity(BaseModel):
+ # domain entities must ALWAYS be valid
+ # (use smth like `delay_validation` otherwise)
+ model_config = ConfigDict(validate_assignment=True)
-class User(BaseModel):
+
+class User(Entity):
id: UUID = Field(default_factory=generate_uuid)
email: str
added_at: datetime = Field(default_factory=get_datetime_now)
-class RecurrenceInfo(BaseModel):
+class RecurrenceInfo(Entity):
period: int
type: RecurrencePeriodType
flexible_mode: bool # like an exclamation mark in Todoist
-class Task(BaseModel):
+class Task(Entity):
id: UUID = Field(default_factory=generate_uuid)
user_id: UUID
section_id: UUID
@@ -92,20 +104,47 @@ def unarchive(self) -> None:
self.is_archived = False
-class Section(BaseModel):
+class Section(Entity):
id: UUID = Field(default_factory=generate_uuid)
user_id: UUID
title: str
- parent_id: Optional[UUID] = None
- tasks: list[Task]
+ parent_id: Optional[UUID] # is None <=> it's the *root section* for this user
added_at: datetime = Field(default_factory=get_datetime_now)
+ tasks: list[Task] # can be loaded or not
+ subsections: list["Section"] # can be loaded or not
+
def __eq__(self, other: object) -> bool:
return isinstance(other, Section) and self.id == other.id
def __hash__(self) -> int:
return hash(self.id)
+ def is_root(self) -> bool:
+ return self.parent_id is None
+
+ @classmethod
+ def create_root_section(cls, user_id: UUID) -> "Section":
+ # *Root section* is a system object that helps unify the structure of
+ # the user sections tree. With the root tree, we can easily rely on the
+ # parent section to sort its subsections. Without the root tree, we
+ # would have to sort the first-level sections with separate logics.
+ #
+ # Root section is invisible to user and protected from changes.
+ return cls(
+ user_id=user_id,
+ title="[System] Root section",
+ parent_id=None,
+ tasks=[],
+ subsections=[],
+ )
+
+ # @model_validator(mode="after")
+ # def check_only_leaf_can_have_tasks(self) -> "Section":
+ # if self.subsections and self.tasks:
+ # raise SomePlantyDomainExeception(...)
+ # return self
+
def insert_task(self, task: Task, index: Optional[NonNegativeInt] = None) -> None:
if index is None:
index = len(self.tasks)
@@ -116,7 +155,7 @@ def insert_task(self, task: Task, index: Optional[NonNegativeInt] = None) -> Non
def remove_task(self, task: Task) -> Task:
if task.section_id != self.id:
- raise RemovingFromWrongSectionError()
+ raise RemovingTaskFromWrongSectionError()
self.tasks = [t for t in self.tasks if t.id != task.id]
return task
@@ -130,11 +169,38 @@ def move_task(
task_to_move = section_from.remove_task(task_to_move)
section_to.insert_task(task_to_move, index)
+ def insert_subsection(
+ self, subsection: "Section", index: Optional[NonNegativeInt] = None
+ ) -> None:
+ if index is None:
+ index = len(self.subsections)
+ if index > len(self.subsections):
+ raise MovingSectionIndexError()
+ subsection.parent_id = self.id
+ self.subsections.insert(index, subsection)
+
+ def remove_subsection(self, subsection: "Section") -> "Section":
+ if subsection.parent_id != self.id:
+ raise RemovingSectionFromWrongSectionError()
+ self.subsections = [s for s in self.subsections if s.id != subsection.id]
+ return subsection
+
+ @staticmethod
+ def move_section(
+ section: "Section",
+ section_from: "Section",
+ section_to: "Section",
+ index: NonNegativeInt,
+ ) -> None:
+ # section === subsection (every section is a subsection)
+ subsection = section_from.remove_subsection(section)
+ section_to.insert_subsection(subsection, index)
+
def shuffle_tasks(self) -> None:
random.shuffle(self.tasks)
-class Attachment(BaseModel):
+class Attachment(Entity):
id: UUID = Field(default_factory=generate_uuid)
aes_key_b64: str
aes_iv_b64: str
diff --git a/planty/domain/tests/test_section_entity.py b/planty/domain/tests/test_section_entity.py
index ce1d391..1fb88e1 100644
--- a/planty/domain/tests/test_section_entity.py
+++ b/planty/domain/tests/test_section_entity.py
@@ -2,7 +2,12 @@
from contextlib import AbstractContextManager, nullcontext as does_not_raise
import pytest
from planty.domain.task import Section, Task
-from planty.domain.exceptions import RemovingFromWrongSectionError, MovingTaskIndexError
+from planty.domain.exceptions import (
+ MovingSectionIndexError,
+ RemovingSectionFromWrongSectionError,
+ RemovingTaskFromWrongSectionError,
+ MovingTaskIndexError,
+)
@pytest.mark.parametrize(
@@ -61,7 +66,7 @@ def test_remove_task_from_wrong_section(
task = nonperiodic_task
assert task.section_id != section.id
- with pytest.raises(RemovingFromWrongSectionError):
+ with pytest.raises(RemovingTaskFromWrongSectionError):
section.remove_task(task)
@@ -95,14 +100,13 @@ def test_move_task_to_the_same_section(
(1, 2, False, None),
(2, 2, False, None),
(3, 2, False, None),
- (2, 1, False, None),
(1, 327, False, pytest.raises(MovingTaskIndexError)),
- (1, 1, True, pytest.raises(RemovingFromWrongSectionError)),
+ (1, 1, True, pytest.raises(RemovingTaskFromWrongSectionError)),
],
)
def test_move_task_to_the_another_section(
nonempty_section: Section,
- another_nonempty_section: Section,
+ section_current_tasks: Section,
index_from_move: int,
index_to_move: int,
expected_raises: Optional[AbstractContextManager[None]],
@@ -112,9 +116,9 @@ def test_move_task_to_the_another_section(
if not expected_raises:
expected_raises = does_not_raise()
- assert nonempty_section != another_nonempty_section
+ assert nonempty_section != section_current_tasks
section_to = nonempty_section
- section_from = another_nonempty_section
+ section_from = section_current_tasks
task = (section_from if not mistakenly_swap else section_to).tasks[index_from_move]
@@ -136,6 +140,68 @@ def test_move_task_to_the_another_section(
assert section_to.tasks[index_to_move] == task
+@pytest.mark.parametrize(
+ "index_from_move, index_to_move, mistakenly_swap, expected_raises",
+ [
+ (0, 0, False, None),
+ (1, 0, False, None),
+ (2, 0, False, None),
+ (0, 1, False, None),
+ (1, 1, False, None),
+ (2, 1, False, None),
+ (1, 327, False, pytest.raises(MovingSectionIndexError)),
+ (1, 1, True, pytest.raises(RemovingSectionFromWrongSectionError)),
+ ],
+)
+def test_move_section_to_another_section(
+ section_sometimes_later: Section,
+ section_current_tasks: Section,
+ index_from_move: int,
+ index_to_move: int,
+ expected_raises: Optional[AbstractContextManager[None]],
+ mistakenly_swap: bool,
+) -> None:
+ assert len(section_current_tasks.subsections) >= 2
+ assert len(section_sometimes_later.subsections) >= 4
+
+ raises_exception = bool(expected_raises)
+ if not expected_raises:
+ expected_raises = does_not_raise()
+
+ assert section_sometimes_later != section_current_tasks
+
+ section_from = section_sometimes_later
+ section_to = section_current_tasks
+
+ section = section_from.subsections[index_from_move]
+
+ if mistakenly_swap:
+ section_to, section_from = section_from, section_to
+
+ section_to_subsections_ids_before = {s.id for s in section_to.subsections}
+ section_from_subsections_ids_before = {s.id for s in section_from.subsections}
+
+ with expected_raises:
+ Section.move_section(section, section_from, section_to, index_to_move)
+
+ if raises_exception:
+ return
+
+ section_to_subsections_ids_after = {s.id for s in section_to.subsections}
+ section_from_subsections_ids_after = {s.id for s in section_from.subsections}
+
+ assert (
+ section_to_subsections_ids_before | {section.id}
+ == section_to_subsections_ids_after
+ )
+ assert (
+ section_from_subsections_ids_before - {section.id}
+ == section_from_subsections_ids_after
+ )
+
+ assert section_to.subsections[index_to_move] == section
+
+
def test_shuffle_tasks(nonempty_section: Section) -> None:
section = nonempty_section
task_ids_before = {task.id for task in section.tasks}
diff --git a/planty/infrastructure/models.py b/planty/infrastructure/models.py
index 2d3d927..e2da819 100644
--- a/planty/infrastructure/models.py
+++ b/planty/infrastructure/models.py
@@ -112,26 +112,30 @@ class SectionModel(Base):
user_id: Mapped[UUID] = mapped_column(ForeignKey("user.id"))
added_at: Mapped[datetime] = mapped_column(DateTime)
+ index: Mapped[int] # ordering inside parent
+
tasks = relationship("TaskModel", back_populates="section")
user = relationship("UserModel", back_populates="sections")
@classmethod
- def from_entity(cls, section: Section) -> "SectionModel":
+ def from_entity(cls, section: Section, index: NonNegativeInt) -> "SectionModel":
return cls(
id=section.id,
title=section.title,
user_id=section.user_id,
parent_id=section.parent_id,
added_at=section.added_at,
+ index=index,
)
- def to_entity(self, tasks: list[Task]) -> Section:
+ def to_entity(self, tasks: list[Task], subsections: list[Section]) -> Section:
return Section(
id=self.id,
title=self.title,
user_id=self.user_id,
parent_id=self.parent_id,
tasks=tasks,
+ subsections=subsections,
)
diff --git a/planty/infrastructure/repositories.py b/planty/infrastructure/repositories.py
index df8f68f..0aa84d4 100644
--- a/planty/infrastructure/repositories.py
+++ b/planty/infrastructure/repositories.py
@@ -82,6 +82,7 @@ async def update_or_create(
task_model.recurrence_type = task.recurrence.type
task_model.flexible_recurrence_mode = task.recurrence.flexible_mode
+ # update index if it's meant to be updated
if index is not None:
task_model.index = index
@@ -189,11 +190,15 @@ def __init__(self, db_session: AsyncSession, task_repo: "ITaskRepository"):
self._db_session = db_session
self._task_repo = task_repo
- async def add(self, section: Section) -> None:
- section_model = SectionModel.from_entity(section)
+ async def add(self, section: Section, index: NonNegativeInt) -> None:
+ section_model = SectionModel.from_entity(section, index=index)
self._db_session.add(section_model)
- async def get(self, section_id: UUID) -> Section:
+ async def get(
+ self,
+ section_id: UUID,
+ with_direct_subsections: bool = False,
+ ) -> Section:
result = await self._db_session.execute(
select(SectionModel).where(SectionModel.id == section_id)
)
@@ -201,35 +206,65 @@ async def get(self, section_id: UUID) -> Section:
if section_model is None:
raise SectionNotFoundException(section_id=section_id)
tasks = await self._task_repo.get_section_tasks(section_id)
- return section_model.to_entity(tasks=tasks)
+ subsections = []
+ if with_direct_subsections:
+ subsection_models = (
+ (
+ await self._db_session.execute(
+ select(SectionModel).where(SectionModel.id == section_id)
+ )
+ )
+ .scalars()
+ .all()
+ )
+ direct_subsections = [
+ s.to_entity(tasks=[], subsections=[]) for s in subsection_models
+ ]
+ subsections = direct_subsections
+ return section_model.to_entity(tasks=tasks, subsections=subsections)
- async def get_all(self, user_id: UUID) -> list[Section]:
+ async def get_all_without_tasks(self, user_id: UUID) -> list[Section]:
result = await self._db_session.execute(
select(SectionModel).where(SectionModel.user_id == user_id)
)
- section_models = result.scalars()
- return [
- section_model.to_entity(
- tasks=await self._task_repo.get_section_tasks(section_model.id)
- )
+ section_models = list(result.scalars().all())
+ top_level_sections = self.get_sections_tree(section_models)
+ return top_level_sections
+
+ @staticmethod
+ def get_sections_tree(
+ section_models: list[SectionModel], return_as_tree: bool = True
+ ) -> list[Section]:
+ # TODO: extract to generic function for other entities
+ section_models.sort(key=lambda s: s.index)
+ # TODO: use delay_validation here
+ id_to_section: dict[UUID, Section] = {
+ section_model.id: section_model.to_entity(tasks=[], subsections=[])
for section_model in section_models
- ]
+ }
+ top_level_sections = []
+ all_sections = []
+ for section in id_to_section.values():
+ if (parent_id := section.parent_id) is None:
+ top_level_sections.append(section)
+ else:
+ parent_section = id_to_section[parent_id]
+ parent_section.subsections.append(section)
+ all_sections.append(section)
+ if return_as_tree:
+ return top_level_sections
+ else:
+ return all_sections
- async def update_without_tasks(self, section: Section) -> None:
+ async def count_subsections(self, section_id: UUID) -> int:
result = await self._db_session.execute(
- select(SectionModel).where(SectionModel.id == section.id)
+ select(SectionModel).where(SectionModel.parent_id == section_id)
)
- section_model: Optional[SectionModel] = result.scalar_one_or_none()
- if section_model is None:
- raise SectionNotFoundException(section_id=section.id)
+ return len(result.all())
- section_model.title = section.title
- section_model.parent_id = section.parent_id
-
- self._db_session.add(section_model)
-
- async def update(self, section: Section) -> None:
- # Warning: does not update any excluded tasks from section.tasks
+ async def update(
+ self, section: Section, index: Optional[NonNegativeInt] = None
+ ) -> None:
result = await self._db_session.execute(
select(SectionModel).where(SectionModel.id == section.id)
)
@@ -240,6 +275,12 @@ async def update(self, section: Section) -> None:
section_model.title = section.title
section_model.parent_id = section.parent_id
+ # update index if it's meant to be updated
+ if index is not None:
+ section_model.index = index
+
+ # Tasks can be loaded or not, it doesn't matter
+ # Warning: does not update any excluded tasks from section.tasks
for i, task in enumerate(section.tasks):
await self._task_repo.update_or_create(task, index=i)
diff --git a/planty/resources/data.json b/planty/resources/data.json
index 6a20ccd..9593ef8 100644
--- a/planty/resources/data.json
+++ b/planty/resources/data.json
@@ -21,25 +21,116 @@
],
"sections": [
{
- "id": "6ff6e896-5da3-46ec-bf66-0a317c5496fa",
- "title": "Current tasks",
+ "id": "0d966845-254b-4b5c-b8a7-8d34dcd3d527",
+ "title": "[System] Root section",
"user_id": "38df4136-36b2-4171-8459-27f411af8323",
"parent_id": null,
- "added_at": "2024-03-28T04:11:17.677Z"
+ "added_at": "2024-03-28T04:11:17.677Z",
+ "index": 0
+ },
+ {
+ "id": "15dc3a23-8518-4a6a-a5a1-3bceb8c2b78a",
+ "title": "[System] Root section",
+ "user_id": "73ca2340-76bd-4abe-b872-7e82a9528c45",
+ "parent_id": null,
+ "added_at": "2024-03-28T04:11:17.677Z",
+ "index": 0
+ },
+ {
+ "id": "7e98e010-9d89-4dd2-be8e-773808e1ad85",
+ "title": "๐ฉ Inbox",
+ "user_id": "38df4136-36b2-4171-8459-27f411af8323",
+ "parent_id": "0d966845-254b-4b5c-b8a7-8d34dcd3d527",
+ "added_at": "2024-03-28T04:11:17.677Z",
+ "index": 0
+ },
+ {
+ "id": "6ff6e896-5da3-46ec-bf66-0a317c5496fa",
+ "title": "๐ Current tasks",
+ "user_id": "38df4136-36b2-4171-8459-27f411af8323",
+ "parent_id": "0d966845-254b-4b5c-b8a7-8d34dcd3d527",
+ "added_at": "2024-03-28T04:11:17.677Z",
+ "index": 1
+ },
+ {
+ "id": "febd1d82-b872-4b67-a15b-961b9aa24ed6",
+ "title": "Today",
+ "user_id": "38df4136-36b2-4171-8459-27f411af8323",
+ "parent_id": "6ff6e896-5da3-46ec-bf66-0a317c5496fa",
+ "added_at": "2024-03-28T04:11:17.677Z",
+ "index": 0
+ },
+ {
+ "id": "090eda97-dd2d-45bb-baa0-7814313e5a38",
+ "title": "This week",
+ "user_id": "38df4136-36b2-4171-8459-27f411af8323",
+ "parent_id": "6ff6e896-5da3-46ec-bf66-0a317c5496fa",
+ "added_at": "2024-03-28T04:11:17.677Z",
+ "index": 1
+ },
+ {
+ "id": "f28a4518-eac3-4d60-86a7-f279801c2a3f",
+ "title": "๐ฏ Projects",
+ "user_id": "38df4136-36b2-4171-8459-27f411af8323",
+ "parent_id": "0d966845-254b-4b5c-b8a7-8d34dcd3d527",
+ "added_at": "2024-03-28T04:11:17.677Z",
+ "index": 2
},
{
"id": "36ea0a4f-0334-464d-8066-aa359ecfdcba",
- "title": "Tasks for later",
+ "title": "๐ Sometime later",
"user_id": "38df4136-36b2-4171-8459-27f411af8323",
- "parent_id": null,
- "added_at": "2024-09-28T04:11:17.677Z"
+ "parent_id": "0d966845-254b-4b5c-b8a7-8d34dcd3d527",
+ "added_at": "2024-09-28T04:11:17.677Z",
+ "index": 3
+ },
+ {
+ "id": "b9547aee-cba5-418e-b450-7914e44c9231",
+ "title": "โ Waiting for others",
+ "user_id": "38df4136-36b2-4171-8459-27f411af8323",
+ "parent_id": "0d966845-254b-4b5c-b8a7-8d34dcd3d527",
+ "added_at": "2024-09-28T04:11:17.677Z",
+ "index": 4
},
{
"id": "a5b2010d-c27c-4f22-be47-828e065f9607",
- "title": "Chores",
+ "title": "๐งน Chores",
"user_id": "38df4136-36b2-4171-8459-27f411af8323",
- "parent_id": null,
- "added_at": "2024-09-28T04:11:17.677Z"
+ "parent_id": "36ea0a4f-0334-464d-8066-aa359ecfdcba",
+ "added_at": "2024-09-28T04:11:17.677Z",
+ "index": 0
+ },
+ {
+ "id": "6754b40e-aa0d-4b0d-9dba-4d15c751b270",
+ "title": "๐ผ Duties",
+ "user_id": "38df4136-36b2-4171-8459-27f411af8323",
+ "parent_id": "36ea0a4f-0334-464d-8066-aa359ecfdcba",
+ "added_at": "2024-09-28T04:11:17.677Z",
+ "index": 1
+ },
+ {
+ "id": "5fa09005-4ba9-417b-a9cb-82f182cd1f26",
+ "title": "๐ป Programming",
+ "user_id": "38df4136-36b2-4171-8459-27f411af8323",
+ "parent_id": "36ea0a4f-0334-464d-8066-aa359ecfdcba",
+ "added_at": "2024-09-28T04:11:17.677Z",
+ "index": 2
+ },
+ {
+ "id": "f05b7527-8173-4d14-a1bd-49aaba743247",
+ "title": "๐ธ Music",
+ "user_id": "38df4136-36b2-4171-8459-27f411af8323",
+ "parent_id": "36ea0a4f-0334-464d-8066-aa359ecfdcba",
+ "added_at": "2024-09-28T04:11:17.677Z",
+ "index": 3
+ },
+ {
+ "id": "9eb24997-5801-49f9-9562-9a59961fcee5",
+ "title": "๐ฆ Would be great to do",
+ "user_id": "38df4136-36b2-4171-8459-27f411af8323",
+ "parent_id": "36ea0a4f-0334-464d-8066-aa359ecfdcba",
+ "added_at": "2024-09-28T04:11:17.677Z",
+ "index": 4
}
],
"tasks": [