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": [