data-syncmaster 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. data_syncmaster-0.1.1.dist-info/LICENSE.txt +203 -0
  2. data_syncmaster-0.1.1.dist-info/METADATA +115 -0
  3. data_syncmaster-0.1.1.dist-info/RECORD +110 -0
  4. data_syncmaster-0.1.1.dist-info/WHEEL +4 -0
  5. syncmaster/__init__.py +6 -0
  6. syncmaster/backend/__init__.py +2 -0
  7. syncmaster/backend/api/__init__.py +2 -0
  8. syncmaster/backend/api/deps.py +20 -0
  9. syncmaster/backend/api/monitoring.py +10 -0
  10. syncmaster/backend/api/router.py +10 -0
  11. syncmaster/backend/api/v1/__init__.py +2 -0
  12. syncmaster/backend/api/v1/auth/__init__.py +2 -0
  13. syncmaster/backend/api/v1/auth/router.py +32 -0
  14. syncmaster/backend/api/v1/auth/utils.py +26 -0
  15. syncmaster/backend/api/v1/connections.py +300 -0
  16. syncmaster/backend/api/v1/groups.py +225 -0
  17. syncmaster/backend/api/v1/queue.py +148 -0
  18. syncmaster/backend/api/v1/router.py +18 -0
  19. syncmaster/backend/api/v1/transfers/__init__.py +2 -0
  20. syncmaster/backend/api/v1/transfers/router.py +469 -0
  21. syncmaster/backend/api/v1/transfers/utils.py +17 -0
  22. syncmaster/backend/api/v1/users.py +75 -0
  23. syncmaster/backend/export_openapi_schema.py +26 -0
  24. syncmaster/backend/handler.py +203 -0
  25. syncmaster/backend/logger.py +2 -0
  26. syncmaster/backend/main.py +63 -0
  27. syncmaster/backend/pre_start.py +94 -0
  28. syncmaster/backend/services/__init__.py +4 -0
  29. syncmaster/backend/services/auth.py +58 -0
  30. syncmaster/backend/services/unit_of_work.py +44 -0
  31. syncmaster/config.py +110 -0
  32. syncmaster/db/__init__.py +2 -0
  33. syncmaster/db/alembic.ini +41 -0
  34. syncmaster/db/base.py +28 -0
  35. syncmaster/db/factory.py +37 -0
  36. syncmaster/db/migrations/README +1 -0
  37. syncmaster/db/migrations/__init__.py +2 -0
  38. syncmaster/db/migrations/env.py +87 -0
  39. syncmaster/db/migrations/script.py.mako +24 -0
  40. syncmaster/db/migrations/versions/2023-11-23_478240cdad4b_init.py +242 -0
  41. syncmaster/db/migrations/versions/__init__.py +2 -0
  42. syncmaster/db/mixins.py +33 -0
  43. syncmaster/db/models.py +194 -0
  44. syncmaster/db/repositories/__init__.py +22 -0
  45. syncmaster/db/repositories/base.py +109 -0
  46. syncmaster/db/repositories/connection.py +138 -0
  47. syncmaster/db/repositories/credentials_repository.py +87 -0
  48. syncmaster/db/repositories/group.py +264 -0
  49. syncmaster/db/repositories/queue.py +195 -0
  50. syncmaster/db/repositories/repository_with_owner.py +115 -0
  51. syncmaster/db/repositories/run.py +78 -0
  52. syncmaster/db/repositories/transfer.py +202 -0
  53. syncmaster/db/repositories/user.py +72 -0
  54. syncmaster/db/repositories/utils.py +25 -0
  55. syncmaster/db/utils.py +31 -0
  56. syncmaster/dto/__init__.py +2 -0
  57. syncmaster/dto/connections.py +60 -0
  58. syncmaster/dto/transfers.py +46 -0
  59. syncmaster/exceptions/__init__.py +13 -0
  60. syncmaster/exceptions/base.py +12 -0
  61. syncmaster/exceptions/connection.py +28 -0
  62. syncmaster/exceptions/credentials.py +8 -0
  63. syncmaster/exceptions/group.py +27 -0
  64. syncmaster/exceptions/queue.py +16 -0
  65. syncmaster/exceptions/run.py +19 -0
  66. syncmaster/exceptions/transfer.py +39 -0
  67. syncmaster/exceptions/user.py +11 -0
  68. syncmaster/schemas/__init__.py +2 -0
  69. syncmaster/schemas/v1/__init__.py +54 -0
  70. syncmaster/schemas/v1/auth.py +12 -0
  71. syncmaster/schemas/v1/connection_types.py +9 -0
  72. syncmaster/schemas/v1/connections/__init__.py +2 -0
  73. syncmaster/schemas/v1/connections/connection.py +146 -0
  74. syncmaster/schemas/v1/connections/hdfs.py +40 -0
  75. syncmaster/schemas/v1/connections/hive.py +40 -0
  76. syncmaster/schemas/v1/connections/oracle.py +58 -0
  77. syncmaster/schemas/v1/connections/postgres.py +48 -0
  78. syncmaster/schemas/v1/connections/s3.py +66 -0
  79. syncmaster/schemas/v1/file_formats.py +7 -0
  80. syncmaster/schemas/v1/groups.py +39 -0
  81. syncmaster/schemas/v1/page.py +40 -0
  82. syncmaster/schemas/v1/queue.py +32 -0
  83. syncmaster/schemas/v1/status.py +16 -0
  84. syncmaster/schemas/v1/transfer_types.py +6 -0
  85. syncmaster/schemas/v1/transfers/__init__.py +172 -0
  86. syncmaster/schemas/v1/transfers/db.py +23 -0
  87. syncmaster/schemas/v1/transfers/file/__init__.py +2 -0
  88. syncmaster/schemas/v1/transfers/file/base.py +47 -0
  89. syncmaster/schemas/v1/transfers/file/hdfs.py +27 -0
  90. syncmaster/schemas/v1/transfers/file/s3.py +27 -0
  91. syncmaster/schemas/v1/transfers/file_format.py +29 -0
  92. syncmaster/schemas/v1/transfers/run.py +37 -0
  93. syncmaster/schemas/v1/transfers/strategy.py +15 -0
  94. syncmaster/schemas/v1/types.py +5 -0
  95. syncmaster/schemas/v1/users.py +83 -0
  96. syncmaster/worker/__init__.py +2 -0
  97. syncmaster/worker/base.py +14 -0
  98. syncmaster/worker/config.py +18 -0
  99. syncmaster/worker/controller.py +127 -0
  100. syncmaster/worker/handlers/__init__.py +2 -0
  101. syncmaster/worker/handlers/base.py +49 -0
  102. syncmaster/worker/handlers/file/__init__.py +2 -0
  103. syncmaster/worker/handlers/file/base.py +56 -0
  104. syncmaster/worker/handlers/file/hdfs.py +14 -0
  105. syncmaster/worker/handlers/file/s3.py +20 -0
  106. syncmaster/worker/handlers/hive.py +41 -0
  107. syncmaster/worker/handlers/oracle.py +48 -0
  108. syncmaster/worker/handlers/postgres.py +47 -0
  109. syncmaster/worker/spark.py +93 -0
  110. syncmaster/worker/transfer.py +85 -0
@@ -0,0 +1,109 @@
1
+ # SPDX-FileCopyrightText: 2023-2024 MTS (Mobile Telesystems)
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ from abc import ABC
4
+ from typing import Any, Generic, TypeVar
5
+
6
+ from sqlalchemy import ScalarResult, Select, and_, delete, func, insert, select, update
7
+ from sqlalchemy.exc import NoResultFound
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+
10
+ from syncmaster.db.base import Base
11
+ from syncmaster.db.utils import Pagination
12
+ from syncmaster.exceptions import EntityNotFoundError
13
+
14
+ Model = TypeVar("Model", bound=Base)
15
+
16
+
17
+ class Repository(Generic[Model], ABC):
18
+ def __init__(self, model: type[Model], session: AsyncSession):
19
+ self._model = model
20
+ self._session = session
21
+
22
+ async def _read_by_id(self, id: int, **kwargs: Any) -> Model:
23
+ if hasattr(self._model, "is_deleted"):
24
+ query = select(self._model).filter_by(is_deleted=False, id=id, **kwargs)
25
+ obj = (await self._session.scalars(query)).first()
26
+ else:
27
+ obj = await self._session.get(self._model, id)
28
+ if obj is None:
29
+ raise EntityNotFoundError
30
+ return obj
31
+
32
+ @staticmethod
33
+ def _model_as_dict(model: Model) -> dict[str, Any]:
34
+ d = []
35
+ for c in model.__table__.columns:
36
+ if c.name == "id": # 'id' is PK autoincrement
37
+ continue
38
+ d.append(c.name)
39
+
40
+ return {key: getattr(model, key) for key in d}
41
+
42
+ async def _copy(self, *args: Any, **kwargs: Any) -> Model:
43
+ query_prev_row = select(self._model).where(*args)
44
+ result_prev_row = await self._session.scalars(query_prev_row)
45
+ origin_model = result_prev_row.one()
46
+
47
+ d = self._model_as_dict(origin_model)
48
+
49
+ for k, v in kwargs.items():
50
+ if v is None:
51
+ kwargs.update({k: getattr(origin_model, k)})
52
+
53
+ d.update(kwargs) # Process kwargs in order to keep only what needs to be updated
54
+ query_insert_new_row = insert(self._model).values(**d).returning(self._model)
55
+ try:
56
+ new_row = await self._session.scalars(query_insert_new_row)
57
+ await self._session.flush()
58
+ obj = new_row.one()
59
+ except NoResultFound as e:
60
+ raise EntityNotFoundError from e
61
+ return obj
62
+
63
+ async def _update(self, *args: Any, **kwargs: Any) -> Model:
64
+ query = update(self._model).where(*args).values(**kwargs).returning(self._model)
65
+ try:
66
+ result = await self._session.scalars(query)
67
+ await self._session.flush()
68
+ obj = result.one()
69
+ except NoResultFound as e:
70
+ raise EntityNotFoundError from e
71
+ return obj
72
+
73
+ async def _delete(self, id: int) -> Model:
74
+ if hasattr(self._model, "is_deleted"):
75
+ return await self._update(
76
+ and_(self._model.is_deleted.is_(False), self._model.id == id),
77
+ is_deleted=True,
78
+ )
79
+
80
+ query = delete(self._model).filter_by(id=id).returning(self._model)
81
+ result = await self._session.scalars(query)
82
+ await self._session.flush()
83
+ return result.one()
84
+
85
+ async def _paginate_raw_result(self, query: Select, page: int, page_size: int) -> Pagination:
86
+ items_result = await self._session.execute(query.limit(page_size).offset((page - 1) * page_size))
87
+ total: int = await self._session.scalar(select(func.count()).select_from(query.subquery()))
88
+ return Pagination(
89
+ items=items_result.all(),
90
+ total=total,
91
+ page=page,
92
+ page_size=page_size,
93
+ )
94
+
95
+ async def _paginate_scalar_result(self, query: Select, page: int, page_size: int) -> Pagination:
96
+ """
97
+ This method is needed for those queries where all fields are needed,
98
+ the scalars method discards all fields except the first
99
+ """
100
+ items_result: ScalarResult[Model] = await self._session.scalars(
101
+ query.limit(page_size).offset((page - 1) * page_size)
102
+ )
103
+ total: int = await self._session.scalar(select(func.count()).select_from(query.subquery()))
104
+ return Pagination(
105
+ items=items_result.all(),
106
+ total=total,
107
+ page=page,
108
+ page_size=page_size,
109
+ )
@@ -0,0 +1,138 @@
1
+ # SPDX-FileCopyrightText: 2023-2024 MTS (Mobile Telesystems)
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ from typing import Any, NoReturn
4
+
5
+ from sqlalchemy import ScalarResult, insert, select
6
+ from sqlalchemy.exc import DBAPIError, IntegrityError, NoResultFound
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+
9
+ from syncmaster.db.models import Connection
10
+ from syncmaster.db.repositories.repository_with_owner import RepositoryWithOwner
11
+ from syncmaster.db.utils import Pagination
12
+ from syncmaster.exceptions import EntityNotFoundError, SyncmasterError
13
+ from syncmaster.exceptions.connection import (
14
+ ConnectionNotFoundError,
15
+ ConnectionOwnerError,
16
+ DuplicatedConnectionNameError,
17
+ )
18
+ from syncmaster.exceptions.group import GroupNotFoundError
19
+ from syncmaster.exceptions.user import UserNotFoundError
20
+
21
+
22
+ class ConnectionRepository(RepositoryWithOwner[Connection]):
23
+ def __init__(self, session: AsyncSession):
24
+ super().__init__(model=Connection, session=session)
25
+
26
+ async def paginate(
27
+ self,
28
+ page: int,
29
+ page_size: int,
30
+ group_id: int,
31
+ ) -> Pagination:
32
+ stmt = select(Connection).where(
33
+ Connection.is_deleted.is_(False),
34
+ Connection.group_id == group_id,
35
+ )
36
+
37
+ return await self._paginate_scalar_result(
38
+ query=stmt.order_by(Connection.name),
39
+ page=page,
40
+ page_size=page_size,
41
+ )
42
+
43
+ async def read_by_id(
44
+ self,
45
+ connection_id: int,
46
+ ) -> Connection:
47
+ stmt = select(Connection).where(Connection.id == connection_id, Connection.is_deleted.is_(False))
48
+ result: ScalarResult[Connection] = await self._session.scalars(stmt)
49
+ try:
50
+ return result.one()
51
+ except NoResultFound as e:
52
+ raise ConnectionNotFoundError from e
53
+
54
+ async def create(
55
+ self,
56
+ group_id: int,
57
+ name: str,
58
+ description: str,
59
+ data: dict[str, Any],
60
+ ) -> Connection:
61
+ query = (
62
+ insert(Connection)
63
+ .values(
64
+ group_id=group_id,
65
+ name=name,
66
+ description=description,
67
+ data=data,
68
+ )
69
+ .returning(Connection)
70
+ )
71
+ try:
72
+ result: ScalarResult[Connection] = await self._session.scalars(query)
73
+ except IntegrityError as e:
74
+ self._raise_error(e)
75
+ else:
76
+ await self._session.flush()
77
+ return result.one()
78
+
79
+ async def update(
80
+ self,
81
+ connection_id: int,
82
+ name: str | None,
83
+ description: str | None,
84
+ connection_data: dict[str, Any],
85
+ ) -> Connection:
86
+ try:
87
+ connection = await self.read_by_id(connection_id=connection_id)
88
+ for key in connection.data:
89
+ if key not in connection_data or connection_data[key] is None:
90
+ connection_data[key] = connection.data[key]
91
+ return await self._update(
92
+ Connection.id == connection_id,
93
+ Connection.is_deleted.is_(False),
94
+ name=name or connection.name,
95
+ description=description or connection.description,
96
+ data=connection_data,
97
+ )
98
+ except IntegrityError as e:
99
+ self._raise_error(e)
100
+
101
+ async def delete(
102
+ self,
103
+ connection_id: int,
104
+ ) -> None:
105
+ try:
106
+ await self._delete(connection_id)
107
+ except (NoResultFound, EntityNotFoundError) as e:
108
+ raise ConnectionNotFoundError from e
109
+
110
+ async def copy(
111
+ self,
112
+ connection_id: int,
113
+ new_group_id: int,
114
+ new_name: str | None,
115
+ ) -> Connection:
116
+ try:
117
+ kwargs_for_copy = dict(group_id=new_group_id, name=new_name)
118
+ new_connection = await self._copy(
119
+ Connection.id == connection_id,
120
+ **kwargs_for_copy,
121
+ )
122
+
123
+ return new_connection
124
+
125
+ except IntegrityError as integrity_error:
126
+ self._raise_error(integrity_error)
127
+
128
+ def _raise_error(self, err: DBAPIError) -> NoReturn:
129
+ constraint = err.__cause__.__cause__.constraint_name
130
+ if constraint == "fk__connection__group_id__group":
131
+ raise GroupNotFoundError from err
132
+ if constraint == "fk__connection__user_id__user":
133
+ raise UserNotFoundError from err
134
+ if constraint == "ck__connection__owner_constraint":
135
+ raise ConnectionOwnerError from err
136
+ if constraint == "uq__connection__name_group_id":
137
+ raise DuplicatedConnectionNameError from err
138
+ raise SyncmasterError from err
@@ -0,0 +1,87 @@
1
+ # SPDX-FileCopyrightText: 2023-2024 MTS (Mobile Telesystems)
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ from __future__ import annotations
4
+
5
+ from typing import NoReturn
6
+
7
+ from sqlalchemy import ScalarResult, delete, insert, select
8
+ from sqlalchemy.exc import DBAPIError, IntegrityError, NoResultFound
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ from syncmaster.config import Settings
12
+ from syncmaster.db.models import AuthData
13
+ from syncmaster.db.repositories.base import Repository
14
+ from syncmaster.db.repositories.utils import decrypt_auth_data, encrypt_auth_data
15
+ from syncmaster.exceptions import SyncmasterError
16
+ from syncmaster.exceptions.credentials import AuthDataNotFoundError
17
+
18
+
19
+ class CredentialsRepository(Repository[AuthData]):
20
+ def __init__(
21
+ self,
22
+ session: AsyncSession,
23
+ settings: Settings,
24
+ model: type[AuthData],
25
+ ):
26
+ super().__init__(model=model, session=session)
27
+ self._settings = settings
28
+
29
+ async def get_for_connection(
30
+ self,
31
+ connection_id: int,
32
+ ) -> dict:
33
+ query = select(AuthData).where(AuthData.connection_id == connection_id)
34
+ try:
35
+ result: ScalarResult[AuthData] = await self._session.scalars(query)
36
+ return decrypt_auth_data(result.one().value, settings=self._settings)
37
+ except NoResultFound as e:
38
+ raise AuthDataNotFoundError(f"Connection id = {connection_id}") from e
39
+
40
+ async def add_to_connection(self, connection_id: int, data: dict) -> AuthData:
41
+ query = (
42
+ insert(AuthData)
43
+ .values(
44
+ value=encrypt_auth_data(value=data, settings=self._settings),
45
+ connection_id=connection_id,
46
+ )
47
+ .returning(AuthData)
48
+ )
49
+ try:
50
+ result: ScalarResult[AuthData] = await self._session.scalars(query)
51
+ except IntegrityError as e:
52
+ self._raise_error(e)
53
+ else:
54
+ await self._session.flush()
55
+ return result.one()
56
+
57
+ async def delete_from_connection(self, connection_id: int) -> AuthData:
58
+ query = delete(AuthData).where(AuthData.connection_id == connection_id).returning(AuthData)
59
+
60
+ try:
61
+ result: ScalarResult[AuthData] = await self._session.scalars(query)
62
+ except IntegrityError as e:
63
+ self._raise_error(e)
64
+ else:
65
+ await self._session.flush()
66
+ return result.one()
67
+
68
+ async def update(
69
+ self,
70
+ connection_id: int,
71
+ credential_data: dict,
72
+ ) -> AuthData:
73
+ creds = await self.get_for_connection(connection_id)
74
+ try:
75
+ for key in creds:
76
+ if key not in credential_data or credential_data[key] is None:
77
+ credential_data[key] = creds[key]
78
+
79
+ return await self._update(
80
+ AuthData.connection_id == connection_id,
81
+ value=encrypt_auth_data(value=credential_data, settings=self._settings),
82
+ )
83
+ except IntegrityError as e:
84
+ self._raise_error(e)
85
+
86
+ def _raise_error(self, err: DBAPIError) -> NoReturn:
87
+ raise SyncmasterError from err
@@ -0,0 +1,264 @@
1
+ # SPDX-FileCopyrightText: 2023-2024 MTS (Mobile Telesystems)
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ import re
4
+ from typing import NoReturn
5
+
6
+ from sqlalchemy import ScalarResult, insert, or_, select, update
7
+ from sqlalchemy.exc import DBAPIError, IntegrityError, NoResultFound
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+
10
+ from syncmaster.db.models import Group, User, UserGroup
11
+ from syncmaster.db.repositories.base import Repository
12
+ from syncmaster.db.utils import Pagination, Permission
13
+ from syncmaster.exceptions import EntityNotFoundError, SyncmasterError
14
+ from syncmaster.exceptions.group import (
15
+ AlreadyIsGroupMemberError,
16
+ AlreadyIsNotGroupMemberError,
17
+ GroupAdminNotFoundError,
18
+ GroupAlreadyExistsError,
19
+ GroupNotFoundError,
20
+ )
21
+ from syncmaster.exceptions.user import UserNotFoundError
22
+
23
+
24
+ class GroupRepository(Repository[Group]):
25
+ def __init__(self, session: AsyncSession):
26
+ super().__init__(model=Group, session=session)
27
+
28
+ async def paginate_all(
29
+ self,
30
+ page: int,
31
+ page_size: int,
32
+ ) -> Pagination:
33
+ stmt = select(Group).where(Group.is_deleted.is_(False))
34
+ return await self._paginate_scalar_result(query=stmt.order_by(Group.name), page=page, page_size=page_size)
35
+
36
+ async def paginate_for_user(
37
+ self,
38
+ page: int,
39
+ page_size: int,
40
+ current_user_id: int,
41
+ ):
42
+ stmt = (
43
+ select(Group)
44
+ .join(
45
+ UserGroup,
46
+ UserGroup.group_id == Group.id,
47
+ )
48
+ .where(
49
+ Group.is_deleted.is_(False),
50
+ or_(
51
+ UserGroup.user_id == current_user_id,
52
+ Group.owner_id == current_user_id,
53
+ ),
54
+ )
55
+ .group_by(Group.id)
56
+ )
57
+
58
+ return await self._paginate_scalar_result(query=stmt.order_by(Group.name), page=page, page_size=page_size)
59
+
60
+ async def read_by_id(
61
+ self,
62
+ group_id: int,
63
+ ) -> Group:
64
+ stmt = select(Group).where(Group.id == group_id, Group.is_deleted.is_(False))
65
+ try:
66
+ result: ScalarResult[Group] = await self._session.scalars(stmt)
67
+ return result.one()
68
+ except NoResultFound as e:
69
+ raise GroupNotFoundError from e
70
+
71
+ async def create(self, name: str, description: str, owner_id: int) -> Group:
72
+ query = (
73
+ insert(Group)
74
+ .values(
75
+ name=name,
76
+ description=description,
77
+ owner_id=owner_id,
78
+ )
79
+ .returning(Group)
80
+ )
81
+ try:
82
+ result: ScalarResult[Group] = await self._session.scalars(query)
83
+ await self._session.flush()
84
+ except IntegrityError as err:
85
+ self._raise_error(err)
86
+ else:
87
+ return result.one()
88
+
89
+ async def update(
90
+ self,
91
+ group_id: int,
92
+ name: str,
93
+ description: str,
94
+ owner_id: int,
95
+ ) -> Group:
96
+ args = [Group.id == group_id, Group.is_deleted.is_(False)]
97
+ try:
98
+ return await self._update(
99
+ *args,
100
+ name=name,
101
+ description=description,
102
+ owner_id=owner_id,
103
+ )
104
+ except EntityNotFoundError as e:
105
+ raise GroupNotFoundError from e
106
+ except IntegrityError as e:
107
+ self._raise_error(e)
108
+
109
+ async def update_member_role(
110
+ self,
111
+ group_id: int,
112
+ user_id: int,
113
+ role: str,
114
+ ):
115
+ try:
116
+ row_res = await self._session.scalars(
117
+ update(UserGroup)
118
+ .where(
119
+ UserGroup.group_id == group_id,
120
+ UserGroup.user_id == user_id,
121
+ )
122
+ .values(
123
+ group_id=group_id,
124
+ user_id=user_id,
125
+ role=role,
126
+ )
127
+ .returning(UserGroup)
128
+ )
129
+ await self._session.flush()
130
+ obj = row_res.one_or_none()
131
+ except IntegrityError as err:
132
+ self._raise_error(err)
133
+
134
+ if not obj:
135
+ raise UserNotFoundError
136
+
137
+ return obj
138
+
139
+ async def get_member_paginate(
140
+ self,
141
+ page: int,
142
+ page_size: int,
143
+ group_id: int,
144
+ ) -> Pagination:
145
+ group = await self.read_by_id(group_id=group_id)
146
+ stmt = (
147
+ select(User, UserGroup.role)
148
+ .join(
149
+ UserGroup,
150
+ UserGroup.user_id == User.id,
151
+ )
152
+ .where(
153
+ User.is_deleted.is_(False),
154
+ User.is_active.is_(True),
155
+ UserGroup.group_id == group.id,
156
+ )
157
+ .order_by(User.username)
158
+ )
159
+ return await self._paginate_raw_result(stmt, page=page, page_size=page_size)
160
+
161
+ async def delete(self, group_id: int) -> None:
162
+ try:
163
+ await self._delete(group_id)
164
+ except EntityNotFoundError as e:
165
+ raise GroupNotFoundError from e
166
+
167
+ async def add_user(
168
+ self,
169
+ group_id: int,
170
+ new_user_id: int,
171
+ role: str,
172
+ ) -> None:
173
+ try:
174
+ await self._session.execute(
175
+ insert(UserGroup).values(
176
+ group_id=group_id,
177
+ user_id=new_user_id,
178
+ role=role,
179
+ )
180
+ )
181
+ except IntegrityError as integrity_error:
182
+ self._raise_error(integrity_error)
183
+ else:
184
+ await self._session.flush()
185
+
186
+ async def get_group_permission(self, user: User, group_id: int) -> Permission:
187
+ """
188
+ Method for determining CRUD rights in a specified group
189
+ 'DEVELOPER' and 'MAINTAINER' does not have WRITE and DELETE permission in the GROUP repository
190
+ """
191
+ # Check: group exists
192
+ if not await self._session.get(Group, group_id):
193
+ raise GroupNotFoundError
194
+
195
+ owner_query = (
196
+ (
197
+ select(Group).where(
198
+ Group.owner_id == user.id,
199
+ Group.id == group_id,
200
+ )
201
+ )
202
+ .exists()
203
+ .select()
204
+ )
205
+
206
+ is_owner = await self._session.scalar(owner_query)
207
+
208
+ if is_owner or user.is_superuser:
209
+ return Permission.DELETE
210
+
211
+ group_role_query = select(UserGroup).where(
212
+ UserGroup.group_id == group_id,
213
+ UserGroup.user_id == user.id,
214
+ )
215
+
216
+ user_group = await self._session.scalar(group_role_query)
217
+
218
+ if not user_group:
219
+ return Permission.NONE
220
+
221
+ return Permission.READ
222
+
223
+ async def delete_user(
224
+ self,
225
+ group_id: int,
226
+ target_user_id: int,
227
+ ) -> None:
228
+ user_group = await self._session.get(
229
+ UserGroup,
230
+ {
231
+ "group_id": group_id,
232
+ "user_id": target_user_id,
233
+ },
234
+ )
235
+ if user_group is None:
236
+ raise AlreadyIsNotGroupMemberError
237
+ await self._session.delete(user_group)
238
+ await self._session.flush()
239
+
240
+ def _raise_error(self, err: DBAPIError) -> NoReturn:
241
+ constraint = err.__cause__.__cause__.constraint_name
242
+
243
+ if constraint == "fk__group__owner_id__user":
244
+ raise GroupAdminNotFoundError from err
245
+
246
+ if constraint == "uq__group__name":
247
+ raise GroupAlreadyExistsError from err
248
+
249
+ if constraint == "pk__user_group":
250
+ raise AlreadyIsGroupMemberError from err
251
+
252
+ if constraint == "fk__user_group__group_id__group":
253
+ detail = err.__cause__.__cause__.detail
254
+ pattern = r'Key \(group_id\)=\(-?\d+\) is not present in table "group".'
255
+ if re.match(pattern, detail):
256
+ raise GroupNotFoundError from err
257
+
258
+ if constraint == "fk__user_group__user_id__user":
259
+ detail = err.__cause__.__cause__.detail
260
+ pattern = r'Key \(user_id\)=\(-?\d+\) is not present in table "user".'
261
+ if re.match(pattern, detail):
262
+ raise UserNotFoundError from err
263
+
264
+ raise SyncmasterError from err