1122 lines
48 KiB
Python
1122 lines
48 KiB
Python
"""
|
||
Представления для управления заявками на источники.
|
||
"""
|
||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||
from django.http import JsonResponse
|
||
from django.shortcuts import render
|
||
from django.views import View
|
||
from django.views.generic import ListView, CreateView, UpdateView
|
||
from django.urls import reverse_lazy
|
||
from django.db.models import Q
|
||
from django.utils import timezone
|
||
|
||
from mainapp.models import SourceRequest, SourceRequestStatusHistory, Source, Satellite
|
||
from mainapp.forms import SourceRequestForm
|
||
|
||
import re
|
||
import pandas as pd
|
||
from datetime import datetime
|
||
from django.contrib.gis.geos import Point
|
||
|
||
|
||
class SourceRequestListView(LoginRequiredMixin, ListView):
|
||
"""Список заявок на источники."""
|
||
model = SourceRequest
|
||
template_name = 'mainapp/source_request_list.html'
|
||
context_object_name = 'requests'
|
||
paginate_by = 50
|
||
|
||
def get_queryset(self):
|
||
queryset = SourceRequest.objects.select_related(
|
||
'source', 'source__info', 'source__ownership',
|
||
'satellite',
|
||
'created_by__user', 'updated_by__user'
|
||
).order_by('-created_at')
|
||
|
||
# Фильтр по статусу
|
||
status = self.request.GET.get('status')
|
||
if status:
|
||
queryset = queryset.filter(status=status)
|
||
|
||
# Фильтр по приоритету
|
||
priority = self.request.GET.get('priority')
|
||
if priority:
|
||
queryset = queryset.filter(priority=priority)
|
||
|
||
# Фильтр по источнику
|
||
source_id = self.request.GET.get('source_id')
|
||
if source_id:
|
||
queryset = queryset.filter(source_id=source_id)
|
||
|
||
# Фильтр по ГСО успешно
|
||
gso_success = self.request.GET.get('gso_success')
|
||
if gso_success == 'true':
|
||
queryset = queryset.filter(gso_success=True)
|
||
elif gso_success == 'false':
|
||
queryset = queryset.filter(gso_success=False)
|
||
|
||
# Фильтр по Кубсат успешно
|
||
kubsat_success = self.request.GET.get('kubsat_success')
|
||
if kubsat_success == 'true':
|
||
queryset = queryset.filter(kubsat_success=True)
|
||
elif kubsat_success == 'false':
|
||
queryset = queryset.filter(kubsat_success=False)
|
||
|
||
# Поиск
|
||
search = self.request.GET.get('search')
|
||
if search:
|
||
queryset = queryset.filter(
|
||
Q(source__id__icontains=search) |
|
||
Q(comment__icontains=search)
|
||
)
|
||
|
||
return queryset
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
context['status_choices'] = SourceRequest.STATUS_CHOICES
|
||
context['priority_choices'] = SourceRequest.PRIORITY_CHOICES
|
||
context['current_status'] = self.request.GET.get('status', '')
|
||
context['current_priority'] = self.request.GET.get('priority', '')
|
||
context['search_query'] = self.request.GET.get('search', '')
|
||
context['form'] = SourceRequestForm()
|
||
return context
|
||
|
||
|
||
class SourceRequestCreateView(LoginRequiredMixin, CreateView):
|
||
"""Создание заявки на источник."""
|
||
model = SourceRequest
|
||
form_class = SourceRequestForm
|
||
template_name = 'mainapp/source_request_form.html'
|
||
success_url = reverse_lazy('mainapp:source_request_list')
|
||
|
||
def get_form_kwargs(self):
|
||
kwargs = super().get_form_kwargs()
|
||
# Передаем source_id если он есть в GET параметрах
|
||
source_id = self.request.GET.get('source_id')
|
||
if source_id:
|
||
kwargs['source_id'] = source_id
|
||
return kwargs
|
||
|
||
def form_valid(self, form):
|
||
# Устанавливаем created_by
|
||
form.instance.created_by = getattr(self.request.user, 'customuser', None)
|
||
form.instance.updated_by = getattr(self.request.user, 'customuser', None)
|
||
|
||
response = super().form_valid(form)
|
||
|
||
# Создаем начальную запись в истории
|
||
SourceRequestStatusHistory.objects.create(
|
||
source_request=self.object,
|
||
old_status='',
|
||
new_status=self.object.status,
|
||
changed_by=form.instance.created_by,
|
||
)
|
||
|
||
# Если это AJAX запрос, возвращаем JSON
|
||
if self.request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||
return JsonResponse({
|
||
'success': True,
|
||
'message': 'Заявка успешно создана',
|
||
'request_id': self.object.id
|
||
})
|
||
|
||
return response
|
||
|
||
def form_invalid(self, form):
|
||
if self.request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||
return JsonResponse({
|
||
'success': False,
|
||
'errors': form.errors
|
||
}, status=400)
|
||
return super().form_invalid(form)
|
||
|
||
|
||
class SourceRequestUpdateView(LoginRequiredMixin, UpdateView):
|
||
"""Редактирование заявки на источник."""
|
||
model = SourceRequest
|
||
form_class = SourceRequestForm
|
||
template_name = 'mainapp/source_request_form.html'
|
||
success_url = reverse_lazy('mainapp:source_request_list')
|
||
|
||
def form_valid(self, form):
|
||
# Устанавливаем updated_by
|
||
form.instance.updated_by = getattr(self.request.user, 'customuser', None)
|
||
|
||
response = super().form_valid(form)
|
||
|
||
# Если это AJAX запрос, возвращаем JSON
|
||
if self.request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||
return JsonResponse({
|
||
'success': True,
|
||
'message': 'Заявка успешно обновлена',
|
||
'request_id': self.object.id
|
||
})
|
||
|
||
return response
|
||
|
||
def form_invalid(self, form):
|
||
if self.request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||
return JsonResponse({
|
||
'success': False,
|
||
'errors': form.errors
|
||
}, status=400)
|
||
return super().form_invalid(form)
|
||
|
||
|
||
class SourceRequestDeleteView(LoginRequiredMixin, View):
|
||
"""Удаление заявки на источник."""
|
||
|
||
def post(self, request, pk):
|
||
try:
|
||
source_request = SourceRequest.objects.get(pk=pk)
|
||
source_request.delete()
|
||
return JsonResponse({
|
||
'success': True,
|
||
'message': 'Заявка успешно удалена'
|
||
})
|
||
except SourceRequest.DoesNotExist:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'Заявка не найдена'
|
||
}, status=404)
|
||
|
||
|
||
class SourceRequestBulkDeleteView(LoginRequiredMixin, View):
|
||
"""Массовое удаление заявок."""
|
||
|
||
def post(self, request):
|
||
import json
|
||
try:
|
||
data = json.loads(request.body)
|
||
ids = data.get('ids', [])
|
||
|
||
if not ids:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'Не выбраны заявки для удаления'
|
||
}, status=400)
|
||
|
||
deleted_count, _ = SourceRequest.objects.filter(pk__in=ids).delete()
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'message': 'Заявки удалены',
|
||
'deleted_count': deleted_count
|
||
})
|
||
except json.JSONDecodeError:
|
||
return JsonResponse({
|
||
'success': False,
|
||
'error': 'Неверный формат данных'
|
||
}, status=400)
|
||
|
||
|
||
class SourceRequestExportView(LoginRequiredMixin, View):
|
||
"""Экспорт заявок в Excel."""
|
||
|
||
def get(self, request):
|
||
from django.http import HttpResponse
|
||
from openpyxl import Workbook
|
||
from openpyxl.styles import Font, Alignment, PatternFill
|
||
from io import BytesIO
|
||
|
||
# Получаем заявки с фильтрами
|
||
queryset = SourceRequest.objects.select_related(
|
||
'satellite'
|
||
).order_by('-created_at')
|
||
|
||
# Применяем фильтры
|
||
status = request.GET.get('status')
|
||
if status:
|
||
queryset = queryset.filter(status=status)
|
||
|
||
priority = request.GET.get('priority')
|
||
if priority:
|
||
queryset = queryset.filter(priority=priority)
|
||
|
||
gso_success = request.GET.get('gso_success')
|
||
if gso_success == 'true':
|
||
queryset = queryset.filter(gso_success=True)
|
||
elif gso_success == 'false':
|
||
queryset = queryset.filter(gso_success=False)
|
||
|
||
kubsat_success = request.GET.get('kubsat_success')
|
||
if kubsat_success == 'true':
|
||
queryset = queryset.filter(kubsat_success=True)
|
||
elif kubsat_success == 'false':
|
||
queryset = queryset.filter(kubsat_success=False)
|
||
|
||
# Создаём Excel файл
|
||
wb = Workbook()
|
||
ws = wb.active
|
||
ws.title = "Заявки"
|
||
|
||
# Заголовки (как в импорте, но без источника + приоритет + статус + комментарий)
|
||
headers = [
|
||
'Дата постановки задачи',
|
||
'Дата формирования карточки',
|
||
'Дата проведения',
|
||
'Спутник',
|
||
'Частота Downlink',
|
||
'Частота Uplink',
|
||
'Перенос',
|
||
'Координаты ГСО',
|
||
'Район',
|
||
'Приоритет',
|
||
'Статус',
|
||
'Результат ГСО',
|
||
'Результат кубсата',
|
||
'Координаты источника',
|
||
'Координаты объекта',
|
||
'Комментарий',
|
||
]
|
||
|
||
# Стили
|
||
header_font = Font(bold=True)
|
||
header_fill = PatternFill(start_color="DDDDDD", end_color="DDDDDD", fill_type="solid")
|
||
green_fill = PatternFill(start_color="90EE90", end_color="90EE90", fill_type="solid")
|
||
red_fill = PatternFill(start_color="FF6B6B", end_color="FF6B6B", fill_type="solid")
|
||
gray_fill = PatternFill(start_color="D3D3D3", end_color="D3D3D3", fill_type="solid")
|
||
|
||
# Записываем заголовки
|
||
for col_num, header in enumerate(headers, 1):
|
||
cell = ws.cell(row=1, column=col_num, value=header)
|
||
cell.font = header_font
|
||
cell.fill = header_fill
|
||
cell.alignment = Alignment(horizontal='center', vertical='center')
|
||
|
||
# Записываем данные
|
||
for row_num, req in enumerate(queryset, 2):
|
||
# Дата постановки задачи
|
||
ws.cell(row=row_num, column=1, value=req.request_date.strftime('%d.%m.%Y') if req.request_date else '')
|
||
|
||
# Дата формирования карточки
|
||
ws.cell(row=row_num, column=2, value=req.card_date.strftime('%d.%m.%Y') if req.card_date else '')
|
||
|
||
# Дата проведения
|
||
planned_at_local = timezone.localtime(req.planned_at) if req.planned_at else None
|
||
planned_at_str = ''
|
||
if planned_at_local:
|
||
if planned_at_local.hour == 0 and planned_at_local.minute == 0:
|
||
planned_at_str = planned_at_local.strftime('%d.%m.%y')
|
||
else:
|
||
planned_at_str = planned_at_local.strftime('%d.%m.%y %H:%M')
|
||
ws.cell(row=row_num, column=3, value=planned_at_str)
|
||
|
||
# Спутник
|
||
satellite_str = ''
|
||
if req.satellite:
|
||
satellite_str = req.satellite.name
|
||
if req.satellite.norad:
|
||
satellite_str += f' ({req.satellite.norad})'
|
||
ws.cell(row=row_num, column=4, value=satellite_str)
|
||
|
||
# Частота Downlink
|
||
ws.cell(row=row_num, column=5, value=req.downlink if req.downlink else '')
|
||
|
||
# Частота Uplink
|
||
ws.cell(row=row_num, column=6, value=req.uplink if req.uplink else '')
|
||
|
||
# Перенос
|
||
ws.cell(row=row_num, column=7, value=req.transfer if req.transfer else '')
|
||
|
||
# Координаты ГСО
|
||
coords_gso = ''
|
||
if req.coords:
|
||
coords_gso = f'{req.coords.y:.6f} {req.coords.x:.6f}'
|
||
ws.cell(row=row_num, column=8, value=coords_gso)
|
||
|
||
# Район
|
||
ws.cell(row=row_num, column=9, value=req.region or '')
|
||
|
||
# Приоритет
|
||
ws.cell(row=row_num, column=10, value=req.get_priority_display())
|
||
|
||
# Статус (с цветом)
|
||
status_cell = ws.cell(row=row_num, column=11, value=req.get_status_display())
|
||
if req.status in ['successful', 'result_received']:
|
||
status_cell.fill = green_fill
|
||
elif req.status == 'unsuccessful':
|
||
status_cell.fill = red_fill
|
||
else:
|
||
status_cell.fill = gray_fill
|
||
|
||
# Результат ГСО (с цветом)
|
||
gso_cell = ws.cell(row=row_num, column=12)
|
||
if req.gso_success is True:
|
||
gso_cell.value = 'Да'
|
||
gso_cell.fill = green_fill
|
||
elif req.gso_success is False:
|
||
gso_cell.value = 'Нет'
|
||
gso_cell.fill = red_fill
|
||
else:
|
||
gso_cell.value = ''
|
||
|
||
# Результат кубсата (с цветом)
|
||
kubsat_cell = ws.cell(row=row_num, column=13)
|
||
if req.kubsat_success is True:
|
||
kubsat_cell.value = 'Да'
|
||
kubsat_cell.fill = green_fill
|
||
elif req.kubsat_success is False:
|
||
kubsat_cell.value = 'Нет'
|
||
kubsat_cell.fill = red_fill
|
||
else:
|
||
kubsat_cell.value = ''
|
||
|
||
# Координаты источника
|
||
coords_source = ''
|
||
if req.coords_source:
|
||
coords_source = f'{req.coords_source.y:.6f} {req.coords_source.x:.6f}'
|
||
ws.cell(row=row_num, column=14, value=coords_source)
|
||
|
||
# Координаты объекта
|
||
coords_object = ''
|
||
if req.coords_object:
|
||
coords_object = f'{req.coords_object.y:.6f} {req.coords_object.x:.6f}'
|
||
ws.cell(row=row_num, column=15, value=coords_object)
|
||
|
||
# Комментарий
|
||
ws.cell(row=row_num, column=16, value=req.comment or '')
|
||
|
||
# Автоширина колонок
|
||
for column in ws.columns:
|
||
max_length = 0
|
||
column_letter = column[0].column_letter
|
||
for cell in column:
|
||
try:
|
||
if len(str(cell.value)) > max_length:
|
||
max_length = len(str(cell.value))
|
||
except:
|
||
pass
|
||
adjusted_width = min(max_length + 2, 40)
|
||
ws.column_dimensions[column_letter].width = adjusted_width
|
||
|
||
# Сохраняем в BytesIO
|
||
output = BytesIO()
|
||
wb.save(output)
|
||
output.seek(0)
|
||
|
||
# Возвращаем файл
|
||
response = HttpResponse(
|
||
output.read(),
|
||
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||
)
|
||
filename = f'source_requests_{datetime.now().strftime("%Y%m%d_%H%M")}.xlsx'
|
||
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||
|
||
return response
|
||
|
||
|
||
class SourceRequestAPIView(LoginRequiredMixin, View):
|
||
"""API для получения данных о заявках источника."""
|
||
|
||
def get(self, request, source_id):
|
||
try:
|
||
source = Source.objects.get(pk=source_id)
|
||
except Source.DoesNotExist:
|
||
return JsonResponse({'error': 'Источник не найден'}, status=404)
|
||
|
||
requests = SourceRequest.objects.filter(source=source).select_related(
|
||
'created_by__user', 'updated_by__user'
|
||
).prefetch_related('status_history__changed_by__user').order_by('-created_at')
|
||
|
||
data = []
|
||
for req in requests:
|
||
# Получаем историю статусов
|
||
history = []
|
||
for h in req.status_history.all().order_by('-changed_at'):
|
||
history.append({
|
||
'old_status': h.get_old_status_display() if h.old_status else '-',
|
||
'new_status': h.get_new_status_display(),
|
||
'changed_at': timezone.localtime(h.changed_at).strftime('%d.%m.%Y %H:%M') if h.changed_at else '-',
|
||
'changed_by': str(h.changed_by) if h.changed_by else '-',
|
||
})
|
||
|
||
data.append({
|
||
'id': req.id,
|
||
'status': req.status,
|
||
'status_display': req.get_status_display(),
|
||
'priority': req.priority,
|
||
'priority_display': req.get_priority_display(),
|
||
'planned_at': (
|
||
timezone.localtime(req.planned_at).strftime('%d.%m.%Y')
|
||
if req.planned_at and timezone.localtime(req.planned_at).hour == 0 and timezone.localtime(req.planned_at).minute == 0
|
||
else timezone.localtime(req.planned_at).strftime('%d.%m.%Y %H:%M') if req.planned_at
|
||
else '-'
|
||
),
|
||
'request_date': req.request_date.strftime('%d.%m.%Y') if req.request_date else '-',
|
||
'status_updated_at': timezone.localtime(req.status_updated_at).strftime('%d.%m.%Y %H:%M') if req.status_updated_at else '-',
|
||
'gso_success': req.gso_success,
|
||
'kubsat_success': req.kubsat_success,
|
||
'comment': req.comment or '-',
|
||
'created_at': timezone.localtime(req.created_at).strftime('%d.%m.%Y %H:%M') if req.created_at else '-',
|
||
'created_by': str(req.created_by) if req.created_by else '-',
|
||
'history': history,
|
||
})
|
||
|
||
return JsonResponse({
|
||
'source_id': source_id,
|
||
'requests': data,
|
||
'count': len(data)
|
||
})
|
||
|
||
|
||
class SourceRequestDetailAPIView(LoginRequiredMixin, View):
|
||
"""API для получения детальной информации о заявке."""
|
||
|
||
def get(self, request, pk):
|
||
try:
|
||
req = SourceRequest.objects.select_related(
|
||
'source', 'source__info', 'source__ownership',
|
||
'satellite',
|
||
'created_by__user', 'updated_by__user'
|
||
).prefetch_related(
|
||
'status_history__changed_by__user',
|
||
'source__source_objitems__parameter_obj__modulation',
|
||
'source__source_objitems__geo_obj'
|
||
).get(pk=pk)
|
||
except SourceRequest.DoesNotExist:
|
||
return JsonResponse({'error': 'Заявка не найдена'}, status=404)
|
||
|
||
# Получаем историю статусов
|
||
history = []
|
||
for h in req.status_history.all().order_by('-changed_at'):
|
||
history.append({
|
||
'old_status': h.get_old_status_display() if h.old_status else '-',
|
||
'new_status': h.get_new_status_display(),
|
||
'changed_at': timezone.localtime(h.changed_at).strftime('%d.%m.%Y %H:%M') if h.changed_at else '-',
|
||
'changed_by': str(h.changed_by) if h.changed_by else '-',
|
||
})
|
||
|
||
# Получаем данные из первой точки источника (имя, модуляция, символьная скорость)
|
||
source_data = _get_source_extra_data(req.source) if req.source else {
|
||
'objitem_name': '-', 'modulation': '-', 'symbol_rate': '-'
|
||
}
|
||
|
||
# Координаты ГСО из заявки или из источника
|
||
coords_lat = None
|
||
coords_lon = None
|
||
if req.coords:
|
||
coords_lat = req.coords.y
|
||
coords_lon = req.coords.x
|
||
elif req.source and req.source.coords_average:
|
||
coords_lat = req.source.coords_average.y
|
||
coords_lon = req.source.coords_average.x
|
||
|
||
# Координаты источника
|
||
coords_source_lat = None
|
||
coords_source_lon = None
|
||
if req.coords_source:
|
||
coords_source_lat = req.coords_source.y
|
||
coords_source_lon = req.coords_source.x
|
||
|
||
# Координаты объекта
|
||
coords_object_lat = None
|
||
coords_object_lon = None
|
||
if req.coords_object:
|
||
coords_object_lat = req.coords_object.y
|
||
coords_object_lon = req.coords_object.x
|
||
|
||
data = {
|
||
'id': req.id,
|
||
'source_id': req.source_id,
|
||
'satellite_id': req.satellite_id,
|
||
'satellite_name': req.satellite.name if req.satellite else '-',
|
||
'status': req.status,
|
||
'status_display': req.get_status_display(),
|
||
'priority': req.priority,
|
||
'priority_display': req.get_priority_display(),
|
||
'planned_at': timezone.localtime(req.planned_at).strftime('%Y-%m-%dT%H:%M') if req.planned_at else '',
|
||
'planned_at_display': (
|
||
timezone.localtime(req.planned_at).strftime('%d.%m.%Y')
|
||
if req.planned_at and timezone.localtime(req.planned_at).hour == 0 and timezone.localtime(req.planned_at).minute == 0
|
||
else timezone.localtime(req.planned_at).strftime('%d.%m.%Y %H:%M') if req.planned_at
|
||
else '-'
|
||
),
|
||
'request_date': req.request_date.isoformat() if req.request_date else None,
|
||
'request_date_display': req.request_date.strftime('%d.%m.%Y') if req.request_date else '-',
|
||
'card_date': req.card_date.isoformat() if req.card_date else None,
|
||
'card_date_display': req.card_date.strftime('%d.%m.%Y') if req.card_date else '-',
|
||
'status_updated_at': timezone.localtime(req.status_updated_at).strftime('%d.%m.%Y %H:%M') if req.status_updated_at else '-',
|
||
'downlink': req.downlink,
|
||
'uplink': req.uplink,
|
||
'transfer': req.transfer,
|
||
'region': req.region or '',
|
||
'gso_success': req.gso_success,
|
||
'kubsat_success': req.kubsat_success,
|
||
'comment': req.comment or '',
|
||
'created_at': timezone.localtime(req.created_at).strftime('%d.%m.%Y %H:%M') if req.created_at else '-',
|
||
'created_by': str(req.created_by) if req.created_by else '-',
|
||
'history': history,
|
||
# Координаты ГСО
|
||
'coords_lat': coords_lat,
|
||
'coords_lon': coords_lon,
|
||
# Координаты источника
|
||
'coords_source_lat': coords_source_lat,
|
||
'coords_source_lon': coords_source_lon,
|
||
# Координаты объекта
|
||
'coords_object_lat': coords_object_lat,
|
||
'coords_object_lon': coords_object_lon,
|
||
'points_count': req.points_count,
|
||
'objitem_name': source_data['objitem_name'],
|
||
'modulation': source_data['modulation'],
|
||
'symbol_rate': source_data['symbol_rate'],
|
||
}
|
||
|
||
return JsonResponse(data)
|
||
|
||
|
||
def _get_source_extra_data(source):
|
||
"""Получает дополнительные данные из первой точки источника."""
|
||
objitem_name = '-'
|
||
modulation = '-'
|
||
symbol_rate = '-'
|
||
|
||
if source:
|
||
# Получаем первую точку источника (сортируем по дате ГЛ)
|
||
objitems = source.source_objitems.select_related(
|
||
'parameter_obj__modulation', 'geo_obj'
|
||
).order_by('geo_obj__timestamp')
|
||
|
||
first_objitem = objitems.first()
|
||
if first_objitem:
|
||
objitem_name = first_objitem.name or '-'
|
||
if first_objitem.parameter_obj:
|
||
if first_objitem.parameter_obj.modulation:
|
||
modulation = first_objitem.parameter_obj.modulation.name
|
||
if first_objitem.parameter_obj.bod_velocity and first_objitem.parameter_obj.bod_velocity > 0:
|
||
symbol_rate = str(int(first_objitem.parameter_obj.bod_velocity))
|
||
|
||
return {
|
||
'objitem_name': objitem_name,
|
||
'modulation': modulation,
|
||
'symbol_rate': symbol_rate,
|
||
}
|
||
|
||
|
||
class SourceDataAPIView(LoginRequiredMixin, View):
|
||
"""API для получения данных источника (координаты, имя точки, модуляция, символьная скорость, транспондер)."""
|
||
|
||
def get(self, request, source_id):
|
||
from mainapp.utils import calculate_mean_coords
|
||
|
||
try:
|
||
source = Source.objects.select_related('info', 'ownership').prefetch_related(
|
||
'source_objitems__parameter_obj__modulation',
|
||
'source_objitems__parameter_obj__id_satellite',
|
||
'source_objitems__transponder__sat_id',
|
||
'source_objitems__geo_obj'
|
||
).get(pk=source_id)
|
||
except Source.DoesNotExist:
|
||
return JsonResponse({'error': 'Источник не найден', 'found': False}, status=404)
|
||
|
||
# Получаем данные из точек источника
|
||
source_data = _get_source_extra_data(source)
|
||
|
||
# Рассчитываем усреднённые координаты из всех точек (сортируем по дате ГЛ)
|
||
objitems = source.source_objitems.select_related('geo_obj', 'transponder', 'transponder__sat_id').order_by('geo_obj__timestamp')
|
||
|
||
avg_coords = None
|
||
points_count = 0
|
||
|
||
# Данные для заявки
|
||
downlink = None # Частота из первой точки источника
|
||
uplink = None # Частота + перенос
|
||
transfer = None # Перенос из транспондера
|
||
satellite_id = None
|
||
satellite_name = None
|
||
|
||
for objitem in objitems:
|
||
if hasattr(objitem, 'geo_obj') and objitem.geo_obj and objitem.geo_obj.coords:
|
||
coord = (float(objitem.geo_obj.coords.x), float(objitem.geo_obj.coords.y))
|
||
points_count += 1
|
||
if avg_coords is None:
|
||
avg_coords = coord
|
||
else:
|
||
avg_coords, _ = calculate_mean_coords(avg_coords, coord)
|
||
|
||
# Берём частоту из первой точки источника (parameter_obj.frequency)
|
||
if downlink is None and objitem.parameter_obj and objitem.parameter_obj.frequency:
|
||
downlink = objitem.parameter_obj.frequency
|
||
|
||
# Берём перенос из первого транспондера
|
||
if transfer is None and objitem.transponder and objitem.transponder.transfer:
|
||
transfer = objitem.transponder.transfer
|
||
|
||
# Берём спутник из транспондера или параметров
|
||
if satellite_id is None:
|
||
if objitem.transponder and objitem.transponder.sat_id:
|
||
satellite_id = objitem.transponder.sat_id.pk
|
||
satellite_name = objitem.transponder.sat_id.name
|
||
elif objitem.parameter_obj and objitem.parameter_obj.id_satellite:
|
||
satellite_id = objitem.parameter_obj.id_satellite.pk
|
||
satellite_name = objitem.parameter_obj.id_satellite.name
|
||
|
||
# Вычисляем uplink = downlink + transfer
|
||
if downlink is not None and transfer is not None:
|
||
uplink = downlink + transfer
|
||
|
||
# Если нет координат из точек, берём из источника
|
||
coords_lat = None
|
||
coords_lon = None
|
||
if avg_coords:
|
||
coords_lon = avg_coords[0]
|
||
coords_lat = avg_coords[1]
|
||
elif source.coords_average:
|
||
coords_lat = source.coords_average.y
|
||
coords_lon = source.coords_average.x
|
||
|
||
data = {
|
||
'found': True,
|
||
'source_id': source_id,
|
||
'coords_lat': coords_lat,
|
||
'coords_lon': coords_lon,
|
||
'points_count': points_count,
|
||
'objitem_name': source_data['objitem_name'],
|
||
'modulation': source_data['modulation'],
|
||
'symbol_rate': source_data['symbol_rate'],
|
||
'info': source.info.name if source.info else '-',
|
||
'ownership': source.ownership.name if source.ownership else '-',
|
||
# Данные из транспондера
|
||
'downlink': downlink,
|
||
'uplink': uplink,
|
||
'transfer': transfer,
|
||
'satellite_id': satellite_id,
|
||
'satellite_name': satellite_name,
|
||
}
|
||
|
||
return JsonResponse(data)
|
||
|
||
|
||
class SourceRequestImportView(LoginRequiredMixin, View):
|
||
"""Импорт заявок из Excel файла."""
|
||
|
||
def get(self, request):
|
||
"""Отображает форму загрузки файла."""
|
||
return render(request, 'mainapp/source_request_import.html')
|
||
|
||
def post(self, request):
|
||
"""Обрабатывает загруженный Excel файл."""
|
||
from openpyxl import load_workbook
|
||
from openpyxl.styles import PatternFill
|
||
|
||
if 'file' not in request.FILES:
|
||
return JsonResponse({'success': False, 'error': 'Файл не загружен'}, status=400)
|
||
|
||
file = request.FILES['file']
|
||
|
||
try:
|
||
# Читаем Excel файл с openpyxl для доступа к цветам
|
||
wb = load_workbook(file, data_only=True)
|
||
ws = wb.worksheets[0]
|
||
|
||
# Получаем заголовки (очищаем от пробелов)
|
||
headers = []
|
||
for cell in ws[1]:
|
||
val = cell.value
|
||
if val:
|
||
headers.append(str(val).strip())
|
||
else:
|
||
headers.append(None)
|
||
|
||
# Находим индекс столбца "Результат кубсата"
|
||
kubsat_col_idx = None
|
||
for i, h in enumerate(headers):
|
||
if h and 'кубсат' in h.lower():
|
||
kubsat_col_idx = i
|
||
break
|
||
|
||
results = {
|
||
'created': 0,
|
||
'errors': [],
|
||
'skipped': 0,
|
||
'headers': [h for h in headers if h], # Для отладки
|
||
}
|
||
|
||
custom_user = getattr(request.user, 'customuser', None)
|
||
|
||
# Обрабатываем строки начиная со второй (первая - заголовки)
|
||
for row_idx, row in enumerate(ws.iter_rows(min_row=2, values_only=False), start=2):
|
||
try:
|
||
# Получаем значения
|
||
row_values = {headers[i]: cell.value for i, cell in enumerate(row) if i < len(headers)}
|
||
|
||
# Получаем цвет ячейки "Результат кубсата"
|
||
kubsat_is_red = False
|
||
kubsat_value = None
|
||
if kubsat_col_idx is not None and kubsat_col_idx < len(row):
|
||
kubsat_cell = row[kubsat_col_idx]
|
||
kubsat_value = kubsat_cell.value
|
||
# Проверяем цвет заливки
|
||
if kubsat_cell.fill and kubsat_cell.fill.fgColor:
|
||
color = kubsat_cell.fill.fgColor
|
||
if color.type == 'rgb' and color.rgb:
|
||
# Красный цвет: FF0000 или близкие оттенки
|
||
rgb = color.rgb
|
||
if isinstance(rgb, str) and len(rgb) >= 6:
|
||
# Убираем альфа-канал если есть
|
||
rgb = rgb[-6:]
|
||
r = int(rgb[0:2], 16)
|
||
g = int(rgb[2:4], 16)
|
||
b = int(rgb[4:6], 16)
|
||
# Считаем красным если R > 200 и G < 100 и B < 100
|
||
if r > 180 and g < 120 and b < 120:
|
||
kubsat_is_red = True
|
||
|
||
self._process_row(row_values, row_idx, results, custom_user, kubsat_is_red, kubsat_value)
|
||
except Exception as e:
|
||
results['errors'].append(f"Строка {row_idx}: {str(e)}")
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'created': results['created'],
|
||
'skipped': results['skipped'],
|
||
'skipped_rows': results.get('skipped_rows', [])[:20],
|
||
'errors': results['errors'][:20],
|
||
'total_errors': len(results['errors']),
|
||
'headers': results.get('headers', [])[:15], # Для отладки
|
||
})
|
||
|
||
except Exception as e:
|
||
return JsonResponse({'success': False, 'error': f'Ошибка чтения файла: {str(e)}'}, status=400)
|
||
|
||
def _process_row(self, row, row_idx, results, custom_user, kubsat_is_red=False, kubsat_value=None):
|
||
"""Обрабатывает одну строку из Excel."""
|
||
# Пропускаем полностью пустые строки (все значения None или пустые)
|
||
has_any_data = any(v for v in row.values() if v is not None and str(v).strip())
|
||
if not has_any_data:
|
||
results['skipped'] += 1
|
||
return
|
||
|
||
# Парсим дату заявки (Дата постановки задачи)
|
||
request_date = self._parse_date(row.get('Дата постановки задачи'))
|
||
|
||
# Парсим дату формирования карточки
|
||
card_date = self._parse_date(row.get('Дата формирования карточки'))
|
||
|
||
# Парсим дату и время планирования (Дата проведения)
|
||
planned_at = self._parse_datetime(row.get('Дата проведения'))
|
||
|
||
# Ищем спутник по NORAD
|
||
satellite = self._find_satellite(row.get('Спутник'))
|
||
|
||
# Парсим частоты
|
||
downlink = self._parse_float(row.get('Частота Downlink'))
|
||
uplink = self._parse_float(row.get('Частота Uplink'))
|
||
transfer = self._parse_float(row.get('Перенос'))
|
||
|
||
# Парсим координаты ГСО
|
||
coords = self._parse_coords(row.get('Координаты ГСО'))
|
||
|
||
# Район
|
||
region = str(row.get('Район', '')).strip() if row.get('Район') else None
|
||
|
||
# Результат ГСО
|
||
gso_result = row.get('Результат ГСО')
|
||
gso_success = None
|
||
comment_parts = []
|
||
|
||
if gso_result:
|
||
gso_str = str(gso_result).strip().lower()
|
||
if gso_str in ('успешно', 'да', 'true', '1'):
|
||
gso_success = True
|
||
else:
|
||
gso_success = False
|
||
comment_parts.append(f"Результат ГСО: {str(gso_result).strip()}")
|
||
|
||
# Результат кубсата - по цвету ячейки
|
||
kubsat_success = None
|
||
if kubsat_is_red:
|
||
kubsat_success = False
|
||
elif kubsat_value:
|
||
kubsat_success = True
|
||
|
||
# Добавляем значение кубсата в комментарий
|
||
if kubsat_value:
|
||
comment_parts.append(f"Результат кубсата: {str(kubsat_value).strip()}")
|
||
|
||
# Координаты источника
|
||
coords_source = self._parse_coords(row.get('Координаты источника'))
|
||
|
||
# Координаты объекта
|
||
coords_object = self._parse_coords(row.get('Координаты объекта'))
|
||
|
||
# Проверяем дубликат по совокупности полей: спутник, downlink, uplink, перенос, координаты ГСО, дата проведения
|
||
if self._is_duplicate(satellite, downlink, uplink, transfer, coords, planned_at):
|
||
results['skipped'] += 1
|
||
# Добавляем информацию о пропущенной строке
|
||
sat_name = satellite.name if satellite else '-'
|
||
planned_str = planned_at.strftime('%d.%m.%y %H:%M') if planned_at else '-'
|
||
if 'skipped_rows' not in results:
|
||
results['skipped_rows'] = []
|
||
results['skipped_rows'].append(f"Строка {row_idx}: дубликат (спутник: {sat_name}, дата: {planned_str})")
|
||
return
|
||
|
||
# Определяем статус по логике:
|
||
# - если есть координата источника -> result_received
|
||
# - если нет координаты источника, но ГСО успешно -> successful
|
||
# - если нет координаты источника и ГСО не успешно -> unsuccessful
|
||
status = 'planned'
|
||
if coords_source:
|
||
status = 'result_received'
|
||
elif gso_success is True:
|
||
status = 'successful'
|
||
elif gso_success is False:
|
||
status = 'unsuccessful'
|
||
|
||
# Собираем комментарий
|
||
comment = '; '.join(comment_parts) if comment_parts else None
|
||
|
||
# Создаём заявку
|
||
source_request = SourceRequest(
|
||
source=None,
|
||
satellite=satellite,
|
||
status=status,
|
||
priority='medium',
|
||
request_date=request_date,
|
||
card_date=card_date,
|
||
planned_at=planned_at,
|
||
downlink=downlink,
|
||
uplink=uplink,
|
||
transfer=transfer,
|
||
region=region,
|
||
gso_success=gso_success,
|
||
kubsat_success=kubsat_success,
|
||
comment=comment,
|
||
created_by=custom_user,
|
||
updated_by=custom_user,
|
||
)
|
||
|
||
# Устанавливаем координаты
|
||
if coords:
|
||
source_request.coords = Point(coords[1], coords[0], srid=4326)
|
||
|
||
if coords_source:
|
||
source_request.coords_source = Point(coords_source[1], coords_source[0], srid=4326)
|
||
|
||
if coords_object:
|
||
source_request.coords_object = Point(coords_object[1], coords_object[0], srid=4326)
|
||
|
||
source_request.save()
|
||
|
||
# Создаём начальную запись в истории
|
||
SourceRequestStatusHistory.objects.create(
|
||
source_request=source_request,
|
||
old_status='',
|
||
new_status=source_request.status,
|
||
changed_by=custom_user,
|
||
)
|
||
|
||
results['created'] += 1
|
||
|
||
def _parse_date(self, value):
|
||
"""Парсит дату из различных форматов."""
|
||
if pd.isna(value):
|
||
return None
|
||
|
||
if isinstance(value, datetime):
|
||
return value.date()
|
||
|
||
value_str = str(value).strip()
|
||
|
||
# Пробуем разные форматы
|
||
formats = ['%d.%m.%Y', '%d.%m.%y', '%Y-%m-%d', '%d/%m/%Y', '%d/%m/%y']
|
||
for fmt in formats:
|
||
try:
|
||
return datetime.strptime(value_str, fmt).date()
|
||
except ValueError:
|
||
continue
|
||
|
||
return None
|
||
|
||
def _parse_datetime(self, value):
|
||
"""Парсит дату и время из различных форматов."""
|
||
if pd.isna(value):
|
||
return None
|
||
|
||
if isinstance(value, datetime):
|
||
return value
|
||
|
||
value_str = str(value).strip()
|
||
|
||
# Пробуем разные форматы
|
||
formats = [
|
||
'%d.%m.%y %H:%M', '%d.%m.%Y %H:%M', '%d.%m.%y %H:%M:%S', '%d.%m.%Y %H:%M:%S',
|
||
'%Y-%m-%d %H:%M', '%Y-%m-%d %H:%M:%S', '%d/%m/%Y %H:%M', '%d/%m/%y %H:%M'
|
||
]
|
||
for fmt in formats:
|
||
try:
|
||
return datetime.strptime(value_str, fmt)
|
||
except ValueError:
|
||
continue
|
||
|
||
return None
|
||
|
||
def _find_satellite(self, value):
|
||
"""Ищет спутник по названию с NORAD в скобках."""
|
||
if pd.isna(value):
|
||
return None
|
||
|
||
value_str = str(value).strip()
|
||
|
||
# Ищем NORAD в скобках: "NSS 12 (36032)"
|
||
match = re.search(r'\((\d+)\)', value_str)
|
||
if match:
|
||
norad = int(match.group(1))
|
||
try:
|
||
return Satellite.objects.get(norad=norad)
|
||
except Satellite.DoesNotExist:
|
||
pass
|
||
|
||
# Пробуем найти по имени
|
||
name = re.sub(r'\s*\(\d+\)\s*', '', value_str).strip()
|
||
if name:
|
||
satellite = Satellite.objects.filter(name__icontains=name).first()
|
||
if satellite:
|
||
return satellite
|
||
|
||
return None
|
||
|
||
def _parse_float(self, value):
|
||
"""Парсит число с плавающей точкой."""
|
||
if pd.isna(value):
|
||
return None
|
||
|
||
try:
|
||
# Заменяем запятую на точку
|
||
value_str = str(value).replace(',', '.').strip()
|
||
return float(value_str)
|
||
except (ValueError, TypeError):
|
||
return None
|
||
|
||
def _is_duplicate(self, satellite, downlink, uplink, transfer, coords, planned_at):
|
||
"""Проверяет, существует ли уже заявка с такими же параметрами.
|
||
|
||
Проверка по совокупности полей: спутник, downlink, uplink, перенос, координаты ГСО, дата проведения.
|
||
"""
|
||
from django.contrib.gis.measure import D
|
||
|
||
# Базовый фильтр
|
||
qs = SourceRequest.objects.filter(
|
||
satellite=satellite,
|
||
)
|
||
|
||
# Фильтр по downlink (с допуском)
|
||
if downlink is not None:
|
||
qs = qs.filter(downlink__gte=downlink - 0.01, downlink__lte=downlink + 0.01)
|
||
else:
|
||
qs = qs.filter(downlink__isnull=True)
|
||
|
||
# Фильтр по uplink (с допуском)
|
||
if uplink is not None:
|
||
qs = qs.filter(uplink__gte=uplink - 0.01, uplink__lte=uplink + 0.01)
|
||
else:
|
||
qs = qs.filter(uplink__isnull=True)
|
||
|
||
# Фильтр по transfer (с допуском)
|
||
if transfer is not None:
|
||
qs = qs.filter(transfer__gte=transfer - 0.01, transfer__lte=transfer + 0.01)
|
||
else:
|
||
qs = qs.filter(transfer__isnull=True)
|
||
|
||
# Фильтр по координатам ГСО
|
||
if coords is not None:
|
||
# Проверяем координаты с допуском ~100 метров
|
||
coords_point = Point(coords[1], coords[0], srid=4326)
|
||
qs = qs.filter(coords__distance_lte=(coords_point, D(m=100)))
|
||
else:
|
||
qs = qs.filter(coords__isnull=True)
|
||
|
||
# Фильтр по дате проведения
|
||
if planned_at is not None:
|
||
qs = qs.filter(planned_at=planned_at)
|
||
else:
|
||
qs = qs.filter(planned_at__isnull=True)
|
||
|
||
return qs.exists()
|
||
|
||
def _parse_coords(self, value):
|
||
"""Парсит координаты из строки. Возвращает (lat, lon) или None.
|
||
|
||
Поддерживаемые форматы:
|
||
- "26.223, 33.969" (числа через запятую с пробелом)
|
||
- "24.920695 46.733201" (точка как десятичный разделитель, пробел между координатами)
|
||
- "24,920695 46,733201" (запятая как десятичный разделитель, пробел между координатами)
|
||
- "24.920695, 46.733201" (точка как десятичный разделитель, запятая+пробел между координатами)
|
||
- "21.763585. 39.158290" (точка с пробелом между координатами)
|
||
|
||
Если значение содержит текст (не числа) - возвращает None.
|
||
"""
|
||
if pd.isna(value):
|
||
return None
|
||
|
||
value_str = str(value).strip()
|
||
if not value_str:
|
||
return None
|
||
|
||
# Пробуем извлечь два числа из строки с помощью регулярного выражения
|
||
# Ищем числа в формате: целое или дробное (с точкой или запятой как десятичным разделителем)
|
||
# Паттерн: -?[0-9]+[.,]?[0-9]*
|
||
numbers = re.findall(r'-?\d+[.,]?\d*', value_str)
|
||
|
||
if len(numbers) >= 2:
|
||
try:
|
||
lat = float(numbers[0].replace(',', '.'))
|
||
lon = float(numbers[1].replace(',', '.'))
|
||
# Проверяем, что координаты в разумных пределах
|
||
if -90 <= lat <= 90 and -180 <= lon <= 180:
|
||
return (lat, lon)
|
||
# Может быть перепутаны местами
|
||
if -90 <= lon <= 90 and -180 <= lat <= 180:
|
||
return (lon, lat)
|
||
except (ValueError, TypeError):
|
||
pass
|
||
|
||
# Формат "21.763585. 39.158290" - точка с пробелом как разделитель координат
|
||
if re.search(r'\.\s+', value_str):
|
||
parts = re.split(r'\.\s+', value_str)
|
||
if len(parts) >= 2:
|
||
try:
|
||
lat = float(parts[0].replace(',', '.'))
|
||
lon = float(parts[1].replace(',', '.'))
|
||
return (lat, lon)
|
||
except (ValueError, TypeError):
|
||
pass
|
||
|
||
# Формат "24.920695, 46.733201" - запятая с пробелом как разделитель координат
|
||
if ', ' in value_str:
|
||
parts = value_str.split(', ')
|
||
if len(parts) >= 2:
|
||
try:
|
||
lat = float(parts[0].replace(',', '.'))
|
||
lon = float(parts[1].replace(',', '.'))
|
||
return (lat, lon)
|
||
except (ValueError, TypeError):
|
||
pass
|
||
|
||
# Формат "24,920695 46,733201" или "24.920695 46.733201" - пробел как разделитель координат
|
||
# Сначала разбиваем по пробелам
|
||
parts = value_str.split()
|
||
if len(parts) >= 2:
|
||
try:
|
||
# Заменяем запятую на точку в каждой части отдельно
|
||
lat = float(parts[0].replace(',', '.'))
|
||
lon = float(parts[1].replace(',', '.'))
|
||
return (lat, lon)
|
||
except (ValueError, TypeError):
|
||
pass
|
||
|
||
# Формат "24.920695;46.733201" - точка с запятой как разделитель
|
||
if ';' in value_str:
|
||
parts = value_str.split(';')
|
||
if len(parts) >= 2:
|
||
try:
|
||
lat = float(parts[0].strip().replace(',', '.'))
|
||
lon = float(parts[1].strip().replace(',', '.'))
|
||
return (lat, lon)
|
||
except (ValueError, TypeError):
|
||
pass
|
||
|
||
# Если ничего не подошло - возвращаем None (текст или некорректный формат)
|
||
return None
|