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.
- data_syncmaster-0.1.1.dist-info/LICENSE.txt +203 -0
- data_syncmaster-0.1.1.dist-info/METADATA +115 -0
- data_syncmaster-0.1.1.dist-info/RECORD +110 -0
- data_syncmaster-0.1.1.dist-info/WHEEL +4 -0
- syncmaster/__init__.py +6 -0
- syncmaster/backend/__init__.py +2 -0
- syncmaster/backend/api/__init__.py +2 -0
- syncmaster/backend/api/deps.py +20 -0
- syncmaster/backend/api/monitoring.py +10 -0
- syncmaster/backend/api/router.py +10 -0
- syncmaster/backend/api/v1/__init__.py +2 -0
- syncmaster/backend/api/v1/auth/__init__.py +2 -0
- syncmaster/backend/api/v1/auth/router.py +32 -0
- syncmaster/backend/api/v1/auth/utils.py +26 -0
- syncmaster/backend/api/v1/connections.py +300 -0
- syncmaster/backend/api/v1/groups.py +225 -0
- syncmaster/backend/api/v1/queue.py +148 -0
- syncmaster/backend/api/v1/router.py +18 -0
- syncmaster/backend/api/v1/transfers/__init__.py +2 -0
- syncmaster/backend/api/v1/transfers/router.py +469 -0
- syncmaster/backend/api/v1/transfers/utils.py +17 -0
- syncmaster/backend/api/v1/users.py +75 -0
- syncmaster/backend/export_openapi_schema.py +26 -0
- syncmaster/backend/handler.py +203 -0
- syncmaster/backend/logger.py +2 -0
- syncmaster/backend/main.py +63 -0
- syncmaster/backend/pre_start.py +94 -0
- syncmaster/backend/services/__init__.py +4 -0
- syncmaster/backend/services/auth.py +58 -0
- syncmaster/backend/services/unit_of_work.py +44 -0
- syncmaster/config.py +110 -0
- syncmaster/db/__init__.py +2 -0
- syncmaster/db/alembic.ini +41 -0
- syncmaster/db/base.py +28 -0
- syncmaster/db/factory.py +37 -0
- syncmaster/db/migrations/README +1 -0
- syncmaster/db/migrations/__init__.py +2 -0
- syncmaster/db/migrations/env.py +87 -0
- syncmaster/db/migrations/script.py.mako +24 -0
- syncmaster/db/migrations/versions/2023-11-23_478240cdad4b_init.py +242 -0
- syncmaster/db/migrations/versions/__init__.py +2 -0
- syncmaster/db/mixins.py +33 -0
- syncmaster/db/models.py +194 -0
- syncmaster/db/repositories/__init__.py +22 -0
- syncmaster/db/repositories/base.py +109 -0
- syncmaster/db/repositories/connection.py +138 -0
- syncmaster/db/repositories/credentials_repository.py +87 -0
- syncmaster/db/repositories/group.py +264 -0
- syncmaster/db/repositories/queue.py +195 -0
- syncmaster/db/repositories/repository_with_owner.py +115 -0
- syncmaster/db/repositories/run.py +78 -0
- syncmaster/db/repositories/transfer.py +202 -0
- syncmaster/db/repositories/user.py +72 -0
- syncmaster/db/repositories/utils.py +25 -0
- syncmaster/db/utils.py +31 -0
- syncmaster/dto/__init__.py +2 -0
- syncmaster/dto/connections.py +60 -0
- syncmaster/dto/transfers.py +46 -0
- syncmaster/exceptions/__init__.py +13 -0
- syncmaster/exceptions/base.py +12 -0
- syncmaster/exceptions/connection.py +28 -0
- syncmaster/exceptions/credentials.py +8 -0
- syncmaster/exceptions/group.py +27 -0
- syncmaster/exceptions/queue.py +16 -0
- syncmaster/exceptions/run.py +19 -0
- syncmaster/exceptions/transfer.py +39 -0
- syncmaster/exceptions/user.py +11 -0
- syncmaster/schemas/__init__.py +2 -0
- syncmaster/schemas/v1/__init__.py +54 -0
- syncmaster/schemas/v1/auth.py +12 -0
- syncmaster/schemas/v1/connection_types.py +9 -0
- syncmaster/schemas/v1/connections/__init__.py +2 -0
- syncmaster/schemas/v1/connections/connection.py +146 -0
- syncmaster/schemas/v1/connections/hdfs.py +40 -0
- syncmaster/schemas/v1/connections/hive.py +40 -0
- syncmaster/schemas/v1/connections/oracle.py +58 -0
- syncmaster/schemas/v1/connections/postgres.py +48 -0
- syncmaster/schemas/v1/connections/s3.py +66 -0
- syncmaster/schemas/v1/file_formats.py +7 -0
- syncmaster/schemas/v1/groups.py +39 -0
- syncmaster/schemas/v1/page.py +40 -0
- syncmaster/schemas/v1/queue.py +32 -0
- syncmaster/schemas/v1/status.py +16 -0
- syncmaster/schemas/v1/transfer_types.py +6 -0
- syncmaster/schemas/v1/transfers/__init__.py +172 -0
- syncmaster/schemas/v1/transfers/db.py +23 -0
- syncmaster/schemas/v1/transfers/file/__init__.py +2 -0
- syncmaster/schemas/v1/transfers/file/base.py +47 -0
- syncmaster/schemas/v1/transfers/file/hdfs.py +27 -0
- syncmaster/schemas/v1/transfers/file/s3.py +27 -0
- syncmaster/schemas/v1/transfers/file_format.py +29 -0
- syncmaster/schemas/v1/transfers/run.py +37 -0
- syncmaster/schemas/v1/transfers/strategy.py +15 -0
- syncmaster/schemas/v1/types.py +5 -0
- syncmaster/schemas/v1/users.py +83 -0
- syncmaster/worker/__init__.py +2 -0
- syncmaster/worker/base.py +14 -0
- syncmaster/worker/config.py +18 -0
- syncmaster/worker/controller.py +127 -0
- syncmaster/worker/handlers/__init__.py +2 -0
- syncmaster/worker/handlers/base.py +49 -0
- syncmaster/worker/handlers/file/__init__.py +2 -0
- syncmaster/worker/handlers/file/base.py +56 -0
- syncmaster/worker/handlers/file/hdfs.py +14 -0
- syncmaster/worker/handlers/file/s3.py +20 -0
- syncmaster/worker/handlers/hive.py +41 -0
- syncmaster/worker/handlers/oracle.py +48 -0
- syncmaster/worker/handlers/postgres.py +47 -0
- syncmaster/worker/spark.py +93 -0
- 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
|