feat: improve contract selection with descriptions, enhanced visibility filtering, and user access controls

This commit is contained in:
kdeterme 2026-08-17 08:18:25 +02:00
parent 8b4c854416
commit 016ce202b4
9 changed files with 170 additions and 21 deletions

View file

@ -344,6 +344,14 @@ class Contract(models.Model):
def __str__(self):
return f"Contrat {self.contract_number} - {self.company.name}"
@property
def display_name(self):
desc = (self.description or '').strip()
if desc:
truncated = desc[:45] + ('…' if len(desc) > 45 else '')
return f"{self.contract_number} ({truncated})"
return self.contract_number
class Meta:
ordering = ['contract_number']

View file

@ -195,3 +195,40 @@ class TeamViewsAjaxTests(TestCase):
member = CompanyMember.objects.get(user=target_user)
self.assertEqual(member.name, 'New Target Member')
self.assertIn(team_b, member.teams.all())
class ContractDisplayNameTests(TestCase):
def setUp(self):
from datetime import date
from contracts.models import Contract, Company
self.company = Company.objects.create(name="Company Tests")
self.contract_no_desc = Contract.objects.create(
company=self.company,
contract_number="CTR-001",
start_date=date(2025, 1, 1),
end_date=date(2026, 1, 1)
)
self.contract_short_desc = Contract.objects.create(
company=self.company,
contract_number="CTR-002",
description="Entretien espaces verts",
start_date=date(2025, 1, 1),
end_date=date(2026, 1, 1)
)
self.contract_long_desc = Contract.objects.create(
company=self.company,
contract_number="CTR-003",
description="Travaux et maintenance préventive et corrective de la signalisation lumineuse tricolore",
start_date=date(2025, 1, 1),
end_date=date(2026, 1, 1)
)
def test_display_name_no_description(self):
self.assertEqual(self.contract_no_desc.display_name, "CTR-001")
def test_display_name_short_description(self):
self.assertEqual(self.contract_short_desc.display_name, "CTR-002 (Entretien espaces verts)")
def test_display_name_long_description_truncated(self):
self.assertTrue(self.contract_long_desc.display_name.startswith("CTR-003 (Travaux et maintenance préventive et correct"))
self.assertTrue(self.contract_long_desc.display_name.endswith("…)"))

View file

@ -87,9 +87,13 @@
contracts.forEach(contract => {
const option = document.createElement('option');
option.value = contract.id;
option.textContent = contract.contract_number;
option.textContent = contract.label || contract.contract_number;
contractSelect.appendChild(option);
});
if (contracts.length === 1) {
contractSelect.value = String(contracts[0].id);
}
} catch (error) {
console.error('Error loading contracts:', error);

View file

@ -213,9 +213,12 @@ document.addEventListener("DOMContentLoaded", () => {
contracts.forEach(contract => {
const opt = document.createElement("option");
opt.value = contract.id;
opt.textContent = contract.contract_number;
opt.textContent = contract.label || contract.contract_number;
contractSelect.appendChild(opt);
});
if (contracts.length === 1) {
contractSelect.value = String(contracts[0].id);
}
}
})
.catch(() => {

View file

@ -125,12 +125,14 @@
contracts.forEach((contract) => {
const option = document.createElement('option');
option.value = String(contract.id);
option.textContent = contract.contract_number;
option.textContent = contract.label || contract.contract_number;
contractSelect.appendChild(option);
});
if (selectedContractId) {
contractSelect.value = String(selectedContractId);
} else if (contracts.length === 1) {
contractSelect.value = String(contracts[0].id);
}
} catch (error) {
console.error('Quick edit contract loading error:', error);

View file

@ -330,7 +330,7 @@
<option value="">-</option>
{% for contract in contracts %}
<option value="{{ contract.id }}" {% if contract.id == intervention.contract.id %}selected{% endif %}>
{{ contract.contract_number }}
{{ contract.contract_number }}{% if contract.description %} ({{ contract.description|truncatechars:45 }}){% endif %}
</option>
{% endfor %}
</select>
@ -403,14 +403,14 @@
<button class="btn btn-sm border-0" style="cursor: default;"><i class="bi bi-file-earmark-text"></i></button>
<span class="label mx-1">{% translate "Contrat" %}:</span>
<div class="field-display">
<span class="field-value" id="contractDisplay">{{ intervention.contract.contract_number|default:'-' }}</span>
<span class="field-value" id="contractDisplay">{% if intervention.contract %}{{ intervention.contract.contract_number }}{% if intervention.contract.description %} ({{ intervention.contract.description|truncatechars:45 }}){% endif %}{% else %}-{% endif %}</span>
</div>
<div class="field-edit d-none" style="flex: 1;">
<select class="form-select form-select-sm">
<option value="">-</option>
{% for contract in contracts %}
<option value="{{ contract.id }}" {% if contract.id == intervention.contract.id %}selected{% endif %}>
{{ contract.contract_number }}
{{ contract.contract_number }}{% if contract.description %} ({{ contract.description|truncatechars:45 }}){% endif %}
</option>
{% endfor %}
</select>

View file

@ -530,6 +530,77 @@ class DefaultContractTests(TestCase):
self.assertEqual(itv.assigned_provider, self.company)
class GetContractsByProviderTests(TestCase):
def setUp(self):
from datetime import date
from django.contrib.auth import get_user_model
from common.models import UserConfig, UserContractAccess, Role
from contracts.models import Contract, Company
self.User = get_user_model()
self.manager_role, _ = Role.objects.get_or_create(name='manager')
self.company = Company.objects.create(name="Provider A")
self.contract1 = Contract.objects.create(
contract_number="C-001",
description="Short desc",
company=self.company,
start_date=date(2025, 1, 1),
end_date=date(2030, 1, 1)
)
self.contract2 = Contract.objects.create(
contract_number="C-002",
description="Another desc",
company=self.company,
start_date=date(2025, 1, 1),
end_date=date(2030, 1, 1)
)
# User limited to contracts
self.limited_user = self.User.objects.create_user(username='limited-user', password='pwd')
self.limited_config = UserConfig.objects.create(
user=self.limited_user,
is_intern=True,
limit_interventions_to_contracts=True,
)
self.limited_config.roles.add(self.manager_role)
# Give access only to contract1
UserContractAccess.objects.create(user_config=self.limited_config, contract=self.contract1, can_view_interventions=True)
# Unrestricted internal user
self.unlimited_user = self.User.objects.create_user(username='unlimited-user', password='pwd')
self.unlimited_config = UserConfig.objects.create(
user=self.unlimited_user,
is_intern=True,
limit_interventions_to_contracts=False,
)
self.unlimited_config.roles.add(self.manager_role)
def test_limited_user_only_gets_single_accessible_contract(self):
self.client.login(username='limited-user', password='pwd')
url = reverse('interventions:get_contracts_by_provider')
response = self.client.get(f"{url}?provider_id={self.company.id}")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(len(data), 1)
self.assertEqual(data[0]['id'], self.contract1.id)
self.assertEqual(data[0]['contract_number'], "C-001")
self.assertEqual(data[0]['description'], "Short desc")
self.assertEqual(data[0]['label'], "C-001 (Short desc)")
def test_unlimited_user_gets_all_contracts(self):
self.client.login(username='unlimited-user', password='pwd')
url = reverse('interventions:get_contracts_by_provider')
response = self.client.get(f"{url}?provider_id={self.company.id}")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(len(data), 2)
contract_numbers = [c['contract_number'] for c in data]
self.assertIn("C-001", contract_numbers)
self.assertIn("C-002", contract_numbers)

View file

@ -230,8 +230,8 @@ def intervention_detail(request, intervention_id):
contract_id = intervention.order.contract.id if intervention.order else None
# Limiter les providers et contracts selon les permissions de l'utilisateur
if user_config.is_intern:
# Utilisateurs internes : accès à tous les prestataires et contrats
if user_config.is_intern and not user_config.limit_interventions_to_contracts:
# Utilisateurs internes non limités aux contrats : accès à tous les prestataires et contrats
providers = Company.objects.all()
if intervention.thematic:
providers = providers.filter(contracts__thematics=intervention.thematic).distinct()
@ -240,9 +240,9 @@ def intervention_detail(request, intervention_id):
# Filtrer par thématique si l'intervention en a une
if intervention.thematic:
contracts_qs = contracts_qs.filter(thematics=intervention.thematic)
contracts = contracts_qs.distinct().values("id", "contract_number")
contracts = contracts_qs.distinct()
else:
# Utilisateurs externes (external_managers) : limiter aux contrats accessibles
# Utilisateurs limités aux contrats (externes ou avec limit_interventions_to_contracts=True)
accessible_contracts_ids = UserContractAccess.objects.filter(
user_config=user_config,
can_view_interventions=True
@ -264,7 +264,7 @@ def intervention_detail(request, intervention_id):
else:
contracts_qs = accessible_contracts_qs
contracts = contracts_qs.distinct().values("id", "contract_number")
contracts = contracts_qs.distinct()
projects = Project.objects.filter(thematics=intervention.thematic) if intervention.thematic else None
project_interv = ProjectIntervention.objects.filter(intervention=intervention).first()
@ -2572,7 +2572,16 @@ def update_intervention(request, intervention_id):
# Handle notes separately (special case)
notes_data = data.pop('notes', None)
for field, new_value in data.items():
# Sort fields to process assigned_provider before contract
def _field_priority(item):
f_name = item[0]
if f_name == 'assigned_provider':
return 0
if f_name == 'contract':
return 1
return 2
for field, new_value in sorted(data.items(), key=_field_priority):
if field not in allowed_update_fields:
errors.append(_('You do not have permission to update field: %(field)s') % {'field': field})
permission_denied = True
@ -2724,16 +2733,16 @@ def get_contracts_by_provider(request):
return JsonResponse({"error": _("Missing provider_id")}, status=400)
user_config = get_object_or_404(UserConfig, user=request.user)
if not user_config.roles.filter(name__in=['admin', 'manager', 'operator', 'external_manager', 'controller']).exists():
if not user_config.roles.filter(name__in=['admin', 'manager', 'operator', 'external_manager', 'controller', 'top_manager']).exists() and not request.user.is_superuser:
return JsonResponse({"error": "Permission denied"}, status=403)
if user_config.roles.filter(name__in=['external_manager']).exists():
if user_config.limit_interventions_to_contracts or not user_config.is_intern:
accessible_contracts_ids = UserContractAccess.objects.filter(
user_config=user_config,
contract__company_id=provider_id,
can_view_interventions=True
).values_list('contract__id', flat=True)
contracts = Contract.objects.filter(id__in=accessible_contracts_ids)
contracts = Contract.objects.filter(id__in=accessible_contracts_ids, company_id=provider_id)
else:
contracts = Contract.objects.filter(company_id=provider_id)
@ -2741,9 +2750,24 @@ def get_contracts_by_provider(request):
if thematic_id:
contracts = contracts.filter(thematics__id=thematic_id)
contracts = contracts.values("id", "contract_number")
contracts = contracts.distinct().order_by("contract_number")
return JsonResponse(list(contracts), safe=False)
contract_data = []
for c in contracts:
desc = (c.description or '').strip()
if desc:
truncated = desc[:45] + ('…' if len(desc) > 45 else '')
label = f"{c.contract_number} ({truncated})"
else:
label = c.contract_number
contract_data.append({
"id": c.id,
"contract_number": c.contract_number,
"description": desc,
"label": label,
})
return JsonResponse(contract_data, safe=False)
def add_custom_operation(request, intervention_id):

View file

@ -2431,8 +2431,8 @@ def _update_intervention_field(intervention, field, new_value, user_config=None)
company = Company.objects.filter(id=new_value).first()
if company and intervention.assigned_provider != company:
# Pour les external_managers, vérifier qu'ils ont accès à au moins un contrat de ce prestataire
if user_config and not user_config.is_intern:
# Vérifier l'accès aux contrats pour les utilisateurs limités aux contrats ou externes
if user_config and (user_config.limit_interventions_to_contracts or not user_config.is_intern):
has_access = UserContractAccess.objects.filter(
user_config=user_config,
contract__company=company,
@ -2455,8 +2455,8 @@ def _update_intervention_field(intervention, field, new_value, user_config=None)
contract = Contract.objects.filter(id=new_value).first()
if contract:
# Pour les external_managers, vérifier qu'ils ont accès à ce contrat
if user_config and not user_config.is_intern:
# Vérifier l'accès au contrat pour les utilisateurs limités aux contrats ou externes
if user_config and (user_config.limit_interventions_to_contracts or not user_config.is_intern):
has_access = UserContractAccess.objects.filter(
user_config=user_config,
contract=contract,