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
This commit is contained in:
kdeterme 2026-10-07 11:57:57 +02:00
parent 9a85696d90
commit b307791b90
10 changed files with 123 additions and 10 deletions

View file

@ -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."

View file

@ -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)),

View file

@ -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,
}

View file

@ -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,
),
]

View file

@ -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",

View file

@ -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

View file

@ -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()

View file

@ -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))

View file

@ -220,6 +220,9 @@ class ControlQuantityInlineFormSet(BaseInlineFormSet):
qs = Contract.objects.none()
if user and hasattr(user, "config"):
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)

View file

@ -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(),