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';
@ -23,13 +26,11 @@
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 * Update teams based on selected thematic
*/ */
function updateTeams() { function updateTeams() {
if (!$team.length) return; if (!$team.length || !$thematic.length) return;
var thematicId = $thematic.val(); var thematicId = $thematic.val();
var currentTeamVal = $team.val(); var currentTeamVal = $team.val();
@ -64,6 +65,7 @@
* Update categories based on selected thematic * Update categories based on selected thematic
*/ */
function updateCategories() { function updateCategories() {
if (!$thematic.length || !$categoriesFrom.length) return;
var thematicId = $thematic.val(); var thematicId = $thematic.val();
if (!thematicId) { if (!thematicId) {
@ -115,6 +117,7 @@
* Update asset models based on selected categories (or thematic if no categories) * Update asset models based on selected categories (or thematic if no categories)
*/ */
function updateAssetModels() { function updateAssetModels() {
if (!$thematic.length || !$assetModelsFrom.length) return;
var thematicId = $thematic.val(); var thematicId = $thematic.val();
var categoryIds = $categoriesTo.find('option').map(function() { var categoryIds = $categoriesTo.find('option').map(function() {
return $(this).val(); return $(this).val();
@ -164,14 +167,136 @@
}); });
} }
/**
* 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
$(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($('<option></option>').val('').text('---------'));
}
}
// Listen to thematic changes // Listen to thematic changes
if ($thematic.length) {
$thematic.on('change', function() { $thematic.on('change', function() {
updateCategories(); updateCategories();
updateTeams(); updateTeams();
updateContractsInlines();
}); });
// Listen to categories changes using MutationObserver // Listen to categories changes using MutationObserver
// (filter_horizontal uses JavaScript to move options, not change events)
if ($categoriesTo.length) { if ($categoriesTo.length) {
var observer = new MutationObserver(function(mutations) { var observer = new MutationObserver(function(mutations) {
updateAssetModels(); updateAssetModels();
@ -182,6 +307,7 @@
subtree: 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)