From b307791b9093d1379cd6f1c2e0fedf046ee7f028 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Wed, 7 Oct 2026 11:57:57 +0200 Subject: [PATCH] feat(contracts): add global permissions to view contract posts and prices on UserConfig - Add can_view_all_posts and can_view_all_prices boolean fields to UserConfig - Add migration 0020 with automatic data migration for Legal Office users - Update permissions helpers, UserConfigAdmin, copy_user_config and intervention detail view - Add unit tests for global posts and prices permissions --- loko/common/admin.py | 6 ++- .../commands/export_user_configs.py | 2 + .../commands/import_user_configs.py | 2 + ..._userconfig_can_view_all_posts_and_more.py | 39 +++++++++++++++++++ loko/common/models.py | 10 +++++ loko/common/user_services.py | 4 +- loko/contracts/permissions.py | 18 ++++++++- loko/contracts/tests.py | 34 ++++++++++++++++ loko/controls/forms.py | 11 ++++-- loko/interventions/views/detail.py | 7 +++- 10 files changed, 123 insertions(+), 10 deletions(-) create mode 100644 loko/common/migrations/0020_userconfig_can_view_all_posts_and_more.py diff --git a/loko/common/admin.py b/loko/common/admin.py index de173b4..f07410f 100644 --- a/loko/common/admin.py +++ b/loko/common/admin.py @@ -620,7 +620,7 @@ class UserContractAccessAdmin(admin.ModelAdmin): @admin.register(UserConfig) class UserConfigAdmin(admin.ModelAdmin): list_display = ("user", "display_contracts", "display_thematics", "display_views", "is_intern", "display_roles", "company", "display_member_teams", "duplicate_button") - list_filter = ("thematics", "roles", "company", "user__companymember__teams", "contracts", "accessible_views", "auto_logout_hours") + list_filter = ("thematics", "roles", "company", "user__companymember__teams", "contracts", "can_view_all_posts", "can_view_all_prices", "accessible_views", "auto_logout_hours") search_fields = ("user__username", "user__first_name", "user__last_name", "contracts__contract_number") inlines = [UserThematicsInline, UserContractAccessInline, UserWarehouseAccessInline, UserWarehouseLocationAccessInline, UserCompanyTeamAccessInline, UserAssetTypeAccessInline, UserAssetAccessInline] filter_horizontal = ("linked_configs", "accessible_views") @@ -637,6 +637,10 @@ class UserConfigAdmin(admin.ModelAdmin): 'fields': ('accessible_views',), 'description': "Configurer les vues auxquelles l'utilisateur a accès." }), + ('Permissions globales / Contrats', { + 'fields': ('can_view_all_posts', 'can_view_all_prices'), + 'description': "Permet de consulter les prestations et/ou les prix sur tous les contrats sans configuration contractuelle individuelle." + }), ('eSign / Signalisation', { 'fields': ('esign_can_edit', 'esign_can_manage_rcp', 'esign_can_validate_rcp', 'esign_organizations'), 'description': "Permissions spécifiques pour l'application eSign / Signalisation." diff --git a/loko/common/management/commands/export_user_configs.py b/loko/common/management/commands/export_user_configs.py index 6be640f..61c20e8 100644 --- a/loko/common/management/commands/export_user_configs.py +++ b/loko/common/management/commands/export_user_configs.py @@ -113,6 +113,8 @@ class Command(BaseCommand): 'hide_intervention_map_by_default': cfg.hide_intervention_map_by_default, 'auto_logout_hours': cfg.auto_logout_hours, 'can_add_unique_tag_documents': cfg.can_add_unique_tag_documents, + 'can_view_all_posts': cfg.can_view_all_posts, + 'can_view_all_prices': cfg.can_view_all_prices, 'roles': list(cfg.roles.values_list('name', flat=True)), 'accessible_views': list(cfg.accessible_views.values_list('code', flat=True)), 'linked_configs': list(cfg.linked_configs.values_list('user__username', flat=True)), diff --git a/loko/common/management/commands/import_user_configs.py b/loko/common/management/commands/import_user_configs.py index de02f3f..59b585a 100644 --- a/loko/common/management/commands/import_user_configs.py +++ b/loko/common/management/commands/import_user_configs.py @@ -199,6 +199,8 @@ class Command(BaseCommand): 'hide_intervention_map_by_default': entry.get('hide_intervention_map_by_default', False), 'auto_logout_hours': entry.get('auto_logout_hours'), 'can_add_unique_tag_documents': entry.get('can_add_unique_tag_documents', False), + 'can_view_all_posts': entry.get('can_view_all_posts', False), + 'can_view_all_prices': entry.get('can_view_all_prices', False), 'default_thematic': default_thematic, 'company': company_obj, } diff --git a/loko/common/migrations/0020_userconfig_can_view_all_posts_and_more.py b/loko/common/migrations/0020_userconfig_can_view_all_posts_and_more.py new file mode 100644 index 0000000..aac6525 --- /dev/null +++ b/loko/common/migrations/0020_userconfig_can_view_all_posts_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 6.0.7 on 2026-10-07 09:41 + +from django.db import migrations, models + + +def set_legal_office_posts_and_prices(apps, schema_editor): + UserConfig = apps.get_model('common', 'UserConfig') + Company = apps.get_model('contracts', 'Company') + legal_companies = Company.objects.filter(name__icontains="Legal Office") + if legal_companies.exists(): + UserConfig.objects.filter(company__in=legal_companies).update( + can_view_all_posts=True, + can_view_all_prices=True, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0019_userthematics_can_edit_management_zones'), + ('contracts', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='userconfig', + name='can_view_all_posts', + field=models.BooleanField(default=False, help_text="Permet à l'utilisateur de consulter les postes/prestations sur l'ensemble des contrats sans nécessiter d'accès contractuel explicite.", verbose_name='Peut voir toutes les prestations'), + ), + migrations.AddField( + model_name='userconfig', + name='can_view_all_prices', + field=models.BooleanField(default=False, help_text="Permet à l'utilisateur de consulter les prix des postes sur l'ensemble des contrats sans nécessiter d'accès contractuel explicite.", verbose_name='Peut voir tous les prix'), + ), + migrations.RunPython( + set_legal_office_posts_and_prices, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/loko/common/models.py b/loko/common/models.py index 28a0984..5a9e196 100644 --- a/loko/common/models.py +++ b/loko/common/models.py @@ -305,6 +305,16 @@ class UserConfig(models.Model): verbose_name="Peut ajouter des documents avec tags uniques", help_text="Permet aux utilisateurs externes d'ajouter/modifier des documents avec des tags uniques (CME, PIS, As-Built, etc.).", ) + can_view_all_posts = models.BooleanField( + default=False, + verbose_name=_("Peut voir toutes les prestations"), + help_text=_("Permet à l'utilisateur de consulter les postes/prestations sur l'ensemble des contrats sans nécessiter d'accès contractuel explicite."), + ) + can_view_all_prices = models.BooleanField( + default=False, + verbose_name=_("Peut voir tous les prix"), + help_text=_("Permet à l'utilisateur de consulter les prix des postes sur l'ensemble des contrats sans nécessiter d'accès contractuel explicite."), + ) esign_can_edit = models.BooleanField( default=False, verbose_name="eSign : Peut éditer", diff --git a/loko/common/user_services.py b/loko/common/user_services.py index 73b95fd..a424897 100644 --- a/loko/common/user_services.py +++ b/loko/common/user_services.py @@ -168,8 +168,10 @@ def copy_user_config(source_user_or_config: Any, target_user_or_config: Any, cop dict(source_config.calendar_preferences) if source_config.calendar_preferences is not None else None ) - # Permissions spécifiques (eSign, documents, panoramax, transport exceptionnel) + # Permissions spécifiques (eSign, documents, panoramax, transport exceptionnel, contrats) target_config.can_add_unique_tag_documents = source_config.can_add_unique_tag_documents + target_config.can_view_all_posts = source_config.can_view_all_posts + target_config.can_view_all_prices = source_config.can_view_all_prices target_config.esign_can_edit = source_config.esign_can_edit target_config.esign_can_manage_rcp = source_config.esign_can_manage_rcp target_config.esign_can_validate_rcp = source_config.esign_can_validate_rcp diff --git a/loko/contracts/permissions.py b/loko/contracts/permissions.py index 6846e81..5dc5427 100644 --- a/loko/contracts/permissions.py +++ b/loko/contracts/permissions.py @@ -90,11 +90,17 @@ def user_has_access_to_contract(user, contract_id, for_assets=False): def user_can_view_contract_prices(user, contract_id): """Vrai si l'utilisateur est admin ou dispose du droit can_view_prices sur le contrat.""" + if getattr(user, 'is_superuser', False): + return True + try: config = user.config except AttributeError: return False + if getattr(config, 'can_view_all_prices', False): + return True + user_roles = set(config.roles.values_list("name", flat=True)) if not user_roles.intersection(ALLOWED_ROLES_FOR_CONTRACT_ACCESS): return False @@ -102,9 +108,10 @@ def user_can_view_contract_prices(user, contract_id): if "admin" in user_roles or "top_manager" in user_roles: return True + cid = getattr(contract_id, 'id', contract_id) return UserContractAccess.objects.filter( user_config=config, - contract_id=contract_id, + contract_id=cid, can_view_prices=True ).exists() @@ -209,11 +216,17 @@ def user_has_access_to_contract_posts(user, contract_id): """ Vérifie que l'utilisateur a accès aux postes du contrat. """ + if getattr(user, 'is_superuser', False): + return True + try: config = user.config except AttributeError: return False + if getattr(config, 'can_view_all_posts', False): + return True + user_roles = set(config.roles.values_list("name", flat=True)) if not user_roles.intersection(ALLOWED_ROLES_FOR_CONTRACT_ACCESS): return False @@ -221,9 +234,10 @@ def user_has_access_to_contract_posts(user, contract_id): if "admin" in user_roles or "top_manager" in user_roles: return True + cid = getattr(contract_id, 'id', contract_id) return UserContractAccess.objects.filter( user_config=config, - contract_id=contract_id, + contract_id=cid, can_view_posts=True ).exists() diff --git a/loko/contracts/tests.py b/loko/contracts/tests.py index af79075..f9dcd1e 100644 --- a/loko/contracts/tests.py +++ b/loko/contracts/tests.py @@ -723,3 +723,37 @@ class ContractGeojsonEndpointTests(TestCase): self.assertEqual(len(data["features"]), 0) +class GlobalPostsAndPricesPermissionsTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="viewer_user", password="password123") + self.role_viewer, _ = Role.objects.get_or_create(name="viewer") + self.config = UserConfig.objects.create( + user=self.user, + can_view_all_posts=False, + can_view_all_prices=False, + ) + self.config.roles.add(self.role_viewer) + self.contract = Contract.objects.create( + contract_number="CTR-TEST-001", + description="Test Contract" + ) + + def test_default_viewer_cannot_view_posts_or_prices(self): + from contracts.permissions import user_can_view_contract_prices, user_has_access_to_contract_posts + self.assertFalse(user_can_view_contract_prices(self.user, self.contract.id)) + self.assertFalse(user_has_access_to_contract_posts(self.user, self.contract.id)) + + def test_viewer_with_can_view_all_posts(self): + from contracts.permissions import user_has_access_to_contract_posts + self.config.can_view_all_posts = True + self.config.save() + self.assertTrue(user_has_access_to_contract_posts(self.user, self.contract.id)) + + def test_viewer_with_can_view_all_prices(self): + from contracts.permissions import user_can_view_contract_prices + self.config.can_view_all_prices = True + self.config.save() + self.assertTrue(user_can_view_contract_prices(self.user, self.contract.id)) + + + diff --git a/loko/controls/forms.py b/loko/controls/forms.py index 060cb21..dce2514 100644 --- a/loko/controls/forms.py +++ b/loko/controls/forms.py @@ -220,10 +220,13 @@ class ControlQuantityInlineFormSet(BaseInlineFormSet): qs = Contract.objects.none() if user and hasattr(user, "config"): - allowed_ids = UserContractAccess.objects.filter( - user_config=user.config, can_view_posts=True - ).values_list("contract_id", flat=True) - qs = Contract.objects.filter(id__in=allowed_ids).order_by("contract_number") + if getattr(user.config, 'can_view_all_posts', False): + qs = Contract.objects.all().order_by("contract_number") + else: + allowed_ids = UserContractAccess.objects.filter( + user_config=user.config, can_view_posts=True + ).values_list("contract_id", flat=True) + qs = Contract.objects.filter(id__in=allowed_ids).order_by("contract_number") # Injecter le même queryset dans chaque form for form in self.forms: diff --git a/loko/interventions/views/detail.py b/loko/interventions/views/detail.py index c9a4a1f..7f38987 100644 --- a/loko/interventions/views/detail.py +++ b/loko/interventions/views/detail.py @@ -181,7 +181,7 @@ def intervention_detail(request, intervention_id): can_check_intervention = True # Filter the positions associated with this intervention - if user_contract_access and user_contract_access.can_view_posts: + if getattr(user_config, 'can_view_all_posts', False) or (user_contract_access and user_contract_access.can_view_posts): can_view_posts = True contract_posts = list( InterventionContractPost.objects.filter(intervention=intervention) @@ -702,7 +702,10 @@ def intervention_detail(request, intervention_id): "contract_posts": contract_posts, "total_general": total_general, "total_validated": total_validated, - 'can_view_prices': user_contract_access.can_view_prices if user_contract_access else False, + 'can_view_prices': ( + getattr(user_config, 'can_view_all_prices', False) + or (user_contract_access.can_view_prices if user_contract_access else False) + ), 'geom_geojson': geom_geojson, 'allowed_transitions': json.dumps(allowed_transitions), 'add_note_form': InterventionNoteForm(),