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) + +