diff --git a/camlaps/admin.py b/camlaps/admin.py index 1efbb5b..ccbba81 100644 --- a/camlaps/admin.py +++ b/camlaps/admin.py @@ -1,3 +1,5 @@ +"""Django admin конфигурация приложения camlaps.""" + from django.contrib import admin from .models import Camera, TimelapseJob @@ -5,6 +7,7 @@ from .models import Camera, TimelapseJob @admin.register(Camera) class CameraAdmin(admin.ModelAdmin): + """Настройки отображения модели Camera в админке.""" list_display = ('name', 'slug', 'storage_path', 'is_active', 'updated_at') list_filter = ('is_active',) search_fields = ('name', 'slug', 'storage_path') @@ -12,6 +15,7 @@ class CameraAdmin(admin.ModelAdmin): @admin.register(TimelapseJob) class TimelapseJobAdmin(admin.ModelAdmin): + """Настройки отображения модели TimelapseJob в админке.""" list_display = ( 'id', 'camera', diff --git a/camlaps/models.py b/camlaps/models.py index f27196c..e362eae 100644 --- a/camlaps/models.py +++ b/camlaps/models.py @@ -1,3 +1,5 @@ +"""Модели приложения camlaps: камеры и задания таймлапса.""" + from datetime import time from django.core.validators import MaxValueValidator, MinValueValidator @@ -6,9 +8,7 @@ from django.db import models class Camera(models.Model): - """ - модель камеры хранящая основные настройки камеры, такие как имя, код, путь в storage, RTSP URL, ожидаемая ширина, высота, активность - """ + """Камера: имя, идентификатор, путь к данным, опционально RTSP и ожидаемое разрешение.""" name = models.CharField(max_length=120, verbose_name='Наименование') slug = models.SlugField(max_length=80, unique=True, verbose_name='Код камеры') storage_path = models.CharField( @@ -33,9 +33,7 @@ class Camera(models.Model): class TimelapseJob(models.Model): - """ - модель задачи создания timelapse, хранящая основные настройки задачи, такие как камера, даты выборки, частота выборки, FPS, включать ночные кадры, время начала дня, время конца дня - """ + """Задание сборки таймлапса: параметры выборки кадров, статус, прогресс и результаты.""" class Status(models.TextChoices): PLANNED = 'planned', 'Запланировано' RUNNING = 'running', 'В работе' diff --git a/camlaps/services/cameras.py b/camlaps/services/cameras.py index addd6b2..3c6cc69 100644 --- a/camlaps/services/cameras.py +++ b/camlaps/services/cameras.py @@ -1,3 +1,5 @@ +"""Сервисы для работы с камерами и storage: превью, автообнаружение, утилиты.""" + import logging from pathlib import Path @@ -11,6 +13,7 @@ logger = logging.getLogger('camlaps') def _storage_root() -> Path: + """Возвращает корень storage, в который смонтированы фото камер.""" storage_path = getattr(settings, 'STORAGE_PATH', None) if storage_path is None: return Path('/app/storage') @@ -18,6 +21,7 @@ def _storage_root() -> Path: 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] slug = base_slug suffix = 1 @@ -31,6 +35,7 @@ def generate_unique_camera_slug(directory_name: str, storage_path: str) -> str: def is_storage_available() -> bool: + """Проверяет, что storage примонтирован и доступен как директория.""" logger.info('cameras:storage_available:start') try: root = _storage_root() @@ -43,6 +48,7 @@ def is_storage_available() -> bool: def list_active_cameras() -> list[Camera]: + """Возвращает список активных камер для отображения в UI.""" logger.info('cameras:list_active:start') try: 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: + """Возвращает путь к lastsnap.jpg для камеры или None, если файла нет/путь небезопасен.""" logger.info('cameras:lastsnap_path:start camera_id=%s', camera.id) try: 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]]: + """Сканирует подпапки storage и возвращает кандидатов камер без записи в БД.""" logger.info('cameras:discover_candidates:start') try: 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: + """Создает записи Camera в БД только для выбранных storage_path, возвращает количество созданных.""" logger.info('cameras:create_from_candidates:start count=%s', len(selected_storage_paths)) try: created = 0 diff --git a/camlaps/services/jobs.py b/camlaps/services/jobs.py new file mode 100644 index 0000000..a122a12 --- /dev/null +++ b/camlaps/services/jobs.py @@ -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 \ No newline at end of file diff --git a/camlaps/services/timelapse_worker.py b/camlaps/services/timelapse_worker.py index 59b5245..f22601e 100644 --- a/camlaps/services/timelapse_worker.py +++ b/camlaps/services/timelapse_worker.py @@ -1,3 +1,5 @@ +"""Фоновый воркер сборки таймлапса: выбор кадров из storage и сборка видео через ffmpeg.""" + import logging import shutil import subprocess diff --git a/camlaps/templates/camlaps/job_detail.html b/camlaps/templates/camlaps/job_detail.html index 456a4d4..9ff35f6 100644 --- a/camlaps/templates/camlaps/job_detail.html +++ b/camlaps/templates/camlaps/job_detail.html @@ -16,6 +16,12 @@ {% endif %} + {% if job.status != 'running' %} +
+ {% csrf_token %} + +
+ {% endif %} Назад diff --git a/camlaps/templates/camlaps/job_list.html b/camlaps/templates/camlaps/job_list.html index 23438a1..3c883fd 100644 --- a/camlaps/templates/camlaps/job_list.html +++ b/camlaps/templates/camlaps/job_list.html @@ -37,6 +37,49 @@
Задача #{{ retried }} снова поставлена в очередь.
{% endif %} +{% if deleted == 'running' %} +
Нельзя удалить задачу, пока она выполняется.
+{% elif deleted %} +
Задача #{{ deleted }} удалена.
+{% endif %} + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + Сброс +
+
+
+
+
@@ -90,6 +133,12 @@ {% endif %} + {% if job.status != 'running' %} + + {% csrf_token %} + + + {% endif %} Открыть diff --git a/camlaps/urls.py b/camlaps/urls.py index 2ab84c7..8f2c040 100644 --- a/camlaps/urls.py +++ b/camlaps/urls.py @@ -1,3 +1,5 @@ +"""URLConf приложения camlaps.""" + from django.urls import path from . import views @@ -14,6 +16,7 @@ urlpatterns = [ path('jobs/start/', views.start_queue, name='start_queue'), path('jobs//run-now/', views.run_job_now, name='run_job_now'), path('jobs//retry/', views.retry_job, name='retry_job'), + path('jobs//delete/', views.delete_job, name='delete_job'), path('jobs/logs/', views.worker_logs, name='worker_logs'), path('jobs/logs/clear/', views.clear_worker_logs, name='clear_worker_logs'), path('jobs//', views.job_detail, name='job_detail'), diff --git a/camlaps/views.py b/camlaps/views.py index fe1589e..5575245 100644 --- a/camlaps/views.py +++ b/camlaps/views.py @@ -1,6 +1,7 @@ import logging import subprocess import sys +from datetime import date from django.conf import settings from django.http import FileResponse, Http404 @@ -9,6 +10,7 @@ from django.urls import reverse from django.views.decorators.http import require_POST from .models import Camera, TimelapseJob +from .services.jobs import delete_job_and_artifacts from .services.cameras import ( create_cameras_from_candidates, discover_camera_candidates, @@ -58,14 +60,39 @@ def camera_preview(request, camera_id: int): 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): """Отображает таблицу задач и признак активности очереди для автообновления.""" qs = TimelapseJob.objects.select_related('camera').all() + camera_id = request.GET.get('camera') if 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] has_active_jobs = qs.filter(status__in=[TimelapseJob.Status.PLANNED, TimelapseJob.Status.RUNNING]).exists() + return render( request, 'camlaps/job_list.html', @@ -74,6 +101,15 @@ def job_list(request): 'queue_started': request.GET.get('started'), 'retried': request.GET.get('retried'), '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}") +@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): job = get_object_or_404(TimelapseJob.objects.select_related('camera'), pk=job_id) video_url = None