diff --git a/README.md b/README.md index 73d2f912..f0a9c8e0 100644 --- a/README.md +++ b/README.md @@ -9,16 +9,17 @@ features it has right now: 2. each project can have many todo categores (with add/edit/delete) 3. each category can have many todo item (with add/edit/delete) 4. each category can be shared among diffrent projects (with detach/attach) -5. each project can be shared with other users (with detach/attach) +5. each project can be shared with other users (with detach/attach, only owners can share with other users) 6. each todo can have many comments (with add/edit/delete) 7. you can sort todo categories or todo items in any arbitrary order and it will be saved on your user account (I'm storing this using linked list) 8. each todo can have multiple tags (with add/edit/delete) 9. you can search (all projects/project specific) by tag 10. adding todo dependencies which also works across projects (with add/delete) - for instance you can't mark a todo as `Done` unless all of its dependencies or dependencies of those dependencies are marked as `Done` 11. creating projects from a default template -12. adding rules to todo categories (currently we only support `MARK_AS_DONE` action meaning when you move a todo item to a todo category, it will be automatically marked as `Done`) +12. adding rules to todo categories (currently we only support `MARK_AS_DONE` and `MARK_AS_UNDONE` action meaning when you move a todo item to a todo category, it will be automatically marked as `Done` or `Undone` depending on what you chose) 13. setting due dates for each todoitem (with add/edit/remove) -14. setting custom permissions per user (PENDING FEATURE: update these permissions, currenly you have to detach and ask the OWNER to invite you with the new permissions) +14. setting custom permissions per user +15. changing project permissions in project settings page (only owners can change permissions) # demo @@ -30,9 +31,8 @@ You can find the demo at [this](https://todos-fohoov.vercel.app/) url (it might 2. goto the frontend project and follow the steps of its README.md # known bugs -1. conditional rendering (affects NavbarItems but I fixed it with a work around for now): https://github.com/sveltejs/svelte/issues/10321 -2. animation bugs(this bug basically destroys my UX but whatever :D): https://github.com/sveltejs/svelte/issues/10493 -3. reassignment not causing rerender (affects Alert component dismiss functionality): https://github.com/sveltejs/svelte/issues/10593 +1. animation bugs(this bug basically destroys my UX but whatever :D): https://github.com/sveltejs/svelte/issues/10493 +2. reassignment not causing rerender (affects Alert component dismiss functionality): https://github.com/sveltejs/svelte/issues/10593 # important you might need to delete the database after an update. I'll not implement migrations until I'm completely happy with the project. If you are required to delete the db after an update I'll mention it in the release notes (_/ω\_) diff --git a/backend/api/__init__.py b/backend/api/__init__.py index eb04d5c9..96f0c04f 100644 --- a/backend/api/__init__.py +++ b/backend/api/__init__.py @@ -1,5 +1,6 @@ from re import I -from fastapi import FastAPI, Request +import typing +from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.routing import APIRoute @@ -16,10 +17,13 @@ from error.exceptions import UserFriendlyError -def db_excepted_exception_handler(request: Request, ex: UserFriendlyError): +def db_excepted_exception_handler(request: Request, ex: Exception): return JSONResponse( status_code=400, - content={"code": ex.code, "message": ex.description}, + content={ + "code": typing.cast(UserFriendlyError, ex).code, + "message": typing.cast(UserFriendlyError, ex).description, + }, ) diff --git a/backend/api/conftest.py b/backend/api/conftest.py index 5bbedfb0..1ddc715d 100644 --- a/backend/api/conftest.py +++ b/backend/api/conftest.py @@ -1,77 +1,77 @@ from typing import List, TypedDict + +from fastapi import FastAPI from fastapi.testclient import TestClient import pytest from api import create_app from api.dependencies.db import get_db -from api.dependencies.db_test import get_test_db -from db.schemas.user import UserCreate -from db.test import init_db -from db.utils.user_crud import create_user +from db.schemas.user import User +from db.test import init_db, SessionLocalTest -TestUserType = TypedDict("TestUserType", {"username": str, "password": str}) +TestUserType = TypedDict("TestUserType", {"id": int, "username": str, "password": str}) _TEST_USERS: List[TestUserType] = [ - {"username": "test_username1", "password": "test_password1"}, - {"username": "test_username2", "password": "test_password2"}, - {"username": "test_username3", "password": "test_password3"}, + {"id": -1, "username": "test_username1", "password": "test_password1"}, + {"id": -1, "username": "test_username2", "password": "test_password2"}, + {"id": -1, "username": "test_username3", "password": "test_password3"}, ] @pytest.fixture(scope="session") -def test_app(): - """Create and return a test FastAPI application.""" - app = create_app() - app.dependency_overrides[get_db] = get_test_db - return app - - -@pytest.fixture(scope="session") -def test_client(test_app): +def test_client(test_app: FastAPI): """Create a TestClient using the test FastAPI application.""" with TestClient(test_app) as client: yield client @pytest.fixture(scope="session") -def test_db(): - """Fixture to provide a database session for testing.""" +def test_app(): + """Create and return a test FastAPI application.""" init_db() # Ensure the database is initialized - db = next( - get_test_db() - ) # Manually get the first (and only) yield which is the session object - try: - yield db - finally: - db.close() # Ensure the session is closed after the test(s) + app = create_app() + + def get_test_db(): + db = SessionLocalTest() + try: + yield db + finally: + db.close() + + app.dependency_overrides[get_db] = get_test_db + return app @pytest.fixture(scope="session", autouse=True) -def test_users(test_db): +def test_users(test_client: TestClient, test_app: FastAPI): """Create test users in the database, ensuring each user is created only once.""" for user in _TEST_USERS: - create_user( - test_db, - UserCreate.model_validate( - { - "username": user["username"], - "password": user["password"], - "confirm_password": user["password"], - } - ), + response = test_client.post( + "/user/signup", + json={ + "username": user["username"], + "password": user["password"], + "confirm_password": user["password"], + }, ) + + assert response.status_code == 200 + + parsed_user = User.model_validate(response.json(), strict=True) + user["id"] = parsed_user.id + return _TEST_USERS @pytest.fixture(scope="function") -def access_token_factory(test_app): +def access_token_factory(test_client: TestClient): def _get_access_token( user: TestUserType, ) -> str: - response = TestClient(test_app).post( + response = test_client.post( "/oauth/token", headers={"Content-Type": "application/x-www-form-urlencoded"}, data={ diff --git a/backend/api/dependencies/db_test.py b/backend/api/dependencies/db_test.py deleted file mode 100644 index 171fd9b6..00000000 --- a/backend/api/dependencies/db_test.py +++ /dev/null @@ -1,9 +0,0 @@ -from db.test import SessionLocalTest - - -def get_test_db(): - db = SessionLocalTest() - try: - yield db - finally: - db.close() diff --git a/backend/api/routes/conftest.py b/backend/api/routes/conftest.py index 3bfa57ec..6c050cf1 100644 --- a/backend/api/routes/conftest.py +++ b/backend/api/routes/conftest.py @@ -4,7 +4,11 @@ from api.conftest import TestUserType from db.models.user_project_permission import Permission -from db.schemas.project import Project +from db.schemas.project import ( + Project, + ProjectAttachAssociation, + ProjectAttachAssociationResponse, +) from db.schemas.todo_category import TodoCategory from db.schemas.todo_item import TodoItem @@ -56,6 +60,11 @@ def _create_category(user: TestUserType, project_id: int): "project_id": project_id, }, ) + + assert ( + response.status_code == 200 + ), "category should be created with status = 200" + category = TodoCategory.model_validate(response.json(), strict=True) return category @@ -105,4 +114,8 @@ def _attach_to_user( attach_to_user_response.status_code == 200 ), "Sharing project with permissions failed" + return ProjectAttachAssociationResponse.model_validate( + attach_to_user_response.json(), strict=True + ) + return _attach_to_user diff --git a/backend/api/routes/project/project.py b/backend/api/routes/project/project.py index e14db71d..b25baab0 100644 --- a/backend/api/routes/project/project.py +++ b/backend/api/routes/project/project.py @@ -1,3 +1,4 @@ +import builtins from typing import Annotated from fastapi import APIRouter, Depends, Response from starlette.status import HTTP_200_OK @@ -6,6 +7,7 @@ from api.dependencies.oauth import get_current_user from db.models.user import User from db.schemas.project import ( + PartialUserWithPermission, Project, ProjectAttachAssociationResponse, ProjectCreate, @@ -13,6 +15,7 @@ ProjectRead, ProjectAttachAssociation, ProjectUpdate, + ProjectUpdateUserPermissions, ) from db.utils import project_crud @@ -57,6 +60,26 @@ def detach_from_user( return Response(status_code=HTTP_200_OK) +@router.patch(path="/update-user-permissions", response_model=PartialUserWithPermission) +def update_permissions( + current_user: Annotated[User, Depends(get_current_user)], + permissions: ProjectUpdateUserPermissions, + db: Session = Depends(get_db), +): + updated_project = project_crud.update_user_permissions( + db, permissions, current_user.id + ) + + user = builtins.list( + filter( + lambda user: user.id == permissions.user_id, + Project.model_validate(updated_project).users, + ) + )[0] + + return user + + @router.get("/search", response_model=Project) def search( current_user: Annotated[User, Depends(get_current_user)], diff --git a/backend/api/routes/project/test_project.py b/backend/api/routes/project/test_project.py index a1b82a3d..31d5bfd7 100644 --- a/backend/api/routes/project/test_project.py +++ b/backend/api/routes/project/test_project.py @@ -1,3 +1,4 @@ +from tkinter import ALL from typing import Callable, Dict import pytest from fastapi.testclient import TestClient @@ -6,8 +7,15 @@ TestUserType, ) from api.routes.error import UserFriendlyErrorSchema +from api.routes.user import test_user from db.models.user_project_permission import Permission -from db.schemas.project import Project +from db.schemas.project import ( + PartialUserWithPermission, + Project, + ProjectAttachAssociation, + ProjectAttachAssociationResponse, +) +from db.schemas.todo_category import TodoCategory from error.exceptions import ErrorCode @@ -33,6 +41,7 @@ def test_create_project( ) assert response.status_code == 200 + project = Project.model_validate(response.json(), strict=True) # Common assertions for both cases @@ -106,6 +115,74 @@ def test_project_accessibility( ), "user B should be able to see user A's project after it was shared" +def test_owner_detaching_projects( + auth_header_factory: Callable[[TestUserType], Dict[str, str]], + test_project_factory: Callable[[TestUserType], Project], + test_attach_project_to_user: Callable[ + [TestUserType, TestUserType, int, list[Permission]], + ProjectAttachAssociationResponse, + ], + test_users: list[TestUserType], + test_client: TestClient, +): + + # Project creation by user A + user_a = test_users[0] + user_b = test_users[1] + project = test_project_factory(user_a) + + attach_response = test_attach_project_to_user( + user_a, user_b, project.id, [Permission.CREATE_COMMENT] + ) + + detach_by_owner_response_json = test_client.request( + method="delete", + url="/project/detach-from-user", + headers=auth_header_factory(user_a), + json={ + "project_id": project.id, + "user_id": attach_response.user_id, + }, + ) + + assert ( + detach_by_owner_response_json.status_code == 200 + ), "owner should be detach from other users" + + test_attach_project_to_user(user_a, user_b, project.id, [Permission.CREATE_COMMENT]) + + attach_to_user_c_response = test_attach_project_to_user( + user_a, test_users[2], project.id, [Permission.CREATE_COMMENT] + ) + + detach_by_user_response_json = test_client.request( + method="delete", + url="/project/detach-from-user", + headers=auth_header_factory(user_b), + json={ + "project_id": project.id, + "user_id": attach_to_user_c_response.user_id, + }, + ) + + assert ( + detach_by_user_response_json.status_code == 400 + ), "none-owner shouldn't be able to detach projects from other users" + + detach_by_owner_response_json = test_client.request( + method="delete", + url="/project/detach-from-user", + headers=auth_header_factory(user_a), + json={ + "project_id": project.id, + "user_id": attach_to_user_c_response.user_id, + }, + ) + assert ( + detach_by_owner_response_json.status_code == 200 + ), "owner should be able to detach projects from other users" + + def test_cannot_share_project_to_same_user_multiple_times( auth_header_factory: Callable[[TestUserType], Dict[str, str]], test_project_factory: Callable[[TestUserType], Project], @@ -137,7 +214,6 @@ def test_cannot_share_project_to_same_user_multiple_times( project, [ Permission.ALL, - Permission.CREATE_TODO_ITEM, ], ) _reattach_project_to_user( @@ -147,11 +223,219 @@ def test_cannot_share_project_to_same_user_multiple_times( project, [ Permission.ALL, - Permission.UPDATE_TODO_CATEGORY, ], ) +def test_user_permissions_per_project( + auth_header_factory: Callable[[TestUserType], Dict[str, str]], + test_project_factory: Callable[[TestUserType], Project], + test_attach_project_to_user: Callable[ + [TestUserType, TestUserType, int, list[Permission]], None + ], + test_users: list[TestUserType], + test_client: TestClient, +): + user_a = test_users[0] # Owner + user_b = test_users[1] # Shared user with permission + + # Create the projects + project_one = test_project_factory(user_a) + project_two = test_project_factory( + user_b + ) # just created this project because this should leak into project one permissions list + + # Share project_two with user_a with CREATE_TODO_CATEGORY permission just make sure permissions don't leak to other projects + test_attach_project_to_user( + user_b, user_a, project_two.id, [Permission.CREATE_TODO_CATEGORY] + ) + + # Share project_one with user_b with UPDATE_TODO_CATEGORY permission + test_attach_project_to_user( + user_a, user_b, project_one.id, [Permission.UPDATE_TODO_CATEGORY] + ) + + projects_json = test_client.get( + "/project/list", headers=auth_header_factory(user_a) + ).json() + parsed_projects = [Project.model_validate(x, strict=True) for x in projects_json] + assert len(parsed_projects) >= 2, "at least two projects should exist" + + parsed_project_one = list( + filter(lambda project: project.id == project_one.id, parsed_projects) + )[0] + assert parsed_project_one is not None + + parsed_project_two = list( + filter(lambda project: project.id == project_two.id, parsed_projects) + )[0] + assert parsed_project_two is not None + + assert ( + len(parsed_project_one.users) == 2 + ), "project one should be associated with two users" + + assert parsed_project_one.users[0].username == user_a["username"] + assert parsed_project_one.users[1].username == user_b["username"] + assert parsed_project_one.users[0].permissions == [Permission.ALL] + assert parsed_project_one.users[1].permissions == [Permission.UPDATE_TODO_CATEGORY] + + assert parsed_project_two.users[0].username == user_a["username"] + assert parsed_project_two.users[1].username == user_b["username"] + assert parsed_project_two.users[0].permissions == [Permission.CREATE_TODO_CATEGORY] + assert parsed_project_two.users[1].permissions == [Permission.ALL] + + +@pytest.mark.parametrize("values", [[], "", None]) +def test_cannot_pass_empty_permissions_to_attach_association( + auth_header_factory: Callable[[TestUserType], Dict[str, str]], + test_project_factory: Callable[[TestUserType], Project], + test_users: list[TestUserType], + test_client: TestClient, + values: str | list | None, +): + p1 = test_project_factory(test_users[0]) + response = test_client.post( + "/project/attach-to-user", + headers=auth_header_factory(test_users[0]), + json={ + "project_id": p1.id, + "username": test_users[1], + "permissions": values, + }, + ) + + assert ( + response.status_code == 422 + ), "we shouldn't be able to pass empty permissions lists to this service" + + +def test_updating_user_permissions( + auth_header_factory: Callable[[TestUserType], Dict[str, str]], + test_project_factory: Callable[[TestUserType], Project], + test_attach_project_to_user: Callable[ + [TestUserType, TestUserType, int, list[Permission]], None + ], + test_users: list[TestUserType], + test_client: TestClient, +): + user_a = test_users[0] # Owner + user_b = test_users[1] # Shared user with permission + + # Create the projects + project_one = test_project_factory(user_a) + project_two = test_project_factory( + user_b + ) # just created this project because this should leak into project one permissions list + + # Share project_two with user_a with CREATE_TODO_CATEGORY permission just make sure permissions don't leak to other projects + test_attach_project_to_user( + user_b, user_a, project_two.id, [Permission.CREATE_TODO_CATEGORY] + ) + + # Share project_one with user_b with UPDATE_TODO_CATEGORY permission + test_attach_project_to_user( + user_a, user_b, project_one.id, [Permission.UPDATE_TODO_CATEGORY] + ) + + user_no_access_response = test_client.patch( + "/project/update-user-permissions", + headers=auth_header_factory(user_a), + json={ + "project_id": project_one.id, + "user_id": test_users[2]["id"], + "permissions": [Permission.ALL], + }, + ) + + assert user_no_access_response.status_code == 400 + assert ( + UserFriendlyErrorSchema.model_validate(user_no_access_response.json()).code + == ErrorCode.USER_DOESNT_HAVE_ACCESS_TO_PROJECT + ) + + change_user_b_permissions = test_client.patch( + "/project/update-user-permissions", + headers=auth_header_factory(user_a), + json={ + "project_id": project_one.id, + "user_id": user_b["id"], + "permissions": [Permission.ALL], + }, + ) + assert change_user_b_permissions.status_code == 200 + assert PartialUserWithPermission.model_validate( + change_user_b_permissions.json() + ).permissions == [Permission.ALL] + + change_user_a_permissions = test_client.patch( + "/project/update-user-permissions", + headers=auth_header_factory(user_b), + json={ + "project_id": project_one.id, + "user_id": user_a["id"], + "permissions": [Permission.DELETE_COMMENT, Permission.DELETE_TODO_ITEM], + }, + ) + + assert change_user_a_permissions.status_code == 200 + assert ( + PartialUserWithPermission.model_validate( + change_user_a_permissions.json() + ).permissions.sort() + == [Permission.DELETE_COMMENT, Permission.DELETE_TODO_ITEM].sort() + ) + + searched_project = Project.model_validate( + test_client.get( + "/project/search", + headers=auth_header_factory(user_a), + params={"project_id": project_one.id}, + ).json() + ) + + assert len(searched_project.users) == 2 + + user_a_permissions = next( + filter(lambda user: user.id == user_a["id"], searched_project.users) + ) + user_b_permissions = next( + filter(lambda user: user.id == user_b["id"], searched_project.users) + ) + + assert ( + user_a_permissions.permissions.sort() + == [Permission.DELETE_COMMENT, Permission.DELETE_TODO_ITEM].sort() + ) + assert user_b_permissions.permissions == [Permission.ALL] + + +def test_cannot_set_all_with_other_permissions( + auth_header_factory: Callable[[TestUserType], Dict[str, str]], + test_project_factory: Callable[[TestUserType], Project], + test_users: list[TestUserType], + test_client: TestClient, +): + user_a = test_users[0] # Owner + + # Create the projects + project_one = test_project_factory(user_a) + + attach_to_user_response = test_client.post( + "/project/attach-to-user", + headers=auth_header_factory(user_a), + json={ + "project_id": project_one.id, + "username": test_users[1]["username"], + "permissions": [Permission.ALL, Permission.CREATE_COMMENT], + }, + ) + + assert ( + attach_to_user_response.status_code == 422 + ), "shouldn't be able to set ALL permission alongside other permissions to one user" + + def _reattach_project_to_user( auth_header_factory: Callable[[TestUserType], dict[str, str]], test_users: list[TestUserType], diff --git a/backend/api/routes/tag/test_tag.py b/backend/api/routes/tag/test_tag.py index cd655bc6..5ba23dae 100644 --- a/backend/api/routes/tag/test_tag.py +++ b/backend/api/routes/tag/test_tag.py @@ -39,7 +39,7 @@ def test_todo_tag_permissions( user_a, user_b, project_two.id, - [Permission.ALL, Permission.DELETE_TODO_CATEGORY], + [Permission.ALL], ) # Create a todo item under project_one diff --git a/backend/api/routes/todo_category/test_todo_category.py b/backend/api/routes/todo_category/test_todo_category.py index a6bcff13..173952c1 100644 --- a/backend/api/routes/todo_category/test_todo_category.py +++ b/backend/api/routes/todo_category/test_todo_category.py @@ -8,6 +8,28 @@ from db.schemas.todo_category import TodoCategory +def test_todo_category_create( + test_project_factory: Callable[[TestUserType], Project], + test_category_factory: Callable[[TestUserType, int], TodoCategory], + test_attach_project_to_user: Callable[ + [TestUserType, TestUserType, int, list[Permission]], None + ], + test_users: list[TestUserType], +): + user_a = test_users[0] # Owner + user_b = test_users[1] # Shared user with permission + + # Create a project + project_one = test_project_factory(user_a) + + # Share project_one with user_b with UPDATE_TODO_CATEGORY permission + test_attach_project_to_user( + user_a, user_b, project_one.id, [Permission.CREATE_TODO_CATEGORY] + ) + + test_category_factory(user_b, project_one.id) + + def test_todo_category_permissions( auth_header_factory: Callable[[TestUserType], Dict[str, str]], test_project_factory: Callable[[TestUserType], Project], @@ -37,7 +59,7 @@ def test_todo_category_permissions( user_a, user_b, project_two.id, - [Permission.ALL, Permission.DELETE_TODO_CATEGORY], + [Permission.ALL], ) # Try updating a category by user_c (should fail) diff --git a/backend/api/routes/todo_item/test_todo_item.py b/backend/api/routes/todo_item/test_todo_item.py index fda9b0ce..414f7c75 100644 --- a/backend/api/routes/todo_item/test_todo_item.py +++ b/backend/api/routes/todo_item/test_todo_item.py @@ -203,7 +203,7 @@ def test_todo_item_permissions( user_a, user_b, project_two.id, - [Permission.ALL, Permission.DELETE_TODO_CATEGORY], + [Permission.ALL], ) # Create a todo item in project_one's category diff --git a/backend/api/routes/todo_item_comment/test_todo_item_comment.py b/backend/api/routes/todo_item_comment/test_todo_item_comment.py index 6804867c..2a2a66e1 100644 --- a/backend/api/routes/todo_item_comment/test_todo_item_comment.py +++ b/backend/api/routes/todo_item_comment/test_todo_item_comment.py @@ -39,8 +39,6 @@ def test_todo_comment_permissions( test_users[1], project_two.id, [ - Permission.CREATE_COMMENT, - Permission.DELETE_COMMENT, Permission.ALL, ], ) diff --git a/backend/db/models/project.py b/backend/db/models/project.py index 35585d09..c7a2ee89 100644 --- a/backend/db/models/project.py +++ b/backend/db/models/project.py @@ -1,4 +1,4 @@ -from typing import List +from typing import TYPE_CHECKING, List from sqlalchemy import String, func, select from sqlalchemy.orm import Mapped from sqlalchemy.orm import mapped_column @@ -7,27 +7,35 @@ from sqlalchemy.ext.hybrid import hybrid_property +if TYPE_CHECKING: + from db.models.tag import Tag + from db.models.todo_category import TodoCategory + from db.models.user import User + from db.models.project_user_association import ProjectUserAssociation + + class Project(BasesWithCreatedDate): __tablename__ = "project" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) title: Mapped[str] = mapped_column(String()) description: Mapped[str] = mapped_column(String()) - users: Mapped[List["User"]] = relationship( # type: ignore - "User", secondary="project_user_association", back_populates="projects" + users: Mapped[List["User"]] = relationship( + secondary="project_user_association", back_populates="projects" ) - todo_categories: Mapped[List["TodoCategory"]] = relationship( # type: ignore - argument="TodoCategory", + todo_categories: Mapped[List["TodoCategory"]] = relationship( secondary="todo_category_project_association", back_populates="projects", order_by="desc(TodoCategory.id)", ) - tags: Mapped[List["Tag"]] = relationship( # type: ignore - "Tag", + tags: Mapped[List["Tag"]] = relationship( back_populates="project", cascade="all, delete-orphan", order_by="desc(Tag.id)", ) + associations: Mapped[List["ProjectUserAssociation"]] = relationship( + back_populates="project", viewonly=True + ) # TODO: i dont know about the performance implications of these hybrid queries... like does sqlalchemy remove the need to query the db again if the object is already there? @hybrid_property @@ -57,7 +65,10 @@ def pending_todos_count(self): # type: ignore for todo_category in self.todo_categories: pending_todos += len( list( - filter(lambda todo_item: todo_item.is_done == False, todo_category.items) # type: ignore + filter( + lambda todo_item: todo_item.is_done == False, + todo_category.items, + ) ) ) return pending_todos diff --git a/backend/db/models/project_user_association.py b/backend/db/models/project_user_association.py index 1f48caf8..06c20836 100644 --- a/backend/db/models/project_user_association.py +++ b/backend/db/models/project_user_association.py @@ -1,22 +1,39 @@ -from typing import List +from __future__ import annotations + + +from typing import TYPE_CHECKING, List from sqlalchemy import ForeignKey, UniqueConstraint from sqlalchemy.orm import Mapped, relationship from sqlalchemy.orm import mapped_column from db.models.base import BasesWithCreatedDate +if TYPE_CHECKING: + from db.models.project import Project + from db.models.user import User + from db.models.user_project_permission import UserProjectPermission + + class ProjectUserAssociation(BasesWithCreatedDate): __tablename__ = "project_user_association" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) - user_id: Mapped[int] = mapped_column(ForeignKey("user.id", ondelete="CASCADE")) + user_id: Mapped[int] = mapped_column( + ForeignKey("user.id", ondelete="CASCADE"), nullable=False + ) project_id: Mapped[int] = mapped_column( - ForeignKey("project.id", ondelete="CASCADE") + ForeignKey("project.id", ondelete="CASCADE"), nullable=False ) - permissions: Mapped[List["UserProjectPermission"]] = relationship( # type: ignore - "UserProjectPermission", + permissions: Mapped[List["UserProjectPermission"]] = relationship( back_populates="project_user_association", cascade="all, delete-orphan", ) + project: Mapped["Project"] = relationship( + foreign_keys=[project_id], back_populates="associations", viewonly=True + ) + user: Mapped["User"] = relationship( + foreign_keys=[user_id], back_populates="associations", viewonly=True + ) + __table_args__ = (UniqueConstraint("user_id", "project_id"),) diff --git a/backend/db/models/tag.py b/backend/db/models/tag.py index 9979098f..390e99bf 100644 --- a/backend/db/models/tag.py +++ b/backend/db/models/tag.py @@ -1,10 +1,14 @@ -from typing import List +from typing import TYPE_CHECKING, List from sqlalchemy import CheckConstraint, ForeignKey, String, UniqueConstraint from sqlalchemy.orm import Mapped from sqlalchemy.orm import mapped_column from sqlalchemy.orm import relationship from db.models.base import BasesWithCreatedDate +if TYPE_CHECKING: + from db.models.project import Project + from db.models.todo_item import TodoItem + class Tag(BasesWithCreatedDate): __tablename__ = "tag" @@ -14,11 +18,10 @@ class Tag(BasesWithCreatedDate): project_id: Mapped[int] = mapped_column( ForeignKey("project.id", ondelete="CASCADE") ) - project: Mapped[List["Project"]] = relationship( # type: ignore - "Project", back_populates="tags", single_parent=True + project: Mapped[List["Project"]] = relationship( + back_populates="tags", single_parent=True ) - todos: Mapped[List["TodoItem"]] = relationship( # type: ignore - "TodoItem", + todos: Mapped[List["TodoItem"]] = relationship( secondary="todo_item_tag_association", back_populates="tags", order_by="desc(TodoItem.id)", diff --git a/backend/db/models/todo_category.py b/backend/db/models/todo_category.py index dfebc006..c679292f 100644 --- a/backend/db/models/todo_category.py +++ b/backend/db/models/todo_category.py @@ -1,4 +1,4 @@ -from typing import List +from typing import TYPE_CHECKING, List from sqlalchemy import String from sqlalchemy.orm import Mapped from sqlalchemy.orm import mapped_column @@ -6,6 +6,11 @@ from db.models.base import BasesWithCreatedDate from db.models.todo_category_order import TodoCategoryOrder +if TYPE_CHECKING: + from db.models.todo_item import TodoItem + from db.models.project import Project + from db.models.todo_category_action import TodoCategoryAction + class TodoCategory(BasesWithCreatedDate): __tablename__ = "todo_category" @@ -13,25 +18,21 @@ class TodoCategory(BasesWithCreatedDate): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) title: Mapped[str] = mapped_column(String()) description: Mapped[str] = mapped_column(String()) - items: Mapped[List["TodoItem"]] = relationship( # type: ignore - "TodoItem", + items: Mapped[List["TodoItem"]] = relationship( back_populates="category", cascade="all, delete-orphan", order_by="desc(TodoItem.id), desc(TodoItem.is_done)", ) - actions: Mapped[List["TodoCategoryAction"]] = relationship( # type: ignore - "TodoCategoryAction", + actions: Mapped[List["TodoCategoryAction"]] = relationship( back_populates="category", cascade="all, delete-orphan", ) - projects: Mapped[List["Project"]] = relationship( # type: ignore - "Project", + projects: Mapped[List["Project"]] = relationship( secondary="todo_category_project_association", back_populates="todo_categories", order_by="desc(Project.id)", ) orders: Mapped[List[TodoCategoryOrder]] = relationship( - "TodoCategoryOrder", foreign_keys=[TodoCategoryOrder.category_id], cascade="all, delete-orphan", back_populates="category", diff --git a/backend/db/models/todo_category_action.py b/backend/db/models/todo_category_action.py index d97c6468..3c6ecde5 100644 --- a/backend/db/models/todo_category_action.py +++ b/backend/db/models/todo_category_action.py @@ -1,4 +1,5 @@ import enum +from typing import TYPE_CHECKING from sqlalchemy import ( Enum, ForeignKey, @@ -10,6 +11,10 @@ from db.models.base import BasesWithCreatedDate +if TYPE_CHECKING: + from db.models.todo_category import TodoCategory + + class Action(enum.StrEnum): AUTO_MARK_AS_DONE = enum.auto() AUTO_MARK_AS_UNDONE = enum.auto() @@ -23,8 +28,7 @@ class TodoCategoryAction(BasesWithCreatedDate): ForeignKey("todo_category.id", ondelete="CASCADE") ) action: Mapped[Action] = mapped_column(Enum(Action, validate_strings=True)) - category: Mapped["TodoCategory"] = relationship( # type: ignore - "TodoCategory", + category: Mapped["TodoCategory"] = relationship( back_populates="actions", single_parent=True, ) diff --git a/backend/db/models/todo_category_order.py b/backend/db/models/todo_category_order.py index d9271250..7dc6687e 100644 --- a/backend/db/models/todo_category_order.py +++ b/backend/db/models/todo_category_order.py @@ -1,9 +1,13 @@ +from typing import TYPE_CHECKING from sqlalchemy import CheckConstraint, Connection, ForeignKey, UniqueConstraint, event from sqlalchemy.orm import Mapped, Session, Mapper from sqlalchemy.orm import mapped_column from sqlalchemy.orm import relationship from db.models.base import BaseOrderedItem, BasesWithCreatedDate +if TYPE_CHECKING: + from db.models.todo_category import TodoCategory + class TodoCategoryOrder(BasesWithCreatedDate, BaseOrderedItem): __tablename__ = "todo_category_order" @@ -21,7 +25,7 @@ class TodoCategoryOrder(BasesWithCreatedDate, BaseOrderedItem): right_id: Mapped[int | None] = mapped_column( ForeignKey("todo_category.id", ondelete="CASCADE"), nullable=True ) - category: Mapped["TodoCategory"] = relationship( # type: ignore + category: Mapped["TodoCategory"] = relationship( foreign_keys=[category_id], single_parent=True, back_populates="orders" ) diff --git a/backend/db/models/todo_item.py b/backend/db/models/todo_item.py index 55690bae..82840966 100644 --- a/backend/db/models/todo_item.py +++ b/backend/db/models/todo_item.py @@ -8,7 +8,13 @@ from db.models.todo_item_dependency import TodoItemDependency from db.models.todo_item_order import TodoItemOrder from sqlalchemy.ext.hybrid import hybrid_property -from typing import List, Optional +from typing import TYPE_CHECKING, List, Optional + + +if TYPE_CHECKING: + from db.models.user import User + from db.models.tag import Tag + from db.models.todo_category import TodoCategory class TodoItem(BasesWithCreatedDate): @@ -22,8 +28,7 @@ class TodoItem(BasesWithCreatedDate): ForeignKey("todo_category.id", ondelete="CASCADE") ) due_date: Mapped[datetime | None] = mapped_column(DateTime(), nullable=True) - category: Mapped["TodoCategory"] = relationship( # type: ignore - "TodoCategory", + category: Mapped["TodoCategory"] = relationship( back_populates="items", single_parent=True, cascade="all, delete-orphan", @@ -32,29 +37,26 @@ class TodoItem(BasesWithCreatedDate): ForeignKey("user.id"), nullable=True ) comments: Mapped[List[TodoItemComment]] = relationship( - "TodoItemComment", foreign_keys=[TodoItemComment.todo_id], back_populates="todo", cascade="all, delete-orphan", ) - tags: Mapped[List["Tag"]] = relationship( # type: ignore + tags: Mapped[List["Tag"]] = relationship( secondary="todo_item_tag_association", back_populates="todos" ) - dependencies: Mapped[List["TodoItemDependency"]] = relationship( # type: ignore + dependencies: Mapped[List["TodoItemDependency"]] = relationship( foreign_keys=[TodoItemDependency.todo_id], back_populates="todo", cascade="all, delete-orphan", ) order: Mapped[TodoItemOrder | None] = relationship( - "TodoItemOrder", foreign_keys=[TodoItemOrder.todo_id], uselist=False, back_populates="todo", cascade="all, delete-orphan", ) - marked_as_done_by: Mapped[Optional["User"]] = relationship( # type: ignore - "User", + marked_as_done_by: Mapped[Optional["User"]] = relationship( uselist=False, back_populates="done_todos", ) diff --git a/backend/db/models/todo_item_comments.py b/backend/db/models/todo_item_comments.py index 8631917e..e237c01e 100644 --- a/backend/db/models/todo_item_comments.py +++ b/backend/db/models/todo_item_comments.py @@ -1,9 +1,13 @@ +from typing import TYPE_CHECKING from sqlalchemy import ForeignKey, Text from sqlalchemy.orm import Mapped from sqlalchemy.orm import mapped_column from sqlalchemy.orm import relationship from db.models.base import BasesWithCreatedDate +if TYPE_CHECKING: + from db.models.todo_item import TodoItem + class TodoItemComment(BasesWithCreatedDate): __tablename__ = "todo_item_comment" @@ -11,6 +15,6 @@ class TodoItemComment(BasesWithCreatedDate): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) todo_id: Mapped[int] = mapped_column(ForeignKey("todo_item.id", ondelete="CASCADE")) message: Mapped[str] = mapped_column(Text(), nullable=False) - todo: Mapped["TodoItem"] = relationship( # type: ignore + todo: Mapped["TodoItem"] = relationship( foreign_keys=[todo_id], single_parent=True, back_populates="comments" ) diff --git a/backend/db/models/todo_item_dependency.py b/backend/db/models/todo_item_dependency.py index eac29398..238c320e 100644 --- a/backend/db/models/todo_item_dependency.py +++ b/backend/db/models/todo_item_dependency.py @@ -1,3 +1,4 @@ +from typing import TYPE_CHECKING from sqlalchemy import ForeignKey, UniqueConstraint, func, select from sqlalchemy.orm import Mapped from sqlalchemy.orm import mapped_column @@ -5,6 +6,9 @@ from db.models.base import BasesWithCreatedDate from sqlalchemy.ext.hybrid import hybrid_property +if TYPE_CHECKING: + from db.models.todo_item import TodoItem + class TodoItemDependency(BasesWithCreatedDate): __tablename__ = "todo_item_dependency" @@ -15,15 +19,14 @@ class TodoItemDependency(BasesWithCreatedDate): ForeignKey("todo_item.id", ondelete="CASCADE") ) - todo: Mapped["TodoItem"] = relationship( # type: ignore - "TodoItem", + todo: Mapped["TodoItem"] = relationship( back_populates="dependencies", foreign_keys=[todo_id], single_parent=True, ) - dependant_todo: Mapped["TodoItem"] = relationship( # type: ignore - "TodoItem", foreign_keys=[dependant_todo_id], single_parent=True + dependant_todo: Mapped["TodoItem"] = relationship( + foreign_keys=[dependant_todo_id], single_parent=True ) __table_args__ = (UniqueConstraint("todo_id", "dependant_todo_id"),) diff --git a/backend/db/models/todo_item_order.py b/backend/db/models/todo_item_order.py index 8f15d46c..6d28fd7b 100644 --- a/backend/db/models/todo_item_order.py +++ b/backend/db/models/todo_item_order.py @@ -1,9 +1,13 @@ -from sqlalchemy import CheckConstraint, Connection, ForeignKey, event -from sqlalchemy.orm import Mapped, Session, Mapper +from typing import TYPE_CHECKING +from sqlalchemy import CheckConstraint, ForeignKey +from sqlalchemy.orm import Mapped from sqlalchemy.orm import mapped_column from sqlalchemy.orm import relationship from db.models.base import BaseOrderedItem, BasesWithCreatedDate +if TYPE_CHECKING: + from db.models.todo_item import TodoItem + class TodoItemOrder(BasesWithCreatedDate, BaseOrderedItem): __tablename__ = "todo_item_order" @@ -18,7 +22,7 @@ class TodoItemOrder(BasesWithCreatedDate, BaseOrderedItem): right_id: Mapped[int | None] = mapped_column( ForeignKey("todo_item.id", ondelete="CASCADE"), nullable=True, unique=True ) - todo: Mapped["TodoItem"] = relationship( # type: ignore + todo: Mapped["TodoItem"] = relationship( foreign_keys=[todo_id], single_parent=True, back_populates="order" ) diff --git a/backend/db/models/user.py b/backend/db/models/user.py index 158d1aa8..7bc4fdd2 100644 --- a/backend/db/models/user.py +++ b/backend/db/models/user.py @@ -1,4 +1,4 @@ -from typing import List +from typing import TYPE_CHECKING, List from sqlalchemy import String from sqlalchemy.orm import Mapped from sqlalchemy.orm import mapped_column @@ -7,14 +7,24 @@ from db.models.base import BasesWithCreatedDate +if TYPE_CHECKING: + from db.models.project import Project + from db.models.todo_item import TodoItem + from db.models.project_user_association import ProjectUserAssociation + + class User(BasesWithCreatedDate): __tablename__ = "user" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) username: Mapped[str] = mapped_column(String(30)) password: Mapped[str] = mapped_column(String()) - projects: Mapped[List["Project"]] = relationship( # type: ignore - "Project", secondary="project_user_association", back_populates="users" + projects: Mapped[List["Project"]] = relationship( + secondary="project_user_association", back_populates="users" ) - done_todos: Mapped[list["TodoItem"]] = relationship( # type: ignore - "TodoItem", back_populates="marked_as_done_by" + done_todos: Mapped[List["TodoItem"]] = relationship( + back_populates="marked_as_done_by" + ) + + associations: Mapped[List["ProjectUserAssociation"]] = relationship( + back_populates="user", viewonly=True ) diff --git a/backend/db/models/user_project_permission.py b/backend/db/models/user_project_permission.py index c59cbb6b..88104cbf 100644 --- a/backend/db/models/user_project_permission.py +++ b/backend/db/models/user_project_permission.py @@ -1,17 +1,21 @@ +from __future__ import annotations + + import enum -from db.models.base import Base, BasesWithCreatedDate +from typing import TYPE_CHECKING +from db.models.base import Base from sqlalchemy import ( - CheckConstraint, - Connection, Enum, ForeignKey, UniqueConstraint, - event, ) -from sqlalchemy.orm import Mapped, Session, Mapper +from sqlalchemy.orm import Mapped from sqlalchemy.orm import mapped_column from sqlalchemy.orm import relationship -from db.models.base import BaseOrderedItem, BasesWithCreatedDate + + +if TYPE_CHECKING: + from db.models.project_user_association import ProjectUserAssociation class Permission(enum.StrEnum): @@ -45,13 +49,12 @@ class UserProjectPermission(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) project_user_association_id: Mapped[int] = mapped_column( - ForeignKey("project_user_association.id", ondelete="CASCADE") + ForeignKey("project_user_association.id", ondelete="CASCADE"), nullable=False ) permission: Mapped[Permission] = mapped_column( Enum(Permission, validate_strings=True) ) - project_user_association: Mapped["ProjectUserAssociation"] = relationship( # type: ignore - "ProjectUserAssociation", + project_user_association: Mapped["ProjectUserAssociation"] = relationship( back_populates="permissions", single_parent=True, cascade="all, delete-orphan", diff --git a/backend/db/schemas/project.py b/backend/db/schemas/project.py index ef4ba20a..b7cc1b57 100644 --- a/backend/db/schemas/project.py +++ b/backend/db/schemas/project.py @@ -1,7 +1,14 @@ from dataclasses import dataclass import json -from pydantic import BaseModel, ConfigDict, Field, field_validator - +from pydantic import ( + BaseModel, + ConfigDict, + Field, + field_validator, + computed_field, +) + +from db.models.base import Base from db.models.user_project_permission import Permission @@ -36,12 +43,11 @@ class ProjectUpdate(ProjectCreate): class ProjectDetachAssociation(ProjectBase): project_id: int + user_id: int | None = Field(default=None) -class ProjectAttachAssociation(ProjectBase): - project_id: int - username: str = Field(min_length=3, max_length=100) - permissions: list[Permission] +class _Permissions(BaseModel): + permissions: list[Permission] = Field(min_length=1) @field_validator("permissions") @classmethod @@ -54,15 +60,61 @@ def permissions_length(cls, permissions: list[Permission]) -> list[Permission]: if len(set(permissions)) != len(permissions): raise ValueError("repetitive values in permissions list is not allowed") + return list(set(permissions)) + + @field_validator("permissions", mode="after") + @classmethod + def has_other_permissions_with_owner_permission( + cls, permissions: list[Permission] + ) -> list[Permission]: + if ( + len(list(filter(lambda perm: perm == Permission.ALL, permissions))) == 1 + and len(permissions) > 1 + ): + raise ValueError( + "if a user has the `ALL` permission then setting other permissions to them is not allowed" + ) return permissions +class ProjectAttachAssociation(ProjectBase, _Permissions): + project_id: int + username: str = Field(min_length=3, max_length=100) + + class ProjectAttachAssociationResponse(ProjectBase): project_id: int user_id: int -class PartialUser(BaseModel): +class ProjectUpdateUserPermissions(ProjectBase, _Permissions): + project_id: int + user_id: int + + +class _UserProjectPermission(BaseModel): + permission: Permission + + model_config = ConfigDict(from_attributes=True) + + +class _ProjectUserAssociation(BaseModel): + user_id: int + project_id: int + permissions: list[_UserProjectPermission] + + model_config = ConfigDict(from_attributes=True) + + +class _PartialUser(BaseModel): + id: int + username: str + associations: list[_ProjectUserAssociation] + + model_config = ConfigDict(from_attributes=True) + + +class PartialUserWithPermission(_Permissions): id: int username: str @@ -79,13 +131,68 @@ class ProjectPartialTag(BaseModel): id: int name: str + model_config = ConfigDict(from_attributes=True) + class Project(ProjectBase): id: int title: str description: str - users: list[PartialUser] todo_categories: list[PartialTodoCategory] tags: list[ProjectPartialTag] done_todos_count: int pending_todos_count: int + + users_: list[_PartialUser] = Field(exclude=True, default=[], alias="users") + + @computed_field + @property + def users(self) -> list[PartialUserWithPermission]: + # TODO: think of another way, this way sux ass also is shitty performance wise, + # new solution should follow these: + # 1- for each project's user's permissions we should NOT perform a new query to the database (if user has 10000 associations then 9999 of them could be redundant :}) because accessing project.users[x].association is a new query then association.permissions is also another query + # 2- it should be universal, whenever I return a project schema, the permissions should be there per user (even in create we return the project which should contain the user who created it with the associated permission) + # (preferred) 3- removes the associations as a relationship property on User and Project models + return [ + PartialUserWithPermission( + **user.model_dump(), + permissions=[ + perm.permission + for association in filter( + lambda association: association.project_id == self.id, + user.associations, + ) + for perm in association.permissions + ], + ) + for user in self.users_ + ] + + @field_validator("users_", mode="before") + @classmethod + def ignore_if_users_provided(cls, value, validation_data): + if ( + isinstance(value, list) + and len(value) > 0 + and not isinstance(value[0], Base) + ): + return [ + { + "id": user["id"], + "username": user["username"], + "associations": [ + { + "user_id": user["id"], + "project_id": validation_data.data["id"], + "permissions": [ + {"permission": Permission(perm)} + for perm in user["permissions"] + ], + } + ], + } + for user in value + ] + return value + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/db/utils/project_crud.py b/backend/db/utils/project_crud.py index 35e6bde2..0453f160 100644 --- a/backend/db/utils/project_crud.py +++ b/backend/db/utils/project_crud.py @@ -1,3 +1,4 @@ +import typing from sqlalchemy import func from sqlalchemy.exc import IntegrityError from db.models.project import Project @@ -11,10 +12,15 @@ ProjectDetachAssociation, ProjectRead, ProjectUpdate, + ProjectUpdateUserPermissions, ) from sqlalchemy.orm import Session from db.schemas.todo_category import TodoCategoryCreate -from db.utils.shared.permission_query import join_with_permission_query_if_required +from db.utils.shared.permission_query import ( + join_with_permission_query_if_required, + PermissionsType, + validate_item_exists_with_permissions, +) from error.exceptions import ErrorCode, UserFriendlyError from db.models.todo_category import TodoCategory @@ -60,6 +66,53 @@ def update(db: Session, project: ProjectUpdate, user_id: int): return db_item +def update_user_permissions( + db: Session, permissions: ProjectUpdateUserPermissions, user_id: int +): + # check if current user is owner + validate_project_belongs_to_user( + db, permissions.project_id, user_id, [Permission.ALL] + ) + + # check if the user we are changing has access to this project + try: + validate_project_belongs_to_user( + db, permissions.project_id, permissions.user_id, None + ) + except UserFriendlyError as ex: + raise UserFriendlyError( + ErrorCode.USER_DOESNT_HAVE_ACCESS_TO_PROJECT, + "The user that you are trying to update doesn't have access to this project or doesn't exist", + ) + + association = ( + db.query(ProjectUserAssociation) + .filter( + ProjectUserAssociation.project_id == permissions.project_id, + ProjectUserAssociation.user_id == permissions.user_id, + ) + .first() + ) + + if association is None: + raise # not possible, just to mute type-hints + + db.query(UserProjectPermission).filter( + UserProjectPermission.project_user_association_id == association.id + ).delete() + + for permission in permissions.permissions: + db.add( + UserProjectPermission( + project_user_association_id=association.id, permission=permission + ) + ) + + db.commit() + + return get_project(db, ProjectRead(project_id=permissions.project_id), user_id) + + def attach_to_user(db: Session, association: ProjectAttachAssociation, user_id: int): user = db.query(User).filter(User.username == association.username).first() if user is None: @@ -106,12 +159,19 @@ def detach_from_user(db: Session, association: ProjectDetachAssociation, user_id db, association.project_id, user_id, - None, + [Permission.ALL] if association.user_id is not None else None, ) + target_user_id = association.user_id if association.user_id is not None else user_id + + if target_user_id != user_id: + validate_project_belongs_to_user( + db, association.project_id, target_user_id, None + ) + db.query(ProjectUserAssociation).filter( ProjectUserAssociation.project_id == association.project_id, - ProjectUserAssociation.user_id == user_id, + ProjectUserAssociation.user_id == target_user_id, ).delete() if ( @@ -157,33 +217,24 @@ def delete_project(db: Session, project_id: int): def get_project(db: Session, project: ProjectRead, user_id: int): - result = ( - db.query(Project) - .filter(Project.id == project.project_id) - .join(Project.users) - .filter(User.id == user_id) - .first() - ) + result = get_projects(db, user_id, project.project_id) - if result is None: + if len(result) == 0: raise UserFriendlyError( ErrorCode.PROJECT_NOT_FOUND, "project doesn't exist or doesn't belong to current user", ) - return result + return result[0] -def get_projects(db: Session, user_id: int): - result = ( - db.query(Project) - .join(Project.users) - .filter(User.id == user_id) - .order_by(Project.id.asc()) - .all() - ) +def get_projects(db: Session, user_id: int, project_id: int | None = None): + query = db.query(Project).join(Project.users).filter(User.id == user_id) - return result + if project_id is not None: + query = query.filter(Project.id == project_id) + + return query.order_by(Project.id.asc()).all() def add_default_template_categories(db, project_id: int, user_id: int): @@ -208,7 +259,7 @@ def validate_project_belongs_to_user( db: Session, project_id: int, user_id: int, - permissions: list[Permission] | None, + permissions: PermissionsType, ): query = ( db.query(Project) @@ -219,8 +270,9 @@ def validate_project_belongs_to_user( query = join_with_permission_query_if_required(query, permissions) - if query.count() < (len(permissions) if permissions is not None else 1): - raise UserFriendlyError( - ErrorCode.PROJECT_NOT_FOUND, - "project doesn't exist or doesn't belong to user or you don't have the permission to perform the requested action", - ) + validate_item_exists_with_permissions( + query, + permissions, + ErrorCode.PROJECT_NOT_FOUND, + "project doesn't exist or doesn't belong to user or you don't have the permission to perform the requested action", + ) diff --git a/backend/db/utils/shared/permission_query.py b/backend/db/utils/shared/permission_query.py index a995831c..80e4c76c 100644 --- a/backend/db/utils/shared/permission_query.py +++ b/backend/db/utils/shared/permission_query.py @@ -1,3 +1,4 @@ +import typing from sqlalchemy import and_ from db.models.base import Base from db.models.project import Project @@ -5,24 +6,43 @@ from db.models.user_project_permission import Permission, UserProjectPermission from sqlalchemy.orm import Query +from error.exceptions import ErrorCode, UserFriendlyError + +PermissionsType = typing.Sequence[Permission | set[Permission]] | None + def join_with_permission_query_if_required[ T: Base -](query: Query[T], permissions: list[Permission] | None): +](query: Query[T], permissions: PermissionsType): + """join the the current query with a permissions query + + :param permissions: takes an array of permissions, for example: 1- has A and B = [A, B], 2- has (A or B) and C = [{A, B}, C] + Return: a new query with permissions query joined with it + """ + if permissions is None: return query - if len(permissions) == 0: - raise Exception("permissions length cannot be empty, did meant to pass None?") + expanded_permissions: list[Permission] = [] + for permission in permissions: + if isinstance(permission, Permission): + expanded_permissions.append(permission) + continue + expanded_permissions.extend(typing.cast(tuple[Permission], permission)) - if any(permission == Permission.ALL for permission in permissions): - permissions = [Permission.ALL] + if len(expanded_permissions) == 0: + raise Exception( + "expanded_permissions length cannot be empty, did meant to pass None?" + ) - if any(permission != Permission.ALL for permission in permissions): - permissions = permissions + [Permission.ALL] + if any(permission == Permission.ALL for permission in expanded_permissions): + expanded_permissions = [Permission.ALL] + + if any(permission != Permission.ALL for permission in expanded_permissions): + expanded_permissions = expanded_permissions + [Permission.ALL] # TODO: THERE SHOULD BE A BETTER WAY, but the purpose is that the query should already be joined with Projects table - # because if its not we are not sure whose permissions we are checking + # because if its not we are not sure whose expanded_permissions we are checking if not any(joins[0].parent.entity == Project for joins in query._setup_joins): # type: ignore raise Exception("query should be already joined with Project table") @@ -34,7 +54,20 @@ def join_with_permission_query_if_required[ raise Exception("query is already joined with UserProjectPermission") query = query.join(ProjectUserAssociation.permissions).filter( - UserProjectPermission.permission.in_(permissions) + UserProjectPermission.permission.in_(expanded_permissions) ) return query + + +def validate_item_exists_with_permissions( + query: Query, + permissions: PermissionsType, + error_code: ErrorCode, + error_message: str, +): + if query.count() < (len(permissions) if permissions is not None else 1): + raise UserFriendlyError( + error_code, + error_message, + ) diff --git a/backend/db/utils/tag_crud.py b/backend/db/utils/tag_crud.py index b0c74bc5..0e09dee5 100644 --- a/backend/db/utils/tag_crud.py +++ b/backend/db/utils/tag_crud.py @@ -1,3 +1,4 @@ +import typing from sqlalchemy import and_ from sqlalchemy.orm import Session from db.models.project import Project @@ -16,7 +17,11 @@ TagSearch, TagUpdate, ) -from db.utils.shared.permission_query import join_with_permission_query_if_required +from db.utils.shared.permission_query import ( + join_with_permission_query_if_required, + PermissionsType, + validate_item_exists_with_permissions, +) from error.exceptions import ErrorCode, UserFriendlyError from db.utils.project_crud import ( validate_project_belongs_to_user, @@ -196,7 +201,7 @@ def validate_tag_belongs_to_user_by_name( tag_name: str, project_id: int | None, user_id: int, - permissions: list[Permission] | None, + permissions: PermissionsType, ): query = ( db.query(Tag) @@ -211,18 +216,19 @@ def validate_tag_belongs_to_user_by_name( query = join_with_permission_query_if_required(query, permissions) - if query.count() < (len(permissions) if permissions is not None else 1): - raise UserFriendlyError( - ErrorCode.TAG_NOT_FOUND, - "tag not found or doesn't belong to user or you don't have the permission to perform the requested action", - ) + validate_item_exists_with_permissions( + query, + permissions, + ErrorCode.TAG_NOT_FOUND, + "tag not found or doesn't belong to user or you don't have the permission to perform the requested action", + ) def validate_tag_belongs_to_user_by_id( db: Session, tag_id: int, user_id: int, - permissions: list[Permission] | None, + permissions: PermissionsType, ): query = ( db.query(Tag) @@ -233,8 +239,9 @@ def validate_tag_belongs_to_user_by_id( ) query = join_with_permission_query_if_required(query, permissions) - if query.count() < (len(permissions) if permissions is not None else 1): - raise UserFriendlyError( - ErrorCode.TAG_NOT_FOUND, - "tag not found or doesn't belong to user or you don't have the permission to perform the requested action", - ) + validate_item_exists_with_permissions( + query, + permissions, + ErrorCode.TAG_NOT_FOUND, + "tag not found or doesn't belong to user or you don't have the permission to perform the requested action", + ) diff --git a/backend/db/utils/todo_category_crud.py b/backend/db/utils/todo_category_crud.py index 7ef0aa99..d237e8d8 100644 --- a/backend/db/utils/todo_category_crud.py +++ b/backend/db/utils/todo_category_crud.py @@ -1,3 +1,4 @@ +import typing from sqlalchemy import and_ from db.models.project_user_association import ProjectUserAssociation from db.models.todo_category_action import Action, TodoCategoryAction @@ -22,7 +23,11 @@ TodoCategoryUpdateItem, TodoCategoryUpdateOrder, ) -from db.utils.shared.permission_query import join_with_permission_query_if_required +from db.utils.shared.permission_query import ( + join_with_permission_query_if_required, + PermissionsType, + validate_item_exists_with_permissions, +) from error.exceptions import ErrorCode, UserFriendlyError from db.utils.project_crud import validate_project_belongs_to_user @@ -118,25 +123,29 @@ def update_item(db: Session, category: TodoCategoryUpdateItem, user_id: int): def update_order(db: Session, moving_item: TodoCategoryUpdateOrder, user_id: int): + required_permissions = [ + {Permission.UPDATE_TODO_CATEGORY, Permission.CREATE_TODO_CATEGORY} + ] + validate_todo_category_belongs_to_user( db, moving_item.id, user_id, - [Permission.UPDATE_TODO_CATEGORY], + required_permissions, ) if moving_item.left_id is not None: validate_todo_category_belongs_to_user( - db, moving_item.left_id, user_id, [Permission.UPDATE_TODO_CATEGORY] + db, moving_item.left_id, user_id, required_permissions ) if moving_item.right_id is not None: validate_todo_category_belongs_to_user( - db, moving_item.right_id, user_id, [Permission.UPDATE_TODO_CATEGORY] + db, moving_item.right_id, user_id, required_permissions ) validate_project_belongs_to_user( db, moving_item.project_id, user_id, - [Permission.UPDATE_TODO_CATEGORY], + required_permissions, ) def create_order(id: int, left_id: int | None, right_id: int | None): @@ -257,7 +266,10 @@ def detach_from_project( def validate_todo_category_belongs_to_user( - db: Session, category_id: int, user_id: int, permissions: list[Permission] | None + db: Session, + category_id: int, + user_id: int, + permissions: PermissionsType, ): query = ( db.query(TodoCategory) @@ -269,11 +281,12 @@ def validate_todo_category_belongs_to_user( query = join_with_permission_query_if_required(query, permissions) - if query.count() < (len(permissions) if permissions is not None else 1): - raise UserFriendlyError( - ErrorCode.TODO_CATEGORY_NOT_FOUND, - "todo category doesn't exist or doesn't belong to user or you don't have the permission to perform the requested action", - ) + validate_item_exists_with_permissions( + query, + permissions, + ErrorCode.TODO_CATEGORY_NOT_FOUND, + "todo category doesn't exist or doesn't belong to user or you don't have the permission to perform the requested action", + ) def _update_actions( diff --git a/backend/db/utils/todo_item_comment_crud.py b/backend/db/utils/todo_item_comment_crud.py index 9648b37f..c025fb18 100644 --- a/backend/db/utils/todo_item_comment_crud.py +++ b/backend/db/utils/todo_item_comment_crud.py @@ -1,3 +1,4 @@ +import typing import builtins from sqlalchemy.orm import Session from db.models.todo_item import TodoItem @@ -9,6 +10,7 @@ TodoCommentSearch, TodoCommentUpdate, ) +from db.utils.shared.permission_query import PermissionsType from error.exceptions import ErrorCode, UserFriendlyError from db.utils.todo_item_crud import validate_todo_item_belongs_to_user @@ -68,7 +70,7 @@ def validate_todo_comment_belongs_to_user( db: Session, todo_comment_id: int, user_id: int, - permissions: builtins.list[Permission] | None, + permissions: PermissionsType, ): todo_comment = ( db.query(TodoItemComment).filter(TodoItemComment.id == todo_comment_id).first() @@ -87,4 +89,4 @@ def validate_todo_comment_belongs_to_user( db, todo_comment.todo_id, user_id, permissions ) except UserFriendlyError: - raise error + raise diff --git a/backend/db/utils/todo_item_crud.py b/backend/db/utils/todo_item_crud.py index a1c989b3..b5432fd1 100644 --- a/backend/db/utils/todo_item_crud.py +++ b/backend/db/utils/todo_item_crud.py @@ -1,3 +1,4 @@ +import typing import datetime from types import NoneType from typing import List @@ -29,7 +30,11 @@ SearchTodoItemParams, TodoItemUpdateOrder, ) -from db.utils.shared.permission_query import join_with_permission_query_if_required +from db.utils.shared.permission_query import ( + PermissionsType, + join_with_permission_query_if_required, + validate_item_exists_with_permissions, +) from error.exceptions import ErrorCode, UserFriendlyError from db.utils.project_crud import validate_project_belongs_to_user from db.utils.todo_category_crud import validate_todo_category_belongs_to_user @@ -316,7 +321,10 @@ def remove_todo_dependency( def validate_todo_item_belongs_to_user( - db: Session, todo_id: int, user_id: int, permissions: list[Permission] | None + db: Session, + todo_id: int, + user_id: int, + permissions: PermissionsType, ): query = ( db.query(TodoItem) @@ -329,11 +337,12 @@ def validate_todo_item_belongs_to_user( query = join_with_permission_query_if_required(query, permissions) - if query.count() < (len(permissions) if permissions is not None else 1): - raise UserFriendlyError( - ErrorCode.TODO_NOT_FOUND, - "todo item doesn't exist or doesn't belong to user or you don't have the permission to perform the requested action", - ) + validate_item_exists_with_permissions( + query, + permissions, + ErrorCode.TODO_NOT_FOUND, + "todo item doesn't exist or doesn't belong to user or you don't have the permission to perform the requested action", + ) def _perform_actions( @@ -399,7 +408,10 @@ def _update_done_status( def _validate_dependencies_are_resolved( - db: Session, todo: TodoItem, user_id: int, permissions: list[Permission] + db: Session, + todo: TodoItem, + user_id: int, + permissions: PermissionsType, ): for dependency in todo.dependencies: try: diff --git a/backend/error/exceptions.py b/backend/error/exceptions.py index 1b30e40c..09f63a43 100644 --- a/backend/error/exceptions.py +++ b/backend/error/exceptions.py @@ -23,6 +23,7 @@ class ErrorCode(StrEnum): CANT_CHANGE_ACTION = auto() ACTION_PREVENTED_TODO_UPDATE = auto() PERMISSION_DENIED = auto() + USER_DOESNT_HAVE_ACCESS_TO_PROJECT = auto() class UserFriendlyError(Exception): diff --git a/frontend/.prettierrc b/frontend/.prettierrc index c60a8c10..8bc6e864 100644 --- a/frontend/.prettierrc +++ b/frontend/.prettierrc @@ -4,6 +4,5 @@ "trailingComma": "none", "printWidth": 100, "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], - "pluginSearchDirs": ["."], "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] } diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json index 814ba098..f9b6f53c 100644 --- a/frontend/.vscode/settings.json +++ b/frontend/.vscode/settings.json @@ -6,8 +6,10 @@ "felteerror", "feltesuccess", "fortawesome", + "Fwal", "submitclienterror", "submitended", + "submitfailed", "submitredirected", "submitstarted", "submitsucceeded", diff --git a/frontend/package.json b/frontend/package.json index e13ad8f0..43454f7f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -45,7 +45,7 @@ "prettier": "^3.0.3", "prettier-plugin-svelte": "^3.1.2", "prettier-plugin-tailwindcss": "^0.5.5", - "svelte": "^5.0.0-next.76", + "svelte": "^5.0.0-next.80", "svelte-check": "^3.6.7", "svelte-fa": "^4.0.2", "tailwindcss": "^3.3.3", diff --git a/frontend/src/lib/actions/form/submit-types.ts b/frontend/src/lib/actions/form/submit-types.ts index 1092ca22..8b729494 100644 --- a/frontend/src/lib/actions/form/submit-types.ts +++ b/frontend/src/lib/actions/form/submit-types.ts @@ -1,11 +1,16 @@ import type { SubmitFunction } from '@sveltejs/kit'; import type { z } from 'zod'; import type { ValidatorOptions } from './validator-types'; -import type { StandardFormActionNames } from './utils'; +import type { + ParsedFormData, + StandardFormActionError, + StandardFormActionNames, + getFormErrors +} from './utils'; export type EnhanceOptions< TSchema extends z.ZodTypeAny, - TFormAction, + TFormAction extends StandardFormActionError, TKey extends StandardFormActionNames = never > = { form: TFormAction; @@ -23,7 +28,7 @@ export type EnhanceOptions< }; export type FormActionResultType< - TFormAction, + TFormAction extends StandardFormActionError, TKey extends StandardFormActionNames = never > = TFormAction extends { response: infer TResult } ? Extract['response'] @@ -33,13 +38,16 @@ export type FormActionResultType< export type SubmitEvents< TSchema extends z.ZodTypeAny, - TFormAction, + TFormAction extends StandardFormActionError, TKey extends StandardFormActionNames = never > = { 'on:submitstarted'?: (e: SubmitStartEventType) => void; 'on:submitended'?: (e: SubmitEndedEventType) => void; 'on:submitredirected'?: (e: SubmitRedirectedEventType) => void; 'on:submitsucceeded'?: (e: SubmitSucceededEventType) => void; + 'on:submitfailed'?: ( + e: SubmitFailedEventType + ) => void /*** called on both client-side and server-side errors*/; }; export type SubmitStartEventType = CustomEvent; @@ -48,14 +56,20 @@ export type SubmitEndedEventType = CustomEvent; export type SubmitRedirectedEventType = CustomEvent<{ redirectUrl: URL; - formData: z.infer; + formData: ParsedFormData; }>; export type SubmitSucceededEventType< TSchema extends z.ZodTypeAny, - TFormAction, + TFormAction extends StandardFormActionError, TKey extends StandardFormActionNames = never > = CustomEvent<{ response: FormActionResultType; - formData: z.infer; + formData: ParsedFormData; + parsedFormData: z.infer; +}>; + +export type SubmitFailedEventType = CustomEvent<{ + formData: ParsedFormData; + error: ReturnType>; }>; diff --git a/frontend/src/lib/actions/form/submit.ts b/frontend/src/lib/actions/form/submit.ts index 9dae08f1..8384f20f 100644 --- a/frontend/src/lib/actions/form/submit.ts +++ b/frontend/src/lib/actions/form/submit.ts @@ -10,11 +10,17 @@ import type { SubmitRedirectedEventType, SubmitStartEventType, SubmitEndedEventType, - SubmitSucceededEventType + SubmitSucceededEventType, + SubmitFailedEventType } from './submit-types'; import { validate } from './validator'; -import type { ValidatorErrorEvent } from './validator-types'; -import type { StandardFormActionNames } from './utils'; +import type { SubmitClientErrorEventType, ValidatorErrorEvents } from './validator-types'; +import { + convertFormDataToObject, + getFormErrors, + type StandardFormActionError, + type StandardFormActionNames +} from './utils'; import { invalidateAll } from '$app/navigation'; export function superEnhance( @@ -25,20 +31,24 @@ export function superEnhance( >; export function superEnhance< TSchema extends z.ZodTypeAny, - TFormAction, + TFormAction extends StandardFormActionError, TKey extends StandardFormActionNames = never >( node: HTMLFormElement, options: EnhanceOptions ): ActionReturn< EnhanceOptions, - ValidatorErrorEvent & SubmitEvents + ValidatorErrorEvents & SubmitEvents >; export function superEnhance< TSchema extends z.ZodTypeAny, - TFormAction = never, + TFormAction extends StandardFormActionError = never, TKey extends StandardFormActionNames = never >(node: HTMLFormElement, options?: Partial>) { + if (options?.action && !node.action.endsWith(`?/${options.action.toString()}`)) { + throw new Error('form.action should end with the passed action in enhancer options'); + } + const handleSubmit = options?.submit ?? _defaultSubmitHandler(node, options); @@ -46,18 +56,25 @@ export function superEnhance< const enhancer = enhance(node, handleSubmit); node.addEventListener('reset', _superResetHandler); + function _handleClientSideError(event: Event) { + _fireSubmitFailureForClientSideError(node, event as SubmitClientErrorEventType); + } + + node.addEventListener('submitclienterror', _handleClientSideError); + return { destroy() { validator?.destroy && validator.destroy(); enhancer.destroy(); node.removeEventListener('reset', _superResetHandler); + node.removeEventListener('submitclienterror', _handleClientSideError); } }; } function _defaultSubmitHandler< TSchema extends z.ZodTypeAny, - TFormAction, + TFormAction extends StandardFormActionError, TKey extends StandardFormActionNames = never >( node: HTMLFormElement, @@ -74,11 +91,23 @@ function _defaultSubmitHandler< console.debug('s-form-result'); console.debug(_getResultFromFormAction(result.data, options)); console.debug('e-form-result'); + + const parsedFormData = await options?.validator?.schema.safeParseAsync( + convertFormDataToObject(formData) + ); + + if (parsedFormData && !parsedFormData?.success) { + throw new Error( + "for some reason server-side validations succeeded but the client-side validations didn't, OR the client data changed since the form has been submitted" + ); + } + node.dispatchEvent( new CustomEvent('submitsucceeded', { detail: { response: _getResultFromFormAction(result.data, options), - formData: Object.fromEntries(formData) as z.infer + formData: convertFormDataToObject(formData), + parsedFormData: parsedFormData?.data } }) satisfies SubmitSucceededEventType ); @@ -89,10 +118,28 @@ function _defaultSubmitHandler< redirectUrl: result.location.startsWith('/') ? new URL(location.origin + result.location) : new URL(result.location), - formData: Object.fromEntries(formData) + formData: convertFormDataToObject(formData) } }) satisfies SubmitRedirectedEventType ); + } else if (result.type == 'error') { + node.dispatchEvent( + new CustomEvent('submitfailed', { + detail: { + error: getFormErrors(result), + formData: convertFormDataToObject(formData) + } + }) satisfies SubmitFailedEventType + ); + } else if (result.type == 'failure') { + node.dispatchEvent( + new CustomEvent('submitfailed', { + detail: { + error: getFormErrors(result.data as any), + formData: convertFormDataToObject(formData) + } + }) satisfies SubmitFailedEventType + ); } if (options?.ignoreSamePageConstraint) { @@ -120,7 +167,7 @@ function _defaultSubmitHandler< function _getResultFromFormAction< TSchema extends z.ZodTypeAny, - TFormAction, + TFormAction extends StandardFormActionError, TKey extends StandardFormActionNames = never >( data: Record | undefined, @@ -137,6 +184,23 @@ function _getResultFromFormAction< return data[options.action as string]['response']; } +function _fireSubmitFailureForClientSideError< + TSchema extends z.ZodTypeAny, + TFormAction extends StandardFormActionError +>(node: HTMLFormElement, event: SubmitClientErrorEventType) { + node.dispatchEvent( + new CustomEvent('submitfailed', { + detail: { + error: { + errors: event.detail.errors as any, + message: 'Invalid form, please review your inputs' + }, + formData: event.detail.formData + } + }) satisfies SubmitFailedEventType + ); +} + function _superResetHandler(event: Event) { const node = event.target as HTMLFormElement; _focusOnFirstVisibleInput(node); diff --git a/frontend/src/lib/actions/form/validator-types.ts b/frontend/src/lib/actions/form/validator-types.ts index 73f88d9c..b5877df1 100644 --- a/frontend/src/lib/actions/form/validator-types.ts +++ b/frontend/src/lib/actions/form/validator-types.ts @@ -1,3 +1,4 @@ +import type { ParsedFormData } from '$lib/actions/form/utils'; import type { z } from 'zod'; export type ValidatorErrorsType = z.typeToFlattenedError< @@ -8,10 +9,11 @@ export type ValidatorOptions = { schema: TSchema; }; -export type ValidatorErrorEvent = { - 'on:submitclienterror': (e: CustomEvent>) => void; +export type ValidatorErrorEvents = { + 'on:submitclienterror': (e: SubmitClientErrorEventType) => void; }; -export type SubmitClientErrorEventType = CustomEvent< - ValidatorErrorsType ->; +export type SubmitClientErrorEventType = CustomEvent<{ + errors: ValidatorErrorsType; + formData: ParsedFormData; +}>; diff --git a/frontend/src/lib/actions/form/validator.ts b/frontend/src/lib/actions/form/validator.ts index e1da9470..0295f546 100644 --- a/frontend/src/lib/actions/form/validator.ts +++ b/frontend/src/lib/actions/form/validator.ts @@ -3,7 +3,7 @@ import type { z } from 'zod'; import { convertFormDataToObject } from './utils'; import type { ValidatorOptions, - ValidatorErrorEvent, + ValidatorErrorEvents, ValidatorErrorsType, SubmitClientErrorEventType } from './validator-types'; @@ -11,7 +11,7 @@ import type { export function validate( node: HTMLFormElement, options: ValidatorOptions -): ActionReturn, ValidatorErrorEvent> { +): ActionReturn, ValidatorErrorEvents> { const formClientSideValidateHandler = async (event: SubmitEvent) => { if (!options) { return; @@ -27,7 +27,7 @@ export function validate( event.stopImmediatePropagation(); node.dispatchEvent( new CustomEvent('submitclienterror', { - detail: errors + detail: { errors, formData: convertFormDataToObject(new FormData(node)) } }) satisfies SubmitClientErrorEventType ); }; diff --git a/frontend/src/lib/components/Confirm.svelte b/frontend/src/lib/components/Confirm.svelte index e1d9cf6f..a7782e39 100644 --- a/frontend/src/lib/components/Confirm.svelte +++ b/frontend/src/lib/components/Confirm.svelte @@ -1,4 +1,6 @@ @@ -14,6 +17,7 @@ const { confirmText = 'confirm', cancelText = 'cancel', + confirmButtonType = 'button', onConfirmed, onCanceled }: Props = $props(); @@ -30,7 +34,7 @@
@@ -40,6 +44,7 @@ hide(); onCanceled?.(); }} + type="button" data-testid="confirm-cancel" > {cancelText} @@ -51,6 +56,7 @@ hide(); onConfirmed?.(); }} + type={confirmButtonType} data-testid="confirm-accept" > {confirmText} diff --git a/frontend/src/lib/components/DarkModeSwitch.svelte b/frontend/src/lib/components/DarkModeSwitch.svelte index 19eeaa69..d941a0f3 100644 --- a/frontend/src/lib/components/DarkModeSwitch.svelte +++ b/frontend/src/lib/components/DarkModeSwitch.svelte @@ -14,13 +14,13 @@ diff --git a/frontend/src/lib/components/Drawer.svelte b/frontend/src/lib/components/Drawer.svelte index 1276f4ee..ec8deff5 100644 --- a/frontend/src/lib/components/Drawer.svelte +++ b/frontend/src/lib/components/Drawer.svelte @@ -3,9 +3,10 @@ import Fa from 'svelte-fa'; import { faBarsStaggered } from '@fortawesome/free-solid-svg-icons'; + import { drawer } from '$lib/stores/drawer'; import type { Snippet } from 'svelte'; - type SnippetParams = [{ closeDrawer: () => void }]; + export type SnippetParams = [{ closeDrawer: () => void }]; export type Props = { id: string; @@ -60,6 +61,9 @@ {/snippet} {#snippet end()} + {#each drawer.navbar.end as snippet} + {@render snippet({ closeDrawer })} + {/each} {#if navbarEnd} {@render navbarEnd({ closeDrawer })} {/if} diff --git a/frontend/src/lib/components/Fragment.svelte b/frontend/src/lib/components/Fragment.svelte deleted file mode 100644 index 4fa864ce..00000000 --- a/frontend/src/lib/components/Fragment.svelte +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/lib/components/Spinner.svelte b/frontend/src/lib/components/Spinner.svelte index fd8b94fd..6c89fe61 100644 --- a/frontend/src/lib/components/Spinner.svelte +++ b/frontend/src/lib/components/Spinner.svelte @@ -5,13 +5,32 @@
diff --git a/frontend/src/lib/components/buttons/LoadingButton.svelte b/frontend/src/lib/components/buttons/LoadingButton.svelte index 9b6667d5..0ccd6f17 100644 --- a/frontend/src/lib/components/buttons/LoadingButton.svelte +++ b/frontend/src/lib/components/buttons/LoadingButton.svelte @@ -1,17 +1,29 @@ -