Merge pull request #11 from BlackChaosNL/fea/invitations

Adds invitations to main branch with the following routes:

- Create invitation.
- Remove invitation.
- Accept Invitation.
- Decline Invitation.
This commit was merged in pull request #11.
This commit is contained in:
Jeroen Vijgen
2025-07-12 17:24:03 +03:00
committed by GitHub
17 changed files with 888 additions and 70 deletions
+3 -5
View File
@@ -1,8 +1,6 @@
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from pydantic_settings import BaseSettings, SettingsConfigDict # type: ignore from pydantic_settings import BaseSettings, SettingsConfigDict
from passlib.context import CryptContext # type: ignore from pwdlib import PasswordHash
import pytz
class Settings(BaseSettings): class Settings(BaseSettings):
PROJECT_NAME: str = "StoneEdge Asset Management System" PROJECT_NAME: str = "StoneEdge Asset Management System"
PROJECT_VERSION: str = "0.0.1" PROJECT_VERSION: str = "0.0.1"
@@ -19,7 +17,7 @@ class Settings(BaseSettings):
ACCESS_TOKEN_EXPIRE_MIN: int = 10 ACCESS_TOKEN_EXPIRE_MIN: int = 10
REFRESH_TOKEN_EXPIRE_MIN: int = 20 REFRESH_TOKEN_EXPIRE_MIN: int = 20
BACKEND_CORS_ORIGINS: list = ["*"] BACKEND_CORS_ORIGINS: list = ["*"]
CRYPT: CryptContext = CryptContext(schemes=["bcrypt"], deprecated="auto") CRYPT: PasswordHash = PasswordHash.recommended()
OAUTH2_SCHEME: OAuth2PasswordBearer = OAuth2PasswordBearer(tokenUrl="token") OAUTH2_SCHEME: OAuth2PasswordBearer = OAuth2PasswordBearer(tokenUrl="token")
model_config = SettingsConfigDict(env_file=".env") model_config = SettingsConfigDict(env_file=".env")
+1
View File
@@ -8,6 +8,7 @@ modules: dict[str, Any] = {
"modules.auth.models", "modules.auth.models",
"modules.users.models", "modules.users.models",
"modules.organizations.models", "modules.organizations.models",
"modules.invitations.models",
] ]
} }
+2
View File
@@ -10,6 +10,7 @@ from modules.assets.router import router as asset_router
from modules.auth.router import router as auth_router from modules.auth.router import router as auth_router
from modules.users.router import router as users_router from modules.users.router import router as users_router
from modules.organizations.router import router as organizations_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.httpsredirect import HTTPSRedirectMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware from fastapi.middleware.trustedhost import TrustedHostMiddleware
@@ -53,3 +54,4 @@ app.include_router(auth_router)
app.include_router(users_router) app.include_router(users_router)
app.include_router(organizations_router) app.include_router(organizations_router)
app.include_router(asset_router) app.include_router(asset_router)
app.include_router(invitations_router)
@@ -12,9 +12,6 @@ async def upgrade(db: BaseDBAsyncClient) -> str:
"ADMIN" INT NOT NULL DEFAULT 0 "ADMIN" INT NOT NULL DEFAULT 0
) /* ACL */; ) /* ACL */;
CREATE TABLE IF NOT EXISTS "organization" ( 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, "id" CHAR(36) NOT NULL PRIMARY KEY,
"name" VARCHAR(128) NOT NULL, "name" VARCHAR(128) NOT NULL,
"type" VARCHAR(128) NOT NULL, "type" VARCHAR(128) NOT NULL,
@@ -23,41 +20,57 @@ CREATE TABLE IF NOT EXISTS "organization" (
"state" VARCHAR(128), "state" VARCHAR(128),
"city" VARCHAR(128), "city" VARCHAR(128),
"country" VARCHAR(128), "country" VARCHAR(128),
"disabled" INT NOT NULL DEFAULT 0 "disabled" INT NOT NULL DEFAULT 0,
) /* Organization */;
CREATE TABLE IF NOT EXISTS "user" (
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"modified_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, "id" CHAR(36) NOT NULL PRIMARY KEY,
"email" VARCHAR(128) NOT NULL, "email" VARCHAR(128) NOT NULL,
"username" TEXT NOT NULL, "username" TEXT NOT NULL,
"name" TEXT NOT NULL, "name" TEXT NOT NULL,
"surname" TEXT NOT NULL, "surname" TEXT NOT NULL,
"password" VARCHAR(128), "password" VARCHAR(128),
"disabled" INT NOT NULL DEFAULT 0 "disabled" INT NOT NULL DEFAULT 0,
) /* User */;
CREATE TABLE IF NOT EXISTS "token" (
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"modified_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, "id" CHAR(36) NOT NULL PRIMARY KEY,
"token_type" VARCHAR(128) NOT NULL DEFAULT 'Bearer', "token_type" VARCHAR(128) NOT NULL DEFAULT 'Bearer',
"access_token" TEXT, "access_token" TEXT,
"refresh_token" TEXT, "refresh_token" TEXT,
"disabled" INT NOT NULL DEFAULT 0, "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, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"modified_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "modified_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"disabled_at" 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, "id" CHAR(36) NOT NULL PRIMARY KEY,
"disabled" 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,
"acl_id" CHAR(36) NOT NULL REFERENCES "acl" ("id") ON DELETE CASCADE, "acl_id" CHAR(36) NOT NULL REFERENCES "acl" ("id") ON DELETE CASCADE,
"organization_id" CHAR(36) NOT NULL REFERENCES "organization" ("id") ON DELETE CASCADE, "organization_id" CHAR(36) REFERENCES "organization" ("id") ON DELETE SET NULL,
"user_id" CHAR(36) NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE "user_id" CHAR(36) REFERENCES "user" ("id") ON DELETE SET NULL
) /* Membership */; ) /* 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 DEFAULT 0,
"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
);
CREATE TABLE IF NOT EXISTS "aerich" ( CREATE TABLE IF NOT EXISTS "aerich" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
"version" VARCHAR(255) NOT NULL, "version" VARCHAR(255) NOT NULL,
-11
View File
@@ -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)
+4 -5
View File
@@ -4,11 +4,8 @@ from tortoise import fields
import uuid import uuid
from datetime import datetime from datetime import datetime
from mixins.CMDMixin import CMDMixin
from config import settings
class Token(Model):
class Token(Model, CMDMixin):
""" """
Token Token
@@ -21,9 +18,11 @@ class Token(Model, CMDMixin):
access_token: str = fields.TextField(null=True) access_token: str = fields.TextField(null=True)
refresh_token: str = fields.TextField(null=True) refresh_token: str = fields.TextField(null=True)
disabled: 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) -> None: async def delete(self) -> None:
self.disabled = True self.disabled = True
self.disabled_at = datetime.now(tz=pytz.UTC) self.disabled_at = datetime.now(tz=pytz.UTC)
await self.save() await self.save()
+17 -8
View File
@@ -1,13 +1,12 @@
from datetime import datetime from datetime import datetime
from typing import Annotated from typing import Annotated
import uuid
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from fastapi.routing import APIRouter from fastapi.routing import APIRouter
import pytz import pytz
from modules.users.utils import get_current_active_user 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.utils import create_jwt_tokens, get_tokens_from_logged_in_user
from modules.auth.models import Token from modules.auth.models import Token
from modules.users.models import User
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from tortoise.expressions import Q from tortoise.expressions import Q
from config import settings 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. 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: 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: 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: 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) tokens = await create_jwt_tokens(user)
return {"jwt": tokens} 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)]): async def logout(user: Annotated[User, Depends(get_current_active_user)]):
""" """
Logout Logout
@@ -110,7 +117,9 @@ async def refresh_login(
return {"jwt": tokens} 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): async def register(user: register_model):
# Prevent existing users from reapplying for our system. # Prevent existing users from reapplying for our system.
existing_user: User | None = await User.filter( existing_user: User | None = await User.filter(
@@ -0,0 +1,30 @@
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)
receiver: str = fields.CharField(max_length=128)
sender: uuid.UUID = fields.UUIDField()
org_id: uuid.UUID = fields.UUIDField()
message: str | None = fields.TextField(null=True)
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()
@@ -0,0 +1,214 @@
from typing import Annotated, List
import uuid
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, send_invitation_for_org
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/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]:
"""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(sender=user.id) | (Q(receiver=user.username) | Q(receiver=user.email)))
& Q(disabled=False)
)
@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:
"""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(sender=user.id) & Q(disabled=False)
)
if not invite:
raise HTTPException(
status_code=403,
detail="The invitation doesn't exist or you don't have access to it.",
)
await invite.delete()
@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(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.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,
)
@@ -0,0 +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
@@ -7,7 +7,6 @@ from tortoise.exceptions import ConfigurationError
from tortoise.models import Model from tortoise.models import Model
from tortoise import fields from tortoise import fields
from mixins.CMDMixin import CMDMixin
class EnumField(fields.CharField): class EnumField(fields.CharField):
""" """
@@ -52,8 +51,7 @@ class OrganizationType(Enum):
EXTRA_LARGE_ORGANIZATION: str = "xl_org" # 1000 - 5000+ EXTRA_LARGE_ORGANIZATION: str = "xl_org" # 1000 - 5000+
class Organization(Model):
class Organization(Model, CMDMixin):
""" """
Organization Organization
@@ -79,6 +77,9 @@ class Organization(Model, CMDMixin):
on_delete=fields.NO_ACTION, on_delete=fields.NO_ACTION,
) )
disabled: 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)
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.id} - {self.name}" return f"{self.id} - {self.name}"
@@ -90,5 +91,3 @@ class Organization(Model, CMDMixin):
self.disabled = True self.disabled = True
self.disabled_at = datetime.now(tz=pytz.UTC) self.disabled_at = datetime.now(tz=pytz.UTC)
await self.save() await self.save()
@@ -20,11 +20,10 @@ router = APIRouter(prefix="/api/v1/organizations", tags=["orgs"])
async def all_active_organizations( async def all_active_organizations(
user: Annotated[User, Depends(get_current_active_user)], user: Annotated[User, Depends(get_current_active_user)],
) -> List[Organization]: ) -> List[Organization]:
memberships: List[Membership] = list( memberships: List[Membership] = await Membership.filter(
await Membership.filter( Q(user_id=user.id) & Q(disabled=False)
Q(user_id=user.id) & Q(disabled=False) ).prefetch_related("organization")
).prefetch_related("organization")
)
organizations: List[Organization] = [] organizations: List[Organization] = []
if len(memberships) < 1: if len(memberships) < 1:
@@ -59,7 +58,7 @@ async def delete_organization(
for member in all_memberships: for member in all_memberships:
await member.acl.delete() await member.acl.delete()
await member.delete() await member.delete()
await membership.acl.delete() await membership.acl.delete()
await membership.delete() await membership.delete()
return return
+10 -6
View File
@@ -6,13 +6,12 @@ from tortoise.models import Model
from tortoise import fields from tortoise import fields
from modules.organizations.models import Organization from modules.organizations.models import Organization
from mixins.CMDMixin import CMDMixin
from config import settings from config import settings
crypt = settings.CRYPT crypt = settings.CRYPT
class User(Model, CMDMixin): class User(Model):
""" """
User User
@@ -35,7 +34,9 @@ class User(Model, CMDMixin):
on_delete=fields.NO_ACTION, on_delete=fields.NO_ACTION,
) )
disabled: bool = fields.BooleanField(default=False) 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: def __str__(self) -> str:
return f"{self.id} - {self.name} {self.surname}" return f"{self.id} - {self.name} {self.surname}"
@@ -90,7 +91,7 @@ class ACL(Model):
""" """
class Membership(Model, CMDMixin): class Membership(Model):
""" """
Membership Membership
@@ -98,10 +99,13 @@ class Membership(Model, CMDMixin):
""" """
id: uuid.UUID = fields.UUIDField(primary_key=True) id: uuid.UUID = fields.UUIDField(primary_key=True)
organization: Organization = fields.ForeignKeyField("models.Organization") organization: Organization = fields.ForeignKeyField("models.Organization", null=True, on_delete=fields.SET_NULL)
user: User = fields.ForeignKeyField("models.User") user: User = fields.ForeignKeyField("models.User", null=True, on_delete=fields.SET_NULL)
acl: ACL = fields.ForeignKeyField("models.ACL") acl: ACL = fields.ForeignKeyField("models.ACL")
disabled: 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: async def delete(self, force: bool = False) -> None:
if force: if force:
@@ -4,7 +4,7 @@ tortoise-orm[asyncpg]>=0.25.1
uvicorn>=0.34.3 uvicorn>=0.34.3
black>=25.1.0 black>=25.1.0
joserfc>=1.1.0 joserfc>=1.1.0
passlib>=1.7.4 pwdlib[argon2]>=0.2.1
pytz>=2025.2 pytz>=2025.2
ptpython>=3.0.30 ptpython>=3.0.30
msgspec>=0.19.0 msgspec>=0.19.0
@@ -1,6 +1,5 @@
from tests.base_test import Test from tests.base_test import Test
from modules.users.models import User from modules.users.models import User
import pytest # type: ignore
from httpx import AsyncClient from httpx import AsyncClient
from config import settings from config import settings
from unittest.mock import ANY from unittest.mock import ANY
@@ -0,0 +1,547 @@
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_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
):
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."
}
@@ -1,12 +1,9 @@
from httpx import AsyncClient from httpx import AsyncClient
from modules.users.models import ACL, Membership from modules.users.models import ACL, Membership
from modules.organizations.models import Organization from modules.organizations.models import Organization
from config import settings
from unittest.mock import ANY from unittest.mock import ANY
from tests.base_test import Test from tests.base_test import Test
crypt = settings.CRYPT
class TestOrganizationRoute(Test): class TestOrganizationRoute(Test):
async def test_get_organizations_from_api( async def test_get_organizations_from_api(
@@ -153,9 +150,7 @@ class TestOrganizationRoute(Test):
READ=True, WRITE=True, REPORT=True, MANAGE=False, ADMIN=False READ=True, WRITE=True, REPORT=True, MANAGE=False, ADMIN=False
) )
await Membership.create( await Membership.create(user=user, organization=organization, acl=acl)
user=user, organization=organization, acl=acl
)
deleted_org = await client.delete( deleted_org = await client.delete(
f"https://localhost/api/v1/organizations/{organization.id}", f"https://localhost/api/v1/organizations/{organization.id}",