Compare commits

...

30 Commits

Author SHA1 Message Date
vikynoah
83dca59817 fix: BaseSchemaModelOUT alter (#43) 2025-04-02 09:58:36 +02:00
vikynoah
b80d26586d fix: Response model (#41)
* feat: Filter response fields

* changes

* changes

* import fix

* changes
2025-03-27 09:33:04 +01:00
creyD
c2e2469027 Adjusted files for isort & autopep 2025-03-24 08:26:44 +00:00
renovate[bot]
8363055984 feat(deps): update dependency python to 3.13 (#39)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-24 09:26:14 +01:00
vikynoah
f586ce5c03 feat: Filter response fields (#40)
* feat: Filter response fields

* changes

* changes
2025-03-24 09:25:52 +01:00
creyD
0af8f05edf Adjusted files for isort & autopep 2025-03-06 14:36:12 +00:00
vikynoah
b73c524e8d feat: Delete User from Auth0 on invite delete (#38) 2025-03-06 15:35:42 +01:00
creyD
be260b0ee6 Adjusted files for isort & autopep 2025-02-26 16:11:54 +00:00
renovate[bot]
b0f2815568 feat(deps): update dependency stripe to v11.6.0 (#36)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-26 17:11:23 +01:00
vikynoah
6ac609f3f4 feat: Add CSV file Helper (#37) 2025-02-26 17:11:07 +01:00
creyD
53ed939451 Adjusted files for isort & autopep 2025-02-24 17:27:48 +00:00
c56d14c2fd Merge pull request #35 from vikynoah/invite_bug
fix: add company id to invite user
2025-02-24 18:27:17 +01:00
vikynoah
1e9bcb92b6 fix: add company id to invite user 2025-02-24 11:49:17 +01:00
5e16bd5cbc fix: fixed issue that creyPY couldn't be used without PSQL 2025-02-19 10:27:51 +01:00
creyD
50b444be89 Adjusted files for isort & autopep 2025-02-14 09:10:55 +00:00
e12c86e352 Merge pull request #34 from vikynoah/obj_lifecycle_patch
feat : Add Patch to obj lifecycle
2025-02-14 10:10:21 +01:00
vikynoah
0708a48301 feat : Add Patch to obj lifecycle 2025-02-13 02:05:15 +01:00
34595d52f2 Merge pull request #33 from creyD/renovate/stripe-11.x
feat(deps): update dependency stripe to v11.5.0
2025-02-05 09:38:28 +01:00
renovate[bot]
421725ad10 feat(deps): update dependency stripe to v11.5.0 2025-01-27 22:02:52 +00:00
31c4cbb055 fix: fixed multiple bugs in database handling 2025-01-27 16:26:26 +01:00
410ae12f8e feat: added ssl option to test database 2025-01-27 13:16:55 +01:00
1f224c44bc feat: added sslmode flag 2025-01-27 13:09:16 +01:00
5b0cc0d87d fix: fixed tests 2025-01-24 19:10:36 +01:00
ecfc0fc167 fix: fixed issue with new mixin 2025-01-24 19:04:07 +01:00
eb62c87679 feat: added experimental init and annotation mixins 2025-01-24 18:58:39 +01:00
2ad7700f72 Merge pull request #30 from creyD/renovate/stripe-11.x
feat(deps): update dependency stripe to v11
2025-01-24 18:39:30 +01:00
1d7b767623 Merge pull request #31 from vikynoah/pagination_fix 2025-01-24 15:29:52 +01:00
vikynoah
f1f29e84c2 fix: Pagination Fix 2025-01-24 15:23:08 +01:00
dcb9afb8f2 fix: added btree_gist extension option 2025-01-24 11:01:48 +01:00
renovate[bot]
10c1ea5411 feat(deps): update dependency stripe to v11 2025-01-21 21:13:13 +00:00
18 changed files with 190 additions and 47 deletions

View File

@@ -38,7 +38,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
python-version: '3.13'
- run: python -m pip install --upgrade pip
- run: |
python -m pip install -r requirements.txt
@@ -93,7 +93,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
python-version: '3.13'
- name: Install dependencies
run: |

View File

@@ -1,4 +1,4 @@
from typing import Type, TypeVar, overload
from typing import Type, TypeVar, overload, List
from uuid import UUID
from fastapi import HTTPException
@@ -19,13 +19,19 @@ async def get_object_or_404(
db: AsyncSession,
expunge: bool = False,
lookup_column: str = "id",
response_fields: List[str] = [],
) -> T:
pass
@overload
def get_object_or_404(
db_class: Type[T], id: UUID | str, db: Session, expunge: bool = False, lookup_column: str = "id"
db_class: Type[T],
id: UUID | str,
db: Session,
expunge: bool = False,
lookup_column: str = "id",
response_fields: List[str] = [],
) -> T:
pass
@@ -36,10 +42,17 @@ def get_object_or_404(
db: Session | AsyncSession,
expunge: bool = False,
lookup_column: str = "id",
response_fields: List[str] = [],
) -> T:
async def _get_async_object() -> T:
query = select(db_class).filter(getattr(db_class, lookup_column) == id)
if response_fields:
selected_columns = [
getattr(db_class, field) for field in response_fields if hasattr(db_class, field)
]
query = select(*selected_columns).select_from(db_class)
else:
query = select(db_class).filter(getattr(db_class, lookup_column) == id)
result = await db.execute(query)
obj = result.scalar_one_or_none()
if obj is None:
@@ -49,7 +62,14 @@ def get_object_or_404(
return obj
def _get_sync_object() -> T:
obj = db.query(db_class).filter(getattr(db_class, lookup_column) == id).one_or_none()
if response_fields:
selected_columns = [
getattr(db_class, field) for field in response_fields if hasattr(db_class, field)
]
query = db.query(*selected_columns).filter(getattr(db_class, lookup_column) == id)
else:
query = db.query(db_class).filter(getattr(db_class, lookup_column) == id)
obj = query.one_or_none()
if obj is None:
raise HTTPException(status_code=404, detail="The object does not exist.") # type: ignore
if expunge:

View File

@@ -1,3 +1,8 @@
from .async_session import * # noqa
from .helpers import * # noqa
from .session import * # noqa
try:
import sqlalchemy
from .async_session import *
from .helpers import *
from .session import *
except ImportError:
print("SQLAlchemy not installed. Database functionality will be disabled.")

View File

@@ -1,15 +1,14 @@
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from .common import SQLALCHEMY_DATABASE_URL, name
from .common import SQLALCHEMY_DATABASE_URL, name, ssl_mode
async_engine = create_async_engine(
SQLALCHEMY_DATABASE_URL + name, pool_pre_ping=True, connect_args={"sslmode": "require"}
SQLALCHEMY_DATABASE_URL + name, pool_pre_ping=True, connect_args={"sslmode": ssl_mode}
)
AsyncSessionLocal = sessionmaker(
AsyncSessionLocal = async_sessionmaker(
bind=async_engine,
class_=AsyncSession,
expire_on_commit=False,

View File

@@ -10,4 +10,6 @@ password = os.getenv("POSTGRES_PASSWORD", "root")
port = os.getenv("POSTGRES_PORT", "5432")
name = os.getenv("POSTGRES_DB", "fastapi")
ssl_mode = os.getenv("SSL_MODE", "require")
SQLALCHEMY_DATABASE_URL = f"postgresql+psycopg://{user}:{password}@{host}:{port}/"

View File

@@ -4,10 +4,10 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm.session import Session
from .common import SQLALCHEMY_DATABASE_URL, name
from .common import SQLALCHEMY_DATABASE_URL, name, ssl_mode
engine = create_engine(
SQLALCHEMY_DATABASE_URL + name, pool_pre_ping=True, connect_args={"sslmode": "require"}
SQLALCHEMY_DATABASE_URL + name, pool_pre_ping=True, connect_args={"sslmode": ssl_mode}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

View File

@@ -1 +1,2 @@
from .base import * # noqa
from .mixins import * # noqa

View File

@@ -7,9 +7,11 @@ from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm import as_declarative
from sqlalchemy.sql import func
from .mixins import AutoAnnotateMixin, AutoInitMixin
@as_declarative()
class Base:
class Base(AutoAnnotateMixin, AutoInitMixin):
__abstract__ = True
# Primary key as uuid
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)

View File

@@ -0,0 +1,36 @@
from sqlalchemy import Column
from sqlalchemy.orm import Mapped
class AutoAnnotateMixin:
@classmethod
def __init_subclass__(cls) -> None:
super().__init_subclass__()
annotations = {}
for key, value in cls.__dict__.items():
if isinstance(value, Column):
annotations[key] = Mapped[value.type.python_type]
cls.__annotations__ = annotations
class AutoInitMixin:
@classmethod
def __init_subclass__(cls) -> None:
super().__init_subclass__()
init_params = []
for key, value in cls.__dict__.items():
if isinstance(value, Column):
if not value.nullable and not value.default and not value.server_default:
init_params.append((key, value.type.python_type))
def __init__(self, **kwargs):
super(cls, self).__init__()
for key, _ in init_params:
if key not in kwargs:
raise TypeError(f"Missing required argument: {key}")
setattr(self, key, kwargs[key])
for key, value in kwargs.items():
if key not in init_params and hasattr(self.__class__, key):
setattr(self, key, value)
cls.__init__ = __init__

View File

@@ -1,27 +1,26 @@
from contextlib import suppress
from math import ceil
from typing import Any, Generic, Optional, Self, Sequence, TypeVar, Union, overload
from contextlib import suppress
from pydantic import BaseModel
from fastapi_pagination import Params
from fastapi_pagination.bases import AbstractPage, AbstractParams
from fastapi import Query
from fastapi_pagination.api import apply_items_transformer, create_page
from fastapi_pagination.bases import AbstractPage, AbstractParams, RawParams
from fastapi_pagination.ext.sqlalchemy import create_paginate_query
from fastapi_pagination.types import (
AdditionalData,
AsyncItemsTransformer,
GreaterEqualOne,
GreaterEqualZero,
AdditionalData,
SyncItemsTransformer,
AsyncItemsTransformer,
ItemsTransformer,
SyncItemsTransformer,
)
from fastapi_pagination.api import create_page, apply_items_transformer
from fastapi_pagination.utils import verify_params
from fastapi_pagination.ext.sqlalchemy import create_paginate_query
from fastapi_pagination.bases import AbstractParams, RawParams
from pydantic import BaseModel
from pydantic.json_schema import SkipJsonSchema
from sqlalchemy.sql.selectable import Select
from sqlalchemy.orm.session import Session
from sqlalchemy import select, func
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession, async_scoped_session
from fastapi import Query
from sqlalchemy.orm.session import Session
from sqlalchemy.sql.selectable import Select
from sqlalchemy.util import await_only, greenlet_spawn
T = TypeVar("T")
@@ -29,7 +28,7 @@ T = TypeVar("T")
class PaginationParams(BaseModel, AbstractParams):
page: int = Query(1, ge=1, description="Page number")
size: int = Query(50, ge=1, le=100, description="Page size")
size: int = Query(50, ge=1, description="Page size")
pagination: bool = Query(True, description="Toggle pagination")
def to_raw_params(self) -> RawParams:
@@ -62,7 +61,7 @@ class Page(AbstractPage[T], Generic[T]):
total: Optional[int] = None,
**kwargs: Any,
) -> Self:
if not isinstance(params, Params):
if not isinstance(params, PaginationParams):
raise TypeError("Page should be used with Params")
size = params.size or total or len(items)
@@ -170,9 +169,9 @@ def _paginate(
total = connection.scalar(count_query)
if params.pagination is False and total > 0:
params = Params(page=1, size=total)
params = PaginationParams(page=1, size=total)
else:
params = Params(page=params.page, size=params.size)
params = PaginationParams(page=params.page, size=params.size)
query = create_paginate_query(query, params)
items = connection.execute(query).all()

View File

@@ -1 +1,2 @@
from .base import * # noqa
from .response_schema import * #noqa

View File

@@ -11,6 +11,6 @@ class BaseSchemaModelIN(BaseModel):
class BaseSchemaModelOUT(BaseSchemaModelIN):
id: UUID
id: UUID | str
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,40 @@
from typing import List, Optional, Type
from pydantic import BaseModel, create_model
from fastapi import Query
class ResponseModelDependency:
def __init__(self, model_class: Type[BaseModel]):
self.model_class = model_class
def __call__(self, response_fields: Optional[List[str]] = Query(None)) -> Type[BaseModel]:
def process_result(result, fields=None):
if not fields:
return result
if hasattr(result, "_fields"):
row_fields = result._fields
return dict(zip(row_fields, result))
elif isinstance(result, tuple):
return dict(zip(fields, result))
elif isinstance(result, dict):
return result
else:
return {field: getattr(result, field) for field in fields if hasattr(result, field)}
if not response_fields:
return self.model_class, None, process_result
all_annotations = {}
for cls in self.model_class.__mro__:
if hasattr(cls, "__annotations__"):
all_annotations.update(cls.__annotations__)
fields = {}
for field in response_fields:
if field in all_annotations:
fields[field] = (all_annotations[field], None)
dynamic_model = create_model(f"Dynamic{self.model_class.__name__}", **fields)
return dynamic_model, response_fields, process_result

View File

@@ -3,7 +3,7 @@ import unittest
from typing import Type
from httpx import ASGITransport, AsyncClient, Response
from sqlalchemy import create_engine
from sqlalchemy import create_engine, text
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy_utils import create_database, database_exists, drop_database
@@ -20,20 +20,30 @@ class AbstractTestAPI(unittest.IsolatedAsyncioTestCase):
transport=ASGITransport(app=app), base_url="http://testserver", follow_redirects=True
)
cls.default_headers = headers
print("setting up abstract")
@classmethod
def setup_database(cls, sync_db_url: str, async_db_url: str, base: Type[Base]):
def setup_database(
cls,
sync_db_url: str,
async_db_url: str,
base: Type[Base],
btree_gist: bool = False,
ssl_mode: str = "require",
):
cls.engine_s = create_engine(
sync_db_url,
echo=False,
pool_pre_ping=True,
connect_args={"sslmode": "require"},
connect_args={"sslmode": ssl_mode},
)
if database_exists(cls.engine_s.url):
drop_database(cls.engine_s.url)
create_database(cls.engine_s.url)
if btree_gist:
with cls.engine_s.begin() as conn:
conn.execute(text("CREATE EXTENSION IF NOT EXISTS btree_gist"))
# Migrate
base.metadata.create_all(cls.engine_s)
@@ -41,7 +51,7 @@ class AbstractTestAPI(unittest.IsolatedAsyncioTestCase):
async_db_url,
echo=False,
pool_pre_ping=True,
connect_args={"sslmode": "require"},
connect_args={"sslmode": ssl_mode},
)
async def get(self, url: str, r_code: int = 200, parse_json=True) -> dict | bytes:
@@ -130,6 +140,7 @@ class AbstractTestAPI(unittest.IsolatedAsyncioTestCase):
pagination: bool = True,
id_field: str = "id",
created_at_check: bool = True,
patch: dict | None = None,
):
# GET LIST
re = await self.get(url)
@@ -154,6 +165,14 @@ class AbstractTestAPI(unittest.IsolatedAsyncioTestCase):
re = await self.get(f"{url}{obj_id}/")
self.assertEqual(re[id_field], obj_id)
# PATCH
if patch:
for key, value in patch.items():
input_obj[key] = value
re = await self.patch(f"{url}{obj_id}/", obj=input_obj)
for key, value in patch.items():
self.assertEqual(re[key], value)
# GET LIST
re = await self.get(url)
if pagination:

View File

@@ -1,5 +1,7 @@
import secrets
import string
import csv
from pathlib import Path
def create_random_password(length: int = 12) -> str:
@@ -14,3 +16,11 @@ def create_random_password(length: int = 12) -> str:
password += [secrets.choice(all_characters) for _ in range(length - 4)]
secrets.SystemRandom().shuffle(password)
return "".join(password)
def data_to_csv(file: Path, data: list) -> None:
with file.open(mode="w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=data[0].keys(), delimiter=";")
writer.writeheader()
writer.writerows(data)

View File

@@ -101,7 +101,7 @@ def request_verification_mail(sub: str) -> None:
return re.json()
def create_user_invite(email: str) -> dict:
def create_user_invite(email: str, company_id: str) -> dict:
re = requests.post(
f"https://{AUTH0_DOMAIN}/api/v2/users",
headers={"Authorization": f"Bearer {get_management_token()}"},
@@ -111,6 +111,7 @@ def create_user_invite(email: str) -> dict:
"password": create_random_password(),
"verify_email": False,
"app_metadata": {"invitedToMyApp": True},
"user_metadata": {"company_ids": [company_id]},
},
timeout=5,
)
@@ -119,6 +120,16 @@ def create_user_invite(email: str) -> dict:
return re.json()
def delete_user_invite(user_id: str) -> None:
re = requests.delete(
f"https://{AUTH0_DOMAIN}/api/v2/users/{user_id}",
headers={"Authorization": f"Bearer {get_management_token()}"},
timeout=5,
)
if re.status_code != 204:
raise HTTPException(re.status_code, re.json())
def password_change_mail(email: str) -> bool:
re = requests.post(
f"https://{AUTH0_DOMAIN}/dbconnections/change_password",

View File

@@ -1 +1 @@
stripe==10.12.0 # Stripe
stripe==11.6.0 # Stripe

View File

@@ -7,9 +7,7 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from creyPY.fastapi.app import generate_unique_id
from creyPY.fastapi.crud import (
get_object_or_404,
)
from creyPY.fastapi.crud import get_object_or_404
from creyPY.fastapi.models.base import Base
@@ -65,7 +63,7 @@ class TestMyFunction(unittest.TestCase):
def test_get_object_or_404_existing_object(self):
# Arrange
obj_id = UUID("123e4567-e89b-12d3-a456-426614174000")
obj = MockDBClass(obj_id)
obj = MockDBClass(id=obj_id)
self.db.add(obj)
self.db.commit()