Merge pull request 'Added user spaces' (#24) from #15_user_spaces into dev
Reviewed-on: #24 Closes #15
This commit is contained in:
commit
bf0f5aa54d
@ -1,7 +1,6 @@
|
||||
from abc import abstractmethod
|
||||
from typing import Type, Union
|
||||
|
||||
from api_graphql.abc.input_abc import InputABC
|
||||
from api_graphql.abc.query_abc import QueryABC
|
||||
from api_graphql.field.mutation_field_builder import MutationFieldBuilder
|
||||
from core.database.abc.data_access_object_abc import DataAccessObjectABC
|
||||
@ -22,6 +21,7 @@ class MutationABC(QueryABC):
|
||||
name: str,
|
||||
mutation_name: str,
|
||||
require_any_permission=None,
|
||||
require_any=None,
|
||||
public: bool = False,
|
||||
):
|
||||
"""
|
||||
@ -29,24 +29,30 @@ class MutationABC(QueryABC):
|
||||
:param str name: GraphQL mutation name
|
||||
:param str mutation_name: Internal (class) mutation name without "Mutation" suffix
|
||||
:param list[Permissions] require_any_permission: List of permissions required to access the field
|
||||
:param tuple[list[Permissions], list[callable]] require_any: List of permissions and resolvers required to access the field
|
||||
:param bool public: Define if the field can resolve without authentication
|
||||
:return:
|
||||
"""
|
||||
if require_any_permission is None:
|
||||
require_any_permission = []
|
||||
from api_graphql.definition import QUERIES
|
||||
|
||||
self.field(
|
||||
field = (
|
||||
MutationFieldBuilder(name)
|
||||
.with_resolver(
|
||||
lambda *args, **kwargs: [
|
||||
x for x in QUERIES if x.name == f"{mutation_name}Mutation"
|
||||
][0]
|
||||
)
|
||||
.with_require_any_permission(require_any_permission)
|
||||
.with_public(public)
|
||||
)
|
||||
|
||||
if require_any_permission is not None:
|
||||
field.with_require_any_permission(require_any_permission)
|
||||
|
||||
if require_any is not None:
|
||||
field.with_require_any(*require_any)
|
||||
|
||||
self.field(field)
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_assignments(
|
||||
foreign_objs: list[int],
|
||||
|
@ -79,6 +79,7 @@ class QueryABC(ObjectType):
|
||||
):
|
||||
return
|
||||
|
||||
resolver_results = []
|
||||
for x in resolvers:
|
||||
user = await Route.get_authenticated_user_or_api_key_or_default()
|
||||
user_permissions = []
|
||||
@ -86,14 +87,16 @@ class QueryABC(ObjectType):
|
||||
user_permissions = await user.permissions
|
||||
|
||||
if iscoroutinefunction(x):
|
||||
result = await x(
|
||||
QueryContext(data, user, user_permissions, *args, **kwargs)
|
||||
resolver_results.append(
|
||||
await x(QueryContext(data, user, user_permissions, *args, **kwargs))
|
||||
)
|
||||
else:
|
||||
result = x(QueryContext(data, user, user_permissions, *args, **kwargs))
|
||||
resolver_results.append(
|
||||
x(QueryContext(data, user, user_permissions, *args, **kwargs))
|
||||
)
|
||||
|
||||
if not result:
|
||||
raise AccessDenied()
|
||||
if not any(resolver_results):
|
||||
raise AccessDenied()
|
||||
|
||||
def field(
|
||||
self,
|
||||
@ -120,7 +123,7 @@ class QueryABC(ObjectType):
|
||||
skip = None
|
||||
|
||||
if field.default_filter:
|
||||
filters.append(field.default_filter(*args, **kwargs))
|
||||
filters.append(await field.default_filter(*args, **kwargs))
|
||||
|
||||
if field.filter_type and "filter" in kwargs:
|
||||
in_filters = kwargs["filter"]
|
||||
@ -210,7 +213,6 @@ class QueryABC(ObjectType):
|
||||
f"{field.name}: {field.input_type.__name__} {kwargs[field.input_key]}"
|
||||
)
|
||||
input_obj = field.input_type(kwargs[field.input_key])
|
||||
|
||||
del kwargs[field.input_key]
|
||||
|
||||
return await resolver_wrapper(input_obj, mutation, info, **kwargs)
|
||||
|
@ -1,5 +1,5 @@
|
||||
from asyncio import iscoroutinefunction
|
||||
from typing import Self, Type
|
||||
from typing import Self, Type, Union
|
||||
|
||||
from ariadne.types import Resolver
|
||||
|
||||
@ -58,7 +58,11 @@ class MutationFieldBuilder(FieldBuilderABC):
|
||||
self._resolver = resolver_wrapper
|
||||
return self
|
||||
|
||||
def with_input(self, input_type: Type[InputABC], input_key: str = None) -> Self:
|
||||
def with_input(
|
||||
self,
|
||||
input_type: Type[Union[InputABC, str, int, bool]],
|
||||
input_key: str = "input",
|
||||
) -> Self:
|
||||
self._input_type = input_type
|
||||
self._input_key = input_key
|
||||
return self
|
||||
|
@ -11,3 +11,6 @@ class GroupFilter(DbModelFilterABC):
|
||||
|
||||
self.add_field("name", StringFilter)
|
||||
self.add_field("description", StringFilter)
|
||||
|
||||
self.add_field("isNull", bool)
|
||||
self.add_field("isNotNull", bool)
|
||||
|
@ -39,8 +39,8 @@ input StringFilter {
|
||||
startsWith: String
|
||||
endsWith: String
|
||||
|
||||
isNull: String
|
||||
isNotNull: String
|
||||
isNull: Boolean
|
||||
isNotNull: Boolean
|
||||
}
|
||||
|
||||
input IntFilter {
|
||||
@ -51,8 +51,8 @@ input IntFilter {
|
||||
less: Int
|
||||
lessOrEqual: Int
|
||||
|
||||
isNull: Int
|
||||
isNotNull: Int
|
||||
isNull: Boolean
|
||||
isNotNull: Boolean
|
||||
in: [Int]
|
||||
notIn: [Int]
|
||||
}
|
||||
@ -61,8 +61,8 @@ input BooleanFilter {
|
||||
equal: Boolean
|
||||
notEqual: Int
|
||||
|
||||
isNull: Int
|
||||
isNotNull: Int
|
||||
isNull: Boolean
|
||||
isNotNull: Boolean
|
||||
}
|
||||
|
||||
input DateFilter {
|
||||
@ -78,8 +78,8 @@ input DateFilter {
|
||||
contains: String
|
||||
notContains: String
|
||||
|
||||
isNull: String
|
||||
isNotNull: String
|
||||
isNull: Boolean
|
||||
isNotNull: Boolean
|
||||
|
||||
in: [String]
|
||||
notIn: [String]
|
||||
|
@ -61,6 +61,9 @@ input GroupFilter {
|
||||
editor: IntFilter
|
||||
created: DateFilter
|
||||
updated: DateFilter
|
||||
|
||||
isNull: Boolean
|
||||
isNotNull: Boolean
|
||||
}
|
||||
|
||||
type GroupMutation {
|
||||
|
@ -11,4 +11,6 @@ type Mutation {
|
||||
setting: SettingMutation
|
||||
userSetting: UserSettingMutation
|
||||
featureFlag: FeatureFlagMutation
|
||||
|
||||
privacy: PrivacyMutation
|
||||
}
|
5
api/src/api_graphql/graphql/privacy.gql
Normal file
5
api/src/api_graphql/graphql/privacy.gql
Normal file
@ -0,0 +1,5 @@
|
||||
type PrivacyMutation {
|
||||
exportData(userId: Int!): String
|
||||
anonymizeData(userId: Int!): Boolean
|
||||
deleteData(userId: Int!): Boolean
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
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
|
||||
|
||||
|
||||
@ -45,20 +46,26 @@ class Mutation(MutationABC):
|
||||
self.add_mutation_type(
|
||||
"group",
|
||||
"Group",
|
||||
require_any_permission=[
|
||||
Permissions.groups_create,
|
||||
Permissions.groups_update,
|
||||
Permissions.groups_delete,
|
||||
],
|
||||
require_any=(
|
||||
[
|
||||
Permissions.groups_create,
|
||||
Permissions.groups_update,
|
||||
Permissions.groups_delete,
|
||||
],
|
||||
[by_user_setup_mutation],
|
||||
),
|
||||
)
|
||||
self.add_mutation_type(
|
||||
"shortUrl",
|
||||
"ShortUrl",
|
||||
require_any_permission=[
|
||||
Permissions.short_urls_create,
|
||||
Permissions.short_urls_update,
|
||||
Permissions.short_urls_delete,
|
||||
],
|
||||
require_any=(
|
||||
[
|
||||
Permissions.short_urls_create,
|
||||
Permissions.short_urls_update,
|
||||
Permissions.short_urls_delete,
|
||||
],
|
||||
[by_user_setup_mutation],
|
||||
),
|
||||
)
|
||||
|
||||
self.add_mutation_type(
|
||||
@ -79,3 +86,7 @@ class Mutation(MutationABC):
|
||||
Permissions.administrator,
|
||||
],
|
||||
)
|
||||
self.add_mutation_type(
|
||||
"privacy",
|
||||
"Privacy",
|
||||
)
|
||||
|
@ -1,8 +1,13 @@
|
||||
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 data.schemas.public.group import Group
|
||||
from data.schemas.public.group_dao import groupDao
|
||||
@ -17,27 +22,30 @@ class GroupMutation(MutationABC):
|
||||
def __init__(self):
|
||||
MutationABC.__init__(self, "Group")
|
||||
|
||||
self.mutation(
|
||||
"create",
|
||||
self.resolve_create,
|
||||
GroupCreateInput,
|
||||
require_any_permission=[Permissions.groups_create],
|
||||
self.field(
|
||||
MutationFieldBuilder("create")
|
||||
.with_resolver(self.resolve_create)
|
||||
.with_input(GroupCreateInput)
|
||||
.with_require_any([Permissions.groups_create], [by_user_setup_mutation])
|
||||
)
|
||||
self.mutation(
|
||||
"update",
|
||||
self.resolve_update,
|
||||
GroupUpdateInput,
|
||||
require_any_permission=[Permissions.groups_update],
|
||||
|
||||
self.field(
|
||||
MutationFieldBuilder("update")
|
||||
.with_resolver(self.resolve_update)
|
||||
.with_input(GroupUpdateInput)
|
||||
.with_require_any([Permissions.groups_update], [by_user_setup_mutation])
|
||||
)
|
||||
self.mutation(
|
||||
"delete",
|
||||
self.resolve_delete,
|
||||
require_any_permission=[Permissions.groups_delete],
|
||||
|
||||
self.field(
|
||||
MutationFieldBuilder("delete")
|
||||
.with_resolver(self.resolve_delete)
|
||||
.with_require_any([Permissions.groups_delete], [by_user_setup_mutation])
|
||||
)
|
||||
self.mutation(
|
||||
"restore",
|
||||
self.resolve_restore,
|
||||
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])
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@ -72,9 +80,18 @@ class GroupMutation(MutationABC):
|
||||
async def resolve_create(cls, obj: GroupCreateInput, *_):
|
||||
logger.debug(f"create group: {obj.__dict__}")
|
||||
|
||||
already_exists = await groupDao.find_by({Group.name: obj.name})
|
||||
if len(already_exists) > 0:
|
||||
raise ValueError(f"Group {obj.name} already exists")
|
||||
|
||||
group = Group(
|
||||
0,
|
||||
obj.name,
|
||||
(
|
||||
(await Route.get_user()).id
|
||||
if await FeatureFlags.has_feature(FeatureFlagsEnum.per_user_setup)
|
||||
else None
|
||||
),
|
||||
)
|
||||
gid = await groupDao.create(group)
|
||||
|
||||
@ -90,6 +107,12 @@ 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}}
|
||||
)
|
||||
if len(already_exists) > 0:
|
||||
raise ValueError(f"Group {obj.name} already exists")
|
||||
|
||||
group = await groupDao.get_by_id(obj.id)
|
||||
group.name = obj.name
|
||||
await groupDao.update(group)
|
||||
|
63
api/src/api_graphql/mutations/privacy_mutation.py
Normal file
63
api/src/api_graphql/mutations/privacy_mutation.py
Normal file
@ -0,0 +1,63 @@
|
||||
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.service.exceptions import UnauthorizedException, AccessDenied
|
||||
from core.logger import APILogger
|
||||
from service.data_privacy_service import DataPrivacyService
|
||||
from service.permission.permissions_enum import Permissions
|
||||
|
||||
logger = APILogger(__name__)
|
||||
|
||||
|
||||
class PrivacyMutation(MutationABC):
|
||||
def __init__(self):
|
||||
MutationABC.__init__(self, "Privacy")
|
||||
|
||||
self.field(
|
||||
MutationFieldBuilder("exportData")
|
||||
.with_resolver(self.resolve_export_data)
|
||||
.with_input(int, "userId")
|
||||
.with_public(True)
|
||||
)
|
||||
self.field(
|
||||
MutationFieldBuilder("anonymizeData")
|
||||
.with_resolver(self.resolve_anonymize_data)
|
||||
.with_input(int, "userId")
|
||||
.with_public(True)
|
||||
)
|
||||
self.field(
|
||||
MutationFieldBuilder("deleteData")
|
||||
.with_resolver(self.resolve_delete_data)
|
||||
.with_input(int, "userId")
|
||||
.with_public(True)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _permission_check(user_id: int):
|
||||
user = await Route.get_user()
|
||||
if user is None:
|
||||
raise UnauthorizedException()
|
||||
|
||||
if user.id != user_id and not user.has_permission(Permissions.administrator):
|
||||
raise AccessDenied()
|
||||
|
||||
@staticmethod
|
||||
async def resolve_export_data(user_id: int, *_):
|
||||
logger.debug(f"export data for user: {user_id}")
|
||||
await PrivacyMutation._permission_check(user_id)
|
||||
|
||||
return await DataPrivacyService.export_user_data(user_id)
|
||||
|
||||
@staticmethod
|
||||
async def resolve_anonymize_data(user_id: int, *_):
|
||||
logger.debug(f"anonymize data for user: {user_id}")
|
||||
await PrivacyMutation._permission_check(user_id)
|
||||
|
||||
return await DataPrivacyService.anonymize_user(user_id)
|
||||
|
||||
@staticmethod
|
||||
async def resolve_delete_data(user_id: int, *_):
|
||||
logger.debug(f"delete data for user: {user_id}")
|
||||
await PrivacyMutation._permission_check(user_id)
|
||||
|
||||
return await DataPrivacyService.delete_user_data(user_id)
|
@ -2,7 +2,7 @@ from api_graphql.abc.mutation_abc import MutationABC
|
||||
from api_graphql.input.setting_input import SettingInput
|
||||
from core.logger import APILogger
|
||||
from data.schemas.system.setting import Setting
|
||||
from data.schemas.system.setting_dao import settingsDao
|
||||
from data.schemas.system.setting_dao import settingDao
|
||||
from service.permission.permissions_enum import Permissions
|
||||
|
||||
logger = APILogger(__name__)
|
||||
@ -22,11 +22,11 @@ class SettingMutation(MutationABC):
|
||||
async def resolve_change(obj: SettingInput, *_):
|
||||
logger.debug(f"create new setting: {input}")
|
||||
|
||||
setting = await settingsDao.find_single_by({Setting.key: obj.key})
|
||||
setting = await settingDao.find_single_by({Setting.key: obj.key})
|
||||
if setting is None:
|
||||
raise ValueError(f"Setting with key {obj.key} not found")
|
||||
|
||||
setting.value = obj.value
|
||||
await settingsDao.update(setting)
|
||||
await settingDao.update(setting)
|
||||
|
||||
return await settingsDao.get_by_id(setting.id)
|
||||
return await settingDao.get_by_id(setting.id)
|
||||
|
@ -1,6 +1,11 @@
|
||||
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.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 data.schemas.public.domain_dao import domainDao
|
||||
from data.schemas.public.group_dao import groupDao
|
||||
@ -17,38 +22,46 @@ class ShortUrlMutation(MutationABC):
|
||||
def __init__(self):
|
||||
MutationABC.__init__(self, "ShortUrl")
|
||||
|
||||
self.mutation(
|
||||
"create",
|
||||
self.resolve_create,
|
||||
ShortUrlCreateInput,
|
||||
require_any_permission=[Permissions.short_urls_create],
|
||||
self.field(
|
||||
MutationFieldBuilder("create")
|
||||
.with_resolver(self.resolve_create)
|
||||
.with_input(ShortUrlCreateInput)
|
||||
.with_require_any([Permissions.short_urls_create], [by_user_setup_mutation])
|
||||
)
|
||||
self.mutation(
|
||||
"update",
|
||||
self.resolve_update,
|
||||
ShortUrlUpdateInput,
|
||||
require_any_permission=[Permissions.short_urls_update],
|
||||
|
||||
self.field(
|
||||
MutationFieldBuilder("update")
|
||||
.with_resolver(self.resolve_update)
|
||||
.with_input(ShortUrlUpdateInput)
|
||||
.with_require_any([Permissions.short_urls_update], [by_user_setup_mutation])
|
||||
)
|
||||
self.mutation(
|
||||
"delete",
|
||||
self.resolve_delete,
|
||||
require_any_permission=[Permissions.short_urls_delete],
|
||||
|
||||
self.field(
|
||||
MutationFieldBuilder("delete")
|
||||
.with_resolver(self.resolve_delete)
|
||||
.with_require_any([Permissions.short_urls_delete], [by_user_setup_mutation])
|
||||
)
|
||||
self.mutation(
|
||||
"restore",
|
||||
self.resolve_restore,
|
||||
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])
|
||||
)
|
||||
self.mutation(
|
||||
"trackVisit",
|
||||
self.resolve_track_visit,
|
||||
require_any_permission=[Permissions.short_urls_update],
|
||||
|
||||
self.field(
|
||||
MutationFieldBuilder("trackVisit")
|
||||
.with_resolver(self.resolve_track_visit)
|
||||
.with_require_any_permission([Permissions.short_urls_update])
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def resolve_create(obj: ShortUrlCreateInput, *_):
|
||||
logger.debug(f"create short_url: {obj.__dict__}")
|
||||
|
||||
already_exists = await shortUrlDao.find_by({ShortUrl.short_url: obj.short_url})
|
||||
if len(already_exists) > 0:
|
||||
raise ValueError(f"Short URL {obj.short_url} already exists")
|
||||
|
||||
short_url = ShortUrl(
|
||||
0,
|
||||
obj.short_url,
|
||||
@ -57,6 +70,11 @@ 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
|
||||
),
|
||||
)
|
||||
nid = await shortUrlDao.create(short_url)
|
||||
return await shortUrlDao.get_by_id(nid)
|
||||
@ -68,6 +86,11 @@ class ShortUrlMutation(MutationABC):
|
||||
short_url = await shortUrlDao.get_by_id(obj.id)
|
||||
|
||||
if obj.short_url is not None:
|
||||
already_exists = await shortUrlDao.find_by(
|
||||
{ShortUrl.short_url: obj.short_url}
|
||||
)
|
||||
if len(already_exists) > 0:
|
||||
raise ValueError(f"Short URL {obj.short_url} already exists")
|
||||
short_url.short_url = obj.short_url
|
||||
|
||||
if obj.target_url is not None:
|
||||
|
@ -1,10 +1,12 @@
|
||||
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_setting_input import UserSettingInput
|
||||
from core.logger import APILogger
|
||||
from core.string import first_to_lower
|
||||
from data.schemas.public.user_setting import UserSetting
|
||||
from data.schemas.public.user_setting_dao import userSettingsDao
|
||||
from data.schemas.system.setting_dao import settingsDao
|
||||
from data.schemas.public.user_setting_dao import userSettingDao
|
||||
from data.schemas.system.setting_dao import settingDao
|
||||
from service.permission.permissions_enum import Permissions
|
||||
|
||||
logger = APILogger(__name__)
|
||||
@ -13,13 +15,20 @@ logger = APILogger(__name__)
|
||||
class UserSettingMutation(MutationABC):
|
||||
def __init__(self):
|
||||
MutationABC.__init__(self, "UserSetting")
|
||||
self.mutation(
|
||||
"change",
|
||||
self.resolve_change,
|
||||
UserSettingInput,
|
||||
require_any_permission=[Permissions.settings_update],
|
||||
self.field(
|
||||
MutationFieldBuilder("change")
|
||||
.with_resolver(self.resolve_change)
|
||||
.with_change_broadcast(
|
||||
f"{first_to_lower(self.name.replace("Mutation", ""))}Change"
|
||||
)
|
||||
.with_input(UserSettingInput, "input")
|
||||
.with_require_any([], [self._x])
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _x(ctx):
|
||||
return ctx.data.user_id == (await Route.get_user()).id
|
||||
|
||||
@staticmethod
|
||||
async def resolve_change(obj: UserSettingInput, *_):
|
||||
logger.debug(f"create new setting: {input}")
|
||||
@ -28,13 +37,13 @@ class UserSettingMutation(MutationABC):
|
||||
logger.debug("user not authorized")
|
||||
return None
|
||||
|
||||
setting = await userSettingsDao.find_single_by(
|
||||
setting = await userSettingDao.find_single_by(
|
||||
[{UserSetting.user_id: user.id}, {UserSetting.key: obj.key}]
|
||||
)
|
||||
if setting is None:
|
||||
await userSettingsDao.create(UserSetting(0, user.id, obj.key, obj.value))
|
||||
await userSettingDao.create(UserSetting(0, user.id, obj.key, obj.value))
|
||||
else:
|
||||
setting.value = obj.value
|
||||
await userSettingsDao.update(setting)
|
||||
await userSettingDao.update(setting)
|
||||
|
||||
return await userSettingsDao.find_by_key(user, obj.key)
|
||||
return await userSettingDao.find_by_key(user, obj.key)
|
||||
|
@ -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 group_by_assignment_resolver
|
||||
from api_graphql.require_any_resolvers import by_assignment_resolver
|
||||
from data.schemas.public.group import Group
|
||||
from data.schemas.public.group_dao import groupDao
|
||||
from data.schemas.public.group_role_assignment_dao import groupRoleAssignmentDao
|
||||
@ -21,7 +21,7 @@ class GroupHistoryQuery(DbHistoryModelQueryABC):
|
||||
[
|
||||
Permissions.groups,
|
||||
],
|
||||
[group_by_assignment_resolver],
|
||||
[by_assignment_resolver],
|
||||
)
|
||||
)
|
||||
self.set_field(
|
||||
|
@ -1,6 +1,6 @@
|
||||
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 group_by_assignment_resolver
|
||||
from api_graphql.require_any_resolvers import by_assignment_resolver
|
||||
from data.schemas.public.group import Group
|
||||
from data.schemas.public.group_dao import groupDao
|
||||
from data.schemas.public.group_role_assignment_dao import groupRoleAssignmentDao
|
||||
@ -21,7 +21,7 @@ class GroupQuery(DbModelQueryABC):
|
||||
[
|
||||
Permissions.groups,
|
||||
],
|
||||
[group_by_assignment_resolver],
|
||||
[by_assignment_resolver],
|
||||
)
|
||||
)
|
||||
self.set_field("roles", self._get_roles)
|
||||
|
@ -11,7 +11,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.require_any_resolvers import group_by_assignment_resolver
|
||||
from api_graphql.require_any_resolvers import (
|
||||
by_assignment_resolver,
|
||||
by_user_setup_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
|
||||
@ -27,9 +32,9 @@ from data.schemas.public.group_dao import groupDao
|
||||
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 userSettingsDao
|
||||
from data.schemas.public.user_setting_dao import userSettingDao
|
||||
from data.schemas.system.feature_flag_dao import featureFlagDao
|
||||
from data.schemas.system.setting_dao import settingsDao
|
||||
from data.schemas.system.setting_dao import settingDao
|
||||
from service.permission.permissions_enum import Permissions
|
||||
|
||||
|
||||
@ -109,7 +114,8 @@ class Query(QueryABC):
|
||||
]
|
||||
)
|
||||
)
|
||||
self.field(
|
||||
|
||||
group_field = (
|
||||
DaoFieldBuilder("groups")
|
||||
.with_dao(groupDao)
|
||||
.with_filter(GroupFilter)
|
||||
@ -120,17 +126,35 @@ class Query(QueryABC):
|
||||
Permissions.short_urls_create,
|
||||
Permissions.short_urls_update,
|
||||
],
|
||||
[group_by_assignment_resolver],
|
||||
[by_assignment_resolver, by_user_setup_resolver],
|
||||
)
|
||||
)
|
||||
self.field(
|
||||
|
||||
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 = (
|
||||
DaoFieldBuilder("shortUrls")
|
||||
.with_dao(shortUrlDao)
|
||||
.with_filter(ShortUrlFilter)
|
||||
.with_sort(Sort[ShortUrl])
|
||||
.with_require_any([Permissions.short_urls], [group_by_assignment_resolver])
|
||||
.with_require_any(
|
||||
[Permissions.short_urls],
|
||||
[by_assignment_resolver, by_user_setup_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)
|
||||
@ -182,8 +206,8 @@ class Query(QueryABC):
|
||||
@staticmethod
|
||||
async def _resolve_settings(*args, **kwargs):
|
||||
if "key" in kwargs:
|
||||
return [await settingsDao.find_by_key(kwargs["key"])]
|
||||
return await settingsDao.get_all()
|
||||
return [await settingDao.find_by_key(kwargs["key"])]
|
||||
return await settingDao.get_all()
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_user_settings(*args, **kwargs):
|
||||
@ -192,13 +216,17 @@ class Query(QueryABC):
|
||||
return None
|
||||
|
||||
if "key" in kwargs:
|
||||
return await userSettingsDao.find_by(
|
||||
return await userSettingDao.find_by(
|
||||
[{UserSetting.user_id: user.id}, {UserSetting.key: kwargs["key"]}]
|
||||
)
|
||||
return await userSettingsDao.find_by({UserSetting.user_id: user.id})
|
||||
return await userSettingDao.find_by({UserSetting.user_id: user.id})
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_feature_flags(*args, **kwargs):
|
||||
if "key" in kwargs:
|
||||
return [await featureFlagDao.find_by_key(kwargs["key"])]
|
||||
return await featureFlagDao.get_all()
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_default_user_filter(*args, **kwargs) -> dict:
|
||||
return {"user": {"id": {"equal": (await Route.get_user()).id}}}
|
||||
|
@ -1,10 +1,12 @@
|
||||
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 service.permission.permissions_enum import Permissions
|
||||
|
||||
|
||||
async def group_by_assignment_resolver(ctx: QueryContext) -> bool:
|
||||
async def by_assignment_resolver(ctx: QueryContext) -> bool:
|
||||
if not isinstance(ctx.data, CollectionResult):
|
||||
return False
|
||||
|
||||
@ -19,12 +21,23 @@ async def group_by_assignment_resolver(ctx: QueryContext) -> bool:
|
||||
and all(r.id in role_ids for r in roles)
|
||||
]
|
||||
|
||||
ctx.data.nodes = [
|
||||
node
|
||||
return all(
|
||||
(await node.group) is not None and (await node.group).id in filtered_groups
|
||||
for node in ctx.data.nodes
|
||||
if (await node.group) is not None
|
||||
and (await node.group).id in filtered_groups
|
||||
]
|
||||
return True
|
||||
)
|
||||
|
||||
return True
|
||||
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)
|
||||
|
@ -15,6 +15,7 @@ class QueryContext:
|
||||
data: Any,
|
||||
user: Optional[User],
|
||||
user_permissions: Optional[list[Permissions]],
|
||||
is_mutation: bool = False,
|
||||
*args,
|
||||
**kwargs
|
||||
):
|
||||
@ -31,11 +32,17 @@ class QueryContext:
|
||||
self._resolve_info = arg
|
||||
continue
|
||||
|
||||
self._filter = kwargs.get("filter", {})
|
||||
self._filter = kwargs.get("filters", {})
|
||||
self._sort = kwargs.get("sort", {})
|
||||
self._skip = get_value(kwargs, "skip", int)
|
||||
self._take = get_value(kwargs, "take", int)
|
||||
|
||||
self._input = kwargs.get("input", None)
|
||||
self._args = args
|
||||
self._kwargs = kwargs
|
||||
|
||||
self._is_mutation = is_mutation
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
return self._data
|
||||
@ -64,5 +71,21 @@ class QueryContext:
|
||||
def take(self) -> Optional[int]:
|
||||
return self._take
|
||||
|
||||
@property
|
||||
def input(self) -> Optional[Any]:
|
||||
return self._input
|
||||
|
||||
@property
|
||||
def args(self) -> tuple:
|
||||
return self._args
|
||||
|
||||
@property
|
||||
def kwargs(self) -> dict:
|
||||
return self._kwargs
|
||||
|
||||
@property
|
||||
def is_mutation(self) -> bool:
|
||||
return self._is_mutation
|
||||
|
||||
def has_permission(self, permission: Permissions) -> bool:
|
||||
return permission.value in self._user_permissions
|
||||
|
@ -1,20 +1,38 @@
|
||||
from typing import Union
|
||||
|
||||
from core.configuration.feature_flags_enum import FeatureFlagsEnum
|
||||
from core.environment import Environment
|
||||
from data.schemas.system.feature_flag_dao import featureFlagDao
|
||||
|
||||
|
||||
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,
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def overwrite_flag(key: str):
|
||||
return key in FeatureFlags._overwrite_flags
|
||||
|
||||
@staticmethod
|
||||
def get_default(key: FeatureFlagsEnum) -> bool:
|
||||
return FeatureFlags._flags[key.value]
|
||||
|
||||
@staticmethod
|
||||
async def has_feature(key: FeatureFlagsEnum) -> bool:
|
||||
value = await featureFlagDao.find_by_key(key.value)
|
||||
if value is None:
|
||||
return False
|
||||
async def has_feature(key: Union[str, FeatureFlagsEnum]) -> bool:
|
||||
key_value = key.value if isinstance(key, FeatureFlagsEnum) else key
|
||||
|
||||
return value.value
|
||||
value = await featureFlagDao.find_by_key(key_value)
|
||||
return (
|
||||
value.value
|
||||
if value
|
||||
else FeatureFlags.get_default(FeatureFlagsEnum(key_value))
|
||||
)
|
||||
|
@ -2,5 +2,6 @@ from enum import Enum
|
||||
|
||||
|
||||
class FeatureFlagsEnum(Enum):
|
||||
# modules
|
||||
version_endpoint = "VersionEndpoint"
|
||||
technical_demo_banner = "TechnicalDemoBanner"
|
||||
per_user_setup = "PerUserSetup"
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -54,7 +54,7 @@ class DbModelABC(ABC):
|
||||
|
||||
from data.schemas.administration.user_dao import userDao
|
||||
|
||||
return await userDao.find_single_by({"id": self._editor_id})
|
||||
return await userDao.get_by_id(self._editor_id)
|
||||
|
||||
@property
|
||||
def created(self) -> datetime:
|
||||
@ -63,3 +63,7 @@ class DbModelABC(ABC):
|
||||
@property
|
||||
def updated(self) -> datetime:
|
||||
return self._updated
|
||||
|
||||
@updated.setter
|
||||
def updated(self, value: datetime):
|
||||
self._updated = value
|
||||
|
@ -14,6 +14,15 @@ class DbModelDaoABC[T_DBM](DataAccessObjectABC[T_DBM]):
|
||||
|
||||
self.attribute(DbModelABC.id, int, ignore=True)
|
||||
self.attribute(DbModelABC.deleted, bool)
|
||||
self.attribute(DbModelABC.editor_id, int, ignore=True)
|
||||
self.attribute(DbModelABC.created, datetime, "created", ignore=True)
|
||||
self.attribute(DbModelABC.updated, datetime, "updated", ignore=True)
|
||||
self.attribute(DbModelABC.editor_id, int, ignore=True) # handled by db trigger
|
||||
|
||||
self.reference(
|
||||
"editor", "id", DbModelABC.editor_id, "administration.users"
|
||||
) # not relevant for updates due to editor_id
|
||||
|
||||
self.attribute(
|
||||
DbModelABC.created, datetime, ignore=True
|
||||
) # handled by db trigger
|
||||
self.attribute(
|
||||
DbModelABC.updated, datetime, ignore=True
|
||||
) # handled by db trigger
|
||||
|
@ -1,9 +1,4 @@
|
||||
from core.database.database_settings import DatabaseSettings
|
||||
from core.database.db_context import DBContext
|
||||
from core.environment import Environment
|
||||
from core.logger import DBLogger
|
||||
|
||||
logger = DBLogger(__name__)
|
||||
|
||||
|
||||
class Database:
|
||||
@ -19,30 +14,3 @@ class Database:
|
||||
@classmethod
|
||||
def connect(cls):
|
||||
cls._db.connect()
|
||||
|
||||
@classmethod
|
||||
async def startup_db(cls):
|
||||
from data.service.migration_service import MigrationService
|
||||
|
||||
logger.info("Init DB")
|
||||
db = DBContext()
|
||||
host = Environment.get("DB_HOST", str)
|
||||
port = Environment.get("DB_PORT", int)
|
||||
user = Environment.get("DB_USER", str)
|
||||
password = Environment.get("DB_PASSWORD", str)
|
||||
database = Environment.get("DB_DATABASE", str)
|
||||
|
||||
if None in [host, port, user, password, database]:
|
||||
logger.fatal(
|
||||
"DB settings are not set correctly",
|
||||
EnvironmentError("DB settings are not set correctly"),
|
||||
)
|
||||
|
||||
await db.connect(
|
||||
DatabaseSettings(
|
||||
host=host, port=port, user=user, password=password, database=database
|
||||
)
|
||||
)
|
||||
Database.init(db)
|
||||
migrations = MigrationService(db)
|
||||
await migrations.migrate()
|
||||
|
@ -1,6 +1,9 @@
|
||||
import uuid
|
||||
from typing import Optional, Any
|
||||
|
||||
from psycopg import OperationalError
|
||||
from psycopg_pool import PoolTimeout
|
||||
|
||||
from core.database.database_settings import DatabaseSettings
|
||||
from core.database.postgres_pool import PostgresPool
|
||||
from core.environment import Environment
|
||||
@ -26,23 +29,23 @@ class DBContext:
|
||||
except Exception as e:
|
||||
logger.fatal("Connecting to database failed", e)
|
||||
|
||||
async def execute(self, statement: str, args=None) -> list[list]:
|
||||
async def execute(self, statement: str, args=None, multi=True) -> list[list]:
|
||||
logger.trace(f"execute {statement} with args: {args}")
|
||||
return await self._pool.execute(statement, args)
|
||||
return await self._pool.execute(statement, args, multi)
|
||||
|
||||
async def select_map(self, statement: str, args=None) -> list[dict]:
|
||||
logger.trace(f"select {statement} with args: {args}")
|
||||
try:
|
||||
return await self._pool.select_map(statement, args)
|
||||
except Exception as e:
|
||||
except (OperationalError, PoolTimeout) as e:
|
||||
if self._fails >= 3:
|
||||
logger.error(f"Database error caused by {statement}", e)
|
||||
logger.error(f"Database error caused by `{statement}`", e)
|
||||
uid = uuid.uuid4()
|
||||
raise Exception(
|
||||
f"Query failed three times with {type(e).__name__}. Contact an admin with the UID: {uid}"
|
||||
)
|
||||
|
||||
logger.error(f"Database error caused by {statement}", e)
|
||||
logger.error(f"Database error caused by `{statement}`", e)
|
||||
self._fails += 1
|
||||
try:
|
||||
logger.debug("Retry select")
|
||||
@ -50,6 +53,9 @@ class DBContext:
|
||||
except Exception as e:
|
||||
pass
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Database error caused by `{statement}`", e)
|
||||
raise e
|
||||
|
||||
async def select(
|
||||
self, statement: str, args=None
|
||||
@ -57,15 +63,15 @@ class DBContext:
|
||||
logger.trace(f"select {statement} with args: {args}")
|
||||
try:
|
||||
return await self._pool.select(statement, args)
|
||||
except Exception as e:
|
||||
except (OperationalError, PoolTimeout) as e:
|
||||
if self._fails >= 3:
|
||||
logger.error(f"Database error caused by {statement}", e)
|
||||
logger.error(f"Database error caused by `{statement}`", e)
|
||||
uid = uuid.uuid4()
|
||||
raise Exception(
|
||||
f"Query failed three times with {type(e).__name__}. Contact an admin with the UID: {uid}"
|
||||
)
|
||||
|
||||
logger.error(f"Database error caused by {statement}", e)
|
||||
logger.error(f"Database error caused by `{statement}`", e)
|
||||
self._fails += 1
|
||||
try:
|
||||
logger.debug("Retry select")
|
||||
@ -73,3 +79,6 @@ class DBContext:
|
||||
except Exception as e:
|
||||
pass
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Database error caused by `{statement}`", e)
|
||||
raise e
|
||||
|
@ -1,4 +1,4 @@
|
||||
from typing import Optional
|
||||
from typing import Optional, Any
|
||||
|
||||
from psycopg import sql
|
||||
from psycopg_pool import AsyncConnectionPool, PoolTimeout
|
||||
@ -21,7 +21,7 @@ class PostgresPool:
|
||||
f"host={database_settings.host} "
|
||||
f"port={database_settings.port} "
|
||||
f"user={database_settings.user} "
|
||||
f"password={B64Helper.decode(database_settings.password)} "
|
||||
f"password={database_settings.password} "
|
||||
f"dbname={database_settings.database}"
|
||||
)
|
||||
self._pool_size = pool_size
|
||||
@ -41,18 +41,31 @@ class PostgresPool:
|
||||
logger.fatal(f"Failed to connect to the database", e)
|
||||
return pool
|
||||
|
||||
async def execute(self, query: str, args=None) -> list[list]:
|
||||
@staticmethod
|
||||
async def _exec_sql(cursor: Any, query: str, args=None, multi=True):
|
||||
if multi:
|
||||
queries = query.split(";")
|
||||
for q in queries:
|
||||
if q.strip() == "":
|
||||
continue
|
||||
|
||||
await cursor.execute(sql.SQL(q), args)
|
||||
else:
|
||||
await cursor.execute(sql.SQL(query), args)
|
||||
|
||||
async def execute(self, query: str, args=None, multi=True) -> list[list]:
|
||||
"""
|
||||
Execute a SQL statement, it could be with args and without args. The usage is
|
||||
similar to the execute() function in the psycopg module.
|
||||
:param query: SQL clause
|
||||
:param args: args needed by the SQL clause
|
||||
:param multi: if the query is a multi-statement
|
||||
:return: return result
|
||||
"""
|
||||
async with await self._get_pool() as pool:
|
||||
async with pool.connection() as con:
|
||||
async with con.cursor() as cursor:
|
||||
await cursor.execute(sql.SQL(query), args)
|
||||
await self._exec_sql(cursor, query, args, multi)
|
||||
|
||||
if (
|
||||
cursor.description is not None
|
||||
@ -68,34 +81,36 @@ class PostgresPool:
|
||||
else:
|
||||
return []
|
||||
|
||||
async def select(self, query: str, args=None) -> list[str]:
|
||||
async def select(self, query: str, args=None, multi=True) -> list[str]:
|
||||
"""
|
||||
Execute a SQL statement, it could be with args and without args. The usage is
|
||||
similar to the execute() function in the psycopg module.
|
||||
:param query: SQL clause
|
||||
:param args: args needed by the SQL clause
|
||||
:param multi: if the query is a multi-statement
|
||||
:return: return result
|
||||
"""
|
||||
async with await self._get_pool() as pool:
|
||||
async with pool.connection() as con:
|
||||
async with con.cursor() as cursor:
|
||||
await cursor.execute(sql.SQL(query), args)
|
||||
await self._exec_sql(cursor, query, args, multi)
|
||||
|
||||
res = await cursor.fetchall()
|
||||
return list(res)
|
||||
|
||||
async def select_map(self, query: str, args=None) -> list[dict]:
|
||||
async def select_map(self, query: str, args=None, multi=True) -> list[dict]:
|
||||
"""
|
||||
Execute a SQL statement, it could be with args and without args. The usage is
|
||||
similar to the execute() function in the psycopg module.
|
||||
:param query: SQL clause
|
||||
:param args: args needed by the SQL clause
|
||||
:param multi: if the query is a multi-statement
|
||||
:return: return result
|
||||
"""
|
||||
async with await self._get_pool() as pool:
|
||||
async with pool.connection() as con:
|
||||
async with con.cursor() as cursor:
|
||||
await cursor.execute(sql.SQL(query), args)
|
||||
await self._exec_sql(cursor, query, args, multi)
|
||||
|
||||
res = await cursor.fetchall()
|
||||
res_map: list[dict] = []
|
||||
|
150
api/src/core/database/sql_select_builder.py
Normal file
150
api/src/core/database/sql_select_builder.py
Normal file
@ -0,0 +1,150 @@
|
||||
from typing import Optional
|
||||
|
||||
from core.database.external_data_temp_table_builder import ExternalDataTempTableBuilder
|
||||
|
||||
|
||||
class SQLSelectBuilder:
|
||||
|
||||
def __init__(self, table_name: str, primary_key: str):
|
||||
self._table_name = table_name
|
||||
self._primary_key = primary_key
|
||||
|
||||
self._temp_tables: dict[str, ExternalDataTempTableBuilder] = {}
|
||||
self._to_use_temp_tables: list[str] = []
|
||||
self._attributes: list[str] = []
|
||||
self._tables: list[str] = [table_name]
|
||||
self._joins: dict[str, (str, str)] = {}
|
||||
self._conditions: list[str] = []
|
||||
self._order_by: str = ""
|
||||
self._limit: Optional[int] = None
|
||||
self._offset: Optional[int] = None
|
||||
|
||||
def with_temp_table(
|
||||
self, temp_table: ExternalDataTempTableBuilder
|
||||
) -> "SQLSelectBuilder":
|
||||
self._temp_tables[temp_table.table_name] = temp_table
|
||||
return self
|
||||
|
||||
def use_temp_table(self, temp_table_name: str):
|
||||
if temp_table_name not in self._temp_tables:
|
||||
raise ValueError(f"Temp table {temp_table_name} not found.")
|
||||
|
||||
self._to_use_temp_tables.append(temp_table_name)
|
||||
|
||||
def with_attribute(self, attr: str, ignore_table_name=False) -> "SQLSelectBuilder":
|
||||
if not ignore_table_name and not attr.startswith(self._table_name):
|
||||
attr = f"{self._table_name}.{attr}"
|
||||
|
||||
self._attributes.append(attr)
|
||||
return self
|
||||
|
||||
def with_foreign_attribute(self, attr: str) -> "SQLSelectBuilder":
|
||||
self._attributes.append(attr)
|
||||
return self
|
||||
|
||||
def with_table(self, table_name: str) -> "SQLSelectBuilder":
|
||||
self._tables.append(table_name)
|
||||
return self
|
||||
|
||||
def _check_prefix(self, attr: str) -> str:
|
||||
assert attr is not None
|
||||
|
||||
valid_prefixes = [
|
||||
"levenshtein",
|
||||
self._table_name,
|
||||
*self._joins.keys(),
|
||||
*self._temp_tables.keys(),
|
||||
]
|
||||
if not any(attr.startswith(f"{prefix}.") for prefix in valid_prefixes):
|
||||
attr = f"{self._table_name}.{attr}"
|
||||
|
||||
return attr
|
||||
|
||||
def with_value_condition(
|
||||
self, attr: str, operator: str, value: str
|
||||
) -> "SQLSelectBuilder":
|
||||
attr = self._check_prefix(attr)
|
||||
self._conditions.append(f"{attr} {operator} {value}")
|
||||
return self
|
||||
|
||||
def with_levenshtein_condition(self, condition: str) -> "SQLSelectBuilder":
|
||||
self._conditions.append(condition)
|
||||
return self
|
||||
|
||||
def with_condition(self, attr: str, operator: str) -> "SQLSelectBuilder":
|
||||
attr = self._check_prefix(attr)
|
||||
self._conditions.append(f"{attr} {operator}")
|
||||
return self
|
||||
|
||||
def with_grouped_conditions(self, conditions: list[str]) -> "SQLSelectBuilder":
|
||||
self._conditions.append(f"({' AND '.join(conditions)})")
|
||||
return self
|
||||
|
||||
def with_left_join(self, table: str, on: str) -> "SQLSelectBuilder":
|
||||
if table in self._joins:
|
||||
self._joins[table] = (f"{self._joins[table][0]} AND {on}", "LEFT")
|
||||
|
||||
self._joins[table] = (on, "LEFT")
|
||||
return self
|
||||
|
||||
def with_inner_join(self, table: str, on: str) -> "SQLSelectBuilder":
|
||||
if table in self._joins:
|
||||
self._joins[table] = (f"{self._joins[table][0]} AND {on}", "INNER")
|
||||
|
||||
self._joins[table] = (on, "INNER")
|
||||
return self
|
||||
|
||||
def with_right_join(self, table: str, on: str) -> "SQLSelectBuilder":
|
||||
if table in self._joins:
|
||||
self._joins[table] = (f"{self._joins[table][0]} AND {on}", "RIGHT")
|
||||
|
||||
self._joins[table] = (on, "RIGHT")
|
||||
return self
|
||||
|
||||
def with_limit(self, limit: int) -> "SQLSelectBuilder":
|
||||
self._limit = limit
|
||||
return self
|
||||
|
||||
def with_offset(self, offset: int) -> "SQLSelectBuilder":
|
||||
self._offset = offset
|
||||
return self
|
||||
|
||||
def with_order_by(self, column: str, direction: str = "ASC") -> "SQLSelectBuilder":
|
||||
self._order_by = f"{column} {direction}"
|
||||
return self
|
||||
|
||||
async def _handle_temp_table_use(self, query) -> str:
|
||||
new_query = ""
|
||||
|
||||
for temp_table_name in self._to_use_temp_tables:
|
||||
temp_table = self._temp_tables[temp_table_name]
|
||||
new_query += await self._temp_tables[temp_table_name].build()
|
||||
self.with_left_join(
|
||||
temp_table.table_name,
|
||||
f"{temp_table.join_ref_table}.{self._primary_key} = {temp_table.table_name}.{temp_table.primary_key}",
|
||||
)
|
||||
|
||||
return f"{new_query} {query}" if new_query != "" else query
|
||||
|
||||
async def build(self) -> str:
|
||||
query = await self._handle_temp_table_use("")
|
||||
|
||||
attributes = ", ".join(self._attributes) if self._attributes else "*"
|
||||
query += f"SELECT {attributes} FROM {", ".join(self._tables)}"
|
||||
|
||||
for join in self._joins:
|
||||
query += f" {self._joins[join][1]} JOIN {join} ON {self._joins[join][0]}"
|
||||
|
||||
if self._conditions:
|
||||
query += " WHERE " + " AND ".join(self._conditions)
|
||||
|
||||
if self._order_by:
|
||||
query += f" ORDER BY {self._order_by}"
|
||||
|
||||
if self._limit is not None:
|
||||
query += f" LIMIT {self._limit}"
|
||||
|
||||
if self._offset is not None:
|
||||
query += f" OFFSET {self._offset}"
|
||||
|
||||
return query
|
@ -1,3 +1,4 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
@ -28,10 +29,16 @@ class User(DbModelABC):
|
||||
|
||||
@property
|
||||
def username(self):
|
||||
if self._keycloak_id == str(uuid.UUID(int=0)):
|
||||
return "ANONYMOUS"
|
||||
|
||||
return Keycloak.admin.get_user(self._keycloak_id).get("username")
|
||||
|
||||
@property
|
||||
def email(self):
|
||||
if self._keycloak_id == str(uuid.UUID(int=0)):
|
||||
return "ANONYMOUS"
|
||||
|
||||
return Keycloak.admin.get_user(self._keycloak_id).get("email")
|
||||
|
||||
@async_property
|
||||
@ -50,3 +57,9 @@ class User(DbModelABC):
|
||||
from data.schemas.administration.user_dao import userDao
|
||||
|
||||
return await userDao.has_permission(self.id, permission)
|
||||
|
||||
async def anonymize(self):
|
||||
from data.schemas.administration.user_dao import userDao
|
||||
|
||||
self._keycloak_id = str(uuid.UUID(int=0))
|
||||
await userDao.update(self)
|
||||
|
@ -1,6 +1,7 @@
|
||||
from typing import Optional, Union
|
||||
|
||||
from core.database.abc.db_model_dao_abc import DbModelDaoABC
|
||||
from core.database.external_data_temp_table_builder import ExternalDataTempTableBuilder
|
||||
from core.logger import DBLogger
|
||||
from data.schemas.administration.user import User
|
||||
from data.schemas.permission.permission_dao import permissionDao
|
||||
@ -14,7 +15,19 @@ class UserDao(DbModelDaoABC[User]):
|
||||
def __init__(self):
|
||||
DbModelDaoABC.__init__(self, __name__, User, "administration.users")
|
||||
|
||||
self.attribute(User.keycloak_id, str)
|
||||
self.attribute(User.keycloak_id, str, aliases=["keycloakId"])
|
||||
|
||||
async def get_users():
|
||||
return [(x.id, x.username, x.email) for x in await self.get_all()]
|
||||
|
||||
self.use_external_fields(
|
||||
ExternalDataTempTableBuilder()
|
||||
.with_table_name(self._table_name)
|
||||
.with_field("id", "int", True)
|
||||
.with_field("username", "text")
|
||||
.with_field("email", "text")
|
||||
.with_value_getter(get_users)
|
||||
)
|
||||
|
||||
async def get_by_keycloak_id(self, keycloak_id: str) -> User:
|
||||
return await self.get_single_by({User.keycloak_id: keycloak_id})
|
||||
|
@ -1,6 +1,8 @@
|
||||
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
|
||||
|
||||
@ -10,6 +12,7 @@ class Group(DbModelABC):
|
||||
self,
|
||||
id: SerialId,
|
||||
name: str,
|
||||
user_id: Optional[SerialId] = None,
|
||||
deleted: bool = False,
|
||||
editor_id: Optional[SerialId] = None,
|
||||
created: Optional[datetime] = None,
|
||||
@ -17,6 +20,7 @@ class Group(DbModelABC):
|
||||
):
|
||||
DbModelABC.__init__(self, id, deleted, editor_id, created, updated)
|
||||
self._name = name
|
||||
self._user_id = user_id
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@ -25,3 +29,17 @@ class Group(DbModelABC):
|
||||
@name.setter
|
||||
def name(self, value: str):
|
||||
self._name = value
|
||||
|
||||
@property
|
||||
def user_id(self) -> Optional[SerialId]:
|
||||
return self._user_id
|
||||
|
||||
@async_property
|
||||
async def user(self):
|
||||
if self._user_id is None:
|
||||
return None
|
||||
|
||||
from data.schemas.administration.user_dao import userDao
|
||||
|
||||
user = await userDao.get_by_id(self.user_id)
|
||||
return user
|
||||
|
@ -11,6 +11,9 @@ 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")
|
||||
|
||||
async def get_by_name(self, name: str) -> Group:
|
||||
result = await self._db.select_map(
|
||||
f"SELECT * FROM {self._table_name} WHERE Name = '{name}'"
|
||||
|
@ -18,6 +18,7 @@ class ShortUrl(DbModelABC):
|
||||
group_id: Optional[SerialId],
|
||||
domain_id: Optional[SerialId],
|
||||
loading_screen: Optional[str] = None,
|
||||
user_id: Optional[SerialId] = None,
|
||||
deleted: bool = False,
|
||||
editor_id: Optional[SerialId] = None,
|
||||
created: Optional[datetime] = None,
|
||||
@ -34,6 +35,8 @@ class ShortUrl(DbModelABC):
|
||||
loading_screen = False
|
||||
self._loading_screen = loading_screen
|
||||
|
||||
self._user_id = user_id
|
||||
|
||||
@property
|
||||
def short_url(self) -> str:
|
||||
return self._short_url
|
||||
@ -106,6 +109,20 @@ class ShortUrl(DbModelABC):
|
||||
def loading_screen(self, value: Optional[str]):
|
||||
self._loading_screen = value
|
||||
|
||||
@property
|
||||
def user_id(self) -> Optional[SerialId]:
|
||||
return self._user_id
|
||||
|
||||
@async_property
|
||||
async def user(self):
|
||||
if self._user_id is None:
|
||||
return None
|
||||
|
||||
from data.schemas.administration.user_dao import userDao
|
||||
|
||||
user = await userDao.get_by_id(self.user_id)
|
||||
return user
|
||||
|
||||
def to_dto(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
|
@ -18,5 +18,8 @@ class ShortUrlDao(DbModelDaoABC[ShortUrl]):
|
||||
self.reference("domain", "id", ShortUrl.domain_id, "public.domains")
|
||||
self.attribute(ShortUrl.loading_screen, bool)
|
||||
|
||||
self.attribute(ShortUrl.user_id, int)
|
||||
self.reference("user", "id", ShortUrl.user_id, "administration.users")
|
||||
|
||||
|
||||
shortUrlDao = ShortUrlDao()
|
||||
|
@ -21,4 +21,4 @@ class UserSettingDao(DbModelDaoABC[UserSetting]):
|
||||
)
|
||||
|
||||
|
||||
userSettingsDao = UserSettingDao()
|
||||
userSettingDao = UserSettingDao()
|
||||
|
@ -17,4 +17,4 @@ class SettingDao(DbModelDaoABC[Setting]):
|
||||
return await self.find_single_by({Setting.key: key})
|
||||
|
||||
|
||||
settingsDao = SettingDao()
|
||||
settingDao = SettingDao()
|
||||
|
11
api/src/data/scripts/2025-04-18-12-15-user-spaces.sql
Normal file
11
api/src/data/scripts/2025-04-18-12-15-user-spaces.sql
Normal file
@ -0,0 +1,11 @@
|
||||
ALTER TABLE public.groups
|
||||
ADD COLUMN IF NOT EXISTS UserId INT NULL REFERENCES administration.users (Id);
|
||||
|
||||
ALTER TABLE public.groups_history
|
||||
ADD COLUMN IF NOT EXISTS UserId INT NULL REFERENCES administration.users (Id);
|
||||
|
||||
ALTER TABLE public.short_urls
|
||||
ADD COLUMN IF NOT EXISTS UserId INT NULL REFERENCES administration.users (Id);
|
||||
|
||||
ALTER TABLE public.short_urls_history
|
||||
ADD COLUMN IF NOT EXISTS UserId INT NULL REFERENCES administration.users (Id);
|
@ -0,0 +1,7 @@
|
||||
-- Drop the existing unique constraint
|
||||
ALTER TABLE administration.users DROP CONSTRAINT IF EXISTS UC_KeycloakId;
|
||||
|
||||
-- Add a partial unique index to allow guid-0 duplicates
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_keycloakid
|
||||
ON administration.users (KeycloakId)
|
||||
WHERE KeycloakId != '00000000-0000-0000-0000-000000000000';
|
@ -21,6 +21,7 @@ class FeatureFlagsSeeder(DataSeederABC):
|
||||
x.value: FeatureFlags.get_default(x) for x in FeatureFlagsEnum
|
||||
}
|
||||
|
||||
# Create new feature flags
|
||||
to_create = [
|
||||
FeatureFlag(0, x, possible_feature_flags[x])
|
||||
for x in possible_feature_flags.keys()
|
||||
@ -31,6 +32,19 @@ class FeatureFlagsSeeder(DataSeederABC):
|
||||
to_create_dicts = {x.key: x.value for x in to_create}
|
||||
logger.debug(f"Created feature flags: {to_create_dicts}")
|
||||
|
||||
# Update existing feature flags if they can be overwritten and have a different value
|
||||
to_update = [
|
||||
FeatureFlag(x.id, x.key, possible_feature_flags[x.key])
|
||||
for x in feature_flags
|
||||
if FeatureFlags.overwrite_flag(x.key)
|
||||
and x.value != possible_feature_flags[x.key]
|
||||
]
|
||||
if len(to_update) > 0:
|
||||
await featureFlagDao.update_many(to_update)
|
||||
to_update_dicts = {x.key: x.value for x in to_update}
|
||||
logger.debug(f"Updated feature flags: {to_update_dicts}")
|
||||
|
||||
# Delete feature flags that are no longer defined
|
||||
to_delete = [
|
||||
x for x in feature_flags if x.key not in possible_feature_flags.keys()
|
||||
]
|
||||
|
@ -3,7 +3,7 @@ from typing import Any
|
||||
from core.logger import DBLogger
|
||||
from data.abc.data_seeder_abc import DataSeederABC
|
||||
from data.schemas.system.setting import Setting
|
||||
from data.schemas.system.setting_dao import settingsDao
|
||||
from data.schemas.system.setting_dao import settingDao
|
||||
|
||||
logger = DBLogger(__name__)
|
||||
|
||||
@ -18,8 +18,8 @@ class SettingsSeeder(DataSeederABC):
|
||||
|
||||
@staticmethod
|
||||
async def _seed_if_not_exists(key: str, value: Any):
|
||||
existing = await settingsDao.find_by_key(key)
|
||||
existing = await settingDao.find_by_key(key)
|
||||
if existing is not None:
|
||||
return
|
||||
|
||||
await settingsDao.create(Setting(0, key, str(value)))
|
||||
await settingDao.create(Setting(0, key, str(value)))
|
||||
|
@ -77,7 +77,7 @@ class MigrationService:
|
||||
|
||||
logger.debug(f"Running upgrade migration: {migration.name}")
|
||||
|
||||
await self._db.execute(migration.script)
|
||||
await self._db.execute(migration.script, multi=False)
|
||||
|
||||
await executedMigrationDao.create(
|
||||
ExecutedMigration(migration.name), skip_editor=True
|
||||
|
126
api/src/service/data_privacy_service.py
Normal file
126
api/src/service/data_privacy_service.py
Normal file
@ -0,0 +1,126 @@
|
||||
import importlib
|
||||
import json
|
||||
from typing import Type
|
||||
|
||||
from api.auth.keycloak_client import Keycloak
|
||||
from api.broadcast import broadcast
|
||||
from core.database.abc.data_access_object_abc import DataAccessObjectABC
|
||||
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
|
||||
|
||||
logger = Logger("DataPrivacy")
|
||||
|
||||
|
||||
class DataPrivacyService:
|
||||
|
||||
@staticmethod
|
||||
def _dynamic_import_dao(dao_class: Type[DataAccessObjectABC]):
|
||||
"""
|
||||
Dynamically import a DAO class and its instance.
|
||||
:param dao_class: The DAO class to be imported.
|
||||
:return: The DAO instance.
|
||||
"""
|
||||
module = importlib.import_module(dao_class.__module__)
|
||||
dao_instance = getattr(
|
||||
module, first_to_lower(first_to_lower(dao_class.__name__))
|
||||
)
|
||||
return dao_instance
|
||||
|
||||
@classmethod
|
||||
async def _collect_user_relevant_dao(cls):
|
||||
"""
|
||||
Collect all DAO classes that are relevant for data privacy.
|
||||
:return: List of relevant DAO classes.
|
||||
"""
|
||||
# This method should return a list of DAOs that are relevant for data privacy
|
||||
# For example, it could return a list of DAOs that contain user data
|
||||
classes: list[DataAccessObjectABC] = [
|
||||
cls._dynamic_import_dao(dao) for dao in DbModelDaoABC.__subclasses__()
|
||||
]
|
||||
return [x for x in classes if x.has_attribute("user_id")]
|
||||
|
||||
@classmethod
|
||||
async def export_user_data(cls, user_id: int):
|
||||
"""
|
||||
Export user data from the database.
|
||||
:param user_id: ID of the user whose data is to be exported.
|
||||
:return: User data in a structured format.
|
||||
"""
|
||||
# Logic to export user data
|
||||
user = await userDao.find_by_id(user_id)
|
||||
if user is None:
|
||||
raise ValueError("User not found")
|
||||
|
||||
collected_data = {"user": userDao.to_dict(await userDao.find_by_id(user_id))}
|
||||
|
||||
daos = await cls._collect_user_relevant_dao()
|
||||
for dao in daos:
|
||||
data = await dao.find_by([{"userid": user_id}])
|
||||
collected_data[first_to_lower(type(dao).__name__.replace("Dao", "s"))] = [
|
||||
dao.to_dict(x) for x in data
|
||||
]
|
||||
|
||||
return json.dumps(collected_data, default=str)
|
||||
|
||||
@staticmethod
|
||||
async def anonymize_user(user_id: int):
|
||||
"""
|
||||
Anonymize user data in the database.
|
||||
:param user_id: ID of the user to be anonymized.
|
||||
"""
|
||||
user = await userDao.find_by_id(user_id)
|
||||
if user is None:
|
||||
raise ValueError("User not found")
|
||||
|
||||
keycloak_id = user.keycloak_id
|
||||
|
||||
# Anonymize internal data
|
||||
await user.anonymize()
|
||||
|
||||
# Anonymize external data
|
||||
try:
|
||||
Keycloak.admin.delete_user(keycloak_id)
|
||||
await broadcast.publish("userLogout", user.id)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to anonymize external data for user {user_id}", e)
|
||||
raise ValueError("Failed to anonymize external data") from e
|
||||
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
async def delete_user_data(cls, user_id: int):
|
||||
"""
|
||||
Delete user data from the database.
|
||||
:param user_id: ID of the user whose data is to be deleted.
|
||||
"""
|
||||
user = await userDao.find_by_id(user_id)
|
||||
if user is None:
|
||||
raise ValueError("User not found")
|
||||
|
||||
keycloak_id = user.keycloak_id
|
||||
|
||||
daos = await cls._collect_user_relevant_dao()
|
||||
for dao in daos:
|
||||
data = await dao.find_by([{"userid": user_id}])
|
||||
try:
|
||||
await dao.delete_many(data, hard_delete=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete data for user {user_id}", e)
|
||||
raise ValueError("Failed to delete data") from e
|
||||
|
||||
try:
|
||||
await userDao.delete(user)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete user {user_id}", e)
|
||||
raise ValueError("Failed to delete user") from e
|
||||
|
||||
# Delete external data
|
||||
try:
|
||||
Keycloak.admin.delete_user(keycloak_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete external data for user {user_id}", e)
|
||||
raise ValueError("Failed to delete external data") from e
|
||||
|
||||
return True
|
@ -17,7 +17,7 @@
|
||||
<app-footer></app-footer>
|
||||
|
||||
<p-toast></p-toast>
|
||||
<p-confirmDialog #cd key="confirmConfirmationDialog" [baseZIndex]="10000">
|
||||
<p-confirmDialog #cd key="confirmConfirmationDialog" [baseZIndex]="10000" styleClass="max-w-md">
|
||||
<ng-template pTemplate="footer">
|
||||
<div class="flex gap-2.5 items-center justify-end">
|
||||
<p-button
|
||||
|
@ -32,6 +32,7 @@ import { ConfigService } from 'src/app/service/config.service';
|
||||
import { ServerUnavailableComponent } from 'src/app/components/error/server-unavailable/server-unavailable.component';
|
||||
import { SpinnerService } from 'src/app/service/spinner.service';
|
||||
import { AuthService } from 'src/app/service/auth.service';
|
||||
import { TranslationPreloaderService } from 'src/app/service/translation-preloader.service';
|
||||
|
||||
if (environment.production) {
|
||||
Logger.enableProductionMode();
|
||||
@ -39,8 +40,12 @@ if (environment.production) {
|
||||
Logger.enableDevMode();
|
||||
}
|
||||
|
||||
export function preloadTranslations(preloader: TranslationPreloaderService) {
|
||||
return () => preloader.preloadLanguages();
|
||||
}
|
||||
|
||||
export function HttpLoaderFactory(http: HttpClient) {
|
||||
return new TranslateHttpLoader(http);
|
||||
return new TranslateHttpLoader(http, '/assets/i18n/', '.json');
|
||||
}
|
||||
|
||||
export function appInitializerFactory(
|
||||
@ -89,6 +94,12 @@ export function appInitializerFactory(
|
||||
AppRoutingModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_INITIALIZER,
|
||||
useFactory: preloadTranslations,
|
||||
deps: [TranslationPreloaderService],
|
||||
multi: true,
|
||||
},
|
||||
MessageService,
|
||||
ConfirmationService,
|
||||
DialogService,
|
||||
|
@ -11,7 +11,7 @@
|
||||
<!-- <img src="/assets/images/logo.svg" alt="logo"/>-->
|
||||
</div>
|
||||
<div class="app-name">
|
||||
<h1>LAN-Maestro</h1>
|
||||
<h1>Open-Redirect</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Component, EventEmitter, OnDestroy, OnInit } from '@angular/core';
|
||||
import { MenuItem, PrimeNGConfig } from 'primeng/api';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
@ -9,8 +9,13 @@ import { AuthService } from 'src/app/service/auth.service';
|
||||
import { MenuElement } from 'src/app/model/view/menu-element';
|
||||
import { SidebarService } from 'src/app/service/sidebar.service';
|
||||
import { ConfigService } from 'src/app/service/config.service';
|
||||
import { UserSettingsService } from 'src/app/service/user_settings.service';
|
||||
import { UserSettingsService } from 'src/app/service/user-settings.service';
|
||||
import { SettingsService } from 'src/app/service/settings.service';
|
||||
import { DataPrivacyService } from 'src/app/service/data-privacy.service';
|
||||
import { ConfirmationDialogService } from 'src/app/service/confirmation-dialog.service';
|
||||
import { Logger } from 'src/app/service/logger.service';
|
||||
|
||||
const logger = new Logger('Header');
|
||||
|
||||
@Component({
|
||||
selector: 'app-header',
|
||||
@ -28,6 +33,8 @@ export class HeaderComponent implements OnInit, OnDestroy {
|
||||
|
||||
menu: MenuElement[] = [];
|
||||
|
||||
private langChange: EventEmitter<void> = new EventEmitter<void>();
|
||||
|
||||
constructor(
|
||||
private translateService: TranslateService,
|
||||
private ngConfig: PrimeNGConfig,
|
||||
@ -36,8 +43,14 @@ export class HeaderComponent implements OnInit, OnDestroy {
|
||||
private sidebarService: SidebarService,
|
||||
private config: ConfigService,
|
||||
private settings: SettingsService,
|
||||
private userSettings: UserSettingsService
|
||||
private userSettings: UserSettingsService,
|
||||
private dataPrivacyService: DataPrivacyService,
|
||||
private confirmation: ConfirmationDialogService
|
||||
) {
|
||||
this.langChange.subscribe(async () => {
|
||||
await this.initUserMenuList();
|
||||
});
|
||||
|
||||
this.guiService.isMobile$
|
||||
.pipe(takeUntil(this.unsubscribe$))
|
||||
.subscribe(isMobile => {
|
||||
@ -48,11 +61,11 @@ export class HeaderComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
|
||||
this.auth.user$.pipe(takeUntil(this.unsubscribe$)).subscribe(async user => {
|
||||
await this.initMenuLists();
|
||||
this.user = user;
|
||||
|
||||
await this.loadTheme();
|
||||
await this.loadLang();
|
||||
|
||||
this.user = user;
|
||||
this.guiService.loadedGuiSettings$.next(true);
|
||||
});
|
||||
|
||||
@ -117,23 +130,71 @@ export class HeaderComponent implements OnInit, OnDestroy {
|
||||
visible: !!this.user,
|
||||
},
|
||||
{
|
||||
separator: true,
|
||||
label: this.translateService.instant('header.privacy'),
|
||||
items: [
|
||||
{
|
||||
label: this.translateService.instant('privacy.export_data'),
|
||||
command: () => {
|
||||
if (!this.user) {
|
||||
return;
|
||||
}
|
||||
this.dataPrivacyService.downloadDataExportJson(this.user?.id);
|
||||
},
|
||||
icon: 'pi pi-download',
|
||||
},
|
||||
{
|
||||
label: this.translateService.instant('privacy.delete_data'),
|
||||
command: () => {
|
||||
this.confirmation.confirmDialog({
|
||||
header: 'privacy.delete_data_header',
|
||||
message: 'privacy.delete_data_message',
|
||||
accept: () => {
|
||||
if (!this.user) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dataPrivacyService
|
||||
.anonymizeData(this.user.id)
|
||||
.subscribe(() => {});
|
||||
},
|
||||
});
|
||||
},
|
||||
icon: 'pi pi-trash',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: this.translateService.instant('header.logout'),
|
||||
command: async () => {
|
||||
await this.auth.logout();
|
||||
},
|
||||
icon: 'pi pi-sign-out',
|
||||
label: this.translateService.instant('header.profile'),
|
||||
items: [
|
||||
{
|
||||
label: this.translateService.instant('header.edit_profile'),
|
||||
command: () => {
|
||||
window.open(
|
||||
`${this.config.settings.keycloak.url}/realms/${this.config.settings.keycloak.realm}/account`,
|
||||
'_blank'
|
||||
);
|
||||
},
|
||||
icon: 'pi pi-user-edit',
|
||||
},
|
||||
{
|
||||
label: this.translateService.instant('header.logout'),
|
||||
command: async () => {
|
||||
await this.auth.logout();
|
||||
},
|
||||
icon: 'pi pi-sign-out',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
translate(lang: string) {
|
||||
logger.debug('translate', lang);
|
||||
this.translateService.use(lang);
|
||||
this.translateService
|
||||
.get('primeng')
|
||||
.subscribe(res => this.ngConfig.setTranslation(res));
|
||||
this.langChange.next();
|
||||
}
|
||||
|
||||
async loadTheme() {
|
||||
|
@ -4,6 +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';
|
||||
|
||||
const log = new Logger('PermissionGuard');
|
||||
|
||||
@ -14,11 +15,18 @@ export class PermissionGuard {
|
||||
constructor(
|
||||
private router: Router,
|
||||
private toast: ToastService,
|
||||
private auth: AuthService
|
||||
private auth: AuthService,
|
||||
private features: FeatureFlagService
|
||||
) {}
|
||||
|
||||
async canActivate(route: ActivatedRouteSnapshot): Promise<boolean> {
|
||||
const permissions = route.data['permissions'] as PermissionsEnum[];
|
||||
const checkByPerUserSetup = route.data['checkByPerUserSetup'] as boolean;
|
||||
|
||||
const isPerUserSetup = await this.features.get('PerUserSetup');
|
||||
if (checkByPerUserSetup && isPerUserSetup) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!permissions || permissions.length === 0) {
|
||||
return true;
|
||||
|
@ -1,5 +1,16 @@
|
||||
import { Theme } from 'src/app/model/view/theme';
|
||||
|
||||
export interface AppSettingsFromConfig {
|
||||
termsUrl: string;
|
||||
privacyURL: string;
|
||||
imprintURL: string;
|
||||
themes: Theme[];
|
||||
loadingScreen: LoadingScreenSettings;
|
||||
keycloak: KeycloakSettings;
|
||||
api: ApiSettings;
|
||||
languages?: string[];
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
termsUrl: string;
|
||||
privacyURL: string;
|
||||
@ -8,6 +19,7 @@ export interface AppSettings {
|
||||
loadingScreen: LoadingScreenSettings;
|
||||
keycloak: KeycloakSettings;
|
||||
api: ApiSettings;
|
||||
languages: string[];
|
||||
}
|
||||
|
||||
export interface LoadingScreenSettings {
|
||||
|
@ -22,7 +22,7 @@ const routes: Routes = [
|
||||
m => m.GroupsModule
|
||||
),
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permissions: [PermissionsEnum.groups] },
|
||||
data: { permissions: [PermissionsEnum.groups], checkByPerUserSetup: true },
|
||||
},
|
||||
{
|
||||
path: 'urls',
|
||||
@ -36,6 +36,7 @@ const routes: Routes = [
|
||||
PermissionsEnum.shortUrls,
|
||||
PermissionsEnum.shortUrlsByAssignment,
|
||||
],
|
||||
checkByPerUserSetup: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -228,7 +228,7 @@ export class DomainsDataService
|
||||
return this.apollo
|
||||
.mutate<{ domain: { delete: boolean } }>({
|
||||
mutation: gql`
|
||||
mutation deleteDomain($id: ID!) {
|
||||
mutation deleteDomain($id: Int!) {
|
||||
domain {
|
||||
delete(id: $id)
|
||||
}
|
||||
@ -251,7 +251,7 @@ export class DomainsDataService
|
||||
return this.apollo
|
||||
.mutate<{ domain: { restore: boolean } }>({
|
||||
mutation: gql`
|
||||
mutation restoreDomain($id: ID!) {
|
||||
mutation restoreDomain($id: Int!) {
|
||||
domain {
|
||||
restore(id: $id)
|
||||
}
|
||||
|
@ -27,8 +27,9 @@
|
||||
type="text"
|
||||
formControlName="name"/>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div *ngIf="!isPerUserSetup" class="divider"></div>
|
||||
<p-multiSelect
|
||||
*ngIf="!isPerUserSetup"
|
||||
[options]="roles"
|
||||
formControlName="roles"
|
||||
optionLabel="name"
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { FormControl, FormGroup, Validators } from '@angular/forms';
|
||||
import { ToastService } from 'src/app/service/toast.service';
|
||||
import { FormPageBase } from 'src/app/core/base/form-page-base';
|
||||
@ -10,28 +10,40 @@ import {
|
||||
import { GroupsDataService } from 'src/app/modules/admin/groups/groups.data.service';
|
||||
import { Role } from 'src/app/model/entities/role';
|
||||
import { CommonDataService } from 'src/app/modules/shared/service/common-data.service';
|
||||
import { FeatureFlagService } from 'src/app/service/feature-flag.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-group-form-page',
|
||||
templateUrl: './group-form-page.component.html',
|
||||
styleUrl: './group-form-page.component.scss',
|
||||
})
|
||||
export class GroupFormPageComponent extends FormPageBase<
|
||||
Group,
|
||||
GroupCreateInput,
|
||||
GroupUpdateInput,
|
||||
GroupsDataService
|
||||
> {
|
||||
export class GroupFormPageComponent
|
||||
extends FormPageBase<
|
||||
Group,
|
||||
GroupCreateInput,
|
||||
GroupUpdateInput,
|
||||
GroupsDataService
|
||||
>
|
||||
implements OnInit
|
||||
{
|
||||
roles: Role[] = [];
|
||||
isPerUserSetup = true;
|
||||
|
||||
constructor(
|
||||
private features: FeatureFlagService,
|
||||
private toast: ToastService,
|
||||
private cds: CommonDataService
|
||||
) {
|
||||
super();
|
||||
this.cds.getAllRoles().subscribe(roles => {
|
||||
this.roles = roles;
|
||||
});
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
this.isPerUserSetup = await this.features.get('PerUserSetup');
|
||||
if (!this.isPerUserSetup) {
|
||||
this.cds.getAllRoles().subscribe(roles => {
|
||||
this.roles = roles;
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.nodeId) {
|
||||
this.node = this.new();
|
||||
|
@ -238,7 +238,7 @@ export class GroupsDataService
|
||||
return this.apollo
|
||||
.mutate<{ group: { delete: boolean } }>({
|
||||
mutation: gql`
|
||||
mutation deleteGroup($id: ID!) {
|
||||
mutation deleteGroup($id: Int!) {
|
||||
group {
|
||||
delete(id: $id)
|
||||
}
|
||||
@ -261,7 +261,7 @@ export class GroupsDataService
|
||||
return this.apollo
|
||||
.mutate<{ group: { restore: boolean } }>({
|
||||
mutation: gql`
|
||||
mutation restoreGroup($id: ID!) {
|
||||
mutation restoreGroup($id: Int!) {
|
||||
group {
|
||||
restore(id: $id)
|
||||
}
|
||||
|
@ -21,7 +21,8 @@ const routes: Routes = [
|
||||
component: GroupFormPageComponent,
|
||||
canActivate: [PermissionGuard],
|
||||
data: {
|
||||
permissions: [PermissionsEnum.apiKeysCreate],
|
||||
permissions: [PermissionsEnum.groupsCreate],
|
||||
checkByPerUserSetup: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -29,7 +30,8 @@ const routes: Routes = [
|
||||
component: GroupFormPageComponent,
|
||||
canActivate: [PermissionGuard],
|
||||
data: {
|
||||
permissions: [PermissionsEnum.apiKeysUpdate],
|
||||
permissions: [PermissionsEnum.groupsUpdate],
|
||||
checkByPerUserSetup: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -37,7 +39,8 @@ const routes: Routes = [
|
||||
component: HistoryComponent,
|
||||
canActivate: [PermissionGuard],
|
||||
data: {
|
||||
permissions: [PermissionsEnum.domains],
|
||||
permissions: [PermissionsEnum.groups],
|
||||
checkByPerUserSetup: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { PageBase } from 'src/app/core/base/page-base';
|
||||
import { ToastService } from 'src/app/service/toast.service';
|
||||
import { ConfirmationDialogService } from 'src/app/service/confirmation-dialog.service';
|
||||
@ -6,28 +6,63 @@ import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum';
|
||||
import { Group } from 'src/app/model/entities/group';
|
||||
import { GroupsDataService } from 'src/app/modules/admin/groups/groups.data.service';
|
||||
import { GroupsColumns } from 'src/app/modules/admin/groups/groups.columns';
|
||||
import { AuthService } from 'src/app/service/auth.service';
|
||||
import { ConfigService } from 'src/app/service/config.service';
|
||||
import { FeatureFlagService } from 'src/app/service/feature-flag.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-groups',
|
||||
templateUrl: './groups.page.html',
|
||||
styleUrl: './groups.page.scss',
|
||||
})
|
||||
export class GroupsPage extends PageBase<
|
||||
Group,
|
||||
GroupsDataService,
|
||||
GroupsColumns
|
||||
> {
|
||||
export class GroupsPage
|
||||
extends PageBase<Group, GroupsDataService, GroupsColumns>
|
||||
implements OnInit
|
||||
{
|
||||
constructor(
|
||||
private toast: ToastService,
|
||||
private confirmation: ConfirmationDialogService
|
||||
private confirmation: ConfirmationDialogService,
|
||||
private auth: AuthService,
|
||||
private config: ConfigService,
|
||||
private features: FeatureFlagService
|
||||
) {
|
||||
super(true, {
|
||||
read: [PermissionsEnum.groups],
|
||||
create: [PermissionsEnum.groupsCreate],
|
||||
update: [PermissionsEnum.groupsUpdate],
|
||||
delete: [PermissionsEnum.groupsDelete],
|
||||
restore: [PermissionsEnum.groupsDelete],
|
||||
});
|
||||
super(true);
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
this.requiredPermissions = {
|
||||
read: (await this.features.get('PerUserSetup'))
|
||||
? []
|
||||
: [PermissionsEnum.groups],
|
||||
create: (await this.features.get('PerUserSetup'))
|
||||
? []
|
||||
: (await this.auth.hasAnyPermissionLazy(
|
||||
this.requiredPermissions.create ?? []
|
||||
))
|
||||
? (this.requiredPermissions.create ?? [])
|
||||
: [],
|
||||
update: (await this.features.get('PerUserSetup'))
|
||||
? []
|
||||
: (await this.auth.hasAnyPermissionLazy(
|
||||
this.requiredPermissions.update ?? []
|
||||
))
|
||||
? (this.requiredPermissions.update ?? [])
|
||||
: [],
|
||||
delete: (await this.features.get('PerUserSetup'))
|
||||
? []
|
||||
: (await this.auth.hasAnyPermissionLazy(
|
||||
this.requiredPermissions.delete ?? []
|
||||
))
|
||||
? (this.requiredPermissions.delete ?? [])
|
||||
: [],
|
||||
restore: (await this.features.get('PerUserSetup'))
|
||||
? []
|
||||
: (await this.auth.hasAnyPermissionLazy(
|
||||
this.requiredPermissions.restore ?? []
|
||||
))
|
||||
? (this.requiredPermissions.restore ?? [])
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
load(silent?: boolean): void {
|
||||
|
@ -65,7 +65,7 @@
|
||||
></p-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-page-input">
|
||||
<div class="form-page-input" *ngIf="!isPerUserSetup">
|
||||
<p class="label">{{ 'common.domain' | translate }}</p>
|
||||
<div
|
||||
class="value">
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { FormControl, FormGroup, Validators } from '@angular/forms';
|
||||
import { ToastService } from 'src/app/service/toast.service';
|
||||
import { FormPageBase } from 'src/app/core/base/form-page-base';
|
||||
@ -10,29 +10,45 @@ import {
|
||||
import { ShortUrlsDataService } from 'src/app/modules/admin/short-urls/short-urls.data.service';
|
||||
import { Group } from 'src/app/model/entities/group';
|
||||
import { Domain } from 'src/app/model/entities/domain';
|
||||
import { FeatureFlagService } from 'src/app/service/feature-flag.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-short-url-form-page',
|
||||
templateUrl: './short-url-form-page.component.html',
|
||||
styleUrl: './short-url-form-page.component.scss',
|
||||
})
|
||||
export class ShortUrlFormPageComponent extends FormPageBase<
|
||||
ShortUrl,
|
||||
ShortUrlCreateInput,
|
||||
ShortUrlUpdateInput,
|
||||
ShortUrlsDataService
|
||||
> {
|
||||
export class ShortUrlFormPageComponent
|
||||
extends FormPageBase<
|
||||
ShortUrl,
|
||||
ShortUrlCreateInput,
|
||||
ShortUrlUpdateInput,
|
||||
ShortUrlsDataService
|
||||
>
|
||||
implements OnInit
|
||||
{
|
||||
groups: Group[] = [];
|
||||
domains: Domain[] = [];
|
||||
|
||||
constructor(private toast: ToastService) {
|
||||
isPerUserSetup = true;
|
||||
|
||||
constructor(
|
||||
private features: FeatureFlagService,
|
||||
private toast: ToastService
|
||||
) {
|
||||
super();
|
||||
this.dataService.getAllGroups().subscribe(groups => {
|
||||
this.groups = groups;
|
||||
});
|
||||
this.dataService.getAllDomains().subscribe(domains => {
|
||||
this.domains = domains;
|
||||
});
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
this.isPerUserSetup = await this.features.get('PerUserSetup');
|
||||
|
||||
if (!this.isPerUserSetup) {
|
||||
this.dataService.getAllDomains().subscribe(domains => {
|
||||
this.domains = domains;
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.nodeId) {
|
||||
this.node = this.new();
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Injectable, Provider } from '@angular/core';
|
||||
import { merge, Observable } from 'rxjs';
|
||||
import { forkJoin, merge, Observable } from 'rxjs';
|
||||
import {
|
||||
Create,
|
||||
Delete,
|
||||
@ -48,50 +48,88 @@ export class ShortUrlsDataService
|
||||
skip?: number | undefined,
|
||||
take?: number | undefined
|
||||
): Observable<QueryResult<ShortUrl>> {
|
||||
return this.apollo
|
||||
.query<{ shortUrls: QueryResult<ShortUrl> }>({
|
||||
query: gql`
|
||||
query getShortUrls($filter: [ShortUrlFilter], $sort: [ShortUrlSort]) {
|
||||
shortUrls(filter: $filter, sort: $sort) {
|
||||
count
|
||||
totalCount
|
||||
nodes {
|
||||
const query1 = this.apollo.query<{ shortUrls: QueryResult<ShortUrl> }>({
|
||||
query: gql`
|
||||
query getShortUrls($filter: [ShortUrlFilter], $sort: [ShortUrlSort]) {
|
||||
shortUrls(filter: $filter, sort: $sort) {
|
||||
nodes {
|
||||
id
|
||||
shortUrl
|
||||
targetUrl
|
||||
description
|
||||
loadingScreen
|
||||
visits
|
||||
group {
|
||||
id
|
||||
shortUrl
|
||||
targetUrl
|
||||
description
|
||||
loadingScreen
|
||||
visits
|
||||
group {
|
||||
id
|
||||
name
|
||||
}
|
||||
domain {
|
||||
id
|
||||
name
|
||||
}
|
||||
|
||||
...DB_MODEL
|
||||
name
|
||||
}
|
||||
domain {
|
||||
id
|
||||
name
|
||||
}
|
||||
|
||||
...DB_MODEL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${DB_MODEL_FRAGMENT}
|
||||
`,
|
||||
variables: {
|
||||
filter: [{ group: { deleted: { equal: false } } }, ...(filter ?? [])],
|
||||
sort: [{ id: SortOrder.DESC }, ...(sort ?? [])],
|
||||
skip: skip,
|
||||
take: take,
|
||||
},
|
||||
${DB_MODEL_FRAGMENT}
|
||||
`,
|
||||
variables: {
|
||||
filter: [{ group: { deleted: { equal: false } } }, ...(filter ?? [])],
|
||||
sort: [{ id: SortOrder.DESC }, ...(sort ?? [])],
|
||||
skip,
|
||||
take,
|
||||
},
|
||||
});
|
||||
|
||||
const query2 = this.apollo.query<{ shortUrls: QueryResult<ShortUrl> }>({
|
||||
query: gql`
|
||||
query getShortUrls($filter: [ShortUrlFilter], $sort: [ShortUrlSort]) {
|
||||
shortUrls(filter: $filter, sort: $sort) {
|
||||
nodes {
|
||||
id
|
||||
shortUrl
|
||||
targetUrl
|
||||
description
|
||||
loadingScreen
|
||||
visits
|
||||
group {
|
||||
id
|
||||
name
|
||||
}
|
||||
domain {
|
||||
id
|
||||
name
|
||||
}
|
||||
|
||||
...DB_MODEL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${DB_MODEL_FRAGMENT}
|
||||
`,
|
||||
variables: {
|
||||
filter: [{ group: { isNull: true } }, ...(filter ?? [])],
|
||||
sort: [{ id: SortOrder.DESC }, ...(sort ?? [])],
|
||||
skip,
|
||||
take,
|
||||
},
|
||||
});
|
||||
|
||||
return forkJoin([query1, query2]).pipe(
|
||||
map(([result1, result2]) => {
|
||||
const nodes = [
|
||||
...result1.data.shortUrls.nodes,
|
||||
...result2.data.shortUrls.nodes,
|
||||
];
|
||||
const uniqueNodes = Array.from(
|
||||
new Map(nodes.map(node => [node.id, node])).values()
|
||||
);
|
||||
return { ...result1.data.shortUrls, nodes: uniqueNodes };
|
||||
})
|
||||
.pipe(
|
||||
catchError(err => {
|
||||
this.spinner.hide();
|
||||
throw err;
|
||||
})
|
||||
)
|
||||
.pipe(map(result => result.data.shortUrls));
|
||||
);
|
||||
}
|
||||
|
||||
loadById(id: number): Observable<ShortUrl> {
|
||||
@ -285,7 +323,7 @@ export class ShortUrlsDataService
|
||||
return this.apollo
|
||||
.mutate<{ shortUrl: { delete: boolean } }>({
|
||||
mutation: gql`
|
||||
mutation deleteShortUrl($id: ID!) {
|
||||
mutation deleteShortUrl($id: Int!) {
|
||||
shortUrl {
|
||||
delete(id: $id)
|
||||
}
|
||||
@ -308,7 +346,7 @@ export class ShortUrlsDataService
|
||||
return this.apollo
|
||||
.mutate<{ shortUrl: { restore: boolean } }>({
|
||||
mutation: gql`
|
||||
mutation restoreShortUrl($id: ID!) {
|
||||
mutation restoreShortUrl($id: Int!) {
|
||||
shortUrl {
|
||||
restore(id: $id)
|
||||
}
|
||||
|
@ -22,6 +22,7 @@ const routes: Routes = [
|
||||
canActivate: [PermissionGuard],
|
||||
data: {
|
||||
permissions: [PermissionsEnum.shortUrlsCreate],
|
||||
checkByPerUserSetup: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -30,6 +31,7 @@ const routes: Routes = [
|
||||
canActivate: [PermissionGuard],
|
||||
data: {
|
||||
permissions: [PermissionsEnum.shortUrlsUpdate],
|
||||
checkByPerUserSetup: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -37,7 +39,8 @@ const routes: Routes = [
|
||||
component: HistoryComponent,
|
||||
canActivate: [PermissionGuard],
|
||||
data: {
|
||||
permissions: [PermissionsEnum.domains],
|
||||
permissions: [PermissionsEnum.shortUrls],
|
||||
checkByPerUserSetup: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
@ -41,7 +41,7 @@
|
||||
icon="pi pi-pencil"
|
||||
tooltipPosition="left"
|
||||
pTooltip="{{ 'table.update' | translate }}"
|
||||
[disabled]="url?.deleted"
|
||||
[disabled]="url.deleted"
|
||||
routerLink="edit/{{ url.id }}"></p-button>
|
||||
<p-button
|
||||
*ngIf="hasPermissions.delete"
|
||||
@ -49,10 +49,10 @@
|
||||
icon="pi pi-trash"
|
||||
tooltipPosition="left"
|
||||
pTooltip="{{ 'table.delete' | translate }}"
|
||||
[disabled]="url?.deleted"
|
||||
[disabled]="url.deleted"
|
||||
(click)="delete(url)"></p-button>
|
||||
<p-button
|
||||
*ngIf="url?.deleted && hasPermissions.restore"
|
||||
*ngIf="url.deleted && hasPermissions.restore"
|
||||
class="icon-btn btn"
|
||||
icon="pi pi-undo"
|
||||
tooltipPosition="left"
|
||||
|
@ -12,6 +12,7 @@ import QrCodeWithLogo from 'qrcode-with-logos';
|
||||
import { FileUpload, FileUploadHandlerEvent } from 'primeng/fileupload';
|
||||
import { ConfigService } from 'src/app/service/config.service';
|
||||
import { ResolvedTableColumn } from 'src/app/modules/shared/components/table/table.model';
|
||||
import { FeatureFlagService } from 'src/app/service/feature-flag.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-short-urls',
|
||||
@ -67,7 +68,8 @@ export class ShortUrlsPage
|
||||
private toast: ToastService,
|
||||
private confirmation: ConfirmationDialogService,
|
||||
private auth: AuthService,
|
||||
private config: ConfigService
|
||||
private config: ConfigService,
|
||||
private features: FeatureFlagService
|
||||
) {
|
||||
super(true, {
|
||||
read: [PermissionsEnum.shortUrls],
|
||||
@ -80,21 +82,26 @@ export class ShortUrlsPage
|
||||
|
||||
async ngOnInit() {
|
||||
this.hasPermissions = {
|
||||
read: await this.auth.hasAnyPermissionLazy(
|
||||
this.requiredPermissions.read ?? []
|
||||
),
|
||||
create: await this.auth.hasAnyPermissionLazy(
|
||||
this.requiredPermissions.create ?? []
|
||||
),
|
||||
update: await this.auth.hasAnyPermissionLazy(
|
||||
this.requiredPermissions.update ?? []
|
||||
),
|
||||
delete: await this.auth.hasAnyPermissionLazy(
|
||||
this.requiredPermissions.delete ?? []
|
||||
),
|
||||
restore: await this.auth.hasAnyPermissionLazy(
|
||||
this.requiredPermissions.restore ?? []
|
||||
),
|
||||
read:
|
||||
(await this.auth.hasAnyPermissionLazy(
|
||||
this.requiredPermissions.read ?? []
|
||||
)) || (await this.features.get('PerUserSetup')),
|
||||
create:
|
||||
(await this.auth.hasAnyPermissionLazy(
|
||||
this.requiredPermissions.create ?? []
|
||||
)) || (await this.features.get('PerUserSetup')),
|
||||
update:
|
||||
(await this.auth.hasAnyPermissionLazy(
|
||||
this.requiredPermissions.update ?? []
|
||||
)) || (await this.features.get('PerUserSetup')),
|
||||
delete:
|
||||
(await this.auth.hasAnyPermissionLazy(
|
||||
this.requiredPermissions.delete ?? []
|
||||
)) || (await this.features.get('PerUserSetup')),
|
||||
restore:
|
||||
(await this.auth.hasAnyPermissionLazy(
|
||||
this.requiredPermissions.restore ?? []
|
||||
)) || (await this.features.get('PerUserSetup')),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,10 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { throwError } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { AppSettings } from 'src/app/model/config/app-settings';
|
||||
import {
|
||||
AppSettings,
|
||||
AppSettingsFromConfig,
|
||||
} from 'src/app/model/config/app-settings';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { environment } from 'src/environments/environment';
|
||||
|
||||
@ -33,6 +36,7 @@ export class ConfigService {
|
||||
realm: '',
|
||||
clientId: '',
|
||||
},
|
||||
languages: ['en', 'de'],
|
||||
};
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
@ -40,7 +44,7 @@ export class ConfigService {
|
||||
loadSettings(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.http
|
||||
.get<AppSettings>(`/assets/config/${environment.config}`)
|
||||
.get<AppSettingsFromConfig>(`/assets/config/${environment.config}`)
|
||||
.pipe(
|
||||
catchError(error => {
|
||||
reject(error);
|
||||
@ -48,13 +52,17 @@ export class ConfigService {
|
||||
})
|
||||
)
|
||||
.subscribe(settings => {
|
||||
this.settings = settings;
|
||||
if (this.settings.themes.length === 0) {
|
||||
this.settings.themes.push({
|
||||
if (settings.themes.length === 0) {
|
||||
settings.themes.push({
|
||||
label: 'Maxlan',
|
||||
name: 'maxlan',
|
||||
});
|
||||
}
|
||||
if (settings.languages?.length === 0) {
|
||||
settings.languages = ['en', 'de'];
|
||||
}
|
||||
|
||||
this.settings = settings as AppSettings;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
77
web/src/app/service/data-privacy.service.ts
Normal file
77
web/src/app/service/data-privacy.service.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Apollo, gql } from 'apollo-angular';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class DataPrivacyService {
|
||||
constructor(private apollo: Apollo) {}
|
||||
|
||||
downloadDataExportJson(userId: number): void {
|
||||
this.exportDataPrivacy(userId).subscribe(dataString => {
|
||||
const jsonData = JSON.stringify(JSON.parse(dataString), null, 2); // Format nicely
|
||||
const blob = new Blob([jsonData], { type: 'application/json' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = 'exported-data.json';
|
||||
anchor.click();
|
||||
|
||||
window.URL.revokeObjectURL(url);
|
||||
});
|
||||
}
|
||||
|
||||
exportDataPrivacy(userId: number): Observable<string> {
|
||||
return this.apollo
|
||||
.mutate<{ privacy: { exportData: string } }>({
|
||||
mutation: gql`
|
||||
mutation exportDataPrivacy($userId: Int!) {
|
||||
privacy {
|
||||
exportData(userId: $userId)
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
userId,
|
||||
},
|
||||
})
|
||||
.pipe(map(result => result.data?.privacy.exportData ?? ''));
|
||||
}
|
||||
|
||||
anonymizeData(userId: number): Observable<boolean> {
|
||||
return this.apollo
|
||||
.mutate<{ privacy: { anonymizeData: boolean } }>({
|
||||
mutation: gql`
|
||||
mutation anonymizeDataPrivacy($userId: Int!) {
|
||||
privacy {
|
||||
anonymizeData(userId: $userId)
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
userId,
|
||||
},
|
||||
})
|
||||
.pipe(map(result => result.data?.privacy.anonymizeData ?? false));
|
||||
}
|
||||
|
||||
deleteData(userId: number): Observable<boolean> {
|
||||
return this.apollo
|
||||
.mutate<{ privacy: { deleteData: boolean } }>({
|
||||
mutation: gql`
|
||||
mutation deleteDataPrivacy($userId: Int!) {
|
||||
privacy {
|
||||
deleteData(userId: $userId)
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
userId,
|
||||
},
|
||||
})
|
||||
.pipe(map(result => result.data?.privacy.deleteData ?? false));
|
||||
}
|
||||
}
|
@ -3,6 +3,7 @@ import { BehaviorSubject } from 'rxjs';
|
||||
import { MenuElement } from 'src/app/model/view/menu-element';
|
||||
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';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@ -11,7 +12,10 @@ export class SidebarService {
|
||||
visible$ = new BehaviorSubject<boolean>(true);
|
||||
elements$ = new BehaviorSubject<MenuElement[]>([]);
|
||||
|
||||
constructor(private auth: AuthService) {
|
||||
constructor(
|
||||
private auth: AuthService,
|
||||
private featureFlags: FeatureFlagService
|
||||
) {
|
||||
this.auth.user$.subscribe(async () => {
|
||||
await this.setElements();
|
||||
});
|
||||
@ -40,16 +44,19 @@ export class SidebarService {
|
||||
label: 'common.groups',
|
||||
icon: 'pi pi-tags',
|
||||
routerLink: ['/admin/groups'],
|
||||
visible: await this.auth.hasAnyPermissionLazy([PermissionsEnum.groups]),
|
||||
visible:
|
||||
(await this.auth.hasAnyPermissionLazy([PermissionsEnum.groups])) ||
|
||||
(await this.featureFlags.get('PerUserSetup')),
|
||||
},
|
||||
{
|
||||
label: 'common.urls',
|
||||
icon: 'pi pi-tag',
|
||||
routerLink: ['/admin/urls'],
|
||||
visible: await this.auth.hasAnyPermissionLazy([
|
||||
PermissionsEnum.shortUrls,
|
||||
PermissionsEnum.shortUrlsByAssignment,
|
||||
]),
|
||||
visible:
|
||||
(await this.auth.hasAnyPermissionLazy([
|
||||
PermissionsEnum.shortUrls,
|
||||
PermissionsEnum.shortUrlsByAssignment,
|
||||
])) || (await this.featureFlags.get('PerUserSetup')),
|
||||
},
|
||||
await this.sectionAdmin(),
|
||||
];
|
||||
|
25
web/src/app/service/translation-preloader.service.ts
Normal file
25
web/src/app/service/translation-preloader.service.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
import { ConfigService } from 'src/app/service/config.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TranslationPreloaderService {
|
||||
constructor(
|
||||
private translate: TranslateService,
|
||||
private http: HttpClient,
|
||||
private config: ConfigService
|
||||
) {}
|
||||
|
||||
preloadLanguages(): Promise<void> {
|
||||
const loadRequests = this.config.settings.languages.map(async lang => {
|
||||
const translations = await lastValueFrom(
|
||||
this.http.get(`/assets/i18n/${lang}.json`)
|
||||
);
|
||||
this.translate.setTranslation(lang, translations, true); // merge = true
|
||||
});
|
||||
|
||||
return Promise.all(loadRequests).then(() => {});
|
||||
}
|
||||
}
|
@ -76,7 +76,10 @@
|
||||
"count_header": "Gruppe(n)"
|
||||
},
|
||||
"header": {
|
||||
"logout": "Ausloggen"
|
||||
"edit_profile": "Profil bearbeiten",
|
||||
"logout": "Ausloggen",
|
||||
"privacy": "Datenschutz",
|
||||
"profile": "Profil"
|
||||
},
|
||||
"history": {
|
||||
"header": "Historie"
|
||||
@ -211,6 +214,12 @@
|
||||
"weak": "Woche",
|
||||
"weekHeader": "Wk"
|
||||
},
|
||||
"privacy": {
|
||||
"delete_data": "Daten löschen",
|
||||
"delete_data_header": "Bestätigung zur Löschung deiner personenbezogenen Daten",
|
||||
"delete_data_message": "Bestätigung zur Löschung deiner personenbezogenen Daten<br><br>Du bist dabei, dein Konto dauerhaft zu löschen.<br>Damit werden deine personenbezogenen Daten unkenntlich gemacht.<br>Eine Wiederherstellung deines Kontos oder deiner Daten ist anschließend nicht mehr möglich.<br><br>Hinweis:<br>Aus rechtlichen oder betrieblichen Gründen (z. B. gesetzliche Aufbewahrungspflichten) können bestimmte Daten weiterhin in anonymisierter oder pseudonymisierter Form gespeichert bleiben. Diese enthalten keine Informationen mehr, die dich als Person identifizierbar machen.<br><br>Bitte bestätige, dass du dies verstanden hast und mit der Löschung fortfahren möchtest.",
|
||||
"export_data": "Daten exportieren"
|
||||
},
|
||||
"role": {
|
||||
"count_header": "Rolle(n)"
|
||||
},
|
||||
|
@ -76,7 +76,10 @@
|
||||
"count_header": "Group(s)"
|
||||
},
|
||||
"header": {
|
||||
"logout": "Logout"
|
||||
"edit_profile": "Edit profile",
|
||||
"logout": "Logout",
|
||||
"privacy": "Privacy",
|
||||
"profile": "Profile"
|
||||
},
|
||||
"history": {
|
||||
"header": "History"
|
||||
@ -211,6 +214,12 @@
|
||||
"weak": "Weak",
|
||||
"weekHeader": "Wk"
|
||||
},
|
||||
"privacy": {
|
||||
"delete_data": "Delete data",
|
||||
"delete_data_header": "Confirmation of deletion of your personal data",
|
||||
"delete_data_message": "Confirmation of deletion of your personal data<br><br>You are about to permanently delete your account.<br>Your personal data will be anonymized and cannot be recovered.<br>It will no longer be possible to restore your account or associated data.<br><br>Note:<br>For legal or operational reasons (e.g. statutory retention obligations), certain data may continue to be stored in anonymized or pseudonymized form. This data no longer contains any information that can identify you as a person.<br><br>Please confirm that you understand this and wish to proceed with the deletion.",
|
||||
"export_data": "Export data"
|
||||
},
|
||||
"role": {
|
||||
"count_header": "Role(s)"
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user