добавил удаление таймлапсов
All checks were successful
Deploy timelaps / deploy (push) Successful in 5s
All checks were successful
Deploy timelaps / deploy (push) Successful in 5s
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
"""Django admin конфигурация приложения camlaps."""
|
||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from .models import Camera, TimelapseJob
|
from .models import Camera, TimelapseJob
|
||||||
@@ -5,6 +7,7 @@ from .models import Camera, TimelapseJob
|
|||||||
|
|
||||||
@admin.register(Camera)
|
@admin.register(Camera)
|
||||||
class CameraAdmin(admin.ModelAdmin):
|
class CameraAdmin(admin.ModelAdmin):
|
||||||
|
"""Настройки отображения модели Camera в админке."""
|
||||||
list_display = ('name', 'slug', 'storage_path', 'is_active', 'updated_at')
|
list_display = ('name', 'slug', 'storage_path', 'is_active', 'updated_at')
|
||||||
list_filter = ('is_active',)
|
list_filter = ('is_active',)
|
||||||
search_fields = ('name', 'slug', 'storage_path')
|
search_fields = ('name', 'slug', 'storage_path')
|
||||||
@@ -12,6 +15,7 @@ class CameraAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(TimelapseJob)
|
@admin.register(TimelapseJob)
|
||||||
class TimelapseJobAdmin(admin.ModelAdmin):
|
class TimelapseJobAdmin(admin.ModelAdmin):
|
||||||
|
"""Настройки отображения модели TimelapseJob в админке."""
|
||||||
list_display = (
|
list_display = (
|
||||||
'id',
|
'id',
|
||||||
'camera',
|
'camera',
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"""Модели приложения camlaps: камеры и задания таймлапса."""
|
||||||
|
|
||||||
from datetime import time
|
from datetime import time
|
||||||
|
|
||||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
@@ -6,9 +8,7 @@ from django.db import models
|
|||||||
|
|
||||||
|
|
||||||
class Camera(models.Model):
|
class Camera(models.Model):
|
||||||
"""
|
"""Камера: имя, идентификатор, путь к данным, опционально RTSP и ожидаемое разрешение."""
|
||||||
модель камеры хранящая основные настройки камеры, такие как имя, код, путь в storage, RTSP URL, ожидаемая ширина, высота, активность
|
|
||||||
"""
|
|
||||||
name = models.CharField(max_length=120, verbose_name='Наименование')
|
name = models.CharField(max_length=120, verbose_name='Наименование')
|
||||||
slug = models.SlugField(max_length=80, unique=True, verbose_name='Код камеры')
|
slug = models.SlugField(max_length=80, unique=True, verbose_name='Код камеры')
|
||||||
storage_path = models.CharField(
|
storage_path = models.CharField(
|
||||||
@@ -33,9 +33,7 @@ class Camera(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class TimelapseJob(models.Model):
|
class TimelapseJob(models.Model):
|
||||||
"""
|
"""Задание сборки таймлапса: параметры выборки кадров, статус, прогресс и результаты."""
|
||||||
модель задачи создания timelapse, хранящая основные настройки задачи, такие как камера, даты выборки, частота выборки, FPS, включать ночные кадры, время начала дня, время конца дня
|
|
||||||
"""
|
|
||||||
class Status(models.TextChoices):
|
class Status(models.TextChoices):
|
||||||
PLANNED = 'planned', 'Запланировано'
|
PLANNED = 'planned', 'Запланировано'
|
||||||
RUNNING = 'running', 'В работе'
|
RUNNING = 'running', 'В работе'
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"""Сервисы для работы с камерами и storage: превью, автообнаружение, утилиты."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -11,6 +13,7 @@ logger = logging.getLogger('camlaps')
|
|||||||
|
|
||||||
|
|
||||||
def _storage_root() -> Path:
|
def _storage_root() -> Path:
|
||||||
|
"""Возвращает корень storage, в который смонтированы фото камер."""
|
||||||
storage_path = getattr(settings, 'STORAGE_PATH', None)
|
storage_path = getattr(settings, 'STORAGE_PATH', None)
|
||||||
if storage_path is None:
|
if storage_path is None:
|
||||||
return Path('/app/storage')
|
return Path('/app/storage')
|
||||||
@@ -18,6 +21,7 @@ def _storage_root() -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def generate_unique_camera_slug(directory_name: str, storage_path: str) -> str:
|
def generate_unique_camera_slug(directory_name: str, storage_path: str) -> str:
|
||||||
|
"""Генерирует уникальный slug камеры для новых записей, избегая конфликтов."""
|
||||||
base_slug = (slugify(directory_name) or f'camera-{directory_name.lower()}')[:70]
|
base_slug = (slugify(directory_name) or f'camera-{directory_name.lower()}')[:70]
|
||||||
slug = base_slug
|
slug = base_slug
|
||||||
suffix = 1
|
suffix = 1
|
||||||
@@ -31,6 +35,7 @@ def generate_unique_camera_slug(directory_name: str, storage_path: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def is_storage_available() -> bool:
|
def is_storage_available() -> bool:
|
||||||
|
"""Проверяет, что storage примонтирован и доступен как директория."""
|
||||||
logger.info('cameras:storage_available:start')
|
logger.info('cameras:storage_available:start')
|
||||||
try:
|
try:
|
||||||
root = _storage_root()
|
root = _storage_root()
|
||||||
@@ -43,6 +48,7 @@ def is_storage_available() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def list_active_cameras() -> list[Camera]:
|
def list_active_cameras() -> list[Camera]:
|
||||||
|
"""Возвращает список активных камер для отображения в UI."""
|
||||||
logger.info('cameras:list_active:start')
|
logger.info('cameras:list_active:start')
|
||||||
try:
|
try:
|
||||||
cameras = list(Camera.objects.filter(is_active=True).order_by('name'))
|
cameras = list(Camera.objects.filter(is_active=True).order_by('name'))
|
||||||
@@ -54,6 +60,7 @@ def list_active_cameras() -> list[Camera]:
|
|||||||
|
|
||||||
|
|
||||||
def get_camera_lastsnap_path(camera: Camera) -> Path | None:
|
def get_camera_lastsnap_path(camera: Camera) -> Path | None:
|
||||||
|
"""Возвращает путь к lastsnap.jpg для камеры или None, если файла нет/путь небезопасен."""
|
||||||
logger.info('cameras:lastsnap_path:start camera_id=%s', camera.id)
|
logger.info('cameras:lastsnap_path:start camera_id=%s', camera.id)
|
||||||
try:
|
try:
|
||||||
root = _storage_root().resolve()
|
root = _storage_root().resolve()
|
||||||
@@ -75,6 +82,7 @@ def get_camera_lastsnap_path(camera: Camera) -> Path | None:
|
|||||||
|
|
||||||
|
|
||||||
def discover_camera_candidates() -> list[dict[str, str]]:
|
def discover_camera_candidates() -> list[dict[str, str]]:
|
||||||
|
"""Сканирует подпапки storage и возвращает кандидатов камер без записи в БД."""
|
||||||
logger.info('cameras:discover_candidates:start')
|
logger.info('cameras:discover_candidates:start')
|
||||||
try:
|
try:
|
||||||
root = _storage_root()
|
root = _storage_root()
|
||||||
@@ -100,6 +108,7 @@ def discover_camera_candidates() -> list[dict[str, str]]:
|
|||||||
|
|
||||||
|
|
||||||
def create_cameras_from_candidates(selected_storage_paths: list[str]) -> int:
|
def create_cameras_from_candidates(selected_storage_paths: list[str]) -> int:
|
||||||
|
"""Создает записи Camera в БД только для выбранных storage_path, возвращает количество созданных."""
|
||||||
logger.info('cameras:create_from_candidates:start count=%s', len(selected_storage_paths))
|
logger.info('cameras:create_from_candidates:start count=%s', len(selected_storage_paths))
|
||||||
try:
|
try:
|
||||||
created = 0
|
created = 0
|
||||||
|
|||||||
52
camlaps/services/jobs.py
Normal file
52
camlaps/services/jobs.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""Сервисы для операций над заданиями: удаление и чистка артефактов."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from ..models import TimelapseJob
|
||||||
|
|
||||||
|
logger = logging.getLogger('camlaps')
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_output_video_path(job: TimelapseJob) -> Path | None:
|
||||||
|
"""Возвращает абсолютный путь к mp4 из output_rel_path или None, если видео не задано/путь небезопасен."""
|
||||||
|
rel = (job.output_rel_path or '').strip().lstrip('/')
|
||||||
|
if not rel:
|
||||||
|
return None
|
||||||
|
|
||||||
|
filename = Path(rel).name
|
||||||
|
export_dir = Path(settings.TIMELAPS_EXPORT_DIR).resolve()
|
||||||
|
candidate = (export_dir / filename).resolve()
|
||||||
|
|
||||||
|
if candidate != export_dir and export_dir not in candidate.parents:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def delete_job_and_artifacts(job_id: int) -> bool:
|
||||||
|
"""Удаляет задачу и её видеофайл (если существует). Возвращает True, если файл видео был удалён."""
|
||||||
|
logger.info('jobs:delete:start job_id=%s', job_id)
|
||||||
|
|
||||||
|
job = TimelapseJob.objects.select_for_update().filter(pk=job_id).first()
|
||||||
|
if not job:
|
||||||
|
logger.info('jobs:delete:done job_not_found=true job_id=%s', job_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
video_path = _resolve_output_video_path(job)
|
||||||
|
video_deleted = False
|
||||||
|
|
||||||
|
if video_path and video_path.exists():
|
||||||
|
try:
|
||||||
|
video_path.unlink()
|
||||||
|
video_deleted = True
|
||||||
|
except Exception:
|
||||||
|
logger.exception('jobs:delete:video_unlink_error job_id=%s', job_id)
|
||||||
|
|
||||||
|
job.delete()
|
||||||
|
logger.info('jobs:delete:done job_id=%s video_deleted=%s', job_id, video_deleted)
|
||||||
|
return video_deleted
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"""Фоновый воркер сборки таймлапса: выбор кадров из storage и сборка видео через ffmpeg."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|||||||
@@ -16,6 +16,12 @@
|
|||||||
<button type="submit" class="btn btn-warning">Запустить снова</button>
|
<button type="submit" class="btn btn-warning">Запустить снова</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if job.status != 'running' %}
|
||||||
|
<form method="post" action="{% url 'camlaps:delete_job' job.id %}" onsubmit="return confirm('Удалить задачу и видео (если есть)?')">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-outline-danger">Удалить</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
<a class="btn btn-outline-secondary" href="{% url 'camlaps:job_list' %}">Назад</a>
|
<a class="btn btn-outline-secondary" href="{% url 'camlaps:job_list' %}">Назад</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,6 +37,49 @@
|
|||||||
<div class="alert alert-success py-2">Задача #{{ retried }} снова поставлена в очередь.</div>
|
<div class="alert alert-success py-2">Задача #{{ retried }} снова поставлена в очередь.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if deleted == 'running' %}
|
||||||
|
<div class="alert alert-warning py-2">Нельзя удалить задачу, пока она выполняется.</div>
|
||||||
|
{% elif deleted %}
|
||||||
|
<div class="alert alert-success py-2">Задача #{{ deleted }} удалена.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="get" class="card mb-3">
|
||||||
|
<div class="card-body py-3">
|
||||||
|
<div class="row g-2 align-items-end">
|
||||||
|
<div class="col-12 col-md-3">
|
||||||
|
<label class="form-label mb-1">Камера</label>
|
||||||
|
<select class="form-select" name="camera">
|
||||||
|
<option value="">Все</option>
|
||||||
|
{% for c in cameras %}
|
||||||
|
<option value="{{ c.id }}" {% if filters.camera == c.id|stringformat:'s' %}selected{% endif %}>{{ c.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-3">
|
||||||
|
<label class="form-label mb-1">Статус</label>
|
||||||
|
<select class="form-select" name="status">
|
||||||
|
<option value="">Все</option>
|
||||||
|
{% for value,label in status_choices %}
|
||||||
|
<option value="{{ value }}" {% if filters.status == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-2">
|
||||||
|
<label class="form-label mb-1">Создано с</label>
|
||||||
|
<input class="form-control" type="date" name="created_from" value="{{ filters.created_from }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-2">
|
||||||
|
<label class="form-label mb-1">по</label>
|
||||||
|
<input class="form-control" type="date" name="created_to" value="{{ filters.created_to }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-2 d-flex gap-2">
|
||||||
|
<button class="btn btn-primary" type="submit">Фильтр</button>
|
||||||
|
<a class="btn btn-outline-secondary" href="{% url 'camlaps:job_list' %}">Сброс</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-striped align-middle">
|
<table class="table table-striped align-middle">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -90,6 +133,12 @@
|
|||||||
<button type="submit" class="btn btn-sm btn-warning">Запустить снова</button>
|
<button type="submit" class="btn btn-sm btn-warning">Запустить снова</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if job.status != 'running' %}
|
||||||
|
<form method="post" action="{% url 'camlaps:delete_job' job.id %}" onsubmit="return confirm('Удалить задачу и видео (если есть)?')">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger">Удалить</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
<a class="btn btn-sm btn-outline-primary" href="{% url 'camlaps:job_detail' job.id %}">Открыть</a>
|
<a class="btn btn-sm btn-outline-primary" href="{% url 'camlaps:job_detail' job.id %}">Открыть</a>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"""URLConf приложения camlaps."""
|
||||||
|
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
@@ -14,6 +16,7 @@ urlpatterns = [
|
|||||||
path('jobs/start/', views.start_queue, name='start_queue'),
|
path('jobs/start/', views.start_queue, name='start_queue'),
|
||||||
path('jobs/<int:job_id>/run-now/', views.run_job_now, name='run_job_now'),
|
path('jobs/<int:job_id>/run-now/', views.run_job_now, name='run_job_now'),
|
||||||
path('jobs/<int:job_id>/retry/', views.retry_job, name='retry_job'),
|
path('jobs/<int:job_id>/retry/', views.retry_job, name='retry_job'),
|
||||||
|
path('jobs/<int:job_id>/delete/', views.delete_job, name='delete_job'),
|
||||||
path('jobs/logs/', views.worker_logs, name='worker_logs'),
|
path('jobs/logs/', views.worker_logs, name='worker_logs'),
|
||||||
path('jobs/logs/clear/', views.clear_worker_logs, name='clear_worker_logs'),
|
path('jobs/logs/clear/', views.clear_worker_logs, name='clear_worker_logs'),
|
||||||
path('jobs/<int:job_id>/', views.job_detail, name='job_detail'),
|
path('jobs/<int:job_id>/', views.job_detail, name='job_detail'),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http import FileResponse, Http404
|
from django.http import FileResponse, Http404
|
||||||
@@ -9,6 +10,7 @@ from django.urls import reverse
|
|||||||
from django.views.decorators.http import require_POST
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
from .models import Camera, TimelapseJob
|
from .models import Camera, TimelapseJob
|
||||||
|
from .services.jobs import delete_job_and_artifacts
|
||||||
from .services.cameras import (
|
from .services.cameras import (
|
||||||
create_cameras_from_candidates,
|
create_cameras_from_candidates,
|
||||||
discover_camera_candidates,
|
discover_camera_candidates,
|
||||||
@@ -58,14 +60,39 @@ def camera_preview(request, camera_id: int):
|
|||||||
return FileResponse(path.open('rb'), content_type='image/jpeg')
|
return FileResponse(path.open('rb'), content_type='image/jpeg')
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_iso_date(value: str | None) -> date | None:
|
||||||
|
"""Парсит дату YYYY-MM-DD из query string."""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def job_list(request):
|
def job_list(request):
|
||||||
"""Отображает таблицу задач и признак активности очереди для автообновления."""
|
"""Отображает таблицу задач и признак активности очереди для автообновления."""
|
||||||
qs = TimelapseJob.objects.select_related('camera').all()
|
qs = TimelapseJob.objects.select_related('camera').all()
|
||||||
|
|
||||||
camera_id = request.GET.get('camera')
|
camera_id = request.GET.get('camera')
|
||||||
if camera_id:
|
if camera_id:
|
||||||
qs = qs.filter(camera_id=camera_id)
|
qs = qs.filter(camera_id=camera_id)
|
||||||
|
|
||||||
|
status = request.GET.get('status')
|
||||||
|
if status in {c[0] for c in TimelapseJob.Status.choices}:
|
||||||
|
qs = qs.filter(status=status)
|
||||||
|
|
||||||
|
created_from = _parse_iso_date(request.GET.get('created_from'))
|
||||||
|
if created_from:
|
||||||
|
qs = qs.filter(created_at__date__gte=created_from)
|
||||||
|
|
||||||
|
created_to = _parse_iso_date(request.GET.get('created_to'))
|
||||||
|
if created_to:
|
||||||
|
qs = qs.filter(created_at__date__lte=created_to)
|
||||||
|
|
||||||
jobs = qs.order_by('-created_at')[:200]
|
jobs = qs.order_by('-created_at')[:200]
|
||||||
has_active_jobs = qs.filter(status__in=[TimelapseJob.Status.PLANNED, TimelapseJob.Status.RUNNING]).exists()
|
has_active_jobs = qs.filter(status__in=[TimelapseJob.Status.PLANNED, TimelapseJob.Status.RUNNING]).exists()
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
'camlaps/job_list.html',
|
'camlaps/job_list.html',
|
||||||
@@ -74,6 +101,15 @@ def job_list(request):
|
|||||||
'queue_started': request.GET.get('started'),
|
'queue_started': request.GET.get('started'),
|
||||||
'retried': request.GET.get('retried'),
|
'retried': request.GET.get('retried'),
|
||||||
'has_active_jobs': has_active_jobs,
|
'has_active_jobs': has_active_jobs,
|
||||||
|
'cameras': Camera.objects.order_by('name'),
|
||||||
|
'status_choices': TimelapseJob.Status.choices,
|
||||||
|
'filters': {
|
||||||
|
'camera': camera_id or '',
|
||||||
|
'status': status or '',
|
||||||
|
'created_from': request.GET.get('created_from', ''),
|
||||||
|
'created_to': request.GET.get('created_to', ''),
|
||||||
|
},
|
||||||
|
'deleted': request.GET.get('deleted'),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -175,6 +211,21 @@ def retry_job(request, job_id: int):
|
|||||||
return redirect(f"{reverse('camlaps:job_list')}?retried={job_id}")
|
return redirect(f"{reverse('camlaps:job_list')}?retried={job_id}")
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def delete_job(request, job_id: int):
|
||||||
|
"""Удаляет задачу и её артефакты (mp4) при наличии."""
|
||||||
|
logger.info('job:delete:start job_id=%s', job_id)
|
||||||
|
job = get_object_or_404(TimelapseJob, pk=job_id)
|
||||||
|
|
||||||
|
if job.status == TimelapseJob.Status.RUNNING:
|
||||||
|
logger.info('job:delete:done job_id=%s blocked=running', job_id)
|
||||||
|
return redirect(f"{reverse('camlaps:job_list')}?deleted=running")
|
||||||
|
|
||||||
|
delete_job_and_artifacts(job_id)
|
||||||
|
logger.info('job:delete:done job_id=%s', job_id)
|
||||||
|
return redirect(f"{reverse('camlaps:job_list')}?deleted={job_id}")
|
||||||
|
|
||||||
|
|
||||||
def job_detail(request, job_id: int):
|
def job_detail(request, job_id: int):
|
||||||
job = get_object_or_404(TimelapseJob.objects.select_related('camera'), pk=job_id)
|
job = get_object_or_404(TimelapseJob.objects.select_related('camera'), pk=job_id)
|
||||||
video_url = None
|
video_url = None
|
||||||
|
|||||||
Reference in New Issue
Block a user