From f98f69bb20072f7a3736472308f3f73f67b98195 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Goran=20Meki=C4=87?= Date: Sun, 21 Jun 2026 02:28:45 +0200 Subject: [PATCH] Break Freenit into modules --- bin/freenit.sh | 32 +- freenit/__init__.py | 2 +- freenit/api/__init__.py | 42 ++- freenit/api/project.py | 43 ++- freenit/api/theme.py | 86 ----- freenit/base_config.py | 29 +- freenit/models/sql/base.py | 59 ++-- freenit/models/sql/theme.py | 25 -- freenit/models/theme.py | 6 - freenit/modules.py | 127 ++++++++ freenit/project/project/api/__init__.py | 7 +- freenit/project/project/base_config.py | 5 +- migrations/0001_initial.py | 406 +++++++----------------- oxyde_config.py | 10 +- tests/test_modules.py | 44 +++ tests/test_project.py | 71 ++++- 16 files changed, 478 insertions(+), 516 deletions(-) delete mode 100644 freenit/api/theme.py delete mode 100644 freenit/models/sql/theme.py delete mode 100644 freenit/models/theme.py create mode 100644 freenit/modules.py create mode 100644 tests/test_modules.py diff --git a/bin/freenit.sh b/bin/freenit.sh index be0987a..1c6a08d 100755 --- a/bin/freenit.sh +++ b/bin/freenit.sh @@ -458,7 +458,10 @@ EOF await store.auth.logout() } - onMount(async () => { await store.auth.refresh_token() }) + onMount(async () => { + await store.loadModules() + await store.auth.refresh_token() + }) @@ -585,33 +588,6 @@ EOF -EOF - - mkdir -p 'src/routes/themes/[pk]' - cat >'src/routes/themes/[pk]/+page.ts' < { - return { - name: params.name - } -} -EOF - cat >'src/routes/themes/[pk]/+page.svelte' < - import { Theme } from 'freenit' - import store from '\$lib/store' - - const { data: props } = \$props() - - - -EOF - cat >'src/routes/themes/+page.svelte' < - import { Themes } from 'freenit' - import store from '\$lib/store' - - - EOF mkdir -p 'src/routes/users/[pk]' diff --git a/freenit/__init__.py b/freenit/__init__.py index 5c0d602..818ad96 100644 --- a/freenit/__init__.py +++ b/freenit/__init__.py @@ -1 +1 @@ -__version__ = "0.3.27" +__version__ = "0.3.28" diff --git a/freenit/api/__init__.py b/freenit/api/__init__.py index b787b48..63c2ab4 100644 --- a/freenit/api/__init__.py +++ b/freenit/api/__init__.py @@ -1,14 +1,32 @@ -import freenit.api.auth -import freenit.api.dav -import freenit.api.domain -import freenit.api.jabber -import freenit.api.mail -import freenit.api.mailinglist -import freenit.api.omemo -import freenit.api.project -import freenit.api.role -import freenit.api.sieve -import freenit.api.theme -import freenit.api.user +from importlib import import_module + +from freenit.config import getConfig +from freenit.modules import MODULES, get_api_modules, resolve_modules from .router import api + +config = getConfig() + +# Resolve the configured modules (including dependencies) and import their API +# modules. Each API module registers its routes on the shared `api` app as an +# import side-effect, preserving the existing decorator-based registration. +_module_names = resolve_modules(config.modules) +for _api_module in get_api_modules(config.modules): + import_module(_api_module) + + +@api.get("/") +async def api_root(): + """Discovery endpoint: list active modules. + + The frontend uses this response to decide which features to expose. + """ + return { + "modules": sorted(_module_names), + "meta": { + name: { + "dependencies": MODULES[name].dependencies, + } + for name in _module_names + }, + } diff --git a/freenit/api/project.py b/freenit/api/project.py index a9ad9b5..74dfcb4 100644 --- a/freenit/api/project.py +++ b/freenit/api/project.py @@ -102,6 +102,11 @@ class TaskResponse(pydantic.BaseModel): updated_at: datetime | None = None +class TaskDetailResponse(TaskResponse): + children: list[TaskResponse] = [] + parent: TaskResponse | None = None + + class ProjectGroupCreate(pydantic.BaseModel): name: str description: str | None = None @@ -261,9 +266,7 @@ async def _get_project_group_permissions(group_id: int) -> list[str]: return [p.permission for p in permissions] -async def _set_project_group_permissions( - group_id: int, permissions: list[str] -) -> None: +async def _set_project_group_permissions(group_id: int, permissions: list[str]) -> None: existing = await ProjectGroupPermission.objects.filter(group_id=group_id).all() for perm in existing: await perm.delete() @@ -524,9 +527,19 @@ async def post( @route("/tasks/{id}", tags=tags) class TaskDetailAPI: @staticmethod - async def get(id: int, _: User = Depends(project_perms)) -> TaskResponse: + async def get(id: int, _: User = Depends(project_perms)) -> TaskDetailResponse: task = await _get_task(id) - return TaskResponse.model_validate(task) + children = await Task.objects.filter(parent_id=id).order_by("position").all() + parent = None + if task.parent_id is not None: + try: + parent = await Task.objects.get(id=task.parent_id) + except oxyde.NotFoundError: + parent = None + data = TaskResponse.model_validate(task).model_dump() + data["children"] = [TaskResponse.model_validate(child) for child in children] + data["parent"] = TaskResponse.model_validate(parent) if parent else None + return TaskDetailResponse(**data) @staticmethod async def patch( @@ -588,7 +601,9 @@ async def get( perpage, ) for item in result.data: - object.__setattr__(item, "permissions", await _get_project_group_permissions(item.id)) + object.__setattr__( + item, "permissions", await _get_project_group_permissions(item.id) + ) return result @staticmethod @@ -609,7 +624,9 @@ async def post( updated_at=now, ) await _set_project_group_permissions(group.id, data.permissions) - object.__setattr__(group, "permissions", await _get_project_group_permissions(group.id)) + object.__setattr__( + group, "permissions", await _get_project_group_permissions(group.id) + ) return ProjectGroupResponse.model_validate(group) @@ -618,7 +635,9 @@ class ProjectGroupDetailAPI: @staticmethod async def get(id: int, _: User = Depends(project_perms)) -> ProjectGroupResponse: group = await _get_project_group(id) - object.__setattr__(group, "permissions", await _get_project_group_permissions(id)) + object.__setattr__( + group, "permissions", await _get_project_group_permissions(id) + ) return ProjectGroupResponse.model_validate(group) @staticmethod @@ -629,13 +648,17 @@ async def patch( ) -> ProjectGroupResponse: group = await _get_project_group(id) if data.name: - await _check_project_group_name_unique(group.project_id, data.name, exclude_id=id) + await _check_project_group_name_unique( + group.project_id, data.name, exclude_id=id + ) update = data.model_dump(exclude_none=True) if "permissions" in update: await _set_project_group_permissions(id, update.pop("permissions")) if update: await group.patch(ProjectGroupOptional(**update)) - object.__setattr__(group, "permissions", await _get_project_group_permissions(id)) + object.__setattr__( + group, "permissions", await _get_project_group_permissions(id) + ) return ProjectGroupResponse.model_validate(group) @staticmethod diff --git a/freenit/api/theme.py b/freenit/api/theme.py deleted file mode 100644 index 1dbc313..0000000 --- a/freenit/api/theme.py +++ /dev/null @@ -1,86 +0,0 @@ -import oxyde -from fastapi import Depends, Header, HTTPException - -from freenit.api.router import route -from freenit.config import getConfig -from freenit.decorators import description -from freenit.models.pagination import Page, paginate -from freenit.models.theme import Theme, ThemeOptional -from freenit.models.user import User -from freenit.permissions import theme_perms - -tags = ["theme"] -config = getConfig() -default_theme = { - "name": config.theme_name, - "bg_color": "#ffffff", - "bg_secondary_color": "#f3f3f6", - "color_primary": "#14854F", - "color_lightGrey": "#d2d6dd", - "color_grey": "#747681", - "color_darkGrey": "#3f4144", - "color_error": "#d43939", - "color_success": "#28bd14", - "grid_maxWidth": "120rem", - "grid_gutter": "2rem", - "font_size": "1.6rem", - "font_color": "#333333", - "font_family_sans": "", - "font_family_mono": "monaco, Consolas, Lucida Console, monospace", -} - - -@route("/themes", tags=tags) -class ThemeListAPI: - @staticmethod - @description("Get themes") - async def get( - page: int = Header(default=1), - perpage: int = Header(default=10), - ) -> Page[Theme]: - themes = Theme.objects - return await paginate(themes, page, perpage) - - @staticmethod - async def post(theme: Theme, _: User = Depends(theme_perms)) -> Theme: - await theme.save() - return theme - - -@route("/themes/{name}", tags=tags) -class ThemeDetailAPI: - @staticmethod - async def get(name: str) -> Theme: - try: - theme = await Theme.objects.get(name=name) - except oxyde.NotFoundError: - raise HTTPException(status_code=404, detail="No such theme") - return theme - - @staticmethod - async def patch( - name: str, theme_data: ThemeOptional, _: User = Depends(theme_perms) - ) -> Theme: - try: - 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 - - @staticmethod - async def delete(name: str, _: User = Depends(theme_perms)) -> Theme: - try: - theme = await Theme.objects.get(name=name) - except oxyde.NotFoundError: - raise HTTPException(status_code=404, detail="No such theme") - await theme.delete() - return theme - - -@route("/theme/active", tags=tags) -class ThemeActiveAPI: - @staticmethod - async def get() -> Theme: - theme, _ = await Theme.objects.get_or_create(**default_theme) - return theme diff --git a/freenit/base_config.py b/freenit/base_config.py index 4b0c9dd..e06e84b 100644 --- a/freenit/base_config.py +++ b/freenit/base_config.py @@ -134,14 +134,13 @@ class BaseConfig: port = 5000 debug = False dburl = "sqlite:///db.sqlite" - database = None + _database = None secret = "SECRET" # nosec user = "freenit.models.sql.user" role = "freenit.models.sql.role" - theme = "freenit.models.sql.theme" mailinglist = "freenit.models.sql.mailinglist" project = "freenit.models.sql.project" - theme_name = "Freenit" + modules = ["auth"] meta = None auth = Auth() mail = None @@ -157,7 +156,16 @@ def __init__(self): dbpath = Path(dburl.removeprefix("sqlite:///")).resolve() dburl = f"sqlite:///{dbpath}" self.dburl = dburl - self.database = oxyde.AsyncDatabase(self.dburl, overwrite=True) + + @property + def database(self): + if self._database is None: + self._database = oxyde.AsyncDatabase(self.dburl, overwrite=True) + return self._database + + @database.setter + def database(self, value): + self._database = value def __repr__(self): return ( @@ -186,6 +194,19 @@ class TestConfig(BaseConfig): debug = True dburl = "sqlite:///test.sqlite" auth = Auth(secure=False) + modules = [ + "auth", + "user", + "role", + "project", + "mailinglist", + "domain", + "dav", + "mail", + "sieve", + "jabber", + "omemo", + ] class ProdConfig(BaseConfig): diff --git a/freenit/models/sql/base.py b/freenit/models/sql/base.py index 71c304b..edf6bb7 100644 --- a/freenit/models/sql/base.py +++ b/freenit/models/sql/base.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import ClassVar - import oxyde import pydantic from fastapi import HTTPException @@ -40,7 +38,7 @@ class RoleRelationManager: def __init__(self, user: User): self.user = user - async def add(self, role: Role): + async def add(self, role: "BaseRole"): try: await UserRole.objects.create( user=self.user, @@ -52,7 +50,7 @@ async def add(self, role: Role): 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): + async def remove(self, role: "BaseRole"): 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()) @@ -75,7 +73,9 @@ async def remove(self, role: "BaseRole"): 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") + users: list["User"] = oxyde.Field( + default_factory=list, db_m2m=True, db_through="UserRole" + ) class Meta: is_table = True @@ -86,7 +86,11 @@ def model_post_init(self, __context): 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)] + return [ + user.email + for user in users + if any(role.id == self.id for role in user.roles) + ] class User(OxydeBaseModel): @@ -97,14 +101,18 @@ class User(OxydeBaseModel): active: bool = oxyde.Field(default=False) admin: bool = oxyde.Field(default=False) omemo_bundle: str | None = oxyde.Field(default=None) - roles: list[BaseRole] = oxyde.Field(default_factory=list, db_m2m=True, db_through="UserRole") + 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 []))) + object.__setattr__( + self, "roles", RoleList(self, list(getattr(self, "roles", []) or [])) + ) def check(self, password: str) -> bool: if self.password is None: @@ -114,9 +122,11 @@ def check(self, password: str) -> bool: @classmethod async def login(cls, credentials) -> "User": try: - user = await cls.objects.prefetch("roles").filter( - email=credentials.email, active=True - ).get() + 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): @@ -135,7 +145,9 @@ async def fetch_roles(self) -> list[BaseRole]: 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") + role: BaseRole | None = oxyde.Field( + default=None, db_fk="id", db_on_delete="CASCADE" + ) class Meta: is_table = True @@ -143,29 +155,6 @@ class Meta: unique_together = [("user_id", "role_id")] -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() - - class Meta: - is_table = True - table_name = "theme" - - User.model_rebuild() BaseRole.model_rebuild() UserRole.model_rebuild() diff --git a/freenit/models/sql/theme.py b/freenit/models/sql/theme.py deleted file mode 100644 index b66fa57..0000000 --- a/freenit/models/sql/theme.py +++ /dev/null @@ -1,25 +0,0 @@ -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/theme.py b/freenit/models/theme.py deleted file mode 100644 index c825331..0000000 --- a/freenit/models/theme.py +++ /dev/null @@ -1,6 +0,0 @@ -from freenit.config import getConfig - -config = getConfig() -theme = config.get_model("theme") -Theme = theme.Theme -ThemeOptional = theme.ThemeOptional diff --git a/freenit/modules.py b/freenit/modules.py new file mode 100644 index 0000000..ccd4d0a --- /dev/null +++ b/freenit/modules.py @@ -0,0 +1,127 @@ +from dataclasses import dataclass, field +from typing import List, Set + + +@dataclass +class Module: + name: str + dependencies: List[str] = field(default_factory=list) + models: List[str] = field(default_factory=list) + api: str = "" + + def __hash__(self): + return hash(self.name) + + +MODULES = { + "auth": Module( + name="auth", + dependencies=["user", "role"], + models=["freenit.models.sql.base"], + api="freenit.api.auth", + ), + "user": Module( + name="user", + dependencies=["role"], + api="freenit.api.user", + ), + "role": Module( + name="role", + dependencies=["user"], + api="freenit.api.role", + ), + "project": Module( + name="project", + dependencies=["user"], + models=["freenit.models.sql.project"], + api="freenit.api.project", + ), + "mailinglist": Module( + name="mailinglist", + dependencies=["user"], + models=["freenit.models.sql.mailinglist"], + api="freenit.api.mailinglist", + ), + "domain": Module( + name="domain", + dependencies=["user"], + api="freenit.api.domain", + ), + "dav": Module( + name="dav", + dependencies=["user"], + api="freenit.api.dav", + ), + "mail": Module( + name="mail", + dependencies=["user"], + api="freenit.api.mail", + ), + "sieve": Module( + name="sieve", + dependencies=["user"], + api="freenit.api.sieve", + ), + "jabber": Module( + name="jabber", + dependencies=["user"], + api="freenit.api.jabber", + ), + "omemo": Module( + name="omemo", + dependencies=["user"], + api="freenit.api.omemo", + ), +} + + +def resolve_modules(requested: List[str]) -> Set[str]: + """Resolve active modules including dependencies. + + Handles circular dependencies by including every module that is part of the + cycle once any member of the cycle is requested. + """ + resolved: Set[str] = set() + stack: List[str] = [] + + def visit(name: str) -> None: + if name in resolved: + return + if name in stack: + # Circular dependency: include every module on the cycle. + cycle_start = stack.index(name) + for item in stack[cycle_start:]: + resolved.add(item) + return + module = MODULES.get(name) + if module is None: + raise ValueError(f"Unknown freenit module: {name}") + stack.append(name) + for dependency in module.dependencies: + visit(dependency) + stack.pop() + resolved.add(name) + + for name in requested: + visit(name) + + return resolved + + +def get_api_modules(requested: List[str]) -> List[str]: + """Return API module import paths for the resolved modules.""" + resolved = resolve_modules(requested) + return sorted(MODULES[name].api for name in resolved if MODULES[name].api) + + +def get_models(requested: List[str]) -> List[str]: + """Return SQL model module paths for migrations.""" + resolved = resolve_modules(requested) + seen = set() + models = [] + for name in sorted(resolved): + for model in MODULES[name].models: + if model not in seen: + seen.add(model) + models.append(model) + return models diff --git a/freenit/project/project/api/__init__.py b/freenit/project/project/api/__init__.py index d5e0130..1e1ca63 100644 --- a/freenit/project/project/api/__init__.py +++ b/freenit/project/project/api/__init__.py @@ -1,4 +1,7 @@ -__all__ = ["api", "user", "role", "mail", "dav"] +# The Freenit API is built dynamically from the modules listed in +# config.modules (default: ["auth"]). Importing the router here mounts all +# configured modules. Add project-specific routes below if needed. -from freenit.api import role, user, mail, dav from freenit.api.router import api + +__all__ = ["api"] diff --git a/freenit/project/project/base_config.py b/freenit/project/project/base_config.py index 51814df..36c5b18 100644 --- a/freenit/project/project/base_config.py +++ b/freenit/project/project/base_config.py @@ -4,6 +4,9 @@ class BaseConfig(FreenitBaseConfig): name = "NAME" version = "0.0.1" + # Modules are inherited from FreenitBaseConfig (default: ["auth"]). + # Add feature modules here, e.g.: + # modules = ["auth", "project"] stalwart_url = "http://stalwart.example.com" stalwart_admin = "%admin" stalwart_admin_pass = "" # nosec: B105 @@ -22,5 +25,5 @@ class TestConfig(BaseConfig): class ProdConfig(BaseConfig): - secret = "MORESECURESECRET" #nosec + secret = "MORESECURESECRET" # nosec mail = Mail() diff --git a/migrations/0001_initial.py b/migrations/0001_initial.py index 3561962..2139904 100644 --- a/migrations/0001_initial.py +++ b/migrations/0001_initial.py @@ -12,316 +12,143 @@ def upgrade(ctx): "user_role", fields=[ { - 'name': 'id', - 'python_type': 'int', - 'db_type': None, - 'nullable': True, - 'primary_key': True, - 'unique': False, - 'default': None, - 'auto_increment': False + "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, }, - { - '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": "fk_user_role_user_id", + "columns": ["user_id"], + "ref_table": "user", + "ref_columns": ["id"], + "on_delete": "CASCADE", + "on_update": "CASCADE", }, { - 'name': 'color_error', - 'python_type': 'str', - 'db_type': None, - 'nullable': False, - 'primary_key': False, - 'unique': False, - 'default': None, - 'auto_increment': False + "name": "fk_user_role_role_id", + "columns": ["role_id"], + "ref_table": "role", + "ref_columns": ["id"], + "on_delete": "CASCADE", + "on_update": "CASCADE", }, - { - '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": "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': '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": "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, }, - { - '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 - } ], ) @@ -330,5 +157,4 @@ 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/oxyde_config.py b/oxyde_config.py index 7d20ecd..79c0174 100644 --- a/oxyde_config.py +++ b/oxyde_config.py @@ -3,6 +3,9 @@ from oxyde.migrations.utils import detect_dialect +from freenit.config import getConfig +from freenit.modules import get_models + def database_url(): env = os.getenv("FREENIT_ENV", "prod") @@ -30,11 +33,8 @@ def database_dialect(): return detect_dialect(database_url()) -MODELS = [ - "freenit.models.sql.base", - "freenit.models.sql.mailinglist", - "freenit.models.sql.project", -] +config = getConfig() +MODELS = get_models(config.modules) DIALECT = database_dialect() MIGRATIONS_DIR = "migrations" DATABASES = { diff --git a/tests/test_modules.py b/tests/test_modules.py new file mode 100644 index 0000000..b127f12 --- /dev/null +++ b/tests/test_modules.py @@ -0,0 +1,44 @@ +import pytest + +from freenit.modules import get_api_modules, get_models, resolve_modules + + +@pytest.mark.asyncio +class TestModules: + async def test_resolve_auth_includes_user_and_role(self): + resolved = resolve_modules(["auth"]) + assert resolved == {"auth", "user", "role"} + + async def test_resolve_project_includes_user_role(self): + resolved = resolve_modules(["project"]) + assert resolved == {"project", "user", "role"} + + async def test_resolve_multiple_modules(self): + resolved = resolve_modules(["auth", "project", "mailinglist"]) + assert resolved == {"auth", "project", "mailinglist", "user", "role"} + + async def test_resolve_unknown_module_raises(self): + with pytest.raises(ValueError): + resolve_modules(["auth", "unknown"]) + + async def test_get_api_modules(self): + api_modules = get_api_modules(["auth", "project"]) + assert "freenit.api.auth" in api_modules + assert "freenit.api.project" in api_modules + assert "freenit.api.mailinglist" not in api_modules + + async def test_get_models(self): + models = get_models(["auth", "project"]) + assert "freenit.models.sql.base" in models + assert "freenit.models.sql.project" in models + assert "freenit.models.sql.mailinglist" not in models + + async def test_discovery_endpoint(self, client): + response = client.get("/") + assert response.status_code == 200 + data = response.json() + assert "modules" in data + assert "meta" in data + assert "auth" in data["modules"] + assert "user" in data["modules"] + assert "role" in data["modules"] diff --git a/tests/test_project.py b/tests/test_project.py index 224ec84..874e973 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -76,7 +76,9 @@ async def test_update_project_duplicate_name(self, client): await project1.save() project2 = factories.ProjectFactory(name="Project Two", created_by_id=user.id) await project2.save() - response = client.patch(f"/projects/{project2.id}", data={"name": "Project One"}) + response = client.patch( + f"/projects/{project2.id}", data={"name": "Project One"} + ) assert response.status_code == 409 @@ -245,9 +247,7 @@ async def test_create_column_duplicate_name(self, client): await board.save() column = factories.ColumnFactory(board_id=board.id, name="Column X") await column.save() - response = client.post( - f"/boards/{board.id}/columns", data={"name": "Column X"} - ) + response = client.post(f"/boards/{board.id}/columns", data={"name": "Column X"}) assert response.status_code == 409 async def test_update_column_duplicate_name(self, client): @@ -316,6 +316,49 @@ async def test_get_task(self, client): response = client.get(f"/tasks/{task.id}") assert response.status_code == 200 assert response.json()["id"] == task.id + assert response.json()["children"] == [] + assert response.json()["parent"] is None + + async def test_get_task_includes_children(self, client): + user = factories.User() + await user.save() + client.login(user=user) + project = factories.ProjectFactory(created_by_id=user.id) + await project.save() + board = factories.BoardFactory(project_id=project.id) + await board.save() + column = factories.ColumnFactory(board_id=board.id) + await column.save() + parent = factories.TaskFactory(column_id=column.id) + await parent.save() + child = factories.TaskFactory(column_id=column.id, parent_id=parent.id) + await child.save() + response = client.get(f"/tasks/{parent.id}") + assert response.status_code == 200 + data = response.json() + assert data["id"] == parent.id + assert len(data["children"]) == 1 + assert data["children"][0]["id"] == child.id + + async def test_get_task_includes_parent(self, client): + user = factories.User() + await user.save() + client.login(user=user) + project = factories.ProjectFactory(created_by_id=user.id) + await project.save() + board = factories.BoardFactory(project_id=project.id) + await board.save() + column = factories.ColumnFactory(board_id=board.id) + await column.save() + parent = factories.TaskFactory(column_id=column.id) + await parent.save() + child = factories.TaskFactory(column_id=column.id, parent_id=parent.id) + await child.save() + response = client.get(f"/tasks/{child.id}") + assert response.status_code == 200 + data = response.json() + assert data["id"] == child.id + assert data["parent"]["id"] == parent.id async def test_update_task(self, client): user = factories.User() @@ -401,9 +444,7 @@ async def test_task_self_parent_rejected(self, client): await column.save() task = factories.TaskFactory(column_id=column.id) await task.save() - response = client.patch( - f"/tasks/{task.id}", data={"parent_id": task.id} - ) + response = client.patch(f"/tasks/{task.id}", data={"parent_id": task.id}) assert response.status_code == 400 @@ -481,7 +522,9 @@ async def test_create_project_group_duplicate_name(self, client): await project.save() group = factories.ProjectGroupFactory(project_id=project.id, name="Group X") await group.save() - response = client.post(f"/projects/{project.id}/groups", data={"name": "Group X"}) + response = client.post( + f"/projects/{project.id}/groups", data={"name": "Group X"} + ) assert response.status_code == 409 async def test_update_project_group_duplicate_name(self, client): @@ -494,7 +537,9 @@ async def test_update_project_group_duplicate_name(self, client): await group1.save() group2 = factories.ProjectGroupFactory(project_id=project.id, name="Group Two") await group2.save() - response = client.patch(f"/project-groups/{group2.id}", data={"name": "Group One"}) + response = client.patch( + f"/project-groups/{group2.id}", data={"name": "Group One"} + ) assert response.status_code == 409 @@ -508,7 +553,9 @@ async def test_add_project_group_member(self, client): await project.save() group = factories.ProjectGroupFactory(project_id=project.id) await group.save() - response = client.post(f"/project-groups/{group.id}/members", data={"user_id": user.id}) + response = client.post( + f"/project-groups/{group.id}/members", data={"user_id": user.id} + ) assert response.status_code == 200 result = response.json() assert result["group_id"] == group.id @@ -553,7 +600,9 @@ async def test_add_duplicate_project_group_member(self, client): await group.save() member = factories.ProjectMemberFactory(group_id=group.id, user_id=user.id) await member.save() - response = client.post(f"/project-groups/{group.id}/members", data={"user_id": user.id}) + response = client.post( + f"/project-groups/{group.id}/members", data={"user_id": user.id} + ) assert response.status_code == 409