From 60d2faf2f6e605a429c0dc7bb29eecc10c7e2bbd Mon Sep 17 00:00:00 2001 From: Sven Heidemann Date: Sat, 15 Mar 2025 10:49:27 +0100 Subject: [PATCH 01/10] Fixed api key edit --- .../api_graphql/mutations/api_key_mutation.py | 2 +- .../data/schemas/administration/api_key.py | 2 +- .../permission/api_key_permission_dao.py | 8 +- api/src/data/seeder/permission_seeder.py | 2 +- web/src/app/model/entities/api-key.ts | 4 +- .../api-keys/api-keys.data.service.ts | 16 ++- .../api-key-form-page.component.html | 120 +++++++++--------- .../form-page/api-key-form-page.component.ts | 23 ++-- 8 files changed, 94 insertions(+), 83 deletions(-) diff --git a/api/src/api_graphql/mutations/api_key_mutation.py b/api/src/api_graphql/mutations/api_key_mutation.py index ff5fb06..b3bcf5f 100644 --- a/api/src/api_graphql/mutations/api_key_mutation.py +++ b/api/src/api_graphql/mutations/api_key_mutation.py @@ -63,7 +63,7 @@ class APIKeyMutation(MutationABC): if obj.permissions is not None: permissions = [ - x for x in await apiKeyPermissionDao.get_by_role_id(api_key.id) + x for x in await apiKeyPermissionDao.find_by_api_key_id(api_key.id) ] to_delete = ( diff --git a/api/src/data/schemas/administration/api_key.py b/api/src/data/schemas/administration/api_key.py index 944f844..3e3d9a2 100644 --- a/api/src/data/schemas/administration/api_key.py +++ b/api/src/data/schemas/administration/api_key.py @@ -40,7 +40,7 @@ class ApiKey(DbModelABC): return [ 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: diff --git a/api/src/data/schemas/permission/api_key_permission_dao.py b/api/src/data/schemas/permission/api_key_permission_dao.py index 77da9bb..0852c24 100644 --- a/api/src/data/schemas/permission/api_key_permission_dao.py +++ b/api/src/data/schemas/permission/api_key_permission_dao.py @@ -15,23 +15,23 @@ class ApiKeyPermissionDao(DbModelDaoABC[ApiKeyPermission]): self.attribute(ApiKeyPermission.api_key_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 ) -> list[ApiKeyPermission]: f = [{ApiKeyPermission.api_key_id: api_key_id}] if not with_deleted: 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 ) -> list[ApiKeyPermission]: f = [{ApiKeyPermission.permission_id: permission_id}] if not with_deleted: f.append({ApiKeyPermission.deleted: False}) - return await self.get_by(f) + return await self.find_by(f) apiKeyPermissionDao = ApiKeyPermissionDao() diff --git a/api/src/data/seeder/permission_seeder.py b/api/src/data/seeder/permission_seeder.py index 9fabe4f..72ec6e8 100644 --- a/api/src/data/seeder/permission_seeder.py +++ b/api/src/data/seeder/permission_seeder.py @@ -71,7 +71,7 @@ class PermissionSeeder(DataSeederABC): if admin_api_key is None: 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 ) to_assign = [ diff --git a/web/src/app/model/entities/api-key.ts b/web/src/app/model/entities/api-key.ts index 987f5c0..3b8c69a 100644 --- a/web/src/app/model/entities/api-key.ts +++ b/web/src/app/model/entities/api-key.ts @@ -1,5 +1,5 @@ -import { DbModel } from "src/app/model/entities/db-model"; -import { Permission } from "src/app/model/entities/role"; +import { DbModel } from 'src/app/model/entities/db-model'; +import { Permission } from 'src/app/model/entities/role'; export interface ApiKey extends DbModel { identifier?: string; diff --git a/web/src/app/modules/admin/administration/api-keys/api-keys.data.service.ts b/web/src/app/modules/admin/administration/api-keys/api-keys.data.service.ts index 3c0c1cd..24b6bf9 100644 --- a/web/src/app/modules/admin/administration/api-keys/api-keys.data.service.ts +++ b/web/src/app/modules/admin/administration/api-keys/api-keys.data.service.ts @@ -91,14 +91,16 @@ export class ApiKeysDataService .query<{ apiKeys: QueryResult }>({ query: gql` query getApiKey($id: Int) { - apiKey(filter: { id: { equal: $id } }) { - id - identifier - permissions { - name - } + apiKeys(filter: { id: { equal: $id } }) { + nodes { + id + identifier + permissions { + name + } - ...DB_MODEL + ...DB_MODEL + } } } diff --git a/web/src/app/modules/admin/administration/api-keys/form-page/api-key-form-page.component.html b/web/src/app/modules/admin/administration/api-keys/form-page/api-key-form-page.component.html index a4f5628..8cdc353 100644 --- a/web/src/app/modules/admin/administration/api-keys/form-page/api-key-form-page.component.html +++ b/web/src/app/modules/admin/administration/api-keys/form-page/api-key-form-page.component.html @@ -1,63 +1,65 @@ - -

- {{ 'common.api_key' | translate }} - {{ - (isUpdate ? 'sidebar.header.update' : 'sidebar.header.create') - | translate - }} -

-
+ *ngIf="node" + [formGroup]="form" + [isUpdate]="isUpdate" + (onSave)="save()" + (onClose)="close()"> + +

+ {{ 'common.role' | translate }} + {{ + (isUpdate ? 'sidebar.header.update' : 'sidebar.header.create') + | translate + }} +

+
- -
-

{{ 'common.id' | translate }}

- -
-
-

{{ 'common.identifier' | translate }}

- -
-
- -
-
-
-
- -
- -
-
- -
- - - > - -
+ +
+

{{ 'common.id' | translate }}

+
-
-
- +
+

{{ 'common.identifier' | translate }}

+ +
+
+ +
+
+
+
+ +
+ +
+
+ +
+
+ + + +
+
+

+ {{ permission.description }} +

+
+
+
+
+
+ diff --git a/web/src/app/modules/admin/administration/api-keys/form-page/api-key-form-page.component.ts b/web/src/app/modules/admin/administration/api-keys/form-page/api-key-form-page.component.ts index 5b2d941..84e6ce1 100644 --- a/web/src/app/modules/admin/administration/api-keys/form-page/api-key-form-page.component.ts +++ b/web/src/app/modules/admin/administration/api-keys/form-page/api-key-form-page.component.ts @@ -11,6 +11,7 @@ import { ApiKeyUpdateInput, } from 'src/app/model/entities/api-key'; import { ApiKeysDataService } from 'src/app/modules/admin/administration/api-keys/api-keys.data.service'; +import { TranslateService } from '@ngx-translate/core'; @Component({ selector: 'app-api-key-form-page', @@ -26,7 +27,10 @@ export class ApiKeyFormPageComponent extends FormPageBase< permissionGroups: { [key: string]: Permission[] } = {}; allPermissions: Permission[] = []; - constructor(private toast: ToastService) { + constructor( + private toast: ToastService, + private translate: TranslateService + ) { super(); this.initializePermissions().then(() => { if (!this.nodeId) { @@ -36,12 +40,10 @@ export class ApiKeyFormPageComponent extends FormPageBase< return; } - this.dataService - .load([{ id: { equal: this.nodeId } }]) - .subscribe(apiKey => { - this.node = apiKey.nodes[0]; - this.setForm(this.node); - }); + this.dataService.loadById(this.nodeId).subscribe(apiKey => { + this.node = apiKey; + this.setForm(this.node); + }); }); } @@ -49,7 +51,12 @@ export class ApiKeyFormPageComponent extends FormPageBase< const permissions = await firstValueFrom( this.dataService.getAllPermissions() ); - this.allPermissions = permissions; + this.allPermissions = permissions.map(x => { + const key = `permission_descriptions.${x.name}`; + const description = this.translate.instant(key); + x.description = description === key ? undefined : description; + return x; + }); this.permissionGroups = permissions.reduce( (acc, p) => { const group = p.name.includes('.') ? p.name.split('.')[0] : p.name; -- 2.45.2 From e68b10933f35b486148bb3311c7c2b39bf5b38ec Mon Sep 17 00:00:00 2001 From: Sven Heidemann Date: Sat, 15 Mar 2025 19:30:00 +0100 Subject: [PATCH 02/10] Fixed dev build --- .gitea/workflows/build dev.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/build dev.yaml b/.gitea/workflows/build dev.yaml index a0a930e..1ff30d3 100644 --- a/.gitea/workflows/build dev.yaml +++ b/.gitea/workflows/build dev.yaml @@ -17,7 +17,7 @@ jobs: - name: Get Date and Build Number run: | - git fetch --tags + git fetch git tag DATE=$(date +'%Y.%m.%d') TAG_COUNT=$(git tag -l "${DATE}.*" | wc -l) -- 2.45.2 From ab3f0b07c2065e47113188c8bcf1f660e40e1271 Mon Sep 17 00:00:00 2001 From: edraft Date: Fri, 21 Mar 2025 16:21:46 +0100 Subject: [PATCH 03/10] Some improvements from lan-maestro --- api/src/api_graphql/query.py | 2 +- .../database/abc/data_access_object_abc.py | 294 +++++++++----- .../external_data_temp_table_builder.py | 72 ++++ .../menu-bar/menu-bar.component.html | 45 ++- .../components/menu-bar/menu-bar.component.ts | 33 +- .../components/table/table.component.html | 376 ++++++++++-------- .../components/table/table.component.ts | 26 ++ .../shared/components/table/table.model.ts | 5 + web/src/app/service/config.service.ts | 8 +- web/src/app/service/gui.service.ts | 4 + 10 files changed, 579 insertions(+), 286 deletions(-) create mode 100644 api/src/core/database/external_data_temp_table_builder.py diff --git a/api/src/api_graphql/query.py b/api/src/api_graphql/query.py index b3c01a1..6e999f6 100644 --- a/api/src/api_graphql/query.py +++ b/api/src/api_graphql/query.py @@ -193,7 +193,7 @@ class Query(QueryABC): if "key" in kwargs: 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}) 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 98f4a62..55867cc 100644 --- a/api/src/core/database/abc/data_access_object_abc.py +++ b/api/src/core/database/abc/data_access_object_abc.py @@ -7,6 +7,7 @@ from typing import Generic, Optional, Union, TypeVar, Any, Type from core.const import DATETIME_FORMAT from core.database.abc.db_model_abc import DbModelABC 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.logger import DBLogger from core.string import camel_to_snake @@ -16,6 +17,7 @@ T_DBM = TypeVar("T_DBM", bound=DbModelABC) class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): + _external_fields: dict[str, ExternalDataTempTableBuilder] = {} @abstractmethod 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.__foreign_tables: dict[str, str] = {} + self.__foreign_table_keys: dict[str, str] = {} self.__date_attributes: set[str] = set() self.__ignored_attributes: set[str] = set() @@ -42,12 +45,12 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): return self._table_name def attribute( - self, - attr_name: Attribute, - attr_type: type, - db_name: str = None, - ignore=False, - primary_key=False, + self, + attr_name: Attribute, + attr_type: type, + db_name: str = None, + ignore=False, + primary_key=False, ): """ Add an attribute for db and object mapping to the data access object @@ -77,21 +80,20 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): self.__date_attributes.add(db_name) def reference( - self, - attr: Attribute, - primary_attr: Attribute, - foreign_attr: Attribute, - table_name: str, + self, + attr: Attribute, + primary_attr: Attribute, + foreign_attr: Attribute, + table_name: str, ): """ 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 foreign_attr: Name of the foreign key in the object :param str table_name: Name of the table to reference :return: """ - if table_name == self._table_name: - return if isinstance(attr, property): attr = attr.fget.__name__ @@ -105,11 +107,18 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): foreign_attr = foreign_attr.lower().replace("_", "") + self.__foreign_table_keys[attr] = foreign_attr + if table_name == self._table_name: + return + self.__joins[foreign_attr] = ( f"LEFT JOIN {table_name} ON {table_name}.{primary_attr} = {self._table_name}.{foreign_attr}" ) 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: """ Convert a result from the database to an object @@ -136,7 +145,11 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): query += f" {self.__joins[join]}" 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) if len(result) == 0: @@ -168,11 +181,11 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): return self.to_object(result[0]) async def get_by( - self, - filters: AttributeFilters = None, - sorts: AttributeSorts = None, - take: int = None, - skip: int = None, + self, + filters: AttributeFilters = None, + sorts: AttributeSorts = None, + take: int = None, + skip: int = None, ) -> list[T_DBM]: """ Get all objects by the given filters @@ -185,7 +198,7 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): :raises ValueError: When no result is found """ 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: raise ValueError("No result found") @@ -193,11 +206,11 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): return [self.to_object(x) for x in result] async def get_single_by( - self, - filters: AttributeFilters = None, - sorts: AttributeSorts = None, - take: int = None, - skip: int = None, + self, + filters: AttributeFilters = None, + sorts: AttributeSorts = None, + take: int = None, + skip: int = None, ) -> T_DBM: """ Get a single object by the given filters @@ -218,11 +231,11 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): return result[0] async def find_by( - self, - filters: AttributeFilters = None, - sorts: AttributeSorts = None, - take: int = None, - skip: int = None, + self, + filters: AttributeFilters = None, + sorts: AttributeSorts = None, + take: int = None, + skip: int = None, ) -> list[Optional[T_DBM]]: """ Find all objects by the given filters @@ -234,7 +247,7 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): :rtype: list[Optional[T_DBM]] """ 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: return [] @@ -242,11 +255,11 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): return [self.to_object(x) for x in result] async def find_single_by( - self, - filters: AttributeFilters = None, - sorts: AttributeSorts = None, - take: int = None, - skip: int = None, + self, + filters: AttributeFilters = None, + sorts: AttributeSorts = None, + take: int = None, + skip: int = None, ) -> Optional[T_DBM]: """ Find a single object by the given filters @@ -346,7 +359,7 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): await self._db.execute(query) async def _build_delete_statement( - self, obj: T_DBM, hard_delete: bool = False + self, obj: T_DBM, hard_delete: bool = False ) -> str: if hard_delete: return f""" @@ -424,7 +437,7 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): return "NULL" if isinstance(value, Enum): - return str(value.value) + return f"'{value.value}'" if isinstance(value, bool): return "true" if value else "false" @@ -461,77 +474,115 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): return cast_type(value) - def _build_conditional_query( - self, - filters: AttributeFilters = None, - sorts: AttributeSorts = None, - take: int = None, - skip: int = None, + 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, + filters: AttributeFilters = None, + sorts: AttributeSorts = None, + take: int = None, + skip: int = None, ) -> str: query = f"SELECT {self._table_name}.* FROM {self._table_name}" - for join in self.__joins: query += f" {self.__joins[join]}" 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 + ) + query += f" WHERE {conditions}" if sorts is not None and (not isinstance(sorts, list) or len(sorts) > 0): query += f" ORDER BY {self._build_order_by(sorts)}" if take is not None: query += f" LIMIT {take}" if skip is not None: query += f" OFFSET {skip}" + + if not query.endswith(";"): + 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 :param filters: - :return: + :return: SQL conditions & External field table dependencies """ + external_field_table_deps = [] if not isinstance(filters, list): filters = [filters] conditions = [] for f in filters: + f_conditions = [] + for attr, values in f.items(): if isinstance(attr, property): attr = attr.fget.__name__ if attr in self.__foreign_tables: foreign_table = self.__foreign_tables[attr] - conditions.extend( - self._build_foreign_conditions(foreign_table, values) - ) + cons, eftd = self._build_foreign_conditions(foreign_table, values) + if eftd: + external_field_table_deps.extend(eftd) + + f_conditions.extend(cons) continue if attr == "fuzzy": - conditions.append( - " OR ".join( - 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), - ) - ) + self._handle_fuzzy_filter_conditions( + f_conditions, external_field_table_deps, values ) 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): for operator, value in values.items(): - conditions.append( - self._build_condition( - f"{self._table_name}.{db_name}", operator, value - ) + f_conditions.append( + self._build_condition(f"{db_name}", operator, value) ) elif isinstance(values, list): sub_conditions = [] @@ -539,38 +590,42 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): if isinstance(value, dict): for operator, val in value.items(): sub_conditions.append( - self._build_condition( - f"{self._table_name}.{db_name}", operator, val - ) + self._build_condition(f"{db_name}", operator, val) ) else: sub_conditions.append( self._get_value_validation_sql(db_name, value) ) - conditions.append(f"({' OR '.join(sub_conditions)})") + f_conditions.append(f"({' OR '.join(sub_conditions)})") 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( - self, fields: list[str], term: str, threshold: int = 10 + fields: list[str], term: str, threshold: int = 10 ) -> list[str]: conditions = [] for field in fields: conditions.append( - f"levenshtein({field}, '{term}') <= {threshold}" + f"levenshtein({field}::TEXT, '{term}') <= {threshold}" ) # Adjust the threshold as needed 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 :param table: Foreign table name :param values: Filter values - :return: List of conditions + :return: List of conditions, List of external field tables """ + external_field_table_deps = [] conditions = [] for attr, sub_values in values.items(): if isinstance(attr, property): @@ -578,25 +633,43 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): if attr in self.__foreign_tables: foreign_table = self.__foreign_tables[attr] - conditions.extend( - self._build_foreign_conditions(foreign_table, sub_values) + sub_conditions, eftd = self._build_foreign_conditions( + 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 - 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): for operator, value in sub_values.items(): 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): sub_conditions = [] for value in sub_values: if isinstance(value, dict): for operator, val in value.items(): 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: sub_conditions.append( @@ -606,14 +679,55 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): else: 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): 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": - return f"{self._table_name}.{field} IS NULL" - return f"{self._table_name}.{field} = {value}" + return f"{field_selector} IS NULL" + return f"{field_selector} = {value}" def _build_condition(self, db_name: str, operator: str, value: Any) -> str: """ diff --git a/api/src/core/database/external_data_temp_table_builder.py b/api/src/core/database/external_data_temp_table_builder.py new file mode 100644 index 0000000..dea48cc --- /dev/null +++ b/api/src/core/database/external_data_temp_table_builder.py @@ -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}; + """ + ) diff --git a/web/src/app/modules/shared/components/menu-bar/menu-bar.component.html b/web/src/app/modules/shared/components/menu-bar/menu-bar.component.html index 7c6d6c0..632f922 100644 --- a/web/src/app/modules/shared/components/menu-bar/menu-bar.component.html +++ b/web/src/app/modules/shared/components/menu-bar/menu-bar.component.html @@ -1,12 +1,37 @@ diff --git a/web/src/app/modules/shared/components/menu-bar/menu-bar.component.ts b/web/src/app/modules/shared/components/menu-bar/menu-bar.component.ts index 98a3110..8ce2683 100644 --- a/web/src/app/modules/shared/components/menu-bar/menu-bar.component.ts +++ b/web/src/app/modules/shared/components/menu-bar/menu-bar.component.ts @@ -1,11 +1,32 @@ -import { Component, Input } from "@angular/core"; -import { MenuElement } from "src/app/model/view/menu-element"; +import { Component, EventEmitter, Input, OnDestroy } from '@angular/core'; +import { MenuElement } from 'src/app/model/view/menu-element'; +import { GuiService } from 'src/app/service/gui.service'; +import { takeUntil } from 'rxjs/operators'; @Component({ - selector: "app-menu-bar", - templateUrl: "./menu-bar.component.html", - styleUrl: "./menu-bar.component.scss", + selector: 'app-menu-bar', + templateUrl: './menu-bar.component.html', + styleUrl: './menu-bar.component.scss', }) -export class MenuBarComponent { +export class MenuBarComponent implements OnDestroy { @Input() elements: MenuElement[] = []; + protected theme = ''; + private unsubscribe$ = new EventEmitter(); + + get visibleElements() { + return this.elements.filter(e => e.visible !== false); + } + + constructor(private guiService: GuiService) { + this.guiService.theme$ + .pipe(takeUntil(this.unsubscribe$)) + .subscribe(theme => { + this.theme = theme; + }); + } + + ngOnDestroy() { + this.unsubscribe$.next(); + this.unsubscribe$.complete(); + } } diff --git a/web/src/app/modules/shared/components/table/table.component.html b/web/src/app/modules/shared/components/table/table.component.html index 2ac8766..9f23d16 100644 --- a/web/src/app/modules/shared/components/table/table.component.html +++ b/web/src/app/modules/shared/components/table/table.component.html @@ -1,147 +1,172 @@ - -
-
-
-
- {{ skip + 1 }} {{ 'table.to' | translate }} - {{ skip + rows.length }} {{ 'table.of' | translate }} - {{ totalCount }} - - {{ countHeaderTranslation | translate }} -
-
-
-
- - - + +
+
+
+
+ {{ skip + 1 }} {{ 'table.to' | translate }} + {{ skip + rows.length }} {{ 'table.of' | translate }} + {{ totalCount }} + + {{ countHeaderTranslation | translate }} +
+
+
+
+ + + - - - -
-
-
- - - - -
- {{ column.translationKey | translate }} - + (click)="toggleShowDeleted()"> + + + +
- - - + - - + + -
- - - - - -
- - - - - - - - + {{ column.translationKey | translate }} + +
+ + + + + + +
+ + +
+ + + + + + +
+ + + + + +
+ + + +
+ + + + - + {{ r.value | customDate: 'dd.MM.yyyy HH:mm:ss' }} - + @@ -151,56 +176,57 @@ - + {{ r.value | protect }} - + - {{ r.value }} - - - - - + - - - - - - - - - - - {{ 'table.no_entries_found' | translate }} - - - - + + + + + + + + + + + {{ 'table.no_entries_found' | translate }} + + + +
diff --git a/web/src/app/modules/shared/components/table/table.component.ts b/web/src/app/modules/shared/components/table/table.component.ts index b5b89f6..7b6e17c 100644 --- a/web/src/app/modules/shared/components/table/table.component.ts +++ b/web/src/app/modules/shared/components/table/table.component.ts @@ -10,6 +10,7 @@ import { import { ResolvedTableColumn, TableColumn, + TableColumnFuzzyFilter, TableRequireAnyPermissions, } from 'src/app/modules/shared/components/table/table.model'; import { TableLazyLoadEvent } from 'primeng/table'; @@ -46,6 +47,7 @@ export class TableComponent implements OnInit { } @Input({ required: true }) columns: TableColumn[] = []; + @Input() fuzzyFilter?: TableColumnFuzzyFilter; get visibleColumns() { return this.columns.filter(x => x.visible); @@ -70,6 +72,8 @@ export class TableComponent implements OnInit { @Input() sort: Sort[] = []; @Output() sortChange = new EventEmitter(); + @Input() rowDisabled: (row: T) => boolean = () => false; + // eslint-disable-next-line @angular-eslint/no-output-native @Output() load = new EventEmitter(); @Input() loading = true; @@ -92,6 +96,10 @@ export class TableComponent implements OnInit { @ContentChild(CustomRowActionsDirective, { read: TemplateRef }) customRowActions!: TemplateRef; + get customRowActionsVisible() { + return !!this.customRowActions; + } + protected resolvedColumns: ResolvedTableColumn[][] = []; protected filterForm!: FormGroup; protected defaultFilterForm!: FormGroup; @@ -244,6 +252,13 @@ export class TableComponent implements OnInit { buildDefaultFilterForm() { this.defaultFilterForm = new FormGroup({}); + if (this.fuzzyFilter) { + this.defaultFilterForm.addControl( + 'fuzzy', + new FormControl(undefined) + ); + } + this.columns .filter(x => x.filterable) .forEach(x => { @@ -301,6 +316,17 @@ export class TableComponent implements OnInit { changes[key] !== '' ) .map(key => { + if (key === 'fuzzy') { + if (!this.fuzzyFilter) return {}; + return { + fuzzy: { + term: changes[key], + fields: this.fuzzyFilter?.columns, + threshold: this.fuzzyFilter?.threshold, + }, + }; + } + const column = this.columns.find(x => x.name === key); if (!column || !column.filterable) { return {}; diff --git a/web/src/app/modules/shared/components/table/table.model.ts b/web/src/app/modules/shared/components/table/table.model.ts index a0b3f1b..f21eb5e 100644 --- a/web/src/app/modules/shared/components/table/table.model.ts +++ b/web/src/app/modules/shared/components/table/table.model.ts @@ -22,6 +22,11 @@ export interface TableColumn { class?: string; } +export interface TableColumnFuzzyFilter { + columns: string[]; + threshold?: number; +} + export interface ResolvedTableColumn { value: TableColumnValue; data: T; diff --git a/web/src/app/service/config.service.ts b/web/src/app/service/config.service.ts index 2a853ed..16a7376 100644 --- a/web/src/app/service/config.service.ts +++ b/web/src/app/service/config.service.ts @@ -15,8 +15,8 @@ export class ConfigService { imprintURL: '', themes: [ { - label: 'Maxlan', - name: 'maxlan', + label: 'Open-redirect', + name: 'open-redirect', }, ], loadingScreen: { @@ -51,8 +51,8 @@ export class ConfigService { this.settings = settings; if (this.settings.themes.length === 0) { this.settings.themes.push({ - label: 'Maxlan', - name: 'maxlan', + label: 'Open-redirect', + name: 'open-redirect', }); } resolve(); diff --git a/web/src/app/service/gui.service.ts b/web/src/app/service/gui.service.ts index 10fae79..c84c5a6 100644 --- a/web/src/app/service/gui.service.ts +++ b/web/src/app/service/gui.service.ts @@ -29,6 +29,10 @@ export class GuiService { this.sidebarService.hide(); } }); + + this.theme$.subscribe(theme => { + console.warn('theme changed', theme); + }); } @HostListener('window:resize', ['$event']) -- 2.45.2 From 347f8486af8fdc78a04ea40bcc56484cf68d1018 Mon Sep 17 00:00:00 2001 From: edraft Date: Fri, 18 Apr 2025 02:26:59 +0200 Subject: [PATCH 04/10] 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); -- 2.45.2 From 1824ff7564634dfbf63b4e4a3ab8e382d3de5585 Mon Sep 17 00:00:00 2001 From: edraft Date: Fri, 18 Apr 2025 10:46:06 +0200 Subject: [PATCH 05/10] Updated history and frontend --- api/src/api/route.py | 25 +- api/src/api/route_api_key_extension.py | 27 ++ api/src/api/route_user_extension.py | 5 +- .../abc/db_history_model_query_abc.py | 3 +- api/src/api_graphql/field/dao_field.py | 8 +- .../api_graphql/field/dao_field_builder.py | 10 +- .../field/mutation_field_builder.py | 3 + api/src/api_graphql/graphql/domain.gql | 14 + api/src/api_graphql/graphql/group.gql | 14 + api/src/api_graphql/graphql/permission.gql | 17 +- api/src/api_graphql/graphql/short_url.gql | 18 + api/src/api_graphql/graphql/subscription.gql | 1 + .../queries/domain_history_query.py | 22 + api/src/api_graphql/queries/domain_query.py | 5 +- .../queries/group_history_query.py | 47 ++ api/src/api_graphql/queries/group_query.py | 6 +- .../queries/short_url_history_query.py | 14 + .../api_graphql/queries/short_url_query.py | 3 +- api/src/api_graphql/subscription.py | 6 + .../database/abc/data_access_object_abc.py | 100 ++--- api/src/core/get_value.py | 20 +- api/src/redirector.py | 6 +- web/src/app/app.component.html | 65 +-- web/src/app/app.component.ts | 29 +- web/src/app/app.module.ts | 29 +- .../error/not-found/not-found.component.ts | 3 +- .../server-unavailable.component.ts | 7 +- .../components/footer/footer.component.html | 19 +- .../components/footer/footer.component.scss | 16 - .../footer/footer.component.spec.ts | 40 +- .../app/components/footer/footer.component.ts | 18 +- .../components/header/header.component.html | 125 +++--- .../header/header.component.spec.ts | 26 +- .../app/components/header/header.component.ts | 32 +- .../app/components/home/home.component.html | 1 + .../components/home/home.component.spec.ts | 24 -- web/src/app/components/home/home.component.ts | 8 +- .../sidebar/sidebar.component.spec.ts | 26 +- .../components/sidebar/sidebar.component.ts | 18 +- .../spinner/spinner.component.spec.ts | 8 +- .../components/spinner/spinner.component.ts | 12 +- web/src/app/core/base/error-component-base.ts | 9 + web/src/app/core/base/form-page-base.ts | 4 +- web/src/app/core/base/page-base.ts | 1 - .../base/page-with-history.data.service.ts | 49 +++ web/src/app/core/base/page.columns.ts | 27 +- web/src/app/core/guard/auth.guard.ts | 39 +- web/src/app/core/guard/permission.guard.ts | 28 +- web/src/app/core/token.interceptor.ts | 46 +- web/src/app/model/auth/permissionsEnum.ts | 4 + web/src/app/model/auth/user.ts | 7 +- web/src/app/model/entities/api-key.ts | 4 +- web/src/app/model/entities/db-model.ts | 16 +- web/src/app/model/entities/role.ts | 6 +- web/src/app/model/graphql/db-model.query.ts | 20 +- web/src/app/model/graphql/editor.query.ts | 2 +- .../app/model/graphql/filter/sort.model.ts | 4 +- web/src/app/model/view/menu-element.ts | 1 + web/src/app/model/view/themes.enum.ts | 2 +- .../administration/administration.module.ts | 5 + .../api-keys/api-keys.data.service.ts | 71 ++- .../api-keys/api-keys.module.ts | 11 +- .../api-keys/api-keys.page.html | 1 + .../api-key-form-page.component.html | 116 +++-- .../form-page/api-key-form-page.component.ts | 23 +- .../api-keys/history/history.component.html | 3 + .../api-keys/history/history.component.scss | 0 .../history/history.component.spec.ts | 65 +++ .../api-keys/history/history.component.ts | 18 + .../feature-flags/feature-flags.page.ts | 23 +- .../roles/history/history.component.html | 3 + .../roles/history/history.component.scss | 0 .../roles/history/history.component.spec.ts | 65 +++ .../roles/history/history.component.ts | 18 + .../administration/roles/roles.columns.ts | 18 +- .../roles/roles.data.service.ts | 65 ++- .../administration/roles/roles.module.ts | 11 +- .../administration/roles/roles.page.html | 1 + .../administration/settings/settings.page.ts | 23 +- .../form-page/user-form-page.component.html | 25 +- .../form-page/user-form-page.component.ts | 2 +- .../users/history/history.component.html | 3 + .../users/history/history.component.scss | 0 .../users/history/history.component.spec.ts | 65 +++ .../users/history/history.component.ts | 18 + .../administration/users/users.columns.ts | 14 +- .../users/users.data.service.ts | 66 ++- .../administration/users/users.module.ts | 11 +- .../administration/users/users.page.html | 1 + .../history/history-sidebar.component.html | 32 ++ .../history/history-sidebar.component.scss | 0 .../history/history-sidebar.component.spec.ts | 54 +++ .../history/history-sidebar.component.ts | 138 ++++++ .../menu-bar/menu-bar.component.html | 66 +-- .../menu-bar/menu-bar.component.spec.ts | 32 +- .../side-menu/side-menu.component.spec.ts | 8 +- .../side-menu/side-menu.component.ts | 10 +- .../slidein/form-page.component.html | 2 +- .../slidein/form-page.component.spec.ts | 30 +- .../components/slidein/form-page.component.ts | 14 +- .../components/table/table.component.html | 403 +++++++++--------- .../components/table/table.component.ts | 39 +- .../shared/components/table/table.model.ts | 3 +- web/src/app/modules/shared/date.ts | 10 +- web/src/app/modules/shared/deep-copy.ts | 30 +- web/src/app/modules/shared/form.ts | 6 +- web/src/app/modules/shared/pipes/bool.pipe.ts | 8 +- .../modules/shared/pipes/customDate.pipe.ts | 16 +- .../app/modules/shared/pipes/protect.pipe.ts | 12 +- web/src/app/modules/shared/shared.module.ts | 40 +- .../modules/shared/test/page.columns.mock.ts | 5 + web/src/app/service/auth.service.ts | 47 +- web/src/app/service/config.service.ts | 8 +- .../service/confirmation-dialog.service.ts | 14 +- web/src/app/service/error-handling.service.ts | 1 - web/src/app/service/feature-flag.service.ts | 85 ++++ web/src/app/service/file.service.ts | 17 +- web/src/app/service/filter.service.ts | 163 +++---- web/src/app/service/gui.service.ts | 23 +- web/src/app/service/logger.service.ts | 10 +- web/src/app/service/settings.service.ts | 2 +- web/src/app/service/sidebar.service.ts | 31 +- web/src/app/service/spinner.service.ts | 22 +- web/src/app/service/toast.service.ts | 28 +- web/src/app/service/version.service.ts | 26 ++ web/src/assets/version.json | 3 + 126 files changed, 2282 insertions(+), 1050 deletions(-) create mode 100644 api/src/api/route_api_key_extension.py create mode 100644 api/src/api_graphql/queries/domain_history_query.py create mode 100644 api/src/api_graphql/queries/group_history_query.py create mode 100644 api/src/api_graphql/queries/short_url_history_query.py create mode 100644 web/src/app/core/base/error-component-base.ts create mode 100644 web/src/app/core/base/page-with-history.data.service.ts create mode 100644 web/src/app/modules/admin/administration/api-keys/history/history.component.html create mode 100644 web/src/app/modules/admin/administration/api-keys/history/history.component.scss create mode 100644 web/src/app/modules/admin/administration/api-keys/history/history.component.spec.ts create mode 100644 web/src/app/modules/admin/administration/api-keys/history/history.component.ts create mode 100644 web/src/app/modules/admin/administration/roles/history/history.component.html create mode 100644 web/src/app/modules/admin/administration/roles/history/history.component.scss create mode 100644 web/src/app/modules/admin/administration/roles/history/history.component.spec.ts create mode 100644 web/src/app/modules/admin/administration/roles/history/history.component.ts create mode 100644 web/src/app/modules/admin/administration/users/history/history.component.html create mode 100644 web/src/app/modules/admin/administration/users/history/history.component.scss create mode 100644 web/src/app/modules/admin/administration/users/history/history.component.spec.ts create mode 100644 web/src/app/modules/admin/administration/users/history/history.component.ts create mode 100644 web/src/app/modules/shared/components/history/history-sidebar.component.html create mode 100644 web/src/app/modules/shared/components/history/history-sidebar.component.scss create mode 100644 web/src/app/modules/shared/components/history/history-sidebar.component.spec.ts create mode 100644 web/src/app/modules/shared/components/history/history-sidebar.component.ts create mode 100644 web/src/app/modules/shared/test/page.columns.mock.ts create mode 100644 web/src/app/service/feature-flag.service.ts create mode 100644 web/src/app/service/version.service.ts create mode 100644 web/src/assets/version.json diff --git a/api/src/api/route.py b/api/src/api/route.py index 40edd8b..58f3242 100644 --- a/api/src/api/route.py +++ b/api/src/api/route.py @@ -8,32 +8,16 @@ from starlette.routing import Route as StarletteRoute from api.errors import unauthorized from api.middleware.request import get_request +from api.route_api_key_extension import RouteApiKeyExtension from api.route_user_extension import RouteUserExtension from core.environment import Environment from data.schemas.administration.api_key import ApiKey -from data.schemas.administration.api_key_dao import apiKeyDao from data.schemas.administration.user import User -class Route(RouteUserExtension): +class Route(RouteUserExtension, RouteApiKeyExtension): 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 async def _get_auth_type( cls, request: Request, auth_header: str @@ -79,8 +63,7 @@ class Route(RouteUserExtension): return await cls._get_auth_type(request, auth_header) @classmethod - async def is_authorized(cls) -> bool: - request = get_request() + async def is_authorized(cls, request: Request) -> bool: if request is None: return False @@ -119,7 +102,7 @@ class Route(RouteUserExtension): return await f(request, *args, **kwargs) return f(request, *args, **kwargs) - if not await cls.is_authorized(): + if not await cls.is_authorized(request): return unauthorized() if iscoroutinefunction(f): diff --git a/api/src/api/route_api_key_extension.py b/api/src/api/route_api_key_extension.py new file mode 100644 index 0000000..cb9eb5e --- /dev/null +++ b/api/src/api/route_api_key_extension.py @@ -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 diff --git a/api/src/api/route_user_extension.py b/api/src/api/route_user_extension.py index b728a92..585390d 100644 --- a/api/src/api/route_user_extension.py +++ b/api/src/api/route_user_extension.py @@ -18,6 +18,7 @@ logger = Logger(__name__) class RouteUserExtension: + _cached_users: dict[int, User] = {} @classmethod def _get_user_id_from_token(cls, request: Request) -> Optional[str]: @@ -62,9 +63,7 @@ class RouteUserExtension: if request is None: return None - return await userDao.find_single_by( - [{User.keycloak_id: cls.get_token(request)}, {User.deleted: False}] - ) + return await userDao.find_by_keycloak_id(cls.get_token(request)) @classmethod async def get_user_or_default(cls) -> Optional[User]: 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 index 0755c16..3e42066 100644 --- a/api/src/api_graphql/abc/db_history_model_query_abc.py +++ b/api/src/api_graphql/abc/db_history_model_query_abc.py @@ -7,8 +7,7 @@ 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" + def __init__(self, name: str = __name__): QueryABC.__init__(self, f"{name}History") self.set_field("id", lambda x, *_: x.id) diff --git a/api/src/api_graphql/field/dao_field.py b/api/src/api_graphql/field/dao_field.py index 194238b..e9773b3 100644 --- a/api/src/api_graphql/field/dao_field.py +++ b/api/src/api_graphql/field/dao_field.py @@ -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.field_abc import FieldABC @@ -19,6 +19,7 @@ class DaoField(FieldABC): public: bool = False, dao: DataAccessObjectABC = None, filter_type: Type[FilterABC] = None, + default_filter: Callable = None, sort_type: Type[T] = None, direct_result: bool = False, ): @@ -28,6 +29,7 @@ class DaoField(FieldABC): self._public = public self._dao = dao self._filter_type = filter_type + self._default_filter = default_filter self._sort_type = sort_type self._direct_result = direct_result @@ -41,6 +43,10 @@ class DaoField(FieldABC): ) -> Optional[Type[FilterABC]]: return self._filter_type + @property + def default_filter(self) -> Optional[Callable]: + return self._default_filter + @property def sort_type(self) -> Optional[Type[T]]: return self._sort_type diff --git a/api/src/api_graphql/field/dao_field_builder.py b/api/src/api_graphql/field/dao_field_builder.py index 5aa0984..156f77c 100644 --- a/api/src/api_graphql/field/dao_field_builder.py +++ b/api/src/api_graphql/field/dao_field_builder.py @@ -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.filter_abc import FilterABC @@ -14,6 +14,7 @@ class DaoFieldBuilder(FieldBuilderABC): self._dao = None self._filter_type = None + self._default_filter = None self._sort_type = None self._direct_result = False @@ -27,6 +28,12 @@ class DaoFieldBuilder(FieldBuilderABC): self._filter_type = filter_type 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: assert sort_type is not None, "sort cannot be None" self._sort_type = sort_type @@ -45,6 +52,7 @@ class DaoFieldBuilder(FieldBuilderABC): self._public, self._dao, self._filter_type, + self._default_filter, self._sort_type, self._direct_result, ) diff --git a/api/src/api_graphql/field/mutation_field_builder.py b/api/src/api_graphql/field/mutation_field_builder.py index a27a415..bf33dda 100644 --- a/api/src/api_graphql/field/mutation_field_builder.py +++ b/api/src/api_graphql/field/mutation_field_builder.py @@ -38,6 +38,9 @@ class MutationFieldBuilder(FieldBuilderABC): await broadcast.publish(f"{source}", result) return result + self._resolver = resolver_wrapper + return self + def with_change_broadcast(self, source: str): assert self._resolver is not None, "resolver cannot be None for broadcast" diff --git a/api/src/api_graphql/graphql/domain.gql b/api/src/api_graphql/graphql/domain.gql index eb4dfa8..0396492 100644 --- a/api/src/api_graphql/graphql/domain.gql +++ b/api/src/api_graphql/graphql/domain.gql @@ -4,6 +4,18 @@ type DomainResult { nodes: [Domain] } +type DomainHistory implements DbHistoryModel { + id: Int + name: String + + shortUrls: [ShortUrl] + + deleted: Boolean + editor: String + created: String + updated: String +} + type Domain implements DbModel { id: Int name: String @@ -14,6 +26,8 @@ type Domain implements DbModel { editor: User created: String updated: String + + history: [DomainHistory] } input DomainSort { diff --git a/api/src/api_graphql/graphql/group.gql b/api/src/api_graphql/graphql/group.gql index 3fc8491..2979c4d 100644 --- a/api/src/api_graphql/graphql/group.gql +++ b/api/src/api_graphql/graphql/group.gql @@ -4,6 +4,19 @@ type GroupResult { 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 { id: Int name: String @@ -15,6 +28,7 @@ type Group implements DbModel { editor: User created: String updated: String + history: [GroupHistory] } input GroupSort { diff --git a/api/src/api_graphql/graphql/permission.gql b/api/src/api_graphql/graphql/permission.gql index cd531d0..8299e1a 100644 --- a/api/src/api_graphql/graphql/permission.gql +++ b/api/src/api_graphql/graphql/permission.gql @@ -4,6 +4,17 @@ type PermissionResult { nodes: [Permission] } +type PermissionHistory implements DbHistoryModel { + id: Int + name: String + description: String + + deleted: Boolean + editor: String + created: String + updated: String +} + type Permission implements DbModel { id: Int name: String @@ -13,6 +24,8 @@ type Permission implements DbModel { editor: User created: String updated: String + + history: [PermissionHistory] } input PermissionSort { @@ -21,7 +34,7 @@ input PermissionSort { description: SortOrder deleted: SortOrder - editorId: SortOrder + editor: UserSort created: SortOrder updated: SortOrder } @@ -32,7 +45,7 @@ input PermissionFilter { description: StringFilter deleted: BooleanFilter - editor: IntFilter + editor: UserFilter created: DateFilter updated: DateFilter } diff --git a/api/src/api_graphql/graphql/short_url.gql b/api/src/api_graphql/graphql/short_url.gql index afc4e8f..c766a66 100644 --- a/api/src/api_graphql/graphql/short_url.gql +++ b/api/src/api_graphql/graphql/short_url.gql @@ -4,6 +4,23 @@ type ShortUrlResult { 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 { id: Int shortUrl: String @@ -18,6 +35,7 @@ type ShortUrl implements DbModel { editor: User created: String updated: String + history: [ShortUrlHistory] } input ShortUrlSort { diff --git a/api/src/api_graphql/graphql/subscription.gql b/api/src/api_graphql/graphql/subscription.gql index 9aa8729..8ee85eb 100644 --- a/api/src/api_graphql/graphql/subscription.gql +++ b/api/src/api_graphql/graphql/subscription.gql @@ -9,6 +9,7 @@ type Subscription { settingChange: SubscriptionChange userChange: SubscriptionChange userSettingChange: SubscriptionChange + userLogout: SubscriptionChange domainChange: SubscriptionChange groupChange: SubscriptionChange diff --git a/api/src/api_graphql/queries/domain_history_query.py b/api/src/api_graphql/queries/domain_history_query.py new file mode 100644 index 0000000..ad4f4a2 --- /dev/null +++ b/api/src/api_graphql/queries/domain_history_query.py @@ -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}}, + ] + ) diff --git a/api/src/api_graphql/queries/domain_query.py b/api/src/api_graphql/queries/domain_query.py index e3bfc43..c39042b 100644 --- a/api/src/api_graphql/queries/domain_query.py +++ b/api/src/api_graphql/queries/domain_query.py @@ -1,5 +1,6 @@ from api_graphql.abc.db_model_query_abc import DbModelQueryABC 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.short_url import ShortUrl 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): 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("shortUrls", self._get_urls) + self.set_history_reference_dao(shortUrlDao, "domainid") + @staticmethod async def _get_urls(domain: Domain, *_): return await shortUrlDao.find_by({ShortUrl.domain_id: domain.id}) diff --git a/api/src/api_graphql/queries/group_history_query.py b/api/src/api_graphql/queries/group_history_query.py new file mode 100644 index 0000000..2c062c6 --- /dev/null +++ b/api/src/api_graphql/queries/group_history_query.py @@ -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}}, + ] + ) diff --git a/api/src/api_graphql/queries/group_query.py b/api/src/api_graphql/queries/group_query.py index 1e28ac6..7effacc 100644 --- a/api/src/api_graphql/queries/group_query.py +++ b/api/src/api_graphql/queries/group_query.py @@ -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 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 @@ -10,7 +11,7 @@ from service.permission.permissions_enum import Permissions class GroupQuery(DbModelQueryABC): def __init__(self): - DbModelQueryABC.__init__(self, "Group") + DbModelQueryABC.__init__(self, "Group", groupDao, with_history=True) self.set_field("name", lambda x, *_: x.name) self.field( @@ -25,6 +26,9 @@ class GroupQuery(DbModelQueryABC): ) self.set_field("roles", self._get_roles) + self.set_history_reference_dao(shortUrlDao, "groupid") + self.set_history_reference_dao(groupRoleAssignmentDao, "groupid") + @staticmethod async def _get_urls(group: Group, *_): return await shortUrlDao.find_by({ShortUrl.group_id: group.id}) diff --git a/api/src/api_graphql/queries/short_url_history_query.py b/api/src/api_graphql/queries/short_url_history_query.py new file mode 100644 index 0000000..2d56696 --- /dev/null +++ b/api/src/api_graphql/queries/short_url_history_query.py @@ -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) diff --git a/api/src/api_graphql/queries/short_url_query.py b/api/src/api_graphql/queries/short_url_query.py index 10561b2..1a103d6 100644 --- a/api/src/api_graphql/queries/short_url_query.py +++ b/api/src/api_graphql/queries/short_url_query.py @@ -1,9 +1,10 @@ from api_graphql.abc.db_model_query_abc import DbModelQueryABC +from data.schemas.public.short_url_dao import shortUrlDao class ShortUrlQuery(DbModelQueryABC): 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("targetUrl", lambda x, *_: x.target_url) diff --git a/api/src/api_graphql/subscription.py b/api/src/api_graphql/subscription.py index e17bbfd..02f0e57 100644 --- a/api/src/api_graphql/subscription.py +++ b/api/src/api_graphql/subscription.py @@ -49,6 +49,12 @@ class Subscription(SubscriptionABC): .with_public(True) ) + self.subscribe( + SubscriptionFieldBuilder("userLogout") + .with_resolver(lambda message, *_: message.message) + .with_public(True) + ) + self.subscribe( SubscriptionFieldBuilder("domainChange") .with_resolver(lambda message, *_: message.message) 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 3c98f40..e83b684 100644 --- a/api/src/core/database/abc/data_access_object_abc.py +++ b/api/src/core/database/abc/data_access_object_abc.py @@ -45,13 +45,13 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): return self._table_name def attribute( - self, - attr_name: Attribute, - attr_type: type, - db_name: str = None, - ignore=False, - primary_key=False, - aliases: list[str] = None, + self, + attr_name: Attribute, + attr_type: type, + 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 @@ -91,11 +91,11 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): self.__date_attributes.add(db_name) def reference( - self, - attr: Attribute, - primary_attr: Attribute, - foreign_attr: Attribute, - table_name: str, + self, + attr: Attribute, + primary_attr: Attribute, + foreign_attr: Attribute, + table_name: str, ): """ Add a reference to another table for the given attribute @@ -168,12 +168,12 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): 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, + 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: @@ -225,11 +225,11 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): return self.to_object(result[0]) async def get_by( - self, - filters: AttributeFilters = None, - sorts: AttributeSorts = None, - take: int = None, - skip: int = None, + self, + filters: AttributeFilters = None, + sorts: AttributeSorts = None, + take: int = None, + skip: int = None, ) -> list[T_DBM]: """ Get all objects by the given filters @@ -250,11 +250,11 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): return [self.to_object(x) for x in result] async def get_single_by( - self, - filters: AttributeFilters = None, - sorts: AttributeSorts = None, - take: int = None, - skip: int = None, + self, + filters: AttributeFilters = None, + sorts: AttributeSorts = None, + take: int = None, + skip: int = None, ) -> T_DBM: """ Get a single object by the given filters @@ -275,11 +275,11 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): return result[0] async def find_by( - self, - filters: AttributeFilters = None, - sorts: AttributeSorts = None, - take: int = None, - skip: int = None, + self, + filters: AttributeFilters = None, + sorts: AttributeSorts = None, + take: int = None, + skip: int = None, ) -> list[Optional[T_DBM]]: """ Find all objects by the given filters @@ -299,11 +299,11 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): return [self.to_object(x) for x in result] async def find_single_by( - self, - filters: AttributeFilters = None, - sorts: AttributeSorts = None, - take: int = None, - skip: int = None, + self, + filters: AttributeFilters = None, + sorts: AttributeSorts = None, + take: int = None, + skip: int = None, ) -> Optional[T_DBM]: """ Find a single object by the given filters @@ -432,7 +432,7 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): await self._db.execute(query) async def _build_delete_statement( - self, obj: T_DBM, hard_delete: bool = False + self, obj: T_DBM, hard_delete: bool = False ) -> str: if hard_delete: return f""" @@ -548,7 +548,7 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): return cast_type(value) async def _handle_query_external_temp_tables( - self, query: str, external_table_deps: list[str], ignore_fields=False + self, query: str, external_table_deps: list[str], ignore_fields=False ) -> str: for dep in external_table_deps: temp_table = self._external_fields[dep] @@ -566,11 +566,11 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): return query async def _build_conditional_query( - self, - filters: AttributeFilters = None, - sorts: AttributeSorts = None, - take: int = None, - skip: int = None, + self, + filters: AttributeFilters = None, + sorts: AttributeSorts = None, + take: int = None, + skip: int = None, ) -> str: filter_conditions = [] sort_conditions = [] @@ -667,7 +667,7 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): 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) + 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: @@ -700,7 +700,7 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): @staticmethod def _build_fuzzy_conditions( - fields: list[str], term: str, threshold: int = 10 + fields: list[str], term: str, threshold: int = 10 ) -> list[str]: conditions = [] for field in fields: @@ -711,7 +711,7 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): return conditions def _build_foreign_conditions( - self, table: str, values: dict + self, table: str, values: dict ) -> (list[str], list[str]): """ Build SQL conditions for foreign key references @@ -776,7 +776,7 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): return conditions, external_field_table_deps def _handle_fuzzy_filter_conditions( - self, conditions, external_field_table_deps, sub_values + self, conditions, external_field_table_deps, sub_values ): fuzzy_fields = get_value(sub_values, "fields", list[str]) fuzzy_fields_db_names = [] @@ -915,7 +915,7 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): return ", ".join(sort_clauses), external_field_table_deps def _build_foreign_order_by( - self, table: str, direction: dict + self, table: str, direction: dict ) -> (list[str], list[str]): """ Build SQL order by clause for foreign key references diff --git a/api/src/core/get_value.py b/api/src/core/get_value.py index 39b34e8..36d68f5 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,8 +26,8 @@ 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: @@ -41,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/redirector.py b/api/src/redirector.py index 0b9f1b1..28ff9b8 100644 --- a/api/src/redirector.py +++ b/api/src/redirector.py @@ -111,7 +111,11 @@ def _find_short_url_by_path(path: str) -> Optional[dict]: if "errors" in data: 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 data = data["data"]["shortUrls"]["nodes"] diff --git a/web/src/app/app.component.html b/web/src/app/app.component.html index be4ad3a..b3cf78b 100644 --- a/web/src/app/app.component.html +++ b/web/src/app/app.component.html @@ -1,36 +1,37 @@ -
- +
+
+ {{ 'technical_demo_banner' | translate }} +
+ -
- -
- -
-
- +
+ +
+ +
+
+ - - - -
- - -
-
-
+ + + +
+ + +
+
+
- - - - \ No newline at end of file diff --git a/web/src/app/app.component.ts b/web/src/app/app.component.ts index b7dc7b5..f2e73ec 100644 --- a/web/src/app/app.component.ts +++ b/web/src/app/app.component.ts @@ -2,8 +2,8 @@ import { Component, OnDestroy } from '@angular/core'; import { SidebarService } from 'src/app/service/sidebar.service'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; -import { AuthService } from 'src/app/service/auth.service'; import { GuiService } from 'src/app/service/gui.service'; +import { FeatureFlagService } from 'src/app/service/feature-flag.service'; @Component({ selector: 'app-root', @@ -11,36 +11,35 @@ import { GuiService } from 'src/app/service/gui.service'; styleUrl: './app.component.scss', }) export class AppComponent implements OnDestroy { - theme = 'open-redirect'; showSidebar = false; - hideUI = false; - isLoggedIn = false; + theme = 'lan-maestro'; + showTechnicalDemoBanner = false; + + loadedGuiSettings = false; unsubscribe$ = new Subject(); constructor( private sidebar: SidebarService, - private auth: AuthService, - private gui: GuiService + private gui: GuiService, + private features: FeatureFlagService ) { - this.auth.loadUser(); - - this.auth.user$.pipe(takeUntil(this.unsubscribe$)).subscribe(user => { - this.isLoggedIn = user !== null && user !== undefined; + this.features.get('TechnicalDemoBanner').then(showTechnicalDemoBanner => { + this.showTechnicalDemoBanner = showTechnicalDemoBanner; }); - this.sidebar.visible$ .pipe(takeUntil(this.unsubscribe$)) .subscribe(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.theme = theme; }); + this.gui.loadedGuiSettings$ + .pipe(takeUntil(this.unsubscribe$)) + .subscribe(loaded => { + this.loadedGuiSettings = loaded; + }); } ngOnDestroy() { diff --git a/web/src/app/app.module.ts b/web/src/app/app.module.ts index b84161c..ab8bd3f 100644 --- a/web/src/app/app.module.ts +++ b/web/src/app/app.module.ts @@ -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 { 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 { ConfigService } from 'src/app/service/config.service'; 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) { Logger.enableProductionMode(); @@ -95,6 +104,20 @@ export function appInitializerFactory( 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); + } +} diff --git a/web/src/app/components/error/not-found/not-found.component.ts b/web/src/app/components/error/not-found/not-found.component.ts index d80e028..cbd1a96 100644 --- a/web/src/app/components/error/not-found/not-found.component.ts +++ b/web/src/app/components/error/not-found/not-found.component.ts @@ -1,8 +1,9 @@ import { Component } from '@angular/core'; +import { ErrorComponentBase } from 'src/app/core/base/error-component-base'; @Component({ selector: 'app-not-found', templateUrl: './not-found.component.html', styleUrls: ['./not-found.component.scss'], }) -export class NotFoundComponent {} +export class NotFoundComponent extends ErrorComponentBase {} diff --git a/web/src/app/components/error/server-unavailable/server-unavailable.component.ts b/web/src/app/components/error/server-unavailable/server-unavailable.component.ts index f0815c0..9072d0c 100644 --- a/web/src/app/components/error/server-unavailable/server-unavailable.component.ts +++ b/web/src/app/components/error/server-unavailable/server-unavailable.component.ts @@ -1,13 +1,16 @@ import { Component } from '@angular/core'; import { Router } from '@angular/router'; +import { ErrorComponentBase } from 'src/app/core/base/error-component-base'; @Component({ selector: 'app-server-unavailable', templateUrl: './server-unavailable.component.html', styleUrls: ['./server-unavailable.component.scss'], }) -export class ServerUnavailableComponent { - constructor(private router: Router) {} +export class ServerUnavailableComponent extends ErrorComponentBase { + constructor(private router: Router) { + super(); + } async retryConnection() { await this.router.navigate(['/']); diff --git a/web/src/app/components/footer/footer.component.html b/web/src/app/components/footer/footer.component.html index 73105a4..f1682cf 100644 --- a/web/src/app/components/footer/footer.component.html +++ b/web/src/app/components/footer/footer.component.html @@ -1,7 +1,14 @@ -