добавил удаление таймлапсов
All checks were successful
Deploy timelaps / deploy (push) Successful in 5s

This commit is contained in:
ack
2026-04-19 23:21:07 +03:00
parent 587cb0bcb4
commit ae15bee2d1
9 changed files with 180 additions and 6 deletions

View File

@@ -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',

View File

@@ -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', 'В работе'

View File

@@ -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
View 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

View File

@@ -1,3 +1,5 @@
"""Фоновый воркер сборки таймлапса: выбор кадров из storage и сборка видео через ffmpeg."""
import logging import logging
import shutil import shutil
import subprocess import subprocess

View File

@@ -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>

View File

@@ -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>

View File

@@ -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'),

View File

@@ -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