This commit is contained in:
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.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,
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user