Setup infrastructure for first patch

This commit is contained in:
2024-12-30 02:44:48 +02:00
parent fb32bd33cd
commit c7b9f44c92
9 changed files with 254 additions and 59 deletions
+14 -2
View File
@@ -1,6 +1,18 @@
class Settings:
PROJECT_NAME:str = "Open Asset Manager"
from pydantic_settings import BaseSettings, SettingsConfigDict
import pytz
class Settings(BaseSettings):
PROJECT_NAME: str = "Open Asset Manager"
PROJECT_VERSION: str = "0.0.1"
PROJECT_SUMMARY: str = "Product API for Open Asset Manager."
SECRET_KEY: str | None = None
HASHING_SCHEME: str = "HS256"
PSQL_CONNECT_STR: str | None = None
ACCESS_TOKEN_EXPIRE_MIN: int = 30
REFRESH_TOKEN_EXPIRE_MIN: int = 60
DEFAULT_TIMEZONE = pytz.UTC
model_config = SettingsConfigDict(env_file=".env")
settings = Settings()
+12 -11
View File
@@ -1,13 +1,15 @@
from typing_extensions import Any
from tortoise import Tortoise
import os
from config import settings
db_url = os.getenv('PSQL_CONNECT_STR')
modules: dict[str, Any] = {'models': [
'.models',
'.modules.auth.models',
'.modules.assets.models',
]}
db_url = settings.PSQL_CONNECT_STR
modules: dict[str, Any] = {
"models": [
".models",
".modules.auth.models",
".modules.assets.models",
]
}
TORTOISE_ORM = {
"connections": {"default": db_url},
@@ -19,11 +21,10 @@ TORTOISE_ORM = {
},
}
async def init_db():
await Tortoise.init(
db_url=db_url,
modules=modules
)
await Tortoise.init(db_url=db_url, modules=modules)
async def migrate_db():
await init_db()
+4 -7
View File
@@ -1,13 +1,9 @@
from fastapi import FastAPI
from starlette.responses import RedirectResponse
from .config import settings
from .modules.assets.router import router as asset_router
from dotenv import load_dotenv
from src.config import settings
from modules.assets.router import router as asset_router
from tortoise.contrib.fastapi import register_tortoise
from .database import db_url, modules
load_dotenv()
from src.database import db_url, modules
app = FastAPI(
title=settings.PROJECT_NAME,
@@ -25,6 +21,7 @@ register_tortoise(
app.include_router(asset_router)
@app.get("/")
async def main():
return RedirectResponse(url="/docs")
+177 -35
View File
@@ -1,13 +1,21 @@
from datetime import datetime
from enum import Enum
from typing import Type
import uuid
from pydantic import EmailStr
from tortoise.exceptions import ConfigurationError
from tortoise.models import Model
from tortoise import fields
from passlib.context import CryptContext # type: ignore
from src.config import settings
crypt = CryptContext(schemes=["bcrypt"], deprecated="auto")
class EnumField(fields.CharField):
"""
An example extension to CharField that serializes Enums
to and from a str representation in the DB.
Serializes Enums to and from a str representation in the DB.
"""
def __init__(self, enum_type: Type[Enum], **kwargs):
@@ -24,51 +32,185 @@ class EnumField(fields.CharField):
return self._enum_type(value)
except Exception:
raise ValueError(
"Database value {} does not exist on Enum {}.".format(value, self._enum_type)
"Database value {} does not exist on Enum {}.".format(
value, self._enum_type
)
)
class OrganizationType(Enum):
HOME = 1 # Home use (Any size)
SMALL_ORGANIZATION = 2 # 1-100
MEDIUM_ORGANIZATION = 3 # 100 - 500
LARGE_ORGANIZATION = 4 # 500 - 1000
EXTRA_LARGE_ORGANIZATION = 5 # 1000 - 5000+
"""
Represents the following:
class CreatedAndModifiedMixin():
created = fields.DatetimeField(null=True, auto_now_add=True)
modified = fields.DatetimeField(null=True, auto_now=True)
1. Is this a commercial entity or not?
2. What size is it?
class Organization(Model, CreatedAndModifiedMixin):
id = fields.UUIDField(primary_key=True)
name = fields.CharField(max_length=128)
type = EnumField(OrganizationType)
users = fields.ManyToManyField('models.User',
related_name='members',
through="Membership",
forward_key='user_id',
backward_key='organization_id',
null=True
All choices should be representative of the org.
There are no seat costs.
"""
HOME: int = 1 # Home use (Any size)
SMALL_ORGANIZATION: int = 2 # 1-100
MEDIUM_ORGANIZATION: int = 3 # 100 - 500
LARGE_ORGANIZATION: int = 4 # 500 - 1000
EXTRA_LARGE_ORGANIZATION: int = 5 # 1000 - 5000+
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)
class Organization(Model, CMDMixin):
"""
Organization
This class holds the organization for a household / organization
and makes sure that we can add users.
"""
id: uuid = fields.UUIDField(primary_key=True)
name: str = fields.CharField(max_length=128)
type: str = EnumField(OrganizationType)
users: uuid = fields.ManyToManyField(
"models.User",
related_name="members",
through="Membership",
forward_key="user_id",
backward_key="organization_id",
null=True,
on_delete=fields.NO_ACTION,
)
disabled: bool = fields.BooleanField(default=False)
def __str__(self) -> str:
return f"{self.id} - {self.name}"
class User(Model, CreatedAndModifiedMixin):
id = fields.UUIDField(primary_key=True)
name = fields.TextField()
surname = fields.TextField()
password = fields.CharField(max_length=128, null=True)
organizations = fields.ManyToManyField('models.Organization',
related_name='members',
through='Membership',
forward_key='organization_id',
backward_key='user_id',
null=True
def delete(self) -> None:
self.disabled = True
self.disabled_at = datetime.now(tz=settings.DEFAULT_TIMEZONE)
self.save()
class Token(Model, CMDMixin):
"""
Token
Creates the access tokens for the User
"""
id: uuid = fields.UUIDField(primary_key=True)
user: uuid = fields.ForeignKeyField("models.User")
token_type: str = fields.CharField(max_length=128, default="bearer")
access_token: str = fields.CharField(max_length=128, null=True)
refresh_token: str = fields.CharField(max_length=128, null=True)
disabled: bool = fields.BooleanField(default=False)
def delete(self) -> None:
self.disabled = True
self.disabled_at = datetime.now(tz=settings.DEFAULT_TIMEZONE)
self.save()
class User(Model, CMDMixin):
"""
User
This holds all of our users
"""
id: uuid = fields.UUIDField(primary_key=True)
email: EmailStr = fields.CharField(max_length=128)
username: str = fields.TextField(max_length=128)
name: str = fields.TextField(max_length=128)
surname: str = fields.TextField(max_length=128)
password: str = fields.CharField(max_length=128, null=True)
organizations: uuid = fields.ManyToManyField(
"models.Organization",
related_name="members",
through="Membership",
forward_key="organization_id",
backward_key="user_id",
null=True,
on_delete=fields.NO_ACTION,
)
disabled: bool = fields.BooleanField(default=False)
tokens = fields.ForeignKeyField("models.Token")
def __str__(self) -> str:
return f"{self.id} - {self.name} {self.surname}"
class Membership(Model, CreatedAndModifiedMixin):
organization = fields.ForeignKeyField('models.Organization')
user = fields.ForeignKeyField('models.User')
def set_password(self, password: str) -> None:
secret_key = settings.SECRET_KEY
if secret_key is None:
return False
self.password = crypt.hash(
password,
secret=secret_key,
scheme=settings.HASHING_SCHEME,
)
self.save() # Make sure to save the model in DB
def check_against_password(self, password: str) -> bool:
secret_key = settings.SECRET_KEY
if secret_key is None:
return False
return crypt.verify(
password,
secret=secret_key,
scheme=settings.HASHING_SCHEME,
)
def delete(self) -> None:
self.disabled = True
self.disabled_at = datetime.now(tz=settings.DEFAULT_TIMEZONE)
self.save()
class ACL(Model):
"""
ACL
Access control lists, every invited user gets an ACL and this decides whether you grant / deny access to certain parts of our system.
"""
id: uuid = fields.UUIDField(primary_key=True)
READ: bool = fields.BooleanField(default=False)
WRITE: bool = fields.BooleanField(default=False)
REPORT: bool = fields.BooleanField(default=False)
MANAGE: bool = fields.BooleanField(default=False)
ADMIN: bool = fields.BooleanField(default=False)
def __str__(self) -> str:
return f"""
ID: {self.id},
READ: {self.READ},
WRITE: {self.WRITE},
REPORT: {self.REPORT},
MANAGE: {self.MANAGE},
ADMIN: {self.ADMIN}
"""
class Membership(Model, CMDMixin):
"""
Membership
Creates a connection between an user and a company together with an ACL.
"""
id: uuid = fields.UUIDField(primary_key=True)
organization: Organization = fields.ForeignKeyField("models.Organization")
user: User = fields.ForeignKeyField("models.User")
acl: ACL = fields.ForeignKeyField("models.ACL")
disabled: bool = fields.BooleanField(default=False)
def delete(self) -> None:
self.disabled = True
self.disabled_at = datetime.now(tz=settings.DEFAULT_TIMEZONE)
self.save()
@@ -1 +1,33 @@
import os
from fastapi.security import OAuth2PasswordBearer
from fastapi.routing import APIRouter
from src.modules.auth.schemas import UserModel
from src.models import User
from fastapi import HTTPException
from src.config import settings
router = APIRouter(prefix="/auth")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
ACCESS_TOKEN_EXPIRE_MINUTES = settings.ACCESS_TOKEN_EXPIRE_MINUTES
error: str = "E-Mail Address or password is incorrect"
@router.post("/")
async def login(email: str, password: str):
user: User = await User.get_or_none(email=email)
if user is None:
HTTPException(status_code=401, detail=error)
if user.check_against_password(password) is False:
HTTPException(status_code=401, detail=error)
@router.post("/refresh")
async def refresh_login():
pass
@router.post("/register")
async def register():
pass
@@ -0,0 +1,7 @@
from tortoise.contrib.pydantic import pydantic_model_creator
from src.models import Organization, User
OrganizationModel = pydantic_model_creator(Organization)
UserModel = pydantic_model_creator(User)
@@ -1,7 +1,9 @@
aerich==0.8.0
fastapi[standard]==0.115.5
uvicorn==0.31.1
pydantic-settings==2.7.0
tortoise-orm[asyncpg]==0.22.1
uvicorn==0.31.1
black==24.10.0
python-dotenv==1.0.1
uvicorn[standard]==0.34.0
authlib==1.3.2
passlib==1.7.4
pytz==2024.2