фиксы
This commit is contained in:
@@ -307,6 +307,10 @@ class Postgres(DatabaseBase, Database):
|
||||
project.deleted_at = datetime.datetime.now()
|
||||
await project.save()
|
||||
|
||||
async def check_channel_exists_in_workspace(self, channel_id: uuid.UUID, workspace_id: uuid.UUID) -> bool:
|
||||
project = await domain.Project.get_or_none(channel_id=channel_id, workspace_id=workspace_id)
|
||||
return project is not None
|
||||
|
||||
async def get_active_purchase(self, project_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Purchase | None:
|
||||
return await domain.Purchase.get_or_none(
|
||||
project_id=project_id, creative_id=creative_id, status=domain.PurchaseStatus.ACTIVE
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
import uuid
|
||||
|
||||
import aioboto3
|
||||
from botocore.config import Config
|
||||
from pydantic import BaseModel
|
||||
from types_aiobotocore_s3.client import S3Client
|
||||
|
||||
@@ -23,6 +24,19 @@ class S3(S3Storage):
|
||||
self.config = config
|
||||
self.session = aioboto3.Session()
|
||||
self._client: S3Client | None = None
|
||||
# Конфигурация для S3-совместимых хранилищ (не AWS)
|
||||
# Отключаем строгую проверку контрольных сумм для совместимости с Beget и другими провайдерами
|
||||
# См: https://github.com/open-webui/open-webui/issues/16758
|
||||
self.boto_config = Config(
|
||||
signature_version='s3v4',
|
||||
s3={
|
||||
'payload_signing_enabled': False,
|
||||
'addressing_style': 'auto',
|
||||
},
|
||||
# Отключаем строгую проверку контрольных сумм (boto3 >= 1.40.5)
|
||||
request_checksum_calculation='when_required',
|
||||
response_checksum_validation='when_required',
|
||||
)
|
||||
|
||||
async def connect(self) -> None:
|
||||
# Создаем клиента для проверки/создания bucket
|
||||
@@ -32,6 +46,7 @@ class S3(S3Storage):
|
||||
aws_access_key_id=self.config.ACCESS_KEY_ID,
|
||||
aws_secret_access_key=self.config.SECRET_ACCESS_KEY,
|
||||
region_name=self.config.REGION,
|
||||
config=self.boto_config,
|
||||
) as client:
|
||||
# Проверяем существование bucket
|
||||
try:
|
||||
@@ -54,6 +69,7 @@ class S3(S3Storage):
|
||||
aws_access_key_id=self.config.ACCESS_KEY_ID,
|
||||
aws_secret_access_key=self.config.SECRET_ACCESS_KEY,
|
||||
region_name=self.config.REGION,
|
||||
config=self.boto_config,
|
||||
) as client:
|
||||
await client.put_object(
|
||||
Bucket=self.config.BUCKET_NAME,
|
||||
@@ -70,6 +86,7 @@ class S3(S3Storage):
|
||||
aws_access_key_id=self.config.ACCESS_KEY_ID,
|
||||
aws_secret_access_key=self.config.SECRET_ACCESS_KEY,
|
||||
region_name=self.config.REGION,
|
||||
config=self.boto_config,
|
||||
) as client:
|
||||
response = await client.get_object(Bucket=self.config.BUCKET_NAME, Key=key)
|
||||
data = await response['Body'].read()
|
||||
@@ -83,6 +100,7 @@ class S3(S3Storage):
|
||||
aws_access_key_id=self.config.ACCESS_KEY_ID,
|
||||
aws_secret_access_key=self.config.SECRET_ACCESS_KEY,
|
||||
region_name=self.config.REGION,
|
||||
config=self.boto_config,
|
||||
) as client:
|
||||
await client.delete_object(Bucket=self.config.BUCKET_NAME, Key=key)
|
||||
log.info(f'Deleted file from S3: {key}')
|
||||
|
||||
@@ -67,3 +67,18 @@ async def delete_project(
|
||||
project_id=project_id,
|
||||
user_id=current_user.user_id,
|
||||
)
|
||||
|
||||
|
||||
@projects_router.put('/{project_id}/move')
|
||||
async def move_project(
|
||||
workspace_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
request: dto.MoveProjectRequest,
|
||||
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
||||
) -> dto.ProjectOutput:
|
||||
return await deps.get_usecase().move_project_to_workspace(
|
||||
user_id=current_user.user_id,
|
||||
source_workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
target_workspace_id=request.target_workspace_id,
|
||||
)
|
||||
|
||||
@@ -47,6 +47,7 @@ __all__ = (
|
||||
'LoginTokenExpired',
|
||||
'LoginTokenAlreadyUsed',
|
||||
'ProjectNotFound',
|
||||
'ProjectChannelConflict',
|
||||
'PurchaseNotFound',
|
||||
'PurchaseChannelNotFound',
|
||||
'ChannelNotFound',
|
||||
@@ -78,6 +79,7 @@ from .error import (
|
||||
LoginTokenNotFound,
|
||||
PlacementNotFound,
|
||||
ProjectNotFound,
|
||||
ProjectChannelConflict,
|
||||
PurchaseChannelNotFound,
|
||||
PurchaseNotFound,
|
||||
TelegramChannelNotFound,
|
||||
|
||||
@@ -43,6 +43,10 @@ def ProjectNotFound(project_id: uuid.UUID | None = None) -> HTTPException:
|
||||
return HTTPException(status.HTTP_404_NOT_FOUND, f'Project {project_id} not found')
|
||||
|
||||
|
||||
def ProjectChannelConflict() -> HTTPException:
|
||||
return HTTPException(status.HTTP_409_CONFLICT, 'Project channel already exists in target workspace')
|
||||
|
||||
|
||||
def PurchaseNotFound(purchase_id: uuid.UUID | None = None) -> HTTPException:
|
||||
if purchase_id is None:
|
||||
return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase not found')
|
||||
|
||||
@@ -5,6 +5,7 @@ __all__ = (
|
||||
'GetWorkspaceProjectsOutput',
|
||||
'GetProjectInput',
|
||||
'ArchiveProjectInput',
|
||||
'MoveProjectRequest',
|
||||
'DisconnectProjectByTgIdInput',
|
||||
'ConnectProjectInput',
|
||||
'ProjectOutput',
|
||||
@@ -129,6 +130,7 @@ from .project import (
|
||||
GetProjectInput,
|
||||
GetWorkspaceProjectsInput,
|
||||
GetWorkspaceProjectsOutput,
|
||||
MoveProjectRequest,
|
||||
ProjectOutput,
|
||||
UpdateProjectInviteLinkTypeInput,
|
||||
UpdateProjectPermissionsInput,
|
||||
|
||||
@@ -70,3 +70,7 @@ class ArchiveProjectInput(pydantic.BaseModel):
|
||||
workspace_id: uuid.UUID
|
||||
project_id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
|
||||
|
||||
class MoveProjectRequest(pydantic.BaseModel):
|
||||
target_workspace_id: uuid.UUID
|
||||
|
||||
@@ -33,6 +33,7 @@ from .project.delete_project import delete_project
|
||||
from .project.disconnect_project_by_tg_id import disconnect_project_by_tg_id
|
||||
from .project.get_project import get_project
|
||||
from .project.get_workspace_projects import get_workspace_projects
|
||||
from .project.move_project_to_workspace import move_project_to_workspace
|
||||
from .project.tg_add_project import tg_add_project
|
||||
from .project.update_project_invite_link_type import update_project_invite_link_type
|
||||
from .project.update_project_permissions import update_project_permissions
|
||||
@@ -167,6 +168,8 @@ class Database(typing.Protocol):
|
||||
|
||||
async def delete_project(self, workspace_id: UUID, project_id: UUID) -> None: ...
|
||||
|
||||
async def check_channel_exists_in_workspace(self, channel_id: UUID, workspace_id: UUID) -> bool: ...
|
||||
|
||||
async def get_active_purchase(self, project_id: UUID, creative_id: UUID) -> domain.Purchase | None: ...
|
||||
|
||||
async def get_purchase(self, workspace_id: UUID, purchase_id: UUID) -> domain.Purchase | None: ...
|
||||
@@ -423,6 +426,7 @@ class Usecase:
|
||||
get_project = get_project
|
||||
archive_project = archive_project
|
||||
delete_project = delete_project
|
||||
move_project_to_workspace = move_project_to_workspace
|
||||
disconnect_project_by_tg_id = disconnect_project_by_tg_id
|
||||
update_project_permissions = update_project_permissions
|
||||
update_project_invite_link_type = update_project_invite_link_type
|
||||
|
||||
72
src/usecase/project/move_project_to_workspace.py
Normal file
72
src/usecase/project/move_project_to_workspace.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import logging
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain, dto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def move_project_to_workspace(
|
||||
self: 'Usecase',
|
||||
user_id: uuid.UUID,
|
||||
source_workspace_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
target_workspace_id: uuid.UUID,
|
||||
) -> dto.ProjectOutput:
|
||||
# Получаем project из source workspace
|
||||
project = await self.database.get_project(source_workspace_id, project_id=project_id)
|
||||
if not project:
|
||||
raise domain.ProjectNotFound(project_id)
|
||||
|
||||
await project.fetch_related('channel')
|
||||
|
||||
# Проверяем что target workspace существует
|
||||
target_workspace = await self.database.get_workspace(target_workspace_id)
|
||||
if not target_workspace:
|
||||
raise domain.WorkspaceNotFound(target_workspace_id)
|
||||
|
||||
# Проверяем permissions в source workspace (scope на конкретный project)
|
||||
await self.ensure_workspace_permission(
|
||||
source_workspace_id, user_id, domain.PermissionKey.PROJECTS_WRITE, for_project_id=project_id
|
||||
)
|
||||
|
||||
# Проверяем permissions в target workspace
|
||||
await self.ensure_workspace_permission(target_workspace_id, user_id, domain.PermissionKey.PROJECTS_WRITE)
|
||||
|
||||
# Проверяем что канал не существует в target workspace (UNIQUE constraint)
|
||||
if await self.database.check_channel_exists_in_workspace(project.channel.id, target_workspace_id):
|
||||
raise domain.ProjectChannelConflict()
|
||||
|
||||
# Выполняем перенос в транзакции
|
||||
async with self.database.transaction():
|
||||
# Удаляем project-scoped permissions (они теряют смысл в новом workspace)
|
||||
await domain.WorkspaceUserPermissionScope.filter(project_id=project_id).delete()
|
||||
|
||||
# Обновляем workspace_id
|
||||
project.workspace_id = target_workspace_id
|
||||
await self.database.update_project(project)
|
||||
|
||||
# Логирование операции
|
||||
log.info(
|
||||
'Project moved to another workspace',
|
||||
extra={
|
||||
'project_id': str(project_id),
|
||||
'channel_id': str(project.channel.id),
|
||||
'source_workspace_id': str(source_workspace_id),
|
||||
'target_workspace_id': str(target_workspace_id),
|
||||
'user_id': str(user_id),
|
||||
},
|
||||
)
|
||||
|
||||
return dto.ProjectOutput(
|
||||
id=project.id,
|
||||
telegram_id=project.channel.telegram_id,
|
||||
title=project.channel.title,
|
||||
username=project.channel.username,
|
||||
status=project.status,
|
||||
purchase_invite_type_default=project.purchase_invite_type_default,
|
||||
)
|
||||
Reference in New Issue
Block a user