This commit is contained in:
@@ -14,7 +14,7 @@
|
|||||||
- Для правок существующих файлов: всегда показывать diff‑превью и ждать принятия.
|
- Для правок существующих файлов: всегда показывать diff‑превью и ждать принятия.
|
||||||
|
|
||||||
### Новые файлы
|
### Новые файлы
|
||||||
- Для новых файлов всегда давать: полное имя + абсолютный путь + полный контент одним блоком.
|
- Новые- файлы — строго один файл = один code block с абсолютным путём в заголовке; пояснения после блоков
|
||||||
|
|
||||||
### Безопасность
|
### Безопасность
|
||||||
- Никогда не логировать/не печатать: SECRET_KEY, пароли БД, токены.
|
- Никогда не логировать/не печатать: SECRET_KEY, пароли БД, токены.
|
||||||
|
|||||||
0
camlaps/camlaps/services/_ init _.py
Normal file
0
camlaps/camlaps/services/_ init _.py
Normal file
34
camlaps/forms.py
Normal file
34
camlaps/forms.py
Normal 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'}),
|
||||||
|
}
|
||||||
60
camlaps/services/cameras.py
Normal file
60
camlaps/services/cameras.py
Normal 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
|
||||||
79
camlaps/templates/camlaps/job_create.html
Normal file
79
camlaps/templates/camlaps/job_create.html
Normal 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 %}
|
||||||
57
camlaps/templates/camlaps/job_detail.html
Normal file
57
camlaps/templates/camlaps/job_detail.html
Normal 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 %}
|
||||||
48
camlaps/templates/camlaps/job_list.html
Normal file
48
camlaps/templates/camlaps/job_list.html
Normal 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 %}
|
||||||
@@ -2,7 +2,6 @@ from django.http import FileResponse, Http404
|
|||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from .forms import TimelapseJobCreateForm
|
|
||||||
from .models import Camera, TimelapseJob
|
from .models import Camera, TimelapseJob
|
||||||
from .services.cameras import get_camera_lastsnap_path, is_storage_available, list_active_cameras
|
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):
|
def job_create(request, camera_id: int):
|
||||||
|
from .forms import TimelapseJobCreateForm
|
||||||
|
|
||||||
camera = get_object_or_404(Camera, pk=camera_id, is_active=True)
|
camera = get_object_or_404(Camera, pk=camera_id, is_active=True)
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
@@ -59,4 +60,16 @@ def job_create(request, camera_id: int):
|
|||||||
else:
|
else:
|
||||||
form = TimelapseJobCreateForm()
|
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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user