заработал интерфейс
All checks were successful
Deploy timelaps / deploy (push) Successful in 5s

This commit is contained in:
ack
2026-04-19 19:19:03 +03:00
parent 7f1aa4b465
commit a3df30184e
8 changed files with 294 additions and 3 deletions

View File

@@ -14,7 +14,7 @@
- Для правок существующих файлов: всегда показывать diffпревью и ждать принятия.
### Новые файлы
- Для новых файлов всегда давать: полное имя + абсолютный путь + полный контент одним блоком.
- Новые- файлы — строго один файл = один code block с абсолютным путём в заголовке; пояснения после блоков
### Безопасность
- Никогда не логировать/не печатать: SECRET_KEY, пароли БД, токены.

View File

34
camlaps/forms.py Normal file
View File

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

View File

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

View File

@@ -0,0 +1,79 @@
{% extends 'base.html' %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Новый таймлапс: {{ camera.name }}</h2>
<a class="btn btn-outline-secondary" href="{% url 'camlaps:index' %}">Назад</a>
</div>
<form method="post" class="card shadow-sm">
{% csrf_token %}
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Дата начала</label>
{{ form.date_from }}
{% if form.date_from.errors %}<div class="text-danger small">{{ form.date_from.errors }}</div>{% endif %}
</div>
<div class="col-md-6">
<label class="form-label">Дата окончания</label>
{{ form.date_to }}
{% if form.date_to.errors %}<div class="text-danger small">{{ form.date_to.errors }}</div>{% endif %}
</div>
<div class="col-12">
<label class="form-label">Частота выборки</label>
{{ form.sampling_preset }}
<div class="d-flex justify-content-between small text-muted">
{% for value,label in sampling_choices %}
<div style="width: calc(100% / ({{ sampling_max|add:1 }})); text-align: center;">{{ label }}</div>
{% endfor %}
</div>
</div>
<div class="col-md-4">
<label class="form-label">FPS</label>
{{ form.fps }}
{% if form.fps.errors %}<div class="text-danger small">{{ form.fps.errors }}</div>{% endif %}
</div>
<div class="col-md-8 d-flex align-items-end">
<div class="form-check">
{{ form.include_night }}
<label class="form-check-label" for="{{ form.include_night.id_for_label }}">Включать ночные кадры</label>
</div>
</div>
<div class="col-md-6" id="day-start-wrap">
<label class="form-label">Начало дня</label>
{{ form.day_start_time }}
</div>
<div class="col-md-6" id="day-end-wrap">
<label class="form-label">Конец дня</label>
{{ form.day_end_time }}
</div>
<div class="col-12">
<button class="btn btn-primary" type="submit">Создать задачу</button>
</div>
</div>
</div>
</form>
<script>
(function() {
const includeNight = document.getElementById("{{ form.include_night.auto_id }}");
const dayStart = document.getElementById("day-start-wrap");
const dayEnd = document.getElementById("day-end-wrap");
function sync() {
const enabled = includeNight && includeNight.checked;
if (dayStart) dayStart.style.display = enabled ? "none" : "";
if (dayEnd) dayEnd.style.display = enabled ? "none" : "";
}
if (includeNight) includeNight.addEventListener("change", sync);
sync();
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,57 @@
{% extends 'base.html' %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Задание #{{ job.id }}</h2>
<a class="btn btn-outline-secondary" href="{% url 'camlaps:job_list' %}">Назад</a>
</div>
<div class="card shadow-sm">
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<div class="text-muted">Камера</div>
<div class="fw-semibold">{{ job.camera.name }}</div>
</div>
<div class="col-md-6">
<div class="text-muted">Статус</div>
<div class="fw-semibold">{{ job.get_status_display }} ({{ job.progress_percent }}%)</div>
</div>
<div class="col-md-6">
<div class="text-muted">Период</div>
<div class="fw-semibold">{{ job.date_from }} → {{ job.date_to }}</div>
</div>
<div class="col-md-6">
<div class="text-muted">Интервал</div>
<div class="fw-semibold">{{ job.get_sampling_preset_display }}</div>
</div>
<div class="col-md-6">
<div class="text-muted">FPS</div>
<div class="fw-semibold">{{ job.fps }}</div>
</div>
<div class="col-md-6">
<div class="text-muted">Ночь</div>
<div class="fw-semibold">
{% if job.include_night %}
Включена
{% else %}
Отключена ({{ job.day_start_time }}{{ job.day_end_time }})
{% endif %}
</div>
</div>
{% if job.error_message %}
<div class="col-12">
<div class="alert alert-danger mb-0">{{ job.error_message }}</div>
</div>
{% endif %}
{% if video_url %}
<div class="col-12">
<a class="btn btn-success" href="{{ video_url }}" target="_blank">Открыть видео</a>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,48 @@
{% extends 'base.html' %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Очередь задач</h2>
</div>
<div class="table-responsive">
<table class="table table-striped align-middle">
<thead>
<tr>
<th>ID</th>
<th>Камера</th>
<th>Период</th>
<th>Интервал</th>
<th>FPS</th>
<th>Статус</th>
<th>Прогресс</th>
<th></th>
</tr>
</thead>
<tbody>
{% for job in jobs %}
<tr>
<td>{{ job.id }}</td>
<td>{{ job.camera.name }}</td>
<td>{{ job.date_from }} → {{ job.date_to }}</td>
<td>{{ job.get_sampling_preset_display }}</td>
<td>{{ job.fps }}</td>
<td>{{ job.get_status_display }}</td>
<td>
{% if job.status == 'success' %}
100%
{% else %}
{{ job.progress_percent }}%
{% endif %}
</td>
<td class="text-end">
<a class="btn btn-sm btn-outline-primary" href="{% url 'camlaps:job_detail' job.id %}">Открыть</a>
</td>
</tr>
{% empty %}
<tr><td colspan="8">Пока нет задач.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

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