diff --git a/api/src/api/route_user_extension.py b/api/src/api/route_user_extension.py index 585390d..ec2cebd 100644 --- a/api/src/api/route_user_extension.py +++ b/api/src/api/route_user_extension.py @@ -51,6 +51,10 @@ class RouteUserExtension: if request is None: return None + dev_user = await cls.get_dev_user() + if dev_user is not None: + return dev_user + user_id = cls._get_user_id_from_token(request) if not user_id: return None @@ -63,6 +67,15 @@ class RouteUserExtension: if request is None: return None + if "Authorization" not in request.headers: + return None + + if len(request.headers.get("Authorization").split()) < 2: + return None + + if request.headers.get("Authorization").split()[0] != "DEV-User": + return None + return await userDao.find_by_keycloak_id(cls.get_token(request)) @classmethod diff --git a/api/src/api_graphql/abc/query_abc.py b/api/src/api_graphql/abc/query_abc.py index c3d2ca6..84975cf 100644 --- a/api/src/api_graphql/abc/query_abc.py +++ b/api/src/api_graphql/abc/query_abc.py @@ -174,9 +174,9 @@ class QueryABC(ObjectType): kwargs["sort"] = None if iscoroutinefunction(field.resolver): - collection = await field.collection_resolver(*args) + collection = await field.resolver(*args) else: - collection = field.collection_resolver(*args) + collection = field.resolver(*args) return self._resolve_collection( collection, diff --git a/api/src/api_graphql/field/collection_field.py b/api/src/api_graphql/field/collection_field.py index e41d81e..0c586a9 100644 --- a/api/src/api_graphql/field/collection_field.py +++ b/api/src/api_graphql/field/collection_field.py @@ -15,7 +15,7 @@ class CollectionField(FieldABC): require_any_permission: list[Permissions] = None, require_any: TRequireAny = None, public: bool = False, - collection_resolver: Callable = None, + resolver: Callable = None, filter_type: Type[CollectionFilterABC] = None, sort_type: Type[T] = None, ): @@ -23,13 +23,13 @@ class CollectionField(FieldABC): self._name = name self._public = public - self._collection_resolver = collection_resolver + self._resolver = resolver self._filter_type = filter_type self._sort_type = sort_type @property - def collection_resolver(self) -> Optional[Callable]: - return self._collection_resolver + def resolver(self) -> Optional[Callable]: + return self._resolver @property def filter_type( diff --git a/api/src/api_graphql/field/collection_field_builder.py b/api/src/api_graphql/field/collection_field_builder.py index 8a52b9c..355fda7 100644 --- a/api/src/api_graphql/field/collection_field_builder.py +++ b/api/src/api_graphql/field/collection_field_builder.py @@ -11,13 +11,13 @@ class CollectionFieldBuilder(FieldBuilderABC): def __init__(self, name: str): FieldBuilderABC.__init__(self, name) - self._collection_resolver = None + self._resolver = None self._filter_type = None self._sort_type = None - def with_collection_resolver(self, collection_resolver: Callable) -> Self: - assert collection_resolver is not None, "collection_resolver cannot be None" - self._collection_resolver = collection_resolver + def with_resolver(self, resolver: Callable) -> Self: + assert resolver is not None, "resolver cannot be None" + self._resolver = resolver return self def with_filter(self, filter_type: Type[CollectionFilterABC]) -> Self: @@ -31,16 +31,14 @@ class CollectionFieldBuilder(FieldBuilderABC): return self def build(self) -> CollectionField: - assert ( - self._collection_resolver is not None - ), "collection_resolver cannot be None" + assert self._resolver is not None, "resolver cannot be None" return CollectionField( self._name, self._require_any_permission, self._require_any, self._public, - self._collection_resolver, + self._resolver, self._filter_type, self._sort_type, ) diff --git a/api/src/api_graphql/filter/collection/api_key_collection_filter.py b/api/src/api_graphql/filter/collection/api_key_collection_filter.py deleted file mode 100644 index 3bd1f7c..0000000 --- a/api/src/api_graphql/filter/collection/api_key_collection_filter.py +++ /dev/null @@ -1,18 +0,0 @@ -from typing import Optional - -from api_graphql.abc.db_model_collection_filter_abc import DbModelCollectionFilterABC -from api_graphql.abc.filter.string_filter import StringCollectionFilter -from api_graphql.abc.collection_filter_abc import FilterOperator -from core.typing import T - - -class ApiKeyCollectionFilter(DbModelCollectionFilterABC): - def __init__( - self, - obj: dict, - op: Optional[FilterOperator] = None, - source_value: Optional[T] = None, - ): - DbModelCollectionFilterABC.__init__(self, obj, op, source_value) - - self.add_field("identifier", StringCollectionFilter) diff --git a/api/src/api_graphql/filter/collection/ip_list_collection_filter.py b/api/src/api_graphql/filter/collection/ip_list_collection_filter.py deleted file mode 100644 index 21c040e..0000000 --- a/api/src/api_graphql/filter/collection/ip_list_collection_filter.py +++ /dev/null @@ -1,21 +0,0 @@ -from typing import Optional - -from api_graphql.abc.db_model_collection_filter_abc import DbModelCollectionFilterABC -from api_graphql.abc.filter.string_filter import StringCollectionFilter -from api_graphql.abc.collection_filter_abc import FilterOperator -from core.typing import T - - -class IpListFilter(DbModelCollectionFilterABC): - def __init__( - self, - obj: dict, - op: Optional[FilterOperator] = None, - source_value: Optional[T] = None, - ): - DbModelCollectionFilterABC.__init__(self, obj, op, source_value) - - self.add_field("ip", StringCollectionFilter) - self.add_field("description", StringCollectionFilter) - self.add_field("mac", StringCollectionFilter) - self.add_field("dns", StringCollectionFilter) diff --git a/api/src/api_graphql/filter/collection/news_collection_filter.py b/api/src/api_graphql/filter/collection/news_collection_filter.py deleted file mode 100644 index 78be285..0000000 --- a/api/src/api_graphql/filter/collection/news_collection_filter.py +++ /dev/null @@ -1,20 +0,0 @@ -from typing import Optional - -from api_graphql.abc.db_model_collection_filter_abc import DbModelCollectionFilterABC -from api_graphql.abc.filter.bool_filter import BoolCollectionFilter -from api_graphql.abc.filter.string_filter import StringCollectionFilter -from api_graphql.abc.collection_filter_abc import FilterOperator -from core.typing import T - - -class NewsFilter(DbModelCollectionFilterABC): - def __init__( - self, - obj: dict, - op: Optional[FilterOperator] = None, - source_value: Optional[T] = None, - ): - DbModelCollectionFilterABC.__init__(self, obj, op, source_value) - - self.add_field("title", StringCollectionFilter) - self.add_field("published", BoolCollectionFilter) diff --git a/api/src/api_graphql/filter/collection/permission_collection_filter.py b/api/src/api_graphql/filter/collection/permission_collection_filter.py deleted file mode 100644 index 03132c0..0000000 --- a/api/src/api_graphql/filter/collection/permission_collection_filter.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import Optional - -from api_graphql.abc.db_model_collection_filter_abc import DbModelCollectionFilterABC -from api_graphql.abc.filter.string_filter import StringCollectionFilter -from api_graphql.abc.collection_filter_abc import FilterOperator -from core.typing import T - - -class PermissionFilter(DbModelCollectionFilterABC): - def __init__( - self, - obj: dict, - op: Optional[FilterOperator] = None, - source_value: Optional[T] = None, - ): - DbModelCollectionFilterABC.__init__(self, obj, op, source_value) - - self.add_field("name", StringCollectionFilter) - self.add_field("description", StringCollectionFilter) diff --git a/api/src/api_graphql/filter/collection/role_collection_filter.py b/api/src/api_graphql/filter/collection/role_collection_filter.py deleted file mode 100644 index 6c01b12..0000000 --- a/api/src/api_graphql/filter/collection/role_collection_filter.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import Optional - -from api_graphql.abc.db_model_collection_filter_abc import DbModelCollectionFilterABC -from api_graphql.abc.filter.string_filter import StringCollectionFilter -from api_graphql.abc.collection_filter_abc import FilterOperator -from core.typing import T - - -class RoleFilter(DbModelCollectionFilterABC): - def __init__( - self, - obj: dict, - op: Optional[FilterOperator] = None, - source_value: Optional[T] = None, - ): - DbModelCollectionFilterABC.__init__(self, obj, op, source_value) - - self.add_field("name", StringCollectionFilter) - self.add_field("description", StringCollectionFilter) diff --git a/api/src/api_graphql/filter/collection/user_collection_filter.py b/api/src/api_graphql/filter/collection/user_collection_filter.py deleted file mode 100644 index 8e64940..0000000 --- a/api/src/api_graphql/filter/collection/user_collection_filter.py +++ /dev/null @@ -1,20 +0,0 @@ -from typing import Optional - -from api_graphql.abc.db_model_collection_filter_abc import DbModelCollectionFilterABC -from api_graphql.abc.filter.string_filter import StringCollectionFilter -from api_graphql.abc.collection_filter_abc import FilterOperator -from core.typing import T - - -class UserFilter(DbModelCollectionFilterABC): - def __init__( - self, - obj: dict, - op: Optional[FilterOperator] = None, - source_value: Optional[T] = None, - ): - DbModelCollectionFilterABC.__init__(self, obj, op, source_value) - - self.add_field("keycloakId", StringCollectionFilter) - self.add_field("username", StringCollectionFilter) - self.add_field("email", StringCollectionFilter) diff --git a/api/src/api_graphql/filter/group_filter.py b/api/src/api_graphql/filter/group_filter.py index f71148d..3b053db 100644 --- a/api/src/api_graphql/filter/group_filter.py +++ b/api/src/api_graphql/filter/group_filter.py @@ -1,5 +1,6 @@ from api_graphql.abc.db_model_filter_abc import DbModelFilterABC from api_graphql.abc.filter.string_filter import StringFilter +from api_graphql.filter.user_space_filter import UserSpaceFilter class GroupFilter(DbModelFilterABC): @@ -11,6 +12,7 @@ class GroupFilter(DbModelFilterABC): self.add_field("name", StringFilter) self.add_field("description", StringFilter) + self.add_field("userSpace", UserSpaceFilter, "userspace") self.add_field("isNull", bool) self.add_field("isNotNull", bool) diff --git a/api/src/api_graphql/filter/short_url_filter.py b/api/src/api_graphql/filter/short_url_filter.py index 02a5097..c925eef 100644 --- a/api/src/api_graphql/filter/short_url_filter.py +++ b/api/src/api_graphql/filter/short_url_filter.py @@ -2,6 +2,7 @@ from api_graphql.abc.db_model_filter_abc import DbModelFilterABC from api_graphql.abc.filter.string_filter import StringFilter from api_graphql.filter.domain_filter import DomainFilter from api_graphql.filter.group_filter import GroupFilter +from api_graphql.filter.user_space_filter import UserSpaceFilter class ShortUrlFilter(DbModelFilterABC): @@ -17,3 +18,5 @@ class ShortUrlFilter(DbModelFilterABC): self.add_field("group", GroupFilter) self.add_field("domain", DomainFilter) + + self.add_field("userSpace", UserSpaceFilter, "userspace") diff --git a/api/src/api_graphql/filter/user_space_filter.py b/api/src/api_graphql/filter/user_space_filter.py new file mode 100644 index 0000000..17052a2 --- /dev/null +++ b/api/src/api_graphql/filter/user_space_filter.py @@ -0,0 +1,17 @@ +from api_graphql.abc.db_model_filter_abc import DbModelFilterABC +from api_graphql.abc.filter.string_filter import StringFilter +from api_graphql.filter.user_filter import UserFilter + + +class UserSpaceFilter(DbModelFilterABC): + def __init__( + self, + obj: dict, + ): + DbModelFilterABC.__init__(self, obj) + + self.add_field("name", StringFilter) + self.add_field("owner", UserFilter) + + self.add_field("isNull", bool) + self.add_field("isNotNull", bool) diff --git a/api/src/api_graphql/graphql/group.gql b/api/src/api_graphql/graphql/group.gql index f7f9315..9282d72 100644 --- a/api/src/api_graphql/graphql/group.gql +++ b/api/src/api_graphql/graphql/group.gql @@ -8,6 +8,8 @@ type GroupHistory implements DbHistoryModel { id: Int name: String + userSpace: UserSpace + shortUrls: [ShortUrl] roles: [Role] @@ -21,6 +23,8 @@ type Group implements DbModel { id: Int name: String + userSpace: UserSpace + shortUrls: [ShortUrl] roles: [Role] @@ -55,6 +59,8 @@ input GroupFilter { id: IntFilter name: StringFilter + userSpace: UserSpaceFilter + fuzzy: GroupFuzzy deleted: BooleanFilter @@ -74,6 +80,7 @@ type GroupMutation { } input GroupCreateInput { + userSpaceId: Int! name: String! roles: [Int] } diff --git a/api/src/api_graphql/graphql/mutation.gql b/api/src/api_graphql/graphql/mutation.gql index 6dff6f7..a49d9a6 100644 --- a/api/src/api_graphql/graphql/mutation.gql +++ b/api/src/api_graphql/graphql/mutation.gql @@ -4,8 +4,10 @@ type Mutation { user: UserMutation role: RoleMutation - group: GroupMutation domain: DomainMutation + + userSpace: UserSpaceMutation + group: GroupMutation shortUrl: ShortUrlMutation setting: SettingMutation diff --git a/api/src/api_graphql/graphql/query.gql b/api/src/api_graphql/graphql/query.gql index 6b6e454..85acf01 100644 --- a/api/src/api_graphql/graphql/query.gql +++ b/api/src/api_graphql/graphql/query.gql @@ -12,6 +12,9 @@ type Query { notExistingUsersFromKeycloak: [KeycloakUser] domains(filter: [DomainFilter], sort: [DomainSort], skip: Int, take: Int): DomainResult + + assignedUserSpaces: UserSpaceResult + userSpaces(filter: [UserSpaceFilter], sort: [UserSpaceSort], skip: Int, take: Int): UserSpaceResult groups(filter: [GroupFilter], sort: [GroupSort], skip: Int, take: Int): GroupResult shortUrls(filter: [ShortUrlFilter], sort: [ShortUrlSort], skip: Int, take: Int): ShortUrlResult diff --git a/api/src/api_graphql/graphql/short_url.gql b/api/src/api_graphql/graphql/short_url.gql index c766a66..154328a 100644 --- a/api/src/api_graphql/graphql/short_url.gql +++ b/api/src/api_graphql/graphql/short_url.gql @@ -14,6 +14,7 @@ type ShortUrlHistory implements DbHistoryModel { group: Group domain: Domain + userSpace: UserSpace deleted: Boolean editor: String @@ -27,9 +28,11 @@ type ShortUrl implements DbModel { targetUrl: String description: String visits: Int + loadingScreen: Boolean + group: Group domain: Domain - loadingScreen: Boolean + userSpace: UserSpace deleted: Boolean editor: User @@ -68,9 +71,12 @@ input ShortUrlFilter { targetUrl: StringFilter description: StringFilter loadingScreen: BooleanFilter + group: GroupFilter domain: DomainFilter + userSpace: UserSpaceFilter + fuzzy: ShortUrlFuzzy deleted: BooleanFilter @@ -88,6 +94,7 @@ type ShortUrlMutation { } input ShortUrlCreateInput { + userSpaceId: Int! shortUrl: String! targetUrl: String! description: String diff --git a/api/src/api_graphql/graphql/subscription.gql b/api/src/api_graphql/graphql/subscription.gql index 8ee85eb..e228c04 100644 --- a/api/src/api_graphql/graphql/subscription.gql +++ b/api/src/api_graphql/graphql/subscription.gql @@ -12,6 +12,7 @@ type Subscription { userLogout: SubscriptionChange domainChange: SubscriptionChange + userSpaceChange: SubscriptionChange groupChange: SubscriptionChange shortUrlChange: SubscriptionChange } \ No newline at end of file diff --git a/api/src/api_graphql/graphql/user_space.gql b/api/src/api_graphql/graphql/user_space.gql new file mode 100644 index 0000000..cc28baf --- /dev/null +++ b/api/src/api_graphql/graphql/user_space.gql @@ -0,0 +1,88 @@ +type UserSpaceResult { + totalCount: Int + count: Int + nodes: [UserSpace] +} + +type UserSpaceHistory implements DbHistoryModel { + id: Int + name: String + owner: User + + users: [User] + + deleted: Boolean + editor: String + created: String + updated: String +} + +type UserSpace implements DbModel { + id: Int + name: String + owner: User + + users: [User] + + deleted: Boolean + editor: User + created: String + updated: String + history: [UserSpaceHistory] +} + +input UserSpaceSort { + id: SortOrder + name: SortOrder + owner: SortOrder + + deleted: SortOrder + editorId: SortOrder + created: SortOrder + updated: SortOrder +} + +enum UserSpaceFuzzyFields { + name + owner +} + +input UserSpaceFuzzy { + fields: [UserSpaceFuzzyFields] + term: String + threshold: Int +} + +input UserSpaceFilter { + id: IntFilter + name: StringFilter + owner: UserFilter + + fuzzy: UserSpaceFuzzy + + deleted: BooleanFilter + editor: IntFilter + created: DateFilter + updated: DateFilter + + isNull: Boolean + isNotNull: Boolean +} + +type UserSpaceMutation { + create(input: UserSpaceCreateInput!): UserSpace + update(input: UserSpaceUpdateInput!): UserSpace + delete(id: Int!): Boolean + restore(id: Int!): Boolean +} + +input UserSpaceCreateInput { + name: String! + users: [Int] +} + +input UserSpaceUpdateInput { + id: Int! + name: String + users: [Int] +} \ No newline at end of file diff --git a/api/src/api_graphql/input/group_create_input.py b/api/src/api_graphql/input/group_create_input.py index 994224e..29a0d71 100644 --- a/api/src/api_graphql/input/group_create_input.py +++ b/api/src/api_graphql/input/group_create_input.py @@ -6,9 +6,14 @@ class GroupCreateInput(InputABC): def __init__(self, src: dict): InputABC.__init__(self, src) + self._user_space_id = self.option("userSpaceId", int, required=True) self._name = self.option("name", str, required=True) self._roles = self.option("roles", list[int]) + @property + def user_space_id(self) -> int: + return self._user_space_id + @property def name(self) -> str: return self._name diff --git a/api/src/api_graphql/input/short_url_create_input.py b/api/src/api_graphql/input/short_url_create_input.py index 94c4b9e..19a8847 100644 --- a/api/src/api_graphql/input/short_url_create_input.py +++ b/api/src/api_graphql/input/short_url_create_input.py @@ -8,6 +8,7 @@ class ShortUrlCreateInput(InputABC): def __init__(self, src: dict): InputABC.__init__(self, src) + self._user_space_id = self.option("userSpaceId", int, required=True) self._short_url = self.option("shortUrl", str, required=True) self._target_url = self.option("targetUrl", str, required=True) self._description = self.option("description", str) @@ -15,6 +16,10 @@ class ShortUrlCreateInput(InputABC): self._domain_id = self.option("domainId", int) self._loading_screen = self.option("loadingScreen", bool) + @property + def user_space_id(self) -> int: + return self._user_space_id + @property def short_url(self) -> str: return self._short_url diff --git a/api/src/api_graphql/input/user_space_create_input.py b/api/src/api_graphql/input/user_space_create_input.py new file mode 100644 index 0000000..e12fadc --- /dev/null +++ b/api/src/api_graphql/input/user_space_create_input.py @@ -0,0 +1,18 @@ +from api_graphql.abc.input_abc import InputABC + + +class UserSpaceCreateInput(InputABC): + + def __init__(self, src: dict): + InputABC.__init__(self, src) + + self._name = self.option("name", str, required=True) + self._users = self.option("users", list[int]) + + @property + def name(self) -> str: + return self._name + + @property + def users(self) -> list[int]: + return self._users diff --git a/api/src/api_graphql/input/user_space_update_input.py b/api/src/api_graphql/input/user_space_update_input.py new file mode 100644 index 0000000..9d40397 --- /dev/null +++ b/api/src/api_graphql/input/user_space_update_input.py @@ -0,0 +1,23 @@ +from api_graphql.abc.input_abc import InputABC + + +class UserSpaceUpdateInput(InputABC): + + def __init__(self, src: dict): + InputABC.__init__(self, src) + + self._id = self.option("id", int, required=True) + self._name = self.option("name", str) + self._users = self.option("users", list[int]) + + @property + def id(self) -> int: + return self._id + + @property + def name(self) -> str: + return self._name + + @property + def users(self) -> list[int]: + return self._users diff --git a/api/src/api_graphql/mutation.py b/api/src/api_graphql/mutation.py index f996321..e23a529 100644 --- a/api/src/api_graphql/mutation.py +++ b/api/src/api_graphql/mutation.py @@ -1,5 +1,4 @@ from api_graphql.abc.mutation_abc import MutationABC -from api_graphql.require_any_resolvers import by_user_setup_mutation from service.permission.permissions_enum import Permissions @@ -44,28 +43,34 @@ class Mutation(MutationABC): ], ) self.add_mutation_type( - "group", - "Group", + "userSpace", + "UserSpace", require_any=( [ - Permissions.groups_create, - Permissions.groups_update, - Permissions.groups_delete, + Permissions.user_spaces_create, + Permissions.user_spaces_update, + Permissions.user_spaces_delete, ], - [by_user_setup_mutation], + [self._test], ), ) + self.add_mutation_type( + "group", + "Group", + require_any_permission=[ + Permissions.groups_create, + Permissions.groups_update, + Permissions.groups_delete, + ], + ) self.add_mutation_type( "shortUrl", "ShortUrl", - require_any=( - [ - Permissions.short_urls_create, - Permissions.short_urls_update, - Permissions.short_urls_delete, - ], - [by_user_setup_mutation], - ), + require_any_permission=[ + Permissions.short_urls_create, + Permissions.short_urls_update, + Permissions.short_urls_delete, + ], ) self.add_mutation_type( @@ -90,3 +95,7 @@ class Mutation(MutationABC): "privacy", "Privacy", ) + + @staticmethod + async def _test(*args, **kwargs): + return True diff --git a/api/src/api_graphql/mutations/group_mutation.py b/api/src/api_graphql/mutations/group_mutation.py index 16addbd..fc1c2ca 100644 --- a/api/src/api_graphql/mutations/group_mutation.py +++ b/api/src/api_graphql/mutations/group_mutation.py @@ -1,14 +1,11 @@ from typing import Optional -from api.route import Route from api_graphql.abc.mutation_abc import MutationABC from api_graphql.field.mutation_field_builder import MutationFieldBuilder from api_graphql.input.group_create_input import GroupCreateInput from api_graphql.input.group_update_input import GroupUpdateInput -from api_graphql.require_any_resolvers import by_user_setup_mutation -from core.configuration.feature_flags import FeatureFlags -from core.configuration.feature_flags_enum import FeatureFlagsEnum from core.logger import APILogger +from core.string import first_to_lower from data.schemas.public.group import Group from data.schemas.public.group_dao import groupDao from data.schemas.public.group_role_assignment import GroupRoleAssignment @@ -26,26 +23,38 @@ class GroupMutation(MutationABC): MutationFieldBuilder("create") .with_resolver(self.resolve_create) .with_input(GroupCreateInput) - .with_require_any([Permissions.groups_create], [by_user_setup_mutation]) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any_permission([Permissions.groups_create]) ) self.field( MutationFieldBuilder("update") .with_resolver(self.resolve_update) .with_input(GroupUpdateInput) - .with_require_any([Permissions.groups_update], [by_user_setup_mutation]) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any_permission([Permissions.groups_update]) ) self.field( MutationFieldBuilder("delete") .with_resolver(self.resolve_delete) - .with_require_any([Permissions.groups_delete], [by_user_setup_mutation]) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any_permission([Permissions.groups_delete]) ) self.field( MutationFieldBuilder("restore") .with_resolver(self.resolve_restore) - .with_require_any([Permissions.groups_delete], [by_user_setup_mutation]) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any_permission([Permissions.groups_delete]) ) @staticmethod @@ -87,12 +96,9 @@ class GroupMutation(MutationABC): group = Group( 0, obj.name, - ( - (await Route.get_user()).id - if await FeatureFlags.has_feature(FeatureFlagsEnum.per_user_setup) - else None - ), + obj.user_space_id, ) + gid = await groupDao.create(group) await cls._handle_group_role_assignments(gid, obj.roles) diff --git a/api/src/api_graphql/mutations/short_url_mutation.py b/api/src/api_graphql/mutations/short_url_mutation.py index b471fba..03102b3 100644 --- a/api/src/api_graphql/mutations/short_url_mutation.py +++ b/api/src/api_graphql/mutations/short_url_mutation.py @@ -3,10 +3,8 @@ from api_graphql.abc.mutation_abc import MutationABC from api_graphql.field.mutation_field_builder import MutationFieldBuilder from api_graphql.input.short_url_create_input import ShortUrlCreateInput from api_graphql.input.short_url_update_input import ShortUrlUpdateInput -from api_graphql.require_any_resolvers import by_user_setup_mutation -from core.configuration.feature_flags import FeatureFlags -from core.configuration.feature_flags_enum import FeatureFlagsEnum from core.logger import APILogger +from core.string import first_to_lower from data.schemas.public.domain_dao import domainDao from data.schemas.public.group_dao import groupDao from data.schemas.public.short_url import ShortUrl @@ -26,31 +24,46 @@ class ShortUrlMutation(MutationABC): MutationFieldBuilder("create") .with_resolver(self.resolve_create) .with_input(ShortUrlCreateInput) - .with_require_any([Permissions.short_urls_create], [by_user_setup_mutation]) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any_permission([Permissions.short_urls_create]) ) self.field( MutationFieldBuilder("update") .with_resolver(self.resolve_update) .with_input(ShortUrlUpdateInput) - .with_require_any([Permissions.short_urls_update], [by_user_setup_mutation]) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any_permission([Permissions.short_urls_update]) ) self.field( MutationFieldBuilder("delete") .with_resolver(self.resolve_delete) - .with_require_any([Permissions.short_urls_delete], [by_user_setup_mutation]) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any_permission([Permissions.short_urls_delete]) ) self.field( MutationFieldBuilder("restore") .with_resolver(self.resolve_restore) - .with_require_any([Permissions.short_urls_delete], [by_user_setup_mutation]) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any_permission([Permissions.short_urls_delete]) ) self.field( MutationFieldBuilder("trackVisit") .with_resolver(self.resolve_track_visit) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) .with_require_any_permission([Permissions.short_urls_update]) ) @@ -70,11 +83,7 @@ class ShortUrlMutation(MutationABC): obj.group_id, obj.domain_id, obj.loading_screen, - ( - (await Route.get_user()).id - if await FeatureFlags.has_feature(FeatureFlagsEnum.per_user_setup) - else None - ), + obj.user_space_id, ) nid = await shortUrlDao.create(short_url) return await shortUrlDao.get_by_id(nid) diff --git a/api/src/api_graphql/mutations/user_space_mutation.py b/api/src/api_graphql/mutations/user_space_mutation.py new file mode 100644 index 0000000..36690db --- /dev/null +++ b/api/src/api_graphql/mutations/user_space_mutation.py @@ -0,0 +1,125 @@ +from api.route import Route +from api_graphql.abc.mutation_abc import MutationABC +from api_graphql.field.mutation_field_builder import MutationFieldBuilder +from api_graphql.input.user_space_create_input import UserSpaceCreateInput +from api_graphql.input.user_space_update_input import UserSpaceUpdateInput +from core.logger import APILogger +from core.string import first_to_lower +from data.schemas.administration.user_dao import userDao +from data.schemas.public.user_space import UserSpace +from data.schemas.public.user_space_dao import userSpaceDao +from data.schemas.public.user_space_user import UserSpaceUser +from data.schemas.public.user_space_user_dao import userSpaceUserDao +from service.permission.permissions_enum import Permissions + +logger = APILogger(__name__) + + +class UserSpaceMutation(MutationABC): + def __init__(self): + MutationABC.__init__(self, "UserSpace") + + self.field( + MutationFieldBuilder("create") + .with_resolver(self.resolve_create) + .with_input(UserSpaceCreateInput) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any_permission([Permissions.user_spaces_create]) + ) + + self.field( + MutationFieldBuilder("update") + .with_resolver(self.resolve_update) + .with_input(UserSpaceUpdateInput) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any_permission([Permissions.user_spaces_update]) + ) + + self.field( + MutationFieldBuilder("delete") + .with_resolver(self.resolve_delete) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any_permission([Permissions.user_spaces_delete]) + ) + + self.field( + MutationFieldBuilder("restore") + .with_resolver(self.resolve_restore) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any_permission([Permissions.user_spaces_delete]) + ) + + async def resolve_create(self, obj: UserSpaceCreateInput, *_): + logger.debug(f"create user_space: {obj.__dict__}") + + already_exists = await userSpaceDao.find_by({UserSpace.name: obj.name}) + if len(already_exists) > 0: + raise ValueError(f"UserSpace {obj.name} already exists") + + user_space = UserSpace(0, obj.name, (await Route.get_user()).id) + gid = await userSpaceDao.create(user_space) + + await self._resolve_assignments( + obj.get("users", []), + user_space, + UserSpaceUser.user_space_id, + UserSpaceUser.user_id, + userSpaceDao, + userSpaceUserDao, + UserSpaceUser, + userDao, + ) + + return await userSpaceDao.get_by_id(gid) + + async def resolve_update(self, obj: UserSpaceUpdateInput, *_): + logger.debug(f"update user_space: {input}") + + if await userSpaceDao.find_by_id(obj.id) is None: + raise ValueError(f"UserSpace with id {obj.id} not found") + + user_space = await userSpaceDao.get_by_id(obj.id) + if obj.name is not None: + already_exists = await userSpaceDao.find_by( + {UserSpace.name: obj.name, UserSpace.id: {"ne": obj.id}} + ) + if len(already_exists) > 0: + raise ValueError(f"UserSpace {obj.name} already exists") + + user_space.name = obj.name + await userSpaceDao.update(user_space) + + await self._resolve_assignments( + obj.get("users", []), + user_space, + UserSpaceUser.user_space_id, + UserSpaceUser.user_id, + userSpaceDao, + userSpaceUserDao, + UserSpaceUser, + userDao, + ) + + return await userSpaceDao.get_by_id(obj.id) + + @staticmethod + async def resolve_delete(*_, id: str): + logger.debug(f"delete user_space: {id}") + user_space = await userSpaceDao.get_by_id(id) + await userSpaceDao.delete(user_space) + return True + + @staticmethod + async def resolve_restore(*_, id: str): + logger.debug(f"restore user_space: {id}") + user_space = await userSpaceDao.get_by_id(id) + await userSpaceDao.restore(user_space) + return True diff --git a/api/src/api_graphql/queries/group_history_query.py b/api/src/api_graphql/queries/group_history_query.py index dbfe220..28695ef 100644 --- a/api/src/api_graphql/queries/group_history_query.py +++ b/api/src/api_graphql/queries/group_history_query.py @@ -1,6 +1,6 @@ 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 by_assignment_resolver +from api_graphql.require_any_resolvers import by_group_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 @@ -14,6 +14,7 @@ class GroupHistoryQuery(DbHistoryModelQueryABC): DbHistoryModelQueryABC.__init__(self, "Group") self.set_field("name", lambda x, *_: x.name) + self.set_field("userSpace", lambda x, *_: x.user_space) self.field( ResolverFieldBuilder("shortUrls") .with_resolver(self._get_urls) @@ -21,7 +22,7 @@ class GroupHistoryQuery(DbHistoryModelQueryABC): [ Permissions.groups, ], - [by_assignment_resolver], + [by_group_assignment_resolver], ) ) self.set_field( diff --git a/api/src/api_graphql/queries/group_query.py b/api/src/api_graphql/queries/group_query.py index 8d5b559..be7334c 100644 --- a/api/src/api_graphql/queries/group_query.py +++ b/api/src/api_graphql/queries/group_query.py @@ -1,6 +1,7 @@ from api_graphql.abc.db_model_query_abc import DbModelQueryABC from api_graphql.field.resolver_field_builder import ResolverFieldBuilder -from api_graphql.require_any_resolvers import by_assignment_resolver +from api_graphql.require_any_resolvers import by_group_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 @@ -14,6 +15,7 @@ class GroupQuery(DbModelQueryABC): DbModelQueryABC.__init__(self, "Group", groupDao, with_history=True) self.set_field("name", lambda x, *_: x.name) + self.set_field("userSpace", lambda x, *_: x.user_space) self.field( ResolverFieldBuilder("shortUrls") .with_resolver(self._get_urls) @@ -21,7 +23,7 @@ class GroupQuery(DbModelQueryABC): [ Permissions.groups, ], - [by_assignment_resolver], + [by_group_assignment_resolver], ) ) self.set_field("roles", self._get_roles) diff --git a/api/src/api_graphql/queries/short_url_history_query.py b/api/src/api_graphql/queries/short_url_history_query.py index 2d56696..facfaed 100644 --- a/api/src/api_graphql/queries/short_url_history_query.py +++ b/api/src/api_graphql/queries/short_url_history_query.py @@ -8,7 +8,10 @@ class ShortUrlQuery(DbHistoryModelQueryABC): 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) + + self.set_field("group", lambda x, *_: x.group) + self.set_field("domain", lambda x, *_: x.domain) + + self.set_field("userSpace", lambda x, *_: x.user_space) diff --git a/api/src/api_graphql/queries/short_url_query.py b/api/src/api_graphql/queries/short_url_query.py index 1a103d6..c16d672 100644 --- a/api/src/api_graphql/queries/short_url_query.py +++ b/api/src/api_graphql/queries/short_url_query.py @@ -9,7 +9,10 @@ class ShortUrlQuery(DbModelQueryABC): 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) + + self.set_field("group", lambda x, *_: x.group) + self.set_field("domain", lambda x, *_: x.domain) + + self.set_field("userSpace", lambda x, *_: x.user_space) diff --git a/api/src/api_graphql/queries/user_space_history_query.py b/api/src/api_graphql/queries/user_space_history_query.py new file mode 100644 index 0000000..a74483f --- /dev/null +++ b/api/src/api_graphql/queries/user_space_history_query.py @@ -0,0 +1,22 @@ +from api_graphql.abc.db_history_model_query_abc import DbHistoryModelQueryABC +from data.schemas.administration.user_dao import userDao +from data.schemas.public.user_space_user_dao import userSpaceUserDao + + +class UserSpaceHistoryQuery(DbHistoryModelQueryABC): + def __init__(self): + DbHistoryModelQueryABC.__init__(self, "UserSpace") + + self.set_field("name", lambda x, *_: x.name) + self.set_field("owner", lambda x, *_: x.owner) + self.set_field( + "users", + lambda x, *_: self._resolve_foreign_history( + x.updated, + x.id, + userSpaceUserDao, + userDao, + lambda y: y.user_id, + obj_key="userspaceid", + ), + ) diff --git a/api/src/api_graphql/queries/user_space_query.py b/api/src/api_graphql/queries/user_space_query.py new file mode 100644 index 0000000..cdd1b72 --- /dev/null +++ b/api/src/api_graphql/queries/user_space_query.py @@ -0,0 +1,21 @@ +from api_graphql.abc.db_model_query_abc import DbModelQueryABC +from data.schemas.public.group_dao import groupDao +from data.schemas.public.short_url_dao import shortUrlDao +from data.schemas.public.user_space import UserSpace +from data.schemas.public.user_space_dao import userSpaceDao + + +class UserSpaceQuery(DbModelQueryABC): + def __init__(self): + DbModelQueryABC.__init__(self, "UserSpace", userSpaceDao, with_history=True) + + self.set_field("name", lambda x, *_: x.name) + self.set_field("owner", lambda x, *_: x.owner) + self.set_field("users", self._get_users) + + self.set_history_reference_dao(groupDao, "userspaceid") + self.set_history_reference_dao(shortUrlDao, "userspaceid") + + @staticmethod + async def _get_users(group: UserSpace, *_): + return await userSpaceDao.get(group.id) diff --git a/api/src/api_graphql/query.py b/api/src/api_graphql/query.py index 2e80df3..657ffc6 100644 --- a/api/src/api_graphql/query.py +++ b/api/src/api_graphql/query.py @@ -2,6 +2,7 @@ from api.auth.keycloak_client import Keycloak from api.route import Route from api_graphql.abc.query_abc import QueryABC from api_graphql.abc.sort_abc import Sort +from api_graphql.field.collection_field_builder import CollectionFieldBuilder from api_graphql.field.dao_field_builder import DaoFieldBuilder from api_graphql.field.resolver_field_builder import ResolverFieldBuilder from api_graphql.filter.api_key_filter import ApiKeyFilter @@ -11,12 +12,10 @@ from api_graphql.filter.permission_filter import PermissionFilter from api_graphql.filter.role_filter import RoleFilter from api_graphql.filter.short_url_filter import ShortUrlFilter from api_graphql.filter.user_filter import UserFilter +from api_graphql.filter.user_space_filter import UserSpaceFilter from api_graphql.require_any_resolvers import ( - by_assignment_resolver, - by_user_setup_resolver, + by_group_assignment_resolver, ) -from core.configuration.feature_flags import FeatureFlags -from core.configuration.feature_flags_enum import FeatureFlagsEnum from data.schemas.administration.api_key import ApiKey from data.schemas.administration.api_key_dao import apiKeyDao from data.schemas.administration.user import User @@ -33,6 +32,8 @@ from data.schemas.public.short_url import ShortUrl from data.schemas.public.short_url_dao import shortUrlDao from data.schemas.public.user_setting import UserSetting from data.schemas.public.user_setting_dao import userSettingDao +from data.schemas.public.user_space import UserSpace +from data.schemas.public.user_space_dao import userSpaceDao from data.schemas.system.feature_flag_dao import featureFlagDao from data.schemas.system.setting_dao import settingDao from service.permission.permissions_enum import Permissions @@ -109,13 +110,31 @@ class Query(QueryABC): .with_require_any_permission( [ Permissions.domains, - Permissions.short_urls_create, - Permissions.short_urls_update, + Permissions.domains_create, + Permissions.domains_update, ] ) ) - group_field = ( + self.field( + CollectionFieldBuilder("assignedUserSpaces").with_resolver( + self._resolve_assigned_user_spaces + ) + ) + + self.field( + DaoFieldBuilder("userSpaces") + .with_dao(userSpaceDao) + .with_filter(UserSpaceFilter) + .with_sort(Sort[UserSpace]) + .with_require_any_permission( + [ + Permissions.user_spaces, + ] + ) + ) + + self.field( DaoFieldBuilder("groups") .with_dao(groupDao) .with_filter(GroupFilter) @@ -126,35 +145,21 @@ class Query(QueryABC): Permissions.short_urls_create, Permissions.short_urls_update, ], - [by_assignment_resolver, by_user_setup_resolver], + [by_group_assignment_resolver], ) ) - if FeatureFlags.get_default(FeatureFlagsEnum.per_user_setup): - group_field = group_field.with_default_filter( - self._resolve_default_user_filter - ) - - self.field(group_field) - - short_url_field = ( + self.field( DaoFieldBuilder("shortUrls") .with_dao(shortUrlDao) .with_filter(ShortUrlFilter) .with_sort(Sort[ShortUrl]) .with_require_any( [Permissions.short_urls], - [by_assignment_resolver, by_user_setup_resolver], + [by_group_assignment_resolver], ) ) - if FeatureFlags.get_default(FeatureFlagsEnum.per_user_setup): - short_url_field = short_url_field.with_default_filter( - self._resolve_default_user_filter - ) - - self.field(short_url_field) - self.field( ResolverFieldBuilder("settings") .with_resolver(self._resolve_settings) @@ -230,3 +235,11 @@ class Query(QueryABC): @staticmethod async def _resolve_default_user_filter(*args, **kwargs) -> dict: return {"user": {"id": {"equal": (await Route.get_user()).id}}} + + @staticmethod + async def _resolve_assigned_user_spaces(*args, **kwargs): + user = await Route.get_user() + if user is None: + return [] + + return await userSpaceDao.get_assigned_by_user_id(user.id) diff --git a/api/src/api_graphql/require_any_resolvers.py b/api/src/api_graphql/require_any_resolvers.py index 8eea17f..153aaaf 100644 --- a/api/src/api_graphql/require_any_resolvers.py +++ b/api/src/api_graphql/require_any_resolvers.py @@ -1,12 +1,32 @@ from api_graphql.service.collection_result import CollectionResult from api_graphql.service.query_context import QueryContext -from core.configuration.feature_flags import FeatureFlags -from core.configuration.feature_flags_enum import FeatureFlagsEnum from data.schemas.public.group_dao import groupDao +from data.schemas.public.user_space_user_dao import userSpaceUserDao from service.permission.permissions_enum import Permissions -async def by_assignment_resolver(ctx: QueryContext) -> bool: +async def by_user_space_assignment_resolver(ctx: QueryContext) -> bool: + if not isinstance(ctx.data, CollectionResult): + return False + + user = ctx.user + + assigned_user_space_ids = { + us.user_space_id for us in await userSpaceUserDao.find_by_user_id(user.id) + } + + for node in ctx.data.nodes: + user_space = await node.user_space + if user_space is None: + continue + + if user_space.owner_id == user.id or user_space.id in assigned_user_space_ids: + return True + + return False + + +async def by_group_assignment_resolver(ctx: QueryContext) -> bool: if not isinstance(ctx.data, CollectionResult): return False @@ -27,17 +47,3 @@ async def by_assignment_resolver(ctx: QueryContext) -> bool: ) return False - - -async def by_user_setup_resolver(ctx: QueryContext) -> bool: - if not isinstance(ctx.data, CollectionResult): - return False - - if not FeatureFlags.has_feature(FeatureFlagsEnum.per_user_setup): - return False - - return all(x.user_id == ctx.user.id for x in ctx.data.nodes) - - -async def by_user_setup_mutation(ctx: QueryContext) -> bool: - return await FeatureFlags.has_feature(FeatureFlagsEnum.per_user_setup) diff --git a/api/src/api_graphql/subscription.py b/api/src/api_graphql/subscription.py index 02f0e57..6821afd 100644 --- a/api/src/api_graphql/subscription.py +++ b/api/src/api_graphql/subscription.py @@ -60,6 +60,11 @@ class Subscription(SubscriptionABC): .with_resolver(lambda message, *_: message.message) .with_require_any_permission([Permissions.domains]) ) + self.subscribe( + SubscriptionFieldBuilder("userSpaceChange").with_resolver( + lambda message, *_: message.message + ) + ) self.subscribe( SubscriptionFieldBuilder("groupChange") .with_resolver(lambda message, *_: message.message) diff --git a/api/src/core/configuration/feature_flags.py b/api/src/core/configuration/feature_flags.py index 8eaa333..4bccd0f 100644 --- a/api/src/core/configuration/feature_flags.py +++ b/api/src/core/configuration/feature_flags.py @@ -9,14 +9,9 @@ class FeatureFlags: _flags = { FeatureFlagsEnum.version_endpoint.value: True, # 15.01.2025 FeatureFlagsEnum.technical_demo_banner.value: False, # 18.04.2025 - FeatureFlagsEnum.per_user_setup.value: Environment.get( - "PER_USER_SETUP", bool, False - ), # 18.04.2025 } - _overwrite_flags = [ - FeatureFlagsEnum.per_user_setup.value, - ] + _overwrite_flags = [] @staticmethod def overwrite_flag(key: str): diff --git a/api/src/core/configuration/feature_flags_enum.py b/api/src/core/configuration/feature_flags_enum.py index c5af428..2c6ac5d 100644 --- a/api/src/core/configuration/feature_flags_enum.py +++ b/api/src/core/configuration/feature_flags_enum.py @@ -4,4 +4,3 @@ from enum import Enum class FeatureFlagsEnum(Enum): version_endpoint = "VersionEndpoint" technical_demo_banner = "TechnicalDemoBanner" - per_user_setup = "PerUserSetup" 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 a3003db..64518f7 100644 --- a/api/src/core/database/abc/data_access_object_abc.py +++ b/api/src/core/database/abc/data_access_object_abc.py @@ -37,6 +37,7 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): self.__db_names: dict[str, str] = {} self.__foreign_tables: dict[str, tuple[str, str]] = {} self.__foreign_table_keys: dict[str, str] = {} + self.__foreign_dao: dict[str, "DataAccessObjectABC"] = {} self.__date_attributes: set[str] = set() self.__ignored_attributes: set[str] = set() @@ -108,6 +109,7 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): primary_attr: Attribute, foreign_attr: Attribute, table_name: str, + reference_dao: "DataAccessObjectABC" = None, ): """ Add a reference to another table for the given attribute @@ -115,6 +117,7 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): :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 + :param DataAccessObjectABC reference_dao: The data access object for the referenced table :return: """ if isinstance(attr, property): @@ -131,6 +134,9 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): foreign_attr = foreign_attr.lower().replace("_", "") self.__foreign_table_keys[attr] = foreign_attr + if reference_dao is not None: + self.__foreign_dao[attr] = reference_dao + if table_name == self._table_name: return @@ -622,7 +628,27 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): external_table_deps.append(external_field) parse_node(value, f"{external_field}.{key}") elif parent_key in self.__foreign_table_keys: - parse_node({key: value}, self.__foreign_table_keys[parent_key]) + if key in operators: + parse_node({key: value}, self.__foreign_table_keys[parent_key]) + continue + + if parent_key in self.__foreign_dao: + foreign_dao = self.__foreign_dao[parent_key] + if key in foreign_dao.__foreign_tables: + x = f"{self.__foreign_tables[parent_key][0]}.{foreign_dao.__foreign_table_keys[key]}" + parse_node( + value, + f"{self.__foreign_tables[parent_key][0]}.{foreign_dao.__foreign_table_keys[key]}", + ) + continue + + if parent_key in self.__foreign_tables: + parse_node( + value, f"{self.__foreign_tables[parent_key][0]}.{key}" + ) + continue + + parse_node({parent_key: value}) elif key in operators: operator = operators[key] if key == "contains" or key == "notContains": @@ -633,6 +659,23 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): value = f"{value}%" elif key == "endsWith": value = f"%{value}" + elif key == "isNull" or key == "isNotNull": + is_null_value = ( + value.get("equal", None) + if isinstance(value, dict) + else value + ) + + if is_null_value is None: + operator = operators[key] + elif (key == "isNull" and is_null_value) or ( + key == "isNotNull" and not is_null_value + ): + operator = "IS NULL" + else: + operator = "IS NOT NULL" + + conditions.append((parent_key, operator, None)) elif (key == "equal" or key == "notEqual") and value is None: operator = operators["isNull"] @@ -641,6 +684,8 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]): elif isinstance(value, dict): if key in self.__foreign_table_keys: parse_node(value, key) + elif key in self.__db_names and parent_key is not None: + parse_node({f"{parent_key}": value}) elif key in self.__db_names: parse_node(value, self.__db_names[key]) else: diff --git a/api/src/data/schemas/permission/role_user.py b/api/src/data/schemas/permission/role_user.py index 6af5bfc..f210aed 100644 --- a/api/src/data/schemas/permission/role_user.py +++ b/api/src/data/schemas/permission/role_user.py @@ -3,11 +3,12 @@ from typing import Optional from async_property import async_property +from core.database.abc.db_join_model_abc import DbJoinModelABC from core.typing import SerialId from core.database.abc.db_model_abc import DbModelABC -class RoleUser(DbModelABC): +class RoleUser(DbJoinModelABC): def __init__( self, id: SerialId, @@ -18,7 +19,9 @@ class RoleUser(DbModelABC): created: Optional[datetime] = None, updated: Optional[datetime] = None, ): - DbModelABC.__init__(self, id, deleted, editor_id, created, updated) + DbJoinModelABC.__init__( + self, id, role_id, user_id, deleted, editor_id, created, updated + ) self._role_id = role_id self._user_id = user_id diff --git a/api/src/data/schemas/public/group.py b/api/src/data/schemas/public/group.py index da11936..a67398d 100644 --- a/api/src/data/schemas/public/group.py +++ b/api/src/data/schemas/public/group.py @@ -12,7 +12,7 @@ class Group(DbModelABC): self, id: SerialId, name: str, - user_id: Optional[SerialId] = None, + user_space_id: Optional[SerialId] = None, deleted: bool = False, editor_id: Optional[SerialId] = None, created: Optional[datetime] = None, @@ -20,7 +20,7 @@ class Group(DbModelABC): ): DbModelABC.__init__(self, id, deleted, editor_id, created, updated) self._name = name - self._user_id = user_id + self._user_space_id = user_space_id @property def name(self) -> str: @@ -31,15 +31,14 @@ class Group(DbModelABC): self._name = value @property - def user_id(self) -> Optional[SerialId]: - return self._user_id + def user_space_id(self) -> Optional[SerialId]: + return self._user_space_id @async_property - async def user(self): - if self._user_id is None: + async def user_space(self): + if self._user_space_id is None: return None - from data.schemas.administration.user_dao import userDao + from data.schemas.public.user_space_dao import userSpaceDao - user = await userDao.get_by_id(self.user_id) - return user + return await userSpaceDao.get_by_id(self._user_space_id) diff --git a/api/src/data/schemas/public/group_dao.py b/api/src/data/schemas/public/group_dao.py index e856f04..2fc8a70 100644 --- a/api/src/data/schemas/public/group_dao.py +++ b/api/src/data/schemas/public/group_dao.py @@ -11,8 +11,8 @@ class GroupDao(DbModelDaoABC[Group]): DbModelDaoABC.__init__(self, __name__, Group, "public.groups") self.attribute(Group.name, str) - self.attribute(Group.user_id, int) - self.reference("user", "id", Group.user_id, "administration.users") + self.attribute(Group.user_space_id, int) + self.reference("userspace", "id", Group.user_space_id, "public.user_spaces") async def get_by_name(self, name: str) -> Group: result = await self._db.select_map( diff --git a/api/src/data/schemas/public/short_url.py b/api/src/data/schemas/public/short_url.py index 8d23653..b36ac9b 100644 --- a/api/src/data/schemas/public/short_url.py +++ b/api/src/data/schemas/public/short_url.py @@ -18,7 +18,7 @@ class ShortUrl(DbModelABC): group_id: Optional[SerialId], domain_id: Optional[SerialId], loading_screen: Optional[str] = None, - user_id: Optional[SerialId] = None, + user_space_id: Optional[SerialId] = None, deleted: bool = False, editor_id: Optional[SerialId] = None, created: Optional[datetime] = None, @@ -35,7 +35,7 @@ class ShortUrl(DbModelABC): loading_screen = False self._loading_screen = loading_screen - self._user_id = user_id + self._user_space_id = user_space_id @property def short_url(self) -> str: @@ -110,18 +110,17 @@ class ShortUrl(DbModelABC): self._loading_screen = value @property - def user_id(self) -> Optional[SerialId]: - return self._user_id + def user_space_id(self) -> Optional[SerialId]: + return self._user_space_id @async_property - async def user(self): - if self._user_id is None: + async def user_space(self): + if self._user_space_id is None: return None - from data.schemas.administration.user_dao import userDao + from data.schemas.public.user_space_dao import userSpaceDao - user = await userDao.get_by_id(self.user_id) - return user + return await userSpaceDao.get_by_id(self._user_space_id) def to_dto(self) -> dict: return { diff --git a/api/src/data/schemas/public/short_url_dao.py b/api/src/data/schemas/public/short_url_dao.py index 66e0003..ced0e19 100644 --- a/api/src/data/schemas/public/short_url_dao.py +++ b/api/src/data/schemas/public/short_url_dao.py @@ -13,13 +13,17 @@ class ShortUrlDao(DbModelDaoABC[ShortUrl]): self.attribute(ShortUrl.target_url, str) self.attribute(ShortUrl.description, str) self.attribute(ShortUrl.group_id, int) - self.reference("group", "id", ShortUrl.group_id, "public.groups") + from data.schemas.public.group_dao import groupDao + + self.reference("group", "id", ShortUrl.group_id, "public.groups", groupDao) self.attribute(ShortUrl.domain_id, int) - self.reference("domain", "id", ShortUrl.domain_id, "public.domains") + from data.schemas.public.domain_dao import domainDao + + self.reference("domain", "id", ShortUrl.domain_id, "public.domains", domainDao) self.attribute(ShortUrl.loading_screen, bool) - self.attribute(ShortUrl.user_id, int) - self.reference("user", "id", ShortUrl.user_id, "administration.users") + self.attribute(ShortUrl.user_space_id, int) + self.reference("userspace", "id", ShortUrl.user_space_id, "public.user_spaces") shortUrlDao = ShortUrlDao() diff --git a/api/src/data/schemas/public/user_space.py b/api/src/data/schemas/public/user_space.py new file mode 100644 index 0000000..ac451ee --- /dev/null +++ b/api/src/data/schemas/public/user_space.py @@ -0,0 +1,45 @@ +from datetime import datetime +from typing import Optional + +from async_property import async_property + +from core.database.abc.db_model_abc import DbModelABC +from core.typing import SerialId + + +class UserSpace(DbModelABC): + def __init__( + self, + id: SerialId, + name: str, + owner_id: SerialId, + 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) + self._name = name + self._owner_id = owner_id + + @property + def name(self) -> str: + return self._name + + @name.setter + def name(self, value: str): + self._name = value + + @property + def owner_id(self) -> SerialId: + return self._owner_id + + @owner_id.setter + def owner_id(self, value: SerialId): + self._owner_id = value + + @async_property + async def owner(self): + from data.schemas.administration.user_dao import userDao + + return await userDao.get_by_id(self._owner_id) diff --git a/api/src/data/schemas/public/user_space_dao.py b/api/src/data/schemas/public/user_space_dao.py new file mode 100644 index 0000000..aedac49 --- /dev/null +++ b/api/src/data/schemas/public/user_space_dao.py @@ -0,0 +1,49 @@ +from core.database.abc.db_model_dao_abc import DbModelDaoABC +from core.logger import DBLogger +from data.schemas.public.user_space import UserSpace + +logger = DBLogger(__name__) + + +class UserSpaceDao(DbModelDaoABC[UserSpace]): + def __init__(self): + DbModelDaoABC.__init__(self, __name__, UserSpace, "public.user_spaces") + self.attribute(UserSpace.name, str) + self.attribute(UserSpace.owner_id, int) + + async def get_by_name(self, name: str) -> UserSpace: + result = await self._db.select_map( + f"SELECT * FROM {self._table_name} WHERE Name = '{name}'" + ) + return self.to_object(result[0]) + + async def get_users(self, user_space_id: int) -> list: + result = await self._db.select_map( + f""" + SELECT u.* + FROM administration.users u + JOIN public.user_spaces_users usu ON u.id = usu.userId + WHERE usu.userSpaceId = {user_space_id} + AND usu.deleted = FALSE + """ + ) + + from data.schemas.administration.user_dao import userDao + + return [userDao.to_object(x) for x in result] + + async def get_assigned_by_user_id(self, user_id: int): + result = await self._db.select_map( + f""" + SELECT DISTINCT us.* + FROM public.user_spaces us + LEFT JOIN public.user_spaces_users usu ON us.id = usu.userSpaceId + WHERE (usu.userId = {user_id} OR us.ownerId = {user_id}) + AND us.deleted = FALSE; + """ + ) + + return [self.to_object(x) for x in result] + + +userSpaceDao = UserSpaceDao() diff --git a/api/src/data/schemas/public/user_space_user.py b/api/src/data/schemas/public/user_space_user.py new file mode 100644 index 0000000..6c093e6 --- /dev/null +++ b/api/src/data/schemas/public/user_space_user.py @@ -0,0 +1,46 @@ +from datetime import datetime +from typing import Optional + +from async_property import async_property + +from core.database.abc.db_join_model_abc import DbJoinModelABC +from core.database.abc.db_model_abc import DbModelABC +from core.typing import SerialId + + +class UserSpaceUser(DbJoinModelABC): + def __init__( + self, + id: SerialId, + user_space_id: SerialId, + user_id: SerialId, + deleted: bool = False, + editor_id: Optional[SerialId] = None, + created: Optional[datetime] = None, + updated: Optional[datetime] = None, + ): + DbJoinModelABC.__init__( + self, id, user_space_id, user_id, deleted, editor_id, created, updated + ) + self._user_space_id = user_space_id + self._user_id = user_id + + @property + def user_space_id(self) -> SerialId: + return self._user_space_id + + @async_property + async def user_space(self): + from data.schemas.public.user_space_dao import userSpaceDao + + return await userSpaceDao.get_by_id(self._user_space_id) + + @property + def user_id(self) -> SerialId: + return self._user_id + + @async_property + async def user(self): + from data.schemas.administration.user_dao import userDao + + return await userDao.get_by_id(self._user_id) diff --git a/api/src/data/schemas/public/user_space_user_dao.py b/api/src/data/schemas/public/user_space_user_dao.py new file mode 100644 index 0000000..8ed77bc --- /dev/null +++ b/api/src/data/schemas/public/user_space_user_dao.py @@ -0,0 +1,35 @@ +from core.database.abc.db_model_dao_abc import DbModelDaoABC +from core.logger import DBLogger +from data.schemas.public.user_space_user import UserSpaceUser + +logger = DBLogger(__name__) + + +class UserSpaceUserDao(DbModelDaoABC[UserSpaceUser]): + def __init__(self): + DbModelDaoABC.__init__( + self, __name__, UserSpaceUser, "public.user_spaces_users" + ) + self.attribute(UserSpaceUser.user_space_id, str) + self.attribute(UserSpaceUser.user_id, str) + + async def get_by_user_space_id( + self, user_space_id: str, with_deleted=False + ) -> list[UserSpaceUser]: + f = [{UserSpaceUser.user_space_id: user_space_id}] + if not with_deleted: + f.append({UserSpaceUser.deleted: False}) + + return await self.find_by(f) + + async def get_by_user_id( + self, user_id: str, with_deleted=False + ) -> list[UserSpaceUser]: + f = [{UserSpaceUser.user_id: user_id}] + if not with_deleted: + f.append({UserSpaceUser.deleted: False}) + + return await self.find_by(f) + + +userSpaceUserDao = UserSpaceUserDao() diff --git a/api/src/data/scripts/2025-04-30-21-50-user-spaces2.sql b/api/src/data/scripts/2025-04-30-21-50-user-spaces2.sql new file mode 100644 index 0000000..fcea5a1 --- /dev/null +++ b/api/src/data/scripts/2025-04-30-21-50-user-spaces2.sql @@ -0,0 +1,79 @@ +CREATE TABLE IF NOT EXISTS public.user_spaces +( + Id SERIAL PRIMARY KEY, + Name VARCHAR(255) NOT NULL, + OwnerId INT NOT NULL REFERENCES administration.users (Id), + -- for history + Deleted BOOLEAN NOT NULL DEFAULT FALSE, + EditorId INT NULL REFERENCES administration.users (Id), + Created timestamptz NOT NULL DEFAULT NOW(), + Updated timestamptz NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS public.user_spaces_history +( + LIKE public.user_spaces +); + +CREATE TRIGGER user_spaces_history_trigger + BEFORE INSERT OR UPDATE OR DELETE + ON public.user_spaces + FOR EACH ROW +EXECUTE FUNCTION public.history_trigger_function(); + +CREATE TABLE IF NOT EXISTS public.user_spaces_users +( + Id SERIAL PRIMARY KEY, + UserSpaceId INT NOT NULL REFERENCES public.user_spaces (Id), + UserId INT NOT NULL REFERENCES administration.users (Id), + -- for history + Deleted BOOLEAN NOT NULL DEFAULT FALSE, + EditorId INT NULL REFERENCES administration.users (Id), + Created timestamptz NOT NULL DEFAULT NOW(), + Updated timestamptz NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS public.user_spaces_users_history +( + LIKE public.user_spaces_users +); + +CREATE TRIGGER user_spaces_users_history_trigger + BEFORE INSERT OR UPDATE OR DELETE + ON public.user_spaces_users + FOR EACH ROW +EXECUTE FUNCTION public.history_trigger_function(); + +-- add user spaces +INSERT INTO public.user_spaces (Name, OwnerId) +VALUES ('Default', (SELECT id FROM administration.users ORDER BY id LIMIT 1)); + +INSERT INTO public.user_spaces_users (UserSpaceId, UserId) +SELECT 1 AS UserSpaceId, u.id AS UserId +FROM administration.users u +WHERE u.deleted = FALSE; + +-- change other tables +ALTER TABLE public.groups + ADD COLUMN IF NOT EXISTS UserSpaceId INT NOT NULL REFERENCES public.user_spaces (Id) default 1; + +ALTER TABLE public.groups + DROP COLUMN IF EXISTS UserId; + +ALTER TABLE public.groups_history + ADD COLUMN IF NOT EXISTS UserSpaceId INT NOT NULL REFERENCES public.user_spaces (Id) default 1; + +ALTER TABLE public.groups_history + DROP COLUMN IF EXISTS UserId; + +ALTER TABLE public.short_urls + ADD COLUMN IF NOT EXISTS UserSpaceId INT NOT NULL REFERENCES public.user_spaces (Id) default 1; + +ALTER TABLE public.short_urls + DROP COLUMN IF EXISTS UserId; + +ALTER TABLE public.short_urls_history + ADD COLUMN IF NOT EXISTS UserSpaceId INT NOT NULL REFERENCES public.user_spaces (Id) default 1; + +ALTER TABLE public.short_urls_history + DROP COLUMN IF EXISTS UserId; \ No newline at end of file diff --git a/api/src/service/permission/permissions_enum.py b/api/src/service/permission/permissions_enum.py index 267a9bc..f054ff0 100644 --- a/api/src/service/permission/permissions_enum.py +++ b/api/src/service/permission/permissions_enum.py @@ -26,6 +26,18 @@ class Permissions(Enum): settings = "settings" settings_update = "settings.update" + # domains + domains = "domains" + domains_create = "domains.create" + domains_update = "domains.update" + domains_delete = "domains.delete" + + # user spaces + user_spaces = "user_spaces" + user_spaces_create = "user_spaces.create" + user_spaces_update = "user_spaces.update" + user_spaces_delete = "user_spaces.delete" + """ Permissions """ @@ -38,12 +50,6 @@ class Permissions(Enum): """ Public """ - # domains - domains = "domains" - domains_create = "domains.create" - domains_update = "domains.update" - domains_delete = "domains.delete" - # groups groups = "groups" groups_create = "groups.create" diff --git a/web/src/app/components/home/home.component.html b/web/src/app/components/home/home.component.html index 5f2c53f..e69de29 100644 --- a/web/src/app/components/home/home.component.html +++ b/web/src/app/components/home/home.component.html @@ -1 +0,0 @@ -
home works!
diff --git a/web/src/app/core/base/form-page-base.ts b/web/src/app/core/base/form-page-base.ts index e05c78b..ad2e32f 100644 --- a/web/src/app/core/base/form-page-base.ts +++ b/web/src/app/core/base/form-page-base.ts @@ -26,8 +26,12 @@ export abstract class FormPageBase< protected filterService = inject(FilterService); protected dataService = inject(PageDataService) as S; - protected constructor(idKey: string = 'id') { + private backRoute: string = '..'; + + protected constructor(idKey: string = 'id', backRoute: string = '..') { const id = this.route.snapshot.params[idKey]; + this.backRoute = backRoute; + this.validateRoute(id); this.buildForm(); @@ -46,7 +50,7 @@ export abstract class FormPageBase< } close() { - const backRoute = this.nodeId ? '../..' : '..'; + const backRoute = this.nodeId ? `${this.backRoute}/..` : this.backRoute; this.router.navigate([backRoute], { relativeTo: this.route }).then(() => { this.filterService.onLoad.emit(); diff --git a/web/src/app/core/guard/permission.guard.ts b/web/src/app/core/guard/permission.guard.ts index ca1a076..fc07bcf 100644 --- a/web/src/app/core/guard/permission.guard.ts +++ b/web/src/app/core/guard/permission.guard.ts @@ -4,7 +4,6 @@ import { Logger } from 'src/app/service/logger.service'; import { ToastService } from 'src/app/service/toast.service'; import { AuthService } from 'src/app/service/auth.service'; import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; -import { FeatureFlagService } from 'src/app/service/feature-flag.service'; const log = new Logger('PermissionGuard'); @@ -15,18 +14,11 @@ export class PermissionGuard { constructor( private router: Router, private toast: ToastService, - private auth: AuthService, - private features: FeatureFlagService + private auth: AuthService ) {} async canActivate(route: ActivatedRouteSnapshot): Promise{{ 'common.domain' | translate }}
{{ 'common.id' | translate }}
+ +{{ 'common.name' | translate }}
+ +