Compare commits

...

2 Commits

23 changed files with 4083 additions and 1560 deletions

View File

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

View File

@@ -2,57 +2,55 @@
from django import forms
# Local imports
from .models import Geo, Modulation, ObjItem, Parameter, Polarization, Satellite, Standard
from .widgets import TagSelectWidget
from .models import (
Geo,
Modulation,
ObjItem,
Parameter,
Polarization,
Satellite,
Source,
Standard,
)
from .widgets import CheckboxSelectMultipleWidget
class UploadFileForm(forms.Form):
file = forms.FileField(
label="Выберите файл",
widget=forms.FileInput(attrs={
'class': 'form-file-input'
})
widget=forms.FileInput(attrs={"class": "form-file-input"}),
)
class LoadExcelData(forms.Form):
file = forms.FileField(
label="Выберите Excel файл",
widget=forms.FileInput(attrs={
'class': 'form-control',
'accept': '.xlsx,.xls'
})
widget=forms.FileInput(attrs={"class": "form-control", "accept": ".xlsx,.xls"}),
)
sat_choice = forms.ModelChoiceField(
queryset=Satellite.objects.all(),
label="Выберите спутник",
widget=forms.Select(attrs={
'class': 'form-select'
})
widget=forms.Select(attrs={"class": "form-select"}),
)
number_input = forms.IntegerField(
label="Введите число объектов",
min_value=0,
widget=forms.NumberInput(attrs={
'class': 'form-control'
})
widget=forms.NumberInput(attrs={"class": "form-control"}),
)
class LoadCsvData(forms.Form):
file = forms.FileField(
label="Выберите CSV файл",
widget=forms.FileInput(attrs={
'class': 'form-control',
'accept': '.csv'
})
widget=forms.FileInput(attrs={"class": "form-control", "accept": ".csv"}),
)
class UploadVchLoad(UploadFileForm):
sat_choice = forms.ModelChoiceField(
queryset=Satellite.objects.all(),
label="Выберите спутник",
widget=forms.Select(attrs={
'class': 'form-select'
})
widget=forms.Select(attrs={"class": "form-select"}),
)
@@ -60,9 +58,7 @@ class VchLinkForm(forms.Form):
sat_choice = forms.ModelChoiceField(
queryset=Satellite.objects.all(),
label="Выберите спутник",
widget=forms.Select(attrs={
'class': 'form-select'
})
widget=forms.Select(attrs={"class": "form-select"}),
)
# ku_range = forms.ChoiceField(
# choices=[(9750.0, '9750'), (10750.0, '10750')],
@@ -74,18 +70,22 @@ class VchLinkForm(forms.Form):
label="Разброс по частоте (не используется)",
required=False,
initial=0.0,
widget=forms.NumberInput(attrs={
'class': 'form-control',
'placeholder': 'Не используется - погрешность определяется автоматически'
})
widget=forms.NumberInput(
attrs={
"class": "form-control",
"placeholder": "Не используется - погрешность определяется автоматически",
}
),
)
value2 = forms.FloatField(
label="Разброс по полосе (в %)",
widget=forms.NumberInput(attrs={
'class': 'form-control',
'placeholder': 'Введите погрешность полосы в процентах',
'step': '0.1'
})
widget=forms.NumberInput(
attrs={
"class": "form-control",
"placeholder": "Введите погрешность полосы в процентах",
"step": "0.1",
}
),
)
@@ -106,10 +106,7 @@ class NewEventForm(forms.Form):
# )
file = forms.FileField(
label="Выберите файл",
widget=forms.FileInput(attrs={
'class': 'form-control',
'accept': '.xlsx,.xls'
})
widget=forms.FileInput(attrs={"class": "form-control", "accept": ".xlsx,.xls"}),
)
@@ -117,53 +114,43 @@ class FillLyngsatDataForm(forms.Form):
"""Форма для заполнения данных из Lyngsat с поддержкой кеширования"""
REGION_CHOICES = [
('europe', 'Европа'),
('asia', 'Азия'),
('america', 'Америка'),
('atlantic', 'Атлантика'),
("europe", "Европа"),
("asia", "Азия"),
("america", "Америка"),
("atlantic", "Атлантика"),
]
satellites = forms.ModelMultipleChoiceField(
queryset=Satellite.objects.all().order_by('name'),
queryset=Satellite.objects.all().order_by("name"),
label="Выберите спутники",
widget=forms.SelectMultiple(attrs={
'class': 'form-select',
'size': '10'
}),
widget=forms.SelectMultiple(attrs={"class": "form-select", "size": "10"}),
required=True,
help_text="Удерживайте Ctrl (Cmd на Mac) для выбора нескольких спутников"
help_text="Удерживайте Ctrl (Cmd на Mac) для выбора нескольких спутников",
)
regions = forms.MultipleChoiceField(
choices=REGION_CHOICES,
label="Выберите регионы",
widget=forms.SelectMultiple(attrs={
'class': 'form-select',
'size': '4'
}),
widget=forms.SelectMultiple(attrs={"class": "form-select", "size": "4"}),
required=True,
initial=['europe', 'asia', 'america', 'atlantic'],
help_text="Удерживайте Ctrl (Cmd на Mac) для выбора нескольких регионов"
initial=["europe", "asia", "america", "atlantic"],
help_text="Удерживайте Ctrl (Cmd на Mac) для выбора нескольких регионов",
)
use_cache = forms.BooleanField(
label="Использовать кеширование",
required=False,
initial=True,
widget=forms.CheckboxInput(attrs={
'class': 'form-check-input'
}),
help_text="Использовать кешированные данные (ускоряет повторные запросы)"
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
help_text="Использовать кешированные данные (ускоряет повторные запросы)",
)
force_refresh = forms.BooleanField(
label="Принудительно обновить данные",
required=False,
initial=False,
widget=forms.CheckboxInput(attrs={
'class': 'form-check-input'
}),
help_text="Игнорировать кеш и получить свежие данные с сайта"
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
help_text="Игнорировать кеш и получить свежие данные с сайта",
)
@@ -171,26 +158,22 @@ class LinkLyngsatForm(forms.Form):
"""Форма для привязки источников LyngSat к объектам"""
satellites = forms.ModelMultipleChoiceField(
queryset=Satellite.objects.all().order_by('name'),
queryset=Satellite.objects.all().order_by("name"),
label="Выберите спутники",
widget=forms.SelectMultiple(attrs={
'class': 'form-select',
'size': '10'
}),
widget=forms.SelectMultiple(attrs={"class": "form-select", "size": "10"}),
required=False,
help_text="Оставьте пустым для обработки всех спутников"
help_text="Оставьте пустым для обработки всех спутников",
)
frequency_tolerance = forms.FloatField(
label="Допуск по частоте (МГц)",
initial=0.5,
min_value=0,
widget=forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.1'
}),
help_text="Допустимое отклонение частоты при сравнении"
widget=forms.NumberInput(attrs={"class": "form-control", "step": "0.1"}),
help_text="Допустимое отклонение частоты при сравнении",
)
class ParameterForm(forms.ModelForm):
"""
Форма для создания и редактирования параметров ВЧ загрузки.
@@ -201,73 +184,88 @@ class ParameterForm(forms.ModelForm):
class Meta:
model = Parameter
fields = [
'id_satellite', 'frequency', 'freq_range', 'polarization',
'bod_velocity', 'modulation', 'snr', 'standard'
"id_satellite",
"frequency",
"freq_range",
"polarization",
"bod_velocity",
"modulation",
"snr",
"standard",
]
widgets = {
'id_satellite': forms.Select(attrs={
'class': 'form-select',
'required': True
}),
'frequency': forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.000001',
'min': '0',
'max': '50000',
'placeholder': 'Введите частоту в МГц'
}),
'freq_range': forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.000001',
'min': '0',
'max': '1000',
'placeholder': 'Введите полосу частот в МГц'
}),
'bod_velocity': forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.001',
'min': '0',
'placeholder': 'Введите символьную скорость в БОД'
}),
'snr': forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.001',
'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'}),
"id_satellite": forms.Select(
attrs={"class": "form-select", "required": True}
),
"frequency": forms.NumberInput(
attrs={
"class": "form-control",
"step": "0.000001",
"min": "0",
"max": "50000",
"placeholder": "Введите частоту в МГц",
}
),
"freq_range": forms.NumberInput(
attrs={
"class": "form-control",
"step": "0.000001",
"min": "0",
"max": "1000",
"placeholder": "Введите полосу частот в МГц",
}
),
"bod_velocity": forms.NumberInput(
attrs={
"class": "form-control",
"step": "0.001",
"min": "0",
"placeholder": "Введите символьную скорость в БОД",
}
),
"snr": forms.NumberInput(
attrs={
"class": "form-control",
"step": "0.001",
"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 = {
'id_satellite': 'Спутник',
'frequency': 'Частота (МГц)',
'freq_range': 'Полоса частот (МГц)',
'polarization': 'Поляризация',
'bod_velocity': 'Символьная скорость (БОД)',
'modulation': 'Модуляция',
'snr': 'ОСШ (дБ)',
'standard': 'Стандарт',
"id_satellite": "Спутник",
"frequency": "Частота (МГц)",
"freq_range": "Полоса частот (МГц)",
"polarization": "Поляризация",
"bod_velocity": "Символьная скорость (БОД)",
"modulation": "Модуляция",
"snr": "ОСШ (дБ)",
"standard": "Стандарт",
}
help_texts = {
'frequency': 'Частота в диапазоне от 0 до 50000 МГц',
'freq_range': 'Полоса частот в диапазоне от 0 до 1000 МГц',
'bod_velocity': 'Символьная скорость должна быть положительной',
'snr': 'Отношение сигнал/шум в диапазоне от -50 до 100 дБ',
"frequency": "Частота в диапазоне от 0 до 50000 МГц",
"freq_range": "Полоса частот в диапазоне от 0 до 1000 МГц",
"bod_velocity": "Символьная скорость должна быть положительной",
"snr": "Отношение сигнал/шум в диапазоне от -50 до 100 дБ",
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Динамически загружаем choices для select полей
self.fields['id_satellite'].queryset = Satellite.objects.all().order_by('name')
self.fields['polarization'].queryset = Polarization.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"].queryset = Satellite.objects.all().order_by("name")
self.fields["polarization"].queryset = Polarization.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):
"""
@@ -276,42 +274,54 @@ class ParameterForm(forms.ModelForm):
Проверяет соотношение между частотой, полосой частот и символьной скоростью.
"""
cleaned_data = super().clean()
frequency = cleaned_data.get('frequency')
freq_range = cleaned_data.get('freq_range')
bod_velocity = cleaned_data.get('bod_velocity')
frequency = cleaned_data.get("frequency")
freq_range = cleaned_data.get("freq_range")
bod_velocity = cleaned_data.get("bod_velocity")
# Проверка что частота больше полосы частот
if frequency and freq_range:
if freq_range > frequency:
self.add_error('freq_range', 'Полоса частот не может быть больше частоты')
self.add_error(
"freq_range", "Полоса частот не может быть больше частоты"
)
# Проверка что символьная скорость соответствует полосе частот
if bod_velocity and freq_range:
if bod_velocity > freq_range * 1000000: # Конвертация МГц в Гц
self.add_error('bod_velocity', 'Символьная скорость не может превышать полосу частот')
self.add_error(
"bod_velocity",
"Символьная скорость не может превышать полосу частот",
)
return cleaned_data
class GeoForm(forms.ModelForm):
class Meta:
model = Geo
fields = ['location', 'comment', 'is_average', 'mirrors']
fields = ["location", "comment", "is_average", "mirrors"]
widgets = {
'location': forms.TextInput(attrs={'class': 'form-control'}),
'comment': forms.TextInput(attrs={'class': 'form-control'}),
'is_average': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'mirrors': TagSelectWidget(attrs={'id': 'id_geo-mirrors'}),
"location": forms.TextInput(attrs={"class": "form-control"}),
"comment": forms.TextInput(attrs={"class": "form-control"}),
"is_average": forms.CheckboxInput(attrs={"class": "form-check-input"}),
"mirrors": CheckboxSelectMultipleWidget(
attrs={
"id": "id_geo-mirrors",
"placeholder": "Выберите спутники...",
}
),
}
labels = {
'location': 'Местоположение',
'comment': 'Комментарий',
'is_average': 'Усреднённое',
'mirrors': 'Спутники-зеркала, использованные для приёма',
"location": "Местоположение",
"comment": "Комментарий",
"is_average": "Усреднённое",
"mirrors": "Спутники-зеркала, использованные для приёма",
}
help_texts = {
'mirrors': 'Начните вводить название спутника для поиска',
"mirrors": "Выберите спутники из списка",
}
class ObjItemForm(forms.ModelForm):
"""
Форма для создания и редактирования объектов (источников сигнала).
@@ -322,25 +332,27 @@ class ObjItemForm(forms.ModelForm):
class Meta:
model = ObjItem
fields = ['name']
fields = ["name"]
widgets = {
'name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Введите название объекта',
'maxlength': '100'
}),
"name": forms.TextInput(
attrs={
"class": "form-control",
"placeholder": "Введите название объекта",
"maxlength": "100",
}
),
}
labels = {
'name': 'Название объекта',
"name": "Название объекта",
}
help_texts = {
'name': 'Уникальное название объекта/источника сигнала',
"name": "Уникальное название объекта/источника сигнала",
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Делаем поле name необязательным, так как оно может быть пустым
self.fields['name'].required = False
self.fields["name"].required = False
def clean_name(self):
"""
@@ -348,7 +360,7 @@ class ObjItemForm(forms.ModelForm):
Проверяет что название не состоит только из пробелов.
"""
name = self.cleaned_data.get('name')
name = self.cleaned_data.get("name")
if name:
# Удаляем лишние пробелы
@@ -356,6 +368,165 @@ class ObjItemForm(forms.ModelForm):
# Проверяем что после удаления пробелов что-то осталось
if not name:
raise forms.ValidationError('Название не может состоять только из пробелов')
raise forms.ValidationError(
"Название не может состоять только из пробелов"
)
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=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=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=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=7 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=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=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=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=16 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=19 column_label="Тип источника" checked=True %}
{% 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=21 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=5 column_label="Полоса, МГц" 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="Сим. 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=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=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=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=15 column_label="Создано" checked=True %}
{% 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=18 column_label="Усреднённое" checked=False %}
{% 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="Тип источника" 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>
</div>

View File

@@ -32,6 +32,7 @@
</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>

View File

@@ -6,27 +6,93 @@
{% block title %}{% if object %}Редактировать объект: {{ object.name }}{% else %}Создать объект{% endif %}{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/checkbox-select-multiple.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; }
.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; }
.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;
}
.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 {
display: flex;
gap: 10px;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.map-control-btn {
padding: 0.375rem 0.75rem;
border: 1px solid #ced4da;
@@ -34,25 +100,43 @@
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));
}
/* 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>
{% endblock %}
@@ -65,10 +149,12 @@
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
<button type="submit" form="objitem-form" class="btn btn-primary btn-action">Сохранить</button>
{% 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 %}
<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>
@@ -186,16 +272,16 @@
<div class="col-md-6">
<div class="mb-3">
<label for="id_geo_latitude" class="form-label">Широта:</label>
<input type="number" step="0.000001" class="form-control"
id="id_geo_latitude" name="geo_latitude"
<input type="number" step="0.000001" class="form-control" id="id_geo_latitude"
name="geo_latitude"
value="{% if object.geo_obj and object.geo_obj.coords %}{{ object.geo_obj.coords.y|unlocalize }}{% endif %}">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="id_geo_longitude" class="form-label">Долгота:</label>
<input type="number" step="0.000001" class="form-control"
id="id_geo_longitude" name="geo_longitude"
<input type="number" step="0.000001" class="form-control" id="id_geo_longitude"
name="geo_longitude"
value="{% if object.geo_obj and object.geo_obj.coords %}{{ object.geo_obj.coords.x|unlocalize }}{% endif %}">
</div>
</div>
@@ -218,14 +304,12 @@
<div class="datetime-group">
<div>
<label for="id_timestamp_date" class="form-label">Дата:</label>
<input type="date" class="form-control"
id="id_timestamp_date" name="timestamp_date"
<input type="date" class="form-control" 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 %}">
</div>
<div>
<label for="id_timestamp_time" class="form-label">Время:</label>
<input type="time" class="form-control"
id="id_timestamp_time" name="timestamp_time"
<input type="time" class="form-control" 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 %}">
</div>
</div>
@@ -253,6 +337,9 @@
{% leaflet_css %}
<script src="{% static 'leaflet-markers/js/leaflet-color-markers.js' %}"></script>
<!-- Подключаем кастомный виджет для мультивыбора -->
<script src="{% static 'js/checkbox-select-multiple.js' %}"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
// Инициализация карты

View File

@@ -70,7 +70,8 @@
<!-- Filter Toggle Button -->
<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> Фильтры
<span id="filterCounter" class="badge bg-danger" style="display: none;">0</span>
</button>
@@ -85,7 +86,8 @@
<!-- Selected Items Counter Button -->
<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> Список
<span id="selectedCounter" class="badge bg-info" style="display: none;">0</span>
</button>
@@ -202,52 +204,19 @@
</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 %}>
<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 %}>
<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>
@@ -258,13 +227,13 @@
<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 %}>
<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 %}>
<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>
@@ -289,8 +258,8 @@
</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:'' }}">
<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 -->
@@ -316,6 +285,7 @@
</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 %}
@@ -341,11 +311,22 @@
{% for item in processed_objects %}
<tr>
<td class="text-center">
<input type="checkbox" class="form-check-input item-checkbox"
value="{{ item.id }}">
<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>
<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>
@@ -364,7 +345,8 @@
<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;">
<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 %}
@@ -373,7 +355,9 @@
</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 }}">
<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 %}
@@ -720,8 +704,8 @@
// Initialize column visibility - hide creation columns by default
function initColumnVisibility() {
const creationDateCheckbox = document.querySelector('input[data-column="14"]');
const creationUserCheckbox = document.querySelector('input[data-column="15"]');
const creationDateCheckbox = document.querySelector('input[data-column="15"]');
const creationUserCheckbox = document.querySelector('input[data-column="16"]');
if (creationDateCheckbox) {
creationDateCheckbox.checked = false;
toggleColumn(creationDateCheckbox);
@@ -733,9 +717,9 @@
}
// Hide comment, is_average, and standard columns by default
const commentCheckbox = document.querySelector('input[data-column="16"]');
const isAverageCheckbox = document.querySelector('input[data-column="17"]');
const standardCheckbox = document.querySelector('input[data-column="18"]');
const commentCheckbox = document.querySelector('input[data-column="17"]');
const isAverageCheckbox = document.querySelector('input[data-column="18"]');
const standardCheckbox = document.querySelector('input[data-column="19"]');
if (commentCheckbox) {
commentCheckbox.checked = false;
@@ -904,22 +888,21 @@
id: itemId,
name: row.cells[1].textContent,
satellite: row.cells[2].textContent,
frequency: row.cells[3].textContent,
freq_range: row.cells[4].textContent,
polarization: row.cells[5].textContent,
bod_velocity: row.cells[6].textContent,
modulation: row.cells[7].textContent,
snr: row.cells[8].textContent,
geo_timestamp: row.cells[9].textContent,
geo_location: row.cells[10].textContent,
geo_coords: row.cells[11].textContent,
kupsat_coords: row.cells[12].textContent,
valid_coords: row.cells[13].textContent,
updated_at: row.cells[12].textContent,
updated_by: row.cells[13].textContent,
created_at: row.cells[14].textContent,
created_by: row.cells[15].textContent,
mirrors: row.cells[21].textContent
transponder: row.cells[3].textContent,
frequency: row.cells[4].textContent,
freq_range: row.cells[5].textContent,
polarization: row.cells[6].textContent,
bod_velocity: row.cells[7].textContent,
modulation: row.cells[8].textContent,
snr: row.cells[9].textContent,
geo_timestamp: row.cells[10].textContent,
geo_location: row.cells[11].textContent,
geo_coords: row.cells[12].textContent,
updated_at: row.cells[13].textContent,
updated_by: row.cells[14].textContent,
created_at: row.cells[15].textContent,
created_by: row.cells[16].textContent,
mirrors: row.cells[22].textContent
};
window.selectedItems.push(rowData);
@@ -966,6 +949,7 @@
</td>
<td>${item.name}</td>
<td>${item.satellite}</td>
<td>${item.transponder}</td>
<td>${item.frequency}</td>
<td>${item.freq_range}</td>
<td>${item.polarization}</td>
@@ -975,8 +959,6 @@
<td>${item.geo_timestamp}</td>
<td>${item.geo_location}</td>
<td>${item.geo_coords}</td>
<td>${item.kupsat_coords}</td>
<td>${item.valid_coords}</td>
<td>${item.updated_at}</td>
<td>${item.updated_by}</td>
<td>${item.created_at}</td>
@@ -1056,7 +1038,8 @@
<h5 class="modal-title" id="lyngsatModalLabel">
<i class="bi bi-tv"></i> Данные источника LyngSat
</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 class="modal-body" id="lyngsatModalBody">
<div class="text-center py-4">
@@ -1202,5 +1185,128 @@ function showLyngsatModal(lyngsatId) {
`;
});
}
// 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>
<!-- 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 %}

View File

@@ -1,10 +1,74 @@
{% extends "mapsapp/map2d_base.html" %}
{% 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;
}
</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>
// Цвета для стандартных маркеров (из 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 getColorIcon = function(color) {
return L.icon({
@@ -19,47 +83,52 @@
var overlays = [];
// Создаём слои для каждого объекта
{% for group in groups %}
var groupIndex = {{ forloop.counter0 }};
var groupName = '{{ group.name|escapejs }}';
var colorName = markerColors[groupIndex % markerColors.length];
var groupIcon = getColorIcon(colorName);
var groupLayer = L.layerGroup();
var subgroup = [];
{% for point_data in group.points %}
var pointName = "{{ group.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);
subgroup.push({
label: "{{ forloop.counter }} - {{ point_data.frequency }}",
label: "{{ forloop.counter }} - {{ point_data.frequency|escapejs }}",
layer: marker
});
{% endfor %}
overlays.push({
label: '{{ group.name|escapejs }}',
label: groupName,
selectAllCheckbox: true,
children: subgroup,
layer: groupLayer
});
{% 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,
autoZIndex: true
});
// Add the layer control to the map
layerControl.addTo(map);
// Calculate map bounds to fit all markers
// Подгоняем карту под все маркеры
{% if groups %}
var groupBounds = L.featureGroup([]);
{% for group in groups %}
@@ -67,40 +136,7 @@
groupBounds.addLayer(L.marker([{{ point_data.point.1|safe }}, {{ point_data.point.0|safe }}]));
{% endfor %}
{% endfor %}
map.fitBounds(groupBounds.getBounds().pad(0.1)); // Add some padding
{% else %}
map.setView([55.75, 37.62], 5); // Default view if no markers
map.fitBounds(groupBounds.getBounds().pad(0.1));
{% 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>
{% 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;
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>
{% endblock %}
@@ -55,11 +65,20 @@
</select>
</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 -->
<div>
<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> Фильтры
<span id="filterCounter" class="badge bg-danger" style="display: none;">0</span>
</button>
</div>
@@ -185,6 +204,9 @@
<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>
<th scope="col" class="text-center" style="min-width: 60px;">
<a href="javascript:void(0)" onclick="updateSort('id')" class="text-white text-decoration-none">
ID
@@ -229,12 +251,16 @@
{% endif %}
</a>
</th>
<th scope="col" class="text-center" style="min-width: 100px;">Детали</th>
<th scope="col" class="text-center" style="min-width: 150px;">Действия</th>
</tr>
</thead>
<tbody>
{% for source in processed_sources %}
<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>{{ source.coords_average }}</td>
<td>{{ source.coords_kupsat }}</td>
@@ -244,15 +270,44 @@
<td>{{ source.created_at|date:"d.m.Y H:i" }}</td>
<td>{{ source.updated_at|date:"d.m.Y H:i" }}</td>
<td class="text-center">
<button type="button" class="btn btn-sm btn-outline-primary"
onclick="showSourceDetails({{ source.id }})">
<i class="bi bi-eye"></i> Показать
<div class="btn-group" role="group">
{% if source.objitem_count > 0 %}
<a href="{% url 'mainapp:show_source_with_points_map' source.id %}"
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>
</tr>
{% empty %}
<tr>
<td colspan="9" class="text-center text-muted">Нет данных для отображения</td>
<td colspan="10" class="text-center text-muted">Нет данных для отображения</td>
</tr>
{% endfor %}
</tbody>
@@ -285,6 +340,10 @@
<table class="table table-striped table-hover table-sm">
<thead class="table-light sticky-top">
<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>
@@ -319,6 +378,55 @@
{% block extra_js %}
<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
function performSearch() {
const searchValue = document.getElementById('toolbar-search').value.trim();
@@ -377,6 +485,103 @@ function updateSort(field) {
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
function showSourceDetails(sourceId) {
// Update modal title
@@ -420,6 +625,10 @@ function showSourceDetails(sourceId) {
data.objitems.forEach(objitem => {
const row = document.createElement('tr');
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.satellite_name}</td>
<td>${objitem.frequency}</td>
@@ -434,6 +643,9 @@ function showSourceDetails(sourceId) {
`;
tbody.appendChild(row);
});
// Setup modal select-all checkbox
setupModalSelectAll();
} else {
// Show no data message
document.getElementById('modalNoData').style.display = 'block';
@@ -449,5 +661,32 @@ function showSourceDetails(sourceId) {
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>
{% 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,
ShowMapView,
ShowSelectedObjectsMapView,
ShowSourcesMapView,
ShowSourceWithPointsMapView,
SourceListView,
SourceUpdateView,
SourceDeleteView,
SourceObjItemsAPIView,
SigmaParameterDataAPIView,
TransponderDataAPIView,
UploadVchLoadView,
custom_logout,
)
@@ -36,6 +41,8 @@ app_name = 'mainapp'
urlpatterns = [
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('actions/', ActionsPageView.as_view(), name='actions'),
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('map-points/', ShowMapView.as_view(), name='admin_show_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('cluster/', ClusterTestView.as_view(), name='cluster'),
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/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/transponder/<int:transponder_id>/', TransponderDataAPIView.as_view(), name='transponder_data_api'),
path('kubsat-excel/', ProcessKubsatView.as_view(), name='kubsat_excel'),
path('object/create/', ObjItemCreateView.as_view(), name='objitem_create'),
path('object/<int:pk>/edit/', ObjItemUpdateView.as_view(), name='objitem_update'),

View File

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

View File

@@ -299,3 +299,34 @@ class LyngsatTaskStatusAPIView(LoginRequiredMixin, View):
response_data['status'] = task.state
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.
"""
from collections import defaultdict
from django.contrib.admin.views.decorators import staff_member_required
@@ -41,7 +42,7 @@ class ShowMapView(RoleRequiredMixin, View):
or not obj.geo_obj.coords
):
continue
param = getattr(obj, 'parameter_obj', None)
param = getattr(obj, "parameter_obj", None)
if not param:
continue
points.append(
@@ -92,7 +93,7 @@ class ShowSelectedObjectsMapView(LoginRequiredMixin, View):
or not obj.geo_obj.coords
):
continue
param = getattr(obj, 'parameter_obj', None)
param = getattr(obj, "parameter_obj", None)
if not param:
continue
points.append(
@@ -121,6 +122,139 @@ class ShowSelectedObjectsMapView(LoginRequiredMixin, View):
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):
"""Test view for clustering functionality."""

View File

@@ -3,12 +3,15 @@ Source related views.
"""
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.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 ..forms import SourceForm
from ..models import Source
from ..utils import parse_pagination_params
@@ -22,8 +25,8 @@ class SourceListView(LoginRequiredMixin, View):
# Get pagination parameters
page_number, items_per_page = parse_pagination_params(request)
# Get sorting parameters
sort_param = request.GET.get("sort", "-created_at")
# Get sorting parameters (default to ID ascending)
sort_param = request.GET.get("sort", "id")
# Get filter parameters
search_query = request.GET.get("search", "").strip()
@@ -37,10 +40,9 @@ class SourceListView(LoginRequiredMixin, View):
date_to = request.GET.get("date_to", "").strip()
# Get all Source objects with query optimization
sources = Source.objects.select_related(
'created_by__user',
'updated_by__user'
).prefetch_related(
# Using annotate to count ObjItems efficiently (single query with GROUP BY)
# Using prefetch_related for reverse ForeignKey relationships to avoid N+1 queries
sources = Source.objects.prefetch_related(
'source_objitems',
'source_objitems__parameter_obj',
'source_objitems__geo_obj'
@@ -164,8 +166,6 @@ class SourceListView(LoginRequiredMixin, View):
'objitem_count': objitem_count,
'created_at': source.created_at,
'updated_at': source.updated_at,
'created_by': source.created_by,
'updated_by': source.updated_by,
})
# Prepare context for template
@@ -188,3 +188,117 @@ class SourceListView(LoginRequiredMixin, View):
}
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

@@ -23,7 +23,7 @@ class Transponders(models.Model):
blank=True,
verbose_name="Название транспондера",
db_index=True,
help_text="Название транспондера"
help_text="Название транспондера",
)
downlink = models.FloatField(
blank=True,
@@ -52,14 +52,14 @@ class Transponders(models.Model):
null=True,
verbose_name="Название зоны",
db_index=True,
help_text="Название зоны покрытия транспондера"
help_text="Название зоны покрытия транспондера",
)
snr = models.FloatField(
blank=True,
null=True,
verbose_name="Полоса",
# validators=[MinValueValidator(0), MaxValueValidator(1000)],
help_text="Полоса частот в МГц (0-1000)"
help_text="Полоса частот в МГц (0-1000)",
)
created_at = models.DateTimeField(
auto_now_add=True,
@@ -99,7 +99,7 @@ class Transponders(models.Model):
null=True,
blank=True,
verbose_name="Поляризация",
help_text="Поляризация сигнала"
help_text="Поляризация сигнала",
)
sat_id = models.ForeignKey(
Satellite,
@@ -107,20 +107,19 @@ class Transponders(models.Model):
related_name="tran_satellite",
verbose_name="Спутник",
db_index=True,
help_text="Спутник, которому принадлежит транспондер"
help_text="Спутник, которому принадлежит транспондер",
)
# Вычисляемые поля
transfer = models.GeneratedField(
expression=ExpressionWrapper(
Abs(F('downlink') - F('uplink')),
output_field=models.FloatField()
Abs(F("downlink") - F("uplink")), output_field=models.FloatField()
),
output_field=models.FloatField(),
db_persist=True,
null=True,
blank=True,
verbose_name="Перенос"
verbose_name="Перенос",
)
# def clean(self):
@@ -143,10 +142,8 @@ class Transponders(models.Model):
class Meta:
verbose_name = "Транспондер"
verbose_name_plural = "Транспондеры"
ordering = ['sat_id', 'downlink']
ordering = ["sat_id", "downlink"]
indexes = [
models.Index(fields=['sat_id', 'downlink']),
models.Index(fields=['sat_id', 'zone_name']),
models.Index(fields=["sat_id", "downlink"]),
models.Index(fields=["sat_id", "zone_name"]),
]

View File

@@ -2,7 +2,8 @@
{% load static %}
{% block content %}
<div class="db-objects-panel" style="position: absolute; top: 100px; z-index: 1000; background: white; padding: 10px; border: 1px solid #ccc; border-radius: 5px;">
<div class="db-objects-panel"
style="position: absolute; top: 100px; z-index: 1000; background: white; padding: 10px; border: 1px solid #ccc; border-radius: 5px;">
<div class="panel-title">Объекты из базы</div>
<select id="objectSelector" class="object-select">
<option value="">— Выберите объект —</option>
@@ -10,14 +11,18 @@
<option value="{{ sat.id }}">{{ sat.name }}</option>
{% endfor %}
</select>
<button id="loadObjectBtn" class="load-btn" style="display: block; width: 100%; margin-top: 10px;">Все точки</button>
<button id="loadObjectTransBtn" class="load-btn" style="display: block; width: 100%; margin-top: 10px;">Точки транспондеров</button>
<button id="clearMarkersBtn" type="button" onclick="clearAllMarkers()" style="display: block; width: 100%; margin-top: 10px;">Очистить маркеры</button>
<button id="loadObjectBtn" class="load-btn" style="display: block; width: 100%; margin-top: 10px;">Все
точки</button>
<button id="loadObjectTransBtn" class="load-btn" style="display: block; width: 100%; margin-top: 10px;">Точки
транспондеров</button>
<button id="clearMarkersBtn" type="button" onclick="clearAllMarkers()"
style="display: block; width: 100%; margin-top: 10px;">Очистить маркеры</button>
</div>
<div class="footprint-control" style="position: absolute; top: 270px; z-index: 1000; background: white; padding: 10px; border: 1px solid #ccc; border-radius: 5px;">
<div class="footprint-control"
style="position: absolute; top: 270px; z-index: 1000; background: white; padding: 10px; border: 1px solid #ccc; border-radius: 5px;">
<div class="panel-title">Области покрытия</div>
<div class="footprint-actions">
<button id="showAllFootprints">Показать все</button>