diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 5f8c445..b38035e 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -20,6 +20,11 @@ jobs: else git clone https://gitea.tertelius.space/ack/timelaps.git . fi + + # 3. собираем .env файл из секретов Gitea + echo "DJANGO_SECRET_KEY=${{ secrets.DJANGO_SECRET_KEY }}" > .env + echo "ENV_TYPE=${{ secrets.ENV_TYPE }}" >> .env + echo "DJANGO_DEBUG=${{ secrets.DJANGO_DEBUG }}" >> .env - # 3. Запускаем сборку + # 4. Запускаем сборку docker compose up -d --build \ No newline at end of file diff --git a/.trae/rules/main.md b/.trae/rules/main.md new file mode 100644 index 0000000..6937318 --- /dev/null +++ b/.trae/rules/main.md @@ -0,0 +1,45 @@ +# AI_RULES +Роль: Senior Django Backend Developer. + +Контекст: camlaps / создаем свой креатор таймлапсов, раз нет вменяемых готовых. + +## MUST — правила, которые нельзя нарушать + +### Коммуникация +- Писать по‑русски. + +### Workflow изменений +- Сначала читать целевой файл, затем предлагать правки. +- Сложная логика живёт в services (service layer), views остаются тонкими. +- Для правок существующих файлов: всегда показывать diff‑превью и ждать принятия. + +### Новые файлы +- Для новых файлов всегда давать: полное имя + абсолютный путь + полный контент одним блоком. + +### Безопасность +- Никогда не логировать/не печатать: SECRET_KEY, пароли БД, токены. +- В logs — только тех. сообщения/ошибки/диагностика без секретов. + +### Логи +- Для внутренних функций/сервисов: logger = logging.getLogger('camlaps'). +- Перед выполнением: logger.info('fn:start ...'). +- После успеха: logger.info('fn:done ...'). +- Ошибки: logger.exception('fn:error ...') и пробрасывать дальше. + +### Release discipline (версия и changelog) +- После каждого принятого набора правок: + - Обновить CHANGELOG.md в секции [Unreleased] (Added/Changed/Fixed). + + +## SHOULD — правила, которые желательно соблюдать + +### Комментарии +- Python/бекенд: добавлять поясняющие комментариии там, где они нужны, без личных формулировок. +- Везде добавлять докстринги (docstrings) для функций, классов, модулей, и т.д. +- Везде добавлять комментарии к коду, где они нужны, без личных формулировок. + +- Django HTML‑шаблоны: не добавлять template‑комментарии ({# ... #}). + +### Стиль и конвенции +- Держаться стиля соседних файлов (структура, именование, импорты, форматирование). +- Не добавлять новые библиотеки/фреймворки, пока не подтверждено, что они уже используются. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e69de29 diff --git a/camlaps/__init__.py b/camlaps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/camlaps/admin.py b/camlaps/admin.py new file mode 100644 index 0000000..b123241 --- /dev/null +++ b/camlaps/admin.py @@ -0,0 +1,28 @@ +from django.contrib import admin + +from .models import Camera, TimelapseJob + + +@admin.register(Camera) +class CameraAdmin(admin.ModelAdmin): + list_display = ('name', 'slug', 'storage_path', 'is_active', 'updated_at') + list_filter = ('is_active',) + search_fields = ('name', 'slug', 'storage_path') + + +@admin.register(TimelapseJob) +class TimelapseJobAdmin(admin.ModelAdmin): + list_display = ( + 'id', + 'camera', + 'date_from', + 'date_to', + 'sampling_interval_minutes', + 'fps', + 'status', + 'progress_percent', + 'created_at', + ) + list_filter = ('status', 'include_night', 'sampling_preset', 'camera') + search_fields = ('camera__name', 'output_rel_path', 'error_message') + readonly_fields = ('progress_percent', 'frames_total', 'frames_processed', 'started_at', 'finished_at') diff --git a/camlaps/apps.py b/camlaps/apps.py new file mode 100644 index 0000000..780a78c --- /dev/null +++ b/camlaps/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class CamlapsConfig(AppConfig): + name = 'camlaps' diff --git a/camlaps/migrations/0001_initial.py b/camlaps/migrations/0001_initial.py new file mode 100644 index 0000000..7deb3ae --- /dev/null +++ b/camlaps/migrations/0001_initial.py @@ -0,0 +1,65 @@ +# Generated by Django 6.0.4 on 2026-04-19 14:42 + +import datetime +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Camera', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=120, verbose_name='Наименование')), + ('slug', models.SlugField(max_length=80, unique=True, verbose_name='Код камеры')), + ('storage_path', models.CharField(help_text='Относительный путь внутри /app/storage, например: Camera3', max_length=255, verbose_name='Путь в storage')), + ('rtsp_url', models.URLField(blank=True, verbose_name='RTSP URL (опционально)')), + ('expected_width', models.PositiveIntegerField(blank=True, null=True, verbose_name='Ожидаемая ширина')), + ('expected_height', models.PositiveIntegerField(blank=True, null=True, verbose_name='Ожидаемая высота')), + ('is_active', models.BooleanField(default=True, verbose_name='Активна')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Камера', + 'verbose_name_plural': 'Камеры', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='TimelapseJob', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date_from', models.DateField(verbose_name='Дата начала выборки')), + ('date_to', models.DateField(verbose_name='Дата окончания выборки')), + ('sampling_preset', models.PositiveSmallIntegerField(choices=[(0, '1 кадр / 15 минут'), (1, '1 кадр / 30 минут'), (2, '1 кадр / 45 минут'), (3, '1 кадр / час'), (4, '1 кадр / 1.5 часа'), (5, '1 кадр / 2 часа'), (6, '1 кадр / 3 часа'), (7, '1 кадр / 4 часа'), (8, '1 кадр / 6 часов'), (9, '1 кадр / 12 часов'), (10, '1 кадр / сутки')], default=3, verbose_name='Частота выборки')), + ('fps', models.PositiveSmallIntegerField(default=25, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(120)], verbose_name='FPS итогового видео')), + ('include_night', models.BooleanField(default=True, verbose_name='Включать ночные кадры')), + ('day_start_time', models.TimeField(default=datetime.time(6, 0), verbose_name='Начало дня')), + ('day_end_time', models.TimeField(default=datetime.time(22, 0), verbose_name='Конец дня')), + ('status', models.CharField(choices=[('planned', 'Запланировано'), ('running', 'В работе'), ('success', 'Успешно'), ('error', 'Ошибка')], db_index=True, default='planned', max_length=16)), + ('progress_percent', models.PositiveSmallIntegerField(default=0, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)], verbose_name='Прогресс, %')), + ('frames_total', models.PositiveIntegerField(blank=True, null=True, verbose_name='Всего кадров')), + ('frames_processed', models.PositiveIntegerField(default=0, verbose_name='Обработано кадров')), + ('output_rel_path', models.CharField(blank=True, max_length=255, verbose_name='Путь к видео в storage')), + ('error_message', models.TextField(blank=True, verbose_name='Текст ошибки')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('started_at', models.DateTimeField(blank=True, null=True)), + ('finished_at', models.DateTimeField(blank=True, null=True)), + ('camera', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='jobs', to='camlaps.camera', verbose_name='Камера')), + ], + options={ + 'verbose_name': 'Задание таймлапса', + 'verbose_name_plural': 'Задания таймлапса', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/camlaps/migrations/__init__.py b/camlaps/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/camlaps/models.py b/camlaps/models.py new file mode 100644 index 0000000..306d1dd --- /dev/null +++ b/camlaps/models.py @@ -0,0 +1,110 @@ +from datetime import time + +from django.core.validators import MaxValueValidator, MinValueValidator +from django.core.exceptions import ValidationError +from django.db import models + + +class Camera(models.Model): + """ + модель камеры хранящая основные настройки камеры, такие как имя, код, путь в storage, RTSP URL, ожидаемая ширина, высота, активность + """ + name = models.CharField(max_length=120, verbose_name='Наименование') + slug = models.SlugField(max_length=80, unique=True, verbose_name='Код камеры') + storage_path = models.CharField( + max_length=255, + verbose_name='Путь в storage', + help_text='Относительный путь внутри /app/storage, например: Camera3', + ) + rtsp_url = models.URLField(blank=True, verbose_name='RTSP URL (опционально)') + expected_width = models.PositiveIntegerField(null=True, blank=True, verbose_name='Ожидаемая ширина') + expected_height = models.PositiveIntegerField(null=True, blank=True, verbose_name='Ожидаемая высота') + is_active = models.BooleanField(default=True, verbose_name='Активна') + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = 'Камера' + verbose_name_plural = 'Камеры' + ordering = ['name'] + + def __str__(self): + return self.name + + +class TimelapseJob(models.Model): + """ + модель задачи создания timelapse, хранящая основные настройки задачи, такие как камера, даты выборки, частота выборки, FPS, включать ночные кадры, время начала дня, время конца дня + """ + class Status(models.TextChoices): + PLANNED = 'planned', 'Запланировано' + RUNNING = 'running', 'В работе' + SUCCESS = 'success', 'Успешно' + ERROR = 'error', 'Ошибка' + + SAMPLING_PRESET_MINUTES = (15, 30, 45, 60, 90, 120, 180, 240, 360, 720, 1440) + + class SamplingPreset(models.IntegerChoices): + EVERY_15_MIN = 0, '1 кадр / 15 минут' + EVERY_30_MIN = 1, '1 кадр / 30 минут' + EVERY_45_MIN = 2, '1 кадр / 45 минут' + EVERY_HOUR = 3, '1 кадр / час' + EVERY_90_MIN = 4, '1 кадр / 1.5 часа' + EVERY_2_HOURS = 5, '1 кадр / 2 часа' + EVERY_3_HOURS = 6, '1 кадр / 3 часа' + EVERY_4_HOURS = 7, '1 кадр / 4 часа' + EVERY_6_HOURS = 8, '1 кадр / 6 часов' + EVERY_12_HOURS = 9, '1 кадр / 12 часов' + EVERY_DAY = 10, '1 кадр / сутки' + + camera = models.ForeignKey(Camera, on_delete=models.PROTECT, related_name='jobs', verbose_name='Камера') + date_from = models.DateField(verbose_name='Дата начала выборки') + date_to = models.DateField(verbose_name='Дата окончания выборки') + sampling_preset = models.PositiveSmallIntegerField( + choices=SamplingPreset.choices, + default=SamplingPreset.EVERY_HOUR, + verbose_name='Частота выборки', + ) + fps = models.PositiveSmallIntegerField( + default=25, + validators=[MinValueValidator(1), MaxValueValidator(120)], + verbose_name='FPS итогового видео', + ) + include_night = models.BooleanField(default=True, verbose_name='Включать ночные кадры') + day_start_time = models.TimeField(default=time(6, 0), verbose_name='Начало дня') + day_end_time = models.TimeField(default=time(22, 0), verbose_name='Конец дня') + + status = models.CharField(max_length=16, choices=Status.choices, default=Status.PLANNED, db_index=True) + progress_percent = models.PositiveSmallIntegerField( + default=0, + validators=[MinValueValidator(0), MaxValueValidator(100)], + verbose_name='Прогресс, %', + ) + frames_total = models.PositiveIntegerField(null=True, blank=True, verbose_name='Всего кадров') + frames_processed = models.PositiveIntegerField(default=0, verbose_name='Обработано кадров') + + output_rel_path = models.CharField(max_length=255, blank=True, verbose_name='Путь к видео в storage') + error_message = models.TextField(blank=True, verbose_name='Текст ошибки') + + created_at = models.DateTimeField(auto_now_add=True) + started_at = models.DateTimeField(null=True, blank=True) + finished_at = models.DateTimeField(null=True, blank=True) + + class Meta: + verbose_name = 'Задание таймлапса' + verbose_name_plural = 'Задания таймлапса' + ordering = ['-created_at'] + + def __str__(self): + return f'{self.camera.name}: {self.date_from} → {self.date_to}' + + @property + def sampling_interval_minutes(self): + return self.SAMPLING_PRESET_MINUTES[int(self.sampling_preset)] + + def clean(self): + if self.date_to < self.date_from: + raise ValidationError({'date_to': 'Дата окончания должна быть не раньше даты начала.'}) + + if not self.include_night and self.day_start_time >= self.day_end_time: + raise ValidationError({'day_end_time': 'Для режима без ночи конец дня должен быть позже начала дня.'}) diff --git a/camlaps/templates/camlaps/index.html b/camlaps/templates/camlaps/index.html new file mode 100644 index 0000000..de5db57 --- /dev/null +++ b/camlaps/templates/camlaps/index.html @@ -0,0 +1,23 @@ +{% extends 'base.html' %} + +{% block content %} +
Путь: /app/storage/Camera1
+