Compare commits

...

11 Commits

Author SHA1 Message Date
9ddd85d36a Merge pull request 'tech_update' (#23) from tech_update into master
Some checks failed
Build on push / prepare (push) Successful in 5s
Build on push / build-web (push) Failing after 48s
Build on push / build-redirector (push) Failing after 4s
Build on push / build-api (push) Successful in 52s
Test API before pr merge / test-lint (pull_request) Successful in 11s
Test before pr merge / test-lint (pull_request) Successful in 42s
Test before pr merge / test-translation-lint (pull_request) Successful in 40s
Test before pr merge / test-before-merge (pull_request) Successful in 1m36s
Reviewed-on: #23
2025-04-18 12:14:58 +02:00
41e4729ff7 Fixed api test
All checks were successful
Test API before pr merge / test-lint (pull_request) Successful in 19s
Test before pr merge / test-lint (pull_request) Successful in 40s
Test before pr merge / test-translation-lint (pull_request) Successful in 39s
Test before pr merge / test-before-merge (pull_request) Successful in 1m40s
2025-04-18 12:12:35 +02:00
04c47d636e Fixed tests
Some checks failed
Test before pr merge / test-lint (pull_request) Successful in 37s
Test before pr merge / test-before-merge (pull_request) Has been cancelled
Test before pr merge / test-translation-lint (pull_request) Has been cancelled
2025-04-18 12:11:49 +02:00
b872c33c02 Handle visible columns for short urls
Some checks failed
Test before pr merge / test-lint (pull_request) Failing after 35s
Test before pr merge / test-translation-lint (pull_request) Failing after 1m8s
Test before pr merge / test-before-merge (pull_request) Failing after 1m31s
2025-04-18 11:59:36 +02:00
7b62022a83 Updated build
Some checks failed
Test before pr merge / test-lint (pull_request) Failing after 1m4s
Test before pr merge / test-translation-lint (pull_request) Failing after 32s
Test before pr merge / test-before-merge (pull_request) Failing after 1m27s
2025-04-18 11:15:15 +02:00
165c140a25 Added history to frontend 2025-04-18 11:03:13 +02:00
1824ff7564 Updated history and frontend 2025-04-18 10:46:14 +02:00
347f8486af Technical update 2025-04-18 02:26:59 +02:00
ab3f0b07c2 Some improvements from lan-maestro
All checks were successful
Build on push / prepare (push) Successful in 10s
Build on push / build-redirector (push) Successful in 35s
Build on push / build-api (push) Successful in 36s
Build on push / build-web (push) Successful in 1m8s
2025-03-21 16:21:46 +01:00
e68b10933f Fixed dev build
Some checks failed
Build on push / prepare (push) Failing after 5s
Build on push / build-api (push) Has been skipped
Build on push / build-redirector (push) Has been skipped
Build on push / build-web (push) Has been skipped
2025-03-15 19:30:00 +01:00
60d2faf2f6 Fixed api key edit
All checks were successful
Build on push / prepare (push) Successful in 6s
Build on push / build-redirector (push) Successful in 29s
Build on push / build-api (push) Successful in 31s
Build on push / build-web (push) Successful in 54s
2025-03-15 12:07:06 +01:00
200 changed files with 3982 additions and 1165 deletions

View File

@ -17,7 +17,7 @@ jobs:
- name: Get Date and Build Number - name: Get Date and Build Number
run: | run: |
git fetch --tags git fetch
git tag git tag
DATE=$(date +'%Y.%m.%d') DATE=$(date +'%Y.%m.%d')
TAG_COUNT=$(git tag -l "${DATE}.*" | wc -l) TAG_COUNT=$(git tag -l "${DATE}.*" | wc -l)
@ -54,6 +54,7 @@ jobs:
uses: actions/download-artifact@v3 uses: actions/download-artifact@v3
with: with:
name: version name: version
path: version.txt
- name: Build docker - name: Build docker
run: | run: |
@ -86,6 +87,7 @@ jobs:
uses: actions/download-artifact@v3 uses: actions/download-artifact@v3
with: with:
name: version name: version
path: version.txt
- name: Build docker - name: Build docker
run: | run: |
@ -117,6 +119,7 @@ jobs:
uses: actions/download-artifact@v3 uses: actions/download-artifact@v3
with: with:
name: version name: version
path: version.txt
- name: Prepare web build - name: Prepare web build
run: | run: |
@ -131,7 +134,7 @@ jobs:
- name: Build docker - name: Build docker
run: | run: |
cd web cd web
docker build --no-cache -t git.sh-edraft.de/sh-edraft.de/open-redirect-web:$(cat ../version.txt) . docker build --no-cache --build-arg VERSION=$(cat version.txt) -t git.sh-edraft.de/sh-edraft.de/open-redirect-web:$(cat ../version.txt) .
- name: Login to registry git.sh-edraft.de - name: Login to registry git.sh-edraft.de
uses: https://github.com/docker/login-action@v1 uses: https://github.com/docker/login-action@v1

View File

@ -86,6 +86,7 @@ jobs:
uses: actions/download-artifact@v3 uses: actions/download-artifact@v3
with: with:
name: version name: version
path: version.txt
- name: Build docker - name: Build docker
run: | run: |
@ -117,6 +118,7 @@ jobs:
uses: actions/download-artifact@v3 uses: actions/download-artifact@v3
with: with:
name: version name: version
path: version.txt
- name: Prepare web build - name: Prepare web build
run: | run: |
@ -131,7 +133,7 @@ jobs:
- name: Build docker - name: Build docker
run: | run: |
cd web cd web
docker build --no-cache -t git.sh-edraft.de/sh-edraft.de/open-redirect-web:$(cat ../version.txt) . docker build --no-cache --build-arg VERSION=$(cat version.txt) -t git.sh-edraft.de/sh-edraft.de/open-redirect-web:$(cat ../version.txt) .
- name: Login to registry git.sh-edraft.de - name: Login to registry git.sh-edraft.de
uses: https://github.com/docker/login-action@v1 uses: https://github.com/docker/login-action@v1

View File

@ -1,5 +1,5 @@
name: Test before pr merge name: Test API before pr merge
run-name: Test before pr merge run-name: Test API before pr merge
on: on:
pull_request: pull_request:
types: types:

View File

@ -8,32 +8,16 @@ from starlette.routing import Route as StarletteRoute
from api.errors import unauthorized from api.errors import unauthorized
from api.middleware.request import get_request from api.middleware.request import get_request
from api.route_api_key_extension import RouteApiKeyExtension
from api.route_user_extension import RouteUserExtension from api.route_user_extension import RouteUserExtension
from core.environment import Environment from core.environment import Environment
from data.schemas.administration.api_key import ApiKey from data.schemas.administration.api_key import ApiKey
from data.schemas.administration.api_key_dao import apiKeyDao
from data.schemas.administration.user import User from data.schemas.administration.user import User
class Route(RouteUserExtension): class Route(RouteUserExtension, RouteApiKeyExtension):
registered_routes: list[StarletteRoute] = [] registered_routes: list[StarletteRoute] = []
@classmethod
async def get_api_key(cls, request: Request) -> ApiKey:
auth_header = request.headers.get("Authorization", None)
api_key = auth_header.split(" ")[1]
return await apiKeyDao.find_by_key(api_key)
@classmethod
async def _verify_api_key(cls, req: Request) -> bool:
auth_header = req.headers.get("Authorization", None)
if not auth_header or not auth_header.startswith("API-Key "):
return False
api_key = auth_header.split(" ")[1]
api_key_from_db = await apiKeyDao.find_by_key(api_key)
return api_key_from_db is not None and not api_key_from_db.deleted
@classmethod @classmethod
async def _get_auth_type( async def _get_auth_type(
cls, request: Request, auth_header: str cls, request: Request, auth_header: str
@ -79,8 +63,7 @@ class Route(RouteUserExtension):
return await cls._get_auth_type(request, auth_header) return await cls._get_auth_type(request, auth_header)
@classmethod @classmethod
async def is_authorized(cls) -> bool: async def is_authorized(cls, request: Request) -> bool:
request = get_request()
if request is None: if request is None:
return False return False
@ -119,7 +102,7 @@ class Route(RouteUserExtension):
return await f(request, *args, **kwargs) return await f(request, *args, **kwargs)
return f(request, *args, **kwargs) return f(request, *args, **kwargs)
if not await cls.is_authorized(): if not await cls.is_authorized(request):
return unauthorized() return unauthorized()
if iscoroutinefunction(f): if iscoroutinefunction(f):

View File

@ -0,0 +1,27 @@
from starlette.requests import Request
from data.schemas.administration.api_key import ApiKey
from data.schemas.administration.api_key_dao import apiKeyDao
class RouteApiKeyExtension:
@classmethod
async def get_api_key(cls, request: Request) -> ApiKey:
auth_header = request.headers.get("Authorization", None)
api_key = auth_header.split(" ")[1]
return await apiKeyDao.find_single_by(
[{ApiKey.key: api_key}, {ApiKey.deleted: False}]
)
@classmethod
async def _verify_api_key(cls, req: Request) -> bool:
auth_header = req.headers.get("Authorization", None)
if not auth_header or not auth_header.startswith("API-Key "):
return False
api_key = auth_header.split(" ")[1]
api_key_from_db = await apiKeyDao.find_single_by(
[{ApiKey.key: api_key}, {ApiKey.deleted: False}]
)
return api_key_from_db is not None and not api_key_from_db.deleted

View File

@ -18,6 +18,7 @@ logger = Logger(__name__)
class RouteUserExtension: class RouteUserExtension:
_cached_users: dict[int, User] = {}
@classmethod @classmethod
def _get_user_id_from_token(cls, request: Request) -> Optional[str]: def _get_user_id_from_token(cls, request: Request) -> Optional[str]:
@ -62,9 +63,7 @@ class RouteUserExtension:
if request is None: if request is None:
return None return None
return await userDao.find_single_by( return await userDao.find_by_keycloak_id(cls.get_token(request))
[{User.keycloak_id: cls.get_token(request)}, {User.deleted: False}]
)
@classmethod @classmethod
async def get_user_or_default(cls) -> Optional[User]: async def get_user_or_default(cls) -> Optional[User]:

View File

@ -0,0 +1,59 @@
from datetime import datetime
from typing import Union, Callable, Any
from api_graphql.abc.query_abc import QueryABC
from core.database.abc.data_access_object_abc import DataAccessObjectABC
class DbHistoryModelQueryABC(QueryABC):
def __init__(self, name: str = __name__):
QueryABC.__init__(self, f"{name}History")
self.set_field("id", lambda x, *_: x.id)
self.set_field("deleted", lambda x, *_: x.deleted)
self.set_field("editor", self._resolve_editor)
self.set_field("created", lambda x, *_: x.created)
self.set_field("updated", lambda x, *_: x.updated)
@staticmethod
async def _resolve_editor(x, *_):
editor = await x.editor
return editor.username if editor else None
@staticmethod
async def _resolve_foreign_history(
updated: datetime,
obj_ident: Union[str, int],
join_dao: DataAccessObjectABC,
foreign_dao: DataAccessObjectABC,
foreign_join_key: Callable[[Any], Any],
obj_key="id",
*_,
):
foreign_history = sorted(
[
*await join_dao.find_by(
[
{obj_key: obj_ident},
{"updated": {"lessOrEqual": updated}},
]
),
*await join_dao.get_history(
obj_ident,
by_key=obj_key,
until=updated,
),
],
key=lambda x: x.updated,
)
foreign_ids = set()
for foreign in foreign_history:
if not foreign.deleted:
foreign_ids.add(foreign_join_key(foreign))
continue
foreign_ids.discard(foreign_join_key(foreign))
return [await foreign_dao.get_by_id(x) for x in sorted(foreign_ids)]

View File

@ -23,5 +23,5 @@ class DbModelCollectionFilterABC[T](CollectionFilterABC):
self.add_field("id", IntCollectionFilter) self.add_field("id", IntCollectionFilter)
self.add_field("deleted", BoolCollectionFilter) self.add_field("deleted", BoolCollectionFilter)
self.add_field("editor", IntCollectionFilter) self.add_field("editor", IntCollectionFilter)
self.add_field("createdUtc", DateCollectionFilter) self.add_field("created", DateCollectionFilter)
self.add_field("updatedUtc", DateCollectionFilter) self.add_field("updated", DateCollectionFilter)

View File

@ -1,10 +1,11 @@
from typing import Optional from typing import Optional
from api_graphql.abc.filter.bool_filter import BoolFilter from api_graphql.abc.filter.bool_filter import BoolFilter
from api_graphql.abc.filter.date_filter import DateFilter
from api_graphql.abc.filter.fuzzy_filter import FuzzyFilter
from api_graphql.abc.filter.int_filter import IntFilter from api_graphql.abc.filter.int_filter import IntFilter
from api_graphql.abc.filter.string_filter import StringFilter from api_graphql.abc.filter.string_filter import StringFilter
from api_graphql.abc.filter_abc import FilterABC from api_graphql.abc.filter_abc import FilterABC
from api_graphql.filter.fuzzy_filter import FuzzyFilter
class DbModelFilterABC[T](FilterABC[T]): class DbModelFilterABC[T](FilterABC[T]):
@ -18,7 +19,7 @@ class DbModelFilterABC[T](FilterABC[T]):
self.add_field("id", IntFilter) self.add_field("id", IntFilter)
self.add_field("deleted", BoolFilter) self.add_field("deleted", BoolFilter)
self.add_field("editor", UserFilter) self.add_field("editor", UserFilter)
self.add_field("createdUtc", StringFilter, "created") self.add_field("created", DateFilter)
self.add_field("updatedUtc", StringFilter, "updated") self.add_field("updated", DateFilter)
self.add_field("fuzzy", FuzzyFilter) self.add_field("fuzzy", FuzzyFilter)

View File

@ -1,18 +1,54 @@
from copy import deepcopy
from typing import Optional
from api_graphql.abc.query_abc import QueryABC from api_graphql.abc.query_abc import QueryABC
from data.schemas.administration.user import User from core.database.abc.data_access_object_abc import DataAccessObjectABC
from core.logger import APILogger
logger = APILogger("api.api")
class DbModelQueryABC(QueryABC): class DbModelQueryABC(QueryABC):
def __init__(self, name: str = __name__): def __init__(
self,
name: str = __name__,
dao: DataAccessObjectABC = None,
with_history: bool = False,
):
QueryABC.__init__(self, name) QueryABC.__init__(self, name)
self._dao: Optional[DataAccessObjectABC] = dao
self.set_field("id", lambda x, *_: x.id) self.set_field("id", lambda x, *_: x.id)
self.set_field("deleted", lambda x, *_: x.deleted) self.set_field("deleted", lambda x, *_: x.deleted)
self.set_field("editor", self.__get_editor) self.set_field("editor", lambda x, *_: x.editor)
self.set_field("createdUtc", lambda x, *_: x.created) self.set_field("created", lambda x, *_: x.created)
self.set_field("updatedUtc", lambda x, *_: x.updated) self.set_field("updated", lambda x, *_: x.updated)
@staticmethod if with_history:
async def __get_editor(x: User, *_): self.set_field("history", self._resolve_history)
return await x.editor
self._history_reference_daos: dict[DataAccessObjectABC, str] = {}
async def _resolve_history(self, x, *_):
if self._dao is None:
raise Exception("DAO not set for history query")
history = sorted(
[await self._dao.get_by_id(x.id), *await self._dao.get_history(x.id)],
key=lambda h: h.updated,
reverse=True,
)
return history
def set_history_reference_dao(self, dao: DataAccessObjectABC, key: str = None):
"""
Set the reference DAO for history resolution.
:param dao:
:param key: The key to use for resolving history.
:return:
"""
if key is None:
key = "id"
self._history_reference_daos[dao] = key

View File

@ -0,0 +1,15 @@
from typing import Optional
from api_graphql.abc.filter_abc import FilterABC
class FuzzyFilter(FilterABC):
def __init__(
self,
obj: Optional[dict],
):
FilterABC.__init__(self, obj)
self.add_field("fields", list)
self.add_field("term", str)
self.add_field("threshold", int)

View File

@ -18,3 +18,5 @@ class IntFilter(FilterABC):
self.add_field("lessOrEqual", int) self.add_field("lessOrEqual", int)
self.add_field("isNull", int) self.add_field("isNull", int)
self.add_field("isNotNull", int) self.add_field("isNotNull", int)
self.add_field("in", list)
self.add_field("notIn", list)

View File

@ -18,3 +18,5 @@ class StringFilter(FilterABC):
self.add_field("endsWith", str) self.add_field("endsWith", str)
self.add_field("isNull", str) self.add_field("isNull", str)
self.add_field("isNotNull", str) self.add_field("isNotNull", str)
self.add_field("in", list)
self.add_field("notIn", list)

View File

@ -19,10 +19,12 @@ class FilterABC[T](ABC):
def add_field( def add_field(
self, self,
field: str, field: str,
filter_type: Union[Type["FilterABC"], Type[Union[int, str, bool, datetime]]], filter_type: Union[
Type["FilterABC"], Type[Union[int, str, bool, datetime, list]]
],
db_name=None, db_name=None,
): ):
if field not in self._obj and db_name not in self._obj: if field not in self._obj:
return return
if db_name is None: if db_name is None:

View File

@ -1,6 +1,7 @@
from abc import ABC from abc import ABC
from typing import Optional, Type, get_origin, get_args from typing import Optional, Type, get_origin, get_args
from core.get_value import get_value
from core.typing import T from core.typing import T
@ -12,11 +13,15 @@ class InputABC(ABC):
ABC.__init__(self) ABC.__init__(self)
self._src = src self._src = src
self._options = {}
def option( def option(
self, field: str, cast_type: Type[T], default=None, required=False self, field: str, cast_type: Type[T], default=None, required=False
) -> Optional[T]: ) -> Optional[T]:
if required and field not in self._src: if required and field not in self._src:
raise ValueError(f"{field} is required") raise ValueError(f"{field} is required")
self._options[field] = cast_type
if field not in self._src: if field not in self._src:
return default return default
@ -28,4 +33,4 @@ class InputABC(ABC):
return cast_type(value) return cast_type(value)
def get(self, field: str, default=None) -> Optional[T]: def get(self, field: str, default=None) -> Optional[T]:
return self._src.get(field, default) return get_value(self._src, field, self._options[field], default)

View File

@ -1,7 +1,12 @@
from abc import abstractmethod from abc import abstractmethod
from typing import Type, Union
from api_graphql.abc.input_abc import InputABC
from api_graphql.abc.query_abc import QueryABC from api_graphql.abc.query_abc import QueryABC
from api_graphql.field.mutation_field_builder import MutationFieldBuilder from api_graphql.field.mutation_field_builder import MutationFieldBuilder
from core.database.abc.data_access_object_abc import DataAccessObjectABC
from core.database.abc.db_join_model_abc import DbJoinModelABC
from core.typing import T
from service.permission.permissions_enum import Permissions from service.permission.permissions_enum import Permissions
@ -41,3 +46,79 @@ class MutationABC(QueryABC):
.with_require_any_permission(require_any_permission) .with_require_any_permission(require_any_permission)
.with_public(public) .with_public(public)
) )
@staticmethod
async def _resolve_assignments(
foreign_objs: list[int],
resolved_obj: T,
reference_key_own: Union[str, property],
reference_key_foreign: Union[str, property],
source_dao: DataAccessObjectABC[T],
join_dao: DataAccessObjectABC[T],
join_type: Type[DbJoinModelABC],
foreign_dao: DataAccessObjectABC[T],
):
if foreign_objs is None:
return
reference_key_own_attr = reference_key_own
if isinstance(reference_key_own, property):
reference_key_own_attr = reference_key_own.fget.__name__
reference_key_foreign_attr = reference_key_foreign
if isinstance(reference_key_foreign, property):
reference_key_foreign_attr = reference_key_foreign.fget.__name__
foreign_list = await join_dao.find_by(
[{reference_key_own: resolved_obj.id}, {"deleted": False}]
)
to_delete = (
foreign_list
if len(foreign_objs) == 0
else await join_dao.find_by(
[
{reference_key_own: resolved_obj.id},
{reference_key_foreign: {"notIn": foreign_objs}},
]
)
)
foreign_ids = [getattr(x, reference_key_foreign_attr) for x in foreign_list]
deleted_foreign_ids = [
getattr(x, reference_key_foreign_attr)
for x in await join_dao.find_by(
[{reference_key_own: resolved_obj.id}, {"deleted": True}]
)
]
to_create = [
join_type(0, resolved_obj.id, x)
for x in foreign_objs
if x not in foreign_ids and x not in deleted_foreign_ids
]
to_restore = [
await join_dao.get_single_by(
[
{reference_key_own: resolved_obj.id},
{reference_key_foreign: x},
]
)
for x in foreign_objs
if x not in foreign_ids and x in deleted_foreign_ids
]
if len(to_delete) > 0:
await join_dao.delete_many(to_delete)
if len(to_create) > 0:
await join_dao.create_many(to_create)
if len(to_restore) > 0:
await join_dao.restore_many(to_restore)
foreign_changes = [*to_delete, *to_create, *to_restore]
if len(foreign_changes) > 0:
await source_dao.touch(resolved_obj)
await foreign_dao.touch_many_by_id(
[getattr(x, reference_key_foreign_attr) for x in foreign_changes]
)

View File

@ -6,11 +6,12 @@ from typing import Callable, Type, get_args, Any, Union
from ariadne import ObjectType, SubscriptionType from ariadne import ObjectType, SubscriptionType
from graphql import GraphQLResolveInfo from graphql import GraphQLResolveInfo
from starlette.requests import Request
from typing_extensions import deprecated from typing_extensions import deprecated
from api.middleware.request import get_request
from api.route import Route from api.route import Route
from api_graphql.abc.collection_filter_abc import CollectionFilterABC from api_graphql.abc.collection_filter_abc import CollectionFilterABC
from api_graphql.abc.field_abc import FieldABC
from api_graphql.abc.input_abc import InputABC from api_graphql.abc.input_abc import InputABC
from api_graphql.abc.sort_abc import Sort from api_graphql.abc.sort_abc import Sort
from api_graphql.field.collection_field import CollectionField from api_graphql.field.collection_field import CollectionField
@ -46,8 +47,8 @@ class QueryABC(ObjectType):
self._subscriptions: dict[str, SubscriptionType] = {} self._subscriptions: dict[str, SubscriptionType] = {}
@staticmethod @staticmethod
async def _authorize(): async def _authorize(request: Request):
if not await Route.is_authorized(): if not await Route.is_authorized(request):
raise UnauthorizedException() raise UnauthorizedException()
@staticmethod @staticmethod
@ -71,8 +72,6 @@ class QueryABC(ObjectType):
*args, *args,
**kwargs, **kwargs,
): ):
info = args[0]
if len(permissions) > 0: if len(permissions) > 0:
user = await Route.get_authenticated_user_or_api_key_or_default() user = await Route.get_authenticated_user_or_api_key_or_default()
if user is not None and all( if user is not None and all(
@ -120,6 +119,9 @@ class QueryABC(ObjectType):
take = None take = None
skip = None skip = None
if field.default_filter:
filters.append(field.default_filter(*args, **kwargs))
if field.filter_type and "filter" in kwargs: if field.filter_type and "filter" in kwargs:
in_filters = kwargs["filter"] in_filters = kwargs["filter"]
if not isinstance(in_filters, list): if not isinstance(in_filters, list):
@ -227,7 +229,7 @@ class QueryABC(ObjectType):
async def wrapper(*args, **kwargs): async def wrapper(*args, **kwargs):
if not field.public: if not field.public:
await self._authorize() await self._authorize(get_request())
if ( if (
field.require_any is None field.require_any is None

View File

@ -2,8 +2,8 @@ from abc import abstractmethod
from asyncio import iscoroutinefunction from asyncio import iscoroutinefunction
from ariadne import SubscriptionType from ariadne import SubscriptionType
from graphql import GraphQLResolveInfo
from api.middleware.request import get_request
from api_graphql.abc.query_abc import QueryABC from api_graphql.abc.query_abc import QueryABC
from api_graphql.field.subscription_field_builder import SubscriptionFieldBuilder from api_graphql.field.subscription_field_builder import SubscriptionFieldBuilder
from core.logger import APILogger from core.logger import APILogger
@ -20,9 +20,12 @@ class SubscriptionABC(SubscriptionType, QueryABC):
def subscribe(self, builder: SubscriptionFieldBuilder): def subscribe(self, builder: SubscriptionFieldBuilder):
field = builder.build() field = builder.build()
async def wrapper(*args, **kwargs): async def wrapper(_, info: GraphQLResolveInfo, *args, **kwargs):
# rebuild args for resolvers
args = [_, info, *args]
if not field.public: if not field.public:
await self._authorize() r = info.context.get("request")
await self._authorize(r)
if ( if (
field.require_any is None field.require_any is None

View File

@ -1,6 +1,7 @@
import importlib import importlib
import os import os
from api_graphql.abc.db_history_model_query_abc import DbHistoryModelQueryABC
from api_graphql.abc.db_model_query_abc import DbModelQueryABC from api_graphql.abc.db_model_query_abc import DbModelQueryABC
from api_graphql.abc.mutation_abc import MutationABC from api_graphql.abc.mutation_abc import MutationABC
from api_graphql.abc.query_abc import QueryABC from api_graphql.abc.query_abc import QueryABC
@ -20,7 +21,12 @@ def import_graphql_schema_part(part: str):
import_graphql_schema_part("queries") import_graphql_schema_part("queries")
import_graphql_schema_part("mutations") import_graphql_schema_part("mutations")
sub_query_classes = [DbModelQueryABC, MutationABC, SubscriptionABC] sub_query_classes = [
DbModelQueryABC,
DbHistoryModelQueryABC,
MutationABC,
SubscriptionABC,
]
query_classes = [ query_classes = [
*[y for x in sub_query_classes for y in x.__subclasses__()], *[y for x in sub_query_classes for y in x.__subclasses__()],
*[x for x in QueryABC.__subclasses__() if x not in sub_query_classes], *[x for x in QueryABC.__subclasses__() if x not in sub_query_classes],

View File

@ -1,4 +1,4 @@
from typing import Union, Type, Optional from typing import Union, Type, Optional, Callable
from api_graphql.abc.collection_filter_abc import CollectionFilterABC from api_graphql.abc.collection_filter_abc import CollectionFilterABC
from api_graphql.abc.field_abc import FieldABC from api_graphql.abc.field_abc import FieldABC
@ -19,6 +19,7 @@ class DaoField(FieldABC):
public: bool = False, public: bool = False,
dao: DataAccessObjectABC = None, dao: DataAccessObjectABC = None,
filter_type: Type[FilterABC] = None, filter_type: Type[FilterABC] = None,
default_filter: Callable = None,
sort_type: Type[T] = None, sort_type: Type[T] = None,
direct_result: bool = False, direct_result: bool = False,
): ):
@ -28,6 +29,7 @@ class DaoField(FieldABC):
self._public = public self._public = public
self._dao = dao self._dao = dao
self._filter_type = filter_type self._filter_type = filter_type
self._default_filter = default_filter
self._sort_type = sort_type self._sort_type = sort_type
self._direct_result = direct_result self._direct_result = direct_result
@ -41,6 +43,10 @@ class DaoField(FieldABC):
) -> Optional[Type[FilterABC]]: ) -> Optional[Type[FilterABC]]:
return self._filter_type return self._filter_type
@property
def default_filter(self) -> Optional[Callable]:
return self._default_filter
@property @property
def sort_type(self) -> Optional[Type[T]]: def sort_type(self) -> Optional[Type[T]]:
return self._sort_type return self._sort_type

View File

@ -1,4 +1,4 @@
from typing import Type, Self from typing import Type, Self, Callable
from api_graphql.abc.field_builder_abc import FieldBuilderABC from api_graphql.abc.field_builder_abc import FieldBuilderABC
from api_graphql.abc.filter_abc import FilterABC from api_graphql.abc.filter_abc import FilterABC
@ -14,6 +14,7 @@ class DaoFieldBuilder(FieldBuilderABC):
self._dao = None self._dao = None
self._filter_type = None self._filter_type = None
self._default_filter = None
self._sort_type = None self._sort_type = None
self._direct_result = False self._direct_result = False
@ -27,6 +28,12 @@ class DaoFieldBuilder(FieldBuilderABC):
self._filter_type = filter_type self._filter_type = filter_type
return self return self
def with_default_filter(self, filter: Callable) -> Self:
assert filter is not None, "filter cannot be None"
assert callable(filter), "filter must be callable"
self._default_filter = filter
return self
def with_sort(self, sort_type: Type[T]) -> Self: def with_sort(self, sort_type: Type[T]) -> Self:
assert sort_type is not None, "sort cannot be None" assert sort_type is not None, "sort cannot be None"
self._sort_type = sort_type self._sort_type = sort_type
@ -45,6 +52,7 @@ class DaoFieldBuilder(FieldBuilderABC):
self._public, self._public,
self._dao, self._dao,
self._filter_type, self._filter_type,
self._default_filter,
self._sort_type, self._sort_type,
self._direct_result, self._direct_result,
) )

View File

@ -38,6 +38,9 @@ class MutationFieldBuilder(FieldBuilderABC):
await broadcast.publish(f"{source}", result) await broadcast.publish(f"{source}", result)
return result return result
self._resolver = resolver_wrapper
return self
def with_change_broadcast(self, source: str): def with_change_broadcast(self, source: str):
assert self._resolver is not None, "resolver cannot be None for broadcast" assert self._resolver is not None, "resolver cannot be None for broadcast"

View File

@ -4,16 +4,30 @@ type ApiKeyResult {
nodes: [ApiKey] nodes: [ApiKey]
} }
type ApiKeyHistory implements DbHistoryModel {
id: Int
identifier: String
key: String
permissions: [Permission]
deleted: Boolean
editor: String
created: String
updated: String
}
type ApiKey implements DbModel { type ApiKey implements DbModel {
id: ID id: Int
identifier: String identifier: String
key: String key: String
permissions: [Permission] permissions: [Permission]
deleted: Boolean deleted: Boolean
editor: User editor: User
createdUtc: String created: String
updatedUtc: String updated: String
history: [ApiKeyHistory]
} }
input ApiKeySort { input ApiKeySort {
@ -22,12 +36,18 @@ input ApiKeySort {
deleted: SortOrder deleted: SortOrder
editor: UserSort editor: UserSort
createdUtc: SortOrder created: SortOrder
updatedUtc: SortOrder updated: SortOrder
} }
enum ApiKeyFuzzyFields { enum ApiKeyFuzzyFields {
id
identifier identifier
deleted
editor
created
updated
} }
input ApiKeyFuzzy { input ApiKeyFuzzy {
@ -42,24 +62,24 @@ input ApiKeyFilter {
deleted: BooleanFilter deleted: BooleanFilter
editorId: IntFilter editorId: IntFilter
createdUtc: DateFilter created: DateFilter
updatedUtc: DateFilter updated: DateFilter
} }
type ApiKeyMutation { type ApiKeyMutation {
create(input: ApiKeyCreateInput!): ApiKey create(input: ApiKeyCreateInput!): ApiKey
update(input: ApiKeyUpdateInput!): ApiKey update(input: ApiKeyUpdateInput!): ApiKey
delete(identifier: String!): Boolean delete(id: Int!): Boolean
restore(identifier: String!): Boolean restore(id: Int!): Boolean
} }
input ApiKeyCreateInput { input ApiKeyCreateInput {
identifier: String identifier: String
permissions: [ID] permissions: [Int]
} }
input ApiKeyUpdateInput { input ApiKeyUpdateInput {
id: ID! id: Int!
identifier: String identifier: String
permissions: [ID] permissions: [Int]
} }

View File

@ -1,12 +1,21 @@
scalar Upload scalar Upload
interface DbModel { interface DbModel {
id: ID id: Int
deleted: Boolean deleted: Boolean
editor: User editor: User
createdUtc: String created: String
updatedUtc: String updated: String
}
interface DbHistoryModel {
id: Int
deleted: Boolean
editor: String
created: String
updated: String
} }
enum SortOrder { enum SortOrder {
@ -44,6 +53,8 @@ input IntFilter {
isNull: Int isNull: Int
isNotNull: Int isNotNull: Int
in: [Int]
notIn: [Int]
} }
input BooleanFilter { input BooleanFilter {
@ -58,9 +69,18 @@ input DateFilter {
equal: String equal: String
notEqual: String notEqual: String
greater: String
greaterOrEqual: String
less: String
lessOrEqual: String
contains: String contains: String
notContains: String notContains: String
isNull: String isNull: String
isNotNull: String isNotNull: String
in: [String]
notIn: [String]
} }

View File

@ -0,0 +1,12 @@
enum Attendance {
absent
present
delayed
canceled
}
enum Payment {
not_paid
paid
refunded
}

View File

@ -4,16 +4,30 @@ type DomainResult {
nodes: [Domain] nodes: [Domain]
} }
type DomainHistory implements DbHistoryModel {
id: Int
name: String
shortUrls: [ShortUrl]
deleted: Boolean
editor: String
created: String
updated: String
}
type Domain implements DbModel { type Domain implements DbModel {
id: ID id: Int
name: String name: String
shortUrls: [ShortUrl] shortUrls: [ShortUrl]
deleted: Boolean deleted: Boolean
editor: User editor: User
createdUtc: String created: String
updatedUtc: String updated: String
history: [DomainHistory]
} }
input DomainSort { input DomainSort {
@ -22,8 +36,8 @@ input DomainSort {
deleted: SortOrder deleted: SortOrder
editorId: SortOrder editorId: SortOrder
createdUtc: SortOrder created: SortOrder
updatedUtc: SortOrder updated: SortOrder
} }
enum DomainFuzzyFields { enum DomainFuzzyFields {
@ -44,15 +58,15 @@ input DomainFilter {
deleted: BooleanFilter deleted: BooleanFilter
editor: IntFilter editor: IntFilter
createdUtc: DateFilter created: DateFilter
updatedUtc: DateFilter updated: DateFilter
} }
type DomainMutation { type DomainMutation {
create(input: DomainCreateInput!): Domain create(input: DomainCreateInput!): Domain
update(input: DomainUpdateInput!): Domain update(input: DomainUpdateInput!): Domain
delete(id: ID!): Boolean delete(id: Int!): Boolean
restore(id: ID!): Boolean restore(id: Int!): Boolean
} }
input DomainCreateInput { input DomainCreateInput {
@ -60,6 +74,6 @@ input DomainCreateInput {
} }
input DomainUpdateInput { input DomainUpdateInput {
id: ID! id: Int!
name: String name: String
} }

View File

@ -1,12 +1,12 @@
type FeatureFlag implements DbModel { type FeatureFlag implements DbModel {
id: ID id: Int
key: String key: String
value: Boolean value: Boolean
deleted: Boolean deleted: Boolean
editor: User editor: User
createdUtc: String created: String
updatedUtc: String updated: String
} }
type FeatureFlagMutation { type FeatureFlagMutation {

View File

@ -4,8 +4,21 @@ type GroupResult {
nodes: [Group] nodes: [Group]
} }
type GroupHistory implements DbHistoryModel {
id: Int
name: String
shortUrls: [ShortUrl]
roles: [Role]
deleted: Boolean
editor: String
created: String
updated: String
}
type Group implements DbModel { type Group implements DbModel {
id: ID id: Int
name: String name: String
shortUrls: [ShortUrl] shortUrls: [ShortUrl]
@ -13,8 +26,9 @@ type Group implements DbModel {
deleted: Boolean deleted: Boolean
editor: User editor: User
createdUtc: String created: String
updatedUtc: String updated: String
history: [GroupHistory]
} }
input GroupSort { input GroupSort {
@ -23,8 +37,8 @@ input GroupSort {
deleted: SortOrder deleted: SortOrder
editorId: SortOrder editorId: SortOrder
createdUtc: SortOrder created: SortOrder
updatedUtc: SortOrder updated: SortOrder
} }
enum GroupFuzzyFields { enum GroupFuzzyFields {
@ -45,24 +59,24 @@ input GroupFilter {
deleted: BooleanFilter deleted: BooleanFilter
editor: IntFilter editor: IntFilter
createdUtc: DateFilter created: DateFilter
updatedUtc: DateFilter updated: DateFilter
} }
type GroupMutation { type GroupMutation {
create(input: GroupCreateInput!): Group create(input: GroupCreateInput!): Group
update(input: GroupUpdateInput!): Group update(input: GroupUpdateInput!): Group
delete(id: ID!): Boolean delete(id: Int!): Boolean
restore(id: ID!): Boolean restore(id: Int!): Boolean
} }
input GroupCreateInput { input GroupCreateInput {
name: String! name: String!
roles: [ID] roles: [Int]
} }
input GroupUpdateInput { input GroupUpdateInput {
id: ID! id: Int!
name: String name: String
roles: [ID] roles: [Int]
} }

View File

@ -4,15 +4,28 @@ type PermissionResult {
nodes: [Permission] nodes: [Permission]
} }
type PermissionHistory implements DbHistoryModel {
id: Int
name: String
description: String
deleted: Boolean
editor: String
created: String
updated: String
}
type Permission implements DbModel { type Permission implements DbModel {
id: ID id: Int
name: String name: String
description: String description: String
deleted: Boolean deleted: Boolean
editor: User editor: User
createdUtc: String created: String
updatedUtc: String updated: String
history: [PermissionHistory]
} }
input PermissionSort { input PermissionSort {
@ -21,9 +34,9 @@ input PermissionSort {
description: SortOrder description: SortOrder
deleted: SortOrder deleted: SortOrder
editorId: SortOrder editor: UserSort
createdUtc: SortOrder created: SortOrder
updatedUtc: SortOrder updated: SortOrder
} }
input PermissionFilter { input PermissionFilter {
@ -32,13 +45,13 @@ input PermissionFilter {
description: StringFilter description: StringFilter
deleted: BooleanFilter deleted: BooleanFilter
editor: IntFilter editor: UserFilter
createdUtc: DateFilter created: DateFilter
updatedUtc: DateFilter updated: DateFilter
} }
input PermissionInput { input PermissionInput {
id: ID id: Int
name: String name: String
description: String description: String
} }

View File

@ -9,7 +9,7 @@ type Query {
user: User user: User
userHasPermission(permission: String!): Boolean userHasPermission(permission: String!): Boolean
userHasAnyPermission(permissions: [String]!): Boolean userHasAnyPermission(permissions: [String]!): Boolean
notExistingUsersFromKeycloak: KeycloakUserResult notExistingUsersFromKeycloak: [KeycloakUser]
domains(filter: [DomainFilter], sort: [DomainSort], skip: Int, take: Int): DomainResult domains(filter: [DomainFilter], sort: [DomainSort], skip: Int, take: Int): DomainResult
groups(filter: [GroupFilter], sort: [GroupSort], skip: Int, take: Int): GroupResult groups(filter: [GroupFilter], sort: [GroupSort], skip: Int, take: Int): GroupResult

View File

@ -4,8 +4,21 @@ type RoleResult {
nodes: [Role] nodes: [Role]
} }
type RoleHistory implements DbHistoryModel {
id: Int
name: String
description: String
permissions: [Permission]
deleted: Boolean
editor: String
created: String
updated: String
}
type Role implements DbModel { type Role implements DbModel {
id: ID id: Int
name: String name: String
description: String description: String
permissions: [Permission] permissions: [Permission]
@ -13,8 +26,10 @@ type Role implements DbModel {
deleted: Boolean deleted: Boolean
editor: User editor: User
createdUtc: String created: String
updatedUtc: String updated: String
history: [RoleHistory]
} }
input RoleSort { input RoleSort {
@ -24,13 +39,19 @@ input RoleSort {
deleted: SortOrder deleted: SortOrder
editor: UserSort editor: UserSort
createdUtc: SortOrder created: SortOrder
updatedUtc: SortOrder updated: SortOrder
} }
enum RoleFuzzyFields { enum RoleFuzzyFields {
id
name name
description description
deleted
editor
created
updated
} }
input RoleFuzzy { input RoleFuzzy {
@ -48,26 +69,26 @@ input RoleFilter {
deleted: BooleanFilter deleted: BooleanFilter
editor_id: IntFilter editor_id: IntFilter
createdUtc: DateFilter created: DateFilter
updatedUtc: DateFilter updated: DateFilter
} }
type RoleMutation { type RoleMutation {
create(input: RoleCreateInput!): Role create(input: RoleCreateInput!): Role
update(input: RoleUpdateInput!): Role update(input: RoleUpdateInput!): Role
delete(id: ID!): Boolean delete(id: Int!): Boolean
restore(id: ID!): Boolean restore(id: Int!): Boolean
} }
input RoleCreateInput { input RoleCreateInput {
name: String! name: String!
description: String description: String
permissions: [ID] permissions: [Int]
} }
input RoleUpdateInput { input RoleUpdateInput {
id: ID! id: Int!
name: String name: String
description: String description: String
permissions: [ID] permissions: [Int]
} }

View File

@ -1,12 +1,12 @@
type Setting implements DbModel { type Setting implements DbModel {
id: ID id: Int
key: String key: String
value: String value: String
deleted: Boolean deleted: Boolean
editor: User editor: User
createdUtc: String created: String
updatedUtc: String updated: String
} }
type SettingMutation { type SettingMutation {

View File

@ -4,8 +4,25 @@ type ShortUrlResult {
nodes: [ShortUrl] nodes: [ShortUrl]
} }
type ShortUrlHistory implements DbHistoryModel {
id: Int
shortUrl: String
targetUrl: String
description: String
visits: Int
loadingScreen: Boolean
group: Group
domain: Domain
deleted: Boolean
editor: String
created: String
updated: String
}
type ShortUrl implements DbModel { type ShortUrl implements DbModel {
id: ID id: Int
shortUrl: String shortUrl: String
targetUrl: String targetUrl: String
description: String description: String
@ -16,8 +33,9 @@ type ShortUrl implements DbModel {
deleted: Boolean deleted: Boolean
editor: User editor: User
createdUtc: String created: String
updatedUtc: String updated: String
history: [ShortUrlHistory]
} }
input ShortUrlSort { input ShortUrlSort {
@ -28,8 +46,8 @@ input ShortUrlSort {
deleted: SortOrder deleted: SortOrder
editorId: SortOrder editorId: SortOrder
createdUtc: SortOrder created: SortOrder
updatedUtc: SortOrder updated: SortOrder
} }
enum ShortUrlFuzzyFields { enum ShortUrlFuzzyFields {
@ -57,33 +75,33 @@ input ShortUrlFilter {
deleted: BooleanFilter deleted: BooleanFilter
editor: IntFilter editor: IntFilter
createdUtc: DateFilter created: DateFilter
updatedUtc: DateFilter updated: DateFilter
} }
type ShortUrlMutation { type ShortUrlMutation {
create(input: ShortUrlCreateInput!): ShortUrl create(input: ShortUrlCreateInput!): ShortUrl
update(input: ShortUrlUpdateInput!): ShortUrl update(input: ShortUrlUpdateInput!): ShortUrl
delete(id: ID!): Boolean delete(id: Int!): Boolean
restore(id: ID!): Boolean restore(id: Int!): Boolean
trackVisit(id: ID!, agent: String): Boolean trackVisit(id: Int!, agent: String): Boolean
} }
input ShortUrlCreateInput { input ShortUrlCreateInput {
shortUrl: String! shortUrl: String!
targetUrl: String! targetUrl: String!
description: String description: String
groupId: ID groupId: Int
domainId: ID domainId: Int
loadingScreen: Boolean loadingScreen: Boolean
} }
input ShortUrlUpdateInput { input ShortUrlUpdateInput {
id: ID! id: Int!
shortUrl: String shortUrl: String
targetUrl: String targetUrl: String
description: String description: String
groupId: ID groupId: Int
domainId: ID domainId: Int
loadingScreen: Boolean loadingScreen: Boolean
} }

View File

@ -9,6 +9,7 @@ type Subscription {
settingChange: SubscriptionChange settingChange: SubscriptionChange
userChange: SubscriptionChange userChange: SubscriptionChange
userSettingChange: SubscriptionChange userSettingChange: SubscriptionChange
userLogout: SubscriptionChange
domainChange: SubscriptionChange domainChange: SubscriptionChange
groupChange: SubscriptionChange groupChange: SubscriptionChange

View File

@ -1,9 +1,3 @@
type KeycloakUserResult {
totalCount: Int
count: Int
nodes: [KeycloakUser]
}
type KeycloakUser { type KeycloakUser {
keycloakId: String keycloakId: String
username: String username: String
@ -15,8 +9,22 @@ type UserResult {
nodes: [User] nodes: [User]
} }
type UserHistory implements DbHistoryModel {
id: Int
keycloakId: String
username: String
email: String
roles: [Role]
deleted: Boolean
editor: String
created: String
updated: String
}
type User implements DbModel { type User implements DbModel {
id: ID id: Int
keycloakId: String keycloakId: String
username: String username: String
email: String email: String
@ -24,8 +32,10 @@ type User implements DbModel {
deleted: Boolean deleted: Boolean
editor: User editor: User
createdUtc: String created: String
updatedUtc: String updated: String
history: [UserHistory]
} }
input UserSort { input UserSort {
@ -36,14 +46,20 @@ input UserSort {
deleted: SortOrder deleted: SortOrder
editor: UserSort editor: UserSort
createdUtc: SortOrder created: SortOrder
updatedUtc: SortOrder updated: SortOrder
} }
enum UserFuzzyFields { enum UserFuzzyFields {
id
keycloakId keycloakId
username username
email email
deleted
editor
created
updated
} }
input UserFuzzy { input UserFuzzy {
@ -62,23 +78,23 @@ input UserFilter {
deleted: BooleanFilter deleted: BooleanFilter
editor: UserFilter editor: UserFilter
createdUtc: DateFilter created: DateFilter
updatedUtc: DateFilter updated: DateFilter
} }
type UserMutation { type UserMutation {
create(input: UserCreateInput!): User create(input: UserCreateInput!): User
update(input: UserUpdateInput!): User update(input: UserUpdateInput!): User
delete(id: ID!): Boolean delete(id: Int!): Boolean
restore(id: ID!): Boolean restore(id: Int!): Boolean
} }
input UserCreateInput { input UserCreateInput {
keycloakId: String keycloakId: String
roles: [ID] roles: [Int]
} }
input UserUpdateInput { input UserUpdateInput {
id: ID id: Int
roles: [ID] roles: [Int]
} }

View File

@ -1,12 +1,12 @@
type UserSetting implements DbModel { type UserSetting implements DbModel {
id: ID id: Int
key: String key: String
value: String value: String
deleted: Boolean deleted: Boolean
editor: User editor: User
createdUtc: String created: String
updatedUtc: String updated: String
} }
type UserSettingMutation { type UserSettingMutation {

View File

@ -1,5 +1,3 @@
from uuid import uuid4
from api_graphql.abc.mutation_abc import MutationABC from api_graphql.abc.mutation_abc import MutationABC
from api_graphql.input.api_key_create_input import ApiKeyCreateInput from api_graphql.input.api_key_create_input import ApiKeyCreateInput
from api_graphql.input.api_key_update_input import ApiKeyUpdateInput from api_graphql.input.api_key_update_input import ApiKeyUpdateInput
@ -8,6 +6,7 @@ from data.schemas.administration.api_key import ApiKey
from data.schemas.administration.api_key_dao import apiKeyDao from data.schemas.administration.api_key_dao import apiKeyDao
from data.schemas.permission.api_key_permission import ApiKeyPermission from data.schemas.permission.api_key_permission import ApiKeyPermission
from data.schemas.permission.api_key_permission_dao import apiKeyPermissionDao from data.schemas.permission.api_key_permission_dao import apiKeyPermissionDao
from data.schemas.permission.permission_dao import permissionDao
from service.permission.permissions_enum import Permissions from service.permission.permissions_enum import Permissions
logger = APILogger(__name__) logger = APILogger(__name__)
@ -44,77 +43,28 @@ class APIKeyMutation(MutationABC):
async def resolve_create(obj: ApiKeyCreateInput, *_): async def resolve_create(obj: ApiKeyCreateInput, *_):
logger.debug(f"create api key: {obj.__dict__}") logger.debug(f"create api key: {obj.__dict__}")
api_key = ApiKey( api_key = ApiKey.new(obj.identifier)
0,
obj.identifier,
str(uuid4()),
)
await apiKeyDao.create(api_key) await apiKeyDao.create(api_key)
api_key = await apiKeyDao.get_by_identifier(api_key.identifier) api_key = await apiKeyDao.get_single_by([{ApiKey.identifier: obj.identifier}])
await apiKeyPermissionDao.create_many( await apiKeyPermissionDao.create_many(
[ApiKeyPermission(0, api_key.id, x) for x in obj.permissions] [ApiKeyPermission(0, api_key.id, x) for x in obj.permissions]
) )
return api_key return api_key
@staticmethod async def resolve_update(self, obj: ApiKeyUpdateInput, *_):
async def resolve_update(obj: ApiKeyUpdateInput, *_):
logger.debug(f"update api key: {input}") logger.debug(f"update api key: {input}")
api_key = await apiKeyDao.get_by_id(obj.id) api_key = await apiKeyDao.get_by_id(obj.id)
if obj.permissions is not None: await self._resolve_assignments(
permissions = [ obj.get("permissions", []),
x for x in await apiKeyPermissionDao.get_by_role_id(api_key.id) api_key,
] ApiKeyPermission.api_key_id,
ApiKeyPermission.permission_id,
to_delete = ( apiKeyDao,
permissions apiKeyPermissionDao,
if len(obj.permissions) == 0 ApiKeyPermission,
else await apiKeyPermissionDao.find_by( permissionDao,
[ )
{ApiKeyPermission.api_key_id: api_key.id},
{
ApiKeyPermission.permission_id: {
"notIn": obj.get("permissions", [])
}
},
]
)
)
permission_ids = [x.permission_id for x in permissions]
deleted_permission_ids = [
x.permission_id
for x in await apiKeyPermissionDao.find_by(
[
{ApiKeyPermission.api_key_id: api_key.id},
{ApiKeyPermission.deleted: True},
]
)
]
to_create = [
ApiKeyPermission(0, api_key.id, x)
for x in obj.permissions
if x not in permission_ids and x not in deleted_permission_ids
]
to_restore = [
await apiKeyPermissionDao.get_single_by(
[
{ApiKeyPermission.api_key_id: api_key.id},
{ApiKeyPermission.permission_id: x},
]
)
for x in obj.permissions
if x not in permission_ids and x in deleted_permission_ids
]
if len(to_delete) > 0:
await apiKeyPermissionDao.delete_many(to_delete)
if len(to_create) > 0:
await apiKeyPermissionDao.create_many(to_create)
if len(to_restore) > 0:
await apiKeyPermissionDao.restore_many(to_restore)
return api_key return api_key

View File

@ -2,6 +2,7 @@ from api_graphql.abc.mutation_abc import MutationABC
from api_graphql.input.role_create_input import RoleCreateInput from api_graphql.input.role_create_input import RoleCreateInput
from api_graphql.input.role_update_input import RoleUpdateInput from api_graphql.input.role_update_input import RoleUpdateInput
from core.logger import APILogger from core.logger import APILogger
from data.schemas.permission.permission_dao import permissionDao
from data.schemas.permission.role import Role from data.schemas.permission.role import Role
from data.schemas.permission.role_dao import roleDao from data.schemas.permission.role_dao import roleDao
from data.schemas.permission.role_permission import RolePermission from data.schemas.permission.role_permission import RolePermission
@ -54,63 +55,23 @@ class RoleMutation(MutationABC):
return role return role
@staticmethod async def resolve_update(self, obj: RoleUpdateInput, *_):
async def resolve_update(obj: RoleUpdateInput, *_):
logger.debug(f"update role: {obj.__dict__}") logger.debug(f"update role: {obj.__dict__}")
role = await roleDao.get_by_id(obj.id) role = await roleDao.get_by_id(obj.id)
role.name = obj.get("name", role.name) role.name = obj.get("name", role.name)
role.description = obj.get("description", role.description) role.description = obj.get("description", role.description)
await roleDao.update(role) await roleDao.update(role)
if obj.permissions is not None: await self._resolve_assignments(
permissions = [x for x in await rolePermissionDao.get_by_role_id(role.id)] obj.get("permissions", []),
role,
to_delete = ( RolePermission.role_id,
permissions RolePermission.permission_id,
if len(obj.permissions) == 0 roleDao,
else await rolePermissionDao.find_by( rolePermissionDao,
[ RolePermission,
{RolePermission.role_id: role.id}, permissionDao,
{ )
RolePermission.permission_id: {
"notIn": obj.get("permissions", [])
}
},
]
)
)
permission_ids = [x.permission_id for x in permissions]
deleted_permission_ids = [
x.permission_id
for x in await rolePermissionDao.find_by(
[{RolePermission.role_id: role.id}, {RolePermission.deleted: True}]
)
]
to_create = [
RolePermission(0, role.id, x)
for x in obj.permissions
if x not in permission_ids and x not in deleted_permission_ids
]
to_restore = [
await rolePermissionDao.get_single_by(
[
{RolePermission.role_id: role.id},
{RolePermission.permission_id: x},
]
)
for x in obj.permissions
if x not in permission_ids and x in deleted_permission_ids
]
if len(to_delete) > 0:
await rolePermissionDao.delete_many(to_delete)
if len(to_create) > 0:
await rolePermissionDao.create_many(to_create)
if len(to_restore) > 0:
await rolePermissionDao.restore_many(to_restore)
return role return role

View File

@ -1,10 +1,13 @@
from api.auth.keycloak_client import Keycloak from api.auth.keycloak_client import Keycloak
from api.broadcast import broadcast
from api.route import Route
from api_graphql.abc.mutation_abc import MutationABC from api_graphql.abc.mutation_abc import MutationABC
from api_graphql.input.user_create_input import UserCreateInput from api_graphql.input.user_create_input import UserCreateInput
from api_graphql.input.user_update_input import UserUpdateInput from api_graphql.input.user_update_input import UserUpdateInput
from core.logger import APILogger from core.logger import APILogger
from data.schemas.administration.user import User from data.schemas.administration.user import User
from data.schemas.administration.user_dao import userDao from data.schemas.administration.user_dao import userDao
from data.schemas.permission.role_dao import roleDao
from data.schemas.permission.role_user import RoleUser from data.schemas.permission.role_user import RoleUser
from data.schemas.permission.role_user_dao import roleUserDao from data.schemas.permission.role_user_dao import roleUserDao
from service.permission.permissions_enum import Permissions from service.permission.permissions_enum import Permissions
@ -49,62 +52,26 @@ class UserMutation(MutationABC):
raise ValueError(f"Keycloak user with id {obj.keycloak_id} does not exist") raise ValueError(f"Keycloak user with id {obj.keycloak_id} does not exist")
user = User(0, obj.keycloak_id) user = User(0, obj.keycloak_id)
await userDao.create(user) user_id = await userDao.create(user)
user = await userDao.get_by_keycloak_id(user.keycloak_id) user = await userDao.get_by_id(user_id)
await roleUserDao.create_many([RoleUser(0, user.id, x) for x in obj.roles]) await roleUserDao.create_many([RoleUser(0, user.id, x) for x in set(obj.roles)])
return user return user
@staticmethod async def resolve_update(self, obj: UserUpdateInput, *_):
async def resolve_update(obj: UserUpdateInput, *_):
logger.debug(f"update user: {obj.__dict__}") logger.debug(f"update user: {obj.__dict__}")
user = await userDao.get_by_id(obj.id) user = await userDao.get_by_id(obj.id)
if obj.roles is not None: await self._resolve_assignments(
roles = await roleUserDao.get_by_user_id(user.id) obj.get("roles", []),
user,
to_delete = ( RoleUser.user_id,
roles RoleUser.role_id,
if len(obj.roles) == 0 userDao,
else await roleUserDao.find_by( roleUserDao,
[ RoleUser,
{RoleUser.user_id: user.id}, roleDao,
{RoleUser.role_id: {"notIn": obj.get("roles", [])}}, )
]
)
)
role_ids = [x.role_id for x in roles]
deleted_role_ids = [
x.role_id
for x in await roleUserDao.find_by(
[{RoleUser.user_id: user.id}, {RoleUser.deleted: True}]
)
]
to_create = [
RoleUser(0, x, user.id)
for x in obj.roles
if x not in role_ids and x not in deleted_role_ids
]
to_restore = [
await roleUserDao.get_single_by(
[
{RoleUser.user_id: user.id},
{RoleUser.role_id: x},
]
)
for x in obj.roles
if x not in role_ids and x in deleted_role_ids
]
if len(to_delete) > 0:
await roleUserDao.delete_many(to_delete)
if len(to_create) > 0:
await roleUserDao.create_many(to_create)
if len(to_restore) > 0:
await roleUserDao.restore_many(to_restore)
return user return user
@ -113,6 +80,13 @@ class UserMutation(MutationABC):
logger.debug(f"delete user: {id}") logger.debug(f"delete user: {id}")
user = await userDao.get_by_id(id) user = await userDao.get_by_id(id)
await userDao.delete(user) await userDao.delete(user)
try:
active_user = await Route.get_user_or_default()
if active_user is not None and active_user.id == user.id:
await broadcast.publish("userLogout", user.id)
Keycloak.admin.user_logout(user_id=user.keycloak_id)
except Exception as e:
logger.error(f"Failed to logout user from Keycloak", e)
return True return True
@staticmethod @staticmethod

View File

@ -0,0 +1,22 @@
from api_graphql.abc.db_history_model_query_abc import DbHistoryModelQueryABC
from data.schemas.permission.api_key_permission_dao import apiKeyPermissionDao
from data.schemas.permission.permission_dao import permissionDao
class ApiKeyHistoryQuery(DbHistoryModelQueryABC):
def __init__(self):
DbHistoryModelQueryABC.__init__(self, "ApiKey")
self.set_field("identifier", lambda x, *_: x.identifier)
self.set_field("key", lambda x, *_: x.key)
self.set_field(
"permissions",
lambda x, *_: self._resolve_foreign_history(
x.updated,
x.id,
apiKeyPermissionDao,
permissionDao,
lambda y: y.permission_id,
obj_key="apikeyid",
),
)

View File

@ -1,9 +1,14 @@
from api_graphql.abc.db_model_query_abc import DbModelQueryABC from api_graphql.abc.db_model_query_abc import DbModelQueryABC
from data.schemas.administration.api_key_dao import apiKeyDao
from data.schemas.permission.role_permission_dao import rolePermissionDao
class ApiKeyQuery(DbModelQueryABC): class ApiKeyQuery(DbModelQueryABC):
def __init__(self): def __init__(self):
DbModelQueryABC.__init__(self, "ApiKey") DbModelQueryABC.__init__(self, "ApiKey", apiKeyDao, with_history=True)
self.set_field("identifier", lambda x, *_: x.identifier) self.set_field("identifier", lambda x, *_: x.identifier)
self.set_field("key", lambda x, *_: x.key) self.set_field("key", lambda x, *_: x.key)
self.set_field("permissions", lambda x, *_: x.permissions)
self.set_history_reference_dao(rolePermissionDao, "apikeyid")

View File

@ -0,0 +1,22 @@
from api_graphql.abc.db_history_model_query_abc import DbHistoryModelQueryABC
from data.schemas.public.domain import Domain
from data.schemas.public.short_url import ShortUrl
from data.schemas.public.short_url_dao import shortUrlDao
class DomainHistoryQuery(DbHistoryModelQueryABC):
def __init__(self):
DbHistoryModelQueryABC.__init__(self, "Domain")
self.set_field("name", lambda x, *_: x.name)
self.set_field("shortUrls", self._get_urls)
@staticmethod
async def _get_urls(domain: Domain, *_):
return await shortUrlDao.find_by(
[
{ShortUrl.domain_id: domain.id},
{ShortUrl.deleted: False},
{"updated": {"lessOrEqual": domain.updated}},
]
)

View File

@ -1,5 +1,6 @@
from api_graphql.abc.db_model_query_abc import DbModelQueryABC from api_graphql.abc.db_model_query_abc import DbModelQueryABC
from data.schemas.public.domain import Domain from data.schemas.public.domain import Domain
from data.schemas.public.domain_dao import domainDao
from data.schemas.public.group import Group from data.schemas.public.group import Group
from data.schemas.public.short_url import ShortUrl from data.schemas.public.short_url import ShortUrl
from data.schemas.public.short_url_dao import shortUrlDao from data.schemas.public.short_url_dao import shortUrlDao
@ -7,11 +8,13 @@ from data.schemas.public.short_url_dao import shortUrlDao
class DomainQuery(DbModelQueryABC): class DomainQuery(DbModelQueryABC):
def __init__(self): def __init__(self):
DbModelQueryABC.__init__(self, "Domain") DbModelQueryABC.__init__(self, "Domain", domainDao, with_history=True)
self.set_field("name", lambda x, *_: x.name) self.set_field("name", lambda x, *_: x.name)
self.set_field("shortUrls", self._get_urls) self.set_field("shortUrls", self._get_urls)
self.set_history_reference_dao(shortUrlDao, "domainid")
@staticmethod @staticmethod
async def _get_urls(domain: Domain, *_): async def _get_urls(domain: Domain, *_):
return await shortUrlDao.find_by({ShortUrl.domain_id: domain.id}) return await shortUrlDao.find_by({ShortUrl.domain_id: domain.id})

View File

@ -0,0 +1,47 @@
from api_graphql.abc.db_history_model_query_abc import DbHistoryModelQueryABC
from api_graphql.field.resolver_field_builder import ResolverFieldBuilder
from api_graphql.require_any_resolvers import group_by_assignment_resolver
from data.schemas.public.group import Group
from data.schemas.public.group_dao import groupDao
from data.schemas.public.group_role_assignment_dao import groupRoleAssignmentDao
from data.schemas.public.short_url import ShortUrl
from data.schemas.public.short_url_dao import shortUrlDao
from service.permission.permissions_enum import Permissions
class GroupHistoryQuery(DbHistoryModelQueryABC):
def __init__(self):
DbHistoryModelQueryABC.__init__(self, "Group")
self.set_field("name", lambda x, *_: x.name)
self.field(
ResolverFieldBuilder("shortUrls")
.with_resolver(self._get_urls)
.with_require_any(
[
Permissions.groups,
],
[group_by_assignment_resolver],
)
)
self.set_field(
"roles",
lambda x, *_: self._resolve_foreign_history(
x.updated,
x.id,
groupRoleAssignmentDao,
groupDao,
lambda y: y.role_id,
obj_key="groupid",
),
)
@staticmethod
async def _get_urls(group: Group, *_):
return await shortUrlDao.find_by(
[
{ShortUrl.group_id: group.id},
{ShortUrl.deleted: False},
{"updated": {"lessOrEqual": group.updated}},
]
)

View File

@ -3,6 +3,7 @@ from api_graphql.field.resolver_field_builder import ResolverFieldBuilder
from api_graphql.require_any_resolvers import group_by_assignment_resolver from api_graphql.require_any_resolvers import group_by_assignment_resolver
from data.schemas.public.group import Group from data.schemas.public.group import Group
from data.schemas.public.group_dao import groupDao from data.schemas.public.group_dao import groupDao
from data.schemas.public.group_role_assignment_dao import groupRoleAssignmentDao
from data.schemas.public.short_url import ShortUrl from data.schemas.public.short_url import ShortUrl
from data.schemas.public.short_url_dao import shortUrlDao from data.schemas.public.short_url_dao import shortUrlDao
from service.permission.permissions_enum import Permissions from service.permission.permissions_enum import Permissions
@ -10,7 +11,7 @@ from service.permission.permissions_enum import Permissions
class GroupQuery(DbModelQueryABC): class GroupQuery(DbModelQueryABC):
def __init__(self): def __init__(self):
DbModelQueryABC.__init__(self, "Group") DbModelQueryABC.__init__(self, "Group", groupDao, with_history=True)
self.set_field("name", lambda x, *_: x.name) self.set_field("name", lambda x, *_: x.name)
self.field( self.field(
@ -25,6 +26,9 @@ class GroupQuery(DbModelQueryABC):
) )
self.set_field("roles", self._get_roles) self.set_field("roles", self._get_roles)
self.set_history_reference_dao(shortUrlDao, "groupid")
self.set_history_reference_dao(groupRoleAssignmentDao, "groupid")
@staticmethod @staticmethod
async def _get_urls(group: Group, *_): async def _get_urls(group: Group, *_):
return await shortUrlDao.find_by({ShortUrl.group_id: group.id}) return await shortUrlDao.find_by({ShortUrl.group_id: group.id})

View File

@ -0,0 +1,24 @@
from api_graphql.abc.db_history_model_query_abc import DbHistoryModelQueryABC
from data.schemas.administration.user_dao import userDao
from data.schemas.permission.permission_dao import permissionDao
from data.schemas.permission.role_permission_dao import rolePermissionDao
from data.schemas.permission.role_user_dao import roleUserDao
class RoleHistoryQuery(DbHistoryModelQueryABC):
def __init__(self):
DbHistoryModelQueryABC.__init__(self, "Role")
self.set_field("name", lambda x, *_: x.name)
self.set_field("description", lambda x, *_: x.description)
self.set_field(
"permissions",
lambda x, *_: self._resolve_foreign_history(
x.updated,
x.id,
rolePermissionDao,
permissionDao,
lambda y: y.permission_id,
obj_key="roleid",
),
)

View File

@ -1,11 +1,17 @@
from api_graphql.abc.db_model_query_abc import DbModelQueryABC from api_graphql.abc.db_model_query_abc import DbModelQueryABC
from data.schemas.permission.role_dao import roleDao
from data.schemas.permission.role_permission_dao import rolePermissionDao
from data.schemas.permission.role_user_dao import roleUserDao
class RoleQuery(DbModelQueryABC): class RoleQuery(DbModelQueryABC):
def __init__(self): def __init__(self):
DbModelQueryABC.__init__(self, "Role") DbModelQueryABC.__init__(self, "Role", roleDao, with_history=True)
self.set_field("name", lambda x, *_: x.name) self.set_field("name", lambda x, *_: x.name)
self.set_field("description", lambda x, *_: x.description) self.set_field("description", lambda x, *_: x.description)
self.set_field("permissions", lambda x, *_: x.permissions) self.set_field("permissions", lambda x, *_: x.permissions)
self.set_field("users", lambda x, *_: x.users) self.set_field("users", lambda x, *_: x.users)
self.set_history_reference_dao(rolePermissionDao, "roleid")
self.set_history_reference_dao(roleUserDao, "roleid")

View File

@ -0,0 +1,14 @@
from api_graphql.abc.db_history_model_query_abc import DbHistoryModelQueryABC
class ShortUrlQuery(DbHistoryModelQueryABC):
def __init__(self):
DbHistoryModelQueryABC.__init__(self, "ShortUrl")
self.set_field("shortUrl", lambda x, *_: x.short_url)
self.set_field("targetUrl", lambda x, *_: x.target_url)
self.set_field("description", lambda x, *_: x.description)
self.set_field("group", lambda x, *_: x.group)
self.set_field("domain", lambda x, *_: x.domain)
self.set_field("visits", lambda x, *_: x.visit_count)
self.set_field("loadingScreen", lambda x, *_: x.loading_screen)

View File

@ -1,9 +1,10 @@
from api_graphql.abc.db_model_query_abc import DbModelQueryABC from api_graphql.abc.db_model_query_abc import DbModelQueryABC
from data.schemas.public.short_url_dao import shortUrlDao
class ShortUrlQuery(DbModelQueryABC): class ShortUrlQuery(DbModelQueryABC):
def __init__(self): def __init__(self):
DbModelQueryABC.__init__(self, "ShortUrl") DbModelQueryABC.__init__(self, "ShortUrl", shortUrlDao, with_history=True)
self.set_field("shortUrl", lambda x, *_: x.short_url) self.set_field("shortUrl", lambda x, *_: x.short_url)
self.set_field("targetUrl", lambda x, *_: x.target_url) self.set_field("targetUrl", lambda x, *_: x.target_url)

View File

@ -0,0 +1,23 @@
from api_graphql.abc.db_history_model_query_abc import DbHistoryModelQueryABC
from data.schemas.permission.role_dao import roleDao
from data.schemas.permission.role_user_dao import roleUserDao
class UserHistoryQuery(DbHistoryModelQueryABC):
def __init__(self):
DbHistoryModelQueryABC.__init__(self, "User")
self.set_field("keycloakId", lambda x, *_: x.keycloak_id)
self.set_field("username", lambda x, *_: x.username)
self.set_field("email", lambda x, *_: x.email)
self.set_field(
"roles",
lambda x, *_: self._resolve_foreign_history(
x.updated,
x.id,
roleUserDao,
roleDao,
lambda y: y.role_id,
obj_key="userid",
),
)

View File

@ -1,11 +1,15 @@
from api_graphql.abc.db_model_query_abc import DbModelQueryABC from api_graphql.abc.db_model_query_abc import DbModelQueryABC
from data.schemas.administration.user_dao import userDao
from data.schemas.permission.role_user_dao import roleUserDao
class UserQuery(DbModelQueryABC): class UserQuery(DbModelQueryABC):
def __init__(self): def __init__(self):
DbModelQueryABC.__init__(self, "User") DbModelQueryABC.__init__(self, "User", userDao, with_history=True)
self.set_field("keycloakId", lambda x, *_: x.keycloak_id) self.set_field("keycloakId", lambda x, *_: x.keycloak_id)
self.set_field("username", lambda x, *_: x.username) self.set_field("username", lambda x, *_: x.username)
self.set_field("email", lambda x, *_: x.email) self.set_field("email", lambda x, *_: x.email)
self.set_field("roles", lambda x, *_: x.roles) self.set_field("roles", lambda x, *_: x.roles)
self.set_history_reference_dao(roleUserDao, "userid")

View File

@ -193,7 +193,7 @@ class Query(QueryABC):
if "key" in kwargs: if "key" in kwargs:
return await userSettingsDao.find_by( return await userSettingsDao.find_by(
{UserSetting.user_id: user.id, UserSetting.key: kwargs["key"]} [{UserSetting.user_id: user.id}, {UserSetting.key: kwargs["key"]}]
) )
return await userSettingsDao.find_by({UserSetting.user_id: user.id}) return await userSettingsDao.find_by({UserSetting.user_id: user.id})

View File

@ -49,6 +49,12 @@ class Subscription(SubscriptionABC):
.with_public(True) .with_public(True)
) )
self.subscribe(
SubscriptionFieldBuilder("userLogout")
.with_resolver(lambda message, *_: message.message)
.with_public(True)
)
self.subscribe( self.subscribe(
SubscriptionFieldBuilder("domainChange") SubscriptionFieldBuilder("domainChange")
.with_resolver(lambda message, *_: message.message) .with_resolver(lambda message, *_: message.message)

View File

@ -7,15 +7,17 @@ from typing import Generic, Optional, Union, TypeVar, Any, Type
from core.const import DATETIME_FORMAT from core.const import DATETIME_FORMAT
from core.database.abc.db_model_abc import DbModelABC from core.database.abc.db_model_abc import DbModelABC
from core.database.database import Database from core.database.database import Database
from core.database.external_data_temp_table_builder import ExternalDataTempTableBuilder
from core.get_value import get_value from core.get_value import get_value
from core.logger import DBLogger from core.logger import DBLogger
from core.string import camel_to_snake from core.string import camel_to_snake
from core.typing import T, Attribute, AttributeFilters, AttributeSorts from core.typing import T, Attribute, AttributeFilters, AttributeSorts, Id
T_DBM = TypeVar("T_DBM", bound=DbModelABC) T_DBM = TypeVar("T_DBM", bound=DbModelABC)
class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
_external_fields: dict[str, ExternalDataTempTableBuilder] = {}
@abstractmethod @abstractmethod
def __init__(self, source: str, model_type: Type[T_DBM], table_name: str): def __init__(self, source: str, model_type: Type[T_DBM], table_name: str):
@ -30,6 +32,7 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
self.__db_names: dict[str, str] = {} self.__db_names: dict[str, str] = {}
self.__foreign_tables: dict[str, str] = {} self.__foreign_tables: dict[str, str] = {}
self.__foreign_table_keys: dict[str, str] = {}
self.__date_attributes: set[str] = set() self.__date_attributes: set[str] = set()
self.__ignored_attributes: set[str] = set() self.__ignored_attributes: set[str] = set()
@ -48,6 +51,7 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
db_name: str = None, db_name: str = None,
ignore=False, ignore=False,
primary_key=False, primary_key=False,
aliases: list[str] = None,
): ):
""" """
Add an attribute for db and object mapping to the data access object Add an attribute for db and object mapping to the data access object
@ -56,6 +60,7 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
:param str db_name: Name of the field in the database, if None the attribute lowered attr_name without "_" is used :param str db_name: Name of the field in the database, if None the attribute lowered attr_name without "_" is used
:param bool ignore: Defines if field is ignored for create and update (for e.g. auto increment fields or created/updated fields) :param bool ignore: Defines if field is ignored for create and update (for e.g. auto increment fields or created/updated fields)
:param bool primary_key: Defines if field is the primary key :param bool primary_key: Defines if field is the primary key
:param list[str] aliases: List of aliases for the attribute name
:return: :return:
""" """
if isinstance(attr_name, property): if isinstance(attr_name, property):
@ -69,11 +74,20 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
db_name = attr_name.lower().replace("_", "") db_name = attr_name.lower().replace("_", "")
self.__db_names[attr_name] = db_name self.__db_names[attr_name] = db_name
self.__db_names[db_name] = db_name
if aliases is not None:
for alias in aliases:
if alias in self.__db_names:
raise ValueError(f"Alias {alias} already exists")
self.__db_names[alias] = db_name
if primary_key: if primary_key:
self.__primary_key = db_name self.__primary_key = db_name
self.__primary_key_type = attr_type self.__primary_key_type = attr_type
if attr_type in [datetime, datetime.datetime]: if attr_type in [datetime, datetime.datetime]:
self.__date_attributes.add(attr_name)
self.__date_attributes.add(db_name) self.__date_attributes.add(db_name)
def reference( def reference(
@ -85,13 +99,12 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
): ):
""" """
Add a reference to another table for the given attribute Add a reference to another table for the given attribute
:param Attribute attr: Name of the attribute in the object
:param str primary_attr: Name of the primary key in the foreign object :param str primary_attr: Name of the primary key in the foreign object
:param str foreign_attr: Name of the foreign key in the object :param str foreign_attr: Name of the foreign key in the object
:param str table_name: Name of the table to reference :param str table_name: Name of the table to reference
:return: :return:
""" """
if table_name == self._table_name:
return
if isinstance(attr, property): if isinstance(attr, property):
attr = attr.fget.__name__ attr = attr.fget.__name__
@ -105,11 +118,18 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
foreign_attr = foreign_attr.lower().replace("_", "") foreign_attr = foreign_attr.lower().replace("_", "")
self.__foreign_table_keys[attr] = foreign_attr
if table_name == self._table_name:
return
self.__joins[foreign_attr] = ( self.__joins[foreign_attr] = (
f"LEFT JOIN {table_name} ON {table_name}.{primary_attr} = {self._table_name}.{foreign_attr}" f"LEFT JOIN {table_name} ON {table_name}.{primary_attr} = {self._table_name}.{foreign_attr}"
) )
self.__foreign_tables[attr] = table_name self.__foreign_tables[attr] = table_name
def use_external_fields(self, builder: ExternalDataTempTableBuilder):
self._external_fields[builder.table_name] = builder
def to_object(self, result: dict) -> T_DBM: def to_object(self, result: dict) -> T_DBM:
""" """
Convert a result from the database to an object Convert a result from the database to an object
@ -136,16 +156,53 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
query += f" {self.__joins[join]}" query += f" {self.__joins[join]}"
if filters is not None and (not isinstance(filters, list) or len(filters) > 0): if filters is not None and (not isinstance(filters, list) or len(filters) > 0):
query += f" WHERE {self._build_conditions(filters)}" conditions, external_table_deps = await self._build_conditions(filters)
query = await self._handle_query_external_temp_tables(
query, external_table_deps, ignore_fields=True
)
query += f" WHERE {conditions};"
result = await self._db.select_map(query) result = await self._db.select_map(query)
if len(result) == 0: if len(result) == 0:
return 0 return 0
return result[0]["count"] return result[0]["count"]
async def get_history(
self,
entry_id: int,
by_key: str = None,
when: datetime = None,
until: datetime = None,
without_deleted=False,
) -> list[T_DBM]:
query = f"SELECT {self._table_name}_history.* FROM {self._table_name}_history"
for join in self.__joins:
query += f" {self.__joins[join].replace(self._table_name, f'{self._table_name}_history')}"
query += f" WHERE {f'{self._table_name}_history.{self.__primary_key}' if by_key is None else f'{self._table_name}_history.{by_key}'} = {entry_id}"
if self._default_filter_condition is not None:
query += f" AND {self._default_filter_condition}"
if without_deleted:
query += f" AND {self._table_name}_history.deleted = false"
if when is not None:
query += f" AND {self._attr_from_date_to_char(f'{self._table_name}_history.updated')} = '{when.strftime(DATETIME_FORMAT)}'"
if until is not None:
query += f" AND {self._attr_from_date_to_char(f'{self._table_name}_history.updated')} <= '{until.strftime(DATETIME_FORMAT)}'"
query += f" ORDER BY {self._table_name}_history.updated DESC;"
result = await self._db.select_map(query)
if result is None:
return []
return [self.to_object(x) for x in result]
async def get_all(self) -> list[T_DBM]: async def get_all(self) -> list[T_DBM]:
result = await self._db.select_map( result = await self._db.select_map(
f"SELECT * FROM {self._table_name}{f" WHERE {self._default_filter_condition}" if self._default_filter_condition is not None else ''}" f"SELECT * FROM {self._table_name}{f" WHERE {self._default_filter_condition}" if self._default_filter_condition is not None else ''} ORDER BY {self.__primary_key};"
) )
if result is None: if result is None:
return [] return []
@ -185,7 +242,7 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
:raises ValueError: When no result is found :raises ValueError: When no result is found
""" """
result = await self._db.select_map( result = await self._db.select_map(
self._build_conditional_query(filters, sorts, take, skip) await self._build_conditional_query(filters, sorts, take, skip)
) )
if not result or len(result) == 0: if not result or len(result) == 0:
raise ValueError("No result found") raise ValueError("No result found")
@ -234,7 +291,7 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
:rtype: list[Optional[T_DBM]] :rtype: list[Optional[T_DBM]]
""" """
result = await self._db.select_map( result = await self._db.select_map(
self._build_conditional_query(filters, sorts, take, skip) await self._build_conditional_query(filters, sorts, take, skip)
) )
if not result or len(result) == 0: if not result or len(result) == 0:
return [] return []
@ -265,6 +322,35 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
raise ValueError("More than one result found") raise ValueError("More than one result found")
return result[0] return result[0]
async def touch(self, obj: T_DBM):
"""
Touch the entry to update the last updated date
:return:
"""
await self._db.execute(
f"""
UPDATE {self._table_name}
SET updated = NOW()
WHERE {self.__primary_key} = {self._get_primary_key_value_sql(obj)};
"""
)
async def touch_many_by_id(self, ids: list[Id]):
"""
Touch the entries to update the last updated date
:return:
"""
if len(ids) == 0:
return
await self._db.execute(
f"""
UPDATE {self._table_name}
SET updated = NOW()
WHERE {self.__primary_key} IN ({", ".join([str(x) for x in ids])});
"""
)
async def _build_create_statement(self, obj: T_DBM, skip_editor=False) -> str: async def _build_create_statement(self, obj: T_DBM, skip_editor=False) -> str:
allowed_fields = [ allowed_fields = [
x for x in self.__attributes.keys() if x not in self.__ignored_attributes x for x in self.__attributes.keys() if x not in self.__ignored_attributes
@ -424,7 +510,7 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
return "NULL" return "NULL"
if isinstance(value, Enum): if isinstance(value, Enum):
return str(value.value) return f"'{value.value}'"
if isinstance(value, bool): if isinstance(value, bool):
return "true" if value else "false" return "true" if value else "false"
@ -461,77 +547,136 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
return cast_type(value) return cast_type(value)
def _build_conditional_query( async def _handle_query_external_temp_tables(
self, query: str, external_table_deps: list[str], ignore_fields=False
) -> str:
for dep in external_table_deps:
temp_table = self._external_fields[dep]
temp_table_sql = await temp_table.build()
if not ignore_fields:
query = query.replace(
" FROM",
f", {','.join([f'{temp_table.table_name}.{x}' for x in temp_table.fields.keys() if x not in self.__db_names])} FROM",
)
query = f"{temp_table_sql}\n{query}"
query += f" LEFT JOIN {temp_table.table_name} ON {temp_table.join_ref_table}.{self.__primary_key} = {temp_table.table_name}.{temp_table.primary_key}"
return query
async def _build_conditional_query(
self, self,
filters: AttributeFilters = None, filters: AttributeFilters = None,
sorts: AttributeSorts = None, sorts: AttributeSorts = None,
take: int = None, take: int = None,
skip: int = None, skip: int = None,
) -> str: ) -> str:
query = f"SELECT {self._table_name}.* FROM {self._table_name}" filter_conditions = []
sort_conditions = []
external_table_deps = []
query = f"SELECT {self._table_name}.* FROM {self._table_name}"
for join in self.__joins: for join in self.__joins:
query += f" {self.__joins[join]}" query += f" {self.__joins[join]}"
# Collect dependencies from filters
if filters is not None and (not isinstance(filters, list) or len(filters) > 0): if filters is not None and (not isinstance(filters, list) or len(filters) > 0):
query += f" WHERE {self._build_conditions(filters)}" filter_conditions, filter_deps = await self._build_conditions(filters)
external_table_deps.extend(filter_deps)
# Collect dependencies from sorts
if sorts is not None and (not isinstance(sorts, list) or len(sorts) > 0): if sorts is not None and (not isinstance(sorts, list) or len(sorts) > 0):
query += f" ORDER BY {self._build_order_by(sorts)}" sort_conditions, sort_deps = self._build_order_by(sorts)
external_table_deps.extend(sort_deps)
# Handle external table dependencies before WHERE and ORDER BY
if external_table_deps:
query = await self._handle_query_external_temp_tables(
query, external_table_deps
)
# Add WHERE clause
if filters is not None and (not isinstance(filters, list) or len(filters) > 0):
query += f" WHERE {filter_conditions}"
# Add ORDER BY clause
if sorts is not None and (not isinstance(sorts, list) or len(sorts) > 0):
query += f" ORDER BY {sort_conditions}"
if take is not None: if take is not None:
query += f" LIMIT {take}" query += f" LIMIT {take}"
if skip is not None: if skip is not None:
query += f" OFFSET {skip}" query += f" OFFSET {skip}"
if not query.endswith(";"):
query += ";"
return query return query
def _build_conditions(self, filters: AttributeFilters) -> str: def _get_external_field_key(self, field_name: str) -> Optional[str]:
"""
Returns the key to get the external field if found, otherwise None.
:param str field_name: The name of the field to search for.
:return: The key if found, otherwise None.
:rtype: Optional[str]
"""
for key, builder in self._external_fields.items():
if field_name in builder.fields and field_name not in self.__db_names:
return key
return None
async def _build_conditions(self, filters: AttributeFilters) -> (str, list[str]):
""" """
Build SQL conditions from the given filters Build SQL conditions from the given filters
:param filters: :param filters:
:return: :return: SQL conditions & External field table dependencies
""" """
external_field_table_deps = []
if not isinstance(filters, list): if not isinstance(filters, list):
filters = [filters] filters = [filters]
conditions = [] conditions = []
for f in filters: for f in filters:
f_conditions = []
for attr, values in f.items(): for attr, values in f.items():
if isinstance(attr, property): if isinstance(attr, property):
attr = attr.fget.__name__ attr = attr.fget.__name__
if attr in self.__foreign_tables: if attr in self.__foreign_tables:
foreign_table = self.__foreign_tables[attr] foreign_table = self.__foreign_tables[attr]
conditions.extend( cons, eftd = self._build_foreign_conditions(foreign_table, values)
self._build_foreign_conditions(foreign_table, values) if eftd:
) external_field_table_deps.extend(eftd)
f_conditions.extend(cons)
continue continue
if attr == "fuzzy": if attr == "fuzzy":
conditions.append( self._handle_fuzzy_filter_conditions(
" OR ".join( f_conditions, external_field_table_deps, values
self._build_fuzzy_conditions(
[
(
self.__db_names[x]
if x in self.__db_names
else self.__db_names[camel_to_snake(x)]
)
for x in get_value(values, "fields", list[str])
],
get_value(values, "term", str),
get_value(values, "threshold", int, 5),
)
)
) )
continue continue
db_name = self.__db_names[attr] external_fields_table_name = self._get_external_field_key(attr)
if external_fields_table_name is not None:
external_fields_table = self._external_fields[
external_fields_table_name
]
db_name = f"{external_fields_table.table_name}.{attr}"
external_field_table_deps.append(external_fields_table.table_name)
elif (
isinstance(values, dict) or isinstance(values, list)
) and not attr in self.__foreign_tables:
db_name = f"{self._table_name}.{self.__db_names[attr]}"
else:
db_name = self.__db_names[attr]
if isinstance(values, dict): if isinstance(values, dict):
for operator, value in values.items(): for operator, value in values.items():
conditions.append( f_conditions.append(
self._build_condition( self._build_condition(f"{db_name}", operator, value)
f"{self._table_name}.{db_name}", operator, value
)
) )
elif isinstance(values, list): elif isinstance(values, list):
sub_conditions = [] sub_conditions = []
@ -539,38 +684,42 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
if isinstance(value, dict): if isinstance(value, dict):
for operator, val in value.items(): for operator, val in value.items():
sub_conditions.append( sub_conditions.append(
self._build_condition( self._build_condition(f"{db_name}", operator, val)
f"{self._table_name}.{db_name}", operator, val
)
) )
else: else:
sub_conditions.append( sub_conditions.append(
self._get_value_validation_sql(db_name, value) self._get_value_validation_sql(db_name, value)
) )
conditions.append(f"({' OR '.join(sub_conditions)})") f_conditions.append(f"({' OR '.join(sub_conditions)})")
else: else:
conditions.append(self._get_value_validation_sql(db_name, values)) f_conditions.append(self._get_value_validation_sql(db_name, values))
return " AND ".join(conditions) conditions.append(f"({' OR '.join(f_conditions)})")
return " AND ".join(conditions), external_field_table_deps
@staticmethod
def _build_fuzzy_conditions( def _build_fuzzy_conditions(
self, fields: list[str], term: str, threshold: int = 10 fields: list[str], term: str, threshold: int = 10
) -> list[str]: ) -> list[str]:
conditions = [] conditions = []
for field in fields: for field in fields:
conditions.append( conditions.append(
f"levenshtein({field}, '{term}') <= {threshold}" f"levenshtein({field}::TEXT, '{term}') <= {threshold}"
) # Adjust the threshold as needed ) # Adjust the threshold as needed
return conditions return conditions
def _build_foreign_conditions(self, table: str, values: dict) -> list[str]: def _build_foreign_conditions(
self, table: str, values: dict
) -> (list[str], list[str]):
""" """
Build SQL conditions for foreign key references Build SQL conditions for foreign key references
:param table: Foreign table name :param table: Foreign table name
:param values: Filter values :param values: Filter values
:return: List of conditions :return: List of conditions, List of external field tables
""" """
external_field_table_deps = []
conditions = [] conditions = []
for attr, sub_values in values.items(): for attr, sub_values in values.items():
if isinstance(attr, property): if isinstance(attr, property):
@ -578,25 +727,43 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
if attr in self.__foreign_tables: if attr in self.__foreign_tables:
foreign_table = self.__foreign_tables[attr] foreign_table = self.__foreign_tables[attr]
conditions.extend( sub_conditions, eftd = self._build_foreign_conditions(
self._build_foreign_conditions(foreign_table, sub_values) foreign_table, sub_values
)
if len(eftd) > 0:
external_field_table_deps.extend(eftd)
conditions.extend(sub_conditions)
continue
if attr == "fuzzy":
self._handle_fuzzy_filter_conditions(
conditions, external_field_table_deps, sub_values
) )
continue continue
db_name = f"{table}.{attr.lower().replace('_', '')}" external_fields_table_name = self._get_external_field_key(attr)
if external_fields_table_name is not None:
external_fields_table = self._external_fields[
external_fields_table_name
]
db_name = f"{external_fields_table.table_name}.{attr}"
external_field_table_deps.append(external_fields_table.table_name)
else:
db_name = f"{table}.{attr.lower().replace('_', '')}"
if isinstance(sub_values, dict): if isinstance(sub_values, dict):
for operator, value in sub_values.items(): for operator, value in sub_values.items():
conditions.append( conditions.append(
f"({self._build_condition(db_name, operator, value)} OR {self._build_condition(db_name, "isNull", None)})") f"{self._build_condition(db_name, operator, value)}"
)
elif isinstance(sub_values, list): elif isinstance(sub_values, list):
sub_conditions = [] sub_conditions = []
for value in sub_values: for value in sub_values:
if isinstance(value, dict): if isinstance(value, dict):
for operator, val in value.items(): for operator, val in value.items():
sub_conditions.append( sub_conditions.append(
f"({self._build_condition(db_name, operator, val)} OR {self._build_condition(db_name, "isNull", None)})" f"{self._build_condition(db_name, operator, val)}"
) )
else: else:
sub_conditions.append( sub_conditions.append(
@ -606,14 +773,55 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
else: else:
conditions.append(self._get_value_validation_sql(db_name, sub_values)) conditions.append(self._get_value_validation_sql(db_name, sub_values))
return conditions return conditions, external_field_table_deps
def _handle_fuzzy_filter_conditions(
self, conditions, external_field_table_deps, sub_values
):
fuzzy_fields = get_value(sub_values, "fields", list[str])
fuzzy_fields_db_names = []
for fuzzy_field in fuzzy_fields:
external_fields_table_name = self._get_external_field_key(fuzzy_field)
if external_fields_table_name is not None:
external_fields_table = self._external_fields[
external_fields_table_name
]
fuzzy_fields_db_names.append(
f"{external_fields_table.table_name}.{fuzzy_field}"
)
external_field_table_deps.append(external_fields_table.table_name)
elif fuzzy_field in self.__db_names:
fuzzy_fields_db_names.append(
f"{self._table_name}.{self.__db_names[fuzzy_field]}"
)
elif fuzzy_field in self.__foreign_tables:
fuzzy_fields_db_names.append(
f"{self._table_name}.{self.__foreign_table_keys[fuzzy_field]}"
)
else:
fuzzy_fields_db_names.append(
self.__db_names[camel_to_snake(fuzzy_field)]
)
conditions.append(
f"({' OR '.join(
self._build_fuzzy_conditions(
[x for x in fuzzy_fields_db_names],
get_value(sub_values, "term", str),
get_value(sub_values, "threshold", int, 5),
)
)
})"
)
def _get_value_validation_sql(self, field: str, value: Any): def _get_value_validation_sql(self, field: str, value: Any):
value = self._get_value_sql(value) value = self._get_value_sql(value)
field_selector = f"{self._table_name}.{field}"
if field in self.__foreign_tables:
field_selector = self.__db_names[field]
if value == "NULL": if value == "NULL":
return f"{self._table_name}.{field} IS NULL" return f"{field_selector} IS NULL"
return f"{self._table_name}.{field} = {value}" return f"{field_selector} = {value}"
def _build_condition(self, db_name: str, operator: str, value: Any) -> str: def _build_condition(self, db_name: str, operator: str, value: Any) -> str:
""" """
@ -623,8 +831,10 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
:param value: :param value:
:return: :return:
""" """
if db_name in self.__date_attributes: attr = db_name.split(".")[-1]
db_name = f"TO_CHAR({db_name}, 'DD.MM.YYYY HH24:MI:SS.US')"
if attr in self.__date_attributes:
db_name = self._attr_from_date_to_char(db_name)
sql_value = self._get_value_sql(value) sql_value = self._get_value_sql(value)
if operator == "equal": if operator == "equal":
@ -660,12 +870,17 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
else: else:
raise ValueError(f"Unsupported operator: {operator}") raise ValueError(f"Unsupported operator: {operator}")
def _build_order_by(self, sorts: AttributeSorts) -> str: @staticmethod
def _attr_from_date_to_char(attr: str) -> str:
return f"TO_CHAR({attr}, 'YYYY-MM-DD HH24:MI:SS.US TZ')"
def _build_order_by(self, sorts: AttributeSorts) -> (str, list[str]):
""" """
Build SQL order by clause from the given sorts Build SQL order by clause from the given sorts
:param sorts: :param sorts:
:return: :return:
""" """
external_field_table_deps = []
if not isinstance(sorts, list): if not isinstance(sorts, list):
sorts = [sorts] sorts = [sorts]
@ -677,35 +892,38 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
if attr in self.__foreign_tables: if attr in self.__foreign_tables:
foreign_table = self.__foreign_tables[attr] foreign_table = self.__foreign_tables[attr]
sort_clauses.extend( f_sorts, eftd = self._build_foreign_order_by(
self._build_foreign_order_by(foreign_table, direction) foreign_table, direction
) )
if eftd:
external_field_table_deps.extend(eftd)
sort_clauses.extend(f_sorts)
continue continue
match attr: external_fields_table_name = self._get_external_field_key(attr)
case "createdUtc": if external_fields_table_name is not None:
attr = "created" external_fields_table = self._external_fields[
case "updatedUtc": external_fields_table_name
attr = "updated" ]
db_name = f"{external_fields_table.table_name}.{attr}"
if attr.endswith("Utc") and attr.split("Utc")[0].lower() in [ external_field_table_deps.append(external_fields_table.table_name)
"created", else:
"updated", db_name = self.__db_names[attr]
]:
attr = attr.replace("Utc", "")
db_name = self.__db_names[attr]
sort_clauses.append(f"{db_name} {direction.upper()}") sort_clauses.append(f"{db_name} {direction.upper()}")
return ", ".join(sort_clauses) return ", ".join(sort_clauses), external_field_table_deps
def _build_foreign_order_by(self, table: str, direction: str) -> list[str]: def _build_foreign_order_by(
self, table: str, direction: dict
) -> (list[str], list[str]):
""" """
Build SQL order by clause for foreign key references Build SQL order by clause for foreign key references
:param table: Foreign table name :param table: Foreign table name
:param direction: Sort direction :param direction: Sort direction
:return: List of order by clauses :return: List of order by clauses
""" """
external_field_table_deps = []
sort_clauses = [] sort_clauses = []
for attr, sub_direction in direction.items(): for attr, sub_direction in direction.items():
if isinstance(attr, property): if isinstance(attr, property):
@ -713,15 +931,25 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
if attr in self.__foreign_tables: if attr in self.__foreign_tables:
foreign_table = self.__foreign_tables[attr] foreign_table = self.__foreign_tables[attr]
sort_clauses.extend( f_sorts, eftd = self._build_foreign_order_by(foreign_table, direction)
self._build_foreign_order_by(foreign_table, sub_direction) if eftd:
) external_field_table_deps.extend(eftd)
sort_clauses.extend(f_sorts)
continue continue
db_name = f"{table}.{attr.lower().replace('_', '')}" external_fields_table_name = self._get_external_field_key(attr)
if external_fields_table_name is not None:
external_fields_table = self._external_fields[
external_fields_table_name
]
db_name = f"{external_fields_table.table_name}.{attr}"
external_field_table_deps.append(external_fields_table.table_name)
else:
db_name = f"{table}.{attr.lower().replace('_', '')}"
sort_clauses.append(f"{db_name} {sub_direction.upper()}") sort_clauses.append(f"{db_name} {sub_direction.upper()}")
return sort_clauses return sort_clauses, external_field_table_deps
@staticmethod @staticmethod
async def _get_editor_id(obj: T_DBM): async def _get_editor_id(obj: T_DBM):

View File

@ -0,0 +1,19 @@
from datetime import datetime
from typing import Optional
from core.database.abc.db_model_abc import DbModelABC
from core.typing import Id, SerialId
class DbJoinModelABC(DbModelABC):
def __init__(
self,
id: Id,
source_id: Id,
foreign_id: Id,
deleted: bool = False,
editor_id: Optional[SerialId] = None,
created: Optional[datetime] = None,
updated: Optional[datetime] = None,
):
DbModelABC.__init__(self, id, deleted, editor_id, created, updated)

View File

@ -54,7 +54,7 @@ class DbModelABC(ABC):
from data.schemas.administration.user_dao import userDao from data.schemas.administration.user_dao import userDao
return await userDao.get_by_id(self._editor_id) return await userDao.find_single_by({"id": self._editor_id})
@property @property
def created(self) -> datetime: def created(self) -> datetime:

View File

@ -15,5 +15,5 @@ class DbModelDaoABC[T_DBM](DataAccessObjectABC[T_DBM]):
self.attribute(DbModelABC.id, int, ignore=True) self.attribute(DbModelABC.id, int, ignore=True)
self.attribute(DbModelABC.deleted, bool) self.attribute(DbModelABC.deleted, bool)
self.attribute(DbModelABC.editor_id, int, ignore=True) self.attribute(DbModelABC.editor_id, int, ignore=True)
self.attribute(DbModelABC.created, datetime, "createdutc", ignore=True) self.attribute(DbModelABC.created, datetime, "created", ignore=True)
self.attribute(DbModelABC.updated, datetime, "updatedutc", ignore=True) self.attribute(DbModelABC.updated, datetime, "updated", ignore=True)

View File

@ -0,0 +1,72 @@
import textwrap
from typing import Callable
class ExternalDataTempTableBuilder:
def __init__(self):
self._table_name = None
self._fields: dict[str, str] = {}
self._primary_key = "id"
self._join_ref_table = None
self._value_getter = None
@property
def table_name(self) -> str:
return self._table_name
@property
def fields(self) -> dict[str, str]:
return self._fields
@property
def primary_key(self) -> str:
return self._primary_key
@property
def join_ref_table(self) -> str:
return self._join_ref_table
def with_table_name(self, table_name: str) -> "ExternalDataTempTableBuilder":
self._join_ref_table = table_name
if "." in table_name:
table_name = table_name.split(".")[-1]
if not table_name.endswith("_temp"):
table_name = f"{table_name}_temp"
self._table_name = table_name
return self
def with_field(
self, name: str, sql_type: str, primary=False
) -> "ExternalDataTempTableBuilder":
if primary:
sql_type += " PRIMARY KEY"
self._primary_key = name
self._fields[name] = sql_type
return self
def with_value_getter(
self, value_getter: Callable
) -> "ExternalDataTempTableBuilder":
self._value_getter = value_getter
return self
async def build(self) -> str:
assert self._table_name is not None, "Table name is required"
assert self._value_getter is not None, "Value getter is required"
values_str = ", ".join([f"{value}" for value in await self._value_getter()])
return textwrap.dedent(
f"""
DROP TABLE IF EXISTS {self._table_name};
CREATE TEMP TABLE {self._table_name} (
{", ".join([f"{k} {v}" for k, v in self._fields.items()])}
);
INSERT INTO {self._table_name} VALUES {values_str};
"""
)

View File

@ -29,6 +29,11 @@ def get_value(
value, value,
cast_type if not hasattr(cast_type, "__origin__") else cast_type.__origin__, cast_type if not hasattr(cast_type, "__origin__") else cast_type.__origin__,
): ):
# Handle list[int] case explicitly
if hasattr(cast_type, "__origin__") and cast_type.__origin__ == list:
subtype = cast_type.__args__[0] if hasattr(cast_type, "__args__") else None
if subtype is not None:
return [subtype(item) for item in value]
return value return value
try: try:

View File

@ -40,7 +40,7 @@ class ApiKey(DbModelABC):
return [ return [
await x.permission await x.permission
for x in await apiKeyPermissionDao.get_by_api_key_id(self.id) for x in await apiKeyPermissionDao.find_by_api_key_id(self.id)
] ]
async def has_permission(self, permission: Permissions) -> bool: async def has_permission(self, permission: Permissions) -> bool:

View File

@ -15,23 +15,23 @@ class ApiKeyPermissionDao(DbModelDaoABC[ApiKeyPermission]):
self.attribute(ApiKeyPermission.api_key_id, int) self.attribute(ApiKeyPermission.api_key_id, int)
self.attribute(ApiKeyPermission.permission_id, int) self.attribute(ApiKeyPermission.permission_id, int)
async def get_by_api_key_id( async def find_by_api_key_id(
self, api_key_id: int, with_deleted=False self, api_key_id: int, with_deleted=False
) -> list[ApiKeyPermission]: ) -> list[ApiKeyPermission]:
f = [{ApiKeyPermission.api_key_id: api_key_id}] f = [{ApiKeyPermission.api_key_id: api_key_id}]
if not with_deleted: if not with_deleted:
f.append({ApiKeyPermission.deleted: False}) f.append({ApiKeyPermission.deleted: False})
return await self.get_by(f) return await self.find_by(f)
async def get_by_permission_id( async def find_by_permission_id(
self, permission_id: int, with_deleted=False self, permission_id: int, with_deleted=False
) -> list[ApiKeyPermission]: ) -> list[ApiKeyPermission]:
f = [{ApiKeyPermission.permission_id: permission_id}] f = [{ApiKeyPermission.permission_id: permission_id}]
if not with_deleted: if not with_deleted:
f.append({ApiKeyPermission.deleted: False}) f.append({ApiKeyPermission.deleted: False})
return await self.get_by(f) return await self.find_by(f)
apiKeyPermissionDao = ApiKeyPermissionDao() apiKeyPermissionDao = ApiKeyPermissionDao()

View File

@ -0,0 +1,2 @@
DROP EXTENSION IF EXISTS fuzzystrmatch;
CREATE EXTENSION fuzzystrmatch SCHEMA public;

View File

@ -0,0 +1,133 @@
ALTER TABLE system._executed_migrations
RENAME COLUMN createdutc TO created;
ALTER TABLE system.files
RENAME COLUMN createdutc TO created;
ALTER TABLE system.files_history
RENAME COLUMN createdutc TO created;
ALTER TABLE public.short_url_visits
RENAME COLUMN createdutc TO created;
ALTER TABLE public.short_url_visits_history
RENAME COLUMN createdutc TO created;
ALTER TABLE system.feature_flags_history
RENAME COLUMN createdutc TO created;
ALTER TABLE public.user_settings_history
RENAME COLUMN createdutc TO created;
ALTER TABLE administration.users
RENAME COLUMN createdutc TO created;
ALTER TABLE administration.users_history
RENAME COLUMN createdutc TO created;
ALTER TABLE public.groups
RENAME COLUMN createdutc TO created;
ALTER TABLE public.groups_history
RENAME COLUMN createdutc TO created;
ALTER TABLE public.short_urls
RENAME COLUMN createdutc TO created;
ALTER TABLE administration.api_keys
RENAME COLUMN createdutc TO created;
ALTER TABLE administration.api_keys_history
RENAME COLUMN createdutc TO created;
ALTER TABLE public.domains
RENAME COLUMN createdutc TO created;
ALTER TABLE public.domains_history
RENAME COLUMN createdutc TO created;
ALTER TABLE public.short_urls_history
RENAME COLUMN createdutc TO created;
ALTER TABLE system.settings
RENAME COLUMN createdutc TO created;
ALTER TABLE public.group_role_assignments
RENAME COLUMN createdutc TO created;
ALTER TABLE public.group_role_assignments_history
RENAME COLUMN createdutc TO created;
ALTER TABLE system.settings_history
RENAME COLUMN createdutc TO created;
ALTER TABLE public.user_settings
RENAME COLUMN createdutc TO created;
ALTER TABLE permission.permissions
RENAME COLUMN createdutc TO created;
ALTER TABLE permission.permissions_history
RENAME COLUMN createdutc TO created;
ALTER TABLE permission.roles
RENAME COLUMN createdutc TO created;
ALTER TABLE permission.roles_history
RENAME COLUMN createdutc TO created;
ALTER TABLE permission.role_permissions
RENAME COLUMN createdutc TO created;
ALTER TABLE permission.role_permissions_history
RENAME COLUMN createdutc TO created;
ALTER TABLE permission.role_users
RENAME COLUMN createdutc TO created;
ALTER TABLE permission.role_users_history
RENAME COLUMN createdutc TO created;
ALTER TABLE permission.api_key_permissions
RENAME COLUMN createdutc TO created;
ALTER TABLE permission.api_key_permissions_history
RENAME COLUMN createdutc TO created;
ALTER TABLE system.feature_flags
RENAME COLUMN createdutc TO created;
ALTER TABLE system._executed_migrations
RENAME COLUMN updatedutc TO updated;
ALTER TABLE system.files
RENAME COLUMN updatedutc TO updated;
ALTER TABLE system.files_history
RENAME COLUMN updatedutc TO updated;
ALTER TABLE public.short_url_visits
RENAME COLUMN updatedutc TO updated;
ALTER TABLE public.short_url_visits_history
RENAME COLUMN updatedutc TO updated;
ALTER TABLE system.feature_flags_history
RENAME COLUMN updatedutc TO updated;
ALTER TABLE public.user_settings_history
RENAME COLUMN updatedutc TO updated;
ALTER TABLE administration.users
RENAME COLUMN updatedutc TO updated;
ALTER TABLE administration.users_history
RENAME COLUMN updatedutc TO updated;
ALTER TABLE public.groups
RENAME COLUMN updatedutc TO updated;
ALTER TABLE public.groups_history
RENAME COLUMN updatedutc TO updated;
ALTER TABLE public.short_urls
RENAME COLUMN updatedutc TO updated;
ALTER TABLE administration.api_keys
RENAME COLUMN updatedutc TO updated;
ALTER TABLE administration.api_keys_history
RENAME COLUMN updatedutc TO updated;
ALTER TABLE public.domains
RENAME COLUMN updatedutc TO updated;
ALTER TABLE public.domains_history
RENAME COLUMN updatedutc TO updated;
ALTER TABLE public.short_urls_history
RENAME COLUMN updatedutc TO updated;
ALTER TABLE system.settings
RENAME COLUMN updatedutc TO updated;
ALTER TABLE public.group_role_assignments
RENAME COLUMN updatedutc TO updated;
ALTER TABLE public.group_role_assignments_history
RENAME COLUMN updatedutc TO updated;
ALTER TABLE system.settings_history
RENAME COLUMN updatedutc TO updated;
ALTER TABLE public.user_settings
RENAME COLUMN updatedutc TO updated;
ALTER TABLE permission.permissions
RENAME COLUMN updatedutc TO updated;
ALTER TABLE permission.permissions_history
RENAME COLUMN updatedutc TO updated;
ALTER TABLE permission.roles
RENAME COLUMN updatedutc TO updated;
ALTER TABLE permission.roles_history
RENAME COLUMN updatedutc TO updated;
ALTER TABLE permission.role_permissions
RENAME COLUMN updatedutc TO updated;
ALTER TABLE permission.role_permissions_history
RENAME COLUMN updatedutc TO updated;
ALTER TABLE permission.role_users
RENAME COLUMN updatedutc TO updated;
ALTER TABLE permission.role_users_history
RENAME COLUMN updatedutc TO updated;
ALTER TABLE permission.api_key_permissions
RENAME COLUMN updatedutc TO updated;
ALTER TABLE permission.api_key_permissions_history
RENAME COLUMN updatedutc TO updated;
ALTER TABLE system.feature_flags
RENAME COLUMN updatedutc TO updated;

View File

@ -0,0 +1,37 @@
CREATE OR REPLACE FUNCTION public.history_trigger_function()
RETURNS TRIGGER AS
$$
DECLARE
schema_name TEXT;
history_table_name TEXT;
BEGIN
-- Construct the name of the history table based on the current table
schema_name := TG_TABLE_SCHEMA;
history_table_name := TG_TABLE_NAME || '_history';
IF (TG_OP = 'INSERT') THEN
RETURN NEW;
END IF;
-- Insert the old row into the history table on UPDATE or DELETE
IF (TG_OP = 'UPDATE' OR TG_OP = 'DELETE') THEN
EXECUTE format(
'INSERT INTO %I.%I SELECT ($1).*',
schema_name,
history_table_name
)
USING OLD;
END IF;
-- For UPDATE, update the UpdatedUtc column and return the new row
IF (TG_OP = 'UPDATE') THEN
NEW.updated := NOW(); -- Update the UpdatedUtc column
RETURN NEW;
END IF;
-- For DELETE, return OLD to allow the deletion
IF (TG_OP = 'DELETE') THEN
RETURN OLD;
END IF;
END;
$$ LANGUAGE plpgsql;

View File

@ -0,0 +1,23 @@
ALTER TABLE permission.role_permissions
ADD CONSTRAINT unique_role_permission
UNIQUE (roleid, permissionid);
ALTER TABLE permission.api_key_permissions
ADD CONSTRAINT unique_api_key_permission
UNIQUE (apikeyid, permissionid);
ALTER TABLE permission.role_users
ADD CONSTRAINT unique_role_user
UNIQUE (roleid, userid);
ALTER TABLE public.user_settings
ADD CONSTRAINT unique_user_setting
UNIQUE (userid, key);
ALTER TABLE system.settings
ADD CONSTRAINT unique_system_setting
UNIQUE (key);
ALTER TABLE system.feature_flags
ADD CONSTRAINT unique_feature_flag
UNIQUE (key);

View File

@ -71,7 +71,7 @@ class PermissionSeeder(DataSeederABC):
if admin_api_key is None: if admin_api_key is None:
return return
admin_permissions = await apiKeyPermissionDao.get_by_api_key_id( admin_permissions = await apiKeyPermissionDao.find_by_api_key_id(
admin_api_key.id, with_deleted=True admin_api_key.id, with_deleted=True
) )
to_assign = [ to_assign = [

View File

@ -111,7 +111,11 @@ def _find_short_url_by_path(path: str) -> Optional[dict]:
if "errors" in data: if "errors" in data:
logger.warning(f"Failed to find short url by path {path} -> {data["errors"]}") logger.warning(f"Failed to find short url by path {path} -> {data["errors"]}")
if "data" not in data or "shortUrls" not in data["data"] or "nodes" not in data["data"]["shortUrls"]: if (
"data" not in data
or "shortUrls" not in data["data"]
or "nodes" not in data["data"]["shortUrls"]
):
return None return None
data = data["data"]["shortUrls"]["nodes"] data = data["data"]["shortUrls"]["nodes"]

View File

@ -4,4 +4,8 @@ RUN rm -rf /usr/share/nginx/html/*
COPY ./dist/open-redirect/browser/ /usr/share/nginx/html COPY ./dist/open-redirect/browser/ /usr/share/nginx/html
RUN apk update RUN apk update
RUN apk add bash RUN apk add bash
ARG VERSION
RUN echo "{\"version\": \"${VERSION}\"}" > /usr/share/nginx/html/assets/version.json
CMD /bin/bash -c "envsubst '\$CONTAINER_NAME' < /etc/nginx/conf.d/nginx.conf.template > /etc/nginx/nginx.conf; nginx -g 'daemon off;'" CMD /bin/bash -c "envsubst '\$CONTAINER_NAME' < /etc/nginx/conf.d/nginx.conf.template > /etc/nginx/nginx.conf; nginx -g 'daemon off;'"

View File

@ -9,7 +9,10 @@
"misprintCoefficient": "0.9", "misprintCoefficient": "0.9",
"ignoredKeys": [ "ignoredKeys": [
"permissions.*", "permissions.*",
"permission_descriptions.*" "permission_descriptions.*",
"event.participants.attendance_states.*",
"event.participants.payment_*",
"primeng.*"
], ],
"ignoredMisprintKeys": [], "ignoredMisprintKeys": [],
"customRegExpToFindKeys": [ "customRegExpToFindKeys": [

View File

@ -1,36 +1,37 @@
<main *ngIf="isLoggedIn && !hideUI; else home" [class]="theme"> <main [class]="theme">
<app-header></app-header> <div
class="warning bg3 flex justify-center p-1.5"
*ngIf="showTechnicalDemoBanner">
{{ 'technical_demo_banner' | translate }}
</div>
<app-header></app-header>
<div class="app"> <div class="app">
<aside *ngIf="showSidebar"> <aside *ngIf="showSidebar">
<app-sidebar></app-sidebar> <app-sidebar></app-sidebar>
</aside> </aside>
<section class="component"> <section class="component" *ngIf="loadedGuiSettings">
<router-outlet></router-outlet> <router-outlet></router-outlet>
</section> </section>
</div> </div>
<app-footer></app-footer> <app-footer></app-footer>
<p-toast></p-toast> <p-toast></p-toast>
<p-confirmDialog #cd key="confirmConfirmationDialog" [baseZIndex]="10000"> <p-confirmDialog #cd key="confirmConfirmationDialog" [baseZIndex]="10000">
<ng-template pTemplate="footer"> <ng-template pTemplate="footer">
<div class="flex gap-2.5 items-center justify-end"> <div class="flex gap-2.5 items-center justify-end">
<p-button <p-button
label="{{ 'dialog.abort' | translate }}" label="{{ 'dialog.abort' | translate }}"
class="btn icon-btn danger-icon-btn" class="btn icon-btn danger-icon-btn"
icon="pi pi-times-circle" icon="pi pi-times-circle"
(onClick)="cd.reject()"></p-button> (onClick)="cd.reject()"></p-button>
<p-button <p-button
label="{{ 'dialog.confirm' | translate }}" label="{{ 'dialog.confirm' | translate }}"
class="btn" class="btn"
icon="pi pi-check-circle" icon="pi pi-check-circle"
(onClick)="cd.accept()"></p-button> (onClick)="cd.accept()"></p-button>
</div> </div>
</ng-template> </ng-template>
</p-confirmDialog> </p-confirmDialog>
</main> </main>
<app-spinner></app-spinner> <app-spinner></app-spinner>
<ng-template #home>
<router-outlet></router-outlet>
</ng-template>

View File

@ -2,8 +2,8 @@ import { Component, OnDestroy } from '@angular/core';
import { SidebarService } from 'src/app/service/sidebar.service'; import { SidebarService } from 'src/app/service/sidebar.service';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
import { AuthService } from 'src/app/service/auth.service';
import { GuiService } from 'src/app/service/gui.service'; import { GuiService } from 'src/app/service/gui.service';
import { FeatureFlagService } from 'src/app/service/feature-flag.service';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@ -11,36 +11,35 @@ import { GuiService } from 'src/app/service/gui.service';
styleUrl: './app.component.scss', styleUrl: './app.component.scss',
}) })
export class AppComponent implements OnDestroy { export class AppComponent implements OnDestroy {
theme = 'open-redirect';
showSidebar = false; showSidebar = false;
hideUI = false; theme = 'lan-maestro';
isLoggedIn = false; showTechnicalDemoBanner = false;
loadedGuiSettings = false;
unsubscribe$ = new Subject<void>(); unsubscribe$ = new Subject<void>();
constructor( constructor(
private sidebar: SidebarService, private sidebar: SidebarService,
private auth: AuthService, private gui: GuiService,
private gui: GuiService private features: FeatureFlagService
) { ) {
this.auth.loadUser(); this.features.get('TechnicalDemoBanner').then(showTechnicalDemoBanner => {
this.showTechnicalDemoBanner = showTechnicalDemoBanner;
this.auth.user$.pipe(takeUntil(this.unsubscribe$)).subscribe(user => {
this.isLoggedIn = user !== null && user !== undefined;
}); });
this.sidebar.visible$ this.sidebar.visible$
.pipe(takeUntil(this.unsubscribe$)) .pipe(takeUntil(this.unsubscribe$))
.subscribe(visible => { .subscribe(visible => {
this.showSidebar = visible; this.showSidebar = visible;
}); });
this.gui.hideGui$.pipe(takeUntil(this.unsubscribe$)).subscribe(hide => {
this.hideUI = hide;
});
this.gui.theme$.pipe(takeUntil(this.unsubscribe$)).subscribe(theme => { this.gui.theme$.pipe(takeUntil(this.unsubscribe$)).subscribe(theme => {
this.theme = theme; this.theme = theme;
}); });
this.gui.loadedGuiSettings$
.pipe(takeUntil(this.unsubscribe$))
.subscribe(loaded => {
this.loadedGuiSettings = loaded;
});
} }
ngOnDestroy() { ngOnDestroy() {

View File

@ -1,4 +1,11 @@
import { APP_INITIALIZER, ErrorHandler, NgModule } from '@angular/core'; import {
APP_INITIALIZER,
ApplicationRef,
DoBootstrap,
ErrorHandler,
Injector,
NgModule,
} from '@angular/core';
import { BrowserModule } from '@angular/platform-browser'; import { BrowserModule } from '@angular/platform-browser';
import { AppRoutingModule } from './app-routing.module'; import { AppRoutingModule } from './app-routing.module';
@ -23,6 +30,8 @@ import { SidebarComponent } from './components/sidebar/sidebar.component';
import { ErrorHandlingService } from 'src/app/service/error-handling.service'; import { ErrorHandlingService } from 'src/app/service/error-handling.service';
import { ConfigService } from 'src/app/service/config.service'; import { ConfigService } from 'src/app/service/config.service';
import { ServerUnavailableComponent } from 'src/app/components/error/server-unavailable/server-unavailable.component'; import { ServerUnavailableComponent } from 'src/app/components/error/server-unavailable/server-unavailable.component';
import { SpinnerService } from 'src/app/service/spinner.service';
import { AuthService } from 'src/app/service/auth.service';
if (environment.production) { if (environment.production) {
Logger.enableProductionMode(); Logger.enableProductionMode();
@ -95,6 +104,20 @@ export function appInitializerFactory(
useClass: ErrorHandlingService, useClass: ErrorHandlingService,
}, },
], ],
bootstrap: [AppComponent],
}) })
export class AppModule {} export class AppModule implements DoBootstrap {
constructor(private injector: Injector) {}
async ngDoBootstrap(appRef: ApplicationRef) {
const spinner = this.injector.get(SpinnerService);
spinner.show();
const auth = this.injector.get(AuthService);
const user = await auth.loadUser();
if (!user) {
await auth.login();
}
appRef.bootstrap(AppComponent);
}
}

View File

@ -1,8 +1,9 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { ErrorComponentBase } from 'src/app/core/base/error-component-base';
@Component({ @Component({
selector: 'app-not-found', selector: 'app-not-found',
templateUrl: './not-found.component.html', templateUrl: './not-found.component.html',
styleUrls: ['./not-found.component.scss'], styleUrls: ['./not-found.component.scss'],
}) })
export class NotFoundComponent {} export class NotFoundComponent extends ErrorComponentBase {}

View File

@ -1,13 +1,16 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { ErrorComponentBase } from 'src/app/core/base/error-component-base';
@Component({ @Component({
selector: 'app-server-unavailable', selector: 'app-server-unavailable',
templateUrl: './server-unavailable.component.html', templateUrl: './server-unavailable.component.html',
styleUrls: ['./server-unavailable.component.scss'], styleUrls: ['./server-unavailable.component.scss'],
}) })
export class ServerUnavailableComponent { export class ServerUnavailableComponent extends ErrorComponentBase {
constructor(private router: Router) {} constructor(private router: Router) {
super();
}
async retryConnection() { async retryConnection() {
await this.router.navigate(['/']); await this.router.navigate(['/']);

View File

@ -1,7 +1,14 @@
<footer> <footer class="flex justify-between pl-1 pr-1">
<a [href]="termsUrl">{{ 'footer.terms' | translate }}</a> <div class="hidden md:block">
<span class="divider"> | </span> <span>web: {{ webVersion }}</span>
<a [href]="privacyUrl">{{ 'footer.privacy' | translate }}</a> <span class="divider"> | </span>
<span class="divider"> | </span> <span>api: {{ apiVersion }}</span>
<a [href]="imprintUrl">{{ 'footer.imprint' | translate }}</a> </div>
<div>
<a [href]="termsUrl">{{ 'footer.terms' | translate }}</a>
<span class="divider"> | </span>
<a [href]="privacyUrl">{{ 'footer.privacy' | translate }}</a>
<span class="divider"> | </span>
<a [href]="imprintUrl">{{ 'footer.imprint' | translate }}</a>
</div>
</footer> </footer>

View File

@ -1,16 +0,0 @@
@import "../../../styles/constants.scss";
footer {
width: 100%;
min-height: 25px;
padding: 0 5px;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
a {
text-decoration: none;
}
}

View File

@ -1,23 +1,37 @@
import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FooterComponent } from "src/app/components/footer/footer.component"; import { FooterComponent } from 'src/app/components/footer/footer.component';
import { TranslateModule } from "@ngx-translate/core"; import { TranslateModule } from '@ngx-translate/core';
import { BrowserModule } from "@angular/platform-browser"; import { SharedModule } from 'src/app/modules/shared/shared.module';
import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; import { AuthService } from 'src/app/service/auth.service';
import { SharedModule } from "src/app/modules/shared/shared.module"; import { KeycloakService } from 'keycloak-angular';
import { ErrorHandlingService } from 'src/app/service/error-handling.service';
import { ToastService } from 'src/app/service/toast.service';
import { ConfirmationService, MessageService } from 'primeng/api';
import { ActivatedRoute } from '@angular/router';
import { of } from 'rxjs';
describe("FooterComponent", () => { describe('FooterComponent', () => {
let component: FooterComponent; let component: FooterComponent;
let fixture: ComponentFixture<FooterComponent>; let fixture: ComponentFixture<FooterComponent>;
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
declarations: [FooterComponent], declarations: [FooterComponent],
imports: [ imports: [SharedModule, TranslateModule.forRoot()],
BrowserModule, providers: [
BrowserAnimationsModule, AuthService,
SharedModule, KeycloakService,
TranslateModule.forRoot(), ErrorHandlingService,
ToastService,
MessageService,
ConfirmationService,
{
provide: ActivatedRoute,
useValue: {
snapshot: { params: of({}) },
},
},
], ],
}).compileComponents(); }).compileComponents();
}); });
@ -28,7 +42,7 @@ describe("FooterComponent", () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
it("should create", () => { it('should create', () => {
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
}); });

View File

@ -1,5 +1,7 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { ConfigService } from 'src/app/service/config.service'; import { ConfigService } from 'src/app/service/config.service';
import { VersionService } from 'src/app/service/version.service';
import { ToastService } from 'src/app/service/toast.service';
@Component({ @Component({
selector: 'app-footer', selector: 'app-footer',
@ -7,7 +9,21 @@ import { ConfigService } from 'src/app/service/config.service';
styleUrls: ['./footer.component.scss'], styleUrls: ['./footer.component.scss'],
}) })
export class FooterComponent { export class FooterComponent {
constructor(private config: ConfigService) {} webVersion = '0.0.0';
apiVersion = '0.0.0';
constructor(
private toast: ToastService,
private config: ConfigService,
private version: VersionService
) {
this.version.getApiVersion().subscribe(version => {
this.apiVersion = version;
});
this.version.getWebVersion().subscribe(version => {
this.webVersion = version.version;
});
}
get termsUrl(): string { get termsUrl(): string {
return this.config.settings.termsUrl; return this.config.settings.termsUrl;

View File

@ -1,65 +1,68 @@
<header> <header>
<div class="header"> <div class="header">
<div class="flex items-center justify-center">
<p-button
*ngIf="user"
icon="pi pi-bars"
class="btn icon-btn p-button-text"
(onClick)="toggleSidebar()"
></p-button>
</div>
<div class="logo">
<!-- <img src="/assets/images/logo.svg" alt="logo"/>-->
</div>
<div class="app-name">
<h1>Open-redirect</h1>
</div>
</div>
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<div class="flex items-center justify-center"> <p-button
<p-button type="button"
type="button" icon="pi pi-bars"
icon="pi pi-palette" class="btn icon-btn p-button-text"
class="btn icon-btn p-button-text" (onClick)="toggleSidebar()"></p-button>
(onClick)="themeMenu.toggle($event)"></p-button>
<p-menu
#themeMenu
[popup]="true"
[model]="themeList"
class="lang-menu"></p-menu>
</div>
<div class="flex items-center justify-center">
<p-button
type="button"
icon="pi pi-globe"
class="btn icon-btn p-button-text"
(onClick)="langMenu.toggle($event)"></p-button>
<p-menu
#langMenu
[popup]="true"
[model]="langList"
class="lang-menu"></p-menu>
</div>
<div class="flex items-center justify-center">
<p-button
*ngIf="!user; else loggedIn"
icon="pi pi-sign-in"
class="btn icon-btn p-button-text"
(onClick)="login()"></p-button>
<ng-template #loggedIn>
<p-button
type="button"
icon="pi pi-user"
class="btn icon-btn p-button-text"
(onClick)="userMenu.toggle($event)"></p-button>
<p-menu
#userMenu
[popup]="true"
[model]="userMenuList"
class="user-menu"></p-menu>
</ng-template>
</div>
</div> </div>
<div class="logo">
<!-- <img src="/assets/images/logo.svg" alt="logo"/>-->
</div>
<div class="app-name">
<h1>LAN-Maestro</h1>
</div>
</div>
<div class="flex items-center justify-center w-1/3" *ngIf="menu.length > 0">
<app-menu-bar class="w-full" [elements]="menu"></app-menu-bar>
</div>
<div class="flex items-center justify-center">
<div class="flex items-center justify-center" *ngIf="themeList.length > 0">
<p-button
type="button"
icon="pi pi-palette"
class="btn icon-btn p-button-text"
(onClick)="themeMenu.toggle($event)"></p-button>
<p-menu
#themeMenu
[popup]="true"
[model]="themeList"
class="lang-menu"></p-menu>
</div>
<div class="flex items-center justify-center" *ngIf="langList.length > 0">
<p-button
type="button"
icon="pi pi-globe"
class="btn icon-btn p-button-text"
(onClick)="langMenu.toggle($event)"></p-button>
<p-menu
#langMenu
[popup]="true"
[model]="langList"
class="lang-menu"></p-menu>
</div>
<div class="flex items-center justify-center">
<p-button
*ngIf="!user; else loggedIn"
icon="pi pi-sign-in"
class="btn icon-btn p-button-text"
(onClick)="login()"></p-button>
<ng-template #loggedIn>
<p-button
type="button"
icon="pi pi-user"
class="btn icon-btn p-button-text"
(onClick)="userMenu.toggle($event)"></p-button>
<p-menu
#userMenu
[popup]="true"
[model]="userMenuList"
class="user-menu"></p-menu>
</ng-template>
</div>
</div>
</header> </header>

View File

@ -1,16 +1,16 @@
import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HeaderComponent } from "src/app/components/header/header.component"; import { HeaderComponent } from 'src/app/components/header/header.component';
import { TranslateModule } from "@ngx-translate/core"; import { TranslateModule } from '@ngx-translate/core';
import { ConfirmationService, MessageService } from "primeng/api"; import { ConfirmationService, MessageService } from 'primeng/api';
import { ActivatedRoute } from "@angular/router"; import { ActivatedRoute } from '@angular/router';
import { of } from "rxjs"; import { of } from 'rxjs';
import { SharedModule } from "src/app/modules/shared/shared.module"; import { SharedModule } from 'src/app/modules/shared/shared.module';
import { ErrorHandlingService } from "src/app/service/error-handling.service"; import { ErrorHandlingService } from 'src/app/service/error-handling.service';
import { ToastService } from "src/app/service/toast.service"; import { ToastService } from 'src/app/service/toast.service';
import { AuthService } from "src/app/service/auth.service"; import { AuthService } from 'src/app/service/auth.service';
import { KeycloakService } from "keycloak-angular"; import { KeycloakService } from 'keycloak-angular';
describe("HeaderComponent", () => { describe('HeaderComponent', () => {
let component: HeaderComponent; let component: HeaderComponent;
let fixture: ComponentFixture<HeaderComponent>; let fixture: ComponentFixture<HeaderComponent>;
@ -41,7 +41,7 @@ describe("HeaderComponent", () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
it("should create", () => { it('should create', () => {
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
}); });

View File

@ -48,12 +48,12 @@ export class HeaderComponent implements OnInit, OnDestroy {
}); });
this.auth.user$.pipe(takeUntil(this.unsubscribe$)).subscribe(async user => { this.auth.user$.pipe(takeUntil(this.unsubscribe$)).subscribe(async user => {
this.user = user;
await this.initMenuLists(); await this.initMenuLists();
if (user) { await this.loadTheme();
await this.loadTheme(); await this.loadLang();
await this.loadLang();
} this.user = user;
this.guiService.loadedGuiSettings$.next(true);
}); });
this.themeList = this.config.settings.themes.map(theme => { this.themeList = this.config.settings.themes.map(theme => {
@ -87,27 +87,7 @@ export class HeaderComponent implements OnInit, OnDestroy {
} }
async initMenuList() { async initMenuList() {
this.menu = [ this.menu = [];
{
label: 'common.news',
routerLink: ['/'],
icon: 'pi pi-home',
},
{
label: 'header.menu.about',
routerLink: ['/about'],
icon: 'pi pi-info',
},
];
if (this.auth.user$.value) {
this.menu.push({
label: 'header.menu.admin',
routerLink: ['/admin'],
icon: 'pi pi-cog',
visible: await this.auth.isAdmin(),
});
}
} }
async initLangMenuList() { async initLangMenuList() {

View File

@ -0,0 +1 @@
<p>home works!</p>

View File

@ -1,15 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HomeComponent } from './home.component'; import { HomeComponent } from './home.component';
import { SharedModule } from 'src/app/modules/shared/shared.module';
import { TranslateModule } from '@ngx-translate/core';
import { AuthService } from 'src/app/service/auth.service';
import { KeycloakService } from 'keycloak-angular';
import { ErrorHandlingService } from 'src/app/service/error-handling.service';
import { ToastService } from 'src/app/service/toast.service';
import { ConfirmationService, MessageService } from 'primeng/api';
import { ActivatedRoute } from '@angular/router';
import { of } from 'rxjs';
describe('HomeComponent', () => { describe('HomeComponent', () => {
let component: HomeComponent; let component: HomeComponent;
@ -18,21 +9,6 @@ describe('HomeComponent', () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
declarations: [HomeComponent], declarations: [HomeComponent],
imports: [SharedModule, TranslateModule.forRoot()],
providers: [
AuthService,
KeycloakService,
ErrorHandlingService,
ToastService,
MessageService,
ConfirmationService,
{
provide: ActivatedRoute,
useValue: {
snapshot: { params: of({}) },
},
},
],
}).compileComponents(); }).compileComponents();
fixture = TestBed.createComponent(HomeComponent); fixture = TestBed.createComponent(HomeComponent);

View File

@ -1,5 +1,5 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { KeycloakService } from 'keycloak-angular'; import { SpinnerService } from 'src/app/service/spinner.service';
@Component({ @Component({
selector: 'app-home', selector: 'app-home',
@ -7,9 +7,7 @@ import { KeycloakService } from 'keycloak-angular';
styleUrl: './home.component.scss', styleUrl: './home.component.scss',
}) })
export class HomeComponent { export class HomeComponent {
constructor(private keycloak: KeycloakService) { constructor(private spinner: SpinnerService) {
if (!this.keycloak.isLoggedIn()) { this.spinner.hide();
this.keycloak.login().then(() => {});
}
} }
} }

View File

@ -1,17 +1,17 @@
import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SidebarComponent } from "./sidebar.component"; import { SidebarComponent } from './sidebar.component';
import { SharedModule } from "src/app/modules/shared/shared.module"; import { SharedModule } from 'src/app/modules/shared/shared.module';
import { TranslateModule } from "@ngx-translate/core"; import { TranslateModule } from '@ngx-translate/core';
import { AuthService } from "src/app/service/auth.service"; import { AuthService } from 'src/app/service/auth.service';
import { ErrorHandlingService } from "src/app/service/error-handling.service"; import { ErrorHandlingService } from 'src/app/service/error-handling.service';
import { ToastService } from "src/app/service/toast.service"; import { ToastService } from 'src/app/service/toast.service';
import { ConfirmationService, MessageService } from "primeng/api"; import { ConfirmationService, MessageService } from 'primeng/api';
import { ActivatedRoute } from "@angular/router"; import { ActivatedRoute } from '@angular/router';
import { of } from "rxjs"; import { of } from 'rxjs';
import { KeycloakService } from "keycloak-angular"; import { KeycloakService } from 'keycloak-angular';
describe("SidebarComponent", () => { describe('SidebarComponent', () => {
let component: SidebarComponent; let component: SidebarComponent;
let fixture: ComponentFixture<SidebarComponent>; let fixture: ComponentFixture<SidebarComponent>;
@ -40,7 +40,7 @@ describe("SidebarComponent", () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
it("should create", () => { it('should create', () => {
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
}); });

View File

@ -1,13 +1,13 @@
import { Component, OnDestroy } from "@angular/core"; import { Component, OnDestroy } from '@angular/core';
import { MenuElement } from "src/app/model/view/menu-element"; import { MenuElement } from 'src/app/model/view/menu-element';
import { Subject } from "rxjs"; import { Subject } from 'rxjs';
import { SidebarService } from "src/app/service/sidebar.service"; import { SidebarService } from 'src/app/service/sidebar.service';
import { takeUntil } from "rxjs/operators"; import { takeUntil } from 'rxjs/operators';
@Component({ @Component({
selector: "app-sidebar", selector: 'app-sidebar',
templateUrl: "./sidebar.component.html", templateUrl: './sidebar.component.html',
styleUrl: "./sidebar.component.scss", styleUrl: './sidebar.component.scss',
}) })
export class SidebarComponent implements OnDestroy { export class SidebarComponent implements OnDestroy {
elements: MenuElement[] = []; elements: MenuElement[] = [];
@ -17,7 +17,7 @@ export class SidebarComponent implements OnDestroy {
constructor(private sidebar: SidebarService) { constructor(private sidebar: SidebarService) {
this.sidebar.elements$ this.sidebar.elements$
.pipe(takeUntil(this.unsubscribe$)) .pipe(takeUntil(this.unsubscribe$))
.subscribe((elements) => { .subscribe(elements => {
this.elements = elements; this.elements = elements;
}); });
} }

View File

@ -1,8 +1,8 @@
import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SpinnerComponent } from "src/app/components/spinner/spinner.component"; import { SpinnerComponent } from 'src/app/components/spinner/spinner.component';
describe("SpinnerComponent", () => { describe('SpinnerComponent', () => {
let component: SpinnerComponent; let component: SpinnerComponent;
let fixture: ComponentFixture<SpinnerComponent>; let fixture: ComponentFixture<SpinnerComponent>;
@ -18,7 +18,7 @@ describe("SpinnerComponent", () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
it("should create", () => { it('should create', () => {
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
}); });

View File

@ -1,16 +1,16 @@
import { Component } from "@angular/core"; import { Component } from '@angular/core';
import { SpinnerService } from "src/app/service/spinner.service"; import { SpinnerService } from 'src/app/service/spinner.service';
@Component({ @Component({
selector: "app-spinner", selector: 'app-spinner',
templateUrl: "./spinner.component.html", templateUrl: './spinner.component.html',
styleUrls: ["./spinner.component.scss"], styleUrls: ['./spinner.component.scss'],
}) })
export class SpinnerComponent { export class SpinnerComponent {
showSpinnerState = false; showSpinnerState = false;
constructor(public spinnerService: SpinnerService) { constructor(public spinnerService: SpinnerService) {
this.spinnerService.showSpinnerState$.subscribe((value) => { this.spinnerService.showSpinnerState$.subscribe(value => {
this.showSpinnerState = value; this.showSpinnerState = value;
}); });
} }

View File

@ -0,0 +1,9 @@
import { inject } from '@angular/core';
import { SpinnerService } from 'src/app/service/spinner.service';
export class ErrorComponentBase {
constructor() {
const spinner = inject(SpinnerService);
spinner.hide();
}
}

View File

@ -26,8 +26,8 @@ export abstract class FormPageBase<
protected filterService = inject(FilterService); protected filterService = inject(FilterService);
protected dataService = inject(PageDataService) as S; protected dataService = inject(PageDataService) as S;
protected constructor() { protected constructor(idKey: string = 'id') {
const id = this.route.snapshot.params['id']; const id = this.route.snapshot.params[idKey];
this.validateRoute(id); this.validateRoute(id);
this.buildForm(); this.buildForm();

View File

@ -115,7 +115,6 @@ export abstract class PageBase<
.onChange() .onChange()
.pipe(takeUntil(this.unsubscribe$)) .pipe(takeUntil(this.unsubscribe$))
.subscribe(() => { .subscribe(() => {
logger.debug('Reload data');
this.load(true); this.load(true);
}); });
} }

View File

@ -0,0 +1,49 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { MutationResult } from 'apollo-angular';
import { Filter } from 'src/app/model/graphql/filter/filter.model';
import { Sort } from 'src/app/model/graphql/filter/sort.model';
import { QueryResult } from 'src/app/model/entities/query-result';
import { DbModel } from 'src/app/model/entities/db-model';
@Injectable({
providedIn: 'root',
})
export abstract class PageWithHistoryDataService<T> {
abstract load(
filter?: Filter[],
sort?: Sort[],
skip?: number,
take?: number
): Observable<QueryResult<T>>;
abstract loadHistory(id: number, options?: object): Observable<DbModel[]>;
abstract onChange(): Observable<void>;
}
export interface Create<T, C> {
create(object: C): Observable<T | undefined> | Observable<MutationResult>;
}
export interface Update<T, U> {
update(object: U): Observable<T | undefined> | Observable<MutationResult>;
}
export interface Delete<T> {
delete(
object: T
): Observable<T | undefined | boolean> | Observable<MutationResult>;
}
export interface Restore<T> {
restore(
object: T
): Observable<T | undefined | boolean> | Observable<MutationResult>;
}
export interface LoadHistory<T> {
loadHistory(
id: number
): Observable<T | undefined | boolean> | Observable<DbModel[] | undefined>;
}

View File

@ -14,6 +14,7 @@ export const ID_COLUMN = {
translationKey: 'common.id', translationKey: 'common.id',
type: 'number', type: 'number',
filterable: true, filterable: true,
sortable: true,
value: (row: { id?: number }) => row.id, value: (row: { id?: number }) => row.id,
class: 'max-w-24', class: 'max-w-24',
}; };
@ -23,6 +24,7 @@ export const NAME_COLUMN = {
translationKey: 'common.name', translationKey: 'common.name',
type: 'text', type: 'text',
filterable: true, filterable: true,
sortable: true,
value: (row: { name?: string }) => row.name, value: (row: { name?: string }) => row.name,
}; };
@ -31,6 +33,7 @@ export const DESCRIPTION_COLUMN = {
translationKey: 'common.description', translationKey: 'common.description',
type: 'text', type: 'text',
filterable: true, filterable: true,
sortable: true,
value: (row: { description?: string }) => row.description, value: (row: { description?: string }) => row.description,
}; };
@ -38,35 +41,49 @@ export const DELETED_COLUMN = {
name: 'deleted', name: 'deleted',
translationKey: 'common.deleted', translationKey: 'common.deleted',
type: 'bool', type: 'bool',
filterable: true, filterable: false,
sortable: true,
value: (row: DbModel) => row.deleted, value: (row: DbModel) => row.deleted,
visible: false,
}; };
export const EDITOR_COLUMN = { export const EDITOR_COLUMN = {
name: 'editor', name: 'editor',
translationKey: 'common.editor', translationKey: 'common.editor',
type: 'text',
filterable: true,
value: (row: DbModel) => row.editor?.username, value: (row: DbModel) => row.editor?.username,
filterSelector: (mode: string, value: unknown) => {
return { editor: { username: { [mode]: value } } };
},
class: 'max-w-32',
visible: false,
}; };
export const CREATED_UTC_COLUMN = { export const CREATED_UTC_COLUMN = {
name: 'createdUtc', name: 'created',
translationKey: 'common.created', translationKey: 'common.created',
type: 'date', type: 'date',
filterable: true, filterable: true,
value: (row: DbModel) => row.createdUtc, sortable: true,
value: (row: DbModel) => row.created,
class: 'max-w-32', class: 'max-w-32',
visible: false,
}; };
export const UPDATED_UTC_COLUMN = { export const UPDATED_UTC_COLUMN = {
name: 'updatedUtc', name: 'updated',
translationKey: 'common.updated', translationKey: 'common.updated',
type: 'date', type: 'date',
filterable: true, filterable: true,
value: (row: DbModel) => row.updatedUtc, sortable: true,
value: (row: DbModel) => row.updated,
class: 'max-w-32', class: 'max-w-32',
visible: false,
}; };
export const DB_MODEL_COLUMNS = [ export const DB_MODEL_COLUMNS = [
DELETED_COLUMN,
EDITOR_COLUMN, EDITOR_COLUMN,
CREATED_UTC_COLUMN, CREATED_UTC_COLUMN,
UPDATED_UTC_COLUMN, UPDATED_UTC_COLUMN,

View File

@ -1,22 +1,39 @@
import { Injectable } from "@angular/core"; import { Injectable } from '@angular/core';
import { CanActivate } from "@angular/router"; import { CanActivate, Router } from '@angular/router';
import { KeycloakService } from "keycloak-angular"; import { KeycloakService } from 'keycloak-angular';
import { Logger } from 'src/app/service/logger.service';
import { AuthService } from 'src/app/service/auth.service';
const logger = new Logger('AuthGuard');
@Injectable({ @Injectable({
providedIn: "root", providedIn: 'root',
}) })
export class AuthGuard implements CanActivate { export class AuthGuard implements CanActivate {
constructor(private keycloak: KeycloakService) {} constructor(
private keycloak: KeycloakService,
private auth: AuthService,
private router: Router
) {}
async canActivate(): Promise<boolean> { async canActivate(): Promise<boolean> {
if (this.keycloak.isTokenExpired()) { try {
await this.keycloak.updateToken(); if (!this.keycloak.isLoggedIn()) {
} logger.debug('User not logged in, redirecting to login page');
await this.auth.login();
if (!this.keycloak.isLoggedIn()) { }
await this.keycloak.login();
if (this.keycloak.isTokenExpired()) {
logger.debug('Token expired, updating token');
await this.keycloak.updateToken();
}
} catch (err) {
logger.error('Error during authentication', err);
await this.router.navigate(['/']);
return false;
} }
logger.debug('Check is user logged in');
return this.keycloak.isLoggedIn(); return this.keycloak.isLoggedIn();
} }
} }

View File

@ -1,24 +1,24 @@
import { Injectable } from "@angular/core"; import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Router } from "@angular/router"; import { ActivatedRouteSnapshot, Router } from '@angular/router';
import { Logger } from "src/app/service/logger.service"; import { Logger } from 'src/app/service/logger.service';
import { ToastService } from "src/app/service/toast.service"; import { ToastService } from 'src/app/service/toast.service';
import { AuthService } from "src/app/service/auth.service"; import { AuthService } from 'src/app/service/auth.service';
import { PermissionsEnum } from "src/app/model/auth/permissionsEnum"; import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum';
const log = new Logger("PermissionGuard"); const log = new Logger('PermissionGuard');
@Injectable({ @Injectable({
providedIn: "root", providedIn: 'root',
}) })
export class PermissionGuard { export class PermissionGuard {
constructor( constructor(
private router: Router, private router: Router,
private toast: ToastService, private toast: ToastService,
private auth: AuthService, private auth: AuthService
) {} ) {}
async canActivate(route: ActivatedRouteSnapshot): Promise<boolean> { async canActivate(route: ActivatedRouteSnapshot): Promise<boolean> {
const permissions = route.data["permissions"] as PermissionsEnum[]; const permissions = route.data['permissions'] as PermissionsEnum[];
if (!permissions || permissions.length === 0) { if (!permissions || permissions.length === 0) {
return true; return true;
@ -26,11 +26,11 @@ export class PermissionGuard {
const validate = await this.auth.hasAnyPermissionLazy(permissions); const validate = await this.auth.hasAnyPermissionLazy(permissions);
if (!validate) { if (!validate) {
log.debug("Permission denied", permissions); log.debug('Permission denied', permissions);
this.toast.warn("common.warning", "error.permission_denied"); this.toast.warn('common.warning', 'error.permission_denied');
this.router.navigate(["/"]).then(); this.router.navigate(['/']).then();
} }
log.debug("Permission granted", permissions); log.debug('Permission granted', permissions);
return validate; return validate;
} }
} }

View File

@ -2,27 +2,55 @@ import { HttpInterceptorFn } from '@angular/common/http';
import { KeycloakService } from 'keycloak-angular'; import { KeycloakService } from 'keycloak-angular';
import { inject } from '@angular/core'; import { inject } from '@angular/core';
import { from, switchMap } from 'rxjs'; import { from, switchMap } from 'rxjs';
import { ConfigService } from 'src/app/service/config.service';
import { catchError } from 'rxjs/operators';
import { AuthService } from 'src/app/service/auth.service';
export const tokenInterceptor: HttpInterceptorFn = (req, next) => { export const tokenInterceptor: HttpInterceptorFn = (req, next) => {
const keycloak = inject(KeycloakService); const config = inject(ConfigService);
if (
!config.settings.api.url ||
!req.url.startsWith(config.settings.api.url)
) {
return next(req);
}
const keycloak = inject(KeycloakService);
if (!keycloak.isLoggedIn()) { if (!keycloak.isLoggedIn()) {
return next(req); return next(req);
} }
if (keycloak.isTokenExpired()) {
keycloak.updateToken().then();
}
return from(keycloak.getToken()).pipe( return from(keycloak.getToken()).pipe(
switchMap(token => { switchMap(token => {
const modifiedReq = token if (!token) {
? req.clone({ return next(req);
}
if (!keycloak.isTokenExpired()) {
return next(
req.clone({
headers: req.headers.set('Authorization', `Bearer ${token}`), headers: req.headers.set('Authorization', `Bearer ${token}`),
}) })
: req; );
}
return next(modifiedReq); return from(keycloak.updateToken(30)).pipe(
switchMap(() => {
return keycloak.getToken();
}),
switchMap(newToken => {
return next(
req.clone({
headers: req.headers.set('Authorization', `Bearer ${newToken}`),
})
);
}),
catchError(() => {
const auth = inject(AuthService);
auth.logout().then();
return next(req);
})
);
}) })
); );
}; };

View File

@ -2,6 +2,10 @@ export enum PermissionsEnum {
// Administration // Administration
administrator = 'administrator', administrator = 'administrator',
// Settings
settings = 'settings',
settingsUpdate = 'settings.update',
apiKeys = 'api_keys', apiKeys = 'api_keys',
apiKeysCreate = 'api_keys.create', apiKeysCreate = 'api_keys.create',
apiKeysUpdate = 'api_keys.update', apiKeysUpdate = 'api_keys.update',

View File

@ -1,13 +1,14 @@
import { Role } from "src/app/model/entities/role"; import { Role } from 'src/app/model/entities/role';
import { DbModel } from "src/app/model/entities/db-model"; import { DbModelWithHistory } from 'src/app/model/entities/db-model';
export interface NotExistingUser { export interface NotExistingUser {
keycloakId: string; keycloakId: string;
username: string; username: string;
} }
export interface User extends DbModel { export interface User extends DbModelWithHistory {
id: number; id: number;
keycloakId: string;
username: string; username: string;
email: string; email: string;
roles: Role[]; roles: Role[];

View File

@ -1,7 +1,7 @@
import { DbModel } from "src/app/model/entities/db-model"; import { DbModelWithHistory } from 'src/app/model/entities/db-model';
import { Permission } from "src/app/model/entities/role"; import { Permission } from 'src/app/model/entities/role';
export interface ApiKey extends DbModel { export interface ApiKey extends DbModelWithHistory {
identifier?: string; identifier?: string;
key?: string; key?: string;
permissions?: Permission[]; permissions?: Permission[];

View File

@ -1,9 +1,19 @@
import { User } from "src/app/model/auth/user"; import { User } from 'src/app/model/auth/user';
export interface DbModelWithHistory {
id?: number;
editor?: User;
deleted?: boolean;
created?: Date;
updated?: Date;
history?: DbModel[];
}
export interface DbModel { export interface DbModel {
id?: number; id?: number;
editor?: User; editor?: User;
deleted?: boolean; deleted?: boolean;
createdUtc?: Date; created?: Date;
updatedUtc?: Date; updated?: Date;
} }

Some files were not shown because too many files have changed in this diff Show More