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:
@@ -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")
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+29
-16
@@ -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,
|
||||||
@@ -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,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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
|||||||
Reference in New Issue
Block a user