From 016ce202b4db33a61e93667b4b4481898190e98b Mon Sep 17 00:00:00 2001 From: kdeterme Date: Mon, 17 Aug 2026 08:18:25 +0200 Subject: [PATCH] feat: improve contract selection with descriptions, enhanced visibility filtering, and user access controls --- loko/contracts/models.py | 8 +++ loko/contracts/tests.py | 37 ++++++++++ .../interventions/detail/cms_edit_mode.js | 6 +- .../static/interventions/detail/fields.js | 5 +- .../static/interventions/detail/layout.js | 4 +- .../interventions/detail/card_info.html | 6 +- .../tests/test_visibility_permissions.py | 71 +++++++++++++++++++ loko/interventions/views/detail.py | 46 +++++++++--- loko/interventions/views/helpers.py | 8 +-- 9 files changed, 170 insertions(+), 21 deletions(-) diff --git a/loko/contracts/models.py b/loko/contracts/models.py index 1dcf2a7..98b90bd 100644 --- a/loko/contracts/models.py +++ b/loko/contracts/models.py @@ -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'] diff --git a/loko/contracts/tests.py b/loko/contracts/tests.py index b49ea74..abbb7e1 100644 --- a/loko/contracts/tests.py +++ b/loko/contracts/tests.py @@ -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("…)")) diff --git a/loko/interventions/static/interventions/detail/cms_edit_mode.js b/loko/interventions/static/interventions/detail/cms_edit_mode.js index 4ae46be..5986e27 100644 --- a/loko/interventions/static/interventions/detail/cms_edit_mode.js +++ b/loko/interventions/static/interventions/detail/cms_edit_mode.js @@ -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); diff --git a/loko/interventions/static/interventions/detail/fields.js b/loko/interventions/static/interventions/detail/fields.js index 15ae475..870fe80 100644 --- a/loko/interventions/static/interventions/detail/fields.js +++ b/loko/interventions/static/interventions/detail/fields.js @@ -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(() => { diff --git a/loko/interventions/static/interventions/detail/layout.js b/loko/interventions/static/interventions/detail/layout.js index cb07654..4bee3c3 100644 --- a/loko/interventions/static/interventions/detail/layout.js +++ b/loko/interventions/static/interventions/detail/layout.js @@ -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); diff --git a/loko/interventions/templates/interventions/detail/card_info.html b/loko/interventions/templates/interventions/detail/card_info.html index bfed468..594c7dd 100644 --- a/loko/interventions/templates/interventions/detail/card_info.html +++ b/loko/interventions/templates/interventions/detail/card_info.html @@ -330,7 +330,7 @@ {% for contract in contracts %} {% endfor %} @@ -403,14 +403,14 @@ {% translate "Contrat" %}:
- {{ intervention.contract.contract_number|default:'-' }} + {% if intervention.contract %}{{ intervention.contract.contract_number }}{% if intervention.contract.description %} ({{ intervention.contract.description|truncatechars:45 }}){% endif %}{% else %}-{% endif %}
diff --git a/loko/interventions/tests/test_visibility_permissions.py b/loko/interventions/tests/test_visibility_permissions.py index 8fc6ecc..d3eae01 100644 --- a/loko/interventions/tests/test_visibility_permissions.py +++ b/loko/interventions/tests/test_visibility_permissions.py @@ -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) + + + diff --git a/loko/interventions/views/detail.py b/loko/interventions/views/detail.py index ae17e35..a7d3a71 100644 --- a/loko/interventions/views/detail.py +++ b/loko/interventions/views/detail.py @@ -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): diff --git a/loko/interventions/views/helpers.py b/loko/interventions/views/helpers.py index 4755f73..7eac521 100644 --- a/loko/interventions/views/helpers.py +++ b/loko/interventions/views/helpers.py @@ -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,