diff --git a/api/requirements.txt b/api/requirements.txt index 88df780..51ce70d 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -11,3 +11,4 @@ Jinja2==3.1.5 python-keycloak==5.3.1 python-multipart==0.0.20 websockets==15.0 +sqlparse==0.5.3 diff --git a/api/src/api/auth/keycloak_client.py b/api/src/api/auth/keycloak_client.py index 79ee820..466bd2b 100644 --- a/api/src/api/auth/keycloak_client.py +++ b/api/src/api/auth/keycloak_client.py @@ -9,18 +9,28 @@ class Keycloak: client: Optional[KeycloakOpenID] = None admin: Optional[KeycloakAdmin] = None + url: Optional[str] = None + client_id: Optional[str] = None + realm: Optional[str] = None + __client_secret: Optional[str] = None + @classmethod def init(cls): + cls.url = Environment.get("KEYCLOAK_URL", str) + cls.client_id = Environment.get("KEYCLOAK_CLIENT_ID", str) + cls.realm = Environment.get("KEYCLOAK_REALM", str) + cls.__client_secret = Environment.get("KEYCLOAK_CLIENT_SECRET", str) + cls.client = KeycloakOpenID( - server_url=Environment.get("KEYCLOAK_URL", str), - client_id=Environment.get("KEYCLOAK_CLIENT_ID", str), - realm_name=Environment.get("KEYCLOAK_REALM", str), - client_secret_key=Environment.get("KEYCLOAK_CLIENT_SECRET", str), + server_url=cls.url, + client_id=cls.client_id, + realm_name=cls.realm, + client_secret_key=cls.__client_secret, ) connection = KeycloakOpenIDConnection( - server_url=Environment.get("KEYCLOAK_URL", str), - client_id=Environment.get("KEYCLOAK_CLIENT_ID", str), - realm_name=Environment.get("KEYCLOAK_REALM", str), - client_secret_key=Environment.get("KEYCLOAK_CLIENT_SECRET", str), + server_url=cls.url, + client_id=cls.client_id, + realm_name=cls.realm, + client_secret_key=cls.__client_secret, ) cls.admin = KeycloakAdmin(connection=connection) 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/routes/invitation.py b/api/src/api/routes/invitation.py new file mode 100644 index 0000000..dc063a4 --- /dev/null +++ b/api/src/api/routes/invitation.py @@ -0,0 +1,72 @@ +from starlette.requests import Request +from starlette.responses import RedirectResponse, JSONResponse + +from api.auth.keycloak_client import Keycloak +from api.route import Route +from core.environment import Environment +from core.logger import Logger +from data.schemas.administration.user import User +from data.schemas.administration.user_dao import userDao +from data.schemas.public.user_invitation import UserInvitation +from data.schemas.public.user_invitation_dao import userInvitationDao +from data.schemas.public.user_space_user import UserSpaceUser +from data.schemas.public.user_space_user_dao import userSpaceUserDao + +BasePath = f"/api/invitation" +logger = Logger(__name__) + + +def _redirect_to_client(): + client_urls = Environment.get("CLIENT_URLS", str) + if client_urls is None: + return JSONResponse({"message": "Invitation accepted."}) + + return RedirectResponse(client_urls.split(",")[0], status_code=303) + + +@Route.get(f"{BasePath}/accept/{{invitation_id:path}}") +async def accept_invitation(request: Request): + invitation_id = request.path_params["invitation_id"] + + user_invitation = await userInvitationDao.find_single_by( + [{UserInvitation.invitation: invitation_id}] + ) + if user_invitation is None: + return JSONResponse({"error": "Invitation not found."}, 404) + + if user_invitation.deleted: + return _redirect_to_client() + + keycloak_user = Keycloak.admin.get_users({"email": user_invitation.email}) + if len(keycloak_user) == 0: + redirect_uri = f"{str(request.base_url)}api/invitation/accept/{invitation_id}" + registration_url = f"{Keycloak.url}/realms/{Keycloak.realm}/protocol/openid-connect/auth?prompt=create&client_id={Keycloak.client_id}&response_type=code&scope=openid&redirect_uri={redirect_uri}" + return RedirectResponse(registration_url, status_code=303) + + if user_invitation.email not in [ + x.email for x in await userDao.find_by([{User.deleted: False}]) + ]: + user_id = await userDao.create(User(0, keycloak_user[0]["id"])) + await userSpaceUserDao.create( + UserSpaceUser(0, user_invitation.user_space_id, user_id) + ) + + await userInvitationDao.delete(user_invitation) + + return _redirect_to_client() + + +@Route.get(f"{BasePath}/user-space/accept/{{invitation_id:path}}") +async def accept_user_space_invitation(request: Request): + invitation_id = request.path_params["invitation_id"] + + user_space_user = await userSpaceUserDao.find_single_by( + [{UserSpaceUser.invitation: invitation_id}, {UserSpaceUser.deleted: False}] + ) + if user_space_user is None: + return JSONResponse({"error": "Invitation not found or already accepted."}, 404) + + user_space_user.invitation = None + await userSpaceUserDao.update(user_space_user) + + return _redirect_to_client() diff --git a/api/src/api_graphql/abc/mutation_abc.py b/api/src/api_graphql/abc/mutation_abc.py index 95e61f4..b305bfe 100644 --- a/api/src/api_graphql/abc/mutation_abc.py +++ b/api/src/api_graphql/abc/mutation_abc.py @@ -1,5 +1,5 @@ from abc import abstractmethod -from typing import Type, Union +from typing import Type, Union, Any from api_graphql.abc.query_abc import QueryABC from api_graphql.field.mutation_field_builder import MutationFieldBuilder diff --git a/api/src/api_graphql/abc/query_abc.py b/api/src/api_graphql/abc/query_abc.py index c3d2ca6..e2564b5 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, @@ -240,6 +240,18 @@ class QueryABC(ObjectType): ): await self._require_any_permission(field.require_any_permission) + if isinstance(field, MutationField): + if field.require_any is not None: + await self._require_any( + None, + *field.require_any, + *args, + **kwargs, + ) + + result = await resolver(*args, **kwargs) + return result + result = await resolver(*args, **kwargs) if field.require_any is not None: 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/field/mutation_field_builder.py b/api/src/api_graphql/field/mutation_field_builder.py index 245045f..8ee5330 100644 --- a/api/src/api_graphql/field/mutation_field_builder.py +++ b/api/src/api_graphql/field/mutation_field_builder.py @@ -60,7 +60,7 @@ class MutationFieldBuilder(FieldBuilderABC): def with_input( self, - input_type: Type[Union[InputABC, str, int, bool]], + input_type: Type[Union[InputABC, str, int, bool, list]], input_key: str = "input", ) -> Self: self._input_type = input_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..dafada4 --- /dev/null +++ b/api/src/api_graphql/graphql/user_space.gql @@ -0,0 +1,90 @@ +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 + + inviteUsers(id: Int!, emails: [String!]!): 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..dac85fb 100644 --- a/api/src/api_graphql/mutation.py +++ b/api/src/api_graphql/mutation.py @@ -1,5 +1,6 @@ from api_graphql.abc.mutation_abc import MutationABC -from api_graphql.require_any_resolvers import by_user_setup_mutation +from api_graphql.require_any_resolvers import has_assigned_user_spaces +from api_graphql.service.query_context import QueryContext from service.permission.permissions_enum import Permissions @@ -43,6 +44,18 @@ class Mutation(MutationABC): Permissions.domains_delete, ], ) + self.add_mutation_type( + "userSpace", + "UserSpace", + require_any=( + [ + Permissions.user_spaces_create, + Permissions.user_spaces_update, + Permissions.user_spaces_delete, + ], + [lambda ctx: True], + ), + ) self.add_mutation_type( "group", "Group", @@ -52,7 +65,7 @@ class Mutation(MutationABC): Permissions.groups_update, Permissions.groups_delete, ], - [by_user_setup_mutation], + [has_assigned_user_spaces], ), ) self.add_mutation_type( @@ -64,7 +77,7 @@ class Mutation(MutationABC): Permissions.short_urls_update, Permissions.short_urls_delete, ], - [by_user_setup_mutation], + [has_assigned_user_spaces], ), ) diff --git a/api/src/api_graphql/mutations/group_mutation.py b/api/src/api_graphql/mutations/group_mutation.py index 16addbd..e430e11 100644 --- a/api/src/api_graphql/mutations/group_mutation.py +++ b/api/src/api_graphql/mutations/group_mutation.py @@ -1,14 +1,12 @@ 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 api_graphql.require_any_resolvers import has_assigned_user_spaces 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 +24,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([Permissions.groups_create], [has_assigned_user_spaces]) ) 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([Permissions.groups_update], [has_assigned_user_spaces]) ) 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([Permissions.groups_delete], [has_assigned_user_spaces]) ) 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([Permissions.groups_delete], [has_assigned_user_spaces]) ) @staticmethod @@ -87,12 +97,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) @@ -107,9 +114,7 @@ class GroupMutation(MutationABC): raise ValueError(f"Group with id {obj.id} not found") if obj.name is not None: - already_exists = await groupDao.find_by( - {Group.name: obj.name, Group.id: {"ne": obj.id}} - ) + already_exists = await groupDao.find_by({Group.name: obj.name}) if len(already_exists) > 0: raise ValueError(f"Group {obj.name} already exists") diff --git a/api/src/api_graphql/mutations/short_url_mutation.py b/api/src/api_graphql/mutations/short_url_mutation.py index b471fba..0c537eb 100644 --- a/api/src/api_graphql/mutations/short_url_mutation.py +++ b/api/src/api_graphql/mutations/short_url_mutation.py @@ -3,10 +3,9 @@ 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 api_graphql.require_any_resolvers import has_assigned_user_spaces 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 +25,54 @@ 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( + [Permissions.short_urls_create], [has_assigned_user_spaces] + ) ) 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( + [Permissions.short_urls_update], [has_assigned_user_spaces] + ) ) 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( + [Permissions.short_urls_delete], [has_assigned_user_spaces] + ) ) 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( + [Permissions.short_urls_delete], [has_assigned_user_spaces] + ) ) 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 +92,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_setting_mutation.py b/api/src/api_graphql/mutations/user_setting_mutation.py index ff1be20..b06f9f4 100644 --- a/api/src/api_graphql/mutations/user_setting_mutation.py +++ b/api/src/api_graphql/mutations/user_setting_mutation.py @@ -22,12 +22,12 @@ class UserSettingMutation(MutationABC): f"{first_to_lower(self.name.replace("Mutation", ""))}Change" ) .with_input(UserSettingInput, "input") - .with_require_any([], [self._x]) + .with_require_any([], [self._check_user]) ) @staticmethod - async def _x(ctx): - return ctx.data.user_id == (await Route.get_user()).id + async def _check_user(ctx): + return ctx.user.id == (await Route.get_user()).id @staticmethod async def resolve_change(obj: UserSettingInput, *_): 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..0f95436 --- /dev/null +++ b/api/src/api_graphql/mutations/user_space_mutation.py @@ -0,0 +1,221 @@ +from uuid import uuid4 + +from api.middleware.request import get_request +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 api_graphql.service.query_context import QueryContext +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_invitation import UserInvitation +from data.schemas.public.user_invitation_dao import userInvitationDao +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.mail_service import mailService +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" + ) + ) + + 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( + [Permissions.user_spaces_update], + [self._resolve_input_user_space_assigned], + ) + ) + + self.field( + MutationFieldBuilder("delete") + .with_resolver(self.resolve_delete) + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any( + [Permissions.user_spaces_delete], + [self._resolve_input_user_space_assigned], + ) + ) + + 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]) + ) + + self.field( + MutationFieldBuilder("inviteUsers") + .with_resolver(self.resolve_invite_users) + .with_input(list, "emails") + .with_change_broadcast( + f"{first_to_lower(self.name.replace("Mutation", ""))}Change" + ) + .with_require_any( + [Permissions.user_spaces_update], + [self._resolve_input_user_space_assigned], + ) + ) + + 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}) + 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 + + @staticmethod + async def resolve_invite_users(emails: list[str], *_, id: str): + logger.debug(f"invite users to user_space: {id} with emails: {emails}") + + user_space = await userSpaceDao.get_by_id(id) + user_space_user_emails = [ + x.email for x in await userSpaceDao.get_users(user_space.id) + ] + + email_invitations = {} + + base_url = str(get_request().base_url) + for email in emails: + if email in user_space_user_emails: + continue + user = await userDao.find_single_by({"email": {"equal": email}}) + + invitation = uuid4() + if user is None: + user_invitation = await userInvitationDao.find_by( + [ + {UserInvitation.email: email}, + {UserInvitation.user_space_id: user_space.id}, + ] + ) + if len(user_invitation) > 0: + continue + + await userInvitationDao.create( + UserInvitation( + 0, + email, + str(invitation), + user_space.id, + ) + ) + email_invitations[email] = ( + f"{base_url}api/invitation/accept/{invitation}" + ) + continue + + email_invitations[email] = ( + f"{base_url}api/invitation/user-space/accept/{invitation}" + ) + await userSpaceUserDao.create( + UserSpaceUser(0, user_space.id, user.id, str(invitation)) + ) + + for email, invitation in email_invitations.items(): + logger.debug(f"Invitation for {email}: {invitation}") + mailService.send( + email, + "You're invited to join Open-Redirect", + f"Hi,\n\nYou’ve been invited to join Open-Redirect, a secure collaboration platform for your team. " + f"To complete your registration, please use the following invitation code:\n\n" + f"{invitation}\n\n" + "If you weren’t expecting this invitation, you can safely ignore this message.\n\n" + "Thanks,\n" + "The Open-Redirect Team", + ) + + return True + + @staticmethod + async def _resolve_input_user_space_assigned(ctx: QueryContext): + check_dict = ctx.kwargs + if "input" in ctx.kwargs: + check_dict = ctx.kwargs["input"] + + return "id" in check_dict and check_dict["id"] in [ + x.id for x in await userSpaceDao.get_assigned_by_user_id(ctx.user.id) + ] 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..e022232 --- /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(space: UserSpace, *_): + return await userSpaceDao.get_users(space.id, with_invitations=False) diff --git a/api/src/api_graphql/query.py b/api/src/api_graphql/query.py index 2e80df3..8e3f3be 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,12 @@ 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, + by_user_space_assignment_resolver, + has_assigned_user_spaces, ) -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 +34,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 @@ -56,19 +59,21 @@ class Query(QueryABC): .with_filter(PermissionFilter) .with_sort(Sort[Permission]) ) + self.field( DaoFieldBuilder("roles") .with_dao(roleDao) .with_filter(RoleFilter) .with_sort(Sort[Role]) - .with_require_any_permission( + .with_require_any( [ Permissions.roles, Permissions.users_create, Permissions.users_update, Permissions.groups_create, Permissions.groups_update, - ] + ], + [has_assigned_user_spaces], ) ) @@ -106,16 +111,36 @@ class Query(QueryABC): .with_dao(domainDao) .with_filter(DomainFilter) .with_sort(Sort[Domain]) - .with_require_any_permission( + .with_require_any( [ Permissions.domains, - Permissions.short_urls_create, - Permissions.short_urls_update, - ] + Permissions.domains_create, + Permissions.domains_update, + ], + [has_assigned_user_spaces], ) ) - 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( + [ + Permissions.user_spaces, + ], + [lambda ctx: all(x.owner_id == ctx.user.id for x in ctx.data.nodes)], + ) + ) + + self.field( DaoFieldBuilder("groups") .with_dao(groupDao) .with_filter(GroupFilter) @@ -126,35 +151,21 @@ class Query(QueryABC): Permissions.short_urls_create, Permissions.short_urls_update, ], - [by_assignment_resolver, by_user_setup_resolver], + [by_user_space_assignment_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_user_space_assignment_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 +241,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..cff57fe 100644 --- a/api/src/api_graphql/require_any_resolvers.py +++ b/api/src/api_graphql/require_any_resolvers.py @@ -1,12 +1,36 @@ 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_dao import userSpaceDao +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 + + if len(ctx.data.nodes) == 0: + return True + + user = ctx.user + + assigned_user_space_ids = { + us.user_space_id for us in await userSpaceUserDao.get_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 len(ctx.data.nodes) == 0 + + +async def by_group_assignment_resolver(ctx: QueryContext) -> bool: if not isinstance(ctx.data, CollectionResult): return False @@ -29,15 +53,6 @@ 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) +async def has_assigned_user_spaces(ctx: QueryContext): + user_spaces = await userSpaceDao.get_assigned_by_user_id(ctx.user.id) + return len(user_spaces) > 0 diff --git a/api/src/api_graphql/subscription.py b/api/src/api_graphql/subscription.py index 02f0e57..ff499d7 100644 --- a/api/src/api_graphql/subscription.py +++ b/api/src/api_graphql/subscription.py @@ -1,5 +1,10 @@ +from typing import AsyncGenerator + +from api.broadcast import broadcast +from api.route import Route from api_graphql.abc.subscription_abc import SubscriptionABC from api_graphql.field.subscription_field_builder import SubscriptionFieldBuilder +from api_graphql.require_any_resolvers import has_assigned_user_spaces from service.permission.permissions_enum import Permissions @@ -49,9 +54,16 @@ class Subscription(SubscriptionABC): .with_public(True) ) + async def _user_logout_generator(*args, **kwargs) -> AsyncGenerator[str, None]: + async with broadcast.subscribe(channel="userLogout") as subscriber: + async for message in subscriber: + if message.message == (await Route.get_user()).id: + yield message + self.subscribe( SubscriptionFieldBuilder("userLogout") .with_resolver(lambda message, *_: message.message) + .with_generator(_user_logout_generator) .with_public(True) ) @@ -60,13 +72,18 @@ 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) + .with_public(True) + ) self.subscribe( SubscriptionFieldBuilder("groupChange") .with_resolver(lambda message, *_: message.message) - .with_require_any_permission([Permissions.groups]) + .with_require_any([Permissions.groups], [has_assigned_user_spaces]) ) self.subscribe( SubscriptionFieldBuilder("shortUrlChange") .with_resolver(lambda message, *_: message.message) - .with_require_any_permission([Permissions.short_urls]) + .with_require_any([Permissions.short_urls], [has_assigned_user_spaces]) ) 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/core/database/postgres_pool.py b/api/src/core/database/postgres_pool.py index 6115c40..87d8b1c 100644 --- a/api/src/core/database/postgres_pool.py +++ b/api/src/core/database/postgres_pool.py @@ -1,5 +1,6 @@ from typing import Optional, Any +import sqlparse from psycopg import sql from psycopg_pool import AsyncConnectionPool, PoolTimeout @@ -44,7 +45,9 @@ class PostgresPool: @staticmethod async def _exec_sql(cursor: Any, query: str, args=None, multi=True): if multi: - queries = query.split(";") + queries = [ + str(stmt).strip() for stmt in sqlparse.parse(query) if str(stmt).strip() + ] for q in queries: if q.strip() == "": continue 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/short_url_visit_dao.py b/api/src/data/schemas/public/short_url_visit_dao.py index 99f72a5..959c25a 100644 --- a/api/src/data/schemas/public/short_url_visit_dao.py +++ b/api/src/data/schemas/public/short_url_visit_dao.py @@ -15,7 +15,9 @@ class ShortUrlVisitDao(DbModelDaoABC[ShortUrlVisit]): async def count_by_id(self, fid: int) -> int: result = await self._db.select_map( - f"SELECT COUNT(*) FROM {self._table_name} WHERE shortUrlId = {fid}" + f"""SELECT COUNT(*) FROM {self._table_name} + WHERE shortUrlId = {fid} + AND deleted = FALSE""" ) if result is None or len(result) == 0: return 0 diff --git a/api/src/data/schemas/public/user_invitation.py b/api/src/data/schemas/public/user_invitation.py new file mode 100644 index 0000000..182446e --- /dev/null +++ b/api/src/data/schemas/public/user_invitation.py @@ -0,0 +1,51 @@ +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 UserInvitation(DbModelABC): + def __init__( + self, + id: SerialId, + email: str, + invitation: str, + user_space_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._email = email + self._user_space_id = user_space_id + self._invitation = invitation + + @property + def email(self) -> str: + return self._email + + @email.setter + def email(self, value: str): + self._email = value + + @property + def invitation(self) -> Optional[str]: + return self._invitation + + @invitation.setter + def invitation(self, value: Optional[str]): + self._invitation = value + + @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) diff --git a/api/src/data/schemas/public/user_invitation_dao.py b/api/src/data/schemas/public/user_invitation_dao.py new file mode 100644 index 0000000..0aae2e9 --- /dev/null +++ b/api/src/data/schemas/public/user_invitation_dao.py @@ -0,0 +1,16 @@ +from core.database.abc.db_model_dao_abc import DbModelDaoABC +from data.schemas.public.user_invitation import UserInvitation + + +class UserInvitationDao(DbModelDaoABC[UserInvitation]): + + def __init__(self): + DbModelDaoABC.__init__( + self, __name__, UserInvitation, "public.user_invitations" + ) + self.attribute(UserInvitation.email, str) + self.attribute(UserInvitation.invitation, str) + self.attribute(UserInvitation.user_space_id, int) + + +userInvitationDao = UserInvitationDao() 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..fd8c818 --- /dev/null +++ b/api/src/data/schemas/public/user_space_dao.py @@ -0,0 +1,50 @@ +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, with_invitations=True) -> 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 + {'' if with_invitations else 'AND usu.invitation IS NULL'} + """ + ) + + 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..5c9fa39 --- /dev/null +++ b/api/src/data/schemas/public/user_space_user.py @@ -0,0 +1,56 @@ +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, + invitation: Optional[str] = None, + 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 + self._invitation = invitation + + @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) + + @property + def invitation(self) -> Optional[str]: + return self._invitation + + @invitation.setter + def invitation(self, value: Optional[str]): + self._invitation = value 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..280b8b6 --- /dev/null +++ b/api/src/data/schemas/public/user_space_user_dao.py @@ -0,0 +1,39 @@ +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, int) + self.attribute(UserSpaceUser.user_id, int) + self.attribute(UserSpaceUser.invitation, 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}, + {UserSpaceUser.invitation: None}, + ] + 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}, {UserSpaceUser.invitation: None}] + 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/data/scripts/2025-05-01-17-40-user-spaces2-invitation.sql b/api/src/data/scripts/2025-05-01-17-40-user-spaces2-invitation.sql new file mode 100644 index 0000000..e432dff --- /dev/null +++ b/api/src/data/scripts/2025-05-01-17-40-user-spaces2-invitation.sql @@ -0,0 +1,5 @@ +ALTER TABLE public.user_spaces_users + ADD COLUMN invitation UUID DEFAULT NULL; + +ALTER TABLE public.user_spaces_users_history + ADD COLUMN invitation UUID DEFAULT NULL; diff --git a/api/src/data/scripts/2025-05-01-17-40-user-spaces2-new-user-invitation.sql b/api/src/data/scripts/2025-05-01-17-40-user-spaces2-new-user-invitation.sql new file mode 100644 index 0000000..66fc27f --- /dev/null +++ b/api/src/data/scripts/2025-05-01-17-40-user-spaces2-new-user-invitation.sql @@ -0,0 +1,23 @@ +CREATE TABLE public.user_invitations +( + id SERIAL PRIMARY KEY, + email VARCHAR(255) NOT NULL, + invitation UUID NOT NULL, + userSpaceId INT NOT NULL REFERENCES public.user_spaces (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 public.user_invitations_history +( + LIKE public.user_invitations +); + +CREATE TRIGGER user_invitations_history_trigger + BEFORE INSERT OR UPDATE OR DELETE + ON public.user_invitations + FOR EACH ROW +EXECUTE FUNCTION public.history_trigger_function(); \ No newline at end of file diff --git a/api/src/data/scripts/2025-05-02-01-00-user-spaces-unique-invites.sql b/api/src/data/scripts/2025-05-02-01-00-user-spaces-unique-invites.sql new file mode 100644 index 0000000..b967bef --- /dev/null +++ b/api/src/data/scripts/2025-05-02-01-00-user-spaces-unique-invites.sql @@ -0,0 +1,10 @@ +CREATE UNIQUE INDEX IF NOT EXISTS user_invitations_unique_email + ON public.user_invitations (email) + WHERE email != 'ANONYMOUS'; + +ALTER TABLE public.user_invitations + ADD CONSTRAINT user_invitations_unique_invitation UNIQUE (invitation); + +ALTER TABLE public.user_spaces_users + ADD CONSTRAINT user_spaces_users_unique_invitation UNIQUE (invitation), + ADD CONSTRAINT user_spaces_users_unique_entry UNIQUE (userspaceid, userid); \ No newline at end of file diff --git a/api/src/redirector.py b/api/src/redirector.py index 28ff9b8..a47cee5 100644 --- a/api/src/redirector.py +++ b/api/src/redirector.py @@ -83,7 +83,26 @@ def _find_short_url_by_path(path: str) -> Optional[dict]: json={ "query": f""" query getShortUrlByPath($path: String!) {{ - shortUrls(filter: {{ shortUrl: {{ equal: $path }}, deleted: {{ equal: false }}, group: {{ deleted: {{ equal: false }} }} }}) {{ + shortUrls(filter: [{{ shortUrl: {{ equal: $path }} }}, {{ deleted: {{ equal: false }} }}, {{ group: {{ deleted: {{ equal: false }} }} }}]) {{ + nodes {{ + id + shortUrl + targetUrl + description + group {{ + id + name + }} + domain {{ + id + name + }} + loadingScreen + deleted + }} + }} + + shortUrlsWithoutGroup: shortUrls(filter: [{{ shortUrl: {{ equal: $path }} }}, {{ deleted: {{ equal: false }} }}, {{ group: {{ isNull: true }} }}]) {{ nodes {{ id shortUrl @@ -115,14 +134,18 @@ def _find_short_url_by_path(path: str) -> Optional[dict]: "data" not in data or "shortUrls" not in data["data"] or "nodes" not in data["data"]["shortUrls"] + or "nodes" not in data["data"]["shortUrlsWithoutGroup"] ): return None - data = data["data"]["shortUrls"]["nodes"] - if len(data) == 0: + nodes = [ + *data["data"]["shortUrls"]["nodes"], + *data["data"]["shortUrlsWithoutGroup"]["nodes"], + ] + if len(nodes) == 0: return None - return data[0] + return nodes[0] async def _handle_short_url(request: Request, short_url: dict): @@ -145,7 +168,7 @@ async def _track_visit(r: Request, short_url: dict): f"{api_url}/graphql", json={ "query": f""" - mutation trackShortUrlVisit($id: ID!, $agent: String) {{ + mutation trackShortUrlVisit($id: Int!, $agent: String) {{ shortUrl {{ trackVisit(id: $id, agent: $agent) }} diff --git a/api/src/service/data_privacy_service.py b/api/src/service/data_privacy_service.py index e0d6d82..044df4c 100644 --- a/api/src/service/data_privacy_service.py +++ b/api/src/service/data_privacy_service.py @@ -9,6 +9,7 @@ from core.database.abc.db_model_dao_abc import DbModelDaoABC from core.logger import Logger from core.string import first_to_lower from data.schemas.administration.user_dao import userDao +from data.schemas.public.user_invitation_dao import userInvitationDao logger = Logger("DataPrivacy") @@ -77,7 +78,16 @@ class DataPrivacyService: keycloak_id = user.keycloak_id # Anonymize internal data + user_invitations = await userInvitationDao.find_by( + [{"email": {"equal": user.email}}] + ) + for user_invitation in user_invitations: + user_invitation.email = "ANONYMOUS" + user_invitation.deleted = True + await userInvitationDao.update(user_invitation) + await user.anonymize() + await userDao.delete(user) # Anonymize external data try: diff --git a/api/src/service/mail_service.py b/api/src/service/mail_service.py new file mode 100644 index 0000000..884ce14 --- /dev/null +++ b/api/src/service/mail_service.py @@ -0,0 +1,46 @@ +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +from core.environment import Environment +from core.logger import Logger + +logger = Logger("MailService") + + +class MailService: + + def __init__(self): + self._server = Environment.get("MAIL_SERVER", str) + self._port = Environment.get("MAIL_PORT", int) + self._user = Environment.get("MAIL_USER", str) + self._password = Environment.get("MAIL_PASSWORD", str) + self._from = Environment.get("MAIL_FROM", str) + + if not all([self._server, self._port, self._user, self._password, self._from]): + raise ValueError("Mail server configuration is incomplete.") + + def send(self, to: str, subject: str, body: str): + logger.debug(f"Preparing to send email to: {to}, subject: {subject}") + + msg = MIMEMultipart() + msg["From"] = self._from + msg["To"] = to + msg["Subject"] = subject + + msg.attach(MIMEText(body, "plain")) + + try: + logger.info(f"Connecting to mail server: {self._server}:{self._port}") + with smtplib.SMTP(self._server, self._port) as server: + server.starttls() + logger.debug("Starting TLS encryption") + server.login(self._user, self._password) + logger.info(f"Logged in as: {self._user}") + server.sendmail(self._from, to, msg.as_string()) + logger.info(f"Email successfully sent to: {to}") + except Exception as e: + logger.error(f"Failed to send email to {to}", e) + + +mailService = MailService() 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..684ee2c 100644 --- a/web/src/app/core/guard/permission.guard.ts +++ b/web/src/app/core/guard/permission.guard.ts @@ -4,7 +4,7 @@ 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'; +import { SidebarService } from 'src/app/service/sidebar.service'; const log = new Logger('PermissionGuard'); @@ -16,16 +16,18 @@ export class PermissionGuard { private router: Router, private toast: ToastService, private auth: AuthService, - private features: FeatureFlagService + private sidebar: SidebarService ) {} async canActivate(route: ActivatedRouteSnapshot): Promise{{ 'common.domain' | translate }}
{{ 'common.id' | translate }}
+ +{{ 'common.name' | translate }}
+ +{{ 'user_space.assign_users' | translate }}
+{{ 'user_space.invite_users' | translate }}
+