feat: implement dynamic filtering for contracts and contract posts in the intervention admin interface
This commit is contained in:
parent
5bfde04f1a
commit
0d35e71cfc
5 changed files with 401 additions and 155 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
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 || !$thematic.length) return;
|
||||
var thematicId = $thematic.val();
|
||||
var currentTeamVal = $team.val();
|
||||
|
||||
/**
|
||||
* 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/',
|
||||
data: { thematic_id: thematicId || '' },
|
||||
success: function(data) {
|
||||
$team.empty();
|
||||
$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;
|
||||
$.ajax({
|
||||
url: '/interventions/ajax/get-teams-by-thematic/',
|
||||
data: { thematic_id: thematicId || '' },
|
||||
success: function(data) {
|
||||
$team.empty();
|
||||
$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);
|
||||
});
|
||||
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();
|
||||
/**
|
||||
* Update categories based on selected thematic
|
||||
*/
|
||||
function updateCategories() {
|
||||
if (!$thematic.length || !$categoriesFrom.length) return;
|
||||
var thematicId = $thematic.val();
|
||||
|
||||
// Fetch filtered categories
|
||||
$.ajax({
|
||||
url: '/interventions/ajax/get-categories-by-thematic/',
|
||||
data: { thematic_id: thematicId },
|
||||
success: function(data) {
|
||||
// Clear both lists
|
||||
if (!thematicId) {
|
||||
// Clear categories if no thematic selected
|
||||
$categoriesFrom.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();
|
||||
},
|
||||
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();
|
||||
// Get current selections
|
||||
var selectedCategories = $categoriesTo.find('option').map(function() {
|
||||
return $(this).val();
|
||||
}).get();
|
||||
|
||||
if (!thematicId) {
|
||||
// Clear asset models if no thematic selected
|
||||
$assetModelsFrom.empty();
|
||||
$assetModelsTo.empty();
|
||||
return;
|
||||
// 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 = $('<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() {
|
||||
return $(this).val();
|
||||
}).get();
|
||||
/**
|
||||
* 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();
|
||||
|
||||
// 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
|
||||
if (!thematicId) {
|
||||
// Clear asset models if no thematic selected
|
||||
$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');
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Listen to thematic changes
|
||||
$thematic.on('change', function() {
|
||||
updateCategories();
|
||||
updateTeams();
|
||||
});
|
||||
// Get current selections
|
||||
var selectedModels = $assetModelsTo.find('option').map(function() {
|
||||
return $(this).val();
|
||||
}).get();
|
||||
|
||||
// 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();
|
||||
// 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));
|
||||
});
|
||||
|
||||
observer.observe($categoriesTo[0], {
|
||||
childList: true,
|
||||
subtree: true
|
||||
// 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
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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('<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'),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue