import logging import subprocess import sys from django.conf import settings from django.http import FileResponse, Http404 from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.views.decorators.http import require_POST from .models import Camera, TimelapseJob from .services.cameras import ( create_cameras_from_candidates, discover_camera_candidates, get_camera_lastsnap_path, is_storage_available, list_active_cameras, ) logger = logging.getLogger('camlaps') def index(request): cameras = list_active_cameras() context = { 'cameras': cameras, 'storage_available': is_storage_available(), 'added': request.GET.get('added'), } return render(request, 'camlaps/index.html', context) @require_POST def discover_cameras(request): cameras = list_active_cameras() candidates = discover_camera_candidates() context = { 'cameras': cameras, 'storage_available': is_storage_available(), 'discovered_candidates': candidates, 'discovered_count': len(candidates), } return render(request, 'camlaps/index.html', context) @require_POST def apply_discovered_cameras(request): selected_paths = request.POST.getlist('selected_storage_paths') created = create_cameras_from_candidates(selected_paths) return redirect(f"{reverse('camlaps:index')}?added={created}") def camera_preview(request, camera_id: int): camera = get_object_or_404(Camera, pk=camera_id, is_active=True) path = get_camera_lastsnap_path(camera) if not path: raise Http404 return FileResponse(path.open('rb'), content_type='image/jpeg') def job_list(request): """Отображает таблицу задач и признак активности очереди для автообновления.""" qs = TimelapseJob.objects.select_related('camera').all() camera_id = request.GET.get('camera') if camera_id: qs = qs.filter(camera_id=camera_id) jobs = qs.order_by('-created_at')[:200] has_active_jobs = qs.filter(status__in=[TimelapseJob.Status.PLANNED, TimelapseJob.Status.RUNNING]).exists() return render( request, 'camlaps/job_list.html', { 'jobs': jobs, 'queue_started': request.GET.get('started'), 'retried': request.GET.get('retried'), 'has_active_jobs': has_active_jobs, }, ) def worker_logs(request): """Показывает последние строки лога воркера в интерфейсе.""" logger.info('logs:view:start') log_file = settings.LOG_DIR / 'camlaps.log' lines = [] if log_file.exists(): with log_file.open('r', encoding='utf-8', errors='replace') as f: lines = f.readlines()[-300:] logger.info('logs:view:done lines=%s', len(lines)) return render(request, 'camlaps/worker_logs.html', {'log_lines': ''.join(lines)}) @require_POST def clear_worker_logs(request): logger.info('logs:clear:start') try: log_file = settings.LOG_DIR / 'camlaps.log' log_file.parent.mkdir(parents=True, exist_ok=True) log_file.write_text('', encoding='utf-8') logger.info('logs:clear:done') return redirect(f"{reverse('camlaps:worker_logs')}?cleared=1") except Exception: logger.exception('logs:clear:error') return redirect(f"{reverse('camlaps:worker_logs')}?cleared=0") @require_POST def start_queue(request): """Запускает воркер одним проходом для следующей задачи из очереди.""" logger.info('queue:start_request:start') has_planned = TimelapseJob.objects.filter(status=TimelapseJob.Status.PLANNED).exists() if not has_planned: logger.info('queue:start_request:done no_planned_jobs=true') return redirect(f"{reverse('camlaps:job_list')}?started=none") cmd = [sys.executable, 'manage.py', 'run_timelapse_worker', '--once'] kwargs = { 'cwd': settings.BASE_DIR, 'stdout': subprocess.DEVNULL, 'stderr': subprocess.DEVNULL, } if sys.platform.startswith('win'): kwargs['creationflags'] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP try: subprocess.Popen(cmd, **kwargs) logger.info('queue:start_request:done worker_started=true') return redirect(f"{reverse('camlaps:job_list')}?started=worker") except Exception: logger.exception('queue:start_request:error') return redirect(f"{reverse('camlaps:job_list')}?started=error") @require_POST def retry_job(request, job_id: int): """Сбрасывает задачу в planned для повторного запуска после ошибки.""" logger.info('job:retry:start job_id=%s', job_id) job = get_object_or_404(TimelapseJob, pk=job_id) if job.status == TimelapseJob.Status.RUNNING: logger.info('job:retry:done job_id=%s blocked=running', job_id) return redirect(f"{reverse('camlaps:job_list')}?retried=running") job.status = TimelapseJob.Status.PLANNED job.progress_percent = 0 job.frames_total = None job.frames_processed = 0 job.days_total = 0 job.days_with_frames = 0 job.days_skipped = 0 job.error_message = '' job.started_at = None job.finished_at = None job.save( update_fields=[ 'status', 'progress_percent', 'frames_total', 'frames_processed', 'days_total', 'days_with_frames', 'days_skipped', 'error_message', 'started_at', 'finished_at', ] ) logger.info('job:retry:done job_id=%s', job_id) return redirect(f"{reverse('camlaps:job_list')}?retried={job_id}") def job_detail(request, job_id: int): job = get_object_or_404(TimelapseJob.objects.select_related('camera'), pk=job_id) video_url = None if job.output_rel_path: rel = job.output_rel_path.lstrip('/') if rel.startswith('timelapses/'): video_url = '/' + rel else: video_url = '/timelapses/' + rel.split('/')[-1] return render( request, 'camlaps/job_detail.html', { 'job': job, 'video_url': video_url, 'run_now': request.GET.get('run_now'), }, ) @require_POST def run_job_now(request, job_id: int): """Немедленно запускает конкретную задачу через management command.""" logger.info('job:run_now:start job_id=%s', job_id) job = get_object_or_404(TimelapseJob, pk=job_id) if job.status == TimelapseJob.Status.RUNNING: logger.info('job:run_now:done job_id=%s blocked=running', job_id) return redirect(f"{reverse('camlaps:job_list')}?started=running") cmd = [sys.executable, 'manage.py', 'run_timelapse_worker', '--job-id', str(job_id)] kwargs = { 'cwd': settings.BASE_DIR, 'stdout': subprocess.DEVNULL, 'stderr': subprocess.DEVNULL, } if sys.platform.startswith('win'): kwargs['creationflags'] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP try: subprocess.Popen(cmd, **kwargs) logger.info('job:run_now:done job_id=%s worker_started=true', job_id) return redirect(f"{reverse('camlaps:job_list')}?started={job_id}") except Exception: logger.exception('job:run_now:error job_id=%s', job_id) return redirect(f"{reverse('camlaps:job_list')}?started=error") 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': form = TimelapseJobCreateForm(request.POST) if form.is_valid(): job = form.save(commit=False) job.camera = camera job.save() return redirect(reverse('camlaps:job_detail', kwargs={'job_id': job.id})) else: form = TimelapseJobCreateForm() 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, }, )