loko/loko/sign/tests.py

1443 lines
56 KiB
Python

from django.contrib.auth.models import User
from django.test import TestCase, Client
from django.urls import reverse
from common.models import UserConfig, Thematic, UserThematics, AppView
class SignViewTests(TestCase):
"""Tests for the sign view functionality."""
def setUp(self):
"""Set up test data."""
self.client = Client()
# Create application views
self.asset_view = AppView.objects.create(
code='asset_management',
name_fr='Gestion des assets',
name_nl='Asset Management',
icon='bi-database',
url_name='home:index',
is_default=True,
order=1
)
self.sign_view = AppView.objects.create(
code='sign',
name_fr='Signalisation',
name_nl='Signalisatie',
icon='bi-signpost',
url_name='sign:index',
order=2
)
# Create a thematic for sign
self.sign_thematic = Thematic.objects.create(
code='sign',
name_fr='Signalisation',
name_nl='Signalisatie',
icon='bi-signpost',
color='#0022ff'
)
# Create a user with access to sign view
self.user_with_access = User.objects.create_user(
username='signuser',
password='testpass123'
)
self.config_with_access = UserConfig.objects.create(
user=self.user_with_access
)
self.config_with_access.accessible_views.add(self.asset_view, self.sign_view)
# Give the user access to sign thematic
UserThematics.objects.create(
user_config=self.config_with_access,
thematic=self.sign_thematic,
can_view_assets=True,
can_edit_assets=False
)
# Create a user without access to sign view
self.user_without_access = User.objects.create_user(
username='notsignuser',
password='testpass123'
)
self.config_without_access = UserConfig.objects.create(
user=self.user_without_access
)
self.config_without_access.accessible_views.add(self.asset_view)
# Create a user with only sign access
self.user_sign_only = User.objects.create_user(
username='signonly',
password='testpass123'
)
self.config_sign_only = UserConfig.objects.create(
user=self.user_sign_only
)
self.config_sign_only.accessible_views.add(self.sign_view)
UserThematics.objects.create(
user_config=self.config_sign_only,
thematic=self.sign_thematic,
can_view_assets=True
)
def test_sign_index_access_granted(self):
"""Test that users with sign access can view the page."""
self.client.force_login(self.user_with_access)
response = self.client.get(reverse('sign:index'))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'sign/index.html')
def test_sign_index_access_denied(self):
"""Test that users without sign access get 403 Forbidden."""
self.client.force_login(self.user_without_access)
response = self.client.get(reverse('sign:index'))
self.assertEqual(response.status_code, 403)
def test_sign_requires_authentication(self):
"""Test that unauthenticated users are redirected to login."""
response = self.client.get(reverse('sign:index'))
self.assertRedirects(
response,
f"{reverse('login')}?next={reverse('sign:index')}"
)
def test_sign_index_contains_required_elements(self):
"""Test that the sign page contains required UI elements."""
self.client.force_login(self.user_with_access)
response = self.client.get(reverse('sign:index'))
# Check for main container
self.assertContains(response, 'sign-container')
# Check for left panel
self.assertContains(response, 'sign-panel')
# Check for map container
self.assertContains(response, 'sign-map')
# Check for tabs
self.assertContains(response, 'tasks-tab')
self.assertContains(response, 'filters-tab')
self.assertContains(response, 'regulations-tab')
def test_sign_filter_parameters(self):
"""Test that filter parameters are passed to context."""
self.client.force_login(self.user_with_access)
response = self.client.get(
reverse('sign:index'),
{'municipality': ['1', '2'], 'panel': 'filters'}
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context['active_panel'], 'filters')
self.assertIn('1', response.context['selected_municipalities'])
self.assertIn('2', response.context['selected_municipalities'])
class ViewSwitcherTests(TestCase):
"""Tests for the view switcher functionality in the header."""
def setUp(self):
"""Set up test data."""
self.client = Client()
# Create application views
self.asset_view = AppView.objects.create(
code='asset_management',
name_fr='Gestion des assets',
name_nl='Asset Management',
icon='bi-database',
url_name='home:index',
is_default=True,
order=1
)
self.sign_view = AppView.objects.create(
code='sign',
name_fr='Signalisation',
name_nl='Signalisatie',
icon='bi-signpost',
url_name='sign:index',
order=2
)
# Create users with different access levels
self.user_both_views = User.objects.create_user(
username='bothviews',
password='testpass123'
)
self.config_both = UserConfig.objects.create(
user=self.user_both_views
)
self.config_both.accessible_views.add(self.asset_view, self.sign_view)
self.user_asset_only = User.objects.create_user(
username='assetonly',
password='testpass123'
)
self.config_asset = UserConfig.objects.create(
user=self.user_asset_only
)
self.config_asset.accessible_views.add(self.asset_view)
self.user_sign_only = User.objects.create_user(
username='signonly',
password='testpass123'
)
self.config_sign = UserConfig.objects.create(
user=self.user_sign_only
)
self.config_sign.accessible_views.add(self.sign_view)
def test_view_switcher_shown_for_both_views(self):
"""Test that view switcher is shown for users with access to both views."""
self.client.force_login(self.user_both_views)
response = self.client.get(reverse('home:index'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'viewSwitcherDropdown')
def test_view_switcher_hidden_for_single_view_asset(self):
"""Test that view switcher dropdown is not shown for asset-only users."""
self.client.force_login(self.user_asset_only)
response = self.client.get(reverse('home:index'))
self.assertEqual(response.status_code, 200)
# The dropdown should not be shown
self.assertNotContains(response, 'viewSwitcherDropdown')
class UserConfigViewAccessTests(TestCase):
"""Tests for UserConfig view access with generic AppView model."""
def setUp(self):
"""Set up test data."""
self.asset_view = AppView.objects.create(
code='asset_management',
name_fr='Gestion des assets',
name_nl='Asset Management',
icon='bi-database',
url_name='home:index',
is_default=True,
order=1
)
self.sign_view = AppView.objects.create(
code='sign',
name_fr='Signalisation',
name_nl='Signalisatie',
icon='bi-signpost',
url_name='sign:index',
order=2
)
def test_can_access_view_method(self):
"""Test the can_access_view method."""
user = User.objects.create_user(username='testuser', password='pass')
config = UserConfig.objects.create(user=user)
config.accessible_views.add(self.sign_view)
self.assertTrue(config.can_access_view('sign'))
self.assertFalse(config.can_access_view('asset_management'))
def test_can_add_multiple_views(self):
"""Test that multiple views can be added."""
user = User.objects.create_user(username='testuser', password='pass')
config = UserConfig.objects.create(user=user)
config.accessible_views.add(self.asset_view, self.sign_view)
self.assertTrue(config.can_access_view('asset_management'))
self.assertTrue(config.can_access_view('sign'))
self.assertEqual(config.accessible_views.count(), 2)
def test_get_accessible_views_ordered(self):
"""Test that get_accessible_views returns views in order."""
user = User.objects.create_user(username='testuser', password='pass')
config = UserConfig.objects.create(user=user)
config.accessible_views.add(self.sign_view, self.asset_view)
views = list(config.get_accessible_views())
self.assertEqual(views[0].code, 'asset_management') # order=1
self.assertEqual(views[1].code, 'sign') # order=2
class AppViewModelTests(TestCase):
"""Tests for the AppView model."""
def test_app_view_creation(self):
"""Test creating an AppView."""
view = AppView.objects.create(
code='test_view',
name_fr='Vue de test',
name_nl='Test view',
icon='bi-test',
url_name='test:index',
is_default=False,
order=10
)
self.assertEqual(view.code, 'test_view')
self.assertEqual(view.name_fr, 'Vue de test')
self.assertEqual(view.url_name, 'test:index')
def test_app_view_name_property(self):
"""Test the name property returns the correct language."""
view = AppView.objects.create(
code='test_view',
name_fr='Vue de test',
name_nl='Test view',
icon='bi-test',
url_name='test:index'
)
# Default language should be French
self.assertEqual(view.name, 'Vue de test')
class ESignPermissionTests(TestCase):
"""Tests for the refined eSign permissions."""
def setUp(self):
from django.contrib.auth.models import Permission
from common.models import Role
# Create basic roles
self.admin_role, _ = Role.objects.get_or_create(name='admin')
self.manager_role, _ = Role.objects.get_or_create(name='manager')
# Create superuser
self.superuser = User.objects.create_superuser(username='super', password='password')
# Create normal users
self.user_none = User.objects.create_user(username='none', password='password')
self.config_none = UserConfig.objects.create(user=self.user_none)
self.user_edit = User.objects.create_user(username='edit', password='password')
self.config_edit = UserConfig.objects.create(user=self.user_edit, esign_can_edit=True)
self.user_manage = User.objects.create_user(username='manage', password='password')
self.config_manage = UserConfig.objects.create(user=self.user_manage, esign_can_manage_rcp=True)
self.user_validate = User.objects.create_user(username='validate', password='password')
self.config_validate = UserConfig.objects.create(user=self.user_validate, esign_can_validate_rcp=True)
# Users with roles
self.user_admin = User.objects.create_user(username='admin_user', password='password')
self.config_admin = UserConfig.objects.create(user=self.user_admin)
self.config_admin.roles.add(self.admin_role)
self.user_manager = User.objects.create_user(username='manager_user', password='password')
self.config_manager = UserConfig.objects.create(user=self.user_manager)
self.config_manager.roles.add(self.manager_role)
# User with Django built-in permission
self.user_django_perm = User.objects.create_user(username='django_perm', password='password')
self.config_django_perm = UserConfig.objects.create(user=self.user_django_perm)
add_perm = Permission.objects.filter(codename='add_signpole', content_type__app_label='sign').first()
if not add_perm:
add_perm = Permission.objects.filter(codename='add_signpole').first()
self.user_django_perm.user_permissions.add(add_perm)
def test_user_can_edit_esign_assets(self):
from sign.permissions import user_can_edit_esign_assets
# Superuser should be able to edit
self.assertTrue(user_can_edit_esign_assets(self.superuser))
# Normal user should not be able to edit
self.assertFalse(user_can_edit_esign_assets(self.user_none))
# User with esign_can_edit should be able to edit
self.assertTrue(user_can_edit_esign_assets(self.user_edit))
# User with django perm should be able to edit
# Refresh user instance to load permissions
user_django = User.objects.get(pk=self.user_django_perm.pk)
self.assertTrue(user_can_edit_esign_assets(user_django))
def test_user_can_manage_regulations(self):
from sign.permissions import user_can_manage_regulations
# Superuser should be able to manage
self.assertTrue(user_can_manage_regulations(self.superuser))
# Normal user should not be able to manage
self.assertFalse(user_can_manage_regulations(self.user_none))
# User with esign_can_manage_rcp should be able to manage
self.assertTrue(user_can_manage_regulations(self.user_manage))
# Admin and manager roles should be able to manage
self.assertTrue(user_can_manage_regulations(self.user_admin))
self.assertTrue(user_can_manage_regulations(self.user_manager))
def test_user_can_approve_regulation(self):
from sign.permissions import user_can_approve_regulation
# Superuser should be able to approve
self.assertTrue(user_can_approve_regulation(self.superuser))
# Normal user should not be able to approve
self.assertFalse(user_can_approve_regulation(self.user_none))
# User with esign_can_validate_rcp should be able to approve
self.assertTrue(user_can_approve_regulation(self.user_validate))
# Admin role should be able to approve
self.assertTrue(user_can_approve_regulation(self.user_admin))
# Manager role should NOT be able to approve (requires admin or validate permission)
self.assertFalse(user_can_approve_regulation(self.user_manager))
class ESignOrganizationRestrictionsTests(TestCase):
"""Tests for eSign Organization restrictions and validation."""
def setUp(self):
from sign.models import Organization, ComplementaryPoliceRegulation
self.client = Client()
# Create basic app views so we can access 'sign' view
self.sign_view = AppView.objects.create(
code='sign',
name_fr='Signalisation',
name_nl='Signalisatie',
icon='bi-signpost',
url_name='sign:index',
order=2
)
# Create Organizations
self.org_a = Organization.objects.create(
name_fr="Commune A (FR)",
name_nl="Commune A (NL)",
code="ORG_A",
organization_type="municipality"
)
self.org_b = Organization.objects.create(
name_fr="Commune B (FR)",
name_nl="Commune B (NL)",
code="ORG_B",
organization_type="municipality"
)
# Create users
# 1. Superuser
self.superuser = User.objects.create_superuser(
username='super_org',
password='testpass123'
)
self.config_super = UserConfig.objects.create(user=self.superuser)
self.config_super.accessible_views.add(self.sign_view)
# 2. Restricted User for Org A (can manage RCP)
self.user_a = User.objects.create_user(
username='user_a',
password='testpass123'
)
self.config_a = UserConfig.objects.create(
user=self.user_a,
esign_can_manage_rcp=True
)
self.config_a.accessible_views.add(self.sign_view)
self.config_a.esign_organizations.add(self.org_a)
# 3. Restricted User for Org B (can manage RCP)
self.user_b = User.objects.create_user(
username='user_b',
password='testpass123'
)
self.config_b = UserConfig.objects.create(
user=self.user_b,
esign_can_manage_rcp=True
)
self.config_b.accessible_views.add(self.sign_view)
self.config_b.esign_organizations.add(self.org_b)
# 4. User without any allowed organizations (Visitor / read-only)
self.user_visitor = User.objects.create_user(
username='user_visitor',
password='testpass123'
)
self.config_visitor = UserConfig.objects.create(
user=self.user_visitor
)
self.config_visitor.accessible_views.add(self.sign_view)
# Create some complementary police regulations (RCPs)
self.reg_a = ComplementaryPoliceRegulation.objects.create(
title="Regulation Org A",
reference="REF-A-001",
description="Test A",
organization=self.org_a,
status="in_preparation",
uploaded_by=self.user_a
)
self.reg_b = ComplementaryPoliceRegulation.objects.create(
title="Regulation Org B",
reference="REF-B-001",
description="Test B",
organization=self.org_b,
status="in_preparation",
uploaded_by=self.user_b
)
def test_asset_forms_organization_required(self):
"""Test that SignPoleForm and SignPanelForm require an organization."""
import json
from sign.forms import SignPoleForm, SignPanelForm
# 1. SignPoleForm validation
pole_data_no_org = {
'orientation': 90,
'lat': 50.85,
'lon': 4.35
}
form_pole = SignPoleForm(data=pole_data_no_org)
self.assertFalse(form_pole.is_valid())
self.assertIn('organization', form_pole.errors)
pole_data_with_org = pole_data_no_org.copy()
pole_data_with_org['organization'] = self.org_a.id
form_pole_ok = SignPoleForm(data=pole_data_with_org)
self.assertTrue(form_pole_ok.is_valid())
# 2. SignPanelForm validation
panel_data_no_org = {
'orientation': 180,
'support_type': 'pole',
'is_temporary': False,
'lat': 50.85,
'lon': 4.35
}
form_panel = SignPanelForm(data=panel_data_no_org)
self.assertFalse(form_panel.is_valid())
self.assertIn('organization', form_panel.errors)
panel_data_with_org = panel_data_no_org.copy()
panel_data_with_org['organization'] = self.org_a.id
form_panel_ok = SignPanelForm(data=panel_data_with_org)
self.assertTrue(form_panel_ok.is_valid())
def test_universal_asset_visibility(self):
"""Test that any logged-in user can view assets without being blocked by organization permissions."""
import json
self.client.force_login(self.user_visitor)
response = self.client.get(reverse('sign:index'))
self.assertEqual(response.status_code, 200)
response_poles = self.client.post(
reverse('sign:sign_poles_overview_geojson'),
data=json.dumps({}),
content_type='application/json'
)
self.assertEqual(response_poles.status_code, 200)
def test_superuser_bypass_restrictions(self):
"""Test that superusers can see and modify any regulation across all organizations."""
import json
self.client.force_login(self.superuser)
# 1. Superuser should see both regulations in list
response = self.client.get(reverse('sign:regulations_list'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.reg_a.title)
self.assertContains(response, self.reg_b.title)
# 2. Superuser should see both regulations in search
response_search = self.client.get(reverse('sign:regulations_search'), {'q': ''})
self.assertEqual(response_search.status_code, 200)
results = response_search.json().get('results', [])
self.assertEqual(len(results), 2)
# 3. Superuser can access detail of both
response_detail_a = self.client.get(reverse('sign:regulation_detail', kwargs={'regulation_id': self.reg_a.id}))
self.assertEqual(response_detail_a.status_code, 200)
response_detail_b = self.client.get(reverse('sign:regulation_detail', kwargs={'regulation_id': self.reg_b.id}))
self.assertEqual(response_detail_b.status_code, 200)
def test_restricted_user_visibility_and_access(self):
"""Test that a restricted user can only see and access regulations of their organization."""
import json
self.client.force_login(self.user_a)
# 1. List only contains reg_a
response_list = self.client.get(reverse('sign:regulations_list'))
self.assertEqual(response_list.status_code, 200)
self.assertContains(response_list, self.reg_a.title)
self.assertNotContains(response_list, self.reg_b.title)
# 2. Search only returns reg_a
response_search = self.client.get(reverse('sign:regulations_search'), {'q': ''})
self.assertEqual(response_search.status_code, 200)
results = response_search.json().get('results', [])
self.assertEqual(len(results), 1)
self.assertEqual(results[0]['id'], self.reg_a.id)
# 3. Detail view for reg_a works, but reg_b raises PermissionDenied (403)
response_detail_a = self.client.get(reverse('sign:regulation_detail', kwargs={'regulation_id': self.reg_a.id}))
self.assertEqual(response_detail_a.status_code, 200)
response_detail_b = self.client.get(reverse('sign:regulation_detail', kwargs={'regulation_id': self.reg_b.id}))
self.assertEqual(response_detail_b.status_code, 403)
# 4. Docx generation for reg_a works (or handles it, but not 403), but reg_b raises 403
response_docx_b = self.client.get(reverse('sign:regulation_generate_docx', kwargs={'regulation_id': self.reg_b.id}))
self.assertEqual(response_docx_b.status_code, 403)
def test_regulation_creation_organization_check(self):
"""Test that creating a regulation is limited to the user's allowed organizations."""
self.client.force_login(self.user_a)
# 1. Try creating a regulation for org_b (not allowed)
response_create_forbidden = self.client.post(
reverse('sign:regulation_create'),
{
'title': 'New Reg Forbidden',
'reference': 'REF-F-001',
'description': 'Description',
'organization': self.org_b.id
}
)
self.assertEqual(response_create_forbidden.status_code, 403)
# 2. Try creating a regulation for org_a (allowed)
response_create_ok = self.client.post(
reverse('sign:regulation_create'),
{
'title': 'New Reg Allowed',
'reference': 'REF-OK-001',
'description': 'Description',
'organization': self.org_a.id
}
)
self.assertEqual(response_create_ok.status_code, 200)
self.assertTrue(response_create_ok.json().get('success'))
def test_regulation_editing_organization_check(self):
"""Test that editing a regulation and updating its organization checks user permissions."""
self.client.force_login(self.user_a)
# 1. Edit reg_a: change title, keep org_a -> OK
response_edit_ok = self.client.post(
reverse('sign:regulation_edit', kwargs={'regulation_id': self.reg_a.id}),
{
'title': 'Updated Title',
'reference': self.reg_a.reference,
'description': self.reg_a.description,
'organization': self.org_a.id
}
)
self.assertEqual(response_edit_ok.status_code, 200)
self.assertTrue(response_edit_ok.json().get('success'))
# 2. Edit reg_a: try to change organization to org_b -> Forbidden
response_edit_forbidden = self.client.post(
reverse('sign:regulation_edit', kwargs={'regulation_id': self.reg_a.id}),
{
'title': 'Updated Title',
'reference': self.reg_a.reference,
'description': self.reg_a.description,
'organization': self.org_b.id
}
)
self.assertEqual(response_edit_forbidden.status_code, 403)
# 3. Try to edit reg_b (which belongs to org_b) -> Forbidden (user_can_edit_regulation checks organization)
response_edit_other_rcp = self.client.post(
reverse('sign:regulation_edit', kwargs={'regulation_id': self.reg_b.id}),
{
'title': 'Updated Title Other',
'reference': self.reg_b.reference,
'description': self.reg_b.description,
'organization': self.org_b.id
}
)
self.assertEqual(response_edit_other_rcp.status_code, 403)
class CopySignDataCommandTests(TestCase):
"""Tests for the copy_sign_data management command."""
def test_copy_sign_data_command(self):
from io import StringIO
from django.core.management import call_command
from django.contrib.gis.geos import Point
from common.models import Municipality, AdrStreet
from assets.models import AssetCategory
from assets.models.sign import (
SignPanelType as AssetsSignPanelType,
SignPoleModel as AssetsSignPoleModel,
SignPanelModel as AssetsSignPanelModel,
SignStreet as AssetsSignStreet,
SignPole as AssetsSignPole,
SignPanel as AssetsSignPanel,
ComplementaryPoliceRegulation as AssetsComplementaryPoliceRegulation,
)
from sign.models import (
SignPanelType,
SignPoleModel,
SignPanelModel,
SignStreet,
SignPole,
SignPanel,
ComplementaryPoliceRegulation,
Organization,
)
# 1. Setup Source Data in Assets
muni = Municipality.objects.create(fid="1000", name_fr="Bruxelles", name_nl="Brussel")
adr_street = AdrStreet.objects.create(
fid="STR-1",
name_fr="Rue Royale",
name_nl="Koningsstraat",
municipality=muni,
)
org = Organization.objects.create(
code="SPRB",
name_fr="Bruxelles Mobilité",
name_nl="Brussel Mobiliteit",
organization_type="region",
)
cat = AssetCategory.objects.create(code="sign_cat", name_fr="Sign Cat")
src_ptype = AssetsSignPanelType.objects.create(
code="C1",
name_fr="Sens interdit",
name_nl="Verboden richting",
default_width=60.0,
default_height=60.0,
)
src_pmodel = AssetsSignPoleModel.objects.create(
code="PM-1",
name_fr="Poteau Acier 3m",
category=cat,
color="Gris",
height=3.0,
)
src_panel_model = AssetsSignPanelModel.objects.create(
code="PANM-1",
name_fr="Modèle C1 60",
category=cat,
shape="rond",
)
src_street = AssetsSignStreet.objects.create(
code="STR-ROYALE",
name_fr="Rue Royale",
name_nl="Koningsstraat",
municipality=muni,
)
src_pole = AssetsSignPole.objects.create(
code="POL-001",
name_fr="Poteau 1",
street=src_street,
model=src_pmodel,
category=cat,
geom=Point(149000, 171000, srid=3812),
)
src_panel = AssetsSignPanel.objects.create(
code="PAN-001",
name_fr="Panneau C1",
street=src_street,
pole=src_pole,
model=src_panel_model,
signpanel_type=src_ptype,
category=cat,
geom=Point(149000, 171000, srid=3812),
)
src_reg = AssetsComplementaryPoliceRegulation.objects.create(
title="Règlement Test",
reference="RCP-TEST-001",
)
src_reg.panels.add(src_panel)
# 2. Execute command
out = StringIO()
call_command("copy_sign_data", stdout=out)
output = out.getvalue()
self.assertIn("Copy of sign data completed successfully", output)
# 3. Assertions on copied destination models in Sign app
dst_ptype = SignPanelType.objects.filter(code="C1").first()
self.assertIsNotNone(dst_ptype)
self.assertEqual(dst_ptype.default_width, 60.0)
dst_pole = SignPole.objects.filter(code="POL-001").first()
self.assertIsNotNone(dst_pole)
self.assertEqual(dst_pole.street, adr_street)
self.assertEqual(dst_pole.organization, org)
dst_panel = SignPanel.objects.filter(code="PAN-001").first()
self.assertIsNotNone(dst_panel)
self.assertEqual(dst_panel.pole, dst_pole)
self.assertEqual(dst_panel.street, adr_street)
self.assertEqual(dst_panel.organization, org)
dst_reg = ComplementaryPoliceRegulation.objects.filter(reference="RCP-TEST-001").first()
self.assertIsNotNone(dst_reg)
self.assertIn(dst_panel, dst_reg.panels.all())
# 4. Verify idempotence
out2 = StringIO()
call_command("copy_sign_data", stdout=out2)
self.assertEqual(SignPole.objects.filter(code="POL-001").count(), 1)
self.assertEqual(SignPanel.objects.filter(code="PAN-001").count(), 1)
class SignStreetAssignmentTests(TestCase):
"""Tests for resolving AdrStreet and avoiding SignStreet assignment errors."""
def setUp(self):
from django.contrib.gis.geos import Point, LineString, Polygon, MultiPolygon
from common.models import Municipality, AdrStreet, AppView, UserConfig, Thematic, UserThematics
from assets.models import AssetCategory
from sign.models import (
SignPoleModel,
SignPanelModel,
SignPanelType,
SignStreet,
Organization,
)
self.client = Client()
# Create sign view and thematic
self.sign_view, _ = AppView.objects.get_or_create(
code='sign',
defaults={
'name_fr': 'Signalisation',
'name_nl': 'Signalisatie',
'url_name': 'sign:index',
'order': 2,
}
)
self.sign_thematic, _ = Thematic.objects.get_or_create(
code='sign',
defaults={
'name_fr': 'Signalisation',
'name_nl': 'Signalisatie',
'color': '#0022ff',
}
)
# Admin user with full edit access
self.user = User.objects.create_superuser(
username='admin_sign',
password='testpass123',
email='admin@test.com'
)
self.config = UserConfig.objects.create(user=self.user)
self.config.accessible_views.add(self.sign_view)
UserThematics.objects.create(
user_config=self.config,
thematic=self.sign_thematic,
can_view_assets=True,
can_edit_assets=True,
)
# Organization
self.org = Organization.objects.create(
code="GAN",
name_fr="Ganshoren",
name_nl="Ganshoren",
organization_type="municipality",
)
# Category
self.category = AssetCategory.objects.create(
code="sign_cat_test",
name_fr="Catégorie Signalisation Test",
)
# Municipality
self.muni = Municipality.objects.create(
fid="1083",
name_fr="Ganshoren",
name_nl="Ganshoren",
)
# AdrStreet (the actual address street in common)
poly = Polygon(((145000, 172000), (145000, 172500), (145500, 172500), (145500, 172000), (145000, 172000)))
self.adr_street = AdrStreet.objects.create(
fid="ADR-GAN-001",
name_fr="Avenue Charles-Quint",
name_nl="Keizer Karellaan",
municipality=self.muni,
geom=MultiPolygon(poly, srid=3812),
centroid=Point(145250, 172250, srid=3812),
)
# SignStreet (the road network geometry layer)
self.sign_street = SignStreet.objects.create(
code="GAN_R20_001",
name_fr="Avenue Charles-Quint",
name_nl="Keizer Karellaan",
municipality=self.muni,
geom=MultiPolygon(poly, srid=3812),
)
# Pole and panel models
self.pole_model = SignPoleModel.objects.create(
code="PM-TEST",
name_fr="Poteau Standard Test",
category=self.category,
height=3.0,
)
self.panel_model = SignPanelModel.objects.create(
code="PANM-TEST",
name_fr="Modèle C1 Test",
category=self.category,
shape="rond",
)
self.panel_type = SignPanelType.objects.create(
code="C1",
name_fr="Sens interdit",
name_nl="Verboden richting",
default_width=60.0,
default_height=60.0,
)
def test_get_closest_adr_street(self):
from sign.views import get_closest_adr_street
from django.contrib.gis.geos import Point
# Test resolving using SignStreet directly
adr = get_closest_adr_street(sign_street=self.sign_street)
self.assertEqual(adr, self.adr_street)
# Test resolving using spatial coords in Lambert 2008 (3812)
pt_3812 = Point(145250, 172250, srid=3812)
adr_pt = get_closest_adr_street(point=pt_3812)
self.assertEqual(adr_pt, self.adr_street)
def test_sign_pole_create_assigns_adr_street(self):
"""Test adding a pole at Charles-Quint does not crash with SignStreet assignment error."""
from django.contrib.gis.geos import Point
self.client.force_login(self.user)
pt = Point(145250, 172250, srid=3812)
pt.transform(4326)
response = self.client.post(reverse('sign:sign_pole_create'), {
'model': self.pole_model.id,
'organization': self.org.id,
'orientation': 0,
'lat': pt.y,
'lon': pt.x,
})
self.assertEqual(response.status_code, 200, response.content)
data = response.json()
self.assertTrue(data.get('success'))
from sign.models import SignPole
pole = SignPole.objects.get(id=data['id'])
# Must be an instance of AdrStreet, not SignStreet
from common.models import AdrStreet
self.assertIsInstance(pole.street, AdrStreet)
self.assertEqual(pole.street, self.adr_street)
def test_sign_panel_create_assigns_adr_street(self):
"""Test adding a panel at Charles-Quint assigns AdrStreet correctly."""
from django.contrib.gis.geos import Point
self.client.force_login(self.user)
pt = Point(145250, 172250, srid=3812)
pt.transform(4326)
response = self.client.post(reverse('sign:sign_panel_create'), {
'signpanel_type': self.panel_type.id,
'model': self.panel_model.id,
'organization': self.org.id,
'orientation': 0,
'support_type': 'pole',
'lat': pt.y,
'lon': pt.x,
})
self.assertEqual(response.status_code, 200, response.content)
data = response.json()
self.assertTrue(data.get('success'))
from sign.models import SignPanel
panel = SignPanel.objects.get(id=data['id'])
from common.models import AdrStreet
self.assertIsInstance(panel.street, AdrStreet)
self.assertEqual(panel.street, self.adr_street)
def test_sign_panel_create_attached_to_pole(self):
"""Test adding a panel attached to an existing pole inherits pole's street and sets vertical order."""
from django.contrib.gis.geos import Point
from sign.models import SignPole, SignPanel
from common.models import AdrStreet
pt = Point(145250, 172250, srid=3812)
pole = SignPole.objects.create(
street=self.adr_street,
model=self.pole_model,
organization=self.org,
geom=pt,
lon=4.31,
lat=50.86,
)
self.client.force_login(self.user)
response = self.client.post(reverse('sign:sign_panel_create'), {
'pole_id': pole.id,
'signpanel_type': self.panel_type.id,
'model': self.panel_model.id,
'organization': self.org.id,
'orientation': 0,
'support_type': 'pole',
})
self.assertEqual(response.status_code, 200, response.content)
data = response.json()
self.assertTrue(data.get('success'))
panel = SignPanel.objects.get(id=data['id'])
self.assertEqual(panel.pole, pole)
self.assertEqual(panel.street, self.adr_street)
self.assertIsInstance(panel.street, AdrStreet)
self.assertEqual(panel.vertical_order, 1)
class SupportRepresentationAndModelTests(TestCase):
def setUp(self):
from common.models import Municipality, AdrStreet, UserConfig, AppView
from sign.models import SignPanelType, SignPoleModel, SignPanelModel, SignPole, SignPanel, Organization
from assets.models import TrafficLightIntersection, TrafficLightPoleModel, TrafficLightPole
from django.contrib.auth import get_user_model
from django.contrib.gis.geos import Point
User = get_user_model()
self.user = User.objects.create_user(username="support_user", password="password", is_superuser=True)
sign_view, _ = AppView.objects.get_or_create(
code="sign",
defaults={
"name_fr": "Signalisation",
"name_nl": "Signalisatie",
"url_name": "sign:index",
}
)
self.config = UserConfig.objects.create(user=self.user, esign_can_edit=True)
self.config.accessible_views.add(sign_view)
self.muni = Municipality.objects.create(fid="1080", name_fr="Molenbeek", name_nl="Molenbeek")
self.adr_street = AdrStreet.objects.create(
fid="STR-100",
name_fr="Rue de Test",
name_nl="Teststraat",
municipality=self.muni,
)
self.org = Organization.objects.create(
code="TEST_ORG",
name_fr="Test Organisation",
name_nl="Test Organisatie",
organization_type="region",
)
self.pole_model = SignPoleModel.objects.create(
code="POL-MOD-1",
name_fr="Modèle Mât 4m",
)
self.panel_model = SignPanelModel.objects.create(
code="PAN-MOD-1",
name_fr="Modèle Type B",
)
self.panel_type = SignPanelType.objects.create(
code="B1",
name_fr="Céder le passage",
name_nl="Voorrang verlenen",
default_width=70.0,
default_height=70.0,
)
self.tl_intersection = TrafficLightIntersection.objects.create(
code="TL-INT-01",
name_fr="Carrefour Test",
)
self.tl_pole_model = TrafficLightPoleModel.objects.create(
code="TL-PM-01",
name_fr="Poteau Tricolore Acier 6m",
)
self.tl_pole = TrafficLightPole.objects.create(
code="TL-P-01",
intersection=self.tl_intersection,
model=self.tl_pole_model,
geom=Point(145000, 172000, srid=3812),
lon=4.32,
lat=50.85,
)
def test_signpole_support_type(self):
"""Test that SignPole supports support_type choices and defaults."""
from sign.models import SignPole
pole = SignPole.objects.create(
code="P-TEST-01",
street=self.adr_street,
organization=self.org,
support_type="trafficlightpole",
model=self.pole_model,
)
self.assertEqual(pole.support_type, "trafficlightpole")
self.assertEqual(pole.support_type_display, "Poteau de signalisation lumineuse")
def test_signpanel_effective_support_and_model(self):
"""Test effective_support_type and support_model_display on SignPanel."""
from sign.models import SignPole, SignPanel
# 1. Panel attached to pole with support_type
pole = SignPole.objects.create(
code="P-TEST-02",
street=self.adr_street,
organization=self.org,
support_type="lightingpole",
model=self.pole_model,
)
panel1 = SignPanel.objects.create(
code="PANEL-01",
pole=pole,
model=self.panel_model,
signpanel_type=self.panel_type,
organization=self.org,
)
self.assertEqual(panel1.effective_support_type, "lightingpole")
self.assertEqual(panel1.effective_support_type_display, "Poteau d'éclairage")
self.assertEqual(panel1.support_model_display, "Modèle Mât 4m")
# 2. Panel attached directly to trafficlight_pole
panel2 = SignPanel.objects.create(
code="PANEL-02",
trafficlight_pole=self.tl_pole,
model=self.panel_model,
signpanel_type=self.panel_type,
organization=self.org,
)
self.assertEqual(panel2.effective_support_type, "trafficlightpole")
self.assertEqual(panel2.effective_support_type_display, "Poteau de signalisation lumineuse")
self.assertEqual(panel2.support_model_display, "Poteau Tricolore Acier 6m")
# 3. Standalone panel with support_type and custom support_model
panel3 = SignPanel.objects.create(
code="PANEL-03",
support_type="trafficlightpole",
support_model="Feu Swarco Carrefour",
model=self.panel_model,
signpanel_type=self.panel_type,
organization=self.org,
)
self.assertEqual(panel3.effective_support_type, "trafficlightpole")
self.assertEqual(panel3.effective_support_type_display, "Poteau de signalisation lumineuse")
self.assertEqual(panel3.support_model_display, "Feu Swarco Carrefour")
def test_sign_assets_geojson_includes_support_representation(self):
"""Test that sign_assets_geojson includes support_type, support_model and standalone support features."""
from django.contrib.gis.geos import Point
from sign.models import SignPanel
import json
pt = Point(145000, 172000, srid=3812)
panel = SignPanel.objects.create(
code="PANEL-TL-01",
street=self.adr_street,
support_type="trafficlightpole",
support_model="Feu Siemens 3 Feux",
model=self.panel_model,
signpanel_type=self.panel_type,
organization=self.org,
geom=pt,
lon=4.32,
lat=50.85,
)
self.client.force_login(self.user)
response = self.client.post(
reverse('sign:sign_assets_geojson'),
data=json.dumps({'organization': [self.org.id]}),
content_type='application/json'
)
self.assertEqual(response.status_code, 200)
data = response.json()
features = data.get('features', [])
self.assertTrue(len(features) >= 2)
panel_feat = next((f for f in features if f['properties'].get('asset_type') == 'signpanel'), None)
self.assertIsNotNone(panel_feat)
self.assertEqual(panel_feat['properties']['support_type'], 'trafficlightpole')
self.assertEqual(panel_feat['properties']['support_model'], 'Feu Siemens 3 Feux')
support_feat = next((f for f in features if f['properties'].get('asset_type') == 'signpole'), None)
self.assertIsNotNone(support_feat)
self.assertEqual(support_feat['properties']['support_type'], 'trafficlightpole')
self.assertEqual(support_feat['properties']['support_model'], 'Feu Siemens 3 Feux')
self.assertTrue(support_feat['properties'].get('is_standalone_support'))
def test_sign_poles_overview_geojson_includes_standalone_trafficlight(self):
"""Test sign_poles_overview_geojson includes standalone panel supports."""
from django.contrib.gis.geos import Point
from sign.models import SignPanel
import json
pt = Point(145000, 172000, srid=3812)
SignPanel.objects.create(
code="PANEL-TL-OV",
street=self.adr_street,
support_type="trafficlightpole",
support_model="Feu Carrefour 1",
organization=self.org,
geom=pt,
lon=4.32,
lat=50.85,
)
self.client.force_login(self.user)
response = self.client.post(
reverse('sign:sign_poles_overview_geojson'),
data=json.dumps({'organization': [self.org.id]}),
content_type='application/json'
)
self.assertEqual(response.status_code, 200)
data = response.json()
features = data.get('features', [])
tl_support = next((f for f in features if f['properties'].get('support_type') == 'trafficlightpole'), None)
self.assertIsNotNone(tl_support)
self.assertEqual(tl_support['properties']['support_model'], 'Feu Carrefour 1')
self.assertTrue(tl_support['properties'].get('is_standalone_support'))
def test_sign_assets_geojson_excludes_inactive_statuses_by_default(self):
"""Verify that sign_assets_geojson excludes removed/archived panels by default, but includes them when filtered."""
from django.contrib.gis.geos import Point
from sign.models import SignPanel
import json
pt = Point(145000, 172000, srid=3812)
active_panel = SignPanel.objects.create(
code="PANEL-ACTIVE-01",
street=self.adr_street,
model=self.panel_model,
signpanel_type=self.panel_type,
organization=self.org,
geom=pt,
lon=4.32,
lat=50.85,
status="active",
)
archived_panel = SignPanel.objects.create(
code="PANEL-ARCHIVED-01",
street=self.adr_street,
model=self.panel_model,
signpanel_type=self.panel_type,
organization=self.org,
geom=pt,
lon=4.32,
lat=50.85,
status="archived",
)
self.client.force_login(self.user)
# Default query (no status specified): only active panel returned
resp = self.client.post(
reverse('sign:sign_assets_geojson'),
data=json.dumps({'organization': [self.org.id]}),
content_type='application/json'
)
self.assertEqual(resp.status_code, 200)
features = resp.json().get('features', [])
codes = [f['properties']['code'] for f in features if f['properties'].get('asset_type') == 'signpanel']
self.assertIn("PANEL-ACTIVE-01", codes)
self.assertNotIn("PANEL-ARCHIVED-01", codes)
# Explicit query for archived status: archived panel returned
resp_archived = self.client.post(
reverse('sign:sign_assets_geojson'),
data=json.dumps({'organization': [self.org.id], 'asset_status': ['archived']}),
content_type='application/json'
)
self.assertEqual(resp_archived.status_code, 200)
features_archived = resp_archived.json().get('features', [])
codes_archived = [f['properties']['code'] for f in features_archived if f['properties'].get('asset_type') == 'signpanel']
self.assertIn("PANEL-ARCHIVED-01", codes_archived)
self.assertNotIn("PANEL-ACTIVE-01", codes_archived)
def test_sign_assets_geojson_suppresses_orphan_duplicate_at_pole(self):
"""Verify that sign_assets_geojson suppresses unlinked orphan panels that duplicate an attached panel on a pole."""
from django.contrib.gis.geos import Point
from sign.models import SignPole, SignPanel
import json
pt = Point(145000, 172000, srid=3812)
pole = SignPole.objects.create(
code="POLE-DUP-01",
street=self.adr_street,
organization=self.org,
model=self.pole_model,
geom=pt,
lon=4.35,
lat=50.85,
)
attached_panel = SignPanel.objects.create(
code="PANEL-ATT-01",
street=self.adr_street,
pole=pole,
model=self.panel_model,
signpanel_type=self.panel_type,
organization=self.org,
geom=pt,
lon=4.35,
lat=50.85,
status="active",
)
# Duplicate orphan panel at exact same coordinates with same signpanel_type
orphan_duplicate = SignPanel.objects.create(
code="PANEL-ORPH-01",
street=self.adr_street,
pole=None,
model=self.panel_model,
signpanel_type=self.panel_type,
organization=self.org,
geom=pt,
lon=4.35,
lat=50.85,
status="active",
)
self.client.force_login(self.user)
resp = self.client.post(
reverse('sign:sign_assets_geojson'),
data=json.dumps({'organization': [self.org.id]}),
content_type='application/json'
)
self.assertEqual(resp.status_code, 200)
features = resp.json().get('features', [])
panel_features = [f for f in features if f['properties'].get('asset_type') == 'signpanel']
panel_codes = [f['properties']['code'] for f in panel_features]
# The attached panel is kept, the redundant orphan is suppressed
self.assertIn("PANEL-ATT-01", panel_codes)
self.assertNotIn("PANEL-ORPH-01", panel_codes)
def test_check_duplicate_sign_panels_command(self):
"""Verify that check_duplicate_sign_panels command detects and fixes duplicate orphan panels."""
from django.contrib.gis.geos import Point
from django.core.management import call_command
from io import StringIO
from sign.models import SignPole, SignPanel
pt = Point(145000, 172000, srid=3812)
pole = SignPole.objects.create(
code="POLE-CMD-01",
street=self.adr_street,
organization=self.org,
model=self.pole_model,
geom=pt,
lon=4.36,
lat=50.86,
)
SignPanel.objects.create(
code="PANEL-CMD-ATT",
street=self.adr_street,
pole=pole,
model=self.panel_model,
signpanel_type=self.panel_type,
organization=self.org,
geom=pt,
lon=4.36,
lat=50.86,
status="active",
)
orphan_panel = SignPanel.objects.create(
code="PANEL-CMD-ORPH",
street=self.adr_street,
pole=None,
model=self.panel_model,
signpanel_type=self.panel_type,
organization=self.org,
geom=pt,
lon=4.36,
lat=50.86,
status="active",
)
# 1. Dry run
out = StringIO()
call_command("check_duplicate_sign_panels", org_id=self.org.id, stdout=out)
output = out.getvalue()
self.assertIn("Doublons orphelins redondants détectés : 1", output)
orphan_panel.refresh_from_db()
self.assertEqual(orphan_panel.status, "active")
# 2. Fix mode (archive)
out_fix = StringIO()
call_command("check_duplicate_sign_panels", org_id=self.org.id, fix=True, action="archive", stdout=out_fix)
fix_output = out_fix.getvalue()
self.assertIn("1 doublon(s) orphelin(s) archives", fix_output.replace("é", "e"))
orphan_panel.refresh_from_db()
self.assertEqual(orphan_panel.status, "archived")
class SignPanelCategoryFilterTest(TestCase):
"""Tests for panel type category grouping and filter controls."""
def setUp(self):
from django.contrib.auth.models import User
from common.models import UserConfig, AppView
from sign.models import SignPanelType
self.user = User.objects.create_superuser(username="filter_test_user", password="password123", email="filter@test.com")
self.sign_view, _ = AppView.objects.get_or_create(
code='sign',
defaults={
'name_fr': 'Signalisation',
'name_nl': 'Signalisatie',
'url_name': 'sign:index',
}
)
self.user_config = UserConfig.objects.create(user=self.user)
self.user_config.accessible_views.add(self.sign_view)
# Create panel types across different categories
self.type_danger_1 = SignPanelType.objects.create(code="A1a", name_fr="Virage à droite", name_nl="Bocht rechts")
self.type_danger_2 = SignPanelType.objects.create(code="A1b", name_fr="Virage à gauche", name_nl="Bocht links")
self.type_priority = SignPanelType.objects.create(code="B1", name_fr="Céder le passage", name_nl="Voorrang verlenen")
self.type_prohibition = SignPanelType.objects.create(code="C1", name_fr="Sens interdit", name_nl="Verboden richting")
def test_get_grouped_panel_types(self):
"""Verify get_grouped_panel_types categorizes panel types according to Belgian traffic code."""
from sign.views import get_grouped_panel_types
grouped, groups = get_grouped_panel_types()
# Check group labels exist
group_keys = [g[0] for g in groups]
self.assertIn("danger", group_keys)
self.assertIn("priority", group_keys)
self.assertIn("prohibition", group_keys)
# Check danger group contains A1a and A1b
danger_codes = [p['code'] for p in grouped['danger']]
self.assertIn("A1a", danger_codes)
self.assertIn("A1b", danger_codes)
# Check priority contains B1
priority_codes = [p['code'] for p in grouped['priority']]
self.assertIn("B1", priority_codes)
def test_index_view_renders_category_filter_controls(self):
"""Verify the sign index view renders the category select-all checkboxes and quick-action toolbar."""
self.client.force_login(self.user)
response = self.client.get(reverse('sign:index'))
self.assertEqual(response.status_code, 200)
content = response.content.decode('utf-8')
# Verify accordion item with group key
self.assertIn('data-group-key="danger"', content)
# Verify group select-all checkbox
self.assertIn('class="form-check-input group-select-all m-0"', content)
self.assertIn('id="check-group-danger"', content)
self.assertIn('data-group="danger"', content)
# Verify count badge
self.assertIn('id="badge-danger"', content)
# Verify toolbar buttons: Tout cocher / Tout décocher
self.assertIn('select-all-btn', content)
self.assertIn('deselect-all-btn', content)
# Verify data-group attribute on panel item
self.assertIn('data-group="danger"', content)