From a3df30184ed71923485e538fc5cdb739db1ff8da Mon Sep 17 00:00:00 2001 From: ack Date: Sun, 19 Apr 2026 19:19:03 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B7=D0=B0=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=B0=D0=BB=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9?= =?UTF-8?q?=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trae/rules/main.md | 2 +- camlaps/camlaps/services/_ init _.py | 0 camlaps/forms.py | 34 ++++++++++ camlaps/services/cameras.py | 60 +++++++++++++++++ camlaps/templates/camlaps/job_create.html | 79 +++++++++++++++++++++++ camlaps/templates/camlaps/job_detail.html | 57 ++++++++++++++++ camlaps/templates/camlaps/job_list.html | 48 ++++++++++++++ camlaps/views.py | 17 ++++- 8 files changed, 294 insertions(+), 3 deletions(-) create mode 100644 camlaps/camlaps/services/_ init _.py create mode 100644 camlaps/forms.py create mode 100644 camlaps/services/cameras.py create mode 100644 camlaps/templates/camlaps/job_create.html create mode 100644 camlaps/templates/camlaps/job_detail.html create mode 100644 camlaps/templates/camlaps/job_list.html diff --git a/.trae/rules/main.md b/.trae/rules/main.md index 6937318..3d5b79e 100644 --- a/.trae/rules/main.md +++ b/.trae/rules/main.md @@ -14,7 +14,7 @@ - Для правок существующих файлов: всегда показывать diff‑превью и ждать принятия. ### Новые файлы -- Для новых файлов всегда давать: полное имя + абсолютный путь + полный контент одним блоком. +- Новые- файлы — строго один файл = один code block с абсолютным путём в заголовке; пояснения после блоков ### Безопасность - Никогда не логировать/не печатать: SECRET_KEY, пароли БД, токены. diff --git a/camlaps/camlaps/services/_ init _.py b/camlaps/camlaps/services/_ init _.py new file mode 100644 index 0000000..e69de29 diff --git a/camlaps/forms.py b/camlaps/forms.py new file mode 100644 index 0000000..7f0e1b4 --- /dev/null +++ b/camlaps/forms.py @@ -0,0 +1,34 @@ +from django import forms + +from .models import TimelapseJob + + +class TimelapseJobCreateForm(forms.ModelForm): + class Meta: + model = TimelapseJob + fields = ( + 'date_from', + 'date_to', + 'sampling_preset', + 'fps', + 'include_night', + 'day_start_time', + 'day_end_time', + ) + widgets = { + 'date_from': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), + 'date_to': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), + 'sampling_preset': forms.NumberInput( + attrs={ + 'type': 'range', + 'min': 0, + 'max': len(TimelapseJob.SAMPLING_PRESET_MINUTES) - 1, + 'step': 1, + 'class': 'form-range', + } + ), + 'fps': forms.NumberInput(attrs={'class': 'form-control', 'min': 1, 'max': 120}), + 'include_night': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + 'day_start_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), + 'day_end_time': forms.TimeInput(attrs={'type': 'time', 'class': 'form-control'}), + } \ No newline at end of file diff --git a/camlaps/services/cameras.py b/camlaps/services/cameras.py new file mode 100644 index 0000000..0364cf1 --- /dev/null +++ b/camlaps/services/cameras.py @@ -0,0 +1,60 @@ +import logging +from pathlib import Path + +from django.conf import settings + +from ..models import Camera + + +logger = logging.getLogger('camlaps') + + +def _storage_root() -> Path: + storage_path = getattr(settings, 'STORAGE_PATH', None) + if storage_path is None: + return Path('/app/storage') + return Path(storage_path) + + +def is_storage_available() -> bool: + logger.info('cameras:storage_available:start') + try: + root = _storage_root() + ok = root.exists() and root.is_dir() + logger.info('cameras:storage_available:done ok=%s', ok) + return ok + except Exception: + logger.exception('cameras:storage_available:error') + raise + + +def list_active_cameras() -> list[Camera]: + logger.info('cameras:list_active:start') + try: + cameras = list(Camera.objects.filter(is_active=True).order_by('name')) + logger.info('cameras:list_active:done count=%s', len(cameras)) + return cameras + except Exception: + logger.exception('cameras:list_active:error') + raise + + +def get_camera_lastsnap_path(camera: Camera) -> Path | None: + logger.info('cameras:lastsnap_path:start camera_id=%s', camera.id) + try: + root = _storage_root().resolve() + candidate = (root / camera.storage_path / 'lastsnap.jpg').resolve() + + if candidate != root and root not in candidate.parents: + logger.info('cameras:lastsnap_path:done camera_id=%s found=false', camera.id) + return None + + if not candidate.exists() or not candidate.is_file(): + logger.info('cameras:lastsnap_path:done camera_id=%s found=false', camera.id) + return None + + logger.info('cameras:lastsnap_path:done camera_id=%s found=true', camera.id) + return candidate + except Exception: + logger.exception('cameras:lastsnap_path:error camera_id=%s', camera.id) + raise \ No newline at end of file diff --git a/camlaps/templates/camlaps/job_create.html b/camlaps/templates/camlaps/job_create.html new file mode 100644 index 0000000..8f17d33 --- /dev/null +++ b/camlaps/templates/camlaps/job_create.html @@ -0,0 +1,79 @@ +{% extends 'base.html' %} + +{% block content %} +
+

Новый таймлапс: {{ camera.name }}

+ Назад +
+ +
+ {% csrf_token %} +
+
+
+ + {{ form.date_from }} + {% if form.date_from.errors %}
{{ form.date_from.errors }}
{% endif %} +
+
+ + {{ form.date_to }} + {% if form.date_to.errors %}
{{ form.date_to.errors }}
{% endif %} +
+ +
+ + {{ form.sampling_preset }} +
+ {% for value,label in sampling_choices %} +
{{ label }}
+ {% endfor %} +
+
+ +
+ + {{ form.fps }} + {% if form.fps.errors %}
{{ form.fps.errors }}
{% endif %} +
+ +
+
+ {{ form.include_night }} + +
+
+ +
+ + {{ form.day_start_time }} +
+
+ + {{ form.day_end_time }} +
+ +
+ +
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/camlaps/templates/camlaps/job_detail.html b/camlaps/templates/camlaps/job_detail.html new file mode 100644 index 0000000..fb51376 --- /dev/null +++ b/camlaps/templates/camlaps/job_detail.html @@ -0,0 +1,57 @@ +{% extends 'base.html' %} + +{% block content %} +
+

Задание #{{ job.id }}

+ Назад +
+ +
+
+
+
+
Камера
+
{{ job.camera.name }}
+
+
+
Статус
+
{{ job.get_status_display }} ({{ job.progress_percent }}%)
+
+
+
Период
+
{{ job.date_from }} → {{ job.date_to }}
+
+
+
Интервал
+
{{ job.get_sampling_preset_display }}
+
+
+
FPS
+
{{ job.fps }}
+
+
+
Ночь
+
+ {% if job.include_night %} + Включена + {% else %} + Отключена ({{ job.day_start_time }}–{{ job.day_end_time }}) + {% endif %} +
+
+ + {% if job.error_message %} +
+
{{ job.error_message }}
+
+ {% endif %} + + {% if video_url %} + + {% endif %} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/camlaps/templates/camlaps/job_list.html b/camlaps/templates/camlaps/job_list.html new file mode 100644 index 0000000..b7e424a --- /dev/null +++ b/camlaps/templates/camlaps/job_list.html @@ -0,0 +1,48 @@ +{% extends 'base.html' %} + +{% block content %} +
+

Очередь задач

+
+ +
+ + + + + + + + + + + + + + + {% for job in jobs %} + + + + + + + + + + + {% empty %} + + {% endfor %} + +
IDКамераПериодИнтервалFPSСтатусПрогресс
{{ job.id }}{{ job.camera.name }}{{ job.date_from }} → {{ job.date_to }}{{ job.get_sampling_preset_display }}{{ job.fps }}{{ job.get_status_display }} + {% if job.status == 'success' %} + 100% + {% else %} + {{ job.progress_percent }}% + {% endif %} + + Открыть +
Пока нет задач.
+
+{% endblock %} \ No newline at end of file diff --git a/camlaps/views.py b/camlaps/views.py index 38bf6de..201ee9a 100644 --- a/camlaps/views.py +++ b/camlaps/views.py @@ -2,7 +2,6 @@ from django.http import FileResponse, Http404 from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse -from .forms import TimelapseJobCreateForm from .models import Camera, TimelapseJob from .services.cameras import get_camera_lastsnap_path, is_storage_available, list_active_cameras @@ -47,6 +46,8 @@ def job_detail(request, job_id: int): def job_create(request, camera_id: int): + from .forms import TimelapseJobCreateForm + camera = get_object_or_404(Camera, pk=camera_id, is_active=True) if request.method == 'POST': @@ -59,4 +60,16 @@ def job_create(request, camera_id: int): else: form = TimelapseJobCreateForm() - return render(request, 'camlaps/job_create.html', {'camera': camera, 'form': form}) + sampling_choices = TimelapseJob.SamplingPreset.choices + sampling_max = len(TimelapseJob.SAMPLING_PRESET_MINUTES) - 1 + + return render( + request, + 'camlaps/job_create.html', + { + 'camera': camera, + 'form': form, + 'sampling_choices': sampling_choices, + 'sampling_max': sampling_max, + }, + )