Виджет для формы выбора зеркал

This commit is contained in:
2025-11-13 21:09:39 +03:00
parent 8e0d32c307
commit 5ab6770809
7 changed files with 799 additions and 374 deletions

View File

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

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

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

View File

@@ -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

@@ -37,10 +37,9 @@ class SourceListView(LoginRequiredMixin, View):
date_to = request.GET.get("date_to", "").strip() date_to = request.GET.get("date_to", "").strip()
# Get all Source objects with query optimization # Get all Source objects with query optimization
sources = Source.objects.select_related( # Using annotate to count ObjItems efficiently (single query with GROUP BY)
'created_by__user', # Using prefetch_related for reverse ForeignKey relationships to avoid N+1 queries
'updated_by__user' sources = Source.objects.prefetch_related(
).prefetch_related(
'source_objitems', 'source_objitems',
'source_objitems__parameter_obj', 'source_objitems__parameter_obj',
'source_objitems__geo_obj' 'source_objitems__geo_obj'
@@ -164,8 +163,6 @@ class SourceListView(LoginRequiredMixin, View):
'objitem_count': objitem_count, 'objitem_count': objitem_count,
'created_at': source.created_at, 'created_at': source.created_at,
'updated_at': source.updated_at, 'updated_at': source.updated_at,
'created_by': source.created_by,
'updated_by': source.updated_by,
}) })
# Prepare context for template # Prepare context for template

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',)