feat: implement dynamic filtering for contracts and contract posts in the intervention admin interface

This commit is contained in:
kdeterme 2026-08-19 16:12:54 +02:00
parent 5bfde04f1a
commit 0d35e71cfc
5 changed files with 401 additions and 155 deletions

View file

@ -784,7 +784,17 @@ class OperationTemplateContractPostInline(admin.TabularInline):
fields = ('contract', 'contract_post', 'default_quantity', 'comment') fields = ('contract', 'contract_post', 'default_quantity', 'comment')
verbose_name = "Poste de contrat par défaut" verbose_name = "Poste de contrat par défaut"
verbose_name_plural = "Postes de contrats 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) @admin.register(OperationTemplate)
@ -833,8 +843,21 @@ class ContractOperationTemplatePostAdmin(admin.ModelAdmin):
'operation_template__name', 'operation_template__name',
'contract_post__code', 'contract_post__code',
'contract_post__description_fr', '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) @admin.register(OperationTemplateItem)

View file

@ -1,7 +1,10 @@
/** /**
* Dynamic filtering for OperationTemplate admin form * Dynamic filtering for OperationTemplate and ContractOperationTemplatePost admin forms:
* Updates categories based on thematic selection * - Updates categories based on thematic selection
* Updates asset models based on categories 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() { (function() {
'use strict'; 'use strict';
@ -16,172 +19,295 @@
var $ = django.jQuery; var $ = django.jQuery;
$(document).ready(function() { $(document).ready(function() {
var $thematic = $('#id_thematic'); var $thematic = $('#id_thematic');
var $team = $('#id_team'); var $team = $('#id_team');
var $categoriesFrom = $('#id_categories_from'); var $categoriesFrom = $('#id_categories_from');
var $categoriesTo = $('#id_categories_to'); var $categoriesTo = $('#id_categories_to');
var $assetModelsFrom = $('#id_asset_models_from'); var $assetModelsFrom = $('#id_asset_models_from');
var $assetModelsTo = $('#id_asset_models_to'); 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();
$.ajax({ /**
url: '/interventions/ajax/get-teams-by-thematic/', * Update teams based on selected thematic
data: { thematic_id: thematicId || '' }, */
success: function(data) { function updateTeams() {
$team.empty(); if (!$team.length || !$thematic.length) return;
$team.append($('<option></option>').val('').text('---------')); var thematicId = $thematic.val();
var hasCurrentVal = false; var currentTeamVal = $team.val();
data.teams.forEach(function(team) {
var option = $('<option></option>') $.ajax({
.val(team.id) url: '/interventions/ajax/get-teams-by-thematic/',
.text(team.name); data: { thematic_id: thematicId || '' },
if (String(team.id) === String(currentTeamVal)) { success: function(data) {
option.prop('selected', true); $team.empty();
hasCurrentVal = true; $team.append($('<option></option>').val('').text('---------'));
var hasCurrentVal = false;
data.teams.forEach(function(team) {
var option = $('<option></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); },
}); error: function() {
if (!hasCurrentVal && currentTeamVal) { console.error('Error fetching teams');
$team.val('');
} }
}, });
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() { * Update categories based on selected thematic
return $(this).val(); */
}).get(); function updateCategories() {
if (!$thematic.length || !$categoriesFrom.length) return;
// Fetch filtered categories var thematicId = $thematic.val();
$.ajax({
url: '/interventions/ajax/get-categories-by-thematic/', if (!thematicId) {
data: { thematic_id: thematicId }, // Clear categories if no thematic selected
success: function(data) {
// Clear both lists
$categoriesFrom.empty(); $categoriesFrom.empty();
$categoriesTo.empty(); $categoriesTo.empty();
// Populate categories
data.categories.forEach(function(cat) {
var option = $('<option></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(); updateAssetModels();
}, return;
error: function() {
console.error('Error fetching categories');
} }
});
} // Get current selections
var selectedCategories = $categoriesTo.find('option').map(function() {
/** return $(this).val();
* Update asset models based on selected categories (or thematic if no categories) }).get();
*/
function updateAssetModels() { // Fetch filtered categories
var thematicId = $thematic.val(); $.ajax({
var categoryIds = $categoriesTo.find('option').map(function() { url: '/interventions/ajax/get-categories-by-thematic/',
return $(this).val(); data: { thematic_id: thematicId },
}).get(); success: function(data) {
// Clear both lists
if (!thematicId) { $categoriesFrom.empty();
// Clear asset models if no thematic selected $categoriesTo.empty();
$assetModelsFrom.empty();
$assetModelsTo.empty(); // Populate categories
return; data.categories.forEach(function(cat) {
var option = $('<option></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() { * Update asset models based on selected categories (or thematic if no categories)
return $(this).val(); */
}).get(); function updateAssetModels() {
if (!$thematic.length || !$assetModelsFrom.length) return;
// Fetch filtered asset models var thematicId = $thematic.val();
$.ajax({ var categoryIds = $categoriesTo.find('option').map(function() {
url: '/interventions/ajax/get-asset-models/', return $(this).val();
data: { }).get();
thematic_id: thematicId,
category_ids: categoryIds.join(',') if (!thematicId) {
}, // Clear asset models if no thematic selected
success: function(data) {
// Clear both lists
$assetModelsFrom.empty(); $assetModelsFrom.empty();
$assetModelsTo.empty(); $assetModelsTo.empty();
return;
// Populate asset models
data.models.forEach(function(model) {
var option = $('<option></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');
} }
// 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 = $('<option></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($('<option></option>').val('').text('---------'));
var hasCurrent = false;
data.contracts.forEach(function(c) {
var opt = $('<option></option>').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($('<option></option>').val('').text('---------'));
$postSelect.val('');
return;
}
$.ajax({
url: '/interventions/ajax/get-posts-by-contract/',
data: { contract_id: contractId },
success: function(data) {
$postSelect.empty();
$postSelect.append($('<option></option>').val('').text('---------'));
var matched = false;
if (data.posts && data.posts.length > 0) {
data.posts.forEach(function(post) {
var option = $('<option></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($('<option></option>').val('').text('---------'));
}
}
// Bind all inline rows
$('tr.form-row, tr[id^="contract_posts-"], .dynamic-contract_posts, tbody tr').each(function() {
bindContractPostRow($(this));
}); });
}
// Listen for new inline rows added dynamically in Django Admin
// Listen to thematic changes $(document).on('formset:added', function(event, $row, formsetName) {
$thematic.on('change', function() { bindContractPostRow($row);
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();
}); });
// 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($('<option></option>').val('').text('---------'));
}
}
observer.observe($categoriesTo[0], { // Listen to thematic changes
childList: true, if ($thematic.length) {
subtree: true $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
});
}
}
}); });
} }

View file

@ -1207,6 +1207,38 @@ class ContractOperationTemplatePostTests(TestCase):
self.assertIn(f'value="{order.id}"', content) self.assertIn(f'value="{order.id}"', content)
self.assertIn('selected', 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)

View file

@ -107,6 +107,8 @@ urlpatterns = [
path('ajax/get-subcategories/', views.ajax_get_subcategories, name='ajax_get_subcategories'), 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-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-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 # Template check for assets without matching templates
path('<int:intervention_id>/check-asset-templates/', views.check_asset_templates_for_intervention, name='check_asset_templates'), path('<int:intervention_id>/check-asset-templates/', views.check_asset_templates_for_intervention, name='check_asset_templates'),
path('<int:intervention_id>/create-operations-from-template/', views.create_operations_from_selected_template, name='create_operations_from_template'), path('<int:intervention_id>/create-operations-from-template/', views.create_operations_from_selected_template, name='create_operations_from_template'),

View file

@ -1300,3 +1300,66 @@ def ajax_get_replacement_models_by_category(request):
except Exception as e: except Exception as e:
return JsonResponse({'error': str(e)}, status=400) 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)