diff --git a/loko/interventions/admin.py b/loko/interventions/admin.py
index d58c428..cf6a6e8 100644
--- a/loko/interventions/admin.py
+++ b/loko/interventions/admin.py
@@ -784,7 +784,17 @@ class OperationTemplateContractPostInline(admin.TabularInline):
fields = ('contract', 'contract_post', 'default_quantity', 'comment')
verbose_name = "Poste de contrat par défaut"
verbose_name_plural = "Postes de contrats par défaut"
- autocomplete_fields = ('contract', 'contract_post')
+
+ def formfield_for_foreignkey(self, db_field, request, **kwargs):
+ if db_field.name == "contract":
+ template_id = request.resolver_match.kwargs.get('object_id') if request.resolver_match else None
+ if template_id:
+ template = OperationTemplate.objects.filter(pk=template_id).first()
+ if template and template.thematic_id:
+ kwargs["queryset"] = Contract.objects.filter(
+ thematics=template.thematic
+ ).distinct().select_related('company').order_by('contract_number')
+ return super().formfield_for_foreignkey(db_field, request, **kwargs)
@admin.register(OperationTemplate)
@@ -833,8 +843,21 @@ class ContractOperationTemplatePostAdmin(admin.ModelAdmin):
'operation_template__name',
'contract_post__code',
'contract_post__description_fr',
+ 'contract_post__description_nl',
)
- autocomplete_fields = ('contract', 'operation_template', 'contract_post')
+ autocomplete_fields = ('operation_template',)
+
+ class Media:
+ js = ('interventions/admin_operation_template_dynamic.js',)
+
+ def formfield_for_foreignkey(self, db_field, request, **kwargs):
+ if db_field.name == "contract_post":
+ obj_id = request.resolver_match.kwargs.get('object_id') if request.resolver_match else None
+ if obj_id:
+ obj = ContractOperationTemplatePost.objects.filter(pk=obj_id).first()
+ if obj and obj.contract_id:
+ kwargs["queryset"] = ContractPost.objects.filter(contract_id=obj.contract_id).order_by('order_number')
+ return super().formfield_for_foreignkey(db_field, request, **kwargs)
@admin.register(OperationTemplateItem)
diff --git a/loko/interventions/static/interventions/admin_operation_template_dynamic.js b/loko/interventions/static/interventions/admin_operation_template_dynamic.js
index 35d7b75..528e0e1 100644
--- a/loko/interventions/static/interventions/admin_operation_template_dynamic.js
+++ b/loko/interventions/static/interventions/admin_operation_template_dynamic.js
@@ -1,7 +1,10 @@
/**
- * Dynamic filtering for OperationTemplate admin form
- * Updates categories based on thematic selection
- * Updates asset models based on categories selection
+ * Dynamic filtering for OperationTemplate and ContractOperationTemplatePost admin forms:
+ * - Updates categories based on thematic selection
+ * - Updates asset models based on categories selection
+ * - Updates teams based on thematic selection
+ * - Updates inline contracts based on thematic selection
+ * - Updates contract posts dynamically based on selected contract (inlines and standalone form)
*/
(function() {
'use strict';
@@ -16,172 +19,295 @@
var $ = django.jQuery;
$(document).ready(function() {
- var $thematic = $('#id_thematic');
- var $team = $('#id_team');
- var $categoriesFrom = $('#id_categories_from');
- var $categoriesTo = $('#id_categories_to');
- var $assetModelsFrom = $('#id_asset_models_from');
- var $assetModelsTo = $('#id_asset_models_to');
-
- if (!$thematic.length) return;
-
- /**
- * Update teams based on selected thematic
- */
- function updateTeams() {
- if (!$team.length) return;
- var thematicId = $thematic.val();
- var currentTeamVal = $team.val();
+ var $thematic = $('#id_thematic');
+ var $team = $('#id_team');
+ var $categoriesFrom = $('#id_categories_from');
+ var $categoriesTo = $('#id_categories_to');
+ var $assetModelsFrom = $('#id_asset_models_from');
+ var $assetModelsTo = $('#id_asset_models_to');
- $.ajax({
- url: '/interventions/ajax/get-teams-by-thematic/',
- data: { thematic_id: thematicId || '' },
- success: function(data) {
- $team.empty();
- $team.append($('').val('').text('---------'));
- var hasCurrentVal = false;
- data.teams.forEach(function(team) {
- var option = $('')
- .val(team.id)
- .text(team.name);
- if (String(team.id) === String(currentTeamVal)) {
- option.prop('selected', true);
- hasCurrentVal = true;
+ /**
+ * Update teams based on selected thematic
+ */
+ function updateTeams() {
+ if (!$team.length || !$thematic.length) return;
+ var thematicId = $thematic.val();
+ var currentTeamVal = $team.val();
+
+ $.ajax({
+ url: '/interventions/ajax/get-teams-by-thematic/',
+ data: { thematic_id: thematicId || '' },
+ success: function(data) {
+ $team.empty();
+ $team.append($('').val('').text('---------'));
+ var hasCurrentVal = false;
+ data.teams.forEach(function(team) {
+ var option = $('')
+ .val(team.id)
+ .text(team.name);
+ if (String(team.id) === String(currentTeamVal)) {
+ option.prop('selected', true);
+ hasCurrentVal = true;
+ }
+ $team.append(option);
+ });
+ if (!hasCurrentVal && currentTeamVal) {
+ $team.val('');
}
- $team.append(option);
- });
- if (!hasCurrentVal && currentTeamVal) {
- $team.val('');
+ },
+ error: function() {
+ console.error('Error fetching teams');
}
- },
- error: function() {
- console.error('Error fetching teams');
- }
- });
- }
-
- /**
- * Update categories based on selected thematic
- */
- function updateCategories() {
- var thematicId = $thematic.val();
-
- if (!thematicId) {
- // Clear categories if no thematic selected
- $categoriesFrom.empty();
- $categoriesTo.empty();
- updateAssetModels();
- return;
+ });
}
-
- // Get current selections
- var selectedCategories = $categoriesTo.find('option').map(function() {
- return $(this).val();
- }).get();
-
- // Fetch filtered categories
- $.ajax({
- url: '/interventions/ajax/get-categories-by-thematic/',
- data: { thematic_id: thematicId },
- success: function(data) {
- // Clear both lists
+
+ /**
+ * Update categories based on selected thematic
+ */
+ function updateCategories() {
+ if (!$thematic.length || !$categoriesFrom.length) return;
+ var thematicId = $thematic.val();
+
+ if (!thematicId) {
+ // Clear categories if no thematic selected
$categoriesFrom.empty();
$categoriesTo.empty();
-
- // Populate categories
- data.categories.forEach(function(cat) {
- var option = $('')
- .val(cat.id)
- .text(cat.name);
-
- // If previously selected, add to "chosen" list
- if (selectedCategories.indexOf(String(cat.id)) !== -1) {
- $categoriesTo.append(option);
- } else {
- $categoriesFrom.append(option);
- }
- });
-
- // Update asset models after categories change
updateAssetModels();
- },
- error: function() {
- console.error('Error fetching categories');
+ return;
}
- });
- }
-
- /**
- * Update asset models based on selected categories (or thematic if no categories)
- */
- function updateAssetModels() {
- var thematicId = $thematic.val();
- var categoryIds = $categoriesTo.find('option').map(function() {
- return $(this).val();
- }).get();
-
- if (!thematicId) {
- // Clear asset models if no thematic selected
- $assetModelsFrom.empty();
- $assetModelsTo.empty();
- return;
+
+ // Get current selections
+ var selectedCategories = $categoriesTo.find('option').map(function() {
+ return $(this).val();
+ }).get();
+
+ // Fetch filtered categories
+ $.ajax({
+ url: '/interventions/ajax/get-categories-by-thematic/',
+ data: { thematic_id: thematicId },
+ success: function(data) {
+ // Clear both lists
+ $categoriesFrom.empty();
+ $categoriesTo.empty();
+
+ // Populate categories
+ data.categories.forEach(function(cat) {
+ var option = $('')
+ .val(cat.id)
+ .text(cat.name);
+
+ // If previously selected, add to "chosen" list
+ if (selectedCategories.indexOf(String(cat.id)) !== -1) {
+ $categoriesTo.append(option);
+ } else {
+ $categoriesFrom.append(option);
+ }
+ });
+
+ // Update asset models after categories change
+ updateAssetModels();
+ },
+ error: function() {
+ console.error('Error fetching categories');
+ }
+ });
}
- // Get current selections
- var selectedModels = $assetModelsTo.find('option').map(function() {
- return $(this).val();
- }).get();
-
- // Fetch filtered asset models
- $.ajax({
- url: '/interventions/ajax/get-asset-models/',
- data: {
- thematic_id: thematicId,
- category_ids: categoryIds.join(',')
- },
- success: function(data) {
- // Clear both lists
+ /**
+ * Update asset models based on selected categories (or thematic if no categories)
+ */
+ function updateAssetModels() {
+ if (!$thematic.length || !$assetModelsFrom.length) return;
+ var thematicId = $thematic.val();
+ var categoryIds = $categoriesTo.find('option').map(function() {
+ return $(this).val();
+ }).get();
+
+ if (!thematicId) {
+ // Clear asset models if no thematic selected
$assetModelsFrom.empty();
$assetModelsTo.empty();
-
- // Populate asset models
- data.models.forEach(function(model) {
- var option = $('')
- .val(model.value)
- .text(model.display);
-
- // If previously selected, add to "chosen" list
- if (selectedModels.indexOf(model.value) !== -1) {
- $assetModelsTo.append(option);
- } else {
- $assetModelsFrom.append(option);
- }
- });
- },
- error: function() {
- console.error('Error fetching asset models');
+ return;
}
+
+ // Get current selections
+ var selectedModels = $assetModelsTo.find('option').map(function() {
+ return $(this).val();
+ }).get();
+
+ // Fetch filtered asset models
+ $.ajax({
+ url: '/interventions/ajax/get-asset-models/',
+ data: {
+ thematic_id: thematicId,
+ category_ids: categoryIds.join(',')
+ },
+ success: function(data) {
+ // Clear both lists
+ $assetModelsFrom.empty();
+ $assetModelsTo.empty();
+
+ // Populate asset models
+ data.models.forEach(function(model) {
+ var option = $('')
+ .val(model.value)
+ .text(model.display);
+
+ // If previously selected, add to "chosen" list
+ if (selectedModels.indexOf(model.value) !== -1) {
+ $assetModelsTo.append(option);
+ } else {
+ $assetModelsFrom.append(option);
+ }
+ });
+ },
+ error: function() {
+ console.error('Error fetching asset models');
+ }
+ });
+ }
+
+ /**
+ * Update contract options in inlines based on selected thematic
+ */
+ function updateContractsInlines() {
+ if (!$thematic.length) return;
+ var thematicId = $thematic.val();
+ $.ajax({
+ url: '/interventions/ajax/get-contracts-by-thematic/',
+ data: { thematic_id: thematicId || '' },
+ success: function(data) {
+ $('select[name$="-contract"]').each(function() {
+ var $cSelect = $(this);
+ var currentVal = $cSelect.val();
+ $cSelect.empty();
+ $cSelect.append($('').val('').text('---------'));
+ var hasCurrent = false;
+ data.contracts.forEach(function(c) {
+ var opt = $('').val(c.id).text(c.label);
+ if (String(c.id) === String(currentVal)) {
+ opt.prop('selected', true);
+ hasCurrent = true;
+ }
+ $cSelect.append(opt);
+ });
+ if (!hasCurrent && currentVal) {
+ $cSelect.val('');
+ $cSelect.trigger('change.dynamicPosts');
+ }
+ });
+ }
+ });
+ }
+
+ /**
+ * Update posts dropdown based on selected contract
+ */
+ function updatePostsForContract($contractSelect, $postSelect, keepSelectedVal) {
+ var contractId = $contractSelect.val();
+ var currentPostVal = keepSelectedVal !== undefined ? keepSelectedVal : $postSelect.val();
+
+ if (!contractId) {
+ $postSelect.empty();
+ $postSelect.append($('').val('').text('---------'));
+ $postSelect.val('');
+ return;
+ }
+
+ $.ajax({
+ url: '/interventions/ajax/get-posts-by-contract/',
+ data: { contract_id: contractId },
+ success: function(data) {
+ $postSelect.empty();
+ $postSelect.append($('').val('').text('---------'));
+ var matched = false;
+ if (data.posts && data.posts.length > 0) {
+ data.posts.forEach(function(post) {
+ var option = $('')
+ .val(post.id)
+ .text(post.label);
+ if (String(post.id) === String(currentPostVal)) {
+ option.prop('selected', true);
+ matched = true;
+ }
+ $postSelect.append(option);
+ });
+ }
+ if (matched) {
+ $postSelect.val(currentPostVal);
+ } else if (currentPostVal) {
+ $postSelect.val('');
+ }
+ },
+ error: function() {
+ console.error('Error fetching contract posts');
+ }
+ });
+ }
+
+ function bindContractPostRow($row) {
+ var $contract = $row.find('select[name$="-contract"]');
+ var $post = $row.find('select[name$="-contract_post"]');
+ if (!$contract.length || !$post.length) return;
+
+ $contract.off('change.dynamicPosts').on('change.dynamicPosts', function() {
+ updatePostsForContract($contract, $post);
+ });
+
+ // Initial load for existing row: if contract is selected, populate options
+ if ($contract.val()) {
+ var initialPostVal = $post.val();
+ updatePostsForContract($contract, $post, initialPostVal);
+ } else {
+ $post.empty().append($('').val('').text('---------'));
+ }
+ }
+
+ // Bind all inline rows
+ $('tr.form-row, tr[id^="contract_posts-"], .dynamic-contract_posts, tbody tr').each(function() {
+ bindContractPostRow($(this));
});
- }
-
- // Listen to thematic changes
- $thematic.on('change', function() {
- updateCategories();
- updateTeams();
- });
-
- // Listen to categories changes using MutationObserver
- // (filter_horizontal uses JavaScript to move options, not change events)
- if ($categoriesTo.length) {
- var observer = new MutationObserver(function(mutations) {
- updateAssetModels();
+
+ // Listen for new inline rows added dynamically in Django Admin
+ $(document).on('formset:added', function(event, $row, formsetName) {
+ bindContractPostRow($row);
});
+
+ // Standalone form (ContractOperationTemplatePostAdmin)
+ var $standaloneContract = $('#id_contract');
+ var $standalonePost = $('#id_contract_post');
+ if ($standaloneContract.length && $standalonePost.length) {
+ $standaloneContract.on('change', function() {
+ updatePostsForContract($standaloneContract, $standalonePost);
+ });
+ if ($standaloneContract.val()) {
+ var initialPostVal = $standalonePost.val();
+ updatePostsForContract($standaloneContract, $standalonePost, initialPostVal);
+ } else {
+ $standalonePost.empty().append($('').val('').text('---------'));
+ }
+ }
- observer.observe($categoriesTo[0], {
- childList: true,
- subtree: true
- });
- }
+ // Listen to thematic changes
+ if ($thematic.length) {
+ $thematic.on('change', function() {
+ updateCategories();
+ updateTeams();
+ updateContractsInlines();
+ });
+
+ // Listen to categories changes using MutationObserver
+ if ($categoriesTo.length) {
+ var observer = new MutationObserver(function(mutations) {
+ updateAssetModels();
+ });
+
+ observer.observe($categoriesTo[0], {
+ childList: true,
+ subtree: true
+ });
+ }
+ }
});
}
diff --git a/loko/interventions/tests/test_preventive_templates.py b/loko/interventions/tests/test_preventive_templates.py
index 6fbbe1f..48f99c6 100644
--- a/loko/interventions/tests/test_preventive_templates.py
+++ b/loko/interventions/tests/test_preventive_templates.py
@@ -1207,6 +1207,38 @@ class ContractOperationTemplatePostTests(TestCase):
self.assertIn(f'value="{order.id}"', content)
self.assertIn('selected', content)
+ def test_ajax_get_posts_by_contract(self):
+ """Test the AJAX endpoint filtering contract posts by contract."""
+ self.client.login(username='test_user_posts', password='password123')
+ url = reverse('interventions:ajax_get_posts_by_contract')
+
+ # Call with no contract_id
+ res = self.client.get(url)
+ self.assertEqual(res.status_code, 200)
+ self.assertEqual(res.json().get('posts'), [])
+
+ # Call with self.contract
+ res = self.client.get(url, {'contract_id': self.contract.id})
+ self.assertEqual(res.status_code, 200)
+ posts_data = res.json().get('posts', [])
+ post_ids = [p['id'] for p in posts_data]
+ self.assertIn(self.post_1.id, post_ids)
+ self.assertIn(self.post_2.id, post_ids)
+ self.assertNotIn(self.post_other.id, post_ids)
+
+ def test_ajax_get_contracts_by_thematic(self):
+ """Test the AJAX endpoint filtering contracts by thematic."""
+ self.client.login(username='test_user_posts', password='password123')
+ url = reverse('interventions:ajax_get_contracts_by_thematic')
+
+ res = self.client.get(url, {'thematic_id': self.thematic.id})
+ self.assertEqual(res.status_code, 200)
+ contracts_data = res.json().get('contracts', [])
+ contract_ids = [c['id'] for c in contracts_data]
+ self.assertIn(self.contract.id, contract_ids)
+ self.assertNotIn(self.contract_other.id, contract_ids)
+
+
diff --git a/loko/interventions/urls.py b/loko/interventions/urls.py
index e314990..95178ed 100644
--- a/loko/interventions/urls.py
+++ b/loko/interventions/urls.py
@@ -107,6 +107,8 @@ urlpatterns = [
path('ajax/get-subcategories/', views.ajax_get_subcategories, name='ajax_get_subcategories'),
path('ajax/get-asset-models/', views.ajax_get_asset_models, name='ajax_get_asset_models'),
path('ajax/get-replacement-models-by-category/', views.ajax_get_replacement_models_by_category, name='ajax_get_replacement_models_by_category'),
+ path('ajax/get-posts-by-contract/', views.ajax_get_posts_by_contract, name='ajax_get_posts_by_contract'),
+ path('ajax/get-contracts-by-thematic/', views.ajax_get_contracts_by_thematic, name='ajax_get_contracts_by_thematic'),
# Template check for assets without matching templates
path('/check-asset-templates/', views.check_asset_templates_for_intervention, name='check_asset_templates'),
path('/create-operations-from-template/', views.create_operations_from_selected_template, name='create_operations_from_template'),
diff --git a/loko/interventions/views/general.py b/loko/interventions/views/general.py
index e4f2305..bf2b8a1 100644
--- a/loko/interventions/views/general.py
+++ b/loko/interventions/views/general.py
@@ -1300,3 +1300,66 @@ def ajax_get_replacement_models_by_category(request):
except Exception as e:
return JsonResponse({'error': str(e)}, status=400)
+
+def ajax_get_posts_by_contract(request):
+ """AJAX endpoint to get contract posts for a given contract.
+ Used by Django Admin to dynamically filter contract posts based on selected contract.
+ """
+ contract_id = request.GET.get('contract_id')
+ if not contract_id:
+ return JsonResponse({'posts': []})
+
+ try:
+ from contracts.models import ContractPost
+ posts = ContractPost.objects.filter(contract_id=contract_id).order_by('order_number')
+ lang = get_language()
+ posts_data = []
+ for post in posts:
+ desc = post.description_fr
+ if lang and lang.startswith('nl') and post.description_nl:
+ desc = post.description_nl
+ label_parts = []
+ if post.order_number:
+ label_parts.append(str(post.order_number))
+ if post.code:
+ label_parts.append(str(post.code))
+ if desc:
+ label_parts.append(desc)
+ label = " - ".join(label_parts) if label_parts else f"Poste #{post.id}"
+ posts_data.append({
+ 'id': post.id,
+ 'code': post.code,
+ 'order_number': post.order_number,
+ 'description': desc,
+ 'label': label,
+ })
+ return JsonResponse({'posts': posts_data})
+ except Exception as e:
+ return JsonResponse({'error': str(e)}, status=400)
+
+
+def ajax_get_contracts_by_thematic(request):
+ """AJAX endpoint to get contracts for a given thematic.
+ Used by Django Admin to dynamically filter contracts based on selected thematic.
+ """
+ thematic_id = request.GET.get('thematic_id')
+ try:
+ from contracts.models import Contract
+ if thematic_id:
+ contracts = Contract.objects.filter(thematics__id=thematic_id).distinct().select_related('company').order_by('contract_number')
+ else:
+ contracts = Contract.objects.all().select_related('company').order_by('contract_number')
+ contracts_data = [
+ {
+ 'id': c.id,
+ 'contract_number': c.contract_number,
+ 'description': c.description or '',
+ 'label': f"{c.contract_number} ({c.company.name})" if c.company else c.contract_number,
+ }
+ for c in contracts
+ ]
+ return JsonResponse({'contracts': contracts_data})
+ except Exception as e:
+ return JsonResponse({'error': str(e)}, status=400)
+
+