From 347f8486af8fdc78a04ea40bcc56484cf68d1018 Mon Sep 17 00:00:00 2001 From: edraft Date: Fri, 18 Apr 2025 02:26:59 +0200 Subject: [PATCH] Technical update --- .../abc/db_history_model_query_abc.py | 60 ++++++ .../abc/db_model_collection_filter_abc.py | 4 +- .../api_graphql/abc/db_model_filter_abc.py | 7 +- api/src/api_graphql/abc/db_model_query_abc.py | 52 +++++- .../api_graphql/abc/filter/fuzzy_filter.py | 15 ++ api/src/api_graphql/abc/filter/int_filter.py | 2 + .../api_graphql/abc/filter/string_filter.py | 2 + api/src/api_graphql/abc/filter_abc.py | 6 +- api/src/api_graphql/abc/input_abc.py | 7 +- api/src/api_graphql/abc/mutation_abc.py | 81 ++++++++ api/src/api_graphql/abc/query_abc.py | 14 +- api/src/api_graphql/abc/subscription_abc.py | 9 +- api/src/api_graphql/definition.py | 8 +- api/src/api_graphql/graphql/api_key.gql | 44 +++-- api/src/api_graphql/graphql/base.gql | 26 ++- api/src/api_graphql/graphql/base_model.gql | 12 ++ api/src/api_graphql/graphql/domain.gql | 20 +- api/src/api_graphql/graphql/feature_flag.gql | 6 +- api/src/api_graphql/graphql/group.gql | 24 +-- api/src/api_graphql/graphql/permission.gql | 16 +- api/src/api_graphql/graphql/query.gql | 2 +- api/src/api_graphql/graphql/role.gql | 45 +++-- api/src/api_graphql/graphql/setting.gql | 6 +- api/src/api_graphql/graphql/short_url.gql | 30 +-- api/src/api_graphql/graphql/user.gql | 52 ++++-- api/src/api_graphql/graphql/user_setting.gql | 6 +- .../api_graphql/mutations/api_key_mutation.py | 78 ++------ .../api_graphql/mutations/role_mutation.py | 63 ++----- .../api_graphql/mutations/user_mutation.py | 74 +++----- .../queries/api_key_history_query.py | 22 +++ api/src/api_graphql/queries/api_key_query.py | 7 +- .../api_graphql/queries/role_history_query.py | 24 +++ api/src/api_graphql/queries/role_query.py | 8 +- .../api_graphql/queries/user_history_query.py | 23 +++ api/src/api_graphql/queries/user_query.py | 6 +- .../database/abc/data_access_object_abc.py | 174 +++++++++++++++--- .../core/database/abc/db_join_model_abc.py | 19 ++ api/src/core/database/abc/db_model_dao_abc.py | 4 +- api/src/core/get_value.py | 25 ++- .../2025-04-03-19-45-fix-levenshtein.sql | 2 + .../2025-04-07-12-15-rename-utc-fields.sql | 133 +++++++++++++ ...18-25-rename-utc-fields-update-trigger.sql | 37 ++++ .../2025-04-13-08-45-fix-join-tables.sql | 23 +++ 43 files changed, 942 insertions(+), 336 deletions(-) create mode 100644 api/src/api_graphql/abc/db_history_model_query_abc.py create mode 100644 api/src/api_graphql/abc/filter/fuzzy_filter.py create mode 100644 api/src/api_graphql/graphql/base_model.gql create mode 100644 api/src/api_graphql/queries/api_key_history_query.py create mode 100644 api/src/api_graphql/queries/role_history_query.py create mode 100644 api/src/api_graphql/queries/user_history_query.py create mode 100644 api/src/core/database/abc/db_join_model_abc.py create mode 100644 api/src/data/scripts/2025-04-03-19-45-fix-levenshtein.sql create mode 100644 api/src/data/scripts/2025-04-07-12-15-rename-utc-fields.sql create mode 100644 api/src/data/scripts/2025-04-07-18-25-rename-utc-fields-update-trigger.sql create mode 100644 api/src/data/scripts/2025-04-13-08-45-fix-join-tables.sql diff --git a/api/src/api_graphql/abc/db_history_model_query_abc.py b/api/src/api_graphql/abc/db_history_model_query_abc.py new file mode 100644 index 0000000..0755c16 --- /dev/null +++ b/api/src/api_graphql/abc/db_history_model_query_abc.py @@ -0,0 +1,60 @@ +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 = None): + assert name is not None, f"Name for {__name__} must be provided" + 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)] diff --git a/api/src/api_graphql/abc/db_model_collection_filter_abc.py b/api/src/api_graphql/abc/db_model_collection_filter_abc.py index 31a208a..c05fc61 100644 --- a/api/src/api_graphql/abc/db_model_collection_filter_abc.py +++ b/api/src/api_graphql/abc/db_model_collection_filter_abc.py @@ -23,5 +23,5 @@ class DbModelCollectionFilterABC[T](CollectionFilterABC): self.add_field("id", IntCollectionFilter) self.add_field("deleted", BoolCollectionFilter) self.add_field("editor", IntCollectionFilter) - self.add_field("createdUtc", DateCollectionFilter) - self.add_field("updatedUtc", DateCollectionFilter) + self.add_field("created", DateCollectionFilter) + self.add_field("updated", DateCollectionFilter) diff --git a/api/src/api_graphql/abc/db_model_filter_abc.py b/api/src/api_graphql/abc/db_model_filter_abc.py index d78b1a3..397625a 100644 --- a/api/src/api_graphql/abc/db_model_filter_abc.py +++ b/api/src/api_graphql/abc/db_model_filter_abc.py @@ -1,10 +1,11 @@ from typing import Optional 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.string_filter import StringFilter from api_graphql.abc.filter_abc import FilterABC -from api_graphql.filter.fuzzy_filter import FuzzyFilter class DbModelFilterABC[T](FilterABC[T]): @@ -18,7 +19,7 @@ class DbModelFilterABC[T](FilterABC[T]): self.add_field("id", IntFilter) self.add_field("deleted", BoolFilter) self.add_field("editor", UserFilter) - self.add_field("createdUtc", StringFilter, "created") - self.add_field("updatedUtc", StringFilter, "updated") + self.add_field("created", DateFilter) + self.add_field("updated", DateFilter) self.add_field("fuzzy", FuzzyFilter) diff --git a/api/src/api_graphql/abc/db_model_query_abc.py b/api/src/api_graphql/abc/db_model_query_abc.py index bac9ab7..b44c0c6 100644 --- a/api/src/api_graphql/abc/db_model_query_abc.py +++ b/api/src/api_graphql/abc/db_model_query_abc.py @@ -1,18 +1,54 @@ +from copy import deepcopy +from typing import Optional + 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): - def __init__(self, name: str = __name__): + def __init__( + self, + name: str = __name__, + dao: DataAccessObjectABC = None, + with_history: bool = False, + ): QueryABC.__init__(self, name) + self._dao: Optional[DataAccessObjectABC] = dao + self.set_field("id", lambda x, *_: x.id) self.set_field("deleted", lambda x, *_: x.deleted) - self.set_field("editor", self.__get_editor) - self.set_field("createdUtc", lambda x, *_: x.created) - self.set_field("updatedUtc", lambda x, *_: x.updated) + self.set_field("editor", lambda x, *_: x.editor) + self.set_field("created", lambda x, *_: x.created) + self.set_field("updated", lambda x, *_: x.updated) - @staticmethod - async def __get_editor(x: User, *_): - return await x.editor + if with_history: + self.set_field("history", self._resolve_history) + + 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 diff --git a/api/src/api_graphql/abc/filter/fuzzy_filter.py b/api/src/api_graphql/abc/filter/fuzzy_filter.py new file mode 100644 index 0000000..3d9bc58 --- /dev/null +++ b/api/src/api_graphql/abc/filter/fuzzy_filter.py @@ -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) diff --git a/api/src/api_graphql/abc/filter/int_filter.py b/api/src/api_graphql/abc/filter/int_filter.py index 3cd12ea..92d7293 100644 --- a/api/src/api_graphql/abc/filter/int_filter.py +++ b/api/src/api_graphql/abc/filter/int_filter.py @@ -18,3 +18,5 @@ class IntFilter(FilterABC): self.add_field("lessOrEqual", int) self.add_field("isNull", int) self.add_field("isNotNull", int) + self.add_field("in", list) + self.add_field("notIn", list) diff --git a/api/src/api_graphql/abc/filter/string_filter.py b/api/src/api_graphql/abc/filter/string_filter.py index df879f1..bd0b891 100644 --- a/api/src/api_graphql/abc/filter/string_filter.py +++ b/api/src/api_graphql/abc/filter/string_filter.py @@ -18,3 +18,5 @@ class StringFilter(FilterABC): self.add_field("endsWith", str) self.add_field("isNull", str) self.add_field("isNotNull", str) + self.add_field("in", list) + self.add_field("notIn", list) diff --git a/api/src/api_graphql/abc/filter_abc.py b/api/src/api_graphql/abc/filter_abc.py index c75f971..3d54715 100644 --- a/api/src/api_graphql/abc/filter_abc.py +++ b/api/src/api_graphql/abc/filter_abc.py @@ -19,10 +19,12 @@ class FilterABC[T](ABC): def add_field( self, 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, ): - if field not in self._obj and db_name not in self._obj: + if field not in self._obj: return if db_name is None: diff --git a/api/src/api_graphql/abc/input_abc.py b/api/src/api_graphql/abc/input_abc.py index f0fd5e6..475854f 100644 --- a/api/src/api_graphql/abc/input_abc.py +++ b/api/src/api_graphql/abc/input_abc.py @@ -1,6 +1,7 @@ from abc import ABC from typing import Optional, Type, get_origin, get_args +from core.get_value import get_value from core.typing import T @@ -12,11 +13,15 @@ class InputABC(ABC): ABC.__init__(self) self._src = src + self._options = {} + def option( self, field: str, cast_type: Type[T], default=None, required=False ) -> Optional[T]: if required and field not in self._src: raise ValueError(f"{field} is required") + + self._options[field] = cast_type if field not in self._src: return default @@ -28,4 +33,4 @@ class InputABC(ABC): return cast_type(value) 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) diff --git a/api/src/api_graphql/abc/mutation_abc.py b/api/src/api_graphql/abc/mutation_abc.py index 5768935..59c97ff 100644 --- a/api/src/api_graphql/abc/mutation_abc.py +++ b/api/src/api_graphql/abc/mutation_abc.py @@ -1,7 +1,12 @@ 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.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 @@ -41,3 +46,79 @@ class MutationABC(QueryABC): .with_require_any_permission(require_any_permission) .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] + ) diff --git a/api/src/api_graphql/abc/query_abc.py b/api/src/api_graphql/abc/query_abc.py index bafce27..4b947b6 100644 --- a/api/src/api_graphql/abc/query_abc.py +++ b/api/src/api_graphql/abc/query_abc.py @@ -6,11 +6,12 @@ from typing import Callable, Type, get_args, Any, Union from ariadne import ObjectType, SubscriptionType from graphql import GraphQLResolveInfo +from starlette.requests import Request from typing_extensions import deprecated +from api.middleware.request import get_request from api.route import Route 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.sort_abc import Sort from api_graphql.field.collection_field import CollectionField @@ -46,8 +47,8 @@ class QueryABC(ObjectType): self._subscriptions: dict[str, SubscriptionType] = {} @staticmethod - async def _authorize(): - if not await Route.is_authorized(): + async def _authorize(request: Request): + if not await Route.is_authorized(request): raise UnauthorizedException() @staticmethod @@ -71,8 +72,6 @@ class QueryABC(ObjectType): *args, **kwargs, ): - info = args[0] - if len(permissions) > 0: user = await Route.get_authenticated_user_or_api_key_or_default() if user is not None and all( @@ -120,6 +119,9 @@ class QueryABC(ObjectType): take = None skip = None + if field.default_filter: + filters.append(field.default_filter(*args, **kwargs)) + if field.filter_type and "filter" in kwargs: in_filters = kwargs["filter"] if not isinstance(in_filters, list): @@ -227,7 +229,7 @@ class QueryABC(ObjectType): async def wrapper(*args, **kwargs): if not field.public: - await self._authorize() + await self._authorize(get_request()) if ( field.require_any is None diff --git a/api/src/api_graphql/abc/subscription_abc.py b/api/src/api_graphql/abc/subscription_abc.py index 073142f..c44c240 100644 --- a/api/src/api_graphql/abc/subscription_abc.py +++ b/api/src/api_graphql/abc/subscription_abc.py @@ -2,8 +2,8 @@ from abc import abstractmethod from asyncio import iscoroutinefunction 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.field.subscription_field_builder import SubscriptionFieldBuilder from core.logger import APILogger @@ -20,9 +20,12 @@ class SubscriptionABC(SubscriptionType, QueryABC): def subscribe(self, builder: SubscriptionFieldBuilder): 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: - await self._authorize() + r = info.context.get("request") + await self._authorize(r) if ( field.require_any is None diff --git a/api/src/api_graphql/definition.py b/api/src/api_graphql/definition.py index 772b87f..218fad7 100644 --- a/api/src/api_graphql/definition.py +++ b/api/src/api_graphql/definition.py @@ -1,6 +1,7 @@ import importlib 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.mutation_abc import MutationABC 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("mutations") -sub_query_classes = [DbModelQueryABC, MutationABC, SubscriptionABC] +sub_query_classes = [ + DbModelQueryABC, + DbHistoryModelQueryABC, + MutationABC, + SubscriptionABC, +] query_classes = [ *[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], diff --git a/api/src/api_graphql/graphql/api_key.gql b/api/src/api_graphql/graphql/api_key.gql index 8a6edf2..a1f0ce6 100644 --- a/api/src/api_graphql/graphql/api_key.gql +++ b/api/src/api_graphql/graphql/api_key.gql @@ -4,16 +4,30 @@ type ApiKeyResult { 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 { - id: ID + id: Int identifier: String key: String permissions: [Permission] deleted: Boolean editor: User - createdUtc: String - updatedUtc: String + created: String + updated: String + + history: [ApiKeyHistory] } input ApiKeySort { @@ -22,12 +36,18 @@ input ApiKeySort { deleted: SortOrder editor: UserSort - createdUtc: SortOrder - updatedUtc: SortOrder + created: SortOrder + updated: SortOrder } enum ApiKeyFuzzyFields { + id identifier + + deleted + editor + created + updated } input ApiKeyFuzzy { @@ -42,24 +62,24 @@ input ApiKeyFilter { deleted: BooleanFilter editorId: IntFilter - createdUtc: DateFilter - updatedUtc: DateFilter + created: DateFilter + updated: DateFilter } type ApiKeyMutation { create(input: ApiKeyCreateInput!): ApiKey update(input: ApiKeyUpdateInput!): ApiKey - delete(identifier: String!): Boolean - restore(identifier: String!): Boolean + delete(id: Int!): Boolean + restore(id: Int!): Boolean } input ApiKeyCreateInput { identifier: String - permissions: [ID] + permissions: [Int] } input ApiKeyUpdateInput { - id: ID! + id: Int! identifier: String - permissions: [ID] + permissions: [Int] } \ No newline at end of file diff --git a/api/src/api_graphql/graphql/base.gql b/api/src/api_graphql/graphql/base.gql index ef7d3ae..f389136 100644 --- a/api/src/api_graphql/graphql/base.gql +++ b/api/src/api_graphql/graphql/base.gql @@ -1,12 +1,21 @@ scalar Upload interface DbModel { - id: ID + id: Int deleted: Boolean editor: User - createdUtc: String - updatedUtc: String + created: String + updated: String +} + +interface DbHistoryModel { + id: Int + + deleted: Boolean + editor: String + created: String + updated: String } enum SortOrder { @@ -44,6 +53,8 @@ input IntFilter { isNull: Int isNotNull: Int + in: [Int] + notIn: [Int] } input BooleanFilter { @@ -58,9 +69,18 @@ input DateFilter { equal: String notEqual: String + greater: String + greaterOrEqual: String + + less: String + lessOrEqual: String + contains: String notContains: String isNull: String isNotNull: String + + in: [String] + notIn: [String] } \ No newline at end of file diff --git a/api/src/api_graphql/graphql/base_model.gql b/api/src/api_graphql/graphql/base_model.gql new file mode 100644 index 0000000..d536eea --- /dev/null +++ b/api/src/api_graphql/graphql/base_model.gql @@ -0,0 +1,12 @@ +enum Attendance { + absent + present + delayed + canceled +} + +enum Payment { + not_paid + paid + refunded +} \ No newline at end of file diff --git a/api/src/api_graphql/graphql/domain.gql b/api/src/api_graphql/graphql/domain.gql index 3783c83..eb4dfa8 100644 --- a/api/src/api_graphql/graphql/domain.gql +++ b/api/src/api_graphql/graphql/domain.gql @@ -5,15 +5,15 @@ type DomainResult { } type Domain implements DbModel { - id: ID + id: Int name: String shortUrls: [ShortUrl] deleted: Boolean editor: User - createdUtc: String - updatedUtc: String + created: String + updated: String } input DomainSort { @@ -22,8 +22,8 @@ input DomainSort { deleted: SortOrder editorId: SortOrder - createdUtc: SortOrder - updatedUtc: SortOrder + created: SortOrder + updated: SortOrder } enum DomainFuzzyFields { @@ -44,15 +44,15 @@ input DomainFilter { deleted: BooleanFilter editor: IntFilter - createdUtc: DateFilter - updatedUtc: DateFilter + created: DateFilter + updated: DateFilter } type DomainMutation { create(input: DomainCreateInput!): Domain update(input: DomainUpdateInput!): Domain - delete(id: ID!): Boolean - restore(id: ID!): Boolean + delete(id: Int!): Boolean + restore(id: Int!): Boolean } input DomainCreateInput { @@ -60,6 +60,6 @@ input DomainCreateInput { } input DomainUpdateInput { - id: ID! + id: Int! name: String } \ No newline at end of file diff --git a/api/src/api_graphql/graphql/feature_flag.gql b/api/src/api_graphql/graphql/feature_flag.gql index 69826b6..7b5b64d 100644 --- a/api/src/api_graphql/graphql/feature_flag.gql +++ b/api/src/api_graphql/graphql/feature_flag.gql @@ -1,12 +1,12 @@ type FeatureFlag implements DbModel { - id: ID + id: Int key: String value: Boolean deleted: Boolean editor: User - createdUtc: String - updatedUtc: String + created: String + updated: String } type FeatureFlagMutation { diff --git a/api/src/api_graphql/graphql/group.gql b/api/src/api_graphql/graphql/group.gql index 4d62320..3fc8491 100644 --- a/api/src/api_graphql/graphql/group.gql +++ b/api/src/api_graphql/graphql/group.gql @@ -5,7 +5,7 @@ type GroupResult { } type Group implements DbModel { - id: ID + id: Int name: String shortUrls: [ShortUrl] @@ -13,8 +13,8 @@ type Group implements DbModel { deleted: Boolean editor: User - createdUtc: String - updatedUtc: String + created: String + updated: String } input GroupSort { @@ -23,8 +23,8 @@ input GroupSort { deleted: SortOrder editorId: SortOrder - createdUtc: SortOrder - updatedUtc: SortOrder + created: SortOrder + updated: SortOrder } enum GroupFuzzyFields { @@ -45,24 +45,24 @@ input GroupFilter { deleted: BooleanFilter editor: IntFilter - createdUtc: DateFilter - updatedUtc: DateFilter + created: DateFilter + updated: DateFilter } type GroupMutation { create(input: GroupCreateInput!): Group update(input: GroupUpdateInput!): Group - delete(id: ID!): Boolean - restore(id: ID!): Boolean + delete(id: Int!): Boolean + restore(id: Int!): Boolean } input GroupCreateInput { name: String! - roles: [ID] + roles: [Int] } input GroupUpdateInput { - id: ID! + id: Int! name: String - roles: [ID] + roles: [Int] } \ No newline at end of file diff --git a/api/src/api_graphql/graphql/permission.gql b/api/src/api_graphql/graphql/permission.gql index 51210fc..cd531d0 100644 --- a/api/src/api_graphql/graphql/permission.gql +++ b/api/src/api_graphql/graphql/permission.gql @@ -5,14 +5,14 @@ type PermissionResult { } type Permission implements DbModel { - id: ID + id: Int name: String description: String deleted: Boolean editor: User - createdUtc: String - updatedUtc: String + created: String + updated: String } input PermissionSort { @@ -22,8 +22,8 @@ input PermissionSort { deleted: SortOrder editorId: SortOrder - createdUtc: SortOrder - updatedUtc: SortOrder + created: SortOrder + updated: SortOrder } input PermissionFilter { @@ -33,12 +33,12 @@ input PermissionFilter { deleted: BooleanFilter editor: IntFilter - createdUtc: DateFilter - updatedUtc: DateFilter + created: DateFilter + updated: DateFilter } input PermissionInput { - id: ID + id: Int name: String description: String } diff --git a/api/src/api_graphql/graphql/query.gql b/api/src/api_graphql/graphql/query.gql index 2aabbcd..6b6e454 100644 --- a/api/src/api_graphql/graphql/query.gql +++ b/api/src/api_graphql/graphql/query.gql @@ -9,7 +9,7 @@ type Query { user: User userHasPermission(permission: String!): Boolean userHasAnyPermission(permissions: [String]!): Boolean - notExistingUsersFromKeycloak: KeycloakUserResult + notExistingUsersFromKeycloak: [KeycloakUser] domains(filter: [DomainFilter], sort: [DomainSort], skip: Int, take: Int): DomainResult groups(filter: [GroupFilter], sort: [GroupSort], skip: Int, take: Int): GroupResult diff --git a/api/src/api_graphql/graphql/role.gql b/api/src/api_graphql/graphql/role.gql index e69b1e6..0be7952 100644 --- a/api/src/api_graphql/graphql/role.gql +++ b/api/src/api_graphql/graphql/role.gql @@ -4,8 +4,21 @@ type RoleResult { 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 { - id: ID + id: Int name: String description: String permissions: [Permission] @@ -13,8 +26,10 @@ type Role implements DbModel { deleted: Boolean editor: User - createdUtc: String - updatedUtc: String + created: String + updated: String + + history: [RoleHistory] } input RoleSort { @@ -24,13 +39,19 @@ input RoleSort { deleted: SortOrder editor: UserSort - createdUtc: SortOrder - updatedUtc: SortOrder + created: SortOrder + updated: SortOrder } enum RoleFuzzyFields { + id name description + + deleted + editor + created + updated } input RoleFuzzy { @@ -48,26 +69,26 @@ input RoleFilter { deleted: BooleanFilter editor_id: IntFilter - createdUtc: DateFilter - updatedUtc: DateFilter + created: DateFilter + updated: DateFilter } type RoleMutation { create(input: RoleCreateInput!): Role update(input: RoleUpdateInput!): Role - delete(id: ID!): Boolean - restore(id: ID!): Boolean + delete(id: Int!): Boolean + restore(id: Int!): Boolean } input RoleCreateInput { name: String! description: String - permissions: [ID] + permissions: [Int] } input RoleUpdateInput { - id: ID! + id: Int! name: String description: String - permissions: [ID] + permissions: [Int] } \ No newline at end of file diff --git a/api/src/api_graphql/graphql/setting.gql b/api/src/api_graphql/graphql/setting.gql index 88f9700..f0c8ff1 100644 --- a/api/src/api_graphql/graphql/setting.gql +++ b/api/src/api_graphql/graphql/setting.gql @@ -1,12 +1,12 @@ type Setting implements DbModel { - id: ID + id: Int key: String value: String deleted: Boolean editor: User - createdUtc: String - updatedUtc: String + created: String + updated: String } type SettingMutation { diff --git a/api/src/api_graphql/graphql/short_url.gql b/api/src/api_graphql/graphql/short_url.gql index a9753bd..afc4e8f 100644 --- a/api/src/api_graphql/graphql/short_url.gql +++ b/api/src/api_graphql/graphql/short_url.gql @@ -5,7 +5,7 @@ type ShortUrlResult { } type ShortUrl implements DbModel { - id: ID + id: Int shortUrl: String targetUrl: String description: String @@ -16,8 +16,8 @@ type ShortUrl implements DbModel { deleted: Boolean editor: User - createdUtc: String - updatedUtc: String + created: String + updated: String } input ShortUrlSort { @@ -28,8 +28,8 @@ input ShortUrlSort { deleted: SortOrder editorId: SortOrder - createdUtc: SortOrder - updatedUtc: SortOrder + created: SortOrder + updated: SortOrder } enum ShortUrlFuzzyFields { @@ -57,33 +57,33 @@ input ShortUrlFilter { deleted: BooleanFilter editor: IntFilter - createdUtc: DateFilter - updatedUtc: DateFilter + created: DateFilter + updated: DateFilter } type ShortUrlMutation { create(input: ShortUrlCreateInput!): ShortUrl update(input: ShortUrlUpdateInput!): ShortUrl - delete(id: ID!): Boolean - restore(id: ID!): Boolean - trackVisit(id: ID!, agent: String): Boolean + delete(id: Int!): Boolean + restore(id: Int!): Boolean + trackVisit(id: Int!, agent: String): Boolean } input ShortUrlCreateInput { shortUrl: String! targetUrl: String! description: String - groupId: ID - domainId: ID + groupId: Int + domainId: Int loadingScreen: Boolean } input ShortUrlUpdateInput { - id: ID! + id: Int! shortUrl: String targetUrl: String description: String - groupId: ID - domainId: ID + groupId: Int + domainId: Int loadingScreen: Boolean } diff --git a/api/src/api_graphql/graphql/user.gql b/api/src/api_graphql/graphql/user.gql index c66b38c..411074a 100644 --- a/api/src/api_graphql/graphql/user.gql +++ b/api/src/api_graphql/graphql/user.gql @@ -1,9 +1,3 @@ -type KeycloakUserResult { - totalCount: Int - count: Int - nodes: [KeycloakUser] -} - type KeycloakUser { keycloakId: String username: String @@ -15,8 +9,22 @@ type UserResult { 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 { - id: ID + id: Int keycloakId: String username: String email: String @@ -24,8 +32,10 @@ type User implements DbModel { deleted: Boolean editor: User - createdUtc: String - updatedUtc: String + created: String + updated: String + + history: [UserHistory] } input UserSort { @@ -36,14 +46,20 @@ input UserSort { deleted: SortOrder editor: UserSort - createdUtc: SortOrder - updatedUtc: SortOrder + created: SortOrder + updated: SortOrder } enum UserFuzzyFields { + id keycloakId username email + + deleted + editor + created + updated } input UserFuzzy { @@ -62,23 +78,23 @@ input UserFilter { deleted: BooleanFilter editor: UserFilter - createdUtc: DateFilter - updatedUtc: DateFilter + created: DateFilter + updated: DateFilter } type UserMutation { create(input: UserCreateInput!): User update(input: UserUpdateInput!): User - delete(id: ID!): Boolean - restore(id: ID!): Boolean + delete(id: Int!): Boolean + restore(id: Int!): Boolean } input UserCreateInput { keycloakId: String - roles: [ID] + roles: [Int] } input UserUpdateInput { - id: ID - roles: [ID] + id: Int + roles: [Int] } \ No newline at end of file diff --git a/api/src/api_graphql/graphql/user_setting.gql b/api/src/api_graphql/graphql/user_setting.gql index 2eba639..e51fcac 100644 --- a/api/src/api_graphql/graphql/user_setting.gql +++ b/api/src/api_graphql/graphql/user_setting.gql @@ -1,12 +1,12 @@ type UserSetting implements DbModel { - id: ID + id: Int key: String value: String deleted: Boolean editor: User - createdUtc: String - updatedUtc: String + created: String + updated: String } type UserSettingMutation { diff --git a/api/src/api_graphql/mutations/api_key_mutation.py b/api/src/api_graphql/mutations/api_key_mutation.py index b3bcf5f..f1afe93 100644 --- a/api/src/api_graphql/mutations/api_key_mutation.py +++ b/api/src/api_graphql/mutations/api_key_mutation.py @@ -1,5 +1,3 @@ -from uuid import uuid4 - from api_graphql.abc.mutation_abc import MutationABC from api_graphql.input.api_key_create_input import ApiKeyCreateInput 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.permission.api_key_permission import ApiKeyPermission 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 logger = APILogger(__name__) @@ -44,77 +43,28 @@ class APIKeyMutation(MutationABC): async def resolve_create(obj: ApiKeyCreateInput, *_): logger.debug(f"create api key: {obj.__dict__}") - api_key = ApiKey( - 0, - obj.identifier, - str(uuid4()), - ) + api_key = ApiKey.new(obj.identifier) 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( [ApiKeyPermission(0, api_key.id, x) for x in obj.permissions] ) return api_key - @staticmethod - async def resolve_update(obj: ApiKeyUpdateInput, *_): + async def resolve_update(self, obj: ApiKeyUpdateInput, *_): logger.debug(f"update api key: {input}") api_key = await apiKeyDao.get_by_id(obj.id) - if obj.permissions is not None: - permissions = [ - x for x in await apiKeyPermissionDao.find_by_api_key_id(api_key.id) - ] - - to_delete = ( - permissions - if len(obj.permissions) == 0 - else await apiKeyPermissionDao.find_by( - [ - {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) + await self._resolve_assignments( + obj.get("permissions", []), + api_key, + ApiKeyPermission.api_key_id, + ApiKeyPermission.permission_id, + apiKeyDao, + apiKeyPermissionDao, + ApiKeyPermission, + permissionDao, + ) return api_key diff --git a/api/src/api_graphql/mutations/role_mutation.py b/api/src/api_graphql/mutations/role_mutation.py index 92090da..3993882 100644 --- a/api/src/api_graphql/mutations/role_mutation.py +++ b/api/src/api_graphql/mutations/role_mutation.py @@ -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_update_input import RoleUpdateInput 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_dao import roleDao from data.schemas.permission.role_permission import RolePermission @@ -54,63 +55,23 @@ class RoleMutation(MutationABC): return role - @staticmethod - async def resolve_update(obj: RoleUpdateInput, *_): + async def resolve_update(self, obj: RoleUpdateInput, *_): logger.debug(f"update role: {obj.__dict__}") role = await roleDao.get_by_id(obj.id) role.name = obj.get("name", role.name) role.description = obj.get("description", role.description) await roleDao.update(role) - if obj.permissions is not None: - permissions = [x for x in await rolePermissionDao.get_by_role_id(role.id)] - - to_delete = ( - permissions - if len(obj.permissions) == 0 - else await rolePermissionDao.find_by( - [ - {RolePermission.role_id: role.id}, - { - 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) + await self._resolve_assignments( + obj.get("permissions", []), + role, + RolePermission.role_id, + RolePermission.permission_id, + roleDao, + rolePermissionDao, + RolePermission, + permissionDao, + ) return role diff --git a/api/src/api_graphql/mutations/user_mutation.py b/api/src/api_graphql/mutations/user_mutation.py index 98358a2..bffef7c 100644 --- a/api/src/api_graphql/mutations/user_mutation.py +++ b/api/src/api_graphql/mutations/user_mutation.py @@ -1,10 +1,13 @@ 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.input.user_create_input import UserCreateInput from api_graphql.input.user_update_input import UserUpdateInput from core.logger import APILogger from data.schemas.administration.user import User 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_dao import roleUserDao 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") user = User(0, obj.keycloak_id) - await userDao.create(user) - user = await userDao.get_by_keycloak_id(user.keycloak_id) - await roleUserDao.create_many([RoleUser(0, user.id, x) for x in obj.roles]) + user_id = await userDao.create(user) + user = await userDao.get_by_id(user_id) + await roleUserDao.create_many([RoleUser(0, user.id, x) for x in set(obj.roles)]) return user - @staticmethod - async def resolve_update(obj: UserUpdateInput, *_): + async def resolve_update(self, obj: UserUpdateInput, *_): logger.debug(f"update user: {obj.__dict__}") user = await userDao.get_by_id(obj.id) - if obj.roles is not None: - roles = await roleUserDao.get_by_user_id(user.id) - - to_delete = ( - roles - if len(obj.roles) == 0 - else await roleUserDao.find_by( - [ - {RoleUser.user_id: user.id}, - {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) + await self._resolve_assignments( + obj.get("roles", []), + user, + RoleUser.user_id, + RoleUser.role_id, + userDao, + roleUserDao, + RoleUser, + roleDao, + ) return user @@ -113,6 +80,13 @@ class UserMutation(MutationABC): logger.debug(f"delete user: {id}") user = await userDao.get_by_id(id) 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 @staticmethod diff --git a/api/src/api_graphql/queries/api_key_history_query.py b/api/src/api_graphql/queries/api_key_history_query.py new file mode 100644 index 0000000..b086be5 --- /dev/null +++ b/api/src/api_graphql/queries/api_key_history_query.py @@ -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", + ), + ) diff --git a/api/src/api_graphql/queries/api_key_query.py b/api/src/api_graphql/queries/api_key_query.py index cc0875b..1c7d249 100644 --- a/api/src/api_graphql/queries/api_key_query.py +++ b/api/src/api_graphql/queries/api_key_query.py @@ -1,9 +1,14 @@ 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): 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("key", lambda x, *_: x.key) + self.set_field("permissions", lambda x, *_: x.permissions) + + self.set_history_reference_dao(rolePermissionDao, "apikeyid") diff --git a/api/src/api_graphql/queries/role_history_query.py b/api/src/api_graphql/queries/role_history_query.py new file mode 100644 index 0000000..474949e --- /dev/null +++ b/api/src/api_graphql/queries/role_history_query.py @@ -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", + ), + ) diff --git a/api/src/api_graphql/queries/role_query.py b/api/src/api_graphql/queries/role_query.py index 8f50047..cdac0b3 100644 --- a/api/src/api_graphql/queries/role_query.py +++ b/api/src/api_graphql/queries/role_query.py @@ -1,11 +1,17 @@ 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): 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("description", lambda x, *_: x.description) self.set_field("permissions", lambda x, *_: x.permissions) self.set_field("users", lambda x, *_: x.users) + + self.set_history_reference_dao(rolePermissionDao, "roleid") + self.set_history_reference_dao(roleUserDao, "roleid") diff --git a/api/src/api_graphql/queries/user_history_query.py b/api/src/api_graphql/queries/user_history_query.py new file mode 100644 index 0000000..a122301 --- /dev/null +++ b/api/src/api_graphql/queries/user_history_query.py @@ -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", + ), + ) diff --git a/api/src/api_graphql/queries/user_query.py b/api/src/api_graphql/queries/user_query.py index 50719d4..9b56e5b 100644 --- a/api/src/api_graphql/queries/user_query.py +++ b/api/src/api_graphql/queries/user_query.py @@ -1,11 +1,15 @@ 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): 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("username", lambda x, *_: x.username) self.set_field("email", lambda x, *_: x.email) self.set_field("roles", lambda x, *_: x.roles) + + self.set_history_reference_dao(roleUserDao, "userid") diff --git a/api/src/core/database/abc/data_access_object_abc.py b/api/src/core/database/abc/data_access_object_abc.py index 55867cc..3c98f40 100644 --- a/api/src/core/database/abc/data_access_object_abc.py +++ b/api/src/core/database/abc/data_access_object_abc.py @@ -11,7 +11,7 @@ from core.database.external_data_temp_table_builder import ExternalDataTempTable from core.get_value import get_value from core.logger import DBLogger 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) @@ -51,6 +51,7 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): db_name: str = None, ignore=False, primary_key=False, + aliases: list[str] = None, ): """ Add an attribute for db and object mapping to the data access object @@ -59,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 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 list[str] aliases: List of aliases for the attribute name :return: """ if isinstance(attr_name, property): @@ -72,11 +74,20 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): db_name = attr_name.lower().replace("_", "") 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: self.__primary_key = db_name self.__primary_key_type = attr_type if attr_type in [datetime, datetime.datetime]: + self.__date_attributes.add(attr_name) self.__date_attributes.add(db_name) def reference( @@ -156,9 +167,42 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): return 0 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]: 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: return [] @@ -278,6 +322,35 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): raise ValueError("More than one result found") 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: allowed_fields = [ x for x in self.__attributes.keys() if x not in self.__ignored_attributes @@ -499,20 +572,41 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): take: int = None, skip: int = None, ) -> str: + filter_conditions = [] + sort_conditions = [] + + external_table_deps = [] query = f"SELECT {self._table_name}.* FROM {self._table_name}" for join in self.__joins: query += f" {self.__joins[join]}" + # Collect dependencies from filters if filters is not None and (not isinstance(filters, list) or len(filters) > 0): - conditions, external_table_deps = await 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): + 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 ) - query += f" WHERE {conditions}" + + # 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 {self._build_order_by(sorts)}" + query += f" ORDER BY {sort_conditions}" + if take is not None: query += f" LIMIT {take}" + if skip is not None: query += f" OFFSET {skip}" @@ -737,8 +831,10 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): :param value: :return: """ - if db_name in self.__date_attributes: - db_name = f"TO_CHAR({db_name}, 'DD.MM.YYYY HH24:MI:SS.US')" + attr = db_name.split(".")[-1] + + if attr in self.__date_attributes: + db_name = self._attr_from_date_to_char(db_name) sql_value = self._get_value_sql(value) if operator == "equal": @@ -774,12 +870,17 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): else: 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 :param sorts: :return: """ + external_field_table_deps = [] if not isinstance(sorts, list): sorts = [sorts] @@ -791,35 +892,38 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): if attr in self.__foreign_tables: foreign_table = self.__foreign_tables[attr] - sort_clauses.extend( - self._build_foreign_order_by(foreign_table, direction) + f_sorts, eftd = self._build_foreign_order_by( + foreign_table, direction ) + if eftd: + external_field_table_deps.extend(eftd) + + sort_clauses.extend(f_sorts) continue - match attr: - case "createdUtc": - attr = "created" - case "updatedUtc": - attr = "updated" - - if attr.endswith("Utc") and attr.split("Utc")[0].lower() in [ - "created", - "updated", - ]: - attr = attr.replace("Utc", "") - - 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) + else: + db_name = self.__db_names[attr] 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 :param table: Foreign table name :param direction: Sort direction :return: List of order by clauses """ + external_field_table_deps = [] sort_clauses = [] for attr, sub_direction in direction.items(): if isinstance(attr, property): @@ -827,15 +931,25 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): if attr in self.__foreign_tables: foreign_table = self.__foreign_tables[attr] - sort_clauses.extend( - self._build_foreign_order_by(foreign_table, sub_direction) - ) + f_sorts, eftd = self._build_foreign_order_by(foreign_table, direction) + if eftd: + external_field_table_deps.extend(eftd) + + sort_clauses.extend(f_sorts) 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()}") - return sort_clauses + return sort_clauses, external_field_table_deps @staticmethod async def _get_editor_id(obj: T_DBM): diff --git a/api/src/core/database/abc/db_join_model_abc.py b/api/src/core/database/abc/db_join_model_abc.py new file mode 100644 index 0000000..cc79592 --- /dev/null +++ b/api/src/core/database/abc/db_join_model_abc.py @@ -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) diff --git a/api/src/core/database/abc/db_model_dao_abc.py b/api/src/core/database/abc/db_model_dao_abc.py index 62f32d1..9d8d638 100644 --- a/api/src/core/database/abc/db_model_dao_abc.py +++ b/api/src/core/database/abc/db_model_dao_abc.py @@ -15,5 +15,5 @@ class DbModelDaoABC[T_DBM](DataAccessObjectABC[T_DBM]): self.attribute(DbModelABC.id, int, ignore=True) self.attribute(DbModelABC.deleted, bool) self.attribute(DbModelABC.editor_id, int, ignore=True) - self.attribute(DbModelABC.created, datetime, "createdutc", ignore=True) - self.attribute(DbModelABC.updated, datetime, "updatedutc", ignore=True) + self.attribute(DbModelABC.created, datetime, "created", ignore=True) + self.attribute(DbModelABC.updated, datetime, "updated", ignore=True) diff --git a/api/src/core/get_value.py b/api/src/core/get_value.py index e0022d3..39b34e8 100644 --- a/api/src/core/get_value.py +++ b/api/src/core/get_value.py @@ -4,11 +4,11 @@ from core.typing import T def get_value( - source: dict, - key: str, - cast_type: Type[T], - default: Optional[T] = None, - list_delimiter: str = ",", + source: dict, + key: str, + cast_type: Type[T], + default: Optional[T] = None, + list_delimiter: str = ",", ) -> Optional[T]: """ Get value from source dictionary and cast it to a specified type. @@ -26,9 +26,14 @@ def get_value( value = source[key] if isinstance( - value, - cast_type if not hasattr(cast_type, "__origin__") else cast_type.__origin__, + value, + 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 try: @@ -36,11 +41,11 @@ def get_value( return value.lower() in ["true", "1"] if ( - cast_type if not hasattr(cast_type, "__origin__") else cast_type.__origin__ + cast_type if not hasattr(cast_type, "__origin__") else cast_type.__origin__ ) == list: if ( - not (value.startswith("[") and value.endswith("]")) - and list_delimiter not in value + not (value.startswith("[") and value.endswith("]")) + and list_delimiter not in value ): raise ValueError( "List values must be enclosed in square brackets or use a delimiter." diff --git a/api/src/data/scripts/2025-04-03-19-45-fix-levenshtein.sql b/api/src/data/scripts/2025-04-03-19-45-fix-levenshtein.sql new file mode 100644 index 0000000..bbc52e5 --- /dev/null +++ b/api/src/data/scripts/2025-04-03-19-45-fix-levenshtein.sql @@ -0,0 +1,2 @@ +DROP EXTENSION IF EXISTS fuzzystrmatch; +CREATE EXTENSION fuzzystrmatch SCHEMA public; \ No newline at end of file diff --git a/api/src/data/scripts/2025-04-07-12-15-rename-utc-fields.sql b/api/src/data/scripts/2025-04-07-12-15-rename-utc-fields.sql new file mode 100644 index 0000000..edb7726 --- /dev/null +++ b/api/src/data/scripts/2025-04-07-12-15-rename-utc-fields.sql @@ -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; \ No newline at end of file diff --git a/api/src/data/scripts/2025-04-07-18-25-rename-utc-fields-update-trigger.sql b/api/src/data/scripts/2025-04-07-18-25-rename-utc-fields-update-trigger.sql new file mode 100644 index 0000000..bb86572 --- /dev/null +++ b/api/src/data/scripts/2025-04-07-18-25-rename-utc-fields-update-trigger.sql @@ -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; \ No newline at end of file diff --git a/api/src/data/scripts/2025-04-13-08-45-fix-join-tables.sql b/api/src/data/scripts/2025-04-13-08-45-fix-join-tables.sql new file mode 100644 index 0000000..2e7e92c --- /dev/null +++ b/api/src/data/scripts/2025-04-13-08-45-fix-join-tables.sql @@ -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);