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 @@ -