From 9a01074ad1abb6e79617d7aefdafdd48583ea48e Mon Sep 17 00:00:00 2001 From: Jeroen Vijgen Date: Wed, 25 Jun 2025 17:00:01 +0000 Subject: [PATCH 1/4] Add start of invitations route --- api/asset_manager/src/main.py | 2 + .../src/modules/invitations/models.py | 15 +++++ .../src/modules/invitations/router.py | 66 +++++++++++++++++++ .../src/modules/invitations/schemas.py | 0 4 files changed, 83 insertions(+) create mode 100644 api/asset_manager/src/modules/invitations/models.py create mode 100644 api/asset_manager/src/modules/invitations/router.py create mode 100644 api/asset_manager/src/modules/invitations/schemas.py diff --git a/api/asset_manager/src/main.py b/api/asset_manager/src/main.py index 4000414b..205bcea4 100644 --- a/api/asset_manager/src/main.py +++ b/api/asset_manager/src/main.py @@ -10,6 +10,7 @@ from modules.assets.router import router as asset_router from modules.auth.router import router as auth_router from modules.users.router import router as users_router from modules.organizations.router import router as organizations_router +from modules.invitations.router import router as invitations_router from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware from fastapi.middleware.trustedhost import TrustedHostMiddleware @@ -53,3 +54,4 @@ app.include_router(auth_router) app.include_router(users_router) app.include_router(organizations_router) app.include_router(asset_router) +app.include_router(invitations_router) diff --git a/api/asset_manager/src/modules/invitations/models.py b/api/asset_manager/src/modules/invitations/models.py new file mode 100644 index 00000000..9e276916 --- /dev/null +++ b/api/asset_manager/src/modules/invitations/models.py @@ -0,0 +1,15 @@ + +import uuid +from tortoise import Model, fields + +from mixins import CMDMixin +from modules.users.models import User + + +class Invite(Model, CMDMixin): + id: uuid.UUID = fields.UUIDField(primary_key=True) + receiver: str = fields.CharField(max_length=128) + sender: str = fields.UUIDField() + message: str | None = fields.TextField(null=True) + accepted: bool = fields.BooleanField() + disabled: bool = fields.BooleanField(default=False) diff --git a/api/asset_manager/src/modules/invitations/router.py b/api/asset_manager/src/modules/invitations/router.py new file mode 100644 index 00000000..b4c540bb --- /dev/null +++ b/api/asset_manager/src/modules/invitations/router.py @@ -0,0 +1,66 @@ +from typing import Annotated +import uuid +from fastapi import APIRouter, Depends, HTTPException + +from modules.invitations.models import Invite +from modules.users.models import User +from modules.users.utils import get_current_active_user + +from tortoise.expressions import Q + + +router = APIRouter(prefix="/api/v1/Users", tags=["User"]) + + +@router.get("/") +async def get_all_invitations(user: Annotated[User, Depends(get_current_active_user)]): + pass + + +@router.get("/accept/{invitation_id}") +async def accept_invitation( + user: Annotated[User, Depends(get_current_active_user)], invitation_id: uuid.UUID +): + invite: Invite | None = await Invite.get_or_none( + Q(id=invitation_id) & (Q(receiver=user.username) | Q(receiver=user.email)) + ) + invite.accepted = True + invite.save() + return invite + + + +@router.get("/reject/{invitation_id}") +async def reject_invitation( + user: Annotated[User, Depends(get_current_active_user)], invitation_id: uuid.UUID +) -> Invite: + invite: Invite | None = await Invite.get_or_none( + Q(id=invitation_id) & (Q(receiver=user.username) | Q(receiver=user.email)) + ) + invite.accepted = False + invite.save() + return invite + + +@router.get("/send") +async def accept_invitation( + user: Annotated[User, Depends(get_current_active_user)], +): + pass + + +@router.get("/cancel/{invitation_id}", status_code=204) +async def accept_invitation( + user: Annotated[User, Depends(get_current_active_user)], invitation_id: uuid.UUID +) -> None: + invite: Invite | None = await Invite.get_or_none( + Q(id=invitation_id) & Q(sender=user.id) + ) + + if not invite: + raise HTTPException( + status_code=403, + detail="The invitation doesn't exist or you don't have access to it.", + ) + + invite.delete() diff --git a/api/asset_manager/src/modules/invitations/schemas.py b/api/asset_manager/src/modules/invitations/schemas.py new file mode 100644 index 00000000..e69de29b From b65c292d833aaef28bfbd2435d84fce3bcf0858b Mon Sep 17 00:00:00 2001 From: Jeroen Vijgen Date: Wed, 9 Jul 2025 16:34:42 +0000 Subject: [PATCH 2/4] Replace Passlib with drop-in replacement, add more invitation routes, start tests --- api/asset_manager/src/config.py | 8 ++- api/asset_manager/src/database.py | 1 + ...21149_init.py => 0_20250709154845_init.py} | 40 ++++++++++----- api/asset_manager/src/mixins/CMDMixin.py | 11 ---- api/asset_manager/src/modules/auth/models.py | 9 ++-- .../src/modules/invitations/models.py | 12 ++--- .../src/modules/invitations/router.py | 51 +++++++++++++------ .../src/modules/invitations/schemas.py | 7 +++ .../src/modules/organizations/models.py | 9 ++-- .../src/modules/organizations/router.py | 11 ++-- api/asset_manager/src/modules/users/models.py | 12 +++-- .../src/requirements/requirements.txt | 2 +- 12 files changed, 101 insertions(+), 72 deletions(-) rename api/asset_manager/src/migrations/models/{0_20250625121149_init.py => 0_20250709154845_init.py} (84%) delete mode 100644 api/asset_manager/src/mixins/CMDMixin.py diff --git a/api/asset_manager/src/config.py b/api/asset_manager/src/config.py index 40b8e6a8..845266a6 100644 --- a/api/asset_manager/src/config.py +++ b/api/asset_manager/src/config.py @@ -1,8 +1,6 @@ from fastapi.security import OAuth2PasswordBearer -from pydantic_settings import BaseSettings, SettingsConfigDict # type: ignore -from passlib.context import CryptContext # type: ignore -import pytz - +from pydantic_settings import BaseSettings, SettingsConfigDict +from pwdlib import PasswordHash class Settings(BaseSettings): PROJECT_NAME: str = "StoneEdge Asset Management System" PROJECT_VERSION: str = "0.0.1" @@ -19,7 +17,7 @@ class Settings(BaseSettings): ACCESS_TOKEN_EXPIRE_MIN: int = 10 REFRESH_TOKEN_EXPIRE_MIN: int = 20 BACKEND_CORS_ORIGINS: list = ["*"] - CRYPT: CryptContext = CryptContext(schemes=["bcrypt"], deprecated="auto") + CRYPT: PasswordHash = PasswordHash.recommended() OAUTH2_SCHEME: OAuth2PasswordBearer = OAuth2PasswordBearer(tokenUrl="token") model_config = SettingsConfigDict(env_file=".env") diff --git a/api/asset_manager/src/database.py b/api/asset_manager/src/database.py index 5213f465..16934f48 100644 --- a/api/asset_manager/src/database.py +++ b/api/asset_manager/src/database.py @@ -8,6 +8,7 @@ modules: dict[str, Any] = { "modules.auth.models", "modules.users.models", "modules.organizations.models", + "modules.invitations.models", ] } diff --git a/api/asset_manager/src/migrations/models/0_20250625121149_init.py b/api/asset_manager/src/migrations/models/0_20250709154845_init.py similarity index 84% rename from api/asset_manager/src/migrations/models/0_20250625121149_init.py rename to api/asset_manager/src/migrations/models/0_20250709154845_init.py index a1585f4a..3f17e96c 100644 --- a/api/asset_manager/src/migrations/models/0_20250625121149_init.py +++ b/api/asset_manager/src/migrations/models/0_20250709154845_init.py @@ -12,9 +12,6 @@ async def upgrade(db: BaseDBAsyncClient) -> str: "ADMIN" INT NOT NULL DEFAULT 0 ) /* ACL */; CREATE TABLE IF NOT EXISTS "organization" ( - "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - "modified_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - "disabled_at" TIMESTAMP, "id" CHAR(36) NOT NULL PRIMARY KEY, "name" VARCHAR(128) NOT NULL, "type" VARCHAR(128) NOT NULL, @@ -23,41 +20,56 @@ CREATE TABLE IF NOT EXISTS "organization" ( "state" VARCHAR(128), "city" VARCHAR(128), "country" VARCHAR(128), - "disabled" INT NOT NULL DEFAULT 0 -) /* Organization */; -CREATE TABLE IF NOT EXISTS "user" ( + "disabled" INT NOT NULL DEFAULT 0, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "modified_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - "disabled_at" TIMESTAMP, + "disabled_at" TIMESTAMP +) /* Organization */; +CREATE TABLE IF NOT EXISTS "user" ( "id" CHAR(36) NOT NULL PRIMARY KEY, "email" VARCHAR(128) NOT NULL, "username" TEXT NOT NULL, "name" TEXT NOT NULL, "surname" TEXT NOT NULL, "password" VARCHAR(128), - "disabled" INT NOT NULL DEFAULT 0 -) /* User */; -CREATE TABLE IF NOT EXISTS "token" ( + "disabled" INT NOT NULL DEFAULT 0, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "modified_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - "disabled_at" TIMESTAMP, + "disabled_at" TIMESTAMP +) /* User */; +CREATE TABLE IF NOT EXISTS "token" ( "id" CHAR(36) NOT NULL PRIMARY KEY, "token_type" VARCHAR(128) NOT NULL DEFAULT 'Bearer', "access_token" TEXT, "refresh_token" TEXT, "disabled" INT NOT NULL DEFAULT 0, - "user_id" CHAR(36) NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE -) /* Token */; -CREATE TABLE IF NOT EXISTS "membership" ( "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "modified_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "disabled_at" TIMESTAMP, + "user_id" CHAR(36) NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE +) /* Token */; +CREATE TABLE IF NOT EXISTS "membership" ( "id" CHAR(36) NOT NULL PRIMARY KEY, "disabled" INT NOT NULL DEFAULT 0, + "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + "modified_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + "disabled_at" TIMESTAMP, "acl_id" CHAR(36) NOT NULL REFERENCES "acl" ("id") ON DELETE CASCADE, "organization_id" CHAR(36) NOT NULL REFERENCES "organization" ("id") ON DELETE CASCADE, "user_id" CHAR(36) NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE ) /* Membership */; +CREATE TABLE IF NOT EXISTS "invite" ( + "id" CHAR(36) NOT NULL PRIMARY KEY, + "receiver" VARCHAR(128) NOT NULL, + "sender" CHAR(36) NOT NULL, + "org_id" CHAR(36) NOT NULL, + "message" TEXT, + "accepted" INT NOT NULL, + "disabled" INT NOT NULL DEFAULT 0, + "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + "modified_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + "disabled_at" TIMESTAMP +); CREATE TABLE IF NOT EXISTS "aerich" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "version" VARCHAR(255) NOT NULL, diff --git a/api/asset_manager/src/mixins/CMDMixin.py b/api/asset_manager/src/mixins/CMDMixin.py deleted file mode 100644 index 1d3b1b11..00000000 --- a/api/asset_manager/src/mixins/CMDMixin.py +++ /dev/null @@ -1,11 +0,0 @@ -from tortoise import fields - -class CMDMixin(): - """ - Created, modified and delete mixin, these are required for every class. - """ - - created_at = fields.DatetimeField(null=True, auto_now_add=True) - modified_at = fields.DatetimeField(null=True, auto_now=True) - disabled_at = fields.DatetimeField(null=True) - diff --git a/api/asset_manager/src/modules/auth/models.py b/api/asset_manager/src/modules/auth/models.py index a1c3b0cb..fe5fa3f5 100644 --- a/api/asset_manager/src/modules/auth/models.py +++ b/api/asset_manager/src/modules/auth/models.py @@ -4,11 +4,8 @@ from tortoise import fields import uuid from datetime import datetime -from mixins.CMDMixin import CMDMixin -from config import settings - -class Token(Model, CMDMixin): +class Token(Model): """ Token @@ -21,9 +18,11 @@ class Token(Model, CMDMixin): access_token: str = fields.TextField(null=True) refresh_token: str = fields.TextField(null=True) disabled: bool = fields.BooleanField(default=False) + created_at = fields.DatetimeField(null=True, auto_now_add=True) + modified_at = fields.DatetimeField(null=True, auto_now=True) + disabled_at = fields.DatetimeField(null=True) async def delete(self) -> None: self.disabled = True self.disabled_at = datetime.now(tz=pytz.UTC) await self.save() - diff --git a/api/asset_manager/src/modules/invitations/models.py b/api/asset_manager/src/modules/invitations/models.py index 9e276916..582dfa33 100644 --- a/api/asset_manager/src/modules/invitations/models.py +++ b/api/asset_manager/src/modules/invitations/models.py @@ -1,15 +1,15 @@ - import uuid from tortoise import Model, fields -from mixins import CMDMixin -from modules.users.models import User - -class Invite(Model, CMDMixin): +class Invite(Model): id: uuid.UUID = fields.UUIDField(primary_key=True) receiver: str = fields.CharField(max_length=128) - sender: str = fields.UUIDField() + sender: uuid.UUID = fields.UUIDField() + org_id: uuid.UUID = fields.UUIDField() message: str | None = fields.TextField(null=True) accepted: bool = fields.BooleanField() disabled: bool = fields.BooleanField(default=False) + created_at = fields.DatetimeField(null=True, auto_now_add=True) + modified_at = fields.DatetimeField(null=True, auto_now=True) + disabled_at = fields.DatetimeField(null=True) diff --git a/api/asset_manager/src/modules/invitations/router.py b/api/asset_manager/src/modules/invitations/router.py index b4c540bb..5cfd4c07 100644 --- a/api/asset_manager/src/modules/invitations/router.py +++ b/api/asset_manager/src/modules/invitations/router.py @@ -1,8 +1,10 @@ -from typing import Annotated +from typing import Annotated, List import uuid from fastapi import APIRouter, Depends, HTTPException from modules.invitations.models import Invite +from modules.invitations.schemas import invitation_model + from modules.users.models import User from modules.users.utils import get_current_active_user @@ -12,44 +14,63 @@ from tortoise.expressions import Q router = APIRouter(prefix="/api/v1/Users", tags=["User"]) -@router.get("/") -async def get_all_invitations(user: Annotated[User, Depends(get_current_active_user)]): - pass +@router.get("/", response_model=List[invitation_model]) +async def get_all_invitations( + user: Annotated[User, Depends(get_current_active_user)], +) -> List[Invite]: + invites: List[Invite] | None = await Invite.filter( + Q(receiver=user.username) | Q(receiver=user.email) + ) + + return invites -@router.get("/accept/{invitation_id}") +@router.get("/accept/{invitation_id}", response_model=invitation_model) async def accept_invitation( user: Annotated[User, Depends(get_current_active_user)], invitation_id: uuid.UUID -): +) -> Invite: invite: Invite | None = await Invite.get_or_none( Q(id=invitation_id) & (Q(receiver=user.username) | Q(receiver=user.email)) ) + + if not invite: + raise HTTPException( + status_code=403, + detail="The invitation doesn't exist or you don't have access to it.", + ) + invite.accepted = True - invite.save() - return invite + return await invite.save() - -@router.get("/reject/{invitation_id}") +@router.get("/reject/{invitation_id}", response_model=invitation_model) async def reject_invitation( user: Annotated[User, Depends(get_current_active_user)], invitation_id: uuid.UUID ) -> Invite: invite: Invite | None = await Invite.get_or_none( Q(id=invitation_id) & (Q(receiver=user.username) | Q(receiver=user.email)) ) + + if not invite: + raise HTTPException( + status_code=403, + detail="The invitation doesn't exist or you don't have access to it.", + ) + invite.accepted = False - invite.save() - return invite + return await invite.save() @router.get("/send") -async def accept_invitation( +async def send_invitation( user: Annotated[User, Depends(get_current_active_user)], ): + # Check if user is Manager or Higher to send an invitation. + # Should send an E-Mail as notification. pass -@router.get("/cancel/{invitation_id}", status_code=204) +@router.get("/cancel/{invitation_id}", response_model=invitation_model) async def accept_invitation( user: Annotated[User, Depends(get_current_active_user)], invitation_id: uuid.UUID ) -> None: @@ -63,4 +84,4 @@ async def accept_invitation( detail="The invitation doesn't exist or you don't have access to it.", ) - invite.delete() + return await invite.delete() diff --git a/api/asset_manager/src/modules/invitations/schemas.py b/api/asset_manager/src/modules/invitations/schemas.py index e69de29b..2ef3455c 100644 --- a/api/asset_manager/src/modules/invitations/schemas.py +++ b/api/asset_manager/src/modules/invitations/schemas.py @@ -0,0 +1,7 @@ + +from tortoise.contrib.pydantic import pydantic_model_creator + +from modules.invitations.models import Invite + +invitation_model = pydantic_model_creator(Invite) + diff --git a/api/asset_manager/src/modules/organizations/models.py b/api/asset_manager/src/modules/organizations/models.py index 970ea68b..306d6194 100644 --- a/api/asset_manager/src/modules/organizations/models.py +++ b/api/asset_manager/src/modules/organizations/models.py @@ -7,7 +7,6 @@ from tortoise.exceptions import ConfigurationError from tortoise.models import Model from tortoise import fields -from mixins.CMDMixin import CMDMixin class EnumField(fields.CharField): """ @@ -52,8 +51,7 @@ class OrganizationType(Enum): EXTRA_LARGE_ORGANIZATION: str = "xl_org" # 1000 - 5000+ - -class Organization(Model, CMDMixin): +class Organization(Model): """ Organization @@ -79,6 +77,9 @@ class Organization(Model, CMDMixin): on_delete=fields.NO_ACTION, ) disabled: bool = fields.BooleanField(default=False) + created_at = fields.DatetimeField(null=True, auto_now_add=True) + modified_at = fields.DatetimeField(null=True, auto_now=True) + disabled_at = fields.DatetimeField(null=True) def __str__(self) -> str: return f"{self.id} - {self.name}" @@ -90,5 +91,3 @@ class Organization(Model, CMDMixin): self.disabled = True self.disabled_at = datetime.now(tz=pytz.UTC) await self.save() - - diff --git a/api/asset_manager/src/modules/organizations/router.py b/api/asset_manager/src/modules/organizations/router.py index 07922520..ba4be69a 100644 --- a/api/asset_manager/src/modules/organizations/router.py +++ b/api/asset_manager/src/modules/organizations/router.py @@ -20,11 +20,10 @@ router = APIRouter(prefix="/api/v1/organizations", tags=["orgs"]) async def all_active_organizations( user: Annotated[User, Depends(get_current_active_user)], ) -> List[Organization]: - memberships: List[Membership] = list( - await Membership.filter( - Q(user_id=user.id) & Q(disabled=False) - ).prefetch_related("organization") - ) + memberships: List[Membership] = await Membership.filter( + Q(user_id=user.id) & Q(disabled=False) + ).prefetch_related("organization") + organizations: List[Organization] = [] if len(memberships) < 1: @@ -59,7 +58,7 @@ async def delete_organization( for member in all_memberships: await member.acl.delete() await member.delete() - + await membership.acl.delete() await membership.delete() return diff --git a/api/asset_manager/src/modules/users/models.py b/api/asset_manager/src/modules/users/models.py index 6afb9dd5..c7d26362 100644 --- a/api/asset_manager/src/modules/users/models.py +++ b/api/asset_manager/src/modules/users/models.py @@ -6,13 +6,12 @@ from tortoise.models import Model from tortoise import fields from modules.organizations.models import Organization -from mixins.CMDMixin import CMDMixin from config import settings crypt = settings.CRYPT -class User(Model, CMDMixin): +class User(Model): """ User @@ -35,7 +34,9 @@ class User(Model, CMDMixin): on_delete=fields.NO_ACTION, ) disabled: bool = fields.BooleanField(default=False) - # tokens = fields.ForeignKeyField("models.Token") + created_at = fields.DatetimeField(null=True, auto_now_add=True) + modified_at = fields.DatetimeField(null=True, auto_now=True) + disabled_at = fields.DatetimeField(null=True) def __str__(self) -> str: return f"{self.id} - {self.name} {self.surname}" @@ -90,7 +91,7 @@ class ACL(Model): """ -class Membership(Model, CMDMixin): +class Membership(Model): """ Membership @@ -102,6 +103,9 @@ class Membership(Model, CMDMixin): user: User = fields.ForeignKeyField("models.User") acl: ACL = fields.ForeignKeyField("models.ACL") disabled: bool = fields.BooleanField(default=False) + created_at = fields.DatetimeField(null=True, auto_now_add=True) + modified_at = fields.DatetimeField(null=True, auto_now=True) + disabled_at = fields.DatetimeField(null=True) async def delete(self, force: bool = False) -> None: if force: diff --git a/api/asset_manager/src/requirements/requirements.txt b/api/asset_manager/src/requirements/requirements.txt index dd831c37..917f1342 100644 --- a/api/asset_manager/src/requirements/requirements.txt +++ b/api/asset_manager/src/requirements/requirements.txt @@ -4,7 +4,7 @@ tortoise-orm[asyncpg]>=0.25.1 uvicorn>=0.34.3 black>=25.1.0 joserfc>=1.1.0 -passlib>=1.7.4 +pwdlib[argon2]>=0.2.1 pytz>=2025.2 ptpython>=3.0.30 msgspec>=0.19.0 From 8b7330755119b606b43ed570162a5ce06779640e Mon Sep 17 00:00:00 2001 From: Jeroen Vijgen Date: Sat, 12 Jul 2025 13:51:56 +0000 Subject: [PATCH 3/4] Finish api for inviting people to your organization(s) --- ...54845_init.py => 3_20250710123814_None.py} | 9 +- api/asset_manager/src/modules/auth/router.py | 25 +- .../src/modules/invitations/models.py | 17 +- .../src/modules/invitations/router.py | 219 +++++++-- .../src/modules/invitations/schemas.py | 15 +- api/asset_manager/src/modules/users/models.py | 4 +- .../test_authentication.py | 1 - .../test_invitation_routes.py | 457 ++++++++++++++++++ .../test_organization_routes.py | 7 +- 9 files changed, 685 insertions(+), 69 deletions(-) rename api/asset_manager/src/migrations/models/{0_20250709154845_init.py => 3_20250710123814_None.py} (92%) create mode 100644 api/asset_manager/src/tests/test_invitation_routes/test_invitation_routes.py diff --git a/api/asset_manager/src/migrations/models/0_20250709154845_init.py b/api/asset_manager/src/migrations/models/3_20250710123814_None.py similarity index 92% rename from api/asset_manager/src/migrations/models/0_20250709154845_init.py rename to api/asset_manager/src/migrations/models/3_20250710123814_None.py index 3f17e96c..6078219b 100644 --- a/api/asset_manager/src/migrations/models/0_20250709154845_init.py +++ b/api/asset_manager/src/migrations/models/3_20250710123814_None.py @@ -55,8 +55,8 @@ CREATE TABLE IF NOT EXISTS "membership" ( "modified_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "disabled_at" TIMESTAMP, "acl_id" CHAR(36) NOT NULL REFERENCES "acl" ("id") ON DELETE CASCADE, - "organization_id" CHAR(36) NOT NULL REFERENCES "organization" ("id") ON DELETE CASCADE, - "user_id" CHAR(36) NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE + "organization_id" CHAR(36) REFERENCES "organization" ("id") ON DELETE SET NULL, + "user_id" CHAR(36) REFERENCES "user" ("id") ON DELETE SET NULL ) /* Membership */; CREATE TABLE IF NOT EXISTS "invite" ( "id" CHAR(36) NOT NULL PRIMARY KEY, @@ -64,11 +64,12 @@ CREATE TABLE IF NOT EXISTS "invite" ( "sender" CHAR(36) NOT NULL, "org_id" CHAR(36) NOT NULL, "message" TEXT, - "accepted" INT NOT NULL, + "accepted" INT NOT NULL DEFAULT 0, "disabled" INT NOT NULL DEFAULT 0, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "modified_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - "disabled_at" TIMESTAMP + "disabled_at" TIMESTAMP, + "acl_id" CHAR(36) NOT NULL REFERENCES "acl" ("id") ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS "aerich" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, diff --git a/api/asset_manager/src/modules/auth/router.py b/api/asset_manager/src/modules/auth/router.py index f1d80b56..c42b8931 100644 --- a/api/asset_manager/src/modules/auth/router.py +++ b/api/asset_manager/src/modules/auth/router.py @@ -1,13 +1,12 @@ from datetime import datetime from typing import Annotated -import uuid from fastapi.security import OAuth2PasswordRequestForm from fastapi.routing import APIRouter import pytz from modules.users.utils import get_current_active_user +from modules.users.models import User from modules.auth.utils import create_jwt_tokens, get_tokens_from_logged_in_user from modules.auth.models import Token -from modules.users.models import User from fastapi import Depends, HTTPException, status from tortoise.expressions import Q from config import settings @@ -31,23 +30,31 @@ async def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]): Logs the user into our API, creates tokens and passes them back to User. """ - user: User | None = await User.filter(Q(email=form.username) | Q(username=form.username)).first() + user: User | None = await User.filter( + Q(email=form.username) | Q(username=form.username) + ).first() if user is None: - raise HTTPException(status_code=401, detail=account_error) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail=account_error + ) if user.check_against_password(form.password) is False: - raise HTTPException(status_code=401, detail=account_error) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail=account_error + ) if user.disabled is True: - raise HTTPException(status_code=401, detail=account_error) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail=account_error + ) tokens = await create_jwt_tokens(user) return {"jwt": tokens} -@router.get("/logout", status_code=204) +@router.get("/logout", status_code=status.HTTP_204_NO_CONTENT) async def logout(user: Annotated[User, Depends(get_current_active_user)]): """ Logout @@ -110,7 +117,9 @@ async def refresh_login( return {"jwt": tokens} -@router.post("/register", status_code=201, response_model=user_model) +@router.post( + "/register", status_code=status.HTTP_201_CREATED, response_model=user_model +) async def register(user: register_model): # Prevent existing users from reapplying for our system. existing_user: User | None = await User.filter( diff --git a/api/asset_manager/src/modules/invitations/models.py b/api/asset_manager/src/modules/invitations/models.py index 582dfa33..3e0c5bce 100644 --- a/api/asset_manager/src/modules/invitations/models.py +++ b/api/asset_manager/src/modules/invitations/models.py @@ -1,6 +1,10 @@ +from datetime import datetime import uuid +import pytz from tortoise import Model, fields +from modules.users.models import ACL + class Invite(Model): id: uuid.UUID = fields.UUIDField(primary_key=True) @@ -8,8 +12,19 @@ class Invite(Model): sender: uuid.UUID = fields.UUIDField() org_id: uuid.UUID = fields.UUIDField() message: str | None = fields.TextField(null=True) - accepted: bool = fields.BooleanField() + acl: ACL = fields.ForeignKeyField("models.ACL") + accepted: bool = fields.BooleanField(default=False) disabled: bool = fields.BooleanField(default=False) created_at = fields.DatetimeField(null=True, auto_now_add=True) modified_at = fields.DatetimeField(null=True, auto_now=True) disabled_at = fields.DatetimeField(null=True) + + + async def delete(self, force: bool = False) -> None: + if force: + await Model.delete(self) + else: + self.disabled = True + self.disabled_at = datetime.now(tz=pytz.UTC) + await self.save() + diff --git a/api/asset_manager/src/modules/invitations/router.py b/api/asset_manager/src/modules/invitations/router.py index 5cfd4c07..94697740 100644 --- a/api/asset_manager/src/modules/invitations/router.py +++ b/api/asset_manager/src/modules/invitations/router.py @@ -1,36 +1,54 @@ from typing import Annotated, List import uuid -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, status +from modules.organizations.models import Organization from modules.invitations.models import Invite -from modules.invitations.schemas import invitation_model +from modules.invitations.schemas import invitation_model, send_invitation_for_org -from modules.users.models import User +from modules.users.models import ACL, Membership, User from modules.users.utils import get_current_active_user from tortoise.expressions import Q - -router = APIRouter(prefix="/api/v1/Users", tags=["User"]) +router = APIRouter(prefix="/api/v1/invitations", tags=["invites"]) @router.get("/", response_model=List[invitation_model]) async def get_all_invitations( user: Annotated[User, Depends(get_current_active_user)], ) -> List[Invite]: - invites: List[Invite] | None = await Invite.filter( - Q(receiver=user.username) | Q(receiver=user.email) + """Returns all invitations for user requesting, except disabled invites. + + Args: + user (Annotated[User, Depends(get_current_active_user)]): Returns user token. + + Returns: + List[Invite]: A list of invitations. + """ + return await Invite.filter( + Q(receiver=user.username) | Q(receiver=user.email) & Q(disabled=False) ) - return invites - -@router.get("/accept/{invitation_id}", response_model=invitation_model) -async def accept_invitation( +@router.delete("/{invitation_id}", response_model=invitation_model) +async def delete_invitation( user: Annotated[User, Depends(get_current_active_user)], invitation_id: uuid.UUID -) -> Invite: +) -> None: + """Removes an invitation you have sent + + Args: + user (Annotated[User, Depends(get_current_active_user)]): Returns user token. + invitation_id (uuid.UUID): UUID for the invitation to be removed + + Raises: + HTTPException: When invitation doesn't exist return 403. + + Returns: + Invite: The Invitation model. + """ invite: Invite | None = await Invite.get_or_none( - Q(id=invitation_id) & (Q(receiver=user.username) | Q(receiver=user.email)) + Q(id=invitation_id) & Q(sender=user.id) & Q(disabled=False) ) if not invite: @@ -39,44 +57,29 @@ async def accept_invitation( detail="The invitation doesn't exist or you don't have access to it.", ) - invite.accepted = True - return await invite.save() + await invite.delete() + return invite -@router.get("/reject/{invitation_id}", response_model=invitation_model) -async def reject_invitation( - user: Annotated[User, Depends(get_current_active_user)], invitation_id: uuid.UUID -) -> Invite: - invite: Invite | None = await Invite.get_or_none( - Q(id=invitation_id) & (Q(receiver=user.username) | Q(receiver=user.email)) - ) - - if not invite: - raise HTTPException( - status_code=403, - detail="The invitation doesn't exist or you don't have access to it.", - ) - - invite.accepted = False - return await invite.save() - - -@router.get("/send") -async def send_invitation( - user: Annotated[User, Depends(get_current_active_user)], -): - # Check if user is Manager or Higher to send an invitation. - # Should send an E-Mail as notification. - pass - - -@router.get("/cancel/{invitation_id}", response_model=invitation_model) +@router.get("/accept/{invitation_id}", status_code=status.HTTP_204_NO_CONTENT) async def accept_invitation( user: Annotated[User, Depends(get_current_active_user)], invitation_id: uuid.UUID ) -> None: + """Accepts the invitation sent by a different organization. + + Args: + user (Annotated[User, Depends(get_current_active_user)]): Returns user token. + invitation_id (uuid.UUID): UUID for the organization that the users wants to add the person to. + + Raises: + HTTPException: Raises exception when invite is not available or disabled. + + """ invite: Invite | None = await Invite.get_or_none( - Q(id=invitation_id) & Q(sender=user.id) - ) + Q(id=invitation_id) + & (Q(receiver=user.username) | Q(receiver=user.email)) + & Q(disabled=False) + ).prefetch_related("acl") if not invite: raise HTTPException( @@ -84,4 +87,128 @@ async def accept_invitation( detail="The invitation doesn't exist or you don't have access to it.", ) - return await invite.delete() + if invite.disabled: + raise HTTPException( + status_code=403, + detail="You have already declined the invitation or the invitation was removed, you can't accept it.", + ) + + invite.accepted = True + await invite.save() + # Disable invite after accepting, prevent changing it. + await invite.delete() + + await Membership.create( + user=user, organization=await Organization.get(id=invite.org_id), acl=invite.acl + ) + + +@router.get("/decline/{invitation_id}", status_code=status.HTTP_204_NO_CONTENT) +async def reject_invitation( + user: Annotated[User, Depends(get_current_active_user)], invitation_id: uuid.UUID +) -> None: + """Declines an invitation to join an organization + + Args: + user (Annotated[User, Depends(get_current_active_user)]): Returns user token. + invitation_id (uuid.UUID): The UUID of the invitation + + Raises: + HTTPException: Checks if the invite exists. + HTTPException: Checks if the invite has already accepted. + + Returns: + Invite: The Invitation model. + """ + invite: Invite | None = await Invite.get_or_none( + Q(id=invitation_id) + & (Q(receiver=user.username) | Q(receiver=user.email)) + & Q(disabled=False) + ).prefetch_related("acl") + + if not invite: + raise HTTPException( + status_code=403, + detail="The invitation doesn't exist or you don't have access to it.", + ) + + if invite.accepted: + raise HTTPException( + status_code=403, + detail="The invitation was already accepted, you can't remove it.", + ) + + await invite.delete() + + +@router.post("/send", response_model=invitation_model) +async def send_invitation( + user: Annotated[User, Depends(get_current_active_user)], + invite_details: send_invitation_for_org, +) -> Invite: + """Sends an invitation to e-mail or username. + + Args: + user (Annotated[User, Depends(get_current_active_user)]): Returns user token. + invite_details (send_invitation_for_org): The details for the invitation. + + Raises: + HTTPException: Checks access to the organization posted. + HTTPException: Checks for Manager or Admin permissions and declines if you are not. + + Returns: + Invite: The Invitation model. + """ + # Should send an E-Mail as notification. + membership = await Membership.get_or_none( + Q(user=user.id) & Q(organization=invite_details.org_id) + ).prefetch_related("acl") + + if not membership: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have access to this organization.", + ) + + if not membership.acl.MANAGE or not membership.acl.ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You are not allowed to send invitations for this organization.", + ) + + # Check if user is already part of organization + invited_user: User | None = await User.get_or_none( + Q(username=invite_details.receiver) | Q(email=invite_details.receiver) + ) + + if invited_user: + user_is_part_of_org = await Membership.get_or_none( + Q(user=invited_user) & Q(organization=invite_details.org_id) + ) + if user_is_part_of_org: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="The person you've invited is already part of the organization.", + ) + + acl = None + if invite_details.acl: + acl = await ACL.create( + READ=invite_details.acl.READ, + WRITE=invite_details.acl.WRITE, + REPORT=invite_details.acl.REPORT, + MANAGE=invite_details.acl.MANAGE, + ADMIN=invite_details.acl.ADMIN, + ) + else: + acl = await ACL.create( + READ=True, + ) + + return await Invite.create( + receiver=invite_details.receiver, + sender=user.id, + org_id=invite_details.org_id, + message=invite_details.message, + acl=acl, + ) diff --git a/api/asset_manager/src/modules/invitations/schemas.py b/api/asset_manager/src/modules/invitations/schemas.py index 2ef3455c..432493ea 100644 --- a/api/asset_manager/src/modules/invitations/schemas.py +++ b/api/asset_manager/src/modules/invitations/schemas.py @@ -1,7 +1,20 @@ - +import uuid +from pydantic import BaseModel from tortoise.contrib.pydantic import pydantic_model_creator from modules.invitations.models import Invite invitation_model = pydantic_model_creator(Invite) +class acl_model(BaseModel): + READ: bool + WRITE: bool + REPORT: bool + MANAGE: bool + ADMIN: bool + +class send_invitation_for_org(BaseModel): + org_id: uuid.UUID + receiver: str + acl: acl_model | None + message: str | None diff --git a/api/asset_manager/src/modules/users/models.py b/api/asset_manager/src/modules/users/models.py index c7d26362..be2c70dc 100644 --- a/api/asset_manager/src/modules/users/models.py +++ b/api/asset_manager/src/modules/users/models.py @@ -99,8 +99,8 @@ class Membership(Model): """ id: uuid.UUID = fields.UUIDField(primary_key=True) - organization: Organization = fields.ForeignKeyField("models.Organization") - user: User = fields.ForeignKeyField("models.User") + organization: Organization = fields.ForeignKeyField("models.Organization", null=True, on_delete=fields.SET_NULL) + user: User = fields.ForeignKeyField("models.User", null=True, on_delete=fields.SET_NULL) acl: ACL = fields.ForeignKeyField("models.ACL") disabled: bool = fields.BooleanField(default=False) created_at = fields.DatetimeField(null=True, auto_now_add=True) diff --git a/api/asset_manager/src/tests/test_authentication/test_authentication.py b/api/asset_manager/src/tests/test_authentication/test_authentication.py index c8fe4756..96d0b510 100644 --- a/api/asset_manager/src/tests/test_authentication/test_authentication.py +++ b/api/asset_manager/src/tests/test_authentication/test_authentication.py @@ -1,6 +1,5 @@ from tests.base_test import Test from modules.users.models import User -import pytest # type: ignore from httpx import AsyncClient from config import settings from unittest.mock import ANY diff --git a/api/asset_manager/src/tests/test_invitation_routes/test_invitation_routes.py b/api/asset_manager/src/tests/test_invitation_routes/test_invitation_routes.py new file mode 100644 index 00000000..06e0e430 --- /dev/null +++ b/api/asset_manager/src/tests/test_invitation_routes/test_invitation_routes.py @@ -0,0 +1,457 @@ +from unittest.mock import ANY +from httpx import AsyncClient +from modules.users.models import ACL, Membership +from tests.base_test import Test + + +class TestInvitationalRoutes(Test): + async def test_send_invitations(self, client: AsyncClient, create_user_with_org): + admin, org, _, admintokens = await create_user_with_org( + email="superadmin12@localhost.com", + username="awesomeadmin", + password="awesomeadmin", + is_admin=True, + ) + _, _, _, usertokens = await create_user_with_org(email="user1231@localhost.com") + + invite = await client.post( + "https://localhost/api/v1/invitations/send", + json={ + "org_id": str(org.id), + "receiver": "user1231@localhost.com", + "acl": None, + "message": "Hi! We would like to invite you to our organization.", + }, + headers={"Authorization": f"Bearer {admintokens.access_token}"}, + ) + + assert invite.status_code == 200 + assert invite.json() == { + "accepted": False, + "created_at": ANY, + "disabled": False, + "disabled_at": None, + "id": ANY, + "message": "Hi! We would like to invite you to our organization.", + "modified_at": ANY, + "org_id": str(org.id), + "receiver": "user1231@localhost.com", + "sender": str(admin.id), + } + + user_invites = await client.get( + "https://localhost/api/v1/invitations/", + headers={"Authorization": f"Bearer {usertokens.access_token}"}, + ) + + assert user_invites.status_code == 200 + assert user_invites.json() == [ + { + "id": ANY, + "receiver": "user1231@localhost.com", + "sender": str(admin.id), + "org_id": str(org.id), + "message": "Hi! We would like to invite you to our organization.", + "accepted": False, + "disabled": False, + "created_at": ANY, + "modified_at": ANY, + "disabled_at": None, + } + ] + + async def test_cannot_see_others_invitations( + self, client: AsyncClient, create_user_with_org + ): + admin, org, _, admintokens = await create_user_with_org( + email="superadmin99@localhost.com", + username="awesomeadmin", + password="awesomeadmin", + is_admin=True, + ) + _, _, _, usertokens = await create_user_with_org(email="user18@localhost.com") + + invite = await client.post( + "https://localhost/api/v1/invitations/send", + json={ + "org_id": str(org.id), + "receiver": "user1231@localhost.com", + "acl": None, + "message": "Hi! We would like to invite you to our organization.", + }, + headers={"Authorization": f"Bearer {admintokens.access_token}"}, + ) + + assert invite.status_code == 200 + assert invite.json() == { + "accepted": False, + "created_at": ANY, + "disabled": False, + "disabled_at": None, + "id": ANY, + "message": "Hi! We would like to invite you to our organization.", + "modified_at": ANY, + "org_id": str(org.id), + "receiver": "user1231@localhost.com", + "sender": str(admin.id), + } + + user_invites = await client.get( + "https://localhost/api/v1/invitations/", + headers={"Authorization": f"Bearer {usertokens.access_token}"}, + ) + + assert user_invites.status_code == 200 + assert user_invites.json() == [] + + async def test_accept_sent_invitations( + self, client: AsyncClient, create_user_with_org + ): + admin, org, _, admintokens = await create_user_with_org( + email="superadmin191@localhost.com", + username="awesomeadmin", + password="awesomeadmin", + is_admin=True, + ) + _, _, _, usertokens = await create_user_with_org(email="user8@localhost.com") + + invite = await client.post( + "https://localhost/api/v1/invitations/send", + json={ + "org_id": str(org.id), + "receiver": "user8@localhost.com", + "acl": None, + "message": "Hi! We would like to invite you to our organization.", + }, + headers={"Authorization": f"Bearer {admintokens.access_token}"}, + ) + + assert invite.status_code == 200 + assert invite.json() == { + "accepted": False, + "created_at": ANY, + "disabled": False, + "disabled_at": None, + "id": ANY, + "message": "Hi! We would like to invite you to our organization.", + "modified_at": ANY, + "org_id": str(org.id), + "receiver": "user8@localhost.com", + "sender": str(admin.id), + } + + user_invites = await client.get( + "https://localhost/api/v1/invitations/", + headers={"Authorization": f"Bearer {usertokens.access_token}"}, + ) + + assert user_invites.status_code == 200 + assert user_invites.json() == [ + { + "id": ANY, + "receiver": "user8@localhost.com", + "sender": str(admin.id), + "org_id": str(org.id), + "message": "Hi! We would like to invite you to our organization.", + "accepted": False, + "disabled": False, + "created_at": ANY, + "modified_at": ANY, + "disabled_at": None, + } + ] + + invite_id = user_invites.json()[0]["id"] + + accept_invite = await client.get( + f"https://localhost/api/v1/invitations/accept/{invite_id}", + headers={"Authorization": f"Bearer {usertokens.access_token}"}, + ) + + assert accept_invite.status_code == 204 + + user_invites = await client.get( + "https://localhost/api/v1/invitations/", + headers={"Authorization": f"Bearer {usertokens.access_token}"}, + ) + + assert user_invites.status_code == 200 + # When an invite has been accepted, it should be removed. + assert user_invites.json() == [] + + async def test_decline_sent_invitations( + self, client: AsyncClient, create_user_with_org + ): + admin, org, _, admintokens = await create_user_with_org( + email="superadmin11@localhost.com", + username="awesomeadmin", + password="awesomeadmin", + is_admin=True, + ) + _, _, _, usertokens = await create_user_with_org(email="user98@localhost.com") + + invite = await client.post( + "https://localhost/api/v1/invitations/send", + json={ + "org_id": str(org.id), + "receiver": "user98@localhost.com", + "acl": None, + "message": "Hi! We would like to invite you to our organization.", + }, + headers={"Authorization": f"Bearer {admintokens.access_token}"}, + ) + + assert invite.status_code == 200 + assert invite.json() == { + "accepted": False, + "created_at": ANY, + "disabled": False, + "disabled_at": None, + "id": ANY, + "message": "Hi! We would like to invite you to our organization.", + "modified_at": ANY, + "org_id": str(org.id), + "receiver": "user98@localhost.com", + "sender": str(admin.id), + } + + user_invites = await client.get( + "https://localhost/api/v1/invitations/", + headers={"Authorization": f"Bearer {usertokens.access_token}"}, + ) + + assert user_invites.status_code == 200 + assert user_invites.json() == [ + { + "id": ANY, + "receiver": "user98@localhost.com", + "sender": str(admin.id), + "org_id": str(org.id), + "message": "Hi! We would like to invite you to our organization.", + "accepted": False, + "disabled": False, + "created_at": ANY, + "modified_at": ANY, + "disabled_at": None, + } + ] + + invite_id = user_invites.json()[0]["id"] + + accept_invite = await client.get( + f"https://localhost/api/v1/invitations/decline/{invite_id}", + headers={"Authorization": f"Bearer {usertokens.access_token}"}, + ) + + assert accept_invite.status_code == 204 + + user_invites = await client.get( + "https://localhost/api/v1/invitations/", + headers={"Authorization": f"Bearer {usertokens.access_token}"}, + ) + + assert user_invites.status_code == 200 + assert user_invites.json() == [] + + async def test_prevent_accepting_when_declined_sent_invitations( + self, client: AsyncClient, create_user_with_org + ): + admin, org, _, admintokens = await create_user_with_org( + email="superadmin9612@localhost.com", + username="awesomeadmin", + password="awesomeadmin", + is_admin=True, + ) + _, _, _, usertokens = await create_user_with_org(email="user11918@localhost.com") + + invite = await client.post( + "https://localhost/api/v1/invitations/send", + json={ + "org_id": str(org.id), + "receiver": "user11918@localhost.com", + "acl": None, + "message": "Hi! We would like to invite you to our organization.", + }, + headers={"Authorization": f"Bearer {admintokens.access_token}"}, + ) + + assert invite.status_code == 200 + assert invite.json() == { + "accepted": False, + "created_at": ANY, + "disabled": False, + "disabled_at": None, + "id": ANY, + "message": "Hi! We would like to invite you to our organization.", + "modified_at": ANY, + "org_id": str(org.id), + "receiver": "user11918@localhost.com", + "sender": str(admin.id), + } + + user_invites = await client.get( + "https://localhost/api/v1/invitations/", + headers={"Authorization": f"Bearer {usertokens.access_token}"}, + ) + + assert user_invites.status_code == 200 + assert user_invites.json() == [ + { + "id": ANY, + "receiver": "user11918@localhost.com", + "sender": str(admin.id), + "org_id": str(org.id), + "message": "Hi! We would like to invite you to our organization.", + "accepted": False, + "disabled": False, + "created_at": ANY, + "modified_at": ANY, + "disabled_at": None, + } + ] + + invite_id = user_invites.json()[0]["id"] + + accept_invite = await client.get( + f"https://localhost/api/v1/invitations/decline/{invite_id}", + headers={"Authorization": f"Bearer {usertokens.access_token}"}, + ) + + assert accept_invite.status_code == 204 + + user_invites = await client.get( + "https://localhost/api/v1/invitations/", + headers={"Authorization": f"Bearer {usertokens.access_token}"}, + ) + + assert user_invites.status_code == 200 + assert user_invites.json() == [] + + accept_invite = await client.get( + f"https://localhost/api/v1/invitations/accept/{invite_id}", + headers={"Authorization": f"Bearer {usertokens.access_token}"}, + ) + + assert accept_invite.status_code == 403 + assert accept_invite.json() == { + "detail": "The invitation doesn't exist or you don't have access to it." + } + + async def test_prevent_declining_when_accepted_sent_invitations( + self, client: AsyncClient, create_user_with_org + ): + admin, org, _, admintokens = await create_user_with_org( + email="superadmin9712@localhost.com", + username="awesomeadmin", + password="awesomeadmin", + is_admin=True, + ) + _, _, _, usertokens = await create_user_with_org(email="user14918@localhost.com") + + invite = await client.post( + "https://localhost/api/v1/invitations/send", + json={ + "org_id": str(org.id), + "receiver": "user14918@localhost.com", + "acl": None, + "message": "Hi! We would like to invite you to our organization.", + }, + headers={"Authorization": f"Bearer {admintokens.access_token}"}, + ) + + assert invite.status_code == 200 + assert invite.json() == { + "accepted": False, + "created_at": ANY, + "disabled": False, + "disabled_at": None, + "id": ANY, + "message": "Hi! We would like to invite you to our organization.", + "modified_at": ANY, + "org_id": str(org.id), + "receiver": "user14918@localhost.com", + "sender": str(admin.id), + } + + user_invites = await client.get( + "https://localhost/api/v1/invitations/", + headers={"Authorization": f"Bearer {usertokens.access_token}"}, + ) + + assert user_invites.status_code == 200 + assert user_invites.json() == [ + { + "id": ANY, + "receiver": "user14918@localhost.com", + "sender": str(admin.id), + "org_id": str(org.id), + "message": "Hi! We would like to invite you to our organization.", + "accepted": False, + "disabled": False, + "created_at": ANY, + "modified_at": ANY, + "disabled_at": None, + } + ] + + invite_id = user_invites.json()[0]["id"] + + accept_invite = await client.get( + f"https://localhost/api/v1/invitations/accept/{invite_id}", + headers={"Authorization": f"Bearer {usertokens.access_token}"}, + ) + + assert accept_invite.status_code == 204 + + user_invites = await client.get( + "https://localhost/api/v1/invitations/", + headers={"Authorization": f"Bearer {usertokens.access_token}"}, + ) + + assert user_invites.status_code == 200 + assert user_invites.json() == [] + + decline_invite = await client.get( + f"https://localhost/api/v1/invitations/accept/{invite_id}", + headers={"Authorization": f"Bearer {usertokens.access_token}"}, + ) + + assert decline_invite.status_code == 403 + assert decline_invite.json() == { + "detail": "The invitation doesn't exist or you don't have access to it." + } + + + async def test_prevent_adding_user_whom_already_belongs_to_your_organization( + self, client: AsyncClient, create_user_with_org + ): + _, org, _, admintokens = await create_user_with_org( + email="us3r123@localhost.com", + username="awesomeadmin", + password="awesomeadmin", + is_admin=True, + ) + + user, _, _, _ = await create_user_with_org(email="us3r1234@localhost.com") + + await Membership.create( + user=user, + organization=org, + acl=await ACL.create(READ=True) + ) + + invite = await client.post( + "https://localhost/api/v1/invitations/send", + json={ + "org_id": str(org.id), + "receiver": "us3r1234@localhost.com", + "acl": None, + "message": "Hi! We would like to invite you to our organization.", + }, + headers={"Authorization": f"Bearer {admintokens.access_token}"}, + ) + + assert invite.status_code == 403 + assert invite.json() == { + "detail": "The person you've invited is already part of the organization." + } diff --git a/api/asset_manager/src/tests/test_organizations_routes/test_organization_routes.py b/api/asset_manager/src/tests/test_organizations_routes/test_organization_routes.py index 13a8335c..64cde71f 100644 --- a/api/asset_manager/src/tests/test_organizations_routes/test_organization_routes.py +++ b/api/asset_manager/src/tests/test_organizations_routes/test_organization_routes.py @@ -1,12 +1,9 @@ from httpx import AsyncClient from modules.users.models import ACL, Membership from modules.organizations.models import Organization -from config import settings from unittest.mock import ANY from tests.base_test import Test -crypt = settings.CRYPT - class TestOrganizationRoute(Test): async def test_get_organizations_from_api( @@ -153,9 +150,7 @@ class TestOrganizationRoute(Test): READ=True, WRITE=True, REPORT=True, MANAGE=False, ADMIN=False ) - await Membership.create( - user=user, organization=organization, acl=acl - ) + await Membership.create(user=user, organization=organization, acl=acl) deleted_org = await client.delete( f"https://localhost/api/v1/organizations/{organization.id}", From a6f9504973fe0e52fde97a42ccc65b9c06ce28b1 Mon Sep 17 00:00:00 2001 From: Jeroen Vijgen Date: Sat, 12 Jul 2025 14:14:50 +0000 Subject: [PATCH 4/4] Add two more testcases regarding removing invitations and prevent accepting your own invitations --- .../src/modules/invitations/router.py | 6 +- .../test_invitation_routes.py | 90 +++++++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/api/asset_manager/src/modules/invitations/router.py b/api/asset_manager/src/modules/invitations/router.py index 94697740..16c9b8f3 100644 --- a/api/asset_manager/src/modules/invitations/router.py +++ b/api/asset_manager/src/modules/invitations/router.py @@ -27,11 +27,12 @@ async def get_all_invitations( List[Invite]: A list of invitations. """ return await Invite.filter( - Q(receiver=user.username) | Q(receiver=user.email) & Q(disabled=False) + (Q(sender=user.id) | (Q(receiver=user.username) | Q(receiver=user.email))) + & Q(disabled=False) ) -@router.delete("/{invitation_id}", response_model=invitation_model) +@router.delete("/{invitation_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_invitation( user: Annotated[User, Depends(get_current_active_user)], invitation_id: uuid.UUID ) -> None: @@ -58,7 +59,6 @@ async def delete_invitation( ) await invite.delete() - return invite @router.get("/accept/{invitation_id}", status_code=status.HTTP_204_NO_CONTENT) diff --git a/api/asset_manager/src/tests/test_invitation_routes/test_invitation_routes.py b/api/asset_manager/src/tests/test_invitation_routes/test_invitation_routes.py index 06e0e430..f7be41ab 100644 --- a/api/asset_manager/src/tests/test_invitation_routes/test_invitation_routes.py +++ b/api/asset_manager/src/tests/test_invitation_routes/test_invitation_routes.py @@ -104,6 +104,96 @@ class TestInvitationalRoutes(Test): assert user_invites.status_code == 200 assert user_invites.json() == [] + async def test_removing_invitations( + self, client: AsyncClient, create_user_with_org + ): + admin, org, _, admintokens = await create_user_with_org( + email="superadmin9999@localhost.com", + username="awesomeadmin", + password="awesomeadmin", + is_admin=True, + ) + + invite = await client.post( + "https://localhost/api/v1/invitations/send", + json={ + "org_id": str(org.id), + "receiver": "user9487@localhost.com", + "acl": None, + "message": "Hi! We would like to invite you to our organization.", + }, + headers={"Authorization": f"Bearer {admintokens.access_token}"}, + ) + + assert invite.status_code == 200 + assert invite.json() == { + "accepted": False, + "created_at": ANY, + "disabled": False, + "disabled_at": None, + "id": ANY, + "message": "Hi! We would like to invite you to our organization.", + "modified_at": ANY, + "org_id": str(org.id), + "receiver": "user9487@localhost.com", + "sender": str(admin.id), + } + + invite_id = invite.json()["id"] + removed_invite = await client.delete( + f"https://localhost/api/v1/invitations/{invite_id}", + headers={"Authorization": f"Bearer {admintokens.access_token}"}, + ) + + assert removed_invite.status_code == 204 + + + async def test_cannot_accept_own_invite( + self, client: AsyncClient, create_user_with_org + ): + admin, org, _, admintokens = await create_user_with_org( + email="superadmin18569@localhost.com", + username="awesomeadmin", + password="awesomeadmin", + is_admin=True, + ) + + invite = await client.post( + "https://localhost/api/v1/invitations/send", + json={ + "org_id": str(org.id), + "receiver": "non-existing-user@localhost.com", + "acl": None, + "message": "Hi! We would like to invite you to our organization.", + }, + headers={"Authorization": f"Bearer {admintokens.access_token}"}, + ) + + assert invite.status_code == 200 + assert invite.json() == { + "accepted": False, + "created_at": ANY, + "disabled": False, + "disabled_at": None, + "id": ANY, + "message": "Hi! We would like to invite you to our organization.", + "modified_at": ANY, + "org_id": str(org.id), + "receiver": "non-existing-user@localhost.com", + "sender": str(admin.id), + } + + invite_id = invite.json()["id"] + try_accept_invite = await client.get( + f"https://localhost/api/v1/invitations/accept/{invite_id}", + headers={"Authorization": f"Bearer {admintokens.access_token}"}, + ) + + assert try_accept_invite.status_code == 403 + assert try_accept_invite.json() == { + "detail": "The invitation doesn't exist or you don't have access to it." + } + async def test_accept_sent_invitations( self, client: AsyncClient, create_user_with_org ):