Compare commits

...

2 Commits

23 changed files with 4083 additions and 1560 deletions

View File

@@ -32,13 +32,13 @@ from .models import (
ObjItem, ObjItem,
CustomUser, CustomUser,
Band, Band,
Source Source,
) )
from .filters import ( from .filters import (
GeoKupDistanceFilter, GeoKupDistanceFilter,
GeoValidDistanceFilter, GeoValidDistanceFilter,
UniqueToggleFilter, UniqueToggleFilter,
HasSigmaParameterFilter HasSigmaParameterFilter,
) )
@@ -55,22 +55,24 @@ admin.site.unregister(Group)
# Base Admin Classes # Base Admin Classes
# ============================================================================ # ============================================================================
class BaseAdmin(admin.ModelAdmin): class BaseAdmin(admin.ModelAdmin):
""" """
Базовый класс для всех admin моделей. Базовый класс для всех admin моделей.
Предоставляет общую функциональность: Предоставляет общую функциональность:
- Кнопки сохранения сверху и снизу - Кнопки сохранения сверху и снизу
- Настройка количества элементов на странице - Настройка количества элементов на странице
- Автоматическое заполнение полей created_by и updated_by - Автоматическое заполнение полей created_by и updated_by
""" """
save_on_top = True save_on_top = True
list_per_page = 50 list_per_page = 50
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
""" """
Автоматически заполняет поля created_by и updated_by при сохранении. Автоматически заполняет поля created_by и updated_by при сохранении.
Args: Args:
request: HTTP запрос request: HTTP запрос
obj: Сохраняемый объект модели obj: Сохраняемый объект модели
@@ -79,20 +81,20 @@ class BaseAdmin(admin.ModelAdmin):
""" """
if not change: if not change:
# При создании нового объекта устанавливаем created_by # При создании нового объекта устанавливаем created_by
if hasattr(obj, 'created_by') and not obj.created_by_id: if hasattr(obj, "created_by") and not obj.created_by_id:
obj.created_by = getattr(request.user, 'customuser', None) obj.created_by = getattr(request.user, "customuser", None)
# При любом сохранении обновляем updated_by # При любом сохранении обновляем updated_by
if hasattr(obj, 'updated_by'): if hasattr(obj, "updated_by"):
obj.updated_by = getattr(request.user, 'customuser', None) obj.updated_by = getattr(request.user, "customuser", None)
super().save_model(request, obj, form, change) super().save_model(request, obj, form, change)
class CustomUserInline(admin.StackedInline): class CustomUserInline(admin.StackedInline):
model = CustomUser model = CustomUser
can_delete = False can_delete = False
verbose_name_plural = 'Дополнительная информация пользователя' verbose_name_plural = "Дополнительная информация пользователя"
class LocationForm(forms.ModelForm): class LocationForm(forms.ModelForm):
@@ -105,13 +107,13 @@ class LocationForm(forms.ModelForm):
class Meta: class Meta:
model = Geo model = Geo
fields = '__all__' fields = "__all__"
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if self.instance and self.instance.coords: if self.instance and self.instance.coords:
self.fields['latitude_geo'].initial = self.instance.coords[1] self.fields["latitude_geo"].initial = self.instance.coords[1]
self.fields['longitude_geo'].initial = self.instance.coords[0] self.fields["longitude_geo"].initial = self.instance.coords[0]
# if self.instance and self.instance.coords_kupsat: # if self.instance and self.instance.coords_kupsat:
# self.fields['latitude_kupsat'].initial = self.instance.coords_kupsat[1] # self.fields['latitude_kupsat'].initial = self.instance.coords_kupsat[1]
# self.fields['longitude_kupsat'].initial = self.instance.coords_kupsat[0] # self.fields['longitude_kupsat'].initial = self.instance.coords_kupsat[0]
@@ -122,8 +124,9 @@ class LocationForm(forms.ModelForm):
def save(self, commit=True): def save(self, commit=True):
instance = super().save(commit=False) instance = super().save(commit=False)
from django.contrib.gis.geos import Point from django.contrib.gis.geos import Point
lat = self.cleaned_data.get('latitude_geo')
lon = self.cleaned_data.get('longitude_geo') lat = self.cleaned_data.get("latitude_geo")
lon = self.cleaned_data.get("longitude_geo")
if lat is not None and lon is not None: if lat is not None and lon is not None:
instance.coords = Point(lon, lat, srid=4326) instance.coords = Point(lon, lat, srid=4326)
@@ -150,18 +153,28 @@ class GeoInline(admin.StackedInline):
form = LocationForm form = LocationForm
# readonly_fields = ("distance_coords_kup", "distance_coords_valid", "distance_kup_valid") # readonly_fields = ("distance_coords_kup", "distance_coords_valid", "distance_kup_valid")
prefetch_related = ("mirrors",) prefetch_related = ("mirrors",)
autocomplete_fields = ('mirrors',) autocomplete_fields = ("mirrors",)
fieldsets = ( fieldsets = (
("Основная информация", { (
"fields": ("mirrors", "location", "Основная информация",
# "distance_coords_kup", {
# "distance_coords_valid", "fields": (
# "distance_kup_valid", "mirrors",
"timestamp", "comment",) "location",
}), # "distance_coords_kup",
("Координаты: геолокация", { # "distance_coords_valid",
"fields": ("longitude_geo", "latitude_geo", "coords"), # "distance_kup_valid",
}), "timestamp",
"comment",
)
},
),
(
"Координаты: геолокация",
{
"fields": ("longitude_geo", "latitude_geo", "coords"),
},
),
# ("Координаты: Кубсат", { # ("Координаты: Кубсат", {
# "fields": ("longitude_kupsat", "latitude_kupsat", "coords_kupsat"), # "fields": ("longitude_kupsat", "latitude_kupsat", "coords_kupsat"),
# }), # }),
@@ -174,6 +187,7 @@ class GeoInline(admin.StackedInline):
class UserAdmin(BaseUserAdmin): class UserAdmin(BaseUserAdmin):
inlines = [CustomUserInline] inlines = [CustomUserInline]
admin.site.register(User, UserAdmin) admin.site.register(User, UserAdmin)
@@ -181,80 +195,83 @@ admin.site.register(User, UserAdmin)
# Custom Admin Actions # Custom Admin Actions
# ============================================================================ # ============================================================================
@admin.action(description="Показать выбранные на карте") @admin.action(description="Показать выбранные на карте")
def show_on_map(modeladmin, request, queryset): def show_on_map(modeladmin, request, queryset):
""" """
Action для отображения выбранных Geo объектов на карте. Action для отображения выбранных Geo объектов на карте.
Оптимизирован для работы с большим количеством объектов: Оптимизирован для работы с большим количеством объектов:
использует values_list для получения только ID. использует values_list для получения только ID.
""" """
selected_ids = queryset.values_list('id', flat=True) selected_ids = queryset.values_list("id", flat=True)
ids_str = ','.join(str(pk) for pk in selected_ids) ids_str = ",".join(str(pk) for pk in selected_ids)
return redirect(reverse('mainapp:admin_show_map') + f'?ids={ids_str}') return redirect(reverse("mainapp:admin_show_map") + f"?ids={ids_str}")
@admin.action(description="Показать выбранные объекты на карте") @admin.action(description="Показать выбранные объекты на карте")
def show_selected_on_map(modeladmin, request, queryset): def show_selected_on_map(modeladmin, request, queryset):
""" """
Action для отображения выбранных ObjItem объектов на карте. Action для отображения выбранных ObjItem объектов на карте.
Оптимизирован для работы с большим количеством объектов: Оптимизирован для работы с большим количеством объектов:
использует values_list для получения только ID. использует values_list для получения только ID.
""" """
selected_ids = queryset.values_list('id', flat=True) selected_ids = queryset.values_list("id", flat=True)
ids_str = ','.join(str(pk) for pk in selected_ids) ids_str = ",".join(str(pk) for pk in selected_ids)
return redirect(reverse('mainapp:show_selected_objects_map') + f'?ids={ids_str}') return redirect(reverse("mainapp:show_selected_objects_map") + f"?ids={ids_str}")
@admin.action(description="Экспортировать выбранные объекты в CSV") @admin.action(description="Экспортировать выбранные объекты в CSV")
def export_objects_to_csv(modeladmin, request, queryset): def export_objects_to_csv(modeladmin, request, queryset):
""" """
Action для экспорта выбранных ObjItem объектов в CSV формат. Action для экспорта выбранных ObjItem объектов в CSV формат.
Оптимизирован с использованием select_related и prefetch_related Оптимизирован с использованием select_related и prefetch_related
для минимизации количества запросов к БД. для минимизации количества запросов к БД.
""" """
import csv import csv
from django.http import HttpResponse from django.http import HttpResponse
# Оптимизируем queryset # Оптимизируем queryset
queryset = queryset.select_related( queryset = queryset.select_related(
'geo_obj', "geo_obj",
'created_by__user', "created_by__user",
'updated_by__user', "updated_by__user",
'parameter_obj', "parameter_obj",
'parameter_obj__id_satellite', "parameter_obj__id_satellite",
'parameter_obj__polarization', "parameter_obj__polarization",
'parameter_obj__modulation' "parameter_obj__modulation",
) )
response = HttpResponse(content_type='text/csv; charset=utf-8') response = HttpResponse(content_type="text/csv; charset=utf-8")
response['Content-Disposition'] = 'attachment; filename="objitems_export.csv"' response["Content-Disposition"] = 'attachment; filename="objitems_export.csv"'
response.write('\ufeff') # UTF-8 BOM для корректного отображения в Excel response.write("\ufeff") # UTF-8 BOM для корректного отображения в Excel
writer = csv.writer(response) writer = csv.writer(response)
writer.writerow([ writer.writerow(
'Название', [
'Спутник', "Название",
'Частота (МГц)', "Спутник",
'Полоса (МГц)', "Частота (МГц)",
'Поляризация', "Полоса (МГц)",
'Модуляция', "Поляризация",
'ОСШ', "Модуляция",
'Координаты геолокации', "ОСШ",
'Координаты Кубсата', "Координаты геолокации",
'Координаты оперативного отдела', "Координаты Кубсата",
'Расстояние Гео-Куб (км)', "Координаты оперативного отдела",
'Расстояние Гео-Опер (км)', "Расстояние Гео-Куб (км)",
'Дата создания', "Расстояние Гео-Опер (км)",
'Дата обновления' "Дата создания",
]) "Дата обновления",
]
)
for obj in queryset: for obj in queryset:
param = getattr(obj, 'parameter_obj', None) param = getattr(obj, "parameter_obj", None)
geo = obj.geo_obj geo = obj.geo_obj
# Форматирование координат # Форматирование координат
def format_coords(coords): def format_coords(coords):
if not coords: if not coords:
@@ -263,24 +280,30 @@ def export_objects_to_csv(modeladmin, request, queryset):
lon_str = f"{lon}E" if lon > 0 else f"{abs(lon)}W" lon_str = f"{lon}E" if lon > 0 else f"{abs(lon)}W"
lat_str = f"{lat}N" if lat > 0 else f"{abs(lat)}S" lat_str = f"{lat}N" if lat > 0 else f"{abs(lat)}S"
return f"{lat_str} {lon_str}" return f"{lat_str} {lon_str}"
writer.writerow([ writer.writerow(
obj.name, [
param.id_satellite.name if param and param.id_satellite else "-", obj.name,
param.frequency if param else "-", param.id_satellite.name if param and param.id_satellite else "-",
param.freq_range if param else "-", param.frequency if param else "-",
param.polarization.name if param and param.polarization else "-", param.freq_range if param else "-",
param.modulation.name if param and param.modulation else "-", param.polarization.name if param and param.polarization else "-",
param.snr if param else "-", param.modulation.name if param and param.modulation else "-",
format_coords(geo) if geo and geo.coords else "-", param.snr if param else "-",
format_coords(geo) if geo and geo.coords_kupsat else "-", format_coords(geo) if geo and geo.coords else "-",
format_coords(geo) if geo and geo.coords_valid else "-", format_coords(geo) if geo and geo.coords_kupsat else "-",
round(geo.distance_coords_kup, 3) if geo and geo.distance_coords_kup else "-", format_coords(geo) if geo and geo.coords_valid else "-",
round(geo.distance_coords_valid, 3) if geo and geo.distance_coords_valid else "-", round(geo.distance_coords_kup, 3)
obj.created_at.strftime("%d.%m.%Y %H:%M:%S") if obj.created_at else "-", if geo and geo.distance_coords_kup
obj.updated_at.strftime("%d.%m.%Y %H:%M:%S") if obj.updated_at else "-" else "-",
]) round(geo.distance_coords_valid, 3)
if geo and geo.distance_coords_valid
else "-",
obj.created_at.strftime("%d.%m.%Y %H:%M:%S") if obj.created_at else "-",
obj.updated_at.strftime("%d.%m.%Y %H:%M:%S") if obj.updated_at else "-",
]
)
return response return response
@@ -288,8 +311,10 @@ def export_objects_to_csv(modeladmin, request, queryset):
# Inline Admin Classes # Inline Admin Classes
# ============================================================================ # ============================================================================
class ParameterInline(admin.StackedInline): class ParameterInline(admin.StackedInline):
"""Inline для редактирования параметра объекта.""" """Inline для редактирования параметра объекта."""
model = Parameter model = Parameter
extra = 0 extra = 0
max_num = 1 max_num = 1
@@ -297,36 +322,37 @@ class ParameterInline(admin.StackedInline):
verbose_name = "ВЧ загрузка" verbose_name = "ВЧ загрузка"
verbose_name_plural = "ВЧ загрузка" verbose_name_plural = "ВЧ загрузка"
fields = ( fields = (
'id_satellite', "id_satellite",
'frequency', "frequency",
'freq_range', "freq_range",
'polarization', "polarization",
'modulation', "modulation",
'bod_velocity', "bod_velocity",
'snr', "snr",
'standard' "standard",
) )
autocomplete_fields = ('id_satellite', 'polarization', 'modulation', 'standard') autocomplete_fields = ("id_satellite", "polarization", "modulation", "standard")
# ============================================================================ # ============================================================================
# Admin Classes # Admin Classes
# ============================================================================ # ============================================================================
@admin.register(SigmaParMark) @admin.register(SigmaParMark)
class SigmaParMarkAdmin(BaseAdmin): class SigmaParMarkAdmin(BaseAdmin):
"""Админ-панель для модели SigmaParMark.""" """Админ-панель для модели SigmaParMark."""
list_display = ("mark", "timestamp") list_display = ("mark", "timestamp")
search_fields = ("mark",) search_fields = ("mark",)
ordering = ("-timestamp",) ordering = ("-timestamp",)
list_filter = ( list_filter = (("timestamp", DateRangeQuickSelectListFilterBuilder()),)
("timestamp", DateRangeQuickSelectListFilterBuilder()),
)
@admin.register(Polarization) @admin.register(Polarization)
class PolarizationAdmin(BaseAdmin): class PolarizationAdmin(BaseAdmin):
"""Админ-панель для модели Polarization.""" """Админ-панель для модели Polarization."""
list_display = ("name",) list_display = ("name",)
search_fields = ("name",) search_fields = ("name",)
ordering = ("name",) ordering = ("name",)
@@ -335,6 +361,7 @@ class PolarizationAdmin(BaseAdmin):
@admin.register(Modulation) @admin.register(Modulation)
class ModulationAdmin(BaseAdmin): class ModulationAdmin(BaseAdmin):
"""Админ-панель для модели Modulation.""" """Админ-панель для модели Modulation."""
list_display = ("name",) list_display = ("name",)
search_fields = ("name",) search_fields = ("name",)
ordering = ("name",) ordering = ("name",)
@@ -343,6 +370,7 @@ class ModulationAdmin(BaseAdmin):
@admin.register(Standard) @admin.register(Standard)
class StandardAdmin(BaseAdmin): class StandardAdmin(BaseAdmin):
"""Админ-панель для модели Standard.""" """Админ-панель для модели Standard."""
list_display = ("name",) list_display = ("name",)
search_fields = ("name",) search_fields = ("name",)
ordering = ("name",) ordering = ("name",)
@@ -351,11 +379,12 @@ class StandardAdmin(BaseAdmin):
class SigmaParameterInline(admin.StackedInline): class SigmaParameterInline(admin.StackedInline):
model = SigmaParameter model = SigmaParameter
extra = 0 extra = 0
autocomplete_fields = ['mark'] autocomplete_fields = ["mark"]
readonly_fields = ( readonly_fields = (
"datetime_begin", "datetime_begin",
"datetime_end", "datetime_end",
) )
def has_add_permission(self, request, obj=None): def has_add_permission(self, request, obj=None):
return False return False
@@ -364,12 +393,13 @@ class SigmaParameterInline(admin.StackedInline):
class ParameterAdmin(ImportExportActionModelAdmin, BaseAdmin): class ParameterAdmin(ImportExportActionModelAdmin, BaseAdmin):
""" """
Админ-панель для модели Parameter. Админ-панель для модели Parameter.
Оптимизирована для работы с большим количеством параметров: Оптимизирована для работы с большим количеством параметров:
- Использует select_related для оптимизации запросов - Использует select_related для оптимизации запросов
- Предоставляет фильтры по основным характеристикам - Предоставляет фильтры по основным характеристикам
- Поддерживает импорт/экспорт данных - Поддерживает импорт/экспорт данных
""" """
list_display = ( list_display = (
"id_satellite", "id_satellite",
"frequency", "frequency",
@@ -380,11 +410,17 @@ class ParameterAdmin(ImportExportActionModelAdmin, BaseAdmin):
"snr", "snr",
"standard", "standard",
"related_objitem", "related_objitem",
"sigma_parameter" "sigma_parameter",
) )
list_display_links = ("frequency", "id_satellite") list_display_links = ("frequency", "id_satellite")
list_select_related = ("polarization", "modulation", "standard", "id_satellite", "objitem") list_select_related = (
"polarization",
"modulation",
"standard",
"id_satellite",
"objitem",
)
list_filter = ( list_filter = (
HasSigmaParameterFilter, HasSigmaParameterFilter,
("objitem", MultiSelectRelatedDropdownFilter), ("objitem", MultiSelectRelatedDropdownFilter),
@@ -396,7 +432,7 @@ class ParameterAdmin(ImportExportActionModelAdmin, BaseAdmin):
("freq_range", NumericRangeFilterBuilder()), ("freq_range", NumericRangeFilterBuilder()),
("snr", NumericRangeFilterBuilder()), ("snr", NumericRangeFilterBuilder()),
) )
search_fields = ( search_fields = (
"id_satellite__name", "id_satellite__name",
"frequency", "frequency",
@@ -408,16 +444,17 @@ class ParameterAdmin(ImportExportActionModelAdmin, BaseAdmin):
"standard__name", "standard__name",
"objitem__name", "objitem__name",
) )
ordering = ("-frequency",) ordering = ("-frequency",)
autocomplete_fields = ("objitem",) autocomplete_fields = ("objitem",)
inlines = [SigmaParameterInline] inlines = [SigmaParameterInline]
def related_objitem(self, obj): def related_objitem(self, obj):
"""Отображает связанный ObjItem.""" """Отображает связанный ObjItem."""
if hasattr(obj, 'objitem') and obj.objitem: if hasattr(obj, "objitem") and obj.objitem:
return obj.objitem.name return obj.objitem.name
return "-" return "-"
related_objitem.short_description = "Объект" related_objitem.short_description = "Объект"
related_objitem.admin_order_field = "objitem__name" related_objitem.admin_order_field = "objitem__name"
@@ -427,19 +464,21 @@ class ParameterAdmin(ImportExportActionModelAdmin, BaseAdmin):
if sigma_obj: if sigma_obj:
return f"{sigma_obj[0].frequency}: {sigma_obj[0].freq_range}" return f"{sigma_obj[0].frequency}: {sigma_obj[0].freq_range}"
return "-" return "-"
sigma_parameter.short_description = "ВЧ sigma" sigma_parameter.short_description = "ВЧ sigma"
@admin.register(SigmaParameter) @admin.register(SigmaParameter)
class SigmaParameterAdmin(ImportExportActionModelAdmin, BaseAdmin): class SigmaParameterAdmin(ImportExportActionModelAdmin, BaseAdmin):
""" """
Админ-панель для модели SigmaParameter. Админ-панель для модели SigmaParameter.
Оптимизирована для работы с параметрами Sigma: Оптимизирована для работы с параметрами Sigma:
- Использует select_related и prefetch_related для оптимизации - Использует select_related и prefetch_related для оптимизации
- Предоставляет фильтры по основным характеристикам - Предоставляет фильтры по основным характеристикам
- Поддерживает импорт/экспорт данных - Поддерживает импорт/экспорт данных
""" """
list_display = ( list_display = (
"id_satellite", "id_satellite",
"frequency", "frequency",
@@ -454,14 +493,16 @@ class SigmaParameterAdmin(ImportExportActionModelAdmin, BaseAdmin):
"datetime_end", "datetime_end",
) )
list_display_links = ("id_satellite",) list_display_links = ("id_satellite",)
list_select_related = ("modulation", "standard", "id_satellite", "parameter", "polarization") list_select_related = (
"modulation",
readonly_fields = ( "standard",
"datetime_begin", "id_satellite",
"datetime_end", "parameter",
"transfer_frequency" "polarization",
) )
readonly_fields = ("datetime_begin", "datetime_end", "transfer_frequency")
list_filter = ( list_filter = (
("id_satellite__name", MultiSelectDropdownFilter), ("id_satellite__name", MultiSelectDropdownFilter),
("modulation__name", MultiSelectDropdownFilter), ("modulation__name", MultiSelectDropdownFilter),
@@ -472,7 +513,7 @@ class SigmaParameterAdmin(ImportExportActionModelAdmin, BaseAdmin):
("datetime_begin", DateRangeQuickSelectListFilterBuilder()), ("datetime_begin", DateRangeQuickSelectListFilterBuilder()),
("datetime_end", DateRangeQuickSelectListFilterBuilder()), ("datetime_end", DateRangeQuickSelectListFilterBuilder()),
) )
search_fields = ( search_fields = (
"id_satellite__name", "id_satellite__name",
"frequency", "frequency",
@@ -484,17 +525,25 @@ class SigmaParameterAdmin(ImportExportActionModelAdmin, BaseAdmin):
) )
autocomplete_fields = ("mark",) autocomplete_fields = ("mark",)
ordering = ("-frequency",) ordering = ("-frequency",)
def get_queryset(self, request): def get_queryset(self, request):
"""Оптимизированный queryset с prefetch_related для mark.""" """Оптимизированный queryset с prefetch_related для mark."""
qs = super().get_queryset(request) qs = super().get_queryset(request)
return qs.prefetch_related("mark") return qs.prefetch_related("mark")
@admin.register(Satellite) @admin.register(Satellite)
class SatelliteAdmin(BaseAdmin): class SatelliteAdmin(BaseAdmin):
"""Админ-панель для модели Satellite.""" """Админ-панель для модели Satellite."""
list_display = ("name", "norad", "undersat_point", "launch_date", "created_at", "updated_at")
list_display = (
"name",
"norad",
"undersat_point",
"launch_date",
"created_at",
"updated_at",
)
search_fields = ("name", "norad") search_fields = ("name", "norad")
ordering = ("name",) ordering = ("name",)
filter_horizontal = ("band",) filter_horizontal = ("band",)
@@ -505,6 +554,7 @@ class SatelliteAdmin(BaseAdmin):
@admin.register(Mirror) @admin.register(Mirror)
class MirrorAdmin(ImportExportActionModelAdmin, BaseAdmin): class MirrorAdmin(ImportExportActionModelAdmin, BaseAdmin):
"""Админ-панель для модели Mirror с поддержкой импорта/экспорта.""" """Админ-панель для модели Mirror с поддержкой импорта/экспорта."""
list_display = ("name",) list_display = ("name",)
search_fields = ("name",) search_fields = ("name",)
ordering = ("name",) ordering = ("name",)
@@ -514,28 +564,37 @@ class MirrorAdmin(ImportExportActionModelAdmin, BaseAdmin):
class GeoAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin, BaseAdmin): class GeoAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin, BaseAdmin):
""" """
Админ-панель для модели Geo с поддержкой карты Leaflet. Админ-панель для модели Geo с поддержкой карты Leaflet.
Оптимизирована для работы с геоданными: Оптимизирована для работы с геоданными:
- Использует prefetch_related для оптимизации запросов к mirrors - Использует prefetch_related для оптимизации запросов к mirrors
- Предоставляет фильтры по зеркалам, локации и дате - Предоставляет фильтры по зеркалам, локации и дате
- Поддерживает импорт/экспорт данных - Поддерживает импорт/экспорт данных
- Интегрирована с Leaflet для отображения на карте - Интегрирована с Leaflet для отображения на карте
""" """
form = LocationForm form = LocationForm
# readonly_fields = ("distance_coords_kup", "distance_coords_valid", "distance_kup_valid") # readonly_fields = ("distance_coords_kup", "distance_coords_valid", "distance_kup_valid")
fieldsets = ( fieldsets = (
("Основная информация", { (
"fields": ("mirrors", "location", "Основная информация",
{
"fields": (
"mirrors",
"location",
# "distance_coords_kup", # "distance_coords_kup",
# "distance_coords_valid", # "distance_coords_valid",
# "distance_kup_valid", # "distance_kup_valid",
"timestamp", "comment", "transponder") "timestamp",
}), "comment",
("Координаты: геолокация", { )
"fields": ("longitude_geo", "latitude_geo", "coords") },
}), ),
(
"Координаты: геолокация",
{"fields": ("longitude_geo", "latitude_geo", "coords")},
),
# ("Координаты: Кубсат", { # ("Координаты: Кубсат", {
# "fields": ("longitude_kupsat", "latitude_kupsat", "coords_kupsat") # "fields": ("longitude_kupsat", "latitude_kupsat", "coords_kupsat")
# }), # }),
@@ -543,7 +602,7 @@ class GeoAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin, BaseAdmin):
# "fields": ("longitude_valid", "latitude_valid", "coords_valid") # "fields": ("longitude_valid", "latitude_valid", "coords_valid")
# }), # }),
) )
list_display = ( list_display = (
"formatted_timestamp", "formatted_timestamp",
"location", "location",
@@ -554,38 +613,39 @@ class GeoAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin, BaseAdmin):
"is_average", "is_average",
) )
list_display_links = ("formatted_timestamp",) list_display_links = ("formatted_timestamp",)
list_filter = ( list_filter = (
("mirrors", MultiSelectRelatedDropdownFilter), ("mirrors", MultiSelectRelatedDropdownFilter),
("transponder", MultiSelectRelatedDropdownFilter),
"is_average", "is_average",
("location", MultiSelectDropdownFilter), ("location", MultiSelectDropdownFilter),
("timestamp", DateRangeQuickSelectListFilterBuilder()), ("timestamp", DateRangeQuickSelectListFilterBuilder()),
) )
search_fields = ( search_fields = (
"mirrors__name", "mirrors__name",
"location", "location",
"transponder__name",
) )
autocomplete_fields = ("mirrors", ) autocomplete_fields = ("mirrors",)
ordering = ("-timestamp",) ordering = ("-timestamp",)
actions = [show_on_map] actions = [show_on_map]
settings_overrides = { settings_overrides = {
'DEFAULT_CENTER': (55.7558, 37.6173), "DEFAULT_CENTER": (55.7558, 37.6173),
'DEFAULT_ZOOM': 12, "DEFAULT_ZOOM": 12,
} }
def get_queryset(self, request): def get_queryset(self, request):
"""Оптимизированный queryset с prefetch_related для mirrors.""" """Оптимизированный queryset с prefetch_related для mirrors."""
qs = super().get_queryset(request) qs = super().get_queryset(request)
return qs.prefetch_related("mirrors", "transponder") return qs.prefetch_related(
"mirrors",
)
def mirrors_names(self, obj): def mirrors_names(self, obj):
"""Отображает список зеркал через запятую.""" """Отображает список зеркал через запятую."""
return ", ".join(m.name for m in obj.mirrors.all()) return ", ".join(m.name for m in obj.mirrors.all())
mirrors_names.short_description = "Зеркала" mirrors_names.short_description = "Зеркала"
def formatted_timestamp(self, obj): def formatted_timestamp(self, obj):
@@ -594,6 +654,7 @@ class GeoAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin, BaseAdmin):
return "" return ""
local_time = timezone.localtime(obj.timestamp) local_time = timezone.localtime(obj.timestamp)
return local_time.strftime("%d.%m.%Y %H:%M:%S") return local_time.strftime("%d.%m.%Y %H:%M:%S")
formatted_timestamp.short_description = "Дата и время" formatted_timestamp.short_description = "Дата и время"
formatted_timestamp.admin_order_field = "timestamp" formatted_timestamp.admin_order_field = "timestamp"
@@ -606,6 +667,7 @@ class GeoAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin, BaseAdmin):
lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W"
lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S"
return f"{lat} {lon}" return f"{lat} {lon}"
geo_coords.short_description = "Координаты геолокации" geo_coords.short_description = "Координаты геолокации"
# def kupsat_coords(self, obj): # def kupsat_coords(self, obj):
@@ -631,19 +693,18 @@ class GeoAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin, BaseAdmin):
# valid_coords.short_description = "Координаты оперативного отдела" # valid_coords.short_description = "Координаты оперативного отдела"
@admin.register(ObjItem) @admin.register(ObjItem)
class ObjItemAdmin(BaseAdmin): class ObjItemAdmin(BaseAdmin):
""" """
Админ-панель для модели ObjItem. Админ-панель для модели ObjItem.
Оптимизирована для работы с большим количеством объектов: Оптимизирована для работы с большим количеством объектов:
- Использует select_related и prefetch_related для оптимизации запросов - Использует select_related и prefetch_related для оптимизации запросов
- Предоставляет фильтры по основным параметрам - Предоставляет фильтры по основным параметрам
- Поддерживает поиск по имени, координатам и частоте - Поддерживает поиск по имени, координатам и частоте
- Включает кастомные actions для отображения на карте - Включает кастомные actions для отображения на карте
""" """
list_display = ( list_display = (
"name", "name",
"sat_name", "sat_name",
@@ -664,16 +725,16 @@ class ObjItemAdmin(BaseAdmin):
) )
list_display_links = ("name",) list_display_links = ("name",)
list_select_related = ( list_select_related = (
"geo_obj", "geo_obj",
"created_by__user", "created_by__user",
"updated_by__user", "updated_by__user",
"parameter_obj", "parameter_obj",
"parameter_obj__id_satellite", "parameter_obj__id_satellite",
"parameter_obj__polarization", "parameter_obj__polarization",
"parameter_obj__modulation", "parameter_obj__modulation",
"parameter_obj__standard" "parameter_obj__standard",
) )
list_filter = ( list_filter = (
UniqueToggleFilter, UniqueToggleFilter,
("parameter_obj__id_satellite", MultiSelectRelatedDropdownFilter), ("parameter_obj__id_satellite", MultiSelectRelatedDropdownFilter),
@@ -687,33 +748,34 @@ class ObjItemAdmin(BaseAdmin):
("created_at", DateRangeQuickSelectListFilterBuilder()), ("created_at", DateRangeQuickSelectListFilterBuilder()),
("updated_at", DateRangeQuickSelectListFilterBuilder()), ("updated_at", DateRangeQuickSelectListFilterBuilder()),
) )
search_fields = ( search_fields = (
"name", "name",
"geo_obj__location", "geo_obj__location",
"parameter_obj__frequency", "parameter_obj__frequency",
"parameter_obj__id_satellite__name", "parameter_obj__id_satellite__name",
) )
ordering = ("-updated_at",) ordering = ("-updated_at",)
inlines = [GeoInline, ParameterInline] inlines = [GeoInline, ParameterInline]
actions = [show_selected_on_map, export_objects_to_csv] actions = [show_selected_on_map, export_objects_to_csv]
readonly_fields = ("created_at", "created_by", "updated_at", "updated_by") readonly_fields = ("created_at", "created_by", "updated_at", "updated_by")
fieldsets = ( fieldsets = (
("Основная информация", { ("Основная информация", {"fields": ("name",)}),
"fields": ("name",) (
}), "Метаданные",
("Метаданные", { {
"fields": ("created_at", "created_by", "updated_at", "updated_by"), "fields": ("created_at", "created_by", "updated_at", "updated_by"),
"classes": ("collapse",) "classes": ("collapse",),
}), },
),
) )
def get_queryset(self, request): def get_queryset(self, request):
""" """
Оптимизированный queryset с использованием select_related. Оптимизированный queryset с использованием select_related.
Загружает связанные объекты одним запросом для улучшения производительности. Загружает связанные объекты одним запросом для улучшения производительности.
""" """
qs = super().get_queryset(request) qs = super().get_queryset(request)
@@ -725,23 +787,25 @@ class ObjItemAdmin(BaseAdmin):
"parameter_obj__id_satellite", "parameter_obj__id_satellite",
"parameter_obj__polarization", "parameter_obj__polarization",
"parameter_obj__modulation", "parameter_obj__modulation",
"parameter_obj__standard" "parameter_obj__standard",
) )
def sat_name(self, obj): def sat_name(self, obj):
"""Отображает название спутника из связанного параметра.""" """Отображает название спутника из связанного параметра."""
if hasattr(obj, 'parameter_obj') and obj.parameter_obj: if hasattr(obj, "parameter_obj") and obj.parameter_obj:
if obj.parameter_obj.id_satellite: if obj.parameter_obj.id_satellite:
return obj.parameter_obj.id_satellite.name return obj.parameter_obj.id_satellite.name
return "-" return "-"
sat_name.short_description = "Спутник" sat_name.short_description = "Спутник"
sat_name.admin_order_field = "parameter_obj__id_satellite__name" sat_name.admin_order_field = "parameter_obj__id_satellite__name"
def freq(self, obj): def freq(self, obj):
"""Отображает частоту из связанного параметра.""" """Отображает частоту из связанного параметра."""
if hasattr(obj, 'parameter_obj') and obj.parameter_obj: if hasattr(obj, "parameter_obj") and obj.parameter_obj:
return obj.parameter_obj.frequency return obj.parameter_obj.frequency
return "-" return "-"
freq.short_description = "Частота, МГц" freq.short_description = "Частота, МГц"
freq.admin_order_field = "parameter_obj__frequency" freq.admin_order_field = "parameter_obj__frequency"
@@ -771,40 +835,45 @@ class ObjItemAdmin(BaseAdmin):
def pol(self, obj): def pol(self, obj):
"""Отображает поляризацию из связанного параметра.""" """Отображает поляризацию из связанного параметра."""
if hasattr(obj, 'parameter_obj') and obj.parameter_obj: if hasattr(obj, "parameter_obj") and obj.parameter_obj:
if obj.parameter_obj.polarization: if obj.parameter_obj.polarization:
return obj.parameter_obj.polarization.name return obj.parameter_obj.polarization.name
return "-" return "-"
pol.short_description = "Поляризация" pol.short_description = "Поляризация"
def freq_range(self, obj): def freq_range(self, obj):
"""Отображает полосу частот из связанного параметра.""" """Отображает полосу частот из связанного параметра."""
if hasattr(obj, 'parameter_obj') and obj.parameter_obj: if hasattr(obj, "parameter_obj") and obj.parameter_obj:
return obj.parameter_obj.freq_range return obj.parameter_obj.freq_range
return "-" return "-"
freq_range.short_description = "Полоса, МГц" freq_range.short_description = "Полоса, МГц"
freq_range.admin_order_field = "parameter_obj__freq_range" freq_range.admin_order_field = "parameter_obj__freq_range"
def bod_velocity(self, obj): def bod_velocity(self, obj):
"""Отображает символьную скорость из связанного параметра.""" """Отображает символьную скорость из связанного параметра."""
if hasattr(obj, 'parameter_obj') and obj.parameter_obj: if hasattr(obj, "parameter_obj") and obj.parameter_obj:
return obj.parameter_obj.bod_velocity return obj.parameter_obj.bod_velocity
return "-" return "-"
bod_velocity.short_description = "Сим. v, БОД" bod_velocity.short_description = "Сим. v, БОД"
def modulation(self, obj): def modulation(self, obj):
"""Отображает модуляцию из связанного параметра.""" """Отображает модуляцию из связанного параметра."""
if hasattr(obj, 'parameter_obj') and obj.parameter_obj: if hasattr(obj, "parameter_obj") and obj.parameter_obj:
if obj.parameter_obj.modulation: if obj.parameter_obj.modulation:
return obj.parameter_obj.modulation.name return obj.parameter_obj.modulation.name
return "-" return "-"
modulation.short_description = "Модуляция" modulation.short_description = "Модуляция"
def snr(self, obj): def snr(self, obj):
"""Отображает отношение сигнал/шум из связанного параметра.""" """Отображает отношение сигнал/шум из связанного параметра."""
if hasattr(obj, 'parameter_obj') and obj.parameter_obj: if hasattr(obj, "parameter_obj") and obj.parameter_obj:
return obj.parameter_obj.snr return obj.parameter_obj.snr
return "-" return "-"
snr.short_description = "ОСШ" snr.short_description = "ОСШ"
def geo_coords(self, obj): def geo_coords(self, obj):
@@ -817,6 +886,7 @@ class ObjItemAdmin(BaseAdmin):
lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W"
lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S"
return f"{lat} {lon}" return f"{lat} {lon}"
geo_coords.short_description = "Координаты геолокации" geo_coords.short_description = "Координаты геолокации"
geo_coords.admin_order_field = "geo_obj__coords" geo_coords.admin_order_field = "geo_obj__coords"
@@ -830,6 +900,7 @@ class ObjItemAdmin(BaseAdmin):
lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W"
lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S"
return f"{lat} {lon}" return f"{lat} {lon}"
kupsat_coords.short_description = "Координаты Кубсата" kupsat_coords.short_description = "Координаты Кубсата"
def valid_coords(self, obj): def valid_coords(self, obj):
@@ -842,12 +913,14 @@ class ObjItemAdmin(BaseAdmin):
lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W"
lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S"
return f"{lat} {lon}" return f"{lat} {lon}"
valid_coords.short_description = "Координаты оперативного отдела" valid_coords.short_description = "Координаты оперативного отдела"
@admin.register(Band) @admin.register(Band)
class BandAdmin(ImportExportActionModelAdmin, BaseAdmin): class BandAdmin(ImportExportActionModelAdmin, BaseAdmin):
"""Админ-панель для модели Band.""" """Админ-панель для модели Band."""
list_display = ("name", "border_start", "border_end") list_display = ("name", "border_start", "border_end")
search_fields = ("name",) search_fields = ("name",)
ordering = ("name",) ordering = ("name",)
@@ -855,29 +928,44 @@ class BandAdmin(ImportExportActionModelAdmin, BaseAdmin):
class ObjItemInline(admin.TabularInline): class ObjItemInline(admin.TabularInline):
"""Inline для отображения объектов ObjItem в Source.""" """Inline для отображения объектов ObjItem в Source."""
model = ObjItem model = ObjItem
fk_name = "source" fk_name = "source"
extra = 0 extra = 0
can_delete = False can_delete = False
verbose_name = "Объект" verbose_name = "Объект"
verbose_name_plural = "Объекты" verbose_name_plural = "Объекты"
fields = ("name", "get_geo_coords", "get_satellite", "get_frequency", "get_polarization", "updated_at") fields = (
readonly_fields = ("name", "get_geo_coords", "get_satellite", "get_frequency", "get_polarization", "updated_at") "name",
"get_geo_coords",
"get_satellite",
"get_frequency",
"get_polarization",
"updated_at",
)
readonly_fields = (
"name",
"get_geo_coords",
"get_satellite",
"get_frequency",
"get_polarization",
"updated_at",
)
def get_queryset(self, request): def get_queryset(self, request):
"""Оптимизированный queryset с предзагрузкой связанных объектов.""" """Оптимизированный queryset с предзагрузкой связанных объектов."""
qs = super().get_queryset(request) qs = super().get_queryset(request)
return qs.select_related( return qs.select_related(
'geo_obj', "geo_obj",
'parameter_obj', "parameter_obj",
'parameter_obj__id_satellite', "parameter_obj__id_satellite",
'parameter_obj__polarization' "parameter_obj__polarization",
) )
def get_geo_coords(self, obj): def get_geo_coords(self, obj):
"""Отображает координаты из связанной модели Geo.""" """Отображает координаты из связанной модели Geo."""
if not obj or not hasattr(obj, 'geo_obj'): if not obj or not hasattr(obj, "geo_obj"):
return "-" return "-"
geo = obj.geo_obj geo = obj.geo_obj
if not geo or not geo.coords: if not geo or not geo.coords:
@@ -887,29 +975,41 @@ class ObjItemInline(admin.TabularInline):
lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W"
lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S"
return f"{lat} {lon}" return f"{lat} {lon}"
get_geo_coords.short_description = "Координаты" get_geo_coords.short_description = "Координаты"
def get_satellite(self, obj): def get_satellite(self, obj):
"""Отображает спутник из связанного параметра.""" """Отображает спутник из связанного параметра."""
if hasattr(obj, 'parameter_obj') and obj.parameter_obj and obj.parameter_obj.id_satellite: if (
hasattr(obj, "parameter_obj")
and obj.parameter_obj
and obj.parameter_obj.id_satellite
):
return obj.parameter_obj.id_satellite.name return obj.parameter_obj.id_satellite.name
return "-" return "-"
get_satellite.short_description = "Спутник" get_satellite.short_description = "Спутник"
def get_frequency(self, obj): def get_frequency(self, obj):
"""Отображает частоту из связанного параметра.""" """Отображает частоту из связанного параметра."""
if hasattr(obj, 'parameter_obj') and obj.parameter_obj: if hasattr(obj, "parameter_obj") and obj.parameter_obj:
return obj.parameter_obj.frequency return obj.parameter_obj.frequency
return "-" return "-"
get_frequency.short_description = "Частота, МГц" get_frequency.short_description = "Частота, МГц"
def get_polarization(self, obj): def get_polarization(self, obj):
"""Отображает поляризацию из связанного параметра.""" """Отображает поляризацию из связанного параметра."""
if hasattr(obj, 'parameter_obj') and obj.parameter_obj and obj.parameter_obj.polarization: if (
hasattr(obj, "parameter_obj")
and obj.parameter_obj
and obj.parameter_obj.polarization
):
return obj.parameter_obj.polarization.name return obj.parameter_obj.polarization.name
return "-" return "-"
get_polarization.short_description = "Поляризация" get_polarization.short_description = "Поляризация"
def has_add_permission(self, request, obj=None): def has_add_permission(self, request, obj=None):
return False return False
@@ -917,6 +1017,7 @@ class ObjItemInline(admin.TabularInline):
@admin.register(Source) @admin.register(Source)
class SourceAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin, BaseAdmin): class SourceAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin, BaseAdmin):
"""Админ-панель для модели Source.""" """Админ-панель для модели Source."""
list_display = ("id", "created_at", "updated_at") list_display = ("id", "created_at", "updated_at")
list_filter = ( list_filter = (
("created_at", DateRangeQuickSelectListFilterBuilder()), ("created_at", DateRangeQuickSelectListFilterBuilder()),
@@ -925,13 +1026,17 @@ class SourceAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin, BaseAdmin):
ordering = ("-created_at",) ordering = ("-created_at",)
readonly_fields = ("created_at", "created_by", "updated_at", "updated_by") readonly_fields = ("created_at", "created_by", "updated_at", "updated_by")
inlines = [ObjItemInline] inlines = [ObjItemInline]
fieldsets = ( fieldsets = (
("Координаты: геолокация", { (
"fields": ("coords_kupsat", "coords_valid", "coords_reference") "Координаты: геолокация",
}), {"fields": ("coords_kupsat", "coords_valid", "coords_reference")},
("Метаданные", { ),
"fields": ("created_at", "created_by", "updated_at", "updated_by"), (
"classes": ("collapse",) "Метаданные",
}), {
"fields": ("created_at", "created_by", "updated_at", "updated_by"),
"classes": ("collapse",),
},
),
) )

View File

@@ -2,57 +2,55 @@
from django import forms from django import forms
# Local imports # Local imports
from .models import Geo, Modulation, ObjItem, Parameter, Polarization, Satellite, Standard from .models import (
from .widgets import TagSelectWidget Geo,
Modulation,
ObjItem,
Parameter,
Polarization,
Satellite,
Source,
Standard,
)
from .widgets import CheckboxSelectMultipleWidget
class UploadFileForm(forms.Form): class UploadFileForm(forms.Form):
file = forms.FileField( file = forms.FileField(
label="Выберите файл", label="Выберите файл",
widget=forms.FileInput(attrs={ widget=forms.FileInput(attrs={"class": "form-file-input"}),
'class': 'form-file-input'
})
) )
class LoadExcelData(forms.Form): class LoadExcelData(forms.Form):
file = forms.FileField( file = forms.FileField(
label="Выберите Excel файл", label="Выберите Excel файл",
widget=forms.FileInput(attrs={ widget=forms.FileInput(attrs={"class": "form-control", "accept": ".xlsx,.xls"}),
'class': 'form-control',
'accept': '.xlsx,.xls'
})
) )
sat_choice = forms.ModelChoiceField( sat_choice = forms.ModelChoiceField(
queryset=Satellite.objects.all(), queryset=Satellite.objects.all(),
label="Выберите спутник", label="Выберите спутник",
widget=forms.Select(attrs={ widget=forms.Select(attrs={"class": "form-select"}),
'class': 'form-select'
})
) )
number_input = forms.IntegerField( number_input = forms.IntegerField(
label="Введите число объектов", label="Введите число объектов",
min_value=0, min_value=0,
widget=forms.NumberInput(attrs={ widget=forms.NumberInput(attrs={"class": "form-control"}),
'class': 'form-control'
})
) )
class LoadCsvData(forms.Form): class LoadCsvData(forms.Form):
file = forms.FileField( file = forms.FileField(
label="Выберите CSV файл", label="Выберите CSV файл",
widget=forms.FileInput(attrs={ widget=forms.FileInput(attrs={"class": "form-control", "accept": ".csv"}),
'class': 'form-control',
'accept': '.csv'
})
) )
class UploadVchLoad(UploadFileForm): class UploadVchLoad(UploadFileForm):
sat_choice = forms.ModelChoiceField( sat_choice = forms.ModelChoiceField(
queryset=Satellite.objects.all(), queryset=Satellite.objects.all(),
label="Выберите спутник", label="Выберите спутник",
widget=forms.Select(attrs={ widget=forms.Select(attrs={"class": "form-select"}),
'class': 'form-select'
})
) )
@@ -60,9 +58,7 @@ class VchLinkForm(forms.Form):
sat_choice = forms.ModelChoiceField( sat_choice = forms.ModelChoiceField(
queryset=Satellite.objects.all(), queryset=Satellite.objects.all(),
label="Выберите спутник", label="Выберите спутник",
widget=forms.Select(attrs={ widget=forms.Select(attrs={"class": "form-select"}),
'class': 'form-select'
})
) )
# ku_range = forms.ChoiceField( # ku_range = forms.ChoiceField(
# choices=[(9750.0, '9750'), (10750.0, '10750')], # choices=[(9750.0, '9750'), (10750.0, '10750')],
@@ -74,18 +70,22 @@ class VchLinkForm(forms.Form):
label="Разброс по частоте (не используется)", label="Разброс по частоте (не используется)",
required=False, required=False,
initial=0.0, initial=0.0,
widget=forms.NumberInput(attrs={ widget=forms.NumberInput(
'class': 'form-control', attrs={
'placeholder': 'Не используется - погрешность определяется автоматически' "class": "form-control",
}) "placeholder": "Не используется - погрешность определяется автоматически",
}
),
) )
value2 = forms.FloatField( value2 = forms.FloatField(
label="Разброс по полосе (в %)", label="Разброс по полосе (в %)",
widget=forms.NumberInput(attrs={ widget=forms.NumberInput(
'class': 'form-control', attrs={
'placeholder': 'Введите погрешность полосы в процентах', "class": "form-control",
'step': '0.1' "placeholder": "Введите погрешность полосы в процентах",
}) "step": "0.1",
}
),
) )
@@ -106,256 +106,427 @@ class NewEventForm(forms.Form):
# ) # )
file = forms.FileField( file = forms.FileField(
label="Выберите файл", label="Выберите файл",
widget=forms.FileInput(attrs={ widget=forms.FileInput(attrs={"class": "form-control", "accept": ".xlsx,.xls"}),
'class': 'form-control',
'accept': '.xlsx,.xls'
})
) )
class FillLyngsatDataForm(forms.Form): class FillLyngsatDataForm(forms.Form):
"""Форма для заполнения данных из Lyngsat с поддержкой кеширования""" """Форма для заполнения данных из Lyngsat с поддержкой кеширования"""
REGION_CHOICES = [ REGION_CHOICES = [
('europe', 'Европа'), ("europe", "Европа"),
('asia', 'Азия'), ("asia", "Азия"),
('america', 'Америка'), ("america", "Америка"),
('atlantic', 'Атлантика'), ("atlantic", "Атлантика"),
] ]
satellites = forms.ModelMultipleChoiceField( satellites = forms.ModelMultipleChoiceField(
queryset=Satellite.objects.all().order_by('name'), queryset=Satellite.objects.all().order_by("name"),
label="Выберите спутники", label="Выберите спутники",
widget=forms.SelectMultiple(attrs={ widget=forms.SelectMultiple(attrs={"class": "form-select", "size": "10"}),
'class': 'form-select',
'size': '10'
}),
required=True, required=True,
help_text="Удерживайте Ctrl (Cmd на Mac) для выбора нескольких спутников" help_text="Удерживайте Ctrl (Cmd на Mac) для выбора нескольких спутников",
) )
regions = forms.MultipleChoiceField( regions = forms.MultipleChoiceField(
choices=REGION_CHOICES, choices=REGION_CHOICES,
label="Выберите регионы", label="Выберите регионы",
widget=forms.SelectMultiple(attrs={ widget=forms.SelectMultiple(attrs={"class": "form-select", "size": "4"}),
'class': 'form-select',
'size': '4'
}),
required=True, required=True,
initial=['europe', 'asia', 'america', 'atlantic'], initial=["europe", "asia", "america", "atlantic"],
help_text="Удерживайте Ctrl (Cmd на Mac) для выбора нескольких регионов" help_text="Удерживайте Ctrl (Cmd на Mac) для выбора нескольких регионов",
) )
use_cache = forms.BooleanField( use_cache = forms.BooleanField(
label="Использовать кеширование", label="Использовать кеширование",
required=False, required=False,
initial=True, initial=True,
widget=forms.CheckboxInput(attrs={ widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
'class': 'form-check-input' help_text="Использовать кешированные данные (ускоряет повторные запросы)",
}),
help_text="Использовать кешированные данные (ускоряет повторные запросы)"
) )
force_refresh = forms.BooleanField( force_refresh = forms.BooleanField(
label="Принудительно обновить данные", label="Принудительно обновить данные",
required=False, required=False,
initial=False, initial=False,
widget=forms.CheckboxInput(attrs={ widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
'class': 'form-check-input' help_text="Игнорировать кеш и получить свежие данные с сайта",
}),
help_text="Игнорировать кеш и получить свежие данные с сайта"
) )
class LinkLyngsatForm(forms.Form): class LinkLyngsatForm(forms.Form):
"""Форма для привязки источников LyngSat к объектам""" """Форма для привязки источников LyngSat к объектам"""
satellites = forms.ModelMultipleChoiceField( satellites = forms.ModelMultipleChoiceField(
queryset=Satellite.objects.all().order_by('name'), queryset=Satellite.objects.all().order_by("name"),
label="Выберите спутники", label="Выберите спутники",
widget=forms.SelectMultiple(attrs={ widget=forms.SelectMultiple(attrs={"class": "form-select", "size": "10"}),
'class': 'form-select',
'size': '10'
}),
required=False, required=False,
help_text="Оставьте пустым для обработки всех спутников" help_text="Оставьте пустым для обработки всех спутников",
) )
frequency_tolerance = forms.FloatField( frequency_tolerance = forms.FloatField(
label="Допуск по частоте (МГц)", label="Допуск по частоте (МГц)",
initial=0.5, initial=0.5,
min_value=0, min_value=0,
widget=forms.NumberInput(attrs={ widget=forms.NumberInput(attrs={"class": "form-control", "step": "0.1"}),
'class': 'form-control', help_text="Допустимое отклонение частоты при сравнении",
'step': '0.1'
}),
help_text="Допустимое отклонение частоты при сравнении"
) )
class ParameterForm(forms.ModelForm): class ParameterForm(forms.ModelForm):
""" """
Форма для создания и редактирования параметров ВЧ загрузки. Форма для создания и редактирования параметров ВЧ загрузки.
Работает с одним экземпляром Parameter, связанным с ObjItem через OneToOne связь. Работает с одним экземпляром Parameter, связанным с ObjItem через OneToOne связь.
""" """
class Meta: class Meta:
model = Parameter model = Parameter
fields = [ fields = [
'id_satellite', 'frequency', 'freq_range', 'polarization', "id_satellite",
'bod_velocity', 'modulation', 'snr', 'standard' "frequency",
"freq_range",
"polarization",
"bod_velocity",
"modulation",
"snr",
"standard",
] ]
widgets = { widgets = {
'id_satellite': forms.Select(attrs={ "id_satellite": forms.Select(
'class': 'form-select', attrs={"class": "form-select", "required": True}
'required': True ),
}), "frequency": forms.NumberInput(
'frequency': forms.NumberInput(attrs={ attrs={
'class': 'form-control', "class": "form-control",
'step': '0.000001', "step": "0.000001",
'min': '0', "min": "0",
'max': '50000', "max": "50000",
'placeholder': 'Введите частоту в МГц' "placeholder": "Введите частоту в МГц",
}), }
'freq_range': forms.NumberInput(attrs={ ),
'class': 'form-control', "freq_range": forms.NumberInput(
'step': '0.000001', attrs={
'min': '0', "class": "form-control",
'max': '1000', "step": "0.000001",
'placeholder': 'Введите полосу частот в МГц' "min": "0",
}), "max": "1000",
'bod_velocity': forms.NumberInput(attrs={ "placeholder": "Введите полосу частот в МГц",
'class': 'form-control', }
'step': '0.001', ),
'min': '0', "bod_velocity": forms.NumberInput(
'placeholder': 'Введите символьную скорость в БОД' attrs={
}), "class": "form-control",
'snr': forms.NumberInput(attrs={ "step": "0.001",
'class': 'form-control', "min": "0",
'step': '0.001', "placeholder": "Введите символьную скорость в БОД",
'min': '-50', }
'max': '100', ),
'placeholder': 'Введите ОСШ в дБ' "snr": forms.NumberInput(
}), attrs={
'polarization': forms.Select(attrs={'class': 'form-select'}), "class": "form-control",
'modulation': forms.Select(attrs={'class': 'form-select'}), "step": "0.001",
'standard': forms.Select(attrs={'class': 'form-select'}), "min": "-50",
"max": "100",
"placeholder": "Введите ОСШ в дБ",
}
),
"polarization": forms.Select(attrs={"class": "form-select"}),
"modulation": forms.Select(attrs={"class": "form-select"}),
"standard": forms.Select(attrs={"class": "form-select"}),
} }
labels = { labels = {
'id_satellite': 'Спутник', "id_satellite": "Спутник",
'frequency': 'Частота (МГц)', "frequency": "Частота (МГц)",
'freq_range': 'Полоса частот (МГц)', "freq_range": "Полоса частот (МГц)",
'polarization': 'Поляризация', "polarization": "Поляризация",
'bod_velocity': 'Символьная скорость (БОД)', "bod_velocity": "Символьная скорость (БОД)",
'modulation': 'Модуляция', "modulation": "Модуляция",
'snr': 'ОСШ (дБ)', "snr": "ОСШ (дБ)",
'standard': 'Стандарт', "standard": "Стандарт",
} }
help_texts = { help_texts = {
'frequency': 'Частота в диапазоне от 0 до 50000 МГц', "frequency": "Частота в диапазоне от 0 до 50000 МГц",
'freq_range': 'Полоса частот в диапазоне от 0 до 1000 МГц', "freq_range": "Полоса частот в диапазоне от 0 до 1000 МГц",
'bod_velocity': 'Символьная скорость должна быть положительной', "bod_velocity": "Символьная скорость должна быть положительной",
'snr': 'Отношение сигнал/шум в диапазоне от -50 до 100 дБ', "snr": "Отношение сигнал/шум в диапазоне от -50 до 100 дБ",
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Динамически загружаем choices для select полей # Динамически загружаем choices для select полей
self.fields['id_satellite'].queryset = Satellite.objects.all().order_by('name') self.fields["id_satellite"].queryset = Satellite.objects.all().order_by("name")
self.fields['polarization'].queryset = Polarization.objects.all().order_by('name') self.fields["polarization"].queryset = Polarization.objects.all().order_by(
self.fields['modulation'].queryset = Modulation.objects.all().order_by('name') "name"
self.fields['standard'].queryset = Standard.objects.all().order_by('name') )
self.fields["modulation"].queryset = Modulation.objects.all().order_by("name")
self.fields["standard"].queryset = Standard.objects.all().order_by("name")
# Делаем спутник обязательным полем # Делаем спутник обязательным полем
self.fields['id_satellite'].required = True self.fields["id_satellite"].required = True
def clean(self): def clean(self):
""" """
Дополнительная валидация формы. Дополнительная валидация формы.
Проверяет соотношение между частотой, полосой частот и символьной скоростью. Проверяет соотношение между частотой, полосой частот и символьной скоростью.
""" """
cleaned_data = super().clean() cleaned_data = super().clean()
frequency = cleaned_data.get('frequency') frequency = cleaned_data.get("frequency")
freq_range = cleaned_data.get('freq_range') freq_range = cleaned_data.get("freq_range")
bod_velocity = cleaned_data.get('bod_velocity') bod_velocity = cleaned_data.get("bod_velocity")
# Проверка что частота больше полосы частот # Проверка что частота больше полосы частот
if frequency and freq_range: if frequency and freq_range:
if freq_range > frequency: if freq_range > frequency:
self.add_error('freq_range', 'Полоса частот не может быть больше частоты') self.add_error(
"freq_range", "Полоса частот не может быть больше частоты"
)
# Проверка что символьная скорость соответствует полосе частот # Проверка что символьная скорость соответствует полосе частот
if bod_velocity and freq_range: if bod_velocity and freq_range:
if bod_velocity > freq_range * 1000000: # Конвертация МГц в Гц if bod_velocity > freq_range * 1000000: # Конвертация МГц в Гц
self.add_error('bod_velocity', 'Символьная скорость не может превышать полосу частот') self.add_error(
"bod_velocity",
"Символьная скорость не может превышать полосу частот",
)
return cleaned_data return cleaned_data
class GeoForm(forms.ModelForm): class GeoForm(forms.ModelForm):
class Meta: class Meta:
model = Geo model = Geo
fields = ['location', 'comment', 'is_average', 'mirrors'] fields = ["location", "comment", "is_average", "mirrors"]
widgets = { widgets = {
'location': forms.TextInput(attrs={'class': 'form-control'}), "location": forms.TextInput(attrs={"class": "form-control"}),
'comment': forms.TextInput(attrs={'class': 'form-control'}), "comment": forms.TextInput(attrs={"class": "form-control"}),
'is_average': forms.CheckboxInput(attrs={'class': 'form-check-input'}), "is_average": forms.CheckboxInput(attrs={"class": "form-check-input"}),
'mirrors': TagSelectWidget(attrs={'id': 'id_geo-mirrors'}), "mirrors": CheckboxSelectMultipleWidget(
attrs={
"id": "id_geo-mirrors",
"placeholder": "Выберите спутники...",
}
),
} }
labels = { labels = {
'location': 'Местоположение', "location": "Местоположение",
'comment': 'Комментарий', "comment": "Комментарий",
'is_average': 'Усреднённое', "is_average": "Усреднённое",
'mirrors': 'Спутники-зеркала, использованные для приёма', "mirrors": "Спутники-зеркала, использованные для приёма",
} }
help_texts = { help_texts = {
'mirrors': 'Начните вводить название спутника для поиска', "mirrors": "Выберите спутники из списка",
} }
class ObjItemForm(forms.ModelForm): class ObjItemForm(forms.ModelForm):
""" """
Форма для создания и редактирования объектов (источников сигнала). Форма для создания и редактирования объектов (источников сигнала).
Работает с моделью ObjItem. Параметры ВЧ загрузки обрабатываются отдельно Работает с моделью ObjItem. Параметры ВЧ загрузки обрабатываются отдельно
через ParameterForm с использованием OneToOne связи. через ParameterForm с использованием OneToOne связи.
""" """
class Meta: class Meta:
model = ObjItem model = ObjItem
fields = ['name'] fields = ["name"]
widgets = { widgets = {
'name': forms.TextInput(attrs={ "name": forms.TextInput(
'class': 'form-control', attrs={
'placeholder': 'Введите название объекта', "class": "form-control",
'maxlength': '100' "placeholder": "Введите название объекта",
}), "maxlength": "100",
}
),
} }
labels = { labels = {
'name': 'Название объекта', "name": "Название объекта",
} }
help_texts = { help_texts = {
'name': 'Уникальное название объекта/источника сигнала', "name": "Уникальное название объекта/источника сигнала",
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Делаем поле name необязательным, так как оно может быть пустым # Делаем поле name необязательным, так как оно может быть пустым
self.fields['name'].required = False self.fields["name"].required = False
def clean_name(self): def clean_name(self):
""" """
Валидация поля name. Валидация поля name.
Проверяет что название не состоит только из пробелов. Проверяет что название не состоит только из пробелов.
""" """
name = self.cleaned_data.get('name') name = self.cleaned_data.get("name")
if name: if name:
# Удаляем лишние пробелы # Удаляем лишние пробелы
name = name.strip() name = name.strip()
# Проверяем что после удаления пробелов что-то осталось # Проверяем что после удаления пробелов что-то осталось
if not name: if not name:
raise forms.ValidationError('Название не может состоять только из пробелов') raise forms.ValidationError(
"Название не может состоять только из пробелов"
return name )
return name
class SourceForm(forms.ModelForm):
"""Form for editing Source model with 4 coordinate fields."""
# Координаты ГЛ (coords_average)
average_latitude = forms.FloatField(
required=False,
widget=forms.NumberInput(
attrs={"class": "form-control", "step": "0.000001", "placeholder": "Широта"}
),
label="Широта ГЛ",
)
average_longitude = forms.FloatField(
required=False,
widget=forms.NumberInput(
attrs={
"class": "form-control",
"step": "0.000001",
"placeholder": "Долгота",
}
),
label="Долгота ГЛ",
)
# Координаты Кубсата (coords_kupsat)
kupsat_latitude = forms.FloatField(
required=False,
widget=forms.NumberInput(
attrs={"class": "form-control", "step": "0.000001", "placeholder": "Широта"}
),
label="Широта Кубсата",
)
kupsat_longitude = forms.FloatField(
required=False,
widget=forms.NumberInput(
attrs={
"class": "form-control",
"step": "0.000001",
"placeholder": "Долгота",
}
),
label="Долгота Кубсата",
)
# Координаты оперативников (coords_valid)
valid_latitude = forms.FloatField(
required=False,
widget=forms.NumberInput(
attrs={"class": "form-control", "step": "0.000001", "placeholder": "Широта"}
),
label="Широта оперативников",
)
valid_longitude = forms.FloatField(
required=False,
widget=forms.NumberInput(
attrs={
"class": "form-control",
"step": "0.000001",
"placeholder": "Долгота",
}
),
label="Долгота оперативников",
)
# Координаты справочные (coords_reference)
reference_latitude = forms.FloatField(
required=False,
widget=forms.NumberInput(
attrs={"class": "form-control", "step": "0.000001", "placeholder": "Широта"}
),
label="Широта справочные",
)
reference_longitude = forms.FloatField(
required=False,
widget=forms.NumberInput(
attrs={
"class": "form-control",
"step": "0.000001",
"placeholder": "Долгота",
}
),
label="Долгота справочные",
)
class Meta:
model = Source
fields = [] # Все поля обрабатываются вручную
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Заполняем поля координат из instance
if self.instance and self.instance.pk:
if self.instance.coords_average:
self.fields[
"average_longitude"
].initial = self.instance.coords_average.x
self.fields["average_latitude"].initial = self.instance.coords_average.y
if self.instance.coords_kupsat:
self.fields["kupsat_longitude"].initial = self.instance.coords_kupsat.x
self.fields["kupsat_latitude"].initial = self.instance.coords_kupsat.y
if self.instance.coords_valid:
self.fields["valid_longitude"].initial = self.instance.coords_valid.x
self.fields["valid_latitude"].initial = self.instance.coords_valid.y
if self.instance.coords_reference:
self.fields[
"reference_longitude"
].initial = self.instance.coords_reference.x
self.fields[
"reference_latitude"
].initial = self.instance.coords_reference.y
def save(self, commit=True):
from django.contrib.gis.geos import Point
instance = super().save(commit=False)
# Обработка coords_average
avg_lat = self.cleaned_data.get("average_latitude")
avg_lng = self.cleaned_data.get("average_longitude")
if avg_lat is not None and avg_lng is not None:
instance.coords_average = Point(avg_lng, avg_lat, srid=4326)
else:
instance.coords_average = None
# Обработка coords_kupsat
kup_lat = self.cleaned_data.get("kupsat_latitude")
kup_lng = self.cleaned_data.get("kupsat_longitude")
if kup_lat is not None and kup_lng is not None:
instance.coords_kupsat = Point(kup_lng, kup_lat, srid=4326)
else:
instance.coords_kupsat = None
# Обработка coords_valid
val_lat = self.cleaned_data.get("valid_latitude")
val_lng = self.cleaned_data.get("valid_longitude")
if val_lat is not None and val_lng is not None:
instance.coords_valid = Point(val_lng, val_lat, srid=4326)
else:
instance.coords_valid = None
# Обработка coords_reference
ref_lat = self.cleaned_data.get("reference_latitude")
ref_lng = self.cleaned_data.get("reference_longitude")
if ref_lat is not None and ref_lng is not None:
instance.coords_reference = Point(ref_lng, ref_lat, srid=4326)
else:
instance.coords_reference = None
if commit:
instance.save()
return instance

View File

@@ -0,0 +1,160 @@
.checkbox-multiselect-wrapper {
position: relative;
width: 100%;
}
.multiselect-input-container {
position: relative;
display: flex;
align-items: center;
min-height: 38px;
border: 1px solid #ced4da;
border-radius: 0.25rem;
background-color: #fff;
cursor: text;
padding: 4px 30px 4px 4px;
flex-wrap: wrap;
gap: 4px;
}
.multiselect-input-container:focus-within {
border-color: #86b7fe;
outline: 0;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
}
.multiselect-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
flex: 0 0 auto;
}
.multiselect-tag {
display: inline-flex;
align-items: center;
background-color: #e9ecef;
border: 1px solid #dee2e6;
border-radius: 0.25rem;
padding: 2px 8px;
font-size: 0.875rem;
line-height: 1.5;
white-space: nowrap;
}
.multiselect-tag-remove {
margin-left: 6px;
cursor: pointer;
color: #6c757d;
font-weight: bold;
border: none;
background: none;
padding: 0;
font-size: 1rem;
line-height: 1;
}
.multiselect-tag-remove:hover {
color: #dc3545;
}
.multiselect-search {
flex: 1 1 auto;
min-width: 120px;
border: none;
outline: none;
padding: 4px;
font-size: 0.875rem;
}
.multiselect-search:focus {
box-shadow: none;
}
.multiselect-clear {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
font-size: 1.5rem;
line-height: 1;
color: #6c757d;
cursor: pointer;
padding: 0;
width: 20px;
height: 20px;
display: none;
}
.multiselect-clear:hover {
color: #dc3545;
}
.multiselect-input-container.has-selections .multiselect-clear {
display: block;
}
.multiselect-dropdown {
position: absolute;
left: 0;
right: 0;
background-color: #fff;
border: 1px solid #ced4da;
border-radius: 0.25rem;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
max-height: 300px;
overflow-y: auto;
z-index: 1000;
display: none;
}
/* Открытие вверх (по умолчанию) */
.multiselect-dropdown {
bottom: 100%;
margin-bottom: 2px;
}
/* Открытие вниз (если места сверху недостаточно) */
.multiselect-dropdown.dropdown-below {
bottom: auto;
top: 100%;
margin-top: 2px;
margin-bottom: 0;
}
.multiselect-dropdown.show {
display: block;
}
.multiselect-options {
padding: 4px 0;
}
.multiselect-option {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
margin: 0;
transition: background-color 0.15s ease-in-out;
}
.multiselect-option:hover {
background-color: #f8f9fa;
}
.multiselect-option input[type="checkbox"] {
margin-right: 8px;
cursor: pointer;
}
.multiselect-option .option-label {
flex: 1;
user-select: none;
}
.multiselect-option.hidden {
display: none;
}

View File

@@ -0,0 +1,120 @@
/**
* Checkbox Select Multiple Widget
* Provides a multi-select dropdown with checkboxes and tag display
*/
document.addEventListener('DOMContentLoaded', function() {
// Initialize all checkbox multiselect widgets
document.querySelectorAll('.checkbox-multiselect-wrapper').forEach(function(wrapper) {
initCheckboxMultiselect(wrapper);
});
});
function initCheckboxMultiselect(wrapper) {
const widgetId = wrapper.dataset.widgetId;
const inputContainer = wrapper.querySelector('.multiselect-input-container');
const searchInput = wrapper.querySelector('.multiselect-search');
const dropdown = wrapper.querySelector('.multiselect-dropdown');
const tagsContainer = wrapper.querySelector('.multiselect-tags');
const clearButton = wrapper.querySelector('.multiselect-clear');
const checkboxes = wrapper.querySelectorAll('input[type="checkbox"]');
// Show dropdown when clicking on input container
inputContainer.addEventListener('click', function(e) {
if (e.target !== clearButton) {
positionDropdown();
dropdown.classList.add('show');
searchInput.focus();
}
});
// Position dropdown (up or down based on available space)
function positionDropdown() {
const rect = inputContainer.getBoundingClientRect();
const spaceAbove = rect.top;
const spaceBelow = window.innerHeight - rect.bottom;
const dropdownHeight = 300; // max-height from CSS
// If more space below and enough space, open downward
if (spaceBelow > spaceAbove && spaceBelow >= dropdownHeight) {
dropdown.classList.add('dropdown-below');
} else {
dropdown.classList.remove('dropdown-below');
}
}
// Hide dropdown when clicking outside
document.addEventListener('click', function(e) {
if (!wrapper.contains(e.target)) {
dropdown.classList.remove('show');
}
});
// Search functionality
searchInput.addEventListener('input', function() {
const searchTerm = this.value.toLowerCase();
const options = wrapper.querySelectorAll('.multiselect-option');
options.forEach(function(option) {
const label = option.querySelector('.option-label').textContent.toLowerCase();
if (label.includes(searchTerm)) {
option.classList.remove('hidden');
} else {
option.classList.add('hidden');
}
});
});
// Handle checkbox changes
checkboxes.forEach(function(checkbox) {
checkbox.addEventListener('change', function() {
updateTags();
});
});
// Clear all button
clearButton.addEventListener('click', function(e) {
e.stopPropagation();
checkboxes.forEach(function(checkbox) {
checkbox.checked = false;
});
updateTags();
});
// Update tags display
function updateTags() {
tagsContainer.innerHTML = '';
let hasSelections = false;
checkboxes.forEach(function(checkbox) {
if (checkbox.checked) {
hasSelections = true;
const tag = document.createElement('div');
tag.className = 'multiselect-tag';
tag.innerHTML = `
<span>${checkbox.dataset.label}</span>
<button type="button" class="multiselect-tag-remove" data-value="${checkbox.value}">×</button>
`;
// Remove tag on click
tag.querySelector('.multiselect-tag-remove').addEventListener('click', function(e) {
e.stopPropagation();
checkbox.checked = false;
updateTags();
});
tagsContainer.appendChild(tag);
}
});
// Show/hide clear button
if (hasSelections) {
inputContainer.classList.add('has-selections');
} else {
inputContainer.classList.remove('has-selections');
}
}
// Initialize tags on load
updateTags();
}

View File

@@ -22,24 +22,25 @@
{% include 'mainapp/components/_column_toggle_item.html' with column_index=0 column_label="Выбрать" checked=True %} {% include 'mainapp/components/_column_toggle_item.html' with column_index=0 column_label="Выбрать" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=1 column_label="Имя" checked=True %} {% include 'mainapp/components/_column_toggle_item.html' with column_index=1 column_label="Имя" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=2 column_label="Спутник" checked=True %} {% include 'mainapp/components/_column_toggle_item.html' with column_index=2 column_label="Спутник" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=3 column_label="Част, МГц" checked=True %} {% include 'mainapp/components/_column_toggle_item.html' with column_index=3 column_label="Транспондер" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=4 column_label="Полоса, МГц" checked=True %} {% include 'mainapp/components/_column_toggle_item.html' with column_index=4 column_label="Част, МГц" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=5 column_label="Поляризация" checked=True %} {% include 'mainapp/components/_column_toggle_item.html' with column_index=5 column_label="Полоса, МГц" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=6 column_label="Сим. V" checked=True %} {% include 'mainapp/components/_column_toggle_item.html' with column_index=6 column_label="Поляризация" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=7 column_label="Модул" checked=True %} {% include 'mainapp/components/_column_toggle_item.html' with column_index=7 column_label="Сим. V" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=8 column_label="ОСШ" checked=True %} {% include 'mainapp/components/_column_toggle_item.html' with column_index=8 column_label="Модул" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=9 column_label="Время ГЛ" checked=True %} {% include 'mainapp/components/_column_toggle_item.html' with column_index=9 column_label="ОСШ" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=10 column_label="Местоположение" checked=True %} {% include 'mainapp/components/_column_toggle_item.html' with column_index=10 column_label="Время ГЛ" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=11 column_label="Геолокация" checked=True %} {% include 'mainapp/components/_column_toggle_item.html' with column_index=11 column_label="Местоположение" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=12 column_label="Обновлено" checked=True %} {% include 'mainapp/components/_column_toggle_item.html' with column_index=12 column_label="Геолокация" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=13 column_label="Кем (обновление)" checked=True %} {% include 'mainapp/components/_column_toggle_item.html' with column_index=13 column_label="Обновлено" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=14 column_label="Создано" checked=True %} {% include 'mainapp/components/_column_toggle_item.html' with column_index=14 column_label="Кем (обновление)" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=15 column_label="Кем (создание)" checked=True %} {% include 'mainapp/components/_column_toggle_item.html' with column_index=15 column_label="Создано" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=16 column_label="Комментарий" checked=False %} {% include 'mainapp/components/_column_toggle_item.html' with column_index=16 column_label="Кем (создание)" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=17 column_label="Усреднённое" checked=False %} {% include 'mainapp/components/_column_toggle_item.html' with column_index=17 column_label="Комментарий" checked=False %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=18 column_label="Стандарт" checked=False %} {% include 'mainapp/components/_column_toggle_item.html' with column_index=18 column_label="Усреднённое" checked=False %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=19 column_label="Тип источника" checked=True %} {% include 'mainapp/components/_column_toggle_item.html' with column_index=19 column_label="Стандарт" checked=False %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=20 column_label="Sigma" checked=True %} {% include 'mainapp/components/_column_toggle_item.html' with column_index=20 column_label="Тип источника" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=21 column_label="Зеркала" checked=True %} {% include 'mainapp/components/_column_toggle_item.html' with column_index=21 column_label="Sigma" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=22 column_label="Зеркала" checked=True %}
</ul> </ul>
</div> </div>

View File

@@ -32,6 +32,7 @@
</th> </th>
<th scope="col">Имя</th> <th scope="col">Имя</th>
<th scope="col">Спутник</th> <th scope="col">Спутник</th>
<th scope="col">Транспондер</th>
<th scope="col">Част, МГц</th> <th scope="col">Част, МГц</th>
<th scope="col">Полоса, МГц</th> <th scope="col">Полоса, МГц</th>
<th scope="col">Поляризация</th> <th scope="col">Поляризация</th>
@@ -41,8 +42,6 @@
<th scope="col">Время ГЛ</th> <th scope="col">Время ГЛ</th>
<th scope="col">Местоположение</th> <th scope="col">Местоположение</th>
<th scope="col">Геолокация</th> <th scope="col">Геолокация</th>
<th scope="col">Кубсат</th>
<th scope="col">Опер. отд</th>
<th scope="col">Обновлено</th> <th scope="col">Обновлено</th>
<th scope="col">Кем(обн)</th> <th scope="col">Кем(обн)</th>
<th scope="col">Создано</th> <th scope="col">Создано</th>

View File

@@ -6,27 +6,93 @@
{% block title %}{% if object %}Редактировать объект: {{ object.name }}{% else %}Создать объект{% endif %}{% endblock %} {% block title %}{% if object %}Редактировать объект: {{ object.name }}{% else %}Создать объект{% endif %}{% endblock %}
{% block extra_css %} {% block extra_css %}
<link rel="stylesheet" href="{% static 'css/checkbox-select-multiple.css' %}">
<style> <style>
.form-section { margin-bottom: 2rem; border: 1px solid #dee2e6; border-radius: 0.25rem; padding: 1rem; } .form-section {
.form-section-header { border-bottom: 1px solid #dee2e6; padding-bottom: 0.5rem; margin-bottom: 1rem; } margin-bottom: 2rem;
.btn-action { margin-right: 0.5rem; } border: 1px solid #dee2e6;
.dynamic-form { border: 1px dashed #ced4da; padding: 1rem; margin-top: 1rem; border-radius: 0.25rem; } border-radius: 0.25rem;
.dynamic-form-header { display: flex; justify-content: space-between; align-items: center; } padding: 1rem;
.readonly-field { background-color: #f8f9fa; padding: 0.375rem 0.75rem; border: 1px solid #ced4da; border-radius: 0.25rem; } }
.coord-group { border: 1px solid #dee2e6; padding: 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem; }
.coord-group-header { font-weight: bold; margin-bottom: 0.5rem; } .form-section-header {
.form-check-input { margin-top: 0.25rem; } border-bottom: 1px solid #dee2e6;
.datetime-group { display: flex; gap: 1rem; } padding-bottom: 0.5rem;
.datetime-group > div { flex: 1; } margin-bottom: 1rem;
#map { height: 500px; width: 100%; margin-bottom: 1rem; } }
.map-container { margin-bottom: 1rem; }
.coord-sync-group { border: 1px solid #dee2e6; padding: 0.75rem; border-radius: 0.25rem; } .btn-action {
margin-right: 0.5rem;
}
.dynamic-form {
border: 1px dashed #ced4da;
padding: 1rem;
margin-top: 1rem;
border-radius: 0.25rem;
}
.dynamic-form-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.readonly-field {
background-color: #f8f9fa;
padding: 0.375rem 0.75rem;
border: 1px solid #ced4da;
border-radius: 0.25rem;
}
.coord-group {
border: 1px solid #dee2e6;
padding: 0.75rem;
border-radius: 0.25rem;
margin-bottom: 1rem;
}
.coord-group-header {
font-weight: bold;
margin-bottom: 0.5rem;
}
.form-check-input {
margin-top: 0.25rem;
}
.datetime-group {
display: flex;
gap: 1rem;
}
.datetime-group>div {
flex: 1;
}
#map {
height: 500px;
width: 100%;
margin-bottom: 1rem;
}
.map-container {
margin-bottom: 1rem;
}
.coord-sync-group {
border: 1px solid #dee2e6;
padding: 0.75rem;
border-radius: 0.25rem;
}
.map-controls { .map-controls {
display: flex; display: flex;
gap: 10px; gap: 10px;
margin-bottom: 1rem; margin-bottom: 1rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
.map-control-btn { .map-control-btn {
padding: 0.375rem 0.75rem; padding: 0.375rem 0.75rem;
border: 1px solid #ced4da; border: 1px solid #ced4da;
@@ -34,25 +100,43 @@
border-radius: 0.25rem; border-radius: 0.25rem;
cursor: pointer; cursor: pointer;
} }
.map-control-btn.active { .map-control-btn.active {
background-color: #e9ecef; background-color: #e9ecef;
border-color: #dee2e6; border-color: #dee2e6;
} }
.map-control-btn.edit { .map-control-btn.edit {
background-color: #fff3cd; background-color: #fff3cd;
border-color: #ffeeba; border-color: #ffeeba;
} }
.map-control-btn.save { .map-control-btn.save {
background-color: #d1ecf1; background-color: #d1ecf1;
border-color: #bee5eb; border-color: #bee5eb;
} }
.map-control-btn.cancel { .map-control-btn.cancel {
background-color: #f8d7da; background-color: #f8d7da;
border-color: #f5c6cb; border-color: #f5c6cb;
} }
.leaflet-marker-icon { .leaflet-marker-icon {
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.3)); filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.3));
} }
/* Select2 custom styling */
.select2-container--default .select2-selection--multiple {
border: 1px solid #ced4da;
border-radius: 0.25rem;
min-height: 38px;
}
.select2-container--default.select2-container--focus .select2-selection--multiple {
border-color: #86b7fe;
outline: 0;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
}
</style> </style>
{% endblock %} {% endblock %}
@@ -65,10 +149,12 @@
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %} {% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
<button type="submit" form="objitem-form" class="btn btn-primary btn-action">Сохранить</button> <button type="submit" form="objitem-form" class="btn btn-primary btn-action">Сохранить</button>
{% if object %} {% if object %}
<a href="{% url 'mainapp:objitem_delete' object.id %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-action">Удалить</a> <a href="{% url 'mainapp:objitem_delete' object.id %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}"
class="btn btn-danger btn-action">Удалить</a>
{% endif %} {% endif %}
{% endif %} {% endif %}
<a href="{% url 'mainapp:objitem_list' %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-secondary btn-action">Назад</a> <a href="{% url 'mainapp:objitem_list' %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}"
class="btn btn-secondary btn-action">Назад</a>
</div> </div>
</div> </div>
</div> </div>
@@ -186,16 +272,16 @@
<div class="col-md-6"> <div class="col-md-6">
<div class="mb-3"> <div class="mb-3">
<label for="id_geo_latitude" class="form-label">Широта:</label> <label for="id_geo_latitude" class="form-label">Широта:</label>
<input type="number" step="0.000001" class="form-control" <input type="number" step="0.000001" class="form-control" id="id_geo_latitude"
id="id_geo_latitude" name="geo_latitude" name="geo_latitude"
value="{% if object.geo_obj and object.geo_obj.coords %}{{ object.geo_obj.coords.y|unlocalize }}{% endif %}"> value="{% if object.geo_obj and object.geo_obj.coords %}{{ object.geo_obj.coords.y|unlocalize }}{% endif %}">
</div> </div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div class="mb-3"> <div class="mb-3">
<label for="id_geo_longitude" class="form-label">Долгота:</label> <label for="id_geo_longitude" class="form-label">Долгота:</label>
<input type="number" step="0.000001" class="form-control" <input type="number" step="0.000001" class="form-control" id="id_geo_longitude"
id="id_geo_longitude" name="geo_longitude" name="geo_longitude"
value="{% if object.geo_obj and object.geo_obj.coords %}{{ object.geo_obj.coords.x|unlocalize }}{% endif %}"> value="{% if object.geo_obj and object.geo_obj.coords %}{{ object.geo_obj.coords.x|unlocalize }}{% endif %}">
</div> </div>
</div> </div>
@@ -218,15 +304,13 @@
<div class="datetime-group"> <div class="datetime-group">
<div> <div>
<label for="id_timestamp_date" class="form-label">Дата:</label> <label for="id_timestamp_date" class="form-label">Дата:</label>
<input type="date" class="form-control" <input type="date" class="form-control" id="id_timestamp_date" name="timestamp_date"
id="id_timestamp_date" name="timestamp_date" value="{% if object.geo_obj and object.geo_obj.timestamp %}{{ object.geo_obj.timestamp|date:'Y-m-d' }}{% endif %}">
value="{% if object.geo_obj and object.geo_obj.timestamp %}{{ object.geo_obj.timestamp|date:'Y-m-d' }}{% endif %}">
</div> </div>
<div> <div>
<label for="id_timestamp_time" class="form-label">Время:</label> <label for="id_timestamp_time" class="form-label">Время:</label>
<input type="time" class="form-control" <input type="time" class="form-control" id="id_timestamp_time" name="timestamp_time"
id="id_timestamp_time" name="timestamp_time" value="{% if object.geo_obj and object.geo_obj.timestamp %}{{ object.geo_obj.timestamp|time:'H:i' }}{% endif %}">
value="{% if object.geo_obj and object.geo_obj.timestamp %}{{ object.geo_obj.timestamp|time:'H:i' }}{% endif %}">
</div> </div>
</div> </div>
</div> </div>
@@ -253,201 +337,204 @@
{% leaflet_css %} {% leaflet_css %}
<script src="{% static 'leaflet-markers/js/leaflet-color-markers.js' %}"></script> <script src="{% static 'leaflet-markers/js/leaflet-color-markers.js' %}"></script>
<!-- Подключаем кастомный виджет для мультивыбора -->
<script src="{% static 'js/checkbox-select-multiple.js' %}"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function () {
// Инициализация карты // Инициализация карты
const map = L.map('map').setView([55.75, 37.62], 5); const map = L.map('map').setView([55.75, 37.62], 5);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map); }).addTo(map);
// Функция для создания иконки маркера // Функция для создания иконки маркера
function createMarkerIcon() { function createMarkerIcon() {
return L.icon({ return L.icon({
iconUrl: '{% static "leaflet-markers/img/marker-icon-blue.png" %}', iconUrl: '{% static "leaflet-markers/img/marker-icon-blue.png" %}',
shadowUrl: `{% static 'leaflet-markers/img/marker-shadow.png' %}`, shadowUrl: `{% static 'leaflet-markers/img/marker-shadow.png' %}`,
iconSize: [25, 41], iconSize: [25, 41],
iconAnchor: [12, 41], iconAnchor: [12, 41],
popupAnchor: [1, -34], popupAnchor: [1, -34],
shadowSize: [41, 41] shadowSize: [41, 41]
});
}
const editableLayerGroup = new L.FeatureGroup();
map.addLayer(editableLayerGroup);
// Маркер геолокации
const marker = L.marker([55.75, 37.62], {
draggable: false,
icon: createMarkerIcon(),
title: 'Геолокация'
}).addTo(editableLayerGroup);
marker.bindPopup('Геолокация');
// Синхронизация при изменении формы
function syncFromForm() {
const lat = parseFloat(document.getElementById('id_geo_latitude').value);
const lng = parseFloat(document.getElementById('id_geo_longitude').value);
if (!isNaN(lat) && !isNaN(lng)) {
marker.setLatLng([lat, lng]);
}
}
// Синхронизация при перетаскивании (только если активировано)
marker.on('dragend', function(event) {
const latLng = event.target.getLatLng();
document.getElementById('id_geo_latitude').value = latLng.lat.toFixed(6);
document.getElementById('id_geo_longitude').value = latLng.lng.toFixed(6);
});
// Добавляем методы для управления
marker.enableEditing = function() {
this.dragging.enable();
this.openPopup();
};
marker.disableEditing = function() {
this.dragging.disable();
this.closePopup();
};
marker.syncFromForm = syncFromForm;
// Устанавливаем начальные координаты из полей формы
function initMarkersFromForm() {
const geoLat = parseFloat(document.getElementById('id_geo_latitude').value) || 55.75;
const geoLng = parseFloat(document.getElementById('id_geo_longitude').value) || 37.62;
marker.setLatLng([geoLat, geoLng]);
// Центрируем карту на маркере
map.setView(marker.getLatLng(), 10);
}
// Настройка формы для синхронизации с маркером
function setupFormChange(latFieldId, lngFieldId, marker) {
const latField = document.getElementById(latFieldId);
const lngField = document.getElementById(lngFieldId);
[latField, lngField].forEach(field => {
field.addEventListener('change', function() {
const lat = parseFloat(latField.value);
const lng = parseFloat(lngField.value);
if (!isNaN(lat) && !isNaN(lng)) {
marker.setLatLng([lat, lng]);
map.setView(marker.getLatLng(), 10);
}
}); });
}); }
} const editableLayerGroup = new L.FeatureGroup();
map.addLayer(editableLayerGroup);
// Инициализация // Маркер геолокации
initMarkersFromForm(); const marker = L.marker([55.75, 37.62], {
// Настройка формы для синхронизации с маркером draggable: false,
setupFormChange('id_geo_latitude', 'id_geo_longitude', marker); icon: createMarkerIcon(),
// --- УПРАВЛЕНИЕ РЕДАКТИРОВАНИЕМ --- title: 'Геолокация'
// Кнопки редактирования }).addTo(editableLayerGroup);
const editControlsDiv = L.DomUtil.create('div', 'map-controls'); marker.bindPopup('Геолокация');
editControlsDiv.style.position = 'absolute';
editControlsDiv.style.top = '10px'; // Синхронизация при изменении формы
editControlsDiv.style.right = '10px'; function syncFromForm() {
editControlsDiv.style.zIndex = '1000'; const lat = parseFloat(document.getElementById('id_geo_latitude').value);
editControlsDiv.style.background = 'white'; const lng = parseFloat(document.getElementById('id_geo_longitude').value);
editControlsDiv.style.padding = '10px'; if (!isNaN(lat) && !isNaN(lng)) {
editControlsDiv.style.borderRadius = '4px'; marker.setLatLng([lat, lng]);
editControlsDiv.style.boxShadow = '0 0 10px rgba(0,0,0,0.2)'; }
editControlsDiv.innerHTML = ` }
// Синхронизация при перетаскивании (только если активировано)
marker.on('dragend', function (event) {
const latLng = event.target.getLatLng();
document.getElementById('id_geo_latitude').value = latLng.lat.toFixed(6);
document.getElementById('id_geo_longitude').value = latLng.lng.toFixed(6);
});
// Добавляем методы для управления
marker.enableEditing = function () {
this.dragging.enable();
this.openPopup();
};
marker.disableEditing = function () {
this.dragging.disable();
this.closePopup();
};
marker.syncFromForm = syncFromForm;
// Устанавливаем начальные координаты из полей формы
function initMarkersFromForm() {
const geoLat = parseFloat(document.getElementById('id_geo_latitude').value) || 55.75;
const geoLng = parseFloat(document.getElementById('id_geo_longitude').value) || 37.62;
marker.setLatLng([geoLat, geoLng]);
// Центрируем карту на маркере
map.setView(marker.getLatLng(), 10);
}
// Настройка формы для синхронизации с маркером
function setupFormChange(latFieldId, lngFieldId, marker) {
const latField = document.getElementById(latFieldId);
const lngField = document.getElementById(lngFieldId);
[latField, lngField].forEach(field => {
field.addEventListener('change', function () {
const lat = parseFloat(latField.value);
const lng = parseFloat(lngField.value);
if (!isNaN(lat) && !isNaN(lng)) {
marker.setLatLng([lat, lng]);
map.setView(marker.getLatLng(), 10);
}
});
});
}
// Инициализация
initMarkersFromForm();
// Настройка формы для синхронизации с маркером
setupFormChange('id_geo_latitude', 'id_geo_longitude', marker);
// --- УПРАВЛЕНИЕ РЕДАКТИРОВАНИЕМ ---
// Кнопки редактирования
const editControlsDiv = L.DomUtil.create('div', 'map-controls');
editControlsDiv.style.position = 'absolute';
editControlsDiv.style.top = '10px';
editControlsDiv.style.right = '10px';
editControlsDiv.style.zIndex = '1000';
editControlsDiv.style.background = 'white';
editControlsDiv.style.padding = '10px';
editControlsDiv.style.borderRadius = '4px';
editControlsDiv.style.boxShadow = '0 0 10px rgba(0,0,0,0.2)';
editControlsDiv.innerHTML = `
<div class="map-controls"> <div class="map-controls">
<button type="button" id="edit-btn" class="map-control-btn edit">Редактировать</button> <button type="button" id="edit-btn" class="map-control-btn edit">Редактировать</button>
<button type="button" id="save-btn" class="map-control-btn save" disabled>Сохранить</button> <button type="button" id="save-btn" class="map-control-btn save" disabled>Сохранить</button>
<button type="button" id="cancel-btn" class="map-control-btn cancel" disabled>Отмена</button> <button type="button" id="cancel-btn" class="map-control-btn cancel" disabled>Отмена</button>
</div> </div>
`; `;
map.getContainer().appendChild(editControlsDiv); map.getContainer().appendChild(editControlsDiv);
let isEditing = false; let isEditing = false;
// Сохраняем начальные координаты для отмены // Сохраняем начальные координаты для отмены
const initialPosition = marker.getLatLng(); const initialPosition = marker.getLatLng();
// Включение редактирования // Включение редактирования
document.getElementById('edit-btn').addEventListener('click', function() { document.getElementById('edit-btn').addEventListener('click', function () {
if (isEditing) return; if (isEditing) return;
isEditing = true; isEditing = true;
document.getElementById('edit-btn').classList.add('active'); document.getElementById('edit-btn').classList.add('active');
document.getElementById('save-btn').disabled = false; document.getElementById('save-btn').disabled = false;
document.getElementById('cancel-btn').disabled = false; document.getElementById('cancel-btn').disabled = false;
// Включаем drag для маркера // Включаем drag для маркера
marker.enableEditing(); marker.enableEditing();
// Показываем подсказку // Показываем подсказку
L.popup() L.popup()
.setLatLng(map.getCenter()) .setLatLng(map.getCenter())
.setContent('Перетаскивайте маркер. Нажмите "Сохранить" или "Отмена".') .setContent('Перетаскивайте маркер. Нажмите "Сохранить" или "Отмена".')
.openOn(map); .openOn(map);
}); });
// Сохранение изменений // Сохранение изменений
document.getElementById('save-btn').addEventListener('click', function() { document.getElementById('save-btn').addEventListener('click', function () {
if (!isEditing) return; if (!isEditing) return;
isEditing = false; isEditing = false;
document.getElementById('edit-btn').classList.remove('active'); document.getElementById('edit-btn').classList.remove('active');
document.getElementById('save-btn').disabled = true; document.getElementById('save-btn').disabled = true;
document.getElementById('cancel-btn').disabled = true; document.getElementById('cancel-btn').disabled = true;
// Отключаем редактирование // Отключаем редактирование
marker.disableEditing(); marker.disableEditing();
// Обновляем начальную позицию // Обновляем начальную позицию
initialPosition.lat = marker.getLatLng().lat; initialPosition.lat = marker.getLatLng().lat;
initialPosition.lng = marker.getLatLng().lng; initialPosition.lng = marker.getLatLng().lng;
// Убираем попап подсказки // Убираем попап подсказки
map.closePopup(); map.closePopup();
}); });
// Отмена изменений // Отмена изменений
document.getElementById('cancel-btn').addEventListener('click', function() { document.getElementById('cancel-btn').addEventListener('click', function () {
if (!isEditing) return; if (!isEditing) return;
isEditing = false; isEditing = false;
document.getElementById('edit-btn').classList.remove('active'); document.getElementById('edit-btn').classList.remove('active');
document.getElementById('save-btn').disabled = true; document.getElementById('save-btn').disabled = true;
document.getElementById('cancel-btn').disabled = true; document.getElementById('cancel-btn').disabled = true;
// Возвращаем маркер на исходную позицию // Возвращаем маркер на исходную позицию
marker.setLatLng(initialPosition); marker.setLatLng(initialPosition);
// Отключаем редактирование // Отключаем редактирование
marker.disableEditing(); marker.disableEditing();
// Синхронизируем форму с исходным значением // Синхронизируем форму с исходным значением
document.getElementById('id_geo_latitude').value = initialPosition.lat.toFixed(6); document.getElementById('id_geo_latitude').value = initialPosition.lat.toFixed(6);
document.getElementById('id_geo_longitude').value = initialPosition.lng.toFixed(6); document.getElementById('id_geo_longitude').value = initialPosition.lng.toFixed(6);
map.closePopup(); map.closePopup();
}); });
// Легенда // Легенда
const legend = L.control({ position: 'bottomright' }); const legend = L.control({ position: 'bottomright' });
legend.onAdd = function() { legend.onAdd = function () {
const div = L.DomUtil.create('div', 'info legend'); const div = L.DomUtil.create('div', 'info legend');
div.style.fontSize = '14px'; div.style.fontSize = '14px';
div.style.backgroundColor = 'white'; div.style.backgroundColor = 'white';
div.style.padding = '10px'; div.style.padding = '10px';
div.style.borderRadius = '4px'; div.style.borderRadius = '4px';
div.style.boxShadow = '0 0 10px rgba(0,0,0,0.2)'; div.style.boxShadow = '0 0 10px rgba(0,0,0,0.2)';
div.innerHTML = ` div.innerHTML = `
<h5>Легенда</h5> <h5>Легенда</h5>
<div><span style="color: blue; font-weight: bold;">•</span> Геолокация</div> <div><span style="color: blue; font-weight: bold;">•</span> Геолокация</div>
`; `;
return div; return div;
}; };
legend.addTo(map); legend.addTo(map);
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -70,7 +70,8 @@
<!-- Filter Toggle Button --> <!-- Filter Toggle Button -->
<div> <div>
<button class="btn btn-outline-primary btn-sm" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasFilters" aria-controls="offcanvasFilters"> <button class="btn btn-outline-primary btn-sm" type="button" data-bs-toggle="offcanvas"
data-bs-target="#offcanvasFilters" aria-controls="offcanvasFilters">
<i class="bi bi-funnel"></i> Фильтры <i class="bi bi-funnel"></i> Фильтры
<span id="filterCounter" class="badge bg-danger" style="display: none;">0</span> <span id="filterCounter" class="badge bg-danger" style="display: none;">0</span>
</button> </button>
@@ -82,10 +83,11 @@
<i class="bi bi-plus-circle"></i> Добавить к <i class="bi bi-plus-circle"></i> Добавить к
</button> </button>
</div> </div>
<!-- Selected Items Counter Button --> <!-- Selected Items Counter Button -->
<div> <div>
<button class="btn btn-outline-info btn-sm" type="button" data-bs-toggle="offcanvas" data-bs-target="#selectedItemsOffcanvas" aria-controls="selectedItemsOffcanvas"> <button class="btn btn-outline-info btn-sm" type="button" data-bs-toggle="offcanvas"
data-bs-target="#selectedItemsOffcanvas" aria-controls="selectedItemsOffcanvas">
<i class="bi bi-list-check"></i> Список <i class="bi bi-list-check"></i> Список
<span id="selectedCounter" class="badge bg-info" style="display: none;">0</span> <span id="selectedCounter" class="badge bg-info" style="display: none;">0</span>
</button> </button>
@@ -112,295 +114,277 @@
</div> </div>
<div class="offcanvas-body"> <div class="offcanvas-body">
<form method="get" id="filter-form"> <form method="get" id="filter-form">
<!-- Satellite Selection - Multi-select --> <!-- Satellite Selection - Multi-select -->
<div class="mb-2"> <div class="mb-2">
<label class="form-label">Спутник:</label> <label class="form-label">Спутник:</label>
<div class="d-flex justify-content-between mb-1"> <div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary" <button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('satellite_id', true)">Выбрать</button> onclick="selectAllOptions('satellite_id', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary" <button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('satellite_id', false)">Снять</button> onclick="selectAllOptions('satellite_id', false)">Снять</button>
</div> </div>
<select name="satellite_id" class="form-select form-select-sm mb-2" multiple size="6"> <select name="satellite_id" class="form-select form-select-sm mb-2" multiple size="6">
{% for satellite in satellites %} {% for satellite in satellites %}
<option value="{{ satellite.id }}" {% if satellite.id in selected_satellites %}selected{% endif %}> <option value="{{ satellite.id }}" {% if satellite.id in selected_satellites %}selected{% endif %}>
{{ satellite.name }} {{ satellite.name }}
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
</div>
<!-- Frequency Filter -->
<div class="mb-2">
<label class="form-label">Частота, МГц:</label>
<input type="number" step="0.001" name="freq_min" class="form-control form-control-sm mb-1"
placeholder="От" value="{{ freq_min|default:'' }}">
<input type="number" step="0.001" name="freq_max" class="form-control form-control-sm"
placeholder="До" value="{{ freq_max|default:'' }}">
</div>
<!-- Range Filter -->
<div class="mb-2">
<label class="form-label">Полоса, МГц:</label>
<input type="number" step="0.001" name="range_min" class="form-control form-control-sm mb-1"
placeholder="От" value="{{ range_min|default:'' }}">
<input type="number" step="0.001" name="range_max" class="form-control form-control-sm"
placeholder="До" value="{{ range_max|default:'' }}">
</div>
<!-- SNR Filter -->
<div class="mb-2">
<label class="form-label">ОСШ:</label>
<input type="number" step="0.001" name="snr_min" class="form-control form-control-sm mb-1"
placeholder="От" value="{{ snr_min|default:'' }}">
<input type="number" step="0.001" name="snr_max" class="form-control form-control-sm"
placeholder="До" value="{{ snr_max|default:'' }}">
</div>
<!-- Symbol Rate Filter -->
<div class="mb-2">
<label class="form-label">Сим. v, БОД:</label>
<input type="number" step="0.001" name="bod_min" class="form-control form-control-sm mb-1"
placeholder="От" value="{{ bod_min|default:'' }}">
<input type="number" step="0.001" name="bod_max" class="form-control form-control-sm"
placeholder="До" value="{{ bod_max|default:'' }}">
</div>
<!-- Modulation Filter -->
<div class="mb-2">
<label class="form-label">Модуляция:</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('modulation', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('modulation', false)">Снять</button>
</div>
<select name="modulation" class="form-select form-select-sm mb-2" multiple size="6">
{% for mod in modulations %}
<option value="{{ mod.id }}" {% if mod.id in selected_modulations %}selected{% endif %}>
{{ mod.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Polarization Filter -->
<div class="mb-2">
<label class="form-label">Поляризация:</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('polarization', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('polarization', false)">Снять</button>
</div>
<select name="polarization" class="form-select form-select-sm mb-2" multiple size="4">
{% for pol in polarizations %}
<option value="{{ pol.id }}" {% if pol.id in selected_polarizations %}selected{% endif %}>
{{ pol.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Kubsat Coordinates Filter -->
<div class="mb-2">
<label class="form-label">Координаты Кубсата:</label>
<div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="has_kupsat" id="has_kupsat_1"
value="1" {% if has_kupsat == '1' %}checked{% endif %}>
<label class="form-check-label" for="has_kupsat_1">Есть</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="has_kupsat" id="has_kupsat_0"
value="0" {% if has_kupsat == '0' %}checked{% endif %}>
<label class="form-check-label" for="has_kupsat_0">Нет</label>
</div>
</div>
</div>
<!-- Valid Coordinates Filter -->
<div class="mb-2">
<label class="form-label">Координаты опер. отдела:</label>
<div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="has_valid" id="has_valid_1"
value="1" {% if has_valid == '1' %}checked{% endif %}>
<label class="form-check-label" for="has_valid_1">Есть</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="has_valid" id="has_valid_0"
value="0" {% if has_valid == '0' %}checked{% endif %}>
<label class="form-check-label" for="has_valid_0">Нет</label>
</div>
</div>
</div>
<!-- Source Type Filter -->
<div class="mb-2">
<label class="form-label">Тип источника:</label>
<div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="has_source_type" id="has_source_type_1"
value="1" {% if has_source_type == '1' %}checked{% endif %}>
<label class="form-check-label" for="has_source_type_1">Есть (ТВ)</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="has_source_type" id="has_source_type_0"
value="0" {% if has_source_type == '0' %}checked{% endif %}>
<label class="form-check-label" for="has_source_type_0">Нет</label>
</div>
</div>
</div>
<!-- Sigma Filter -->
<div class="mb-2">
<label class="form-label">Sigma:</label>
<div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="has_sigma" id="has_sigma_1"
value="1" {% if has_sigma == '1' %}checked{% endif %}>
<label class="form-check-label" for="has_sigma_1">Есть</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="has_sigma" id="has_sigma_0"
value="0" {% if has_sigma == '0' %}checked{% endif %}>
<label class="form-check-label" for="has_sigma_0">Нет</label>
</div>
</div>
</div>
<!-- Date Filter -->
<div class="mb-2">
<label class="form-label">Дата ГЛ:</label>
<div class="mb-2">
<div class="btn-group btn-group-sm w-100 mb-1" role="group">
<button type="button" class="btn btn-outline-secondary"
onclick="setDateRange('today')">Сегодня</button>
<button type="button" class="btn btn-outline-secondary"
onclick="setDateRange('week')">Неделя</button>
</div>
<div class="btn-group btn-group-sm w-100 mb-1" role="group">
<button type="button" class="btn btn-outline-secondary"
onclick="setDateRange('month')">Месяц</button>
<button type="button" class="btn btn-outline-secondary"
onclick="setDateRange('year')">Год</button>
</div>
</div>
<input type="date" name="date_from" id="date_from" class="form-control form-control-sm mb-1"
placeholder="От" value="{{ date_from|default:'' }}">
<input type="date" name="date_to" id="date_to" class="form-control form-control-sm"
placeholder="До" value="{{ date_to|default:'' }}">
</div>
<!-- Apply Filters and Reset Buttons -->
<div class="d-grid gap-2 mt-2">
<button type="submit" class="btn btn-primary btn-sm">Применить</button>
<a href="?" class="btn btn-secondary btn-sm">Сбросить</a>
</div>
</form>
</div> </div>
</div>
</div>
<!-- Main Table --> <!-- Frequency Filter -->
<div class="col-md"> <div class="mb-2">
<div class="card h-100"> <label class="form-label">Частота, МГц:</label>
<div class="card-body p-0"> <input type="number" step="0.001" name="freq_min" class="form-control form-control-sm mb-1"
<div class="table-responsive" style="max-height: 75vh; overflow-y: auto;"> placeholder="От" value="{{ freq_min|default:'' }}">
<table class="table table-striped table-hover table-sm" style="font-size: 0.85rem;"> <input type="number" step="0.001" name="freq_max" class="form-control form-control-sm"
<thead class="table-dark sticky-top"> placeholder="До" value="{{ freq_max|default:'' }}">
<tr> </div>
<th scope="col" class="text-center" style="width: 3%;">
<input type="checkbox" id="select-all" class="form-check-input"> <!-- Range Filter -->
</th> <div class="mb-2">
{% include 'mainapp/components/_table_header.html' with label="Имя" field="name" sort=sort %} <label class="form-label">Полоса, МГц:</label>
{% include 'mainapp/components/_table_header.html' with label="Спутник" field="satellite" sort=sort %} <input type="number" step="0.001" name="range_min" class="form-control form-control-sm mb-1"
{% include 'mainapp/components/_table_header.html' with label="Част, МГц" field="frequency" sort=sort %} placeholder="От" value="{{ range_min|default:'' }}">
{% include 'mainapp/components/_table_header.html' with label="Полоса, МГц" field="freq_range" sort=sort %} <input type="number" step="0.001" name="range_max" class="form-control form-control-sm"
{% include 'mainapp/components/_table_header.html' with label="Поляризация" field="polarization" sort=sort %} placeholder="До" value="{{ range_max|default:'' }}">
{% include 'mainapp/components/_table_header.html' with label="Сим. V" field="bod_velocity" sort=sort %} </div>
{% include 'mainapp/components/_table_header.html' with label="Модул" field="modulation" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="ОСШ" field="snr" sort=sort %} <!-- SNR Filter -->
{% include 'mainapp/components/_table_header.html' with label="Время ГЛ" field="geo_timestamp" sort=sort %} <div class="mb-2">
{% include 'mainapp/components/_table_header.html' with label="Местоположение" field="" sortable=False %} <label class="form-label">ОСШ:</label>
{% include 'mainapp/components/_table_header.html' with label="Геолокация" field="" sortable=False %} <input type="number" step="0.001" name="snr_min" class="form-control form-control-sm mb-1"
{% include 'mainapp/components/_table_header.html' with label="Обновлено" field="updated_at" sort=sort %} placeholder="От" value="{{ snr_min|default:'' }}">
{% include 'mainapp/components/_table_header.html' with label="Кем(обн)" field="" sortable=False %} <input type="number" step="0.001" name="snr_max" class="form-control form-control-sm"
{% include 'mainapp/components/_table_header.html' with label="Создано" field="created_at" sort=sort %} placeholder="До" value="{{ snr_max|default:'' }}">
{% include 'mainapp/components/_table_header.html' with label="Кем(созд)" field="" sortable=False %} </div>
{% include 'mainapp/components/_table_header.html' with label="Комментарий" field="" sortable=False %}
{% include 'mainapp/components/_table_header.html' with label="Усреднённое" field="" sortable=False %} <!-- Symbol Rate Filter -->
{% include 'mainapp/components/_table_header.html' with label="Стандарт" field="standard" sort=sort %} <div class="mb-2">
{% include 'mainapp/components/_table_header.html' with label="Тип источника" field="" sortable=False %} <label class="form-label">Сим. v, БОД:</label>
{% include 'mainapp/components/_table_header.html' with label="Sigma" field="" sortable=False %} <input type="number" step="0.001" name="bod_min" class="form-control form-control-sm mb-1"
{% include 'mainapp/components/_table_header.html' with label="Зеркала" field="" sortable=False %} placeholder="От" value="{{ bod_min|default:'' }}">
</tr> <input type="number" step="0.001" name="bod_max" class="form-control form-control-sm"
</thead> placeholder="До" value="{{ bod_max|default:'' }}">
<tbody> </div>
{% for item in processed_objects %}
<tr> <!-- Modulation Filter -->
<td class="text-center"> <div class="mb-2">
<input type="checkbox" class="form-check-input item-checkbox" <label class="form-label">Модуляция:</label>
value="{{ item.id }}"> <div class="d-flex justify-content-between mb-1">
</td> <button type="button" class="btn btn-sm btn-outline-secondary"
<td><a href="{% if item.obj.id %}{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}{% url 'mainapp:objitem_update' item.obj.id %}?{{ request.GET.urlencode }}{% else %}{% url 'mainapp:objitem_detail' item.obj.id %}?{{ request.GET.urlencode }}{% endif %}{% endif %}">{{ item.name }}</a></td> onclick="selectAllOptions('modulation', true)">Выбрать</button>
<td>{{ item.satellite_name }}</td> <button type="button" class="btn btn-sm btn-outline-secondary"
<td>{{ item.frequency }}</td> onclick="selectAllOptions('modulation', false)">Снять</button>
<td>{{ item.freq_range }}</td> </div>
<td>{{ item.polarization }}</td> <select name="modulation" class="form-select form-select-sm mb-2" multiple size="6">
<td>{{ item.bod_velocity }}</td> {% for mod in modulations %}
<td>{{ item.modulation }}</td> <option value="{{ mod.id }}" {% if mod.id in selected_modulations %}selected{% endif %}>
<td>{{ item.snr }}</td> {{ mod.name }}
<td>{{ item.geo_timestamp|date:"d.m.Y H:i" }}</td> </option>
<td>{{ item.geo_location}}</td> {% endfor %}
<td>{{ item.geo_coords }}</td> </select>
<td>{{ item.obj.updated_at|date:"d.m.Y H:i" }}</td> </div>
<td>{{ item.updated_by }}</td>
<td>{{ item.obj.created_at|date:"d.m.Y H:i" }}</td> <!-- Polarization Filter -->
<td>{{ item.obj.created_by }}</td> <div class="mb-2">
<td>{{ item.comment }}</td> <label class="form-label">Поляризация:</label>
<td>{{ item.is_average }}</td> <div class="d-flex justify-content-between mb-1">
<td>{{ item.standard }}</td> <button type="button" class="btn btn-sm btn-outline-secondary"
<td> onclick="selectAllOptions('polarization', true)">Выбрать</button>
{% if item.obj.lyngsat_source %} <button type="button" class="btn btn-sm btn-outline-secondary"
<a href="#" class="text-primary text-decoration-none" onclick="showLyngsatModal({{ item.obj.lyngsat_source.id }}); return false;"> onclick="selectAllOptions('polarization', false)">Снять</button>
<i class="bi bi-tv"></i> ТВ </div>
</a> <select name="polarization" class="form-select form-select-sm mb-2" multiple size="4">
{% else %} {% for pol in polarizations %}
- <option value="{{ pol.id }}" {% if pol.id in selected_polarizations %}selected{% endif %}>
{% endif %} {{ pol.name }}
</td> </option>
<td> {% endfor %}
{% if item.has_sigma %} </select>
<a href="#" class="text-info text-decoration-none" onclick="showSigmaParameterModal({{ item.obj.parameter_obj.id }}); return false;" title="{{ item.sigma_info }}"> </div>
<i class="bi bi-graph-up"></i> {{ item.sigma_info }}
</a>
{% else %} <!-- Source Type Filter -->
- <div class="mb-2">
{% endif %} <label class="form-label">Тип источника:</label>
</td> <div>
<td>{{ item.mirrors }}</td> <div class="form-check form-check-inline">
</tr> <input class="form-check-input" type="checkbox" name="has_source_type"
{% empty %} id="has_source_type_1" value="1" {% if has_source_type == '1' %}checked{% endif %}>
<tr> <label class="form-check-label" for="has_source_type_1">Есть (ТВ)</label>
<td colspan="22" class="text-center py-4"> </div>
{% if selected_satellite_id %} <div class="form-check form-check-inline">
Нет данных для выбранных фильтров <input class="form-check-input" type="checkbox" name="has_source_type"
{% else %} id="has_source_type_0" value="0" {% if has_source_type == '0' %}checked{% endif %}>
Пожалуйста, выберите спутник для отображения данных <label class="form-check-label" for="has_source_type_0">Нет</label>
{% endif %} </div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
</div> </div>
<!-- Sigma Filter -->
<div class="mb-2">
<label class="form-label">Sigma:</label>
<div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="has_sigma" id="has_sigma_1" value="1"
{% if has_sigma == '1' %}checked{% endif %}>
<label class="form-check-label" for="has_sigma_1">Есть</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="has_sigma" id="has_sigma_0" value="0"
{% if has_sigma == '0' %}checked{% endif %}>
<label class="form-check-label" for="has_sigma_0">Нет</label>
</div>
</div>
</div>
<!-- Date Filter -->
<div class="mb-2">
<label class="form-label">Дата ГЛ:</label>
<div class="mb-2">
<div class="btn-group btn-group-sm w-100 mb-1" role="group">
<button type="button" class="btn btn-outline-secondary"
onclick="setDateRange('today')">Сегодня</button>
<button type="button" class="btn btn-outline-secondary"
onclick="setDateRange('week')">Неделя</button>
</div>
<div class="btn-group btn-group-sm w-100 mb-1" role="group">
<button type="button" class="btn btn-outline-secondary"
onclick="setDateRange('month')">Месяц</button>
<button type="button" class="btn btn-outline-secondary"
onclick="setDateRange('year')">Год</button>
</div>
</div>
<input type="date" name="date_from" id="date_from" class="form-control form-control-sm mb-1"
placeholder="От" value="{{ date_from|default:'' }}">
<input type="date" name="date_to" id="date_to" class="form-control form-control-sm" placeholder="До"
value="{{ date_to|default:'' }}">
</div>
<!-- Apply Filters and Reset Buttons -->
<div class="d-grid gap-2 mt-2">
<button type="submit" class="btn btn-primary btn-sm">Применить</button>
<a href="?" class="btn btn-secondary btn-sm">Сбросить</a>
</div>
</form>
</div>
</div>
</div>
<!-- Main Table -->
<div class="col-md">
<div class="card h-100">
<div class="card-body p-0">
<div class="table-responsive" style="max-height: 75vh; overflow-y: auto;">
<table class="table table-striped table-hover table-sm" style="font-size: 0.85rem;">
<thead class="table-dark sticky-top">
<tr>
<th scope="col" class="text-center" style="width: 3%;">
<input type="checkbox" id="select-all" class="form-check-input">
</th>
{% include 'mainapp/components/_table_header.html' with label="Имя" field="name" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="Спутник" field="satellite" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="Транспондер" field="" sortable=False %}
{% include 'mainapp/components/_table_header.html' with label="Част, МГц" field="frequency" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="Полоса, МГц" field="freq_range" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="Поляризация" field="polarization" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="Сим. V" field="bod_velocity" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="Модул" field="modulation" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="ОСШ" field="snr" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="Время ГЛ" field="geo_timestamp" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="Местоположение" field="" sortable=False %}
{% include 'mainapp/components/_table_header.html' with label="Геолокация" field="" sortable=False %}
{% include 'mainapp/components/_table_header.html' with label="Обновлено" field="updated_at" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="Кем(обн)" field="" sortable=False %}
{% include 'mainapp/components/_table_header.html' with label="Создано" field="created_at" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="Кем(созд)" field="" sortable=False %}
{% include 'mainapp/components/_table_header.html' with label="Комментарий" field="" sortable=False %}
{% include 'mainapp/components/_table_header.html' with label="Усреднённое" field="" sortable=False %}
{% include 'mainapp/components/_table_header.html' with label="Стандарт" field="standard" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="Тип источника" field="" sortable=False %}
{% include 'mainapp/components/_table_header.html' with label="Sigma" field="" sortable=False %}
{% include 'mainapp/components/_table_header.html' with label="Зеркала" field="" sortable=False %}
</tr>
</thead>
<tbody>
{% for item in processed_objects %}
<tr>
<td class="text-center">
<input type="checkbox" class="form-check-input item-checkbox" value="{{ item.id }}">
</td>
<td>
<a href="{% if item.obj.id %}{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}{% url 'mainapp:objitem_update' item.obj.id %}?{{ request.GET.urlencode }}{% else %}{% url 'mainapp:objitem_detail' item.obj.id %}?{{ request.GET.urlencode }}{% endif %}{% endif %}">{{ item.name }}</a></td>
<td>{{ item.satellite_name }}</td>
<td>
{% if item.obj.transponder %}
<a href="#" class="text-success text-decoration-none"
onclick="showTransponderModal({{ item.obj.transponder.id }}); return false;"
title="Показать данные транспондера">
<i class="bi bi-broadcast"></i> {{ item.obj.transponder.downlink }}:{{ item.obj.transponder.frequency_range }}
</a>
{% else %}
-
{% endif %}
</td>
<td>{{ item.frequency }}</td>
<td>{{ item.freq_range }}</td>
<td>{{ item.polarization }}</td>
<td>{{ item.bod_velocity }}</td>
<td>{{ item.modulation }}</td>
<td>{{ item.snr }}</td>
<td>{{ item.geo_timestamp|date:"d.m.Y H:i" }}</td>
<td>{{ item.geo_location}}</td>
<td>{{ item.geo_coords }}</td>
<td>{{ item.obj.updated_at|date:"d.m.Y H:i" }}</td>
<td>{{ item.updated_by }}</td>
<td>{{ item.obj.created_at|date:"d.m.Y H:i" }}</td>
<td>{{ item.obj.created_by }}</td>
<td>{{ item.comment }}</td>
<td>{{ item.is_average }}</td>
<td>{{ item.standard }}</td>
<td>
{% if item.obj.lyngsat_source %}
<a href="#" class="text-primary text-decoration-none"
onclick="showLyngsatModal({{ item.obj.lyngsat_source.id }}); return false;">
<i class="bi bi-tv"></i> ТВ
</a>
{% else %}
-
{% endif %}
</td>
<td>
{% if item.has_sigma %}
<a href="#" class="text-info text-decoration-none"
onclick="showSigmaParameterModal({{ item.obj.parameter_obj.id }}); return false;"
title="{{ item.sigma_info }}">
<i class="bi bi-graph-up"></i> {{ item.sigma_info }}
</a>
{% else %}
-
{% endif %}
</td>
<td>{{ item.mirrors }}</td>
</tr>
{% empty %}
<tr>
<td colspan="22" class="text-center py-4">
{% if selected_satellite_id %}
Нет данных для выбранных фильтров
{% else %}
Пожалуйста, выберите спутник для отображения данных
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
<script> <script>
@@ -720,8 +704,8 @@
// Initialize column visibility - hide creation columns by default // Initialize column visibility - hide creation columns by default
function initColumnVisibility() { function initColumnVisibility() {
const creationDateCheckbox = document.querySelector('input[data-column="14"]'); const creationDateCheckbox = document.querySelector('input[data-column="15"]');
const creationUserCheckbox = document.querySelector('input[data-column="15"]'); const creationUserCheckbox = document.querySelector('input[data-column="16"]');
if (creationDateCheckbox) { if (creationDateCheckbox) {
creationDateCheckbox.checked = false; creationDateCheckbox.checked = false;
toggleColumn(creationDateCheckbox); toggleColumn(creationDateCheckbox);
@@ -731,22 +715,22 @@
creationUserCheckbox.checked = false; creationUserCheckbox.checked = false;
toggleColumn(creationUserCheckbox); toggleColumn(creationUserCheckbox);
} }
// Hide comment, is_average, and standard columns by default // Hide comment, is_average, and standard columns by default
const commentCheckbox = document.querySelector('input[data-column="16"]'); const commentCheckbox = document.querySelector('input[data-column="17"]');
const isAverageCheckbox = document.querySelector('input[data-column="17"]'); const isAverageCheckbox = document.querySelector('input[data-column="18"]');
const standardCheckbox = document.querySelector('input[data-column="18"]'); const standardCheckbox = document.querySelector('input[data-column="19"]');
if (commentCheckbox) { if (commentCheckbox) {
commentCheckbox.checked = false; commentCheckbox.checked = false;
toggleColumn(commentCheckbox); toggleColumn(commentCheckbox);
} }
if (isAverageCheckbox) { if (isAverageCheckbox) {
isAverageCheckbox.checked = false; isAverageCheckbox.checked = false;
toggleColumn(isAverageCheckbox); toggleColumn(isAverageCheckbox);
} }
if (standardCheckbox) { if (standardCheckbox) {
standardCheckbox.checked = false; standardCheckbox.checked = false;
toggleColumn(standardCheckbox); toggleColumn(standardCheckbox);
@@ -785,7 +769,7 @@
// Count checkbox filters // Count checkbox filters
const hasKupsatCheckboxes = document.querySelectorAll('input[name="has_kupsat"]:checked'); const hasKupsatCheckboxes = document.querySelectorAll('input[name="has_kupsat"]:checked');
const hasValidCheckboxes = document.querySelectorAll('input[name="has_valid"]:checked'); const hasValidCheckboxes = document.querySelectorAll('input[name="has_valid"]:checked');
if (hasKupsatCheckboxes.length > 0) { if (hasKupsatCheckboxes.length > 0) {
filterCount++; filterCount++;
} }
@@ -853,7 +837,7 @@
} }
// Function to save selected items to localStorage // Function to save selected items to localStorage
window.saveSelectedItemsToStorage = function() { window.saveSelectedItemsToStorage = function () {
try { try {
localStorage.setItem('selectedItems', JSON.stringify(window.selectedItems)); localStorage.setItem('selectedItems', JSON.stringify(window.selectedItems));
} catch (e) { } catch (e) {
@@ -862,7 +846,7 @@
} }
// Function to update the selected items counter // Function to update the selected items counter
window.updateSelectedCounter = function() { window.updateSelectedCounter = function () {
const counterElement = document.getElementById('selectedCounter'); const counterElement = document.getElementById('selectedCounter');
if (window.selectedItems && window.selectedItems.length > 0) { if (window.selectedItems && window.selectedItems.length > 0) {
counterElement.textContent = window.selectedItems.length; counterElement.textContent = window.selectedItems.length;
@@ -870,7 +854,7 @@
} else { } else {
counterElement.style.display = 'none'; counterElement.style.display = 'none';
} }
// Also update the counter in the offcanvas // Also update the counter in the offcanvas
const offcanvasCounter = document.querySelector('#selectedItemsOffcanvas .offcanvas-header .badge'); const offcanvasCounter = document.querySelector('#selectedItemsOffcanvas .offcanvas-header .badge');
if (offcanvasCounter && window.selectedItems && window.selectedItems.length > 0) { if (offcanvasCounter && window.selectedItems && window.selectedItems.length > 0) {
@@ -885,9 +869,9 @@
updateSelectedCounter(); updateSelectedCounter();
// Function to add selected items to the list // Function to add selected items to the list
window.addSelectedToList = function() { window.addSelectedToList = function () {
const checkedCheckboxes = document.querySelectorAll('.item-checkbox:checked'); const checkedCheckboxes = document.querySelectorAll('.item-checkbox:checked');
if (checkedCheckboxes.length === 0) { if (checkedCheckboxes.length === 0) {
alert('Пожалуйста, выберите хотя бы один элемент для добавления в список'); alert('Пожалуйста, выберите хотя бы один элемент для добавления в список');
return; return;
@@ -897,31 +881,30 @@
checkedCheckboxes.forEach(checkbox => { checkedCheckboxes.forEach(checkbox => {
const row = checkbox.closest('tr'); const row = checkbox.closest('tr');
const itemId = checkbox.value; const itemId = checkbox.value;
const itemExists = window.selectedItems.some(item => item.id === itemId); const itemExists = window.selectedItems.some(item => item.id === itemId);
if (!itemExists) { if (!itemExists) {
const rowData = { const rowData = {
id: itemId, id: itemId,
name: row.cells[1].textContent, name: row.cells[1].textContent,
satellite: row.cells[2].textContent, satellite: row.cells[2].textContent,
frequency: row.cells[3].textContent, transponder: row.cells[3].textContent,
freq_range: row.cells[4].textContent, frequency: row.cells[4].textContent,
polarization: row.cells[5].textContent, freq_range: row.cells[5].textContent,
bod_velocity: row.cells[6].textContent, polarization: row.cells[6].textContent,
modulation: row.cells[7].textContent, bod_velocity: row.cells[7].textContent,
snr: row.cells[8].textContent, modulation: row.cells[8].textContent,
geo_timestamp: row.cells[9].textContent, snr: row.cells[9].textContent,
geo_location: row.cells[10].textContent, geo_timestamp: row.cells[10].textContent,
geo_coords: row.cells[11].textContent, geo_location: row.cells[11].textContent,
kupsat_coords: row.cells[12].textContent, geo_coords: row.cells[12].textContent,
valid_coords: row.cells[13].textContent, updated_at: row.cells[13].textContent,
updated_at: row.cells[12].textContent, updated_by: row.cells[14].textContent,
updated_by: row.cells[13].textContent, created_at: row.cells[15].textContent,
created_at: row.cells[14].textContent, created_by: row.cells[16].textContent,
created_by: row.cells[15].textContent, mirrors: row.cells[22].textContent
mirrors: row.cells[21].textContent
}; };
window.selectedItems.push(rowData); window.selectedItems.push(rowData);
} }
}); });
@@ -966,6 +949,7 @@
</td> </td>
<td>${item.name}</td> <td>${item.name}</td>
<td>${item.satellite}</td> <td>${item.satellite}</td>
<td>${item.transponder}</td>
<td>${item.frequency}</td> <td>${item.frequency}</td>
<td>${item.freq_range}</td> <td>${item.freq_range}</td>
<td>${item.polarization}</td> <td>${item.polarization}</td>
@@ -975,8 +959,6 @@
<td>${item.geo_timestamp}</td> <td>${item.geo_timestamp}</td>
<td>${item.geo_location}</td> <td>${item.geo_location}</td>
<td>${item.geo_coords}</td> <td>${item.geo_coords}</td>
<td>${item.kupsat_coords}</td>
<td>${item.valid_coords}</td>
<td>${item.updated_at}</td> <td>${item.updated_at}</td>
<td>${item.updated_by}</td> <td>${item.updated_by}</td>
<td>${item.created_at}</td> <td>${item.created_at}</td>
@@ -997,10 +979,10 @@
// Get IDs of items to remove // Get IDs of items to remove
const idsToRemove = Array.from(checkboxes).map(checkbox => checkbox.value); const idsToRemove = Array.from(checkboxes).map(checkbox => checkbox.value);
// Remove items from the selectedItems array // Remove items from the selectedItems array
window.selectedItems = window.selectedItems.filter(item => !idsToRemove.includes(item.id)); window.selectedItems = window.selectedItems.filter(item => !idsToRemove.includes(item.id));
// Save selected items to localStorage // Save selected items to localStorage
saveSelectedItemsToStorage(); saveSelectedItemsToStorage();
@@ -1018,7 +1000,7 @@
alert('Пожалуйста, выберите хотя бы один элемент для отправки'); alert('Пожалуйста, выберите хотя бы один элемент для отправки');
return; return;
} }
alert(`Отправка ${selectedCount} элементов... (функция в разработке)`); alert(`Отправка ${selectedCount} элементов... (функция в разработке)`);
// Placeholder for actual send functionality // Placeholder for actual send functionality
} }
@@ -1032,10 +1014,10 @@
} }
// Update the selected items table when the offcanvas is shown // Update the selected items table when the offcanvas is shown
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function () {
const offcanvasElement = document.getElementById('selectedItemsOffcanvas'); const offcanvasElement = document.getElementById('selectedItemsOffcanvas');
if (offcanvasElement) { if (offcanvasElement) {
offcanvasElement.addEventListener('show.bs.offcanvas', function() { offcanvasElement.addEventListener('show.bs.offcanvas', function () {
populateSelectedItemsTable(); populateSelectedItemsTable();
}); });
} }
@@ -1056,7 +1038,8 @@
<h5 class="modal-title" id="lyngsatModalLabel"> <h5 class="modal-title" id="lyngsatModalLabel">
<i class="bi bi-tv"></i> Данные источника LyngSat <i class="bi bi-tv"></i> Данные источника LyngSat
</h5> </h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Закрыть"></button> <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"
aria-label="Закрыть"></button>
</div> </div>
<div class="modal-body" id="lyngsatModalBody"> <div class="modal-body" id="lyngsatModalBody">
<div class="text-center py-4"> <div class="text-center py-4">
@@ -1073,32 +1056,32 @@
</div> </div>
<script> <script>
function showLyngsatModal(lyngsatId) { function showLyngsatModal(lyngsatId) {
// Показываем модальное окно // Показываем модальное окно
const modal = new bootstrap.Modal(document.getElementById('lyngsatModal')); const modal = new bootstrap.Modal(document.getElementById('lyngsatModal'));
modal.show(); modal.show();
// Показываем индикатор загрузки // Показываем индикатор загрузки
const modalBody = document.getElementById('lyngsatModalBody'); const modalBody = document.getElementById('lyngsatModalBody');
modalBody.innerHTML = ` modalBody.innerHTML = `
<div class="text-center py-4"> <div class="text-center py-4">
<div class="spinner-border text-primary" role="status"> <div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Загрузка...</span> <span class="visually-hidden">Загрузка...</span>
</div> </div>
</div> </div>
`; `;
// Загружаем данные // Загружаем данные
fetch(`/api/lyngsat/${lyngsatId}/`) fetch(`/api/lyngsat/${lyngsatId}/`)
.then(response => { .then(response => {
if (!response.ok) { if (!response.ok) {
throw new Error('Ошибка загрузки данных'); throw new Error('Ошибка загрузки данных');
} }
return response.json(); return response.json();
}) })
.then(data => { .then(data => {
// Формируем HTML с данными // Формируем HTML с данными
let html = ` let html = `
<div class="container-fluid"> <div class="container-fluid">
<div class="row g-3"> <div class="row g-3">
<div class="col-md-6"> <div class="col-md-6">
@@ -1191,16 +1174,139 @@ function showLyngsatModal(lyngsatId) {
</div> </div>
</div> </div>
`; `;
modalBody.innerHTML = html; modalBody.innerHTML = html;
}) })
.catch(error => { .catch(error => {
modalBody.innerHTML = ` modalBody.innerHTML = `
<div class="alert alert-danger" role="alert"> <div class="alert alert-danger" role="alert">
<i class="bi bi-exclamation-triangle"></i> ${error.message} <i class="bi bi-exclamation-triangle"></i> ${error.message}
</div> </div>
`; `;
}); });
} }
// Function to show transponder modal
function showTransponderModal(transponderId) {
const modal = new bootstrap.Modal(document.getElementById('transponderModal'));
modal.show();
const modalBody = document.getElementById('transponderModalBody');
modalBody.innerHTML = `
<div class="text-center py-4">
<div class="spinner-border text-success" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
</div>
`;
fetch(`/api/transponder/${transponderId}/`)
.then(response => {
if (!response.ok) {
throw new Error('Ошибка загрузки данных транспондера');
}
return response.json();
})
.then(data => {
let html = `
<div class="container-fluid">
<div class="row g-3">
<div class="col-md-6">
<div class="card h-100">
<div class="card-header bg-light">
<strong><i class="bi bi-info-circle"></i> Основная информация</strong>
</div>
<div class="card-body">
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="text-muted" style="width: 40%;">Название:</td>
<td><strong>${data.name || '-'}</strong></td>
</tr>
<tr>
<td class="text-muted">Спутник:</td>
<td><strong>${data.satellite}</strong></td>
</tr>
<tr>
<td class="text-muted">Зона покрытия:</td>
<td>${data.zone_name || '-'}</td>
</tr>
<tr>
<td class="text-muted">Поляризация:</td>
<td><span class="badge bg-info">${data.polarization}</span></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card h-100">
<div class="card-header bg-light">
<strong><i class="bi bi-broadcast"></i> Частотные параметры</strong>
</div>
<div class="card-body">
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="text-muted" style="width: 40%;">Downlink:</td>
<td><strong>${data.downlink} МГц</strong></td>
</tr>
<tr>
<td class="text-muted">Uplink:</td>
<td><strong>${data.uplink || '-'} ${data.uplink ? 'МГц' : ''}</strong></td>
</tr>
<tr>
<td class="text-muted">Полоса:</td>
<td><strong>${data.frequency_range} МГц</strong></td>
</tr>
<tr>
<td class="text-muted">Перенос:</td>
<td>${data.transfer || '-'} ${data.transfer ? 'МГц' : ''}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
`;
modalBody.innerHTML = html;
})
.catch(error => {
modalBody.innerHTML = `
<div class="alert alert-danger" role="alert">
<i class="bi bi-exclamation-triangle"></i> ${error.message}
</div>
`;
});
}
</script> </script>
<!-- Transponder Data Modal -->
<div class="modal fade" id="transponderModal" tabindex="-1" aria-labelledby="transponderModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-success text-white">
<h5 class="modal-title" id="transponderModalLabel">
<i class="bi bi-broadcast"></i> Данные транспондера
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body" id="transponderModalBody">
<div class="text-center py-4">
<div class="spinner-border text-success" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}

View File

@@ -1,10 +1,74 @@
{% extends "mapsapp/map2d_base.html" %} {% extends "mainapp/base.html" %}
{% load static %} {% load static %}
{% block title %}Карта выбранных объектов{% endblock title %} {% block title %}Карта выбранных объектов{% endblock title %}
{% block extra_css %}
<!-- Leaflet CSS -->
<link href="{% static 'leaflet/leaflet.css' %}" rel="stylesheet">
<link href="{% static 'leaflet-measure/leaflet-measure.css' %}" rel="stylesheet">
<link href="{% static 'leaflet-tree/L.Control.Layers.Tree.css' %}" rel="stylesheet">
<style>
body {
overflow: hidden;
}
#map {
position: fixed;
top: 56px; /* Высота navbar */
bottom: 0;
left: 0;
right: 0;
z-index: 1;
}
</style>
{% endblock %}
{% block content %}
<div id="map"></div>
{% endblock content %}
{% block extra_js %} {% block extra_js %}
<!-- Leaflet JavaScript -->
<script src="{% static 'leaflet/leaflet.js' %}"></script>
<script src="{% static 'leaflet-measure/leaflet-measure.ru.js' %}"></script>
<script src="{% static 'leaflet-tree/L.Control.Layers.Tree.js' %}"></script>
<script> <script>
// Цвета для стандартных маркеров (из leaflet-color-markers) // Инициализация карты
let map = L.map('map').setView([55.75, 37.62], 5);
L.control.scale({
imperial: false,
metric: true
}).addTo(map);
map.attributionControl.setPrefix(false);
const street = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
});
street.addTo(map);
const satellite = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
attribution: 'Tiles &copy; Esri'
});
const street_local = L.tileLayer('http://127.0.0.1:8080/styles/basic-preview/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: 'Local Tiles'
});
const baseLayers = {
"Улицы": street,
"Спутник": satellite,
"Локально": street_local
};
L.control.layers(baseLayers).addTo(map);
map.setMaxZoom(18);
map.setMinZoom(0);
L.control.measure({ primaryLengthUnit: 'kilometers' }).addTo(map);
// Цвета для маркеров
var markerColors = ['red', 'green', 'orange', 'violet', 'grey', 'black', 'blue']; var markerColors = ['red', 'green', 'orange', 'violet', 'grey', 'black', 'blue'];
var getColorIcon = function(color) { var getColorIcon = function(color) {
return L.icon({ return L.icon({
@@ -19,47 +83,52 @@
var overlays = []; var overlays = [];
// Создаём слои для каждого объекта
{% for group in groups %} {% for group in groups %}
var groupIndex = {{ forloop.counter0 }}; var groupIndex = {{ forloop.counter0 }};
var groupName = '{{ group.name|escapejs }}';
var colorName = markerColors[groupIndex % markerColors.length]; var colorName = markerColors[groupIndex % markerColors.length];
var groupIcon = getColorIcon(colorName); var groupIcon = getColorIcon(colorName);
var groupLayer = L.layerGroup(); var groupLayer = L.layerGroup();
var subgroup = []; var subgroup = [];
{% for point_data in group.points %} {% for point_data in group.points %}
var pointName = "{{ group.name|escapejs }}"; var pointName = "{{ group.name|escapejs }}";
var marker = L.marker([{{ point_data.point.1|safe }}, {{ point_data.point.0|safe }}], { var marker = L.marker([{{ point_data.point.1|safe }}, {{ point_data.point.0|safe }}], {
icon: groupIcon icon: groupIcon
}).bindPopup(pointName + '<br>' + "{{ point_data.frequency|escapejs }}"); }).bindPopup(pointName + '<br>' + "{{ point_data.frequency|escapejs }}");
groupLayer.addLayer(marker); groupLayer.addLayer(marker);
subgroup.push({ subgroup.push({
label: "{{ forloop.counter }} - {{ point_data.frequency }}", label: "{{ forloop.counter }} - {{ point_data.frequency|escapejs }}",
layer: marker layer: marker
}); });
{% endfor %} {% endfor %}
overlays.push({ overlays.push({
label: '{{ group.name|escapejs }}', label: groupName,
selectAllCheckbox: true, selectAllCheckbox: true,
children: subgroup, children: subgroup,
layer: groupLayer layer: groupLayer
}); });
{% endfor %} {% endfor %}
// Create the layer control with a custom container that includes a select all checkbox // Корневая группа
var layerControl = L.control.layers.tree(baseLayers, overlays, { const rootGroup = {
label: "Все точки",
selectAllCheckbox: true,
children: overlays,
layer: L.layerGroup()
};
// Создаём tree control
const layerControl = L.control.layers.tree(baseLayers, [rootGroup], {
collapsed: false, collapsed: false,
autoZIndex: true autoZIndex: true
}); });
// Add the layer control to the map
layerControl.addTo(map); layerControl.addTo(map);
// Calculate map bounds to fit all markers // Подгоняем карту под все маркеры
{% if groups %} {% if groups %}
var groupBounds = L.featureGroup([]); var groupBounds = L.featureGroup([]);
{% for group in groups %} {% for group in groups %}
@@ -67,40 +136,7 @@
groupBounds.addLayer(L.marker([{{ point_data.point.1|safe }}, {{ point_data.point.0|safe }}])); groupBounds.addLayer(L.marker([{{ point_data.point.1|safe }}, {{ point_data.point.0|safe }}]));
{% endfor %} {% endfor %}
{% endfor %} {% endfor %}
map.fitBounds(groupBounds.getBounds().pad(0.1)); // Add some padding map.fitBounds(groupBounds.getBounds().pad(0.1));
{% else %}
map.setView([55.75, 37.62], 5); // Default view if no markers
{% endif %} {% endif %}
// Add a "Select All" checkbox functionality for all overlays
setTimeout(function() {
// Create a custom "select all" checkbox
var selectAllContainer = document.createElement('div');
selectAllContainer.className = 'leaflet-control-layers-select-all';
selectAllContainer.style.padding = '5px';
selectAllContainer.style.borderBottom = '1px solid #ccc';
selectAllContainer.style.marginBottom = '5px';
selectAllContainer.innerHTML = '<label><input type="checkbox" id="select-all-overlays" checked> Показать все точки</label>';
// Insert the checkbox at the top of the layer control
var layerControlContainer = document.querySelector('.leaflet-control-layers-list');
if (layerControlContainer) {
layerControlContainer.insertBefore(selectAllContainer, layerControlContainer.firstChild);
}
// Add event listener to the "select all" checkbox
document.getElementById('select-all-overlays').addEventListener('change', function() {
var isChecked = this.checked;
// Iterate through all overlays and toggle visibility
for (var i = 0; i < overlays.length; i++) {
if (isChecked) {
map.addLayer(overlays[i].layer);
} else {
map.removeLayer(overlays[i].layer);
}
}
});
}, 500); // Slight delay to ensure the tree control has been rendered
</script> </script>
{% endblock extra_js %} {% endblock extra_js %}

View File

@@ -0,0 +1,63 @@
{% extends 'mainapp/base.html' %}
{% load static %}
{% block title %}Удалить источник #{{ object.id }}{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header bg-danger text-white">
<h4 class="mb-0">Подтверждение удаления</h4>
</div>
<div class="card-body">
<div class="alert alert-warning" role="alert">
<i class="bi bi-exclamation-triangle-fill"></i>
<strong>Внимание!</strong> Вы собираетесь удалить источник.
</div>
<h5>Информация об источнике:</h5>
<ul class="list-group mb-3">
<li class="list-group-item">
<strong>ID:</strong> {{ object.id }}
</li>
<li class="list-group-item">
<strong>Дата создания:</strong>
{% if object.created_at %}{{ object.created_at|date:"d.m.Y H:i" }}{% else %}-{% endif %}
</li>
<li class="list-group-item">
<strong>Создан пользователем:</strong>
{% if object.created_by %}{{ object.created_by }}{% else %}-{% endif %}
</li>
<li class="list-group-item">
<strong>Привязанных объектов:</strong>
<span class="badge bg-primary">{{ objitems_count }}</span>
</li>
</ul>
{% if objitems_count > 0 %}
<div class="alert alert-danger" role="alert">
<i class="bi bi-exclamation-circle-fill"></i>
<strong>Важно!</strong> При удалении источника будут также удалены все {{ objitems_count }} привязанных объектов!
</div>
{% endif %}
<p class="text-muted">Это действие нельзя отменить. Вы уверены, что хотите продолжить?</p>
<form method="post" class="d-inline">
{% csrf_token %}
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash"></i> Да, удалить
</button>
<a href="{% url 'mainapp:source_update' object.id %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}"
class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Отмена
</a>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,652 @@
{% extends 'mainapp/base.html' %}
{% load static %}
{% load static leaflet_tags %}
{% load l10n %}
{% block title %}Редактировать источник #{{ object.id }}{% endblock %}
{% block extra_css %}
<style>
.form-section {
margin-bottom: 2rem;
border: 1px solid #dee2e6;
border-radius: 0.25rem;
padding: 1rem;
}
.form-section-header {
border-bottom: 1px solid #dee2e6;
padding-bottom: 0.5rem;
margin-bottom: 1rem;
}
.btn-action {
margin-right: 0.5rem;
}
.readonly-field {
background-color: #f8f9fa;
padding: 0.375rem 0.75rem;
border: 1px solid #ced4da;
border-radius: 0.25rem;
}
.coord-group {
border: 1px solid #dee2e6;
padding: 0.75rem;
border-radius: 0.25rem;
margin-bottom: 1rem;
}
.coord-group-header {
font-weight: bold;
margin-bottom: 0.5rem;
color: #495057;
}
#map {
height: 500px;
width: 100%;
margin-bottom: 1rem;
}
.map-container {
margin-bottom: 1rem;
}
.map-controls {
display: flex;
gap: 10px;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.map-control-btn {
padding: 0.375rem 0.75rem;
border: 1px solid #ced4da;
background-color: #f8f9fa;
border-radius: 0.25rem;
cursor: pointer;
}
.map-control-btn.active {
background-color: #e9ecef;
border-color: #dee2e6;
}
.map-control-btn.edit {
background-color: #fff3cd;
border-color: #ffeeba;
}
.map-control-btn.save {
background-color: #d1ecf1;
border-color: #bee5eb;
}
.map-control-btn.cancel {
background-color: #f8d7da;
border-color: #f5c6cb;
}
.leaflet-marker-icon {
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.3));
}
.objitems-table {
width: 100%;
margin-top: 1rem;
}
.objitems-table th {
background-color: #f8f9fa;
font-weight: 600;
padding: 0.75rem;
border: 1px solid #dee2e6;
}
.objitems-table td {
padding: 0.5rem 0.75rem;
border: 1px solid #dee2e6;
}
.objitems-table tbody tr:hover {
background-color: #f8f9fa;
}
.objitem-link {
color: #0d6efd;
text-decoration: none;
}
.objitem-link:hover {
text-decoration: underline;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid px-3">
<div class="row mb-3">
<div class="col-12 d-flex justify-content-between align-items-center">
<h2>Редактировать источник #{{ object.id }}</h2>
<div>
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
<button type="submit" form="source-form" class="btn btn-primary btn-action">Сохранить</button>
<a href="{% url 'mainapp:source_delete' object.id %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}"
class="btn btn-danger btn-action">Удалить</a>
{% endif %}
<a href="{% url 'mainapp:home' %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}"
class="btn btn-secondary btn-action">Назад</a>
</div>
</div>
</div>
<form method="post" id="source-form">
{% csrf_token %}
<!-- Основная информация -->
<div class="form-section">
<div class="form-section-header">
<h4>Основная информация</h4>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">ID источника:</label>
<div class="readonly-field">{{ object.id }}</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Дата создания:</label>
<div class="readonly-field">
{% if object.created_at %}{{ object.created_at|date:"d.m.Y H:i" }}{% else %}-{% endif %}
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Создан пользователем:</label>
<div class="readonly-field">
{% if object.created_by %}{{ object.created_by }}{% else %}-{% endif %}
</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Дата последнего изменения:</label>
<div class="readonly-field">
{% if object.updated_at %}{{ object.updated_at|date:"d.m.Y H:i" }}{% else %}-{% endif %}
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Изменен пользователем:</label>
<div class="readonly-field">
{% if object.updated_by %}{{ object.updated_by }}{% else %}-{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Блок с картой -->
<div class="form-section">
<div class="form-section-header">
<h4>Карта</h4>
</div>
<div class="map-container">
<div id="map"></div>
</div>
</div>
<!-- Координаты -->
<div class="form-section">
<div class="form-section-header">
<h4>Координаты</h4>
</div>
<!-- Координаты ГЛ -->
<div class="coord-group">
<div class="coord-group-header">Координаты ГЛ (усреднённые)</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="id_average_latitude" class="form-label">Широта:</label>
{{ form.average_latitude }}
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="id_average_longitude" class="form-label">Долгота:</label>
{{ form.average_longitude }}
</div>
</div>
</div>
</div>
<!-- Координаты Кубсата -->
<div class="coord-group">
<div class="coord-group-header">Координаты Кубсата</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="id_kupsat_latitude" class="form-label">Широта:</label>
{{ form.kupsat_latitude }}
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="id_kupsat_longitude" class="form-label">Долгота:</label>
{{ form.kupsat_longitude }}
</div>
</div>
</div>
</div>
<!-- Координаты оперативников -->
<div class="coord-group">
<div class="coord-group-header">Координаты оперативников</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="id_valid_latitude" class="form-label">Широта:</label>
{{ form.valid_latitude }}
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="id_valid_longitude" class="form-label">Долгота:</label>
{{ form.valid_longitude }}
</div>
</div>
</div>
</div>
<!-- Координаты справочные -->
<div class="coord-group">
<div class="coord-group-header">Координаты справочные</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="id_reference_latitude" class="form-label">Широта:</label>
{{ form.reference_latitude }}
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="id_reference_longitude" class="form-label">Долгота:</label>
{{ form.reference_longitude }}
</div>
</div>
</div>
</div>
</div>
<!-- Привязанные объекты -->
<div class="form-section">
<div class="form-section-header">
<h4>Привязанные объекты ({{ objitems.count }})</h4>
</div>
{% if objitems %}
<div class="table-responsive">
<table class="objitems-table table table-striped">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Спутник</th>
<th>Частота, МГц</th>
<th>Полоса, МГц</th>
<th>Поляризация</th>
<th>Модуляция</th>
<th>Координаты</th>
<th>Создан</th>
<th>Обновлен</th>
</tr>
</thead>
<tbody>
{% for item in objitems %}
<tr>
<td>
<a href="{% url 'mainapp:objitem_update' item.id %}" class="objitem-link">
{{ item.id }}
</a>
</td>
<td>{{ item.name|default:"-" }}</td>
<td>
{% if item.parameter_obj and item.parameter_obj.id_satellite %}
{{ item.parameter_obj.id_satellite.name }}
{% else %}
-
{% endif %}
</td>
<td>
{% if item.parameter_obj %}
{{ item.parameter_obj.frequency|default:"-" }}
{% else %}
-
{% endif %}
</td>
<td>
{% if item.parameter_obj %}
{{ item.parameter_obj.freq_range|default:"-" }}
{% else %}
-
{% endif %}
</td>
<td>
{% if item.parameter_obj and item.parameter_obj.polarization %}
{{ item.parameter_obj.polarization.name }}
{% else %}
-
{% endif %}
</td>
<td>
{% if item.parameter_obj and item.parameter_obj.modulation %}
{{ item.parameter_obj.modulation.name }}
{% else %}
-
{% endif %}
</td>
<td>
{% if item.geo_obj and item.geo_obj.coords %}
{{ item.geo_obj.coords.y|floatformat:6 }}, {{ item.geo_obj.coords.x|floatformat:6 }}
{% else %}
-
{% endif %}
</td>
<td>{{ item.created_at|date:"d.m.Y H:i" }}</td>
<td>{{ item.updated_at|date:"d.m.Y H:i" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">Нет привязанных объектов</p>
{% endif %}
</div>
</form>
</div>
{% endblock %}
{% block extra_js %}
{{ block.super }}
<!-- Подключаем Leaflet -->
{% leaflet_js %}
{% leaflet_css %}
<script src="{% static 'leaflet-markers/js/leaflet-color-markers.js' %}"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
// Инициализация карты
const map = L.map('map').setView([55.75, 37.62], 5);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
// Функция для создания иконок маркеров разных цветов
function createMarkerIcon(color) {
return L.icon({
iconUrl: `{% static "leaflet-markers/img/marker-icon-" %}${color}.png`,
shadowUrl: `{% static 'leaflet-markers/img/marker-shadow.png' %}`,
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
}
const editableLayerGroup = new L.FeatureGroup();
map.addLayer(editableLayerGroup);
// Создаем 4 маркера для разных типов координат
const markers = {
average: {
marker: L.marker([55.75, 37.62], {
draggable: false,
icon: createMarkerIcon('blue'),
title: 'Координаты ГЛ'
}).addTo(editableLayerGroup),
latField: 'id_average_latitude',
lngField: 'id_average_longitude',
label: 'Координаты ГЛ'
},
kupsat: {
marker: L.marker([55.75, 37.62], {
draggable: false,
icon: createMarkerIcon('green'),
title: 'Координаты Кубсата'
}).addTo(editableLayerGroup),
latField: 'id_kupsat_latitude',
lngField: 'id_kupsat_longitude',
label: 'Координаты Кубсата'
},
valid: {
marker: L.marker([55.75, 37.62], {
draggable: false,
icon: createMarkerIcon('red'),
title: 'Координаты оперативников'
}).addTo(editableLayerGroup),
latField: 'id_valid_latitude',
lngField: 'id_valid_longitude',
label: 'Координаты оперативников'
},
reference: {
marker: L.marker([55.75, 37.62], {
draggable: false,
icon: createMarkerIcon('yellow'),
title: 'Координаты справочные'
}).addTo(editableLayerGroup),
latField: 'id_reference_latitude',
lngField: 'id_reference_longitude',
label: 'Координаты справочные'
}
};
// Привязываем попапы к маркерам
Object.values(markers).forEach(m => {
m.marker.bindPopup(m.label);
});
// Синхронизация при перетаскивании
Object.entries(markers).forEach(([key, m]) => {
m.marker.on('dragend', function (event) {
const latLng = event.target.getLatLng();
document.getElementById(m.latField).value = latLng.lat.toFixed(6);
document.getElementById(m.lngField).value = latLng.lng.toFixed(6);
});
// Методы для управления
m.marker.enableEditing = function () {
this.dragging.enable();
this.openPopup();
};
m.marker.disableEditing = function () {
this.dragging.disable();
this.closePopup();
};
});
// Устанавливаем начальные координаты из полей формы
function initMarkersFromForm() {
let hasValidCoords = false;
let centerLat = 55.75;
let centerLng = 37.62;
Object.entries(markers).forEach(([key, m]) => {
const lat = parseFloat(document.getElementById(m.latField).value);
const lng = parseFloat(document.getElementById(m.lngField).value);
if (!isNaN(lat) && !isNaN(lng)) {
m.marker.setLatLng([lat, lng]);
if (!hasValidCoords) {
centerLat = lat;
centerLng = lng;
hasValidCoords = true;
}
} else {
// Скрываем маркер если нет координат
m.marker.setOpacity(0);
}
});
// Центрируем карту
map.setView([centerLat, centerLng], hasValidCoords ? 10 : 5);
}
// Настройка формы для синхронизации с маркерами
function setupFormChange(latFieldId, lngFieldId, marker) {
const latField = document.getElementById(latFieldId);
const lngField = document.getElementById(lngFieldId);
[latField, lngField].forEach(field => {
field.addEventListener('change', function () {
const lat = parseFloat(latField.value);
const lng = parseFloat(lngField.value);
if (!isNaN(lat) && !isNaN(lng)) {
marker.setLatLng([lat, lng]);
marker.setOpacity(1);
map.setView(marker.getLatLng(), 10);
} else {
marker.setOpacity(0);
}
});
});
}
// Инициализация
initMarkersFromForm();
Object.values(markers).forEach(m => {
setupFormChange(m.latField, m.lngField, m.marker);
});
// --- УПРАВЛЕНИЕ РЕДАКТИРОВАНИЕМ ---
const editControlsDiv = L.DomUtil.create('div', 'map-controls');
editControlsDiv.style.position = 'absolute';
editControlsDiv.style.top = '10px';
editControlsDiv.style.right = '10px';
editControlsDiv.style.zIndex = '1000';
editControlsDiv.style.background = 'white';
editControlsDiv.style.padding = '10px';
editControlsDiv.style.borderRadius = '4px';
editControlsDiv.style.boxShadow = '0 0 10px rgba(0,0,0,0.2)';
editControlsDiv.innerHTML = `
<div class="map-controls">
<button type="button" id="edit-btn" class="map-control-btn edit">Редактировать</button>
<button type="button" id="save-btn" class="map-control-btn save" disabled>Сохранить</button>
<button type="button" id="cancel-btn" class="map-control-btn cancel" disabled>Отмена</button>
</div>
`;
map.getContainer().appendChild(editControlsDiv);
let isEditing = false;
const initialPositions = {};
// Сохраняем начальные координаты для отмены
Object.entries(markers).forEach(([key, m]) => {
initialPositions[key] = m.marker.getLatLng();
});
// Включение редактирования
document.getElementById('edit-btn').addEventListener('click', function () {
if (isEditing) return;
isEditing = true;
document.getElementById('edit-btn').classList.add('active');
document.getElementById('save-btn').disabled = false;
document.getElementById('cancel-btn').disabled = false;
// Включаем drag для всех маркеров
Object.values(markers).forEach(m => {
if (m.marker.options.opacity !== 0) {
m.marker.enableEditing();
}
});
// Показываем подсказку
L.popup()
.setLatLng(map.getCenter())
.setContent('Перетаскивайте маркеры. Нажмите "Сохранить" или "Отмена".')
.openOn(map);
});
// Сохранение изменений
document.getElementById('save-btn').addEventListener('click', function () {
if (!isEditing) return;
isEditing = false;
document.getElementById('edit-btn').classList.remove('active');
document.getElementById('save-btn').disabled = true;
document.getElementById('cancel-btn').disabled = true;
// Отключаем редактирование
Object.values(markers).forEach(m => {
m.marker.disableEditing();
});
// Обновляем начальные позиции
Object.entries(markers).forEach(([key, m]) => {
initialPositions[key] = m.marker.getLatLng();
});
map.closePopup();
});
// Отмена изменений
document.getElementById('cancel-btn').addEventListener('click', function () {
if (!isEditing) return;
isEditing = false;
document.getElementById('edit-btn').classList.remove('active');
document.getElementById('save-btn').disabled = true;
document.getElementById('cancel-btn').disabled = true;
// Возвращаем маркеры на исходные позиции
Object.entries(markers).forEach(([key, m]) => {
m.marker.setLatLng(initialPositions[key]);
m.marker.disableEditing();
// Синхронизируем форму с исходными значениями
document.getElementById(m.latField).value = initialPositions[key].lat.toFixed(6);
document.getElementById(m.lngField).value = initialPositions[key].lng.toFixed(6);
});
map.closePopup();
});
// Легенда
const legend = L.control({ position: 'bottomright' });
legend.onAdd = function () {
const div = L.DomUtil.create('div', 'info legend');
div.style.fontSize = '14px';
div.style.backgroundColor = 'white';
div.style.padding = '10px';
div.style.borderRadius = '4px';
div.style.boxShadow = '0 0 10px rgba(0,0,0,0.2)';
div.innerHTML = `
<h5>Легенда</h5>
<div><span style="color: blue; font-weight: bold;">•</span> Координаты ГЛ</div>
<div><span style="color: green; font-weight: bold;">•</span> Координаты Кубсата</div>
<div><span style="color: red; font-weight: bold;">•</span> Координаты оперативников</div>
<div><span style="color: gold; font-weight: bold;">•</span> Координаты справочные</div>
`;
return div;
};
legend.addTo(map);
});
</script>
{% endblock %}

View File

@@ -12,6 +12,16 @@
top: 0; top: 0;
z-index: 10; z-index: 10;
} }
.btn-group .badge {
position: absolute;
top: -5px;
right: -5px;
font-size: 0.65rem;
padding: 0.2em 0.4em;
}
.btn-group .btn {
position: relative;
}
</style> </style>
{% endblock %} {% endblock %}
@@ -55,11 +65,20 @@
</select> </select>
</div> </div>
<!-- Action buttons -->
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-primary btn-sm" title="Показать на карте"
onclick="showSelectedOnMap()">
<i class="bi bi-map"></i> Карта
</button>
</div>
<!-- Filter Toggle Button --> <!-- Filter Toggle Button -->
<div> <div>
<button class="btn btn-outline-primary btn-sm" type="button" data-bs-toggle="offcanvas" <button class="btn btn-outline-primary btn-sm" type="button" data-bs-toggle="offcanvas"
data-bs-target="#offcanvasFilters" aria-controls="offcanvasFilters"> data-bs-target="#offcanvasFilters" aria-controls="offcanvasFilters">
<i class="bi bi-funnel"></i> Фильтры <i class="bi bi-funnel"></i> Фильтры
<span id="filterCounter" class="badge bg-danger" style="display: none;">0</span>
</button> </button>
</div> </div>
@@ -185,6 +204,9 @@
<table class="table table-striped table-hover table-sm" style="font-size: 0.85rem;"> <table class="table table-striped table-hover table-sm" style="font-size: 0.85rem;">
<thead class="table-dark sticky-top"> <thead class="table-dark sticky-top">
<tr> <tr>
<th scope="col" class="text-center" style="width: 3%;">
<input type="checkbox" id="select-all" class="form-check-input">
</th>
<th scope="col" class="text-center" style="min-width: 60px;"> <th scope="col" class="text-center" style="min-width: 60px;">
<a href="javascript:void(0)" onclick="updateSort('id')" class="text-white text-decoration-none"> <a href="javascript:void(0)" onclick="updateSort('id')" class="text-white text-decoration-none">
ID ID
@@ -229,12 +251,16 @@
{% endif %} {% endif %}
</a> </a>
</th> </th>
<th scope="col" class="text-center" style="min-width: 100px;">Детали</th> <th scope="col" class="text-center" style="min-width: 150px;">Действия</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for source in processed_sources %} {% for source in processed_sources %}
<tr> <tr>
<td class="text-center">
<input type="checkbox" class="form-check-input item-checkbox"
value="{{ source.id }}">
</td>
<td class="text-center">{{ source.id }}</td> <td class="text-center">{{ source.id }}</td>
<td>{{ source.coords_average }}</td> <td>{{ source.coords_average }}</td>
<td>{{ source.coords_kupsat }}</td> <td>{{ source.coords_kupsat }}</td>
@@ -244,15 +270,44 @@
<td>{{ source.created_at|date:"d.m.Y H:i" }}</td> <td>{{ source.created_at|date:"d.m.Y H:i" }}</td>
<td>{{ source.updated_at|date:"d.m.Y H:i" }}</td> <td>{{ source.updated_at|date:"d.m.Y H:i" }}</td>
<td class="text-center"> <td class="text-center">
<button type="button" class="btn btn-sm btn-outline-primary" <div class="btn-group" role="group">
onclick="showSourceDetails({{ source.id }})"> {% if source.objitem_count > 0 %}
<i class="bi bi-eye"></i> Показать <a href="{% url 'mainapp:show_source_with_points_map' source.id %}"
</button> target="_blank"
class="btn btn-sm btn-outline-success"
title="Показать источник с точками на карте">
<i class="bi bi-geo-alt"></i>
<span class="badge bg-success">{{ source.objitem_count }}</span>
</a>
{% else %}
<button type="button" class="btn btn-sm btn-outline-secondary" disabled title="Нет точек для отображения">
<i class="bi bi-geo-alt"></i>
</button>
{% endif %}
<button type="button" class="btn btn-sm btn-outline-primary"
onclick="showSourceDetails({{ source.id }})"
title="Показать детали">
<i class="bi bi-eye"></i>
</button>
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
<a href="{% url 'mainapp:source_update' source.id %}"
class="btn btn-sm btn-outline-warning"
title="Редактировать источник">
<i class="bi bi-pencil"></i>
</a>
{% else %}
<button type="button" class="btn btn-sm btn-outline-secondary" disabled title="Недостаточно прав">
<i class="bi bi-pencil"></i>
</button>
{% endif %}
</div>
</td> </td>
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td colspan="9" class="text-center text-muted">Нет данных для отображения</td> <td colspan="10" class="text-center text-muted">Нет данных для отображения</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -285,6 +340,10 @@
<table class="table table-striped table-hover table-sm"> <table class="table table-striped table-hover table-sm">
<thead class="table-light sticky-top"> <thead class="table-light sticky-top">
<tr> <tr>
<th class="text-center" style="width: 3%;">
<input type="checkbox" id="modal-select-all" class="form-check-input">
</th>
<th class="text-center" style="min-width: 60px;">ID</th>
<th>Имя</th> <th>Имя</th>
<th>Спутник</th> <th>Спутник</th>
<th>Частота, МГц</th> <th>Частота, МГц</th>
@@ -319,6 +378,55 @@
{% block extra_js %} {% block extra_js %}
<script> <script>
let lastCheckedIndex = null;
function updateRowHighlight(checkbox) {
const row = checkbox.closest('tr');
if (checkbox.checked) {
row.classList.add('selected');
} else {
row.classList.remove('selected');
}
}
function handleCheckboxClick(e) {
if (e.shiftKey && lastCheckedIndex !== null) {
const checkboxes = document.querySelectorAll('.item-checkbox');
const currentIndex = Array.from(checkboxes).indexOf(e.target);
const startIndex = Math.min(lastCheckedIndex, currentIndex);
const endIndex = Math.max(lastCheckedIndex, currentIndex);
for (let i = startIndex; i <= endIndex; i++) {
checkboxes[i].checked = e.target.checked;
updateRowHighlight(checkboxes[i]);
}
} else {
updateRowHighlight(e.target);
}
lastCheckedIndex = Array.from(document.querySelectorAll('.item-checkbox')).indexOf(e.target);
}
// Function to show selected sources on map
function showSelectedOnMap() {
// Get all checked checkboxes
const checkedCheckboxes = document.querySelectorAll('.item-checkbox:checked');
if (checkedCheckboxes.length === 0) {
alert('Пожалуйста, выберите хотя бы один источник для отображения на карте');
return;
}
// Extract IDs from checked checkboxes
const selectedIds = [];
checkedCheckboxes.forEach(checkbox => {
selectedIds.push(checkbox.value);
});
// Redirect to the map view with selected IDs as query parameter
const url = '{% url "mainapp:show_sources_map" %}' + '?ids=' + selectedIds.join(',');
window.open(url, '_blank'); // Open in a new tab
}
// Search functionality // Search functionality
function performSearch() { function performSearch() {
const searchValue = document.getElementById('toolbar-search').value.trim(); const searchValue = document.getElementById('toolbar-search').value.trim();
@@ -377,6 +485,103 @@ function updateSort(field) {
window.location.search = urlParams.toString(); window.location.search = urlParams.toString();
} }
// Setup radio-like behavior for filter checkboxes
function setupRadioLikeCheckboxes(name) {
const checkboxes = document.querySelectorAll(`input[name="${name}"]`);
checkboxes.forEach(checkbox => {
checkbox.addEventListener('change', function () {
if (this.checked) {
checkboxes.forEach(other => {
if (other !== this) {
other.checked = false;
}
});
}
});
});
}
// Filter counter functionality
function updateFilterCounter() {
const form = document.getElementById('filter-form');
const formData = new FormData(form);
let filterCount = 0;
// Count non-empty form fields
for (const [key, value] of formData.entries()) {
if (value && value.trim() !== '') {
filterCount++;
}
}
// Display the filter counter
const counterElement = document.getElementById('filterCounter');
if (counterElement) {
if (filterCount > 0) {
counterElement.textContent = filterCount;
counterElement.style.display = 'inline';
} else {
counterElement.style.display = 'none';
}
}
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
// Setup select-all checkbox
const selectAllCheckbox = document.getElementById('select-all');
const itemCheckboxes = document.querySelectorAll('.item-checkbox');
if (selectAllCheckbox && itemCheckboxes.length > 0) {
selectAllCheckbox.addEventListener('change', function () {
itemCheckboxes.forEach(checkbox => {
checkbox.checked = selectAllCheckbox.checked;
updateRowHighlight(checkbox);
});
});
itemCheckboxes.forEach(checkbox => {
checkbox.addEventListener('change', function () {
const allChecked = Array.from(itemCheckboxes).every(cb => cb.checked);
selectAllCheckbox.checked = allChecked;
});
// Add shift-click handler
checkbox.addEventListener('click', handleCheckboxClick);
});
}
// Setup radio-like checkboxes for filters
setupRadioLikeCheckboxes('has_coords_average');
setupRadioLikeCheckboxes('has_coords_kupsat');
setupRadioLikeCheckboxes('has_coords_valid');
setupRadioLikeCheckboxes('has_coords_reference');
// Update filter counter on page load
updateFilterCounter();
// Add event listeners to form elements to update counter when filters change
const form = document.getElementById('filter-form');
if (form) {
const inputFields = form.querySelectorAll('input[type="text"], input[type="number"], input[type="date"]');
inputFields.forEach(input => {
input.addEventListener('input', updateFilterCounter);
input.addEventListener('change', updateFilterCounter);
});
const checkboxFields = form.querySelectorAll('input[type="checkbox"]');
checkboxFields.forEach(checkbox => {
checkbox.addEventListener('change', updateFilterCounter);
});
}
// Update counter when offcanvas is shown
const offcanvasElement = document.getElementById('offcanvasFilters');
if (offcanvasElement) {
offcanvasElement.addEventListener('show.bs.offcanvas', updateFilterCounter);
}
});
// Show source details in modal // Show source details in modal
function showSourceDetails(sourceId) { function showSourceDetails(sourceId) {
// Update modal title // Update modal title
@@ -420,6 +625,10 @@ function showSourceDetails(sourceId) {
data.objitems.forEach(objitem => { data.objitems.forEach(objitem => {
const row = document.createElement('tr'); const row = document.createElement('tr');
row.innerHTML = ` row.innerHTML = `
<td class="text-center">
<input type="checkbox" class="form-check-input modal-item-checkbox" value="${objitem.id}">
</td>
<td class="text-center">${objitem.id}</td>
<td>${objitem.name}</td> <td>${objitem.name}</td>
<td>${objitem.satellite_name}</td> <td>${objitem.satellite_name}</td>
<td>${objitem.frequency}</td> <td>${objitem.frequency}</td>
@@ -434,6 +643,9 @@ function showSourceDetails(sourceId) {
`; `;
tbody.appendChild(row); tbody.appendChild(row);
}); });
// Setup modal select-all checkbox
setupModalSelectAll();
} else { } else {
// Show no data message // Show no data message
document.getElementById('modalNoData').style.display = 'block'; document.getElementById('modalNoData').style.display = 'block';
@@ -449,5 +661,32 @@ function showSourceDetails(sourceId) {
errorDiv.style.display = 'block'; errorDiv.style.display = 'block';
}); });
} }
// Setup select-all functionality for modal
function setupModalSelectAll() {
const modalSelectAll = document.getElementById('modal-select-all');
const modalItemCheckboxes = document.querySelectorAll('.modal-item-checkbox');
if (modalSelectAll && modalItemCheckboxes.length > 0) {
// Remove old event listeners by cloning
const newModalSelectAll = modalSelectAll.cloneNode(true);
modalSelectAll.parentNode.replaceChild(newModalSelectAll, modalSelectAll);
newModalSelectAll.addEventListener('change', function() {
const checkboxes = document.querySelectorAll('.modal-item-checkbox');
checkboxes.forEach(checkbox => {
checkbox.checked = newModalSelectAll.checked;
});
});
modalItemCheckboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
const allCheckboxes = document.querySelectorAll('.modal-item-checkbox');
const allChecked = Array.from(allCheckboxes).every(cb => cb.checked);
document.getElementById('modal-select-all').checked = allChecked;
});
});
}
}
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,189 @@
{% extends "mainapp/base.html" %}
{% load static %}
{% block title %}Карта источников{% endblock title %}
{% block extra_css %}
<!-- Leaflet CSS -->
<link href="{% static 'leaflet/leaflet.css' %}" rel="stylesheet">
<link href="{% static 'leaflet-measure/leaflet-measure.css' %}" rel="stylesheet">
<link href="{% static 'leaflet-tree/L.Control.Layers.Tree.css' %}" rel="stylesheet">
<style>
body {
overflow: hidden;
}
#map {
position: fixed;
top: 56px; /* Высота navbar */
bottom: 0;
left: 0;
right: 0;
z-index: 1;
}
.legend {
background: white;
padding: 8px;
border-radius: 4px;
box-shadow: 0 0 10px rgba(0,0,0,0.2);
font-size: 11px;
}
.legend h6 {
font-size: 12px;
margin: 0 0 6px 0;
}
.legend-item {
margin: 3px 0;
display: flex;
align-items: center;
}
.legend-marker {
width: 18px;
height: 30px;
margin-right: 6px;
background-size: contain;
background-repeat: no-repeat;
}
</style>
{% endblock %}
{% block content %}
<div id="map"></div>
{% endblock content %}
{% block extra_js %}
<!-- Leaflet JavaScript -->
<script src="{% static 'leaflet/leaflet.js' %}"></script>
<script src="{% static 'leaflet-measure/leaflet-measure.ru.js' %}"></script>
<script src="{% static 'leaflet-tree/L.Control.Layers.Tree.js' %}"></script>
<script>
// Инициализация карты
let map = L.map('map').setView([55.75, 37.62], 5);
L.control.scale({
imperial: false,
metric: true
}).addTo(map);
map.attributionControl.setPrefix(false);
const street = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
});
street.addTo(map);
const satellite = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
attribution: 'Tiles &copy; Esri'
});
const street_local = L.tileLayer('http://127.0.0.1:8080/styles/basic-preview/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: 'Local Tiles'
});
const baseLayers = {
"Улицы": street,
"Спутник": satellite,
"Локально": street_local
};
L.control.layers(baseLayers).addTo(map);
map.setMaxZoom(18);
map.setMinZoom(0);
L.control.measure({ primaryLengthUnit: 'kilometers' }).addTo(map);
// Цвета для маркеров
var markerColors = {
'blue': 'blue',
'orange': 'orange',
'green': 'green',
'violet': 'violet'
};
var getColorIcon = function(color) {
return L.icon({
iconUrl: '{% static "leaflet-markers/img/marker-icon-" %}' + color + '.png',
shadowUrl: '{% static "leaflet-markers/img/marker-shadow.png" %}',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
};
var overlays = [];
// Создаём слои для каждого типа координат
{% for group in groups %}
var groupName = '{{ group.name|escapejs }}';
var colorName = '{{ group.color }}';
var groupIcon = getColorIcon(colorName);
var groupLayer = L.layerGroup();
var subgroup = [];
{% for point_data in group.points %}
var pointName = "{{ point_data.source_id|escapejs }}";
var marker = L.marker([{{ point_data.point.1|safe }}, {{ point_data.point.0|safe }}], {
icon: groupIcon
}).bindPopup(pointName);
groupLayer.addLayer(marker);
subgroup.push({
label: "{{ forloop.counter }} - {{ point_data.source_id|escapejs }}",
layer: marker
});
{% endfor %}
overlays.push({
label: groupName,
selectAllCheckbox: true,
children: subgroup,
layer: groupLayer
});
{% endfor %}
// Корневая группа
const rootGroup = {
label: "Все точки",
selectAllCheckbox: true,
children: overlays,
layer: L.layerGroup()
};
// Создаём tree control
const layerControl = L.control.layers.tree(baseLayers, [rootGroup], {
collapsed: false,
autoZIndex: true
});
layerControl.addTo(map);
// Подгоняем карту под все маркеры
{% if groups %}
var groupBounds = L.featureGroup([]);
{% for group in groups %}
{% for point_data in group.points %}
groupBounds.addLayer(L.marker([{{ point_data.point.1|safe }}, {{ point_data.point.0|safe }}]));
{% endfor %}
{% endfor %}
map.fitBounds(groupBounds.getBounds().pad(0.1));
{% endif %}
// Добавляем легенду в левый нижний угол
var legend = L.control({ position: 'bottomleft' });
legend.onAdd = function(map) {
var div = L.DomUtil.create('div', 'legend');
div.innerHTML = '<h6><strong>Легенда</strong></h6>';
{% for group in groups %}
div.innerHTML += `
<div class="legend-item">
<div class="legend-marker" style="background-image: url('{% static "leaflet-markers/img/marker-icon-" %}{{ group.color }}.png');"></div>
<span>{{ group.name|escapejs }}</span>
</div>
`;
{% endfor %}
return div;
};
legend.addTo(map);
</script>
{% endblock extra_js %}

View File

@@ -0,0 +1,244 @@
{% extends "mainapp/base.html" %}
{% load static %}
{% block title %}Карта источника #{{ source_id }} с точками{% endblock title %}
{% block extra_css %}
<!-- Leaflet CSS -->
<link href="{% static 'leaflet/leaflet.css' %}" rel="stylesheet">
<link href="{% static 'leaflet-measure/leaflet-measure.css' %}" rel="stylesheet">
<link href="{% static 'leaflet-tree/L.Control.Layers.Tree.css' %}" rel="stylesheet">
<style>
body {
overflow: hidden;
}
#map {
position: fixed;
top: 56px; /* Высота navbar */
bottom: 0;
left: 0;
right: 0;
z-index: 1;
}
.legend {
background: white;
padding: 8px;
border-radius: 4px;
box-shadow: 0 0 10px rgba(0,0,0,0.2);
font-size: 11px;
}
.legend h6 {
font-size: 12px;
margin: 0 0 6px 0;
}
.legend-item {
margin: 3px 0;
display: flex;
align-items: center;
}
.legend-marker {
width: 18px;
height: 30px;
margin-right: 6px;
background-size: contain;
background-repeat: no-repeat;
}
.legend-section {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #ddd;
}
.legend-section:first-child {
margin-top: 0;
padding-top: 0;
border-top: none;
}
.legend-section-title {
font-weight: bold;
font-size: 11px;
margin-bottom: 4px;
}
</style>
{% endblock %}
{% block content %}
<div id="map"></div>
{% endblock content %}
{% block extra_js %}
<!-- Leaflet JavaScript -->
<script src="{% static 'leaflet/leaflet.js' %}"></script>
<script src="{% static 'leaflet-measure/leaflet-measure.ru.js' %}"></script>
<script src="{% static 'leaflet-tree/L.Control.Layers.Tree.js' %}"></script>
<script>
// Инициализация карты
let map = L.map('map').setView([55.75, 37.62], 5);
L.control.scale({
imperial: false,
metric: true
}).addTo(map);
map.attributionControl.setPrefix(false);
const street = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
});
street.addTo(map);
const satellite = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
attribution: 'Tiles &copy; Esri'
});
const street_local = L.tileLayer('http://127.0.0.1:8080/styles/basic-preview/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: 'Local Tiles'
});
const baseLayers = {
"Улицы": street,
"Спутник": satellite,
"Локально": street_local
};
L.control.layers(baseLayers).addTo(map);
map.setMaxZoom(18);
map.setMinZoom(0);
L.control.measure({ primaryLengthUnit: 'kilometers' }).addTo(map);
// Функция для создания иконки
var getColorIcon = function(color) {
return L.icon({
iconUrl: '{% static "leaflet-markers/img/marker-icon-" %}' + color + '.png',
shadowUrl: '{% static "leaflet-markers/img/marker-shadow.png" %}',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
};
var sourceOverlays = [];
var glPointLayers = [];
// Создаём слои для координат источника и точек ГЛ
{% for group in groups %}
var groupName = '{{ group.name|escapejs }}';
var colorName = '{{ group.color }}';
var groupIcon = getColorIcon(colorName);
var groupLayer = L.layerGroup();
{% for point_data in group.points %}
{% if point_data.source_id %}
// Это координата источника
var pointName = "{{ point_data.source_id|escapejs }}";
var marker = L.marker([{{ point_data.point.1|safe }}, {{ point_data.point.0|safe }}], {
icon: groupIcon
}).bindPopup(pointName);
groupLayer.addLayer(marker);
{% else %}
// Это точка ГЛ
var pointName = "{{ point_data.name|escapejs }}";
var marker = L.marker([{{ point_data.point.1|safe }}, {{ point_data.point.0|safe }}], {
icon: groupIcon
}).bindPopup(pointName + '<br>' + "{{ point_data.frequency|escapejs }}");
groupLayer.addLayer(marker);
// Добавляем каждую точку ГЛ отдельно в список
glPointLayers.push({
label: "{{ forloop.counter }} - {{ point_data.name|escapejs }} ({{ point_data.frequency|escapejs }})",
layer: marker
});
{% endif %}
{% endfor %}
// Для координат источника добавляем как отдельный слой без вложенности
{% if group.color in 'blue,orange,green,violet' %}
sourceOverlays.push({
label: groupName,
layer: groupLayer
});
{% endif %}
{% endfor %}
// Создаём иерархию
var treeOverlays = [];
if (sourceOverlays.length > 0) {
treeOverlays.push({
label: "Координаты источника #{{ source_id }}",
selectAllCheckbox: true,
children: sourceOverlays,
layer: L.layerGroup()
});
}
if (glPointLayers.length > 0) {
treeOverlays.push({
label: "Точки ГЛ",
selectAllCheckbox: true,
children: glPointLayers,
layer: L.layerGroup()
});
}
// Создаём tree control
const layerControl = L.control.layers.tree(baseLayers, treeOverlays, {
collapsed: false,
autoZIndex: true
});
layerControl.addTo(map);
// Подгоняем карту под все маркеры
{% if groups %}
var groupBounds = L.featureGroup([]);
{% for group in groups %}
{% for point_data in group.points %}
groupBounds.addLayer(L.marker([{{ point_data.point.1|safe }}, {{ point_data.point.0|safe }}]));
{% endfor %}
{% endfor %}
map.fitBounds(groupBounds.getBounds().pad(0.1));
{% endif %}
// Добавляем легенду в левый нижний угол
var legend = L.control({ position: 'bottomleft' });
legend.onAdd = function(map) {
var div = L.DomUtil.create('div', 'legend');
div.innerHTML = '<h6><strong>Легенда</strong></h6>';
// Координаты источника
var hasSourceCoords = false;
{% for group in groups %}
{% if group.color in 'blue,orange,green,violet' %}
{% if not hasSourceCoords %}
if (!hasSourceCoords) {
div.innerHTML += '<div class="legend-section-title">Координаты источника:</div>';
hasSourceCoords = true;
}
{% endif %}
div.innerHTML += `
<div class="legend-item">
<div class="legend-marker" style="background-image: url('{% static "leaflet-markers/img/marker-icon-" %}{{ group.color }}.png');"></div>
<span>{{ group.name|escapejs }}</span>
</div>
`;
{% endif %}
{% endfor %}
// Точки ГЛ (все одним цветом)
{% for group in groups %}
{% if group.color not in 'blue,orange,green,violet' %}
div.innerHTML += '<div class="legend-section"><div class="legend-section-title">Точки ГЛ:</div></div>';
div.innerHTML += `
<div class="legend-item">
<div class="legend-marker" style="background-image: url('{% static "leaflet-markers/img/marker-icon-" %}{{ group.color }}.png');"></div>
<span>{{ group.name|escapejs }}</span>
</div>
`;
{% endif %}
{% endfor %}
return div;
};
legend.addTo(map);
</script>
{% endblock extra_js %}

View File

@@ -0,0 +1,28 @@
{% load static %}
<div class="checkbox-multiselect-wrapper" data-widget-id="{{ widget.attrs.id }}">
<div class="multiselect-input-container">
<div class="multiselect-tags" id="{{ widget.attrs.id }}_tags"></div>
<input type="text"
class="multiselect-search form-control"
placeholder="{{ widget.attrs.placeholder|default:'Выберите элементы...' }}"
id="{{ widget.attrs.id }}_search"
autocomplete="off">
<button type="button" class="multiselect-clear" id="{{ widget.attrs.id }}_clear" title="Очистить все">×</button>
</div>
<div class="multiselect-dropdown" id="{{ widget.attrs.id }}_dropdown">
<div class="multiselect-options">
{% for group_name, group_choices, group_index in widget.optgroups %}
{% for option in group_choices %}
<label class="multiselect-option">
<input type="checkbox"
name="{{ widget.name }}"
value="{{ option.value }}"
{% if option.selected %}checked{% endif %}
data-label="{{ option.label }}">
<span class="option-label">{{ option.label }}</span>
</label>
{% endfor %}
{% endfor %}
</div>
</div>
</div>

View File

@@ -25,9 +25,14 @@ from .views import (
ProcessKubsatView, ProcessKubsatView,
ShowMapView, ShowMapView,
ShowSelectedObjectsMapView, ShowSelectedObjectsMapView,
ShowSourcesMapView,
ShowSourceWithPointsMapView,
SourceListView, SourceListView,
SourceUpdateView,
SourceDeleteView,
SourceObjItemsAPIView, SourceObjItemsAPIView,
SigmaParameterDataAPIView, SigmaParameterDataAPIView,
TransponderDataAPIView,
UploadVchLoadView, UploadVchLoadView,
custom_logout, custom_logout,
) )
@@ -36,6 +41,8 @@ app_name = 'mainapp'
urlpatterns = [ urlpatterns = [
path('', SourceListView.as_view(), name='home'), path('', SourceListView.as_view(), name='home'),
path('source/<int:pk>/edit/', SourceUpdateView.as_view(), name='source_update'),
path('source/<int:pk>/delete/', SourceDeleteView.as_view(), name='source_delete'),
path('objitems/', ObjItemListView.as_view(), name='objitem_list'), path('objitems/', ObjItemListView.as_view(), name='objitem_list'),
path('actions/', ActionsPageView.as_view(), name='actions'), path('actions/', ActionsPageView.as_view(), name='actions'),
path('excel-data', LoadExcelDataView.as_view(), name='load_excel_data'), path('excel-data', LoadExcelDataView.as_view(), name='load_excel_data'),
@@ -45,6 +52,8 @@ urlpatterns = [
path('csv-data', LoadCsvDataView.as_view(), name='load_csv_data'), path('csv-data', LoadCsvDataView.as_view(), name='load_csv_data'),
path('map-points/', ShowMapView.as_view(), name='admin_show_map'), path('map-points/', ShowMapView.as_view(), name='admin_show_map'),
path('show-selected-objects-map/', ShowSelectedObjectsMapView.as_view(), name='show_selected_objects_map'), path('show-selected-objects-map/', ShowSelectedObjectsMapView.as_view(), name='show_selected_objects_map'),
path('show-sources-map/', ShowSourcesMapView.as_view(), name='show_sources_map'),
path('show-source-with-points-map/<int:source_id>/', ShowSourceWithPointsMapView.as_view(), name='show_source_with_points_map'),
path('delete-selected-objects/', DeleteSelectedObjectsView.as_view(), name='delete_selected_objects'), path('delete-selected-objects/', DeleteSelectedObjectsView.as_view(), name='delete_selected_objects'),
path('cluster/', ClusterTestView.as_view(), name='cluster'), path('cluster/', ClusterTestView.as_view(), name='cluster'),
path('vch-upload/', UploadVchLoadView.as_view(), name='vch_load'), path('vch-upload/', UploadVchLoadView.as_view(), name='vch_load'),
@@ -53,6 +62,7 @@ urlpatterns = [
path('api/lyngsat/<int:lyngsat_id>/', LyngsatDataAPIView.as_view(), name='lyngsat_data_api'), path('api/lyngsat/<int:lyngsat_id>/', LyngsatDataAPIView.as_view(), name='lyngsat_data_api'),
path('api/sigma-parameter/<int:parameter_id>/', SigmaParameterDataAPIView.as_view(), name='sigma_parameter_data_api'), path('api/sigma-parameter/<int:parameter_id>/', SigmaParameterDataAPIView.as_view(), name='sigma_parameter_data_api'),
path('api/source/<int:source_id>/objitems/', SourceObjItemsAPIView.as_view(), name='source_objitems_api'), path('api/source/<int:source_id>/objitems/', SourceObjItemsAPIView.as_view(), name='source_objitems_api'),
path('api/transponder/<int:transponder_id>/', TransponderDataAPIView.as_view(), name='transponder_data_api'),
path('kubsat-excel/', ProcessKubsatView.as_view(), name='kubsat_excel'), path('kubsat-excel/', ProcessKubsatView.as_view(), name='kubsat_excel'),
path('object/create/', ObjItemCreateView.as_view(), name='objitem_create'), path('object/create/', ObjItemCreateView.as_view(), name='objitem_create'),
path('object/<int:pk>/edit/', ObjItemUpdateView.as_view(), name='objitem_update'), path('object/<int:pk>/edit/', ObjItemUpdateView.as_view(), name='objitem_update'),

View File

@@ -23,6 +23,7 @@ from .api import (
SigmaParameterDataAPIView, SigmaParameterDataAPIView,
SourceObjItemsAPIView, SourceObjItemsAPIView,
LyngsatTaskStatusAPIView, LyngsatTaskStatusAPIView,
TransponderDataAPIView,
) )
from .lyngsat import ( from .lyngsat import (
LinkLyngsatSourcesView, LinkLyngsatSourcesView,
@@ -30,8 +31,14 @@ from .lyngsat import (
LyngsatTaskStatusView, LyngsatTaskStatusView,
ClearLyngsatCacheView, ClearLyngsatCacheView,
) )
from .source import SourceListView from .source import SourceListView, SourceUpdateView, SourceDeleteView
from .map import ShowMapView, ShowSelectedObjectsMapView, ClusterTestView from .map import (
ShowMapView,
ShowSelectedObjectsMapView,
ShowSourcesMapView,
ShowSourceWithPointsMapView,
ClusterTestView,
)
__all__ = [ __all__ = [
# Base # Base
@@ -58,6 +65,7 @@ __all__ = [
'SigmaParameterDataAPIView', 'SigmaParameterDataAPIView',
'SourceObjItemsAPIView', 'SourceObjItemsAPIView',
'LyngsatTaskStatusAPIView', 'LyngsatTaskStatusAPIView',
'TransponderDataAPIView',
# LyngSat # LyngSat
'LinkLyngsatSourcesView', 'LinkLyngsatSourcesView',
'FillLyngsatDataView', 'FillLyngsatDataView',
@@ -65,8 +73,12 @@ __all__ = [
'ClearLyngsatCacheView', 'ClearLyngsatCacheView',
# Source # Source
'SourceListView', 'SourceListView',
'SourceUpdateView',
'SourceDeleteView',
# Map # Map
'ShowMapView', 'ShowMapView',
'ShowSelectedObjectsMapView', 'ShowSelectedObjectsMapView',
'ShowSourcesMapView',
'ShowSourceWithPointsMapView',
'ClusterTestView', 'ClusterTestView',
] ]

View File

@@ -299,3 +299,34 @@ class LyngsatTaskStatusAPIView(LoginRequiredMixin, View):
response_data['status'] = task.state response_data['status'] = task.state
return JsonResponse(response_data) return JsonResponse(response_data)
class TransponderDataAPIView(LoginRequiredMixin, View):
"""API endpoint for getting Transponder data."""
def get(self, request, transponder_id):
from mapsapp.models import Transponders
try:
transponder = Transponders.objects.select_related(
'sat_id',
'polarization'
).get(id=transponder_id)
data = {
'id': transponder.id,
'name': transponder.name or '-',
'satellite': transponder.sat_id.name if transponder.sat_id else '-',
'downlink': f"{transponder.downlink:.3f}" if transponder.downlink else '-',
'uplink': f"{transponder.uplink:.3f}" if transponder.uplink else None,
'frequency_range': f"{transponder.frequency_range:.3f}" if transponder.frequency_range else '-',
'polarization': transponder.polarization.name if transponder.polarization else '-',
'zone_name': transponder.zone_name or '-',
'transfer': f"{transponder.transfer:.3f}" if transponder.transfer else None,
}
return JsonResponse(data)
except Transponders.DoesNotExist:
return JsonResponse({'error': 'Транспондер не найден'}, status=404)
except Exception as e:
return JsonResponse({'error': str(e)}, status=500)

View File

@@ -1,6 +1,7 @@
""" """
Map related views for displaying objects on maps. Map related views for displaying objects on maps.
""" """
from collections import defaultdict from collections import defaultdict
from django.contrib.admin.views.decorators import staff_member_required from django.contrib.admin.views.decorators import staff_member_required
@@ -18,7 +19,7 @@ from ..models import ObjItem
@method_decorator(staff_member_required, name="dispatch") @method_decorator(staff_member_required, name="dispatch")
class ShowMapView(RoleRequiredMixin, View): class ShowMapView(RoleRequiredMixin, View):
"""View for displaying objects on map (admin interface).""" """View for displaying objects on map (admin interface)."""
required_roles = ["admin", "moderator"] required_roles = ["admin", "moderator"]
def get(self, request): def get(self, request):
@@ -41,7 +42,7 @@ class ShowMapView(RoleRequiredMixin, View):
or not obj.geo_obj.coords or not obj.geo_obj.coords
): ):
continue continue
param = getattr(obj, 'parameter_obj', None) param = getattr(obj, "parameter_obj", None)
if not param: if not param:
continue continue
points.append( points.append(
@@ -53,7 +54,7 @@ class ShowMapView(RoleRequiredMixin, View):
) )
else: else:
return redirect("admin") return redirect("admin")
grouped = defaultdict(list) grouped = defaultdict(list)
for p in points: for p in points:
grouped[p["name"]].append({"point": p["point"], "frequency": p["freq"]}) grouped[p["name"]].append({"point": p["point"], "frequency": p["freq"]})
@@ -71,7 +72,7 @@ class ShowMapView(RoleRequiredMixin, View):
class ShowSelectedObjectsMapView(LoginRequiredMixin, View): class ShowSelectedObjectsMapView(LoginRequiredMixin, View):
"""View for displaying selected objects on map.""" """View for displaying selected objects on map."""
def get(self, request): def get(self, request):
ids = request.GET.get("ids", "") ids = request.GET.get("ids", "")
points = [] points = []
@@ -92,7 +93,7 @@ class ShowSelectedObjectsMapView(LoginRequiredMixin, View):
or not obj.geo_obj.coords or not obj.geo_obj.coords
): ):
continue continue
param = getattr(obj, 'parameter_obj', None) param = getattr(obj, "parameter_obj", None)
if not param: if not param:
continue continue
points.append( points.append(
@@ -121,9 +122,142 @@ class ShowSelectedObjectsMapView(LoginRequiredMixin, View):
return render(request, "mainapp/objitem_map.html", context) return render(request, "mainapp/objitem_map.html", context)
class ShowSourcesMapView(LoginRequiredMixin, View):
"""View for displaying selected sources on map."""
def get(self, request):
from ..models import Source
ids = request.GET.get("ids", "")
groups = []
if ids:
id_list = [int(x) for x in ids.split(",") if x.isdigit()]
sources = Source.objects.filter(id__in=id_list)
# Define coordinate types with their labels and colors
coord_types = [
("coords_average", "Усредненные координаты", "blue"),
("coords_kupsat", "Координаты Кубсата", "orange"),
("coords_valid", "Координаты оперативников", "green"),
("coords_reference", "Координаты справочные", "violet"),
]
# Group points by coordinate type
for coord_field, label, color in coord_types:
points = []
for source in sources:
coords = getattr(source, coord_field)
if coords:
# coords is a Point object with x (longitude) and y (latitude)
points.append(
{
"point": (coords.x, coords.y), # (lon, lat)
"source_id": f"Источник #{source.id}",
}
)
if points:
groups.append(
{
"name": label,
"points": points,
"color": color,
}
)
else:
return redirect("mainapp:home")
context = {
"groups": groups,
}
return render(request, "mainapp/source_map.html", context)
class ShowSourceWithPointsMapView(LoginRequiredMixin, View):
"""View for displaying a single source with all its related ObjItem points."""
def get(self, request, source_id):
from ..models import Source
try:
source = Source.objects.prefetch_related(
"source_objitems",
"source_objitems__parameter_obj",
"source_objitems__geo_obj",
).get(id=source_id)
except Source.DoesNotExist:
return redirect("mainapp:home")
groups = []
# Цвета для разных типов координат источника
source_coord_types = [
("coords_average", "Усредненные координаты", "blue"),
("coords_kupsat", "Координаты Кубсата", "orange"),
("coords_valid", "Координаты оперативников", "green"),
("coords_reference", "Координаты справочные", "violet"),
]
# Добавляем координаты источника
for coord_field, label, color in source_coord_types:
coords = getattr(source, coord_field)
if coords:
groups.append(
{
"name": label,
"points": [
{
"point": (coords.x, coords.y),
"source_id": f"Источник #{source.id}",
}
],
"color": color,
}
)
# Добавляем все точки ГЛ одной группой
gl_points = source.source_objitems.select_related(
"parameter_obj", "geo_obj"
).all()
# Собираем все точки ГЛ в одну группу
all_gl_points = []
for obj in gl_points:
if (
not hasattr(obj, "geo_obj")
or not obj.geo_obj
or not obj.geo_obj.coords
):
continue
param = getattr(obj, "parameter_obj", None)
if not param:
continue
all_gl_points.append(
{
"point": (obj.geo_obj.coords.x, obj.geo_obj.coords.y),
"name": obj.name,
"frequency": f"{param.frequency} [{param.freq_range}] МГц",
}
)
# Добавляем все точки ГЛ одним цветом (красный)
if all_gl_points:
groups.append(
{"name": "Точки ГЛ", "points": all_gl_points, "color": "red"}
)
context = {
"groups": groups,
"source_id": source_id,
}
return render(request, "mainapp/source_with_points_map.html", context)
class ClusterTestView(LoginRequiredMixin, View): class ClusterTestView(LoginRequiredMixin, View):
"""Test view for clustering functionality.""" """Test view for clustering functionality."""
def get(self, request): def get(self, request):
objs = ObjItem.objects.filter( objs = ObjItem.objects.filter(
name__icontains="! Astra 4A 12654,040 [1,962] МГц H" name__icontains="! Astra 4A 12654,040 [1,962] МГц H"

View File

@@ -3,12 +3,15 @@ Source related views.
""" """
from datetime import datetime from datetime import datetime
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db.models import Count from django.db.models import Count
from django.shortcuts import render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.views import View from django.views import View
from ..forms import SourceForm
from ..models import Source from ..models import Source
from ..utils import parse_pagination_params from ..utils import parse_pagination_params
@@ -22,8 +25,8 @@ class SourceListView(LoginRequiredMixin, View):
# Get pagination parameters # Get pagination parameters
page_number, items_per_page = parse_pagination_params(request) page_number, items_per_page = parse_pagination_params(request)
# Get sorting parameters # Get sorting parameters (default to ID ascending)
sort_param = request.GET.get("sort", "-created_at") sort_param = request.GET.get("sort", "id")
# Get filter parameters # Get filter parameters
search_query = request.GET.get("search", "").strip() search_query = request.GET.get("search", "").strip()
@@ -37,10 +40,9 @@ class SourceListView(LoginRequiredMixin, View):
date_to = request.GET.get("date_to", "").strip() date_to = request.GET.get("date_to", "").strip()
# Get all Source objects with query optimization # Get all Source objects with query optimization
sources = Source.objects.select_related( # Using annotate to count ObjItems efficiently (single query with GROUP BY)
'created_by__user', # Using prefetch_related for reverse ForeignKey relationships to avoid N+1 queries
'updated_by__user' sources = Source.objects.prefetch_related(
).prefetch_related(
'source_objitems', 'source_objitems',
'source_objitems__parameter_obj', 'source_objitems__parameter_obj',
'source_objitems__geo_obj' 'source_objitems__geo_obj'
@@ -164,8 +166,6 @@ class SourceListView(LoginRequiredMixin, View):
'objitem_count': objitem_count, 'objitem_count': objitem_count,
'created_at': source.created_at, 'created_at': source.created_at,
'updated_at': source.updated_at, 'updated_at': source.updated_at,
'created_by': source.created_by,
'updated_by': source.updated_by,
}) })
# Prepare context for template # Prepare context for template
@@ -188,3 +188,117 @@ class SourceListView(LoginRequiredMixin, View):
} }
return render(request, "mainapp/source_list.html", context) return render(request, "mainapp/source_list.html", context)
class AdminModeratorMixin(UserPassesTestMixin):
"""Mixin to restrict access to admin and moderator roles only."""
def test_func(self):
return (
self.request.user.is_authenticated and
hasattr(self.request.user, 'customuser') and
self.request.user.customuser.role in ['admin', 'moderator']
)
def handle_no_permission(self):
messages.error(self.request, 'У вас нет прав для выполнения этого действия.')
return redirect('mainapp:home')
class SourceUpdateView(LoginRequiredMixin, AdminModeratorMixin, View):
"""View for editing Source with 4 coordinate fields and related ObjItems."""
def get(self, request, pk):
source = get_object_or_404(Source, pk=pk)
form = SourceForm(instance=source)
# Get related ObjItems ordered by creation date
objitems = source.source_objitems.select_related(
'parameter_obj',
'parameter_obj__id_satellite',
'parameter_obj__polarization',
'parameter_obj__modulation',
'parameter_obj__standard',
'geo_obj',
'created_by__user',
'updated_by__user'
).order_by('created_at')
context = {
'object': source,
'form': form,
'objitems': objitems,
'full_width_page': True,
}
return render(request, 'mainapp/source_form.html', context)
def post(self, request, pk):
source = get_object_or_404(Source, pk=pk)
form = SourceForm(request.POST, instance=source)
if form.is_valid():
source = form.save(commit=False)
# Set updated_by to current user
if hasattr(request.user, 'customuser'):
source.updated_by = request.user.customuser
source.save()
messages.success(request, f'Источник #{source.id} успешно обновлен.')
# Redirect back with query params if present
if request.GET.urlencode():
return redirect(f"{reverse('mainapp:source_update', args=[source.id])}?{request.GET.urlencode()}")
return redirect('mainapp:source_update', pk=source.id)
# If form is invalid, re-render with errors
objitems = source.source_objitems.select_related(
'parameter_obj',
'parameter_obj__id_satellite',
'parameter_obj__polarization',
'parameter_obj__modulation',
'parameter_obj__standard',
'geo_obj',
'created_by__user',
'updated_by__user'
).order_by('created_at')
context = {
'object': source,
'form': form,
'objitems': objitems,
'full_width_page': True,
}
return render(request, 'mainapp/source_form.html', context)
class SourceDeleteView(LoginRequiredMixin, AdminModeratorMixin, View):
"""View for deleting Source."""
def get(self, request, pk):
source = get_object_or_404(Source, pk=pk)
context = {
'object': source,
'objitems_count': source.source_objitems.count(),
}
return render(request, 'mainapp/source_confirm_delete.html', context)
def post(self, request, pk):
source = get_object_or_404(Source, pk=pk)
source_id = source.id
try:
source.delete()
messages.success(request, f'Источник #{source_id} успешно удален.')
except Exception as e:
messages.error(request, f'Ошибка при удалении источника: {str(e)}')
return redirect('mainapp:source_update', pk=pk)
# Redirect to source list
if request.GET.urlencode():
return redirect(f"{reverse('mainapp:home')}?{request.GET.urlencode()}")
return redirect('mainapp:home')

20
dbapp/mainapp/widgets.py Normal file
View File

@@ -0,0 +1,20 @@
"""
Custom widgets for forms.
"""
from django import forms
from django.utils.safestring import mark_safe
class CheckboxSelectMultipleWidget(forms.CheckboxSelectMultiple):
"""
Custom widget that displays selected items as tags in an input field
with a dropdown containing checkboxes for selection.
"""
template_name = 'mainapp/widgets/checkbox_select_multiple.html'
class Media:
css = {
'all': ('css/checkbox-select-multiple.css',)
}
js = ('js/checkbox-select-multiple.js',)

View File

@@ -12,54 +12,54 @@ from mainapp.models import Polarization, Satellite, get_default_polarization, Cu
class Transponders(models.Model): class Transponders(models.Model):
""" """
Модель транспондера спутника. Модель транспондера спутника.
Хранит информацию о частотах uplink/downlink, зоне покрытия и поляризации. Хранит информацию о частотах uplink/downlink, зоне покрытия и поляризации.
""" """
# Основные поля # Основные поля
name = models.CharField( name = models.CharField(
max_length=30, max_length=30,
null=True, null=True,
blank=True, blank=True,
verbose_name="Название транспондера", verbose_name="Название транспондера",
db_index=True, db_index=True,
help_text="Название транспондера" help_text="Название транспондера",
) )
downlink = models.FloatField( downlink = models.FloatField(
blank=True, blank=True,
null=True, null=True,
verbose_name="Downlink", verbose_name="Downlink",
# validators=[MinValueValidator(0), MaxValueValidator(50000)], # validators=[MinValueValidator(0), MaxValueValidator(50000)],
# help_text="Частота downlink в МГц (0-50000)" # help_text="Частота downlink в МГц (0-50000)"
) )
frequency_range = models.FloatField( frequency_range = models.FloatField(
blank=True, blank=True,
null=True, null=True,
verbose_name="Полоса", verbose_name="Полоса",
# validators=[MinValueValidator(0), MaxValueValidator(1000)], # validators=[MinValueValidator(0), MaxValueValidator(1000)],
# help_text="Полоса частот в МГц (0-1000)" # help_text="Полоса частот в МГц (0-1000)"
) )
uplink = models.FloatField( uplink = models.FloatField(
blank=True, blank=True,
null=True, null=True,
verbose_name="Uplink", verbose_name="Uplink",
# validators=[MinValueValidator(0), MaxValueValidator(50000)], # validators=[MinValueValidator(0), MaxValueValidator(50000)],
# help_text="Частота uplink в МГц (0-50000)" # help_text="Частота uplink в МГц (0-50000)"
) )
zone_name = models.CharField( zone_name = models.CharField(
max_length=255, max_length=255,
blank=True, blank=True,
null=True, null=True,
verbose_name="Название зоны", verbose_name="Название зоны",
db_index=True, db_index=True,
help_text="Название зоны покрытия транспондера" help_text="Название зоны покрытия транспондера",
) )
snr = models.FloatField( snr = models.FloatField(
blank=True, blank=True,
null=True, null=True,
verbose_name="Полоса", verbose_name="Полоса",
# validators=[MinValueValidator(0), MaxValueValidator(1000)], # validators=[MinValueValidator(0), MaxValueValidator(1000)],
help_text="Полоса частот в МГц (0-1000)" help_text="Полоса частот в МГц (0-1000)",
) )
created_at = models.DateTimeField( created_at = models.DateTimeField(
auto_now_add=True, auto_now_add=True,
@@ -89,44 +89,43 @@ class Transponders(models.Model):
verbose_name="Изменен пользователем", verbose_name="Изменен пользователем",
help_text="Пользователь, последним изменивший запись", help_text="Пользователь, последним изменивший запись",
) )
# Связи # Связи
polarization = models.ForeignKey( polarization = models.ForeignKey(
Polarization, Polarization,
default=get_default_polarization, default=get_default_polarization,
on_delete=models.SET_DEFAULT, on_delete=models.SET_DEFAULT,
related_name="tran_polarizations", related_name="tran_polarizations",
null=True, null=True,
blank=True, blank=True,
verbose_name="Поляризация", verbose_name="Поляризация",
help_text="Поляризация сигнала" help_text="Поляризация сигнала",
) )
sat_id = models.ForeignKey( sat_id = models.ForeignKey(
Satellite, Satellite,
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name="tran_satellite", related_name="tran_satellite",
verbose_name="Спутник", verbose_name="Спутник",
db_index=True, db_index=True,
help_text="Спутник, которому принадлежит транспондер" help_text="Спутник, которому принадлежит транспондер",
) )
# Вычисляемые поля # Вычисляемые поля
transfer = models.GeneratedField( transfer = models.GeneratedField(
expression=ExpressionWrapper( expression=ExpressionWrapper(
Abs(F('downlink') - F('uplink')), Abs(F("downlink") - F("uplink")), output_field=models.FloatField()
output_field=models.FloatField()
), ),
output_field=models.FloatField(), output_field=models.FloatField(),
db_persist=True, db_persist=True,
null=True, null=True,
blank=True, blank=True,
verbose_name="Перенос" verbose_name="Перенос",
) )
# def clean(self): # def clean(self):
# """Валидация на уровне модели""" # """Валидация на уровне модели"""
# super().clean() # super().clean()
# # Проверка что downlink и uplink заданы # # Проверка что downlink и uplink заданы
# if self.downlink and self.uplink: # if self.downlink and self.uplink:
# # Обычно uplink выше downlink для спутниковой связи # # Обычно uplink выше downlink для спутниковой связи
@@ -139,14 +138,12 @@ class Transponders(models.Model):
if self.name: if self.name:
return self.name return self.name
return f"Транспондер {self.sat_id.name if self.sat_id else 'Unknown'}" return f"Транспондер {self.sat_id.name if self.sat_id else 'Unknown'}"
class Meta: class Meta:
verbose_name = "Транспондер" verbose_name = "Транспондер"
verbose_name_plural = "Транспондеры" verbose_name_plural = "Транспондеры"
ordering = ['sat_id', 'downlink'] ordering = ["sat_id", "downlink"]
indexes = [ indexes = [
models.Index(fields=['sat_id', 'downlink']), models.Index(fields=["sat_id", "downlink"]),
models.Index(fields=['sat_id', 'zone_name']), models.Index(fields=["sat_id", "zone_name"]),
] ]

File diff suppressed because it is too large Load Diff