[PATCH 1] Add structure, first API and testing. #2
@@ -0,0 +1,2 @@
|
||||
python 3.13.1
|
||||
nodejs 23.4.0
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
admin off
|
||||
email {$EMAIL_ADDRESS}
|
||||
acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
|
||||
grace_period 30s
|
||||
shutdown_delay 60s
|
||||
}
|
||||
|
||||
|
||||
(protect) {
|
||||
@external {
|
||||
not remote_ip private_ranges
|
||||
}
|
||||
abort @external 401
|
||||
}
|
||||
|
||||
stoneedge.{$MAIN_DOMAIN} {
|
||||
handle / {
|
||||
reverse_proxy stoneedge:8000
|
||||
}
|
||||
}
|
||||
|
||||
stoneedge-staging.{$MAIN_DOMAIN} {
|
||||
handle / {
|
||||
import protect
|
||||
abort
|
||||
|
||||
reverse_proxy stoneedge-staging:8000
|
||||
}
|
||||
}
|
||||
|
||||
{$MAIN_DOMAIN} {
|
||||
encode zstd gzip
|
||||
|
||||
handle /ping {
|
||||
@goingDown vars {http.shutting_down} true
|
||||
respond @goingDown "Shutdown in {http.time_until_shutdown}" 503
|
||||
respond "pong" 200
|
||||
}
|
||||
|
||||
handle / {
|
||||
abort 404
|
||||
}
|
||||
|
||||
handle {
|
||||
abort 404
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,19 @@
|
||||
# OpenAssetManager
|
||||
Product for asset documentation for home to big business. Free base-system and non-free (paid) addons.
|
||||
|
||||
Our folder structure:
|
||||
## Products
|
||||
|
||||
All of the projects are located in this project. This is a monorepo. Make sure you have ASDF installed.
|
||||
|
||||
### Web
|
||||
|
||||
All web projects are under `web`, they are react projects.
|
||||
|
||||
### API's
|
||||
Our folder structure for the APIs:
|
||||
```
|
||||
fastapi-project
|
||||
├── alembic/
|
||||
├── src
|
||||
│ ├── auth
|
||||
│ │ ├── router.py # auth main router with all the endpoints
|
||||
│ │ ├── schemas.py # pydantic models
|
||||
│ │ ├── models.py # database models
|
||||
│ │ ├── dependencies.py # router dependencies
|
||||
│ │ ├── config.py # local configs
|
||||
│ │ ├── constants.py # module-specific constants
|
||||
│ │ ├── exceptions.py # module-specific errors
|
||||
│ │ ├── service.py # module-specific business logic
|
||||
│ │ └── utils.py # any other non-business logic functions
|
||||
│ ├── aws
|
||||
│ │ ├── client.py # client model for external service communication
|
||||
│ │ ├── schemas.py
|
||||
│ │ ├── config.py
|
||||
│ │ ├── constants.py
|
||||
│ │ ├── exceptions.py
|
||||
│ │ └── utils.py
|
||||
│ └── posts
|
||||
│ │ ├── router.py
|
||||
│ │ ├── schemas.py
|
||||
│ │ ├── models.py
|
||||
│ │ ├── dependencies.py
|
||||
│ │ ├── constants.py
|
||||
│ │ ├── exceptions.py
|
||||
│ │ ├── service.py
|
||||
│ │ └── utils.py
|
||||
│ ├── config.py # global configs
|
||||
│ ├── models.py # global database models
|
||||
│ ├── exceptions.py # global exceptions
|
||||
│ ├── pagination.py # global module e.g. pagination
|
||||
│ ├── database.py # db connection related stuff
|
||||
│ └── main.py
|
||||
├── migrations/
|
||||
├── tests/
|
||||
│ ├── auth
|
||||
│ ├── aws
|
||||
@@ -48,8 +24,39 @@ fastapi-project
|
||||
│ ├── base.txt
|
||||
│ ├── dev.txt
|
||||
│ └── prod.txt
|
||||
├── router.py # auth main router with all the endpoints
|
||||
├── schemas.py # pydantic models
|
||||
├── models.py # database models
|
||||
│ ├── dependencies.py # router dependencies
|
||||
│ ├── config.py # local configs
|
||||
│ ├── constants.py # module-specific constants
|
||||
│ ├── exceptions.py # module-specific errors
|
||||
│ ├── service.py # module-specific business logic
|
||||
│ └── utils.py # any other non-business logic functions
|
||||
├── aws
|
||||
│ ├── client.py # client model for external service communication
|
||||
│ ├── schemas.py
|
||||
│ ├── config.py
|
||||
│ ├── constants.py
|
||||
│ ├── exceptions.py
|
||||
│ └── utils.py
|
||||
└── posts
|
||||
│ ├── router.py
|
||||
│ ├── schemas.py
|
||||
│ ├── models.py
|
||||
│ ├── dependencies.py
|
||||
│ ├── constants.py
|
||||
│ ├── exceptions.py
|
||||
│ ├── service.py
|
||||
│ └── utils.py
|
||||
├── config.py # global configs
|
||||
├── models.py # global database models
|
||||
├── exceptions.py # global exceptions
|
||||
├── pagination.py # global module e.g. pagination
|
||||
├── database.py # db connection related stuff
|
||||
├── main.py
|
||||
├── .env
|
||||
├── .gitignore
|
||||
├── logging.ini
|
||||
└── alembic.ini
|
||||
└── pyproject.toml
|
||||
```
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
# Sets up the API before preventing root access to the alpine image
|
||||
FROM python:3.13-alpine
|
||||
|
||||
WORKDIR /app
|
||||
WORKDIR /src
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||
|
||||
COPY ./src/ /src/
|
||||
|
||||
RUN apk upgrade --no-cache &&\
|
||||
uv pip install --system -r /src/requirements/requirements.txt
|
||||
|
||||
RUN adduser -D nonroot
|
||||
USER nonroot
|
||||
|
||||
COPY ./src/ /app/
|
||||
|
||||
RUN pip install --upgrade pip &&\
|
||||
pip install uv &&\
|
||||
uv pip install --user --no-cache-dir --upgrade -r /code/requirements/requirements.txt
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["python", "-m"]
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["fastapi", "run"]
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
class Settings:
|
||||
PROJECT_NAME:str = "Open Asset Manager"
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict # type: ignore
|
||||
from passlib.context import CryptContext # type: ignore
|
||||
import pytz
|
||||
|
||||
class Settings(BaseSettings):
|
||||
PROJECT_NAME: str = "StoneEdge Asset Management System"
|
||||
PROJECT_VERSION: str = "0.0.1"
|
||||
PROJECT_SUMMARY: str = "Product API for Open Asset Manager."
|
||||
PROJECT_SUMMARY: str = "Product API for StoneEdge."
|
||||
PROJECT_PUBLIC_URL: str = "localhost"
|
||||
SECRET_KEY: str | None = None
|
||||
PSQL_USERNAME: str = "user"
|
||||
PSQL_PASSWORD: str = "password"
|
||||
PSQL_HOSTNAME: str = "localhost"
|
||||
PSQL_PORT: int = 5432
|
||||
PSQL_DB_NAME: str = "stoneedge"
|
||||
PSQL_TEST_DB_NAME: str = "stoneedge_testing"
|
||||
ACCESS_TOKEN_EXPIRE_MIN: int = 10
|
||||
REFRESH_TOKEN_EXPIRE_MIN: int = 20
|
||||
BACKEND_CORS_ORIGINS: list = ["*"]
|
||||
CRYPT: CryptContext = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
OAUTH2_SCHEME: OAuth2PasswordBearer = OAuth2PasswordBearer(tokenUrl="token")
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
from typing_extensions import Any
|
||||
from tortoise import Tortoise
|
||||
import os
|
||||
from config import settings
|
||||
from aerich import Command
|
||||
|
||||
db_url = os.getenv('PSQL_CONNECT_STR')
|
||||
modules: dict[str, Any] = {'models': [
|
||||
'.models',
|
||||
'.modules.auth.models',
|
||||
'.modules.assets.models',
|
||||
]}
|
||||
modules: dict[str, Any] = {
|
||||
"models": [
|
||||
"modules.assets.models",
|
||||
"modules.auth.models",
|
||||
"modules.users.models",
|
||||
"modules.organizations.models",
|
||||
]
|
||||
}
|
||||
|
||||
TORTOISE_ORM = {
|
||||
"connections": {"default": db_url},
|
||||
"connections": {
|
||||
"default": {
|
||||
"engine": "tortoise.backends.asyncpg",
|
||||
"credentials": {
|
||||
"host": settings.PSQL_HOSTNAME,
|
||||
"database": settings.PSQL_DB_NAME,
|
||||
"user": settings.PSQL_USERNAME,
|
||||
"password": settings.PSQL_PASSWORD,
|
||||
"port": settings.PSQL_PORT,
|
||||
},
|
||||
}
|
||||
},
|
||||
"apps": {
|
||||
"models": {
|
||||
"models": modules.get("models", []) + ["aerich.models"],
|
||||
@@ -19,14 +33,12 @@ TORTOISE_ORM = {
|
||||
},
|
||||
}
|
||||
|
||||
async def init_db():
|
||||
await Tortoise.init(
|
||||
db_url=db_url,
|
||||
modules=modules
|
||||
)
|
||||
|
||||
async def migrate_db():
|
||||
await init_db()
|
||||
aerich = Command(tortoise_config=TORTOISE_ORM)
|
||||
await aerich.init()
|
||||
await aerich.upgrade(run_in_transaction=True)
|
||||
await Tortoise.init(config=TORTOISE_ORM)
|
||||
|
||||
# Generate the schema
|
||||
await Tortoise.generate_schemas(safe=True)
|
||||
async def end_connections_to_db():
|
||||
await Tortoise.close_connections()
|
||||
@@ -1,30 +1,49 @@
|
||||
|
||||
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 tortoise.contrib.fastapi import register_tortoise
|
||||
from .database import db_url, modules
|
||||
from config import settings
|
||||
from database import end_connections_to_db, migrate_db
|
||||
from responses import msgspec_jsonresponse
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from router import router as root_router
|
||||
from modules.assets.router import router as asset_router
|
||||
from modules.auth.router import router as auth_router
|
||||
from modules.users.router import router as users_router
|
||||
from modules.organizations.router import router as organizations_router
|
||||
|
||||
from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware
|
||||
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
await migrate_db()
|
||||
yield
|
||||
await end_connections_to_db()
|
||||
|
||||
load_dotenv()
|
||||
|
||||
app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
title=settings.PROJECT_NAME,
|
||||
version=settings.PROJECT_VERSION,
|
||||
summary=settings.PROJECT_SUMMARY,
|
||||
default_response_class=msgspec_jsonresponse,
|
||||
)
|
||||
|
||||
register_tortoise(
|
||||
app,
|
||||
db_url=db_url,
|
||||
modules=modules,
|
||||
generate_schemas=True,
|
||||
add_exception_handlers=True,
|
||||
)
|
||||
app.add_middleware(HTTPSRedirectMiddleware)
|
||||
app.add_middleware(TrustedHostMiddleware, allowed_hosts=[settings.PROJECT_PUBLIC_URL,])
|
||||
|
||||
# Set all CORS enabled origins
|
||||
if settings.BACKEND_CORS_ORIGINS:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(root_router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(users_router)
|
||||
app.include_router(organizations_router)
|
||||
app.include_router(asset_router)
|
||||
|
||||
@app.get("/")
|
||||
async def main():
|
||||
return RedirectResponse(url="/docs")
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from ptpython.repl import embed # type: ignore
|
||||
|
||||
from database import *
|
||||
|
||||
import asyncio
|
||||
|
||||
|
||||
async def setup():
|
||||
try:
|
||||
await embed(globals=globals(), return_asyncio_coroutine=True, patch_stdout=True)
|
||||
except EOFError:
|
||||
loop.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
asyncio.ensure_future(setup())
|
||||
loop.run_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
@@ -0,0 +1,75 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
CREATE TABLE IF NOT EXISTS "asset" (
|
||||
"id" UUID NOT NULL PRIMARY KEY,
|
||||
"name" VARCHAR(128) NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS "acl" (
|
||||
"id" UUID NOT NULL PRIMARY KEY,
|
||||
"READ" BOOL NOT NULL DEFAULT False,
|
||||
"WRITE" BOOL NOT NULL DEFAULT False,
|
||||
"REPORT" BOOL NOT NULL DEFAULT False,
|
||||
"MANAGE" BOOL NOT NULL DEFAULT False,
|
||||
"ADMIN" BOOL NOT NULL DEFAULT False
|
||||
);
|
||||
COMMENT ON TABLE "acl" IS 'ACL';
|
||||
CREATE TABLE IF NOT EXISTS "organization" (
|
||||
"created_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
"modified_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
"disabled_at" TIMESTAMPTZ,
|
||||
"id" UUID NOT NULL PRIMARY KEY,
|
||||
"name" VARCHAR(128) NOT NULL,
|
||||
"type" VARCHAR(128) NOT NULL,
|
||||
"disabled" BOOL NOT NULL DEFAULT False
|
||||
);
|
||||
COMMENT ON TABLE "organization" IS 'Organization';
|
||||
CREATE TABLE IF NOT EXISTS "user" (
|
||||
"created_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
"modified_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
"disabled_at" TIMESTAMPTZ,
|
||||
"id" UUID NOT NULL PRIMARY KEY,
|
||||
"email" VARCHAR(128) NOT NULL,
|
||||
"username" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"surname" TEXT NOT NULL,
|
||||
"password" VARCHAR(128),
|
||||
"disabled" BOOL NOT NULL DEFAULT False
|
||||
);
|
||||
COMMENT ON TABLE "user" IS 'User';
|
||||
CREATE TABLE IF NOT EXISTS "token" (
|
||||
"created_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
"modified_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
"disabled_at" TIMESTAMPTZ,
|
||||
"id" UUID NOT NULL PRIMARY KEY,
|
||||
"token_type" VARCHAR(128) NOT NULL DEFAULT 'Bearer',
|
||||
"access_token" VARCHAR(128),
|
||||
"refresh_token" VARCHAR(128),
|
||||
"disabled" BOOL NOT NULL DEFAULT False,
|
||||
"user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE
|
||||
);
|
||||
COMMENT ON TABLE "token" IS 'Token';
|
||||
CREATE TABLE IF NOT EXISTS "membership" (
|
||||
"created_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
"modified_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
"disabled_at" TIMESTAMPTZ,
|
||||
"id" UUID NOT NULL PRIMARY KEY,
|
||||
"disabled" BOOL NOT NULL DEFAULT False,
|
||||
"acl_id" UUID NOT NULL REFERENCES "acl" ("id") ON DELETE CASCADE,
|
||||
"organization_id" UUID NOT NULL REFERENCES "organization" ("id") ON DELETE CASCADE,
|
||||
"user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE
|
||||
);
|
||||
COMMENT ON TABLE "membership" IS 'Membership';
|
||||
CREATE TABLE IF NOT EXISTS "aerich" (
|
||||
"id" SERIAL NOT NULL PRIMARY KEY,
|
||||
"version" VARCHAR(255) NOT NULL,
|
||||
"app" VARCHAR(100) NOT NULL,
|
||||
"content" JSONB NOT NULL
|
||||
);"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
"""
|
||||
@@ -0,0 +1,15 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
ALTER TABLE "asset" ADD "disabled_at" TIMESTAMPTZ;
|
||||
ALTER TABLE "asset" ADD "modified_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP;
|
||||
ALTER TABLE "asset" ADD "created_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP;"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
ALTER TABLE "asset" DROP COLUMN "disabled_at";
|
||||
ALTER TABLE "asset" DROP COLUMN "modified_at";
|
||||
ALTER TABLE "asset" DROP COLUMN "created_at";"""
|
||||
@@ -0,0 +1,13 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
ALTER TABLE "token" ALTER COLUMN "refresh_token" TYPE TEXT USING "refresh_token"::TEXT;
|
||||
ALTER TABLE "token" ALTER COLUMN "access_token" TYPE TEXT USING "access_token"::TEXT;"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
ALTER TABLE "token" ALTER COLUMN "refresh_token" TYPE VARCHAR(128) USING "refresh_token"::VARCHAR(128);
|
||||
ALTER TABLE "token" ALTER COLUMN "access_token" TYPE VARCHAR(128) USING "access_token"::VARCHAR(128);"""
|
||||
@@ -0,0 +1,11 @@
|
||||
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)
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
from enum import Enum
|
||||
from typing import Type
|
||||
from tortoise.exceptions import ConfigurationError
|
||||
from tortoise.models import Model
|
||||
from tortoise import fields
|
||||
|
||||
class EnumField(fields.CharField):
|
||||
"""
|
||||
An example extension to CharField that serializes Enums
|
||||
to and from a str representation in the DB.
|
||||
"""
|
||||
|
||||
def __init__(self, enum_type: Type[Enum], **kwargs):
|
||||
super().__init__(128, **kwargs)
|
||||
if not issubclass(enum_type, Enum):
|
||||
raise ConfigurationError("{} is not a subclass of Enum!".format(enum_type))
|
||||
self._enum_type = enum_type
|
||||
|
||||
def to_db_value(self, value: Enum, instance) -> str:
|
||||
return value.value
|
||||
|
||||
def to_python_value(self, value: str) -> Enum:
|
||||
try:
|
||||
return self._enum_type(value)
|
||||
except Exception:
|
||||
raise ValueError(
|
||||
"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+
|
||||
|
||||
class CreatedAndModifiedMixin():
|
||||
created = fields.DatetimeField(null=True, auto_now_add=True)
|
||||
modified = fields.DatetimeField(null=True, auto_now=True)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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 __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')
|
||||
@@ -1,6 +1,7 @@
|
||||
from tortoise.models import Model
|
||||
from tortoise import fields
|
||||
from mixins.CMDMixin import CMDMixin
|
||||
|
||||
class Asset(Model):
|
||||
class Asset(Model, CMDMixin):
|
||||
id = fields.UUIDField(primary_key=True)
|
||||
name = fields.CharField(max_length=128)
|
||||
|
||||
@@ -2,25 +2,22 @@ from uuid import UUID
|
||||
|
||||
from fastapi.routing import APIRouter
|
||||
|
||||
from .models import Asset
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/assets"
|
||||
)
|
||||
|
||||
@router.get("/")
|
||||
async def get_all_assets():
|
||||
return await Asset.get_or_none()
|
||||
pass
|
||||
|
||||
@router.post("/")
|
||||
async def create_asset(name: str):
|
||||
asset = await Asset.create(name=name)
|
||||
return asset
|
||||
pass
|
||||
|
||||
@router.delete("/", status_code=204)
|
||||
async def delete_asset(remove_id: UUID):
|
||||
await Asset.filter(id=remove_id).delete()
|
||||
pass
|
||||
|
||||
@router.get("/{asset_id}")
|
||||
async def get_asset(asset_id: UUID):
|
||||
return Asset.filter(id=asset_id).get_or_none()
|
||||
pass
|
||||
|
||||
@@ -1 +1,29 @@
|
||||
import pytz
|
||||
from tortoise.models import Model
|
||||
from tortoise import fields
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from mixins.CMDMixin import CMDMixin
|
||||
from config import settings
|
||||
|
||||
|
||||
class Token(Model, CMDMixin):
|
||||
"""
|
||||
Token
|
||||
|
||||
Creates the access tokens for the User
|
||||
"""
|
||||
|
||||
id: uuid.UUID = fields.UUIDField(primary_key=True)
|
||||
user: uuid.UUID = fields.ForeignKeyField("models.User")
|
||||
token_type: str = fields.CharField(max_length=128, default="Bearer")
|
||||
access_token: str = fields.TextField(null=True)
|
||||
refresh_token: str = fields.TextField(null=True)
|
||||
disabled: bool = fields.BooleanField(default=False)
|
||||
|
||||
async def delete(self) -> None:
|
||||
self.disabled = True
|
||||
self.disabled_at = datetime.now(tz=pytz.UTC)
|
||||
await self.save()
|
||||
|
||||
|
||||
@@ -1 +1,141 @@
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
import uuid
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from fastapi.routing import APIRouter
|
||||
import pytz
|
||||
from modules.users.utils import get_current_active_user
|
||||
from modules.auth.utils import create_jwt_tokens, get_tokens_from_logged_in_user
|
||||
from modules.auth.models import Token
|
||||
from modules.users.models import User
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from tortoise.expressions import Q
|
||||
from config import settings
|
||||
from modules.users.schemas import user_model
|
||||
from modules.auth.schemas import register_model
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
account_error: str = "E-Mail Address or password is incorrect"
|
||||
token_error: str = "Refresh token not found or something went wrong."
|
||||
user_exists: str = "Account failed to create, please contact support."
|
||||
password_failed: str = "Password validation failed, please try again."
|
||||
|
||||
crypt = settings.CRYPT
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]):
|
||||
"""
|
||||
Login
|
||||
|
||||
Logs the user into our API, creates tokens and passes them back to User.
|
||||
"""
|
||||
user: User | None = await User.filter(email=form.username).first()
|
||||
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail=account_error)
|
||||
|
||||
if user.check_against_password(form.password) is False:
|
||||
raise HTTPException(status_code=401, detail=account_error)
|
||||
|
||||
if user.disabled is True:
|
||||
raise HTTPException(status_code=401, detail=account_error)
|
||||
|
||||
tokens = await create_jwt_tokens(user)
|
||||
|
||||
return {"jwt": tokens}
|
||||
|
||||
|
||||
@router.get("/logout", status_code=204)
|
||||
async def logout(user: Annotated[User, Depends(get_current_active_user)]):
|
||||
"""
|
||||
Logout
|
||||
|
||||
Logout destroys all tokens for User that are currently active.
|
||||
"""
|
||||
get_all_tokens = await Token.filter(Q(user__id=user.id))
|
||||
if get_all_tokens is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_204_NO_CONTENT, detail="An error occurred."
|
||||
)
|
||||
for token in get_all_tokens:
|
||||
await token.delete()
|
||||
return
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
async def refresh_login(
|
||||
refresh_token: Annotated[Token | None, Depends(get_tokens_from_logged_in_user)],
|
||||
):
|
||||
"""
|
||||
Refresh
|
||||
|
||||
After ging this route a token that is active and not disabled, we disable ALL other tokens and pass along new tokens.
|
||||
Tokens are alive for about 10 minutes. Refresh tokens are alive for 20 minutes.
|
||||
"""
|
||||
if refresh_token is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=token_error,
|
||||
)
|
||||
|
||||
# Disable tokens if used after expiration.
|
||||
if (
|
||||
refresh_token.created_at >= datetime.now(tz=pytz.utc)
|
||||
and refresh_token.disabled is False
|
||||
):
|
||||
refresh_token.delete()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=token_error,
|
||||
)
|
||||
|
||||
if refresh_token.disabled is True:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=token_error,
|
||||
)
|
||||
|
||||
get_all_tokens = await Token.filter(Q(user__id=refresh_token.user_id))
|
||||
|
||||
for token in get_all_tokens:
|
||||
if token.id != refresh_token.id:
|
||||
await token.delete()
|
||||
|
||||
tokens = await create_jwt_tokens(
|
||||
user=await User.filter(Q(id=refresh_token.user_id)).first()
|
||||
)
|
||||
|
||||
return {"jwt": tokens}
|
||||
|
||||
|
||||
@router.post("/register", status_code=201, response_model=user_model)
|
||||
async def register(user: register_model):
|
||||
# Prevent existing users from reapplying for our system.
|
||||
existing_user: User | None = await User.filter(
|
||||
Q(email=user.email)
|
||||
& Q(username=user.username)
|
||||
& Q(name=user.name)
|
||||
& Q(surname=user.surname)
|
||||
).get_or_none()
|
||||
|
||||
if existing_user is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=user_exists,
|
||||
)
|
||||
|
||||
if user.password != user.validate_password:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=password_failed,
|
||||
)
|
||||
|
||||
return await User.create(
|
||||
email=user.email,
|
||||
username=user.username,
|
||||
name=user.name,
|
||||
surname=user.surname,
|
||||
password=crypt.hash(user.password),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from tortoise.contrib.pydantic import pydantic_model_creator
|
||||
|
||||
from modules.auth.models import Token
|
||||
|
||||
token_model = pydantic_model_creator(Token)
|
||||
|
||||
class register_model(BaseModel):
|
||||
email: EmailStr
|
||||
username: str
|
||||
name: str
|
||||
surname: str
|
||||
password: str
|
||||
validate_password: str
|
||||
@@ -0,0 +1,82 @@
|
||||
from datetime import timedelta
|
||||
from typing import Annotated
|
||||
import uuid, time
|
||||
|
||||
from tortoise.expressions import Q
|
||||
from fastapi import Depends, HTTPException, status
|
||||
|
||||
from modules.users.models import User
|
||||
from modules.auth.models import Token
|
||||
from config import settings
|
||||
from joserfc import jwt # type: ignore
|
||||
from joserfc.jwk import OctKey # type: ignore
|
||||
from config import settings
|
||||
|
||||
crypt = settings.CRYPT
|
||||
|
||||
|
||||
def create_token(user_id: uuid, offset: timedelta) -> str:
|
||||
"""
|
||||
Creates a JWT token
|
||||
"""
|
||||
user = str(user_id)
|
||||
curr_time = int(time.time())
|
||||
|
||||
return jwt.encode(
|
||||
{"alg": "HS256", "typ": "JWT"},
|
||||
{
|
||||
"iss": f"{settings.PROJECT_PUBLIC_URL}",
|
||||
"sub": f"id:{user}",
|
||||
"nbf": curr_time,
|
||||
"iat": curr_time,
|
||||
"exp": int(time.time() + offset.total_seconds()),
|
||||
},
|
||||
OctKey.import_key(settings.SECRET_KEY),
|
||||
)
|
||||
|
||||
|
||||
async def create_jwt_tokens(user: User) -> Token:
|
||||
"""
|
||||
Create a Token class with the following entities:
|
||||
|
||||
1) A user that is attached to the Token
|
||||
2) A fresh Auth Token
|
||||
3) A fresh Refresh Token.
|
||||
|
||||
This is then returned in the form of an Token class.
|
||||
"""
|
||||
auth_token = create_token(
|
||||
user_id=user.id, offset=timedelta(settings.ACCESS_TOKEN_EXPIRE_MIN)
|
||||
)
|
||||
|
||||
refresh_token = create_token(
|
||||
user_id=user.id, offset=timedelta(settings.REFRESH_TOKEN_EXPIRE_MIN)
|
||||
)
|
||||
|
||||
return await Token.create(
|
||||
user=user,
|
||||
access_token=auth_token,
|
||||
refresh_token=refresh_token,
|
||||
)
|
||||
|
||||
|
||||
async def get_tokens_from_logged_in_user(
|
||||
token: Annotated[str, Depends(settings.OAUTH2_SCHEME)]
|
||||
) -> User | None:
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="An issue occurred with the token.",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload: jwt.Token = jwt.decode(
|
||||
token, OctKey.import_key(settings.SECRET_KEY), algorithms=["HS256"]
|
||||
)
|
||||
id: str | None = payload.claims.get("sub", None)
|
||||
if id is None:
|
||||
raise credentials_exception
|
||||
user_id = id.split(":")[1]
|
||||
except:
|
||||
raise credentials_exception
|
||||
|
||||
return await Token.filter(Q(refresh_token=token) & Q(user__id=user_id)).first()
|
||||
@@ -0,0 +1,89 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Type
|
||||
import uuid
|
||||
import pytz
|
||||
from tortoise.exceptions import ConfigurationError
|
||||
from tortoise.models import Model
|
||||
from tortoise import fields
|
||||
|
||||
from mixins.CMDMixin import CMDMixin
|
||||
|
||||
class EnumField(fields.CharField):
|
||||
"""
|
||||
Serializes Enums to and from a str representation in the DB.
|
||||
"""
|
||||
|
||||
def __init__(self, enum_type: Type[Enum], **kwargs):
|
||||
super().__init__(128, **kwargs)
|
||||
if not issubclass(enum_type, Enum):
|
||||
raise ConfigurationError("{} is not a subclass of Enum!".format(enum_type))
|
||||
self._enum_type = enum_type
|
||||
|
||||
def to_db_value(self, value: Enum, _) -> str:
|
||||
return value.value
|
||||
|
||||
def to_python_value(self, value: str) -> Enum:
|
||||
try:
|
||||
return self._enum_type(value)
|
||||
except Exception:
|
||||
raise ValueError(
|
||||
"Database value {} does not exist on Enum {}.".format(
|
||||
value, self._enum_type
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class OrganizationType(Enum):
|
||||
"""
|
||||
Represents the following:
|
||||
|
||||
1. Is this a commercial entity or not?
|
||||
2. What size is it?
|
||||
|
||||
All choices should be representative of the org.
|
||||
"""
|
||||
|
||||
HOME: str = "home" # Home use (Any size)
|
||||
NON_PROFIT: str = "non_profit"
|
||||
SMALL_ORGANIZATION: str = "s_org" # 1-100
|
||||
MEDIUM_ORGANIZATION: str = "m_org" # 100 - 500
|
||||
LARGE_ORGANIZATION: str = "l_org" # 500 - 1000
|
||||
EXTRA_LARGE_ORGANIZATION: str = "xl_org" # 1000 - 5000+
|
||||
|
||||
|
||||
|
||||
class Organization(Model, CMDMixin):
|
||||
"""
|
||||
Organization
|
||||
|
||||
This class holds the organization for a household / organization
|
||||
and makes sure that we can add users.
|
||||
"""
|
||||
|
||||
id: uuid.UUID = fields.UUIDField(primary_key=True)
|
||||
name: str = fields.CharField(max_length=128)
|
||||
type: str = EnumField(OrganizationType)
|
||||
users: uuid.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}"
|
||||
|
||||
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,17 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/v1/organizations")
|
||||
|
||||
@router.get("/")
|
||||
def all_organizations():
|
||||
pass
|
||||
|
||||
@router.delete("/")
|
||||
def delete_organization():
|
||||
pass
|
||||
|
||||
@router.post("/create")
|
||||
def create_organization():
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
from tortoise.contrib.pydantic import pydantic_model_creator
|
||||
|
||||
from modules.organizations.models import Organization
|
||||
|
||||
OrganizationModel = pydantic_model_creator(Organization)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
from pydantic import EmailStr
|
||||
import pytz
|
||||
from tortoise.models import Model
|
||||
from tortoise import fields
|
||||
|
||||
from modules.organizations.models import Organization
|
||||
from mixins.CMDMixin import CMDMixin
|
||||
from config import settings
|
||||
|
||||
crypt = settings.CRYPT
|
||||
|
||||
|
||||
class User(Model, CMDMixin):
|
||||
"""
|
||||
User
|
||||
|
||||
This holds all of our users
|
||||
"""
|
||||
|
||||
id: uuid.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}"
|
||||
|
||||
async def set_password(self, password: str) -> None:
|
||||
self.password = crypt.hash(password)
|
||||
await self.save() # Make sure to save the model in DB
|
||||
|
||||
def check_against_password(self, password: str) -> bool:
|
||||
return crypt.verify(password, self.password)
|
||||
|
||||
async def update_password(
|
||||
self, old_password, new_password: str, verify_new_password: str
|
||||
) -> bool:
|
||||
if self.check_against_password(old_password) is False:
|
||||
return False
|
||||
if new_password is not verify_new_password:
|
||||
return False
|
||||
await self.set_password(new_password)
|
||||
|
||||
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()
|
||||
|
||||
|
||||
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.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.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)
|
||||
|
||||
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,20 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from modules.users.models import User
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/v1/users", tags=["users"])
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def get_all_users():
|
||||
pass
|
||||
|
||||
@router.post("/")
|
||||
def create_user():
|
||||
pass
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
def get_user():
|
||||
pass
|
||||
@@ -0,0 +1,5 @@
|
||||
from tortoise.contrib.pydantic import pydantic_model_creator
|
||||
|
||||
from modules.users.models import User
|
||||
|
||||
user_model = pydantic_model_creator(User, exclude=["password"])
|
||||
@@ -0,0 +1,47 @@
|
||||
from typing import Annotated
|
||||
from joserfc import jwt # type: ignore
|
||||
from joserfc.jwk import OctKey # type: ignore
|
||||
|
||||
from tortoise.expressions import Q
|
||||
from fastapi import Depends, HTTPException, status
|
||||
|
||||
from modules.users.models import User
|
||||
from config import settings
|
||||
|
||||
|
||||
async def get_user_from_token(
|
||||
token: Annotated[str, Depends(settings.OAUTH2_SCHEME)]
|
||||
) -> User | None:
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="An issue occurred with the token.",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload: jwt.Token = jwt.decode(
|
||||
token, OctKey.import_key(settings.SECRET_KEY), algorithms=["HS256"]
|
||||
)
|
||||
id: str | None = payload.claims.get("sub", None)
|
||||
if id is None:
|
||||
raise credentials_exception
|
||||
user_id = id.split(":")[1]
|
||||
except:
|
||||
raise credentials_exception
|
||||
|
||||
return await User.filter(Q(id=user_id)).first()
|
||||
|
||||
|
||||
async def get_current_active_user(
|
||||
user: Annotated[User | None, Depends(get_user_from_token)],
|
||||
) -> User:
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User is not found or active",
|
||||
)
|
||||
if user.disabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User is not found or active",
|
||||
)
|
||||
return user
|
||||
@@ -1,4 +1,18 @@
|
||||
[tool.black]
|
||||
exclude = '''/
|
||||
# Default values for Black.
|
||||
\.eggs|\.git|\.hg|\.mypy_cache|\.nox|\.tox|\.venv|\.svn|_build|buck-out|build|dist|
|
||||
/'''
|
||||
line-length = 88
|
||||
|
||||
[tool.aerich]
|
||||
tortoise_orm = "database.TORTOISE_ORM"
|
||||
location = "./migrations"
|
||||
src_folder = "./."
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "session"
|
||||
testpaths = [
|
||||
"tests/",
|
||||
]
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
aerich==0.8.0
|
||||
fastapi[standard]==0.115.5
|
||||
uvicorn==0.31.1
|
||||
tortoise-orm[asyncpg]==0.22.1
|
||||
black==24.10.0
|
||||
python-dotenv==1.0.1
|
||||
uvicorn[standard]==0.34.0
|
||||
aerich>=0.8.0
|
||||
fastapi[all]>=0.115.5
|
||||
python-dotenv>=0.21.0
|
||||
tortoise-orm[asyncpg]>=0.22.1
|
||||
uvicorn>=0.31.1
|
||||
black>=24.10.0
|
||||
joserfc>=1.0.1
|
||||
passlib>=1.7.4
|
||||
pytz>=2024.2
|
||||
ptpython>=0.25
|
||||
msgspec>=0.19.0
|
||||
bcrypt>=4.2.1
|
||||
|
||||
# Test Suite
|
||||
httpx>=0.28.1
|
||||
pytest>=8.3.4
|
||||
mock>=5.1.0
|
||||
asyncio>=3.4.3
|
||||
pytest-mock>=3.14.0
|
||||
pytest-asyncio>=0.25.3
|
||||
asgi-lifespan>=2.1.0
|
||||
@@ -0,0 +1,16 @@
|
||||
from typing import Any
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
try:
|
||||
import msgspec # type: ignore
|
||||
except ImportError: # pragma: nocover
|
||||
msgspec = None # type: ignore
|
||||
|
||||
class msgspec_jsonresponse(JSONResponse):
|
||||
"""
|
||||
JSON Response using the high-performance msgspec lib to serialize data to JSON.
|
||||
"""
|
||||
|
||||
def render(self, content: Any) -> bytes:
|
||||
assert msgspec is not None, "msgspec must be installed to use msgspec_jsonresponse"
|
||||
return msgspec.json.encode(content)
|
||||
@@ -0,0 +1,15 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def main() -> RedirectResponse:
|
||||
return RedirectResponse(url="/docs")
|
||||
|
||||
|
||||
@router.get("/ping")
|
||||
async def ping() -> JSONResponse:
|
||||
return {"ping": "pong!"}
|
||||
@@ -0,0 +1,54 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
import httpx, pytest
|
||||
from config import settings
|
||||
from glob import glob
|
||||
|
||||
from asgi_lifespan import LifespanManager # type: ignore
|
||||
|
||||
settings.PSQL_DB_NAME = settings.PSQL_TEST_DB_NAME
|
||||
|
||||
pytest_plugins = [
|
||||
fixture.replace("/", ".").replace("\\", ".").replace(".py", "")
|
||||
for fixture in glob("tests/fixtures/*.py")
|
||||
if "__" not in fixture
|
||||
]
|
||||
|
||||
try:
|
||||
from main import app
|
||||
except ImportError:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.append(str(Path(__file__).parent.parent))
|
||||
from main import app
|
||||
|
||||
ClientManagerType = AsyncGenerator[httpx.AsyncClient, None]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
loop = asyncio.get_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def client_manager(app, base_url="https://localhost", **kw) -> ClientManagerType:
|
||||
app.state.testing = True
|
||||
async with LifespanManager(app):
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url=base_url, **kw) as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def client() -> ClientManagerType:
|
||||
async with client_manager(app) as c:
|
||||
yield c
|
||||
@@ -0,0 +1,86 @@
|
||||
from modules.organizations.models import Organization, OrganizationType
|
||||
from modules.users.models import ACL, Membership, User
|
||||
import pytest # type=ignore
|
||||
from config import settings
|
||||
|
||||
crypt = settings.CRYPT
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
async def use_user_account():
|
||||
org, _ = await Organization.get_or_create(
|
||||
id="6ad4c94e-0522-4912-8d16-02d451f4c92d",
|
||||
defaults={
|
||||
"name": "User's Organization",
|
||||
"type": OrganizationType.HOME,
|
||||
},
|
||||
)
|
||||
acl, _ = await ACL.get_or_create(
|
||||
id="a4e927a3-36e5-4761-badb-0a44ade6616f",
|
||||
defaults={
|
||||
"READ": True,
|
||||
"WRITE": True,
|
||||
"REPORT": True,
|
||||
"MANAGE": False,
|
||||
"ADMIN": False,
|
||||
},
|
||||
)
|
||||
user, _ = await User.get_or_create(
|
||||
id="24235427-9662-4ba3-a9c5-00000000000b",
|
||||
defaults={
|
||||
"email": "user@localhost.com",
|
||||
"username": "user",
|
||||
"name": "awesome",
|
||||
"surname": "user",
|
||||
"password": crypt.hash("userpassword"),
|
||||
},
|
||||
)
|
||||
membership, _ = await Membership.get_or_create(
|
||||
id="833b9511-b2da-4760-8fa4-1a5c7059911e",
|
||||
defaults={
|
||||
"organization": org,
|
||||
"user": user,
|
||||
"acl": acl,
|
||||
},
|
||||
)
|
||||
return org, acl, user, membership
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
async def use_admin_account():
|
||||
org, _ = await Organization.get_or_create(
|
||||
id="de001f44-1bb8-4667-9f9d-2d62d6ad7270",
|
||||
defaults={
|
||||
"name": "Admin's Organization",
|
||||
"type": OrganizationType.EXTRA_LARGE_ORGANIZATION,
|
||||
},
|
||||
)
|
||||
acl, _ = await ACL.get_or_create(
|
||||
id="83c1bfe6-c2ed-4ba1-be03-0e5c1960ec31",
|
||||
defaults={
|
||||
"READ": True,
|
||||
"WRITE": True,
|
||||
"REPORT": True,
|
||||
"MANAGE": True,
|
||||
"ADMIN": True,
|
||||
},
|
||||
)
|
||||
user, _ = await User.get_or_create(
|
||||
id="24235427-9662-4ba3-a9c5-00000000000a",
|
||||
defaults={
|
||||
"email": "admin@localhost.com",
|
||||
"username": "admin",
|
||||
"name": "awesome",
|
||||
"surname": "admin",
|
||||
"password": crypt.hash("adminpassword"),
|
||||
},
|
||||
)
|
||||
membership, _ = await Membership.get_or_create(
|
||||
id="393473ee-c218-4bcf-82cd-cb676c4d8a33",
|
||||
defaults={
|
||||
"organization": org,
|
||||
"user": user,
|
||||
"acl": acl,
|
||||
},
|
||||
)
|
||||
return org, acl, user, membership
|
||||
@@ -0,0 +1,200 @@
|
||||
from modules.users.models import User
|
||||
import pytest # type: ignore
|
||||
from httpx import AsyncClient
|
||||
from config import settings
|
||||
from unittest.mock import ANY
|
||||
from tortoise.expressions import Q
|
||||
|
||||
crypt = settings.CRYPT
|
||||
|
||||
|
||||
class TestAuthentication(object):
|
||||
@pytest.mark.asyncio
|
||||
async def test_authentication_with_non_existing_user_and_password(
|
||||
self, client: AsyncClient
|
||||
):
|
||||
response = await client.post(
|
||||
"https://localhost/api/v1/auth/login",
|
||||
data={
|
||||
"username": "non-existing@localhost.com",
|
||||
"password": "password",
|
||||
"grant_type": "password",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {"detail": "E-Mail Address or password is incorrect"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authentication_with_existing_user_and_wrong_password(
|
||||
self, client: AsyncClient, use_admin_account
|
||||
):
|
||||
_, _, _, _ = use_admin_account
|
||||
response = await client.post(
|
||||
"https://localhost/api/v1/auth/login",
|
||||
data={
|
||||
"username": "admin@localhost.com",
|
||||
"password": "password",
|
||||
"grant_type": "password",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {"detail": "E-Mail Address or password is incorrect"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authentication_with_existing_user_and_password(
|
||||
self, client: AsyncClient, use_admin_account
|
||||
):
|
||||
_, _, admin, _ = use_admin_account
|
||||
response = await client.post(
|
||||
"https://localhost/api/v1/auth/login",
|
||||
data={
|
||||
"username": "admin@localhost.com",
|
||||
"password": "adminpassword",
|
||||
"grant_type": "password",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"jwt": {
|
||||
"created_at": ANY,
|
||||
"user_id": str(admin.id),
|
||||
"id": ANY,
|
||||
"modified_at": ANY,
|
||||
"disabled_at": None,
|
||||
"refresh_token": ANY,
|
||||
"disabled": False,
|
||||
"access_token": ANY,
|
||||
"token_type": "Bearer",
|
||||
}
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logging_out_destroys_tokens(
|
||||
self, client: AsyncClient, use_user_account
|
||||
):
|
||||
_, _, user, _ = use_user_account
|
||||
response = await client.post(
|
||||
"https://localhost/api/v1/auth/login",
|
||||
data={
|
||||
"username": "user@localhost.com",
|
||||
"password": "userpassword",
|
||||
"grant_type": "password",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"jwt": {
|
||||
"created_at": ANY,
|
||||
"user_id": str(user.id),
|
||||
"id": ANY,
|
||||
"modified_at": ANY,
|
||||
"disabled_at": None,
|
||||
"refresh_token": ANY,
|
||||
"disabled": False,
|
||||
"access_token": ANY,
|
||||
"token_type": "Bearer",
|
||||
}
|
||||
}
|
||||
|
||||
access_token = response.json()["jwt"]["access_token"]
|
||||
refresh_token = response.json()["jwt"]["refresh_token"]
|
||||
|
||||
logout = await client.get(
|
||||
"https://localhost/api/v1/auth/logout",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
assert logout.status_code == 204
|
||||
|
||||
refresh_request = await client.post(
|
||||
"https://localhost/api/v1/auth/refresh",
|
||||
headers={"Authorization": f"Bearer {refresh_token}"},
|
||||
)
|
||||
|
||||
assert refresh_request.status_code == 401
|
||||
assert refresh_request.json() == {
|
||||
"detail": "Refresh token not found or something went wrong."
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_new_tokens_upon_refresh(
|
||||
self, client: AsyncClient, use_admin_account
|
||||
):
|
||||
_, _, admin, _ = use_admin_account
|
||||
token = await client.post(
|
||||
"https://localhost/api/v1/auth/login",
|
||||
data={
|
||||
"username": "admin@localhost.com",
|
||||
"password": "adminpassword",
|
||||
"grant_type": "password",
|
||||
},
|
||||
)
|
||||
assert token.status_code == 200
|
||||
assert token.json() == {
|
||||
"jwt": {
|
||||
"created_at": ANY,
|
||||
"user_id": str(admin.id),
|
||||
"id": ANY,
|
||||
"modified_at": ANY,
|
||||
"disabled_at": None,
|
||||
"refresh_token": ANY,
|
||||
"disabled": False,
|
||||
"access_token": ANY,
|
||||
"token_type": "Bearer",
|
||||
}
|
||||
}
|
||||
|
||||
refresh_token = token.json()["jwt"]["refresh_token"]
|
||||
|
||||
response2 = await client.post(
|
||||
"https://localhost/api/v1/auth/refresh",
|
||||
headers={"Authorization": f"Bearer {refresh_token}"},
|
||||
)
|
||||
|
||||
assert response2.status_code == 200
|
||||
assert response2.json() == {
|
||||
"jwt": {
|
||||
"created_at": ANY,
|
||||
"user_id": str(admin.id),
|
||||
"id": ANY,
|
||||
"modified_at": ANY,
|
||||
"disabled_at": None,
|
||||
"refresh_token": ANY,
|
||||
"disabled": False,
|
||||
"access_token": ANY,
|
||||
"token_type": "Bearer",
|
||||
}
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_new_account(self, client: AsyncClient):
|
||||
# Ensure account is never available. Prevents account already being available.
|
||||
check_if_account_exists: User | None = await User.filter(
|
||||
Q(email="superuser@localhost.com")
|
||||
).get_or_none()
|
||||
if check_if_account_exists:
|
||||
await check_if_account_exists.delete(force=True)
|
||||
|
||||
account = await client.post(
|
||||
"https://localhost/api/v1/auth/register",
|
||||
json={
|
||||
"email": "superuser@localhost.com",
|
||||
"username": "superuser",
|
||||
"name": "awesome",
|
||||
"surname": "superuser",
|
||||
"password": "superuserpassword",
|
||||
"validate_password": "superuserpassword",
|
||||
},
|
||||
)
|
||||
|
||||
assert account.status_code == 201
|
||||
assert account.json() == {
|
||||
"created_at": ANY,
|
||||
"disabled": False,
|
||||
"disabled_at": None,
|
||||
"email": "superuser@localhost.com",
|
||||
"id": ANY,
|
||||
"modified_at": ANY,
|
||||
"name": "awesome",
|
||||
"surname": "superuser",
|
||||
"username": "superuser",
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import pytest # type: ignore
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
class TestRootRoute(object):
|
||||
async def test_read_docs_on_main_route(self, client: AsyncClient):
|
||||
response = await client.get("https://localhost/api/v1/")
|
||||
assert response.status_code == 307
|
||||
|
||||
async def test_get_pong(self, client: AsyncClient):
|
||||
response = await client.get("https://localhost/api/v1/ping")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ping": "pong!"}
|
||||
@@ -1,8 +1,12 @@
|
||||
version: "3.7"
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
postgres:
|
||||
|
||||
services:
|
||||
caddy:
|
||||
image: caddy:<version>
|
||||
container_name: caddy
|
||||
image: docker.io/caddy/caddy:2.9-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
@@ -12,9 +16,15 @@ services:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost/ping"]
|
||||
interval: 5m
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
fastapi:
|
||||
container_name: "fastapi"
|
||||
stoneedge:
|
||||
container_name: "stoneedge"
|
||||
restart: unless-stopped
|
||||
build:
|
||||
context: ./api/asset_manager
|
||||
@@ -22,19 +32,21 @@ services:
|
||||
ports:
|
||||
- 8000:8000
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/ping"]
|
||||
interval: 5m
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
links:
|
||||
- "postgres"
|
||||
|
||||
postgres:
|
||||
container_name: "postgres"
|
||||
image: postgres
|
||||
container_name: postgres_container
|
||||
image: docker.io/postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-user}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-passwd}
|
||||
PGDATA: /data/postgres
|
||||
volumes:
|
||||
- postgres:/data/postgres
|
||||
ports:
|
||||
- "5432:5432"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
|
||||
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |