This commit is contained in:
Daniil
2026-04-07 13:42:45 +03:00
parent 7d2f444e1c
commit 259d3da89f
34 changed files with 2130 additions and 788 deletions
+70 -10
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import uuid
from pathlib import Path
from fastapi import (
APIRouter,
@@ -42,8 +43,10 @@ MAX_MB_SIZE = 1024
async def upload_file(
file: UploadFile = FastAPIFile(...),
folder: str = Form(default=""),
project_id: uuid.UUID | None = Form(default=None),
current_user: User = Depends(get_current_user),
storage: StorageService = Depends(get_storage),
db: AsyncSession = Depends(get_db),
) -> FileInfoResponse:
# Validate max file size (matches old behavior).
file.file.seek(0, 2)
@@ -54,11 +57,18 @@ async def upload_file(
if size_bytes > max_size:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File size exceeds the maximum limit of {MAX_MB_SIZE} MB.",
detail=f"Размер файла превышает допустимый лимит в {MAX_MB_SIZE} МБ.",
)
user_folder = get_user_folder(current_user)
resolved_folder = f"{user_folder}/{folder}" if folder else f"{user_folder}/user_upload"
inferred_project_id = project_id
if inferred_project_id is None and folder.startswith("projects/"):
project_token = folder.removeprefix("projects/").split("/", 1)[0]
try:
inferred_project_id = uuid.UUID(project_token)
except ValueError:
inferred_project_id = None
key = await storage.upload_fileobj(
fileobj=file.file,
@@ -68,8 +78,23 @@ async def upload_file(
content_type=file.content_type,
)
service = FileService(db)
file_entry = await service.create_file(
requester=current_user,
data=FileCreate(
project_id=inferred_project_id,
original_filename=file.filename or "upload.bin",
path=key,
storage_backend=get_settings().storage_backend.upper(),
mime_type=file.content_type or "application/octet-stream",
size_bytes=size_bytes,
file_format=Path(file.filename or "upload.bin").suffix.lstrip(".") or None,
is_uploaded=True,
),
)
info = await storage.get_file_info(key)
return FileInfoResponse(
file_id=file_entry.id,
file_path=info.file_path,
file_url=info.file_url,
file_size=info.file_size,
@@ -82,17 +107,24 @@ async def get_file_info(
file_path: str = Query(...),
current_user: User = Depends(get_current_user),
storage: StorageService = Depends(get_storage),
db: AsyncSession = Depends(get_db),
) -> FileInfoResponse:
if not current_user.is_staff:
user_prefix = f"{get_user_folder(current_user)}/"
if not file_path.startswith(user_prefix):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
if not await storage.exists(file_path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
service = FileService(db)
file = await service.get_file_by_path(file_path)
if file is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
info = await storage.get_file_info(file_path)
return FileInfoResponse(
file_id=file.id,
file_path=info.file_path,
file_url=info.file_url,
file_size=info.file_size,
@@ -110,7 +142,7 @@ async def get_local_file(
settings = get_settings()
full_path = (settings.local_storage_dir / file_path).resolve()
if not full_path.exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
return FileResponse(full_path)
@@ -145,14 +177,42 @@ async def retrieve_file_entry(
service = FileService(db)
file = await service.get_file(file_id)
if file is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
if not current_user.is_staff and file.owner_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
return FileRead.model_validate(file)
@router.get("/files/{file_id}/resolve/", response_model=FileInfoResponse)
async def resolve_file_entry(
file_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
storage: StorageService = Depends(get_storage),
) -> FileInfoResponse:
service = FileService(db)
file = await service.get_file(file_id)
if file is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
if not current_user.is_staff and file.owner_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
if not await storage.exists(file.path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
info = await storage.get_file_info(file.path)
return FileInfoResponse(
file_id=file.id,
file_path=file.path,
file_url=info.file_url,
file_size=info.file_size,
filename=file.original_filename or info.filename,
)
@router.patch("/files/{file_id}/", response_model=FileRead)
async def patch_file_entry(
file_id: uuid.UUID,
@@ -163,10 +223,10 @@ async def patch_file_entry(
service = FileService(db)
file = await service.get_file(file_id)
if file is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
if not current_user.is_staff and file.owner_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
file = await service.update_file(file, body)
return FileRead.model_validate(file)
@@ -181,10 +241,10 @@ async def delete_file_entry(
service = FileService(db)
file = await service.get_file(file_id)
if file is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
if not current_user.is_staff and file.owner_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
await service.delete_file(file)
return Response(status_code=status.HTTP_204_NO_CONTENT)
+1
View File
@@ -51,6 +51,7 @@ class FileUpdate(Schema):
class FileInfoResponse(Schema):
file_id: UUID
file_path: str
file_url: str
file_size: int | None = None
+3
View File
@@ -22,6 +22,9 @@ class FileService:
async def get_file(self, file_id: uuid.UUID) -> File | None:
return await self._repo.get_by_id(file_id)
async def get_file_by_path(self, path: str) -> File | None:
return await self._repo.get_by_path(path)
async def create_file(self, *, requester: User, data: FileCreate) -> File:
return await self._repo.create(requester=requester, data=data)
+9 -9
View File
@@ -53,10 +53,10 @@ async def retrieve_job_endpoint(
service = JobService(db)
job = await service.get_job(job_id)
if job is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
if not current_user.is_staff and job.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
return JobRead.model_validate(job)
@@ -71,10 +71,10 @@ async def patch_job_endpoint(
service = JobService(db)
job = await service.get_job(job_id)
if job is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
if not current_user.is_staff and job.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
if body.status == "CANCELLED":
task_service = TaskService(db)
@@ -94,10 +94,10 @@ async def delete_job_endpoint(
service = JobService(db)
job = await service.get_job(job_id)
if job is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
if not current_user.is_staff and job.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
await service.deactivate_job(job)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -136,7 +136,7 @@ async def retrieve_event_endpoint(
service = JobService(db)
event = await service.get_job_event(event_id)
if event is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
return JobEventRead.model_validate(event)
@@ -152,7 +152,7 @@ async def patch_event_endpoint(
service = JobService(db)
event = await service.get_job_event(event_id)
if event is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
event = await service.update_job_event(event, body)
return JobEventRead.model_validate(event)
@@ -168,7 +168,7 @@ async def delete_event_endpoint(
service = JobService(db)
event = await service.get_job_event(event_id)
if event is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
await service.deactivate_job_event(event)
return Response(status_code=status.HTTP_204_NO_CONTENT)
+9 -9
View File
@@ -157,10 +157,10 @@ async def retrieve_mediafile(
repo = MediaFileRepository(db)
media_file = await repo.get_by_id(media_file_id)
if media_file is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
if not current_user.is_staff and media_file.owner_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
return MediaFileRead.model_validate(media_file)
@@ -175,10 +175,10 @@ async def patch_mediafile(
repo = MediaFileRepository(db)
media_file = await repo.get_by_id(media_file_id)
if media_file is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
if not current_user.is_staff and media_file.owner_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
media_file = await repo.update(media_file, body)
return MediaFileRead.model_validate(media_file)
@@ -193,10 +193,10 @@ async def delete_mediafile(
repo = MediaFileRepository(db)
media_file = await repo.get_by_id(media_file_id)
if media_file is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
if not current_user.is_staff and media_file.owner_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
await repo.mark_deleted(media_file)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -237,7 +237,7 @@ async def retrieve_artifact_mediafile(
repo = ArtifactRepository(db)
artifact = await repo.get_by_id(artifact_id)
if artifact is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
return ArtifactMediaFileRead.model_validate(artifact)
@@ -253,7 +253,7 @@ async def patch_artifact_mediafile(
repo = ArtifactRepository(db)
artifact = await repo.get_by_id(artifact_id)
if artifact is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
artifact = await repo.update(artifact, body)
return ArtifactMediaFileRead.model_validate(artifact)
@@ -269,7 +269,7 @@ async def delete_artifact_mediafile(
repo = ArtifactRepository(db)
artifact = await repo.get_by_id(artifact_id)
if artifact is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
await repo.mark_deleted(artifact)
return Response(status_code=status.HTTP_204_NO_CONTENT)
+227 -74
View File
@@ -19,6 +19,9 @@ FRAME_WIDTH_PX = 128
FRAME_FPS = 1
FRAME_JPEG_QUALITY = 5
FRAMES_META_FILENAME = "meta.json"
FFMPEG_PROGRESS_DIVISOR = 1_000_000.0
MediaProgressCallback = Callable[[str, float | None], None]
def get_frames_folder(user_folder: str, file_key: str) -> str:
@@ -55,6 +58,123 @@ async def probe_media(storage: StorageService, *, file_key: str) -> MediaProbeSc
tmp.cleanup()
def _parse_ffmpeg_timecode_seconds(value: str) -> float | None:
parts = value.strip().split(":")
if len(parts) != 3:
return None
try:
hours = int(parts[0])
minutes = int(parts[1])
seconds = float(parts[2])
except ValueError:
return None
return hours * 3600 + minutes * 60 + seconds
def _get_ffmpeg_output_time_seconds(progress_snapshot: dict[str, str]) -> float | None:
timecode = progress_snapshot.get("out_time")
if timecode:
parsed = _parse_ffmpeg_timecode_seconds(timecode)
if parsed is not None:
return parsed
for key in ("out_time_us", "out_time_ms"):
raw_value = progress_snapshot.get(key)
if raw_value is None:
continue
try:
return max(float(raw_value), 0.0) / FFMPEG_PROGRESS_DIVISOR
except ValueError:
continue
return None
def _extract_ffmpeg_out_time_ms(progress_snapshot: dict[str, str]) -> float | None:
seconds = _get_ffmpeg_output_time_seconds(progress_snapshot)
if seconds is None:
return None
return seconds * 1000.0
async def _probe_local_duration_seconds(input_path: str) -> float | None:
proc = await asyncio.create_subprocess_exec(
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"json",
input_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
if proc.returncode != 0:
return None
try:
raw = json.loads(stdout.decode())
except json.JSONDecodeError:
return None
duration = raw.get("format", {}).get("duration")
if duration is None:
return None
try:
value = float(duration)
except (TypeError, ValueError):
return None
return value if value > 0 else None
async def _forward_ffmpeg_progress(
stdout: asyncio.StreamReader | None,
*,
duration_seconds: float | None,
on_progress: MediaProgressCallback | None,
progress_stage: str,
) -> None:
if stdout is None:
return
snapshot: dict[str, str] = {}
last_pct = -1.0
while True:
line = await stdout.readline()
if not line:
break
decoded = line.decode(errors="ignore").strip()
if "=" not in decoded:
continue
key, value = decoded.split("=", 1)
snapshot[key] = value
if key != "progress":
continue
if (
on_progress is not None
and duration_seconds is not None
and duration_seconds > 0
):
output_time_seconds = _get_ffmpeg_output_time_seconds(snapshot)
if output_time_seconds is not None:
pct = min(max((output_time_seconds / duration_seconds) * 100.0, 0.0), 100.0)
if pct > last_pct:
last_pct = pct
on_progress(progress_stage, pct)
snapshot = {}
def _compute_non_silent_segments(
*,
local_audio_path: str,
@@ -83,6 +203,54 @@ def _compute_non_silent_segments(
return segments
def _build_trim_concat_filter(segments: list[tuple[int, int]]) -> str:
parts: list[str] = []
concat_inputs: list[str] = []
for index, (start_ms, end_ms) in enumerate(segments):
start_s = start_ms / 1000.0
end_s = end_ms / 1000.0
video_label = f"v{index}"
audio_label = f"a{index}"
parts.append(
f"[0:v:0]trim=start={start_s:.3f}:end={end_s:.3f},setpts=PTS-STARTPTS[{video_label}]"
)
parts.append(
f"[0:a:0]atrim=start={start_s:.3f}:end={end_s:.3f},asetpts=PTS-STARTPTS[{audio_label}]"
)
concat_inputs.append(f"[{video_label}][{audio_label}]")
return ";".join(parts + ["".join(concat_inputs) + f"concat=n={len(segments)}:v=1:a=1[v][a]"])
def _build_trim_concat_command(
*,
input_path: str,
out_path: str,
segments: list[tuple[int, int]],
) -> list[str]:
return [
"ffmpeg",
"-y",
"-i",
input_path,
"-filter_complex",
_build_trim_concat_filter(segments),
"-map",
"[v]",
"-map",
"[a]",
"-c:v",
"libx264",
"-c:a",
"aac",
"-preset",
"medium",
out_path,
]
async def detect_silence(
storage: StorageService,
*,
@@ -137,6 +305,7 @@ async def apply_silence_cuts(
out_folder: str,
cuts: list[dict],
output_name: str | None = None,
on_progress: MediaProgressCallback | None = None,
) -> FileInfo:
"""Apply explicit cut regions to a media file, concatenating the non-cut parts."""
input_tmp = await storage.download_to_temp(file_key)
@@ -165,59 +334,47 @@ async def apply_silence_cuts(
if not segments:
return await storage.get_file_info(file_key)
output_duration_seconds = sum(end - start for start, end in segments) / 1000.0
with NamedTemporaryFile(
suffix=path.splitext(file_key)[1] or ".mp4", delete=False
) as out:
out_path = out.name
try:
cmd: list[str] = ["ffmpeg"]
for start_ms, end_ms in segments:
start_s = start_ms / 1000.0
duration_s = (end_ms - start_ms) / 1000.0
cmd.extend(
[
"-ss",
f"{start_s:.3f}",
"-t",
f"{duration_s:.3f}",
"-y",
"-i",
input_tmp.path,
]
)
if on_progress is not None:
on_progress("applying_cuts", 0.0)
seg_count = len(segments)
parts = [f"[{i}:v:0][{i}:a:0]" for i in range(seg_count)]
filter_complex = "".join(parts) + f"concat=n={seg_count}:v=1:a=1[v][a]"
cmd.extend(
[
"-filter_complex",
filter_complex,
"-map",
"[v]",
"-map",
"[a]",
"-c:v",
"libx264",
"-c:a",
"aac",
"-preset",
"medium",
out_path,
]
cmd = _build_trim_concat_command(
input_path=input_tmp.path,
out_path=out_path,
segments=segments,
)
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
_, stderr = await proc.communicate()
progress_task = asyncio.create_task(
_forward_ffmpeg_progress(
proc.stdout,
duration_seconds=output_duration_seconds,
on_progress=on_progress,
progress_stage="applying_cuts",
)
)
stderr_task = asyncio.create_task(
proc.stderr.read() if proc.stderr is not None else asyncio.sleep(0, result=b"")
)
await asyncio.gather(progress_task, stderr_task)
await proc.wait()
stderr = stderr_task.result()
if proc.returncode != 0:
raise RuntimeError(f"ffmpeg failed: {stderr.decode(errors='ignore')}")
base_name = output_name or path.basename(file_key)
output_key = path.join(out_folder or "", "silent", base_name)
if on_progress is not None:
on_progress("uploading", None)
with open(out_path, "rb") as out_file:
_ = await storage.upload_fileobj(
fileobj=out_file,
@@ -267,42 +424,10 @@ async def remove_silence(
out_path = out.name
try:
cmd: list[str] = ["ffmpeg"]
for start_ms, end_ms in segments:
start_s = start_ms / 1000.0
duration_s = (end_ms - start_ms) / 1000.0
cmd.extend(
[
"-ss",
f"{start_s:.3f}",
"-t",
f"{duration_s:.3f}",
"-y",
"-i",
input_tmp.path,
]
)
seg_count = len(segments)
parts = [f"[{i}:v:0][{i}:a:0]" for i in range(seg_count)]
filter_complex = "".join(parts) + f"concat=n={seg_count}:v=1:a=1[v][a]"
cmd.extend(
[
"-filter_complex",
filter_complex,
"-map",
"[v]",
"-map",
"[a]",
"-c:v",
"libx264",
"-c:a",
"aac",
"-preset",
"medium",
out_path,
]
cmd = _build_trim_concat_command(
input_path=input_tmp.path,
out_path=out_path,
segments=segments,
)
proc = await asyncio.create_subprocess_exec(
@@ -333,21 +458,34 @@ async def remove_silence(
async def convert_to_mp4(
storage: StorageService, *, file_key: str, out_folder: str
storage: StorageService,
*,
file_key: str,
out_folder: str,
on_progress: MediaProgressCallback | None = None,
) -> FileInfo:
input_tmp = await storage.download_to_temp(file_key)
try:
filename_without_ext = path.splitext(path.basename(file_key))[0]
mp4_filename = f"Конвертированое видео {filename_without_ext}.mp4"
duration_seconds = await _probe_local_duration_seconds(input_tmp.path)
with NamedTemporaryFile(suffix=".mp4", delete=False) as out:
out_path = out.name
try:
if on_progress is not None:
on_progress("converting", 0.0)
cmd = [
"ffmpeg",
"-y",
"-nostdin",
"-v",
"error",
"-progress",
"pipe:1",
"-i",
input_tmp.path,
"-c:v",
@@ -364,11 +502,26 @@ async def convert_to_mp4(
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
_, stderr = await proc.communicate()
progress_task = asyncio.create_task(
_forward_ffmpeg_progress(
proc.stdout,
duration_seconds=duration_seconds,
on_progress=on_progress,
progress_stage="converting",
)
)
stderr_task = asyncio.create_task(
proc.stderr.read() if proc.stderr is not None else asyncio.sleep(0, result=b"")
)
await asyncio.gather(progress_task, stderr_task)
await proc.wait()
stderr = stderr_task.result()
if proc.returncode != 0:
raise RuntimeError(f"ffmpeg failed: {stderr.decode(errors='ignore')}")
output_key = path.join(out_folder or "", "converted", mp4_filename)
if on_progress is not None:
on_progress("uploading", None)
with open(out_path, "rb") as out_file:
_ = await storage.upload_fileobj(
fileobj=out_file,
+1 -1
View File
@@ -80,7 +80,7 @@ async def mark_notification_read(
repo = NotificationRepository(db)
found = await repo.mark_read(notification_id, current_user.id)
if not found:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
@router.post("/read-all/", status_code=status.HTTP_204_NO_CONTENT)
+3 -12
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import json
import logging
import uuid
@@ -30,13 +29,6 @@ JOB_TYPE_LABELS: dict[str, str] = {
"CAPTIONS_GENERATE": "Генерация субтитров",
}
STATUS_TITLES: dict[str, str] = {
"RUNNING": "Задача запущена",
"DONE": "Задача завершена",
"FAILED": "Ошибка выполнения",
}
# ---------------------------------------------------------------------------
# ConnectionManager — singleton for WebSocket pub/sub via Redis
# ---------------------------------------------------------------------------
@@ -113,14 +105,13 @@ class NotificationService:
# Only persist notifications on status changes (not progress-only updates)
notification_id: uuid.UUID | None = None
if notification_type is not None:
title = STATUS_TITLES.get(event.status or "", job_type_label)
notification = await self._repo.create(
NotificationCreate(
user_id=user_id,
job_id=job.id,
project_id=job.project_id,
notification_type=notification_type,
title=title,
title=job_type_label,
message=event.error_message or event.current_message,
payload={
"job_type": job.job_type,
@@ -139,8 +130,8 @@ class NotificationService:
job_id=job.id,
project_id=job.project_id,
job_type=job.job_type,
status=event.status or job.status,
progress_pct=event.progress_pct or job.project_pct,
status=event.status if event.status is not None else job.status,
progress_pct=job.project_pct if event.progress_pct is None else event.progress_pct,
message=event.error_message or event.current_message or job.current_message,
title=job_type_label,
created_at=now,
+6 -6
View File
@@ -50,10 +50,10 @@ async def retrieve_project(
service = ProjectService(db)
project = await service.get_project(project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
if not current_user.is_staff and project.owner_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
return ProjectRead.model_validate(project)
@@ -68,10 +68,10 @@ async def patch_project(
service = ProjectService(db)
project = await service.get_project(project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
if not current_user.is_staff and project.owner_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
project = await service.update_project(project, body)
return ProjectRead.model_validate(project)
@@ -86,10 +86,10 @@ async def delete_project(
service = ProjectService(db)
project = await service.get_project(project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
if not current_user.is_staff and project.owner_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
await service.deactivate_project(project)
return Response(status_code=status.HTTP_204_NO_CONTENT)
+18 -1
View File
@@ -1,6 +1,10 @@
from __future__ import annotations
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from cpv3.db.session import get_db
router = APIRouter(prefix="/api", tags=["System"])
@@ -8,3 +12,16 @@ router = APIRouter(prefix="/api", tags=["System"])
@router.get("/ping/")
async def ping() -> dict[str, str]:
return {"status": "ok"}
@router.get("/health/")
async def health(db: AsyncSession = Depends(get_db)) -> dict[str, str]:
"""Health check for Docker/K8s probes. Verifies DB connectivity."""
try:
await db.execute(text("SELECT 1"))
db_status = "connected"
except Exception:
db_status = "disconnected"
status = "ok" if db_status == "connected" else "degraded"
return {"status": status, "database": db_status}
+2 -2
View File
@@ -173,11 +173,11 @@ async def get_task_status(
if job is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Job not found"
status_code=status.HTTP_404_NOT_FOUND, detail="Задача не найдена"
)
if not current_user.is_staff and job.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
return TaskStatusResponse(
job_id=job.id,
+192 -12
View File
@@ -96,6 +96,10 @@ MESSAGE_COMPLETED = "Завершено"
MESSAGE_PROBING_MEDIA = "Анализ медиафайла"
MESSAGE_PROCESSING = "Обработка"
MESSAGE_CONVERTING = "Конвертация"
MESSAGE_PREPARING_FILE = "Подготовка файла"
MESSAGE_CONVERTING_VIDEO = "Конвертация видео"
MESSAGE_UPLOADING_RESULT = "Загрузка результата"
MESSAGE_SAVING_RESULT = "Сохранение результата"
MESSAGE_RENDERING_CAPTIONS = "Рендеринг субтитров"
MESSAGE_CANCELLED = "Отменено пользователем"
MESSAGE_EXTRACTING_FRAMES = "Извлечение кадров"
@@ -106,6 +110,14 @@ PROGRESS_COMPLETE = 100.0
PROGRESS_MEDIA_PROBE = 50.0
PROGRESS_SILENCE_REMOVE = 30.0
PROGRESS_MEDIA_CONVERT = 30.0
PROGRESS_MEDIA_CONVERT_PREPARING = 5.0
PROGRESS_MEDIA_CONVERT_START = 10.0
PROGRESS_MEDIA_CONVERT_END = 95.0
PROGRESS_MEDIA_CONVERT_SAVING = 99.0
PROGRESS_SILENCE_APPLY_PREPARING = 5.0
PROGRESS_SILENCE_APPLY_START = 10.0
PROGRESS_SILENCE_APPLY_END = 95.0
PROGRESS_SILENCE_APPLY_SAVING = 99.0
PROGRESS_TRANSCRIPTION_START = 20.0
PROGRESS_TRANSCRIPTION_END = 95.0
PROGRESS_CAPTIONS = 30.0
@@ -119,6 +131,7 @@ MESSAGE_DETECTING_SILENCE = "Обнаружение тишины"
MESSAGE_APPLYING_CUTS = "Применение вырезок"
PROGRESS_THROTTLE_SECONDS = 3.0
PROGRESS_CONVERT_THROTTLE_SECONDS = 1.0
ACTIVE_JOB_STATUSES = (JOB_STATUS_PENDING, JOB_STATUS_RUNNING)
DRAMATIQ_BROKER_REF_SEPARATOR = ":"
@@ -481,20 +494,56 @@ def silence_apply_actor(
webhook_url,
TaskWebhookEvent(
status=JOB_STATUS_RUNNING,
current_message=MESSAGE_STARTING,
current_message=MESSAGE_PREPARING_FILE,
progress_pct=PROGRESS_SILENCE_APPLY_PREPARING,
started_at=_utc_now(),
),
)
try:
storage = _get_storage_service()
_send_webhook_event(
webhook_url,
TaskWebhookEvent(
current_message=MESSAGE_APPLYING_CUTS,
progress_pct=PROGRESS_SILENCE_APPLY,
),
)
last_report_time = 0.0
last_progress = PROGRESS_SILENCE_APPLY_PREPARING
def _emit_silence_apply_progress(stage: str, pct: float | None) -> None:
nonlocal last_report_time, last_progress
if stage == "applying_cuts":
raw_pct = min(max(pct or 0.0, 0.0), 100.0)
if raw_pct >= 100.0:
return
mapped = PROGRESS_SILENCE_APPLY_START + (raw_pct / 100.0) * (
PROGRESS_SILENCE_APPLY_END - PROGRESS_SILENCE_APPLY_START
)
message = MESSAGE_APPLYING_CUTS
force = raw_pct == 0.0
elif stage == "uploading":
mapped = PROGRESS_SILENCE_APPLY_END
message = MESSAGE_UPLOADING_RESULT
force = True
else:
return
mapped = round(mapped, 1)
now = time.monotonic()
if not force:
if mapped <= last_progress:
return
if mapped - last_progress < 1.0:
return
if now - last_report_time < PROGRESS_CONVERT_THROTTLE_SECONDS:
return
last_report_time = now
last_progress = max(last_progress, mapped)
_send_webhook_event(
webhook_url,
TaskWebhookEvent(
current_message=message,
progress_pct=last_progress,
),
)
result = _run_async(
apply_silence_cuts(
storage,
@@ -502,8 +551,16 @@ def silence_apply_actor(
out_folder=out_folder,
cuts=cuts,
output_name=output_name,
on_progress=_emit_silence_apply_progress,
)
)
_send_webhook_event(
webhook_url,
TaskWebhookEvent(
current_message=MESSAGE_SAVING_RESULT,
progress_pct=PROGRESS_SILENCE_APPLY_SAVING,
),
)
_send_webhook_event(
webhook_url,
TaskWebhookEvent(
@@ -554,7 +611,8 @@ def media_convert_actor(
webhook_url,
TaskWebhookEvent(
status=JOB_STATUS_RUNNING,
current_message=MESSAGE_STARTING,
current_message=MESSAGE_PREPARING_FILE,
progress_pct=PROGRESS_MEDIA_CONVERT_PREPARING,
started_at=_utc_now(),
),
)
@@ -564,14 +622,63 @@ def media_convert_actor(
raise ValueError(f"Неподдерживаемый формат: {output_format}")
storage = _get_storage_service()
last_report_time = 0.0
last_progress = PROGRESS_MEDIA_CONVERT_PREPARING
def _emit_convert_progress(stage: str, pct: float | None) -> None:
nonlocal last_report_time, last_progress
if stage == "converting":
raw_pct = min(max(pct or 0.0, 0.0), 100.0)
if raw_pct >= 100.0:
return
mapped = PROGRESS_MEDIA_CONVERT_START + (raw_pct / 100.0) * (
PROGRESS_MEDIA_CONVERT_END - PROGRESS_MEDIA_CONVERT_START
)
message = MESSAGE_CONVERTING_VIDEO
force = raw_pct == 0.0
elif stage == "uploading":
mapped = PROGRESS_MEDIA_CONVERT_END
message = MESSAGE_UPLOADING_RESULT
force = True
else:
return
mapped = round(mapped, 1)
now = time.monotonic()
if not force:
if mapped <= last_progress:
return
if mapped - last_progress < 1.0:
return
if now - last_report_time < PROGRESS_CONVERT_THROTTLE_SECONDS:
return
last_report_time = now
last_progress = max(last_progress, mapped)
_send_webhook_event(
webhook_url,
TaskWebhookEvent(
current_message=message,
progress_pct=last_progress,
),
)
result = _run_async(
convert_to_mp4(
storage,
file_key=file_key,
out_folder=out_folder,
on_progress=_emit_convert_progress,
)
)
_send_webhook_event(
webhook_url,
TaskWebhookEvent(
current_message=MESSAGE_CONVERTING,
progress_pct=PROGRESS_MEDIA_CONVERT,
current_message=MESSAGE_SAVING_RESULT,
progress_pct=PROGRESS_MEDIA_CONVERT_SAVING,
),
)
result = _run_async(convert_to_mp4(storage, file_key=file_key, out_folder=out_folder))
_send_webhook_event(
webhook_url,
TaskWebhookEvent(
@@ -1213,6 +1320,12 @@ class TaskService:
except Exception:
logger.exception("Failed to save convert artifacts for job %s", job_id)
if job.job_type == JOB_TYPE_SILENCE_APPLY and event.status == JOB_STATUS_DONE:
try:
await self._save_silence_apply_artifacts(job)
except Exception:
logger.exception("Failed to save silence apply artifacts for job %s", job_id)
if job.job_type == JOB_TYPE_CAPTIONS_GENERATE and event.status == JOB_STATUS_DONE:
try:
await self._save_captions_artifacts(job)
@@ -1427,8 +1540,75 @@ class TaskService:
),
)
updated_output = dict(output_data)
updated_output["file_id"] = str(converted_file.id)
await self._job_repo.update(job, JobUpdate(output_data=updated_output))
logger.info("Saved convert artifacts for job %s", job.id)
async def _save_silence_apply_artifacts(self, job: Job) -> None:
"""Create File and ArtifactMediaFile records for silence-applied video."""
input_data = job.input_data or {}
output_data = job.output_data or {}
file_key: str = input_data["file_key"]
project_id: uuid.UUID | None = (
uuid.UUID(input_data["project_id"]) if input_data.get("project_id") else None
)
file_path: str = output_data["file_path"]
file_size: int = output_data.get("file_size", 0)
user_repo = UserRepository(self._session)
user = await user_repo.get_by_id(job.user_id) # type: ignore[arg-type]
if user is None:
logger.warning(
"User %s not found, skipping silence apply artifact save", job.user_id
)
return
file_repo = FileRepository(self._session)
source_file = await file_repo.get_by_path(file_key)
if source_file is not None:
stem = Path(source_file.original_filename).stem
else:
stem = Path(file_key).stem
processed_filename = f"Видео без тишины {stem}.mp4"
processed_file = await file_repo.create(
requester=user,
data=FileCreate(
project_id=project_id,
original_filename=processed_filename,
path=file_path,
storage_backend="S3",
mime_type="video/mp4",
size_bytes=file_size,
file_format="mp4",
is_uploaded=True,
),
)
artifact_repo = ArtifactRepository(self._session)
await artifact_repo.create(
data=ArtifactMediaFileCreate(
project_id=project_id,
file_id=processed_file.id,
media_file_id=None,
artifact_type="SILENCE_REMOVED_VIDEO",
),
)
updated_output = dict(output_data)
updated_output["file_id"] = str(processed_file.id)
await self._job_repo.update(job, JobUpdate(output_data=updated_output))
logger.info(
"Saved silence apply artifacts for job %s (file_id=%s)",
job.id,
processed_file.id,
)
async def _save_captions_artifacts(self, job: Job) -> None:
"""Create File and ArtifactMediaFile records for captioned video."""
input_data = job.input_data or {}
+40 -5
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
import logging
import ssl
import threading
import time
import uuid
@@ -79,6 +80,13 @@ ERROR_SALUTE_UPLOAD_FAILED = "Ошибка загрузки файла в Salute
ERROR_SALUTE_TASK_FAILED = "Ошибка распознавания SaluteSpeech: {detail}"
ERROR_SALUTE_TIMEOUT = "Превышено время ожидания распознавания SaluteSpeech"
ERROR_SALUTE_UNSUPPORTED_FORMAT = "Неподдерживаемый формат аудио для SaluteSpeech: {ext}"
ERROR_SALUTE_AUTH_KEY_MISSING = "Не задан SALUTE_AUTH_KEY для авторизации SaluteSpeech"
ERROR_SALUTE_SSL_FAILED = (
"SSL ошибка при обращении к SaluteSpeech: {detail}. "
"Если используется корпоративный или локальный сертификат, "
"укажите путь в SALUTE_CA_CERT_PATH. "
"Для локальной отладки можно отключить проверку через SALUTE_SSL_VERIFY=false."
)
_salute_token_lock = threading.Lock()
_salute_token: str | None = None
@@ -487,6 +495,27 @@ def _parse_salute_time(s: str) -> float:
return float(s.rstrip("s"))
def _build_salute_ssl_context() -> ssl.SSLContext:
"""Build SSL context for SaluteSpeech using system trust plus optional custom CA."""
settings = get_settings()
if not settings.salute_ssl_verify:
return ssl._create_unverified_context()
ssl_context = ssl.create_default_context()
if settings.salute_ca_cert_path is not None:
ssl_context.load_verify_locations(cafile=str(settings.salute_ca_cert_path))
return ssl_context
def _get_salute_auth_header_value() -> str:
"""Build Basic auth header for SaluteSpeech from settings."""
settings = get_settings()
auth_key = settings.salute_auth_key.strip()
if not auth_key:
raise RuntimeError(ERROR_SALUTE_AUTH_KEY_MISSING)
return f"Basic {auth_key}"
def _get_salute_access_token(client: httpx.Client) -> str:
"""Get or refresh SaluteSpeech OAuth token. Thread-safe."""
global _salute_token, _salute_token_expires_at
@@ -500,7 +529,7 @@ def _get_salute_access_token(client: httpx.Client) -> str:
response = client.post(
SALUTE_AUTH_URL,
headers={
"Authorization": f"Basic {settings.salute_auth_key}",
"Authorization": _get_salute_auth_header_value(),
"RqUID": str(uuid.uuid4()),
"Content-Type": "application/x-www-form-urlencoded",
},
@@ -708,8 +737,6 @@ def _salute_transcribe_sync(
on_progress: ProgressCallback | None = None,
) -> Document:
"""Synchronous SaluteSpeech transcription (runs in Dramatiq worker thread)."""
settings = get_settings()
ext = Path(local_file_path).suffix.lower()
audio_encoding = SALUTE_ENCODING_MAP.get(ext)
content_type = SALUTE_CONTENT_TYPE_MAP.get(ext)
@@ -725,8 +752,8 @@ def _salute_transcribe_sync(
salute_language = SALUTE_LANGUAGE_MAP.get(language or "", "ru-RU")
try:
verify = str(settings.salute_ca_cert_path) if settings.salute_ca_cert_path else True
with httpx.Client(verify=verify, timeout=30.0) as client:
ssl_context = _build_salute_ssl_context()
with httpx.Client(verify=ssl_context, timeout=30.0) as client:
token = _get_salute_access_token(client)
with open(local_file_path, "rb") as f:
@@ -748,6 +775,14 @@ def _salute_transcribe_sync(
raw_result = _download_salute_result(client, token, response_file_id)
return _build_document_from_salute_result(raw_result, language=salute_language)
except ssl.SSLError as exc:
raise RuntimeError(ERROR_SALUTE_SSL_FAILED.format(detail=str(exc))) from exc
except httpx.ConnectError as exc:
if isinstance(exc.__cause__, ssl.SSLError):
raise RuntimeError(
ERROR_SALUTE_SSL_FAILED.format(detail=str(exc.__cause__))
) from exc
raise
finally:
if cleanup_fn is not None:
cleanup_fn()
+2 -2
View File
@@ -51,7 +51,7 @@ class UserRepository:
await self._session.commit()
except IntegrityError as e:
await self._session.rollback()
raise ValueError("User already exists or violates constraints") from e
raise ValueError("Пользователь уже существует или нарушены ограничения") from e
await self._session.refresh(user)
return user
@@ -66,7 +66,7 @@ class UserRepository:
await self._session.commit()
except IntegrityError as e:
await self._session.rollback()
raise ValueError("Update violates constraints") from e
raise ValueError("Обновление нарушает ограничения") from e
await self._session.refresh(user)
return user
+8 -8
View File
@@ -123,10 +123,10 @@ async def retrieve_user(
service = UserService(db)
user = await service.get_user_by_id(user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
if not current_user.is_staff and user.id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
return await _resolve_avatar(user, storage)
@@ -142,10 +142,10 @@ async def patch_user(
service = UserService(db)
user = await service.get_user_by_id(user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
if not current_user.is_staff and user.id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
try:
user = await service.update_user(user, body)
@@ -164,10 +164,10 @@ async def delete_user(
service = UserService(db)
user = await service.get_user_by_id(user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
if not current_user.is_staff and user.id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
await service.deactivate_user(user)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -201,7 +201,7 @@ async def login(
service = UserService(db)
user = await service.authenticate(body.username, body.password)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Неверные учётные данные")
access, refresh = _issue_tokens(user)
user_read = await _resolve_avatar(user, storage)
@@ -226,5 +226,5 @@ async def refresh(body: TokenRefresh) -> TokenRefreshResponse:
return TokenRefreshResponse(access=access, refresh=body.refresh)
except (ExpiredSignatureError, InvalidTokenError, ValueError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token"
status_code=status.HTTP_401_UNAUTHORIZED, detail="Недействительный токен обновления"
)
+2 -2
View File
@@ -28,7 +28,7 @@ class UserService:
async def create_user(self, data: UserCreate, *, requester: User | None) -> User:
# Keep Django behavior: any authenticated user can create via this endpoint.
if requester is None:
raise ValueError("Authentication required")
raise ValueError("Требуется авторизация")
return await self._repo.create(data=data)
async def register_user(self, data: UserRegister) -> User:
@@ -42,7 +42,7 @@ class UserService:
async def change_password(self, user: User, current_password: str, new_password: str) -> None:
if not verify_password(current_password, user.password_hash):
raise ValueError("Current password is incorrect")
raise ValueError("Текущий пароль неверен")
new_hash = hash_password(new_password)
await self._repo.update_password(user, new_hash)
+6 -6
View File
@@ -44,10 +44,10 @@ async def retrieve_webhook_endpoint(
service = WebhookService(db)
webhook = await service.get_webhook(webhook_id)
if webhook is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
if not current_user.is_staff and webhook.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
return WebhookRead.model_validate(webhook)
@@ -62,10 +62,10 @@ async def patch_webhook_endpoint(
service = WebhookService(db)
webhook = await service.get_webhook(webhook_id)
if webhook is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
if not current_user.is_staff and webhook.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
webhook = await service.update_webhook(webhook, body)
return WebhookRead.model_validate(webhook)
@@ -80,10 +80,10 @@ async def delete_webhook_endpoint(
service = WebhookService(db)
webhook = await service.get_webhook(webhook_id)
if webhook is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
if not current_user.is_staff and webhook.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
await service.deactivate_webhook(webhook)
return Response(status_code=status.HTTP_204_NO_CONTENT)