feat: introduce can_inspect permission per thematic with restricted access to inspection views
This commit is contained in:
parent
d725da4c57
commit
41619256a9
11 changed files with 120 additions and 8 deletions
|
|
@ -292,7 +292,7 @@
|
||||||
|
|
||||||
const iconDisp = document.getElementById('thematic-icon-display');
|
const iconDisp = document.getElementById('thematic-icon-display');
|
||||||
const nameDisp = document.getElementById('thematic-name-display');
|
const nameDisp = document.getElementById('thematic-name-display');
|
||||||
if (iconDisp) iconDisp.className = `bi ${selectedIcon} text-primary`;
|
if (iconDisp) iconDisp.className = `bi ${selectedIcon} text-primary me-2`;
|
||||||
if (nameDisp) nameDisp.textContent = selectedThematicName;
|
if (nameDisp) nameDisp.textContent = selectedThematicName;
|
||||||
|
|
||||||
const categorySelect = document.getElementById('filter-category');
|
const categorySelect = document.getElementById('filter-category');
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,13 @@
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-muted mb-0 small">{% translate "Suivi et historique des contrôles et vérifications sur le terrain" %}</p>
|
<p class="text-muted mb-0 small">{% translate "Suivi et historique des contrôles et vérifications sur le terrain" %}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{% if can_inspect %}
|
||||||
<div>
|
<div>
|
||||||
<a href="{% url 'assets:quick_inspection_map' %}" class="btn btn-primary shadow-sm">
|
<a href="{% url 'assets:quick_inspection_map' %}" class="btn btn-primary shadow-sm">
|
||||||
<i class="bi bi-geo-alt-fill me-1"></i>{% translate "Nouvelle inspection (Carte GPS)" %}
|
<i class="bi bi-geo-alt-fill me-1"></i>{% translate "Nouvelle inspection (Carte GPS)" %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Navigation par Onglets -->
|
<!-- Navigation par Onglets -->
|
||||||
|
|
|
||||||
|
|
@ -143,14 +143,14 @@
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<!-- Bouton Déclencheur Thématique -->
|
<!-- Bouton Déclencheur Thématique -->
|
||||||
<button id="btn-select-thematic" type="button" class="btn filter-btn-pill d-flex align-items-center gap-1.5" data-bs-toggle="modal" data-bs-target="#thematicChooserModal">
|
<button id="btn-select-thematic" type="button" class="btn filter-btn-pill d-flex align-items-center gap-1.5" data-bs-toggle="modal" data-bs-target="#thematicChooserModal">
|
||||||
<i id="thematic-icon-display" class="bi bi-collection text-primary"></i>
|
<i id="thematic-icon-display" class="bi bi-collection text-primary me-2"></i>
|
||||||
<span id="thematic-name-display" class="fw-semibold">{% translate "Thématique" %}</span>
|
<span id="thematic-name-display" class="fw-semibold">{% translate "Thématique" %}</span>
|
||||||
<i class="bi bi-chevron-down text-muted ms-1 small"></i>
|
<i class="bi bi-chevron-down text-muted ms-1 small"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Bouton Déclencheur Catégorie -->
|
<!-- Bouton Déclencheur Catégorie -->
|
||||||
<button id="btn-select-category" type="button" class="btn filter-btn-pill d-flex align-items-center gap-1.5" data-bs-toggle="modal" data-bs-target="#categoryChooserModal">
|
<button id="btn-select-category" type="button" class="btn filter-btn-pill d-flex align-items-center gap-1.5" data-bs-toggle="modal" data-bs-target="#categoryChooserModal">
|
||||||
<i class="bi bi-tag-fill text-secondary"></i>
|
<i class="bi bi-tag-fill text-secondary me-1.5"></i>
|
||||||
<span id="category-name-display" class="fw-semibold">{% translate "Catégorie" %}</span>
|
<span id="category-name-display" class="fw-semibold">{% translate "Catégorie" %}</span>
|
||||||
<i class="bi bi-chevron-down text-muted ms-1 small"></i>
|
<i class="bi bi-chevron-down text-muted ms-1 small"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -91,3 +91,33 @@ class InspectionViewsTestCase(TestCase):
|
||||||
interv = Intervention.objects.filter(code=data['created_intervention_code']).first()
|
interv = Intervention.objects.filter(code=data['created_intervention_code']).first()
|
||||||
self.assertIsNotNone(interv)
|
self.assertIsNotNone(interv)
|
||||||
self.assertEqual(interv.status, 'in_preparation')
|
self.assertEqual(interv.status, 'in_preparation')
|
||||||
|
|
||||||
|
def test_quick_inspection_map_permissions_can_inspect(self):
|
||||||
|
from common.models import UserConfig, UserThematics, Thematic
|
||||||
|
t_parking = Thematic.objects.get(code='parking')
|
||||||
|
t_other, _ = Thematic.objects.get_or_create(code='publiclighting', defaults={'name_fr': 'Éclairage public'})
|
||||||
|
|
||||||
|
user_config, _ = UserConfig.objects.get_or_create(user=self.user)
|
||||||
|
UserThematics.objects.create(user_config=user_config, thematic=t_parking, can_inspect=True)
|
||||||
|
UserThematics.objects.create(user_config=user_config, thematic=t_other, can_inspect=False)
|
||||||
|
|
||||||
|
response = self.client.get(reverse('assets:quick_inspection_map'))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, 'parking')
|
||||||
|
self.assertNotContains(response, 'publiclighting')
|
||||||
|
|
||||||
|
def test_no_inspectable_thematic_hides_button_and_returns_403(self):
|
||||||
|
from common.models import UserConfig, UserThematics, Thematic
|
||||||
|
t_parking = Thematic.objects.get(code='parking')
|
||||||
|
|
||||||
|
user_config, _ = UserConfig.objects.get_or_create(user=self.user)
|
||||||
|
UserThematics.objects.create(user_config=user_config, thematic=t_parking, can_inspect=False)
|
||||||
|
|
||||||
|
# 1. Page liste des inspections -> Bouton 'Nouvelle inspection' masqué
|
||||||
|
resp_list = self.client.get(reverse('assets:inspections_list'))
|
||||||
|
self.assertEqual(resp_list.status_code, 200)
|
||||||
|
self.assertNotContains(resp_list, reverse('assets:quick_inspection_map'))
|
||||||
|
|
||||||
|
# 2. Tentative d'accès direct à quick_inspection_map -> HTTP 403 Forbidden
|
||||||
|
resp_map = self.client.get(reverse('assets:quick_inspection_map'))
|
||||||
|
self.assertEqual(resp_map.status_code, 403)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,15 @@ from assets.models.inspection import (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_inspectable_thematics(user):
|
||||||
|
"""
|
||||||
|
Retourne les thématiques pour lesquelles l'utilisateur détient la permission can_inspect.
|
||||||
|
"""
|
||||||
|
if hasattr(user, 'config'):
|
||||||
|
return user.config.get_inspectable_thematics().order_by('name_fr')
|
||||||
|
return Thematic.objects.all().order_by('name_fr')
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def inspections_list(request):
|
def inspections_list(request):
|
||||||
"""
|
"""
|
||||||
|
|
@ -54,7 +63,8 @@ def inspections_list(request):
|
||||||
Q(inspector__username__icontains=search_q)
|
Q(inspector__username__icontains=search_q)
|
||||||
)
|
)
|
||||||
|
|
||||||
thematics = Thematic.objects.all().order_by('name_fr')
|
thematics = get_user_inspectable_thematics(request.user)
|
||||||
|
can_inspect = thematics.exists()
|
||||||
|
|
||||||
is_mobile = request.path.startswith('/mobile/')
|
is_mobile = request.path.startswith('/mobile/')
|
||||||
base_template = "mobile/mobile_base.html" if is_mobile else "base.html"
|
base_template = "mobile/mobile_base.html" if is_mobile else "base.html"
|
||||||
|
|
@ -70,6 +80,7 @@ def inspections_list(request):
|
||||||
'selected_nature': nature_filter,
|
'selected_nature': nature_filter,
|
||||||
'search_q': search_q,
|
'search_q': search_q,
|
||||||
'thematics': thematics,
|
'thematics': thematics,
|
||||||
|
'can_inspect': can_inspect,
|
||||||
'result_choices': INSPECTION_RESULT_CHOICES,
|
'result_choices': INSPECTION_RESULT_CHOICES,
|
||||||
'nature_choices': INSPECTION_NATURE_CHOICES,
|
'nature_choices': INSPECTION_NATURE_CHOICES,
|
||||||
}
|
}
|
||||||
|
|
@ -81,8 +92,11 @@ def quick_inspection_map(request):
|
||||||
"""
|
"""
|
||||||
Vue cartographique d'inspection rapide optimisée mobile & GPS.
|
Vue cartographique d'inspection rapide optimisée mobile & GPS.
|
||||||
"""
|
"""
|
||||||
thematics = Thematic.objects.all().order_by('name_fr')
|
thematics = get_user_inspectable_thematics(request.user)
|
||||||
categories = AssetCategory.objects.select_related('thematic').all().order_by('name_fr')
|
if not thematics.exists():
|
||||||
|
return HttpResponseForbidden(_("Vous ne disposez d'aucune thématique autorisée pour réaliser des inspections."))
|
||||||
|
|
||||||
|
categories = AssetCategory.objects.filter(thematic__in=thematics).select_related('thematic').order_by('name_fr')
|
||||||
|
|
||||||
is_mobile = request.path.startswith('/mobile/')
|
is_mobile = request.path.startswith('/mobile/')
|
||||||
base_template = "mobile/mobile_base.html" if is_mobile else "base.html"
|
base_template = "mobile/mobile_base.html" if is_mobile else "base.html"
|
||||||
|
|
|
||||||
|
|
@ -489,7 +489,7 @@ class UserThematicsInline(admin.TabularInline):
|
||||||
model = UserThematics
|
model = UserThematics
|
||||||
extra = 1
|
extra = 1
|
||||||
fields = [
|
fields = [
|
||||||
'thematic', 'can_view_assets', 'can_edit_assets', 'can_validate_assets',
|
'thematic', 'can_view_assets', 'can_edit_assets', 'can_validate_assets', 'can_inspect',
|
||||||
'can_view_interventions', 'can_edit_interventions', 'can_edit_locations',
|
'can_view_interventions', 'can_edit_interventions', 'can_edit_locations',
|
||||||
'can_process_observations',
|
'can_process_observations',
|
||||||
'can_view_projects', 'can_edit_projects',
|
'can_view_projects', 'can_edit_projects',
|
||||||
|
|
@ -584,7 +584,7 @@ class UserAssetTypeAccessInline(admin.TabularInline):
|
||||||
|
|
||||||
@admin.register(UserThematics)
|
@admin.register(UserThematics)
|
||||||
class UserThematicsAdmin(admin.ModelAdmin):
|
class UserThematicsAdmin(admin.ModelAdmin):
|
||||||
list_display = ('user_config', 'thematic', 'can_view_interventions', 'can_edit_interventions', 'can_view_assets', 'can_edit_assets', 'can_validate_assets', 'can_process_observations')
|
list_display = ('user_config', 'thematic', 'can_view_interventions', 'can_edit_interventions', 'can_view_assets', 'can_edit_assets', 'can_validate_assets', 'can_inspect', 'can_process_observations')
|
||||||
list_filter = ('thematic',)
|
list_filter = ('thematic',)
|
||||||
search_fields = ('user_config__user__username', 'thematic__name_fr', 'thematic__name_nl')
|
search_fields = ('user_config__user__username', 'thematic__name_fr', 'thematic__name_nl')
|
||||||
raw_id_fields = ['user_config']
|
raw_id_fields = ['user_config']
|
||||||
|
|
|
||||||
18
streetup/common/migrations/0006_userthematics_can_inspect.py
Normal file
18
streetup/common/migrations/0006_userthematics_can_inspect.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 6.0.7 on 2026-08-07 14:27
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('common', '0005_userconfig_regional_standard'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userthematics',
|
||||||
|
name='can_inspect',
|
||||||
|
field=models.BooleanField(default=True, verbose_name='Peut réaliser des inspections'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 6.0.7 on 2026-08-07 14:30
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('common', '0006_userthematics_can_inspect'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='userthematics',
|
||||||
|
name='can_inspect',
|
||||||
|
field=models.BooleanField(default=False, verbose_name='Peut réaliser des inspections'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -447,6 +447,30 @@ class UserConfig(models.Model):
|
||||||
return True
|
return True
|
||||||
return self.userwarehouses.filter(can_administer=True).exists()
|
return self.userwarehouses.filter(can_administer=True).exists()
|
||||||
|
|
||||||
|
def get_inspectable_thematics(self):
|
||||||
|
"""Retourne le QuerySet des thématiques pour lesquelles l'utilisateur est autorisé à réaliser des inspections."""
|
||||||
|
from common.models import Thematic
|
||||||
|
if self.user.is_superuser or self.has_role('admin'):
|
||||||
|
return Thematic.objects.all()
|
||||||
|
|
||||||
|
if not self.userthematics.exists():
|
||||||
|
return Thematic.objects.all()
|
||||||
|
|
||||||
|
return Thematic.objects.filter(
|
||||||
|
userthematics__user_config=self,
|
||||||
|
userthematics__can_inspect=True
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
def can_inspect_thematic(self, thematic):
|
||||||
|
"""Vérifie si l'utilisateur peut réaliser des inspections pour une thématique donnée."""
|
||||||
|
if self.user.is_superuser or self.has_role('admin'):
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not self.userthematics.exists():
|
||||||
|
return True
|
||||||
|
|
||||||
|
return self.userthematics.filter(thematic=thematic, can_inspect=True).exists()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class UserThematics(models.Model):
|
class UserThematics(models.Model):
|
||||||
|
|
@ -462,6 +486,7 @@ class UserThematics(models.Model):
|
||||||
can_view_projects = models.BooleanField(default=False, verbose_name="Peut voir les projets") # Peut voir les projets de la thématique
|
can_view_projects = models.BooleanField(default=False, verbose_name="Peut voir les projets") # Peut voir les projets de la thématique
|
||||||
can_edit_projects = models.BooleanField(default=False, verbose_name="Peut éditer les projets") # Peut créer/modifier les projets de la thématique
|
can_edit_projects = models.BooleanField(default=False, verbose_name="Peut éditer les projets") # Peut créer/modifier les projets de la thématique
|
||||||
can_validate_assets = models.BooleanField(default=False, verbose_name="Peut valider les assets")
|
can_validate_assets = models.BooleanField(default=False, verbose_name="Peut valider les assets")
|
||||||
|
can_inspect = models.BooleanField(default=False, verbose_name="Peut réaliser des inspections")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ("user_config", "thematic") # Empêche les doublons
|
unique_together = ("user_config", "thematic") # Empêche les doublons
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,12 @@
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if can_inspect %}
|
||||||
<a href="{% url 'mobile:quick_inspection_map_mobile' %}" class="btn btn-outline-primary py-2.5 rounded-3 fw-semibold text-start d-flex align-items-center justify-content-between">
|
<a href="{% url 'mobile:quick_inspection_map_mobile' %}" class="btn btn-outline-primary py-2.5 rounded-3 fw-semibold text-start d-flex align-items-center justify-content-between">
|
||||||
<span><i class="bi bi-search me-2"></i>{% translate "Nouvelle inspection" %}</span>
|
<span><i class="bi bi-search me-2"></i>{% translate "Nouvelle inspection" %}</span>
|
||||||
<i class="bi bi-geo-alt-fill"></i>
|
<i class="bi bi-geo-alt-fill"></i>
|
||||||
</a>
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if can_add_structures_repair %}
|
{% if can_add_structures_repair %}
|
||||||
<a href="{% url 'mobile:add_repair_intervention_mobile' %}" class="btn btn-outline-primary py-2.5 rounded-3 fw-semibold text-start d-flex align-items-center justify-content-between">
|
<a href="{% url 'mobile:add_repair_intervention_mobile' %}" class="btn btn-outline-primary py-2.5 rounded-3 fw-semibold text-start d-flex align-items-center justify-content-between">
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,8 @@ def index(request):
|
||||||
inspection_configs.append(config)
|
inspection_configs.append(config)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
can_inspect = user_config.get_inspectable_thematics().exists()
|
||||||
|
|
||||||
return render(request, "mobile/mobile_index.html", {
|
return render(request, "mobile/mobile_index.html", {
|
||||||
'can_edit_green_surfaces': can_edit_green_surfaces,
|
'can_edit_green_surfaces': can_edit_green_surfaces,
|
||||||
'can_access_stock': can_access_stock,
|
'can_access_stock': can_access_stock,
|
||||||
|
|
@ -117,6 +119,7 @@ def index(request):
|
||||||
'can_add_intervention_mobile': can_add_intervention_mobile,
|
'can_add_intervention_mobile': can_add_intervention_mobile,
|
||||||
'is_inspector': is_inspector,
|
'is_inspector': is_inspector,
|
||||||
'inspection_configs': inspection_configs,
|
'inspection_configs': inspection_configs,
|
||||||
|
'can_inspect': can_inspect,
|
||||||
})
|
})
|
||||||
|
|
||||||
@login_not_required
|
@login_not_required
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue