feat: implement granular intervention permissions and support batch field updates via JSON API

This commit is contained in:
kdeterme 2026-08-19 14:25:11 +02:00
parent cde2c7e52f
commit cfd11654ab
4 changed files with 129 additions and 8 deletions

View file

@ -1023,11 +1023,36 @@ def can_edit_intervention(user, intervention, user_config=None):
thematic=thematic,
).first()
if ut:
return UserThematicStatusPermission.objects.filter(
if UserThematicStatusPermission.objects.filter(
user_thematic=ut,
status=status,
can_edit=True,
).exists()
).exists():
return True
# Si pas de contrat et pas de permission de statut thématique explicite :
if contract is None:
# 1. Le créateur de l'intervention peut toujours la modifier s'il n'y a pas de contrat
if intervention.created_by_id == user.id:
return True
# 2. Utilisateur ayant des droits d'édition sur au moins un contrat de la thématique
if UserContractStatusPermission.objects.filter(
user_contract__user_config=user_config,
user_contract__contract__thematics=thematic,
status=status,
can_edit=True,
).exists():
return True
# 3. Utilisateur ayant des droits d'édition sur au moins un contrat du prestataire assigné
if intervention.assigned_provider_id and UserContractStatusPermission.objects.filter(
user_contract__user_config=user_config,
user_contract__contract__company_id=intervention.assigned_provider_id,
status=status,
can_edit=True,
).exists():
return True
# 4. Si l'utilisateur a can_edit_interventions sur la thématique pour les statuts initiaux
if ut.can_edit_interventions and status in ('in_preparation', 'to_be_approved', 'to_be_planned'):
return True
return False

View file

@ -144,19 +144,20 @@
}
}
async function updateField(interventionId, field, value) {
async function updateFieldsBatch(interventionId, payload) {
const response = await fetch(`/interventions/${interventionId}/update/`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken')
},
body: new URLSearchParams({ field, value })
body: JSON.stringify(payload)
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || gettext('Une erreur est survenue.'));
const errMsg = data.error || (data.errors && data.errors.join(', ')) || gettext('Une erreur est survenue.');
throw new Error(errMsg);
}
return data;
}
@ -227,11 +228,16 @@
if (spinnerEl) spinnerEl.classList.remove('d-none');
try {
const payload = {};
if (providerChanged) {
await updateField(interventionId, 'assigned_provider', providerValue);
payload.assigned_provider = providerValue;
}
if (contractChanged) {
await updateField(interventionId, 'contract', contractValue);
payload.contract = contractValue;
}
if (Object.keys(payload).length > 0) {
await updateFieldsBatch(interventionId, payload);
}
if (providerDisplay && providerSelect) {

View file

@ -600,6 +600,94 @@ class GetContractsByProviderTests(TestCase):
self.assertIn("C-002", contract_numbers)
class QuickProviderContractUpdateTests(TestCase):
def setUp(self):
from common.models import UserContractAccess, UserContractStatusPermission
self.User = get_user_model()
self.manager_role, _ = Role.objects.get_or_create(name='manager')
self.thematic = Thematic.objects.create(name_fr='Voirie Test', allow_planning_without_contract=True)
self.company1 = Company.objects.create(name='Company 1')
self.company2 = Company.objects.create(name='Company 2')
self.contract1 = Contract.objects.create(
contract_number="C-001",
company=self.company1,
start_date=timezone.now().date(),
end_date=timezone.now().date() + timedelta(days=365)
)
self.contract1.thematics.add(self.thematic)
self.contract2 = Contract.objects.create(
contract_number="C-002",
company=self.company2,
start_date=timezone.now().date(),
end_date=timezone.now().date() + timedelta(days=365)
)
self.contract2.thematics.add(self.thematic)
# User with limited contract access
self.user = self.User.objects.create_user(username='contract-manager', password='pwd')
self.user_config = UserConfig.objects.create(
user=self.user,
is_intern=True,
limit_interventions_to_contracts=True,
)
self.user_config.roles.add(self.manager_role)
# User thematic without explicit UserThematicStatusPermission
UserThematics.objects.create(
user_config=self.user_config,
thematic=self.thematic,
can_view_interventions=True,
can_edit_interventions=True,
)
# Access and edit permissions on both contracts for to_be_planned status
uca1 = UserContractAccess.objects.create(user_config=self.user_config, contract=self.contract1, can_view_interventions=True)
UserContractStatusPermission.objects.create(user_contract=uca1, status='to_be_planned', can_edit=True, can_view=True)
uca2 = UserContractAccess.objects.create(user_config=self.user_config, contract=self.contract2, can_view_interventions=True)
UserContractStatusPermission.objects.create(user_contract=uca2, status='to_be_planned', can_edit=True, can_view=True)
self.intervention = Intervention.objects.create(
title='Test Quick Edit Intervention',
thematic=self.thematic,
status='to_be_planned',
assigned_provider=self.company1,
contract=self.contract1,
)
def test_batch_update_provider_and_contract(self):
from interventions.permissions import can_edit_intervention
self.assertTrue(can_edit_intervention(self.user, self.intervention))
self.client.login(username='contract-manager', password='pwd')
url = reverse('interventions:update_intervention', args=[self.intervention.id])
payload = {
'assigned_provider': str(self.company2.id),
'contract': str(self.contract2.id),
}
response = self.client.post(url, data=json.dumps(payload), content_type='application/json')
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data['success'])
self.intervention.refresh_from_db()
self.assertEqual(self.intervention.assigned_provider, self.company2)
self.assertEqual(self.intervention.contract, self.contract2)
def test_can_edit_intervention_when_contract_is_none(self):
from interventions.permissions import can_edit_intervention
self.intervention.contract = None
self.intervention.assigned_provider = self.company2
self.intervention.save()
# User should still be allowed to edit because they have edit rights on contracts of this thematic/provider
self.assertTrue(can_edit_intervention(self.user, self.intervention))

View file

@ -2495,6 +2495,8 @@ def _update_intervention_field(intervention, field, new_value, user_config=None)
if not has_access:
return False, _('You do not have access to this contract')
intervention.contract = contract
if contract.company and intervention.assigned_provider != contract.company:
intervention.assigned_provider = contract.company
return True, None
return False, _('Contract not found')