diff --git a/MANIFEST.in b/MANIFEST.in index 9dcdf7f..c64a7fa 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,8 +1,6 @@ include README.md include LICENSE -include alembic.ini include name.py include pytest.ini -graft alembic graft tests graft freenit/project diff --git a/README.md b/README.md index aa6c228..04141c7 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,6 @@ Freenit is based on * [FastAPI](https://fastapi.tiangolo.com/) -* [Ormar](https://github.com/collerek/ormar) +* [Oxyde](https://github.com/oxyde/oxyde) * [Bonsai](https://github.com/noirello/bonsai) * [Svelte](https://svelte.dev) diff --git a/alembic.ini b/alembic.ini deleted file mode 100644 index 3e1492f..0000000 --- a/alembic.ini +++ /dev/null @@ -1,92 +0,0 @@ -# A generic, single database configuration. - -[alembic] -# path to migration scripts -script_location = alembic - -# template used to generate migration files -# file_template = %%(rev)s_%%(slug)s - -# sys.path path, will be prepended to sys.path if present. -# defaults to the current working directory. -prepend_sys_path = . - -# timezone to use when rendering the date -# within the migration file as well as the filename. -# string value is passed to dateutil.tz.gettz() -# leave blank for localtime -# timezone = - -# max length of characters to apply to the -# "slug" field -# truncate_slug_length = 40 - -# set to 'true' to run the environment during -# the 'revision' command, regardless of autogenerate -# revision_environment = false - -# set to 'true' to allow .pyc and .pyo files without -# a source .py file to be detected as revisions in the -# versions/ directory -# sourceless = false - -# version location specification; this defaults -# to alembic/versions. When using multiple version -# directories, initial revisions must be specified with --version-path -# version_locations = %(here)s/bar %(here)s/bat alembic/versions - -# the output encoding used when revision files -# are written from script.py.mako -# output_encoding = utf-8 - -#sqlalchemy.url = sqlite:///db.sqlite - -path_separator = os - - -[post_write_hooks] -# post_write_hooks defines scripts or Python functions that are run -# on newly generated revision scripts. See the documentation for further -# detail and examples - -hooks = ruff - -# format using "ruff" - use the exec runner, execute a binary -ruff.type = exec -ruff.executable = ruff -ruff.options = format REVISION_SCRIPT_FILENAME - -# Logging configuration -[loggers] -keys = root,sqlalchemy,alembic - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = WARN -handlers = console -qualname = - -[logger_sqlalchemy] -level = WARN -handlers = -qualname = sqlalchemy.engine - -[logger_alembic] -level = INFO -handlers = -qualname = alembic - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = NOTSET -formatter = generic - -[formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s -datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py deleted file mode 100644 index 42e71d5..0000000 --- a/alembic/env.py +++ /dev/null @@ -1,14 +0,0 @@ -import os -import sys - -from alembic import context -from freenit.config import getConfig -from freenit.migration import run_migrations_offline, run_migrations_online - -sys.path.append(os.getcwd()) -config = getConfig() - -if context.is_offline_mode(): - run_migrations_offline(config) -else: - run_migrations_online(config) diff --git a/alembic/script.py.mako b/alembic/script.py.mako deleted file mode 100644 index 2c01563..0000000 --- a/alembic/script.py.mako +++ /dev/null @@ -1,24 +0,0 @@ -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} - -""" -from alembic import op -import sqlalchemy as sa -${imports if imports else ""} - -# revision identifiers, used by Alembic. -revision = ${repr(up_revision)} -down_revision = ${repr(down_revision)} -branch_labels = ${repr(branch_labels)} -depends_on = ${repr(depends_on)} - - -def upgrade(): - ${upgrades if upgrades else "pass"} - - -def downgrade(): - ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/f2a9191e9157_initial.py b/alembic/versions/f2a9191e9157_initial.py deleted file mode 100644 index b0bca4c..0000000 --- a/alembic/versions/f2a9191e9157_initial.py +++ /dev/null @@ -1,92 +0,0 @@ -"""initial - -Revision ID: f2a9191e9157 -Revises: -Create Date: 2025-02-01 13:43:38.485626 - -""" - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = "f2a9191e9157" -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - "roles", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("name", sa.Text(), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index(op.f("ix_roles_name"), "roles", ["name"], unique=True) - op.create_table( - "themes", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("name", sa.Text(), nullable=False), - sa.Column("bg_color", sa.Text(), nullable=False), - sa.Column("bg_secondary_color", sa.Text(), nullable=False), - sa.Column("color_primary", sa.Text(), nullable=False), - sa.Column("color_lightGrey", sa.Text(), nullable=False), - sa.Column("color_grey", sa.Text(), nullable=False), - sa.Column("color_darkGrey", sa.Text(), nullable=False), - sa.Column("color_error", sa.Text(), nullable=False), - sa.Column("color_success", sa.Text(), nullable=False), - sa.Column("grid_maxWidth", sa.Text(), nullable=False), - sa.Column("grid_gutter", sa.Text(), nullable=False), - sa.Column("font_size", sa.Text(), nullable=False), - sa.Column("font_color", sa.Text(), nullable=False), - sa.Column("font_family_sans", sa.Text(), nullable=False), - sa.Column("font_family_mono", sa.Text(), nullable=False), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("name"), - ) - op.create_table( - "users", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("email", sa.Text(), nullable=False), - sa.Column("password", sa.Text(), nullable=False), - sa.Column("fullname", sa.Text(), nullable=True), - sa.Column("active", sa.Boolean(), nullable=True), - sa.Column("admin", sa.Boolean(), nullable=True), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("email"), - ) - op.create_table( - "users_roles", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("role", sa.Integer(), nullable=True), - sa.Column("user", sa.Integer(), nullable=True), - sa.ForeignKeyConstraint( - ["role"], - ["roles.id"], - name="fk_users_roles_roles_role_id", - onupdate="CASCADE", - ondelete="CASCADE", - ), - sa.ForeignKeyConstraint( - ["user"], - ["users.id"], - name="fk_users_roles_users_user_id", - onupdate="CASCADE", - ondelete="CASCADE", - ), - sa.PrimaryKeyConstraint("id"), - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table("users_roles") - op.drop_table("users") - op.drop_table("themes") - op.drop_index(op.f("ix_roles_name"), table_name="roles") - op.drop_table("roles") - # ### end Alembic commands ### diff --git a/bin/common.sh b/bin/common.sh index d01ed8c..3cbb9a0 100644 --- a/bin/common.sh +++ b/bin/common.sh @@ -15,6 +15,7 @@ export OFFLINE=${OFFLINE:="no"} setup() { cd ${PROJECT_ROOT} + run_migrations="${2:-yes}" if [ "${SYSPKG}" != "YES" ]; then if [ ! -d ${HOME}/.virtualenvs/${VIRTUALENV} ]; then python${PY_VERSION} -m venv "${HOME}/.virtualenvs/${VIRTUALENV}" @@ -27,11 +28,7 @@ setup() { fi fi - if [ "${DB_TYPE}" = "sql" ]; then - if [ ! -e "alembic/versions" ]; then - mkdir alembic/versions - alembic revision --autogenerate -m initial - fi + if [ "${DB_TYPE}" = "sql" -a "${run_migrations}" != "no" ]; then python migrate.py fi } diff --git a/bin/devel.sh b/bin/devel.sh index 216dbe3..21b0c46 100755 --- a/bin/devel.sh +++ b/bin/devel.sh @@ -9,12 +9,7 @@ export OFFLINE=${OFFLINE:="no"} setup export FREENIT_ENV="dev" - -if [ ! -e "alembic/versions" ]; then - mkdir -p alembic/versions - alembic revision --autogenerate -m initial -fi -alembic upgrade head +python migrate.py echo "Backend" echo "===============" diff --git a/bin/freenit.sh b/bin/freenit.sh index 6579d19..5b9c51b 100755 --- a/bin/freenit.sh +++ b/bin/freenit.sh @@ -98,27 +98,6 @@ EOF - onelove-roles.freebsd-common - onelove-roles.freebsd_freenit EOF - - cat >alembic/env.py<.gitignore</dev/null 2>&1; then + FREENIT_ENV=dev oxyde makemigrations --name initial + fi + echo "Success!" cd .. } diff --git a/bin/security.sh b/bin/security.sh index fccf943..c9f8367 100755 --- a/bin/security.sh +++ b/bin/security.sh @@ -5,5 +5,5 @@ export FREENIT_ENV="test" . ${BIN_DIR}/common.sh -setup no +setup no no bandit `find freenit -type f -name '*.py' | grep -v 'freenit/cli\.py'` diff --git a/bin/test.sh b/bin/test.sh index fca767c..19571eb 100755 --- a/bin/test.sh +++ b/bin/test.sh @@ -5,5 +5,5 @@ export FREENIT_ENV="test" . ${BIN_DIR}/common.sh -setup +setup yes no pytest -v --ignore=freenit/project/ diff --git a/freenit/api/auth/__init__.py b/freenit/api/auth/__init__.py index 2c06325..fe14564 100644 --- a/freenit/api/auth/__init__.py +++ b/freenit/api/auth/__init__.py @@ -66,11 +66,14 @@ async def logout(response: Response): async def register_sql(credentials: LoginInput) -> User: - import ormar.exceptions try: user = await User.objects.get(email=credentials.email) raise HTTPException(status_code=409, detail="User already registered") - except ormar.exceptions.NoMatch: + except Exception as exc: + import oxyde + + if not isinstance(exc, oxyde.NotFoundError): + raise pass user = User( email=credentials.email, @@ -108,7 +111,8 @@ async def register(credentials: LoginInput, host=Header(default="")): @api.post("/auth/verify", response_model=UserSafe, tags=["auth"]) async def verify(verification: Verification): user = await decode(verification.verification) - await user.update(active=True) + user.active = True + await user.save(update_fields={"active"}) return user diff --git a/freenit/api/role/sql.py b/freenit/api/role/sql.py index a05e8a6..c612fa1 100644 --- a/freenit/api/role/sql.py +++ b/freenit/api/role/sql.py @@ -1,5 +1,4 @@ -import ormar -import ormar.exceptions +import oxyde from fastapi import Depends, Header, HTTPException from freenit.api.router import route @@ -23,7 +22,7 @@ async def get( _: User = Depends(role_perms), ) -> Page[RoleSafe]: ret = await paginate( - Role.objects.select_related("users"), + Role.objects, page, perpage, ) @@ -41,8 +40,8 @@ class RoleDetailAPI: @staticmethod async def get(id, _: User = Depends(role_perms)) -> RoleSafe: try: - role = await Role.objects.select_related("users").get(pk=id) - except ormar.exceptions.NoMatch: + role = await Role.objects.filter(id=id).get() + except oxyde.NotFoundError: raise HTTPException(status_code=404, detail="No such role") await role.load_all() return role @@ -53,8 +52,8 @@ async def patch( ) -> RoleSafe: if Role.dbtype() == "sql": try: - role = await Role.objects.get(pk=id) - except ormar.exceptions.NoMatch: + role = await Role.objects.get(id=id) + except oxyde.NotFoundError: raise HTTPException(status_code=404, detail="No such role") await role.patch(role_data) await role.load_all() @@ -67,8 +66,8 @@ async def patch( @staticmethod async def delete(id, _: User = Depends(role_perms)) -> RoleSafe: try: - role = await Role.objects.get(pk=id) - except ormar.exceptions.NoMatch: + role = await Role.objects.get(id=id) + except oxyde.NotFoundError: raise HTTPException(status_code=404, detail="No such role") await role.delete() return role @@ -80,15 +79,16 @@ class RoleUserAPI: @description("Assign user to role") async def post(role_id, user_id, _: User = Depends(role_perms)) -> UserSafe: try: - user = await User.objects.select_related("roles").get(pk=user_id) - except ormar.exceptions.NoMatch: + user = await User.objects.prefetch("roles").filter(id=user_id).get() + except oxyde.NotFoundError: raise HTTPException(status_code=404, detail="No such user") + await user.load_all() for role in user.roles: if role.id == role_id: raise HTTPException(status_code=409, detail="User already assigned") try: - role = await Role.objects.get(pk=role_id) - except ormar.exceptions.NoMatch: + role = await Role.objects.get(id=role_id) + except oxyde.NotFoundError: raise HTTPException(status_code=404, detail="No such role") await user.roles.add(role) return user @@ -97,15 +97,16 @@ async def post(role_id, user_id, _: User = Depends(role_perms)) -> UserSafe: @description("Deassign user to role") async def delete(role_id, user_id, _: User = Depends(role_perms)) -> UserSafe: try: - user = await User.objects.select_related("roles").get(pk=user_id) - except ormar.exceptions.NoMatch: + user = await User.objects.prefetch("roles").filter(id=user_id).get() + except oxyde.NotFoundError: raise HTTPException(status_code=404, detail="No such user") + await user.load_all() try: - role = await Role.objects.get(pk=role_id) - except ormar.exceptions.NoMatch: + role = await Role.objects.get(id=role_id) + except oxyde.NotFoundError: raise HTTPException(status_code=404, detail="No such role") try: await user.roles.remove(role) - except ormar.exceptions.NoMatch: + except oxyde.NotFoundError: raise HTTPException(status_code=404, detail="User is not part of role") return user diff --git a/freenit/api/theme.py b/freenit/api/theme.py index 8df6bda..1dbc313 100644 --- a/freenit/api/theme.py +++ b/freenit/api/theme.py @@ -1,5 +1,4 @@ -import ormar -import ormar.exceptions +import oxyde from fastapi import Depends, Header, HTTPException from freenit.api.router import route @@ -53,8 +52,8 @@ class ThemeDetailAPI: @staticmethod async def get(name: str) -> Theme: try: - theme = await Theme.objects.select_all().get(name=name) - except ormar.exceptions.NoMatch: + theme = await Theme.objects.get(name=name) + except oxyde.NotFoundError: raise HTTPException(status_code=404, detail="No such theme") return theme @@ -63,8 +62,8 @@ async def patch( name: str, theme_data: ThemeOptional, _: User = Depends(theme_perms) ) -> Theme: try: - theme = await Theme.objects.select_all().get(name=name) - except ormar.exceptions.NoMatch: + theme = await Theme.objects.get(name=name) + except oxyde.NotFoundError: raise HTTPException(status_code=404, detail="No such theme") await theme.patch(theme_data) return theme @@ -72,8 +71,8 @@ async def patch( @staticmethod async def delete(name: str, _: User = Depends(theme_perms)) -> Theme: try: - theme = await Theme.objects.select_all().get(name=name) - except ormar.exceptions.NoMatch: + theme = await Theme.objects.get(name=name) + except oxyde.NotFoundError: raise HTTPException(status_code=404, detail="No such theme") await theme.delete() return theme diff --git a/freenit/api/user/sql.py b/freenit/api/user/sql.py index a5422b6..d25e4ab 100644 --- a/freenit/api/user/sql.py +++ b/freenit/api/user/sql.py @@ -1,5 +1,4 @@ -import ormar -import ormar.exceptions +import oxyde from fastapi import Depends, Header, HTTPException from freenit.api.router import route @@ -26,7 +25,7 @@ async def get( _: User = Depends(user_perms), ) -> Page[UserSafe]: return await paginate( - User.objects.select_related(["roles"]), + User.objects.prefetch("roles"), page, perpage, ) @@ -37,8 +36,8 @@ class UserDetailAPI: @staticmethod async def get(id, _: User = Depends(user_perms)) -> UserSafe: try: - user = await User.objects.select_related("roles").get(pk=id) - except ormar.exceptions.NoMatch: + user = await User.objects.prefetch("roles").filter(id=id).get() + except oxyde.NotFoundError: raise HTTPException(status_code=404, detail="No such user") return user @@ -53,8 +52,8 @@ async def patch( if data.password: data.password = encrypt(data.password) try: - user = await User.objects.get(pk=id) - except ormar.exceptions.NoMatch: + user = await User.objects.get(id=id) + except oxyde.NotFoundError: raise HTTPException(status_code=404, detail="No such user") await user.patch(data) return user @@ -66,8 +65,8 @@ async def delete(id, cur_user: User = Depends(user_perms)) -> UserSafe: status_code=403, detail="Only admin users can delete other users" ) try: - user = await User.objects.get(pk=id) - except ormar.exceptions.NoMatch: + user = await User.objects.get(id=id) + except oxyde.NotFoundError: raise HTTPException(status_code=404, detail="No such user") await user.delete() return user diff --git a/freenit/app.py b/freenit/app.py index 528c528..3bfb915 100644 --- a/freenit/app.py +++ b/freenit/app.py @@ -10,10 +10,10 @@ @asynccontextmanager async def lifespan(_: FastAPI): - if not config.database.is_connected: + if not config.database.connected: await config.database.connect() yield - if config.database.is_connected: + if config.database.connected: await config.database.disconnect() diff --git a/freenit/auth.py b/freenit/auth.py index 89c90a2..c013c08 100644 --- a/freenit/auth.py +++ b/freenit/auth.py @@ -1,4 +1,5 @@ import jwt +import oxyde from fastapi import HTTPException, Request from passlib.hash import pbkdf2_sha256 @@ -16,13 +17,10 @@ async def decode(token): if pk is None: raise HTTPException(status_code=403, detail="Unauthorized") if User.dbtype() == "sql": - import ormar - import ormar.exceptions - try: - user = await User.objects.select_related("roles").get(pk=pk) + user = await User.objects.prefetch("roles").filter(id=pk).get() return user - except ormar.exceptions.NoMatch: + except oxyde.NotFoundError: raise HTTPException(status_code=403, detail="Unauthorized") elif User.dbtype() == "ldap": user = await User.get(pk) diff --git a/freenit/base_config.py b/freenit/base_config.py index 769c741..6c649d2 100644 --- a/freenit/base_config.py +++ b/freenit/base_config.py @@ -1,8 +1,8 @@ import socket from importlib import import_module +from pathlib import Path -import databases -import sqlalchemy +import oxyde second = 1 minute = 60 * second @@ -110,10 +110,8 @@ class BaseConfig: hostname = socket.gethostname() port = 5000 debug = False - metadata = sqlalchemy.MetaData() dburl = "sqlite:///db.sqlite" database = None - engine = None secret = "SECRET" # nosec user = "freenit.models.sql.user" role = "freenit.models.sql.role" @@ -125,8 +123,12 @@ class BaseConfig: ldap = None def __init__(self): - self.database = databases.Database(self.dburl) - self.engine = sqlalchemy.create_engine(self.dburl) + dburl = self.dburl + if dburl.startswith("sqlite:///") and not dburl.startswith("sqlite:////"): + dbpath = Path(dburl.removeprefix("sqlite:///")).resolve() + dburl = f"sqlite:///{dbpath}" + self.dburl = dburl + self.database = oxyde.AsyncDatabase(self.dburl, overwrite=True) def __repr__(self): return ( diff --git a/freenit/models/pagination.py b/freenit/models/pagination.py index 9776df2..9948640 100644 --- a/freenit/models/pagination.py +++ b/freenit/models/pagination.py @@ -20,5 +20,6 @@ async def paginate(query, page, perpage): pages = ceil(total / perpage) if total > 0 and page > pages: raise HTTPException(status_code=404, detail="No such page") - data = await query.paginate(page, perpage).all() + offset = max(page - 1, 0) * perpage + data = await query.offset(offset).limit(perpage).all() return Page(data=data, page=page, perpage=perpage, pages=pages, total=total) diff --git a/freenit/models/role.py b/freenit/models/role.py index 068cadb..4bc02e6 100644 --- a/freenit/models/role.py +++ b/freenit/models/role.py @@ -2,11 +2,5 @@ config = getConfig() auth = config.get_model("role") - - -class Role(auth.Role): - pass - - -class RoleOptional(auth.RoleOptional): - pass +Role = auth.Role +RoleOptional = auth.RoleOptional diff --git a/freenit/models/safe.py b/freenit/models/safe.py index a85722c..46ed4ee 100644 --- a/freenit/models/safe.py +++ b/freenit/models/safe.py @@ -1,4 +1,5 @@ from typing import List +from pydantic import BaseModel, ConfigDict, EmailStr from freenit.config import getConfig config = getConfig() @@ -6,8 +7,20 @@ if auth.User.dbtype() == "sql": - UserBase = auth.User.get_pydantic(exclude={"password"}) - RoleBase = config.get_model("role").BaseRole + class UserBase(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int | None = None + email: EmailStr + fullname: str | None = None + active: bool = False + admin: bool = False + + class RoleBase(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int | None = None + name: str elif auth.User.dbtype() == "ldap": UserBase = auth.UserSafe RoleBase = config.get_model("role").Role diff --git a/freenit/models/sql/base.py b/freenit/models/sql/base.py index f604f2f..dd301d0 100644 --- a/freenit/models/sql/base.py +++ b/freenit/models/sql/base.py @@ -1,50 +1,170 @@ -import ormar +from __future__ import annotations + +from typing import ClassVar + +import oxyde import pydantic +from fastapi import HTTPException +from freenit.auth import verify from freenit.config import getConfig config = getConfig() -class OrmarBaseModel(ormar.Model): +class OxydeBaseModel(oxyde.Model): @classmethod def dbtype(cls): return "sql" + @property + def pk(self): + return self.id + async def patch(self, fields): - result = {} - data = fields.model_dump() - for k in data: - if data[k] is not None: - result[k] = data[k] - return await self.update(**result) + data = fields.model_dump(exclude_none=True) + for key, value in data.items(): + setattr(self, key, value) + await self.save(update_fields=data.keys()) + return self + + async def load_all(self): + if hasattr(self, "roles"): + object.__setattr__(self, "roles", await self.fetch_roles()) + if hasattr(self, "users"): + object.__setattr__(self, "users", await self.fetch_users()) + return self + + +class RoleRelationManager: + def __init__(self, user: User): + self.user = user + + async def add(self, role: Role): + try: + await UserRole.objects.create( + user=self.user, + role=role, + user_id=self.user.id, + role_id=role.id, + ) + except oxyde.IntegrityError: + raise HTTPException(status_code=409, detail="User already assigned") + object.__setattr__(self.user, "roles", await self.user.fetch_roles()) + + async def remove(self, role: Role): + link = await UserRole.objects.get(user_id=self.user.id, role_id=role.id) + await link.delete() + object.__setattr__(self.user, "roles", await self.user.fetch_roles()) + + +class RoleList(list): + def __init__(self, user: "User", roles: list["BaseRole"] | None = None): + super().__init__(roles or []) + self.user = user + + async def add(self, role: "BaseRole"): + await RoleRelationManager(self.user).add(role) + self[:] = await self.user.fetch_roles() + + async def remove(self, role: "BaseRole"): + await RoleRelationManager(self.user).remove(role) + self[:] = await self.user.fetch_roles() -class OrmarUserMixin: - id: int = ormar.Integer(primary_key=True) - email: pydantic.EmailStr = ormar.Text(unique=True) - password: str = ormar.Text() - fullname: str = ormar.Text(nullable=True) - active: bool = ormar.Boolean(default=False) - admin: bool = ormar.Boolean(default=False) +class BaseRole(OxydeBaseModel): + id: int | None = oxyde.Field(default=None, db_pk=True) + name: str = oxyde.Field(db_unique=True, db_index=True) + users: list["User"] = oxyde.Field(default_factory=list, db_m2m=True, db_through="UserRole") + + class Meta: + is_table = True + table_name = "role" + + def model_post_init(self, __context): + object.__setattr__(self, "users", list(getattr(self, "users", []) or [])) + + async def fetch_users(self) -> list["User"]: + users = await User.objects.prefetch("roles").all() + return [user.email for user in users if any(role.id == self.id for role in user.roles)] + + +class User(OxydeBaseModel): + id: int | None = oxyde.Field(default=None, db_pk=True) + email: pydantic.EmailStr = oxyde.Field(db_unique=True) + password: str = oxyde.Field() + fullname: str | None = oxyde.Field(default=None) + active: bool = oxyde.Field(default=False) + admin: bool = oxyde.Field(default=False) + roles: list[BaseRole] = oxyde.Field(default_factory=list, db_m2m=True, db_through="UserRole") + + class Meta: + is_table = True + table_name = "user" + + def model_post_init(self, __context): + object.__setattr__(self, "roles", RoleList(self, list(getattr(self, "roles", []) or []))) + + def check(self, password: str) -> bool: + if self.password is None: + return False + return verify(password, self.password) + + @classmethod + async def login(cls, credentials) -> "User": + try: + user = await cls.objects.prefetch("roles").filter( + email=credentials.email, active=True + ).get() + except oxyde.NotFoundError: + raise HTTPException(status_code=403, detail="Failed to login") + if user.check(credentials.password): + return user + raise HTTPException(status_code=403, detail="Failed to login") + + async def fetch_roles(self) -> list[BaseRole]: + links = await UserRole.objects.filter(user_id=self.id).all() + role_ids = [link.role_id for link in links] + if not role_ids: + return RoleList(self, []) + roles = await BaseRole.objects.filter(id__in=role_ids).all() + return RoleList(self, roles) -class OrmarRoleMixin: - id: int = ormar.Integer(primary_key=True) - name: str = ormar.Text(unique=True, index=True) +class UserRole(OxydeBaseModel): + id: int | None = oxyde.Field(default=None, db_pk=True) + user: User | None = oxyde.Field(default=None, db_fk="id", db_on_delete="CASCADE") + role: BaseRole | None = oxyde.Field(default=None, db_fk="id", db_on_delete="CASCADE") + class Meta: + is_table = True + table_name = "user_role" + unique_together = [("user_id", "role_id")] -ormar_config = ormar.OrmarConfig( - database=config.database, - metadata=config.metadata, - engine=config.engine, -) +class Theme(OxydeBaseModel): + id: int | None = oxyde.Field(default=None, db_pk=True) + name: str = oxyde.Field(db_unique=True) + bg_color: str = oxyde.Field() + bg_secondary_color: str = oxyde.Field() + color_primary: str = oxyde.Field() + color_lightGrey: str = oxyde.Field() + color_grey: str = oxyde.Field() + color_darkGrey: str = oxyde.Field() + color_error: str = oxyde.Field() + color_success: str = oxyde.Field() + grid_maxWidth: str = oxyde.Field() + grid_gutter: str = oxyde.Field() + font_size: str = oxyde.Field() + font_color: str = oxyde.Field() + font_family_sans: str = oxyde.Field() + font_family_mono: str = oxyde.Field() -def make_optional(OptionalModel): - for field_name in OptionalModel.model_fields: - OptionalModel.model_fields[field_name].default = None + class Meta: + is_table = True + table_name = "theme" -class BaseRole(OrmarBaseModel, OrmarRoleMixin): - ormar_config = ormar_config.copy(abstract=True) +User.model_rebuild() +BaseRole.model_rebuild() +UserRole.model_rebuild() diff --git a/freenit/models/sql/role.py b/freenit/models/sql/role.py index 118bbda..3e714b4 100644 --- a/freenit/models/sql/role.py +++ b/freenit/models/sql/role.py @@ -1,12 +1,12 @@ -from .base import BaseRole, ormar_config, make_optional +from __future__ import annotations +from pydantic import BaseModel, ConfigDict -class Role(BaseRole): - ormar_config = ormar_config.copy() +from .base import BaseRole as Role -class RoleOptional(BaseRole.get_pydantic()): - pass +class RoleOptional(BaseModel): + model_config = ConfigDict(extra="forbid") + name: str | None = None -make_optional(RoleOptional) diff --git a/freenit/models/sql/theme.py b/freenit/models/sql/theme.py index d8b3894..b66fa57 100644 --- a/freenit/models/sql/theme.py +++ b/freenit/models/sql/theme.py @@ -1,31 +1,25 @@ -import ormar - -from .base import OrmarBaseModel, make_optional, ormar_config - - -class Theme(OrmarBaseModel): - ormar_config = ormar_config.copy() - - id: int = ormar.Integer(primary_key=True) - name: str = ormar.Text(unique=True) - bg_color: str = ormar.Text() - bg_secondary_color: str = ormar.Text() - color_primary: str = ormar.Text() - color_lightGrey: str = ormar.Text() - color_grey: str = ormar.Text() - color_darkGrey: str = ormar.Text() - color_error: str = ormar.Text() - color_success: str = ormar.Text() - grid_maxWidth: str = ormar.Text() - grid_gutter: str = ormar.Text() - font_size: str = ormar.Text() - font_color: str = ormar.Text() - font_family_sans: str = ormar.Text() - font_family_mono: str = ormar.Text() - - -class ThemeOptional(Theme): - pass - - -make_optional(ThemeOptional) +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +from .base import Theme + + +class ThemeOptional(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str | None = None + bg_color: str | None = None + bg_secondary_color: str | None = None + color_primary: str | None = None + color_lightGrey: str | None = None + color_grey: str | None = None + color_darkGrey: str | None = None + color_error: str | None = None + color_success: str | None = None + grid_maxWidth: str | None = None + grid_gutter: str | None = None + font_size: str | None = None + font_color: str | None = None + font_family_sans: str | None = None + font_family_mono: str | None = None diff --git a/freenit/models/sql/user.py b/freenit/models/sql/user.py index 1d5dae5..dc6eeb0 100644 --- a/freenit/models/sql/user.py +++ b/freenit/models/sql/user.py @@ -1,47 +1,16 @@ from __future__ import annotations -import ormar -import ormar.exceptions -from fastapi import HTTPException +from pydantic import BaseModel, ConfigDict, EmailStr -from freenit.auth import verify -from .base import ( - OrmarBaseModel, - OrmarUserMixin, - make_optional, - ormar_config, -) -from freenit.models.role import Role +from .base import User -class BaseUser(OrmarBaseModel, OrmarUserMixin): - def check(self, password: str) -> bool: - if self.password is None: - return False - return verify(password, self.password) +class UserOptional(BaseModel): + model_config = ConfigDict(extra="forbid") - @classmethod - async def login(cls, credentials) -> BaseUser: - try: - user = await cls.objects.select_related("roles").get( - email=credentials.email, active=True - ) - except ormar.exceptions.NoMatch: - raise HTTPException(status_code=403, detail="Failed to login") - if user.check(credentials.password): - return user - raise HTTPException(status_code=403, detail="Failed to login") + email: EmailStr | None = None + password: str | None = None + fullname: str | None = None + active: bool | None = None + admin: bool | None = None - -class User(BaseUser, OrmarUserMixin): - ormar_config = ormar_config.copy() - - roles = ormar.ManyToMany(Role, unique=True) - - -class UserOptional(User): - pass - - -make_optional(UserOptional) -UserOptionalPydantic = User.get_pydantic(exclude={"admin", "active"}) diff --git a/freenit/models/user.py b/freenit/models/user.py index b25ae09..0c0bab0 100644 --- a/freenit/models/user.py +++ b/freenit/models/user.py @@ -2,11 +2,5 @@ config = getConfig() auth = config.get_model("user") - - -class User(auth.User): - pass - - -class UserOptional(auth.UserOptional): - pass +User = auth.User +UserOptional = auth.UserOptional diff --git a/freenit/project/alembic.ini b/freenit/project/alembic.ini deleted file mode 100644 index 53593d5..0000000 --- a/freenit/project/alembic.ini +++ /dev/null @@ -1,90 +0,0 @@ -# A generic, single database configuration. - -[alembic] -# path to migration scripts -script_location = alembic - -# template used to generate migration files -# file_template = %%(rev)s_%%(slug)s - -# sys.path path, will be prepended to sys.path if present. -# defaults to the current working directory. -prepend_sys_path = . - -# timezone to use when rendering the date -# within the migration file as well as the filename. -# string value is passed to dateutil.tz.gettz() -# leave blank for localtime -# timezone = - -# max length of characters to apply to the -# "slug" field -# truncate_slug_length = 40 - -# set to 'true' to run the environment during -# the 'revision' command, regardless of autogenerate -# revision_environment = false - -# set to 'true' to allow .pyc and .pyo files without -# a source .py file to be detected as revisions in the -# versions/ directory -# sourceless = false - -# version location specification; this defaults -# to alembic/versions. When using multiple version -# directories, initial revisions must be specified with --version-path -# version_locations = %(here)s/bar %(here)s/bat alembic/versions - -# the output encoding used when revision files -# are written from script.py.mako -# output_encoding = utf-8 - -#sqlalchemy.url = sqlite:///db.sqlite - - -[post_write_hooks] -# post_write_hooks defines scripts or Python functions that are run -# on newly generated revision scripts. See the documentation for further -# detail and examples - -hooks = ruff - -# format using "ruff" - use the exec runner, execute a binary -ruff.type = exec -ruff.executable = ruff -ruff.options = format REVISION_SCRIPT_FILENAME - -# Logging configuration -[loggers] -keys = root,sqlalchemy,alembic - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = WARN -handlers = console -qualname = - -[logger_sqlalchemy] -level = WARN -handlers = -qualname = sqlalchemy.engine - -[logger_alembic] -level = INFO -handlers = -qualname = alembic - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = NOTSET -formatter = generic - -[formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s -datefmt = %H:%M:%S diff --git a/freenit/project/alembic/script.py.mako b/freenit/project/alembic/script.py.mako deleted file mode 100644 index 2c01563..0000000 --- a/freenit/project/alembic/script.py.mako +++ /dev/null @@ -1,24 +0,0 @@ -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} - -""" -from alembic import op -import sqlalchemy as sa -${imports if imports else ""} - -# revision identifiers, used by Alembic. -revision = ${repr(up_revision)} -down_revision = ${repr(down_revision)} -branch_labels = ${repr(branch_labels)} -depends_on = ${repr(depends_on)} - - -def upgrade(): - ${upgrades if upgrades else "pass"} - - -def downgrade(): - ${downgrades if downgrades else "pass"} diff --git a/freenit/project/bin/common.sh b/freenit/project/bin/common.sh index 4eae208..05c2151 100644 --- a/freenit/project/bin/common.sh +++ b/freenit/project/bin/common.sh @@ -12,6 +12,7 @@ export OFFLINE=${OFFLINE:="no"} setup() { cd ${PROJECT_ROOT} + run_migrations="${2:-yes}" if [ "${SYSPKG}" != "YES" ]; then if [ ! -d ${HOME}/.virtualenvs/${VIRTUALENV} ]; then python${PY_VERSION} -m venv "${HOME}/.virtualenvs/${VIRTUALENV}" @@ -29,9 +30,7 @@ setup() { fi fi - if [ ! -e "alembic/versions" ]; then - mkdir alembic/versions - alembic revision --autogenerate -m initial + if [ "${run_migrations}" != "no" ]; then + oxyde migrate fi - python migrate.py } diff --git a/freenit/project/bin/test.sh b/freenit/project/bin/test.sh index 5d1fba0..f7a1586 100755 --- a/freenit/project/bin/test.sh +++ b/freenit/project/bin/test.sh @@ -5,5 +5,5 @@ export FREENIT_ENV="test" . ${BIN_DIR}/common.sh -setup +setup yes no pytest -v diff --git a/freenit/project/migrate.py b/freenit/project/migrate.py index c508a1c..ad0060f 100644 --- a/freenit/project/migrate.py +++ b/freenit/project/migrate.py @@ -1,15 +1,24 @@ import importlib +import os +import shutil +import subprocess # nosec B404 -from alembic import command -from alembic.config import Config -from name import app_name -alembic_cfg = Config("alembic.ini") +def run_migrations(): + env = os.environ.copy() + env.setdefault("FREENIT_ENV", "prod") + oxyde = shutil.which("oxyde") + if oxyde is None: + raise RuntimeError("oxyde executable not found in PATH") + subprocess.run([oxyde, "migrate"], check=True, env=env) # nosec B603 def db_setup(): - importlib.import_module(f"{app_name}.app") - command.upgrade(alembic_cfg, "head") + run_migrations() + + from name import app_name + + return importlib.import_module(f"{app_name}.app") if __name__ == "__main__": diff --git a/freenit/project/migrations/__init__.py b/freenit/project/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/freenit/project/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/freenit/project/oxyde_config.py b/freenit/project/oxyde_config.py new file mode 100644 index 0000000..caf67ae --- /dev/null +++ b/freenit/project/oxyde_config.py @@ -0,0 +1,40 @@ +import os +from pathlib import Path + +from oxyde.migrations.utils import detect_dialect + + +MODELS = ["freenit.models.sql.base"] +MIGRATIONS_DIR = "migrations" + + +def database_url(): + env = os.getenv("FREENIT_ENV", "prod") + candidates = [ + os.getenv("FREENIT_DBURL"), + os.getenv("DATABASE_URL"), + os.getenv(f"FREENIT_{env.upper()}_DBURL"), + ] + for candidate in candidates: + if candidate: + return candidate + if env == "prod": + raise RuntimeError( + "No database URL configured. Set FREENIT_DBURL, DATABASE_URL, " + "or FREENIT_PROD_DBURL." + ) + filename = "test.sqlite" if env == "test" else "db.sqlite" + return f"sqlite:///{Path(filename).resolve()}" + + +def database_dialect(): + explicit = os.getenv("FREENIT_DIALECT") + if explicit: + return explicit + return detect_dialect(database_url()) + + +DIALECT = database_dialect() +DATABASES = { + "default": database_url(), +} diff --git a/freenit/project/project/app.py b/freenit/project/project/app.py index 528c528..3bfb915 100644 --- a/freenit/project/project/app.py +++ b/freenit/project/project/app.py @@ -10,10 +10,10 @@ @asynccontextmanager async def lifespan(_: FastAPI): - if not config.database.is_connected: + if not config.database.connected: await config.database.connect() yield - if config.database.is_connected: + if config.database.connected: await config.database.disconnect() diff --git a/freenit/project/tests/client.py b/freenit/project/tests/client.py index 1a8cee8..58a1681 100644 --- a/freenit/project/tests/client.py +++ b/freenit/project/tests/client.py @@ -2,6 +2,8 @@ from fastapi.testclient import TestClient +TEST_PASSWORD = "Sekrit" # nosec B105 + class Client(TestClient): def url_for(self, name, host=socket.gethostname()): @@ -38,7 +40,7 @@ def delete(self, endpoint): def login(self, user, endpoint="/auth/login"): data = { "email": user.email, - "password": "Sekrit", + "password": TEST_PASSWORD, } response = self.post(endpoint, data) self.cookies = response.cookies diff --git a/freenit/project/tests/conftest.py b/freenit/project/tests/conftest.py index 06d9c09..4d59fbb 100644 --- a/freenit/project/tests/conftest.py +++ b/freenit/project/tests/conftest.py @@ -1,30 +1,48 @@ import importlib import os +import asyncio +import tempfile +from pathlib import Path import pytest - -from alembic import command -from alembic.config import Config +import oxyde from name import app_name +from migrate import run_migrations from .client import Client -alembic_cfg = Config("alembic.ini") - os.environ["FREENIT_ENV"] = "test" @pytest.fixture def db_setup(): + config = importlib.import_module(f"{app_name}.app").config + asyncio.run(config.database.disconnect()) + fd, db_path = tempfile.mkstemp( + suffix=".sqlite", + dir=Path(__file__).resolve().parent.parent, + ) + os.close(fd) + if os.path.exists(db_path): + os.remove(db_path) + dburl = f"sqlite:///{Path(db_path).resolve()}" + os.environ["FREENIT_DBURL"] = dburl + config.dburl = dburl + config.database = oxyde.AsyncDatabase(dburl, overwrite=True) + app = importlib.import_module(f"{app_name}.app") - command.upgrade(alembic_cfg, "head") + run_migrations() yield app.app - current_path = os.path.dirname(__file__) - os.remove(f"{current_path}/../test.sqlite") + asyncio.run(config.database.disconnect()) + if os.path.exists(db_path): + os.remove(db_path) + os.environ.pop("FREENIT_DBURL", None) @pytest.fixture def client(db_setup): - return Client(db_setup) + client = Client(db_setup) + yield client + client.close() diff --git a/migrate.py b/migrate.py index 35e1e7f..31bbd33 100644 --- a/migrate.py +++ b/migrate.py @@ -1,16 +1,13 @@ import importlib - -from alembic import command -from alembic.config import Config -from name import app_name - -alembic_cfg = Config("alembic.ini") +import os +import subprocess def db_setup(): - app = importlib.import_module(f"{app_name}.app") - command.upgrade(alembic_cfg, "head") - return app + env = os.environ.copy() + env.setdefault("FREENIT_ENV", "prod") + subprocess.run(["oxyde", "migrate"], check=True, env=env) + return importlib.import_module("freenit.app") if __name__ == "__main__": diff --git a/migrations/0001_initial.py b/migrations/0001_initial.py new file mode 100644 index 0000000..3561962 --- /dev/null +++ b/migrations/0001_initial.py @@ -0,0 +1,334 @@ +"""Auto-generated migration. + +Created: 2026-04-13 20:17:08 +""" + +depends_on = None + + +def upgrade(ctx): + """Apply migration.""" + ctx.create_table( + "user_role", + fields=[ + { + 'name': 'id', + 'python_type': 'int', + 'db_type': None, + 'nullable': True, + 'primary_key': True, + 'unique': False, + 'default': None, + 'auto_increment': False + }, + { + 'name': 'user_id', + 'python_type': 'int', + 'db_type': None, + 'nullable': True, + 'primary_key': False, + 'unique': False, + 'default': None, + 'auto_increment': False + }, + { + 'name': 'role_id', + 'python_type': 'int', + 'db_type': None, + 'nullable': True, + 'primary_key': False, + 'unique': False, + 'default': None, + 'auto_increment': False + } + ], + foreign_keys=[ + { + 'name': 'fk_user_role_user_id', + 'columns': [ + 'user_id' + ], + 'ref_table': 'user', + 'ref_columns': [ + 'id' + ], + 'on_delete': 'CASCADE', + 'on_update': 'CASCADE' + }, + { + 'name': 'fk_user_role_role_id', + 'columns': [ + 'role_id' + ], + 'ref_table': 'role', + 'ref_columns': [ + 'id' + ], + 'on_delete': 'CASCADE', + 'on_update': 'CASCADE' + } + ], + ) + ctx.create_table( + "theme", + fields=[ + { + 'name': 'id', + 'python_type': 'int', + 'db_type': None, + 'nullable': True, + 'primary_key': True, + 'unique': False, + 'default': None, + 'auto_increment': False + }, + { + 'name': 'name', + 'python_type': 'str', + 'db_type': None, + 'nullable': False, + 'primary_key': False, + 'unique': True, + 'default': None, + 'auto_increment': False + }, + { + 'name': 'bg_color', + 'python_type': 'str', + 'db_type': None, + 'nullable': False, + 'primary_key': False, + 'unique': False, + 'default': None, + 'auto_increment': False + }, + { + 'name': 'bg_secondary_color', + 'python_type': 'str', + 'db_type': None, + 'nullable': False, + 'primary_key': False, + 'unique': False, + 'default': None, + 'auto_increment': False + }, + { + 'name': 'color_primary', + 'python_type': 'str', + 'db_type': None, + 'nullable': False, + 'primary_key': False, + 'unique': False, + 'default': None, + 'auto_increment': False + }, + { + 'name': 'color_lightGrey', + 'python_type': 'str', + 'db_type': None, + 'nullable': False, + 'primary_key': False, + 'unique': False, + 'default': None, + 'auto_increment': False + }, + { + 'name': 'color_grey', + 'python_type': 'str', + 'db_type': None, + 'nullable': False, + 'primary_key': False, + 'unique': False, + 'default': None, + 'auto_increment': False + }, + { + 'name': 'color_darkGrey', + 'python_type': 'str', + 'db_type': None, + 'nullable': False, + 'primary_key': False, + 'unique': False, + 'default': None, + 'auto_increment': False + }, + { + 'name': 'color_error', + 'python_type': 'str', + 'db_type': None, + 'nullable': False, + 'primary_key': False, + 'unique': False, + 'default': None, + 'auto_increment': False + }, + { + 'name': 'color_success', + 'python_type': 'str', + 'db_type': None, + 'nullable': False, + 'primary_key': False, + 'unique': False, + 'default': None, + 'auto_increment': False + }, + { + 'name': 'grid_maxWidth', + 'python_type': 'str', + 'db_type': None, + 'nullable': False, + 'primary_key': False, + 'unique': False, + 'default': None, + 'auto_increment': False + }, + { + 'name': 'grid_gutter', + 'python_type': 'str', + 'db_type': None, + 'nullable': False, + 'primary_key': False, + 'unique': False, + 'default': None, + 'auto_increment': False + }, + { + 'name': 'font_size', + 'python_type': 'str', + 'db_type': None, + 'nullable': False, + 'primary_key': False, + 'unique': False, + 'default': None, + 'auto_increment': False + }, + { + 'name': 'font_color', + 'python_type': 'str', + 'db_type': None, + 'nullable': False, + 'primary_key': False, + 'unique': False, + 'default': None, + 'auto_increment': False + }, + { + 'name': 'font_family_sans', + 'python_type': 'str', + 'db_type': None, + 'nullable': False, + 'primary_key': False, + 'unique': False, + 'default': None, + 'auto_increment': False + }, + { + 'name': 'font_family_mono', + 'python_type': 'str', + 'db_type': None, + 'nullable': False, + 'primary_key': False, + 'unique': False, + 'default': None, + 'auto_increment': False + } + ], + ) + ctx.create_table( + "role", + fields=[ + { + 'name': 'id', + 'python_type': 'int', + 'db_type': None, + 'nullable': True, + 'primary_key': True, + 'unique': False, + 'default': None, + 'auto_increment': False + }, + { + 'name': 'name', + 'python_type': 'str', + 'db_type': None, + 'nullable': False, + 'primary_key': False, + 'unique': True, + 'default': None, + 'auto_increment': False + } + ], + ) + ctx.create_table( + "user", + fields=[ + { + 'name': 'id', + 'python_type': 'int', + 'db_type': None, + 'nullable': True, + 'primary_key': True, + 'unique': False, + 'default': None, + 'auto_increment': False + }, + { + 'name': 'email', + 'python_type': 'emailstr', + 'db_type': None, + 'nullable': False, + 'primary_key': False, + 'unique': True, + 'default': None, + 'auto_increment': False + }, + { + 'name': 'password', + 'python_type': 'str', + 'db_type': None, + 'nullable': False, + 'primary_key': False, + 'unique': False, + 'default': None, + 'auto_increment': False + }, + { + 'name': 'fullname', + 'python_type': 'str', + 'db_type': None, + 'nullable': True, + 'primary_key': False, + 'unique': False, + 'default': None, + 'auto_increment': False + }, + { + 'name': 'active', + 'python_type': 'bool', + 'db_type': None, + 'nullable': False, + 'primary_key': False, + 'unique': False, + 'default': '0', + 'auto_increment': False + }, + { + 'name': 'admin', + 'python_type': 'bool', + 'db_type': None, + 'nullable': False, + 'primary_key': False, + 'unique': False, + 'default': '0', + 'auto_increment': False + } + ], + ) + + +def downgrade(ctx): + """Revert migration.""" + ctx.drop_table("user") + ctx.drop_table("role") + ctx.drop_table("theme") + ctx.drop_table("user_role") diff --git a/migrations/__init__.py b/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/oxyde_config.py b/oxyde_config.py new file mode 100644 index 0000000..67972bf --- /dev/null +++ b/oxyde_config.py @@ -0,0 +1,38 @@ +import os +from pathlib import Path + +from oxyde.migrations.utils import detect_dialect + + +def database_url(): + env = os.getenv("FREENIT_ENV", "prod") + candidates = [ + os.getenv("FREENIT_DBURL"), + os.getenv("DATABASE_URL"), + os.getenv(f"FREENIT_{env.upper()}_DBURL"), + ] + for candidate in candidates: + if candidate: + return candidate + if env == "prod": + raise RuntimeError( + "No database URL configured. Set FREENIT_DBURL, DATABASE_URL, " + "or FREENIT_PROD_DBURL." + ) + filename = "test.sqlite" if env == "test" else "db.sqlite" + return f"sqlite:///{Path(filename).resolve()}" + + +def database_dialect(): + explicit = os.getenv("FREENIT_DIALECT") + if explicit: + return explicit + return detect_dialect(database_url()) + + +MODELS = ["freenit.models.sql.base"] +DIALECT = database_dialect() +MIGRATIONS_DIR = "migrations" +DATABASES = { + "default": database_url(), +} diff --git a/pyproject.toml b/pyproject.toml index cb47bda..0554b36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,8 +42,7 @@ classifiers = [ build = ["twine"] ldap = ["bonsai"] sql = [ - "alembic", - "ormar", + "oxyde", ] test = [ "aiosqlite", @@ -56,7 +55,6 @@ classifiers = [ ] all = [ "aiosqlite", - "alembic", "bandit", "beanie", "bonsai", @@ -64,7 +62,7 @@ classifiers = [ "mkdocs", "mkdocs-material", "mkdocs-awesome-pages-plugin", - "ormar", + "oxyde", "pytest-asyncio", "pytest-factoryboy", "requests", diff --git a/tests/conftest.py b/tests/conftest.py index 79ece94..d2481f8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,27 +1,42 @@ import os +import asyncio +import tempfile +from pathlib import Path -import pytest +os.environ["FREENIT_ENV"] = "test" -from alembic.config import Config +import pytest +import oxyde from migrate import db_setup as dbs +from freenit.app import config from .client import Client -alembic_cfg = Config("alembic.ini") - -os.environ["FREENIT_ENV"] = "test" - @pytest.fixture def db_setup(): + asyncio.run(config.database.disconnect()) + fd, db_path = tempfile.mkstemp(suffix=".sqlite", dir=Path(__file__).resolve().parent.parent) + os.close(fd) + if os.path.exists(db_path): + os.remove(db_path) + dburl = f"sqlite:///{Path(db_path).resolve()}" + os.environ["FREENIT_DBURL"] = dburl + config.dburl = dburl + config.database = oxyde.AsyncDatabase(dburl, overwrite=True) + app = dbs() yield app.app - current_path = os.path.dirname(__file__) - os.remove(f"{current_path}/../test.sqlite") + asyncio.run(config.database.disconnect()) + if os.path.exists(db_path): + os.remove(db_path) + os.environ.pop("FREENIT_DBURL", None) @pytest.fixture def client(db_setup): - return Client(db_setup) + client = Client(db_setup) + yield client + client.close()