Merge pull request #2 from BlackChaosNL/fea/patch1
[PATCH 1] Add structure, first API and testing.
@@ -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
|
# OpenAssetManager
|
||||||
Product for asset documentation for home to big business. Free base-system and non-free (paid) addons.
|
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
|
fastapi-project
|
||||||
├── alembic/
|
├── migrations/
|
||||||
├── 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
|
|
||||||
├── tests/
|
├── tests/
|
||||||
│ ├── auth
|
│ ├── auth
|
||||||
│ ├── aws
|
│ ├── aws
|
||||||
@@ -48,8 +24,39 @@ fastapi-project
|
|||||||
│ ├── base.txt
|
│ ├── base.txt
|
||||||
│ ├── dev.txt
|
│ ├── dev.txt
|
||||||
│ └── prod.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
|
├── .env
|
||||||
├── .gitignore
|
├── .gitignore
|
||||||
├── logging.ini
|
├── 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
|
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
|
RUN adduser -D nonroot
|
||||||
USER 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
|
EXPOSE 8000
|
||||||
|
|
||||||
ENTRYPOINT ["python", "-m"]
|
ENTRYPOINT ["python", "-m"]
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["fastapi", "run"]
|
||||||
|
|||||||
@@ -1,6 +1,27 @@
|
|||||||
class Settings:
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
PROJECT_NAME:str = "Open Asset Manager"
|
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_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()
|
settings = Settings()
|
||||||
|
|||||||
@@ -1,16 +1,30 @@
|
|||||||
from typing_extensions import Any
|
from typing_extensions import Any
|
||||||
from tortoise import Tortoise
|
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] = {
|
||||||
modules: dict[str, Any] = {'models': [
|
"models": [
|
||||||
'.models',
|
"modules.assets.models",
|
||||||
'.modules.auth.models',
|
"modules.auth.models",
|
||||||
'.modules.assets.models',
|
"modules.users.models",
|
||||||
]}
|
"modules.organizations.models",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
TORTOISE_ORM = {
|
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": {
|
"apps": {
|
||||||
"models": {
|
"models": {
|
||||||
"models": modules.get("models", []) + ["aerich.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():
|
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
|
async def end_connections_to_db():
|
||||||
await Tortoise.generate_schemas(safe=True)
|
await Tortoise.close_connections()
|
||||||
@@ -1,30 +1,49 @@
|
|||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from starlette.responses import RedirectResponse
|
from config import settings
|
||||||
from .config import settings
|
from database import end_connections_to_db, migrate_db
|
||||||
from .modules.assets.router import router as asset_router
|
from responses import msgspec_jsonresponse
|
||||||
from dotenv import load_dotenv
|
from contextlib import asynccontextmanager
|
||||||
from tortoise.contrib.fastapi import register_tortoise
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from .database import db_url, modules
|
|
||||||
|
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(
|
app = FastAPI(
|
||||||
|
lifespan=lifespan,
|
||||||
title=settings.PROJECT_NAME,
|
title=settings.PROJECT_NAME,
|
||||||
version=settings.PROJECT_VERSION,
|
version=settings.PROJECT_VERSION,
|
||||||
summary=settings.PROJECT_SUMMARY,
|
summary=settings.PROJECT_SUMMARY,
|
||||||
|
default_response_class=msgspec_jsonresponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
register_tortoise(
|
app.add_middleware(HTTPSRedirectMiddleware)
|
||||||
app,
|
app.add_middleware(TrustedHostMiddleware, allowed_hosts=[settings.PROJECT_PUBLIC_URL,])
|
||||||
db_url=db_url,
|
|
||||||
modules=modules,
|
|
||||||
generate_schemas=True,
|
|
||||||
add_exception_handlers=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
# 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.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.models import Model
|
||||||
from tortoise import fields
|
from tortoise import fields
|
||||||
|
from mixins.CMDMixin import CMDMixin
|
||||||
|
|
||||||
class Asset(Model):
|
class Asset(Model, CMDMixin):
|
||||||
id = fields.UUIDField(primary_key=True)
|
id = fields.UUIDField(primary_key=True)
|
||||||
name = fields.CharField(max_length=128)
|
name = fields.CharField(max_length=128)
|
||||||
|
|||||||
@@ -2,25 +2,22 @@ from uuid import UUID
|
|||||||
|
|
||||||
from fastapi.routing import APIRouter
|
from fastapi.routing import APIRouter
|
||||||
|
|
||||||
from .models import Asset
|
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/assets"
|
prefix="/assets"
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
async def get_all_assets():
|
async def get_all_assets():
|
||||||
return await Asset.get_or_none()
|
pass
|
||||||
|
|
||||||
@router.post("/")
|
@router.post("/")
|
||||||
async def create_asset(name: str):
|
async def create_asset(name: str):
|
||||||
asset = await Asset.create(name=name)
|
pass
|
||||||
return asset
|
|
||||||
|
|
||||||
@router.delete("/", status_code=204)
|
@router.delete("/", status_code=204)
|
||||||
async def delete_asset(remove_id: UUID):
|
async def delete_asset(remove_id: UUID):
|
||||||
await Asset.filter(id=remove_id).delete()
|
pass
|
||||||
|
|
||||||
@router.get("/{asset_id}")
|
@router.get("/{asset_id}")
|
||||||
async def get_asset(asset_id: UUID):
|
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]
|
[tool.aerich]
|
||||||
tortoise_orm = "database.TORTOISE_ORM"
|
tortoise_orm = "database.TORTOISE_ORM"
|
||||||
location = "./migrations"
|
location = "./migrations"
|
||||||
src_folder = "./."
|
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
|
aerich>=0.8.0
|
||||||
fastapi[standard]==0.115.5
|
fastapi[all]>=0.115.5
|
||||||
uvicorn==0.31.1
|
python-dotenv>=0.21.0
|
||||||
tortoise-orm[asyncpg]==0.22.1
|
tortoise-orm[asyncpg]>=0.22.1
|
||||||
black==24.10.0
|
uvicorn>=0.31.1
|
||||||
python-dotenv==1.0.1
|
black>=24.10.0
|
||||||
uvicorn[standard]==0.34.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:
|
services:
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:<version>
|
container_name: caddy
|
||||||
|
image: docker.io/caddy/caddy:2.9-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
@@ -12,9 +16,15 @@ services:
|
|||||||
- ./Caddyfile:/etc/caddy/Caddyfile
|
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||||
- caddy_data:/data
|
- caddy_data:/data
|
||||||
- caddy_config:/config
|
- caddy_config:/config
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost/ping"]
|
||||||
|
interval: 5m
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
fastapi:
|
stoneedge:
|
||||||
container_name: "fastapi"
|
container_name: "stoneedge"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
build:
|
build:
|
||||||
context: ./api/asset_manager
|
context: ./api/asset_manager
|
||||||
@@ -22,19 +32,21 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- 8000:8000
|
- 8000:8000
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8000/ping"]
|
||||||
interval: 5m
|
interval: 5m
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 15s
|
start_period: 15s
|
||||||
links:
|
|
||||||
- "postgres"
|
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
container_name: "postgres"
|
container_name: postgres_container
|
||||||
image: postgres
|
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
|
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 |