From 718403c29519b7b12c923747f40e18a89e1ab4a2 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Wed, 12 Aug 2026 05:26:50 +0200 Subject: [PATCH] feat: add default contract support to user configurations and auto-assign during intervention creation --- streetup/common/admin.py | 2 +- .../0009_userconfig_default_contract.py | 20 ++++++ streetup/common/models.py | 15 +++++ streetup/interventions/permissions.py | 2 +- .../tests/test_visibility_permissions.py | 62 +++++++++++++++++++ streetup/interventions/views/detail.py | 9 +++ streetup/interventions/views/mobile.py | 22 +++++++ streetup/observations/models.py | 11 ++++ 8 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 streetup/common/migrations/0009_userconfig_default_contract.py diff --git a/streetup/common/admin.py b/streetup/common/admin.py index 6ec5b2e..54015c6 100644 --- a/streetup/common/admin.py +++ b/streetup/common/admin.py @@ -617,7 +617,7 @@ class UserConfigAdmin(admin.ModelAdmin): fieldsets = ( (None, { - 'fields': ('user', 'is_intern', 'roles', 'company', 'company_members_link', 'company_teams_link', 'default_thematic', 'generic_email') + 'fields': ('user', 'is_intern', 'roles', 'company', 'company_members_link', 'company_teams_link', 'default_thematic', 'default_contract', 'generic_email') }), ('Accès aux vues', { 'fields': ('accessible_views',), diff --git a/streetup/common/migrations/0009_userconfig_default_contract.py b/streetup/common/migrations/0009_userconfig_default_contract.py new file mode 100644 index 0000000..73585c3 --- /dev/null +++ b/streetup/common/migrations/0009_userconfig_default_contract.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0.7 on 2026-08-12 03:20 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0008_reset_can_inspect_for_non_admins'), + ('contracts', '0002_initial'), + ] + + operations = [ + migrations.AddField( + model_name='userconfig', + name='default_contract', + field=models.ForeignKey(blank=True, help_text="Contrat assigné par défaut lors de la création d'une intervention si aucun contrat n'est résolu automatiquement.", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_user_accesses', to='contracts.contract', verbose_name='Contrat par défaut'), + ), + ] diff --git a/streetup/common/models.py b/streetup/common/models.py index 3e75b14..1f516a5 100644 --- a/streetup/common/models.py +++ b/streetup/common/models.py @@ -98,6 +98,7 @@ class UserConfig(models.Model): is_intern = models.BooleanField(default=False) roles = models.ManyToManyField(Role, blank=True) default_thematic = models.ForeignKey(Thematic, on_delete=models.SET_NULL, null=True, blank=True, related_name="default_user_accesses") + default_contract = models.ForeignKey("contracts.Contract", on_delete=models.SET_NULL, null=True, blank=True, related_name="default_user_accesses", verbose_name="Contrat par défaut", help_text="Contrat assigné par défaut lors de la création d'une intervention si aucun contrat n'est résolu automatiquement.") thematics = models.ManyToManyField("common.Thematic", through="UserThematics", related_name="user_accesses") contracts = models.ManyToManyField("contracts.Contract", through="UserContractAccess", related_name="user_accesses") warehouses = models.ManyToManyField("stock.Warehouse", through="UserWarehouseAccess", related_name="user_accesses", blank=True) @@ -479,6 +480,20 @@ class UserConfig(models.Model): return self.userthematics.filter(thematic=thematic, can_inspect=True).exists() + def get_default_contract(self): + """ + Retourne le contrat par défaut de l'utilisateur. + Si aucun contrat par défaut n'est spécifié mais que l'utilisateur a limit_interventions_to_contracts=True + et n'a accès qu'à un seul contrat (via UserContractAccess), retourne ce contrat unique. + """ + if self.default_contract_id: + return self.default_contract + if self.limit_interventions_to_contracts: + allowed_contracts = list(self.contracts.all()[:2]) + if len(allowed_contracts) == 1: + return allowed_contracts[0] + return None + class UserThematics(models.Model): diff --git a/streetup/interventions/permissions.py b/streetup/interventions/permissions.py index 9b25eb5..39eb458 100644 --- a/streetup/interventions/permissions.py +++ b/streetup/interventions/permissions.py @@ -407,7 +407,7 @@ def get_allowed_update_fields(user, intervention): _initial_statuses = ('in_preparation', 'to_be_approved') if _thematic_allows_planning_without_contract: _initial_statuses = ('in_preparation', 'to_be_approved', 'to_be_planned') - if intervention.status in _initial_statuses and user_config.is_intern: + if intervention.status in _initial_statuses and (user_config.is_intern or (intervention.contract is None and intervention.created_by_id == user.id)): allowed_fields.update(['assigned_provider', 'contract', 'maintain_type']) # Localisation : modifiable par les internes sur toutes les interventions non validées diff --git a/streetup/interventions/tests/test_visibility_permissions.py b/streetup/interventions/tests/test_visibility_permissions.py index 37ee756..8fc6ecc 100644 --- a/streetup/interventions/tests/test_visibility_permissions.py +++ b/streetup/interventions/tests/test_visibility_permissions.py @@ -469,5 +469,67 @@ class CreatorVisibilityTests(TestCase): self.assertNotIn(itv, filter_viewable_interventions_for_user(self.user2)) +class DefaultContractTests(TestCase): + def setUp(self): + from datetime import date + from django.contrib.auth import get_user_model + from common.models import UserConfig, UserContractAccess + from contracts.models import Contract, Company + + self.User = get_user_model() + self.user = self.User.objects.create_user(username='default-contract-user', password='pwd') + self.company = Company.objects.create(name='Test Company') + self.contract1 = Contract.objects.create(contract_number='CNT-001', company=self.company, start_date=date(2025, 1, 1), end_date=date(2030, 1, 1)) + self.contract2 = Contract.objects.create(contract_number='CNT-002', company=self.company, start_date=date(2025, 1, 1), end_date=date(2030, 1, 1)) + + self.config = UserConfig.objects.create( + user=self.user, + is_intern=False, + limit_interventions_to_contracts=True, + ) + + def test_get_default_contract_explicit(self): + self.config.default_contract = self.contract1 + self.config.save() + self.assertEqual(self.config.get_default_contract(), self.contract1) + + def test_get_default_contract_single_contract_fallback(self): + from common.models import UserContractAccess + UserContractAccess.objects.create(user_config=self.config, contract=self.contract2) + # With only 1 contract in UserContractAccess, get_default_contract returns contract2 + self.assertEqual(self.config.get_default_contract(), self.contract2) + + # With 2 contracts, get_default_contract returns None if default_contract not set + UserContractAccess.objects.create(user_config=self.config, contract=self.contract1) + self.assertIsNone(self.config.get_default_contract()) + + def test_observation_conversion_assigns_default_contract(self): + from observations.models import Observation + from interventions.models import Symptom + from common.models import Thematic, UserContractAccess + + self.config.default_contract = self.contract1 + self.config.save() + UserContractAccess.objects.create(user_config=self.config, contract=self.contract1) + + thematic = Thematic.objects.create(code='roads_def', name_fr='Voirie Def') + symptom = Symptom.objects.create(name_fr='Symptom test', thematic=thematic) + + obs = Observation.objects.create( + symptom=symptom, + thematic=thematic, + created_by=self.user, + latitude=50.85, + longitude=4.35, + address='123 Test Street' + ) + obs.create_auto_intervention() + itv = obs.intervention + self.assertIsNotNone(itv) + self.assertEqual(itv.contract, self.contract1) + self.assertEqual(itv.assigned_provider, self.company) + + + diff --git a/streetup/interventions/views/detail.py b/streetup/interventions/views/detail.py index 59923a4..ae17e35 100644 --- a/streetup/interventions/views/detail.py +++ b/streetup/interventions/views/detail.py @@ -1798,6 +1798,15 @@ def interventions_add(request, thematic_code): intervention.assigned_provider = its_contract.contract.company intervention.save(update_fields=['contract', 'assigned_provider']) + # Fallback contrat par défaut pour les utilisateurs à visibilité limitée aux contrats + if not intervention.contract and user_config and user_config.limit_interventions_to_contracts: + default_contract = user_config.get_default_contract() + if default_contract: + intervention.contract = default_contract + if not intervention.assigned_provider: + intervention.assigned_provider = default_contract.company + intervention.save(update_fields=['contract', 'assigned_provider']) + # -------- Forcer le statut 'à approuver' pour les utilisateurs externes -------- # Applicable si limit_interventions_to_contracts est activé et que l'utilisateur # n'a pas accès au contrat résolu (ou à celui du symptôme). diff --git a/streetup/interventions/views/mobile.py b/streetup/interventions/views/mobile.py index 6afdb22..0215429 100644 --- a/streetup/interventions/views/mobile.py +++ b/streetup/interventions/views/mobile.py @@ -1548,6 +1548,16 @@ def add_repair_intervention_mobile(request): break with transaction.atomic(): + if not contract: + from common.models import UserConfig + user_config = UserConfig.objects.filter(user=request.user).first() + if user_config and user_config.limit_interventions_to_contracts: + default_contract = user_config.get_default_contract() + if default_contract: + contract = default_contract + if not assigned_provider: + assigned_provider = default_contract.company + new_intervention = Intervention( title=title, description=_("Intervention de réparation créée depuis l'application mobile."), @@ -1775,6 +1785,16 @@ def add_intervention_mobile(request, thematic_code): return _render_form([_("Catégorie non trouvée.")]) with transaction.atomic(): + mobile_contract = None + mobile_provider = None + from common.models import UserConfig + user_config = UserConfig.objects.filter(user=request.user).first() + if user_config and user_config.limit_interventions_to_contracts: + default_contract = user_config.get_default_contract() + if default_contract: + mobile_contract = default_contract + mobile_provider = default_contract.company + new_intervention = Intervention( title=title, description='', @@ -1783,6 +1803,8 @@ def add_intervention_mobile(request, thematic_code): priority='3', thematic=thematic, asset_category=category, + contract=mobile_contract, + assigned_provider=mobile_provider, lon=lon, lat=lat, location_code=location_code, diff --git a/streetup/observations/models.py b/streetup/observations/models.py index b00eca0..7305a46 100644 --- a/streetup/observations/models.py +++ b/streetup/observations/models.py @@ -538,6 +538,17 @@ class Observation(models.Model): assigned_provider=self.symptom.provider, ) + if not intervention.contract and self.created_by: + from common.models import UserConfig + user_config = UserConfig.objects.filter(user=self.created_by).first() + if user_config and user_config.limit_interventions_to_contracts: + default_contract = user_config.get_default_contract() + if default_contract: + intervention.contract = default_contract + if not intervention.assigned_provider: + intervention.assigned_provider = default_contract.company + intervention.save(update_fields=['contract', 'assigned_provider']) + # Link to observation and set observation status to 'in_progress' self.intervention = intervention self.status = 'in_progress'