Replace Passlib with drop-in replacement, add more invitation routes, start tests

This commit is contained in:
2025-07-09 16:34:42 +00:00
parent 9a01074ad1
commit b65c292d83
12 changed files with 101 additions and 72 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",
] ]
} }
@@ -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,56 @@ 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) NOT NULL REFERENCES "organization" ("id") ON DELETE CASCADE,
"user_id" CHAR(36) NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE "user_id" CHAR(36) NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE
) /* 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,
"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" ( 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()
@@ -1,15 +1,15 @@
import uuid import uuid
from tortoise import Model, fields from tortoise import Model, fields
from mixins import CMDMixin
from modules.users.models import User
class Invite(Model):
class Invite(Model, CMDMixin):
id: uuid.UUID = fields.UUIDField(primary_key=True) id: uuid.UUID = fields.UUIDField(primary_key=True)
receiver: str = fields.CharField(max_length=128) 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) message: str | None = fields.TextField(null=True)
accepted: bool = fields.BooleanField() accepted: bool = fields.BooleanField()
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)
@@ -1,8 +1,10 @@
from typing import Annotated from typing import Annotated, List
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from modules.invitations.models import Invite from modules.invitations.models import Invite
from modules.invitations.schemas import invitation_model
from modules.users.models import User from modules.users.models import User
from modules.users.utils import get_current_active_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 = APIRouter(prefix="/api/v1/Users", tags=["User"])
@router.get("/") @router.get("/", response_model=List[invitation_model])
async def get_all_invitations(user: Annotated[User, Depends(get_current_active_user)]): async def get_all_invitations(
pass 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( async def accept_invitation(
user: Annotated[User, Depends(get_current_active_user)], invitation_id: uuid.UUID user: Annotated[User, Depends(get_current_active_user)], invitation_id: uuid.UUID
): ) -> Invite:
invite: Invite | None = await Invite.get_or_none( 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(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.accepted = True
invite.save() return await invite.save()
return invite
@router.get("/reject/{invitation_id}", response_model=invitation_model)
@router.get("/reject/{invitation_id}")
async def reject_invitation( async def reject_invitation(
user: Annotated[User, Depends(get_current_active_user)], invitation_id: uuid.UUID user: Annotated[User, Depends(get_current_active_user)], invitation_id: uuid.UUID
) -> Invite: ) -> Invite:
invite: Invite | None = await Invite.get_or_none( 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(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.accepted = False
invite.save() return await invite.save()
return invite
@router.get("/send") @router.get("/send")
async def accept_invitation( async def send_invitation(
user: Annotated[User, Depends(get_current_active_user)], 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 pass
@router.get("/cancel/{invitation_id}", status_code=204) @router.get("/cancel/{invitation_id}", response_model=invitation_model)
async def accept_invitation( async def accept_invitation(
user: Annotated[User, Depends(get_current_active_user)], invitation_id: uuid.UUID user: Annotated[User, Depends(get_current_active_user)], invitation_id: uuid.UUID
) -> None: ) -> None:
@@ -63,4 +84,4 @@ async def accept_invitation(
detail="The invitation doesn't exist or you don't have access to it.", detail="The invitation doesn't exist or you don't have access to it.",
) )
invite.delete() return await invite.delete()
@@ -0,0 +1,7 @@
from tortoise.contrib.pydantic import pydantic_model_creator
from modules.invitations.models import Invite
invitation_model = pydantic_model_creator(Invite)
@@ -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
@@ -102,6 +103,9 @@ class Membership(Model, CMDMixin):
user: User = fields.ForeignKeyField("models.User") user: User = fields.ForeignKeyField("models.User")
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