diff --git a/cdip_admin/api/v2/serializers.py b/cdip_admin/api/v2/serializers.py
index 8224b696f..fedcb39e8 100644
--- a/cdip_admin/api/v2/serializers.py
+++ b/cdip_admin/api/v2/serializers.py
@@ -1347,7 +1347,7 @@ class Meta:
)
def get_unique_together_validators(self):
- # Overriden to disable unique together check as it's handled in the create method
+ # Overridden to disable unique together check as it's handled in the create method
return []
def create(self, validated_data):
diff --git a/cdip_admin/api/v2/views.py b/cdip_admin/api/v2/views.py
index 784264097..c2b443df1 100644
--- a/cdip_admin/api/v2/views.py
+++ b/cdip_admin/api/v2/views.py
@@ -54,7 +54,7 @@ def get_serializer_class(self):
def get_object(self):
return EULA.objects.get_active_eula()
- # Overriden to return a single object (the active eula)
+ # Overridden to return a single object (the active eula)
def list(self, request, *args, **kwargs):
try:
instance = self.get_object()
diff --git a/cdip_admin/cdip_admin/auth/middleware.py b/cdip_admin/cdip_admin/auth/middleware.py
index 9115c69c3..b5b0b5e52 100644
--- a/cdip_admin/cdip_admin/auth/middleware.py
+++ b/cdip_admin/cdip_admin/auth/middleware.py
@@ -71,6 +71,14 @@ def process_request(self, request):
# If specified header doesn't exist then remove any existing
# authenticated remote-user, or return (leaving request.user set to
# AnonymousUser by the AuthenticationMiddleware).
+
+ # Don't force logout for form submissions or HTMX requests if user is already authenticated
+ # This prevents issues with regular form submissions and HTMX requests that don't include the header
+ if (self.force_logout_if_no_header and request.user.is_authenticated and
+ ((request.method == 'POST' and 'csrfmiddlewaretoken' in request.POST) or
+ (request.method == 'GET' and (request.headers.get('HX-Request') or request.path.startswith('/integrations/'))))):
+ return
+
if self.force_logout_if_no_header and request.user.is_authenticated:
self._remove_invalid_user(request)
return
diff --git a/cdip_admin/integrations/filters.py b/cdip_admin/integrations/filters.py
index e9ef4babd..03e0dfe01 100644
--- a/cdip_admin/integrations/filters.py
+++ b/cdip_admin/integrations/filters.py
@@ -178,14 +178,51 @@ class DeviceFilter(django_filters.FilterSet):
empty_label=_("Types"),
)
+ search = django_filters.CharFilter(method='filter_search', label='Search')
+
class Meta:
model = Device
fields = (
"organization",
"inbound_config_type",
"external_id",
+ "search",
)
+ def filter_search(self, queryset, name, value):
+ """
+ Search across inbound_configuration.name, name, external_id, owner.name,
+ inbound_configuration.default_devicegroup.name, and inbound_configuration.type.name fields
+ """
+ if not value:
+ return queryset
+
+ # Create a Q object for OR conditions
+ from django.db.models import Q
+
+ # Search in inbound_configuration.name
+ config_name_q = Q(inbound_configuration__name__icontains=value)
+
+ # Search in device name
+ name_q = Q(name__icontains=value)
+
+ # Search in external_id
+ external_id_q = Q(external_id__icontains=value)
+
+ # Search in owner.name
+ owner_q = Q(inbound_configuration__owner__name__icontains=value)
+
+ # Search in inbound_configuration.default_devicegroup.name
+ devicegroup_q = Q(inbound_configuration__default_devicegroup__name__icontains=value)
+
+ # Search in inbound_configuration.type.name
+ type_name_q = Q(inbound_configuration__type__name__icontains=value)
+
+ # Combine all search conditions with OR
+ search_q = config_name_q | name_q | external_id_q | owner_q | devicegroup_q | type_name_q
+
+ return queryset.filter(search_q).distinct()
+
def __init__(self, *args, **kwargs):
# this can appropriately update the ui filter elements
# check for stored values and set form values accordingly
@@ -203,7 +240,7 @@ def qs(self):
self.request.session["owner_filter"] = self.data["organization"]
if not IsGlobalAdmin.has_permission(None, self.request, None):
return IsOrganizationMember.filter_queryset_for_user(
- qs, self.request.user, "owner__name"
+ qs, self.request.user, "inbound_configuration__owner__name"
)
if "owner_filter" in self.request.session:
return qs.filter(
@@ -344,10 +381,38 @@ def qs(self):
class BridgeIntegrationFilter(django_filters.FilterSet):
enabled = django_filters.BooleanFilter(widget=CustomBooleanWidget)
+ search = django_filters.CharFilter(method='filter_search', label='Search')
class Meta:
model = BridgeIntegration
- fields = ("enabled",)
+ fields = ("enabled", "search")
+
+ def filter_search(self, queryset, name, value):
+ """
+ Search across owner.name, name, state, and additional fields
+ """
+ if not value:
+ return queryset
+
+ # Create a Q object for OR conditions
+ from django.db.models import Q
+
+ # Search in owner.name
+ owner_q = Q(owner__name__icontains=value)
+
+ # Search in name field
+ name_q = Q(name__icontains=value)
+
+ # Search in state JSON field (as text)
+ state_q = Q(state__icontains=value)
+
+ # Search in additional JSON field (as text)
+ additional_q = Q(additional__icontains=value)
+
+ # Combine all search conditions with OR
+ search_q = owner_q | name_q | state_q | additional_q
+
+ return queryset.filter(search_q).distinct()
class CharInFilter(django_filters_rest.BaseInFilter, django_filters_rest.CharFilter):
diff --git a/cdip_admin/integrations/forms.py b/cdip_admin/integrations/forms.py
index 48d087f4e..d903de34c 100644
--- a/cdip_admin/integrations/forms.py
+++ b/cdip_admin/integrations/forms.py
@@ -18,10 +18,12 @@
InboundIntegrationConfiguration,
InboundIntegrationType,
DeviceGroup,
+ SubjectType,
BridgeIntegrationType,
BridgeIntegration,
Device
)
+from .widgets import SearchableMultiSelectField, SubjectTypeAutocompleteField, DeviceSearchableMultiSelectField, DeviceGroupSelectWithLinkField, DeviceGroupAutoCreateField, DeviceGroupDisplayWidget
from django.urls import reverse
from django.core.exceptions import ValidationError
import json
@@ -30,10 +32,17 @@
def tooltip_labels(text):
return f""" ? """
+ title="{text}" tabindex="-1">?"""
class InboundIntegrationConfigurationForm(forms.ModelForm):
+ # Override the default_devicegroup field to use our custom widget
+ default_devicegroup = DeviceGroupSelectWithLinkField(
+ queryset=DeviceGroup.objects.all(),
+ required=False,
+ label="Default Device Group"
+ )
+
class Meta:
model = InboundIntegrationConfiguration
exclude = [
@@ -86,6 +95,22 @@ def __init__(self, *args, request=None, **kwargs):
)
else:
self.fields["owner"].queryset = qs
+
+ # Filter device groups based on user permissions
+ device_group_qs = DeviceGroup.objects.all()
+ if not IsGlobalAdmin.has_permission(None, request, None):
+ self.fields[
+ "default_devicegroup"
+ ].queryset = IsOrganizationMember.filter_queryset_for_user(
+ device_group_qs, request.user, "owner__name"
+ )
+ else:
+ self.fields["default_devicegroup"].queryset = device_group_qs
+
+ # Replace default_devicegroup field with display widget for existing integrations
+ if self.instance.pk and self.instance.default_devicegroup: # If this is an existing integration with a default device group
+ self.fields["default_devicegroup"].widget = DeviceGroupDisplayWidget()
+ self.fields["default_devicegroup"].help_text = "Default Device Group cannot be changed for existing integrations."
# TODO: review how we trigger the warning modal
self.fields['type'].widget.attrs['hx-get'] = reverse("inboundconfigurations/type_modal",
kwargs={"integration_id": self.instance.id})
@@ -99,6 +124,135 @@ def __init__(self, *args, request=None, **kwargs):
request.session["integration_type"] = str(self.instance.type.id)
self.fields['state'].widget.instance = self.instance.type.id
+ def save(self, commit=True):
+ """Override save to preserve default_devicegroup value when using display widget."""
+ instance = super().save(commit=False)
+
+ # If using DeviceGroupDisplayWidget, preserve the original value
+ if self.instance.pk and isinstance(self.fields['default_devicegroup'].widget, DeviceGroupDisplayWidget):
+ instance.default_devicegroup = self.instance.default_devicegroup
+
+ if commit:
+ instance.save()
+ return instance
+
+ helper = FormHelper()
+ helper.add_input(Submit("submit", "Save", css_class="btn-primary"))
+ helper.form_method = "POST"
+
+ helper.layout = Layout(
+ Row(
+ Column(Field("name", autocomplete="off"), css_class="form-group col-lg-3 mb-0"),
+ Column("owner", css_class="form-group col-lg-3 mb-0"),
+ css_class="form-row",
+ ),
+ Row(
+ Column("type", css_class="form-group col-lg-3 mb-0"),
+ Column(
+ Field("provider", autocomplete="off"), css_class="form-group col-lg-3 mb-0"
+ ),
+ css_class="form-row",
+ ),
+ "enabled",
+ Row(
+ Column("default_devicegroup", css_class="form-group col-lg-3 mb-0"),
+ css_class="form-row",
+ ),
+ Row(
+ Column(
+ Field("endpoint", autocomplete="off"),
+ css_class="form-group col-lg-3 mb-0",
+ ),
+ Column(Field("token", autocomplete="off"), css_class="form-group col-lg-3 mb-0"),
+ css_class="form-row",
+ ),
+ Row(
+ Column(Field("login", autocomplete="off"), css_class="form-group col-md-3"),
+ Column(
+ Field("password", autocomplete="off"), css_class="form-group col-md-3"
+ ),
+ css_class="form-row",
+ ),
+ Row(Column("state", css_class="form-group col-lg-6 mb-0")),
+ )
+
+
+class InboundIntegrationConfigurationAddForm(forms.ModelForm):
+ # Override the default_devicegroup field to show auto-create message
+ default_devicegroup = DeviceGroupAutoCreateField(
+ queryset=DeviceGroup.objects.none(),
+ required=False,
+ label="Default Device Group"
+ )
+
+ class Meta:
+ model = InboundIntegrationConfiguration
+ exclude = [
+ "id",
+ ]
+ fields = ("name", "owner", "type", "provider", "enabled",
+ "default_devicegroup", "endpoint", "token",
+ "login", "password", "state")
+ widgets = {
+ "password": PeekabooTextInput(),
+ "token": PeekabooTextInput(),
+ # "state": FormattedJsonFieldWidget(),
+ "apikey": PeekabooTextInput(),
+ "type": forms.Select(
+ attrs={
+ 'name': "type",
+ 'id': 'id_type',
+ 'hx-trigger': 'change',
+ 'hx-target': 'body',
+ 'hx-swap': 'beforeend'
+ }),
+ "state": JSONFormWidget(
+ schema=InboundIntegrationType.objects.configuration_schema,
+ ),
+ "owner": forms.Select(
+ attrs={
+ 'name': "owner",
+ 'hx-trigger': 'load',
+ 'hx-target': '#div_id_state',
+ 'hx-swap': 'outerHTML'
+ },
+ )
+ }
+
+ def __init__(self, *args, request=None, **kwargs):
+ super().__init__(*args, **kwargs)
+ if self.instance:
+ for field_name in self.fields:
+ if self.fields[field_name].help_text != "":
+ self.fields[
+ field_name
+ ].label += tooltip_labels(self.fields[field_name].help_text)
+ self.fields[field_name].help_text = None
+ if request:
+ qs = Organization.objects.all()
+ if not IsGlobalAdmin.has_permission(None, request, None):
+ self.fields[
+ "owner"
+ ].queryset = IsOrganizationMember.filter_queryset_for_user(
+ qs, request.user, "name", admin_only=True
+ )
+ else:
+ self.fields["owner"].queryset = qs
+ # For Add Integration: Allow free type changes without warning modal
+ # The type field will trigger schema updates directly
+ if hasattr(self.instance, 'type'):
+ # TODO: review how we trigger the schema view
+ self.fields['owner'].widget.attrs['hx-get'] = reverse("inboundconfigurations/schema",
+ kwargs={"integration_type": self.instance.type.id,
+ "integration_id": self.instance.id,
+ "update": "false"})
+ if hasattr(request, 'session'):
+ request.session["integration_type"] = str(self.instance.type.id)
+ self.fields['state'].widget.instance = self.instance.type.id
+
+ # Configure type field to use the new add_type_modal endpoint
+ self.fields['type'].widget.attrs['hx-get'] = reverse("inboundconfigurations/add_type_modal")
+
helper = FormHelper()
helper.add_input(Submit("submit", "Save", css_class="btn-primary"))
helper.form_method = "POST"
@@ -141,6 +295,13 @@ def __init__(self, *args, request=None, **kwargs):
class DeviceGroupForm(forms.ModelForm):
+ # Override the destinations field to use our custom widget
+ destinations = SearchableMultiSelectField(
+ queryset=OutboundIntegrationConfiguration.objects.all(),
+ required=False,
+ label="Destinations"
+ )
+
class Meta:
model = DeviceGroup
exclude = [
@@ -179,9 +340,86 @@ def __init__(self, *args, request=None, **kwargs):
class DeviceGroupManagementForm(forms.ModelForm):
+ # Override the destinations field to use our custom widget
+ destinations = SearchableMultiSelectField(
+ queryset=OutboundIntegrationConfiguration.objects.all(),
+ required=False,
+ label="Destinations"
+ )
+
+
+ # Override the default_subject_type field to use our custom widget
+ default_subject_type = SubjectTypeAutocompleteField(
+ required=False,
+ label="Default Subject Type",
+ empty_label="Select or create a subject type..."
+ )
+
+ class Meta:
+ model = DeviceGroup
+ exclude = ["id", "devices"]
+
+ def __init__(self, *args, request=None, **kwargs):
+ super(DeviceGroupManagementForm, self).__init__(*args, **kwargs)
+ for field_name in self.fields:
+ if self.fields[field_name].help_text != "":
+ self.fields[
+ field_name
+ ].label += tooltip_labels(self.fields[field_name].help_text)
+ self.fields[field_name].help_text = None
+ if self.instance and request:
+ qs = Organization.objects.all()
+ if not IsGlobalAdmin.has_permission(None, request, None):
+ self.fields[
+ "owner"
+ ].queryset = IsOrganizationMember.filter_queryset_for_user(
+ qs, request.user, "name"
+ )
+ else:
+ self.fields["owner"].queryset = qs
+
+ field_order = [
+ "name",
+ "owner",
+ "default_subject_type",
+ "destinations",
+ ]
+
+ helper = FormHelper()
+ helper.add_input(Submit("submit", "Save", css_class="btn-primary"))
+ helper.form_method = "POST"
+
+
+class DeviceGroupDevicesManagementForm(forms.ModelForm):
+ # Use a simple multiple choice field for devices
+ devices = forms.ModelMultipleChoiceField(
+ queryset=Device.objects.all(),
+ required=False,
+ label="Devices",
+ widget=forms.CheckboxSelectMultiple
+ )
+
class Meta:
model = DeviceGroup
- exclude = ["id", "name", "destinations", "owner"]
+ fields = ["devices"]
+
+ def __init__(self, *args, request=None, **kwargs):
+ super(DeviceGroupDevicesManagementForm, self).__init__(*args, **kwargs)
+ if self.instance and request:
+ # Filter devices to only show devices from the inbound integration associated with this device group
+ try:
+ inbound_integration = self.instance.inbound_integration_configuration.first()
+ if inbound_integration:
+ # Limit devices to only those from the associated inbound integration
+ self.fields["devices"].queryset = Device.objects.filter(
+ inbound_configuration=inbound_integration
+ ).select_related('inbound_configuration__owner', 'inbound_configuration__type')
+ else:
+ # If no inbound integration is associated, show no devices
+ self.fields["devices"].queryset = Device.objects.none()
+ except Exception:
+ # If there's any error, show no devices
+ self.fields["devices"].queryset = Device.objects.none()
helper = FormHelper()
helper.add_input(Submit("submit", "Save", css_class="btn-primary"))
@@ -430,14 +668,7 @@ class Meta:
"additional": JSONFormWidget(
schema=BridgeIntegrationType.objects.configuration_schema,
),
- "owner": forms.Select(
- attrs={
- 'name': "owner",
- 'hx-trigger': 'load',
- 'hx-target': '#div_id_additional',
- 'hx-swap': 'outerHTML'
- },
- ),
+ "owner": forms.Select(),
"state": FormattedJsonFieldWidget(),
}
@@ -460,16 +691,12 @@ def __init__(self, *args, request=None, **kwargs):
)
else:
self.fields["owner"].queryset = qs
- # TODO: review how we trigger the warning modal
- self.fields['type'].widget.attrs['hx-get'] = reverse("bridges/type_modal",
- kwargs={"integration_id": self.instance.id})
+ # Set up HTMX attributes for type field to trigger modal
+ if self.instance and self.instance.id:
+ self.fields['type'].widget.attrs['hx-get'] = reverse("bridges/type_modal",
+ kwargs={"integration_id": self.instance.id})
- if hasattr(self.instance, 'type'):
- # TODO: review how we trigger the schema view
- self.fields['owner'].widget.attrs['hx-get'] = reverse("bridges/schema",
- kwargs={"integration_type": self.instance.type.id,
- "integration_id": self.instance.id,
- "update": "false"})
+ if hasattr(self.instance, 'type') and self.instance.type:
if hasattr(request, 'session'):
request.session["integration_type"] = str(self.instance.type.id)
self.fields['additional'].widget.instance = self.instance.type.id
diff --git a/cdip_admin/integrations/models/v1/models.py b/cdip_admin/integrations/models/v1/models.py
index b001c64ec..07f262da8 100644
--- a/cdip_admin/integrations/models/v1/models.py
+++ b/cdip_admin/integrations/models/v1/models.py
@@ -433,7 +433,7 @@ class Device(TimestampedModel):
on_delete=models.PROTECT,
blank=True,
null=True,
- help_text="Default subject type. Can be overriden by the integration or data provider",
+ help_text="Default subject type. Can be overridden by the integration or data provider",
)
additional = models.JSONField(
blank=True,
@@ -525,7 +525,7 @@ class DeviceGroup(TimestampedModel):
on_delete=models.PROTECT,
blank=True,
null=True,
- help_text="Subject type to be used unless overriden by the integration or data provider.",
+ help_text="Subject type to be used unless overridden by the integration or data provider.",
)
history = HistoricalRecords()
diff --git a/cdip_admin/integrations/static/integrations/js/README.md b/cdip_admin/integrations/static/integrations/js/README.md
new file mode 100644
index 000000000..94cf6b5f2
--- /dev/null
+++ b/cdip_admin/integrations/static/integrations/js/README.md
@@ -0,0 +1,37 @@
+# Searchable MultiSelect Widget
+
+This directory contains the JavaScript implementation for the searchable multiselect widget used in device group management forms.
+
+## Files
+
+- `searchable-multiselect.js` - Main JavaScript file that provides the searchable multiselect functionality
+
+## Features
+
+- **Search functionality**: Users can type to filter available destinations
+- **Visual selection**: Clear visual distinction between selected and available items
+- **Easy management**: Add/remove items with intuitive buttons
+- **Responsive design**: Works well on different screen sizes
+- **Bootstrap integration**: Uses Bootstrap classes for consistent styling
+
+## Usage
+
+The widget is automatically initialized when the form loads. It provides:
+
+1. A search input to filter available destinations
+2. A "Selected Destinations" section showing currently selected items
+3. An "Available Destinations" section showing items that can be added
+4. Add/remove buttons for each item
+
+## Dependencies
+
+- Font Awesome icons (loaded via CDN)
+- Bootstrap CSS classes
+- Modern JavaScript (ES6+ features)
+
+## Browser Support
+
+- Chrome 60+
+- Firefox 55+
+- Safari 12+
+- Edge 79+
diff --git a/cdip_admin/integrations/static/integrations/js/device-searchable-multiselect.js b/cdip_admin/integrations/static/integrations/js/device-searchable-multiselect.js
new file mode 100644
index 000000000..0aa35ab41
--- /dev/null
+++ b/cdip_admin/integrations/static/integrations/js/device-searchable-multiselect.js
@@ -0,0 +1,228 @@
+/**
+ * Device Searchable Multi-Select Widget
+ * Provides a searchable multiselect interface specifically for devices
+ */
+
+document.addEventListener('DOMContentLoaded', function() {
+ // Initialize all device searchable multiselect widgets
+ const deviceWidgets = document.querySelectorAll('[data-toggle="searchable-multiselect"]');
+ deviceWidgets.forEach(initDeviceWidget);
+});
+
+function initDeviceWidget(container) {
+ const widgetId = container.id;
+ const searchInput = container.querySelector('.search-input');
+ const availableList = container.querySelector('.available-list');
+ const selectedList = container.querySelector('.selected-list');
+ const hiddenInputsContainer = container.querySelector('.hidden-inputs');
+
+ // Get options from data attributes or use defaults
+ const options = {
+ choices: JSON.parse(container.dataset.choices || '[]'),
+ selected: JSON.parse(container.dataset.selected || '[]')
+ };
+
+ console.log('Initializing device widget with options:', options);
+
+ let allChoices = options.choices || [];
+ let selectedValues = new Set(options.selected || []);
+ let filteredChoices = [...allChoices];
+
+ // Initialize the widget
+ function init() {
+ renderSelectedItems();
+ renderAvailableItems();
+ setupEventListeners();
+ }
+
+ // Render selected items
+ function renderSelectedItems() {
+ selectedList.innerHTML = '';
+
+ if (selectedValues.size === 0) {
+ selectedList.innerHTML = `
No devices selected
`;
+ } else {
+ selectedValues.forEach(value => {
+ const choice = allChoices.find(c => c[0] == value);
+ if (choice) {
+ // For devices: choice[0]=id, choice[1]=name, choice[2]=external_id, choice[3]=owner, choice[4]=type, choice[5]=config
+ const item = createSelectedItem(choice[0], choice[1], choice[2], choice[3], choice[4], choice[5]);
+ selectedList.appendChild(item);
+ }
+ });
+ }
+
+ // Always update hidden inputs, even when no items are selected
+ updateHiddenInputs();
+ }
+
+ // Render available items (devices only)
+ function renderAvailableItems() {
+ availableList.innerHTML = '';
+
+ const availableChoices = filteredChoices.filter(choice => !selectedValues.has(choice[0]));
+
+ if (availableChoices.length === 0) {
+ availableList.innerHTML = `No devices available
`;
+ return;
+ }
+
+ availableChoices.forEach(choice => {
+ // For devices: choice[0]=id, choice[1]=name, choice[2]=external_id, choice[3]=owner, choice[4]=type, choice[5]=config
+ const item = createAvailableItem(choice[0], choice[1], choice[2], choice[3], choice[4], choice[5]);
+ availableList.appendChild(item);
+ });
+ }
+
+ // Create a selected item element as a list item (devices only)
+ function createSelectedItem(value, name, externalId, owner, type, config) {
+ const item = document.createElement('div');
+ item.className = 'list-group-item list-group-item-action list-group-item-success d-flex justify-content-between align-items-center mb-1';
+
+ const infoHtml = `
+
+
+
+
+ ${name || 'N/A'}
+
+
+ ${externalId}
+
+
+ ${type}
+
+
+ ${owner}
+
+
+
+
+ `;
+
+ item.innerHTML = `
+ ${infoHtml}
+
+
+
+ `;
+
+ // Add remove functionality
+ item.querySelector('.remove-item').addEventListener('click', (e) => {
+ e.stopPropagation();
+ selectedValues.delete(value);
+ renderSelectedItems();
+ renderAvailableItems();
+ });
+
+ return item;
+ }
+
+ // Create an available item element (devices only)
+ function createAvailableItem(value, name, externalId, owner, type, config) {
+ const item = document.createElement('div');
+ item.className = 'list-group-item list-group-item-action d-flex justify-content-between align-items-center mb-1';
+ item.style.cursor = 'pointer';
+
+ const infoHtml = `
+
+
+
+
+ ${name || 'N/A'}
+
+
+ ${externalId}
+
+
+ ${type}
+
+
+ ${owner}
+
+
+
+
+ `;
+
+ item.innerHTML = `
+ ${infoHtml}
+
+
+
+ `;
+
+ // Add click functionality to add item
+ item.addEventListener('click', (e) => {
+ if (!e.target.closest('.add-item')) {
+ addItem(value);
+ }
+ });
+
+ // Add button click functionality
+ item.querySelector('.add-item').addEventListener('click', (e) => {
+ e.stopPropagation();
+ addItem(value);
+ });
+
+ return item;
+ }
+
+ // Add an item to selected
+ function addItem(value) {
+ selectedValues.add(value);
+ renderSelectedItems();
+ renderAvailableItems();
+ }
+
+ // Update hidden inputs
+ function updateHiddenInputs() {
+ // Clear existing hidden inputs
+ hiddenInputsContainer.innerHTML = '';
+
+ // Add hidden inputs for each selected value
+ selectedValues.forEach(value => {
+ const hiddenInput = document.createElement('input');
+ hiddenInput.type = 'hidden';
+ hiddenInput.name = container.dataset.name || 'devices';
+ hiddenInput.value = value;
+ hiddenInputsContainer.appendChild(hiddenInput);
+ });
+
+ console.log('Updated hidden inputs:', hiddenInputsContainer.innerHTML);
+ }
+
+ // Setup event listeners
+ function setupEventListeners() {
+ // Search functionality - filters devices only
+ searchInput.addEventListener('input', (e) => {
+ const searchTerm = e.target.value.toLowerCase();
+ filteredChoices = allChoices.filter(choice => {
+ // For devices: choice[0]=id, choice[1]=name, choice[2]=external_id, choice[3]=owner, choice[4]=type, choice[5]=config
+ let matches = choice[1].toLowerCase().includes(searchTerm); // name
+ matches = matches ||
+ choice[2].toLowerCase().includes(searchTerm) || // external_id
+ choice[3].toLowerCase().includes(searchTerm) || // owner
+ choice[4].toLowerCase().includes(searchTerm) || // type
+ choice[5].toLowerCase().includes(searchTerm); // config
+
+ return matches;
+ });
+ renderSelectedItems();
+ renderAvailableItems();
+ });
+
+ // Clear search when clicking outside
+ document.addEventListener('click', (e) => {
+ if (!container.contains(e.target)) {
+ searchInput.value = '';
+ filteredChoices = [...allChoices];
+ renderSelectedItems();
+ renderAvailableItems();
+ }
+ });
+ }
+
+ // Initialize the widget
+ init();
+}
diff --git a/cdip_admin/integrations/static/integrations/js/integration-type-warning.js b/cdip_admin/integrations/static/integrations/js/integration-type-warning.js
new file mode 100644
index 000000000..182b43d74
--- /dev/null
+++ b/cdip_admin/integrations/static/integrations/js/integration-type-warning.js
@@ -0,0 +1,31 @@
+// JavaScript for handling Integration Type change warnings
+// Tracks state field changes and modifies HTMX requests accordingly
+
+let stateFieldEdited = false;
+
+// Track when the state field is edited
+function trackStateChanges() {
+ const stateField = document.querySelector('#div_id_state input, #div_id_state textarea, #div_id_state select');
+ if (stateField) {
+ stateField.addEventListener('input', function() {
+ stateFieldEdited = true;
+ });
+ stateField.addEventListener('change', function() {
+ stateFieldEdited = true;
+ });
+ }
+}
+
+// Handle Integration Type changes
+document.addEventListener('DOMContentLoaded', function() {
+ // Set up state change tracking
+ trackStateChanges();
+
+ // Re-setup state change tracking after HTMX updates
+ document.body.addEventListener('htmx:afterRequest', function(event) {
+ if (event.target.id === 'id_type' && event.detail.xhr.status === 200) {
+ // Re-setup state change tracking for any new state fields
+ trackStateChanges();
+ }
+ });
+});
diff --git a/cdip_admin/integrations/static/integrations/js/searchable-multiselect.js b/cdip_admin/integrations/static/integrations/js/searchable-multiselect.js
new file mode 100644
index 000000000..2d6fa6857
--- /dev/null
+++ b/cdip_admin/integrations/static/integrations/js/searchable-multiselect.js
@@ -0,0 +1,306 @@
+/**
+ * Searchable MultiSelect Widget
+ * Provides a user-friendly interface for selecting multiple items from a searchable list
+ * Updated: Fixed empty selection bug - v1.1
+ */
+
+function initSearchableMultiSelect(widgetId, options) {
+ const container = document.getElementById(widgetId + '_container');
+ const searchInput = document.getElementById(widgetId + '_search');
+ const selectedList = document.getElementById(widgetId + '_selected');
+ const availableList = document.getElementById(widgetId + '_available');
+ const hiddenInputsContainer = container.querySelector('.hidden-inputs');
+
+ let allChoices = options.choices || [];
+ let selectedValues = new Set(options.selected || []);
+ let filteredChoices = [...allChoices];
+
+ // Initialize the widget
+ function init() {
+ renderSelectedItems();
+ renderAvailableItems();
+ setupEventListeners();
+ }
+
+ // Render selected items
+ function renderSelectedItems() {
+ selectedList.innerHTML = '';
+
+ if (selectedValues.size === 0) {
+ selectedList.innerHTML = `No destinations selected
`;
+ } else {
+ selectedValues.forEach(value => {
+ const choice = allChoices.find(c => c[0] == value);
+ if (choice) {
+ // For destinations: choice[0]=id, choice[1]=name, choice[2]=owner, choice[3]=type, choice[4]=endpoint
+ const item = createSelectedItem(choice[0], choice[1], choice[2], choice[3], choice[4], undefined);
+ selectedList.appendChild(item);
+ }
+ });
+ }
+
+ // Always update hidden inputs, even when no items are selected
+ updateHiddenInputs();
+ }
+
+ // Render available items (destinations only)
+ function renderAvailableItems() {
+ availableList.innerHTML = '';
+
+ const availableChoices = filteredChoices.filter(choice => !selectedValues.has(choice[0]));
+
+ if (availableChoices.length === 0) {
+ availableList.innerHTML = `No destinations available
`;
+ return;
+ }
+
+ availableChoices.forEach(choice => {
+ // For destinations: choice[0]=id, choice[1]=name, choice[2]=owner, choice[3]=type, choice[4]=endpoint
+ const item = createAvailableItem(choice[0], choice[1], choice[2], choice[3], choice[4], undefined);
+ availableList.appendChild(item);
+ });
+ }
+
+ // Create a selected item element as a list item (destinations only)
+ function createSelectedItem(value, label, owner, type, endpoint, externalId) {
+ const item = document.createElement('div');
+ item.className = 'list-group-item list-group-item-action list-group-item-success d-flex justify-content-between align-items-center mb-1';
+
+ const infoHtml = `
+
+
${label}
+ ${owner} • ${type}
+
+ ${endpoint}
+
+ `;
+
+ item.innerHTML = `
+ ${infoHtml}
+
+
+
+ `;
+
+ // Add remove functionality
+ item.querySelector('.remove-item').addEventListener('click', (e) => {
+ e.stopPropagation();
+ selectedValues.delete(value);
+ renderSelectedItems();
+ renderAvailableItems();
+ });
+
+ return item;
+ }
+
+ // Create an available item element (destinations only)
+ function createAvailableItem(value, label, owner, type, endpoint, externalId) {
+ const item = document.createElement('div');
+ item.className = 'list-group-item list-group-item-action d-flex justify-content-between align-items-center mb-1';
+ item.style.cursor = 'pointer';
+
+ // This function is only used for destinations now
+ const infoHtml = `
+
+
${label}
+ ${owner} • ${type}
+
+ ${endpoint}
+
+ `;
+
+ item.innerHTML = `
+ ${infoHtml}
+
+
+
+ `;
+
+ // Add click functionality to the entire item
+ item.addEventListener('click', (e) => {
+ if (!e.target.closest('.add-item')) {
+ addItem(value);
+ }
+ });
+
+ // Add button click functionality
+ item.querySelector('.add-item').addEventListener('click', (e) => {
+ e.stopPropagation();
+ addItem(value);
+ });
+
+ return item;
+ }
+
+ // Add an item to selected
+ function addItem(value) {
+ selectedValues.add(value);
+ renderSelectedItems();
+ renderAvailableItems();
+ }
+
+ // Update hidden inputs for form submission
+ function updateHiddenInputs() {
+ hiddenInputsContainer.innerHTML = '';
+
+ // Convert Set to Array and use sequential indices
+ const selectedArray = Array.from(selectedValues);
+
+ if (selectedArray.length === 0) {
+ // When no items are selected, create a single hidden input with empty value
+ // This ensures Django knows the field should be cleared
+ const input = document.createElement('input');
+ input.type = 'hidden';
+ input.name = `${options.name}_0`;
+ input.value = '';
+ hiddenInputsContainer.appendChild(input);
+ console.log('Empty selection: created single hidden input with empty value');
+ } else {
+ // Create inputs for each selected item
+ selectedArray.forEach((value, index) => {
+ const input = document.createElement('input');
+ input.type = 'hidden';
+ input.name = `${options.name}_${index}`;
+ input.value = value;
+ hiddenInputsContainer.appendChild(input);
+ });
+ }
+
+ // Debug: log the hidden inputs being created
+ console.log('Hidden inputs created:');
+ const allInputs = hiddenInputsContainer.querySelectorAll('input');
+ allInputs.forEach((input, index) => {
+ console.log(` ${input.name} = ${input.value}`);
+ });
+ }
+
+ // Setup event listeners
+ function setupEventListeners() {
+ // Search functionality - filters destinations only
+ searchInput.addEventListener('input', (e) => {
+ const searchTerm = e.target.value.toLowerCase();
+ filteredChoices = allChoices.filter(choice => {
+ // For destinations: choice[0]=id, choice[1]=name, choice[2]=owner, choice[3]=type, choice[4]=endpoint
+ let matches = choice[1].toLowerCase().includes(searchTerm); // name
+ matches = matches ||
+ choice[2].toLowerCase().includes(searchTerm) || // owner
+ choice[3].toLowerCase().includes(searchTerm) || // type
+ choice[4].toLowerCase().includes(searchTerm); // endpoint
+
+ return matches;
+ });
+ renderSelectedItems();
+ renderAvailableItems();
+ });
+
+ // Clear search when clicking outside
+ document.addEventListener('click', (e) => {
+ if (!container.contains(e.target)) {
+ searchInput.value = '';
+ filteredChoices = [...allChoices];
+ renderSelectedItems();
+ renderAvailableItems();
+ }
+ });
+ }
+
+ // Initialize the widget
+ init();
+}
+
+// CSS styles (injected dynamically) - only inject once
+(function() {
+ if (!document.getElementById('searchable-multiselect-styles')) {
+ const styles = `
+ .searchable-multiselect-container {
+ border: 1px solid #dee2e6;
+ border-radius: 0.375rem;
+ padding: 1rem;
+ background-color: #fff;
+ }
+
+ .search-input-container .search-input {
+ border-radius: 0.375rem;
+ border: 1px solid #ced4da;
+ padding: 0.5rem 0.75rem;
+ }
+
+ .search-input-container .search-input:focus {
+ border-color: #86b7fe;
+ outline: 0;
+ box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
+ }
+
+ .destination-card {
+ transition: all 0.2s ease-in-out;
+ }
+
+ .available-card:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 8px rgba(0,0,0,0.1);
+ }
+
+ .selected-list, .available-list {
+ max-height: 400px;
+ overflow-y: auto;
+ border: 1px solid #dee2e6;
+ border-radius: 0.375rem;
+ padding: 0;
+ background-color: #f8f9fa;
+ }
+
+ .selected-list .list-group-item, .available-list .list-group-item {
+ border: none;
+ border-bottom: 1px solid #dee2e6;
+ border-radius: 0;
+ }
+
+ .selected-list .list-group-item:last-child, .available-list .list-group-item:last-child {
+ border-bottom: none;
+ }
+
+ .info-label {
+ color: #6c757d;
+ font-size: 0.875rem;
+ margin-right: 0.5rem;
+ }
+
+ .info-value {
+ color: #495057;
+ font-size: 0.875rem;
+ }
+
+ .info-row {
+ display: flex;
+ align-items: flex-start;
+ }
+
+ .cursor-pointer {
+ cursor: pointer;
+ }
+
+ .btn-sm {
+ padding: 0.25rem 0.5rem;
+ font-size: 0.875rem;
+ border-radius: 0.25rem;
+ }
+
+ .text-break {
+ word-break: break-all;
+ }
+
+ .card {
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+ }
+
+ .card:hover {
+ box-shadow: 0 4px 8px rgba(0,0,0,0.15);
+ }
+ `;
+
+ const styleSheet = document.createElement('style');
+ styleSheet.id = 'searchable-multiselect-styles';
+ styleSheet.textContent = styles;
+ document.head.appendChild(styleSheet);
+ }
+})();
diff --git a/cdip_admin/integrations/static/integrations/js/subject-type-autocomplete.js b/cdip_admin/integrations/static/integrations/js/subject-type-autocomplete.js
new file mode 100644
index 000000000..fa4bd05cb
--- /dev/null
+++ b/cdip_admin/integrations/static/integrations/js/subject-type-autocomplete.js
@@ -0,0 +1,532 @@
+/**
+ * Subject Type Autocomplete Widget
+ * Provides auto-complete functionality for subject type selection with ability to create new ones
+ * Version: 1.0
+ */
+
+function initSubjectTypeAutocomplete(widgetId, options) {
+ console.log('=== INITIALIZING SUBJECT TYPE WIDGET ===');
+ console.log('Widget ID:', widgetId);
+ console.log('Options:', options);
+
+ const container = document.getElementById(widgetId + '_container');
+ const input = document.getElementById(widgetId + '_input');
+ const hiddenInput = document.getElementById(widgetId + '_hidden');
+ const dropdown = document.getElementById(widgetId + '_dropdown');
+ const dropdownMenu = document.getElementById(widgetId + '_dropdown_menu');
+ const selectedSection = document.getElementById(widgetId + '_selected');
+ const createNewSection = document.getElementById(widgetId + '_create_new');
+
+ console.log('Found elements:', {
+ container: !!container,
+ input: !!input,
+ hiddenInput: !!hiddenInput,
+ dropdown: !!dropdown,
+ dropdownMenu: !!dropdownMenu,
+ selectedSection: !!selectedSection,
+ createNewSection: !!createNewSection
+ });
+
+ if (!container || !input || !hiddenInput) {
+ console.error('Required elements not found for widget:', widgetId);
+ return;
+ }
+
+ // Check if this widget is already initialized
+ if (container.dataset.initialized === 'true') {
+ console.warn('Widget already initialized:', widgetId);
+ return;
+ }
+ container.dataset.initialized = 'true';
+
+ let allChoices = options.choices || [];
+ let currentValue = options.currentValue || '';
+ let selectedChoice = null;
+
+ // Initialize the widget
+ function init() {
+ console.log('Subject Type Widget: Initializing...');
+ console.log('Subject Type Widget: allChoices =', allChoices);
+ console.log('Subject Type Widget: currentValue =', currentValue);
+
+ setupEventListeners();
+
+ // Add form submission debugging
+ const form = container.closest('form');
+ if (form) {
+ form.addEventListener('submit', function(e) {
+ console.log('Form submitted!');
+ console.log('Hidden input value before submit:', hiddenInput.value);
+ console.log('Hidden input name:', hiddenInput.name);
+
+ // Log all form data
+ const formData = new FormData(form);
+ console.log('All form data:');
+ for (let [key, value] of formData.entries()) {
+ console.log(`${key}: ${value}`);
+ }
+ });
+ }
+
+ if (currentValue) {
+ // Find and set the current selection
+ const choice = allChoices.find(c => c[0] === currentValue);
+ if (choice) {
+ selectChoice(choice);
+ }
+ }
+
+ console.log('Subject Type Widget: Initialization complete');
+ }
+
+ // Setup event listeners
+ function setupEventListeners() {
+ // Input typing for autocomplete
+ input.addEventListener('input', handleInput);
+ input.addEventListener('focus', () => {
+ showDropdown();
+ if (dropdownMenu.innerHTML === '') {
+ showAllChoices();
+ }
+ });
+ input.addEventListener('blur', hideDropdown);
+
+ // Dropdown toggle
+ dropdown.addEventListener('click', toggleDropdown);
+
+ // Click outside to close dropdown
+ document.addEventListener('click', (e) => {
+ if (!container.contains(e.target)) {
+ hideDropdown();
+ }
+ });
+
+ // Clear selection
+ container.addEventListener('click', (e) => {
+ if (e.target.closest('.clear-selection')) {
+ clearSelection();
+ }
+ });
+
+ // Show create new section
+ container.addEventListener('click', (e) => {
+ if (e.target.closest('.show-create-new')) {
+ // Get the search term from the input field
+ const currentSearchTerm = input.value.toLowerCase();
+ showCreateNewSection(currentSearchTerm);
+ }
+ });
+
+ // Cancel create new
+ container.addEventListener('click', (e) => {
+ if (e.target.closest('.cancel-create')) {
+ hideCreateNewSection();
+ }
+ });
+
+ // Create new subject type
+ container.addEventListener('click', (e) => {
+ if (e.target.closest('.create-subject-type')) {
+ createNewSubjectType();
+ }
+ });
+
+ // Auto-generate slug from display name
+ const displayNameInput = container.querySelector('.new-display-name');
+ const valueInput = container.querySelector('.new-value');
+
+ if (displayNameInput && valueInput) {
+ displayNameInput.addEventListener('input', (e) => {
+ const slug = e.target.value.toLowerCase()
+ .replace(/[^a-z0-9\s-]/g, '')
+ .replace(/\s+/g, '_')
+ .replace(/_+/g, '_')
+ .replace(/^_|_$/g, '');
+ valueInput.value = slug;
+ // Trigger validation after auto-filling
+ validateValueField();
+ });
+
+ // Add validation for the value field
+ valueInput.addEventListener('input', validateValueField);
+ valueInput.addEventListener('blur', validateValueField);
+ }
+ }
+
+ // Handle input typing
+ function handleInput(e) {
+ const searchTerm = e.target.value.toLowerCase();
+
+ if (searchTerm.length === 0) {
+ showAllChoices();
+ hideCreateNewSection();
+ } else {
+ const filteredChoices = allChoices.filter(choice =>
+ choice[1].toLowerCase().includes(searchTerm) ||
+ choice[2].toLowerCase().includes(searchTerm)
+ );
+
+ // Always render filtered choices (or empty state)
+ renderChoices(filteredChoices);
+
+ // Always show create new option when there's a search term
+ showCreateNewOption(searchTerm);
+ }
+
+ showDropdown();
+ }
+
+ // Show all choices
+ function showAllChoices() {
+ console.log('Subject Type Widget: showAllChoices called, choices =', allChoices);
+ renderChoices(allChoices);
+ }
+
+ // Render choices in dropdown
+ function renderChoices(choices) {
+ console.log('Subject Type Widget: renderChoices called with', choices);
+ dropdownMenu.innerHTML = '';
+
+ if (choices.length === 0) {
+ dropdownMenu.innerHTML = 'No subject types found
';
+ console.log('Subject Type Widget: No choices to render');
+ return;
+ }
+
+ choices.forEach(choice => {
+ const item = document.createElement('div');
+ item.className = 'dropdown-item choice-item';
+ item.innerHTML = `
+
+ ${choice[1]}
+ ${choice[2]}
+
+ `;
+
+ item.addEventListener('click', () => {
+ selectChoice(choice);
+ hideDropdown();
+ });
+
+ dropdownMenu.appendChild(item);
+ });
+ }
+
+ // Show create new option
+ function showCreateNewOption(searchTerm) {
+ // Check if a create new item already exists and remove it
+ const existingCreateNew = dropdownMenu.querySelector('.show-create-new');
+ if (existingCreateNew) {
+ existingCreateNew.remove();
+ }
+
+ const createNewItem = document.createElement('div');
+ createNewItem.className = 'dropdown-item show-create-new';
+ createNewItem.innerHTML = `
+
+ Create new: "${searchTerm}"
+
+ `;
+
+ createNewItem.addEventListener('click', (e) => {
+ e.stopPropagation();
+ showCreateNewSection(searchTerm);
+ hideDropdown();
+ });
+
+ dropdownMenu.appendChild(createNewItem);
+ }
+
+ // Select a choice
+ function selectChoice(choice) {
+ console.log('selectChoice called with:', choice);
+ console.log('Current hidden input value before update:', hiddenInput.value);
+ console.log('Current hidden input name:', hiddenInput.name);
+
+ selectedChoice = choice;
+ hiddenInput.value = choice[0];
+ input.value = '';
+
+ // Show selected section
+ selectedSection.querySelector('.selected-name').textContent = choice[1];
+ selectedSection.querySelector('.selected-value').textContent = choice[2];
+ selectedSection.style.display = 'block';
+
+ console.log('Selected subject type:', choice);
+ console.log('Hidden input value set to:', hiddenInput.value);
+ console.log('Hidden input name:', hiddenInput.name);
+ console.log('Hidden input element:', hiddenInput);
+
+ // Verify the value was actually set
+ setTimeout(() => {
+ console.log('Verification - hidden input value after 100ms:', hiddenInput.value);
+ }, 100);
+ }
+
+ // Clear selection
+ function clearSelection() {
+ selectedChoice = null;
+ hiddenInput.value = '';
+ input.value = '';
+ selectedSection.style.display = 'none';
+ console.log('Cleared subject type selection');
+ }
+
+ // Show dropdown
+ function showDropdown() {
+ console.log('Subject Type Widget: showDropdown called');
+ dropdownMenu.style.display = 'block';
+ dropdown.setAttribute('aria-expanded', 'true');
+ }
+
+ // Hide dropdown
+ function hideDropdown() {
+ // Delay hiding to allow clicks on dropdown items
+ setTimeout(() => {
+ dropdownMenu.style.display = 'none';
+ dropdown.setAttribute('aria-expanded', 'false');
+ }, 150);
+ }
+
+ // Toggle dropdown
+ function toggleDropdown() {
+ if (dropdownMenu.style.display === 'block') {
+ hideDropdown();
+ } else {
+ showDropdown();
+ if (dropdownMenu.innerHTML === '') {
+ showAllChoices();
+ }
+ }
+ }
+
+ // Show create new section
+ function showCreateNewSection(searchTerm = '') {
+ createNewSection.style.display = 'block';
+ input.value = '';
+ hideDropdown();
+
+ // Pre-populate the fields with the search term
+ if (searchTerm) {
+ const displayNameInput = container.querySelector('.new-display-name');
+ const valueInput = container.querySelector('.new-value');
+
+ if (displayNameInput) {
+ // Convert search term to a more user-friendly display name
+ // Handle both underscore-separated slugs and space-separated text
+ let displayName;
+ if (searchTerm.includes('_') || searchTerm.includes('-')) {
+ // If it contains underscores or hyphens, treat as slug and convert to title case
+ const separator = searchTerm.includes('_') ? '_' : '-';
+ displayName = searchTerm
+ .split(separator)
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
+ .join(' ');
+ } else {
+ // If it's regular text, just capitalize each word
+ displayName = searchTerm
+ .split(' ')
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
+ .join(' ');
+ }
+ displayNameInput.value = displayName;
+ }
+
+ if (valueInput) {
+ // Generate a proper slug from the search term
+ const slug = searchTerm.toLowerCase()
+ .replace(/[^a-z0-9\s-]/g, '')
+ .replace(/\s+/g, '_')
+ .replace(/_+/g, '_')
+ .replace(/^_|_$/g, '');
+ valueInput.value = slug;
+ }
+ }
+
+ // Initial validation
+ validateValueField();
+ }
+
+ // Validate the value field
+ function validateValueField() {
+ const valueInput = container.querySelector('.new-value');
+ const createButton = container.querySelector('.create-subject-type');
+ const validationMessage = container.querySelector('.validation-message');
+
+ if (!valueInput || !createButton) return;
+
+ const value = valueInput.value.trim();
+ const isValid = isValidValue(value);
+
+ // Update button state
+ createButton.disabled = !isValid;
+
+ // Update visual feedback
+ if (value.length === 0) {
+ // Empty field - neutral state
+ valueInput.classList.remove('is-valid', 'is-invalid');
+ if (validationMessage) {
+ validationMessage.style.display = 'none';
+ }
+ } else if (isValid) {
+ // Valid field
+ valueInput.classList.remove('is-invalid');
+ valueInput.classList.add('is-valid');
+ if (validationMessage) {
+ validationMessage.style.display = 'none';
+ }
+ } else {
+ // Invalid field
+ valueInput.classList.remove('is-valid');
+ valueInput.classList.add('is-invalid');
+ if (validationMessage) {
+ validationMessage.style.display = 'block';
+ validationMessage.textContent = getValidationMessage(value);
+ }
+ }
+ }
+
+ // Check if value is valid
+ function isValidValue(value) {
+ if (!value || value.length === 0) {
+ return false;
+ }
+
+ // Check length (up to 32 characters)
+ if (value.length > 32) {
+ return false;
+ }
+
+ // Check characters (lowercase letters, digits, underscores, and hyphens)
+ const validPattern = /^[a-z0-9_-]+$/;
+ return validPattern.test(value);
+ }
+
+ // Get validation error message
+ function getValidationMessage(value) {
+ if (value.length > 32) {
+ return 'Value must be 32 characters or less';
+ }
+
+ const invalidChars = value.match(/[^a-z0-9_-]/g);
+ if (invalidChars) {
+ return `Invalid characters: ${invalidChars.join(', ')}. Only lowercase letters, digits, underscores, and hyphens are allowed.`;
+ }
+
+ return 'Invalid value format';
+ }
+
+ // Hide create new section
+ function hideCreateNewSection() {
+ createNewSection.style.display = 'none';
+ }
+
+ // Create new subject type
+ async function createNewSubjectType() {
+ const displayNameInput = container.querySelector('.new-display-name');
+ const valueInput = container.querySelector('.new-value');
+
+ const displayName = displayNameInput.value.trim();
+ const value = valueInput.value.trim();
+
+ if (!displayName || !value) {
+ alert('Please fill in both Display Name and Value fields.');
+ return;
+ }
+
+ try {
+ // Create new subject type via AJAX
+ const response = await fetch('/integrations/api/create-subject-type/', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value,
+ },
+ body: JSON.stringify({
+ display_name: displayName,
+ value: value
+ })
+ });
+
+ if (response.ok) {
+ const data = await response.json();
+ const newChoice = [data.id, data.display_name, data.value];
+
+ // Add to choices list
+ allChoices.push(newChoice);
+ allChoices.sort((a, b) => a[1].localeCompare(b[1]));
+
+ // Select the new choice
+ selectChoice(newChoice);
+
+ // Hide create new section
+ hideCreateNewSection();
+
+ console.log('Created new subject type:', newChoice);
+ } else {
+ const error = await response.json();
+ alert('Error creating subject type: ' + (error.message || 'Unknown error'));
+ }
+ } catch (error) {
+ console.error('Error creating subject type:', error);
+ alert('Error creating subject type. Please try again.');
+ }
+ }
+
+ // Initialize
+ init();
+}
+
+// CSS styles (injected dynamically) - only inject once
+(function() {
+ if (!document.getElementById('subject-type-autocomplete-styles')) {
+ const styles = `
+ .subject-type-autocomplete-container .autocomplete-dropdown {
+ width: 100%;
+ max-height: 200px;
+ overflow-y: auto;
+ border: 1px solid #ced4da;
+ border-radius: 0.375rem;
+ box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
+ }
+
+ .subject-type-autocomplete-container .choice-item {
+ cursor: pointer;
+ padding: 0.5rem 0.75rem;
+ }
+
+ .subject-type-autocomplete-container .choice-item:hover {
+ background-color: #f8f9fa;
+ }
+
+ .subject-type-autocomplete-container .show-create-new {
+ cursor: pointer;
+ padding: 0.5rem 0.75rem;
+ border-top: 1px solid #dee2e6;
+ }
+
+ .subject-type-autocomplete-container .show-create-new:hover {
+ background-color: #e3f2fd;
+ }
+
+ .subject-type-autocomplete-container .dropdown-toggle::after {
+ display: none;
+ }
+
+ .subject-type-autocomplete-container .input-group .form-control {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ }
+
+ .subject-type-autocomplete-container .input-group .btn {
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+ }
+ `;
+
+ const styleSheet = document.createElement('style');
+ styleSheet.id = 'subject-type-autocomplete-styles';
+ styleSheet.textContent = styles;
+ document.head.appendChild(styleSheet);
+ }
+})();
diff --git a/cdip_admin/integrations/templates/integrations/bridge_integration_list.html b/cdip_admin/integrations/templates/integrations/bridge_integration_list.html
index 182cc9f3b..0fbf8e75b 100644
--- a/cdip_admin/integrations/templates/integrations/bridge_integration_list.html
+++ b/cdip_admin/integrations/templates/integrations/bridge_integration_list.html
@@ -19,11 +19,21 @@ Bridge Integrations
{% if filter %}
-
+
diff --git a/cdip_admin/integrations/templates/integrations/device_group_detail.html b/cdip_admin/integrations/templates/integrations/device_group_detail.html
deleted file mode 100644
index 50ea29c6a..000000000
--- a/cdip_admin/integrations/templates/integrations/device_group_detail.html
+++ /dev/null
@@ -1,32 +0,0 @@
-{% extends "base.html" %}
-{% load render_table from django_tables2 %}
-{% load bootstrap4 %}
-{% load static %}
-
-{% block title %}Device Group: {{object.name}}{% endblock %}
-
-{% block content %}
-
{{object.name}}
-
Organization: {{object.owner.name}}
-
-
-
-
Devices in Group
-
-
-
-
-
- {% render_table table %}
-
-{% endblock %}
\ No newline at end of file
diff --git a/cdip_admin/integrations/templates/integrations/device_group_devices_manage.html b/cdip_admin/integrations/templates/integrations/device_group_devices_manage.html
new file mode 100644
index 000000000..675ed18e5
--- /dev/null
+++ b/cdip_admin/integrations/templates/integrations/device_group_devices_manage.html
@@ -0,0 +1,137 @@
+{% extends "base.html" %}
+{% load bootstrap4 %}
+{% load static %}
+
+{% block title %}Manage Devices - {{device_group.name}}{% endblock %}
+
+{% block content %}
+
+
+
Manage Devices in {{device_group.name}}
+
Organization: {{device_group.owner.name}}
+
+
+
+
+ {% if inbound_integration %}
+
+ {% else %}
+
+
+
+
+ No Integration Configuration Found
+
+ This device group is not associated with any inbound integration configuration. No devices can be added.
+
+
+
+ {% endif %}
+
+
+{% endblock %}
+
diff --git a/cdip_admin/integrations/templates/integrations/device_group_management_update.html b/cdip_admin/integrations/templates/integrations/device_group_management_update.html
index 7e2f02b40..aef8607c5 100644
--- a/cdip_admin/integrations/templates/integrations/device_group_management_update.html
+++ b/cdip_admin/integrations/templates/integrations/device_group_management_update.html
@@ -1,10 +1,13 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
+{% load static %}
-{% block title %}Manage Devices{% endblock %}
+{% block title %}Manage Device Group{% endblock %}
{% block content %}
+
+
+
+
+
+
+
{{object.name}}
+
Organization: {{object.owner.name}}
+
+
+
+
+ {% if inbound_integration %}
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if devices %}
+
+
+
+
+ Name
+ External ID
+ Type
+ Owner
+ Configuration
+
+
+
+ {% for device in devices %}
+
+
+
+ {{ device.name|default:"N/A" }}
+
+
+ {{ device.external_id|default:"N/A" }}
+
+ {{ device.inbound_configuration.type.name }}
+
+ {{ device.inbound_configuration.owner.name }}
+ {{ device.inbound_configuration.name|default:"N/A" }}
+
+ {% endfor %}
+
+
+
+
+ {% else %}
+
+
+
No devices in this group
+
Add devices to this group to start managing them.
+
+
+ Add Devices
+
+
+ {% endif %}
+
+
+
+
{% endblock %}
\ No newline at end of file
diff --git a/cdip_admin/integrations/templates/integrations/device_list.html b/cdip_admin/integrations/templates/integrations/device_list.html
index e68ca9d8c..60261da38 100644
--- a/cdip_admin/integrations/templates/integrations/device_list.html
+++ b/cdip_admin/integrations/templates/integrations/device_list.html
@@ -16,11 +16,23 @@
Devices
{% if filter %}
-
-
+
{% endif %}
{% render_table table %}
diff --git a/cdip_admin/integrations/templates/integrations/inbound_integration_configuration_add.html b/cdip_admin/integrations/templates/integrations/inbound_integration_configuration_add.html
index 067593b32..15b860860 100644
--- a/cdip_admin/integrations/templates/integrations/inbound_integration_configuration_add.html
+++ b/cdip_admin/integrations/templates/integrations/inbound_integration_configuration_add.html
@@ -1,7 +1,7 @@
{% extends "base.html" %}
{% load crispy_forms_filters %}
-
{% load crispy_forms_tags %}
+{% load static %}
{% block title %}Add Inbound Integration Configuration{% endblock %}
@@ -12,4 +12,8 @@
Add Inbound Integration
{{form.errors}}
{% crispy form %}
+{% endblock %}
+
+{% block extra_js %}
+
{% endblock %}
\ No newline at end of file
diff --git a/cdip_admin/integrations/urls.py b/cdip_admin/integrations/urls.py
index 49918ed28..df6835ae5 100644
--- a/cdip_admin/integrations/urls.py
+++ b/cdip_admin/integrations/urls.py
@@ -12,22 +12,22 @@
path("devices", views.DeviceList.as_view(), name="device_list"),
path("devices/add", views.DeviceAddView.as_view(), name="device_add"),
path(
- "devicegroups/
",
- views.DeviceGroupDetail.as_view(),
+ "devicegroups/",
+ views.DeviceGroupManagementUpdateView.as_view(),
name="device_group",
),
path("devicegroups", views.DeviceGroupListView.as_view(), name="device_group_list"),
path("devicegroups/add", views.DeviceGroupAddView.as_view(), name="device_group_add"),
- path(
- "devicegroups//edit",
- views.DeviceGroupUpdateView.as_view(),
- name="device_group_update",
- ),
path(
"devicegroups//manage",
views.DeviceGroupManagementUpdateView.as_view(),
name="device_group_management_update",
),
+ path(
+ "devicegroups//devices/manage",
+ views.DeviceGroupDevicesManagementView.as_view(),
+ name="device_group_devices_manage",
+ ),
path("devicestates", views.DeviceStateList.as_view(), name="device_state_list"),
path(
"inboundtypes/",
@@ -94,6 +94,21 @@
views.InboundIntegrationConfigurationUpdateView.type_modal,
name="inboundconfigurations/type_modal",
),
+ path(
+ "inboundconfigurations/add_type_modal/",
+ views.InboundIntegrationConfigurationAddView.type_modal,
+ name="inboundconfigurations/add_type_modal",
+ ),
+ path(
+ "inboundconfigurations/add_schema//",
+ views.InboundIntegrationConfigurationAddView.add_schema,
+ name="inboundconfigurations/add_schema",
+ ),
+ path(
+ "inboundconfigurations/add_dropdown_restore/",
+ views.InboundIntegrationConfigurationAddView.add_dropdown_restore,
+ name="inboundconfigurations/add_dropdown_restore",
+ ),
path(
"inboundconfigurations/schema///",
views.InboundIntegrationConfigurationUpdateView.schema,
@@ -171,5 +186,7 @@
"bridges/dropdown_restore//",
views.BridgeIntegrationUpdateView.dropdown_restore,
name="bridges/dropdown_restore",
- )
+ ),
+ # API endpoints
+ path("api/create-subject-type/", views.create_subject_type_api, name="create_subject_type_api"),
]
diff --git a/cdip_admin/integrations/views.py b/cdip_admin/integrations/views.py
index 42f57a0c0..3dc3f0e67 100644
--- a/cdip_admin/integrations/views.py
+++ b/cdip_admin/integrations/views.py
@@ -1,5 +1,6 @@
import logging
import random
+import json
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin, LoginRequiredMixin
@@ -10,6 +11,9 @@
from django_filters.views import FilterView
from django_tables2.views import SingleTableMixin
from django.db.models import Count
+from django.http import JsonResponse
+from django.views.decorators.http import require_http_methods
+from django.views.decorators.csrf import csrf_exempt
from cdip_admin import settings
from core.permissions import IsGlobalAdmin, IsOrganizationMember
@@ -25,9 +29,11 @@
)
from .forms import (
InboundIntegrationConfigurationForm,
+ InboundIntegrationConfigurationAddForm,
OutboundIntegrationConfigurationForm,
DeviceGroupForm,
DeviceGroupManagementForm,
+ DeviceGroupDevicesManagementForm,
InboundIntegrationTypeForm,
OutboundIntegrationTypeForm,
BridgeIntegrationForm,
@@ -188,23 +194,54 @@ def get_table_data(self):
return qs.annotate(device_count=Count("devices"))
-class DeviceGroupDetail(PermissionRequiredMixin, SingleTableMixin, DetailView):
- template_name = "integrations/device_group_detail.html"
+
+
+class DeviceGroupDevicesManagementView(PermissionRequiredMixin, UpdateView):
+ template_name = "integrations/device_group_devices_manage.html"
+ form_class = DeviceGroupDevicesManagementForm
model = DeviceGroup
- table_class = DeviceTable
- paginate_by = default_paginate_by
- permission_required = "integrations.view_devicegroup"
+ permission_required = "integrations.change_devicegroup"
def get_object(self):
- return get_object_or_404(DeviceGroup, pk=self.kwargs.get("module_id"))
+ device_group = get_object_or_404(
+ DeviceGroup, pk=self.kwargs.get("device_group_id")
+ )
+ if not IsGlobalAdmin.has_permission(None, self.request, None):
+ if not IsOrganizationMember.is_object_owner(
+ self.request.user, device_group.owner
+ ):
+ raise PermissionDenied
+ return device_group
- def get_table_data(self):
- return self.get_object().devices
+ def get(self, request, *args, **kwargs):
+ form_class = self.get_form_class()
+ self.object = self.get_object()
+ form = form_class(instance=self.object, request=request)
+ return self.render_to_response(self.get_context_data(form=form))
+
+ def post(self, request, *args, **kwargs):
+ self.object = self.get_object()
+ form = self.get_form()
+ if form.is_valid():
+ return self.form_valid(form)
+ else:
+ return self.form_invalid(form)
+
+ def form_valid(self, form):
+ form.save()
+ return redirect("device_group", device_group_id=str(self.object.id))
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
- base_url = reverse("device_list")
- context["base_url"] = base_url
+ context["device_group"] = self.object
+
+ # Get the inbound integration configuration that uses this device group as default
+ try:
+ inbound_integration = self.object.inbound_integration_configuration.get()
+ context["inbound_integration"] = inbound_integration
+ except (InboundIntegrationConfiguration.DoesNotExist, InboundIntegrationConfiguration.MultipleObjectsReturned):
+ context["inbound_integration"] = None
+
return context
@@ -251,13 +288,13 @@ class DeviceGroupAddView(PermissionRequiredMixin, FormView):
permission_required = "integrations.add_devicegroup"
def post(self, request, *args, **kwargs):
- form = DeviceGroupForm(request.POST)
+ form = DeviceGroupForm(request.POST, request=request)
if form.is_valid():
config = form.save()
return redirect("device_group", str(config.id))
def get_form(self, form_class=None):
- form = DeviceGroupForm()
+ form = DeviceGroupForm(request=self.request)
if not IsGlobalAdmin.has_permission(None, self.request, None):
# can only add if you are an admin of at least one organization
if not IsOrganizationMember.filter_queryset_for_user(
@@ -269,19 +306,13 @@ def get_form(self, form_class=None):
return form
-class DeviceGroupUpdateView(PermissionRequiredMixin, UpdateView):
+
+
+class DeviceGroupManagementUpdateView(PermissionRequiredMixin, UpdateView):
template_name = "integrations/device_group_update.html"
- form_class = DeviceGroupForm
+ form_class = DeviceGroupManagementForm
model = DeviceGroup
- permission_required = "integrations.change_devicegroup"
-
- def get(self, request, *args, **kwargs):
- form_class = self.get_form_class()
- self.object = self.get_object()
- form = form_class(instance=self.object)
- if not IsGlobalAdmin.has_permission(None, self.request, None):
- form = filter_device_group_form_fields(form, self.request.user)
- return self.render_to_response(self.get_context_data(form=form))
+ permission_required = "integrations.view_devicegroup"
def get_object(self):
device_group = get_object_or_404(
@@ -294,34 +325,54 @@ def get_object(self):
raise PermissionDenied
return device_group
- def get_success_url(self):
- return reverse(
- "device_group", kwargs={"module_id": self.kwargs.get("device_group_id")}
- )
-
-
-class DeviceGroupManagementUpdateView(LoginRequiredMixin, UpdateView):
- template_name = "integrations/device_group_update.html"
- form_class = DeviceGroupManagementForm
- model = DeviceGroup
-
- def get_object(self):
- device_group = get_object_or_404(
- DeviceGroup, pk=self.kwargs.get("device_group_id")
- )
- return device_group
-
def get(self, request, *args, **kwargs):
form_class = self.get_form_class()
self.object = self.get_object()
- form = form_class(instance=self.object)
+ form = form_class(instance=self.object, request=request)
if not IsGlobalAdmin.has_permission(None, self.request, None):
form = filter_device_group_form_fields(form, self.request.user)
return self.render_to_response(self.get_context_data(form=form))
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+
+ # Get the inbound integration configuration that uses this device group as default
+ try:
+ inbound_integration = self.object.inbound_integration_configuration.get()
+ context["inbound_integration"] = inbound_integration
+ except (InboundIntegrationConfiguration.DoesNotExist, InboundIntegrationConfiguration.MultipleObjectsReturned):
+ context["inbound_integration"] = None
+
+ # Add device information for display
+ context["devices"] = self.object.devices.select_related(
+ 'inbound_configuration__owner',
+ 'inbound_configuration__type'
+ ).all()
+
+ return context
+
+ def post(self, request, *args, **kwargs):
+ """Override post method to ensure many-to-many relationships are saved properly."""
+ self.object = self.get_object()
+ form = self.get_form()
+ # Pass request to form for proper initialization
+ form = self.form_class(request.POST, instance=self.object, request=request)
+
+ if form.is_valid():
+ # Save the instance
+ instance = form.save(commit=False)
+ instance.save()
+
+ # Explicitly save many-to-many relationships
+ form._save_m2m()
+
+ return self.form_valid(form)
+ else:
+ return self.form_invalid(form)
+
def get_success_url(self):
return reverse(
- "device_group", kwargs={"module_id": self.kwargs.get("device_group_id")}
+ "device_group", kwargs={"device_group_id": self.kwargs.get("device_group_id")}
)
@@ -503,20 +554,20 @@ def inbound_integration_configuration_detail(request, id):
class InboundIntegrationConfigurationAddView(PermissionRequiredMixin, FormView):
template_name = "integrations/inbound_integration_configuration_add.html"
- form_class = InboundIntegrationConfigurationForm
+ form_class = InboundIntegrationConfigurationAddForm
model = InboundIntegrationConfiguration
permission_required = "integrations.add_inboundintegrationconfiguration"
def post(self, request, *args, **kwargs):
- form = InboundIntegrationConfigurationForm(request.POST)
+ form = InboundIntegrationConfigurationAddForm(request.POST, request=request)
if form.is_valid():
config: InboundIntegrationConfiguration = form.save()
device_group = config.default_devicegroup
- return redirect("device_group_update", device_group_id=device_group.id)
+ return redirect("device_group_management_update", device_group_id=device_group.id)
return render(request, self.template_name, {'form': form})
def get_form(self, form_class=None):
- form = InboundIntegrationConfigurationForm()
+ form = InboundIntegrationConfigurationAddForm(request=self.request)
if not IsGlobalAdmin.has_permission(None, self.request, None):
form.fields[
"owner"
@@ -525,6 +576,84 @@ def get_form(self, form_class=None):
)
return form
+ @staticmethod
+ @requires_csrf_token
+ def type_modal(request):
+ """
+ Type modal for Add Integration form.
+ Always shows warning modal when changing integration type.
+ """
+ if request.GET.get("type"):
+ integration_type = request.GET.get("type")
+ selected_type = InboundIntegrationType.objects.get(id=integration_type)
+ else:
+ integration_type = "none"
+ selected_type = "None"
+
+ # Always show warning modal when changing integration type
+ rendered = render_to_string('integrations/type_modal.html', {
+ 'selected_type': selected_type,
+ 'target': '#div_id_state',
+ 'proceed_button': reverse("inboundconfigurations/add_schema",
+ kwargs={
+ "integration_type": integration_type,
+ "update": "true"
+ }),
+ 'cancel_button': reverse("inboundconfigurations/add_dropdown_restore")
+ })
+ return HttpResponse(rendered)
+
+ @staticmethod
+ @requires_csrf_token
+ def add_schema(request, integration_type, update):
+ """
+ Schema endpoint for Add Integration form.
+ """
+ if integration_type != "none":
+ integration_type_obj = InboundIntegrationType.objects.get(id=integration_type)
+ form = InboundIntegrationConfigurationAddForm()
+
+ # Set the session for the integration type
+ request.session["integration_type"] = integration_type
+
+ # Set up the state field widget based on the schema
+ if integration_type_obj.configuration_schema != {}:
+ form.fields['state'].widget = JSONFormWidget(
+ schema=integration_type_obj.configuration_schema,
+ )
+ else:
+ form.fields['state'].widget = FormattedJsonFieldWidget()
+
+ return HttpResponse(as_crispy_field(form["state"]))
+ return HttpResponse("")
+
+ @staticmethod
+ @requires_csrf_token
+ def add_dropdown_restore(request):
+ """
+ Dropdown restore for Add Integration form.
+ """
+ response = f""""""
+ return HttpResponse(response)
+
class InboundIntegrationConfigurationUpdateView(
PermissionRequiredMixin,
@@ -535,6 +664,12 @@ class InboundIntegrationConfigurationUpdateView(
model = InboundIntegrationConfiguration
permission_required = "integrations.change_inboundintegrationconfiguration"
+ def get_form_kwargs(self):
+ """Add request to form kwargs."""
+ kwargs = super().get_form_kwargs()
+ kwargs['request'] = self.request
+ return kwargs
+
@staticmethod
@requires_csrf_token
def type_modal(request, integration_id):
@@ -616,7 +751,7 @@ def dropdown_restore(request, integration_id):
*
------- """
@@ -732,7 +867,7 @@ class OutboundIntegrationConfigurationUpdateView(PermissionRequiredMixin, Update
@staticmethod
@requires_csrf_token
def type_modal(request, configuration_id):
- if request.GET.get("type") is not '':
+ if request.GET.get("type"):
integration_type = request.GET.get("type")
selected_type = OutboundIntegrationType.objects.get(id=integration_type)
else:
@@ -811,7 +946,7 @@ def dropdown_restore(request, integration_id):
*
------- """
@@ -946,8 +1081,23 @@ class BridgeIntegrationUpdateView(PermissionRequiredMixin, UpdateView):
model = BridgeIntegration
permission_required = "integrations.change_bridgeintegration"
+ def get_form_kwargs(self):
+ """Add request to form kwargs."""
+ kwargs = super().get_form_kwargs()
+ kwargs['request'] = self.request
+ return kwargs
+
+ def post(self, request, *args, **kwargs):
+ """Override post method to ensure proper form handling."""
+ self.object = self.get_object()
+ form = self.get_form()
+
+ if not form.is_valid():
+ return self.form_invalid(form)
+
+ return self.form_valid(form)
+
@staticmethod
- @requires_csrf_token
def type_modal(request, integration_id):
if request.GET.get("type"):
integration_type = request.GET.get("type")
@@ -971,7 +1121,6 @@ def type_modal(request, integration_id):
return HttpResponse(rendered)
@staticmethod
- @requires_csrf_token
def dropdown_restore(request, integration_id):
type_modal = reverse("bridges/type_modal", kwargs={"integration_id": integration_id})
response = f"""