Compare commits

...

138 commits
1.2.0 ... main

Author SHA1 Message Date
f19d09ce5f fix(controls): move new control button next to excel button in toolbar 2026-10-10 20:16:06 +02:00
f10377648a style(controls): darken table row separator borders for better readability 2026-10-10 20:02:17 +02:00
2d221c543c fix(interventions): cancel obsolete and >12m expired tree prescriptions 2026-10-10 16:06:14 +02:00
5ce7179c19 fix(assets): ensure asset geometry layers remain above orthophoto basemap on detail maps 2026-10-10 15:58:12 +02:00
34e4da64a8 feat(assets): add dedicated icon for green surfaces distinct from trees 2026-10-10 15:43:54 +02:00
3037241762 style(controls): increase categories min-width and align project icon with first word 2026-10-10 15:13:35 +02:00
fc8cdb579f refactor(controls): replace bootstrap-table with custom table and column selector 2026-10-10 15:06:26 +02:00
a6378eee90 feat(contracts): add column sorting by code and dates to contracts list 2026-10-10 14:47:28 +02:00
0732f89274 perf(contracts): optimize queries, add server-side pagination, search, thematic/contract filters and redesign UI 2026-10-10 14:39:40 +02:00
34e9743cc3 feat(nature): cancel obsolete tree interventions and compute theoretical next inspection
- Automatically cancel pending interventions whose deadline is earlier than the tree's last diagnostic
- Record cancellation reason in InterventionTimeLine
- Compute theoretical next inspection date as last_phytosanitary_date + pruning_frequency_years
- Add unit test coverage for obsolete intervention cancellation and theoretical inspection calculation
2026-10-10 14:29:57 +02:00
7761f22b77 style(ui): align table and filter modal styles in observations and inspections with assets 2026-10-10 14:16:59 +02:00
af411b2105 feat(nature): create timeline planning events and display due date on asset interventions
- Create InterventionPlanificationTimeLine events (expected_begin and expected_end)
  during Fulcrum tree interventions import and maintenance dates synchronization
- Display planned/completion date instead of creation_time in asset_interventions.html
- Add unit test assertions for planning timeline events
2026-10-10 14:09:15 +02:00
74ef7d3a34 feat: add maintenance dates synchronization and options to tree import command 2026-10-10 13:50:02 +02:00
9af4f99f17 fix(assets): rediriger le fil d'Ariane et retour carte des espaces verts vers la carte unifiée 2026-10-10 13:49:21 +02:00
e201a928b8 feat: add management command to spatially attach trees to green sections with test coverage 2026-10-10 13:37:37 +02:00
958d8dbc65 feat: add management command to import Fulcrum tree inventories and interventions 2026-10-10 12:17:57 +02:00
b953b1f984 feat(assets): add automatic calculation of tree ecological and patrimonial eco-services 2026-10-10 08:34:50 +02:00
e51f698030 feat(assets): allow editing general note field on asset details 2026-10-09 23:22:09 +02:00
15403b8604 fix(inspections): scope viewable inspections and assets by contract and municipality 2026-10-09 22:59:10 +02:00
61ce1d3a4d fix(mobile): enforce absolute positioning on map markers to prevent vertical layout drift 2026-10-09 22:53:42 +02:00
fbd1bc452f fix(mobile): resolve nature tree marker symbology and attribute serialization
- Serialize management_type, managing_authority, is_remarkable, is_dead, vitality, and botanical names in mobile_assets_search
- Allow regional (Bruxelles Mobilité), remarkable, and dead trees to display differentiated icons and colors on mobile map
- Fallback tree display name to vernacular/scientific name
- Update asset_symbology.js cachebuster version in mobile template
- Add unit test coverage for nature tree search serialization
2026-10-09 19:52:11 +02:00
45bc88d1a3 refactor(assets): remove specific naturegreensurface template in favor of detail_generic 2026-10-09 19:28:56 +02:00
b813de3984 fix: update English translation for Surface verte to Green surface 2026-10-09 19:16:19 +02:00
Karl Determe
da9e5c1cb8 feat: add ITS asset models and views for location and bollard management 2026-10-09 15:04:41 +02:00
Karl Determe
cd3f3999cb Merge branch 'main' of https://git.valogeo.be/Valogeo/loko 2026-10-09 14:33:49 +02:00
Karl Determe
3c8dcedccc fix: remove empty deleted file from repository 2026-10-09 14:33:38 +02:00
095161872d feat: add dead tree symbology, icon, and map layer support for nature assets 2026-10-09 14:31:17 +02:00
e65c402395 feat: add basemap preference persistence and layer switcher control support 2026-10-09 14:16:12 +02:00
9bdd68bea2 feat: add regional and remarkable nature asset symbology icons and matching logic 2026-10-09 14:04:08 +02:00
f271d65be2 fix(assets): dynamic nature management_type filter based on visible assets and scope nature endpoints by user accessible municipalities 2026-10-09 13:50:49 +02:00
587f4abf8a feat(nature): distinction arbres régionaux, filtre gestionnaire, tableau compact et essences botaniques 2026-10-09 13:32:46 +02:00
Karl Determe
4823d55f08 Merge branch 'main' of https://git.valogeo.be/Valogeo/loko 2026-10-09 13:17:59 +02:00
Karl Determe
cebe496617 feat: add seed command and fixture for Schuman bollards and CCTV cameras 2026-10-09 13:17:49 +02:00
8afa68e066 feat: add management type and authority fields with visualization and filtering for nature trees 2026-10-09 13:09:58 +02:00
Karl Determe
80c54a083d feat: add unified index, map layers, and ITS asset management views and symbology 2026-10-08 16:45:33 +02:00
Karl Determe
7d5718dec2 feat: add admin, models, views, and templates for ITS assets 2026-10-08 14:29:19 +02:00
bb8d6f98c0 style(home): unify quick action buttons size and center content 2026-10-07 16:08:05 +02:00
dd12e2fde1 feat(home): replace history shortcut with asset search in viewer action bar 2026-10-07 15:58:51 +02:00
875efc67f4 feat(home): add user_history widget for viewers and adjust quick action shortcuts 2026-10-07 15:54:57 +02:00
51e6680763 fix(interventions,contracts): enforce can_view_intervention on detail views and allow contract posts access for global viewers 2026-10-07 14:15:53 +02:00
ea02b12ea5 feat(stock): add product search by code and label in purchase order PDF import 2026-10-07 11:58:17 +02:00
b307791b90 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
2026-10-07 11:57:57 +02:00
9a85696d90 fix(stock): escapejs translated option text in batch location selects 2026-10-07 11:40:57 +02:00
499ffe824e refactor: redesign calendar notes and documents view with chronological sequencing blocks 2026-10-07 10:45:37 +02:00
2ef730508f feat(calendar): intégrer les notes et documents dans la modale d'intervention 2026-10-07 10:26:08 +02:00
Karl Determe
775151c72d feat: add notification services and observation views implementation 2026-10-06 16:22:23 +02:00
Karl Determe
e27a979a53 feat: add views for stock management and observations applications 2026-10-06 15:56:21 +02:00
Karl Determe
5825eac998 feat: add index, detail, and form HTML templates for management zones 2026-10-06 15:46:37 +02:00
Karl Determe
e87fe1531d feat: add management zone form template and corresponding unit tests 2026-10-06 15:23:50 +02:00
Karl Determe
0c1ae3163d feat: add observation detail template with interactive map, photos, and discussion features 2026-10-06 11:31:19 +02:00
Karl Determe
496b6d9c8b feat: implement observations module with views, models, UI templates, translations, and migrations 2026-10-06 10:04:54 +02:00
Karl Determe
58c1ccd531 feat: add observation management features with map integration, views, and migrations 2026-10-06 10:04:40 +02:00
384a2b29c3 fix(common): support list type_filter and fix translation function shadowing in assets preferences 2026-10-05 18:22:47 +02:00
288921b4aa fix(assets): declare mergedManagementZonesGeojson to prevent ReferenceError in unified view 2026-10-05 16:38:43 +02:00
3e35c98a46 git add loko/assets/static/assets/unified_index.js \
loko/assets/static/assets/unified_map_layers.js \
        loko/assets/templates/assets/unified_index.html \
        loko/assets/tests_management_zones.py \
        loko/assets/views/core.py \
        loko/assets/views/management_zones.py

git commit -m "feat(assets): integrate management zones into unified assets view

- Add management zones layers and table row support in unified index
- Replace element type radio filters with 3 checkboxes (assets, locations, management zones)
- Add 3rd card in unified add modal for management zone creation
- Include centroid coordinates, area, and unified properties in management zones GeoJSON
- Add comprehensive integration test suite"
2026-10-05 16:00:12 +02:00
b5b0338e09 feat(assets): add management zones CRUD interfaces and permissions
- Add can_edit_management_zones permission to UserThematics model and admin
- Implement ManagementZoneForm with interactive WGS84 polygon capture and SRID 3812 reprojection
- Add views and routes for listing, creating, editing, detailing, and deleting management zones
- Support document attachments (ManagementZoneDocument) and explicit asset linkages
- Add MapLibre polygon drawing tool in zone form and preview map in detail view
- Integrate management zones shortcuts in nature assets map overlay and index
- Add comprehensive unit tests for permissions, forms, and CRUD operations
2026-10-05 15:35:33 +02:00
d36649f3bf feat(assets): add generic ManagementZone model, admin, geojson API and map overlay 2026-10-05 15:12:16 +02:00
8807531eb3 feat(contracts): add contractual lot perimeters overlay on nature assets map 2026-10-05 14:35:31 +02:00
f81ca93590 feat: add geographic polygon and color fields to Contract model and admin 2026-10-05 14:19:07 +02:00
69f5e65495 feat(assets): add basemap and orthophoto switcher to nature location detail map
- Include LayerSwitcherControl on the nature location detail map.
- Support standard vector, aerial orthophotos, and regional basemaps from window.MAP_BACKGROUNDS.
- Ensure proper z-index layer ordering so nature polygons, trees, and equipments remain above raster basemaps.
- Update unit tests to verify layer switcher integration.
2026-10-05 14:04:02 +02:00
11e1674f73 feat: implement nature location detail view with asset summaries, KPIs, and unit tests 2026-10-05 13:44:18 +02:00
f324eb00bd feat(assets): color-code asset types by technical domain for enhanced visual scanning 2026-10-05 11:48:44 +02:00
10e9f1de50 feat(assets): display assets directly in building tree and add collapse/expand all controls 2026-10-05 11:25:55 +02:00
ab1cfb6ab2 fix(assets): remove duplicate closing div breaking page layout in publicbuilding_detail 2026-10-05 10:59:21 +02:00
6802d1e15f fix(assets): fix js coord syntax error and add interactive multi-floor plans viewer 2026-10-05 10:38:35 +02:00
512f6786e3 refactor: move team daily view helper functions and state variables to file scope 2026-10-05 09:56:25 +02:00
63c4a8e539 git add loko/assets/models/buildings.py \
loko/assets/forms.py \
        loko/assets/admin.py \
        loko/assets/templates/assets/buildings/ \
        loko/assets/tests.py \
        loko/assets/migrations/0028_buildingasset_contractor_name_and_more.py

git commit -m "feat(assets): extend building locations and technical equipment models

- Add comprehensive building locations (stairwells, technical rooms, boiler rooms, offices, etc.)
- Add building physical & technical fields (room numbers, floor types, heated status, dimensions)
- Add complete building envelope & technical equipment types (roofs, facades, windows, doors, boilers, heat pumps, etc.)
- Enrich admin, forms, trees, and detail templates with icons, macro-lots, and maintenance tracking
- Add migration 0028 and unit test coverage"
2026-10-05 09:49:08 +02:00
93f3a49983 fix(calendar): fix canEditThisTeam and isPastDay ReferenceError in renderTeamsDailyGrid 2026-10-05 09:48:53 +02:00
6ee0aa470b feat(calendar): enable drag and drop for team members in daily view
- Render team members as individual draggable chips in daily teams calendar view
- Decouple click (modal inspection) from drag and drop (team/date reassignment)
- Add multi-team conflict confirmation modal on drop
- Maintain permission checks and past date protection
2026-10-05 09:42:10 +02:00
03fa3adaf6 feat: suggest product default locations when parsing purchase orders from PDF 2026-10-05 09:24:22 +02:00
1d212194ad feat: add external destination badges and indigo styling to stock and production order templates 2026-10-05 09:09:43 +02:00
9764b525bb refactor: remove local OCR references and escape translated messages in purchase order scripts 2026-10-01 16:07:32 +02:00
4bb53fb71c feat(mobile): add quote photo/pdf OCR import interface in mobile stock
- Add mobile-optimized purchase order from quote view and template
- Support taking multiple page photos directly from smartphone camera
- Display responsive article cards with catalog product matching
- Provide sticky summary with live totals and confirmation CTA
- Link from mobile purchases list and add unit tests
2026-10-01 15:43:52 +02:00
53d579009a feat(stock): support multi-page photo uploads for quote OCR processing
- Allow uploading multiple quote photos at once via multi-file input
- Process photos sequentially with deskewing and OCR line clustering
- Aggregate articles and metadata across all pages seamlessly
- Attach all uploaded photos to the generated purchase order
- Add unit tests for multi-image quotes and multiple document attachments
2026-10-01 15:28:08 +02:00
4010dfb138 fix(stock): prevent merged quote items via auto-deskew, fixed baseline clustering and item splitting
- Add auto-deskewing for tilted smartphone photos and document scans
- Anchor line clustering to initial baseline to avoid vertical centroid drift
- Normalize OCR numerical artifacts (semicolon decimals, glued VAT percentages)
- Purge catalog URLs from product names to improve catalog matching
- Implement fused item splitter to rescue embedded articles from descriptions
- Add regression tests for item splitting and OCR normalization
2026-10-01 15:12:56 +02:00
32cb154815 feat(stock): support photo/scan quotes via local OCR and propose supplier creation
- Enable image uploads (.png, .jpg, .jpeg, .webp, .tiff) alongside PDFs
- Process photos and scans with local RapidOCR and adaptive bounding box clustering
- Propose new supplier creation with pre-filled fields when quote supplier is not found
- Retain manual catalog product association safeguard
- Add automated unit and integration tests
2026-10-01 14:46:09 +02:00
358fb23d7e refactor(stock): restrict PDF purchase order to existing products and loosen matching similarity
- Replace auto-creation of new products with strict association to existing stock items
- Add dimension normalization and token-based similarity matching (rapidfuzz/difflib)
- Provide ranked candidate suggestions in product dropdown during quote review
- Enforce validation preventing purchase order creation with unassociated products
2026-10-01 14:23:01 +02:00
391966da07 refactor: make PDF parser dependencies optional and lazy-load parser 2026-10-01 13:57:12 +02:00
de6828221b feat: add product validation workflow, supervisor notifications, and PDF purchase order parser 2026-10-01 13:51:27 +02:00
65624c343a style: improve photo carousel modal layout and responsiveness 2026-10-01 10:50:09 +02:00
3920648000 fix(interventions): assign controller only when intervention status changes to finished 2026-10-01 09:53:06 +02:00
8df84704dc fix(interventions): resolve UnboundLocalError contracts_qs for external managers 2026-10-01 09:29:54 +02:00
927c049d02 git add loko/contracts/ loko/interventions/
git commit -m "feat(interventions): configure team maintain types for control auto-assignment and remove structures limitation"
2026-09-30 19:20:59 +02:00
41679d82e9 perf(contracts): optimize ContractAdmin queries on add and change views
- Avoid loading all database ContractPost instances when adding a new contract
- Use select_related('contract') on ContractPost to prevent N+1 queries in __str__
- Only include ContractOperationTemplatePostInline on saved contracts
- Add prefetch_related('teams') for CompanyMember in pause reason assignments
- Optimize ContractAdmin changelist queryset with select_related and prefetch_related
2026-09-30 19:04:39 +02:00
6782959253 feat(home): filter controller shortcuts link by assigned controller and remove sub-counters
- Remove 'to_be_corrected' and 'validated' sub-counters from controller shortcuts widget
- Include assigned_controller parameter in widget URL using the new role-person filter
- Update ControllerShortcutsWidgetTests to assert URL parameter and absence of sub-counters
2026-09-30 18:55:56 +02:00
3d9e7c3202 feat(interventions): add combined filter for manager, controller and inspector with shortnames and teams 2026-09-30 18:49:19 +02:00
b86d805cb5 feat(contracts,home): include finished status in controller shortcuts and display assigned controller in order interventions
- Add 'finished' status to controller shortcuts widget filter and links
- Add conditional 'Contrôleur assigné' column in order interventions table for contracts with auto_assign_control_team
- Optimize order interventions query with select_related for controllers
- Update and compile translations (fr, nl, en)
- Add unit tests for order detail controller display and widget status inclusion
2026-09-30 18:17:30 +02:00
aa75bcbfeb feat(projects): upgrade filter UI to modern modal with badge counter and contextual status styles 2026-09-30 17:59:01 +02:00
244b16b3d3 feat(trafficlights): add cable geometry copy, duplication modal and node editing hints in cross plan import
- Add explanatory guidelines for cable node operations (double-click segment to insert node, double-click node to delete, drag to reposition)
- Add floating reminder pill on map during cable editing mode
- Add "Copier tracé..." dropdown on cable cards to import trace from other cables
- Add "Appliquer aux autres câbles..." modal to duplicate active trace across multiple cables at once
- Add direct map jump button on cable rows in comparative table
- Include db_coords in compared_cables context to preserve pre-existing geometries
- Add tests and update translations (fr, nl, en)
2026-09-30 14:39:32 +02:00
1d99cdd09b feat(assets): ignore auxiliary 'com' wiring and allow archive or delete in cross plan import
- Ignore communication and power supply rows ('com', 'comm', 'alim', etc.) in detector sections
- Add user-selectable 'archive' and 'delete' actions for all assets in cross plan preview
- Handle ProtectedError gracefully when deleting referenced assets
- Add unit tests for wire filtering and archive/delete integration actions
- Update and compile translations (fr, nl, en)
2026-09-30 11:51:55 +02:00
71154a0fb4 feat: standardize preparation order numbering format and add recodification command 2026-09-30 11:07:10 +02:00
dc6638c4a5 feat: add daily intervention visibility restrictions and mobile filters for technicians with tests 2026-09-30 09:47:45 +02:00
79154085ee fix(teams): record automatic leave date on team reassignment and reconcile history 2026-09-30 09:24:09 +02:00
a08ef0f035 feat: add order filtering and default descending sort for preparation and production orders 2026-09-30 08:46:06 +02:00
63a9a080de feat(stock): tri chronologique des bons et réouverture/historique des bons de confection
- Trie les bons de préparation (date) et de confection (created_at) par ordre chronologique FIFO
- Ajoute la réouverture a posteriori des bons de confection terminés avec régularisation automatique des stocks
- Ajoute le modèle ProductionOrderHistory et trace l'historique complet des modifications (statuts, composants, documents)
- Intègre le bouton de réouverture et la section d'historique dans les vues desktop et mobile
2026-09-29 22:38:34 +02:00
c25c4a4415 feat: add image zoom, pan, and reset controls to document viewer 2026-09-29 22:17:20 +02:00
b78c9da50e feat(stock): inclure les statuts 'à traiter' et 'en cours' dans le widget de raccourcis 2026-09-29 22:17:06 +02:00
Karl Determe
e00f9cf784 feat: add stock management module including views, models, templates, and tests 2026-09-29 17:07:29 +02:00
Karl Determe
001bee8ca9 feat: add stock batch views, templates, and comprehensive test suite 2026-09-29 15:20:03 +02:00
Karl Determe
25a64b824a feat: add mobile views, stock management views, and preparation order templates 2026-09-29 13:55:26 +02:00
Karl Determe
f429838a39 feat: add stock management module, common models, and mobile views 2026-09-29 13:30:42 +02:00
Karl Determe
ca4d1b3c1b feat: add views and templates for stock batch creation on desktop and mobile 2026-09-29 12:42:27 +02:00
8b13236a26 fix(interventions,mobile): allow multi-thematic visibility for contractor teams and prevent persistent search query 2026-09-28 21:01:59 +02:00
de73de7328 feat(interventions,stock): sync preparation orders and material status on intervention cancellation
- Fix preparation status calculation by ignoring cancelled orders, preventing false green 'stock ready' icon
- Update item prepared state when preparation order is marked as processed
- Automatically cancel unprepared preparation orders when intervention is canceled
- Send alert notification to stock managers when canceled intervention has already prepared orders
- Add alert banner with direct restock/cancellation action on preparation order detail views
- Prevent editing material switches or creating orders for canceled interventions
- Add and compile translations for FR, EN and NL
- Add comprehensive automated unit tests
2026-09-28 20:01:39 +02:00
c00212f79e fix(interventions): translate modal status and apply assigned color on interventions list and map 2026-09-28 16:26:44 +02:00
37c1016b57 feat(interventions): distinguer les interventions 'à traiter' déjà assignées en rose (#d81b60)
- Ajout de la couleur 'to_be_processed_assigned' (#d81b60) dans STATUS_COLORS (Python & JS).
- Ajout de la propriété is_assigned sur le modèle Intervention.
- Prise en compte de la couleur dans les vues calendrier (membres, timeline, backlog, équipes) et sur la carte MapLibre du modal calendrier.
- Enrichissement des popups carte et des badges du backlog avec l'équipe/membre assigné.
- Ajout de tests unitaires validant la couleur et la sérialisation is_assigned.
2026-09-28 14:45:17 +02:00
0bbe267498 feat(contracts): auto-sync team schedule daily and on mobile access
- Register sync_teams_schedule in init_scheduled_tasks (daily at 04:00 local)
- Trigger sync_member_current_teams on mobile intervention list load
- Trigger sync_member_current_teams on user login signal
2026-09-28 14:17:44 +02:00
f4460a5323 feat: add edit note functionality and UI action buttons 2026-09-25 23:36:04 +02:00
205a05cb5b feat(interventions): aligner les permissions des notes par statut et historiser le mobile
- Autoriser les modifications/suppressions de notes jusqu'au statut 'traité' pour les externes
- Autoriser les modifications/suppressions de notes jusqu'au statut 'validé' pour les internes
- Supprimer la contrainte horaire de 1h sur la modification des notes
- Brancher l'historisation de la chronologie sur la création et modification de notes mobile
- Tracer l'upload des photos de formulaires mobiles dans la chronologie
- Ajouter les tests unitaires et d'intégration correspondants
2026-09-25 23:20:09 +02:00
7911369740 feat: restrict intervention timeline history and filter controls to internal users 2026-09-25 22:53:32 +02:00
2df4621e5b feat(interventions): afficher les statuts de la chronologie sous forme de cercles numérotés
- Reprend le code visuel du stepper supérieur pour les transitions de statut dans l'historique
- Affiche une boule colorée avec le numéro canonique (1 à 15) et le libellé sous chaque statut
- Aligne la flèche de transition au centre des cercles
- Ajuste les contrastes de texte (noir/blanc) selon la luminosité du statut
2026-09-25 22:35:29 +02:00
fd794737a8 style(interventions): standardize timeline status badge widths and use distinct purple for status event
- Assign fixed 115px width to all status badges for vertical alignment of transitions
- Unify left event badge width to 85px for a clean tabular layout
- Use purple (#6f42c1) for the 'Statut' badge and node icon to avoid visual collision with status colors
- Update timeline unit tests accordingly
2026-09-25 22:24:05 +02:00
f510a086bd feat(interventions): trace notes, documents and element links in full timeline
- Add 'note', 'document', and 'link' event types to InterventionTimeLine
- Record timeline events for note additions/edits/deletions (standalone and batch)
- Record document uploads and deletions (desktop and mobile APIs)
- Record asset linking/unlinking with friendly names and linked interventions
- Display detailed items exclusively in the toggleable "Historique complet" view
- Maintain shortname display for all event authors
- Update and compile FR, EN and NL translation catalogs
- Add comprehensive test coverage for all new timeline event records
2026-09-25 22:05:22 +02:00
79af86fb7f feat(interventions): amélioration de la chronologie et traçabilité complète de l'historique
- Ajout des types d'événements 'assignment' et 'modification' sur InterventionTimeLine
- Enregistrement des attributs modifiés (field_changes JSON) et des descriptions d'événements
- Création du service InterventionChangeTracker pour historiser les changements de champs et d'assignations
- Refonte visuelle de card_chronologie avec vue par défaut (statuts + assignations) et bascule vers l'historique complet
- Préservation des shortnames pour tous les auteurs des modifications
- Ajout des traductions i18n et de tests unitaires dédiés
2026-09-25 21:29:42 +02:00
71cb8369a0 feat(interventions): improve non-applicable button symbology in operations 2026-09-25 16:14:13 +02:00
ce924d7af4 fix(interventions): import Contract and ContractPost in admin to fix OperationTemplate change view 2026-09-25 14:40:38 +02:00
c8349a3171 feat: add continuous GPS tracking, pulsing location marker, and distance calculation to inspection maps 2026-09-25 14:23:51 +02:00
97ecd8d9fb fix(inspections): prevent modal event bubbling and add bottom add-ouvrage button in GIEP 2026-09-25 11:47:57 +02:00
ca8d8187c1 feat: hide standard inspection sections for GIEP-specific inspections 2026-09-25 11:34:04 +02:00
b30e65e0a1 i18n: remplacer remarques initiales par remarques globales et mettre à jour les traductions 2026-09-25 11:14:16 +02:00
c6ef935e0b feat(inspections): restructurer le formulaire d'inspection GIEP par ouvrage
- Isoler la météo au niveau de la zone GIEP globale
- Structurer chaque ouvrage : Conformité, Photos dédiées, Formulaire technique, Remarques
- Lier les photos aux ouvrages via field_key et auto-consolider statut et remarques
- Enrichir l'affichage de consultation et ajouter la suite de tests unitaires
2026-09-25 10:03:27 +02:00
5e9fced68f feat: add 7j view mode to team planning 2026-09-25 09:27:32 +02:00
40ce03d812 feat: add rolling horizons and aggregated column support to team planning view modes 2026-09-25 09:18:40 +02:00
Karl Determe
096886a194 feat: add Django admin configuration, team services, and test planning for contracts 2026-09-24 11:33:53 +02:00
Karl Determe
f19eae6067 Merge branch 'main' of https://git.valogeo.be/Valogeo/loko 2026-09-24 11:04:00 +02:00
Karl Determe
0231f2ac00 feat: add Django admin configurations and tests for common app 2026-09-24 11:03:47 +02:00
6b526f7056 fix(inspections): fix mobile zone filter and complete mobile translations in lists and maps 2026-09-23 16:57:41 +02:00
61ef7fc3ea fix(trafficlights): correct cyclopieton 'bF' detection and preserve STIB detectors by default
- Recognize combined 'bF' phases as cyclo-piétons with appropriate 2V200 cyclo-piéto model
- Improve lantern match scoring to pair LANxx_bF with existing LANxx lanterns on multi-lantern poles
- Normalize lantern model matching between Excel deduction and DB names
- Propose 'keep' by default for STIB tram detector loops ('dT') absent from the cross plan
- Add unit tests for cyclo-piéton deduction, lantern matching and STIB detector preservation
2026-09-22 21:59:05 +02:00
c7c76f28dc fix: add legacy file fallback and use atomic transactions for document creation 2026-09-22 19:44:35 +02:00
55cad6d6cc refactor: increase map marker badge size, font size, and update zoom layout parameters 2026-09-21 19:12:50 +02:00
c726d8f37c style(home): refine activity symbol sizes and prevent marker overlap with collision detection 2026-09-21 18:50:15 +02:00
f534758f91 fix(home): safe status choices lookup and thematic localized fields in activity_geojson 2026-09-21 18:39:09 +02:00
1b93f3ae88 fix: fix try-catch block in home_index.js and update homewidget choices 2026-09-21 16:39:49 +02:00
de1d1c3b43 perf: optimize activity_geojson query, add caching and parallelize map data fetching 2026-09-21 16:14:56 +02:00
f0fb0df8ec feat: add operation type icons to map activity points 2026-09-21 15:34:39 +02:00
ef40e48163 feat(assets): add create_structure_folders management command 2026-09-21 15:14:26 +02:00
9726e8b618 refactor: move build metadata arguments to end of Dockerfile to improve cache efficiency 2026-09-21 14:50:18 +02:00
f23107e749 feat: add metro structure types and filtering UI to exceptional transport widget 2026-09-21 14:42:23 +02:00
364 changed files with 47871 additions and 5542 deletions

View file

@ -46,15 +46,8 @@ ENV PROJ_DATA=/opt/conda/envs/loko-env/share/proj
# Argument de build pour activer l'installation des librairies IA (ultralytics, OpenCV...)
# Passer INSTALL_AI_LIBS=true dans le docker-compose pour les inclure
ARG INSTALL_AI_LIBS=false
ARG GIT_COMMIT=unknown
ARG BUILD_DATE=unknown
ARG LOKO_VERSION=1.0.0
ENV GIT_COMMIT_SHA=${GIT_COMMIT}
ENV BUILD_DATE=${BUILD_DATE}
ENV LOKO_VERSION=${LOKO_VERSION}
# Copie et installation des dépendances Python
# Copie et installation des dépendances Python (mis en cache tant que requirements/ ne change pas)
COPY requirements/ /app/requirements/
RUN pip install --no-cache-dir -r requirements/base.txt && \
if [ "$INSTALL_AI_LIBS" = "true" ]; then \
@ -108,6 +101,15 @@ COPY loko/road_detection/templates/ /app/road_detection/templates/
COPY loko/*.py /app/
COPY loko/generated_passwords.txt* /app/
# Métadonnées de build dynamiques (placées à la fin pour préserver le cache de build Docker)
ARG GIT_COMMIT=unknown
ARG BUILD_DATE=unknown
ARG LOKO_VERSION=1.0.0
ENV GIT_COMMIT_SHA=${GIT_COMMIT}
ENV BUILD_DATE=${BUILD_DATE}
ENV LOKO_VERSION=${LOKO_VERSION}
# Exposer le port par défaut
EXPOSE 8000

View file

@ -451,7 +451,10 @@ admin.site.register(ComplementaryPoliceRegulation, AssetAdmin)
admin.site.register(SignParkingSpot, AssetAdmin)
from .models import ITSAssetModel, ITSGeoAsset, ITSGeoAssetContract
from .models import (
ITSAssetModel, ITSGeoAsset, ITSGeoAssetContract,
ITSBollardSystem, ITSBollardPlot, ITSBollardSystemAsset, ITSBollardSystemContract
)
admin.site.register(ITSAssetModel, AssetAdmin)
@ -468,6 +471,44 @@ class ITSGeoAssetContractAdmin(AssetAdmin):
list_filter = ("contract",)
class ITSBollardPlotInline(admin.TabularInline):
model = ITSBollardPlot
extra = 0
fields = ("code", "position", "bollard_type", "model", "serial_number", "status")
show_change_link = True
class ITSBollardSystemAssetInline(admin.TabularInline):
model = ITSBollardSystemAsset
extra = 0
fields = ("asset", "role", "comment")
autocomplete_fields = ("asset",)
show_change_link = True
@admin.register(ITSBollardSystem)
class ITSBollardSystemAdmin(AssetGISAdmin, AssetAdmin):
search_fields = ("code", "name_fr", "name_nl", "location__code", "location__name_fr")
list_display = ("code", "name_fr", "location", "width", "direction", "status", "plots_count")
list_filter = ("status", "direction", "location")
inlines = [ITSBollardPlotInline, ITSBollardSystemAssetInline]
@admin.register(ITSBollardPlot)
class ITSBollardPlotAdmin(AssetGISAdmin, AssetAdmin):
search_fields = ("code", "name_fr", "system__code", "serial_number")
list_display = ("code", "system", "position", "bollard_type", "model", "status")
list_filter = ("bollard_type", "status", "system")
@admin.register(ITSBollardSystemContract)
class ITSBollardSystemContractAdmin(AssetAdmin):
search_fields = ("system__code", "contract__contract_number")
list_display = ("system", "contract", "status", "maintenance_type")
list_filter = ("status", "maintenance_type", "contract")
from .models import ITSLocation, ITSLocationDocument, ITSLocationContract
admin.site.register(ITSLocation, AssetAdmin)
@ -680,21 +721,34 @@ from .models import CleanLitterBinPhoto, CleanLitterBinObservation
from .models import PublicBuilding, BuildingAsset, PublicBuildingDocument
from .models import OutdoorLocation, OutdoorAsset, OutdoorLocationDocument
class PublicBuildingDocumentInline(admin.TabularInline):
model = PublicBuildingDocument
extra = 1
fk_name = "building"
fields = ("document_type", "floor_location", "file", "description")
raw_id_fields = ("floor_location",)
@admin.register(PublicBuilding)
class PublicBuildingAdmin(admin.GISModelAdmin):
list_display = ("code", "name_fr", "location_type", "parent_location", "usage_type", "building_status", "owner", "manager", "is_erp", "dpe_rating")
list_filter = ("location_type", "usage_type", "building_status", "is_erp", "erp_category", "dpe_rating", "pmr_accessible")
search_fields = ("code", "name_fr", "name_nl", "address", "owner")
list_display = ("code", "name_fr", "location_type", "parent_location", "room_number", "level_number", "usage_type", "building_status", "is_heated", "is_erp", "dpe_rating")
list_filter = ("location_type", "floor_type", "is_heated", "usage_type", "building_status", "is_erp", "erp_category", "dpe_rating", "pmr_accessible")
search_fields = ("code", "name_fr", "name_nl", "address", "owner", "room_number")
raw_id_fields = ("parent_location", "manager")
inlines = [PublicBuildingDocumentInline]
@admin.register(BuildingAsset)
class BuildingAssetAdmin(admin.GISModelAdmin):
list_display = ("code", "name_fr", "asset_type", "building", "location", "parent_asset", "status", "obsolescence")
list_filter = ("asset_type", "status", "obsolescence")
search_fields = ("code", "name_fr", "name_nl", "serial_number")
list_display = ("code", "name_fr", "asset_type", "building", "location", "parent_asset", "material", "power_kw", "status", "obsolescence", "next_control_date")
list_filter = ("asset_type", "energy_source", "glazing_type", "opening_type", "status", "obsolescence")
search_fields = ("code", "name_fr", "name_nl", "serial_number", "material", "maintenance_contract_ref", "contractor_name")
raw_id_fields = ("building", "location", "parent_asset")
admin.site.register(PublicBuildingDocument, AssetAdmin)
@admin.register(PublicBuildingDocument)
class PublicBuildingDocumentAdmin(admin.ModelAdmin):
list_display = ("id", "building", "floor_location", "document_type", "file", "upload_date")
list_filter = ("document_type",)
search_fields = ("description", "building__code", "building__name_fr", "floor_location__code")
raw_id_fields = ("building", "floor_location")
@admin.register(OutdoorLocation)
class OutdoorLocationAdmin(admin.GISModelAdmin):
@ -732,4 +786,59 @@ class ParkingSpotAdmin(AssetGISAdmin):
)
search_fields = ("code", "name_fr", "name_nl", "note")
filter_horizontal = ("poles", "panels")
raw_id_fields = ("location", "street")
raw_id_fields = ("location", "street")
# ─── Management Zones ──────────────────────────────────────────────────────────
from django.utils.html import format_html
from django.contrib.contenttypes.admin import GenericTabularInline
from .models import ManagementZoneType, ManagementZone, ManagementZoneAsset, ManagementZoneDocument
class ManagementZoneDocumentInline(admin.TabularInline):
model = ManagementZoneDocument
extra = 1
class ManagementZoneAssetInline(GenericTabularInline):
model = ManagementZoneAsset
ct_field = 'content_type'
ct_fk_field = 'object_id'
extra = 1
@admin.register(ManagementZoneType)
class ManagementZoneTypeAdmin(admin.ModelAdmin):
list_display = ('code', 'name_fr', 'name_nl', 'thematic', 'color_badge')
list_filter = ('thematic',)
search_fields = ('code', 'name_fr', 'name_nl', 'description')
def color_badge(self, obj):
color = obj.default_color or "#198754"
return format_html(
'<span style="display:inline-block;width:16px;height:16px;background-color:{};border:1px solid #999;border-radius:3px;vertical-align:middle;margin-right:6px;"></span>{}',
color, color
)
color_badge.short_description = "Couleur"
@admin.register(ManagementZone)
class ManagementZoneAdmin(AssetGISAdmin):
list_display = ('code', 'name_fr', 'thematic', 'zone_type', 'color_badge', 'target_frequency_days', 'is_active', 'has_geom')
list_filter = ('thematic', 'zone_type', 'is_active')
search_fields = ('code', 'name_fr', 'name_nl', 'instructions', 'description', 'seasonality_notes')
readonly_fields = ('created_at', 'updated_at')
inlines = [ManagementZoneDocumentInline, ManagementZoneAssetInline]
def color_badge(self, obj):
color = obj.get_color()
return format_html(
'<span style="display:inline-block;width:16px;height:16px;background-color:{};border:1px solid #999;border-radius:3px;vertical-align:middle;margin-right:6px;"></span>{}',
color, color
)
color_badge.short_description = "Couleur"
def has_geom(self, obj):
return bool(obj.geom)
has_geom.boolean = True
has_geom.short_description = "Géométrie"

View file

@ -54,4 +54,15 @@ class AssetsConfig(AppConfig):
return False
return True
register('assets', 'InspectionDocument', 'file', guard_inspection_document)
register("assets", "InspectionDocument", "file", guard_inspection_document)
# Guards for PublicBuilding floor plans and documents
def guard_publicbuilding_floor_plan(obj, user):
return user.is_authenticated
register("assets", "PublicBuilding", "floor_plan", guard_publicbuilding_floor_plan)
def guard_publicbuilding_document(obj, user):
return user.is_authenticated
register("assets", "PublicBuildingDocument", "file", guard_publicbuilding_document)

View file

@ -1,3 +1,4 @@
from django.contrib.gis.geos import GEOSGeometry, Polygon, MultiPolygon
from django import forms
from assets.models import TrafficLightIntersectionDocument, PublicLightingStreetDocument, StructureDocument, ControlCenterDocument
@ -287,6 +288,7 @@ class PublicBuildingForm(forms.ModelForm):
'usage_type', 'building_status',
'construction_year', 'floors_count', 'basement_count',
'total_surface_m2', 'usable_surface_m2', 'surface_m2', 'level_number',
'room_number', 'ceiling_height_m', 'is_heated', 'fire_compartment', 'floor_type', 'floor_plan',
'is_erp', 'erp_category', 'erp_type', 'max_capacity', 'pmr_accessible',
'dpe_rating', 'ges_rating', 'has_asbestos_diag', 'has_lead_diag',
'lat', 'lon',
@ -312,6 +314,12 @@ class PublicBuildingForm(forms.ModelForm):
'usable_surface_m2': forms.NumberInput(attrs={'class': 'form-control', 'step': 'any'}),
'surface_m2': forms.NumberInput(attrs={'class': 'form-control', 'step': 'any'}),
'level_number': forms.NumberInput(attrs={'class': 'form-control'}),
'room_number': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ex: B-204, RDC-01'}),
'ceiling_height_m': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'floor_plan': forms.FileInput(attrs={'class': 'form-control', 'accept': '.png,.jpg,.jpeg,.svg,.pdf,.webp'}),
'is_heated': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'fire_compartment': forms.TextInput(attrs={'class': 'form-control'}),
'floor_type': forms.Select(attrs={'class': 'form-select'}),
'is_erp': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'erp_category': forms.Select(attrs={'class': 'form-select'}),
'erp_type': forms.Select(attrs={'class': 'form-select'}),
@ -327,29 +335,15 @@ class PublicBuildingForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['name_fr'].required = False
self.fields['name_nl'].required = False
self.fields['parent_location'].required = False
self.fields['location_type'].required = False
self.fields['address'].required = False
self.fields['description'].required = False
self.fields['owner'].required = False
self.fields['manager'].required = False
self.fields['occupant_entity'].required = False
self.fields['construction_year'].required = False
self.fields['floors_count'].required = False
self.fields['basement_count'].required = False
self.fields['total_surface_m2'].required = False
self.fields['usable_surface_m2'].required = False
self.fields['surface_m2'].required = False
self.fields['level_number'].required = False
self.fields['erp_category'].required = False
self.fields['erp_type'].required = False
self.fields['max_capacity'].required = False
self.fields['dpe_rating'].required = False
self.fields['ges_rating'].required = False
self.fields['lat'].required = False
self.fields['lon'].required = False
for field_name in [
'name_fr', 'name_nl', 'parent_location', 'location_type', 'address', 'description',
'owner', 'manager', 'occupant_entity', 'construction_year', 'floors_count',
'basement_count', 'total_surface_m2', 'usable_surface_m2', 'surface_m2', 'level_number',
'room_number', 'ceiling_height_m', 'fire_compartment', 'floor_type',
'erp_category', 'erp_type', 'max_capacity', 'dpe_rating', 'ges_rating', 'lat', 'lon'
]:
if field_name in self.fields:
self.fields[field_name].required = False
class BuildingAssetForm(forms.ModelForm):
@ -358,8 +352,14 @@ class BuildingAssetForm(forms.ModelForm):
model = BuildingAsset
fields = [
'code', 'name_fr', 'name_nl', 'building', 'location', 'parent_asset',
'asset_type', 'category', 'status', 'obsolescence', 'installation_date',
'serial_number', 'last_control_date', 'next_control_date', 'note'
'asset_type', 'category', 'status', 'obsolescence',
'dimensions', 'surface_m2', 'material',
'power_kw', 'energy_source', 'refrigerant_type', 'refrigerant_charge_kg',
'glazing_type', 'fire_rating', 'opening_type',
'installation_date', 'serial_number',
'maintenance_contract_ref', 'contractor_name',
'last_control_date', 'next_control_date', 'regulatory_periodicity_months',
'replacement_cost_estimate', 'note'
]
widgets = {
'code': forms.TextInput(attrs={'class': 'form-control'}),
@ -372,16 +372,37 @@ class BuildingAssetForm(forms.ModelForm):
'category': forms.Select(attrs={'class': 'form-select'}),
'status': forms.Select(attrs={'class': 'form-select'}),
'obsolescence': forms.Select(attrs={'class': 'form-select'}),
'dimensions': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ex: 120x140 cm, 350 m²'}),
'surface_m2': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'material': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ex: Aluminium, PVC, Zinc, Acier'}),
'power_kw': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.1'}),
'energy_source': forms.Select(attrs={'class': 'form-select'}),
'refrigerant_type': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ex: R32, R410A'}),
'refrigerant_charge_kg': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'glazing_type': forms.Select(attrs={'class': 'form-select'}),
'fire_rating': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ex: EI30, EI60, Coupe-feu 1h'}),
'opening_type': forms.Select(attrs={'class': 'form-select'}),
'installation_date': forms.DateTimeInput(attrs={'class': 'form-control', 'type': 'datetime-local'}),
'serial_number': forms.TextInput(attrs={'class': 'form-control'}),
'maintenance_contract_ref': forms.TextInput(attrs={'class': 'form-control'}),
'contractor_name': forms.TextInput(attrs={'class': 'form-control'}),
'last_control_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
'next_control_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
'regulatory_periodicity_months': forms.NumberInput(attrs={'class': 'form-control'}),
'replacement_cost_estimate': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'note': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field_name in ['name_fr', 'name_nl', 'location', 'parent_asset', 'category', 'installation_date', 'serial_number', 'last_control_date', 'next_control_date', 'note']:
for field_name in [
'name_fr', 'name_nl', 'location', 'parent_asset', 'category',
'dimensions', 'surface_m2', 'material', 'power_kw', 'energy_source',
'refrigerant_type', 'refrigerant_charge_kg', 'glazing_type', 'fire_rating', 'opening_type',
'installation_date', 'serial_number', 'maintenance_contract_ref', 'contractor_name',
'last_control_date', 'next_control_date', 'regulatory_periodicity_months',
'replacement_cost_estimate', 'note'
]:
if field_name in self.fields:
self.fields[field_name].required = False
from common.models import Thematic
@ -439,4 +460,99 @@ class StructureExceptionalTransportForm(forms.ModelForm):
super().__init__(*args, **kwargs)
for name, field in self.fields.items():
if not isinstance(field.widget, forms.CheckboxInput):
field.required = False
field.required = False
# ─── Management Zones Forms ───────────────────────────────────────────────────
from assets.models import ManagementZone, ManagementZoneType, ManagementZoneDocument
class ManagementZoneForm(forms.ModelForm):
"""Formulaire de création et d'édition d'une Zone de gestion."""
geom_geojson = forms.CharField(required=False, widget=forms.HiddenInput())
class Meta:
model = ManagementZone
fields = [
'code', 'name_fr', 'name_nl', 'thematic', 'zone_type', 'color',
'target_frequency_days', 'seasonality_notes', 'instructions', 'description', 'is_active'
]
widgets = {
'code': forms.TextInput(attrs={'class': 'form-control'}),
'name_fr': forms.TextInput(attrs={'class': 'form-control'}),
'name_nl': forms.TextInput(attrs={'class': 'form-control'}),
'thematic': forms.Select(attrs={'class': 'form-select'}),
'zone_type': forms.Select(attrs={'class': 'form-select'}),
'color': forms.TextInput(attrs={'class': 'form-control form-control-color', 'type': 'color'}),
'target_frequency_days': forms.NumberInput(attrs={'class': 'form-control', 'min': 1}),
'seasonality_notes': forms.TextInput(attrs={'class': 'form-control', 'placeholder': "Ex: Tonte d'avril à octobre; fauche tardive en automne"}),
'instructions': forms.Textarea(attrs={'class': 'form-control', 'rows': 4, 'placeholder': "Consignes d'exploitation, précautions tramway, biodiversité..."}),
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
}
def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs)
for field in ['name_nl', 'zone_type', 'color', 'target_frequency_days', 'seasonality_notes', 'instructions', 'description']:
if field in self.fields:
self.fields[field].required = False
if not self.instance.pk and not self.initial.get('color'):
self.initial['color'] = '#198754'
if self.instance and self.instance.pk and self.instance.geom:
geom_wgs84 = self.instance.geom.transform(4326, clone=True)
self.initial['geom_geojson'] = geom_wgs84.geojson
if user:
from assets.permissions import get_editable_thematics_for_management_zones
editable_thematics = get_editable_thematics_for_management_zones(user)
self.fields['thematic'].queryset = editable_thematics
if not self.instance.pk and editable_thematics.count() == 1:
self.fields['thematic'].initial = editable_thematics.first()
thematic_id = None
if self.data.get('thematic'):
thematic_id = self.data.get('thematic')
elif self.instance.pk and self.instance.thematic_id:
thematic_id = self.instance.thematic_id
elif self.initial.get('thematic'):
thematic_id = self.initial.get('thematic')
if hasattr(thematic_id, 'pk'):
thematic_id = thematic_id.pk
if thematic_id:
self.fields['zone_type'].queryset = ManagementZoneType.objects.filter(thematic_id=thematic_id)
else:
self.fields['zone_type'].queryset = ManagementZoneType.objects.all()
def save(self, commit=True):
zone = super().save(commit=False)
geojson_str = (self.cleaned_data.get('geom_geojson') or '').strip()
if geojson_str:
try:
geom = GEOSGeometry(geojson_str, srid=4326)
if isinstance(geom, Polygon):
geom = MultiPolygon(geom, srid=4326)
geom.transform(3812)
zone.geom = geom
except Exception:
pass
elif 'geom_geojson' in self.cleaned_data and not geojson_str:
zone.geom = None
if commit:
zone.save()
return zone
class ManagementZoneDocumentForm(forms.ModelForm):
documents = MultipleFileField(required=True, label="Documents")
class Meta:
model = ManagementZoneDocument
fields = ['documents']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['documents'].widget.attrs.update({'class': 'form-control'})

View file

@ -4833,8 +4833,8 @@ msgid "Description / Remarques"
msgstr "Description / Remarks"
#: assets/templates/assets/components/asset_interventions.html:169
msgid "Détails ou remarques initiales..."
msgstr "Details or initial remarks..."
msgid "Détails ou remarques globales..."
msgstr "Details or global remarks..."
#: assets/templates/assets/components/asset_interventions.html:176
#: assets/templates/assets/components/location_edit_modals.html:98
@ -4966,7 +4966,7 @@ msgstr "Remarks / Observations"
#: assets/templates/assets/components/inspection_form_modal.html:129
msgid "Saisir vos remarques ou détails..."
msgstr "Details or initial remarks..."
msgstr "Details or global remarks..."
#: assets/templates/assets/components/inspection_form_modal.html:138
msgid "Valider l'inspection"
@ -8168,7 +8168,7 @@ msgstr "Accessory"
#: assets/views/core.py:447
msgid "Surface verte"
msgstr "Slippery surface"
msgstr "Green surface"
#: assets/views/core.py:449
msgid "Arbuste"
@ -8573,6 +8573,126 @@ msgstr "GIEP zones"
msgid "Aucune localisation GIEP trouvée à proximité"
msgstr "No location found"
msgid "Supprimer de la DB"
msgstr "Delete from DB"
msgid "Supprimer l'existant de la DB"
msgstr "Delete existing from DB"
msgid "Ne pas créer (Ignorer)"
msgstr "Do not create (Ignore)"
msgid "Archiver l'existant (Ne pas recréer)"
msgstr "Archive existing (do not recreate)"
msgid "Archiver l'existant"
msgstr "Archive existing"
msgid "Aide au tracé des câbles"
msgstr "Cable tracing help"
msgid "Tracer"
msgstr "Trace"
msgid "Double-clic sur la carte pour ajouter un point."
msgstr "Double-click on the map to add a point."
msgid "Ajouter un nœud"
msgstr "Add a node"
msgid "Double-clic sur un segment du câble."
msgstr "Double-click on a cable segment."
msgid "Supprimer un nœud"
msgstr "Delete a node"
msgid "Double-clic sur un nœud (poignée orange)."
msgstr "Double-click on a node (orange handle)."
msgid "Déplacer"
msgstr "Move"
msgid "Glisser-déposer les poignées pour ajuster."
msgstr "Drag and drop handles to adjust."
msgid "Copier tracé"
msgstr "Copy trace"
msgid ""
"Copiez le tracé d'un autre câble suivant le même trajet (ex: 48 et tel.)."
msgstr ""
"Copy the trace of another cable following the same path (e.g. 48 and tel.)."
msgid "Copier tracé…"
msgstr "Copy trace…"
msgid "Copier le tracé d'un autre câble"
msgstr "Copy another cable's trace"
msgid "Appliquer aux autres câbles…"
msgstr "Apply to other cables…"
msgid ""
"Double-clic sur un segment pour ajouter un nœud • Double-clic sur un nœud "
"pour le supprimer • Glisser pour déplacer"
msgstr ""
"Double-click a segment to add a node • Double-click a node to delete • Drag "
"to move"
msgid "Appliquer le tracé à d'autres câbles"
msgstr "Apply trace to other cables"
msgid "Copier le tracé de"
msgstr "Copy trace from"
msgid "vers les câbles sélectionnés ci-dessous :"
msgstr "to the selected cables below:"
msgid "Copier le tracé"
msgstr "Copy trace"
msgid "Le câble source ne possède aucun tracé valide à copier."
msgstr "The source cable has no valid trace to copy."
msgid "Aucun tracé disponible"
msgstr "No trace available"
msgid "Tracé copié de"
msgstr "Trace copied from"
msgid "vers"
msgstr "to"
msgid ""
"Veuillez d'abord tracer au moins deux points pour ce câble avant de le "
"dupliquer."
msgstr "Please trace at least two points for this cable before duplicating."
msgid "Aucun autre câble disponible dans ce carrefour."
msgstr "No other cable available at this intersection."
msgid "Veuillez sélectionner au moins un câble cible."
msgstr "Please select at least one target cable."
msgid "câble(s) mis à jour avec ce tracé !"
msgstr "cable(s) updated with this trace!"
#: assets/templates/assets/buildings/publicbuilding_detail.html
msgid "Tout replier"
msgstr "Collapse all"
#: assets/templates/assets/buildings/publicbuilding_detail.html
msgid "Tout déplier"
msgstr "Expand all"
#: assets/templates/assets/buildings/publicbuilding_detail.html
msgid "Assets physiques rattachés"
msgstr "Linked physical assets"
#: assets/templates/assets/buildings/publicbuilding_detail.html
msgid "Assets rattachés directement au bâtiment"
msgstr "Assets directly linked to building"
#~ msgid "Absent du fichier Excel (Sera conservé ou archivé)"
#~ msgstr "Missing from Excel file (Will be kept or archived)"
@ -8590,3 +8710,57 @@ msgstr "No location found"
#~ msgid "Arceaux cargo constatés"
#~ msgstr "Observed cargo bike racks"
msgid "Type de zone de gestion"
msgstr "Management zone type"
msgid "Types de zones de gestion"
msgstr "Management zone types"
msgid "Zone de gestion"
msgstr "Management zone"
msgid "Zones de gestion"
msgstr "Management zones"
msgid "Consignes et points d'attention"
msgstr "Instructions and points of attention"
msgid "Consignes d'exploitation, points de vigilance, règles de sécurité..."
msgstr "Operational instructions, points of vigilance, safety rules..."
msgid "Périmètre géographique"
msgstr "Geographical perimeter"
msgid "Couleur d'affichage"
msgstr "Display color"
msgid "Code hexadécimal (ex: #198754). Si vide, utilise la couleur du type de zone."
msgstr "Hexadecimal code (e.g. #198754). If empty, the zone type color is used."
msgid "Fréquence cible (jours)"
msgstr "Target frequency (days)"
msgid "Intervalle indicatif entre interventions en jours."
msgstr "Indicative interval between interventions in days."
msgid "Saisonnalité / Calendrier"
msgstr "Seasonality / Calendar"
msgid "Ex: Tonte d'avril à octobre; fauche tardive en septembre; interdiction de taille au printemps."
msgstr "E.g.: Mowing from April to October; late mowing in September; pruning prohibited in spring."
msgid "Asset de zone de gestion"
msgstr "Management zone asset"
msgid "Assets de zones de gestion"
msgstr "Management zone assets"
msgid "Document de zone de gestion"
msgstr "Management zone document"
msgid "Documents de zones de gestion"
msgstr "Management zone documents"
msgid "Si coché, cet équipement est exclu de la zone même si sa géométrie intersecte le périmètre."
msgstr "If checked, this asset is excluded from the zone even if its geometry intersects the perimeter."

View file

@ -613,3 +613,134 @@ msgstr "Collapse models"
#: loko/assets/static/assets/unified_index.js:3726
msgid "Bloc replié. Cliquez sur \"Afficher les modèles\" pour voir les options."
msgstr ""
#: loko/assets/static/assets/inspection_map.js:93
msgid "Rechercher une adresse…"
msgstr "Search an address…"
msgid "Rechercher une adresse..."
msgstr "Search an address..."
#: loko/assets/static/assets/inspection_map.js:748
msgid "Géolocalisation..."
msgstr "Geolocation..."
#: loko/assets/static/assets/inspection_map.js:769
msgid "Position actualisée"
msgstr "Position updated"
#: loko/assets/static/assets/inspection_map.js:773
msgid "GPS indisponible"
msgstr "GPS unavailable"
#: loko/assets/static/assets/inspection_map.js:935
msgid "Inspections (Désactivé)"
msgstr "Inspections (Disabled)"
#: loko/assets/static/assets/inspection_map.js:936
msgid "Inspections (1 mois)"
msgstr "Inspections (1 month)"
#: loko/assets/static/assets/inspection_map.js:937
msgid "Inspections (3 mois)"
msgstr "Inspections (3 months)"
#: loko/assets/static/assets/inspection_map.js:938
msgid "Inspections (6 mois)"
msgstr "Inspections (6 months)"
#: loko/assets/static/assets/inspection_map.js:939
msgid "Inspections (1 an)"
msgstr "Inspections (1 year)"
#: loko/assets/static/assets/inspection_map.js:940
msgid "Inspections (Tout)"
msgstr "Inspections (All)"
msgid "Panneau"
msgstr "Sign"
msgid "Supprimer ce panneau"
msgstr "Delete this sign"
msgid "Mention (ex: Sauf riverains)"
msgstr "Mention (e.g. Except local traffic)"
msgid "Propreté :"
msgstr "Cleanliness:"
msgid "Propre"
msgstr "Clean"
msgid "Sale"
msgstr "Dirty"
msgid "Végétation"
msgstr "Vegetation"
msgid "Dégradé"
msgstr "Damaged"
msgid "Coordonnées GPS manquantes."
msgstr "Missing GPS coordinates."
msgid "Veuillez ajouter au moins un panneau."
msgstr "Please add at least one sign."
msgid "Panneaux créés avec succès !"
msgstr "Signs created successfully!"
msgid "Erreur : "
msgstr "Error: "
msgid "Impossible de créer les panneaux."
msgstr "Unable to create signs."
msgid "Erreur lors de la création : "
msgstr "Error during creation: "
msgid "Créer et Enregistrer"
msgstr "Create and Save"
msgid "Inspection enregistrée !"
msgstr "Inspection saved!"
msgid "Intervention"
msgstr "Intervention"
msgid "créée."
msgstr "created."
msgid "Erreur lors de la validation."
msgstr "Validation error."
msgid "Erreur réseau."
msgstr "Network error."
msgid "Zones de gestion"
msgstr "Management zones"
msgid "Aucune zone de gestion définie pour cette thématique."
msgstr "No management zone defined for this thematic."
msgid "zones"
msgstr "zones"
msgid "Afficher / masquer toutes les zones"
msgstr "Show / hide all zones"
msgid "Opacité"
msgstr "Opacity"
msgid "Fréquence cible :"
msgstr "Target frequency:"
msgid "jours"
msgstr "days"
msgid "Consignes & points d'attention"
msgstr "Instructions & points of attention"
msgid "Centrer sur la zone"
msgstr "Center on zone"

View file

@ -4839,8 +4839,8 @@ msgid "Description / Remarques"
msgstr "Description / Remarques"
#: assets/templates/assets/components/asset_interventions.html:169
msgid "Détails ou remarques initiales..."
msgstr "Détails ou remarques initiales..."
msgid "Détails ou remarques globales..."
msgstr "Détails ou remarques globales..."
#: assets/templates/assets/components/asset_interventions.html:176
#: assets/templates/assets/components/location_edit_modals.html:98
@ -4972,7 +4972,7 @@ msgstr "Remarques / Observations"
#: assets/templates/assets/components/inspection_form_modal.html:129
msgid "Saisir vos remarques ou détails..."
msgstr "Détails ou remarques initiales..."
msgstr "Détails ou remarques globales..."
#: assets/templates/assets/components/inspection_form_modal.html:138
msgid "Valider l'inspection"
@ -8624,6 +8624,128 @@ msgstr "Zones GIEP"
msgid "Aucune localisation GIEP trouvée à proximité"
msgstr "Aucune localisation GIEP trouvée à proximité"
msgid "Supprimer de la DB"
msgstr "Supprimer de la DB"
msgid "Supprimer l'existant de la DB"
msgstr "Supprimer l'existant de la DB"
msgid "Ne pas créer (Ignorer)"
msgstr "Ne pas créer (Ignorer)"
msgid "Archiver l'existant (Ne pas recréer)"
msgstr "Archiver l'existant (Ne pas recréer)"
msgid "Archiver l'existant"
msgstr "Archiver l'existant"
msgid "Aide au tracé des câbles"
msgstr "Aide au tracé des câbles"
msgid "Tracer"
msgstr "Tracer"
msgid "Double-clic sur la carte pour ajouter un point."
msgstr "Double-clic sur la carte pour ajouter un point."
msgid "Ajouter un nœud"
msgstr "Ajouter un nœud"
msgid "Double-clic sur un segment du câble."
msgstr "Double-clic sur un segment du câble."
msgid "Supprimer un nœud"
msgstr "Supprimer un nœud"
msgid "Double-clic sur un nœud (poignée orange)."
msgstr "Double-clic sur un nœud (poignée orange)."
msgid "Déplacer"
msgstr "Déplacer"
msgid "Glisser-déposer les poignées pour ajuster."
msgstr "Glisser-déposer les poignées pour ajuster."
msgid "Copier tracé"
msgstr "Copier tracé"
msgid ""
"Copiez le tracé d'un autre câble suivant le même trajet (ex: 48 et tel.)."
msgstr ""
"Copiez le tracé d'un autre câble suivant le même trajet (ex: 48 et tel.)."
msgid "Copier tracé…"
msgstr "Copier tracé…"
msgid "Copier le tracé d'un autre câble"
msgstr "Copier le tracé d'un autre câble"
msgid "Appliquer aux autres câbles…"
msgstr "Appliquer aux autres câbles…"
msgid ""
"Double-clic sur un segment pour ajouter un nœud • Double-clic sur un nœud "
"pour le supprimer • Glisser pour déplacer"
msgstr ""
"Double-clic sur un segment pour ajouter un nœud • Double-clic sur un nœud "
"pour le supprimer • Glisser pour déplacer"
msgid "Appliquer le tracé à d'autres câbles"
msgstr "Appliquer le tracé à d'autres câbles"
msgid "Copier le tracé de"
msgstr "Copier le tracé de"
msgid "vers les câbles sélectionnés ci-dessous :"
msgstr "vers les câbles sélectionnés ci-dessous :"
msgid "Copier le tracé"
msgstr "Copier le tracé"
msgid "Le câble source ne possède aucun tracé valide à copier."
msgstr "Le câble source ne possède aucun tracé valide à copier."
msgid "Aucun tracé disponible"
msgstr "Aucun tracé disponible"
msgid "Tracé copié de"
msgstr "Tracé copié de"
msgid "vers"
msgstr "vers"
msgid ""
"Veuillez d'abord tracer au moins deux points pour ce câble avant de le "
"dupliquer."
msgstr ""
"Veuillez d'abord tracer au moins deux points pour ce câble avant de le "
"dupliquer."
msgid "Aucun autre câble disponible dans ce carrefour."
msgstr "Aucun autre câble disponible dans ce carrefour."
msgid "Veuillez sélectionner au moins un câble cible."
msgstr "Veuillez sélectionner au moins un câble cible."
msgid "câble(s) mis à jour avec ce tracé !"
msgstr "câble(s) mis à jour avec ce tracé !"
#: assets/templates/assets/buildings/publicbuilding_detail.html
msgid "Tout replier"
msgstr "Tout replier"
#: assets/templates/assets/buildings/publicbuilding_detail.html
msgid "Tout déplier"
msgstr "Tout déplier"
#: assets/templates/assets/buildings/publicbuilding_detail.html
msgid "Assets physiques rattachés"
msgstr "Assets physiques rattachés"
#: assets/templates/assets/buildings/publicbuilding_detail.html
msgid "Assets rattachés directement au bâtiment"
msgstr "Assets rattachés directement au bâtiment"
#~ msgid "Absent du fichier Excel (Sera conservé ou archivé)"
#~ msgstr "Absent du fichier Excel (Sera conservé ou archivé)"
@ -8641,3 +8763,57 @@ msgstr "Aucune localisation GIEP trouvée à proximité"
#~ msgid "Arceaux cargo constatés"
#~ msgstr "Arceaux cargo constatés"
msgid "Type de zone de gestion"
msgstr "Type de zone de gestion"
msgid "Types de zones de gestion"
msgstr "Types de zones de gestion"
msgid "Zone de gestion"
msgstr "Zone de gestion"
msgid "Zones de gestion"
msgstr "Zones de gestion"
msgid "Consignes et points d'attention"
msgstr "Consignes et points d'attention"
msgid "Consignes d'exploitation, points de vigilance, règles de sécurité..."
msgstr "Consignes d'exploitation, points de vigilance, règles de sécurité..."
msgid "Périmètre géographique"
msgstr "Périmètre géographique"
msgid "Couleur d'affichage"
msgstr "Couleur d'affichage"
msgid "Code hexadécimal (ex: #198754). Si vide, utilise la couleur du type de zone."
msgstr "Code hexadécimal (ex: #198754). Si vide, utilise la couleur du type de zone."
msgid "Fréquence cible (jours)"
msgstr "Fréquence cible (jours)"
msgid "Intervalle indicatif entre interventions en jours."
msgstr "Intervalle indicatif entre interventions en jours."
msgid "Saisonnalité / Calendrier"
msgstr "Saisonnalité / Calendrier"
msgid "Ex: Tonte d'avril à octobre; fauche tardive en septembre; interdiction de taille au printemps."
msgstr "Ex: Tonte d'avril à octobre; fauche tardive en septembre; interdiction de taille au printemps."
msgid "Asset de zone de gestion"
msgstr "Asset de zone de gestion"
msgid "Assets de zones de gestion"
msgstr "Assets de zones de gestion"
msgid "Document de zone de gestion"
msgstr "Document de zone de gestion"
msgid "Documents de zones de gestion"
msgstr "Documents de zones de gestion"
msgid "Si coché, cet équipement est exclu de la zone même si sa géométrie intersecte le périmètre."
msgstr "Si coché, cet équipement est exclu de la zone même si sa géométrie intersecte le périmètre."

View file

@ -618,3 +618,134 @@ msgstr "Replier les modèles"
#: loko/assets/static/assets/unified_index.js:3726
msgid "Bloc replié. Cliquez sur \"Afficher les modèles\" pour voir les options."
msgstr "Bloc replié. Cliquez sur \"Afficher les modèles\" pour voir les options."
#: loko/assets/static/assets/inspection_map.js:93
msgid "Rechercher une adresse…"
msgstr "Rechercher une adresse…"
msgid "Rechercher une adresse..."
msgstr "Rechercher une adresse..."
#: loko/assets/static/assets/inspection_map.js:748
msgid "Géolocalisation..."
msgstr "Géolocalisation..."
#: loko/assets/static/assets/inspection_map.js:769
msgid "Position actualisée"
msgstr "Position actualisée"
#: loko/assets/static/assets/inspection_map.js:773
msgid "GPS indisponible"
msgstr "GPS indisponible"
#: loko/assets/static/assets/inspection_map.js:935
msgid "Inspections (Désactivé)"
msgstr "Inspections (Désactivé)"
#: loko/assets/static/assets/inspection_map.js:936
msgid "Inspections (1 mois)"
msgstr "Inspections (1 mois)"
#: loko/assets/static/assets/inspection_map.js:937
msgid "Inspections (3 mois)"
msgstr "Inspections (3 mois)"
#: loko/assets/static/assets/inspection_map.js:938
msgid "Inspections (6 mois)"
msgstr "Inspections (6 mois)"
#: loko/assets/static/assets/inspection_map.js:939
msgid "Inspections (1 an)"
msgstr "Inspections (1 an)"
#: loko/assets/static/assets/inspection_map.js:940
msgid "Inspections (Tout)"
msgstr "Inspections (Tout)"
msgid "Panneau"
msgstr "Panneau"
msgid "Supprimer ce panneau"
msgstr "Supprimer ce panneau"
msgid "Mention (ex: Sauf riverains)"
msgstr "Mention (ex: Sauf riverains)"
msgid "Propreté :"
msgstr "Propreté :"
msgid "Propre"
msgstr "Propre"
msgid "Sale"
msgstr "Sale"
msgid "Végétation"
msgstr "Végétation"
msgid "Dégradé"
msgstr "Dégradé"
msgid "Coordonnées GPS manquantes."
msgstr "Coordonnées GPS manquantes."
msgid "Veuillez ajouter au moins un panneau."
msgstr "Veuillez ajouter au moins un panneau."
msgid "Panneaux créés avec succès !"
msgstr "Panneaux créés avec succès !"
msgid "Erreur : "
msgstr "Erreur : "
msgid "Impossible de créer les panneaux."
msgstr "Impossible de créer les panneaux."
msgid "Erreur lors de la création : "
msgstr "Erreur lors de la création : "
msgid "Créer et Enregistrer"
msgstr "Créer et Enregistrer"
msgid "Inspection enregistrée !"
msgstr "Inspection enregistrée !"
msgid "Intervention"
msgstr "Intervention"
msgid "créée."
msgstr "créée."
msgid "Erreur lors de la validation."
msgstr "Erreur lors de la validation."
msgid "Erreur réseau."
msgstr "Erreur réseau."
msgid "Zones de gestion"
msgstr "Zones de gestion"
msgid "Aucune zone de gestion définie pour cette thématique."
msgstr "Aucune zone de gestion définie pour cette thématique."
msgid "zones"
msgstr "zones"
msgid "Afficher / masquer toutes les zones"
msgstr "Afficher / masquer toutes les zones"
msgid "Opacité"
msgstr "Opacité"
msgid "Fréquence cible :"
msgstr "Fréquence cible :"
msgid "jours"
msgstr "jours"
msgid "Consignes & points d'attention"
msgstr "Consignes & points d'attention"
msgid "Centrer sur la zone"
msgstr "Centrer sur la zone"

View file

@ -4834,8 +4834,8 @@ msgid "Description / Remarques"
msgstr "Hardwarebeschrijving"
#: assets/templates/assets/components/asset_interventions.html:169
msgid "Détails ou remarques initiales..."
msgstr "Details of eerste opmerkingen..."
msgid "Détails ou remarques globales..."
msgstr "Details of globale opmerkingen..."
#: assets/templates/assets/components/asset_interventions.html:176
#: assets/templates/assets/components/location_edit_modals.html:98
@ -4967,7 +4967,7 @@ msgstr "Observaties"
#: assets/templates/assets/components/inspection_form_modal.html:129
msgid "Saisir vos remarques ou détails..."
msgstr "Details of eerste opmerkingen..."
msgstr "Details of globale opmerkingen..."
#: assets/templates/assets/components/inspection_form_modal.html:138
msgid "Valider l'inspection"
@ -8572,6 +8572,128 @@ msgstr "GIEP-zones"
msgid "Aucune localisation GIEP trouvée à proximité"
msgstr "Geen locatie gevonden."
msgid "Supprimer de la DB"
msgstr "Verwijderen uit de database"
msgid "Supprimer l'existant de la DB"
msgstr "Bestaande verwijderen uit de database"
msgid "Ne pas créer (Ignorer)"
msgstr "Niet aanmaken (Negeren)"
msgid "Archiver l'existant (Ne pas recréer)"
msgstr "Bestaande archiveren (niet opnieuw aanmaken)"
msgid "Archiver l'existant"
msgstr "Bestaande archiveren"
msgid "Aide au tracé des câbles"
msgstr "Hulp bij het traceren van kabels"
msgid "Tracer"
msgstr "Traceren"
msgid "Double-clic sur la carte pour ajouter un point."
msgstr "Dubbelklik op de kaart om een punt toe te voegen."
msgid "Ajouter un nœud"
msgstr "Knooppunt toevoegen"
msgid "Double-clic sur un segment du câble."
msgstr "Dubbelklik op een segment van de kabel."
msgid "Supprimer un nœud"
msgstr "Knooppunt verwijderen"
msgid "Double-clic sur un nœud (poignée orange)."
msgstr "Dubbelklik op een knooppunt (oranje handgreep)."
msgid "Déplacer"
msgstr "Verplaatsen"
msgid "Glisser-déposer les poignées pour ajuster."
msgstr "Sleep de handgrepen om aan te passen."
msgid "Copier tracé"
msgstr "Tracé kopiëren"
msgid ""
"Copiez le tracé d'un autre câble suivant le même trajet (ex: 48 et tel.)."
msgstr ""
"Kopieer het tracé van een andere kabel die hetzelfde traject volgt (bv. 48 "
"en tel.)."
msgid "Copier tracé…"
msgstr "Tracé kopiëren…"
msgid "Copier le tracé d'un autre câble"
msgstr "Het tracé van een andere kabel kopiëren"
msgid "Appliquer aux autres câbles…"
msgstr "Toepassen op andere kabels…"
msgid ""
"Double-clic sur un segment pour ajouter un nœud • Double-clic sur un nœud "
"pour le supprimer • Glisser pour déplacer"
msgstr ""
"Dubbelklik op een segment om een knooppunt toe te voegen • Dubbelklik op een"
" knooppunt om te verwijderen • Sleep om te verplaatsen"
msgid "Appliquer le tracé à d'autres câbles"
msgstr "Tracé toepassen op andere kabels"
msgid "Copier le tracé de"
msgstr "Tracé kopiëren van"
msgid "vers les câbles sélectionnés ci-dessous :"
msgstr "naar de onderstaande geselecteerde kabels:"
msgid "Copier le tracé"
msgstr "Tracé kopiëren"
msgid "Le câble source ne possède aucun tracé valide à copier."
msgstr "De bronkabel heeft geen geldig tracé om te kopiëren."
msgid "Aucun tracé disponible"
msgstr "Geen tracé beschikbaar"
msgid "Tracé copié de"
msgstr "Tracé gekopieerd van"
msgid "vers"
msgstr "naar"
msgid ""
"Veuillez d'abord tracer au moins deux points pour ce câble avant de le "
"dupliquer."
msgstr ""
"Teken eerst ten minste twee punten voor deze kabel alvorens te dupliceren."
msgid "Aucun autre câble disponible dans ce carrefour."
msgstr "Geen andere kabel beschikbaar op dit kruispunt."
msgid "Veuillez sélectionner au moins un câble cible."
msgstr "Selecteer ten minste één doelkabel."
msgid "câble(s) mis à jour avec ce tracé !"
msgstr "kabel(s) bijgewerkt met dit tracé!"
#: assets/templates/assets/buildings/publicbuilding_detail.html
msgid "Tout replier"
msgstr "Alles inklappen"
#: assets/templates/assets/buildings/publicbuilding_detail.html
msgid "Tout déplier"
msgstr "Alles uitklappen"
#: assets/templates/assets/buildings/publicbuilding_detail.html
msgid "Assets physiques rattachés"
msgstr "Gekoppelde fysieke assets"
#: assets/templates/assets/buildings/publicbuilding_detail.html
msgid "Assets rattachés directement au bâtiment"
msgstr "Assets direct gekoppeld aan gebouw"
#~ msgid "Absent du fichier Excel (Sera conservé ou archivé)"
#~ msgstr "Ontbreekt in het Excel-bestand (Wordt bewaard of gearchiveerd)"
@ -8589,3 +8711,57 @@ msgstr "Geen locatie gevonden."
#~ msgid "Arceaux cargo constatés"
#~ msgstr "Vastgestelde cargobeugels"
msgid "Type de zone de gestion"
msgstr "Beheerszonetype"
msgid "Types de zones de gestion"
msgstr "Beheerszonetypes"
msgid "Zone de gestion"
msgstr "Beheerszone"
msgid "Zones de gestion"
msgstr "Beheerszones"
msgid "Consignes et points d'attention"
msgstr "Instructies en aandachtspunten"
msgid "Consignes d'exploitation, points de vigilance, règles de sécurité..."
msgstr "Exploitatie-instructies, aandachtspunten, veiligheidsregels..."
msgid "Périmètre géographique"
msgstr "Geografische perimeter"
msgid "Couleur d'affichage"
msgstr "Weergavekleur"
msgid "Code hexadécimal (ex: #198754). Si vide, utilise la couleur du type de zone."
msgstr "Hexadecimale code (bijv. #198754). Indien leeg, wordt de kleur van het zonetype gebruikt."
msgid "Fréquence cible (jours)"
msgstr "Doelfrequentie (dagen)"
msgid "Intervalle indicatif entre interventions en jours."
msgstr "Indicatief interval tussen interventies in dagen."
msgid "Saisonnalité / Calendrier"
msgstr "Seizoensgebondenheid / Kalender"
msgid "Ex: Tonte d'avril à octobre; fauche tardive en septembre; interdiction de taille au printemps."
msgstr "Bijv.: Maaien van april tot oktober; laat maaien in september; snoeiverbod in het voorjaar."
msgid "Asset de zone de gestion"
msgstr "Beheerszone-asset"
msgid "Assets de zones de gestion"
msgstr "Beheerszone-assets"
msgid "Document de zone de gestion"
msgstr "Beheerszonedocument"
msgid "Documents de zones de gestion"
msgstr "Beheerszonedocumenten"
msgid "Si coché, cet équipement est exclu de la zone même si sa géométrie intersecte le périmètre."
msgstr "Indien aangevinkt, wordt dit asset uitgesloten van de zone, zelfs als de geometrie de perimeter doorsnijdt."

View file

@ -615,3 +615,134 @@ msgstr "Modellen inklappen"
#: loko/assets/static/assets/unified_index.js:3726
msgid "Bloc replié. Cliquez sur \"Afficher les modèles\" pour voir les options."
msgstr ""
#: loko/assets/static/assets/inspection_map.js:93
msgid "Rechercher une adresse…"
msgstr "Zoek een adres…"
msgid "Rechercher une adresse..."
msgstr "Zoek een adres..."
#: loko/assets/static/assets/inspection_map.js:748
msgid "Géolocalisation..."
msgstr "Geolocatie..."
#: loko/assets/static/assets/inspection_map.js:769
msgid "Position actualisée"
msgstr "Positie bijgewerkt"
#: loko/assets/static/assets/inspection_map.js:773
msgid "GPS indisponible"
msgstr "GPS niet beschikbaar"
#: loko/assets/static/assets/inspection_map.js:935
msgid "Inspections (Désactivé)"
msgstr "Inspecties (Uitgeschakeld)"
#: loko/assets/static/assets/inspection_map.js:936
msgid "Inspections (1 mois)"
msgstr "Inspecties (1 maand)"
#: loko/assets/static/assets/inspection_map.js:937
msgid "Inspections (3 mois)"
msgstr "Inspecties (3 maanden)"
#: loko/assets/static/assets/inspection_map.js:938
msgid "Inspections (6 mois)"
msgstr "Inspecties (6 maanden)"
#: loko/assets/static/assets/inspection_map.js:939
msgid "Inspections (1 an)"
msgstr "Inspecties (1 jaar)"
#: loko/assets/static/assets/inspection_map.js:940
msgid "Inspections (Tout)"
msgstr "Inspecties (Alles)"
msgid "Panneau"
msgstr "Bord"
msgid "Supprimer ce panneau"
msgstr "Dit bord verwijderen"
msgid "Mention (ex: Sauf riverains)"
msgstr "Vermelding (bijv. Uitgezonderd aangelanden)"
msgid "Propreté :"
msgstr "Netheid:"
msgid "Propre"
msgstr "Schoon"
msgid "Sale"
msgstr "Vuil"
msgid "Végétation"
msgstr "Vegetatie"
msgid "Dégradé"
msgstr "Beschadigd"
msgid "Coordonnées GPS manquantes."
msgstr "Ontbrekende gps-coördinaten."
msgid "Veuillez ajouter au moins un panneau."
msgstr "Voeg minimaal één bord toe."
msgid "Panneaux créés avec succès !"
msgstr "Borden succesvol aangemaakt!"
msgid "Erreur : "
msgstr "Fout: "
msgid "Impossible de créer les panneaux."
msgstr "Kan de borden niet aanmaken."
msgid "Erreur lors de la création : "
msgstr "Fout bij aanmaken: "
msgid "Créer et Enregistrer"
msgstr "Aanmaken en opslaan"
msgid "Inspection enregistrée !"
msgstr "Inspectie opgeslagen!"
msgid "Intervention"
msgstr "Interventie"
msgid "créée."
msgstr "aangemaakt."
msgid "Erreur lors de la validation."
msgstr "Fout bij validatie."
msgid "Erreur réseau."
msgstr "Netwerkfout."
msgid "Zones de gestion"
msgstr "Beheerszones"
msgid "Aucune zone de gestion définie pour cette thématique."
msgstr "Geen beheerszone gedefinieerd voor dit thema."
msgid "zones"
msgstr "zones"
msgid "Afficher / masquer toutes les zones"
msgstr "Alle zones tonen / verbergen"
msgid "Opacité"
msgstr "Dekking"
msgid "Fréquence cible :"
msgstr "Doelfrequentie:"
msgid "jours"
msgstr "dagen"
msgid "Consignes & points d'attention"
msgstr "Instructies & aandachtspunten"
msgid "Centrer sur la zone"
msgstr "Centreren op zone"

View file

@ -0,0 +1,220 @@
import time
from datetime import datetime
from django.core.management.base import BaseCommand
from django.db import connection, transaction
from assets.models import NatureLocation, NatureTree
from common.models import Municipality
MUNICIPALITY_PREFIXES = {
'AND': 'Anderlecht',
'AUD': 'Auderghem',
'BRK': 'Berchem-Sainte-Agathe',
'BSA': 'Berchem-Sainte-Agathe',
'BXL': 'Bruxelles',
'BRU': 'Bruxelles',
'ETT': 'Etterbeek',
'EVE': 'Evere',
'FOR': 'Forest',
'GAN': 'Ganshoren',
'IXL': 'Ixelles',
'IXE': 'Ixelles',
'JET': 'Jette',
'KOE': 'Koekelberg',
'MOL': 'Molenbeek-Saint-Jean',
'STG': 'Saint-Gilles',
'STJ': 'Saint-Josse-ten-Noode',
'SCH': 'Schaerbeek',
'UCC': 'Uccle',
'WAT': 'Watermael-Boitsfort',
'WLU': 'Woluwe-Saint-Lambert',
'WSL': 'Woluwe-Saint-Lambert',
'WSP': 'Woluwe-Saint-Pierre',
}
class Command(BaseCommand):
help = "Rattache spatialement les arbres (NatureTree) aux tronçons des sections vertes (NatureLocation, ex: WAT_R22_008)."
def add_arguments(self, parser):
parser.add_argument(
'--max-distance',
type=float,
default=25.0,
help="Distance maximale en mètres pour le rattachement (défaut: 25.0m, 0 pour intersection stricte)"
)
parser.add_argument(
'--section-code',
type=str,
default=None,
help="Code d'un tronçon spécifique à cibler (ex: WAT_R22_008)"
)
parser.add_argument(
'--dry-run',
action='store_true',
help="Simuler le rattachement sans modifier la base de données"
)
parser.add_argument(
'--skip-sync-munis',
action='store_true',
help="Ne pas synchroniser la commune sur les sections vertes"
)
def log(self, msg):
self.stdout.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}")
def handle(self, *args, **options):
max_dist = options['max_distance']
target_section = options['section_code']
dry_run = options['dry_run']
sync_munis = not options['skip_sync_munis']
self.log("=== RATTACHEMENT SPATIAL DES ARBRES AUX TRONÇONS DE SECTIONS VERTES ===")
self.log(f"Distance max : {max_dist} m {'(Intersection stricte)' if max_dist == 0 else ''}")
if target_section:
self.log(f"Section ciblée : {target_section}")
if dry_run:
self.log("[SIMULATION / DRY-RUN] Aucune modification ne sera enregistrée.")
# -----------------------------------------------------------------
# 1. Synchronisation des communes sur les sections vertes
# -----------------------------------------------------------------
if sync_munis and not dry_run:
self.log("1. Synchronisation des communes sur les sections vertes...")
munis_by_name = {m.name_fr.lower(): m for m in Municipality.objects.all()}
updated_munis = 0
with connection.cursor() as cursor:
# Intersections géométriques
cursor.execute("""
UPDATE assets_naturelocation l
SET municipality_id = m.id
FROM common_municipality m
WHERE l.code ~ '^[A-Z]{3}_'
AND l.municipality_id IS NULL
AND l.geom IS NOT NULL
AND ST_Intersects(ST_PointOnSurface(l.geom), m.geom);
""")
updated_munis += cursor.rowcount
# Complément par préfixe de code pour les cas frontaliers
locs_without_muni = NatureLocation.objects.filter(code__regex=r'^[A-Z]{3}_', municipality__isnull=True)
for loc in locs_without_muni:
pfx = loc.code[:3].upper()
muni_name = MUNICIPALITY_PREFIXES.get(pfx)
if muni_name and muni_name.lower() in munis_by_name:
loc.municipality = munis_by_name[muni_name.lower()]
loc.save(update_fields=['municipality'])
updated_munis += 1
self.log(f" Communes renseignées sur {updated_munis} sections vertes.")
# -----------------------------------------------------------------
# 2. Analyse et Rattachement des Arbres
# -----------------------------------------------------------------
t0 = time.time()
self.log("2. Analyse géospatiale du rattachement arbres -> sections vertes...")
section_filter_sql = ""
params = [max_dist]
if target_section:
section_filter_sql = "AND nearest.code = %s"
params.append(target_section)
count_query = f"""
WITH nearest AS (
SELECT
t.id AS tree_id,
nearest_loc.id AS loc_id,
nearest_loc.code AS loc_code,
nearest_loc.name_fr AS loc_name,
nearest_loc.dist
FROM assets_naturetree t
CROSS JOIN LATERAL (
SELECT l.id, l.code, l.name_fr, ST_Distance(t.geom, l.geom) AS dist
FROM assets_naturelocation l
WHERE l.code ~ '^[A-Z]{{3}}_' AND l.geom IS NOT NULL
ORDER BY t.geom <-> l.geom
LIMIT 1
) nearest_loc
WHERE t.geom IS NOT NULL AND t.lat != 0 AND t.lon != 0
AND nearest_loc.dist <= %s
)
SELECT
COUNT(*) AS total_matched,
COUNT(*) FILTER (WHERE dist = 0) AS inside,
COUNT(*) FILTER (WHERE dist > 0 AND dist <= 5) AS within_5m,
COUNT(*) FILTER (WHERE dist > 5 AND dist <= 15) AS within_15m,
COUNT(*) FILTER (WHERE dist > 15 AND dist <= 25) AS within_25m,
COUNT(*) FILTER (WHERE dist > 25) AS beyond_25m,
COUNT(DISTINCT loc_id) AS distinct_sections
FROM nearest
WHERE 1=1 {section_filter_sql};
"""
with connection.cursor() as cursor:
cursor.execute(count_query, params)
stats = cursor.fetchone()
total_matched = stats[0]
inside = stats[1]
w5m = stats[2]
w15m = stats[3]
w25m = stats[4]
b25m = stats[5]
distinct_sections = stats[6]
self.log(f" Arbres éligibles au rattachement : {total_matched}")
self.log(f" - Directement à l'intérieur (0m) : {inside}")
self.log(f" - Proximité immédiate (1 à 5m) : {w5m}")
self.log(f" - Bordure de voirie (5 à 15m) : {w15m}")
self.log(f" - Zone étendue (15 à 25m) : {w25m}")
if b25m:
self.log(f" - Au-delà (> 25m) : {b25m}")
self.log(f" Nombre de tronçons / sections concernés : {distinct_sections}")
if dry_run:
self.log("Simulation terminée avec succès (aucune écriture en base).")
return
# -----------------------------------------------------------------
# 3. Application de la mise à jour
# -----------------------------------------------------------------
self.log("3. Application de la mise à jour des liaisons (location_id)...")
update_query = f"""
WITH candidates AS (
SELECT
t.id AS tree_id,
nearest.id AS loc_id
FROM assets_naturetree t
CROSS JOIN LATERAL (
SELECT l.id, l.code, ST_Distance(t.geom, l.geom) AS dist
FROM assets_naturelocation l
WHERE l.code ~ '^[A-Z]{{3}}_' AND l.geom IS NOT NULL
ORDER BY t.geom <-> l.geom
LIMIT 1
) nearest
WHERE t.geom IS NOT NULL AND t.lat != 0 AND t.lon != 0
AND nearest.dist <= %s
{section_filter_sql}
)
UPDATE assets_naturetree t
SET location_id = c.loc_id
FROM candidates c
WHERE t.id = c.tree_id
AND (t.location_id IS DISTINCT FROM c.loc_id);
"""
with connection.cursor() as cursor:
cursor.execute(update_query, params)
updated_count = cursor.rowcount
t1 = time.time()
self.log(f"Mise à jour terminée : {updated_count} arbres rattachés en {t1 - t0:.2f} secondes.")
# Vérification ciblée (par exemple WAT_R22_008)
check_code = target_section or 'WAT_R22_008'
check_loc = NatureLocation.objects.filter(code=check_code).first()
if check_loc:
attached_cnt = check_loc.nature_trees.count()
self.log(f"Vérification sur {check_code} ({check_loc.name_fr}) : {attached_cnt} arbres rattachés.")

View file

@ -0,0 +1,279 @@
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.db import connection, transaction
from django.utils import timezone
from django.contrib.contenttypes.models import ContentType
from assets.models import NatureTree
from interventions.models import Intervention, SourceCategory
class Command(BaseCommand):
help = (
"Annule les interventions arboricoles caduques ou périmées :\n"
"1. Interventions prescrites avant un diagnostic plus récent sur l'arbre (remplacées par ré-inspection).\n"
"2. Prescriptions actives dont la date limite est échue depuis plus de X mois (défaut : 12 mois).\n"
"Met à jour les événements de timeline et recalcule automatiquement les dates de prochaine inspection."
)
def add_arguments(self, parser):
parser.add_argument(
'--months',
type=int,
default=12,
help="Délai en mois après échéance au-delà duquel une prescription non exécutée est annulée (défaut : 12).",
)
parser.add_argument(
'--dry-run',
action='store_true',
help="Simuler l'opération et afficher les comptages sans modifier la base de données.",
)
parser.add_argument(
'--no-recalc-trees',
action='store_true',
help="Ne pas recalculer next_inspection_date sur les arbres après l'annulation.",
)
def handle(self, *args, **options):
months = options['months']
dry_run = options['dry_run']
recalc_trees = not options['no_recalc_trees']
now = timezone.now()
# Calcul de la date seuil d'échéance (ex: il y a 12 mois)
cutoff_date = (now - timedelta(days=int(months * 30.4375))).date()
self.stdout.write(self.style.MIGRATE_HEADING(
f"=== Nettoyage des interventions d'arbres caduques et échues ==="
))
self.stdout.write(f"Date de référence : {now.strftime('%d/%m/%Y %H:%M')}")
self.stdout.write(f"Seuil d'échéance ({months} mois) : interventions échues avant le {cutoff_date.strftime('%d/%m/%Y')}\n")
if dry_run:
self.stdout.write(self.style.WARNING("[MODE SIMULATION] Aucune modification ne sera enregistrée.\n"))
content_type_tree = ContentType.objects.get_for_model(NatureTree)
source_fulcrum = SourceCategory.objects.filter(name_fr="Fulcrum").first()
source_fulcrum_id = source_fulcrum.id if source_fulcrum else None
with connection.cursor() as cur:
# -------------------------------------------------------------
# COMPTAGE RÈGLE 1 : Diagnostic plus récent sur l'arbre
# -------------------------------------------------------------
cur.execute("""
SELECT COUNT(DISTINCT i.id)
FROM interventions_intervention i
JOIN interventions_interventionasset ia ON ia.intervention_id = i.id
JOIN assets_naturetree t ON t.id = ia.object_id
WHERE ia.content_type_id = %s
AND i.status NOT IN ('finished', 'canceled')
AND t.last_phytosanitary_date IS NOT NULL
AND (
(i.expected_begin_time IS NOT NULL AND i.expected_begin_time::date < t.last_phytosanitary_date)
OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < t.last_phytosanitary_date)
);
""", [content_type_tree.id])
count_rule_diag = cur.fetchone()[0]
# -------------------------------------------------------------
# COMPTAGE RÈGLE 2 : Échéance dépassée > X mois (sans être dans règle 1)
# -------------------------------------------------------------
cur.execute("""
SELECT COUNT(DISTINCT i.id)
FROM interventions_intervention i
LEFT JOIN interventions_interventionasset ia ON ia.intervention_id = i.id
LEFT JOIN assets_naturetree t ON t.id = ia.object_id AND ia.content_type_id = %s
WHERE i.status NOT IN ('finished', 'canceled')
AND (ia.content_type_id = %s OR i.source_category_id = %s)
AND i.expected_end_time IS NOT NULL
AND i.expected_end_time::date < %s
AND NOT (
t.last_phytosanitary_date IS NOT NULL
AND (
(i.expected_begin_time IS NOT NULL AND i.expected_begin_time::date < t.last_phytosanitary_date)
OR (i.expected_end_time::date < t.last_phytosanitary_date)
)
);
""", [content_type_tree.id, content_type_tree.id, source_fulcrum_id, cutoff_date])
count_rule_expired = cur.fetchone()[0]
# Total combiné
cur.execute("""
SELECT COUNT(DISTINCT i.id)
FROM interventions_intervention i
LEFT JOIN interventions_interventionasset ia ON ia.intervention_id = i.id
LEFT JOIN assets_naturetree t ON t.id = ia.object_id AND ia.content_type_id = %s
WHERE i.status NOT IN ('finished', 'canceled')
AND (ia.content_type_id = %s OR i.source_category_id = %s)
AND (
(t.last_phytosanitary_date IS NOT NULL AND (
(i.expected_begin_time IS NOT NULL AND i.expected_begin_time::date < t.last_phytosanitary_date)
OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < t.last_phytosanitary_date)
))
OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < %s)
);
""", [content_type_tree.id, content_type_tree.id, source_fulcrum_id, cutoff_date])
total_to_cancel = cur.fetchone()[0]
self.stdout.write(f"1. Prescriptions caduques (arbre ré-inspecté ultérieurement) : {count_rule_diag}")
self.stdout.write(f"2. Prescriptions échues depuis plus de {months} mois sans nouveau diag : {count_rule_expired}")
self.stdout.write(self.style.MIGRATE_LABEL(f"Total des interventions à annuler : {total_to_cancel}\n"))
if total_to_cancel == 0:
self.stdout.write(self.style.SUCCESS("Aucune intervention à annuler. La base est déjà assainie."))
return
if dry_run:
self.stdout.write(self.style.WARNING(
f"[MODE SIMULATION] {total_to_cancel} interventions auraient été passées au statut 'canceled'.\n"
"Exécutez sans --dry-run pour appliquer."
))
return
# -------------------------------------------------------------
# EXÉCUTION RÉELLE EN TRANSACTION
# -------------------------------------------------------------
self.stdout.write("Application des annulations...")
with transaction.atomic():
with connection.cursor() as cur:
# 1a. Timeline events pour la règle 1 (diagnostic plus récent)
cur.execute("""
INSERT INTO interventions_interventiontimeline (
intervention_id, event_time, event_type, from_status, to_status,
event_description, field_changes
)
SELECT
i.id,
%s,
'status_change',
i.status,
'canceled',
'Intervention annulée automatiquement : prescription caduque suite au nouveau diagnostic de l''arbre du ' ||
TO_CHAR(t.last_phytosanitary_date, 'DD/MM/YYYY') || '.',
('{"status": ["' || i.status || '", "canceled"]}')::json
FROM interventions_intervention i
JOIN interventions_interventionasset ia ON ia.intervention_id = i.id
JOIN assets_naturetree t ON t.id = ia.object_id
WHERE ia.content_type_id = %s
AND i.status NOT IN ('finished', 'canceled')
AND t.last_phytosanitary_date IS NOT NULL
AND (
(i.expected_begin_time IS NOT NULL AND i.expected_begin_time::date < t.last_phytosanitary_date)
OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < t.last_phytosanitary_date)
)
AND NOT EXISTS (
SELECT 1 FROM interventions_interventiontimeline tl
WHERE tl.intervention_id = i.id
AND tl.to_status = 'canceled'
);
""", [now, content_type_tree.id])
tl_diag_count = cur.rowcount
# 1b. Timeline events pour la règle 2 (échues > X mois)
cur.execute("""
INSERT INTO interventions_interventiontimeline (
intervention_id, event_time, event_type, from_status, to_status,
event_description, field_changes
)
SELECT
i.id,
%s,
'status_change',
i.status,
'canceled',
'Intervention annulée automatiquement : prescription échue depuis plus de ' || %s || ' mois (' ||
TO_CHAR(i.expected_end_time, 'DD/MM/YYYY') || ') sans nouveau diagnostic.',
('{"status": ["' || i.status || '", "canceled"]}')::json
FROM interventions_intervention i
LEFT JOIN interventions_interventionasset ia ON ia.intervention_id = i.id
WHERE i.status NOT IN ('finished', 'canceled')
AND (ia.content_type_id = %s OR i.source_category_id = %s)
AND i.expected_end_time IS NOT NULL
AND i.expected_end_time::date < %s
AND NOT EXISTS (
SELECT 1 FROM interventions_interventiontimeline tl
WHERE tl.intervention_id = i.id
AND tl.to_status = 'canceled'
);
""", [now, months, content_type_tree.id, source_fulcrum_id, cutoff_date])
tl_expired_count = cur.rowcount
# 2. Mise à jour effective du statut vers 'canceled'
cur.execute("""
UPDATE interventions_intervention i
SET status = 'canceled',
status_order = 200,
cancellation_type = 'non_relevant'
FROM interventions_interventionasset ia
LEFT JOIN assets_naturetree t ON t.id = ia.object_id AND ia.content_type_id = %s
WHERE ia.intervention_id = i.id
AND (ia.content_type_id = %s OR i.source_category_id = %s)
AND i.status NOT IN ('finished', 'canceled')
AND (
(t.last_phytosanitary_date IS NOT NULL AND (
(i.expected_begin_time IS NOT NULL AND i.expected_begin_time::date < t.last_phytosanitary_date)
OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < t.last_phytosanitary_date)
))
OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < %s)
);
""", [content_type_tree.id, content_type_tree.id, source_fulcrum_id, cutoff_date])
updated_count = cur.rowcount
# Traitement des interventions orphelines sans lien ia (ex: source Fulcrum sans arbre matché)
cur.execute("""
UPDATE interventions_intervention i
SET status = 'canceled',
status_order = 200,
cancellation_type = 'non_relevant'
WHERE i.source_category_id = %s
AND i.status NOT IN ('finished', 'canceled')
AND i.expected_end_time IS NOT NULL
AND i.expected_end_time::date < %s;
""", [source_fulcrum_id, cutoff_date])
updated_count += cur.rowcount
self.stdout.write(self.style.SUCCESS(
f" {updated_count} interventions passées au statut 'canceled'."
))
self.stdout.write(f" {tl_diag_count + tl_expired_count} événements d'historique créés dans la timeline.")
# 3. Recalcul des prochaines dates d'inspection sur les arbres
if recalc_trees:
self.stdout.write("Recalcul des prochaines inspections sur les arbres...")
# 3a. Arbres avec intervention active résiduelle
cur.execute("""
UPDATE assets_naturetree t
SET next_inspection_date = sub.min_date
FROM (
SELECT ia.object_id AS tree_id, MIN(i.expected_end_time)::date AS min_date
FROM interventions_interventionasset ia
JOIN interventions_intervention i ON i.id = ia.intervention_id
WHERE ia.content_type_id = %s
AND i.status NOT IN ('finished', 'canceled')
AND i.expected_end_time IS NOT NULL
GROUP BY ia.object_id
) sub
WHERE t.id = sub.tree_id;
""", [content_type_tree.id])
trees_active = cur.rowcount
# 3b. Arbres sans intervention active : formule théorique = dernier diagnostic + périodicité
cur.execute("""
UPDATE assets_naturetree t
SET next_inspection_date = (t.last_phytosanitary_date + (COALESCE(t.pruning_frequency_years, 3) || ' years')::interval)::date
WHERE t.last_phytosanitary_date IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM interventions_interventionasset ia
JOIN interventions_intervention i ON i.id = ia.intervention_id
WHERE ia.content_type_id = %s
AND ia.object_id = t.id
AND i.status NOT IN ('finished', 'canceled')
AND i.expected_end_time IS NOT NULL
);
""", [content_type_tree.id])
trees_theo = cur.rowcount
self.stdout.write(f" {trees_active} arbres mis à jour avec prochaine intervention active.")
self.stdout.write(f" {trees_theo} arbres recalculés selon cycle théorique (diag + périodicité).")
self.stdout.write(self.style.SUCCESS("\nOpération terminée avec succès !"))

View file

@ -0,0 +1,210 @@
"""
Management command: create_structure_folders
===========================================
Crée les répertoires par défaut de gestion documentaire pour les structures (ouvrages d'art)
qui n'en possèdent pas encore (ex: stations et tunnels de métro récemment importés).
Pour chaque ouvrage d'art traité :
1. Crée le dossier racine rattaché à l'ouvrage via DocumentFolderAttachment (is_default=True) :
- Nom : "<code_ouvrage> - <nom_ouvrage>" (ex: "MS001 - Station Albert")
- Slug : "<code_minuscule>" (ex: "ms001")
- Dossier parent : "Structures" (slug: "assets-structures")
- Thématique : "structures"
- Visibilité : "internal"
2. Crée l'arborescence standard de sous-dossiers :
- Civil engineering
├── Inspection reports
└── Exceptional transport
- Electromechanics
└── Preventive maintenance
Usage :
python manage.py create_structure_folders [--dry-run] [--type TYPE] [--code CODE]
"""
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils.text import slugify
from assets.models.structures import Structure
from common.models import Thematic
from documents.models import DocumentFolder, DocumentFolderAttachment, VisibilityScope
User = get_user_model()
def ensure_structure_folders(structure, creator_user=None, dry_run=False):
"""
Crée ou s'assure de l'existence du dossier racine et des sous-dossiers
standards pour un ouvrage d'art (Structure).
Idempotent : ne recrée pas ce qui existe déjà.
"""
structure_ct = ContentType.objects.get_for_model(Structure)
structures_thematic = Thematic.objects.filter(code='structures').first()
structures_parent_folder = DocumentFolder.objects.filter(slug='assets-structures').first()
root_slug = slugify(structure.code)
root_folder = DocumentFolder.objects.filter(slug=root_slug).first()
root_attachment = DocumentFolderAttachment.objects.filter(
content_type=structure_ct,
object_id=structure.pk,
is_default=True
).select_related('folder').first()
if not root_folder and root_attachment:
root_folder = root_attachment.folder
root_name = f"{structure.code} - {structure.get_name()}"
root_desc = f"Dossier pour la structure {structure.code} - {structure.get_name()}"
created_root = False
if not root_folder:
if not dry_run:
root_folder = DocumentFolder.objects.create(
name=root_name,
slug=root_slug,
description=root_desc,
visibility=VisibilityScope.INTERNAL,
is_predefined=True,
application_label='structures',
created_by=creator_user
)
if structures_thematic:
root_folder.thematics.add(structures_thematic)
if structures_parent_folder:
root_folder.parent_folders.add(structures_parent_folder)
created_root = True
if not dry_run and root_folder and not root_attachment:
DocumentFolderAttachment.objects.get_or_create(
content_type=structure_ct,
object_id=structure.pk,
folder=root_folder,
defaults={'is_default': True}
)
subfolders_created = 0
def get_or_create_subfolder(parent, name, sub_suffix):
nonlocal subfolders_created
base_slug = f"{root_slug}-{sub_suffix}"[:150]
sub = DocumentFolder.objects.filter(slug=base_slug).first()
if not sub and parent and getattr(parent, 'pk', None):
sub = parent.child_folders.filter(name=name).first()
if not sub:
if not dry_run:
sub = DocumentFolder.objects.create(
name=name,
slug=base_slug,
visibility=VisibilityScope.INTERNAL,
is_predefined=False,
application_label='structures',
created_by=creator_user
)
if structures_thematic:
sub.thematics.add(structures_thematic)
if parent and getattr(parent, 'pk', None):
sub.parent_folders.add(parent)
subfolders_created += 1
return sub, True
return sub, False
# 1. Civil engineering & sous-dossiers
civil_folder, _ = get_or_create_subfolder(root_folder, "Civil engineering", "civil-engineering")
get_or_create_subfolder(civil_folder, "Inspection reports", "civil-engineering-inspection-reports")
get_or_create_subfolder(civil_folder, "Exceptional transport", "civil-engineering-exceptional-transport")
# 2. Electromechanics & sous-dossiers
em_folder, _ = get_or_create_subfolder(root_folder, "Electromechanics", "electromechanics")
get_or_create_subfolder(em_folder, "Preventive maintenance", "electromechanics-preventive-maintenance")
return {
'root_folder': root_folder,
'created_root': created_root,
'subfolders_created': subfolders_created
}
class Command(BaseCommand):
help = "Crée les répertoires par défaut de gestion documentaire pour les structures."
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help="Simule l'exécution sans effectuer d'écritures en base de données."
)
parser.add_argument(
'--type',
dest='structure_type',
help="Filtre par type de structure (ex: metro_station,metro_tunnel)."
)
parser.add_argument(
'--code',
dest='structure_code',
help="Cible un ouvrage spécifique via son code (ex: MS001)."
)
parser.add_argument(
'--user',
dest='username',
help="Nom d'utilisateur créateur des dossiers (par défaut kdeterme ou superuser)."
)
def handle(self, *args, **options):
dry_run = options['dry_run']
structure_type = options.get('structure_type')
structure_code = options.get('structure_code')
username = options.get('username')
if dry_run:
self.stdout.write(self.style.WARNING("=== Mode simulation (--dry-run) activé. Aucune modification en base. ==="))
creator_user = None
if username:
creator_user = User.objects.filter(username=username).first()
if not creator_user:
creator_user = User.objects.filter(username="kdeterme").first() or User.objects.filter(is_superuser=True).first()
qs = Structure.objects.all().order_by('code')
if structure_code:
qs = qs.filter(code__iexact=structure_code)
elif structure_type:
types = [t.strip() for t in structure_type.split(',')]
qs = qs.filter(structure_type__in=types)
structure_ct = ContentType.objects.get_for_model(Structure)
existing_attached_ids = set(
DocumentFolderAttachment.objects.filter(
content_type=structure_ct,
is_default=True
).values_list('object_id', flat=True)
)
missing_structures = [s for s in qs if s.pk not in existing_attached_ids]
self.stdout.write(f"Structures analysées : {qs.count()} | Sans répertoire par défaut : {len(missing_structures)}")
total_roots = 0
total_subfolders = 0
target_structures = missing_structures if not structure_code else list(qs)
for structure in target_structures:
res = ensure_structure_folders(structure, creator_user=creator_user, dry_run=dry_run)
if res['created_root']:
total_roots += 1
total_subfolders += res['subfolders_created']
self.stdout.write(f" ✓ [{structure.code}] {structure.get_name()} -> Dossier racine et arborescence configurés")
status_label = "[Simulation] Répertoires qui seraient créés" if dry_run else "Répertoires créés avec succès"
self.stdout.write(self.style.SUCCESS(
f"\n=== {status_label} ===\n"
f" - Dossiers racines créés : {total_roots}\n"
f" - Sous-dossiers créés : {total_subfolders}\n"
f" - Total dossiers : {total_roots + total_subfolders}"
))

View file

@ -0,0 +1,5 @@
from assets.management.commands.import_fulcrum_trees import Command as BaseFulcrumCommand
class Command(BaseFulcrumCommand):
help = "Importe l'inventaire Fulcrum des arbres régionaux (arbres, commandes/gestion, interventions)."

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,18 @@
# Generated by Django 6.0.7 on 2026-09-21 11:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0026_add_naturetree_specific_fields'),
]
operations = [
migrations.AlterField(
model_name='structure',
name='structure_type',
field=models.CharField(blank=True, choices=[('tunnel', 'Tunnel'), ('bridge', 'Pont'), ('viaduct', 'Viaduc'), ('overpass', 'Passerelle'), ('underpass', 'Passage souterrain'), ('gallery', 'Galerie technique'), ('atypical', 'Ouvrage atypique'), ('metro_station', 'Station de métro'), ('metro_tunnel', 'Tunnel de métro')], max_length=200, null=True),
),
]

View file

@ -0,0 +1,118 @@
# Generated by Django 6.0.7 on 2026-10-05 07:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0027_alter_structure_structure_type'),
]
operations = [
migrations.AddField(
model_name='buildingasset',
name='contractor_name',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Prestataire / Mainteneur'),
),
migrations.AddField(
model_name='buildingasset',
name='dimensions',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Dimensions / Format'),
),
migrations.AddField(
model_name='buildingasset',
name='energy_source',
field=models.CharField(blank=True, choices=[('natural_gas', 'Gaz naturel'), ('electricity', 'Électricité'), ('fuel_oil', 'Fioul domestique'), ('biomass_wood', 'Biomasse / Bois / Pellets'), ('heat_network', 'Réseau de chaleur urbain'), ('heat_pump_air', 'Aérothermie (PAC air-eau / air-air)'), ('heat_pump_geo', 'Géothermie (PAC eau-eau / sol-eau)'), ('solar_thermal', 'Solaire thermique'), ('other', 'Autre source')], max_length=50, null=True, verbose_name="Source d'énergie"),
),
migrations.AddField(
model_name='buildingasset',
name='fire_rating',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='Classement au feu / Coupe-feu'),
),
migrations.AddField(
model_name='buildingasset',
name='glazing_type',
field=models.CharField(blank=True, choices=[('single', 'Simple vitrage'), ('double_standard', 'Double vitrage standard'), ('double_low_e', 'Double vitrage ITR / Haute isolation'), ('triple', 'Triple vitrage'), ('laminated_safety', 'Vitrage feuilleté de sécurité'), ('other', 'Autre vitrage')], max_length=50, null=True, verbose_name='Type de vitrage'),
),
migrations.AddField(
model_name='buildingasset',
name='maintenance_contract_ref',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Réf. contrat maintenance'),
),
migrations.AddField(
model_name='buildingasset',
name='material',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Matériau principal'),
),
migrations.AddField(
model_name='buildingasset',
name='opening_type',
field=models.CharField(blank=True, choices=[('hinged', 'Battant / À la française'), ('sliding', 'Coulissant'), ('tilt_and_turn', 'Oscillo-battant'), ('double_action', 'Va-et-vient'), ('sectional_overhead', 'Sectionnel / Basculant'), ('motorized_automatic', 'Automatique / Motorisé'), ('fixed', 'Châssis fixe / Non ouvrant'), ('other', 'Autre type')], max_length=50, null=True, verbose_name="Type d'ouverture"),
),
migrations.AddField(
model_name='buildingasset',
name='power_kw',
field=models.FloatField(blank=True, null=True, verbose_name='Puissance nominale (kW)'),
),
migrations.AddField(
model_name='buildingasset',
name='refrigerant_charge_kg',
field=models.FloatField(blank=True, null=True, verbose_name='Charge fluide frigorigène (kg)'),
),
migrations.AddField(
model_name='buildingasset',
name='refrigerant_type',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='Fluide frigorigène'),
),
migrations.AddField(
model_name='buildingasset',
name='regulatory_periodicity_months',
field=models.IntegerField(blank=True, null=True, verbose_name='Périodicité contrôle réglementaire (mois)'),
),
migrations.AddField(
model_name='buildingasset',
name='replacement_cost_estimate',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, verbose_name='Coût estimé de renouvellement (€)'),
),
migrations.AddField(
model_name='buildingasset',
name='surface_m2',
field=models.FloatField(blank=True, null=True, verbose_name='Surface unitaire (m²)'),
),
migrations.AddField(
model_name='publicbuilding',
name='ceiling_height_m',
field=models.FloatField(blank=True, null=True, verbose_name='Hauteur sous plafond (m)'),
),
migrations.AddField(
model_name='publicbuilding',
name='fire_compartment',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Secteur / Compartiment coupe-feu'),
),
migrations.AddField(
model_name='publicbuilding',
name='floor_type',
field=models.CharField(blank=True, choices=[('tiles', 'Carrelage / Faïence'), ('parquet', 'Parquet / Bois'), ('linoleum_pvc', 'Linoléum / Vinyle / PVC'), ('concrete', 'Béton lissé / Chape / Résine'), ('carpet', 'Moquette / Textile'), ('other', 'Autre revêtement')], max_length=50, null=True, verbose_name='Revêtement de sol dominant'),
),
migrations.AddField(
model_name='publicbuilding',
name='is_heated',
field=models.BooleanField(default=True, verbose_name='Local chauffé'),
),
migrations.AddField(
model_name='publicbuilding',
name='room_number',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='Numéro de local / porte'),
),
migrations.AlterField(
model_name='buildingasset',
name='asset_type',
field=models.CharField(choices=[('roof', 'Toiture / Couverture / Étanchéité'), ('roof_drainage', 'Zinguerie & Évacuation EP (Gouttière, chéneau)'), ('facade', 'Façade / Bardage / Mur extérieur'), ('window', 'Fenêtre / Baie vitrée / Châssis'), ('skylight', 'Lanterneau / Verrière / Puits de lumière'), ('external_door', "Porte extérieure / Porte d'entrée / Portail"), ('shutter_blind', 'Volet / Store / Brise-soleil (BSO)'), ('insulation', 'Isolation thermique (ITE / ITI)'), ('wall', 'Mur intérieur / Cloison'), ('interior_door', 'Porte intérieure / Bloc-porte'), ('fire_door', 'Porte coupe-feu / Bloc pare-flamme'), ('ceiling', 'Faux-plafond / Plafond suspendu'), ('flooring', 'Revêtement de sol'), ('stairs', 'Escalier / Garde-corps / Main courante'), ('boiler', 'Chaudière (Gaz / Fioul / Biomasse / Pellets)'), ('heat_pump', 'Pompe à chaleur (PAC) / Climatisation réversible'), ('heat_substation', "Sous-station d'échangeur thermique / Réseau urbain"), ('radiator_emitter', 'Émetteur de chaleur (Radiateur, convecteur, plancher)'), ('air_handling_unit', "Centrale de traitement d'air (CTA) / VMC"), ('air_conditioning', 'Climatisation / Groupe froid / Chiller / VRV'), ('water_heater', 'Production Eau Chaude Sanitaire (Ballon ECS)'), ('hvac', 'Chauffage / CVC / Régulation générale'), ('electricity', 'Électricité / TGBT / Tableau divisionnaire'), ('inverter_generator', 'Onduleur / Groupe électrogène de secours'), ('lighting', 'Éclairage intérieur / Luminaires'), ('emergency_lighting', 'Éclairage de sécurité / BAES / BAEH'), ('meter', 'Compteur (Eau / Gaz / Électricité / Calories)'), ('solar_panel', 'Panneaux solaires (Photovoltaïque / Thermique)'), ('ev_charging', 'Borne de recharge véhicule électrique'), ('sanitary', 'Plomberie / Sanitaires (WC, lavabo, douche)'), ('plumbing_piping', "Réseau d'eau / Vanne d'arrêt / Disconnecteur"), ('sump_pump', 'Pompe de relevage (Eaux usées / Eaux pluviales)'), ('rainwater_harvesting', "Récupération d'eau de pluie / Cuve de stockage"), ('fire_safety', 'Sécurité incendie / Centrale SSI / CMSI'), ('smoke_detector', 'Détecteur de fumée / Déclencheur manuel (DM)'), ('fire_extinguisher', "Extincteur / Robinet d'Incendie Armé (RIA)"), ('smoke_exhaust', 'Désenfumage / Trappe / Skydome'), ('access_control', "Contrôle d'accès (Badge, interphone, gâche)"), ('alarm', 'Alarme anti-intrusion / Détecteur de présence'), ('cctv_surveillance', 'Vidéosurveillance / Caméra CCTV'), ('elevator', 'Ascenseur / Monte-charge'), ('disabled_lift', 'Élévateur PMR / Plateforme élévatrice'), ('network_rack', 'Baie de brassage / Rack informatique / RJ45'), ('bms_system', 'Gestion Technique du Bâtiment (GTB / GTC)'), ('civil_engineering', 'Génie civil / Bâti / Gros œuvre'), ('other', 'Autre équipement / asset')], default='civil_engineering', max_length=50, verbose_name="Type d'asset / Équipement"),
),
migrations.AlterField(
model_name='publicbuilding',
name='location_type',
field=models.CharField(choices=[('site', 'Site / Complexe'), ('building', 'Bâtiment / Bloc'), ('wing', 'Aile / Corps de bâtiment'), ('level', 'Niveau / Étage'), ('basement', 'Sous-sol / Cave'), ('attic', 'Combles / Grenier / Toiture-terrasse'), ('mezzanine', 'Mezzanine'), ('stairwell', "Cage d'escalier"), ('corridor', 'Couloir / Coursive / Dégagement'), ('lobby', "Hall d'accueil / Entrée / Sas"), ('emergency_exit', "Issue de secours / Sas d'évacuation"), ('elevator_shaft', "Gaine / Palier d'ascenseur"), ('technical_room', 'Local technique général'), ('boiler_room', 'Chaufferie / Sous-station thermique'), ('electrical_room', 'Local TGBT / Électrique'), ('ventilation_room', 'Local ventilation / CTA'), ('elevator_machine_room', 'Local machinerie ascenseur'), ('server_room', 'Salle serveurs / Baie informatique'), ('room', 'Pièce / Local polyvalent'), ('office', 'Bureau / Espace de travail'), ('meeting_room', 'Salle de réunion / Conférence'), ('classroom', 'Salle de classe / Formation'), ('workshop', 'Atelier / Laboratoire'), ('sanitary_room', 'Sanitaires / Vestiaires / Douches'), ('storage_room', 'Réserve / Stockage / Archives'), ('canteen_kitchen', 'Cuisine / Réfectoire / Restauration'), ('cleaning_room', 'Local ménage / Entretien'), ('waste_room', 'Local poubelles / Déchets'), ('indoor_parking', 'Parking couvert / Garage'), ('outdoor_area', 'Abords / Cour / Parvis / Terrasse'), ('other', 'Autre localisation')], default='building', max_length=50, verbose_name='Type de localisation'),
),
]

View file

@ -0,0 +1,24 @@
# Generated by Django 6.0.7 on 2026-10-05 08:25
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0028_buildingasset_contractor_name_and_more'),
]
operations = [
migrations.AddField(
model_name='publicbuildingdocument',
name='floor_location',
field=models.ForeignKey(blank=True, help_text='Niveau ou local auquel ce document/plan se rapporte.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='floor_documents', to='assets.publicbuilding', verbose_name='Niveau / Étage concerné'),
),
migrations.AlterField(
model_name='publicbuildingdocument',
name='document_type',
field=models.CharField(choices=[('floor_plan', "Plan d'étage / Plan architectural"), ('as_built', 'Plan de récolement (As-Built)'), ('erp_notice', 'Notice de sécurité / Dossier ERP'), ('dpe', 'Diagnostic énergétique (DPE / PEB)'), ('technical_notice', "Notice technique / Manuel d'exploitation"), ('other', 'Autre document')], default='other', max_length=50, verbose_name='Type de document'),
),
]

View file

@ -0,0 +1,102 @@
# Generated by Django 6.0.7 on 2026-10-05 13:06
import common.storages
import django.contrib.gis.db.models.fields
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0029_publicbuildingdocument_floor_location_and_more'),
('common', '0017_territory'),
('contenttypes', '0002_remove_content_type_name'),
('documents', '0004_alter_documentfolder_visibility_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ManagementZone',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=100, unique=True, verbose_name='Code')),
('name_fr', models.CharField(max_length=255, verbose_name='Nom (FR)')),
('name_nl', models.CharField(blank=True, max_length=255, null=True, verbose_name='Nom (NL)')),
('description', models.TextField(blank=True, null=True, verbose_name='Description')),
('instructions', models.TextField(blank=True, help_text="Consignes d'exploitation, points de vigilance, règles de sécurité...", null=True, verbose_name="Consignes et points d'attention")),
('geom', django.contrib.gis.db.models.fields.MultiPolygonField(blank=True, null=True, srid=3812, verbose_name='Périmètre géographique')),
('color', models.CharField(blank=True, help_text='Code hexadécimal (ex: #198754). Si vide, utilise la couleur du type de zone.', max_length=20, null=True, verbose_name="Couleur d'affichage")),
('is_active', models.BooleanField(default=True, verbose_name='Active')),
('target_frequency_days', models.IntegerField(blank=True, help_text='Intervalle indicatif entre interventions en jours.', null=True, verbose_name='Fréquence cible (jours)')),
('seasonality_notes', models.CharField(blank=True, help_text="Ex: Tonte d'avril à octobre; fauche tardive en septembre; interdiction de taille au printemps.", max_length=255, null=True, verbose_name='Saisonnalité / Calendrier')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Date de création')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Date de mise à jour')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_management_zones', to=settings.AUTH_USER_MODEL, verbose_name='Créé par')),
('thematic', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='management_zones', to='common.thematic', verbose_name='Thématique')),
],
options={
'verbose_name': 'Zone de gestion',
'verbose_name_plural': 'Zones de gestion',
'ordering': ['thematic', 'code'],
},
),
migrations.CreateModel(
name='ManagementZoneDocument',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('description', models.TextField(blank=True, null=True, verbose_name='Description du document')),
('upload_date', models.DateTimeField(auto_now=True, verbose_name='Date de téléversement')),
('status', models.CharField(choices=[('active', 'Actif'), ('archived', 'Archivé')], default='active')),
('file', models.FileField(max_length=255, storage=common.storages.PrivateMediaStorage(), upload_to='assets_documents/management_zones/', verbose_name='Fichier')),
('thumbnail', models.ImageField(blank=True, max_length=255, null=True, storage=common.storages.PrivateMediaStorage(), upload_to='assets_documents/management_zones/')),
('managed_document', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_sources', to='documents.manageddocument')),
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Ajouté par')),
('zone', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='assets.managementzone', verbose_name='Zone de gestion')),
],
options={
'verbose_name': 'Document de zone de gestion',
'verbose_name_plural': 'Documents de zones de gestion',
},
),
migrations.CreateModel(
name='ManagementZoneType',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=100, unique=True, verbose_name='Code')),
('name_fr', models.CharField(max_length=255, verbose_name='Nom (FR)')),
('name_nl', models.CharField(blank=True, max_length=255, null=True, verbose_name='Nom (NL)')),
('description', models.TextField(blank=True, null=True, verbose_name='Description')),
('default_color', models.CharField(default='#198754', max_length=20, verbose_name='Couleur par défaut')),
('thematic', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='management_zone_types', to='common.thematic', verbose_name='Thématique')),
],
options={
'verbose_name': 'Type de zone de gestion',
'verbose_name_plural': 'Types de zones de gestion',
'ordering': ['thematic', 'name_fr'],
},
),
migrations.AddField(
model_name='managementzone',
name='zone_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='management_zones', to='assets.managementzonetype', verbose_name='Type de zone de gestion'),
),
migrations.CreateModel(
name='ManagementZoneAsset',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('object_id', models.PositiveIntegerField(verbose_name="ID de l'asset")),
('is_excluded', models.BooleanField(default=False, help_text='Si coché, cet équipement est exclu de la zone même si sa géométrie intersecte le périmètre.', verbose_name='Exclu')),
('notes', models.CharField(blank=True, max_length=255, null=True, verbose_name='Notes')),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name="Type d'asset")),
('management_zone', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='explicit_assets', to='assets.managementzone', verbose_name='Zone de gestion')),
],
options={
'verbose_name': 'Asset de zone de gestion',
'verbose_name_plural': 'Assets de zones de gestion',
'unique_together': {('management_zone', 'content_type', 'object_id')},
},
),
]

View file

@ -0,0 +1,160 @@
# Generated by Django 6.0.7 on 2026-10-08 10:19
import assets.models.core
import django.contrib.gis.db.models.fields
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0030_managementzone_managementzonedocument_and_more'),
('contracts', '0014_contract_color_contract_geom'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='itsgeoasset',
name='extra_data',
field=models.JSONField(blank=True, default=dict, verbose_name='Métadonnées techniques'),
),
migrations.CreateModel(
name='ITSBollardSystem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=50)),
('name_fr', models.CharField(blank=True, max_length=200, null=True)),
('name_nl', models.CharField(blank=True, max_length=200, null=True)),
('external_reference', models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe')),
('status', models.CharField(choices=[('to_be_approved', 'À approuver'), ('planned', 'Planifié'), ('active', 'Actif'), ('damaged', 'Endommagé'), ('to_replace', 'A remplacer'), ('voluntarily_out_of_service', 'Mis hors service volontairement'), ('removed', 'Enlevé'), ('archived', 'Archivé')], default='active', max_length=50)),
('obsolescence', models.CharField(blank=True, choices=[('good', 'Bon état'), ('medium', 'Moyen'), ('bad', 'Dégradé'), ('critical', 'Critique'), ('unknown', 'Inconnu')], default='unknown', null=True)),
('installation_date', models.DateTimeField(blank=True, null=True)),
('uninstallation_date', models.DateTimeField(blank=True, null=True)),
('serial_number', models.CharField(blank=True, max_length=200, null=True)),
('warranty_duration', models.IntegerField(blank=True, null=True, verbose_name='Duration of the warranty (months)')),
('last_inspection_date', models.DateTimeField(blank=True, null=True)),
('inspection_status', models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection")),
('last_preventive_date', models.DateTimeField(blank=True, null=True)),
('last_corrective_date', models.DateTimeField(blank=True, null=True)),
('last_survey_date', models.DateTimeField(blank=True, null=True)),
('brand', models.CharField(blank=True, max_length=500, null=True)),
('marked_as_removed_date', models.DateTimeField(blank=True, null=True, verbose_name='Date de marquage comme retiré')),
('note', models.TextField(blank=True, null=True, verbose_name='Note')),
('validation_status', models.CharField(blank=True, choices=[('to_be_validated', 'À valider'), ('validated', 'Validé'), ('to_be_completed', 'À compléter'), ('rejected', 'Rejeté')], max_length=50, null=True, verbose_name='Statut de validation')),
('funding_program', models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet')),
('updated_at', models.DateTimeField(auto_now=True)),
('lon', models.FloatField(blank=True, null=True)),
('lat', models.FloatField(blank=True, null=True)),
('geojson', models.TextField(blank=True, null=True)),
('geom', django.contrib.gis.db.models.fields.LineStringField(blank=True, null=True, srid=3812, verbose_name='Géométrie (Ligne)')),
('width', models.FloatField(blank=True, null=True, verbose_name='Largeur de passage (m)')),
('direction', models.CharField(blank=True, choices=[('unidirectional', 'Sens unique'), ('bidirectional', 'Double sens')], default='bidirectional', max_length=50, null=True, verbose_name='Sens de circulation')),
('extra_data', models.JSONField(blank=True, default=dict, verbose_name='Métadonnées techniques')),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory')),
('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bollard_systems', to='assets.itslocation', verbose_name='Localisation ITS')),
('marked_as_removed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_marked_as_removed', to=settings.AUTH_USER_MODEL, verbose_name='Marqué comme retiré par')),
('model', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='assets.itsassetmodel', verbose_name='Modèle de système')),
('replaced_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.itsbollardsystem', verbose_name='Remplacé par')),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par')),
],
options={
'verbose_name': 'Système de bornes',
'verbose_name_plural': 'Systèmes de bornes',
},
bases=(assets.models.core.GeoSyncMixin, models.Model),
),
migrations.CreateModel(
name='ITSBollardSystemAsset',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role', models.CharField(choices=[('camera_entry', 'Caméra entrée (LAPI)'), ('camera_exit', 'Caméra sortie (LAPI)'), ('camera_cctv', 'Caméra de contexte / surveillance'), ('cabinet', 'Armoire électrique / automate'), ('totem', 'Totem de commande'), ('traffic_light', 'Feu de franchissement'), ('loop', 'Boucle de détection'), ('other', 'Autre')], default='other', max_length=50, verbose_name="Rôle de l'équipement")),
('comment', models.CharField(blank=True, max_length=255, null=True, verbose_name='Commentaire')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name="Date d'association")),
('asset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bollard_system_links', to='assets.itsgeoasset', verbose_name='Équipement ITS')),
('bollard_system', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='system_assets', to='assets.itsbollardsystem', verbose_name='Système de bornes')),
],
options={
'verbose_name': 'Équipement associé au système de bornes',
'verbose_name_plural': 'Équipements associés au système de bornes',
'unique_together': {('bollard_system', 'asset')},
},
),
migrations.AddField(
model_name='itsbollardsystem',
name='associated_assets',
field=models.ManyToManyField(blank=True, related_name='bollard_systems', through='assets.ITSBollardSystemAsset', to='assets.itsgeoasset', verbose_name='Équipements associés'),
),
migrations.CreateModel(
name='ITSBollardSystemContract',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('active', 'Actif'), ('inactive', 'Inactif'), ('archived', 'Archivé')], default='active', max_length=50)),
('maintenance_type', models.CharField(choices=[('equipments', 'Equipements'), ('office', 'Bureautique'), ('system', 'Système'), ('other', 'Autre')], default='other', max_length=50)),
('contract', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bollard_systems_contracts', to='contracts.contract')),
('system', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bollard_systems_contracts', to='assets.itsbollardsystem')),
],
options={
'verbose_name': 'Contrat de système de bornes',
'verbose_name_plural': 'Contrats de systèmes de bornes',
},
),
migrations.CreateModel(
name='ITSBollardPlot',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=50)),
('name_fr', models.CharField(blank=True, max_length=200, null=True)),
('name_nl', models.CharField(blank=True, max_length=200, null=True)),
('external_reference', models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe')),
('status', models.CharField(choices=[('to_be_approved', 'À approuver'), ('planned', 'Planifié'), ('active', 'Actif'), ('damaged', 'Endommagé'), ('to_replace', 'A remplacer'), ('voluntarily_out_of_service', 'Mis hors service volontairement'), ('removed', 'Enlevé'), ('archived', 'Archivé')], default='active', max_length=50)),
('obsolescence', models.CharField(blank=True, choices=[('good', 'Bon état'), ('medium', 'Moyen'), ('bad', 'Dégradé'), ('critical', 'Critique'), ('unknown', 'Inconnu')], default='unknown', null=True)),
('installation_date', models.DateTimeField(blank=True, null=True)),
('uninstallation_date', models.DateTimeField(blank=True, null=True)),
('serial_number', models.CharField(blank=True, max_length=200, null=True)),
('warranty_duration', models.IntegerField(blank=True, null=True, verbose_name='Duration of the warranty (months)')),
('last_inspection_date', models.DateTimeField(blank=True, null=True)),
('inspection_status', models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection")),
('last_preventive_date', models.DateTimeField(blank=True, null=True)),
('last_corrective_date', models.DateTimeField(blank=True, null=True)),
('last_survey_date', models.DateTimeField(blank=True, null=True)),
('brand', models.CharField(blank=True, max_length=500, null=True)),
('marked_as_removed_date', models.DateTimeField(blank=True, null=True, verbose_name='Date de marquage comme retiré')),
('note', models.TextField(blank=True, null=True, verbose_name='Note')),
('validation_status', models.CharField(blank=True, choices=[('to_be_validated', 'À valider'), ('validated', 'Validé'), ('to_be_completed', 'À compléter'), ('rejected', 'Rejeté')], max_length=50, null=True, verbose_name='Statut de validation')),
('funding_program', models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet')),
('updated_at', models.DateTimeField(auto_now=True)),
('lon', models.FloatField(blank=True, null=True)),
('lat', models.FloatField(blank=True, null=True)),
('geojson', models.TextField(blank=True, null=True)),
('geom', django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=3812, verbose_name='Géométrie (Point)')),
('position', models.PositiveSmallIntegerField(default=1, help_text='Ordre de gauche à droite (1, 2, 3...)', verbose_name='Position sur la traverse')),
('bollard_type', models.CharField(choices=[('automatic', 'Automatique / Escamotable'), ('semi_automatic', 'Semi-automatique'), ('fixed', 'Fixe'), ('removable', 'Amovible')], default='automatic', max_length=50, verbose_name='Type de plot')),
('extra_data', models.JSONField(blank=True, default=dict, verbose_name='Métadonnées techniques')),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory')),
('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bollard_plots', to='assets.itslocation', verbose_name='Localisation ITS')),
('marked_as_removed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_marked_as_removed', to=settings.AUTH_USER_MODEL, verbose_name='Marqué comme retiré par')),
('model', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='assets.itsassetmodel', verbose_name='Modèle de plot')),
('replaced_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.itsbollardplot', verbose_name='Remplacé par')),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par')),
('system', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='plots', to='assets.itsbollardsystem', verbose_name='Système de bornes')),
],
options={
'verbose_name': 'Plot de borne',
'verbose_name_plural': 'Plots de bornes',
'ordering': ['system', 'position'],
'indexes': [models.Index(fields=['code'], name='assets_itsb_code_de6000_idx')],
},
bases=(assets.models.core.GeoSyncMixin, models.Model),
),
migrations.AddIndex(
model_name='itsbollardsystem',
index=models.Index(fields=['code'], name='assets_itsb_code_a4c148_idx'),
),
migrations.AlterUniqueTogether(
name='itsbollardsystemcontract',
unique_together={('system', 'contract')},
),
]

View file

@ -0,0 +1,108 @@
# Generated by Django 6.0.7 on 2026-10-07 15:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0030_managementzone_managementzonedocument_and_more'),
]
operations = [
migrations.AddField(
model_name='naturetree',
name='amenity_value',
field=models.FloatField(blank=True, null=True, verbose_name="Valeur d'aménité (€)"),
),
migrations.AddField(
model_name='naturetree',
name='biodiversity_index',
field=models.FloatField(blank=True, null=True, verbose_name='Indice de biodiversité'),
),
migrations.AddField(
model_name='naturetree',
name='carbon_stock',
field=models.FloatField(blank=True, null=True, verbose_name='Stock de carbone (t CO2)'),
),
migrations.AddField(
model_name='naturetree',
name='cooling_energy_indicator',
field=models.FloatField(blank=True, null=True, verbose_name='Énergie de refroidissement (kWh)'),
),
migrations.AddField(
model_name='naturetree',
name='cooling_indicator',
field=models.FloatField(blank=True, null=True, verbose_name='Indice de rafraîchissement'),
),
migrations.AddField(
model_name='naturetree',
name='cultivar',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Cultivar / Variété'),
),
migrations.AddField(
model_name='naturetree',
name='death_date',
field=models.DateField(blank=True, null=True, verbose_name='Date de dépérissement / mort'),
),
migrations.AddField(
model_name='naturetree',
name='genus',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Genre botanique'),
),
migrations.AddField(
model_name='naturetree',
name='is_chronoxyle',
field=models.BooleanField(default=False, verbose_name='Arbre sénescent / Chronoxyle (bois mort sur pied)'),
),
migrations.AddField(
model_name='naturetree',
name='is_dead',
field=models.BooleanField(default=False, verbose_name='Arbre mort'),
),
migrations.AddField(
model_name='naturetree',
name='is_stump',
field=models.BooleanField(default=False, verbose_name='Souche'),
),
migrations.AddField(
model_name='naturetree',
name='management_type',
field=models.CharField(blank=True, choices=[('municipal', 'Communal (Régie communale)'), ('regional', 'Régional (Bruxelles Mobilité / Région)'), ('community', 'Intercommunal / Autre pouvoir public'), ('private', 'Privé / Tiers')], default='municipal', max_length=50, null=True, verbose_name='Type de gestionnaire'),
),
migrations.AddField(
model_name='naturetree',
name='managing_authority',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Autorité gestionnaire'),
),
migrations.AddField(
model_name='naturetree',
name='number_of_trunks',
field=models.IntegerField(blank=True, default=1, null=True, verbose_name='Nombre de troncs / Cépée'),
),
migrations.AddField(
model_name='naturetree',
name='plantation_coefficient',
field=models.FloatField(blank=True, null=True, verbose_name='Coefficient de plantation'),
),
migrations.AddField(
model_name='naturetree',
name='planting_date',
field=models.DateField(blank=True, null=True, verbose_name='Date de plantation réelle'),
),
migrations.AddField(
model_name='naturetree',
name='scientific_name',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Nom scientifique / latin'),
),
migrations.AddField(
model_name='naturetree',
name='situation_coefficient',
field=models.FloatField(blank=True, null=True, verbose_name='Coefficient de situation'),
),
migrations.AddField(
model_name='naturetree',
name='vernacular_name',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Nom vernaculaire / Essence'),
),
]

View file

@ -0,0 +1,14 @@
# Generated by Django 6.0.7 on 2026-10-09 11:48
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('assets', '0031_itsgeoasset_extra_data_itsbollardsystem_and_more'),
('assets', '0031_naturetree_amenity_value_and_more'),
]
operations = [
]

View file

@ -8,9 +8,10 @@ from .structures import Structure, StructureLocation, StructureGeoAsset, Structu
from .controlcenters import ControlCenter, ControlCenterLocation, ControlCenterGeoAsset, ControlCenterAssetModel, ControlCenterDocument, ControlCenterContract, ControlCenterGeoAssetContract, ControlCenterAssetDocument
from .sign import SignStreet, SignStreetDocument, SignPole, SignPoleModel, SignPanel, SignPanelModel, SignPanelType, ComplementaryPoliceRegulation, SignParkingSpot
from .roads import RoadStreet, RoadStreetDocument, Roadway, RoadwayModel, RoadSidewalk, RoadSidewalkModel, RoadFurniture, RoadFurnitureModel, RoadwayFunction, RoadConcreteBlockSet, RoadDrainModel, RoadDrain
from .its import ITSLocation, ITSLocationDocument, ITSAssetModel, ITSGeoAsset, ITSGeoAssetContract, ITSLocationContract
from .its import ITSLocation, ITSLocationDocument, ITSAssetModel, ITSGeoAsset, ITSGeoAssetContract, ITSLocationContract, ITSBollardSystem, ITSBollardPlot, ITSBollardSystemAsset, ITSBollardSystemContract
from .clean import CleanLocation, CleanLitterBinModel, CleanLitterBin, CleanLitterBinPhoto, CleanLitterBinObservation, CleanGlassContainerModel, CleanGlassContainer, CleanLocationContract
from .buildings import PublicBuilding, BuildingAsset, BuildingEquipment, PublicBuildingDocument
from .outdoor import OutdoorLocation, OutdoorAsset, OutdoorLocationDocument
from .parking import ParkingLocation, ParkingSpot
from inspections.models import Inspection as AssetInspection, ParkingSpotInspection
from inspections.models import Inspection as AssetInspection, ParkingSpotInspection
from .management_zones import ManagementZone, ManagementZoneType, ManagementZoneAsset, ManagementZoneDocument

View file

@ -77,31 +77,148 @@ ERP_TYPE_CHOICES = (
)
LOCATION_TYPE_CHOICES = (
# Ensembles & Bâtiments
('site', _('Site / Complexe')),
('building', _('Bâtiment / Bloc')),
('wing', _('Aile / Corps de bâtiment')),
# Niveaux & Hauteurs
('level', _('Niveau / Étage')),
('room', _('Pièce / Local')),
('basement', _('Sous-sol / Cave')),
('attic', _('Combles / Grenier / Toiture-terrasse')),
('mezzanine', _('Mezzanine')),
# Circulations & Dégagements
('stairwell', _('Cage d\'escalier')),
('corridor', _('Couloir / Coursive / Dégagement')),
('lobby', _('Hall d\'accueil / Entrée / Sas')),
('emergency_exit', _('Issue de secours / Sas d\'évacuation')),
('elevator_shaft', _('Gaine / Palier d\'ascenseur')),
# Locaux techniques
('technical_room', _('Local technique général')),
('boiler_room', _('Chaufferie / Sous-station thermique')),
('electrical_room', _('Local TGBT / Électrique')),
('ventilation_room', _('Local ventilation / CTA')),
('elevator_machine_room', _('Local machinerie ascenseur')),
('server_room', _('Salle serveurs / Baie informatique')),
# Locaux de travail, d'accueil & services
('room', _('Pièce / Local polyvalent')),
('office', _('Bureau / Espace de travail')),
('meeting_room', _('Salle de réunion / Conférence')),
('classroom', _('Salle de classe / Formation')),
('workshop', _('Atelier / Laboratoire')),
('sanitary_room', _('Sanitaires / Vestiaires / Douches')),
('storage_room', _('Réserve / Stockage / Archives')),
('canteen_kitchen', _('Cuisine / Réfectoire / Restauration')),
('cleaning_room', _('Local ménage / Entretien')),
('waste_room', _('Local poubelles / Déchets')),
('indoor_parking', _('Parking couvert / Garage')),
# Espaces extérieurs immédiats
('outdoor_area', _('Abords / Cour / Parvis / Terrasse')),
# Autre
('other', _('Autre localisation')),
)
FLOOR_TYPE_CHOICES = (
('tiles', _('Carrelage / Faïence')),
('parquet', _('Parquet / Bois')),
('linoleum_pvc', _('Linoléum / Vinyle / PVC')),
('concrete', _('Béton lissé / Chape / Résine')),
('carpet', _('Moquette / Textile')),
('other', _('Autre revêtement')),
)
BUILDING_ASSET_TYPE_CHOICES = (
('civil_engineering', _('Génie civil / Bâti')),
('facade', _('Façade / Mur extérieur')),
# Clos, couvert & enveloppe
('roof', _('Toiture / Couverture / Étanchéité')),
('roof_drainage', _('Zinguerie & Évacuation EP (Gouttière, chéneau)')),
('facade', _('Façade / Bardage / Mur extérieur')),
('window', _('Fenêtre / Baie vitrée / Châssis')),
('skylight', _('Lanterneau / Verrière / Puits de lumière')),
('external_door', _('Porte extérieure / Porte d\'entrée / Portail')),
('shutter_blind', _('Volet / Store / Brise-soleil (BSO)')),
('insulation', _('Isolation thermique (ITE / ITI)')),
# Second œuvre & aménagements intérieurs
('wall', _('Mur intérieur / Cloison')),
('roof', _('Toiture / Couverture')),
('hvac', _('Chauffage / CVC / Chaudière')),
('interior_door', _('Porte intérieure / Bloc-porte')),
('fire_door', _('Porte coupe-feu / Bloc pare-flamme')),
('ceiling', _('Faux-plafond / Plafond suspendu')),
('flooring', _('Revêtement de sol')),
('stairs', _('Escalier / Garde-corps / Main courante')),
# Génie climatique & thermique (CVC)
('boiler', _('Chaudière (Gaz / Fioul / Biomasse / Pellets)')),
('heat_pump', _('Pompe à chaleur (PAC) / Climatisation réversible')),
('heat_substation', _('Sous-station d\'échangeur thermique / Réseau urbain')),
('radiator_emitter', _('Émetteur de chaleur (Radiateur, convecteur, plancher)')),
('air_handling_unit', _('Centrale de traitement d\'air (CTA) / VMC')),
('air_conditioning', _('Climatisation / Groupe froid / Chiller / VRV')),
('water_heater', _('Production Eau Chaude Sanitaire (Ballon ECS)')),
('hvac', _('Chauffage / CVC / Régulation générale')),
# Électricité, Énergie & Réseaux
('electricity', _('Électricité / TGBT / Tableau divisionnaire')),
('inverter_generator', _('Onduleur / Groupe électrogène de secours')),
('lighting', _('Éclairage intérieur / Luminaires')),
('emergency_lighting', _('Éclairage de sécurité / BAES / BAEH')),
('meter', _('Compteur (Eau / Gaz / Électricité / Calories)')),
('solar_panel', _('Panneaux solaires (Photovoltaïque / Thermique)')),
('ev_charging', _('Borne de recharge véhicule électrique')),
# Plomberie & Sanitaires
('sanitary', _('Plomberie / Sanitaires (WC, lavabo, douche)')),
('plumbing_piping', _('Réseau d\'eau / Vanne d\'arrêt / Disconnecteur')),
('sump_pump', _('Pompe de relevage (Eaux usées / Eaux pluviales)')),
('rainwater_harvesting', _('Récupération d\'eau de pluie / Cuve de stockage')),
# Sécurité incendie, sûreté & contrôle d'accès
('fire_safety', _('Sécurité incendie / Centrale SSI / CMSI')),
('smoke_detector', _('Détecteur de fumée / Déclencheur manuel (DM)')),
('fire_extinguisher', _('Extincteur / Robinet d\'Incendie Armé (RIA)')),
('smoke_exhaust', _('Désenfumage / Trappe / Skydome')),
('access_control', _('Contrôle d\'accès (Badge, interphone, gâche)')),
('alarm', _('Alarme anti-intrusion / Détecteur de présence')),
('cctv_surveillance', _('Vidéosurveillance / Caméra CCTV')),
# Mobilité verticale
('elevator', _('Ascenseur / Monte-charge')),
('electricity', _('Électricité / TGBT')),
('fire_safety', _('Sécurité incendie / Extincteur / RIA')),
('alarm', _('Alarme / Contrôle d\'accès')),
('meter', _('Compteur (Eau / Gaz / Électricité)')),
('sanitary', _('Plomberie / Sanitaires')),
('disabled_lift', _('Élévateur PMR / Plateforme élévatrice')),
# Réseaux, GTB & Automatismes
('network_rack', _('Baie de brassage / Rack informatique / RJ45')),
('bms_system', _('Gestion Technique du Bâtiment (GTB / GTC)')),
# Gros œuvre & Divers
('civil_engineering', _('Génie civil / Bâti / Gros œuvre')),
('other', _('Autre équipement / asset')),
)
# For backward compatibility
EQUIPMENT_TYPE_CHOICES = BUILDING_ASSET_TYPE_CHOICES
ENERGY_SOURCE_CHOICES = (
('natural_gas', _('Gaz naturel')),
('electricity', _('Électricité')),
('fuel_oil', _('Fioul domestique')),
('biomass_wood', _('Biomasse / Bois / Pellets')),
('heat_network', _('Réseau de chaleur urbain')),
('heat_pump_air', _('Aérothermie (PAC air-eau / air-air)')),
('heat_pump_geo', _('Géothermie (PAC eau-eau / sol-eau)')),
('solar_thermal', _('Solaire thermique')),
('other', _('Autre source')),
)
GLAZING_TYPE_CHOICES = (
('single', _('Simple vitrage')),
('double_standard', _('Double vitrage standard')),
('double_low_e', _('Double vitrage ITR / Haute isolation')),
('triple', _('Triple vitrage')),
('laminated_safety', _('Vitrage feuilleté de sécurité')),
('other', _('Autre vitrage')),
)
OPENING_TYPE_CHOICES = (
('hinged', _('Battant / À la française')),
('sliding', _('Coulissant')),
('tilt_and_turn', _('Oscillo-battant')),
('double_action', _('Va-et-vient')),
('sectional_overhead', _('Sectionnel / Basculant')),
('motorized_automatic', _('Automatique / Motorisé')),
('fixed', _('Châssis fixe / Non ouvrant')),
('other', _('Autre type')),
)
class PublicBuilding(AbstractLocation):
"""
@ -133,6 +250,11 @@ class PublicBuilding(AbstractLocation):
usable_surface_m2 = models.FloatField(null=True, blank=True, verbose_name=_("Surface utile (m²)"))
surface_m2 = models.FloatField(null=True, blank=True, verbose_name=_("Surface spécifique (m²)"))
level_number = models.IntegerField(default=0, verbose_name=_("Numéro d'étage (ex: 0=RDC, 1=1er, -1=Sous-sol)"))
room_number = models.CharField(max_length=50, null=True, blank=True, verbose_name=_("Numéro de local / porte"))
ceiling_height_m = models.FloatField(null=True, blank=True, verbose_name=_("Hauteur sous plafond (m)"))
is_heated = models.BooleanField(default=True, verbose_name=_("Local chauffé"))
fire_compartment = models.CharField(max_length=100, null=True, blank=True, verbose_name=_("Secteur / Compartiment coupe-feu"))
floor_type = models.CharField(max_length=50, choices=FLOOR_TYPE_CHOICES, null=True, blank=True, verbose_name=_("Revêtement de sol dominant"))
floor_plan = models.FileField(upload_to='buildings/plans/%Y/%m/', storage=private_storage, null=True, blank=True, verbose_name=_("Plan d'étage"))
# Réglementation & Accueil du Public
@ -153,9 +275,26 @@ class PublicBuilding(AbstractLocation):
verbose_name_plural = _("Localisations Bâtiments publics")
ordering = ['code', 'name_fr']
@property
def name(self):
return self.name_fr or self.name_nl or self.code
def get_absolute_url(self):
return reverse('assets:publicbuilding_detail', kwargs={'pk': self.pk})
def get_floor_plan_url(self):
"""
Renvoie l'URL du plan d'étage s'il existe (via floor_plan ou document associé de type floor_plan).
"""
if self.floor_plan and self.floor_plan.name:
return reverse('assets:publicbuilding_plan_file', kwargs={'pk': self.pk})
docs = getattr(self, 'floor_documents', None)
if docs:
first_doc = docs.filter(document_type='floor_plan').first()
if first_doc and first_doc.file and first_doc.file.name:
return reverse('assets:publicbuilding_plan_file', kwargs={'pk': self.pk})
return None
def get_usage_type_display(self, standard=None):
"""
Renvoie le libellé du type d'usage adapté dynamiquement à la norme régionale (BE, FR, INT).
@ -166,6 +305,44 @@ class PublicBuilding(AbstractLocation):
return f"Administratif ({terms['admin_building']})"
return dict(USAGE_TYPE_CHOICES).get(self.usage_type, self.usage_type)
def get_icon_class(self):
"""
Retourne la classe d'icône Bootstrap adaptée au type de localisation.
"""
mapping = {
'site': 'bi-diagram-3',
'building': 'bi-building',
'wing': 'bi-building-add',
'level': 'bi-layers',
'basement': 'bi-layer-backward',
'attic': 'bi-triangle',
'mezzanine': 'bi-border-inner',
'stairwell': 'bi-stairs',
'corridor': 'bi-arrow-left-right',
'lobby': 'bi-door-open-fill',
'emergency_exit': 'bi-box-arrow-right',
'elevator_shaft': 'bi-arrow-down-up',
'technical_room': 'bi-tools',
'boiler_room': 'bi-fire',
'electrical_room': 'bi-lightning-charge',
'ventilation_room': 'bi-fan',
'elevator_machine_room': 'bi-gear-wide-connected',
'server_room': 'bi-hdd-network',
'room': 'bi-door-open',
'office': 'bi-briefcase',
'meeting_room': 'bi-people',
'classroom': 'bi-mortarboard',
'workshop': 'bi-hammer',
'sanitary_room': 'bi-badge-wc',
'storage_room': 'bi-archive',
'canteen_kitchen': 'bi-cup-hot',
'cleaning_room': 'bi-bucket',
'waste_room': 'bi-trash',
'indoor_parking': 'bi-car-front',
'outdoor_area': 'bi-tree',
}
return mapping.get(self.location_type, 'bi-geo-alt')
def get_child_locations(self):
return self.child_locations.all()
@ -202,14 +379,36 @@ class PublicBuilding(AbstractLocation):
class BuildingAsset(AbstractGeoAsset):
"""
Modèle représentant un asset ou équipement physique du bâtiment (Génie civil / Bâti, Façades, Toitures, CVC, Ascenseurs...).
Les assets peuvent former une arborescence parent-enfant (ex: Génie civil -> Façade Nord, Toiture, Ascenseur).
Les assets peuvent former une arborescence parent-enfant (ex: Bâti -> Façade -> Châssis, ou Chaudière -> Brûleur).
"""
building = models.ForeignKey(PublicBuilding, on_delete=models.CASCADE, related_name="all_building_assets", verbose_name=_("Bâtiment / Site principal"))
location = models.ForeignKey(PublicBuilding, on_delete=models.SET_NULL, null=True, blank=True, related_name="location_assets", verbose_name=_("Localisation spécifique"))
parent_asset = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name="child_assets", verbose_name=_("Asset parent"))
asset_type = models.CharField(max_length=50, choices=BUILDING_ASSET_TYPE_CHOICES, default='civil_engineering', verbose_name=_("Type d'asset / Équipement"))
# Caractéristiques physiques & dimensionnelles
dimensions = models.CharField(max_length=100, null=True, blank=True, verbose_name=_("Dimensions / Format"))
surface_m2 = models.FloatField(null=True, blank=True, verbose_name=_("Surface unitaire (m²)"))
material = models.CharField(max_length=100, null=True, blank=True, verbose_name=_("Matériau principal"))
# Caractéristiques thermiques, énergétiques & techniques
power_kw = models.FloatField(null=True, blank=True, verbose_name=_("Puissance nominale (kW)"))
energy_source = models.CharField(max_length=50, choices=ENERGY_SOURCE_CHOICES, null=True, blank=True, verbose_name=_("Source d'énergie"))
refrigerant_type = models.CharField(max_length=50, null=True, blank=True, verbose_name=_("Fluide frigorigène"))
refrigerant_charge_kg = models.FloatField(null=True, blank=True, verbose_name=_("Charge fluide frigorigène (kg)"))
# Menuiseries, ouvrants & protection incendie
glazing_type = models.CharField(max_length=50, choices=GLAZING_TYPE_CHOICES, null=True, blank=True, verbose_name=_("Type de vitrage"))
fire_rating = models.CharField(max_length=50, null=True, blank=True, verbose_name=_("Classement au feu / Coupe-feu"))
opening_type = models.CharField(max_length=50, choices=OPENING_TYPE_CHOICES, null=True, blank=True, verbose_name=_("Type d'ouverture"))
# Maintenance, exploitation & suivi réglementaire
maintenance_contract_ref = models.CharField(max_length=100, null=True, blank=True, verbose_name=_("Réf. contrat maintenance"))
contractor_name = models.CharField(max_length=200, null=True, blank=True, verbose_name=_("Prestataire / Mainteneur"))
last_control_date = models.DateField(null=True, blank=True, verbose_name=_("Dernier contrôle réglementaire"))
next_control_date = models.DateField(null=True, blank=True, verbose_name=_("Prochain contrôle réglementaire"))
regulatory_periodicity_months = models.IntegerField(null=True, blank=True, verbose_name=_("Périodicité contrôle réglementaire (mois)"))
replacement_cost_estimate = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True, verbose_name=_("Coût estimé de renouvellement (€)"))
def clean(self):
super().clean()
@ -240,12 +439,266 @@ class BuildingAsset(AbstractGeoAsset):
verbose_name_plural = _("Assets / Équipements de bâtiment")
ordering = ['building', 'code']
@property
def name(self):
return self.name_fr or self.name_nl or self.code
def get_absolute_url(self):
return reverse('assets:publicbuildings_assets_detail', kwargs={'asset_model': 'buildingasset', 'asset_id': self.pk})
def get_child_assets(self):
return self.child_assets.all()
def get_macro_lot(self):
"""
Retourne le macro-lot / domaine technique de l'équipement.
"""
lots = {
# Clos & Couvert
'roof': _('Clos & Couvert'),
'roof_drainage': _('Clos & Couvert'),
'facade': _('Clos & Couvert'),
'window': _('Clos & Couvert'),
'skylight': _('Clos & Couvert'),
'external_door': _('Clos & Couvert'),
'shutter_blind': _('Clos & Couvert'),
'insulation': _('Clos & Couvert'),
# Aménagements & Second œuvre
'wall': _('Aménagements & Second œuvre'),
'interior_door': _('Aménagements & Second œuvre'),
'fire_door': _('Aménagements & Second œuvre'),
'ceiling': _('Aménagements & Second œuvre'),
'flooring': _('Aménagements & Second œuvre'),
'stairs': _('Aménagements & Second œuvre'),
# Génie climatique & CVC
'boiler': _('Génie climatique & CVC'),
'heat_pump': _('Génie climatique & CVC'),
'heat_substation': _('Génie climatique & CVC'),
'radiator_emitter': _('Génie climatique & CVC'),
'air_handling_unit': _('Génie climatique & CVC'),
'air_conditioning': _('Génie climatique & CVC'),
'water_heater': _('Génie climatique & CVC'),
'hvac': _('Génie climatique & CVC'),
# Électricité & Énergie
'electricity': _('Électricité & Énergie'),
'inverter_generator': _('Électricité & Énergie'),
'lighting': _('Électricité & Éclairage'),
'emergency_lighting': _('Électricité & Sécurité'),
'meter': _('Fluides & Comptage'),
'solar_panel': _('Énergies renouvelables'),
'ev_charging': _('Mobilité électrique'),
# Plomberie & Sanitaires
'sanitary': _('Plomberie & Sanitaires'),
'plumbing_piping': _('Plomberie & Réseaux'),
'sump_pump': _('Assainissement & Relevage'),
'rainwater_harvesting': _('Gestion des eaux pluviales'),
# Sécurité incendie & Sûreté
'fire_safety': _('Sécurité incendie'),
'smoke_detector': _('Sécurité incendie'),
'fire_extinguisher': _('Sécurité incendie'),
'smoke_exhaust': _('Sécurité incendie'),
'access_control': _('Sûreté & Contrôle d\'accès'),
'alarm': _('Sûreté & Contrôle d\'accès'),
'cctv_surveillance': _('Vidéosurveillance'),
# Mobilité verticale
'elevator': _('Ascenseurs & Élévateurs'),
'disabled_lift': _('Ascenseurs & Élévateurs'),
# Réseaux & Numérique
'network_rack': _('Réseaux & Télécom'),
'bms_system': _('Gestion Technique du Bâtiment (GTB)'),
# Gros œuvre & Divers
'civil_engineering': _('Génie civil & Structure'),
'other': _('Autre équipement'),
}
return lots.get(self.asset_type, _('Autre'))
def get_icon_class(self):
"""
Retourne l'icône Bootstrap adaptée au type d'asset.
"""
icons = {
'roof': 'bi-house-gear',
'roof_drainage': 'bi-water',
'facade': 'bi-border-style',
'window': 'bi-window',
'skylight': 'bi-sun',
'external_door': 'bi-door-closed',
'shutter_blind': 'bi-blinds',
'insulation': 'bi-shield',
'wall': 'bi-bricks',
'interior_door': 'bi-door-open',
'fire_door': 'bi-shield-shaded',
'ceiling': 'bi-grid-3x3',
'flooring': 'bi-square',
'stairs': 'bi-stairs',
'boiler': 'bi-fire',
'heat_pump': 'bi-snow',
'heat_substation': 'bi-shuffle',
'radiator_emitter': 'bi-thermometer-high',
'air_handling_unit': 'bi-fan',
'air_conditioning': 'bi-wind',
'water_heater': 'bi-droplet-half',
'hvac': 'bi-thermometer-half',
'electricity': 'bi-lightning-charge',
'inverter_generator': 'bi-battery-charging',
'lighting': 'bi-lightbulb',
'emergency_lighting': 'bi-exclamation-triangle',
'meter': 'bi-speedometer2',
'solar_panel': 'bi-sun-fill',
'ev_charging': 'bi-ev-station',
'sanitary': 'bi-badge-wc',
'plumbing_piping': 'bi-pipette',
'sump_pump': 'bi-arrow-up-circle',
'rainwater_harvesting': 'bi-cloud-rain',
'fire_safety': 'bi-shield-shaded',
'smoke_detector': 'bi-bell',
'fire_extinguisher': 'bi-cone-striped',
'smoke_exhaust': 'bi-cloud-arrow-up',
'access_control': 'bi-key',
'alarm': 'bi-shield-exclamation',
'cctv_surveillance': 'bi-camera-video',
'elevator': 'bi-arrow-down-up',
'disabled_lift': 'bi-person-wheelchair',
'network_rack': 'bi-hdd-rack',
'bms_system': 'bi-cpu',
'civil_engineering': 'bi-building-gear',
'other': 'bi-gear',
}
return icons.get(self.asset_type, 'bi-gear')
def get_asset_theme(self):
"""
Retourne la configuration graphique (couleurs d'icône et de badge)
selon la typologie métier de l'asset.
"""
# 1. Sécurité incendie (Rouge alerte)
if self.asset_type in ('fire_safety', 'smoke_detector', 'fire_extinguisher',
'smoke_exhaust', 'fire_door'):
return {
'color': '#b91c1c', # red-700
'bg_color': '#fef2f2', # red-50
'border_color': '#fecaca', # red-200
'icon_color': '#dc2626', # red-600
'badge_class': 'bg-danger-subtle text-danger border',
}
# 2. Génie climatique & CVC (Orange thermique / Chaleur & Froid)
if self.asset_type in ('boiler', 'heat_pump', 'heat_substation', 'radiator_emitter',
'air_handling_unit', 'air_conditioning', 'water_heater', 'hvac'):
return {
'color': '#c2410c', # orange-700
'bg_color': '#fff7ed', # orange-50
'border_color': '#fed7aa', # orange-200
'icon_color': '#ea580c', # orange-600
'badge_class': 'bg-warning-subtle text-danger-emphasis border',
}
# 3. Électricité, Énergie & Éclairage (Ambre / Jaune or)
if self.asset_type in ('electricity', 'inverter_generator', 'lighting', 'emergency_lighting',
'meter', 'solar_panel', 'ev_charging'):
return {
'color': '#a16207', # yellow-700
'bg_color': '#fefce8', # yellow-50
'border_color': '#fef08a', # yellow-200
'icon_color': '#ca8a04', # yellow-600
'badge_class': 'bg-warning-subtle text-dark border',
}
# 4. Sûreté, Contrôle d'accès & Vidéosurveillance (Pourpre / Violet)
if self.asset_type in ('access_control', 'alarm', 'cctv_surveillance'):
return {
'color': '#7e22ce', # purple-700
'bg_color': '#faf5ff', # purple-50
'border_color': '#e9d5ff', # purple-200
'icon_color': '#9333ea', # purple-600
'badge_class': 'bg-primary-subtle text-primary border',
}
# 5. Clos & Couvert / Enveloppe (Teal / Vert canard)
if self.asset_type in ('roof', 'roof_drainage', 'facade', 'window', 'skylight',
'external_door', 'shutter_blind', 'insulation'):
return {
'color': '#0f766e', # teal-700
'bg_color': '#f0fdfa', # teal-50
'border_color': '#99f6e4', # teal-200
'icon_color': '#0d9488', # teal-600
'badge_class': 'bg-success-subtle text-success border',
}
# 6. Plomberie, Sanitaires & Gestion de l'eau (Bleu azur / Sky)
if self.asset_type in ('sanitary', 'plumbing_piping', 'sump_pump', 'rainwater_harvesting'):
return {
'color': '#0369a1', # sky-700
'bg_color': '#f0f9ff', # sky-50
'border_color': '#bae6fd', # sky-200
'icon_color': '#0284c7', # sky-600
'badge_class': 'bg-info-subtle text-info border',
}
# 7. Mobilité verticale / Ascenseurs (Bleu cobalt)
if self.asset_type in ('elevator', 'disabled_lift'):
return {
'color': '#1d4ed8', # blue-700
'bg_color': '#eff6ff', # blue-50
'border_color': '#bfdbfe', # blue-200
'icon_color': '#2563eb', # blue-600
'badge_class': 'bg-primary-subtle text-primary border',
}
# 8. Réseaux & Numérique / GTB (Indigo informatique)
if self.asset_type in ('network_rack', 'bms_system'):
return {
'color': '#4338ca', # indigo-700
'bg_color': '#eef2ff', # indigo-50
'border_color': '#c7d2fe', # indigo-200
'icon_color': '#4f46e5', # indigo-600
'badge_class': 'bg-dark-subtle text-dark border',
}
# 9. Second œuvre & Aménagements intérieurs (Ardoise neutre)
if self.asset_type in ('wall', 'interior_door', 'ceiling', 'flooring', 'stairs'):
return {
'color': '#334155', # slate-700
'bg_color': '#f8fafc', # slate-50
'border_color': '#cbd5e1', # slate-300
'icon_color': '#64748b', # slate-500
'badge_class': 'bg-secondary-subtle text-secondary border',
}
# Par défaut (Gros œuvre & Autre)
return {
'color': '#475569',
'bg_color': '#f1f5f9',
'border_color': '#cbd5e1',
'icon_color': '#64748b',
'badge_class': 'bg-light text-muted border',
}
@property
def theme_color(self):
return self.get_asset_theme()['color']
@property
def theme_icon_color(self):
return self.get_asset_theme()['icon_color']
@property
def theme_bg_color(self):
return self.get_asset_theme()['bg_color']
@property
def theme_border_color(self):
return self.get_asset_theme()['border_color']
@property
def theme_badge_style(self):
t = self.get_asset_theme()
return f"background-color: {t['bg_color']} !important; color: {t['color']} !important; border: 1px solid {t['border_color']} !important;"
@property
def theme_badge_class(self):
return self.get_asset_theme()['badge_class']
# Backward compatibility alias
BuildingEquipment = BuildingAsset
@ -255,14 +708,34 @@ def upload_to_building(instance, filename):
return f"buildings/{instance.building.code}/{filename}"
BUILDING_DOCUMENT_TYPE_CHOICES = (
('floor_plan', _("Plan d'étage / Plan architectural")),
('as_built', _("Plan de récolement (As-Built)")),
('erp_notice', _("Notice de sécurité / Dossier ERP")),
('dpe', _("Diagnostic énergétique (DPE / PEB)")),
('technical_notice', _("Notice technique / Manuel d'exploitation")),
('other', _("Autre document")),
)
class PublicBuildingDocument(Document):
"""
Documents associés à un bâtiment public (plans, notices ERP, DPE).
"""
building = models.ForeignKey(PublicBuilding, on_delete=models.CASCADE, related_name="documents", verbose_name=_("Bâtiment"))
floor_location = models.ForeignKey(
PublicBuilding,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="floor_documents",
verbose_name=_("Niveau / Étage concerné"),
help_text=_("Niveau ou local auquel ce document/plan se rapporte.")
)
file = models.FileField(upload_to=upload_to_building, storage=private_storage, verbose_name=_("Fichier"), max_length=255)
document_type = models.CharField(max_length=50, default='other', verbose_name=_("Type de document"))
document_type = models.CharField(max_length=50, choices=BUILDING_DOCUMENT_TYPE_CHOICES, default='other', verbose_name=_("Type de document"))
class Meta:
verbose_name = _("Document de bâtiment")
verbose_name_plural = _("Documents de bâtiment")

View file

@ -5,7 +5,7 @@
from django.contrib.gis.db import models
from django.urls import reverse
from django.utils.text import slugify
from django.utils.translation import get_language
from django.utils.translation import get_language, gettext_lazy as _
import os
@ -91,6 +91,7 @@ class ITSGeoAsset(AbstractGeoAsset):
location = models.ForeignKey(ITSLocation, related_name="assets", on_delete=models.SET_NULL, null=True, blank=True)
trafficlights_pole = models.ForeignKey("assets.TrafficLightPole", on_delete=models.SET_NULL, null=True, blank=True, related_name="its_assets", verbose_name="Traffic Light Pole")
lighting_pole = models.ForeignKey("assets.PublicLightingPole", on_delete=models.SET_NULL, null=True, blank=True, related_name="its_assets", verbose_name="Lighting Pole")
extra_data = models.JSONField(default=dict, blank=True, verbose_name=_("Métadonnées techniques"))
def clean(self):
super().clean()
@ -117,6 +118,155 @@ class ITSGeoAsset(AbstractGeoAsset):
def get_absolute_url(self):
return reverse('assets:its_assets_detail', kwargs={'asset_model': 'itsgeoasset', 'asset_id':self.id})
BOLLARD_DIRECTION_CHOICES = (
('unidirectional', _('Sens unique')),
('bidirectional', _('Double sens')),
)
class ITSBollardSystem(AbstractGeoAsset):
"""
Système / Traverse de bornes escamotables (représentation linéaire).
Contrôle l'accès à une voie ou zone piétonne.
"""
geom = models.LineStringField(srid=3812, null=True, blank=True, verbose_name=_("Géométrie (Ligne)"))
model = models.ForeignKey(ITSAssetModel, on_delete=models.PROTECT, null=True, blank=True, verbose_name=_("Modèle de système"))
location = models.ForeignKey(ITSLocation, related_name="bollard_systems", on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_("Localisation ITS"))
width = models.FloatField(null=True, blank=True, verbose_name=_("Largeur de passage (m)"))
direction = models.CharField(max_length=50, choices=BOLLARD_DIRECTION_CHOICES, default='bidirectional', null=True, blank=True, verbose_name=_("Sens de circulation"))
extra_data = models.JSONField(default=dict, blank=True, verbose_name=_("Métadonnées techniques"))
associated_assets = models.ManyToManyField(
'assets.ITSGeoAsset',
through='assets.ITSBollardSystemAsset',
related_name="bollard_systems",
blank=True,
verbose_name=_("Équipements associés")
)
class Meta:
verbose_name = _("Système de bornes")
verbose_name_plural = _("Systèmes de bornes")
indexes = [
models.Index(fields=["code"]),
]
def get_absolute_url(self):
return reverse('assets:its_assets_detail', kwargs={'asset_model': 'itsbollardsystem', 'asset_id': self.id})
@property
def plots_count(self):
return self.plots.count()
@property
def name(self):
return self.get_name()
BOLLARD_TYPE_CHOICES = (
('automatic', _('Automatique / Escamotable')),
('semi_automatic', _('Semi-automatique')),
('fixed', _('Fixe')),
('removable', _('Amovible')),
)
class ITSBollardPlot(AbstractGeoAsset):
"""
Plot / borne physique individuel sur un système de bornes.
"""
geom = models.PointField(srid=3812, null=True, blank=True, verbose_name=_("Géométrie (Point)"))
system = models.ForeignKey(ITSBollardSystem, on_delete=models.CASCADE, related_name="plots", verbose_name=_("Système de bornes"))
model = models.ForeignKey(ITSAssetModel, on_delete=models.PROTECT, null=True, blank=True, verbose_name=_("Modèle de plot"))
position = models.PositiveSmallIntegerField(default=1, verbose_name=_("Position sur la traverse"), help_text=_("Ordre de gauche à droite (1, 2, 3...)"))
bollard_type = models.CharField(max_length=50, choices=BOLLARD_TYPE_CHOICES, default='automatic', verbose_name=_("Type de plot"))
location = models.ForeignKey(ITSLocation, related_name="bollard_plots", on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_("Localisation ITS"))
extra_data = models.JSONField(default=dict, blank=True, verbose_name=_("Métadonnées techniques"))
@property
def name(self):
return self.get_name()
def clean(self):
super().clean()
if not self.location_id and self.system and self.system.location_id:
self.location = self.system.location
def save(self, *args, **kwargs):
if not self.location_id and self.system and self.system.location_id:
self.location = self.system.location
super().save(*args, **kwargs)
class Meta:
verbose_name = _("Plot de borne")
verbose_name_plural = _("Plots de bornes")
ordering = ["system", "position"]
indexes = [
models.Index(fields=["code"]),
]
def get_absolute_url(self):
return reverse('assets:its_assets_detail', kwargs={'asset_model': 'itsbollardplot', 'asset_id': self.id})
class ITSBollardSystemAsset(models.Model):
"""
Table de liaison ManyToMany entre un ITSBollardSystem et des ITSGeoAsset
(caméras LAPI, totems, armoires électriques, feux bicolores, boucles...).
"""
class AssetRole(models.TextChoices):
CAMERA_ENTRY = "camera_entry", _("Caméra entrée (LAPI)")
CAMERA_EXIT = "camera_exit", _("Caméra sortie (LAPI)")
CAMERA_CCTV = "camera_cctv", _("Caméra de contexte / surveillance")
CABINET = "cabinet", _("Armoire électrique / automate")
TOTEM = "totem", _("Totem de commande")
TRAFFIC_LIGHT = "traffic_light", _("Feu de franchissement")
LOOP = "loop", _("Boucle de détection")
OTHER = "other", _("Autre")
bollard_system = models.ForeignKey(
ITSBollardSystem,
on_delete=models.CASCADE,
related_name="system_assets",
verbose_name=_("Système de bornes")
)
asset = models.ForeignKey(
ITSGeoAsset,
on_delete=models.CASCADE,
related_name="bollard_system_links",
verbose_name=_("Équipement ITS")
)
role = models.CharField(
max_length=50,
choices=AssetRole.choices,
default=AssetRole.OTHER,
verbose_name=_("Rôle de l'équipement")
)
comment = models.CharField(max_length=255, blank=True, null=True, verbose_name=_("Commentaire"))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Date d'association"))
class Meta:
verbose_name = _("Équipement associé au système de bornes")
verbose_name_plural = _("Équipements associés au système de bornes")
unique_together = ('bollard_system', 'asset')
def __str__(self):
return f"{self.bollard_system.code} - {self.asset.code} ({self.get_role_display()})"
class ITSBollardSystemContract(models.Model):
system = models.ForeignKey(ITSBollardSystem, on_delete=models.CASCADE, related_name="bollard_systems_contracts")
contract = models.ForeignKey("contracts.Contract", on_delete=models.CASCADE, related_name="bollard_systems_contracts")
status = models.CharField(max_length=50, choices=[('active', 'Actif'), ('inactive', 'Inactif'), ('archived', 'Archivé')], default='active')
maintenance_type = models.CharField(max_length=50, choices=[('equipments', 'Equipements'), ('office', 'Bureautique'), ('system', 'Système'), ('other', 'Autre')], default='other')
def __str__(self):
return f"{self.system.code} - {self.contract.contract_number}"
class Meta:
verbose_name = _("Contrat de système de bornes")
verbose_name_plural = _("Contrats de systèmes de bornes")
unique_together = ('system', 'contract')

View file

@ -0,0 +1,214 @@
from django.contrib.gis.db import models
from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericForeignKey
from django.conf import settings
from django.utils.translation import gettext_lazy as _, get_language
import json
from common.models import Thematic, Document
from common.storages import PrivateMediaStorage
private_storage = PrivateMediaStorage()
class ManagementZoneType(models.Model):
"""
Typologie de zone de gestion spécifique à une thématique
(ex: Fauche tardive / refuge biodiversité, Tonte d'agrément, Onde verte coordonnée, etc.).
"""
code = models.CharField(max_length=100, unique=True, verbose_name=_("Code"))
name_fr = models.CharField(max_length=255, verbose_name=_("Nom (FR)"))
name_nl = models.CharField(max_length=255, blank=True, null=True, verbose_name=_("Nom (NL)"))
thematic = models.ForeignKey(
Thematic,
on_delete=models.CASCADE,
related_name="management_zone_types",
verbose_name=_("Thématique")
)
description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
default_color = models.CharField(max_length=20, default="#198754", verbose_name=_("Couleur par défaut"))
def get_name(self):
lang = get_language()
if lang and lang.startswith("nl") and self.name_nl:
return self.name_nl
return self.name_fr
@property
def name(self):
return self.get_name()
def __str__(self):
return f"{self.get_name()} ({self.thematic.name})"
class Meta:
ordering = ["thematic", "name_fr"]
verbose_name = _("Type de zone de gestion")
verbose_name_plural = _("Types de zones de gestion")
class ManagementZone(models.Model):
"""
Zone de gestion générique avec géométrie polygonale.
Regroupe des équipements/assets selon une logique métier, d'exploitation ou de gestion écologique.
"""
code = models.CharField(max_length=100, unique=True, verbose_name=_("Code"))
name_fr = models.CharField(max_length=255, verbose_name=_("Nom (FR)"))
name_nl = models.CharField(max_length=255, blank=True, null=True, verbose_name=_("Nom (NL)"))
thematic = models.ForeignKey(
Thematic,
on_delete=models.PROTECT,
related_name="management_zones",
verbose_name=_("Thématique")
)
zone_type = models.ForeignKey(
ManagementZoneType,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="management_zones",
verbose_name=_("Type de zone de gestion")
)
description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
instructions = models.TextField(
blank=True,
null=True,
verbose_name=_("Consignes et points d'attention"),
help_text=_("Consignes d'exploitation, points de vigilance, règles de sécurité...")
)
geom = models.MultiPolygonField(
srid=3812,
null=True,
blank=True,
verbose_name=_("Périmètre géographique")
)
color = models.CharField(
max_length=20,
blank=True,
null=True,
verbose_name=_("Couleur d'affichage"),
help_text=_("Code hexadécimal (ex: #198754). Si vide, utilise la couleur du type de zone.")
)
is_active = models.BooleanField(default=True, verbose_name=_("Active"))
target_frequency_days = models.IntegerField(
null=True,
blank=True,
verbose_name=_("Fréquence cible (jours)"),
help_text=_("Intervalle indicatif entre interventions en jours.")
)
seasonality_notes = models.CharField(
max_length=255,
blank=True,
null=True,
verbose_name=_("Saisonnalité / Calendrier"),
help_text=_("Ex: Tonte d'avril à octobre; fauche tardive en septembre; interdiction de taille au printemps.")
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Date de création"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Date de mise à jour"))
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="created_management_zones",
verbose_name=_("Créé par")
)
def get_color(self):
if self.color:
return self.color
if self.zone_type and self.zone_type.default_color:
return self.zone_type.default_color
return "#198754"
@property
def geojson_geometry(self):
"""
Retourne la géométrie GeoJSON transformée en WGS84 (EPSG:4326)
pour utilisation directe dans MapLibre / Leaflet.
"""
if not self.geom:
return None
try:
geom_4326 = self.geom.transform(4326, clone=True)
return json.loads(geom_4326.geojson)
except Exception:
return None
def get_name(self):
lang = get_language()
if lang and lang.startswith("nl") and self.name_nl:
return self.name_nl
return self.name_fr
@property
def name(self):
return self.get_name()
def __str__(self):
return f"{self.code} - {self.get_name()}"
class Meta:
ordering = ["thematic", "code"]
verbose_name = _("Zone de gestion")
verbose_name_plural = _("Zones de gestion")
class ManagementZoneAsset(models.Model):
"""
Liaison explicite (inclusion ou exclusion) d'un asset à une zone de gestion.
Permet d'affiner le rattachement au-delà de la stricte inclusion spatiale.
"""
management_zone = models.ForeignKey(
ManagementZone,
on_delete=models.CASCADE,
related_name="explicit_assets",
verbose_name=_("Zone de gestion")
)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, verbose_name=_("Type d'asset"))
object_id = models.PositiveIntegerField(verbose_name=_("ID de l'asset"))
asset = GenericForeignKey('content_type', 'object_id')
is_excluded = models.BooleanField(
default=False,
verbose_name=_("Exclu"),
help_text=_("Si coché, cet équipement est exclu de la zone même si sa géométrie intersecte le périmètre.")
)
notes = models.CharField(max_length=255, blank=True, null=True, verbose_name=_("Notes"))
def __str__(self):
status = "Exclu" if self.is_excluded else "Inclus"
return f"{self.management_zone.code} -> {self.content_type.model} #{self.object_id} ({status})"
class Meta:
unique_together = ('management_zone', 'content_type', 'object_id')
verbose_name = _("Asset de zone de gestion")
verbose_name_plural = _("Assets de zones de gestion")
class ManagementZoneDocument(Document):
"""
Documents, fiches techniques et arrêtés associés à une zone de gestion.
"""
zone = models.ForeignKey(
ManagementZone,
on_delete=models.CASCADE,
related_name="documents",
verbose_name=_("Zone de gestion")
)
file = models.FileField(
upload_to="assets_documents/management_zones/",
storage=private_storage,
verbose_name=_("Fichier"),
max_length=255
)
thumbnail = models.ImageField(
upload_to="assets_documents/management_zones/",
storage=private_storage,
null=True,
blank=True,
max_length=255
)
class Meta:
verbose_name = _("Document de zone de gestion")
verbose_name_plural = _("Documents de zones de gestion")

View file

@ -146,6 +146,13 @@ TREE_PRUNING_CHOICES = [
('none', _("Non taillé / Entretien minimal")),
]
TREE_MANAGEMENT_TYPE_CHOICES = [
('municipal', _("Communal (Régie communale)")),
('regional', _("Régional (Bruxelles Mobilité / Région)")),
('community', _("Intercommunal / Autre pouvoir public")),
('private', _("Privé / Tiers")),
]
BULB_TYPE_CHOICES = [
('tulipes', _('Tulipes')),
@ -224,6 +231,37 @@ class NatureTree(AbstractGeoAsset):
pruning_frequency_years = models.IntegerField(null=True, blank=True, verbose_name=_("Périodicité de taille (années)"))
last_pruning_date = models.DateField(null=True, blank=True, verbose_name=_("Dernière taille / élagage"))
# Gestionnaire & Compétence
managing_authority = models.CharField(max_length=200, null=True, blank=True, verbose_name=_("Autorité gestionnaire"))
management_type = models.CharField(max_length=50, choices=TREE_MANAGEMENT_TYPE_CHOICES, default='municipal', null=True, blank=True, verbose_name=_("Type de gestionnaire"))
# Taxonomie & Botanique
vernacular_name = models.CharField(max_length=200, null=True, blank=True, verbose_name=_("Nom vernaculaire / Essence"))
scientific_name = models.CharField(max_length=200, null=True, blank=True, verbose_name=_("Nom scientifique / latin"))
genus = models.CharField(max_length=100, null=True, blank=True, verbose_name=_("Genre botanique"))
cultivar = models.CharField(max_length=100, null=True, blank=True, verbose_name=_("Cultivar / Variété"))
# Dendrométrie & Morphologie complémentaire
number_of_trunks = models.IntegerField(null=True, blank=True, default=1, verbose_name=_("Nombre de troncs / Cépée"))
# Éco-services & Valeur patrimoniale (Indicateurs écosystémiques)
amenity_value = models.FloatField(null=True, blank=True, verbose_name=_("Valeur d'aménité (€)"))
carbon_stock = models.FloatField(null=True, blank=True, verbose_name=_("Stock de carbone (t CO2)"))
cooling_indicator = models.FloatField(null=True, blank=True, verbose_name=_("Indice de rafraîchissement"))
cooling_energy_indicator = models.FloatField(null=True, blank=True, verbose_name=_("Énergie de refroidissement (kWh)"))
biodiversity_index = models.FloatField(null=True, blank=True, verbose_name=_("Indice de biodiversité"))
# Évaluation patrimoniale & Barème
situation_coefficient = models.FloatField(null=True, blank=True, verbose_name=_("Coefficient de situation"))
plantation_coefficient = models.FloatField(null=True, blank=True, verbose_name=_("Coefficient de plantation"))
# Statut biologique particulier
is_dead = models.BooleanField(default=False, verbose_name=_("Arbre mort"))
is_stump = models.BooleanField(default=False, verbose_name=_("Souche"))
is_chronoxyle = models.BooleanField(default=False, verbose_name=_("Arbre sénescent / Chronoxyle (bois mort sur pied)"))
death_date = models.DateField(null=True, blank=True, verbose_name=_("Date de dépérissement / mort"))
planting_date = models.DateField(null=True, blank=True, verbose_name=_("Date de plantation réelle"))
# Références des choix pour utilisation simple dans les templates
DEVELOPMENT_STAGE_CHOICES = TREE_DEVELOPMENT_STAGE_CHOICES
VITALITY_CHOICES = TREE_VITALITY_CHOICES
@ -233,6 +271,7 @@ class NatureTree(AbstractGeoAsset):
PIT_TYPE_CHOICES = TREE_PIT_TYPE_CHOICES
PROTECTION_CHOICES = TREE_PROTECTION_CHOICES
PRUNING_CHOICES = TREE_PRUNING_CHOICES
MANAGEMENT_TYPE_CHOICES = TREE_MANAGEMENT_TYPE_CHOICES
indexes = [
models.Index(fields=["code"]),
@ -242,6 +281,11 @@ class NatureTree(AbstractGeoAsset):
verbose_name = _("Arbre")
verbose_name_plural = _("Arbres")
def save(self, *args, **kwargs):
from assets.utils.tree_ecoservices import compute_tree_ecoservices
compute_tree_ecoservices(self, overwrite_existing=False)
super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse('assets:nature_assets_detail', kwargs={'asset_model': 'naturetree', 'asset_id':self.id})

View file

@ -17,13 +17,15 @@ private_storage = PrivateMediaStorage()
STRUCTURE_TYPE_CHOICES = (
('tunnel', 'Tunnnel'),
('bridge', 'Pont'),
('viaduct', 'Viaduc'),
('overpass', 'Passerelle'),
('underpass', 'Passage souterrain'),
('gallery', 'Galerie technique'),
('atypical', 'Ouvrage atypique'),
('tunnel', _('Tunnel')),
('bridge', _('Pont')),
('viaduct', _('Viaduc')),
('overpass', _('Passerelle')),
('underpass', _('Passage souterrain')),
('gallery', _('Galerie technique')),
('atypical', _('Ouvrage atypique')),
('metro_station', _('Station de métro')),
('metro_tunnel', _('Tunnel de métro')),
)

View file

@ -16,32 +16,32 @@ ASSET_EDITABLE_FIELDS_BY_ROLE = {
'admin': [
'code', 'external_reference', 'name_fr', 'name_nl', 'status', 'model', 'category',
'serial_number', 'installation_date', 'brand', 'warranty_duration',
'last_inspection_date', 'funding_program', 'geom', 'lon', 'lat',
'last_inspection_date', 'funding_program', 'note', 'geom', 'lon', 'lat',
'location_id', 'intersection_id',
],
'manager': [
'code', 'external_reference', 'name_fr', 'name_nl', 'status', 'model', 'category',
'serial_number', 'installation_date', 'brand', 'warranty_duration',
'last_inspection_date', 'funding_program', 'geom', 'lon', 'lat',
'last_inspection_date', 'funding_program', 'note', 'geom', 'lon', 'lat',
'location_id', 'intersection_id',
],
'controller': [
'code', 'external_reference', 'name_fr', 'name_nl', 'status', 'model', 'category',
'serial_number', 'installation_date', 'brand', 'warranty_duration',
'last_inspection_date', 'funding_program', 'geom', 'lon', 'lat',
'last_inspection_date', 'funding_program', 'note', 'geom', 'lon', 'lat',
'location_id', 'intersection_id',
],
'external_manager': [
'name_fr', 'name_nl', 'external_reference', 'category', 'brand', 'serial_number', 'funding_program'
'name_fr', 'name_nl', 'external_reference', 'category', 'brand', 'serial_number', 'funding_program', 'note',
],
'technician': ['name_fr', 'name_nl', 'external_reference', 'category', 'brand', 'serial_number', 'funding_program'],
'technician': ['name_fr', 'name_nl', 'external_reference', 'category', 'brand', 'serial_number', 'funding_program', 'note'],
'operator': [],
'observer': [],
'viewer': [],
'editor': [
'code', 'external_reference', 'name_fr', 'name_nl', 'status', 'model', 'category',
'serial_number', 'installation_date', 'brand', 'warranty_duration',
'last_inspection_date', 'funding_program', 'geom', 'lon', 'lat',
'last_inspection_date', 'funding_program', 'note', 'geom', 'lon', 'lat',
'location_id', 'intersection_id',
],
}
@ -258,9 +258,9 @@ def can_view_asset(user, asset):
if not can_view:
return False
# Vérifier la restriction par contrat pour les utilisateurs externes
# Vérifier la restriction par contrat et commune pour les utilisateurs externes ou limités aux contrats
is_external = (user_config is None) or (not user_config.is_intern)
must_limit = is_external and (not user_config or user_config.limit_assets_to_contracts)
must_limit = is_external or (user_config and user_config.limit_assets_to_contracts)
if must_limit and user_config:
asset_cids = get_asset_contract_ids(asset)
if asset_cids is not None:
@ -271,8 +271,35 @@ def can_view_asset(user, asset):
can_view_assets=True
).values_list('contract_id', flat=True)
)
if user_config.default_contract_id:
accessible_cids.add(user_config.default_contract_id)
if not (asset_cids & accessible_cids):
return False
else:
# Vérifier la restriction par commune pour les assets sans contrat direct
accessible_munis = get_user_accessible_municipalities(user)
accessible_muni_ids = set(accessible_munis.values_list('id', flat=True))
if accessible_muni_ids:
muni_id = getattr(asset, 'municipality_id', None)
if not muni_id and hasattr(asset, 'location') and asset.location:
muni_id = getattr(asset.location, 'municipality_id', None)
if not muni_id and hasattr(asset, 'street') and asset.street:
muni_id = getattr(asset.street, 'municipality_id', None)
if not muni_id and hasattr(asset, 'pole') and asset.pole:
muni_id = getattr(asset.pole, 'municipality_id', None)
if not muni_id and hasattr(asset.pole, 'street') and asset.pole.street:
muni_id = getattr(asset.pole.street, 'municipality_id', None)
if not muni_id and hasattr(asset, 'intersection') and asset.intersection:
muni_id = getattr(asset.intersection, 'municipality_id', None)
if not muni_id and hasattr(asset, 'building') and asset.building:
muni_id = getattr(asset.building, 'municipality_id', None)
if muni_id is not None:
if muni_id not in accessible_muni_ids:
return False
elif getattr(asset, 'geom', None):
from common.models import Municipality
if not Municipality.objects.filter(id__in=accessible_muni_ids, geom__intersects=asset.geom).exists():
return False
return True
return _check_instance_permission(user, asset, 'can_view', _fallback)
@ -561,6 +588,10 @@ def get_allowed_update_fields_for_asset(user, asset):
role_fields = ASSET_EDITABLE_FIELDS_BY_ROLE.get(role.name, [])
allowed_fields.update(role_fields)
# Superuser fallback if no explicit roles configured
if getattr(user, 'is_superuser', False) and not allowed_fields:
allowed_fields.update(ASSET_EDITABLE_FIELDS_BY_ROLE.get('admin', []))
# Check validation permission
if can_validate_asset(user, asset):
allowed_fields.add('validation_status')
@ -885,3 +916,124 @@ def get_user_accessible_municipalities(user):
lang = get_language() or 'fr'
order_field = 'name_nl' if lang.startswith('nl') else 'name_fr'
return Municipality.objects.filter(id__in=municipality_ids).order_by(order_field)
# ─── Management Zones Permissions ─────────────────────────────────────────────
def can_view_management_zones(user, thematic=None):
"""
Vérifie si l'utilisateur a le droit de visualiser les zones de gestion.
Si thematic est fourni (instance Thematic ou code str), vérifie pour cette thématique.
"""
if not user or not user.is_authenticated:
return False
if getattr(user, 'is_superuser', False) or user.has_perm('assets.view_managementzone'):
return True
try:
user_config = UserConfig.objects.get(user=user)
except UserConfig.DoesNotExist:
return False
if user_config.roles.filter(name__in=['admin', 'operator', 'manager']).exists():
return True
if thematic:
thematic_obj = thematic
if isinstance(thematic, str):
from common.models import Thematic
thematic_obj = Thematic.objects.filter(code=thematic).first()
if not thematic_obj:
return False
try:
user_thematic = UserThematics.objects.get(user_config=user_config, thematic=thematic_obj)
return user_thematic.can_view_assets
except UserThematics.DoesNotExist:
return False
return UserThematics.objects.filter(user_config=user_config, can_view_assets=True).exists()
def can_edit_management_zones(user, thematic=None):
"""
Vérifie si l'utilisateur a le droit de créer ou modifier les zones de gestion.
Autorisé si:
- Superutilisateur ou permission Django explicite (assets.change_managementzone / assets.add_managementzone)
- Rôle 'admin' ou 'manager'
- Ou UserThematics.can_edit_management_zones == True (ou fallback can_edit_assets)
"""
from django.db import models as db_models
if not user or not user.is_authenticated:
return False
if getattr(user, 'is_superuser', False):
return True
if user.has_perm('assets.change_managementzone') or user.has_perm('assets.add_managementzone'):
return True
try:
user_config = UserConfig.objects.get(user=user)
except UserConfig.DoesNotExist:
return False
if user_config.roles.filter(name__in=['admin', 'manager']).exists():
return True
if thematic:
thematic_obj = thematic
if isinstance(thematic, str):
from common.models import Thematic
thematic_obj = Thematic.objects.filter(code=thematic).first()
if not thematic_obj:
return False
try:
user_thematic = UserThematics.objects.get(user_config=user_config, thematic=thematic_obj)
return bool(
getattr(user_thematic, 'can_edit_management_zones', False) or
user_thematic.can_edit_assets
)
except UserThematics.DoesNotExist:
return False
return UserThematics.objects.filter(
user_config=user_config
).filter(
db_models.Q(can_edit_management_zones=True) | db_models.Q(can_edit_assets=True)
).exists()
def can_delete_management_zone(user, zone):
"""Vérifie si l'utilisateur a le droit de supprimer une zone de gestion."""
if not user or not user.is_authenticated:
return False
if getattr(user, 'is_superuser', False) or user.has_perm('assets.delete_managementzone'):
return True
return can_edit_management_zones(user, thematic=zone.thematic)
def get_editable_thematics_for_management_zones(user):
"""
Retourne le queryset des thématiques pour lesquelles l'utilisateur peut créer/éditer des zones de gestion.
"""
from common.models import Thematic
from django.db import models as db_models
if not user or not user.is_authenticated:
return Thematic.objects.none()
if getattr(user, 'is_superuser', False) or user.has_perm('assets.change_managementzone'):
return Thematic.objects.all()
try:
user_config = UserConfig.objects.get(user=user)
except UserConfig.DoesNotExist:
return Thematic.objects.none()
if user_config.roles.filter(name__in=['admin', 'manager']).exists():
return Thematic.objects.all()
return Thematic.objects.filter(
userthematics__user_config=user_config
).filter(
db_models.Q(userthematics__can_edit_management_zones=True) |
db_models.Q(userthematics__can_edit_assets=True)
).distinct()

View file

@ -94,6 +94,9 @@
addIntersectionAssetsLayer(container);
addConnectionLinesLayer();
}
// Ensure asset layers are stacked above basemaps
bringAssetLayersToTop();
});
}
@ -224,6 +227,8 @@
addLineLayer();
addPointLayer();
}
bringAssetLayersToTop();
}
function addPointLayer() {
@ -651,9 +656,29 @@
'current-asset-icon',
];
function getBasemapBeforeLayer(m) {
if (!m || !m.getStyle) return undefined;
const styleLayers = (m.getStyle() && m.getStyle().layers) || [];
const drawLayer = styleLayers.find(l => l.id && (l.id.startsWith('gl-draw-') || l.id.startsWith('custom-draw-')));
if (drawLayer) return drawLayer.id;
// Return first existing asset layer so basemap is placed below it
for (const id of ASSET_LAYER_ORDER) {
if (m.getLayer(id)) return id;
}
return undefined;
}
function bringAssetLayersToTop() {
if (!map || typeof map.isStyleLoaded !== 'function' || !map.isStyleLoaded()) return;
ASSET_LAYER_ORDER.forEach(id => {
if (map.getLayer(id)) map.moveLayer(id);
if (map.getLayer(id)) {
try {
map.moveLayer(id);
} catch (e) {
// ignore if layer cannot be moved
}
}
});
}
@ -661,16 +686,25 @@
if (typeof LayerSwitcherControl === 'undefined') return;
const layerSwitcher = new LayerSwitcherControl({
onBasemapChange: function() {
bringAssetLayersToTop();
},
layers: [
{
id: 'ortho',
name: 'Orthophotos',
icon: 'satellite',
group: 'ortho',
beforeLayer: getBasemapBeforeLayer,
onLayerAdded: () => bringAssetLayersToTop(),
onVisibilityChange: (layerId, isVisible) => {
if (isVisible) bringAssetLayersToTop();
},
layer: {
type: 'raster',
paint: { 'raster-opacity': 1 }
},
basemaps: (window.MAP_BACKGROUNDS && window.MAP_BACKGROUNDS.length > 0) ? window.MAP_BACKGROUNDS : undefined,
sublayers: [
{
id: 'urbis',
@ -700,6 +734,12 @@
map.addControl(layerSwitcher, 'top-left');
// Listen for layer switcher events on map to keep asset layers on top
map.on('layerswitcher:layerAdded', bringAssetLayersToTop);
map.on('layerswitcher:visibilityChange', (e) => {
if (e && e.visible) bringAssetLayersToTop();
});
// The LayerSwitcherControl appends sublayer selectors to document.body with
// position:fixed and hardcoded coordinates designed for fullscreen maps.
// We move each selector into #asset-map (position:relative) and switch to
@ -723,8 +763,9 @@
// After any ortho interaction, put asset layers back on top
map.getContainer().addEventListener('click', (e) => {
if (e.target.closest('.layer-switcher-btn') || e.target.closest('.layer-switcher-sublayer-btn')) {
setTimeout(bringAssetLayersToTop, 150);
if (e.target.closest('.layer-switcher-btn') || e.target.closest('.layer-switcher-sublayer-btn') || e.target.closest('.layer-switcher-item')) {
setTimeout(bringAssetLayersToTop, 50);
setTimeout(bringAssetLayersToTop, 200);
}
}, true);
}
@ -761,6 +802,10 @@
});
}
}
bringAssetLayersToTop();
};
window.bringAssetLayersToTop = bringAssetLayersToTop;
})();

View file

@ -278,6 +278,10 @@
}
});
if (typeof window.bringAssetLayersToTop === 'function') {
window.bringAssetLayersToTop();
}
// Remove draw control and listeners
if (drawInstance && drawControlAdded) {
mapInstance.off('draw.create', handleDrawCreate);

View file

@ -261,10 +261,37 @@
glyphCodepoint: 'f30b',
mapImage: 'icon-naturerwiasset',
},
naturetree_regional: {
thematic: 'nature',
color: '#1d4ed8',
iconUrl: '/static/assets/nature_tree_icon_regional.svg?v=20261009_round',
iconSize: 56,
iconClass: 'bi-tree',
glyphCodepoint: 'f5e1',
mapImage: 'icon-nature-regional',
},
naturetree_remarkable: {
thematic: 'nature',
color: '#d97706',
iconUrl: '/static/assets/nature_tree_icon_remarkable.svg?v=20261009_round',
iconSize: 56,
iconClass: 'bi-tree',
glyphCodepoint: 'f5e1',
mapImage: 'icon-nature-remarkable',
},
naturetree_dead: {
thematic: 'nature',
color: '#6b7280',
iconUrl: '/static/assets/nature_tree_icon_dead.svg?v=20261009_round',
iconSize: 56,
iconClass: 'bi-tree',
glyphCodepoint: 'f5e1',
mapImage: 'icon-nature-dead',
},
naturetree: {
thematic: 'nature',
color: '#2e7d32',
iconUrl: '/static/assets/nature_tree_icon.svg?v=20260521a',
iconUrl: '/static/assets/nature_tree_icon.svg?v=20261009_round',
iconSize: 56,
iconClass: 'bi-tree-fill',
glyphCodepoint: 'f5e1',
@ -273,11 +300,11 @@
naturegreensurface: {
thematic: 'nature',
color: '#2e7d32',
iconUrl: '/static/assets/nature_tree_icon.svg?v=20260521a',
iconSize: 56,
iconClass: 'bi-tree-fill',
glyphCodepoint: 'f5e1',
mapImage: 'icon-nature',
iconUrl: '/static/assets/nature_greensurface_icon.svg?v=20261010a',
iconSize: 52,
iconClass: 'bi-grid-fill',
glyphCodepoint: 'f3fb',
mapImage: 'icon-naturegreensurface',
},
natureshrub: {
thematic: 'nature',
@ -427,6 +454,33 @@
glyphCodepoint: 'f82b',
mapImage: 'icon-sign',
},
// ITS
itsgeoasset: {
thematic: 'its',
color: '#0288d1',
iconUrl: null,
iconClass: 'bi-broadcast-pin',
glyphCodepoint: 'f1d5',
mapImage: 'icon-its',
},
itsbollardsystem: {
thematic: 'its',
color: '#0288d1',
iconUrl: null,
iconClass: 'bi-border-width',
glyphCodepoint: 'f1ca',
mapImage: 'icon-itsbollardsystem',
},
itsbollardplot: {
thematic: 'its',
color: '#0288d1',
iconUrl: '/static/assets/its_bollard_plot_icon.svg?v=20261008',
iconSize: 42,
iconClass: 'bi-cone-striped',
glyphCodepoint: 'f2aa',
mapImage: 'icon-itsbollardplot',
},
};
// ─── 3. Specific Parking Spot Overrides by spot_type ─────────────────────────
@ -551,6 +605,26 @@
}
// 3. Specific asset_type override
if (assetType === 'naturetree' || (thematic === 'nature' && (assetType === 'naturetree' || !assetType))) {
const isDead = item.is_dead === true || item.is_dead === 'true' || item.is_dead === 1 ||
item.vitality === 'dead' || item.status === 'dead' || item.status === 'archived';
if (isDead && ASSET_TYPE_OVERRIDES.naturetree_dead) {
return Object.assign({}, ASSET_TYPE_OVERRIDES.naturetree_dead);
}
const isRemarkable = item.is_remarkable === true || item.is_remarkable === 'true' || item.is_remarkable === 1;
const isRegional = item.management_type === 'regional' ||
(typeof item.managing_authority === 'string' && (item.managing_authority.toLowerCase().includes('région') || item.managing_authority.toLowerCase().includes('mobilité'))) ||
(typeof item.code === 'string' && item.code.toUpperCase().startsWith('BM-ARB'));
if (isRemarkable && ASSET_TYPE_OVERRIDES.naturetree_remarkable) {
return Object.assign({}, ASSET_TYPE_OVERRIDES.naturetree_remarkable);
}
if (isRegional && ASSET_TYPE_OVERRIDES.naturetree_regional) {
return Object.assign({}, ASSET_TYPE_OVERRIDES.naturetree_regional);
}
if (ASSET_TYPE_OVERRIDES.naturetree) {
return Object.assign({}, ASSET_TYPE_OVERRIDES.naturetree);
}
}
if (assetType === 'naturebench') {
const isPlanter = (item.asset_sub_type === 'planter') ||
['Bac à fleurs / Jardinière', 'Bloembak', 'Jardinière', 'Bac à fleurs', 'Bac à plantes'].includes(item.model) ||
@ -598,12 +672,17 @@
const customStyle = options.style ? ` style="${options.style}"` : '';
const classList = ['asset-symbology-icon-img'];
if (options.imgClass) classList.push(options.imgClass);
const imgClass = classList.join(' ');
const defaultImgStyle = 'width: 20px; height: 20px; max-width: 20px; max-height: 20px; object-fit: contain; vertical-align: middle; display: inline-block;';
const finalStyle = options.style ? ` style="${defaultImgStyle} ${options.style}"` : ` style="${defaultImgStyle}"`;
if (symbology.iconUrl) {
const imgClass = options.imgClass || 'asset-symbology-icon-img';
const fallbackUrl = symbology.fallbackUrl || (symbology.iconUrl.includes('.svg') ? symbology.iconUrl.replace(/\.svg(\?.*)?$/, '.png$1') : '');
const fallbackClass = options.iconClass || symbology.iconClass || 'bi-sign-stop-fill';
const onerrorAttr = ` onerror="this.onerror=null; ${fallbackUrl ? `if(this.src && this.src.indexOf('.svg') !== -1){ this.src='${fallbackUrl}'; } else {` : ''} this.style.display='none'; if(this.nextElementSibling){ this.nextElementSibling.style.display=''; } ${fallbackUrl ? '}' : ''}"`;
return `<img src="${symbology.iconUrl}" alt="" class="${imgClass}"${customStyle}${onerrorAttr}><i class="bi ${fallbackClass}" style="display:none;${options.style || ''}"></i>`;
return `<img src="${symbology.iconUrl}" alt="" class="${imgClass}"${finalStyle}${onerrorAttr}><i class="bi ${fallbackClass}" style="display:none;${options.style || ''}"></i>`;
}
const iconClass = options.iconClass || symbology.iconClass || 'bi-pin-map';
@ -626,6 +705,10 @@
publiclighting: injected.publiclighting || THEMATIC_CONFIG.publiclighting.iconUrl,
publiclightingelectricalcabinet: injected.publiclightingelectricalcabinet || ASSET_TYPE_OVERRIDES.publiclightingelectricalcabinet.iconUrl,
nature: injected.nature || THEMATIC_CONFIG.nature.iconUrl,
natureregional: injected.natureregional || (ASSET_TYPE_OVERRIDES.naturetree_regional && ASSET_TYPE_OVERRIDES.naturetree_regional.iconUrl),
natureremarkable: injected.natureremarkable || (ASSET_TYPE_OVERRIDES.naturetree_remarkable && ASSET_TYPE_OVERRIDES.naturetree_remarkable.iconUrl),
naturedead: injected.naturedead || (ASSET_TYPE_OVERRIDES.naturetree_dead && ASSET_TYPE_OVERRIDES.naturetree_dead.iconUrl),
naturegreensurface: injected.naturegreensurface || (ASSET_TYPE_OVERRIDES.naturegreensurface && ASSET_TYPE_OVERRIDES.naturegreensurface.iconUrl),
structures: injected.structures || THEMATIC_CONFIG.structures.iconUrl,
roaddrain: injected.roaddrain || ASSET_TYPE_OVERRIDES.roaddrain.iconUrl,
roadconcreteblockset: injected.roadconcreteblockset || ASSET_TYPE_OVERRIDES.roadconcreteblockset.iconUrl,
@ -633,6 +716,7 @@
natureplanter: injected.natureplanter || (ASSET_TYPE_OVERRIDES.natureplanter && ASSET_TYPE_OVERRIDES.natureplanter.iconUrl),
artworkfountain: injected.artworkfountain || ASSET_TYPE_OVERRIDES.artworkfountain.iconUrl,
artworkmonument: injected.artworkmonument || ASSET_TYPE_OVERRIDES.artworkmonument.iconUrl,
itsbollardplot: injected.itsbollardplot || (ASSET_TYPE_OVERRIDES.itsbollardplot && ASSET_TYPE_OVERRIDES.itsbollardplot.iconUrl),
};
return urls;
}
@ -650,12 +734,16 @@
{ name: 'icon-trafficlightradarpole', codepoint: 'f4e0', color: '#e53935' },
{ name: 'icon-sign', codepoint: 'f82b', color: '#0022ff' },
{ name: 'icon-nature', codepoint: 'f5e1', color: '#2e7d32' },
{ name: 'icon-naturegreensurface', codepoint: 'f3fb', color: '#2e7d32' },
{ name: 'icon-naturebench', codepoint: 'f5e1', color: '#2e7d32' },
{ name: 'icon-natureplanter', codepoint: 'f3ce', color: '#d97706' },
{ name: 'icon-naturerwiasset', codepoint: 'f30b', color: '#0277bd' },
{ name: 'icon-roaddrain', codepoint: 'f617', color: '#1e88e5' },
{ name: 'icon-structures', codepoint: 'f876', color: '#6d4c41' },
{ name: 'icon-its', codepoint: 'f1d5', color: '#0288d1' },
{ name: 'icon-itsgeoasset', codepoint: 'f1d5', color: '#0288d1' },
{ name: 'icon-itsbollardsystem', codepoint: 'f1ca', color: '#0288d1' },
{ name: 'icon-itsbollardplot', codepoint: 'f2aa', color: '#0288d1' },
{ name: 'icon-clean', codepoint: 'f78a', color: '#5e5e5e' },
{ name: 'icon-cleanglasscontainer', codepoint: 'f78a', color: '#16a34a' },
{ name: 'icon-artworks', codepoint: 'f4b0', color: '#f24141' },
@ -742,6 +830,27 @@
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
], 'icon-natureplanter',
['==', ['get', 'asset_type'], 'naturebench'], 'icon-naturebench',
['all',
['any', ['==', ['get', 'asset_type'], 'naturetree'], ['==', ['get', 'thematic'], 'nature']],
['any',
['==', ['get', 'is_dead'], true],
['==', ['get', 'is_dead'], 'true'],
['==', ['get', 'is_dead'], 1],
['==', ['get', 'status'], 'dead'],
['==', ['get', 'status'], 'archived'],
['==', ['get', 'vitality'], 'dead']
]
], 'icon-nature-dead',
['all',
['any', ['==', ['get', 'asset_type'], 'naturetree'], ['==', ['get', 'thematic'], 'nature']],
['any', ['==', ['get', 'is_remarkable'], true], ['==', ['get', 'is_remarkable'], 'true'], ['==', ['get', 'is_remarkable'], 1]]
], 'icon-nature-remarkable',
['all',
['any', ['==', ['get', 'asset_type'], 'naturetree'], ['==', ['get', 'thematic'], 'nature']],
['any', ['==', ['get', 'management_type'], 'regional'], ['==', ['get', 'management_type'], 'Régional']]
], 'icon-nature-regional',
['==', ['get', 'asset_type'], 'naturetree'], 'icon-nature',
['==', ['get', 'asset_type'], 'naturegreensurface'], 'icon-naturegreensurface',
['==', ['get', 'asset_type'], 'drain'], 'icon-roaddrain',
['==', ['get', 'asset_type'], 'roaddrain'], 'icon-roaddrain',
['==', ['get', 'category'], 'Ralentisseur'], 'icon-speedbump',
@ -749,6 +858,9 @@
['==', ['get', 'asset_type'], 'roadfurniture'], 'icon-speedbump',
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 'icon-roadconcreteblockset',
['==', ['get', 'asset_type'], 'naturerwiasset'], 'icon-naturerwiasset',
['==', ['get', 'asset_type'], 'itsbollardplot'], 'icon-itsbollardplot',
['==', ['get', 'asset_type'], 'itsbollardsystem'], 'icon-itsbollardsystem',
['==', ['get', 'asset_type'], 'itsgeoasset'], 'icon-its',
['==', ['get', 'spot_type'], 'scooter_drop_zone'], 'icon-parkingspot-scooter_drop_zone',
['==', ['get', 'spot_type'], 'pmr'], 'icon-parkingspot-pmr',
['==', ['get', 'spot_type'], 'ev_charging'], 'icon-parkingspot-ev_charging',
@ -924,14 +1036,20 @@
'#0288d1'
],
['==', ['get', 'thematic'], 'nature'], '#2e7d32',
['==', ['get', 'asset_type'], 'itsbollardsystem'], '#0288d1',
['match', ['get', 'asset_type'], 'roadway', '#334155', 'sidewalk', '#64748b', getThematicColorExpr()]
],
'line-width': [
'case',
['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], 6,
['==', ['get', 'asset_type'], 'itsbollardsystem'], 5,
3.5
],
'line-opacity': 0.88,
'line-opacity': [
'case',
['==', ['get', 'asset_type'], 'itsbollardsystem'], 0.95,
0.88
],
},
minzoom: 8,
};

View file

@ -79,6 +79,21 @@ const draw = new MapboxDraw({
map.on('load', () => {
map.addControl(draw);
// Contrôle sélecteur de couches (LayerSwitcherControl)
if (typeof LayerSwitcherControl !== 'undefined') {
const layerSwitcher = new LayerSwitcherControl({
layers: [{
id: 'basemap',
name: (typeof gettext === 'function') ? gettext('Fonds de plan & Orthophotos') : 'Fonds de plan & Orthophotos',
icon: 'satellite',
beforeLayer: 'locations',
basemaps: window.MAP_BACKGROUNDS || [],
}],
});
map.addControl(layerSwitcher, 'top-right');
window.layerSwitcher = layerSwitcher;
}
map.boxZoom.enable();
// Initial state

View file

@ -1,44 +0,0 @@
const map = new maplibregl.Map({
style: window.MAP_STYLE_LIGHT,
center: [feature_lon, feature_lat],
zoom: 17,
pitch: 45,
bearing: -17.6,
container: 'map',
antialias: true
});
map.on('load', () => {
const featureData = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: JSON.parse(feature_geojson),
properties: {
name: '...'
}
}
]
};
// Add the circle as a GeoJSON source
map.addSource('green', {
type: 'geojson',
data: featureData
});
map.addLayer({
'id': 'green',
'type': 'fill',
'source': 'green',
'paint': {
'fill-color': '#0094ff',
'fill-opacity': 0.8
}
});
});

View file

@ -89,9 +89,11 @@
if (typeof AddressSearchControl !== 'undefined') {
const autocompleteUrl = window.AUTOCOMPLETE_STREET_URL || '/common/autocomplete-street/';
const i18n = window.INSPECTION_MAP_I18N || {};
const placeholder = i18n.searchAddress || (typeof gettext !== 'undefined' ? gettext('Rechercher une adresse…') : 'Rechercher une adresse…');
const searchCtrl = new AddressSearchControl({
autocompleteUrl: autocompleteUrl,
placeholder: 'Rechercher une adresse...'
placeholder: placeholder
});
map.addControl(searchCtrl, 'top-left');
}
@ -288,7 +290,7 @@
'match', ['coalesce', ['get', 'structure_type'], ''],
['massif drainant', 'tranchee drainante', 'drain', 'collecteur en beton poreux', 'reseau egout separatif', 'oed'], '#8d6e63',
['noue', 'jardin de pluie', 'bande filtrante', 'evc'], '#009688',
['filet eau', 'caniveau infiltrant', 'gargouille de trottoir', 'avaloir de rail', 'trop plein', 'ta'], '#0277bd',
['filet eau', 'caniveau infiltrant', 'gargouille de trottoir', 'avaloir de rail', 'trop plein', 'ta'], '#455a64',
'#0288d1'
],
'line-width': 2.5
@ -341,11 +343,13 @@
['==', ['coalesce', ['get', 'status'], ''], 'inactive'], '#343a40',
'#198754'
],
['match', ['coalesce', ['get', 'model_name'], ''], ['roaddrain', 'drain'], true, false], '#37474f',
[
'match', ['coalesce', ['get', 'structure_type'], ''],
['massif drainant', 'tranchee drainante', 'oed'], '#8d6e63',
['jardin de pluie', 'noue', 'evc'], '#009688',
['bassin orage', 'citerne', 'ta'], '#0277bd',
['avaloir', 'avaloir de rail', 'bouche injection', 'filet eau', 'caniveau infiltrant', 'gargouille de trottoir', 'puits infiltration', 'drain', 'regard', 'vanne regulation', 'vanne isolation'], '#37474f',
'#0288d1'
]
],
@ -729,50 +733,99 @@
}, 400);
}
let gpsWatchId = null;
let isFirstGpsFix = true;
function setupGeolocation() {
const gpsBtn = document.getElementById('btn-gps-locate');
if (gpsBtn) {
gpsBtn.addEventListener('click', locateUser);
gpsBtn.addEventListener('click', function() {
if (window.currentGpsPosition && map) {
map.flyTo({
center: [window.currentGpsPosition.lng, window.currentGpsPosition.lat],
zoom: 18,
duration: 800
});
} else {
startGpsTracking(true);
}
});
}
const hasPreselect = (window.PRESELECT_LON !== null && window.PRESELECT_LAT !== null && typeof window.PRESELECT_LON !== 'undefined' && typeof window.PRESELECT_LAT !== 'undefined');
if (!hasPreselect) {
locateUser();
startGpsTracking(!hasPreselect);
window.addEventListener('beforeunload', stopGpsTracking);
}
function stopGpsTracking() {
if (gpsWatchId !== null && navigator.geolocation) {
navigator.geolocation.clearWatch(gpsWatchId);
gpsWatchId = null;
}
}
function locateUser() {
startGpsTracking(true);
}
function startGpsTracking(shouldFlyTo = false) {
if (!navigator.geolocation) return;
const i18n = window.INSPECTION_MAP_I18N || {};
const statusText = document.getElementById('gps-status-text');
if (statusText) statusText.textContent = 'Géolocalisation...';
if (statusText) statusText.textContent = i18n.geolocation || (typeof gettext !== 'undefined' ? gettext('Géolocalisation...') : 'Géolocalisation...');
navigator.geolocation.getCurrentPosition(
if (gpsWatchId !== null) {
navigator.geolocation.clearWatch(gpsWatchId);
gpsWatchId = null;
}
gpsWatchId = navigator.geolocation.watchPosition(
function(position) {
const lng = position.coords.longitude;
const lat = position.coords.latitude;
const accuracy = position.coords.accuracy;
window.currentGpsPosition = { lng, lat, accuracy, timestamp: Date.now() };
try {
window.dispatchEvent(new CustomEvent('loko:gps-update', {
detail: window.currentGpsPosition
}));
} catch(e) {}
if (typeof maplibregl !== 'undefined' && maplibregl.Marker && map) {
if (userMarker) {
userMarker.setLngLat([lng, lat]);
} else {
const el = document.createElement('div');
el.className = 'user-gps-dot';
el.className = 'user-gps-dot-container';
el.innerHTML = '<div class="user-gps-dot-pulse"></div><div class="user-gps-dot"></div>';
userMarker = new maplibregl.Marker({ element: el })
userMarker = new maplibregl.Marker({ element: el, anchor: 'center' })
.setLngLat([lng, lat])
.addTo(map);
}
map.flyTo({ center: [lng, lat], zoom: 17 });
if (shouldFlyTo || isFirstGpsFix) {
map.flyTo({ center: [lng, lat], zoom: 18, duration: 1000 });
isFirstGpsFix = false;
shouldFlyTo = false;
}
}
if (statusText) {
statusText.textContent = i18n.positionUpdated || (typeof gettext !== 'undefined' ? gettext('Position actualisée') : 'Position actualisée');
}
if (statusText) statusText.textContent = 'Position actualisée';
},
function(err) {
console.warn('GPS error:', err.message);
if (statusText) statusText.textContent = 'GPS indisponible';
console.warn('GPS watchPosition error:', err.message);
if (statusText) {
statusText.textContent = i18n.gpsUnavailable || (typeof gettext !== 'undefined' ? gettext('GPS indisponible') : 'GPS indisponible');
}
},
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 5000 }
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 2000 }
);
}
@ -931,13 +984,17 @@
const STORAGE_HISTORY_PERIOD_KEY = 'loko_insp_history_period';
function getHistoryPeriodLabels() {
const i18n = window.INSPECTION_MAP_I18N || {};
if (i18n.periodLabels) {
return i18n.periodLabels;
}
return {
'disabled': 'Inspections (Désactivé)',
'1m': 'Inspections (1 mois)',
'3m': 'Inspections (3 mois)',
'6m': 'Inspections (6 mois)',
'1y': 'Inspections (1 an)',
'all': 'Inspections (Tout)'
'disabled': (typeof gettext !== 'undefined' ? gettext('Inspections (Désactivé)') : 'Inspections (Désactivé)'),
'1m': (typeof gettext !== 'undefined' ? gettext('Inspections (1 mois)') : 'Inspections (1 mois)'),
'3m': (typeof gettext !== 'undefined' ? gettext('Inspections (3 mois)') : 'Inspections (3 mois)'),
'6m': (typeof gettext !== 'undefined' ? gettext('Inspections (6 mois)') : 'Inspections (6 mois)'),
'1y': (typeof gettext !== 'undefined' ? gettext('Inspections (1 an)') : 'Inspections (1 an)'),
'all': (typeof gettext !== 'undefined' ? gettext('Inspections (Tout)') : 'Inspections (Tout)')
};
}
@ -959,7 +1016,7 @@
}
});
if (labelEl) labelEl.textContent = labels[period] || 'Inspections (3 mois)';
if (labelEl) labelEl.textContent = labels[period] || labels['3m'] || (typeof gettext !== 'undefined' ? gettext('Inspections (3 mois)') : 'Inspections (3 mois)');
if (iconEl) {
if (period === 'disabled') {
iconEl.className = 'bi bi-eye-slash text-muted me-1';
@ -1261,7 +1318,7 @@
document.getElementById('map-insp-summary').textContent = insp.details_summary || 'Aucun constat spécifique.';
const notesEl = document.getElementById('map-insp-notes');
if (notesEl) {
if (insp.notes) {
if (insp.notes && !insp.is_giep) {
notesEl.textContent = insp.notes;
notesEl.classList.remove('d-none');
} else {
@ -1415,6 +1472,23 @@
window.currentGiepLocationId = props.location_id || (props.is_location ? objId : null) || objId;
}
const isGiep = (modelName === 'naturerwiz' || isZoneRwiz);
const quickNotesSection = document.getElementById('quickInspNotesSection');
const quickResultSection = document.getElementById('quickInspResultSection');
const quickPhotosSection = document.getElementById('quickInspPhotosSection');
if (quickNotesSection) {
if (isGiep) quickNotesSection.classList.add('d-none');
else quickNotesSection.classList.remove('d-none');
}
if (quickResultSection) {
if (isGiep) quickResultSection.classList.add('d-none');
else quickResultSection.classList.remove('d-none');
}
if (quickPhotosSection) {
if (isGiep) quickPhotosSection.classList.add('d-none');
else quickPhotosSection.classList.remove('d-none');
}
document.getElementById('insp_target_type').textContent = props.element_type || props.asset_type_display || props.model_name || 'Asset';
document.getElementById('insp_target_code').textContent = props.code || props.target_display || (objId ? ('#' + objId) : '-');
document.getElementById('insp_target_name').textContent = props.name || props.name_fr || props.target_display || '-';
@ -1980,9 +2054,9 @@
row.innerHTML = `
<div class="d-flex align-items-center justify-content-between mb-2">
<span class="badge bg-primary rounded-pill small">#${order} (${labelFr || 'Panneau'})</span>
<span class="badge bg-primary rounded-pill small">#${order} (${labelFr || (window.gettext ? window.gettext('Panneau') : 'Panneau')})</span>
${confidence ? `<span class="badge bg-success-subtle text-success border border-success-subtle extra-small">IA: ${Math.round(confidence * 100)}%</span>` : ''}
<button type="button" class="btn btn-outline-danger btn-xs py-0 px-1.5 btn-remove-panel-row" style="font-size: 0.75rem;" title="Supprimer ce panneau">
<button type="button" class="btn btn-outline-danger btn-xs py-0 px-1.5 btn-remove-panel-row" style="font-size: 0.75rem;" title="${window.gettext ? window.gettext('Supprimer ce panneau') : 'Supprimer ce panneau'}">
<i class="bi bi-trash"></i>
</button>
</div>
@ -1994,23 +2068,23 @@
</div>
<div class="col">
<div class="input-group input-group-sm mb-1">
<span class="input-group-text fw-bold">Code</span>
<span class="input-group-text fw-bold">${window.gettext ? window.gettext('Code') : 'Code'}</span>
<input type="text" class="form-control form-control-sm text-uppercase panel-row-code" value="${upperCode}" placeholder="Ex: C1, D7, B1, M2...">
</div>
<input type="text" class="form-control form-control-sm panel-row-text" value="${text || ''}" placeholder="Mention (ex: Sauf riverains)">
<input type="text" class="form-control form-control-sm panel-row-text" value="${text || ''}" placeholder="${window.gettext ? window.gettext('Mention (ex: Sauf riverains)') : 'Mention (ex: Sauf riverains)'}">
</div>
</div>
<div class="mt-2 pt-1 border-top d-flex align-items-center justify-content-between">
<small class="text-muted extra-small">Propreté :</small>
<small class="text-muted extra-small">${window.gettext ? window.gettext('Propreté :') : 'Propreté :'}</small>
<div class="btn-group btn-group-sm" role="group">
<input type="radio" class="btn-check panel-row-clean" name="row_clean_${order}_${Date.now()}" id="rc_clean_${order}_${Date.now()}" value="clean" ${cleanliness === 'clean' ? 'checked' : ''}>
<label class="btn btn-outline-success btn-xs py-0 px-2" for="rc_clean_${order}_${Date.now()}">Propre</label>
<label class="btn btn-outline-success btn-xs py-0 px-2" for="rc_clean_${order}_${Date.now()}">${window.gettext ? window.gettext('Propre') : 'Propre'}</label>
<input type="radio" class="btn-check panel-row-clean" name="row_clean_${order}_${Date.now()}" id="rc_dirty_${order}_${Date.now()}" value="dirty" ${cleanliness === 'dirty' ? 'checked' : ''}>
<label class="btn btn-outline-warning btn-xs py-0 px-2" for="rc_dirty_${order}_${Date.now()}">Sale</label>
<label class="btn btn-outline-warning btn-xs py-0 px-2" for="rc_dirty_${order}_${Date.now()}">${window.gettext ? window.gettext('Sale') : 'Sale'}</label>
<input type="radio" class="btn-check panel-row-clean" name="row_clean_${order}_${Date.now()}" id="rc_veg_${order}_${Date.now()}" value="vegetation" ${cleanliness === 'vegetation' ? 'checked' : ''}>
<label class="btn btn-outline-warning btn-xs py-0 px-2" for="rc_veg_${order}_${Date.now()}">Végétation</label>
<label class="btn btn-outline-warning btn-xs py-0 px-2" for="rc_veg_${order}_${Date.now()}">${window.gettext ? window.gettext('Végétation') : 'Végétation'}</label>
<input type="radio" class="btn-check panel-row-clean" name="row_clean_${order}_${Date.now()}" id="rc_vand_${order}_${Date.now()}" value="vandalized" ${cleanliness === 'vandalized' ? 'checked' : ''}>
<label class="btn btn-outline-danger btn-xs py-0 px-2" for="rc_vand_${order}_${Date.now()}">Dégradé</label>
<label class="btn btn-outline-danger btn-xs py-0 px-2" for="rc_vand_${order}_${Date.now()}">${window.gettext ? window.gettext('Dégradé') : 'Dégradé'}</label>
</div>
</div>
`;
@ -2043,13 +2117,13 @@
const lon = document.getElementById('new_sign_lon')?.value;
if (!lat || !lon) {
alert('Coordonnées GPS manquantes.');
alert(window.gettext ? window.gettext('Coordonnées GPS manquantes.') : 'Coordonnées GPS manquantes.');
return;
}
const rows = document.querySelectorAll('.new-sign-panel-row');
if (!rows.length) {
alert('Veuillez ajouter au moins un panneau.');
alert(window.gettext ? window.gettext('Veuillez ajouter au moins un panneau.') : 'Veuillez ajouter au moins un panneau.');
return;
}
@ -2074,7 +2148,8 @@
newSignPhotos.forEach(p => formData.append('photos', p));
btnSubmit.disabled = true;
btnSubmit.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Création en cours...';
const creatingMsg = window.gettext ? window.gettext('Création en cours...') : 'Création en cours...';
btnSubmit.innerHTML = `<span class="spinner-border spinner-border-sm me-1"></span> ${creatingMsg}`;
try {
const csrfToken = getCsrfToken();
@ -2095,7 +2170,7 @@
if (data.success) {
modal.hide();
alert(data.message || 'Panneaux créés avec succès !');
alert(data.message || (window.gettext ? window.gettext('Panneaux créés avec succès !') : 'Panneaux créés avec succès !'));
// Basculer automatiquement sur la thématique Signalisation
localStorage.setItem(STORAGE_THEMATIC_KEY, 'sign');
@ -2113,13 +2188,17 @@
loadAssets();
loadInspectionsHistory();
} else {
alert('Erreur : ' + (data.message || 'Impossible de créer les panneaux.'));
const errPrefix = window.gettext ? window.gettext('Erreur : ') : 'Erreur : ';
const defaultErrMsg = window.gettext ? window.gettext('Impossible de créer les panneaux.') : 'Impossible de créer les panneaux.';
alert(errPrefix + (data.message || defaultErrMsg));
}
} catch(err) {
alert('Erreur lors de la création : ' + err.message);
const errCreationMsg = window.gettext ? window.gettext('Erreur lors de la création : ') : 'Erreur lors de la création : ';
alert(errCreationMsg + err.message);
} finally {
btnSubmit.disabled = false;
btnSubmit.innerHTML = '<i class="bi bi-check2-circle fs-6"></i> <span>Créer et Enregistrer</span>';
const createAndSaveMsg = window.gettext ? window.gettext('Créer et Enregistrer') : 'Créer et Enregistrer';
btnSubmit.innerHTML = `<i class="bi bi-check2-circle fs-6"></i> <span>${createAndSaveMsg}</span>`;
}
});
}
@ -2176,19 +2255,22 @@
const modal = bootstrap.Modal.getInstance(modalEl);
if (modal) modal.hide();
let msg = data.message || 'Inspection enregistrée !';
let msg = data.message || (window.gettext ? window.gettext('Inspection enregistrée !') : 'Inspection enregistrée !');
if (data.created_intervention_code) {
msg += ' Intervention ' + data.created_intervention_code + ' créée.';
const intervLabel = window.gettext ? window.gettext('Intervention') : 'Intervention';
const createdLabel = window.gettext ? window.gettext('créée.') : 'créée.';
msg += ` ${intervLabel} ${data.created_intervention_code} ${createdLabel}`;
}
alert(msg);
loadAssets();
} else {
alertDiv.textContent = data.error || 'Erreur lors de la validation.';
const defaultValError = window.gettext ? window.gettext('Erreur lors de la validation.') : 'Erreur lors de la validation.';
alertDiv.textContent = data.error || defaultValError;
alertDiv.classList.remove('d-none');
}
})
.catch(err => {
alertDiv.textContent = 'Erreur réseau.';
alertDiv.textContent = window.gettext ? window.gettext('Erreur réseau.') : 'Erreur réseau.';
alertDiv.classList.remove('d-none');
})
.finally(() => {

View file

@ -0,0 +1,36 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
<ellipse cx="32" cy="54" rx="22" ry="5" fill="#1e293b" opacity="0.3"/>
<ellipse cx="32" cy="52" rx="18" ry="4" fill="#334155"/>
<ellipse cx="32" cy="51" rx="16" ry="3.5" fill="#64748b"/>
<ellipse cx="32" cy="50.5" rx="13" ry="2.8" fill="#1e293b"/>
<defs>
<linearGradient id="bollardGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#0284c7"/>
<stop offset="25%" stop-color="#38bdf8"/>
<stop offset="60%" stop-color="#0284c7"/>
<stop offset="100%" stop-color="#0369a1"/>
</linearGradient>
<linearGradient id="capGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#7dd3fc"/>
<stop offset="100%" stop-color="#0284c7"/>
</linearGradient>
<linearGradient id="bandGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#f59e0b"/>
<stop offset="35%" stop-color="#fef08a"/>
<stop offset="70%" stop-color="#f59e0b"/>
<stop offset="100%" stop-color="#d97706"/>
</linearGradient>
</defs>
<path d="M 21,24 L 21,50 C 21,53 43,53 43,50 L 43,24 Z" fill="url(#bollardGrad)"/>
<path d="M 21,21 L 21,27 C 21,29.5 43,29.5 43,27 L 43,21 C 43,23.5 21,23.5 21,21 Z" fill="url(#bandGrad)"/>
<path d="M 21,15 L 21,21 C 21,23.5 43,23.5 43,21 L 43,15 Z" fill="url(#bollardGrad)"/>
<ellipse cx="32" cy="15" rx="11" ry="3.5" fill="url(#capGrad)" stroke="#bae6fd" stroke-width="0.8"/>
<ellipse cx="32" cy="14.2" rx="7" ry="2" fill="#ffffff" opacity="0.6"/>
<circle cx="26" cy="24.5" r="1" fill="#ffffff"/>
<circle cx="32" cy="25.2" r="1.1" fill="#ffffff"/>
<circle cx="38" cy="24.5" r="1" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1,405 @@
/**
* Management Zones Map Overlay & Layer Switcher Integration
*
* Permet d'afficher en surimpression les zones de gestion (gestion écologique,
* ondes vertes, secteurs prioritaires, etc.) sur la carte MapLibre
* et ajoute une section dédiée "Zones de gestion" dans le LayerSwitcherControl.
*/
(function (window) {
'use strict';
/**
* Calcule la bounding box [[minX, minY], [maxX, maxY]] d'une géométrie GeoJSON.
*/
function getGeometryBBox(geometry) {
if (!geometry || !geometry.coordinates) return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
function traverse(coords) {
if (typeof coords[0] === 'number') {
const [x, y] = coords;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
} else if (Array.isArray(coords)) {
for (let i = 0; i < coords.length; i++) {
traverse(coords[i]);
}
}
}
traverse(geometry.coordinates);
if (minX === Infinity || minY === Infinity) return null;
return [[minX, minY], [maxX, maxY]];
}
/**
* Initialise l'overlay des zones de gestion sur la carte MapLibre.
*/
async function initManagementZonesMapOverlay(options = {}) {
const map = options.map;
if (!map) {
console.warn('[ManagementZonesMapOverlay] Map instance missing.');
return;
}
if (!map.loaded()) {
map.once('load', () => initManagementZonesMapOverlay(options));
return;
}
const thematic = options.thematic || 'nature';
const geojsonUrl = options.geojsonUrl || '/assets/api/management-zones/geojson/';
const layerSwitcher = options.layerSwitcher || window.layerSwitcher;
const beforeLayerId = options.beforeLayerId || null;
const _t = (key, fallback) => (typeof gettext === 'function' ? gettext(key) : (fallback || key));
try {
const url = new URL(geojsonUrl, window.location.origin);
url.searchParams.set('thematic', thematic);
const response = await fetch(url.toString(), {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
if (!response.ok) {
console.warn('[ManagementZonesMapOverlay] Failed to fetch geojson:', response.status);
return;
}
const geojson = await response.json();
if (!geojson || geojson.type !== 'FeatureCollection') {
console.warn('[ManagementZonesMapOverlay] Invalid GeoJSON received.');
return;
}
const features = geojson.features || [];
// 1. Ajouter la source et les couches à MapLibre
const sourceId = 'management-zones-source';
const fillLayerId = 'management-zones-fill';
const lineLayerId = 'management-zones-line';
if (map.getSource(sourceId)) {
map.getSource(sourceId).setData(geojson);
} else {
map.addSource(sourceId, {
type: 'geojson',
data: geojson
});
}
// Couche de remplissage translucide
if (!map.getLayer(fillLayerId)) {
const fillLayerDef = {
id: fillLayerId,
type: 'fill',
source: sourceId,
paint: {
'fill-color': ['coalesce', ['get', 'color'], '#198754'],
'fill-opacity': 0.20
}
};
if (beforeLayerId && map.getLayer(beforeLayerId)) {
map.addLayer(fillLayerDef, beforeLayerId);
} else {
map.addLayer(fillLayerDef);
}
}
// Couche de contour avec tirets pour marquer une zone de gestion
if (!map.getLayer(lineLayerId)) {
const lineLayerDef = {
id: lineLayerId,
type: 'line',
source: sourceId,
paint: {
'line-color': ['coalesce', ['get', 'color'], '#198754'],
'line-width': 2.5,
'line-opacity': 0.85,
'line-dasharray': [3, 1.5]
}
};
if (beforeLayerId && map.getLayer(beforeLayerId)) {
map.addLayer(lineLayerDef, beforeLayerId);
} else {
map.addLayer(lineLayerDef);
}
}
// 2. Gestion des popups et du curseur sur clic
const zonePopup = new maplibregl.Popup({
closeButton: true,
closeOnClick: true,
maxWidth: '340px'
});
map.on('click', fillLayerId, (e) => {
const assetLayerIds = (window.assets_layers_defs || []).map(l => l.id).filter(id => map.getLayer(id));
if (assetLayerIds.length > 0) {
const rendered = map.queryRenderedFeatures(e.point, { layers: assetLayerIds });
if (rendered && rendered.length > 0) {
return;
}
}
if (!e.features || !e.features[0]) return;
const props = e.features[0].properties || {};
let typeHtml = '';
if (props.zone_type_name) {
typeHtml = `<span class="badge me-1" style="background-color: ${props.color || '#198754'}; color: #fff; font-size: 10px;">
${props.zone_type_name}
</span>`;
}
let freqHtml = '';
if (props.target_frequency_days) {
freqHtml = `<div style="color: #64748b; font-size: 11px; margin-top: 3px;">
<i class="bi bi-arrow-repeat me-1"></i>${_t('Fréquence cible :', 'Fréquence cible :')} <strong>${props.target_frequency_days} ${_t('jours', 'jours')}</strong>
</div>`;
}
let seasonHtml = '';
if (props.seasonality_notes) {
seasonHtml = `<div style="color: #64748b; font-size: 11px; margin-top: 3px;">
<i class="bi bi-calendar3 me-1"></i>${props.seasonality_notes}
</div>`;
}
let instructionsHtml = '';
if (props.instructions) {
instructionsHtml = `<div style="background-color: #f8fafc; border-left: 3px solid ${props.color || '#198754'}; padding: 6px; margin-top: 6px; font-size: 11px; color: #334155; border-radius: 2px; max-height: 100px; overflow-y: auto;">
<div style="font-weight: 600; margin-bottom: 2px;"><i class="bi bi-exclamation-triangle me-1"></i>${_t('Consignes & points d\'attention', 'Consignes & points d\'attention')}</div>
${props.instructions}
</div>`;
}
let descHtml = '';
if (props.description) {
descHtml = `<div style="color: #475569; font-size: 11px; margin-top: 4px;">
${props.description}
</div>`;
}
const html = `
<div style="font-family: inherit; font-size: 12px; padding: 2px;">
<div style="display: flex; align-items: center; justify-content: space-between; gap: 6px; margin-bottom: 4px;">
<div style="display: flex; align-items: center; gap: 6px;">
<span style="display: inline-block; width: 12px; height: 12px; border-radius: 3px; background-color: ${props.color || '#198754'}; border: 1px solid rgba(0,0,0,0.2); flex-shrink: 0;"></span>
<strong style="font-size: 13px; color: #1e293b;">${props.code}</strong>
</div>
${typeHtml}
</div>
<div style="font-weight: 600; color: #1e293b; margin-bottom: 4px;">${props.name}</div>
${descHtml}
${freqHtml}
${seasonHtml}
${instructionsHtml}
<div style="margin-top: 8px; text-align: right;">
<a href="/assets/management-zones/${props.id}/" class="btn btn-sm btn-outline-primary py-0 px-2" style="font-size: 11px;">
<i class="bi bi-eye me-1"></i>${_t('Détails & gestion', 'Détails & gestion')} &rarr;
</a>
</div>
</div>
`;
zonePopup.setLngLat(e.lngLat).setHTML(html).addTo(map);
});
map.on('mousemove', fillLayerId, (e) => {
const assetLayerIds = (window.assets_layers_defs || []).map(l => l.id).filter(id => map.getLayer(id));
if (assetLayerIds.length > 0) {
const rendered = map.queryRenderedFeatures(e.point, { layers: assetLayerIds });
if (rendered && rendered.length > 0) {
return;
}
}
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', fillLayerId, () => {
map.getCanvas().style.cursor = '';
});
// 3. Intégration dans le LayerSwitcherControl
if (layerSwitcher && typeof layerSwitcher.addCustomSection === 'function') {
layerSwitcher.addCustomSection({
id: 'management-zones',
title: `<span class="d-flex align-items-center gap-1"><i class="bi bi-bounding-box-circles me-1"></i>${_t('Zones de gestion', 'Zones de gestion')}</span>`,
render: (container, switcher) => {
renderManagementZonesSection(container, switcher, features, map, fillLayerId, lineLayerId, zonePopup, _t, thematic);
}
});
if (features.length > 0) {
layerSwitcher.setOverlayActive(true);
}
}
} catch (err) {
console.error('[ManagementZonesMapOverlay] Error initializing overlay:', err);
}
}
/**
* Rendu HTML et interactions de la section "Zones de gestion" dans le LayerSwitcherControl.
*/
function renderManagementZonesSection(container, switcher, features, map, fillLayerId, lineLayerId, zonePopup, _t, thematic) {
if (!features || features.length === 0) {
const manageUrl = `/assets/management-zones/${thematic ? '?thematic=' + thematic : ''}`;
container.innerHTML = `
<div class="text-muted small px-2 py-1 fst-italic d-flex justify-content-between align-items-center">
<span>${_t('Aucune zone de gestion définie pour cette thématique.', 'Aucune zone de gestion définie pour cette thématique.')}</span>
<a href="${manageUrl}" class="btn btn-link btn-sm p-0 text-muted ms-1" title="${_t('Gérer les zones de gestion', 'Gérer les zones de gestion')}">
<i class="bi bi-box-arrow-up-right"></i>
</a>
</div>
`;
return;
}
const countText = `${features.length} ${_t('zones', 'zones')}`;
const manageUrl = `/assets/management-zones/${thematic ? '?thematic=' + thematic : ''}`;
// En-tête avec toggle général
const headerEl = document.createElement('div');
headerEl.className = 'd-flex justify-content-between align-items-center px-1 mb-2';
headerEl.innerHTML = `
<div class="d-flex align-items-center gap-1">
<span class="text-muted small fw-semibold">${countText}</span>
<a href="${manageUrl}" class="btn btn-link btn-sm p-0 text-muted" style="font-size: 11px;" title="${_t('Gérer les zones de gestion', 'Gérer les zones de gestion')}">
<i class="bi bi-box-arrow-up-right"></i>
</a>
</div>
<div class="form-check form-switch mb-0">
<input class="form-check-input management-zones-master-toggle" type="checkbox" role="switch" checked title="${_t('Afficher / masquer toutes les zones', 'Afficher / masquer toutes les zones')}">
</div>
`;
container.appendChild(headerEl);
const masterToggle = headerEl.querySelector('.management-zones-master-toggle');
// Curseur d'opacité
const opacityRow = document.createElement('div');
opacityRow.className = 'px-1 mb-2';
opacityRow.innerHTML = `
<div class="d-flex justify-content-between align-items-center small text-muted mb-1">
<span>${_t('Opacité', 'Opacité')}</span>
<span class="management-zones-opacity-val">20%</span>
</div>
<input type="range" class="form-range management-zones-opacity-slider" min="5" max="80" value="20" style="height: 4px;">
`;
container.appendChild(opacityRow);
const opacitySlider = opacityRow.querySelector('.management-zones-opacity-slider');
const opacityValLabel = opacityRow.querySelector('.management-zones-opacity-val');
opacitySlider.addEventListener('input', (e) => {
const val = parseInt(e.target.value, 10);
opacityValLabel.textContent = `${val}%`;
if (map.getLayer(fillLayerId)) {
map.setPaintProperty(fillLayerId, 'fill-opacity', val / 100);
}
});
// Liste des zones
const listEl = document.createElement('div');
listEl.className = 'management-zones-list d-flex flex-column gap-1';
listEl.style.maxHeight = '240px';
listEl.style.overflowY = 'auto';
const activeZoneIds = new Set(features.map(f => f.id));
function updateLayersVisibility() {
if (!masterToggle.checked || activeZoneIds.size === 0) {
if (map.getLayer(fillLayerId)) map.setLayoutProperty(fillLayerId, 'visibility', 'none');
if (map.getLayer(lineLayerId)) map.setLayoutProperty(lineLayerId, 'visibility', 'none');
} else {
if (map.getLayer(fillLayerId)) map.setLayoutProperty(fillLayerId, 'visibility', 'visible');
if (map.getLayer(lineLayerId)) map.setLayoutProperty(lineLayerId, 'visibility', 'visible');
const filter = ['in', ['id'], ['literal', Array.from(activeZoneIds)]];
if (map.getLayer(fillLayerId)) map.setFilter(fillLayerId, filter);
if (map.getLayer(lineLayerId)) map.setFilter(lineLayerId, filter);
}
}
masterToggle.addEventListener('change', () => {
const checked = masterToggle.checked;
listEl.querySelectorAll('.zone-item-toggle').forEach(chk => {
chk.checked = checked;
});
if (checked) {
features.forEach(f => activeZoneIds.add(f.id));
} else {
activeZoneIds.clear();
}
updateLayersVisibility();
});
features.forEach(feat => {
const props = feat.properties || {};
const color = props.color || '#198754';
const itemEl = document.createElement('div');
itemEl.className = 'd-flex align-items-center justify-content-between p-1 rounded hover-bg small';
itemEl.style.cursor = 'pointer';
itemEl.innerHTML = `
<div class="d-flex align-items-center gap-2 text-truncate me-2 zone-click-target" style="flex: 1;">
<span style="display:inline-block; width: 12px; height: 12px; border-radius: 50%; background-color: ${color}; border: 1px solid rgba(0,0,0,0.25); flex-shrink: 0;"></span>
<div class="text-truncate">
<div class="fw-semibold text-truncate text-dark" style="font-size: 11px;">${props.code}</div>
<div class="text-muted text-truncate" style="font-size: 10px;">${props.name} ${props.zone_type_name ? `(${props.zone_type_name})` : ''}</div>
</div>
</div>
<div class="d-flex align-items-center gap-1 flex-shrink-0">
<button type="button" class="btn btn-link btn-sm p-0 text-muted zoom-btn" title="${_t('Centrer sur la zone', 'Centrer sur la zone')}">
<i class="bi bi-geo-alt" style="font-size: 12px;"></i>
</button>
<div class="form-check form-switch mb-0 ms-1">
<input class="form-check-input zone-item-toggle" type="checkbox" role="switch" checked data-zone-id="${feat.id}">
</div>
</div>
`;
const itemToggle = itemEl.querySelector('.zone-item-toggle');
itemToggle.addEventListener('change', (e) => {
e.stopPropagation();
if (itemToggle.checked) {
activeZoneIds.add(feat.id);
} else {
activeZoneIds.delete(feat.id);
}
masterToggle.checked = (activeZoneIds.size > 0);
updateLayersVisibility();
});
function zoomToZone() {
const bbox = getGeometryBBox(feat.geometry);
if (bbox) {
map.fitBounds(bbox, { padding: 40, maxZoom: 16 });
}
}
itemEl.querySelector('.zoom-btn').addEventListener('click', (e) => {
e.stopPropagation();
zoomToZone();
});
itemEl.querySelector('.zone-click-target').addEventListener('click', () => {
zoomToZone();
});
listEl.appendChild(itemEl);
});
container.appendChild(listEl);
}
window.initManagementZonesMapOverlay = initManagementZonesMapOverlay;
})(window);

View file

@ -0,0 +1,69 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
<defs>
<!-- Background grass blades gradient (deep rich green) -->
<linearGradient id="grass-dark" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#2e7d32"/>
<stop offset="100%" stop-color="#1b5e20"/>
</linearGradient>
<!-- Mid grass blades gradient (fresh vibrant green) -->
<linearGradient id="grass-mid" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#66bb6a"/>
<stop offset="100%" stop-color="#2e7d32"/>
</linearGradient>
<!-- Side arching grass blades gradient -->
<linearGradient id="grass-light" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#81c784"/>
<stop offset="100%" stop-color="#388e3c"/>
</linearGradient>
<!-- Ground turf base gradient -->
<linearGradient id="turf-base" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#43a047"/>
<stop offset="100%" stop-color="#1b5e20"/>
</linearGradient>
<!-- Front turf lip gradient -->
<linearGradient id="turf-front" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#4caf50"/>
<stop offset="100%" stop-color="#2e7d32"/>
</linearGradient>
<!-- Soil ground base gradient -->
<linearGradient id="soil-base" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#6d4c41"/>
<stop offset="100%" stop-color="#4e342e"/>
</linearGradient>
</defs>
<!-- Ground / Soil base slab -->
<rect x="9" y="52" width="46" height="6" rx="3" fill="url(#soil-base)"/>
<!-- Back turf mound -->
<ellipse cx="32" cy="49" rx="25" ry="7" fill="url(#turf-base)"/>
<!-- Background grass blades -->
<!-- Mid-left tall blade -->
<path d="M 21 48 C 17 32, 16 19, 20 11 C 24 15, 27 29, 28 48 Z" fill="url(#grass-dark)"/>
<!-- Mid-right tall blade -->
<path d="M 36 48 C 37 29, 40 15, 44 11 C 48 19, 47 32, 43 48 Z" fill="url(#grass-dark)"/>
<!-- Main foreground grass blades -->
<!-- Left arching blade -->
<path d="M 14 49 C 10 38, 7 28, 8 19 C 14 21, 21 34, 25 49 Z" fill="url(#grass-light)"/>
<!-- Right arching blade -->
<path d="M 39 49 C 43 34, 50 21, 56 19 C 57 28, 54 38, 50 49 Z" fill="url(#grass-light)"/>
<!-- Center tall proud blade -->
<path d="M 26 49 C 27 30, 28 16, 32 6 C 36 16, 37 30, 38 49 Z" fill="url(#grass-mid)"/>
<!-- Center blade subtle highlight -->
<path d="M 31 48 C 31.5 32, 31.8 18, 32 9 C 32.2 18, 32.8 32, 32.5 48 Z" fill="#a5d6a7" opacity="0.6"/>
<!-- Foreground turf rim -->
<ellipse cx="32" cy="51" rx="23" ry="5" fill="url(#turf-front)"/>
<!-- Small front sprouting blades -->
<path d="M 21 51 C 20 44, 18 36, 17 30 C 20 34, 23 42, 24 51 Z" fill="url(#grass-light)"/>
<path d="M 40 51 C 41 42, 44 34, 47 30 C 46 36, 44 44, 43 51 Z" fill="url(#grass-light)"/>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -0,0 +1,393 @@
/**
* Visualisation cartographique détaillée d'une localisation Nature (StreetUp / Loko)
*/
document.addEventListener('DOMContentLoaded', function () {
const mapContainer = document.getElementById('map');
if (!mapContainer) return;
const lon = typeof feature_lon !== 'undefined' && feature_lon !== null ? feature_lon : 4.3517;
const lat = typeof feature_lat !== 'undefined' && feature_lat !== null ? feature_lat : 50.8503;
const map = new maplibregl.Map({
container: 'map',
style: window.MAP_STYLE_LIGHT || 'https://api.maptiler.com/maps/streets-v2-light/style.json',
center: [lon, lat],
zoom: 16,
pitch: 35,
bearing: -10,
antialias: true
});
// ── Helper to place raster basemaps below nature layers ──
function getBasemapBeforeLayer(m) {
if (!m || !m.getStyle) return undefined;
if (m.getLayer('nature-location-fill')) return 'nature-location-fill';
if (m.getLayer('nature-location-outline')) return 'nature-location-outline';
if (m.getLayer('layer-surfaces-fill')) return 'layer-surfaces-fill';
if (m.getLayer('layer-surfaces-outline')) return 'layer-surfaces-outline';
if (m.getLayer('layer-shrubs')) return 'layer-shrubs';
if (m.getLayer('layer-benches')) return 'layer-benches';
if (m.getLayer('layer-trees')) return 'layer-trees';
return undefined;
}
// ── Ensure strict layer stacking order on top of basemaps ──
function reorderNatureLayers() {
const layerOrder = [
'nature-location-fill',
'nature-location-outline',
'layer-surfaces-fill',
'layer-surfaces-outline',
'layer-shrubs',
'layer-benches',
'layer-trees',
];
layerOrder.forEach(function (layerId) {
if (map.getLayer(layerId)) {
try {
map.moveLayer(layerId);
} catch (e) {
// ignore
}
}
});
}
// Add layer switcher control for basemaps / orthophotos
if (typeof LayerSwitcherControl !== 'undefined') {
const layerSwitcher = new LayerSwitcherControl({
layers: [{
id: 'basemap',
name: 'Fonds de plan & Orthophotos',
icon: 'satellite',
group: 'basemap',
beforeLayer: getBasemapBeforeLayer,
onLayerAdded: () => reorderNatureLayers(),
onVisibilityChange: () => reorderNatureLayers(),
layer: {
type: 'raster',
paint: { 'raster-opacity': 1 }
},
basemaps: (window.MAP_BACKGROUNDS && window.MAP_BACKGROUNDS.length > 0) ? window.MAP_BACKGROUNDS : undefined,
sublayers: [
{
id: 'urbis',
name: window.MAP_ORTHO_WMS_NAME || 'Urbis (Bruxelles)',
default: true,
source: {
type: 'raster',
tiles: [
window.MAP_ORTHO_WMS_URL || 'https://geoservices-urbis.irisnet.be/geoserver/urbisgrid/wms?service=WMS&version=1.3.0&request=GetMap&layers=Ortho&styles=&format=image/png&transparent=true&width=256&height=256&crs=EPSG:3857&bbox={bbox-epsg-3857}'
],
tileSize: 256,
attribution: window.MAP_ORTHO_WMS_ATTRIBUTION || '© Brussels UrbIS'
}
},
{
id: 'vlaanderen',
name: 'Vlaanderen 2023',
source: {
type: 'raster',
tiles: [
'https://geo.api.vlaanderen.be/OMW/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=omwrgb23vl&STYLE=&FORMAT=image/png&TILEMATRIXSET=GoogleMapsVL&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}'
],
tileSize: 256,
attribution: '© Digitaal Vlaanderen - Orthofoto 2023'
}
}
]
}]
});
map.addControl(layerSwitcher, 'top-right');
}
// Add navigation and fullscreen controls
map.addControl(new maplibregl.NavigationControl({ visualizePitch: true }), 'top-right');
map.addControl(new maplibregl.FullscreenControl(), 'top-right');
map.on('load', function () {
let combinedBbox = null;
// 1. Location geometry (polygon / boundary)
if (typeof section_geojson !== 'undefined' && section_geojson) {
const locGeom = typeof section_geojson === 'string' ? JSON.parse(section_geojson) : section_geojson;
const locationFeature = {
type: 'Feature',
geometry: locGeom,
properties: {
name: 'Location'
}
};
map.addSource('nature-location', {
type: 'geojson',
data: locationFeature
});
// Fill layer for the section
map.addLayer({
id: 'nature-location-fill',
type: 'fill',
source: 'nature-location',
paint: {
'fill-color': '#16a34a',
'fill-opacity': 0.15
}
});
// Outline layer for the section
map.addLayer({
id: 'nature-location-outline',
type: 'line',
source: 'nature-location',
paint: {
'line-color': '#15803d',
'line-width': 2.5
}
});
if (typeof turf !== 'undefined') {
try {
combinedBbox = turf.bbox(locationFeature);
} catch (e) {
console.warn('Could not compute location bbox:', e);
}
}
}
// 2. Assets (green surfaces, trees, shrubs, benches)
if (typeof assets_geojson !== 'undefined' && assets_geojson) {
const assetsData = typeof assets_geojson === 'string' ? JSON.parse(assets_geojson) : assets_geojson;
map.addSource('nature-assets', {
type: 'geojson',
data: assetsData
});
// Green surfaces fill
map.addLayer({
id: 'layer-surfaces-fill',
type: 'fill',
source: 'nature-assets',
filter: ['==', ['get', 'asset_type'], 'naturegreensurface'],
paint: {
'fill-color': ['coalesce', ['get', 'color'], '#22c55e'],
'fill-opacity': 0.65
}
});
// Green surfaces outline
map.addLayer({
id: 'layer-surfaces-outline',
type: 'line',
source: 'nature-assets',
filter: ['==', ['get', 'asset_type'], 'naturegreensurface'],
paint: {
'line-color': '#166534',
'line-width': 1.5
}
});
// Trees (circles)
map.addLayer({
id: 'layer-trees',
type: 'circle',
source: 'nature-assets',
filter: ['==', ['get', 'asset_type'], 'naturetree'],
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
13, 4,
16, 7,
19, 12
],
'circle-color': '#15803d',
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
});
// Shrubs (circles)
map.addLayer({
id: 'layer-shrubs',
type: 'circle',
source: 'nature-assets',
filter: ['==', ['get', 'asset_type'], 'natureshrub'],
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
13, 3,
16, 5,
19, 9
],
'circle-color': '#84cc16',
'circle-stroke-width': 1.5,
'circle-stroke-color': '#ffffff'
}
});
// Benches (circles)
map.addLayer({
id: 'layer-benches',
type: 'circle',
source: 'nature-assets',
filter: ['==', ['get', 'asset_type'], 'naturebench'],
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
13, 3,
16, 5,
19, 9
],
'circle-color': '#b45309',
'circle-stroke-width': 1.5,
'circle-stroke-color': '#ffffff'
}
});
// Fit bounds with Turf if features exist
if (typeof turf !== 'undefined' && assetsData.features && assetsData.features.length > 0) {
try {
const assetsBbox = turf.bbox(assetsData);
if (!combinedBbox) {
combinedBbox = assetsBbox;
} else {
combinedBbox = [
Math.min(combinedBbox[0], assetsBbox[0]),
Math.min(combinedBbox[1], assetsBbox[1]),
Math.max(combinedBbox[2], assetsBbox[2]),
Math.max(combinedBbox[3], assetsBbox[3])
];
}
} catch (e) {
console.warn('Could not compute assets bbox:', e);
}
}
}
// Apply fit bounds
if (combinedBbox && combinedBbox[0] !== Infinity) {
map.fitBounds(combinedBbox, {
padding: { top: 40, bottom: 40, left: 40, right: 40 },
maxZoom: 18,
duration: 800
});
}
reorderNatureLayers();
// 3. Interactive Popups
const interactiveLayers = [
'layer-surfaces-fill',
'layer-trees',
'layer-shrubs',
'layer-benches'
];
interactiveLayers.forEach(function (layerId) {
map.on('mouseenter', layerId, function () {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', layerId, function () {
map.getCanvas().style.cursor = '';
});
map.on('click', layerId, function (e) {
if (!e.features || !e.features.length) return;
const f = e.features[0];
const props = f.properties;
let iconClass = 'bi-tree-fill text-success';
let typeBadge = '<span class="badge bg-success">Surface</span>';
if (props.asset_type === 'naturetree') {
iconClass = 'bi-tree-fill text-success';
typeBadge = '<span class="badge bg-success">Arbre</span>';
} else if (props.asset_type === 'natureshrub') {
iconClass = 'bi-flower3 text-success';
typeBadge = '<span class="badge bg-info">Arbuste</span>';
} else if (props.asset_type === 'naturebench') {
iconClass = 'bi-inbox text-warning';
typeBadge = '<span class="badge bg-warning text-dark">Banc / Mobilier</span>';
} else if (props.asset_type === 'naturegreensurface') {
iconClass = 'bi-grid-fill text-success';
typeBadge = '<span class="badge bg-primary">Surface verte</span>';
}
let detailsHtml = '';
if (props.area) {
detailsHtml += `<div class="small text-muted"><i class="bi bi-rulers me-1"></i>Superficie : <strong>${props.area}</strong></div>`;
}
if (props.vitality) {
detailsHtml += `<div class="small text-muted"><i class="bi bi-heart-pulse me-1"></i>Vitalité : <strong>${props.vitality}</strong></div>`;
}
if (props.stage) {
detailsHtml += `<div class="small text-muted"><i class="bi bi-clock-history me-1"></i>Stade : <strong>${props.stage}</strong></div>`;
}
if (props.height) {
detailsHtml += `<div class="small text-muted"><i class="bi bi-arrows-vertical me-1"></i>Hauteur : <strong>${props.height}</strong></div>`;
}
if (props.is_remarkable === 'true' || props.is_remarkable === true) {
detailsHtml += `<div class="badge bg-warning text-dark my-1"><i class="bi bi-star-fill me-1"></i>Arbre remarquable</div>`;
}
const popupHtml = `
<div class="nature-map-popup" style="min-width: 200px; padding: 4px;">
<div class="d-flex justify-content-between align-items-center mb-1 pb-1 border-bottom">
<span class="fw-bold"><i class="bi ${iconClass} me-1"></i>${props.code || ''}</span>
${typeBadge}
</div>
<div class="mb-2">
${props.model ? `<div class="fw-semibold text-dark">${props.model}</div>` : ''}
${props.scientific_name ? `<div class="fst-italic text-muted small">${props.scientific_name}</div>` : ''}
${props.status ? `<div class="small"><span class="badge bg-light text-secondary border mt-1">${props.status}</span></div>` : ''}
${detailsHtml}
</div>
${props.detail_url ? `
<a href="${props.detail_url}" class="btn btn-xs btn-primary w-100 text-center py-1" style="font-size: 0.8rem;">
<i class="bi bi-arrow-right-circle me-1"></i>Consulter la fiche
</a>
` : ''}
</div>
`;
new maplibregl.Popup({ closeButton: true, closeOnClick: true, maxWidth: '280px' })
.setLngLat(e.lngLat)
.setHTML(popupHtml)
.addTo(map);
});
});
// 4. Layer Toggle Handlers
window.toggleNatureLayer = function (layerGroup, isChecked) {
const layerMapping = {
'surfaces': ['layer-surfaces-fill', 'layer-surfaces-outline'],
'trees': ['layer-trees'],
'shrubs': ['layer-shrubs'],
'benches': ['layer-benches']
};
const layers = layerMapping[layerGroup];
if (layers) {
const visibility = isChecked ? 'visible' : 'none';
layers.forEach(function (id) {
if (map.getLayer(id)) {
map.setLayoutProperty(id, 'visibility', visibility);
}
});
}
};
// 5. Global Zoom to Asset helper
window.zoomToAsset = function (assetLon, assetLat, assetCode) {
if (assetLon && assetLat) {
map.flyTo({
center: [assetLon, assetLat],
zoom: 18.5,
pitch: 45,
duration: 1000
});
}
};
});
});

View file

@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
<defs>
<linearGradient id="foliage-dead" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#9ca3af"/>
<stop offset="100%" stop-color="#4b5563"/>
</linearGradient>
<linearGradient id="trunk-dead" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#78716c"/>
<stop offset="100%" stop-color="#57534e"/>
</linearGradient>
</defs>
<ellipse cx="24" cy="24" rx="12" ry="10" fill="url(#foliage-dead)"/>
<ellipse cx="40" cy="24" rx="12" ry="10" fill="url(#foliage-dead)"/>
<ellipse cx="32" cy="18" rx="13" ry="11" fill="url(#foliage-dead)"/>
<ellipse cx="32" cy="29" rx="15" ry="10" fill="url(#foliage-dead)"/>
<rect x="28" y="30" width="8" height="24" rx="2" fill="url(#trunk-dead)"/>
<rect x="25" y="54" width="14" height="5" rx="2" fill="#44403c"/>
</svg>

After

Width:  |  Height:  |  Size: 928 B

View file

@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
<defs>
<linearGradient id="foliage-reg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#60a5fa"/>
<stop offset="100%" stop-color="#1d4ed8"/>
</linearGradient>
<linearGradient id="trunk" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#8d6e63"/>
<stop offset="100%" stop-color="#5d4037"/>
</linearGradient>
</defs>
<ellipse cx="24" cy="24" rx="12" ry="10" fill="url(#foliage-reg)"/>
<ellipse cx="40" cy="24" rx="12" ry="10" fill="url(#foliage-reg)"/>
<ellipse cx="32" cy="18" rx="13" ry="11" fill="url(#foliage-reg)"/>
<ellipse cx="32" cy="29" rx="15" ry="10" fill="url(#foliage-reg)"/>
<rect x="28" y="30" width="8" height="24" rx="2" fill="url(#trunk)"/>
<rect x="25" y="54" width="14" height="5" rx="2" fill="#4e342e"/>
</svg>

After

Width:  |  Height:  |  Size: 913 B

View file

@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
<defs>
<linearGradient id="foliage-rem" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#fbbf24"/>
<stop offset="100%" stop-color="#d97706"/>
</linearGradient>
<linearGradient id="trunk" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#8d6e63"/>
<stop offset="100%" stop-color="#5d4037"/>
</linearGradient>
</defs>
<ellipse cx="24" cy="24" rx="12" ry="10" fill="url(#foliage-rem)"/>
<ellipse cx="40" cy="24" rx="12" ry="10" fill="url(#foliage-rem)"/>
<ellipse cx="32" cy="18" rx="13" ry="11" fill="url(#foliage-rem)"/>
<ellipse cx="32" cy="29" rx="15" ry="10" fill="url(#foliage-rem)"/>
<rect x="28" y="30" width="8" height="24" rx="2" fill="url(#trunk)"/>
<rect x="25" y="54" width="14" height="5" rx="2" fill="#4e342e"/>
</svg>

After

Width:  |  Height:  |  Size: 913 B

View file

@ -19,7 +19,15 @@ var activeCategories = new Set(SELECTED_CATEGORIES || []);
var activeModels = new Set();
var modelFilterCollapsed = true;
var activeConditionalFilters = {};
var currentTypeFilter = 'all'; // 'all' | 'locations' | 'assets'
var currentTypeFilter = 'all'; // legacy fallback
function getActiveTypeFilters() {
const types = [];
if (document.getElementById('type-cb-assets')?.checked) types.push('assets');
if (document.getElementById('type-cb-locations')?.checked) types.push('locations');
if (document.getElementById('type-cb-management-zones')?.checked) types.push('management_zones');
return types;
}
var searchTerms = [];
var searchMode = 'and';
var shouldFitSearch = false;
@ -45,6 +53,7 @@ var assetsLoadModes = {}; // thematic_code → null | 'always' | 'filter' |
// MapLibre sources
var mergedLocationsGeojson = { type: 'FeatureCollection', features: [] };
var mergedManagementZonesGeojson = { type: 'FeatureCollection', features: [] };
var mergedAssetsGeojson = { type: 'FeatureCollection', features: [] };
var currentFetchAbortController = null;
var assetClickPopup = null;
@ -526,7 +535,7 @@ function buildColumns() {
}
},
{
field: 'element_type', title: gettext('Type'), sortable: true, width: 130, widthUnit: 'px', valign: 'middle',
field: 'element_type', title: gettext('Type'), sortable: true, width: 145, widthUnit: 'px', valign: 'middle',
formatter: function (v, row) {
const table = $('#unified-table');
let isCatVisible = false;
@ -534,10 +543,21 @@ function buildColumns() {
isCatVisible = table.length && typeof table.bootstrapTable === 'function' &&
table.bootstrapTable('getVisibleColumns').some(c => c.field === 'category');
} catch (e) {}
if (!isCatVisible && row && row.is_location && row.category) {
return `${escapeHtml(v || '-')} <span class="badge bg-light text-secondary border fw-normal ms-1" style="font-size:0.75rem;">${escapeHtml(row.category)}</span>`;
let iconPrefix = '';
if (typeof AssetSymbology !== 'undefined' && row && !row.is_location && !row.is_management_zone) {
const symb = AssetSymbology.getAssetSymbology(row);
const colorStyle = symb.color ? `color:${symb.color};` : '';
iconPrefix = AssetSymbology.renderIconHtml(symb, {
style: `${colorStyle} width: 20px; height: 20px; vertical-align: middle; margin-right: 6px;`,
imgClass: 'me-1'
});
}
return escapeHtml(v || '-');
if (!isCatVisible && row && row.is_location && row.category) {
return `${iconPrefix}${escapeHtml(v || '-')} <span class="badge bg-light text-secondary border fw-normal ms-1" style="font-size:0.75rem;">${escapeHtml(row.category)}</span>`;
}
return `${iconPrefix}${escapeHtml(v || '-')}`;
}
},
{ field: 'thematic_name', title: gettext('Thématique'), sortable: true, width: 140, widthUnit: 'px', valign: 'middle' },
@ -658,9 +678,31 @@ function applyInitialPreferences() {
// Type filter
if (prefs.type_filter) {
currentTypeFilter = prefs.type_filter;
const rb = document.querySelector(`input[name="unified-type-filter"][value="${prefs.type_filter}"]`);
if (rb) rb.checked = true;
if (Array.isArray(prefs.type_filter)) {
const cbA = document.getElementById('type-cb-assets');
const cbL = document.getElementById('type-cb-locations');
const cbZ = document.getElementById('type-cb-management-zones');
if (cbA) cbA.checked = prefs.type_filter.includes('assets');
if (cbL) cbL.checked = prefs.type_filter.includes('locations');
if (cbZ) cbZ.checked = prefs.type_filter.includes('management_zones');
} else if (typeof prefs.type_filter === 'string') {
const cbA = document.getElementById('type-cb-assets');
const cbL = document.getElementById('type-cb-locations');
const cbZ = document.getElementById('type-cb-management-zones');
if (prefs.type_filter === 'all') {
if (cbA) cbA.checked = true;
if (cbL) cbL.checked = true;
if (cbZ) cbZ.checked = true;
} else if (prefs.type_filter === 'locations') {
if (cbA) cbA.checked = false;
if (cbL) cbL.checked = true;
if (cbZ) cbZ.checked = false;
} else if (prefs.type_filter === 'assets') {
if (cbA) cbA.checked = true;
if (cbL) cbL.checked = false;
if (cbZ) cbZ.checked = false;
}
}
}
// Include no geometry filter
@ -706,6 +748,14 @@ function applyInitialPreferences() {
window._restoredMapCenter = prefs.map_center;
window._restoredMapZoom = prefs.map_zoom;
}
// Basemap preference
if (prefs.basemap) {
window._restoredBasemap = prefs.basemap;
try {
localStorage.setItem('loko_active_basemap', prefs.basemap);
} catch (e) {}
}
} else if (DEFAULT_THEMATIC_CODE && DEFAULT_THEMATIC_CODE in THEMATIC_CONFIG) {
// First visit: pre-select the user's default thematic
activeThematics = new Set([DEFAULT_THEMATIC_CODE]);
@ -733,18 +783,22 @@ function savePreferences() {
).map(cb => cb.value);
const includeNoGeomCheckbox = document.getElementById('include-no-geom');
const includeNoGeom = includeNoGeomCheckbox ? includeNoGeomCheckbox.checked : true;
const activeBasemap = (window.unifiedLayerSwitcher && window.unifiedLayerSwitcher.activeBasemapId)
|| localStorage.getItem('loko_active_basemap')
|| 'default';
const prefs = {
thematics: Array.from(activeThematics),
categories: Array.from(activeCategories),
models: Array.from(activeModels),
conditional_filters: activeConditionalFilters,
visible_columns: getVisibleColumnsPreference(),
type_filter: currentTypeFilter,
type_filter: getActiveTypeFilters(),
status_filters: statusFilters,
validation_status_filters: validationStatusFilters,
include_no_geom: includeNoGeom,
map_center: (typeof map !== 'undefined' && map.getCenter) ? [map.getCenter().lng, map.getCenter().lat] : null,
map_zoom: (typeof map !== 'undefined' && map.getZoom) ? map.getZoom() : null,
basemap: activeBasemap,
};
fetch(SAVE_ASSETS_PREFS_URL, {
method: 'POST',
@ -770,6 +824,12 @@ $(document).ready(function () {
$('#unified-table').bootstrapTable({
columns: buildColumns(),
rowStyle: function (row) {
if (row && (row.is_dead || row.vitality === 'dead' || row.status === 'dead' || row.status === 'archived')) {
return { classes: 'text-muted opacity-75' };
}
return {};
},
height: calcHeight(),
search: false,
pagination: true,
@ -784,9 +844,9 @@ $(document).ready(function () {
onUncheckAll: function (rows) { rows.forEach(r => { r.state = false; selectedIds.delete(r.id); }); updateSelectionStatus(); },
onClickRow: function (row) {
if (!row.lon || !row.lat) return;
const source = row.is_location ? 'locations' : 'assets';
const source = row.is_management_zone ? 'management_zones' : (row.is_location ? 'locations' : 'assets');
if (typeof highlightMapFeature === 'function') {
highlightMapFeature(map, source, row.id, row.is_location ? locationshighlightState : assetshighlightState);
highlightMapFeature(map, source, row.id, row.is_management_zone ? managementZonesHighlightState : (row.is_location ? locationshighlightState : assetshighlightState));
}
},
});
@ -865,11 +925,13 @@ $(document).ready(function () {
renderModelFilters();
});
// ─── Type radio ────────────────────────────────────────────────────────
// ─── Type checkboxes ────────────────────────────────────────────────────
document.querySelectorAll('input[name="unified-type-filter"]').forEach(rb => {
rb.addEventListener('change', function () {
if (this.checked) currentTypeFilter = this.value;
document.querySelectorAll('input[name="filter-element-type"]').forEach(cb => {
cb.addEventListener('change', function () {
applyFilters();
updateFilterBadge();
schedulePreferencesSave();
});
});
@ -884,26 +946,45 @@ $(document).ready(function () {
}
// init from locked element type
if (PREVENTIVE_ELEMENT_TYPE === 'assets') currentTypeFilter = 'assets';
else if (PREVENTIVE_ELEMENT_TYPE === 'locations') currentTypeFilter = 'locations';
if (PREVENTIVE_ELEMENT_TYPE === 'assets') {
const cbA = document.getElementById('type-cb-assets');
const cbL = document.getElementById('type-cb-locations');
const cbZ = document.getElementById('type-cb-management-zones');
if (cbA) cbA.checked = true;
if (cbL) cbL.checked = false;
if (cbZ) cbZ.checked = false;
} else if (PREVENTIVE_ELEMENT_TYPE === 'locations') {
const cbA = document.getElementById('type-cb-assets');
const cbL = document.getElementById('type-cb-locations');
const cbZ = document.getElementById('type-cb-management-zones');
if (cbA) cbA.checked = false;
if (cbL) cbL.checked = true;
if (cbZ) cbZ.checked = false;
}
// ─── Add actions ─────────────────────────────────────────────────────
const addConfig = getUnifiedAddModalConfig();
const assetThematics = Array.isArray(addConfig.asset_thematics) ? addConfig.asset_thematics : [];
const locationThematics = Array.isArray(addConfig.location_thematics) ? addConfig.location_thematics : [];
const managementZoneThematics = Array.isArray(addConfig.management_zone_thematics) ? addConfig.management_zone_thematics : [];
const addButton = document.getElementById('btn-add-asset');
const assetThematicSelect = document.getElementById('unified-add-asset-thematic');
const locationThematicSelect = document.getElementById('unified-add-location-thematic');
const managementZoneThematicSelect = document.getElementById('unified-add-management-zone-thematic');
const chooserEmpty = document.getElementById('unified-add-chooser-empty');
fillSelectWithOptions(assetThematicSelect, assetThematics, assetThematics.length ? gettext('Sélectionner une thématique...') : gettext('Aucune thématique disponible'));
fillSelectWithOptions(locationThematicSelect, locationThematics, locationThematics.length ? gettext('Sélectionner une thématique...') : gettext('Aucune thématique disponible'));
fillSelectWithOptions(managementZoneThematicSelect, managementZoneThematics, managementZoneThematics.length ? gettext('Sélectionner une thématique...') : gettext('Aucune thématique disponible'));
if (assetThematicSelect) assetThematicSelect.disabled = !assetThematics.length;
if (locationThematicSelect) locationThematicSelect.disabled = !locationThematics.length;
if (chooserEmpty) chooserEmpty.classList.toggle('d-none', assetThematics.length > 0 || locationThematics.length > 0);
if (addButton && !assetThematics.length && !locationThematics.length) {
if (managementZoneThematicSelect) managementZoneThematicSelect.disabled = !managementZoneThematics.length;
const hasAnyAddRights = assetThematics.length > 0 || locationThematics.length > 0 || managementZoneThematics.length > 0;
if (chooserEmpty) chooserEmpty.classList.toggle('d-none', hasAnyAddRights);
if (addButton && !hasAnyAddRights) {
addButton.disabled = true;
addButton.title = gettext('Vous n\'avez pas de droits de création');
}
@ -962,6 +1043,17 @@ $(document).ready(function () {
}
});
document.getElementById('unified-add-management-zone-open')?.addEventListener('click', function () {
const thematicCode = managementZoneThematicSelect ? managementZoneThematicSelect.value : '';
if (!thematicCode) {
setModalAlert('unified-add-chooser-alert', gettext('Sélectionnez une thématique pour créer une zone de gestion.'), 'warning');
return;
}
setModalAlert('unified-add-chooser-alert', '', 'info');
const baseUrl = addConfig.management_zone_create_url || '/assets/management-zones/add/';
window.location.href = `${baseUrl}?thematic=${encodeURIComponent(thematicCode)}`;
});
document.getElementById('unifiedAddAssetCategory')?.addEventListener('change', function () {
const thematicCode = document.getElementById('unified-add-asset-thematic-code')?.value || '';
loadUnifiedAssetModels(thematicCode, this.value);
@ -1175,8 +1267,12 @@ $(document).ready(function () {
activeModels.clear();
modelFilterCollapsed = true;
activeConditionalFilters = {};
const allRadio = document.getElementById('type-all');
if (allRadio) { allRadio.checked = true; currentTypeFilter = 'all'; }
const cbA = document.getElementById('type-cb-assets');
const cbL = document.getElementById('type-cb-locations');
const cbZ = document.getElementById('type-cb-management-zones');
if (cbA) cbA.checked = true;
if (cbL) cbL.checked = true;
if (cbZ) cbZ.checked = true;
document.querySelectorAll('[name="filter-status"]').forEach(cb => { cb.checked = false; });
document.querySelectorAll('[name="filter-validation-status"]').forEach(cb => { cb.checked = false; });
const includeNoGeomCheckbox = document.getElementById('include-no-geom');
@ -1515,6 +1611,11 @@ function buildAssetsGeojsonPostBody(thematicCode, bbox, includeNonGeoFilters) {
}
}
if (thematicCode === 'nature') {
if (Array.isArray(thematicFilters.management_type) && thematicFilters.management_type.length) {
payload.management_type = thematicFilters.management_type;
} else if (thematicFilters.management_type) {
payload.management_type = [thematicFilters.management_type];
}
if (thematicFilters.location_type) {
payload.location_type = thematicFilters.location_type;
}
@ -1860,11 +1961,13 @@ function findUnifiedRowIndex(rowRef) {
const rows = $table.bootstrapTable('getData') || [];
const isLocation = !!rowRef.is_location;
const isManagementZone = !!rowRef.is_management_zone;
const matches = (row) => {
if (!!row.is_location !== isLocation) return false;
if (!!row.is_management_zone !== isManagementZone) return false;
if (String(row.id) !== String(rowRef.id)) return false;
if (!isLocation) {
if (!isLocation && !isManagementZone) {
if (String(row.thematic || '') !== String(rowRef.thematic || '')) return false;
if (String(row.asset_type || '') !== String(rowRef.asset_type || '')) return false;
} else {
@ -1878,7 +1981,7 @@ function findUnifiedRowIndex(rowRef) {
if (idx !== -1) return idx;
// Fallback in case some rows do not expose all discriminators.
idx = rows.findIndex(row => !!row.is_location === isLocation && String(row.id) === String(rowRef.id));
idx = rows.findIndex(row => !!row.is_location === isLocation && !!row.is_management_zone === isManagementZone && String(row.id) === String(rowRef.id));
return idx;
}
@ -1947,6 +2050,41 @@ function collectClickedAssets(clickEvent) {
element_type: p.element_type || '',
detail_url: p.detail_url || null,
feature_id: id,
extra_info: p.plots_count != null ? `${p.plots_count} plot(s)` : (p.bollard_type || ''),
management_type: p.management_type || '',
managing_authority: p.managing_authority || '',
is_remarkable: p.is_remarkable || false,
});
});
return Array.from(unique.values());
}
function collectClickedManagementZones(clickEvent) {
const layers = ['management-zones-fill', 'management-zones-line'].filter(layerId => map.getLayer(layerId));
if (!layers.length) return [];
const rendered = map.queryRenderedFeatures(clickEvent.point, { layers });
const unique = new Map();
rendered.forEach(f => {
const p = f.properties || {};
const id = f.id ?? p.id;
if (id == null) return;
const key = `zone|${p.thematic || ''}|${id}`;
if (unique.has(key)) return;
unique.set(key, {
id: id,
code: p.code || '',
name: p.name || '',
thematic: p.thematic || '',
thematic_name: f._thematic_name || p.thematic_name || p.thematic || '',
element_type: p.element_type || gettext('Zone de gestion'),
category: p.category || p.zone_type_name || '',
detail_url: p.detail_url || null,
color: p.color || '',
feature_id: id,
is_management_zone: true,
});
});
@ -2016,12 +2154,29 @@ function bindAssetPopupActions(popupNode) {
}
});
});
popupNode.querySelectorAll('.zone-popup-focus-btn').forEach(btn => {
btn.addEventListener('click', function (e) {
e.preventDefault();
const rowRef = {
id: this.dataset.zoneId,
thematic: this.dataset.thematic,
is_management_zone: true,
};
focusRowInUnifiedTable(rowRef);
if (typeof highlightMapFeature === 'function') {
highlightMapFeature(map, 'management_zones', rowRef.id, managementZonesHighlightState);
}
});
});
}
function showAssetClickPopup(clickEvent) {
const assets = collectClickedAssets(clickEvent);
const locations = collectClickedLocations(clickEvent);
if (!assets.length && !locations.length) return;
const managementZones = collectClickedManagementZones(clickEvent);
if (!assets.length && !locations.length && !managementZones.length) return;
const maxItems = 8;
const visibleAssets = assets.slice(0, maxItems);
@ -2032,15 +2187,26 @@ function showAssetClickPopup(clickEvent) {
const type = escapeHtml(a.element_type || a.asset_type || '');
const thematic = escapeHtml(a.thematic_name || a.thematic || '');
const name = escapeHtml(a.name || '');
const extra = a.extra_info ? ` &bull; <span class="badge bg-light text-primary border" style="font-size:0.7rem;">${escapeHtml(a.extra_info)}</span>` : '';
const detailAnchor = a.detail_url
? `<a href="${escapeHtml(a.detail_url)}">${code}</a>`
: `<span>${code}</span>`;
let iconPrefix = '';
if (typeof AssetSymbology !== 'undefined') {
const symb = AssetSymbology.getAssetSymbology(a);
const colorStyle = symb.color ? `color:${symb.color};` : '';
iconPrefix = AssetSymbology.renderIconHtml(symb, {
style: `${colorStyle} font-size: 1rem; vertical-align: -1px; margin-right: 5px;`,
imgClass: 'me-1'
});
}
return `
<div class="asset-popup-item" style="display:flex; gap:0.5rem; align-items:flex-start; justify-content:space-between; padding:0.25rem 0; border-bottom:1px solid #f1f3f5;">
<div style="min-width:0;">
<div style="font-weight:600; line-height:1.1;">${detailAnchor}</div>
<div style="font-size:0.75rem; color:#6c757d; line-height:1.2;">${type}${thematic ? ' - ' + thematic : ''}</div>
<div style="font-weight:600; line-height:1.1;">${iconPrefix}${detailAnchor}</div>
<div style="font-size:0.75rem; color:#6c757d; line-height:1.2;">${type}${thematic ? ' - ' + thematic : ''}${extra}</div>
${name ? `<div style="font-size:0.75rem; color:#495057; line-height:1.2;">${name}</div>` : ''}
</div>
<button type="button"
@ -2113,10 +2279,56 @@ function showAssetClickPopup(clickEvent) {
`
: '';
const visibleZones = managementZones.slice(0, maxItems);
const hiddenZonesCount = Math.max(0, managementZones.length - visibleZones.length);
const zoneItemsHtml = visibleZones.map(z => {
const code = escapeHtml(z.code || '-');
const type = escapeHtml(z.element_type || gettext('Zone de gestion'));
const thematic = escapeHtml(z.thematic_name || z.thematic || '');
const cat = z.category ? ` &bull; ${escapeHtml(z.category)}` : '';
const dotHtml = z.color ? `<span style="display:inline-block; width:8px; height:8px; border-radius:50%; background-color:${escapeHtml(z.color)}; margin-right:4px; vertical-align:middle; box-shadow:0 0 0 1px rgba(0,0,0,0.15);"></span>` : '';
const name = escapeHtml(z.name || '');
const detailAnchor = z.detail_url
? `<a href="${escapeHtml(z.detail_url)}">${code}</a>`
: `<span>${code}</span>`;
return `
<div class="zone-popup-item" style="display:flex; gap:0.5rem; align-items:flex-start; justify-content:space-between; padding:0.25rem 0; border-bottom:1px solid #f1f3f5;">
<div style="min-width:0;">
<div style="font-weight:600; line-height:1.1;">${detailAnchor}</div>
<div style="font-size:0.75rem; color:#6c757d; line-height:1.2;">${dotHtml}${type}${cat}${thematic ? ' - ' + thematic : ''}</div>
${name ? `<div style="font-size:0.75rem; color:#495057; line-height:1.2;">${name}</div>` : ''}
</div>
<button type="button"
class="btn btn-sm btn-light zone-popup-focus-btn"
data-zone-id="${escapeHtml(z.id)}"
data-thematic="${escapeHtml(z.thematic)}"
title="${escapeHtml(gettext('Voir dans le tableau'))}"
style="transform:translateX(-5px);">
<i class="bi bi-table"></i>
</button>
</div>
`;
}).join('');
const moreZonesHtml = hiddenZonesCount > 0
? `<div style="margin-top:0.35rem; font-size:0.75rem; color:#6c757d;">+${hiddenZonesCount} ${escapeHtml(gettext('autre(s) zone(s)'))}</div>`
: '';
const managementZonesSectionHtml = managementZones.length > 0
? `
<div style="font-weight:700; margin:${(assets.length || locations.length) ? '0.6rem' : '0'} 0 0.35rem 0;">${escapeHtml(gettext('Zones de gestion trouvées'))}: ${managementZones.length}</div>
<div>${zoneItemsHtml}</div>
${moreZonesHtml}
`
: '';
const popupHtml = `
<div style="min-width:260px; max-width:320px;">
${assetsSectionHtml}
${locationsSectionHtml}
${managementZonesSectionHtml}
</div>
`;
@ -2307,6 +2519,23 @@ function refreshData() {
);
}
// ── Management zones: loaded per active thematic
if (cfg.management_zones_geojson_url) {
if (!('management_zones' in geojsonCache[code])) {
const url = cfg.management_zones_geojson_url + '?thematic=' + encodeURIComponent(code);
fetches.push(
fetchGeojsonFeatures(url, { signal }, signal)
.then(data => {
geojsonCache[code].management_zones = data.features || [];
})
.catch(err => {
if (err.name === 'AbortError') throw err;
geojsonCache[code].management_zones = [];
})
);
}
}
// ── Assets: conditional loading
const alwaysLoad = ALWAYS_LOAD_ASSETS_THEMATICS.has(code);
const prevMode = assetsLoadModes[code] || null;
@ -2382,6 +2611,10 @@ function refreshData() {
f._thematic_name = thematicName;
allFeatures.push(f);
});
(cache.management_zones || []).forEach(f => {
f._thematic_name = thematicName;
allFeatures.push(f);
});
});
renderModelFilters();
applyFilters();
@ -2406,19 +2639,27 @@ function applyFilters() {
let features = allFeatures.slice();
// Type filter
if (currentTypeFilter === 'locations') {
features = features.filter(f => f.properties && f.properties.is_location);
} else if (currentTypeFilter === 'assets') {
features = features.filter(f => f.properties && !f.properties.is_location);
}
const cbAssets = document.getElementById('type-cb-assets');
const cbLocations = document.getElementById('type-cb-locations');
const cbZones = document.getElementById('type-cb-management-zones');
const showAssets = cbAssets ? cbAssets.checked : true;
const showLocations = cbLocations ? cbLocations.checked : true;
const showManagementZones = cbZones ? cbZones.checked : true;
features = features.filter(f => {
if (!f.properties) return false;
if (f.properties.is_management_zone) return showManagementZones;
if (f.properties.is_location) return showLocations;
return showAssets;
});
// Include no geom filter
const includeNoGeomCheckbox = document.getElementById('include-no-geom');
const includeNoGeom = includeNoGeomCheckbox ? includeNoGeomCheckbox.checked : true;
if (!includeNoGeom) {
features = features.filter(f => {
// Locations are never filtered by geometry checkbox (only assets)
if (f.properties && f.properties.is_location) return true;
// Locations and management zones are never filtered by geometry checkbox (only assets)
if (f.properties && (f.properties.is_location || f.properties.is_management_zone)) return true;
return f.geometry && (f.geometry.coordinates || f.geometry.type);
});
}
@ -2435,7 +2676,7 @@ function applyFilters() {
const selectedValidationStatuses = Array.from(document.querySelectorAll('[name="filter-validation-status"]:checked')).map(cb => cb.value);
if (selectedValidationStatuses.length) {
features = features.filter(f => {
if (f.properties && f.properties.is_location) return true;
if (f.properties && (f.properties.is_location || f.properties.is_management_zone)) return true;
return selectedValidationStatuses.includes(f.properties && f.properties.validation_status);
});
}
@ -2443,7 +2684,7 @@ function applyFilters() {
// Category filter
if (activeCategories.size) {
features = features.filter(f => {
if (f.properties && f.properties.is_location) return true;
if (f.properties && (f.properties.is_location || f.properties.is_management_zone)) return true;
return activeCategories.has(getFeatureCategoryValue(f));
});
}
@ -2451,7 +2692,7 @@ function applyFilters() {
// Model filter
if (activeModels.size) {
features = features.filter(f => {
if (f.properties && f.properties.is_location) return true;
if (f.properties && (f.properties.is_location || f.properties.is_management_zone)) return true;
return activeModels.has(getFeatureModelValue(f));
});
}
@ -2514,11 +2755,19 @@ function loadTableData(features) {
model: p.model || '',
status: p.status || '',
detail_url: p.detail_url || null,
management_type: p.management_type || '',
managing_authority: p.managing_authority || '',
is_remarkable: p.is_remarkable || false,
scientific_name: p.scientific_name || '',
vernacular_name: p.vernacular_name || '',
is_dead: p.is_dead || false,
vitality: p.vitality || '',
lon: p.lon,
lat: p.lat,
length: Number.isFinite(length) ? length : 0,
area: Number.isFinite(area) ? area : 0,
is_location: p.is_location,
is_management_zone: p.is_management_zone || false,
};
});
@ -2539,6 +2788,7 @@ function loadTableData(features) {
var locationshighlightState = { previousId: null };
var assetshighlightState = { previousId: null };
var managementZonesHighlightState = { previousId: null };
function isPanelTextSpriteId(missingId) {
@ -2611,15 +2861,20 @@ function rebuildMapSources() {
if (typeof map === 'undefined') return;
const locSrc = map.getSource('locations');
const asstSrc = map.getSource('assets');
if (!locSrc || !asstSrc) return; // sources not yet added (before mapLoaded)
const zoneSrc = map.getSource('management_zones');
if (!locSrc || !asstSrc || !zoneSrc) return; // sources not yet added (before mapLoaded)
mergedLocationsGeojson = {
type: 'FeatureCollection',
features: filteredFeatures.filter(f => hasValidCoordinates(f) && f.properties && f.properties.is_location),
};
mergedManagementZonesGeojson = {
type: 'FeatureCollection',
features: filteredFeatures.filter(f => hasValidCoordinates(f) && f.properties && f.properties.is_management_zone),
};
mergedAssetsGeojson = {
type: 'FeatureCollection',
features: filteredFeatures.filter(f => hasValidCoordinates(f) && f.properties && !f.properties.is_location),
features: filteredFeatures.filter(f => hasValidCoordinates(f) && f.properties && !f.properties.is_location && !f.properties.is_management_zone),
};
applyUnifiedSignPanelOffsets(mergedAssetsGeojson);
@ -2627,6 +2882,7 @@ function rebuildMapSources() {
locSrc.setData(mergedLocationsGeojson);
asstSrc.setData(mergedAssetsGeojson);
if (zoneSrc) zoneSrc.setData(mergedManagementZonesGeojson);
}
@ -2642,6 +2898,7 @@ maploader.addEventListener('mapLoaded', () => {
data: window.TERRITORY_MASK_URL || '/common/territory-mask-geojson/'
});
map.addSource('locations', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
map.addSource('management_zones', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
map.addSource('assets', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
// Add layers in render order: fills → lines → circles/symbols (see UNIFIED_LAYERS)
@ -2692,7 +2949,12 @@ maploader.addEventListener('mapLoaded', () => {
// Layer switcher — Urbis & Vlaanderen orthos (top-right, topmost)
if (typeof LayerSwitcherControl !== 'undefined') {
const initialBasemap = window._restoredBasemap || localStorage.getItem('loko_active_basemap') || 'default';
const layerSwitcher = new LayerSwitcherControl({
initialBasemapId: initialBasemap,
onBasemapChange: function (basemapId) {
schedulePreferencesSave();
},
layers: [{
id: 'basemap',
name: 'Fonds de plan & Orthophotos',
@ -2700,7 +2962,10 @@ maploader.addEventListener('mapLoaded', () => {
beforeLayer: (map && map.getLayer('territory-mask') ? 'territory-mask' : 'locations'),
basemaps: window.MAP_BACKGROUNDS || [],
}],
});// Reset view — fly back to initial Brussels Region view
});
window.unifiedLayerSwitcher = layerSwitcher;
// Reset view — fly back to initial Brussels Region view
map.addControl({
onAdd(m) {
this._map = m;
@ -2782,10 +3047,10 @@ maploader.addEventListener('mapLoaded', () => {
});
// Click interaction on map elements: show popup when assets are found at click position.
['assets', 'assets-fill', 'assets-line', 'assets-point', 'sign-panels-circles', 'sign-panels-symbols', 'sign-poles-orientation', 'sign-poles', 'sign-poles-vertical', 'locations', 'locations-circle', 'locations-radar', 'locations-radar-outline', 'locations-outline'].forEach(layerId => {
['assets', 'assets-fill', 'assets-line', 'assets-point', 'sign-panels-circles', 'sign-panels-symbols', 'sign-poles-orientation', 'sign-poles', 'sign-poles-vertical', 'locations', 'locations-circle', 'locations-radar', 'locations-radar-outline', 'locations-outline', 'management-zones-fill', 'management-zones-line'].forEach(layerId => {
if (!map.getLayer(layerId)) return;
map.on('click', layerId, showAssetClickPopup);
if (['assets', 'assets-fill', 'assets-line', 'assets-point', 'sign-panels-circles', 'sign-panels-symbols', 'sign-poles-orientation', 'sign-poles', 'sign-poles-vertical'].includes(layerId)) {
if (['assets', 'assets-fill', 'assets-line', 'assets-point', 'sign-panels-circles', 'sign-panels-symbols', 'sign-poles-orientation', 'sign-poles', 'sign-poles-vertical', 'management-zones-fill'].includes(layerId)) {
map.on('mouseenter', layerId, function () { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', layerId, function () { map.getCanvas().style.cursor = ''; });
}
@ -2795,7 +3060,7 @@ maploader.addEventListener('mapLoaded', () => {
map.on('click', function (e) {
if (!assetClickPopup || !assetClickPopup.isOpen()) return;
const interactiveLayers = ['assets', 'assets-fill', 'assets-line', 'assets-point', 'sign-panels-circles', 'sign-panels-symbols', 'sign-poles-orientation', 'sign-poles', 'sign-poles-vertical', 'locations', 'locations-circle', 'locations-radar', 'locations-radar-outline', 'locations-outline']
const interactiveLayers = ['assets', 'assets-fill', 'assets-line', 'assets-point', 'sign-panels-circles', 'sign-panels-symbols', 'sign-poles-orientation', 'sign-poles', 'sign-poles-vertical', 'locations', 'locations-circle', 'locations-radar', 'locations-radar-outline', 'locations-outline', 'management-zones-fill', 'management-zones-line']
.filter(layerId => map.getLayer(layerId));
if (!interactiveLayers.length) {
assetClickPopup.remove();
@ -2856,7 +3121,7 @@ function updateFilterBadge() {
}, 0);
n += thematicBlocksCount;
if (currentTypeFilter !== 'all') n += 1;
if (getActiveTypeFilters().length < 3) n += 1;
if (document.querySelectorAll('[name="filter-status"]:checked').length > 0) n += 1;
if (document.querySelectorAll('[name="filter-validation-status"]:checked').length > 0) n += 1;

View file

@ -90,7 +90,10 @@ function loadUnifiedMapIcons(map) {
// ── File-based icons (full colour, non-SDF) ─────────────────────────────
// UNIFIED_ICON_URLS is set in the inline <script> of unified_index.html
// using {% static %} so the URL is resolved server-side.
const iconUrls = (typeof UNIFIED_ICON_URLS !== 'undefined') ? UNIFIED_ICON_URLS : {};
const injected = (typeof UNIFIED_ICON_URLS !== 'undefined') ? UNIFIED_ICON_URLS : {};
const iconUrls = (typeof AssetSymbology !== 'undefined' && AssetSymbology.getIconUrls)
? Object.assign({}, AssetSymbology.getIconUrls(), injected)
: injected;
// trafficlights: traffic_light_icon.svg (56 px)
if (iconUrls.trafficlights && !map.hasImage('icon-trafficlights')) {
@ -140,6 +143,27 @@ function loadUnifiedMapIcons(map) {
.then(data => { if (!map.hasImage('icon-nature')) map.addImage('icon-nature', data); })
.catch(e => console.warn('unified_map_layers: could not load nature icon', e));
}
if (iconUrls.natureregional && !map.hasImage('icon-nature-regional')) {
loadNormalisedIcon(iconUrls.natureregional, 56)
.then(data => { if (!map.hasImage('icon-nature-regional')) map.addImage('icon-nature-regional', data); })
.catch(e => console.warn('unified_map_layers: could not load nature regional icon', e));
}
if (iconUrls.natureremarkable && !map.hasImage('icon-nature-remarkable')) {
loadNormalisedIcon(iconUrls.natureremarkable, 56)
.then(data => { if (!map.hasImage('icon-nature-remarkable')) map.addImage('icon-nature-remarkable', data); })
.catch(e => console.warn('unified_map_layers: could not load nature remarkable icon', e));
}
if (iconUrls.naturedead && !map.hasImage('icon-nature-dead')) {
loadNormalisedIcon(iconUrls.naturedead, 56)
.then(data => { if (!map.hasImage('icon-nature-dead')) map.addImage('icon-nature-dead', data); })
.catch(e => console.warn('unified_map_layers: could not load nature dead icon', e));
}
// naturegreensurface: dedicated green surface / lawn SVG (52 px)
if (iconUrls.naturegreensurface && !map.hasImage('icon-naturegreensurface')) {
loadNormalisedIcon(iconUrls.naturegreensurface, 52)
.then(data => { if (!map.hasImage('icon-naturegreensurface')) map.addImage('icon-naturegreensurface', data); })
.catch(e => console.warn('unified_map_layers: could not load naturegreensurface icon', e));
}
// structures: dedicated bridge/tunnel SVG (52 px)
if (iconUrls.structures && !map.hasImage('icon-structures')) {
@ -201,11 +225,22 @@ function loadUnifiedMapIcons(map) {
.then(data => { if (!map.hasImage('icon-artworkmonument')) map.addImage('icon-artworkmonument', data); })
.catch(e => console.warn('unified_map_layers: could not load artworkmonument icon', e));
}
// itsbollardplot: dedicated automatic bollard / plot SVG (42 px)
if (iconUrls.itsbollardplot && !map.hasImage('icon-itsbollardplot')) {
loadNormalisedIcon(iconUrls.itsbollardplot, 42)
.then(data => { if (!map.hasImage('icon-itsbollardplot')) map.addImage('icon-itsbollardplot', data); })
.catch(e => console.warn('unified_map_layers: could not load itsbollardplot icon', e));
}
// If the SVG above is unavailable, publiclighting falls back to Bootstrap glyph.
// ── Bootstrap Icons glyph SDF icons (recoloured by icon-color) ──────────
// codepoints and colors derived from AssetSymbology (single source of truth)
const FILE_ICON_NAMES = new Set(Object.keys(iconUrls).map(k => 'icon-' + k));
const FILE_ICON_NAMES = new Set([
...Object.keys(iconUrls).map(k => 'icon-' + k),
'icon-nature-regional',
'icon-nature-remarkable',
'icon-nature-dead',
]);
const GLYPH_ICONS = (typeof AssetSymbology !== 'undefined')
? AssetSymbology.getGlyphIcons()
@ -384,6 +419,34 @@ const _unified_locations_fill = {
},
};
// ─── Layer 1b — management zones fill ─────────────────────────────────────────
const _unified_management_zones_fill = {
id: 'management-zones-fill',
type: 'fill',
source: 'management_zones',
filter: ['any',
['==', ['geometry-type'], 'Polygon'],
['==', ['geometry-type'], 'MultiPolygon']
],
paint: {
'fill-color': ['coalesce', ['get', 'color'], '#10b981'],
'fill-opacity': 0.25,
'fill-antialias': false,
},
};
// ─── Layer 3b — management zones line ─────────────────────────────────────────
const _unified_management_zones_line = {
id: 'management-zones-line',
type: 'line',
source: 'management_zones',
paint: {
'line-color': ['coalesce', ['get', 'color'], '#10b981'],
'line-width': 2,
'line-dasharray': [3, 2],
},
};
// ─── Layer 2 — asset fill (polygon assets: road surfaces, nature surfaces) ────
const _unified_assets_fill = (typeof AssetSymbology !== 'undefined' && AssetSymbology.getAssetsFillLayer)
? AssetSymbology.getAssetsFillLayer('assets')
@ -606,6 +669,7 @@ const _unified_assets_symbol = {
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'trafficlightelectricalcabinet'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'asset_type'], 'itsbollardplot'],
['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'],
@ -641,6 +705,7 @@ const _unified_assets_symbol = {
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'trafficlightelectricalcabinet'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'asset_type'], 'itsbollardplot'],
['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'],
@ -676,6 +741,7 @@ const _unified_assets_symbol = {
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'asset_type'], 'itsbollardplot'],
['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'],
@ -711,6 +777,7 @@ const _unified_assets_symbol = {
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'asset_type'], 'itsbollardplot'],
['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'],
@ -746,6 +813,7 @@ const _unified_assets_symbol = {
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'asset_type'], 'itsbollardplot'],
['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'],
@ -781,6 +849,7 @@ const _unified_assets_symbol = {
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'asset_type'], 'itsbollardplot'],
['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'],
@ -816,6 +885,7 @@ const _unified_assets_symbol = {
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'asset_type'], 'itsbollardplot'],
['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'],
@ -848,6 +918,7 @@ const _unified_assets_symbol = {
['==', ['get', 'thematic'], 'publiclighting'], 'bottom',
['==', ['get', 'thematic'], 'trafficlights'], 'bottom',
['==', ['get', 'thematic'], 'nature'], 'bottom',
['==', ['get', 'asset_type'], 'itsbollardplot'], 'bottom',
'center'
],
'icon-offset': [
@ -884,6 +955,8 @@ const _unified_assets_symbol = {
['all', ['==', ['get', 'thematic'], 'roads'], ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false]], 0,
['all', ['==', ['get', 'asset_type'], 'naturerwiasset'], ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false]], 0,
['==', ['get', 'asset_type'], 'naturegreensurface'], 0,
['==', ['get', 'asset_type'], 'itsbollardsystem'], 0,
['match', ['geometry-type'], ['LineString', 'MultiLineString'], true, false], 0,
['boolean', ['feature-state', 'highlighted'], false], 1,
0.85
],
@ -1056,14 +1129,20 @@ const _unified_assets_line = (typeof AssetSymbology !== 'undefined' && AssetSymb
'line-color': [
'case',
['==', ['get', 'thematic'], 'nature'], '#2e7d32',
['==', ['get', 'asset_type'], 'itsbollardsystem'], '#0288d1',
THEMATIC_COLOR_EXPR
],
'line-width': [
'case',
['boolean', ['feature-state', 'highlighted'], false], 5,
['boolean', ['feature-state', 'highlighted'], false], 6,
['==', ['get', 'asset_type'], 'itsbollardsystem'], 5,
3
],
'line-opacity': 0.8,
'line-opacity': [
'case',
['==', ['get', 'asset_type'], 'itsbollardsystem'], 0.95,
0.8
],
},
minzoom: 8,
};
@ -1145,10 +1224,12 @@ const UNIFIED_LAYERS = [
_unified_territory_mask, // 0. territory mask (outside commune)
_unified_territory_outline, // 0b. territory boundary line
_unified_locations_fill, // 1. polygon fills (bottom)
_unified_management_zones_fill, // 1b. management zones fill
_unified_assets_fill, // 2. polygon fills
_unified_locations_outline, // 3. lines
_unified_assets_line, // 3b. asset lines
_unified_assets_polygon_outline, // 3c. polygon asset outlines
_unified_management_zones_line, // 3b. management zones line
_unified_assets_line, // 3c. asset lines
_unified_assets_polygon_outline, // 3d. polygon asset outlines
_unified_locations_circle, // 4. circles
_unified_locations_radar_outline,
_unified_locations_radar,

View file

@ -364,6 +364,9 @@
<script src="{% url 'javascript-catalog' %}"></script>
<script src="{% static 'common/base_script.js' %}"></script>
<script src="{% static 'common/base_map.js' %}"></script>
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
<script src="{% static 'contracts/js/contracts_map_overlay.js' %}"></script>
<script src="{% static 'assets/js/management_zones_map_overlay.js' %}"></script>
<script src="{% static 'assets/assets_index.js' %}"></script>
{% block extra_script %}
{% endblock extra_script %}

View file

@ -1,35 +1,48 @@
{% load i18n %}
<ul class="list-group list-group-flush bg-transparent">
<ul class="list-group list-group-flush bg-transparent border-0">
{% for asset_node in asset_nodes %}
<li class="list-group-item bg-transparent border-0 py-1 px-0">
<!-- Row Header -->
<div class="d-flex align-items-center justify-content-between py-1 px-2">
<div class="d-flex align-items-center justify-content-between py-1 px-3">
<!-- Contenu gauche (indenté selon la profondeur) -->
<div class="d-flex align-items-center gap-2 flex-wrap" style="padding-left: calc(({{ depth|default:1 }} - 1) * 1.25rem);">
{% if asset_node.children %}
<button class="btn btn-sm btn-light border p-0 px-1 tree-toggle-btn collapsed"
<button class="btn btn-sm btn-light border p-0 px-1 tree-toggle-btn"
type="button"
data-bs-toggle="collapse"
data-bs-target="#tree-asset-{{ asset_node.asset.id }}"
aria-expanded="false">
<i class="bi bi-chevron-right small"></i>
aria-expanded="true">
<i class="bi bi-chevron-down small"></i>
</button>
{% else %}
<span class="px-1"></span>
<span class="d-inline-block text-center text-muted" style="width: 1.35rem;"><i class="bi bi-dash small"></i></span>
{% endif %}
<i class="bi bi-gear-fill text-info small"></i>
<!-- Icône colorée selon la famille d'asset -->
<i class="bi {{ asset_node.asset.get_icon_class|default:'bi-tools' }}" style="font-size: 1rem; color: {{ asset_node.asset.theme_icon_color }};"></i>
<!-- Code en bleu pour indiquer clairement un lien cliquable -->
<a href="{{ asset_node.asset.get_absolute_url }}" class="fw-bold text-primary text-decoration-underline small">
<!-- Code cliquable avec couleur thématique -->
<a href="{{ asset_node.asset.get_absolute_url }}" class="fw-bold text-decoration-underline small" style="color: {{ asset_node.asset.theme_color }};">
{{ asset_node.asset.code }}
</a>
<span class="small">{{ asset_node.asset.get_name }}</span>
<span class="badge bg-info-subtle text-info border small ms-1">{{ asset_node.asset.get_asset_type_display }}</span>
<span class="small text-dark">{{ asset_node.asset.get_name }}</span>
<!-- Badge Macro-lot sobre -->
<span class="badge bg-light text-muted border small">{{ asset_node.asset.get_macro_lot }}</span>
<!-- Badge Typologie avec couleur thématique différenciée -->
<span class="badge {{ asset_node.asset.theme_badge_class }} small" style="{{ asset_node.asset.theme_badge_style }}">
{{ asset_node.asset.get_asset_type_display }}
</span>
{% if asset_node.asset.status == 'inactive' %}
<span class="badge bg-danger-subtle text-danger border small">{% translate "Inactif" %}</span>
{% endif %}
</div>
</div>
{% if asset_node.children %}
<div id="tree-asset-{{ asset_node.asset.id }}" class="collapse mt-1">
<div id="tree-asset-{{ asset_node.asset.id }}" class="collapse show tree-collapse-node">
{% include "assets/buildings/publicbuilding_asset_tree.html" with asset_nodes=asset_node.children depth=depth|default:1|add:1 only %}
</div>
{% endif %}

View file

@ -133,6 +133,40 @@
<th class="text-muted">{% translate "Surface Utile / Brut" %} :</th>
<td>{{ building.usable_surface_m2|default:"-" }} m² / {{ building.total_surface_m2|default:"-" }} m²</td>
</tr>
{% if building.room_number %}
<tr>
<th class="text-muted">{% translate "Numéro de local / porte" %} :</th>
<td><span class="badge bg-secondary-subtle text-secondary border font-monospace"><i class="bi bi-tag me-1"></i>{{ building.room_number }}</span></td>
</tr>
{% endif %}
{% if building.surface_m2 %}
<tr>
<th class="text-muted">{% translate "Surface spécifique" %} :</th>
<td>{{ building.surface_m2 }} m²</td>
</tr>
{% endif %}
{% if building.ceiling_height_m %}
<tr>
<th class="text-muted">{% translate "Hauteur sous plafond" %} :</th>
<td>{{ building.ceiling_height_m }} m</td>
</tr>
{% endif %}
<tr>
<th class="text-muted">{% translate "Chauffage du local" %} :</th>
<td>{% if building.is_heated %}<span class="text-success"><i class="bi bi-fire me-1"></i>{% translate "Chauffé" %}</span>{% else %}<span class="text-muted"><i class="bi bi-slash-circle me-1"></i>{% translate "Non chauffé" %}</span>{% endif %}</td>
</tr>
{% if building.floor_type %}
<tr>
<th class="text-muted">{% translate "Revêtement de sol" %} :</th>
<td>{{ building.get_floor_type_display }}</td>
</tr>
{% endif %}
{% if building.fire_compartment %}
<tr>
<th class="text-muted">{% translate "Compartiment coupe-feu" %} :</th>
<td>{{ building.fire_compartment }}</td>
</tr>
{% endif %}
<tr>
<th class="text-muted">{{ regional_terms.public_access_category }} :</th>
<td>{{ building.erp_category|default:"-" }}</td>
@ -146,10 +180,20 @@
</div>
<!-- Card 3: Arborescence Unifiée des Localisations & Assets -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light fw-bold d-flex justify-content-between align-items-center">
<div class="card shadow-sm mb-4" id="card-hierarchy-tree">
<div class="card-header bg-light fw-bold d-flex justify-content-between align-items-center flex-wrap gap-2">
<span><i class="bi bi-diagram-3 me-2"></i>{% translate "Arborescence Hiérarchique" %}</span>
<span class="badge bg-primary-subtle text-primary border">{{ location_tree.children|length }} {% translate "sous-localisation(s)" %}</span>
<div class="d-flex align-items-center gap-2">
<div class="btn-group btn-group-sm" role="group" id="tree-expand-collapse-group">
<button type="button" class="btn btn-outline-secondary py-0 px-2 small" id="btn-tree-collapse-all" title="{% translate 'Tout replier' %}">
<i class="bi bi-arrows-collapse me-1"></i>{% translate "Tout replier" %}
</button>
<button type="button" class="btn btn-outline-secondary py-0 px-2 small" id="btn-tree-expand-all" title="{% translate 'Tout déplier' %}">
<i class="bi bi-arrows-expand me-1"></i>{% translate "Tout déplier" %}
</button>
</div>
<span class="badge bg-primary-subtle text-primary border">{{ location_tree.children|length }} {% translate "sous-localisation(s)" %}</span>
</div>
</div>
<div class="card-body p-3">
@ -177,37 +221,42 @@
<!-- Localisation Courante (Racine de l'affichage) -->
<div class="p-2 border rounded bg-white mb-2">
<div class="d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-building text-primary fs-5"></i>
<div class="d-flex align-items-center gap-2 flex-wrap">
<button class="btn btn-sm btn-light border p-0 px-1 tree-toggle-btn"
type="button"
data-bs-toggle="collapse"
data-bs-target="#tree-root-content"
aria-expanded="true">
<i class="bi bi-chevron-down small"></i>
</button>
<i class="bi {{ building.get_icon_class }} text-primary fs-5"></i>
<span class="fw-bold fs-6 text-primary">{{ building.code }}</span>
{% if building.room_number %}
<span class="badge bg-secondary-subtle text-secondary border ms-1 font-monospace"><i class="bi bi-tag me-1"></i>{{ building.room_number }}</span>
{% endif %}
<span class="fw-bold fs-6">- {{ building.get_name }}</span>
<span class="badge bg-primary text-white ms-1">{{ building.get_location_type_display }}</span>
{% if location_tree.assets %}
<button class="btn btn-sm btn-outline-info py-0 px-2 small ms-2 text-nowrap"
type="button"
data-bs-toggle="collapse"
data-bs-target="#tree-assets-root"
aria-expanded="false">
<i class="bi bi-tools me-1"></i>Assets ({{ location_tree.assets|length }})
</button>
<span class="badge bg-info-subtle text-info border small ms-1" title="{% translate 'Assets rattachés directement au bâtiment' %}">
<i class="bi bi-tools me-1"></i>{{ location_tree.assets|length }} {% translate "asset(s)" %}
</span>
{% endif %}
</div>
</div>
<!-- Collapsible Assets de la localisation courante (NON VISIBLES PAR DÉFAUT) -->
{% if location_tree.assets %}
<div id="tree-assets-root" class="collapse mt-2 ms-4">
<div class="card card-body bg-light border-info-subtle p-2 mb-2">
<div class="fw-bold small text-info mb-1"><i class="bi bi-tools me-1"></i>{% translate "Assets rattachés à" %} {{ building.code }} :</div>
{% include "assets/buildings/publicbuilding_asset_tree.html" with asset_nodes=location_tree.assets only %}
<div id="tree-root-content" class="collapse show mt-2">
<!-- Assets rattachés directement au bâtiment racine -->
{% if location_tree.assets %}
<div class="tree-assets-group">
{% include "assets/buildings/publicbuilding_asset_tree.html" with asset_nodes=location_tree.assets depth=2 only %}
</div>
</div>
{% endif %}
{% endif %}
<!-- Sous-localisations & leurs assets -->
<div class="mt-2">
{% include "assets/buildings/publicbuilding_location_tree.html" with node=location_tree depth=1 only %}
<!-- Sous-localisations & leurs assets -->
<div class="tree-locations-group">
{% include "assets/buildings/publicbuilding_location_tree.html" with node=location_tree depth=1 only %}
</div>
</div>
</div>
@ -216,37 +265,167 @@
</div>
<!-- Right Column: Localisation & Documents (sous la carte) -->
<!-- Right Column: Localisation & Plans d'étage / Carte interactive -->
<div class="col-md-5">
<!-- Card Localisation & Carte interactive -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light fw-bold d-flex justify-content-between align-items-center">
<span><i class="bi bi-geo-alt me-2"></i>{% translate "Localisation & Carte" %}</span>
{% if center_lat and center_lon %}
<a href="https://www.google.com/maps?cbll={{ center_lat|floatformat:'6u'}},{{ center_lon|floatformat:'6u'}}&cbp=12,0,0,0,5&layer=c&panoid="
target="_blank" class="btn btn-sm btn-outline-secondary py-0 px-2">
<img src="{% static 'common/google-street-view-logo.png' %}" alt="Street View" height="14" class="me-1">
Street View
</a>
{% endif %}
<!-- Card Localisation, Carte interactive & Plans d'étage -->
<div class="card shadow-sm mb-4" id="card-location-plans">
<div class="card-header bg-light d-flex justify-content-between align-items-center flex-wrap gap-2 py-2">
<div class="d-flex align-items-center">
{% if level_specific_mode %}
<span class="fw-bold text-dark"><i class="bi bi-layers text-primary me-2"></i>{% translate "Plan du niveau" %} : <span class="text-primary">{{ active_floor.name|default:building.name }}</span></span>
{% else %}
<span class="fw-bold text-dark"><i class="bi bi-geo-alt text-primary me-2"></i>{% translate "Localisation & Plans" %}</span>
{% endif %}
</div>
<div class="d-flex align-items-center gap-2">
<div class="btn-group btn-group-sm" role="group" id="view-mode-toggle">
<button type="button" class="btn btn-outline-primary active" id="btn-toggle-plans" title="{% translate "Afficher les plans d'étage" %}">
<i class="bi bi-layers me-1"></i>{% translate "Plans" %}
</button>
<button type="button" class="btn btn-outline-primary" id="btn-toggle-map" title="{% translate "Afficher la carte du site" %}">
<i class="bi bi-map me-1"></i>{% translate "Carte" %}
</button>
</div>
{% if center_lat and center_lon %}
<a href="https://www.google.com/maps?cbll={{ center_lat_json }},{{ center_lon_json }}&cbp=12,0,0,0,5&layer=c&panoid="
target="_blank" class="btn btn-sm btn-outline-secondary py-0 px-2" title="Street View">
<img src="{% static 'common/google-street-view-logo.png' %}" alt="Street View" height="14" class="me-1">
Street View
</a>
{% endif %}
</div>
</div>
<div class="card-body p-0">
<div id="building-map" style="height: 340px; width: 100%; border-radius: 0 0 4px 4px;"></div>
</div>
<div class="card-footer bg-white p-3">
<p class="mb-2 small">
<strong>{% translate "Adresse" %} :</strong> {{ building.address|default:_("Non renseignée") }}
</p>
{% if center_lat and center_lon %}
<div class="small text-muted font-monospace">
WGS84: {{ center_lat|floatformat:6 }}, {{ center_lon|floatformat:6 }}
<!-- 1. VUE PLANS D'ÉTAGE -->
<div id="section-plans" class="card-body p-0">
{% if not level_specific_mode and floors_data %}
<!-- Sélecteur d'étage horizontal -->
<div class="bg-light border-bottom px-3 py-2 d-flex align-items-center gap-1 overflow-auto" id="floors-nav-bar" style="white-space: nowrap;">
<span class="text-muted small fw-semibold me-2"><i class="bi bi-filter me-1"></i>{% translate "Niveaux" %} :</span>
{% for f in floors_data %}
<button type="button"
class="btn btn-sm floor-nav-btn {% if f.id == active_floor.id %}btn-primary text-white shadow-sm{% else %}btn-outline-secondary{% endif %}"
data-floor-id="{{ f.id }}"
data-plan-url="{{ f.plan_url }}"
data-has-plan="{{ f.has_plan|yesno:'true,false' }}"
data-floor-name="{{ f.name }}"
data-surface="{{ f.surface_m2|default:'' }}"
data-rooms-count="{{ f.rooms_count }}"
data-assets-count="{{ f.assets_count }}"
data-floor-url="{{ f.url }}">
{{ f.name }}
{% if f.has_plan %}
<span class="badge bg-white text-primary ms-1" title="{% translate "Plan architectural disponible" %}">📐</span>
{% endif %}
</button>
{% endfor %}
</div>
{% endif %}
<!-- Conteneur du viewer interactif -->
<div id="plan-viewer-wrapper" style="position: relative; height: 380px; width: 100%; background: #0F172A05; overflow: hidden; display: flex; align-items: center; justify-content: center;">
<!-- Floating Controls (Zoom / Pan / Fullscreen / Download) -->
<div class="btn-group-vertical btn-group-sm shadow-sm" style="position: absolute; top: 12px; right: 12px; z-index: 10; background: rgba(255, 255, 255, 0.92); backdrop-filter: blur(4px); border-radius: 6px; border: 1px solid #CBD5E1;">
<button type="button" class="btn btn-light py-1 px-2" id="plan-zoom-in" title="{% translate "Zoom avant (+)" %}">
<i class="bi bi-zoom-in"></i>
</button>
<button type="button" class="btn btn-light py-1 px-2" id="plan-zoom-out" title="{% translate "Zoom arrière (-)" %}">
<i class="bi bi-zoom-out"></i>
</button>
<button type="button" class="btn btn-light py-1 px-2" id="plan-reset" title="{% translate "Recentrer / Ajuster (100%)" %}">
<i class="bi bi-arrows-angle-contract"></i>
</button>
<button type="button" class="btn btn-light py-1 px-2" id="plan-fullscreen" title="{% translate "Plein écran" %}">
<i class="bi bi-arrows-fullscreen"></i>
</button>
<a href="{{ active_floor.plan_url|default:'#' }}" target="_blank" class="btn btn-light py-1 px-2" id="plan-download-link" title="{% translate "Ouvrir / Télécharger le plan" %}" {% if not active_floor.has_plan %}style="display:none;"{% endif %}>
<i class="bi bi-box-arrow-up-right"></i>
</a>
</div>
<!-- Indicateur du niveau affiché -->
<div class="badge bg-dark bg-opacity-75 text-white px-2 py-1" style="position: absolute; bottom: 12px; left: 12px; z-index: 10; font-size: 0.8rem; pointer-events: none;" id="plan-level-indicator">
<i class="bi bi-layers me-1"></i><span id="plan-active-name">{{ active_floor.name|default:building.name }}</span>
</div>
<!-- Pan/Zoom Canvas -->
<div id="plan-viewport" style="width: 100%; height: 100%; cursor: grab; display: flex; align-items: center; justify-content: center; transform-origin: center center; transition: transform 0.08s ease-out;">
<img id="plan-image"
src="{{ active_floor.plan_url|default:'' }}"
alt="{% translate "Plan d'étage" %}"
style="max-width: 95%; max-height: 95%; object-fit: contain; user-select: none; pointer-events: none; {% if not active_floor.has_plan %}display: none;{% endif %}">
</div>
<!-- Empty State when floor has no plan -->
<div id="plan-empty-state" class="text-center p-4" style="{% if active_floor.has_plan %}display: none;{% else %}display: block;{% endif %}">
<i class="bi bi-file-earmark-image fs-1 text-muted d-block mb-2"></i>
<h6 class="text-muted mb-1">{% translate "Aucun plan associé à ce niveau" %}</h6>
<p class="text-muted small mb-3">{% translate "Vous pouvez téléverser un plan d'étage (SVG, image ou PDF) pour ce niveau." %}</p>
{% if can_edit %}
<a href="{% url 'assets:publicbuilding_edit' building.pk %}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-upload me-1"></i>{% translate "Associer un plan" %}
</a>
{% endif %}
</div>
</div>
<!-- Footer info du niveau actif (locaux & caractéristiques) -->
<div class="card-footer bg-white p-3 border-top" id="plan-floor-info">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-2">
<div>
<strong class="text-dark" id="info-floor-title">{{ active_floor.name|default:building.name }}</strong>
<span class="text-muted small ms-2" id="info-floor-code">({{ active_floor.code|default:building.code }})</span>
</div>
<div class="d-flex gap-2">
<span class="badge bg-light text-dark border" id="info-floor-surface">
{% if active_floor.surface_m2 %}
<i class="bi bi-bounding-box me-1 text-primary"></i>{{ active_floor.surface_m2|floatformat:1 }} m²
{% endif %}
</span>
<span class="badge bg-light text-dark border" id="info-floor-rooms">
<i class="bi bi-door-closed me-1 text-primary"></i><span id="info-rooms-count">{{ active_floor.rooms_count|default:0 }}</span> {% translate "local/locaux" %}
</span>
<span class="badge bg-light text-dark border" id="info-floor-assets">
<i class="bi bi-tools me-1 text-primary"></i><span id="info-assets-count">{{ active_floor.assets_count|default:0 }}</span> {% translate "équipement(s)" %}
</span>
</div>
</div>
<!-- Chips des locaux / pièces situés sur cet étage -->
<div id="info-floor-rooms-chips" class="d-flex flex-wrap gap-1 mt-2">
{% for r in active_floor.rooms %}
<a href="{{ r.url }}" class="badge bg-light text-dark border text-decoration-none py-1 px-2" title="{{ r.location_type_display }}">
<i class="bi bi-door-open me-1 text-primary"></i><strong>{{ r.room_number|default:r.code }}</strong> - {{ r.name }}
</a>
{% empty %}
<span class="text-muted small fst-italic">{% translate "Aucun sous-local direct répertorié sur ce niveau." %}</span>
{% endfor %}
</div>
</div>
</div>
<!-- 2. VUE CARTE DU SITE / EMPRISE (MapLibre) -->
<div id="section-map" style="display: none;">
<div class="card-body p-0">
<div id="building-map" style="height: 380px; width: 100%;"></div>
</div>
<div class="card-footer bg-white p-3 border-top">
<p class="mb-2 small">
<strong>{% translate "Adresse" %} :</strong> {{ building.address|default:_("Non renseignée") }}
</p>
{% if center_lat and center_lon %}
<div class="small text-muted font-monospace">
WGS84: {{ center_lat|floatformat:6 }}, {{ center_lon|floatformat:6 }}
</div>
{% endif %}
</div>
</div>
</div>
<!-- Card Documents associés (placée sous la localisation) -->
<!-- Card Documents associés (placée sous les plans et la carte) -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span class="fw-bold"><i class="bi bi-file-earmark-text me-2"></i>{% translate "Documents associés" %} ({{ documents.count }})</span>
@ -258,15 +437,30 @@
<div class="row row-cols-1 row-cols-sm-2 g-3">
{% for doc in documents %}
<div class="col">
<div class="card h-100 border text-center p-2">
{% if doc.document.file %}
<div class="card h-100 border p-2 text-center position-relative">
{% if doc.document_type == 'floor_plan' %}
<span class="badge bg-primary text-white position-absolute top-0 end-0 m-1" style="font-size: 0.65rem;">{% translate "Plan" %}</span>
<i class="bi bi-layers text-primary fs-2 mb-1"></i>
{% else %}
<span class="badge bg-secondary text-white position-absolute top-0 end-0 m-1" style="font-size: 0.65rem;">{{ doc.get_document_type_display }}</span>
<i class="bi bi-file-earmark-pdf text-danger fs-2 mb-1"></i>
<div class="small text-truncate w-100 fw-bold" title="{{ doc.document.file.name }}">{{ doc.document.file.name }}</div>
<a href="{{ doc.document.file.url }}" target="_blank" class="btn btn-sm btn-outline-secondary mt-2">
{% endif %}
<div class="small text-truncate w-100 fw-bold" title="{{ doc.description|default:doc.file.name }}">
{{ doc.description|default:doc.file.name }}
</div>
{% if doc.floor_location %}
<div class="small text-muted text-truncate mb-1" style="font-size: 0.75rem;">
<i class="bi bi-layers-half me-1"></i>{{ doc.floor_location.name }}
</div>
{% endif %}
{% if doc.file %}
<a href="{% url 'private_file_download' %}?app=assets&model=PublicBuildingDocument&pk={{ doc.pk }}&field=file" target="_blank" class="btn btn-sm btn-outline-secondary mt-auto py-0 px-2" style="font-size: 0.8rem;">
<i class="bi bi-download me-1"></i>{% translate "Télécharger" %}
</a>
{% else %}
<i class="bi bi-file-earmark text-secondary fs-2 mb-1"></i>
<span class="text-muted small">{% translate "Fichier indisponible" %}</span>
{% endif %}
</div>
@ -299,11 +493,13 @@
<thead class="table-light">
<tr>
<th>{% translate "Code / Nom" %}</th>
<th>{% translate "Type d'asset" %}</th>
<th>{% translate "Lot / Type d'asset" %}</th>
<th>{% translate "Caractéristiques & Spécifications" %}</th>
<th>{% translate "Localisation spécifique" %}</th>
<th>{% translate "Asset Parent" %}</th>
<th>{% translate "Statut" %}</th>
<th>{% translate "Prochain contrôle" %}</th>
<th>{% translate "Maintenance & Prestataire" %}</th>
<th class="text-end pe-3">{% translate "Actions" %}</th>
</tr>
</thead>
@ -311,18 +507,39 @@
{% for eq in equipments %}
<tr>
<td>
<a href="{{ eq.get_absolute_url }}" class="fw-bold text-decoration-none">{{ eq.code }}</a>
<a href="{{ eq.get_absolute_url }}" class="fw-bold text-decoration-none" style="color: {{ eq.theme_color }};">
<i class="bi {{ eq.get_icon_class }} me-1" style="color: {{ eq.theme_icon_color }};"></i>{{ eq.code }}
</a>
<div class="small text-muted">{{ eq.get_name }}</div>
</td>
<td>
<span class="badge bg-info-subtle text-info border">
<span class="badge bg-light text-muted border me-1">{{ eq.get_macro_lot }}</span>
<span class="badge {{ eq.theme_badge_class }} d-block mt-1" style="{{ eq.theme_badge_style }}">
{{ eq.get_asset_type_display }}
</span>
</td>
<td>
{% if eq.dimensions or eq.surface_m2 %}
<div class="small fw-semibold">{% if eq.dimensions %}{{ eq.dimensions }}{% endif %}{% if eq.surface_m2 %} ({{ eq.surface_m2 }} m²){% endif %}</div>
{% endif %}
{% if eq.power_kw or eq.energy_source %}
<div class="small text-muted">
{% if eq.power_kw %}<i class="bi bi-lightning-charge me-1"></i>{{ eq.power_kw }} kW{% endif %}
{% if eq.energy_source %}<span class="badge bg-warning-subtle text-dark border ms-1">{{ eq.get_energy_source_display }}</span>{% endif %}
</div>
{% endif %}
{% if eq.material or eq.fire_rating or eq.glazing_type %}
<div class="small text-muted">
{% if eq.material %}{{ eq.material }}{% endif %}
{% if eq.glazing_type %} - {{ eq.get_glazing_type_display }}{% endif %}
{% if eq.fire_rating %}<span class="badge bg-danger-subtle text-danger border ms-1">{{ eq.fire_rating }}</span>{% endif %}
</div>
{% endif %}
</td>
<td>
{% if eq.location %}
<a href="{{ eq.location.get_absolute_url }}" class="small text-dark text-decoration-none">
<i class="bi bi-geo-alt me-1"></i>{{ eq.location.code }} - {{ eq.location.get_name }}
<i class="bi {{ eq.location.get_icon_class }} me-1"></i>{{ eq.location.code }} - {{ eq.location.get_name }}
</a>
{% else %}
<span class="small text-muted">{{ eq.building.code }}</span>
@ -331,7 +548,7 @@
<td>
{% if eq.parent_asset %}
<a href="{{ eq.parent_asset.get_absolute_url }}" class="small text-decoration-none">
<i class="bi bi-diagram-2 me-1"></i>{{ eq.parent_asset.code }}
<i class="bi {{ eq.parent_asset.get_icon_class }} me-1"></i>{{ eq.parent_asset.code }}
</a>
{% else %}
<span class="text-muted small">-</span>
@ -343,6 +560,16 @@
</span>
</td>
<td>{{ eq.next_control_date|date:"d/m/Y"|default:"-" }}</td>
<td>
{% if eq.contractor_name %}
<div class="small fw-semibold"><i class="bi bi-building me-1"></i>{{ eq.contractor_name }}</div>
{% endif %}
{% if eq.maintenance_contract_ref %}
<div class="small text-muted font-monospace">Réf: {{ eq.maintenance_contract_ref }}</div>
{% elif not eq.contractor_name %}
<span class="text-muted small">-</span>
{% endif %}
</td>
<td class="text-end pe-3">
<a href="{{ eq.get_absolute_url }}" class="btn btn-sm btn-outline-info py-0 px-2 small text-nowrap" title="{% translate 'Détail de l’asset' %}">
<i class="bi bi-eye me-1"></i>{% translate "Détail" %}
@ -351,7 +578,7 @@
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center text-muted py-4">{% translate "Aucun asset ou équipement technique enregistré." %}</td>
<td colspan="9" class="text-center text-muted py-4">{% translate "Aucun asset ou équipement technique enregistré." %}</td>
</tr>
{% endfor %}
</tbody>
@ -414,87 +641,351 @@
{% block script %}
<script>
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener("DOMContentLoaded", function() {
const buildingGeom = {{ building_geojson_json|safe }};
const centerLon = {{ center_lon|default:4.3572 }};
const centerLat = {{ center_lat|default:50.8477 }};
const centerLon = {{ center_lon_json|safe }} || 4.3572;
const centerLat = {{ center_lat_json|safe }} || 50.8477;
const floorsData = {{ floors_data_json|safe }} || [];
let currentFloor = {{ active_floor_json|safe }};
const hasAnyPlan = {{ has_any_plan|yesno:"true,false" }};
const levelSpecificMode = {{ level_specific_mode|yesno:"true,false" }};
let map = null;
// 1. Initialisation de la carte MapLibre
function initMap() {
const container = document.getElementById('building-map');
if (!container) return;
const container = document.getElementById("building-map");
if (!container || map) return;
map = new maplibregl.Map({
container: 'building-map',
style: '{{ MAP_STYLE_LIGHT|default:"https://api.maptiler.com/maps/streets-v2-light/style.json" }}',
container: "building-map",
style: "{{ MAP_STYLE_LIGHT|default:'https://api.maptiler.com/maps/streets-v2-light/style.json' }}",
center: [centerLon, centerLat],
zoom: 16
});
map.addControl(new maplibregl.NavigationControl());
map.on('load', function() {
map.on("load", function() {
if (buildingGeom) {
map.addSource('building-footprint', {
'type': 'geojson',
'data': buildingGeom
map.addSource("building-footprint", {
"type": "geojson",
"data": buildingGeom
});
map.addLayer({
'id': 'building-fill',
'type': 'fill',
'source': 'building-footprint',
'paint': {
'fill-color': '#2B579A',
'fill-opacity': 0.4
"id": "building-fill",
"type": "fill",
"source": "building-footprint",
"paint": {
"fill-color": "#2B579A",
"fill-opacity": 0.4
}
});
map.addLayer({
'id': 'building-outline',
'type': 'line',
'source': 'building-footprint',
'paint': {
'line-color': '#2B579A',
'line-width': 3
"id": "building-outline",
"type": "line",
"source": "building-footprint",
"paint": {
"line-color": "#2B579A",
"line-width": 3
}
});
}
});
}
initMap();
// 2. Bascule entre Mode Plans d'étage et Mode Carte
const btnTogglePlans = document.getElementById("btn-toggle-plans");
const btnToggleMap = document.getElementById("btn-toggle-map");
const sectionPlans = document.getElementById("section-plans");
const sectionMap = document.getElementById("section-map");
const overviewTab = document.getElementById('overview-tab');
function setViewMode(mode) {
if (mode === "map") {
btnToggleMap?.classList.add("active");
btnTogglePlans?.classList.remove("active");
if (sectionPlans) sectionPlans.style.display = "none";
if (sectionMap) sectionMap.style.display = "block";
if (!map) {
initMap();
} else {
map.resize();
}
} else {
btnTogglePlans?.classList.add("active");
btnToggleMap?.classList.remove("active");
if (sectionPlans) sectionPlans.style.display = "block";
if (sectionMap) sectionMap.style.display = "none";
}
}
btnTogglePlans?.addEventListener("click", function() { setViewMode("plans"); });
btnToggleMap?.addEventListener("click", function() { setViewMode("map"); });
// Par défaut, si aucun plan n'existe et qu'on n'est pas en mode spécifique à un niveau, afficher la carte
if (!hasAnyPlan && !levelSpecificMode && (buildingGeom || (centerLon && centerLat))) {
setViewMode("map");
} else {
setViewMode("plans");
}
// 3. Pan & Zoom interactif sur le viewer de plan
const planViewport = document.getElementById("plan-viewport");
const planImage = document.getElementById("plan-image");
const planWrapper = document.getElementById("plan-viewer-wrapper");
const planEmptyState = document.getElementById("plan-empty-state");
const planDownloadLink = document.getElementById("plan-download-link");
const planActiveName = document.getElementById("plan-active-name");
let scale = 1;
let translateX = 0;
let translateY = 0;
let isDragging = false;
let startX = 0;
let startY = 0;
function updateTransform() {
if (planViewport) {
planViewport.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
}
}
function resetPlanView() {
scale = 1;
translateX = 0;
translateY = 0;
updateTransform();
}
document.getElementById("plan-zoom-in")?.addEventListener("click", function() {
scale = Math.min(scale * 1.3, 6);
updateTransform();
});
document.getElementById("plan-zoom-out")?.addEventListener("click", function() {
scale = Math.max(scale / 1.3, 0.35);
updateTransform();
});
document.getElementById("plan-reset")?.addEventListener("click", resetPlanView);
document.getElementById("plan-fullscreen")?.addEventListener("click", function() {
if (!document.fullscreenElement) {
planWrapper?.requestFullscreen?.();
} else {
document.exitFullscreen?.();
}
});
// Zoom molette
planWrapper?.addEventListener("wheel", function(e) {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.88 : 1.14;
scale = Math.min(Math.max(scale * delta, 0.35), 6);
updateTransform();
}, { passive: false });
// Déplacement souris (pan)
planWrapper?.addEventListener("mousedown", function(e) {
if (e.button !== 0) return;
isDragging = true;
startX = e.clientX - translateX;
startY = e.clientY - translateY;
if (planViewport) planViewport.style.cursor = "grabbing";
});
window.addEventListener("mousemove", function(e) {
if (!isDragging) return;
translateX = e.clientX - startX;
translateY = e.clientY - startY;
updateTransform();
});
window.addEventListener("mouseup", function() {
if (isDragging) {
isDragging = false;
if (planViewport) planViewport.style.cursor = "grab";
}
});
// 4. Navigation entre niveaux / étages
function displayFloor(floor) {
currentFloor = floor;
resetPlanView();
if (planActiveName) planActiveName.textContent = floor.name;
// Mise à jour de l'image du plan
if (floor.has_plan && floor.plan_url) {
if (planImage) {
planImage.src = floor.plan_url;
planImage.style.display = "block";
}
if (planEmptyState) planEmptyState.style.display = "none";
if (planDownloadLink) {
planDownloadLink.href = floor.plan_url;
planDownloadLink.style.display = "inline-block";
}
} else {
if (planImage) planImage.style.display = "none";
if (planEmptyState) planEmptyState.style.display = "block";
if (planDownloadLink) planDownloadLink.style.display = "none";
}
// Mise à jour du bandeau d'information sous le plan
const infoTitle = document.getElementById("info-floor-title");
const infoCode = document.getElementById("info-floor-code");
const infoSurface = document.getElementById("info-floor-surface");
const infoRoomsCount = document.getElementById("info-rooms-count");
const infoAssetsCount = document.getElementById("info-assets-count");
const chipsContainer = document.getElementById("info-floor-rooms-chips");
if (infoTitle) infoTitle.textContent = floor.name;
if (infoCode) infoCode.textContent = `(${floor.code})`;
if (infoSurface) {
if (floor.surface_m2) {
infoSurface.innerHTML = `<i class="bi bi-bounding-box me-1 text-primary"></i>${parseFloat(floor.surface_m2).toFixed(1)} m²`;
infoSurface.style.display = "inline-block";
} else {
infoSurface.style.display = "none";
}
}
if (infoRoomsCount) infoRoomsCount.textContent = floor.rooms_count || 0;
if (infoAssetsCount) infoAssetsCount.textContent = floor.assets_count || 0;
// Locaux de cet étage
if (chipsContainer) {
chipsContainer.innerHTML = "";
if (floor.rooms && floor.rooms.length > 0) {
floor.rooms.forEach(function(room) {
const chip = document.createElement("a");
chip.href = room.url;
chip.className = "badge bg-light text-dark border text-decoration-none py-1 px-2";
chip.title = room.location_type_display || "";
chip.innerHTML = `<i class="bi bi-door-open me-1 text-primary"></i><strong>${room.room_number || room.code}</strong> - ${room.name}`;
chipsContainer.appendChild(chip);
});
} else {
chipsContainer.innerHTML = '<span class="text-muted small fst-italic">{% translate "Aucun sous-local direct répertorié sur ce niveau." %}</span>';
}
}
}
// Écouteurs sur les boutons d'étages
document.querySelectorAll(".floor-nav-btn").forEach(function(btn) {
btn.addEventListener("click", function() {
const floorId = parseInt(btn.getAttribute("data-floor-id"));
const targetFloor = floorsData.find(f => f.id === floorId);
if (!targetFloor) return;
document.querySelectorAll(".floor-nav-btn").forEach(b => {
b.classList.remove("btn-primary", "text-white", "shadow-sm");
b.classList.add("btn-outline-secondary");
});
btn.classList.remove("btn-outline-secondary");
btn.classList.add("btn-primary", "text-white", "shadow-sm");
displayFloor(targetFloor);
});
});
const overviewTab = document.getElementById("overview-tab");
if (overviewTab) {
overviewTab.addEventListener('shown.bs.tab', function () {
overviewTab.addEventListener("shown.bs.tab", function () {
if (map) map.resize();
});
}
// Gestion du chevron plié / déplié dans l'arborescence
document.querySelectorAll('.tree-toggle-btn').forEach(function(btn) {
const targetId = btn.getAttribute('data-bs-target');
document.querySelectorAll(".tree-toggle-btn").forEach(function(btn) {
const targetId = btn.getAttribute("data-bs-target");
const target = document.querySelector(targetId);
if (target) {
target.addEventListener('show.bs.collapse', function (e) {
target.addEventListener("show.bs.collapse", function (e) {
if (e.target !== target) return;
const icon = btn.querySelector('i');
const icon = btn.querySelector("i");
if (icon) {
icon.classList.remove('bi-chevron-right');
icon.classList.add('bi-chevron-down');
icon.classList.remove("bi-chevron-right");
icon.classList.add("bi-chevron-down");
}
});
target.addEventListener('hide.bs.collapse', function (e) {
target.addEventListener("hide.bs.collapse", function (e) {
if (e.target !== target) return;
const icon = btn.querySelector('i');
const icon = btn.querySelector("i");
if (icon) {
icon.classList.remove('bi-chevron-down');
icon.classList.add('bi-chevron-right');
icon.classList.remove("bi-chevron-down");
icon.classList.add("bi-chevron-right");
}
});
}
});
// Gestion du repliage / dépliage global de l'arborescence
const treeCard = document.getElementById("card-hierarchy-tree");
if (treeCard) {
const collapseAllBtn = document.getElementById("btn-tree-collapse-all");
const expandAllBtn = document.getElementById("btn-tree-expand-all");
if (collapseAllBtn) {
collapseAllBtn.addEventListener("click", function() {
// Replier tous les nœuds enfants de l'arborescence (localisations et assets)
treeCard.querySelectorAll(".tree-collapse-node").forEach(function(el) {
if (window.bootstrap && bootstrap.Collapse) {
const bsCollapse = bootstrap.Collapse.getOrCreateInstance(el, { toggle: false });
bsCollapse.hide();
} else {
el.classList.remove("show");
}
});
// Mettre à jour l'état visuel de tous les boutons chevrons
treeCard.querySelectorAll(".tree-toggle-btn").forEach(function(btn) {
if (btn.getAttribute("data-bs-target") !== "#tree-root-content") {
btn.classList.add("collapsed");
btn.setAttribute("aria-expanded", "false");
const icon = btn.querySelector("i");
if (icon) {
icon.classList.remove("bi-chevron-down");
icon.classList.add("bi-chevron-right");
}
}
});
});
}
if (expandAllBtn) {
expandAllBtn.addEventListener("click", function() {
// S'assurer que le conteneur racine est bien déplié
const rootContent = document.getElementById("tree-root-content");
if (rootContent) {
if (window.bootstrap && bootstrap.Collapse) {
bootstrap.Collapse.getOrCreateInstance(rootContent, { toggle: false }).show();
} else {
rootContent.classList.add("show");
}
}
// Déplier tous les nœuds enfants (localisations et assets)
treeCard.querySelectorAll(".tree-collapse-node").forEach(function(el) {
if (window.bootstrap && bootstrap.Collapse) {
const bsCollapse = bootstrap.Collapse.getOrCreateInstance(el, { toggle: false });
bsCollapse.show();
} else {
el.classList.add("show");
}
});
// Mettre à jour l'état visuel de tous les boutons chevrons
treeCard.querySelectorAll(".tree-toggle-btn").forEach(function(btn) {
btn.classList.remove("collapsed");
btn.setAttribute("aria-expanded", "true");
const icon = btn.querySelector("i");
if (icon) {
icon.classList.remove("bi-chevron-right");
icon.classList.add("bi-chevron-down");
}
});
});
}
}
});
</script>
{% endblock script %}

View file

@ -157,6 +157,47 @@
{{ form.usable_surface_m2 }}
{% if form.usable_surface_m2.errors %}<div class="text-danger small mt-1">{{ form.usable_surface_m2.errors.0 }}</div>{% endif %}
</div>
<div class="col-12 mt-3">
<h6 class="border-bottom pb-2 text-primary fw-semibold"><i class="bi bi-door-open me-2"></i>{% translate "Spécificités du local ou du niveau (si applicable)" %}</h6>
</div>
<div class="col-md-3">
<label for="{{ form.room_number.id_for_label }}" class="form-label fw-semibold">{% translate "Numéro de local / porte" %}</label>
{{ form.room_number }}
{% if form.room_number.errors %}<div class="text-danger small mt-1">{{ form.room_number.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-3">
<label for="{{ form.level_number.id_for_label }}" class="form-label fw-semibold">{% translate "Numéro d'étage" %}</label>
{{ form.level_number }}
{% if form.level_number.errors %}<div class="text-danger small mt-1">{{ form.level_number.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-3">
<label for="{{ form.surface_m2.id_for_label }}" class="form-label fw-semibold">{% translate "Surface spécifique (m²)" %}</label>
{{ form.surface_m2 }}
{% if form.surface_m2.errors %}<div class="text-danger small mt-1">{{ form.surface_m2.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-3">
<label for="{{ form.ceiling_height_m.id_for_label }}" class="form-label fw-semibold">{% translate "Hauteur sous plafond (m)" %}</label>
{{ form.ceiling_height_m }}
{% if form.ceiling_height_m.errors %}<div class="text-danger small mt-1">{{ form.ceiling_height_m.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4">
<label for="{{ form.floor_type.id_for_label }}" class="form-label fw-semibold">{% translate "Revêtement de sol" %}</label>
{{ form.floor_type }}
{% if form.floor_type.errors %}<div class="text-danger small mt-1">{{ form.floor_type.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4">
<label for="{{ form.fire_compartment.id_for_label }}" class="form-label fw-semibold">{% translate "Compartiment coupe-feu" %}</label>
{{ form.fire_compartment }}
{% if form.fire_compartment.errors %}<div class="text-danger small mt-1">{{ form.fire_compartment.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4 d-flex align-items-center mt-4">
<div class="form-check">
{{ form.is_heated }}
<label for="{{ form.is_heated.id_for_label }}" class="form-check-label fw-semibold">
{% translate "Local chauffé" %}
</label>
</div>
</div>
</div>
</div>
</div>

View file

@ -38,6 +38,15 @@
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label class="form-label small text-muted">{% translate "Type de localisation" %}</label>
<select name="location_type" class="form-select form-select-sm">
<option value="">-- {% translate "Tous les types" %} --</option>
{% for key, label in location_type_choices %}
<option value="{{ key }}" {% if selected_location_type == key %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label class="form-label small text-muted">{% translate "Statut" %}</label>
<select name="building_status" class="form-select form-select-sm">
@ -47,16 +56,16 @@
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label class="form-label small text-muted">{% translate "Diagnostic DPE" %}</label>
<div class="col-md-1">
<label class="form-label small text-muted">{% translate "DPE" %}</label>
<select name="dpe_rating" class="form-select form-select-sm">
<option value="">-- {% translate "Tous DPE" %} --</option>
<option value="">-- {% translate "Tous" %} --</option>
{% for key, label in dpe_rating_choices %}
<option value="{{ key }}" {% if selected_dpe == key %}selected{% endif %}>DPE {{ label }}</option>
<option value="{{ key }}" {% if selected_dpe == key %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3 d-flex align-items-end gap-2">
<div class="col-md-2 d-flex align-items-end gap-2">
<button type="submit" class="btn btn-sm btn-primary w-100"><i class="bi bi-filter me-1"></i>{% translate "Filtrer" %}</button>
<a href="{% url 'assets:buildings_index' %}" class="btn btn-sm btn-outline-secondary w-100">{% translate "Réinitialiser" %}</a>
</div>
@ -86,8 +95,11 @@
<tr>
<td>
<a href="{% url 'assets:publicbuilding_detail' b.pk %}" class="fw-bold text-decoration-none">
{{ b.code }}
<i class="bi {{ b.get_icon_class }} me-1 text-primary"></i>{{ b.code }}
</a>
{% if b.room_number %}
<span class="badge bg-secondary-subtle text-secondary border small font-monospace"><i class="bi bi-tag me-1"></i>{{ b.room_number }}</span>
{% endif %}
<div class="small text-muted">{{ b.get_name }}</div>
</td>
<td>

View file

@ -15,52 +15,41 @@
<i class="bi bi-chevron-down small"></i>
</button>
{% else %}
<span class="px-2"></span>
<span class="d-inline-block" style="width: 1.35rem;"></span>
{% endif %}
{% if child_node.loc.location_type == 'building' or child_node.loc.location_type == 'site' %}
<i class="bi bi-building text-primary"></i>
{% elif child_node.loc.location_type == 'level' %}
<i class="bi bi-layers text-secondary"></i>
{% elif child_node.loc.location_type == 'room' %}
<i class="bi bi-door-open text-info"></i>
{% else %}
<i class="bi bi-geo-alt text-dark"></i>
{% endif %}
<i class="bi {{ child_node.loc.get_icon_class }} text-primary"></i>
<!-- Code en bleu pour indiquer clairement un lien cliquable -->
<a href="{{ child_node.loc.get_absolute_url }}" class="fw-bold text-primary text-decoration-underline small">
{{ child_node.loc.code }}
</a>
{% if child_node.loc.room_number %}
<span class="badge bg-secondary-subtle text-secondary border small font-monospace"><i class="bi bi-tag me-1"></i>{{ child_node.loc.room_number }}</span>
{% endif %}
<span class="small text-muted">- {{ child_node.loc.get_name }}</span>
<span class="badge bg-primary-subtle text-primary border small">{{ child_node.loc.get_location_type_display }}</span>
{% if child_node.assets %}
<button class="btn btn-sm btn-outline-info py-0 px-2 small text-nowrap"
type="button"
data-bs-toggle="collapse"
data-bs-target="#tree-assets-loc-{{ child_node.loc.id }}"
aria-expanded="false">
<i class="bi bi-tools me-1"></i>Assets ({{ child_node.assets|length }})
</button>
<span class="badge bg-info-subtle text-info border small" title="{% translate 'Assets physiques rattachés' %}">
<i class="bi bi-tools me-1"></i>{{ child_node.assets|length }}
</span>
{% endif %}
</div>
</div>
<!-- Collapsible Assets for this location (NON VISIBLES PAR DÉFAUT) -->
{% if child_node.assets %}
<div id="tree-assets-loc-{{ child_node.loc.id }}" class="collapse mt-2" style="padding-left: calc({{ depth|default:1 }} * 1.25rem);">
<div class="card card-body bg-light border-info-subtle p-2 mb-2 me-3">
<div class="fw-bold small text-info mb-1"><i class="bi bi-tools me-1"></i>{% translate "Assets rattachés à" %} {{ child_node.loc.code }} :</div>
<!-- Collapsible Content (Direct Assets & Child Locations) -->
{% if child_node.children or child_node.assets %}
<div id="tree-loc-{{ child_node.loc.id }}" class="collapse show tree-collapse-node">
<!-- 1. Assets directement rattachés à cette localisation -->
{% if child_node.assets %}
{% include "assets/buildings/publicbuilding_asset_tree.html" with asset_nodes=child_node.assets depth=depth|default:1|add:1 only %}
</div>
</div>
{% endif %}
{% endif %}
<!-- Collapsible Child Locations -->
{% if child_node.children %}
<div id="tree-loc-{{ child_node.loc.id }}" class="collapse show">
{% include "assets/buildings/publicbuilding_location_tree.html" with node=child_node depth=depth|default:1|add:1 only %}
<!-- 2. Sous-localisations -->
{% if child_node.children %}
{% include "assets/buildings/publicbuilding_location_tree.html" with node=child_node depth=depth|default:1|add:1 only %}
{% endif %}
</div>
{% endif %}
</li>

View file

@ -153,7 +153,7 @@
{% endif %}
</ul>
{% if insp.notes %}
{% if insp.notes and not insp.is_giep %}
<div class="mb-3">
<label class="form-label text-muted small fw-semibold"><i class="bi bi-journal-text me-1"></i>{% translate "Remarques / Observations" %}:</label>
<div class="p-2 bg-light rounded border text-secondary small">
@ -162,7 +162,7 @@
</div>
{% endif %}
{% if insp.documents.all %}
{% if insp.documents.all and not insp.is_giep %}
<div>
<label class="form-label text-muted small fw-semibold mb-2"><i class="bi bi-camera me-1"></i>{% translate "Photos & Documents joints" %} ({{ insp.documents.count }}):</label>
<div class="d-flex flex-wrap gap-2 align-items-center">

View file

@ -85,7 +85,9 @@
{% endif %}
</td>
<td class="pe-3">
<small class="text-muted">{{ intervention.creation_time|date:"d/m/Y" }}</small>
<small class="text-muted" title="{% if intervention.end_time %}{% translate 'Date de réalisation' %}{% elif intervention.expected_end_time %}{% translate 'Échéance prévisionnelle' %}{% elif intervention.expected_begin_time %}{% translate 'Date de début prévisionnelle' %}{% else %}{% translate 'Date de création' %}{% endif %}">
{{ intervention.end_time|default:intervention.expected_end_time|default:intervention.expected_begin_time|default:intervention.creation_time|date:"d/m/Y" }}
</small>
</td>
</tr>
{% endfor %}
@ -166,7 +168,7 @@
<div class="mb-3">
<label class="form-label fw-semibold">{% translate "Description / Remarques" %}</label>
<textarea name="init_description" class="form-control" rows="3" placeholder="{% translate 'Détails ou remarques initiales...' %}"></textarea>
<textarea name="init_description" class="form-control" rows="3" placeholder="{% translate 'Détails ou remarques globales...' %}"></textarea>
</div>
</div>
<div class="modal-footer bg-light">

View file

@ -37,7 +37,7 @@
<div id="quickInspectionAlert" class="alert alert-danger d-none mb-3"></div>
<!-- Résultat de contrôle avec boutons colorés -->
<div class="mb-3">
<div class="mb-3" id="quickInspResultSection">
<label class="form-label fw-semibold d-block">{% translate "Résultat du contrôle" %} <span class="text-danger">*</span></label>
<div class="d-flex flex-column gap-2" role="group" aria-label="{% translate 'Résultat' %}">
<div class="row g-2">
@ -83,7 +83,7 @@
</div>
<!-- Section Photos (Mode continu & Fichiers) -->
<div class="card border border-success-subtle mb-3" style="background-color: #e8f6f3;">
<div class="card border border-success-subtle mb-3" style="background-color: #e8f6f3;" id="quickInspPhotosSection">
<div class="card-header bg-transparent border-0 pb-0 pt-2 px-3">
<span class="fw-bold text-dark small"><i class="bi bi-camera me-1 text-teal"></i>{% translate "Photos & Fichiers" %}</span>
</div>
@ -124,7 +124,7 @@
</div>
<!-- Remarques -->
<div class="mb-3">
<div class="mb-3" id="quickInspNotesSection">
<label class="form-label fw-semibold mb-1">{% translate "Remarques / Remarques du constat" %}</label>
<textarea name="notes" id="insp_notes" class="form-control" rows="2" placeholder="{% translate 'Saisir vos remarques ou détails...' %}"></textarea>
</div>

View file

@ -534,6 +534,19 @@
</div>
</td>
</tr>
<!-- Note générale -->
<tr class="asset-editable-field" data-field="note" data-can-edit="{% if 'note' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Note générale" %}</th>
<td>
<div class="field-display">
<span class="field-value text-muted">{{ asset.note|default:"-"|linebreaksbr }}</span>
</div>
<div class="field-edit d-none">
<textarea class="form-control form-control-sm" rows="3">{{ asset.note|default:'' }}</textarea>
</div>
</td>
</tr>
<!-- Champs spécifiques au type d'asset -->
{% get_asset_specific_fields asset as asset_specific_fields %}
{% if asset_specific_fields %}
@ -1839,11 +1852,11 @@
{% block script %}
<!-- MapLibre GL 5.7.0 -->
<script src="{% static 'common/maplibre-gl.js' %}"></script>
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
<script src="{% static 'common/layer-switcher-control.js' %}?v=20261010_order"></script>
<script src="{% static 'assets/asset_cms_edit_mode.js' %}"></script>
<script src="{% static 'assets/asset_location_edit.js' %}"></script>
<!-- MapLibre map initialization for asset detail -->
<script src="{% static 'assets/asset_generic_map.js' %}"></script>
<script src="{% static 'assets/asset_generic_map.js' %}?v=20261010_order"></script>
<!-- MapboxDraw for geometry editing (Point, LineString, Polygon) -->
<script src="{% static 'common/mapbox-gl-draw.js' %}"></script>
<script src="{% static 'assets/asset_geometry_edit.js' %}?v=1.1"></script>
@ -1852,11 +1865,11 @@
{% block extra_scripts %}
<!-- MapLibre GL 5.7.0 -->
<script src="{% static 'common/maplibre-gl.js' %}"></script>
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
<script src="{% static 'common/layer-switcher-control.js' %}?v=20261010_order"></script>
<script src="{% static 'assets/asset_cms_edit_mode.js' %}"></script>
<script src="{% static 'assets/asset_location_edit.js' %}"></script>
<!-- MapLibre map initialization for asset detail -->
<script src="{% static 'assets/asset_generic_map.js' %}"></script>
<script src="{% static 'assets/asset_generic_map.js' %}?v=20261010_order"></script>
<!-- MapboxDraw for geometry editing (Point, LineString, Polygon) -->
<script src="{% static 'common/mapbox-gl-draw.js' %}"></script>
<script src="{% static 'assets/asset_geometry_edit.js' %}?v=1.1"></script>

View file

@ -111,6 +111,11 @@
id: 'assets',
type: 'circle',
source: 'assets',
filter: [
'any',
['==', ['geometry-type'], 'Point'],
['==', ['geometry-type'], 'MultiPoint']
],
paint: {
'circle-radius': [
'interpolate',
@ -135,7 +140,32 @@
minzoom: 8
};
const assets_layers_defs = [assets_layer_def];
const assets_lines_layer_def = {
id: 'assets-lines',
type: 'line',
source: 'assets',
filter: [
'any',
['==', ['geometry-type'], 'LineString'],
['==', ['geometry-type'], 'MultiLineString']
],
paint: {
'line-color': [
'case',
['boolean', ['feature-state', 'highlighted'], false], '#ffeb3b',
'#0022ff'
],
'line-width': [
'case',
['boolean', ['feature-state', 'highlighted'], false], 6,
4
],
'line-opacity': 0.85
},
minzoom: 8
};
const assets_layers_defs = [assets_lines_layer_def, assets_layer_def];
// Filtrer les modèles par catégorie dans le formulaire des assets

View file

@ -0,0 +1,38 @@
{% extends "base.html" %}
{% load static i18n %}
{% block title %}{% translate "Supprimer la zone de gestion" %} {{ zone.code }} | {{ product_name }}{% endblock %}
{% block content %}
<div class="container py-5" style="max-width: 600px;">
<div class="card shadow border-danger">
<div class="card-header bg-danger text-white py-3">
<h5 class="card-title mb-0">
<i class="bi bi-exclamation-triangle-fill me-2"></i>{% translate "Confirmer la suppression" %}
</h5>
</div>
<div class="card-body p-4">
<p>{% translate "Êtes-vous certain de vouloir supprimer définitivement la zone de gestion suivante ?" %}</p>
<div class="alert alert-light border">
<h6 class="fw-bold mb-1">{{ zone.code }} - {{ zone.get_name }}</h6>
<div class="small text-muted">{{ zone.thematic.name }} {% if zone.zone_type %}| {{ zone.zone_type.get_name }}{% endif %}</div>
</div>
<p class="text-danger small">
<i class="bi bi-info-circle me-1"></i>{% translate "Cette action est irréversible. Les documents et associations liés à cette zone seront supprimés." %}
</p>
<form method="post" action="{% url 'assets:management_zone_delete' zone.pk %}">
{% csrf_token %}
<div class="d-flex justify-content-end gap-2 mt-4">
<a href="{% url 'assets:management_zone_detail' zone.pk %}" class="btn btn-outline-secondary">
{% translate "Annuler" %}
</a>
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash me-1"></i>{% translate "Supprimer définitivement" %}
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,434 @@
{% extends "base.html" %}
{% load static i18n %}
{% block title %}{{ zone.code }} - {{ zone.get_name }} | {{ product_name }}{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Breadcrumb & Header -->
<div class="d-flex justify-content-between align-items-center mb-3">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1">
<li class="breadcrumb-item"><a href="/">{% translate "Accueil" %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'assets:index' %}">{% translate "Assets" %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'assets:management_zones_index' %}?thematic={{ zone.thematic.code }}">{% translate "Zones de gestion" %}</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ zone.code }}</li>
</ol>
</nav>
<div class="d-flex gap-2">
<a href="{% url 'assets:management_zones_index' %}?thematic={{ zone.thematic.code }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>{% translate "Retour à la liste" %}
</a>
{% if can_edit %}
<a href="{% url 'assets:management_zone_edit' zone.pk %}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-pencil me-1"></i>{% translate "Modifier" %}
</a>
<button type="button" class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#deleteZoneModal">
<i class="bi bi-trash me-1"></i>{% translate "Supprimer" %}
</button>
{% endif %}
</div>
</div>
<!-- Zone Banner Card -->
<div class="card shadow-sm mb-4 border-start border-4" style="border-left-color: {{ zone.get_color }} !important;">
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-8">
<div class="d-flex align-items-center gap-2 mb-2">
<span style="display:inline-block; width: 16px; height: 16px; border-radius: 4px; background-color: {{ zone.get_color }}; border: 1px solid rgba(0,0,0,0.25);"></span>
<h1 class="h4 mb-0 fw-bold">{{ zone.code }} - {{ zone.get_name }}</h1>
</div>
{% if zone.name_nl and zone.name_nl != zone.name_fr %}
<div class="text-muted small mb-2 fst-italic">{{ zone.name_nl }}</div>
{% endif %}
<div class="d-flex gap-2 flex-wrap">
<span class="badge bg-secondary-subtle text-secondary border">
<i class="bi bi-tag me-1"></i>{{ zone.thematic.name }}
</span>
{% if zone.zone_type %}
<span class="badge" style="background-color: {{ zone.zone_type.default_color }}; color: #fff;">
{{ zone.zone_type.get_name }}
</span>
{% endif %}
{% if zone.is_active %}
<span class="badge bg-success-subtle text-success border">
<i class="bi bi-check-circle me-1"></i>{% translate "Active" %}
</span>
{% else %}
<span class="badge bg-danger-subtle text-danger border">
<i class="bi bi-dash-circle me-1"></i>{% translate "Inactive" %}
</span>
{% endif %}
{% if area_ha %}
<span class="badge bg-light text-dark border">
<i class="bi bi-rulers me-1"></i>{{ area_ha }} ha ({{ area_m2|floatformat:0 }} m²)
</span>
{% endif %}
</div>
</div>
<div class="col-md-4 text-md-end mt-3 mt-md-0">
{% if zone.target_frequency_days %}
<div class="d-inline-block text-center me-3 p-2 bg-light rounded border">
<span class="d-block text-muted small">{% translate "Fréquence cible" %}</span>
<span class="fs-5 fw-bold text-dark">{{ zone.target_frequency_days }} {% translate "j" %}</span>
</div>
{% endif %}
{% if zone.seasonality_notes %}
<div class="d-inline-block text-start p-2 bg-light rounded border">
<span class="d-block text-muted small">{% translate "Période" %}</span>
<span class="small fw-semibold text-dark">{{ zone.seasonality_notes }}</span>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Consignes et Points d'attention (Callout Banner) -->
{% if zone.instructions %}
<div class="card shadow-sm mb-4 border-warning border-start border-4">
<div class="card-body bg-warning bg-opacity-10">
<div class="d-flex align-items-start gap-2">
<i class="bi bi-exclamation-triangle-fill text-warning fs-4 mt-1"></i>
<div>
<h5 class="h6 fw-bold text-dark mb-1">{% translate "Consignes d'exploitation et points de vigilance" %}</h5>
<div class="text-dark" style="white-space: pre-line;">{{ zone.instructions }}</div>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Main Grid: Informations & Carte -->
<div class="row g-4 mb-4">
<!-- Informations & Modalités -->
<div class="col-lg-6">
<div class="card shadow-sm h-100">
<div class="card-header bg-light fw-bold">
<i class="bi bi-info-circle me-2 text-success"></i>{% translate "Détails & Caractéristiques" %}
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4 text-muted">{% translate "Description" %}</dt>
<dd class="col-sm-8">{{ zone.description|default:_("Aucune description renseignée")|linebreaks }}</dd>
<dt class="col-sm-4 text-muted">{% translate "Thématique" %}</dt>
<dd class="col-sm-8">{{ zone.thematic.name }}</dd>
<dt class="col-sm-4 text-muted">{% translate "Type de zone" %}</dt>
<dd class="col-sm-8">{{ zone.zone_type.get_name|default:"-" }}</dd>
<dt class="col-sm-4 text-muted">{% translate "Couleur de repérage" %}</dt>
<dd class="col-sm-8">
<div class="d-flex align-items-center gap-2">
<span style="display:inline-block; width:18px; height:18px; border-radius:3px; background-color: {{ zone.get_color }}; border: 1px solid rgba(0,0,0,0.25);"></span>
<code>{{ zone.get_color }}</code>
</div>
</dd>
<dt class="col-sm-4 text-muted">{% translate "Superficie estimée" %}</dt>
<dd class="col-sm-8">
{% if area_m2 %}
<strong>{{ area_ha }} hectares</strong> ({{ area_m2|floatformat:0 }} m²)
{% else %}
<span class="text-muted">{% translate "Périmètre non défini" %}</span>
{% endif %}
</dd>
<dt class="col-sm-4 text-muted">{% translate "Créé par" %}</dt>
<dd class="col-sm-8">{% if zone.created_by %}{{ zone.created_by.get_full_name|default:zone.created_by.username }}{% else %}-{% endif %}</dd>
<dt class="col-sm-4 text-muted">{% translate "Date de création" %}</dt>
<dd class="col-sm-8">{{ zone.created_at|date:"d/m/Y H:i" }}</dd>
<dt class="col-sm-4 text-muted">{% translate "Dernière mise à jour" %}</dt>
<dd class="col-sm-8">{{ zone.updated_at|date:"d/m/Y H:i" }}</dd>
</dl>
</div>
</div>
</div>
<!-- Carte du périmètre géographique -->
<div class="col-lg-6">
<div class="card shadow-sm h-100">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span class="fw-bold">
<i class="bi bi-map me-2 text-success"></i>{% translate "Périmètre géographique" %}
</span>
{% if zone.geom %}
<span class="badge bg-success">{% translate "Géométrie définie" %}</span>
{% else %}
<span class="badge bg-secondary">{% translate "Aucun périmètre" %}</span>
{% endif %}
</div>
<div class="card-body p-0 position-relative">
<div id="detailZoneMap" style="height: 380px; width: 100%;"></div>
</div>
</div>
</div>
</div>
<!-- Documents & Arrêtés -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span class="fw-bold">
<i class="bi bi-files me-2 text-success"></i>{% translate "Documents & Fiches techniques" %} ({{ documents.count }})
</span>
{% if can_edit %}
<button type="button" class="btn btn-sm btn-outline-success" data-bs-toggle="modal" data-bs-target="#uploadDocModal">
<i class="bi bi-upload me-1"></i>{% translate "Ajouter des documents" %}
</button>
{% endif %}
</div>
<div class="card-body p-0">
{% if documents %}
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light small text-uppercase text-muted">
<tr>
<th>{% translate "Fichier" %}</th>
<th>{% translate "Date d'ajout" %}</th>
<th>{% translate "Ajouté par" %}</th>
<th class="text-end" style="width: 140px;">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for doc in documents %}
<tr>
<td>
<div class="d-flex align-items-center gap-2">
<i class="bi bi-file-earmark-text text-primary fs-5"></i>
<div>
<a href="{{ doc.file.url }}" target="_blank" class="fw-semibold text-decoration-none">
{{ doc.file.name|slice:"25:"|default:doc.file.name }}
</a>
</div>
</div>
</td>
<td>{{ doc.upload_date|date:"d/m/Y H:i" }}</td>
<td>{% if doc.uploaded_by %}{{ doc.uploaded_by.get_full_name|default:doc.uploaded_by.username }}{% else %}-{% endif %}</td>
<td class="text-end">
<a href="{{ doc.file.url }}" target="_blank" class="btn btn-sm btn-outline-secondary" title="{% translate 'Télécharger' %}">
<i class="bi bi-download"></i>
</a>
{% if can_edit %}
<form method="post" action="{% url 'assets:management_zone_delete_document' zone.pk doc.pk %}" class="d-inline" onsubmit="return confirm('{% translate 'Supprimer ce document ?' %}');">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-danger" title="{% translate 'Supprimer' %}">
<i class="bi bi-trash"></i>
</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-4 text-center text-muted small">
<i class="bi bi-folder2-open fs-2 d-block mb-2 text-muted"></i>
{% translate "Aucun document n'a été rattaché à cette zone de gestion." %}
</div>
{% endif %}
</div>
</div>
<!-- Équipements rattachés (Assets explicites) -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light">
<span class="fw-bold">
<i class="bi bi-layers me-2 text-success"></i>{% translate "Équipements rattachés explicitement" %} ({{ explicit_assets.count }})
</span>
</div>
<div class="card-body p-0">
{% if explicit_assets %}
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light small text-uppercase text-muted">
<tr>
<th>{% translate "Équipement" %}</th>
<th>{% translate "Type" %}</th>
<th>{% translate "Statut de rattachement" %}</th>
<th>{% translate "Notes" %}</th>
</tr>
</thead>
<tbody>
{% for ea in explicit_assets %}
<tr>
<td>
<strong>{{ ea.asset|default:ea.object_id }}</strong>
</td>
<td>{{ ea.content_type.name }}</td>
<td>
{% if ea.is_excluded %}
<span class="badge bg-danger-subtle text-danger border">
<i class="bi bi-slash-circle me-1"></i>{% translate "Exclu de la zone" %}
</span>
{% else %}
<span class="badge bg-success-subtle text-success border">
<i class="bi bi-check-circle me-1"></i>{% translate "Inclus dans la zone" %}
</span>
{% endif %}
</td>
<td class="text-muted small">{{ ea.notes|default:"-" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-4 text-center text-muted small">
<p class="mb-0">{% translate "Tous les équipements situés dans le périmètre géographique de la zone y sont automatiquement associés par intersection spatiale." %}</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Modal: Upload Documents -->
{% if can_edit and doc_form %}
<div class="modal fade" id="uploadDocModal" tabindex="-1" aria-labelledby="uploadDocModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="{% url 'assets:management_zone_add_document' zone.pk %}" enctype="multipart/form-data">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title" id="uploadDocModalLabel">
<i class="bi bi-upload me-2 text-success"></i>{% translate "Ajouter des documents" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="{{ doc_form.documents.id_for_label }}" class="form-label fw-semibold">
{% translate "Sélectionner des fichiers" %}
</label>
{{ doc_form.documents }}
<div class="form-text small">
{% translate "Formats acceptés : PDF, Word, Excel, images, etc. Vous pouvez sélectionner plusieurs fichiers simultanément." %}
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="submit" class="btn btn-success">
<i class="bi bi-check-lg me-1"></i>{% translate "Téléverser" %}
</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
<!-- Modal: Delete Zone Confirmation -->
{% if can_edit %}
<div class="modal fade" id="deleteZoneModal" tabindex="-1" aria-labelledby="deleteZoneModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="{% url 'assets:management_zone_delete' zone.pk %}">
{% csrf_token %}
<div class="modal-header bg-danger text-white">
<h5 class="modal-title" id="deleteZoneModalLabel">
<i class="bi bi-exclamation-triangle-fill me-2"></i>{% translate "Confirmer la suppression" %}
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>{% translate "Êtes-vous certain de vouloir supprimer la zone de gestion suivante ?" %}</p>
<div class="alert alert-light border">
<strong>{{ zone.code }}</strong> - {{ zone.get_name }}<br>
<small class="text-muted">{{ zone.thematic.name }} | {{ zone.zone_type.get_name|default:"-" }}</small>
</div>
<p class="text-danger small mb-0">
<i class="bi bi-info-circle me-1"></i>{% translate "Cette action est irréversible. Les documents et historiques liés seront supprimés." %}
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash me-1"></i>{% translate "Supprimer définitivement" %}
</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% endblock content %}
{% block script %}
<script>
document.addEventListener("DOMContentLoaded", function () {
const mapDiv = document.getElementById("detailZoneMap");
if (!mapDiv || typeof maplibregl === "undefined") return;
const zoneColor = "{{ zone.get_color }}";
const geojsonGeometry = {% if zone.geojson_geometry %}{{ zone.geojson_geometry|safe }}{% else %}null{% endif %};
const map = new maplibregl.Map({
container: mapDiv,
style: window.MAP_STYLE_LIGHT,
center: window.MAP_DEFAULT_CENTER || [4.3572, 50.8477],
zoom: 12
});
map.addControl(new maplibregl.NavigationControl(), 'top-right');
map.on('load', function () {
if (!geojsonGeometry) {
return;
}
const feature = {
type: "Feature",
geometry: geojsonGeometry
};
map.addSource("detail-zone-source", {
type: "geojson",
data: feature
});
map.addLayer({
id: "detail-zone-fill",
type: "fill",
source: "detail-zone-source",
paint: {
"fill-color": zoneColor,
"fill-opacity": 0.30
}
});
map.addLayer({
id: "detail-zone-line",
type: "line",
source: "detail-zone-source",
paint: {
"line-color": zoneColor,
"line-width": 3,
"line-dasharray": [3, 1.5]
}
});
// Fit bounds to polygon
const bounds = new maplibregl.LngLatBounds();
const coords = geojsonGeometry.coordinates;
const addCoords = (arr) => {
if (typeof arr[0] === 'number') {
bounds.extend(arr);
} else {
arr.forEach(addCoords);
}
};
addCoords(coords);
if (!bounds.isEmpty()) {
map.fitBounds(bounds, { padding: 45, maxZoom: 16 });
}
});
});
</script>
{% endblock script %}

View file

@ -0,0 +1,568 @@
{% extends "base.html" %}
{% load static i18n %}
{% block title %}{{ title }} | {{ product_name }}{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Breadcrumb & Header -->
<div class="d-flex justify-content-between align-items-center mb-3">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1">
<li class="breadcrumb-item"><a href="/">{% translate "Accueil" %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'assets:index' %}">{% translate "Assets" %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'assets:management_zones_index' %}">{% translate "Zones de gestion" %}</a></li>
{% if not is_create and zone %}
<li class="breadcrumb-item"><a href="{% url 'assets:management_zone_detail' zone.pk %}">{{ zone.code }}</a></li>
<li class="breadcrumb-item active" aria-current="page">{% translate "Modifier" %}</li>
{% else %}
<li class="breadcrumb-item active" aria-current="page">{% translate "Nouvelle zone" %}</li>
{% endif %}
</ol>
</nav>
<div>
{% if not is_create and zone %}
<a href="{% url 'assets:management_zone_detail' zone.pk %}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>{% translate "Annuler" %}
</a>
{% else %}
<a href="{% url 'assets:management_zones_index' %}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>{% translate "Annuler" %}
</a>
{% endif %}
</div>
</div>
<!-- Title -->
<div class="mb-4">
<h1 class="h3 mb-1">
{% if is_create %}
<i class="bi bi-plus-circle-fill text-success me-2"></i>{% translate "Créer une zone de gestion" %}
{% else %}
<i class="bi bi-pencil-square text-success me-2"></i>{% translate "Modifier la zone de gestion" %} {{ zone.code }}
{% endif %}
</h1>
<p class="text-muted mb-0">
{% translate "Définissez les paramètres d'entretien, les consignes et le périmètre géographique de la zone." %}
</p>
</div>
{% if form.errors %}
<div class="alert alert-danger alert-dismissible fade show mb-4" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<strong>{% translate "Certains champs contiennent des erreurs." %}</strong>
{% translate "Veuillez vérifier les informations ci-dessous." %}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
<form method="post" action="" class="needs-validation" id="managementZoneForm" novalidate>
{% csrf_token %}
{{ form.geom_geojson }}
<div class="row g-4">
<!-- Left Column: Form Fields -->
<div class="col-lg-6">
<!-- Informations générales -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light fw-bold">
<i class="bi bi-info-circle me-2 text-success"></i>{% translate "Informations générales" %}
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-5">
<label for="{{ form.code.id_for_label }}" class="form-label fw-semibold">
{% translate "Code de la zone" %} <span class="text-danger">*</span>
</label>
{{ form.code }}
{% if form.code.errors %}<div class="text-danger small mt-1">{{ form.code.errors.0 }}</div>{% endif %}
<div class="form-text small">{% translate "Identifiant unique (ex: ZG-NAT-001)" %}</div>
</div>
<div class="col-md-7">
<label for="{{ form.thematic.id_for_label }}" class="form-label fw-semibold">
{% translate "Thématique" %} <span class="text-danger">*</span>
</label>
{{ form.thematic }}
{% if form.thematic.errors %}<div class="text-danger small mt-1">{{ form.thematic.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-6">
<label for="{{ form.name_fr.id_for_label }}" class="form-label fw-semibold">
{% translate "Nom (FR)" %} <span class="text-danger">*</span>
</label>
{{ form.name_fr }}
{% if form.name_fr.errors %}<div class="text-danger small mt-1">{{ form.name_fr.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-6">
<label for="{{ form.name_nl.id_for_label }}" class="form-label fw-semibold">
{% translate "Nom (NL)" %}
</label>
{{ form.name_nl }}
{% if form.name_nl.errors %}<div class="text-danger small mt-1">{{ form.name_nl.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-8">
<label for="{{ form.zone_type.id_for_label }}" class="form-label fw-semibold">
{% translate "Type de zone de gestion" %}
</label>
{{ form.zone_type }}
{% if form.zone_type.errors %}<div class="text-danger small mt-1">{{ form.zone_type.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4">
<label for="{{ form.color.id_for_label }}" class="form-label fw-semibold">
{% translate "Couleur" %}
</label>
<div class="d-flex align-items-center gap-2">
{{ form.color }}
<span class="small text-muted" id="colorHexLabel"></span>
</div>
{% if form.color.errors %}<div class="text-danger small mt-1">{{ form.color.errors.0 }}</div>{% endif %}
</div>
<div class="col-12">
<div class="form-check form-switch mt-2">
{{ form.is_active }}
<label class="form-check-label fw-semibold ms-2" for="{{ form.is_active.id_for_label }}">
{% translate "Zone active (affichée sur les cartes d'exploitation)" %}
</label>
</div>
</div>
</div>
</div>
</div>
<!-- Entretien, Calendrier & Consignes -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light fw-bold">
<i class="bi bi-calendar-check me-2 text-success"></i>{% translate "Modalités d'entretien & Consignes" %}
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-5">
<label for="{{ form.target_frequency_days.id_for_label }}" class="form-label fw-semibold">
{% translate "Fréquence cible (jours)" %}
</label>
<div class="input-group">
{{ form.target_frequency_days }}
<span class="input-group-text">{% translate "jours" %}</span>
</div>
<div class="form-text small">{% translate "Intervalle indicatif entre passages" %}</div>
{% if form.target_frequency_days.errors %}<div class="text-danger small mt-1">{{ form.target_frequency_days.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-7">
<label for="{{ form.seasonality_notes.id_for_label }}" class="form-label fw-semibold">
{% translate "Saisonnalité / Calendrier" %}
</label>
{{ form.seasonality_notes }}
<div class="form-text small">{% translate "Ex: Fauche en sept; interdiction taille mars-juillet" %}</div>
{% if form.seasonality_notes.errors %}<div class="text-danger small mt-1">{{ form.seasonality_notes.errors.0 }}</div>{% endif %}
</div>
<div class="col-12">
<label for="{{ form.instructions.id_for_label }}" class="form-label fw-semibold text-warning-emphasis">
<i class="bi bi-exclamation-triangle me-1 text-warning"></i>{% translate "Consignes et points d'attention" %}
</label>
{{ form.instructions }}
<div class="form-text small">{% translate "Consignes cruciales : sécurité tramway, respect des zones humides, hauteur de coupe, etc." %}</div>
{% if form.instructions.errors %}<div class="text-danger small mt-1">{{ form.instructions.errors.0 }}</div>{% endif %}
</div>
<div class="col-12">
<label for="{{ form.description.id_for_label }}" class="form-label fw-semibold">
{% translate "Description générale" %}
</label>
{{ form.description }}
{% if form.description.errors %}<div class="text-danger small mt-1">{{ form.description.errors.0 }}</div>{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Right Column: Interactive Map Polygon Drawer -->
<div class="col-lg-6">
<div class="card shadow-sm mb-4 sticky-top" style="top: 20px;">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<div>
<i class="bi bi-bounding-box-circles me-2 text-success"></i>
<span class="fw-bold">{% translate "Périmètre géographique" %}</span>
<small class="text-muted fw-normal">({% translate "polygone" %})</small>
</div>
<div class="d-flex gap-1">
<button type="button" id="polyPickerDrawBtn" class="btn btn-sm btn-outline-success">
<i class="bi bi-pentagon me-1"></i>{% translate "Dessiner" %}
</button>
<button type="button" id="polyPickerFinishBtn" class="btn btn-sm btn-success d-none">
<i class="bi bi-check-circle me-1"></i>{% translate "Terminer le polygone" %}
</button>
<button type="button" id="polyPickerClearBtn" class="btn btn-sm btn-outline-danger" title="{% translate 'Effacer le polygone' %}">
<i class="bi bi-x-circle"></i>
</button>
</div>
</div>
<div class="card-body p-0 position-relative">
<div id="zonePickerMap" style="height: 480px; width: 100%;"></div>
</div>
<div class="card-footer bg-white border-top small text-muted d-flex justify-content-between align-items-center">
<span id="polyPickerHint">
{% translate "Cliquez sur « Dessiner » pour tracer les limites de la zone sur la carte." %}
</span>
<span id="polyStatusBadge" class="badge bg-secondary">
{% translate "Aucun tracé" %}
</span>
</div>
</div>
<!-- Form Submit Actions -->
<div class="d-flex justify-content-end gap-2 mb-4">
{% if not is_create and zone %}
<a href="{% url 'assets:management_zone_detail' zone.pk %}" class="btn btn-outline-secondary">
{% translate "Annuler" %}
</a>
{% else %}
<a href="{% url 'assets:management_zones_index' %}" class="btn btn-outline-secondary">
{% translate "Annuler" %}
</a>
{% endif %}
<button type="submit" class="btn btn-success px-4">
<i class="bi bi-check-lg me-1"></i>
{% if is_create %}{% translate "Créer la zone de gestion" %}{% else %}{% translate "Enregistrer les modifications" %}{% endif %}
</button>
</div>
</div>
</div>
</form>
</div>
{% endblock content %}
{% block script %}
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
<script>
document.addEventListener("DOMContentLoaded", function () {
const mapDiv = document.getElementById("zonePickerMap");
const geomInput = document.getElementById("id_geom_geojson");
const thematicSelect = document.getElementById("id_thematic");
const zoneTypeSelect = document.getElementById("id_zone_type");
const colorInput = document.getElementById("id_color");
const colorHexLabel = document.getElementById("colorHexLabel");
if (colorInput && colorHexLabel) {
colorHexLabel.textContent = colorInput.value;
colorInput.addEventListener("input", () => {
colorHexLabel.textContent = colorInput.value;
if (map && map.getLayer("poly-preview-fill")) {
map.setPaintProperty("poly-preview-fill", "fill-color", colorInput.value);
map.setPaintProperty("poly-preview-line", "line-color", colorInput.value);
}
});
}
// Dynamic zone types when thematic changes
if (thematicSelect && zoneTypeSelect) {
thematicSelect.addEventListener("change", async function () {
const thematicId = thematicSelect.value;
if (!thematicId) return;
try {
const resp = await fetch("{% url 'assets:management_zone_types_ajax' %}?thematic_id=" + encodeURIComponent(thematicId));
if (resp.ok) {
const data = await resp.json();
const currentVal = zoneTypeSelect.value;
zoneTypeSelect.innerHTML = '<option value="">-- {% translate "Sélectionner un type" %} --</option>';
(data.types || []).forEach(t => {
const opt = document.createElement("option");
opt.value = t.id;
opt.textContent = t.name;
if (String(t.id) === String(currentVal)) opt.selected = true;
zoneTypeSelect.appendChild(opt);
});
}
} catch (e) {
console.error("Error updating zone types:", e);
}
});
}
// Interactive Map Polygon Drawer
if (!mapDiv || typeof maplibregl === "undefined") return;
let drawing = false;
let vertices = [];
let vertexMarkers = [];
let map = null;
const drawBtn = document.getElementById("polyPickerDrawBtn");
const finishBtn = document.getElementById("polyPickerFinishBtn");
const clearBtn = document.getElementById("polyPickerClearBtn");
const hintEl = document.getElementById("polyPickerHint");
const badgeEl = document.getElementById("polyStatusBadge");
map = new maplibregl.Map({
container: mapDiv,
style: window.MAP_STYLE_LIGHT,
center: window.MAP_DEFAULT_CENTER || [4.3572, 50.8477],
zoom: 12
});
// 1. Fond de plan & Orthophotos (LayerSwitcherControl)
if (typeof LayerSwitcherControl !== 'undefined') {
const layerSwitcher = new LayerSwitcherControl({
layers: [{
id: 'basemap',
name: "{% translate 'Fonds de plan & Orthophotos' %}",
icon: 'satellite',
group: 'basemap',
beforeLayer: function (mapInstance) {
if (mapInstance && mapInstance.getLayer('poly-preview-fill')) {
return 'poly-preview-fill';
}
return undefined;
},
layer: {
type: 'raster',
paint: { 'raster-opacity': 1 }
},
basemaps: (window.MAP_BACKGROUNDS && window.MAP_BACKGROUNDS.length > 0) ? window.MAP_BACKGROUNDS : undefined,
sublayers: [
{
id: 'urbis',
name: window.MAP_ORTHO_WMS_NAME || 'Urbis (Bruxelles)',
default: true,
source: {
type: 'raster',
tiles: [
window.MAP_ORTHO_WMS_URL || 'https://geoservices-urbis.irisnet.be/geoserver/urbisgrid/wms?service=WMS&version=1.3.0&request=GetMap&layers=Ortho&styles=&format=image/png&transparent=true&width=256&height=256&crs=EPSG:3857&bbox={bbox-epsg-3857}'
],
tileSize: 256,
attribution: window.MAP_ORTHO_WMS_ATTRIBUTION || '© Brussels UrbIS'
}
},
{
id: 'vlaanderen',
name: 'Vlaanderen 2023',
source: {
type: 'raster',
tiles: [
'https://geo.api.vlaanderen.be/OMW/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=omwrgb23vl&STYLE=&FORMAT=image/png&TILEMATRIXSET=GoogleMapsVL&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}'
],
tileSize: 256,
attribution: '© Digitaal Vlaanderen - Orthofoto 2023'
}
}
]
}]
});
map.addControl(layerSwitcher, 'top-right');
}
// 2. Navigation Control (Zoom +/- & Pitch/Bearing)
map.addControl(new maplibregl.NavigationControl({ visualizePitch: true }), 'top-right');
// 3. Outil de recherche d'adresse
if (typeof AddressSearchControl !== 'undefined') {
const searchCtrl = new AddressSearchControl({
autocompleteUrl: "{% url 'common:autocomplete_street' %}",
placeholder: "{% translate 'Rechercher une adresse…' %}"
});
map.addControl(searchCtrl, 'top-left');
}
function getColor() {
return colorInput ? colorInput.value : "#198754";
}
function initPolyLayers() {
if (!map.getSource("poly-preview")) {
map.addSource("poly-preview", {
type: "geojson",
data: { type: "Feature", geometry: { type: "Polygon", coordinates: [] } }
});
map.addLayer({
id: "poly-preview-fill",
type: "fill",
source: "poly-preview",
paint: {
"fill-color": getColor(),
"fill-opacity": 0.25
}
});
map.addLayer({
id: "poly-preview-line",
type: "line",
source: "poly-preview",
paint: {
"line-color": getColor(),
"line-width": 2.5
}
});
}
}
function clearMarkers() {
vertexMarkers.forEach(m => m.remove());
vertexMarkers = [];
}
function addVertexMarker(lng, lat, index) {
const el = document.createElement("div");
el.style.width = "10px";
el.style.height = "10px";
el.style.borderRadius = "50%";
el.style.backgroundColor = index === 0 ? "#dc3545" : "#198754";
el.style.border = "2px solid #ffffff";
el.style.boxShadow = "0 0 3px rgba(0,0,0,0.5)";
el.style.cursor = index === 0 && drawing && vertices.length >= 3 ? "pointer" : "default";
if (index === 0) {
el.title = "{% translate 'Cliquez pour clore le polygone' %}";
el.addEventListener("click", (e) => {
e.stopPropagation();
if (drawing && vertices.length >= 3) {
finishPolygon();
}
});
}
const marker = new maplibregl.Marker({ element: el }).setLngLat([lng, lat]).addTo(map);
vertexMarkers.push(marker);
}
function renderPoly(coords) {
initPolyLayers();
let geom;
if (coords.length >= 3) {
geom = {
type: "Polygon",
coordinates: [coords.concat([coords[0]])]
};
} else if (coords.length === 2) {
geom = {
type: "LineString",
coordinates: coords
};
} else {
geom = { type: "Point", coordinates: coords[0] || [0, 0] };
}
map.getSource("poly-preview").setData({ type: "Feature", geometry: geom });
}
function finishPolygon() {
if (vertices.length < 3) {
alert("{% translate 'Un polygone doit comporter au moins 3 sommets.' %}");
return;
}
drawing = false;
const closed = vertices.concat([vertices[0]]);
const polyGeojson = {
type: "Polygon",
coordinates: [closed]
};
if (geomInput) {
geomInput.value = JSON.stringify(polyGeojson);
}
renderPoly(vertices);
updateUI(false);
hintEl.textContent = "{% translate 'Polygone enregistré. Cliquez sur « Dessiner » pour le refaire.' %}";
badgeEl.className = "badge bg-success";
badgeEl.textContent = `{% translate 'Polygone défini' %} (${vertices.length} {% translate 'sommets' %})`;
}
function updateUI(isDrawing) {
drawBtn.classList.toggle("d-none", isDrawing);
finishBtn.classList.toggle("d-none", !isDrawing);
map.getCanvas().style.cursor = isDrawing ? "crosshair" : "default";
}
function showExistingPolygon(geojsonStr) {
if (!geojsonStr) return;
try {
const geom = JSON.parse(geojsonStr);
let coords = null;
if (geom.type === "Polygon") {
coords = geom.coordinates[0];
} else if (geom.type === "MultiPolygon") {
coords = geom.coordinates[0][0];
}
if (!coords || coords.length === 0) return;
// Strip closing duplicate vertex for marker tracking
const rawVertices = (coords[0][0] === coords[coords.length - 1][0] && coords[0][1] === coords[coords.length - 1][1])
? coords.slice(0, coords.length - 1)
: coords;
vertices = rawVertices;
clearMarkers();
vertices.forEach((c, idx) => addVertexMarker(c[0], c[1], idx));
renderPoly(vertices);
// Fit bounds
const bounds = new maplibregl.LngLatBounds();
coords.forEach(c => bounds.extend(c));
map.fitBounds(bounds, { padding: 50, maxZoom: 16 });
badgeEl.className = "badge bg-success";
badgeEl.textContent = `{% translate 'Polygone existant' %} (${vertices.length} {% translate 'sommets' %})`;
hintEl.textContent = "{% translate 'Polygone actuel affiché. Cliquez sur « Dessiner » pour le modifier.' %}";
} catch (e) {
console.warn("Could not parse existing geometry:", e);
}
}
map.on("load", function () {
initPolyLayers();
if (geomInput && geomInput.value) {
showExistingPolygon(geomInput.value);
}
});
map.on("click", function (e) {
if (!drawing) return;
const { lng, lat } = e.lngLat;
vertices.push([lng, lat]);
addVertexMarker(lng, lat, vertices.length - 1);
renderPoly(vertices);
hintEl.textContent = `{% translate 'Sommets posés' %} : ${vertices.length}. {% translate 'Cliquez sur le premier sommet ou sur « Terminer » pour clore.' %}`;
badgeEl.className = "badge bg-warning text-dark";
badgeEl.textContent = `{% translate 'Tracé en cours' %} (${vertices.length})`;
});
drawBtn.addEventListener("click", function (e) {
e.preventDefault();
drawing = true;
vertices = [];
clearMarkers();
if (geomInput) geomInput.value = "";
initPolyLayers();
map.getSource("poly-preview").setData({ type: "Feature", geometry: { type: "Polygon", coordinates: [] } });
updateUI(true);
hintEl.textContent = "{% translate 'Cliquez sur la carte pour poser chaque sommet.' %}";
badgeEl.className = "badge bg-warning text-dark";
badgeEl.textContent = "{% translate 'Tracé en cours' %}";
});
finishBtn.addEventListener("click", function (e) {
e.preventDefault();
finishPolygon();
});
clearBtn.addEventListener("click", function (e) {
e.preventDefault();
drawing = false;
vertices = [];
clearMarkers();
if (geomInput) geomInput.value = "";
initPolyLayers();
map.getSource("poly-preview").setData({ type: "Feature", geometry: { type: "Polygon", coordinates: [] } });
updateUI(false);
hintEl.textContent = "{% translate 'Périmètre effacé. Cliquez sur « Dessiner » pour recommencer.' %}";
badgeEl.className = "badge bg-secondary";
badgeEl.textContent = "{% translate 'Aucun tracé' %}";
});
});
</script>
{% endblock script %}

View file

@ -0,0 +1,340 @@
{% extends "base.html" %}
{% load static i18n %}
{% block title %}{% translate "Zones de gestion" %} | {{ product_name }}{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Breadcrumb & Header -->
<div class="d-flex justify-content-between align-items-center mb-3">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1">
<li class="breadcrumb-item"><a href="/">{% translate "Accueil" %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'assets:index' %}">{% translate "Assets" %}</a></li>
<li class="breadcrumb-item active" aria-current="page">{% translate "Zones de gestion" %}</li>
</ol>
</nav>
{% if can_create %}
<a href="{% url 'assets:management_zone_create' %}{% if selected_thematic_code %}?thematic={{ selected_thematic_code }}{% endif %}" class="btn btn-success">
<i class="bi bi-plus-lg me-1"></i>{% translate "Nouvelle zone de gestion" %}
</a>
{% endif %}
</div>
<!-- Title Bar -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-1">
<i class="bi bi-diagram-3-fill text-success me-2"></i>{% translate "Zones de gestion" %}
</h1>
<p class="text-muted mb-0">
{% translate "Regroupements géographiques et fonctionnels d'équipements pour l'entretien récurrent, les plans de fauche et l'exploitation." %}
</p>
</div>
<div class="text-end">
<span class="badge bg-light text-dark border fs-6 px-3 py-2">
<strong>{{ total_count }}</strong> {% translate "zone(s) répertoriée(s)" %}
</span>
</div>
</div>
<!-- Filter Card -->
<div class="card shadow-sm mb-4">
<div class="card-body">
<form method="get" class="row g-3 align-items-end">
<!-- Search text -->
<div class="col-md-3">
<label class="form-label small text-muted">{% translate "Recherche" %}</label>
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" name="q" class="form-control" placeholder="{% translate 'Code, nom, consignes...' %}" value="{{ search_query }}">
</div>
</div>
<!-- Thematic -->
<div class="col-md-3">
<label class="form-label small text-muted">{% translate "Thématique" %}</label>
<select name="thematic" id="filterThematic" class="form-select form-select-sm">
<option value="">-- {% translate "Toutes les thématiques" %} --</option>
{% for th in accessible_thematics %}
<option value="{{ th.code }}" {% if selected_thematic_code == th.code %}selected{% endif %}>
{{ th.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Zone Type -->
<div class="col-md-3">
<label class="form-label small text-muted">{% translate "Type de zone" %}</label>
<select name="zone_type" id="filterZoneType" class="form-select form-select-sm">
<option value="">-- {% translate "Tous les types" %} --</option>
{% for zt in available_types %}
<option value="{{ zt.id }}" {% if selected_type_id == zt.id|stringformat:"s" %}selected{% endif %}>
{{ zt.get_name }} ({{ zt.thematic.name }})
</option>
{% endfor %}
</select>
</div>
<!-- Status -->
<div class="col-md-2">
<label class="form-label small text-muted">{% translate "Statut" %}</label>
<select name="status" class="form-select form-select-sm">
<option value="">-- {% translate "Tous les statuts" %} --</option>
<option value="active" {% if selected_status == 'active' %}selected{% endif %}>{% translate "Actives uniquement" %}</option>
<option value="inactive" {% if selected_status == 'inactive' %}selected{% endif %}>{% translate "Inactives" %}</option>
</select>
</div>
<!-- Submit / Reset buttons -->
<div class="col-md-1 d-flex gap-1">
<button type="submit" class="btn btn-sm btn-primary w-100" title="{% translate 'Filtrer' %}">
<i class="bi bi-funnel"></i>
</button>
<a href="{% url 'assets:management_zones_index' %}" class="btn btn-sm btn-outline-secondary" title="{% translate 'Réinitialiser' %}">
<i class="bi bi-x-lg"></i>
</a>
</div>
</form>
</div>
</div>
<!-- Map Overview Card -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center py-2">
<span class="fw-semibold small">
<i class="bi bi-map me-1 text-success"></i>{% translate "Aperçu cartographique des périmètres" %}
</span>
<small class="text-muted" id="mapLoadingStatus">{% translate "Chargement de la carte..." %}</small>
</div>
<div class="card-body p-0">
<div id="zonesOverviewMap" style="height: 320px; width: 100%;"></div>
</div>
</div>
<!-- Zones Table Card -->
<div class="card shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light small text-uppercase text-muted">
<tr>
<th style="width: 140px;">{% translate "Code" %}</th>
<th>{% translate "Nom de la zone" %}</th>
<th>{% translate "Thématique" %}</th>
<th>{% translate "Type de zone" %}</th>
<th>{% translate "Fréquence cible" %}</th>
<th>{% translate "Saisonnalité" %}</th>
<th>{% translate "Statut" %}</th>
<th class="text-end" style="width: 160px;">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for item in zones_list %}
{% with zone=item.zone can_edit=item.can_edit %}
<tr>
<td>
<div class="d-flex align-items-center gap-2">
<span style="display:inline-block; width: 14px; height: 14px; border-radius: 3px; background-color: {{ zone.get_color }}; border: 1px solid rgba(0,0,0,0.2); flex-shrink: 0;"></span>
<a href="{% url 'assets:management_zone_detail' zone.pk %}" class="fw-bold text-decoration-none">
{{ zone.code }}
</a>
</div>
</td>
<td>
<div class="fw-semibold">{{ zone.get_name }}</div>
{% if zone.instructions %}
<div class="text-muted small text-truncate" style="max-width: 320px;" title="{{ zone.instructions }}">
<i class="bi bi-exclamation-triangle text-warning me-1"></i>{{ zone.instructions }}
</div>
{% endif %}
</td>
<td>
<span class="badge bg-secondary-subtle text-secondary border">
{{ zone.thematic.name }}
</span>
</td>
<td>
{% if zone.zone_type %}
<span class="badge" style="background-color: {{ zone.zone_type.default_color }}; color: #fff;">
{{ zone.zone_type.get_name }}
</span>
{% else %}
<span class="text-muted small">-</span>
{% endif %}
</td>
<td>
{% if zone.target_frequency_days %}
<span class="badge bg-info-subtle text-info border">
<i class="bi bi-arrow-repeat me-1"></i>{{ zone.target_frequency_days }} {% translate "jours" %}
</span>
{% else %}
<span class="text-muted small">-</span>
{% endif %}
</td>
<td>
{% if zone.seasonality_notes %}
<span class="small text-muted"><i class="bi bi-calendar3 me-1"></i>{{ zone.seasonality_notes }}</span>
{% else %}
<span class="text-muted small">-</span>
{% endif %}
</td>
<td>
{% if zone.is_active %}
<span class="badge bg-success-subtle text-success border">{% translate "Active" %}</span>
{% else %}
<span class="badge bg-danger-subtle text-danger border">{% translate "Inactive" %}</span>
{% endif %}
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<a href="{% url 'assets:management_zone_detail' zone.pk %}" class="btn btn-outline-primary" title="{% translate 'Détails' %}">
<i class="bi bi-eye"></i>
</a>
{% if can_edit %}
<a href="{% url 'assets:management_zone_edit' zone.pk %}" class="btn btn-outline-secondary" title="{% translate 'Modifier' %}">
<i class="bi bi-pencil"></i>
</a>
<a href="{% url 'assets:management_zone_delete' zone.pk %}" class="btn btn-outline-danger" title="{% translate 'Supprimer' %}">
<i class="bi bi-trash"></i>
</a>
{% endif %}
</div>
</td>
</tr>
{% endwith %}
{% empty %}
<tr>
<td colspan="8" class="text-center py-5 text-muted">
<i class="bi bi-diagram-3 fs-1 d-block mb-2 text-muted"></i>
<p class="mb-2">{% translate "Aucune zone de gestion ne correspond aux critères sélectionnés." %}</p>
{% if can_create %}
<a href="{% url 'assets:management_zone_create' %}{% if selected_thematic_code %}?thematic={{ selected_thematic_code }}{% endif %}" class="btn btn-sm btn-success">
<i class="bi bi-plus-lg me-1"></i>{% translate "Créer une première zone de gestion" %}
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock content %}
{% block script %}
<script>
document.addEventListener("DOMContentLoaded", function () {
const mapDiv = document.getElementById("zonesOverviewMap");
if (!mapDiv || typeof maplibregl === "undefined") return;
const thematicParam = "{{ selected_thematic_code|default:'' }}";
const geojsonUrl = "{% url 'assets:management_zones_geojson' %}" + (thematicParam ? ("?thematic=" + encodeURIComponent(thematicParam)) : "");
const map = new maplibregl.Map({
container: mapDiv,
style: window.MAP_STYLE_LIGHT,
center: window.MAP_DEFAULT_CENTER || [4.3572, 50.8477],
zoom: 11
});
map.addControl(new maplibregl.NavigationControl(), 'top-right');
map.on('load', async function () {
try {
const resp = await fetch(geojsonUrl, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
if (!resp.ok) {
document.getElementById("mapLoadingStatus").textContent = "";
return;
}
const data = await resp.json();
const features = data.features || [];
document.getElementById("mapLoadingStatus").textContent = `${features.length} {% translate "périmètre(s) affiché(s)" %}`;
map.addSource('management-zones-overview', {
type: 'geojson',
data: data
});
map.addLayer({
id: 'management-zones-fill',
type: 'fill',
source: 'management-zones-overview',
paint: {
'fill-color': ['coalesce', ['get', 'color'], '#198754'],
'fill-opacity': 0.25
}
});
map.addLayer({
id: 'management-zones-line',
type: 'line',
source: 'management-zones-overview',
paint: {
'line-color': ['coalesce', ['get', 'color'], '#198754'],
'line-width': 2,
'line-dasharray': [3, 1.5]
}
});
const popup = new maplibregl.Popup({ closeButton: true, closeOnClick: true, maxWidth: '300px' });
map.on('click', 'management-zones-fill', function (e) {
if (!e.features || !e.features[0]) return;
const p = e.features[0].properties;
const detailUrl = "/assets/management-zones/" + p.id + "/";
const html = `
<div style="font-size: 12px; line-height: 1.4;">
<div style="display:flex; align-items:center; gap:6px; margin-bottom:4px;">
<span style="display:inline-block; width:12px; height:12px; border-radius:3px; background-color:${p.color || '#198754'};"></span>
<strong>${p.code}</strong>
</div>
<div class="fw-semibold mb-1">${p.name}</div>
${p.zone_type_name ? `<span class="badge bg-secondary mb-1">${p.zone_type_name}</span>` : ''}
${p.target_frequency_days ? `<div class="text-muted small"><i class="bi bi-arrow-repeat me-1"></i>${p.target_frequency_days} {% translate "jours" %}</div>` : ''}
<div class="mt-2 text-end">
<a href="${detailUrl}" class="btn btn-sm btn-outline-primary py-0 px-2" style="font-size: 11px;">
{% translate "Consulter la fiche" %} &rarr;
</a>
</div>
</div>
`;
popup.setLngLat(e.lngLat).setHTML(html).addTo(map);
});
map.on('mouseenter', 'management-zones-fill', () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', 'management-zones-fill', () => { map.getCanvas().style.cursor = ''; });
// Fit bounds to features
if (features.length > 0) {
const bounds = new maplibregl.LngLatBounds();
features.forEach(f => {
const coords = f.geometry?.coordinates;
if (!coords) return;
const addCoords = (arr) => {
if (typeof arr[0] === 'number') {
bounds.extend(arr);
} else {
arr.forEach(addCoords);
}
};
addCoords(coords);
});
if (!bounds.isEmpty()) {
map.fitBounds(bounds, { padding: 40, maxZoom: 15 });
}
}
} catch (err) {
console.error("Error loading management zones overview map:", err);
document.getElementById("mapLoadingStatus").textContent = "";
}
});
});
</script>
{% endblock script %}

View file

@ -1,5 +1,14 @@
{% extends "assets/base_assets_index.html" %}
{% load static i18n %}
{% block tab_actions %}
<li class="nav-item ms-auto">
<a href="{% url 'assets:management_zones_index' %}?thematic=nature" class="btn btn-sm btn-outline-success d-inline-flex align-items-center gap-1 my-1 me-2" title="{% translate "Gérer les zones de gestion écologique et d'entretien" %}">
<i class="bi bi-diagram-3"></i>
<span>{% translate "Zones de gestion" %}</span>
</a>
</li>
{% endblock tab_actions %}
{% block locations_filter_form %}
@ -55,6 +64,16 @@
{% endfor %}
</select>
</div>
<div class="col-12">
<label for="management_type" class="form-label">{% translate "Gestionnaire / Compétence" %}</label>
<select name="management_type" id="management_type" class="form-select" multiple>
{% for key, value in management_type_choices %}
<option value="{{ key }}" {% if key in selected_management_types %}selected{% endif %}>
{{ value }}
</option>
{% endfor %}
</select>
</div>
<div class="col-12 d-flex justify-content-between mt-4" style="width: 200px;">
<button type="button" id="apply-assets-filters" class="btn btn-sm btn-primary">{% translate "Filtrer" %}</button>
<button type="button" id="reset-assets-filters" class="btn btn-sm btn-secondary">{% translate "Réinitialiser" %}</button>
@ -77,12 +96,15 @@
{ field: 'highway', title: '{% translate "Ring/Autoroute" %}', align: 'center' }
];
var assets_property_fields = ['category', 'model', 'area'];
var assets_property_fields = ['code', 'name', 'managing_authority', 'model', 'scientific_name', 'area'];
var assets_columns = [
{ field: 'category', title: '{% translate "Catégorie" %}' },
{ field: 'code', title: '{% translate "Code" %}' },
{ field: 'name', title: '{% translate "Nom" %}' },
{ field: 'managing_authority', title: '{% translate "Gestionnaire" %}' },
{ field: 'model', title: '{% translate "Modèle" %}' },
{ field: 'scientific_name', title: '{% translate "Botanique" %}' },
{ field: 'area', title: '{% translate "Superficie" %}' }
]
];
const locations_layer_def = {
@ -152,16 +174,25 @@
16, 5,
20, 8
],
'circle-color': ['coalesce', ['get', 'color'], '#0277BD'],
'circle-color': [
'case',
['==', ['get', 'management_type'], 'regional'], '#1E3A8A',
['boolean', ['get', 'is_remarkable'], false], '#F59E0B',
['coalesce', ['get', 'color'], '#15803D']
],
'circle-stroke-width': [
'case',
['boolean', ['feature-state', 'highlighted'], false], 2,
['boolean', ['feature-state', 'highlighted'], false], 2.5,
['==', ['get', 'management_type'], 'regional'], 1.5,
['boolean', ['get', 'is_remarkable'], false], 2,
1
],
'circle-stroke-color': [
'case',
['boolean', ['feature-state', 'highlighted'], false], '#ff0000',
'#000000'
['boolean', ['feature-state', 'highlighted'], false], '#EF4444',
['==', ['get', 'management_type'], 'regional'], '#93C5FD',
['boolean', ['get', 'is_remarkable'], false], '#FEF08A',
'#064E3B'
],
'circle-opacity': 0.8
},
@ -221,5 +252,36 @@
{% endblock const_script %}
{% block extra_script %}
<script>
document.addEventListener("DOMContentLoaded", function () {
function setupNatureOverlays() {
if (typeof initContractsMapOverlay === 'function' && window.map) {
initContractsMapOverlay({
map: window.map,
layerSwitcher: window.layerSwitcher,
thematic: 'nature',
geojsonUrl: "{% url 'contracts:contracts_geojson' %}",
});
}
if (typeof initManagementZonesMapOverlay === 'function' && window.map) {
initManagementZonesMapOverlay({
map: window.map,
layerSwitcher: window.layerSwitcher,
thematic: 'nature',
geojsonUrl: "{% url 'assets:management_zones_geojson' %}",
});
}
}
if (window.map && window.map.loaded && window.map.loaded()) {
setupNatureOverlays();
} else if (window.maploader) {
window.maploader.addEventListener('mapLoaded', () => {
setTimeout(setupNatureOverlays, 100);
});
} else if (window.map) {
window.map.on('load', setupNatureOverlays);
}
});
</script>
{% endblock extra_script %}

View file

@ -2,116 +2,704 @@
{% load static %}
{% load i18n %}
{% block content %}
<div class="row mt-2" style="height: calc(100vh - 200px);">
<div class="col-md-8" style="max-height: calc(100vh - 200px); overflow: auto;">
<h3 class="h3">
<i class="bi bi-suit-club"></i><span class="mx-2">{{ section.code}} - {{ section.name_fr}}</span>
</h3>
<hr>
<div class="row mb-4">
<div class="col-md-6">
<label class="mx-2">{% translate "Zone:" %}</label><i class="bi bi-circle-fill status_{{ surface.status }}"></i> {{ section.zone|default:"" }}<br>
<label class="mx-2">{% translate "Autoroute:" %}</label>{{ section.is_highway|default:"" }}<br>
</div>
<div class="col-md-6">
{% if section.lon %}
<a href="https://www.google.com/maps?cbll={{ section.lat|floatformat:'6u'}},{{ section.lon|floatformat:'6u'}}&cbp=12,0,0,0,5&layer=c&panoid=" target="_blank" style="text-decoration: none; font-size: 16px; display: flex; align-items: center;">
<img src="{% static 'common/google-street-view-logo.png' %}" alt="{% translate 'Street View' %}" height="20" style="margin-right: 8px;">
{% translate "View in Google Street View" %}
</a>
{% endif %}
</div>
</div>
<div class="row mb-4">
<div class="col-md-12">
<label class="mx-2">{% translate "Nombre d'arbres:" %}</label><br>
<label class="mx-2">{% translate "Superficie massif arboré:" %}</label><br>
<label class="mx-2">{% translate "Superficie pré fleuri:" %}</label><br>
</div>
</div>
{% block extra_head %}
<style>
.nature-kpi-card {
border-radius: 10px;
transition: transform 0.15s ease, box-shadow 0.15s ease;
border: 1px solid #e2e8f0;
background: #ffffff;
}
.nature-kpi-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08);
}
.nature-kpi-icon {
width: 44px;
height: 44px;
min-width: 44px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.3rem;
}
.nature-tab-content {
background: #ffffff;
border: 1px solid #dee2e6;
border-top: none;
border-radius: 0 0 8px 8px;
padding: 1rem;
}
#map {
width: 100%;
height: calc(100vh - 230px);
min-height: 520px;
border-radius: 8px;
}
.map-card-wrapper {
position: sticky;
top: 1rem;
}
.map-layer-pill {
font-size: 0.78rem;
cursor: pointer;
user-select: none;
}
</style>
{% endblock extra_head %}
<!-- Documents -->
<div class="card mb-4">
<div class="card-header">
<h4>{% translate "Documents" %}</h4>
</div>
<div class="card-body">
<div class="row">
{% for document in section.documents.all %}
<div class="col-md-2 mb-3">
<a href="{{ document.file.url }}" target="_blank">
<div class="img-thumbnail" style="width: 100%; height: 100px; overflow: hidden;">
{% if document.thumbnail %}
<img src="{{ document.thumbnail.url }}" alt="{{ document.file.name }}" style="width: 100%; height: 100%; object-fit: cover; object-position: center;">
{% else %}
<img src="{{ document.file.url }}" alt="{{ document.file.name }}" style="width: 100%; height: 100%; object-fit: cover; object-position: center;">
{% block content %}
<div class="container-fluid py-3">
<!-- Breadcrumb & Navigation -->
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a href="{% url 'assets:index' %}?thematic=nature" class="text-decoration-none">{% translate "Nature" %}</a></li>
<li class="breadcrumb-item active fw-semibold" aria-current="page">{{ section.code }}</li>
</ol>
</nav>
<div class="d-flex gap-2">
<a href="{% url 'assets:index' %}?thematic=nature" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>{% translate "Retour à la carte" %}
</a>
{% if section.lon and section.lat %}
<a href="https://www.google.com/maps?cbll={{ section.lat|floatformat:'6u'}},{{ section.lon|floatformat:'6u'}}&cbp=12,0,0,0,5&layer=c&panoid="
target="_blank" class="btn btn-sm btn-outline-primary" style="text-decoration: none;">
<img src="{% static 'common/google-street-view-logo.png' %}" alt="{% translate 'Street View' %}" height="18" class="me-1">
{% translate "View in Google Street View" %}
</a>
{% endif %}
</div>
</div>
<!-- Header Card -->
<div class="card shadow-sm mb-4 border-0" style="background: linear-gradient(to right, #f8fafc, #ffffff); border-left: 5px solid #16a34a !important;">
<div class="card-body p-3 p-md-4">
<div class="row align-items-center g-3">
<div class="col-lg-8">
<div class="d-flex align-items-center gap-3">
<div class="rounded-circle bg-success-subtle p-2 d-flex align-items-center justify-content-center text-success" style="width: 52px; height: 52px; min-width: 52px;">
<i class="bi bi-tree-fill" style="font-size: 1.8rem;"></i>
</div>
<div>
<div class="d-flex align-items-center gap-2 flex-wrap">
<h1 class="h4 mb-0 fw-bold text-dark">
{{ section.code }} <span class="text-muted fw-normal">&mdash; {{ section.get_name }}</span>
</h1>
</div>
<div class="d-flex align-items-center gap-2 mt-2 flex-wrap text-muted small">
{% if section.municipality %}
<span><i class="bi bi-geo-alt-fill text-danger me-1"></i>{{ section.municipality }}</span>
<span>&bull;</span>
{% endif %}
<span>
<strong>{% translate "Zone:" %}</strong>
{% if section.zone %}
<span class="badge bg-secondary-subtle text-secondary border">Zone {{ section.zone }}</span>
{% else %}
<span class="text-muted">{% translate "Non définie" %}</span>
{% endif %}
</span>
<span>&bull;</span>
<span>
<strong>{% translate "Autoroute:" %}</strong>
{% if section.is_highway %}
<span class="badge bg-primary-subtle text-primary border">{% translate "Oui" %}</span>
{% else %}
<span class="badge bg-light text-secondary border">{% translate "Non" %}</span>
{% endif %}
</span>
{% if section.category %}
<span>&bull;</span>
<span><i class="bi bi-tag me-1"></i>{{ section.category.get_name }}</span>
{% endif %}
</div>
</a>
</div>
</div>
{% if section.parent_location or section.child_locations.exists %}
<div class="mt-2 pt-2 border-top d-flex gap-3 flex-wrap small text-muted">
{% if section.parent_location %}
<div>
<i class="bi bi-diagram-2 me-1"></i>{% translate "Section parente :" %}
<a href="{{ section.parent_location.get_absolute_url }}" class="fw-semibold text-decoration-none">
{{ section.parent_location.code }} - {{ section.parent_location.get_name }}
</a>
</div>
{% endif %}
{% if section.child_locations.exists %}
<div>
<i class="bi bi-diagram-3 me-1"></i>{% translate "Sous-sections :" %}
{% for child in section.child_locations.all %}
<a href="{{ child.get_absolute_url }}" class="badge bg-light text-dark border text-decoration-none me-1">
{{ child.code }}
</a>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
</div>
<div class="col-lg-4 text-lg-end">
<div class="d-inline-flex gap-2 flex-wrap justify-content-lg-end">
<div class="badge bg-light text-dark border p-2 text-start">
<span class="d-block text-muted" style="font-size: 0.72rem;">{% translate "Total équipements" %}</span>
<span class="fw-bold fs-6">{{ total_assets_count }}</span>
</div>
<div class="badge bg-light text-dark border p-2 text-start">
<span class="d-block text-muted" style="font-size: 0.72rem;">{% translate "Interventions" %}</span>
<span class="fw-bold fs-6 text-primary">{{ interventions|length }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- KPI Metric Cards Grid -->
<div class="row g-3 mb-4">
<!-- 1. Nombre d'arbres -->
<div class="col-sm-6 col-xl-3">
<div class="card h-100 nature-kpi-card shadow-sm p-3">
<div class="d-flex align-items-center gap-3">
<div class="nature-kpi-icon bg-success-subtle text-success">
<i class="bi bi-tree-fill"></i>
</div>
<div>
<div class="text-muted small fw-semibold">{% translate "Nombre d'arbres:" %}</div>
<div class="fs-4 fw-bold text-dark">{{ trees_count }}</div>
<div class="text-muted" style="font-size: 0.75rem;">
{{ trees_active_count }} {% translate "actifs" %}
{% if trees_remarkable_count %}
&bull; <span class="text-warning fw-semibold"><i class="bi bi-star-fill text-warning"></i> {{ trees_remarkable_count }} {% translate "remarquables" %}</span>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<!-- Interventions -->
<div class="card mb-4">
<div class="card-header">
<h4>{% translate "Interventions" %}</h4>
<!-- 2. Superficie totale surfaces vertes -->
<div class="col-sm-6 col-xl-3">
<div class="card h-100 nature-kpi-card shadow-sm p-3">
<div class="d-flex align-items-center gap-3">
<div class="nature-kpi-icon bg-primary-subtle text-primary">
<i class="bi bi-grid-fill"></i>
</div>
<div>
<div class="text-muted small fw-semibold">{% translate "Surfaces végétales:" %}</div>
<div class="fs-4 fw-bold text-dark">{{ total_green_surface|floatformat:1 }} <span class="fs-6 fw-normal text-muted">m²</span></div>
<div class="text-muted" style="font-size: 0.75rem;">
{{ green_surfaces_count }} {% translate "surfaces au total" %}
</div>
</div>
</div>
</div>
<div class="card-body">
<table id="interventions-table" class="table"
data-height="550"
data-pagination="true"
data-page-size="25"
data-click-to-select="true" >
<thead>
<tr>
<th data-field="actions" data-width="100"></th>
<th data-field="code">{% translate "Code" %}</th>
<th data-field="title">{% translate "Titre" %}</th>
<th data-field="begin_date">{% translate "Début" %}</th>
<th data-field="statut">{% translate "Statut" %}</th>
</tr>
<tbody>
{% for interv in interventions %}
</div>
<!-- 3. Superficie massif arboré -->
<div class="col-sm-6 col-xl-3">
<div class="card h-100 nature-kpi-card shadow-sm p-3">
<div class="d-flex align-items-center gap-3">
<div class="nature-kpi-icon bg-warning-subtle text-warning-emphasis">
<i class="bi bi-flower1"></i>
</div>
<div>
<div class="text-muted small fw-semibold">{% translate "Superficie massif arboré:" %}</div>
<div class="fs-4 fw-bold text-dark">{{ surface_massif_arbore|floatformat:1 }} <span class="fs-6 fw-normal text-muted">m²</span></div>
<div class="text-muted" style="font-size: 0.75rem;">
{{ massif_arbore_count }} {% translate "massifs arborés" %}
</div>
</div>
</div>
</div>
</div>
<!-- 4. Superficie pré fleuri -->
<div class="col-sm-6 col-xl-3">
<div class="card h-100 nature-kpi-card shadow-sm p-3">
<div class="d-flex align-items-center gap-3">
<div class="nature-kpi-icon bg-info-subtle text-info-emphasis">
<i class="bi bi-flower2"></i>
</div>
<div>
<div class="text-muted small fw-semibold">{% translate "Superficie pré fleuri:" %}</div>
<div class="fs-4 fw-bold text-dark">{{ surface_pre_fleuri|floatformat:1 }} <span class="fs-6 fw-normal text-muted">m²</span></div>
<div class="text-muted" style="font-size: 0.75rem;">
{{ pre_fleuri_count }} {% translate "prés fleuris" %}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Typology breakdown (if available) -->
{% if surfaces_by_type %}
<div class="card shadow-sm mb-4 border-0">
<div class="card-body p-3">
<div class="d-flex align-items-center justify-content-between mb-2">
<span class="small fw-bold text-secondary text-uppercase"><i class="bi bi-pie-chart-fill me-1"></i>{% translate "Répartition des surfaces végétales" %}</span>
<span class="small text-muted">{{ total_green_surface|floatformat:1 }} m²</span>
</div>
<!-- Progress bar -->
<div class="progress mb-2" style="height: 10px; border-radius: 6px;">
{% for st in surfaces_by_type %}
<div class="progress-bar" role="progressbar"
style="width: {{ st.percent }}%; background-color: {{ st.color }};"
title="{{ st.name }}: {{ st.total_area|floatformat:1 }} m² ({{ st.percent }}%)"
aria-valuenow="{{ st.percent }}" aria-valuemin="0" aria-valuemax="100"></div>
{% endfor %}
</div>
<!-- Badges -->
<div class="d-flex flex-wrap gap-2 mt-2">
{% for st in surfaces_by_type %}
<span class="badge bg-light text-dark border d-inline-flex align-items-center gap-1 py-1 px-2" style="font-size: 0.8rem;">
<span class="d-inline-block rounded-circle" style="width: 8px; height: 8px; background-color: {{ st.color }};"></span>
<span class="fw-semibold">{{ st.name }}</span>:
<span class="text-muted">{{ st.total_area|floatformat:1 }} m² ({{ st.count }})</span>
</span>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<!-- Main Content Layout (Split View: Tabs/Tables on Left, Interactive Map on Right) -->
<div class="row g-4">
<!-- Left Column: Tabs & Tables -->
<div class="col-lg-7 col-xl-7">
<!-- Tabs Navigation -->
<ul class="nav nav-tabs" id="natureLocationTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active fw-semibold" id="surfaces-tab" data-bs-toggle="tab" data-bs-target="#surfaces-pane" type="button" role="tab">
<i class="bi bi-grid-fill text-success me-1"></i>{% translate "Surfaces vertes" %}
<span class="badge bg-success-subtle text-success ms-1">{{ green_surfaces_count }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link fw-semibold" id="trees-tab" data-bs-toggle="tab" data-bs-target="#trees-pane" type="button" role="tab">
<i class="bi bi-tree-fill text-success me-1"></i>{% translate "Arbres" %}
<span class="badge bg-success-subtle text-success ms-1">{{ trees_count }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link fw-semibold" id="equipments-tab" data-bs-toggle="tab" data-bs-target="#equipments-pane" type="button" role="tab">
<i class="bi bi-flower3 text-info me-1"></i>{% translate "Arbustes & Bancs" %}
<span class="badge bg-secondary-subtle text-secondary ms-1">{{ shrubs_count|add:benches_count }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link fw-semibold" id="interventions-tab" data-bs-toggle="tab" data-bs-target="#interventions-pane" type="button" role="tab">
<i class="bi bi-tools text-primary me-1"></i>{% translate "Interventions" %}
<span class="badge bg-primary-subtle text-primary ms-1">{{ interventions|length }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link fw-semibold" id="documents-tab" data-bs-toggle="tab" data-bs-target="#documents-pane" type="button" role="tab">
<i class="bi bi-files text-secondary me-1"></i>{% translate "Documents" %}
<span class="badge bg-secondary-subtle text-secondary ms-1">{{ documents|length }}</span>
</button>
</li>
</ul>
<!-- Tabs Content -->
<div class="tab-content nature-tab-content shadow-sm" id="natureLocationTabsContent">
<!-- TAB 1: Surfaces végétalisées -->
<div class="tab-pane fade show active" id="surfaces-pane" role="tabpanel" aria-labelledby="surfaces-tab">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0" style="font-size: 0.9rem;">
<thead class="table-light">
<tr>
<th>{% translate "Code" %}</th>
<th>{% translate "Typologie / Modèle" %}</th>
<th class="text-end">{% translate "Superficie" %}</th>
<th>{% translate "Pente / Tonte" %}</th>
<th>{% translate "Bulbes" %}</th>
<th>{% translate "Statut" %}</th>
<th class="text-end">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for gs in green_surfaces %}
<tr>
<td class="fw-bold">
<a href="{% url 'assets:nature_assets_detail' 'naturegreensurface' gs.id %}" class="text-decoration-none">
{{ gs.code }}
</a>
</td>
<td>
<a href="{% url 'interventions:interventions_index' %}{{ interv.id }}" type="button" class="btn btn-sm btn-light detail-btn" data-id="${row.id}" data-bs-toggle="tooltip" title="{% translate 'Détail' %}">
<span class="d-inline-block rounded-circle me-1" style="width: 8px; height: 8px; background-color: {{ gs.model.color|default:'#4caf50' }};"></span>
{{ gs.model.get_name|default:"-" }}
</td>
<td class="text-end fw-semibold">
{% if gs.surface_area %}{{ gs.surface_area|floatformat:1 }} m²{% else %}<span class="text-muted">-</span>{% endif %}
</td>
<td>
<span class="small text-muted">
{% if gs.slope %}{{ gs.slope }}%{% else %}-{% endif %} /
{% if gs.mower_frenquency %}{{ gs.mower_frenquency }}x/an{% else %}-{% endif %}
</span>
</td>
<td>
{% if gs.bulb_types %}
<span class="badge bg-info-subtle text-info border">{{ gs.bulb_types|length }} bulbe(s)</span>
{% else %}
<span class="text-muted small">-</span>
{% endif %}
</td>
<td>
<span class="badge {% if gs.status == 'active' %}bg-success{% else %}bg-secondary{% endif %}">
{{ gs.get_status_display }}
</span>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
{% if gs.lon and gs.lat %}
<button type="button" class="btn btn-outline-success" onclick="zoomToAsset({{ gs.lon|stringformat:'f' }}, {{ gs.lat|stringformat:'f' }}, '{{ gs.code }}')" title="{% translate 'Centrer sur la carte' %}">
<i class="bi bi-geo-alt"></i>
</button>
{% endif %}
<a href="{% url 'assets:nature_assets_detail' 'naturegreensurface' gs.id %}" class="btn btn-outline-primary" title="{% translate 'Détail' %}">
<i class="bi bi-arrow-right"></i>
</a>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center text-muted py-4">
<i class="bi bi-grid mb-2 d-block fs-3 text-muted"></i>
{% translate "Aucune surface végétalisée enregistrée sur cette voirie." %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- TAB 2: Arbres -->
<div class="tab-pane fade" id="trees-pane" role="tabpanel" aria-labelledby="trees-tab">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0" style="font-size: 0.9rem;">
<thead class="table-light">
<tr>
<th>{% translate "Code" %}</th>
<th>{% translate "Essence / Modèle" %}</th>
<th>{% translate "Stade" %}</th>
<th>{% translate "Vitalité" %}</th>
<th>{% translate "Risque" %}</th>
<th>{% translate "Statut" %}</th>
<th class="text-end">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for tree in trees %}
<tr>
<td class="fw-bold">
<a href="{% url 'assets:nature_assets_detail' 'naturetree' tree.id %}" class="text-decoration-none">
{{ tree.code }}
</a>
{% if tree.is_remarkable %}
<i class="bi bi-star-fill text-warning ms-1" title="{% translate 'Arbre remarquable' %}"></i>
{% endif %}
</td>
<td>
<div>{{ tree.model.get_name|default:"-" }}</div>
{% if tree.model.scientific_name %}
<div class="small text-muted fst-italic">{{ tree.model.scientific_name }}</div>
{% endif %}
</td>
<td>
<span class="small">{{ tree.get_development_stage_display|default:"-" }}</span>
</td>
<td>
{% if tree.vitality == 'strong' %}
<span class="badge bg-success-subtle text-success border">{% translate "Forte" %}</span>
{% elif tree.vitality == 'normal' %}
<span class="badge bg-info-subtle text-info border">{% translate "Normale" %}</span>
{% elif tree.vitality == 'weak' %}
<span class="badge bg-warning-subtle text-warning-emphasis border">{% translate "Faible" %}</span>
{% elif tree.vitality == 'declining' or tree.vitality == 'dead' %}
<span class="badge bg-danger">{{ tree.get_vitality_display }}</span>
{% else %}
<span class="text-muted small">-</span>
{% endif %}
</td>
<td>
{% if tree.risk_level == 'high' or tree.risk_level == 'imminent_danger' %}
<span class="badge bg-danger">{{ tree.get_risk_level_display }}</span>
{% elif tree.risk_level == 'moderate' %}
<span class="badge bg-warning text-dark">{{ tree.get_risk_level_display }}</span>
{% else %}
<span class="small text-muted">{{ tree.get_risk_level_display|default:"Faible" }}</span>
{% endif %}
</td>
<td>
<span class="badge {% if tree.status == 'active' %}bg-success{% else %}bg-secondary{% endif %}">
{{ tree.get_status_display }}
</span>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
{% if tree.lon and tree.lat %}
<button type="button" class="btn btn-outline-success" onclick="zoomToAsset({{ tree.lon|stringformat:'f' }}, {{ tree.lat|stringformat:'f' }}, '{{ tree.code }}')" title="{% translate 'Centrer sur la carte' %}">
<i class="bi bi-geo-alt"></i>
</button>
{% endif %}
<a href="{% url 'assets:nature_assets_detail' 'naturetree' tree.id %}" class="btn btn-outline-primary" title="{% translate 'Détail' %}">
<i class="bi bi-arrow-right"></i>
</a>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center text-muted py-4">
<i class="bi bi-tree mb-2 d-block fs-3 text-muted"></i>
{% translate "Aucun arbre enregistré sur cette voirie." %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- TAB 3: Arbustes & Bancs -->
<div class="tab-pane fade" id="equipments-pane" role="tabpanel" aria-labelledby="equipments-tab">
<!-- Arbustes -->
<h6 class="fw-bold mb-2 text-dark"><i class="bi bi-flower3 text-success me-1"></i>{% translate "Arbustes" %} ({{ shrubs_count }})</h6>
<div class="table-responsive mb-4">
<table class="table table-hover align-middle mb-0" style="font-size: 0.9rem;">
<thead class="table-light">
<tr>
<th>{% translate "Code" %}</th>
<th>{% translate "Modèle" %}</th>
<th>{% translate "Hauteur" %}</th>
<th>{% translate "Taillé" %}</th>
<th>{% translate "Statut" %}</th>
<th class="text-end">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for shrub in shrubs %}
<tr>
<td class="fw-bold">
<a href="{% url 'assets:nature_assets_detail' 'natureshrub' shrub.id %}" class="text-decoration-none">
{{ shrub.code }}
</a>
</td>
<td>{{ shrub.model.get_name|default:"-" }}</td>
<td>{% if shrub.height %}{{ shrub.height }} m{% else %}-{% endif %}</td>
<td>
{% if shrub.trimmed %}<span class="badge bg-success-subtle text-success">{% translate "Oui" %}</span>{% else %}<span class="badge bg-light text-secondary">{% translate "Non" %}</span>{% endif %}
</td>
<td><span class="badge bg-secondary">{{ shrub.get_status_display }}</span></td>
<td class="text-end">
<div class="btn-group btn-group-sm">
{% if shrub.lon and shrub.lat %}
<button type="button" class="btn btn-outline-success" onclick="zoomToAsset({{ shrub.lon|stringformat:'f' }}, {{ shrub.lat|stringformat:'f' }}, '{{ shrub.code }}')">
<i class="bi bi-geo-alt"></i>
</button>
{% endif %}
<a href="{% url 'assets:nature_assets_detail' 'natureshrub' shrub.id %}" class="btn btn-outline-primary">
<i class="bi bi-arrow-right"></i>
</a>
</div>
</td>
</tr>
{% empty %}
<tr><td colspan="6" class="text-center text-muted py-2 small">{% translate "Aucun arbuste répertorié." %}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Bancs / Mobilier -->
<h6 class="fw-bold mb-2 text-dark"><i class="bi bi-inbox text-warning me-1"></i>{% translate "Mobilier & Bancs" %} ({{ benches_count }})</h6>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0" style="font-size: 0.9rem;">
<thead class="table-light">
<tr>
<th>{% translate "Code" %}</th>
<th>{% translate "Modèle" %}</th>
<th>{% translate "Matériau" %}</th>
<th>{% translate "Dimensions" %}</th>
<th>{% translate "Statut" %}</th>
<th class="text-end">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for bench in benches %}
<tr>
<td class="fw-bold">
<a href="{% url 'assets:nature_assets_detail' 'naturebench' bench.id %}" class="text-decoration-none">
{{ bench.code }}
</a>
</td>
<td>{{ bench.model.get_name|default:"-" }}</td>
<td>{{ bench.material|default:"-" }}</td>
<td>
{% if bench.length or bench.width or bench.height %}
{{ bench.length|default:"-" }} x {{ bench.width|default:"-" }} x {{ bench.height|default:"-" }} m
{% else %}
-
{% endif %}
</td>
<td><span class="badge bg-secondary">{{ bench.get_status_display }}</span></td>
<td class="text-end">
<div class="btn-group btn-group-sm">
{% if bench.lon and bench.lat %}
<button type="button" class="btn btn-outline-success" onclick="zoomToAsset({{ bench.lon|stringformat:'f' }}, {{ bench.lat|stringformat:'f' }}, '{{ bench.code }}')">
<i class="bi bi-geo-alt"></i>
</button>
{% endif %}
<a href="{% url 'assets:nature_assets_detail' 'naturebench' bench.id %}" class="btn btn-outline-primary">
<i class="bi bi-arrow-right"></i>
</a>
</div>
</td>
</tr>
{% empty %}
<tr><td colspan="6" class="text-center text-muted py-2 small">{% translate "Aucun banc ou mobilier répertorié." %}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- TAB 4: Interventions -->
<div class="tab-pane fade" id="interventions-pane" role="tabpanel" aria-labelledby="interventions-tab">
<div class="table-responsive">
<table id="interventions-table" class="table table-hover align-middle mb-0"
data-pagination="true"
data-page-size="25">
<thead class="table-light">
<tr>
<th data-field="actions" class="text-center" style="width: 50px;"></th>
<th data-field="code">{% translate "Code" %}</th>
<th data-field="title">{% translate "Titre" %}</th>
<th data-field="begin_date">{% translate "Début" %}</th>
<th data-field="status">{% translate "Statut" %}</th>
</tr>
</thead>
<tbody>
{% for interv in interventions %}
<tr>
<td class="text-center">
<a href="{% url 'interventions:interventions_index' %}{{ interv.id }}" class="btn btn-sm btn-light detail-btn" title="{% translate 'Détail' %}">
<i class="bi bi-info-circle"></i>
</a>
</td>
<td>{{ interv.code }}</td>
<td class="fw-bold">{{ interv.code }}</td>
<td>{{ interv.title }}</td>
<td>{{ interv.begin_time|date:"d-m-Y H:i" }}</td>
<td>{{ interv.get_status_display }}</td>
<td>{{ interv.begin_time|date:"d-m-Y H:i"|default:"-" }}</td>
<td>
<span class="badge bg-secondary">{{ interv.get_status_display }}</span>
</td>
</tr>
{% endfor %}
</tbody>
</thead>
</table>
{% empty %}
<tr>
<td colspan="5" class="text-center text-muted py-4">
<i class="bi bi-tools mb-2 d-block fs-3 text-muted"></i>
{% translate "Aucune intervention enregistrée sur cette localisation ou ses aménagements." %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- TAB 5: Documents -->
<div class="tab-pane fade" id="documents-pane" role="tabpanel" aria-labelledby="documents-tab">
{% if documents %}
<div class="row g-3">
{% for document in documents %}
<div class="col-6 col-sm-4 col-md-3">
<div class="card h-100 shadow-sm border p-2 text-center">
<a href="{{ document.url }}" target="_blank" class="text-decoration-none text-dark">
<div class="img-thumbnail d-flex align-items-center justify-content-center mx-auto mb-2" style="width: 100%; height: 90px; overflow: hidden; background: #f8fafc;">
{% if document.thumbnail_url %}
<img src="{{ document.thumbnail_url }}" alt="{{ document.title }}" style="max-height: 100%; max-width: 100%; object-fit: contain;">
{% else %}
<i class="bi bi-file-earmark-text fs-2 text-secondary"></i>
{% endif %}
</div>
<div class="fw-semibold small text-truncate" title="{{ document.title }}">{{ document.title }}</div>
{% if document.tag %}
<span class="badge bg-primary-subtle text-primary border my-1" style="font-size: 0.7rem;">{{ document.tag }}</span>
{% endif %}
{% if document.date %}
<div class="text-muted" style="font-size: 0.7rem;">{{ document.date|date:"d/m/Y" }}</div>
{% endif %}
</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-folder2-open mb-2 d-block fs-2 text-muted"></i>
{% translate "Aucun document associé à cette localisation." %}
</div>
{% endif %}
</div>
</div>
</div>
<!-- Right Column: Interactive Map -->
<div class="col-lg-5 col-xl-5">
<div class="card shadow-sm border-0 map-card-wrapper">
<div class="card-header bg-white py-2 px-3 d-flex align-items-center justify-content-between flex-wrap gap-2 border-bottom">
<span class="fw-bold text-dark d-flex align-items-center">
<i class="bi bi-geo-alt-fill text-success me-2"></i>{% translate "Carte & Aménagements" %}
</span>
<!-- Map Layer Controls / Legend -->
<div class="d-flex align-items-center gap-2 flex-wrap">
<div class="form-check form-check-inline m-0 map-layer-pill">
<input class="form-check-input" type="checkbox" id="toggle-surfaces" checked onchange="toggleNatureLayer('surfaces', this.checked)">
<label class="form-check-label" for="toggle-surfaces"><span class="badge bg-success" style="width: 8px; height: 8px; padding: 0; display: inline-block;"></span> {% translate "Surfaces" %}</label>
</div>
<div class="form-check form-check-inline m-0 map-layer-pill">
<input class="form-check-input" type="checkbox" id="toggle-trees" checked onchange="toggleNatureLayer('trees', this.checked)">
<label class="form-check-label" for="toggle-trees"><span class="badge bg-success" style="border-radius: 50%; width: 8px; height: 8px; padding: 0; display: inline-block;"></span> {% translate "Arbres" %}</label>
</div>
<div class="form-check form-check-inline m-0 map-layer-pill">
<input class="form-check-input" type="checkbox" id="toggle-equipments" checked onchange="toggleNatureLayer('shrubs', this.checked); toggleNatureLayer('benches', this.checked)">
<label class="form-check-label" for="toggle-equipments"><span class="badge bg-warning" style="border-radius: 50%; width: 8px; height: 8px; padding: 0; display: inline-block;"></span> {% translate "Autres" %}</label>
</div>
</div>
</div>
<div class="card-body p-0 position-relative">
<div id="map"></div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div id="map">
</div>
</div>
</div>
{% endblock content%}
{% endblock content %}
{% block script %}
{{ section.lon|json_script:"lon_value" }}
{{ section.lat|json_script:"lat_value" }}
{{ section.geojson|json_script:"geojson_value" }}
{{ feature_lon|json_script:"lon_value" }}
{{ feature_lat|json_script:"lat_value" }}
{{ section_geojson|json_script:"section_geojson_value" }}
{{ assets_geojson|json_script:"assets_geojson_value" }}
<script>
const feature_geojson = JSON.parse(document.getElementById("geojson_value").textContent);
const feature_lon = JSON.parse(document.getElementById("lon_value").textContent);
const feature_lat = JSON.parse(document.getElementById("lat_value").textContent);
const section_geojson = JSON.parse(document.getElementById("section_geojson_value").textContent);
const assets_geojson = JSON.parse(document.getElementById("assets_geojson_value").textContent);
</script>
<script src="{% static 'assets/green_detail.js' %}"></script>
<script src="{% static 'common/turf.min.js' %}"></script>
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
<script src="{% static 'assets/nature_location_detail.js' %}"></script>
{% endblock script %}

View file

@ -1,122 +0,0 @@
{% extends "base.html" %}
{% load static %}
{% block content %}
<div class="row mt-2" style="height: calc(100vh - 200px);">
<div class="col-md-8" style="max-height: calc(100vh - 200px); overflow: auto;">
<h3 class="h3">
<i class="bi bi-suit-club"></i><span class="mx-2">{{ asset.code}}</span>
</h3>
<hr>
<div class="row mb-4">
<div class="col-md-6">
<label class="mx-2">Statut:</label><i class="bi bi-circle-fill status_{{ asset.status }}"></i> {{ asset.get_status_display }}<br>
<label class="mx-2">Type:</label>{{ asset.model.name_fr|default:"" }}<br>
</div>
<div class="col-md-6">
{% if asset.lon %}
<a href="https://www.google.com/maps?cbll={{ asset.lat|floatformat:'6u'}},{{ asset.lon|floatformat:'6u'}}&cbp=12,0,0,0,5&layer=c&panoid=" target="_blank" style="text-decoration: none; font-size: 16px; display: flex; align-items: center;">
<img src="{% static 'common/google-street-view-logo.png' %}" alt="Street View" height="20" style="margin-right: 8px;">
View in Google Street View
</a>
{% endif %}
</div>
</div>
<div class="row mb-4">
<div class="col-md-12">
<label class="mx-2">Densité:</label>{{ asset.density }}<br>
<label class="mx-2">Pondération:</label>{{ asset.weighting }}<br>
<label class="mx-2">Fréquence de tonte:</label>{{ asset.weighting }}<br>
</div>
</div>
<div class="row mb-4">
<div class="col-md-6">
<label class="mx-2">Dernier entretien:</label>{{ asset.last_preventive_date|default:"" }}<br>
</div>
</div>
<!-- Documents -->
<div class="card mb-4">
<div class="card-header">
<h4>Documents</h4>
</div>
<div class="card-body">
<div class="row">
{% for document in asset.documents.all %}
<div class="col-md-2 mb-3">
<a href="{{ document.file.url }}" target="_blank">
<div class="img-thumbnail" style="width: 100%; height: 100px; overflow: hidden;">
{% if document.thumbnail %}
<img src="{{ document.thumbnail.url }}" alt="{{ document.file.name }}" style="width: 100%; height: 100%; object-fit: cover; object-position: center;">
{% else %}
<img src="{{ document.file.url }}" alt="{{ document.file.name }}" style="width: 100%; height: 100%; object-fit: cover; object-position: center;">
{% endif %}
</div>
</a>
</div>
{% endfor %}
</div>
</div>
</div>
<!-- Interventions -->
<div class="card mb-4">
<div class="card-header">
<h4>Interventions</h4>
</div>
<div class="card-body">
<table id="interventions-table" class="table"
data-height="550"
data-pagination="true"
data-page-size="25"
data-click-to-select="true" >
<thead>
<tr>
<th data-field="actions" data-width="100"></th>
<th data-field="code">Code</th>
<th data-field="title">Titre</th>
<th data-field="begin_date">Début</th>
<th data-field="statut">Statut</th>
</tr>
<tbody>
{% for interv in interventions %}
<tr>
<td>
<a href="{% url 'interventions:interventions_index' %}{{ interv.id }}" type="button" class="btn btn-sm btn-light detail-btn" data-id="${row.id}" data-bs-toggle="tooltip" title="Detail">
<i class="bi bi-info-circle"></i>
</a>
</td>
<td>{{ interv.code }}</td>
<td>{{ interv.title }}</td>
<td>{{ interv.begin_time|date:"d-m-Y H:i" }}</td>
<td>{{ interv.get_status_display }}</td>
</tr>
{% endfor %}
</tbody>
</thead>
</table>
</div>
</div>
</div>
<div class="col-md-4">
<div id="map">
</div>
</div>
</div>
{% endblock content%}
{% block script %}
{{ asset.lon|json_script:"lon_value" }}
{{ asset.lat|json_script:"lat_value" }}
{{ asset.geojson|json_script:"geojson_value" }}
<script>
const feature_geojson = JSON.parse(document.getElementById("geojson_value").textContent);
const feature_lon = JSON.parse(document.getElementById("lon_value").textContent);
const feature_lat = JSON.parse(document.getElementById("lat_value").textContent);
</script>
<script src="{% static 'assets/green_detail.js' %}"></script>
{% endblock script %}

View file

@ -88,6 +88,24 @@
{% if asset.is_remarkable %}
<span class="badge bg-warning text-dark ms-1 shadow-sm"><i class="bi bi-star-fill text-warning me-1"></i>{% translate "Arbre remarquable" %}</span>
{% endif %}
{# Badge Gestionnaire #}
{% if asset.managing_authority %}
<span class="badge {% if asset.management_type == 'regional' %}bg-primary-subtle text-primary border border-primary{% else %}bg-success-subtle text-success border border-success{% endif %} ms-1">
<i class="bi bi-buildings me-1"></i>{{ asset.managing_authority }}
</span>
{% elif asset.management_type %}
<span class="badge bg-secondary-subtle text-secondary border ms-1">{{ asset.get_management_type_display }}</span>
{% endif %}
{# Badges Biologiques Spéciaux #}
{% if asset.is_dead %}
<span class="badge bg-dark text-white ms-1"><i class="bi bi-x-circle me-1"></i>{% translate "Mort" %}</span>
{% elif asset.is_stump %}
<span class="badge bg-secondary text-white ms-1"><i class="bi bi-dash-circle me-1"></i>{% translate "Souche" %}</span>
{% elif asset.is_chronoxyle %}
<span class="badge bg-warning-subtle text-dark border border-warning ms-1"><i class="bi bi-recycle me-1"></i>{% translate "Chronoxyle" %}</span>
{% endif %}
</div>
<div class="d-flex align-items-center gap-2 mt-1" style="font-size: 0.8rem; color: #6c757d;">
@ -159,6 +177,24 @@
{% if asset.is_remarkable %}
<span class="badge bg-warning text-dark ms-1 shadow-sm"><i class="bi bi-star-fill text-warning me-1"></i>{% translate "Arbre remarquable" %}</span>
{% endif %}
{# Badge Gestionnaire #}
{% if asset.managing_authority %}
<span class="badge {% if asset.management_type == 'regional' %}bg-primary-subtle text-primary border border-primary{% else %}bg-success-subtle text-success border border-success{% endif %} ms-1">
<i class="bi bi-buildings me-1"></i>{{ asset.managing_authority }}
</span>
{% elif asset.management_type %}
<span class="badge bg-secondary-subtle text-secondary border ms-1">{{ asset.get_management_type_display }}</span>
{% endif %}
{# Badges Biologiques Spéciaux #}
{% if asset.is_dead %}
<span class="badge bg-dark text-white ms-1"><i class="bi bi-x-circle me-1"></i>{% translate "Mort" %}</span>
{% elif asset.is_stump %}
<span class="badge bg-secondary text-white ms-1"><i class="bi bi-dash-circle me-1"></i>{% translate "Souche" %}</span>
{% elif asset.is_chronoxyle %}
<span class="badge bg-warning-subtle text-dark border border-warning ms-1"><i class="bi bi-recycle me-1"></i>{% translate "Chronoxyle" %}</span>
{% endif %}
</div>
<div class="d-flex align-items-center gap-2 mt-1" style="font-size: 0.85rem; color: #6c757d;">
@ -221,6 +257,89 @@
</td>
</tr>
<!-- Autorité gestionnaire -->
<tr class="asset-editable-field" data-field="managing_authority" data-can-edit="{% if 'managing_authority' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Autorité gestionnaire" %}</th>
<td>
<div class="field-display">
<span class="field-value fw-semibold text-primary">
{% if asset.managing_authority %}
<i class="bi bi-bank me-1"></i>{{ asset.managing_authority }}
{% if asset.management_type %}
<small class="text-muted fw-normal">({{ asset.get_management_type_display }})</small>
{% endif %}
{% else %}
{{ asset.get_management_type_display|default:"-" }}
{% endif %}
</span>
</div>
<div class="field-edit d-none">
<input type="text" class="form-control form-control-sm" value="{{ asset.managing_authority|default:'' }}" placeholder="{% translate 'Ex: Commune de Watermael-Boitsfort' %}">
</div>
</td>
</tr>
<!-- Type de gestionnaire -->
<tr class="asset-editable-field" data-field="management_type" data-can-edit="{% if 'management_type' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Type de gestionnaire" %}</th>
<td>
<div class="field-display"><span class="field-value">{{ asset.get_management_type_display|default:"-" }}</span></div>
<div class="field-edit d-none">
<select class="form-select form-select-sm">
{% for val, lbl in asset.TREE_MANAGEMENT_TYPE_CHOICES %}
<option value="{{ val }}" {% if asset.management_type == val %}selected{% endif %}>{{ lbl }}</option>
{% endfor %}
</select>
</div>
</td>
</tr>
<!-- Nom scientifique / Latin -->
<tr class="asset-editable-field" data-field="scientific_name" data-can-edit="{% if 'scientific_name' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Nom scientifique / Latin" %}</th>
<td>
<div class="field-display">
<span class="field-value fst-italic fw-medium text-success">
{{ asset.scientific_name|default:"-" }}
</span>
</div>
<div class="field-edit d-none">
<input type="text" class="form-control form-control-sm" value="{{ asset.scientific_name|default:'' }}" placeholder="{% translate 'Ex: Prunus serrulata' %}">
</div>
</td>
</tr>
<!-- Nom vernaculaire / Essence -->
<tr class="asset-editable-field" data-field="vernacular_name" data-can-edit="{% if 'vernacular_name' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Nom vernaculaire / Essence" %}</th>
<td>
<div class="field-display"><span class="field-value">{{ asset.vernacular_name|default:"-" }}</span></div>
<div class="field-edit d-none">
<input type="text" class="form-control form-control-sm" value="{{ asset.vernacular_name|default:'' }}" placeholder="{% translate 'Ex: Cerisier du Japon' %}">
</div>
</td>
</tr>
<!-- Genre & Cultivar -->
{% if asset.genus or asset.cultivar or 'genus' in allowed_update_fields %}
<tr class="asset-editable-field" data-field="genus" data-can-edit="{% if 'genus' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Genre & Cultivar" %}</th>
<td>
<div class="field-display">
<span class="field-value">
{{ asset.genus|default:"-" }}{% if asset.cultivar %} '{{ asset.cultivar }}'{% endif %}
</span>
</div>
<div class="field-edit d-none">
<div class="row g-1">
<div class="col-6"><input type="text" class="form-control form-control-sm" placeholder="Genre" value="{{ asset.genus|default:'' }}"></div>
<div class="col-6"><input type="text" class="form-control form-control-sm" placeholder="Cultivar" value="{{ asset.cultivar|default:'' }}"></div>
</div>
</div>
</td>
</tr>
{% endif %}
<!-- Nom commun FR -->
<tr class="asset-editable-field" data-field="name_fr" data-can-edit="{% if 'name_fr' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Nom commun (FR)" %}</th>
@ -371,7 +490,7 @@
<span class="field-value text-muted">{{ asset.note|default:"-"|linebreaksbr }}</span>
</div>
<div class="field-edit d-none">
<textarea class="form-control form-control-sm" rows="2">{{ asset.note|default:'' }}</textarea>
<textarea class="form-control form-control-sm" rows="3">{{ asset.note|default:'' }}</textarea>
</div>
</td>
</tr>
@ -419,6 +538,25 @@
<table class="table table-striped mb-0 align-middle">
<tbody>
<!-- Nombre de troncs / Cépée -->
<tr class="asset-editable-field" data-field="number_of_trunks" data-can-edit="{% if 'number_of_trunks' in allowed_update_fields %}true{% else %}false{% endif %}">
<th style="width: 35%;">{% translate "Nombre de troncs (cépée)" %}</th>
<td>
<div class="field-display">
<span class="field-value">
{% if asset.number_of_trunks and asset.number_of_trunks > 1 %}
<span class="badge bg-secondary-subtle text-secondary border"><i class="bi bi-diagram-3 me-1"></i>{{ asset.number_of_trunks }} {% translate "troncs" %}</span>
{% else %}
1 {% translate "(tronc unique)" %}
{% endif %}
</span>
</div>
<div class="field-edit d-none">
<input type="number" step="1" min="1" class="form-control form-control-sm" value="{{ asset.number_of_trunks|default:1 }}">
</div>
</td>
</tr>
<!-- Hauteur totale -->
<tr class="asset-editable-field" data-field="height" data-can-edit="{% if 'height' in allowed_update_fields %}true{% else %}false{% endif %}">
<th style="width: 35%;">{% translate "Hauteur totale (m)" %}</th>
@ -518,6 +656,173 @@
</div>
{# ── CARTE ÉCO-SERVICES & VALEUR PATRIMONIALE (GRALITY / BARÈME DE L'ARBRE) ── #}
<div class="card mb-3 shadow-sm border-success-subtle">
<div class="card-header bg-success-subtle d-flex align-items-center justify-content-between">
<h5 class="mb-0 fs-6 fw-bold text-success-emphasis">
<i class="bi bi-feather me-2"></i>{% translate "Éco-services & Valeur patrimoniale" %}
</h5>
<span class="badge bg-success text-white">
<i class="bi bi-shield-check me-1"></i>{% translate "Indicateurs écologiques" %}
</span>
</div>
<div class="card-body">
{# 4 mini-widgets écologiques #}
<div class="row g-2 mb-3">
<!-- Valeur d'aménité -->
<div class="col-6 col-md-3">
<div class="p-2 border border-success-subtle rounded text-center bg-white shadow-sm tree-metric-widget">
<div class="small text-muted mb-1 metric-title text-truncate" title="{% translate "Valeur d'aménité calculée" %}">
<i class="bi bi-currency-euro text-success me-1"></i>{% translate "Aménité" %}
</div>
<div class="fs-5 fw-bold text-success metric-value">
{% if asset.amenity_value %}
{{ asset.amenity_value|floatformat:0 }} <small class="text-muted fw-normal" style="font-size: 0.75rem;">€</small>
{% else %}
<span class="text-muted fs-6">&mdash;</span>
{% endif %}
</div>
</div>
</div>
<!-- Stock carbone -->
<div class="col-6 col-md-3">
<div class="p-2 border border-primary-subtle rounded text-center bg-white shadow-sm tree-metric-widget">
<div class="small text-muted mb-1 metric-title text-truncate" title="{% translate 'Stock de carbone séquestré' %}">
<i class="bi bi-cloud-check text-primary me-1"></i>{% translate "Carbone" %}
</div>
<div class="fs-5 fw-bold text-primary metric-value">
{% if asset.carbon_stock %}
{{ asset.carbon_stock|floatformat:2 }} <small class="text-muted fw-normal" style="font-size: 0.75rem;">t CO₂</small>
{% else %}
<span class="text-muted fs-6">&mdash;</span>
{% endif %}
</div>
</div>
</div>
<!-- Rafraîchissement urbain -->
<div class="col-6 col-md-3">
<div class="p-2 border border-info-subtle rounded text-center bg-white shadow-sm tree-metric-widget">
<div class="small text-muted mb-1 metric-title text-truncate" title="{% translate 'Potentiel de rafraîchissement urbain' %}">
<i class="bi bi-thermometer-snow text-info me-1"></i>{% translate "Fraîcheur" %}
</div>
<div class="fs-5 fw-bold text-info-emphasis metric-value">
{% if asset.cooling_indicator %}
{{ asset.cooling_indicator|floatformat:1 }} <small class="text-muted fw-normal" style="font-size: 0.75rem;">/ 10</small>
{% else %}
<span class="text-muted fs-6">&mdash;</span>
{% endif %}
</div>
</div>
</div>
<!-- Indice de biodiversité -->
<div class="col-6 col-md-3">
<div class="p-2 border border-warning-subtle rounded text-center bg-white shadow-sm tree-metric-widget">
<div class="small text-muted mb-1 metric-title text-truncate" title="{% translate "Indice d'accueil biodiversité" %}">
<i class="bi bi-bug text-warning me-1"></i>{% translate "Biodiversité" %}
</div>
<div class="fs-5 fw-bold text-warning-emphasis metric-value">
{% if asset.biodiversity_index %}
{{ asset.biodiversity_index|floatformat:2 }}
{% else %}
<span class="text-muted fs-6">&mdash;</span>
{% endif %}
</div>
</div>
</div>
</div>
{# Table détaillée des indicateurs écosystémiques #}
<table class="table table-striped mb-0 align-middle">
<tbody>
<!-- Énergie de refroidissement -->
<tr class="asset-editable-field" data-field="cooling_energy_indicator" data-can-edit="{% if 'cooling_energy_indicator' in allowed_update_fields %}true{% else %}false{% endif %}">
<th style="width: 35%;">{% translate "Énergie de refroidissement (kWh)" %}</th>
<td>
<div class="field-display">
<span class="field-value">
{% if asset.cooling_energy_indicator %}
{{ asset.cooling_energy_indicator|floatformat:1 }} kWh
{% else %}
-
{% endif %}
</span>
</div>
<div class="field-edit d-none">
<input type="number" step="0.1" class="form-control form-control-sm" value="{{ asset.cooling_energy_indicator|default:'' }}">
</div>
</td>
</tr>
<!-- Coefficient de situation -->
<tr class="asset-editable-field" data-field="situation_coefficient" data-can-edit="{% if 'situation_coefficient' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Coefficient de situation (barème)" %}</th>
<td>
<div class="field-display"><span class="field-value">{{ asset.situation_coefficient|default:"-" }}</span></div>
<div class="field-edit d-none">
<input type="number" step="0.1" class="form-control form-control-sm" value="{{ asset.situation_coefficient|default:'' }}">
</div>
</td>
</tr>
<!-- Coefficient de plantation -->
<tr class="asset-editable-field" data-field="plantation_coefficient" data-can-edit="{% if 'plantation_coefficient' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Coefficient de plantation (barème)" %}</th>
<td>
<div class="field-display"><span class="field-value">{{ asset.plantation_coefficient|default:"-" }}</span></div>
<div class="field-edit d-none">
<input type="number" step="0.1" class="form-control form-control-sm" value="{{ asset.plantation_coefficient|default:'' }}">
</div>
</td>
</tr>
<!-- Date de plantation réelle -->
<tr class="asset-editable-field" data-field="planting_date" data-can-edit="{% if 'planting_date' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Date de plantation constatée" %}</th>
<td>
<div class="field-display">
<span class="field-value">
{% if asset.planting_date %}
<i class="bi bi-calendar3 me-1 text-muted"></i>{{ asset.planting_date|date:"d/m/Y" }}
{% else %}
-
{% endif %}
</span>
</div>
<div class="field-edit d-none">
<input type="date" class="form-control form-control-sm" value="{{ asset.planting_date|date:'Y-m-d'|default:'' }}">
</div>
</td>
</tr>
<!-- Date de dépérissement / mort -->
{% if asset.death_date or asset.is_dead or 'death_date' in allowed_update_fields %}
<tr class="asset-editable-field" data-field="death_date" data-can-edit="{% if 'death_date' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Date de mort / dépérissement" %}</th>
<td>
<div class="field-display">
<span class="field-value text-danger">
{% if asset.death_date %}
<i class="bi bi-calendar-x me-1"></i>{{ asset.death_date|date:"d/m/Y" }}
{% else %}
-
{% endif %}
</span>
</div>
<div class="field-edit d-none">
<input type="date" class="form-control form-control-sm" value="{{ asset.death_date|date:'Y-m-d'|default:'' }}">
</div>
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
{# ── CARTE 3 : DIAGNOSTIC SANITAIRE & SÉCURITÉ ── #}
<div class="card mb-3 shadow-sm">
<div class="card-header bg-light d-flex align-items-center justify-content-between">

View file

@ -342,20 +342,25 @@
<select name="action_pole_db_{{ item.code }}" class="form-select form-select-sm rounded-2">
<option value="keep">{% translate "Conserver en DB" %}</option>
<option value="archive" selected>{% translate "Archiver" %}</option>
<option value="delete">{% translate "Supprimer de la DB" %}</option>
</select>
{% else %}
<select name="action_pole_{{ item.code }}" class="form-select form-select-sm rounded-2">
{% if item.status == 'new' %}
<option value="add" selected>{% translate "Créer l'asset" %}</option>
<option value="keep">{% translate "Ignorer" %}</option>
<option value="keep">{% translate "Ne pas créer (Ignorer)" %}</option>
{% elif item.status == 'modified' %}
<option value="modify" selected>{% translate "Mettre à jour l'asset" %}</option>
<option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option>
<option value="keep">{% translate "Conserver existant" %}</option>
<option value="archive">{% translate "Archiver l'existant (Ne pas recréer)" %}</option>
<option value="delete">{% translate "Supprimer l'existant de la DB" %}</option>
{% elif item.status == 'identical' %}
<option value="keep" selected>{% translate "Ne rien faire (Identique)" %}</option>
<option value="modify">{% translate "Mettre à jour l'asset" %}</option>
<option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option>
<option value="archive">{% translate "Archiver l'existant" %}</option>
<option value="delete">{% translate "Supprimer l'existant de la DB" %}</option>
{% endif %}
</select>
{% endif %}
@ -438,19 +443,25 @@
<select name="action_lantern_db_{{ item.code }}" class="form-select form-select-sm rounded-2">
<option value="keep">{% translate "Conserver en DB" %}</option>
<option value="archive" selected>{% translate "Archiver" %}</option>
<option value="delete">{% translate "Supprimer de la DB" %}</option>
</select>
{% else %}
<select name="action_lantern_{{ item.code }}" class="form-select form-select-sm rounded-2">
{% if item.status == 'new' %}
<option value="add" selected>{% translate "Créer l'asset" %}</option>
<option value="keep">{% translate "Ignorer" %}</option>
<option value="keep">{% translate "Ne pas créer (Ignorer)" %}</option>
{% elif item.status == 'modified' %}
<option value="modify" selected>{% translate "Mettre à jour l'asset" %}</option>
<option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option>
<option value="keep">{% translate "Conserver existant" %}</option>
<option value="archive">{% translate "Archiver l'existant (Ne pas recréer)" %}</option>
<option value="delete">{% translate "Supprimer l'existant de la DB" %}</option>
{% elif item.status == 'identical' %}
<option value="keep" selected>{% translate "Ne rien faire (Identique)" %}</option>
<option value="modify">{% translate "Mettre à jour l'asset" %}</option>
<option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option>
<option value="archive">{% translate "Archiver l'existant" %}</option>
<option value="delete">{% translate "Supprimer l'existant de la DB" %}</option>
{% endif %}
</select>
{% endif %}
@ -503,11 +514,11 @@
</thead>
<tbody>
{% for item in detectors %}
<tr class="{% if item.status == 'new' %}table-success-light{% elif item.status == 'modified' %}table-warning-light{% elif item.status == 'db_only' %}table-danger-light{% endif %}">
<tr class="{% if item.status == 'new' %}table-success-light{% elif item.status == 'modified' %}table-warning-light{% elif item.status == 'db_only' %}{% if item.action_default == 'keep' %}table-light{% else %}table-danger-light{% endif %}{% endif %}">
<td class="ps-4"><input type="checkbox" name="{% if item.status == 'db_only' %}selected_detector_db_{{ item.code }}{% else %}selected_detector_{{ item.code }}{% endif %}" class="form-check-input detector-checkbox" checked></td>
<td class="font-weight-medium"><code>{{ item.code }}</code><input type="hidden" name="db_id_detector_{{ item.code }}" value="{{ item.db_id|default:'' }}"></td>
<td>
<input type="text" name="name_detector_{{ item.code }}" class="form-control form-control-sm" value="{{ item.name }}">
<input type="text" name="name_detector_{{ item.code }}" class="form-control form-control-sm" value="{{ item.name }}">
</td>
<td><code>{{ item.pole_code|default:"—" }}</code></td>
<td>
@ -518,7 +529,11 @@
{% elif item.status == 'identical' %}
<span class="badge bg-light text-secondary border">{% translate "Identique" %}</span>
{% elif item.status == 'db_only' %}
<span class="badge bg-danger">{% translate "DB uniquement" %}</span>
{% if item.action_default == 'keep' %}
<span class="badge bg-secondary">{% translate "DB uniquement" %}</span>
{% else %}
<span class="badge bg-danger">{% translate "DB uniquement" %}</span>
{% endif %}
{% endif %}
{% for diff in item.details_diff %}
<div class="small text-muted mt-1" style="font-size: 0.75rem; line-height: 1.25;">
@ -531,21 +546,27 @@
<td>
{% if item.status == 'db_only' %}
<select name="action_detector_db_{{ item.code }}" class="form-select form-select-sm rounded-2">
<option value="keep">{% translate "Conserver en DB" %}</option>
<option value="archive" selected>{% translate "Archiver" %}</option>
<option value="keep" {% if item.action_default == 'keep' %}selected{% endif %}>{% translate "Conserver en DB" %}</option>
<option value="archive" {% if item.action_default != 'keep' %}selected{% endif %}>{% translate "Archiver" %}</option>
<option value="delete">{% translate "Supprimer de la DB" %}</option>
</select>
{% else %}
<select name="action_detector_{{ item.code }}" class="form-select form-select-sm rounded-2">
{% if item.status == 'new' %}
<option value="add" selected>{% translate "Créer l'asset" %}</option>
<option value="keep">{% translate "Ignorer" %}</option>
<option value="keep">{% translate "Ne pas créer (Ignorer)" %}</option>
{% elif item.status == 'modified' %}
<option value="modify" selected>{% translate "Mettre à jour l'asset" %}</option>
<option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option>
<option value="keep">{% translate "Conserver existant" %}</option>
<option value="archive">{% translate "Archiver l'existant (Ne pas recréer)" %}</option>
<option value="delete">{% translate "Supprimer l'existant de la DB" %}</option>
{% elif item.status == 'identical' %}
<option value="keep" selected>{% translate "Ne rien faire (Identique)" %}</option>
<option value="modify">{% translate "Mettre à jour l'asset" %}</option>
<option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option>
<option value="archive">{% translate "Archiver l'existant" %}</option>
<option value="delete">{% translate "Supprimer l'existant de la DB" %}</option>
{% endif %}
</select>
{% endif %}
@ -640,6 +661,11 @@
{% else %}
<span class="badge bg-light text-muted border">{% translate "Non tracé" %}</span>
{% endif %}
{% if item.status != 'db_only' %}
<button type="button" class="btn btn-xs btn-outline-primary ms-1 show-cable-on-map-btn" data-code="{{ item.code }}" title="{% translate 'Voir et éditer sur la carte' %}">
<i class="bi bi-geo-alt"></i>
</button>
{% endif %}
</td>
<td><span class="text-secondary small">{{ item.deduced_model_name|default:"—" }}</span></td>
<td><span class="text-secondary small">{{ item.db_model_name|default:"—" }}</span></td>
@ -648,20 +674,25 @@
<select name="action_cable_db_{{ item.code }}" class="form-select form-select-sm rounded-2">
<option value="keep">{% translate "Conserver en DB" %}</option>
<option value="archive" selected>{% translate "Archiver" %}</option>
<option value="delete">{% translate "Supprimer de la DB" %}</option>
</select>
{% else %}
<select name="action_cable_{{ item.code }}" class="form-select form-select-sm rounded-2">
{% if item.status == 'new' %}
<option value="add" selected>{% translate "Créer l'asset" %}</option>
<option value="keep">{% translate "Ignorer" %}</option>
<option value="keep">{% translate "Ne pas créer (Ignorer)" %}</option>
{% elif item.status == 'modified' %}
<option value="modify" selected>{% translate "Mettre à jour l'asset" %}</option>
<option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option>
<option value="keep">{% translate "Conserver existant" %}</option>
<option value="archive">{% translate "Archiver l'existant (Ne pas recréer)" %}</option>
<option value="delete">{% translate "Supprimer l'existant de la DB" %}</option>
{% elif item.status == 'identical' %}
<option value="keep" selected>{% translate "Ne rien faire (Identique)" %}</option>
<option value="modify">{% translate "Mettre à jour l'asset" %}</option>
<option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option>
<option value="archive">{% translate "Archiver l'existant" %}</option>
<option value="delete">{% translate "Supprimer l'existant de la DB" %}</option>
{% endif %}
</select>
{% endif %}
@ -749,6 +780,7 @@
<select name="action_other_{{ item.unique_key }}" class="form-select form-select-sm rounded-2">
<option value="keep" selected>{% translate "Conserver (Par défaut)" %}</option>
<option value="archive">{% translate "Archiver" %}</option>
<option value="delete">{% translate "Supprimer de la DB" %}</option>
</select>
</td>
</tr>
@ -847,13 +879,19 @@
<!-- Map Cables Sub-pane -->
<div class="tab-pane fade h-100" id="map-cables-pane" role="tabpanel" aria-labelledby="map-cables-tab">
<div class="d-flex flex-column h-100 align-items-start justify-content-start">
<div class="mb-2 p-2 bg-light rounded-3 border w-100">
<div class="font-weight-semibold text-success mb-1">
<i class="bi bi-bezier"></i> {% translate "Tracé des câbles" %}
<div class="mb-2 p-2.5 bg-light rounded-3 border w-100 shadow-sm" style="font-size: 0.78rem;">
<div class="d-flex align-items-center justify-content-between mb-1.5 pb-1 border-bottom">
<span class="font-weight-semibold text-success d-flex align-items-center gap-1">
<i class="bi bi-bezier"></i> {% translate "Aide au tracé des câbles" %}
</span>
</div>
<p class="text-secondary mb-0 small">
{% translate "Cliquez sur 'Tracer / Éditer' puis double-cliquez sur la carte pour tracer la ligne du câble." %}
</p>
<ul class="list-unstyled mb-0 text-secondary d-flex flex-column gap-1" style="line-height: 1.35;">
<li><i class="bi bi-plus-circle text-primary me-1"></i><strong>{% translate "Tracer" %} :</strong> {% translate "Double-clic sur la carte pour ajouter un point." %}</li>
<li><i class="bi bi-node-plus text-success me-1"></i><strong>{% translate "Ajouter un nœud" %} :</strong> {% translate "Double-clic sur un segment du câble." %}</li>
<li><i class="bi bi-node-minus text-danger me-1"></i><strong>{% translate "Supprimer un nœud" %} :</strong> {% translate "Double-clic sur un nœud (poignée orange)." %}</li>
<li><i class="bi bi-arrows-move text-warning me-1"></i><strong>{% translate "Déplacer" %} :</strong> {% translate "Glisser-déposer les poignées pour ajuster." %}</li>
<li><i class="bi bi-files text-info me-1"></i><strong>{% translate "Copier tracé" %} :</strong> {% translate "Copiez le tracé d'un autre câble suivant le même trajet (ex: 48 et tel.)." %}</li>
</ul>
</div>
<div class="list-group list-group-flush overflow-auto flex-grow-1 w-100 align-self-stretch">
@ -862,12 +900,23 @@
<div class="list-group-item py-2 px-1 border-bottom d-flex flex-column gap-1 cable-config-item align-self-stretch" data-code="{{ item.code }}">
<div class="d-flex align-items-center justify-content-between w-100">
<strong class="text-dark font-monospace">{{ item.name }}</strong>
<span class="badge cable-geom-status-badge font-monospace" id="badge-cable-status-{{ item.code }}" style="font-size: 0.72rem;">
<span class="text-muted">…</span>
</span>
</div>
<div class="text-muted small font-monospace">{{ item.deduced_model_name }}</div>
<div class="mt-1 align-self-start">
<div class="mt-1 d-flex align-items-center gap-1 flex-wrap">
<button type="button" class="btn btn-xs btn-outline-success py-0.5 px-2 edit-cable-btn rounded-2" data-code="{{ item.code }}">
<i class="bi bi-pencil-square"></i> {% translate "Tracer / Éditer" %}
</button>
<div class="dropdown d-inline-block">
<button type="button" class="btn btn-xs btn-outline-secondary dropdown-toggle py-0.5 px-2 rounded-2 copy-cable-btn" data-bs-toggle="dropdown" aria-expanded="false" title="{% translate "Copier le tracé d'un autre câble" %}">
<i class="bi bi-files"></i> {% translate "Copier tracé…" %}
</button>
<ul class="dropdown-menu dropdown-menu-end shadow-sm copy-cable-menu py-1" data-target-code="{{ item.code }}" style="font-size: 0.8rem; min-width: 220px;">
<!-- Populated dynamically by JS -->
</ul>
</div>
</div>
</div>
{% endif %}
@ -887,10 +936,17 @@
<!-- Mode indicator centered at the top -->
<div class="position-absolute top-0 start-50 translate-middle-x m-3 z-3 bg-white py-1 px-3 rounded-pill shadow border d-flex align-items-center gap-2" style="white-space: nowrap;">
<span class="small text-muted font-monospace font-weight-semibold mb-0" id="map-mode-indicator">{% translate "Mode: Lecture" %}</span>
<button type="button" id="btn-duplicate-trace" class="btn btn-xs btn-outline-primary rounded-pill py-0.5 px-2" style="display: none; font-size: 0.75rem;">
<i class="bi bi-copy"></i> {% translate "Appliquer aux autres câbles…" %}
</button>
<button type="button" id="btn-clear-trace" class="btn btn-xs btn-outline-danger rounded-pill py-0.5 px-2" style="display: none; font-size: 0.75rem;">
<i class="bi bi-trash"></i> {% translate "Effacer" %}
</button>
</div>
<div id="cable-edit-tip" class="position-absolute start-50 translate-middle-x z-2 px-3 py-1 bg-white bg-opacity-95 rounded-pill shadow-sm border small text-muted text-center" style="top: 56px; display: none; font-size: 0.75rem; white-space: nowrap;">
<i class="bi bi-info-circle text-primary me-1"></i>
<span>{% translate "Double-clic sur un segment pour ajouter un nœud • Double-clic sur un nœud pour le supprimer • Glisser pour déplacer" %}</span>
</div>
<div id="map" class="w-100 h-100"></div>
</div>
@ -899,6 +955,46 @@
</div>
</form>
<!-- Modal: Appliquer le tracé d'un câble à d'autres câbles -->
<div class="modal fade" id="duplicateCableModal" tabindex="-1" aria-labelledby="duplicateCableModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content rounded-4 border-0 shadow">
<div class="modal-header border-bottom py-2.5 px-3">
<h6 class="modal-title font-weight-semibold text-dark d-flex align-items-center gap-2 mb-0" id="duplicateCableModalLabel">
<i class="bi bi-copy text-primary"></i> {% translate "Appliquer le tracé à d'autres câbles" %}
</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-3">
<p class="text-secondary small mb-3">
{% translate "Copier le tracé de" %} <strong id="modal-source-cable-name" class="text-dark"></strong> <span id="modal-source-cable-info" class="badge bg-light text-secondary border"></span> {% translate "vers les câbles sélectionnés ci-dessous :" %}
</p>
<div class="p-2.5 bg-light rounded-3 border mb-2">
<div class="form-check mb-0">
<input class="form-check-input" type="checkbox" id="modal-select-all-cables">
<label class="form-check-label font-weight-semibold small text-dark cursor-pointer" for="modal-select-all-cables">
{% translate "Tout sélectionner" %}
</label>
</div>
</div>
<div id="modal-target-cables-list" class="d-flex flex-column gap-1.5 overflow-auto pe-1" style="max-height: 240px;">
<!-- Dynamically generated list of cables -->
</div>
</div>
<div class="modal-footer border-top py-2 px-3 d-flex justify-content-between">
<button type="button" class="btn btn-sm btn-outline-secondary rounded-2" data-bs-dismiss="modal">
{% translate "Annuler" %}
</button>
<button type="button" id="btn-confirm-duplicate-cable" class="btn btn-sm btn-primary rounded-2 px-3 d-flex align-items-center gap-1.5">
<i class="bi bi-check-lg"></i> {% translate "Copier le tracé" %}
</button>
</div>
</div>
</div>
</div>
{% endif %}
</div>
@ -1057,30 +1153,47 @@ document.addEventListener('DOMContentLoaded', () => {
const tableView = document.getElementById('table-view');
const mapView = document.getElementById('map-view');
function switchToMapView(targetTab, cableCodeToEdit) {
tableView.classList.remove('d-block');
tableView.classList.add('d-none');
mapView.classList.remove('d-none');
mapView.classList.add('d-block');
// Trigger MapLibre GL canvas resize recalculation
if (map) {
map.resize();
}
// Activate corresponding tab in map sidebar
if (targetTab === 'poles') {
const tabEl = document.getElementById('map-poles-tab');
if (tabEl) new bootstrap.Tab(tabEl).show();
} else if (targetTab === 'cables') {
const tabEl = document.getElementById('map-cables-tab');
if (tabEl) new bootstrap.Tab(tabEl).show();
if (cableCodeToEdit) {
activateCableEditingMode(cableCodeToEdit);
const item = document.querySelector(`.cable-config-item[data-code="${cableCodeToEdit}"]`);
if (item) {
item.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
}
}
document.querySelectorAll('.show-map-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
e.preventDefault();
const targetTab = this.dataset.targetTab;
tableView.classList.remove('d-block');
tableView.classList.add('d-none');
mapView.classList.remove('d-none');
mapView.classList.add('d-block');
// Trigger MapLibre GL canvas resize recalculation
if (map) {
map.resize();
}
// Activate corresponding tab in map sidebar
if (targetTab === 'poles') {
const tabEl = document.getElementById('map-poles-tab');
if (tabEl) new bootstrap.Tab(tabEl).show();
} else if (targetTab === 'cables') {
const tabEl = document.getElementById('map-cables-tab');
if (tabEl) new bootstrap.Tab(tabEl).show();
}
switchToMapView(this.dataset.targetTab);
});
});
document.querySelectorAll('.show-cable-on-map-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
switchToMapView('cables', this.dataset.code);
});
});
@ -1151,7 +1264,11 @@ document.addEventListener('DOMContentLoaded', () => {
name: "{{ item.name|escapejs }}",
status: "{{ item.status|escapejs }}",
geom_modified: {{ item.geom_modified|yesno:"true,false" }},
connected_poles: {{ item.connected_poles|safe|default:"[]" }}
connected_poles: {{ item.connected_poles|safe|default:"[]" }},
db_coords: {{ item.db_coords|safe|default:"[]" }},
has_db_geom: {{ item.has_db_geom|yesno:"true,false" }},
db_geom_length: {% if item.db_geom_length is not None %}{{ item.db_geom_length|unlocalize }}{% else %}null{% endif %},
deduced_model_name: "{{ item.deduced_model_name|default:''|escapejs }}"
},
{% endfor %}
];
@ -1186,6 +1303,87 @@ document.addEventListener('DOMContentLoaded', () => {
const placedPoles = {};
let layerSwitcher = null;
function calculateLineLengthMeters(coords) {
if (!coords || coords.length < 2) return 0;
const R = 6371000;
let total = 0;
for (let i = 0; i < coords.length - 1; i++) {
const lon1 = coords[i][0] * Math.PI / 180;
const lat1 = coords[i][1] * Math.PI / 180;
const lon2 = coords[i+1][0] * Math.PI / 180;
const lat2 = coords[i+1][1] * Math.PI / 180;
const dLat = lat2 - lat1;
const dLon = lon2 - lon1;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1) * Math.cos(lat2) *
Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
total += R * c;
}
return Math.round(total * 10) / 10;
}
function getCableCoords(code) {
if (cableGeometries[code] && cableGeometries[code].length >= 2) {
return cableGeometries[code];
}
const c = cablesList.find(item => item.code === code);
if (!c) return [];
if (c.db_coords && c.db_coords.length >= 2) {
return c.db_coords;
}
const coords = [];
const connected = c.connected_poles || [];
connected.forEach(pCode => {
if (placedPoles[pCode]) {
coords.push([placedPoles[pCode].lon, placedPoles[pCode].lat]);
}
});
return coords;
}
function updateCableBadge(code) {
const badge = document.getElementById(`badge-cable-status-${code}`);
const coords = getCableCoords(code);
const c = cablesList.find(item => item.code === code);
if (!badge || !c) return;
if (coords && coords.length >= 2) {
const len = calculateLineLengthMeters(coords);
const isModified = cableGeometries[code] !== undefined || c.geom_modified;
if (isModified) {
badge.className = 'badge bg-warning text-dark border border-warning-subtle font-monospace cable-geom-status-badge';
badge.innerHTML = `<i class="bi bi-bezier2"></i> ${coords.length} pts (${len} m)`;
} else {
badge.className = 'badge bg-info-subtle text-info-emphasis border border-info-subtle font-monospace cable-geom-status-badge';
badge.innerHTML = `<i class="bi bi-bezier2"></i> ${coords.length} pts (${len} m)`;
}
} else {
badge.className = 'badge bg-light text-muted border font-monospace cable-geom-status-badge';
badge.textContent = `{% translate "Non tracé" %}`;
}
}
function updateTableCableGeomCell(code) {
const geomCell = document.getElementById(`geom_cell_cable_${code}`);
if (!geomCell) return;
const coords = getCableCoords(code);
if (coords && coords.length >= 2) {
const len = calculateLineLengthMeters(coords);
geomCell.innerHTML = `<span class="badge bg-warning text-dark d-inline-flex align-items-center gap-1"><i class="bi bi-arrows-move"></i> {% translate "Tracé modifié" %} (${len} m)</span> <button type="button" class="btn btn-xs btn-outline-primary ms-1 show-cable-on-map-btn" data-code="${code}" title="{% translate 'Voir et éditer sur la carte' %}"><i class="bi bi-geo-alt"></i></button>`;
} else {
geomCell.innerHTML = `<span class="badge bg-light text-muted border">{% translate "Non tracé" %}</span> <button type="button" class="btn btn-xs btn-outline-primary ms-1 show-cable-on-map-btn" data-code="${code}" title="{% translate 'Tracer sur la carte' %}"><i class="bi bi-geo-alt"></i></button>`;
}
const btn = geomCell.querySelector('.show-cable-on-map-btn');
if (btn) {
btn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
switchToMapView('cables', code);
});
}
}
function markCableAsModified(code) {
const selectAction = document.querySelector(`select[name="action_cable_${code}"]`);
if (selectAction) {
@ -1198,10 +1396,8 @@ document.addEventListener('DOMContentLoaded', () => {
const cb = document.querySelector(`input[name="selected_cable_${code}"]`);
if (cb) cb.checked = true;
const geomCell = document.getElementById(`geom_cell_cable_${code}`);
if (geomCell) {
geomCell.innerHTML = `<span class="badge bg-warning text-dark d-inline-flex align-items-center gap-1"><i class="bi bi-arrows-move"></i> {% translate "Tracé modifié" %}</span>`;
}
updateTableCableGeomCell(code);
updateCableBadge(code);
}
const snapCandidates = initialDxfPoles.map(dp => [dp.lon, dp.lat, dp.x_3812, dp.y_3812]);
@ -1691,8 +1887,12 @@ document.addEventListener('DOMContentLoaded', () => {
deactivateCableEditingMode();
activeEditCable = code;
document.getElementById('map-mode-indicator').textContent = `{% translate "Mode: Tracé" %} ${cablesList.find(c => c.code === code).name}`;
const cableInfo = cablesList.find(c => c.code === code);
const cableName = cableInfo ? cableInfo.name : code;
document.getElementById('map-mode-indicator').textContent = `{% translate "Mode: Tracé" %} ${cableName}`;
document.getElementById('btn-clear-trace').style.display = 'block';
document.getElementById('btn-duplicate-trace').style.display = 'block';
document.getElementById('cable-edit-tip').style.display = 'block';
const mapPane = document.getElementById('map-cables-pane');
mapPane.querySelectorAll('.cable-config-item').forEach(item => item.classList.remove('bg-light'));
@ -1701,25 +1901,21 @@ document.addEventListener('DOMContentLoaded', () => {
let coords = cableGeometries[code];
if (!coords || coords.length === 0) {
const cableInfo = cablesList.find(c => c.code === code);
const connected = cableInfo.connected_poles || [];
coords = [];
connected.forEach(pCode => {
if (placedPoles[pCode]) {
coords.push([placedPoles[pCode].lon, placedPoles[pCode].lat]);
}
});
const defaultCoords = getCableCoords(code);
coords = defaultCoords && defaultCoords.length > 0 ? defaultCoords.map(pt => [pt[0], pt[1]]) : [];
cableGeometries[code] = coords;
}
drawCableHandles(code);
redrawCablesOnMap();
updateCableBadge(code);
}
function deactivateCableEditingMode() {
activeEditCable = null;
document.getElementById('btn-clear-trace').style.display = 'none';
document.getElementById('btn-duplicate-trace').style.display = 'none';
document.getElementById('cable-edit-tip').style.display = 'none';
document.getElementById('map-mode-indicator').textContent = `{% translate "Mode: Lecture" %}`;
cableHandles.forEach(h => h.remove());
@ -1768,6 +1964,7 @@ document.addEventListener('DOMContentLoaded', () => {
redrawCablesOnMap();
saveCablesToForm();
markCableAsModified(code);
populateCopyCableMenus();
});
el.addEventListener('dblclick', function(e) {
@ -1777,6 +1974,7 @@ document.addEventListener('DOMContentLoaded', () => {
redrawCablesOnMap();
saveCablesToForm();
markCableAsModified(code);
populateCopyCableMenus();
});
});
}
@ -1834,6 +2032,7 @@ document.addEventListener('DOMContentLoaded', () => {
redrawCablesOnMap();
saveCablesToForm();
markCableAsModified(activeEditCable);
populateCopyCableMenus();
});
map.on('dblclick', function(e) {
@ -1857,6 +2056,7 @@ document.addEventListener('DOMContentLoaded', () => {
redrawCablesOnMap();
saveCablesToForm();
markCableAsModified(activeEditCable);
populateCopyCableMenus();
});
// Hover mouse styles for active cable line
@ -1880,20 +2080,192 @@ document.addEventListener('DOMContentLoaded', () => {
redrawCablesOnMap();
saveCablesToForm();
markCableAsModified(activeEditCable);
populateCopyCableMenus();
}
});
function copyCableGeometry(sourceCode, targetCode) {
const srcCoords = getCableCoords(sourceCode);
if (!srcCoords || srcCoords.length < 2) {
alert("{% translate "Le câble source ne possède aucun tracé valide à copier." %}");
return false;
}
cableGeometries[targetCode] = srcCoords.map(pt => [pt[0], pt[1]]);
saveCablesToForm();
markCableAsModified(targetCode);
populateCopyCableMenus();
if (activeEditCable === targetCode) {
drawCableHandles(targetCode);
redrawCablesOnMap();
}
return true;
}
function populateCopyCableMenus() {
document.querySelectorAll('.copy-cable-menu').forEach(menu => {
const targetCode = menu.dataset.targetCode;
menu.innerHTML = '';
let hasAvailable = false;
cablesList.forEach(other => {
if (other.code === targetCode || other.status === 'db_only') return;
const coords = getCableCoords(other.code);
if (coords && coords.length >= 2) {
hasAvailable = true;
const len = calculateLineLengthMeters(coords);
const li = document.createElement('li');
li.innerHTML = `
<button type="button" class="dropdown-item py-1.5 px-3 d-flex align-items-center justify-content-between copy-from-cable-btn" data-source="${other.code}" data-target="${targetCode}">
<span class="font-monospace text-truncate me-2">${other.name}</span>
<span class="badge bg-light text-secondary border font-monospace" style="font-size: 0.7rem;">${coords.length} pts (${len} m)</span>
</button>
`;
menu.appendChild(li);
}
});
if (!hasAvailable) {
const li = document.createElement('li');
li.className = 'dropdown-item text-muted small fst-italic py-2';
li.textContent = `{% translate "Aucun tracé disponible" %}`;
menu.appendChild(li);
}
});
// Rebind click listeners for dynamically populated dropdown items
document.querySelectorAll('.copy-from-cable-btn').forEach(btn => {
btn.onclick = function(e) {
e.preventDefault();
const source = this.dataset.source;
const target = this.dataset.target;
const success = copyCableGeometry(source, target);
if (success) {
const targetCable = cablesList.find(c => c.code === target);
const sourceCable = cablesList.find(c => c.code === source);
const targetName = targetCable ? targetCable.name : target;
const sourceName = sourceCable ? sourceCable.name : source;
const tipEl = document.getElementById('cable-edit-tip');
if (tipEl) {
tipEl.style.display = 'block';
const origContent = tipEl.innerHTML;
tipEl.innerHTML = `<i class="bi bi-check-circle-fill text-success me-1"></i><strong>{% translate "Tracé copié de" %} ${sourceName} {% translate "vers" %} ${targetName}</strong>`;
setTimeout(() => {
if (!activeEditCable) tipEl.style.display = 'none';
else tipEl.innerHTML = origContent;
}, 3000);
}
}
};
});
}
// Modal Duplicate Trace logic
const duplicateModalEl = document.getElementById('duplicateCableModal');
const duplicateModal = duplicateModalEl ? new bootstrap.Modal(duplicateModalEl) : null;
const btnDuplicateTrace = document.getElementById('btn-duplicate-trace');
if (btnDuplicateTrace) {
btnDuplicateTrace.addEventListener('click', function() {
if (!activeEditCable) return;
const currentCable = cablesList.find(c => c.code === activeEditCable);
const coords = getCableCoords(activeEditCable);
if (!coords || coords.length < 2) {
alert("{% translate "Veuillez d'abord tracer au moins deux points pour ce câble avant de le dupliquer." %}");
return;
}
const len = calculateLineLengthMeters(coords);
const srcNameEl = document.getElementById('modal-source-cable-name');
const srcInfoEl = document.getElementById('modal-source-cable-info');
if (srcNameEl) srcNameEl.textContent = currentCable ? currentCable.name : activeEditCable;
if (srcInfoEl) srcInfoEl.textContent = `${coords.length} pts • ${len} m`;
const listContainer = document.getElementById('modal-target-cables-list');
if (listContainer) {
listContainer.innerHTML = '';
const otherCables = cablesList.filter(c => c.code !== activeEditCable && c.status !== 'db_only');
if (otherCables.length === 0) {
listContainer.innerHTML = `<div class="text-center text-muted small py-3">{% translate "Aucun autre câble disponible dans ce carrefour." %}</div>`;
const confirmBtn = document.getElementById('btn-confirm-duplicate-cable');
if (confirmBtn) confirmBtn.disabled = true;
} else {
const confirmBtn = document.getElementById('btn-confirm-duplicate-cable');
if (confirmBtn) confirmBtn.disabled = false;
otherCables.forEach(c => {
const cCoords = getCableCoords(c.code);
const hasGeom = cCoords && cCoords.length >= 2;
const cLen = hasGeom ? calculateLineLengthMeters(cCoords) : null;
const geomBadge = hasGeom
? `<span class="badge bg-light text-secondary border font-monospace ms-auto" style="font-size: 0.7rem;">${cCoords.length} pts (${cLen} m)</span>`
: `<span class="badge bg-light text-muted border font-monospace ms-auto" style="font-size: 0.7rem;">{% translate "Non tracé" %}</span>`;
const itemDiv = document.createElement('div');
itemDiv.className = 'form-check d-flex align-items-center p-2 rounded border bg-white';
itemDiv.innerHTML = `
<input class="form-check-input modal-cable-target-cb me-2" type="checkbox" value="${c.code}" id="modal_cb_target_${c.code}">
<label class="form-check-label small text-dark d-flex align-items-center w-100 cursor-pointer mb-0" for="modal_cb_target_${c.code}">
<span class="font-monospace font-weight-semibold me-2">${c.name}</span>
<span class="text-muted small">${c.deduced_model_name ? '(' + c.deduced_model_name + ')' : ''}</span>
${geomBadge}
</label>
`;
listContainer.appendChild(itemDiv);
});
}
}
const selectAllCb = document.getElementById('modal-select-all-cables');
if (selectAllCb) selectAllCb.checked = false;
if (duplicateModal) duplicateModal.show();
});
}
const modalSelectAllCables = document.getElementById('modal-select-all-cables');
if (modalSelectAllCables) {
modalSelectAllCables.addEventListener('change', function() {
const checked = this.checked;
document.querySelectorAll('.modal-cable-target-cb').forEach(cb => {
cb.checked = checked;
});
});
}
const btnConfirmDuplicateCable = document.getElementById('btn-confirm-duplicate-cable');
if (btnConfirmDuplicateCable) {
btnConfirmDuplicateCable.addEventListener('click', function() {
if (!activeEditCable) return;
const selectedCbs = document.querySelectorAll('.modal-cable-target-cb:checked');
if (selectedCbs.length === 0) {
alert("{% translate "Veuillez sélectionner au moins un câble cible." %}");
return;
}
let appliedCount = 0;
selectedCbs.forEach(cb => {
const targetCode = cb.value;
copyCableGeometry(activeEditCable, targetCode);
appliedCount++;
});
if (duplicateModal) duplicateModal.hide();
const tipEl = document.getElementById('cable-edit-tip');
if (tipEl) {
const origContent = tipEl.innerHTML;
tipEl.innerHTML = `<i class="bi bi-check-circle-fill text-success me-1"></i><strong>${appliedCount} {% translate "câble(s) mis à jour avec ce tracé !" %}</strong>`;
setTimeout(() => {
if (activeEditCable) tipEl.innerHTML = origContent;
}, 3000);
}
});
}
function saveCablesToForm() {
cablesList.forEach(c => {
let coords = cableGeometries[c.code];
if (!coords || coords.length === 0) {
coords = [];
const connected = c.connected_poles || [];
connected.forEach(pCode => {
if (placedPoles[pCode]) {
coords.push([placedPoles[pCode].lon, placedPoles[pCode].lat]);
}
});
coords = getCableCoords(c.code);
}
const input = document.getElementById(`geom_cable_${c.code}`);
if (input && coords && coords.length >= 2) {
@ -1926,13 +2298,7 @@ document.addEventListener('DOMContentLoaded', () => {
let coords = cableGeometries[c.code];
if (!coords || coords.length === 0) {
coords = [];
const connected = c.connected_poles || [];
connected.forEach(pCode => {
if (placedPoles[pCode]) {
coords.push([placedPoles[pCode].lon, placedPoles[pCode].lat]);
}
});
coords = getCableCoords(c.code);
}
if (coords && coords.length >= 2) {
@ -1960,6 +2326,12 @@ document.addEventListener('DOMContentLoaded', () => {
map.triggerRepaint();
}
// Initial cable badges and copy menus update
cablesList.forEach(c => {
updateCableBadge(c.code);
});
populateCopyCableMenus();
// ── Georeferenced Plans & DXF Background Loader ──
const georeferencedDocumentsDataEl = document.getElementById('georeferencedDocumentsData');
let georeferencedDocuments = [];

View file

@ -480,7 +480,7 @@
text-transform: uppercase !important;
letter-spacing: 0.04em !important;
border-bottom: 1.5px solid #e2e8f0 !important;
padding: 0.65rem 0.75rem !important;
padding: 0.45rem 0.65rem !important;
vertical-align: middle !important;
white-space: nowrap !important;
}
@ -489,16 +489,28 @@
padding: 0 !important;
font-size: 0.74rem !important;
font-weight: 600 !important;
line-height: 1.4 !important;
line-height: 1.3 !important;
}
/* Rows & Cells */
.bootstrap-table.bootstrap5 #unified-table td {
padding: 0.65rem 0.75rem !important;
padding: 0.32rem 0.65rem !important;
vertical-align: middle !important;
font-size: 0.83rem !important;
font-size: 0.82rem !important;
color: #334155;
border-bottom: 1px solid #f1f5f9 !important;
line-height: 1.3 !important;
}
.asset-symbology-icon-img {
width: 20px !important;
height: 20px !important;
max-width: 20px !important;
max-height: 20px !important;
object-fit: contain !important;
vertical-align: middle !important;
display: inline-block !important;
margin-right: 6px !important;
}
.bootstrap-table.bootstrap5 #unified-table tbody tr {
@ -670,24 +682,24 @@
<div class="filter-section-header">
<label>{% translate "Type d'élément" %}</label>
</div>
<div class="segmented-control">
<div class="filter-option segmented-option">
<input type="radio" name="unified-type-filter" id="type-all" value="all"
{% if preventive_element_type != 'assets' and preventive_element_type != 'locations' %}checked{% endif %}
{% if preventive_element_type %}disabled{% endif %}>
<label for="type-all">{% translate "Assets & localisations" %}</label>
<div class="filter-options d-flex gap-3 flex-wrap">
<div class="filter-option">
<input type="checkbox" name="filter-element-type" id="type-cb-assets" value="assets"
{% if preventive_element_type != 'locations' %}checked{% endif %}
{% if preventive_element_type == 'locations' %}disabled{% endif %}>
<label for="type-cb-assets"><i class="bi bi-layers me-1 text-primary"></i>{% translate "Assets" %}</label>
</div>
<div class="filter-option segmented-option">
<input type="radio" name="unified-type-filter" id="type-locations" value="locations"
{% if preventive_element_type == 'locations' %}checked{% endif %}
{% if preventive_element_type %}disabled{% endif %}>
<label for="type-locations">{% translate "Localisations uniquement" %}</label>
<div class="filter-option">
<input type="checkbox" name="filter-element-type" id="type-cb-locations" value="locations"
{% if preventive_element_type != 'assets' %}checked{% endif %}
{% if preventive_element_type == 'assets' %}disabled{% endif %}>
<label for="type-cb-locations"><i class="bi bi-geo-alt me-1 text-info"></i>{% translate "Localisations" %}</label>
</div>
<div class="filter-option segmented-option">
<input type="radio" name="unified-type-filter" id="type-assets" value="assets"
{% if preventive_element_type == 'assets' %}checked{% endif %}
<div class="filter-option">
<input type="checkbox" name="filter-element-type" id="type-cb-management-zones" value="management_zones"
{% if not preventive_element_type %}checked{% endif %}
{% if preventive_element_type %}disabled{% endif %}>
<label for="type-assets">{% translate "Assets uniquement" %}</label>
<label for="type-cb-management-zones"><i class="bi bi-diagram-3 me-1 text-success"></i>{% translate "Zones de gestion" %}</label>
</div>
</div>
</div>
@ -837,7 +849,7 @@
{% translate "Choisissez d'abord le type d'élément, puis la thématique autorisée." %}
</div>
<div class="row g-3">
<div class="col-md-6">
<div class="col-md-4">
<div class="unified-add-panel h-100">
<h6 class="d-flex align-items-center gap-2"><i class="bi bi-layers"></i>{% translate "Asset" %}</h6>
<label for="unified-add-asset-thematic" class="form-label small text-muted mb-1">{% translate "Thématique" %}</label>
@ -847,7 +859,7 @@
</button>
</div>
</div>
<div class="col-md-6">
<div class="col-md-4">
<div class="unified-add-panel h-100">
<h6 class="d-flex align-items-center gap-2"><i class="bi bi-geo-alt"></i>{% translate "Localisation" %}</h6>
<label for="unified-add-location-thematic" class="form-label small text-muted mb-1">{% translate "Thématique" %}</label>
@ -857,6 +869,16 @@
</button>
</div>
</div>
<div class="col-md-4">
<div class="unified-add-panel h-100">
<h6 class="d-flex align-items-center gap-2"><i class="bi bi-diagram-3 text-success"></i>{% translate "Zone de gestion" %}</h6>
<label for="unified-add-management-zone-thematic" class="form-label small text-muted mb-1">{% translate "Thématique" %}</label>
<select id="unified-add-management-zone-thematic" class="form-select mb-3"></select>
<button type="button" id="unified-add-management-zone-open" class="btn btn-outline-success w-100">
<i class="bi bi-plus-circle me-1"></i>{% translate "Créer une zone" %}
</button>
</div>
</div>
</div>
<div id="unified-add-chooser-empty" class="text-muted small mt-3 d-none">
{% translate "Aucune thématique éditable n'est disponible pour la création." %}
@ -1293,7 +1315,7 @@
{% block script %}
<script src="{% static 'common/mapbox-gl-draw.js' %}"></script>
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
<script src="{% static 'common/layer-switcher-control.js' %}?v=20261009_basemap"></script>
<script>
window.TERRITORY_MASK_URL = "{% url 'common:get_territory_mask_geojson' %}";
const preventive_draft_id = "{{ preventive_draft_id|default:'' }}";
@ -1331,7 +1353,11 @@
trafficlightelectricalcabinet: "{% static 'assets/electrical-panel.png' %}",
publiclighting: "{% static 'assets/public_lamp_post_icon.svg' %}?v=20260521c",
publiclightingelectricalcabinet: "{% static 'assets/electrical-panel.png' %}",
nature: "{% static 'assets/nature_tree_icon.svg' %}?v=20260521a",
nature: "{% static 'assets/nature_tree_icon.svg' %}?v=20261009_round",
natureregional: "{% static 'assets/nature_tree_icon_regional.svg' %}?v=20261009_round",
natureremarkable: "{% static 'assets/nature_tree_icon_remarkable.svg' %}?v=20261009_round",
naturedead: "{% static 'assets/nature_tree_icon_dead.svg' %}?v=20261009_round",
naturegreensurface: "{% static 'assets/nature_greensurface_icon.svg' %}?v=20261010a",
structures: "{% static 'assets/structures_bridge_tunnel_icon.svg' %}?v=20260522b",
roaddrain: "{% static 'assets/roaddrain_grille_icon.svg' %}?v=20260521a",
speedbump: "{% static 'assets/road_speedbump_icon.svg' %}?v=20260920",
@ -1348,7 +1374,7 @@
<script src="{% static 'common/base_script.js' %}"></script>
<script src="{% static 'common/base_map.js' %}"></script>
<script src="{% static 'assets/sign_panels_shared.js' %}?v=20260703c"></script>
<script src="{% static 'assets/asset_symbology.js' %}?v=20260920_grid_options"></script>
<script src="{% static 'assets/unified_map_layers.js' %}?v=20260920_grid_options"></script>
<script src="{% static 'assets/unified_index.js' %}?v=20260920_grid_options"></script>
<script src="{% static 'assets/asset_symbology.js' %}?v=20261010_greensurface"></script>
<script src="{% static 'assets/unified_map_layers.js' %}?v=20261010_greensurface"></script>
<script src="{% static 'assets/unified_index.js' %}?v=20261010_greensurface"></script>
{% endblock script %}

View file

@ -0,0 +1,92 @@
{% extends "assets/detail_generic.html" %}
{% load i18n %}
{% load static %}
{% block extra_blocks %}
<!-- Système de bornes parent -->
<div class="card mb-3" id="bollard-plot-system-card">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="bi bi-diagram-3-fill text-primary me-2"></i>{% translate "Système de bornes de rattachement" %}
</h5>
{% if asset.system %}
<a href="{{ asset.system.get_absolute_url }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-arrow-right-circle me-1"></i>{% translate "Voir le système complet" %}
</a>
{% endif %}
</div>
<div class="card-body">
{% if asset.system %}
<div class="row align-items-center mb-3">
<div class="col-md-7">
<div class="d-flex align-items-center mb-2">
<span class="badge bg-dark rounded-pill fs-6 me-2">#{{ asset.position }}</span>
<h5 class="mb-0">
<a href="{{ asset.system.get_absolute_url }}" class="text-decoration-none">
{{ asset.system.code }} - {{ asset.system.get_name|default:asset.system.code }}
</a>
</h5>
</div>
<div class="text-muted small mb-2">
<i class="bi bi-geo-alt me-1"></i>{{ asset.system.location|default:"Localisation non définie" }}
</div>
<div class="d-flex flex-wrap gap-2">
{% if asset.system.width %}
<span class="badge bg-light text-dark border">
<i class="bi bi-arrows-expand me-1"></i>{% translate "Largeur" %} : {{ asset.system.width }} m
</span>
{% endif %}
{% if asset.system.direction %}
<span class="badge bg-light text-dark border">
<i class="bi bi-signpost-split me-1"></i>{{ asset.system.get_direction_display }}
</span>
{% endif %}
{% if asset.system.model %}
<span class="badge bg-light text-dark border">
<i class="bi bi-tag me-1"></i>{{ asset.system.model.get_name }}
</span>
{% endif %}
</div>
</div>
<div class="col-md-5 text-md-end mt-3 mt-md-0">
<div class="p-3 bg-light rounded border text-start">
<div class="small text-muted mb-1">{% translate "Traverse complète" %} ({{ asset.system.plots.count }} {% translate "plots" %}) :</div>
<div class="d-flex flex-wrap gap-1">
{% for sibling in asset.system.plots.all %}
<a href="{{ sibling.get_absolute_url }}"
class="btn btn-sm {% if sibling.id == asset.id %}btn-primary fw-bold{% else %}btn-outline-secondary{% endif %}"
title="{{ sibling.code }} - {{ sibling.get_bollard_type_display }}">
#{{ sibling.position }} ({{ sibling.code }})
</a>
{% endfor %}
</div>
</div>
</div>
</div>
{% else %}
<div class="text-muted text-center p-3">
<i class="bi bi-exclamation-triangle text-warning me-2"></i>{% translate "Ce plot n'est actuellement rattaché à aucun système de bornes." %}
</div>
{% endif %}
</div>
</div>
<!-- Métadonnées techniques (extra_data) -->
{% if asset.extra_data %}
<div class="card mb-3" id="bollard-plot-extra-data-card">
<div class="card-header bg-light">
<h5 class="mb-0">
<i class="bi bi-code-slash text-secondary me-2"></i>{% translate "Métadonnées techniques" %}
</h5>
</div>
<div class="card-body">
<dl class="row mb-0">
{% for key, val in asset.extra_data.items %}
<dt class="col-sm-4 text-truncate" title="{{ key }}">{{ key }}</dt>
<dd class="col-sm-8 mb-2"><code>{{ val }}</code></dd>
{% endfor %}
</dl>
</div>
</div>
{% endif %}
{% endblock extra_blocks %}

View file

@ -0,0 +1,218 @@
{% extends "assets/detail_generic.html" %}
{% load i18n %}
{% load static %}
{% block extra_blocks %}
<!-- Plots de la traverse -->
<div class="card mb-3" id="bollard-system-plots-card">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="bi bi-geo-alt-fill text-primary me-2"></i>{% translate "Plots de la traverse" %}
<span class="badge bg-primary ms-2">{{ asset.plots.count }}</span>
</h5>
</div>
<div class="card-body p-0">
{% if asset.plots.all %}
<div class="table-responsive">
<table class="table table-hover table-striped mb-0 align-middle">
<thead>
<tr>
<th style="width: 80px;" class="text-center">{% translate "Pos." %}</th>
<th>{% translate "Code" %}</th>
<th>{% translate "Type de borne" %}</th>
<th>{% translate "Modèle" %}</th>
<th>{% translate "N° de série" %}</th>
<th>{% translate "Statut" %}</th>
<th style="width: 110px;" class="text-end">{% translate "Action" %}</th>
</tr>
</thead>
<tbody>
{% for plot in asset.plots.all %}
<tr>
<td class="text-center">
<span class="badge bg-dark rounded-pill">#{{ plot.position }}</span>
</td>
<td>
<a href="{{ plot.get_absolute_url }}" class="fw-bold text-decoration-none">
{{ plot.code }}
</a>
{% if plot.get_name %}
<div class="small text-muted">{{ plot.get_name }}</div>
{% endif %}
</td>
<td>
{% if plot.bollard_type == 'automatic' %}
<span class="badge bg-primary"><i class="bi bi-arrow-up-down me-1"></i>{{ plot.get_bollard_type_display }}</span>
{% elif plot.bollard_type == 'semi_automatic' %}
<span class="badge bg-info text-dark">{{ plot.get_bollard_type_display }}</span>
{% elif plot.bollard_type == 'removable' %}
<span class="badge bg-warning text-dark">{{ plot.get_bollard_type_display }}</span>
{% else %}
<span class="badge bg-secondary">{{ plot.get_bollard_type_display }}</span>
{% endif %}
</td>
<td>{{ plot.model.get_name|default:"-" }}</td>
<td><code>{{ plot.serial_number|default:"-" }}</code></td>
<td>
{% if plot.status == 'active' %}
<span class="badge bg-success">{{ plot.get_status_display }}</span>
{% elif plot.status == 'damaged' %}
<span class="badge bg-warning text-dark">{{ plot.get_status_display }}</span>
{% elif plot.status == 'to_replace' %}
<span class="badge bg-danger">{{ plot.get_status_display }}</span>
{% else %}
<span class="badge bg-secondary">{{ plot.get_status_display }}</span>
{% endif %}
</td>
<td class="text-end">
<a href="{{ plot.get_absolute_url }}" class="btn btn-sm btn-outline-primary" title="{% translate 'Voir le détail du plot' %}">
<i class="bi bi-eye me-1"></i>{% translate "Voir" %}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-4 text-center text-muted">
<i class="bi bi-cone-striped display-6 d-block mb-2 text-secondary opacity-50"></i>
{% translate "Aucun plot physique n'est encore configuré sur ce système de bornes." %}
</div>
{% endif %}
</div>
</div>
<!-- Équipements associés au système (Caméras LAPI, totems, armoires...) -->
<div class="card mb-3" id="bollard-system-assets-card">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="bi bi-hdd-network text-info me-2"></i>{% translate "Équipements associés" %}
<span class="badge bg-secondary ms-2">{{ asset.system_assets.count }}</span>
</h5>
</div>
<div class="card-body p-0">
{% if asset.system_assets.all %}
<div class="table-responsive">
<table class="table table-hover table-striped mb-0 align-middle">
<thead>
<tr>
<th>{% translate "Rôle" %}</th>
<th>{% translate "Équipement" %}</th>
<th>{% translate "Modèle" %}</th>
<th>{% translate "Statut" %}</th>
<th>{% translate "Commentaire" %}</th>
<th style="width: 110px;" class="text-end">{% translate "Action" %}</th>
</tr>
</thead>
<tbody>
{% for item in asset.system_assets.all %}
<tr>
<td>
{% if 'camera' in item.role %}
<span class="badge bg-info text-dark"><i class="bi bi-camera-video me-1"></i>{{ item.get_role_display }}</span>
{% elif item.role == 'cabinet' %}
<span class="badge bg-dark"><i class="bi bi-cpu me-1"></i>{{ item.get_role_display }}</span>
{% elif item.role == 'totem' %}
<span class="badge bg-primary"><i class="bi bi-tablet me-1"></i>{{ item.get_role_display }}</span>
{% elif item.role == 'traffic_light' %}
<span class="badge bg-warning text-dark"><i class="bi bi-traffic-light me-1"></i>{{ item.get_role_display }}</span>
{% else %}
<span class="badge bg-secondary">{{ item.get_role_display }}</span>
{% endif %}
</td>
<td>
<a href="{{ item.asset.get_absolute_url }}" class="fw-bold text-decoration-none">
{{ item.asset.code }}
</a>
{% if item.asset.get_name %}
<div class="small text-muted">{{ item.asset.get_name }}</div>
{% endif %}
</td>
<td>{{ item.asset.model.get_name|default:"-" }}</td>
<td>
{% if item.asset.status == 'active' %}
<span class="badge bg-success">{{ item.asset.get_status_display }}</span>
{% else %}
<span class="badge bg-secondary">{{ item.asset.get_status_display }}</span>
{% endif %}
</td>
<td>{{ item.comment|default:"-" }}</td>
<td class="text-end">
<a href="{{ item.asset.get_absolute_url }}" class="btn btn-sm btn-outline-secondary" title="{% translate 'Voir équipement' %}">
<i class="bi bi-box-arrow-up-right"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-4 text-center text-muted">
<i class="bi bi-camera-video display-6 d-block mb-2 text-secondary opacity-50"></i>
{% translate "Aucun équipement périphérique (caméra, armoire, totem...) n'est actuellement associé." %}
</div>
{% endif %}
</div>
</div>
<!-- Contrats de maintenance liés -->
{% if asset.bollard_systems_contracts.exists %}
<div class="card mb-3" id="bollard-system-contracts-card">
<div class="card-header bg-light">
<h5 class="mb-0">
<i class="bi bi-file-earmark-text text-secondary me-2"></i>{% translate "Contrats d'entretien" %}
<span class="badge bg-secondary ms-2">{{ asset.bollard_systems_contracts.count }}</span>
</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover table-striped mb-0 align-middle">
<thead>
<tr>
<th>{% translate "Numéro de contrat" %}</th>
<th>{% translate "Type de maintenance" %}</th>
<th>{% translate "Statut" %}</th>
</tr>
</thead>
<tbody>
{% for sc in asset.bollard_systems_contracts.all %}
<tr>
<td class="fw-bold">{{ sc.contract.contract_number }} - {{ sc.contract.name }}</td>
<td>{{ sc.get_maintenance_type_display }}</td>
<td>
{% if sc.status == 'active' %}
<span class="badge bg-success">{% translate "Actif" %}</span>
{% else %}
<span class="badge bg-secondary">{{ sc.get_status_display }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
<!-- Métadonnées techniques (extra_data) -->
{% if asset.extra_data %}
<div class="card mb-3" id="bollard-system-extra-data-card">
<div class="card-header bg-light">
<h5 class="mb-0">
<i class="bi bi-code-slash text-secondary me-2"></i>{% translate "Métadonnées techniques" %}
</h5>
</div>
<div class="card-body">
<dl class="row mb-0">
{% for key, val in asset.extra_data.items %}
<dt class="col-sm-4 text-truncate" title="{{ key }}">{{ key }}</dt>
<dd class="col-sm-8 mb-2"><code>{{ val }}</code></dd>
{% endfor %}
</dl>
</div>
</div>
{% endif %}
{% endblock extra_blocks %}

View file

@ -103,11 +103,11 @@ def get_asset_specific_fields(asset):
from django.contrib.gis.db.models import GeometryField
from django.db.models import (ForeignKey, OneToOneField, ManyToManyField, AutoField,
BooleanField, FloatField, DecimalField, IntegerField,
DateField, DateTimeField, TextField)
DateField, DateTimeField, TextField, JSONField)
from django.utils.translation import gettext_lazy as _
from assets.models import AbstractAsset, AbstractGeoAsset
excluded_types = (ForeignKey, OneToOneField, ManyToManyField, AutoField, GeometryField)
excluded_types = (ForeignKey, OneToOneField, ManyToManyField, AutoField, GeometryField, JSONField)
base_field_names = {f.name for f in AbstractAsset._meta.fields}
base_field_names |= {f.name for f in AbstractGeoAsset._meta.fields}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,292 @@
import json
import tempfile
import os
from datetime import date, datetime
from django.test import TestCase
from django.core.management import call_command
from django.contrib.gis.geos import Point, Polygon, MultiPolygon
from common.models import Thematic
from assets.models import NatureTree, AssetCategory
from contracts.models import Contract, ContractOrder, Company
from interventions.models import Intervention, InterventionAsset
class ImportFulcrumTreesCommandTest(TestCase):
def setUp(self):
self.thematic = Thematic.objects.create(name_fr="Nature", name_nl="Natuur")
self.category = AssetCategory.objects.create(id=60, name_fr="Arbres", name_nl="Bomen")
self.company = Company.objects.create(name="Krinkels")
self.contract = Contract.objects.create(
company=self.company,
contract_number="E19.051",
description="Marché E19.051",
start_date=date(2020, 1, 1),
end_date=date(2030, 12, 31)
)
self.contract.thematics.add(self.thematic)
def test_import_fulcrum_trees_and_interventions(self):
# 1. Préparer gestion.csv temporaire
gestion_content = (
"com_id,com_typ,com_nom,_status,com_dat,com_ech\n"
"BC-TEST-001,Marché E19.051 - Diagnostic,Élagage test,commande,2024-05-10,2024-06-15\n"
)
with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False) as f_ges:
f_ges.write(gestion_content)
ges_path = f_ges.name
# 2. Préparer arbes.geojson temporaire
mock_tree_uuid = "11111111-2222-3333-4444-555555555555"
tree_geojson = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [4.3517, 50.8503]
},
"properties": {
"_record_id": mock_tree_uuid,
"id_sit_pos": "099.01.001",
"id_visuel": "V-9901",
"sit_id": "099.01",
"pos_id": "001",
"sit_nom": "Boulevard du Test",
"comm_commune": "Bruxelles",
"arb_ess": "Platanus x hispanica",
"arb_vern": "Platane commun",
"arb_tax": "Platanus hispanica",
"arb_haut": 18.5,
"arb_circ": 140.0,
"arb_diam_tmp": 8.0,
"arb_st": "V",
"arb_vital": "Bo",
"arb_ont": "Ad",
"ges_conduite": "LI",
"cf_sit": 4.5,
"arb_anpla": "1995",
"maj_date": "2024-05-10T00:00:00.000Z"
}
}
]
}
with tempfile.NamedTemporaryFile(mode='w', suffix='.geojson', delete=False) as f_arb:
json.dump(tree_geojson, f_arb)
arb_path = f_arb.name
# 3. Préparer interventions.geojson temporaire
mock_itv_uuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
itv_geojson = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [4.3517, 50.8503]
},
"properties": {
"_record_id": mock_itv_uuid,
"_title": "099.01.001, Formation, Dans 1 an",
"rec_interv_com": "Taille branches basses",
"sit_nom": "Boulevard du Test",
"comm_commune": "Bruxelles",
"sit_id": "099.01",
"pos_id": "001",
"dia_comma": "BC-TEST-001",
"dia_date": "2024-05-10T00:00:00.000Z",
"in_deadline": "2025-05-10T00:00:00.000Z",
"rec_delai": "Pla-1",
"rec_interv": "FO",
"in_fin_yn": "no",
"mater_arbres_lk": [mock_tree_uuid]
}
}
]
}
with tempfile.NamedTemporaryFile(mode='w', suffix='.geojson', delete=False) as f_itv:
json.dump(itv_geojson, f_itv)
itv_path = f_itv.name
try:
# Exécuter la commande
call_command(
'import_fulcrum_trees',
arbes_file=arb_path,
gestion_file=ges_path,
interventions_file=itv_path,
managing_authority="Test Authority",
management_type="regional",
code_prefix="TEST-ARB-"
)
# Vérifications gestion / bon de commande
order = ContractOrder.objects.filter(order_code="BC-TEST-001").first()
self.assertIsNotNone(order)
self.assertEqual(order.contract.contract_number, "E19.051")
self.assertEqual(order.order_status, "sent")
# Vérifications arbre
tree = NatureTree.objects.filter(external_reference=mock_tree_uuid).first()
self.assertIsNotNone(tree)
self.assertEqual(tree.code, "TEST-ARB-099.01.001")
self.assertEqual(tree.managing_authority, "Test Authority")
self.assertEqual(tree.management_type, "regional")
self.assertEqual(tree.circumference, 140.0)
self.assertEqual(tree.height, 18.5)
self.assertIsNotNone(tree.amenity_value)
self.assertGreater(tree.amenity_value, 0)
self.assertIsNotNone(tree.carbon_stock)
self.assertGreater(tree.carbon_stock, 0)
self.assertIsNotNone(tree.cooling_indicator)
# Vérifications intervention
itv = Intervention.objects.filter(source_ref=mock_itv_uuid).first()
self.assertIsNotNone(itv)
self.assertEqual(itv.order_id, order.id)
self.assertEqual(itv.priority, "3") # Pla-1 -> 3
self.assertEqual(itv.status, "to_be_planned")
self.assertIsNotNone(itv.expected_begin_time)
self.assertEqual(itv.expected_begin_time.date(), date(2024, 5, 10))
self.assertIsNotNone(itv.expected_end_time)
self.assertEqual(itv.expected_end_time.date(), date(2025, 5, 10))
# Vérifications dates de maintenance sur l'arbre
self.assertEqual(tree.last_phytosanitary_date, date(2024, 5, 10))
self.assertEqual(tree.next_inspection_date, date(2025, 5, 10))
self.assertEqual(tree.pruning_frequency_years, 1)
# Vérifications événements de planification (timeline)
self.assertEqual(itv.planned_events.count(), 2)
begin_event = itv.planned_events.filter(event_type='expected_begin').first()
self.assertIsNotNone(begin_event)
self.assertEqual(begin_event.event_time.date(), date(2024, 5, 10))
end_event = itv.planned_events.filter(event_type='expected_end').first()
self.assertIsNotNone(end_event)
self.assertEqual(end_event.event_time.date(), date(2025, 5, 10))
# Vérification liaison InterventionAsset
links = InterventionAsset.objects.filter(intervention=itv)
self.assertEqual(links.count(), 1)
self.assertEqual(links.first().object_id, tree.id)
# Test mise à jour sans duplication
tree_geojson['features'][0]['properties']['arb_circ'] = 145.0
with open(arb_path, 'w') as f_arb_update:
json.dump(tree_geojson, f_arb_update)
call_command(
'import_fulcrum_trees',
arbes_file=arb_path,
gestion_file=ges_path,
interventions_file=itv_path,
skip_gestion=True,
skip_interventions=True
)
# Le nombre d'arbres ne doit pas avoir augmenté
self.assertEqual(NatureTree.objects.filter(external_reference=mock_tree_uuid).count(), 1)
tree.refresh_from_db()
self.assertEqual(tree.circumference, 145.0)
finally:
if os.path.exists(ges_path): os.remove(ges_path)
if os.path.exists(arb_path): os.remove(arb_path)
if os.path.exists(itv_path): os.remove(itv_path)
def test_import_fulcrum_regional_trees_alias(self):
from io import StringIO
out = StringIO()
call_command('import_fulcrum_regional_trees', skip_trees=True, skip_gestion=True, skip_interventions=True, stdout=out)
self.assertIn("FIN DE L'IMPORTATION", out.getvalue())
def test_attach_trees_to_green_sections(self):
from assets.models import NatureLocation
from django.contrib.gis.geos import Polygon, MultiPolygon
# Créer une section verte polygonale
poly = Polygon(((648000, 670000), (648100, 670000), (648100, 670100), (648000, 670100), (648000, 670000)), srid=3812)
loc = NatureLocation.objects.create(
code="WAT_R22_008",
name_fr="Boulevard du Souverain",
geom=MultiPolygon(poly, srid=3812)
)
# Créer un arbre à l'intérieur du polygone (sans location)
pt = Point(648050, 670050, srid=3812)
pt_4326 = pt.clone().transform(4326, clone=True)
tree = NatureTree.objects.create(
code="TR-ATTACH-TEST",
name_fr="Arbre test Souverain",
category=self.category,
geom=pt,
lon=pt_4326.x,
lat=pt_4326.y,
location=None
)
self.assertEqual(loc.nature_trees.count(), 0)
# Exécuter le rattachement
call_command('attach_trees_to_green_sections', max_distance=25, skip_sync_munis=True)
tree.refresh_from_db()
self.assertEqual(tree.location_id, loc.id)
self.assertEqual(loc.nature_trees.count(), 1)
def test_cancel_obsolete_interventions_and_theoretical_inspection(self):
from django.contrib.contenttypes.models import ContentType
from interventions.models import Intervention, InterventionAsset, InterventionTimeLine
# Créer un arbre avec dernier diagnostic en 2025 et périodicité 1 an
tree = NatureTree.objects.create(
code="TR-OBSOLETE-TEST",
name_fr="Arbre test obsolète",
category=self.category,
last_phytosanitary_date=date(2025, 12, 4),
pruning_frequency_years=1,
next_inspection_date=date(2023, 7, 22),
geom=Point(648050, 670050, srid=3812),
lon=4.42,
lat=50.80
)
# Créer une intervention en attente échue en 2023 (antérieure au diagnostic de 2025)
itv = Intervention.objects.create(
code="I999999",
title="003.01.270.(1912).00, Entretien, Dans 1 an",
status="to_be_planned",
status_order=5,
expected_begin_time=datetime(2022, 7, 22, 0, 0),
expected_end_time=datetime(2023, 7, 22, 0, 0),
thematic=self.thematic,
geom=MultiPolygon(Polygon(((648000, 670000), (648010, 670000), (648010, 670010), (648000, 670010), (648000, 670000)), srid=3812))
)
ct_tree = ContentType.objects.get_for_model(NatureTree)
InterventionAsset.objects.create(
intervention=itv,
content_type=ct_tree,
object_id=tree.id
)
# Exécuter la synchronisation des dates de maintenance
call_command('import_fulcrum_trees', sync_tree_dates=True)
# Vérifier que l'intervention échue a été annulée
itv.refresh_from_db()
self.assertEqual(itv.status, "canceled")
self.assertEqual(itv.status_order, 200)
self.assertEqual(itv.cancellation_type, "non_relevant")
# Vérifier que l'événement d'annulation a été consigné dans la timeline
tl = InterventionTimeLine.objects.filter(intervention=itv, to_status="canceled").first()
self.assertIsNotNone(tl)
self.assertIn("caduque", tl.event_description)
self.assertIn("04/12/2025", tl.event_description)
# Vérifier que la prochaine inspection de l'arbre est calculée théoriquement : 2025-12-04 + 1 an = 2026-12-04
tree.refresh_from_db()
self.assertEqual(tree.next_inspection_date, date(2026, 12, 4))

View file

@ -0,0 +1,597 @@
from django.test import TestCase, Client
from django.contrib.auth import get_user_model
from django.urls import reverse
from django.contrib.gis.geos import Polygon, MultiPolygon, Point
from common.models import Thematic, Role, UserConfig, UserThematics
from assets.models import (
ManagementZoneType,
ManagementZone,
ManagementZoneAsset,
NatureGreenSurface,
NatureLocation,
)
User = get_user_model()
class ManagementZoneModelTests(TestCase):
def setUp(self):
self.thematic_nature = Thematic.objects.create(
code="nature",
name_fr="Nature",
name_nl="Natuur"
)
self.zone_type = ManagementZoneType.objects.create(
code="fauche_tardive",
name_fr="Fauche tardive",
name_nl="Laat maaien",
thematic=self.thematic_nature,
default_color="#28a745",
description="Zone de gestion écologique avec fauche annuelle en automne."
)
def test_management_zone_type_bilingual_and_str(self):
self.assertEqual(self.zone_type.name_fr, "Fauche tardive")
self.assertEqual(self.zone_type.name_nl, "Laat maaien")
self.assertIn("Fauche tardive", str(self.zone_type))
def test_management_zone_creation_geometry_and_color_fallback(self):
poly = Polygon(((0, 0), (100, 0), (100, 100), (0, 100), (0, 0)))
multi_poly = MultiPolygon([poly], srid=3812)
# 1. Sans couleur explicite -> prend la couleur par défaut du type
zone1 = ManagementZone.objects.create(
code="ZG-NAT-001",
name_fr="Marais de Jette - Prairie",
name_nl="Moeras van Jette - Weide",
thematic=self.thematic_nature,
zone_type=self.zone_type,
geom=multi_poly,
target_frequency_days=365,
seasonality_notes="Fauche en septembre; pas de taille au printemps",
instructions="Attention aux zones humides et aux ornières.",
)
self.assertEqual(zone1.get_color(), "#28a745")
self.assertEqual(zone1.geom.srid, 3812)
self.assertEqual(zone1.geom.geom_type, "MultiPolygon")
# GeoJSON property
geojson = zone1.geojson_geometry
self.assertIsNotNone(geojson)
self.assertEqual(geojson["type"], "MultiPolygon")
# Spatial intersection
pt_inside = Point(50, 50, srid=3812)
pt_outside = Point(200, 200, srid=3812)
self.assertTrue(ManagementZone.objects.filter(pk=zone1.pk, geom__intersects=pt_inside).exists())
self.assertFalse(ManagementZone.objects.filter(pk=zone1.pk, geom__intersects=pt_outside).exists())
# 2. Avec couleur personnalisée explicite
zone2 = ManagementZone.objects.create(
code="ZG-NAT-002",
name_fr="Zone spéciale",
thematic=self.thematic_nature,
color="#ff5722",
)
self.assertEqual(zone2.get_color(), "#ff5722")
class ManagementZoneAssetTests(TestCase):
def setUp(self):
self.thematic_nature = Thematic.objects.create(code="nature", name_fr="Nature", name_nl="Natuur")
self.zone = ManagementZone.objects.create(
code="ZG-NAT-TEST",
name_fr="Zone test",
thematic=self.thematic_nature,
)
self.location = NatureLocation.objects.create(code="LOC-01", name_fr="Parc de Jette")
self.green_surface = NatureGreenSurface.objects.create(
code="GS-01",
name_fr="Pelouse 1",
location=self.location,
)
def test_explicit_asset_association_and_exclusion(self):
# Association explicite d'inclusion
link = ManagementZoneAsset.objects.create(
management_zone=self.zone,
asset=self.green_surface,
is_excluded=False,
notes="Inclus dans le plan"
)
self.assertIn("Inclus", str(link))
self.assertEqual(self.zone.explicit_assets.count(), 1)
self.assertEqual(self.zone.explicit_assets.first().asset, self.green_surface)
# Bascule en exclusion
link.is_excluded = True
link.save()
self.assertIn("Exclu", str(link))
class ManagementZoneGeojsonEndpointTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="nature_manager", password="password")
self.role_manager = Role.objects.create(name="manager")
self.config = UserConfig.objects.create(user=self.user)
self.config.roles.add(self.role_manager)
self.thematic_nature = Thematic.objects.create(code="nature", name_fr="Nature", name_nl="Natuur")
self.thematic_roads = Thematic.objects.create(code="roads", name_fr="Voiries", name_nl="Wegen")
UserThematics.objects.create(
user_config=self.config,
thematic=self.thematic_nature,
can_view_assets=True,
can_edit_assets=True
)
self.zone_type = ManagementZoneType.objects.create(
code="fauche",
name_fr="Fauche tardive",
thematic=self.thematic_nature,
default_color="#198754"
)
poly = Polygon(((0, 0), (10, 0), (10, 10), (0, 10), (0, 0)))
self.multi_poly = MultiPolygon([poly], srid=3812)
self.zone_nature = ManagementZone.objects.create(
code="ZG-NAT-1",
name_fr="Prairie fleurie",
thematic=self.thematic_nature,
zone_type=self.zone_type,
geom=self.multi_poly,
instructions="Ne pas tondre au printemps",
target_frequency_days=180,
seasonality_notes="Septembre",
)
self.zone_roads = ManagementZone.objects.create(
code="ZG-ROAD-1",
name_fr="Itinéraire déneigement P1",
thematic=self.thematic_roads,
geom=self.multi_poly,
color="#0d6efd",
)
def test_anonymous_access_redirects(self):
url = reverse("assets:management_zones_geojson")
response = self.client.get(url)
self.assertEqual(response.status_code, 302)
def test_authenticated_user_access_nature_zones(self):
self.client.login(username="nature_manager", password="password")
url = reverse("assets:management_zones_geojson") + "?thematic=nature"
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["type"], "FeatureCollection")
self.assertEqual(len(data["features"]), 1)
feat = data["features"][0]
self.assertEqual(feat["id"], self.zone_nature.id)
self.assertEqual(feat["properties"]["code"], "ZG-NAT-1")
self.assertEqual(feat["properties"]["name"], "Prairie fleurie")
self.assertEqual(feat["properties"]["thematic"], "nature")
self.assertEqual(feat["properties"]["zone_type_name"], "Fauche tardive")
self.assertEqual(feat["properties"]["color"], "#198754")
self.assertEqual(feat["properties"]["instructions"], "Ne pas tondre au printemps")
self.assertEqual(feat["properties"]["target_frequency_days"], 180)
self.assertEqual(feat["geometry"]["type"], "MultiPolygon")
def test_user_without_thematic_permission_denied(self):
self.client.login(username="nature_manager", password="password")
# nature_manager n'a pas accès à la thématique 'roads'
url = reverse("assets:management_zones_geojson") + "?thematic=roads"
response = self.client.get(url)
self.assertEqual(response.status_code, 403)
def test_superuser_access_all_thematics(self):
admin = User.objects.create_superuser(username="superadmin", password="password", email="admin@example.com")
self.client.login(username="superadmin", password="password")
# Sans paramètre thématique, superuser reçoit toutes les zones avec géométrie
url = reverse("assets:management_zones_geojson")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(len(data["features"]), 2)
import json
from django.core.files.uploadedfile import SimpleUploadedFile
from assets.permissions import (
can_view_management_zones,
can_edit_management_zones,
can_delete_management_zone,
get_editable_thematics_for_management_zones,
)
from assets.forms import ManagementZoneForm
from assets.models import ManagementZoneDocument
class ManagementZonesPermissionAndCRUDTests(TestCase):
def setUp(self):
# Users
self.user_manager = User.objects.create_user(username="mgr", password="password")
self.user_viewer = User.objects.create_user(username="viewer", password="password")
self.user_editor = User.objects.create_user(username="editor", password="password")
self.user_no_access = User.objects.create_user(username="no_access", password="password")
# Thematics
self.thematic_nature = Thematic.objects.create(code="nature", name_fr="Nature", name_nl="Natuur")
self.thematic_roads = Thematic.objects.create(code="roads", name_fr="Voiries", name_nl="Wegen")
# Roles & Configs
self.role_manager = Role.objects.create(name="manager")
self.role_operator = Role.objects.create(name="operator")
self.config_mgr = UserConfig.objects.create(user=self.user_manager)
self.config_mgr.roles.add(self.role_manager)
self.config_viewer = UserConfig.objects.create(user=self.user_viewer)
UserThematics.objects.create(
user_config=self.config_viewer,
thematic=self.thematic_nature,
can_view_assets=True,
can_edit_assets=False,
can_edit_management_zones=False
)
self.config_editor = UserConfig.objects.create(user=self.user_editor)
UserThematics.objects.create(
user_config=self.config_editor,
thematic=self.thematic_nature,
can_view_assets=True,
can_edit_assets=False,
can_edit_management_zones=True # Specific permission
)
self.config_no_access = UserConfig.objects.create(user=self.user_no_access)
# Type de zone
self.zone_type = ManagementZoneType.objects.create(
code="fauche_eco",
name_fr="Fauche écologique",
thematic=self.thematic_nature,
default_color="#20c997"
)
poly = Polygon(((0, 0), (50, 0), (50, 50), (0, 50), (0, 0)))
self.zone = ManagementZone.objects.create(
code="ZG-TEST-01",
name_fr="Zone Test Marais",
name_nl="Testzone Moeras",
thematic=self.thematic_nature,
zone_type=self.zone_type,
geom=MultiPolygon([poly], srid=3812),
target_frequency_days=90,
seasonality_notes="Avril-Septembre",
instructions="Attention à la faune aquatique",
is_active=True
)
def test_permission_functions(self):
# Manager can view and edit any thematic
self.assertTrue(can_view_management_zones(self.user_manager))
self.assertTrue(can_edit_management_zones(self.user_manager))
self.assertTrue(can_edit_management_zones(self.user_manager, thematic=self.thematic_nature))
# Viewer can view nature, but cannot edit
self.assertTrue(can_view_management_zones(self.user_viewer, thematic=self.thematic_nature))
self.assertFalse(can_view_management_zones(self.user_viewer, thematic=self.thematic_roads))
self.assertFalse(can_edit_management_zones(self.user_viewer))
self.assertFalse(can_edit_management_zones(self.user_viewer, thematic=self.thematic_nature))
# Editor with can_edit_management_zones=True
self.assertTrue(can_view_management_zones(self.user_editor, thematic=self.thematic_nature))
self.assertTrue(can_edit_management_zones(self.user_editor))
self.assertTrue(can_edit_management_zones(self.user_editor, thematic=self.thematic_nature))
self.assertFalse(can_edit_management_zones(self.user_editor, thematic=self.thematic_roads))
# Editable thematics
editable = get_editable_thematics_for_management_zones(self.user_editor)
self.assertIn(self.thematic_nature, editable)
self.assertNotIn(self.thematic_roads, editable)
def test_management_zone_form_with_geom_geojson(self):
# Test initial geojson
form = ManagementZoneForm(instance=self.zone, user=self.user_editor)
self.assertIn('geom_geojson', form.initial)
geom_dict = json.loads(form.initial['geom_geojson'])
self.assertEqual(geom_dict['type'], 'MultiPolygon')
# Test saving with new GeoJSON polygon in WGS84
geojson_polygon = {
"type": "Polygon",
"coordinates": [[[4.35, 50.84], [4.36, 50.84], [4.36, 50.85], [4.35, 50.85], [4.35, 50.84]]]
}
data = {
'code': 'ZG-NEW-01',
'name_fr': 'Nouvelle zone',
'thematic': self.thematic_nature.pk,
'zone_type': self.zone_type.pk,
'color': '#ff9900',
'target_frequency_days': 30,
'geom_geojson': json.dumps(geojson_polygon),
'is_active': True,
}
form = ManagementZoneForm(data=data, user=self.user_editor)
self.assertTrue(form.is_valid(), form.errors)
new_zone = form.save()
self.assertEqual(new_zone.code, 'ZG-NEW-01')
self.assertIsNotNone(new_zone.geom)
self.assertEqual(new_zone.geom.srid, 3812)
self.assertEqual(new_zone.geom.geom_type, 'MultiPolygon')
def test_management_zones_index_view(self):
url = reverse("assets:management_zones_index")
# Anonymous -> 302
res = self.client.get(url)
self.assertEqual(res.status_code, 302)
# No access user -> 403
self.client.login(username="no_access", password="password")
res = self.client.get(url)
self.assertEqual(res.status_code, 403)
# Viewer -> 200
self.client.login(username="viewer", password="password")
res = self.client.get(url)
self.assertEqual(res.status_code, 200)
self.assertContains(res, "ZG-TEST-01")
self.assertFalse(res.context['can_create'])
# Editor -> 200 and can_create is True
self.client.login(username="editor", password="password")
res = self.client.get(url)
self.assertEqual(res.status_code, 200)
self.assertTrue(res.context['can_create'])
def test_management_zone_create_view(self):
url = reverse("assets:management_zone_create")
# Viewer cannot create -> 403
self.client.login(username="viewer", password="password")
res = self.client.get(url)
self.assertEqual(res.status_code, 403)
# Editor can access form
self.client.login(username="editor", password="password")
res = self.client.get(url)
self.assertEqual(res.status_code, 200)
self.assertContains(res, "layer-switcher-control.js")
self.assertContains(res, "AddressSearchControl")
# Post valid zone
geojson_polygon = {
"type": "Polygon",
"coordinates": [[[4.35, 50.84], [4.36, 50.84], [4.36, 50.85], [4.35, 50.85], [4.35, 50.84]]]
}
post_data = {
'code': 'ZG-CREATED-01',
'name_fr': 'Zone créée',
'thematic': self.thematic_nature.pk,
'zone_type': self.zone_type.pk,
'color': '#28a745',
'target_frequency_days': 15,
'geom_geojson': json.dumps(geojson_polygon),
'instructions': 'Passage le matin uniquement',
'is_active': True,
}
res = self.client.post(url, post_data)
self.assertEqual(res.status_code, 302)
created_zone = ManagementZone.objects.get(code='ZG-CREATED-01')
self.assertEqual(created_zone.created_by, self.user_editor)
self.assertEqual(created_zone.instructions, 'Passage le matin uniquement')
self.assertEqual(created_zone.geom.srid, 3812)
def test_management_zone_edit_view(self):
url = reverse("assets:management_zone_edit", kwargs={'pk': self.zone.pk})
# Viewer cannot edit -> 403
self.client.login(username="viewer", password="password")
res = self.client.get(url)
self.assertEqual(res.status_code, 403)
# Editor can edit
self.client.login(username="editor", password="password")
res = self.client.get(url)
self.assertEqual(res.status_code, 200)
self.assertContains(res, "layer-switcher-control.js")
self.assertContains(res, "AddressSearchControl")
post_data = {
'code': self.zone.code,
'name_fr': 'Nom Modifié Marais',
'thematic': self.thematic_nature.pk,
'zone_type': self.zone_type.pk,
'color': '#ff5500',
'target_frequency_days': 120,
'geom_geojson': json.dumps({
"type": "Polygon",
"coordinates": [[[4.35, 50.84], [4.37, 50.84], [4.37, 50.86], [4.35, 50.86], [4.35, 50.84]]]
}),
'instructions': 'Consigne mise à jour',
'is_active': True,
}
res = self.client.post(url, post_data)
self.assertEqual(res.status_code, 302)
self.zone.refresh_from_db()
self.assertEqual(self.zone.name_fr, 'Nom Modifié Marais')
self.assertEqual(self.zone.color, '#ff5500')
self.assertEqual(self.zone.target_frequency_days, 120)
def test_management_zone_detail_view(self):
url = reverse("assets:management_zone_detail", kwargs={'pk': self.zone.pk})
# Viewer can view
self.client.login(username="viewer", password="password")
res = self.client.get(url)
self.assertEqual(res.status_code, 200)
self.assertContains(res, "ZG-TEST-01")
self.assertFalse(res.context['can_edit'])
# Editor can view with can_edit=True
self.client.login(username="editor", password="password")
res = self.client.get(url)
self.assertEqual(res.status_code, 200)
self.assertTrue(res.context['can_edit'])
def test_management_zone_delete_view(self):
zone_to_del = ManagementZone.objects.create(
code="ZG-TO-DELETE",
name_fr="A supprimer",
thematic=self.thematic_nature,
)
url = reverse("assets:management_zone_delete", kwargs={'pk': zone_to_del.pk})
# Viewer cannot delete -> 403
self.client.login(username="viewer", password="password")
res = self.client.post(url)
self.assertEqual(res.status_code, 403)
# Editor can delete
self.client.login(username="editor", password="password")
res = self.client.post(url)
self.assertEqual(res.status_code, 302)
self.assertFalse(ManagementZone.objects.filter(pk=zone_to_del.pk).exists())
def test_management_zone_document_upload_and_delete(self):
self.client.login(username="editor", password="password")
# Upload document
upload_url = reverse("assets:management_zone_add_document", kwargs={'pk': self.zone.pk})
fake_file = SimpleUploadedFile("rapport_tonte.pdf", b"Dummy PDF content", content_type="application/pdf")
res = self.client.post(upload_url, {'documents': [fake_file]})
self.assertEqual(res.status_code, 302)
self.assertEqual(self.zone.documents.count(), 1)
doc = self.zone.documents.first()
self.assertEqual(doc.uploaded_by, self.user_editor)
# Delete document
delete_url = reverse("assets:management_zone_delete_document", kwargs={'pk': self.zone.pk, 'document_id': doc.pk})
res = self.client.post(delete_url)
self.assertEqual(res.status_code, 302)
self.assertEqual(self.zone.documents.count(), 0)
def test_management_zone_types_ajax(self):
self.client.login(username="editor", password="password")
url = reverse("assets:management_zone_types_ajax") + f"?thematic_id={self.thematic_nature.pk}"
res = self.client.get(url)
self.assertEqual(res.status_code, 200)
data = res.json()
self.assertEqual(len(data['types']), 1)
self.assertEqual(data['types'][0]['code'], 'fauche_eco')
class ManagementZoneUnifiedViewIntegrationTests(TestCase):
def setUp(self):
self.thematic_nature = Thematic.objects.create(name_fr="Nature", name_nl="Natuur", code="nature")
self.thematic_roads = Thematic.objects.create(name_fr="Voirie", name_nl="Wegen", code="roads")
self.user_editor = User.objects.create_user(username="editor_unified", password="password")
self.user_viewer = User.objects.create_user(username="viewer_unified", password="password")
self.config_editor = UserConfig.objects.create(user=self.user_editor)
self.config_viewer = UserConfig.objects.create(user=self.user_viewer)
UserThematics.objects.create(
user_config=self.config_editor,
thematic=self.thematic_nature,
can_view_assets=True,
can_edit_assets=True,
can_edit_management_zones=True,
)
UserThematics.objects.create(
user_config=self.config_viewer,
thematic=self.thematic_nature,
can_view_assets=True,
can_edit_assets=False,
can_edit_management_zones=False,
)
self.zone_type = ManagementZoneType.objects.create(
thematic=self.thematic_nature,
code="fauche_eco",
name_fr="Fauche tardive",
name_nl="Laat maaien",
default_color="#27ae60",
)
poly = Polygon(((149000, 170000), (149100, 170000), (149100, 170100), (149000, 170100), (149000, 170000)))
multi_poly = MultiPolygon([poly], srid=3812)
self.zone = ManagementZone.objects.create(
thematic=self.thematic_nature,
zone_type=self.zone_type,
code="ZN-TEST-001",
name_fr="Pelouse A",
name_nl="Grasveld A",
geom=multi_poly,
is_active=True,
created_by=self.user_editor,
)
def test_build_thematic_config_includes_management_zones_url(self):
from assets.views.core import build_thematic_config
config = build_thematic_config([self.thematic_nature, self.thematic_roads])
self.assertIn("nature", config)
self.assertIn("management_zones_geojson_url", config["nature"])
self.assertEqual(config["nature"]["management_zones_geojson_url"], reverse("assets:management_zones_geojson"))
def test_build_unified_add_modal_config_permissions(self):
from assets.views.core import build_unified_add_modal_config
# Editor can edit management zones in nature
editor_config = build_unified_add_modal_config([self.thematic_nature], user=self.user_editor)
self.assertIn("management_zone_thematics", editor_config)
editor_codes = [t["code"] for t in editor_config["management_zone_thematics"]]
self.assertIn("nature", editor_codes)
# Viewer cannot edit management zones
viewer_config = build_unified_add_modal_config([self.thematic_nature], user=self.user_viewer)
viewer_codes = [t["code"] for t in viewer_config["management_zone_thematics"]]
self.assertNotIn("nature", viewer_codes)
def test_management_zones_geojson_properties_for_unified_table(self):
self.client.login(username="editor_unified", password="password")
url = reverse("assets:management_zones_geojson") + f"?thematic={self.thematic_nature.code}"
res = self.client.get(url)
self.assertEqual(res.status_code, 200)
data = res.json()
self.assertEqual(len(data["features"]), 1)
feat = data["features"][0]
props = feat["properties"]
self.assertTrue(props["is_management_zone"])
self.assertFalse(props["is_location"])
self.assertEqual(props["asset_type"], "managementzone")
self.assertIn(props["element_type"], ["Zone de gestion", "Management zone"])
self.assertEqual(props["category"], "Fauche tardive")
self.assertEqual(props["status"], "active")
self.assertIsNotNone(props["lon"])
self.assertIsNotNone(props["lat"])
self.assertIsNotNone(props["area"])
self.assertEqual(props["detail_url"], reverse("assets:management_zone_detail", kwargs={"pk": self.zone.pk}))
def test_unified_index_page_contains_management_zone_elements(self):
self.client.login(username="editor_unified", password="password")
url = reverse("assets:index")
res = self.client.get(url)
self.assertEqual(res.status_code, 200)
# Check filter modal checkboxes
self.assertContains(res, 'id="type-cb-assets"')
self.assertContains(res, 'id="type-cb-locations"')
self.assertContains(res, 'id="type-cb-management-zones"')
# Check add modal card
self.assertContains(res, 'id="unified-add-management-zone-thematic"')
self.assertContains(res, 'id="unified-add-management-zone-open"')

View file

@ -1,9 +1,19 @@
from django.urls import path
from .views import core, locations, nature, publiclighting, trafficlights, artworks, structures, controlcenters, sign, roads, its, clean, asset_editing, history, location_editing, export, asset_permissions, water, buildings, outdoor, parking
from .views import core, locations, nature, publiclighting, trafficlights, artworks, structures, controlcenters, sign, roads, its, clean, asset_editing, history, location_editing, export, asset_permissions, water, buildings, outdoor, parking, management_zones
app_name = "assets"
urlpatterns = [
path("", core.unified_assets_index, name="index"),
# Management Zones (Zones de gestion)
path("management-zones/", management_zones.management_zones_index, name="management_zones_index"),
path("management-zones/add/", management_zones.management_zone_create, name="management_zone_create"),
path("management-zones/types/ajax/", management_zones.management_zone_types_ajax, name="management_zone_types_ajax"),
path("management-zones/<int:pk>/", management_zones.management_zone_detail, name="management_zone_detail"),
path("management-zones/<int:pk>/edit/", management_zones.management_zone_edit, name="management_zone_edit"),
path("management-zones/<int:pk>/delete/", management_zones.management_zone_delete, name="management_zone_delete"),
path("management-zones/<int:pk>/add-document/", management_zones.management_zone_add_document, name="management_zone_add_document"),
path("management-zones/<int:pk>/documents/<int:document_id>/delete/", management_zones.management_zone_delete_document, name="management_zone_delete_document"),
path("api/management-zones/geojson/", management_zones.management_zones_geojson, name="management_zones_geojson"),
path("parking/", parking.parking_index, name="parking_index"),
path("parking/assets/", parking.parking_assets, name="parking_assets"),
path("parking/assets/geojson/", parking.parking_assets_geojson, name="parking_assets_geojson"),
@ -14,6 +24,7 @@ urlpatterns = [
path("buildings/", buildings.buildings_index, name="buildings_index"),
path("buildings/<int:pk>/", buildings.building_detail, name="publicbuilding_detail"),
path("buildings/<int:pk>/edit/", buildings.publicbuilding_edit, name="publicbuilding_edit"),
path("buildings/<int:pk>/floor-plan/", buildings.publicbuilding_plan_file, name="publicbuilding_plan_file"),
path("buildings/<str:asset_model>/<int:asset_id>/", buildings.publicbuildings_assets_detail, name="publicbuildings_assets_detail"),
path("export/", export.export_assets, name="export_assets"),

View file

@ -26,6 +26,56 @@ def is_hp_or_ls(text):
return True
return False
def is_wiring_or_non_equipment(text):
"""
Checks if the wire/direction/circuit text refers to auxiliary wiring, communication,
power supply, contact lines, or reserve wires that must NOT be treated as equipment
(neither lantern nor detector).
In cross plans, 'Com' / 'Comm' / 'Communication' represents communication wiring/bus
for detectors/cameras or radar power supplies, NOT an actual detector.
"""
if not text:
return False
t_str = str(text).strip()
t_lower = t_str.lower()
t_clean = re.sub(r'[\s/\\_\-\.:;]+', ' ', t_lower).strip()
t_upper = re.sub(r'\s+', '', t_str.upper())
# 1. Direct keyword matches (word boundary)
if re.search(r'\b(?:com|comm|communication|communicatie|alim|alimentation|voeding|tension|contact|reserve|reservee|réservé|réservée|réserve|libre|vrij|spaar|terre|masse|neutre)\b', t_clean, re.IGNORECASE):
return True
# 2. String starts with or equals COM, COMM, ALIM, etc.
if re.match(r'^(?:COM|COMM|ALIM|CONTACT|TENSION|RESERVE)\d*$', t_upper, re.IGNORECASE):
return True
if t_upper.startswith(("COM_", "COMM_", "ALIM_", "COM-", "COMM-", "ALIM-")):
return True
# 3. Radar power supply (not a detector code like RA, RB, RC, RD, RM, RP)
if t_upper == "RADAR" or t_upper.startswith("RADAR") or (bool(re.search(r'\bradar\b', t_str, re.IGNORECASE)) and not bool(re.match(r'^(?:RA|RB|RC|RD|RM|RP)\d*$', t_upper, re.IGNORECASE))):
return True
return False
def is_cyclopieton_phase(phase):
"""
Checks if a phase code corresponds to a combined pedestrian + cyclist signal (cyclo-piéton).
Standard notation: lowercase letter (pedestrian) + 'F' or 'f' (fiets/cyclist), e.g. 'bF', 'aF', 'cF', 'dF', 'bF1',
or vice versa ('Fa', 'Fb').
"""
if not phase:
return False
ph = str(phase).strip()
if re.match(r'^[a-z][Ff]\d*$', ph) or re.match(r'^[Ff][a-z]\d*$', ph):
return True
if re.match(r'^[a-z]\s*[\+/&]\s*[Ff]\d*$', ph) or re.match(r'^[Ff]\s*[\+/&]\s*[a-z]\d*$', ph):
return True
ph_lower = ph.lower()
if 'cyclo' in ph_lower and ('piet' in ph_lower or 'piéto' in ph_lower or 'ped' in ph_lower):
return True
return False
def parse_cross_plan(excel_path):
"""
Parses a crossroads cross plan (kruisjesplan) Excel file and returns
@ -265,6 +315,11 @@ def parse_cross_plan(excel_path):
pole_has_hp[p] = True
continue
# Check if wire refers to communication, power supply, contact, reserve, etc.
# It must NOT be treated as an equipment (neither lantern nor detector).
if is_wiring_or_non_equipment(richting) or is_wiring_or_non_equipment(kringen):
continue
# Detectors
# Check if push button (e.g. Dka, Dkb, PBa1, BPf4...)
if richting.lower().startswith(("dk", "pb", "bp")):
@ -365,7 +420,7 @@ def parse_cross_plan(excel_path):
# Richting codes are typically: A, B, C, T1, T2, F1, F2, a, b, c...
# Ignore wiring/cabling categories like ALIM, Radar, Com, contact, HP/LS, etc.
normalized = re.sub(r'\s+', ' ', richting).strip()
if is_hp_or_ls(richting) or is_hp_or_ls(kringen) or re.search(r'(?:alim|com|contact|radar|tension|comm|\bhp\b|\bls\b|hp/ls|ls/hp)', normalized, re.IGNORECASE):
if is_hp_or_ls(richting) or is_hp_or_ls(kringen) or is_wiring_or_non_equipment(richting) or is_wiring_or_non_equipment(kringen) or re.search(r'(?:alim|com|contact|radar|tension|comm|\bhp\b|\bls\b|hp/ls|ls/hp)', normalized, re.IGNORECASE):
continue
if p not in pole_phases:
@ -375,9 +430,13 @@ def parse_cross_plan(excel_path):
# Now generate LANTERNS for each pole
deduced_lanterns = []
# helper to sort phases: vehicles first, then trams, then cycles, then pedestrians
# helper to sort phases: vehicles first, then trams, then cycles, then cyclo-piétons / pedestrians
def phase_sort_key(ph):
if ph and ph[0].islower(): # Pedestrian
if is_cyclopieton_phase(ph):
if ph and ph[0].islower():
return (3, ph)
return (2, ph)
elif ph and ph[0].islower(): # Pedestrian
return (3, ph)
elif ph.startswith("T"): # Tram
return (1, ph)
@ -404,7 +463,10 @@ def parse_cross_plan(excel_path):
if is_mast_arm and has_double_marker:
# Determine models for both lanterns
if phase and phase[0].islower(): # Pedestrian
if is_cyclopieton_phase(phase):
model_name_1 = "2V200 cyclo-piéto + HP" if has_hp else "2V200 cyclo-piéto"
model_name_2 = "2V200 cyclo-piéto + HP" if has_hp else "2V200 cyclo-piéto"
elif phase and phase[0].islower(): # Pedestrian
model_name_1 = "2V200 piéton+HP" if has_hp else "2V200"
model_name_2 = "2V200 piéton+HP" if has_hp else "2V200"
elif phase.startswith("T"): # Tram
@ -452,7 +514,12 @@ def parse_cross_plan(excel_path):
lantern_idx += 1
else:
# Deduce single model
if phase and phase[0].islower(): # Pedestrian
if is_cyclopieton_phase(phase):
if has_hp:
model_name = "2V200 cyclo-piéto + HP"
else:
model_name = "2V200 cyclo-piéto"
elif phase and phase[0].islower(): # Pedestrian
if has_hp:
model_name = "2V200 piéton+HP"
else:
@ -542,9 +609,23 @@ def classify_lantern(model_name="", phase="", code="", name=""):
)
)
# Check Cyclo-piéton:
is_cyclopieton = (
not is_tram and not is_bus and (
is_cyclopieton_phase(phase_clean)
or bool(re.search(r'[\b_]LAN\d+_[a-z][Ff]\d*', code_upper))
or "cyclo-piét" in combined_text
or "cyclo-piet" in combined_text
or "cyclopiet" in combined_text
or ("cyclo" in combined_text and any(w in combined_text for w in ["piét", "piet", "voetganger"]))
or (any(w in combined_text for w in ["cycliste", "velo", "vélo", "fiets"])
and any(w in combined_text for w in ["piéton", "pieton", "voetganger", "pedestrian"]))
)
)
# Check Cyclist:
is_cyclist = (
not is_tram and not is_bus and (
not is_tram and not is_bus and not is_cyclopieton and (
any(w in combined_text for w in ["cycliste", "cycl", "velo", "vélo", "fiets"])
or phase_clean.upper().startswith("F")
or bool(re.search(r'\bF\d+\b', code_upper))
@ -553,7 +634,7 @@ def classify_lantern(model_name="", phase="", code="", name=""):
# Check Pedestrian:
is_pedestrian = (
not is_tram and not is_bus and not is_cyclist and (
not is_tram and not is_bus and not is_cyclopieton and not is_cyclist and (
any(w in combined_text for w in ["piéton", "pieton", "voetganger", "pedestrian"])
or (bool(phase_clean) and phase_clean[0].islower() and not phase_clean.startswith("fl"))
or phase_clean.upper().startswith("P")
@ -565,6 +646,8 @@ def classify_lantern(model_name="", phase="", code="", name=""):
category = "tram"
elif is_bus:
category = "bus"
elif is_cyclopieton:
category = "cyclopieton"
elif is_cyclist:
category = "cyclist"
elif is_pedestrian:
@ -634,7 +717,13 @@ def calculate_lantern_match_score(dl, el, is_single_on_pole=False):
return 10000
dl_base_code = dl_code.rsplit("_", 1)[0] if "_" in dl_code else dl_code
is_prefix_code_match = bool(dl_base_code and el_code and dl_base_code == el_code)
el_base_code = el_code.rsplit("_", 1)[0] if "_" in el_code else el_code
is_prefix_code_match = bool(
(dl_base_code and el_code and dl_base_code == el_code)
or (dl_code and el_base_code and dl_code == el_base_code)
or (dl_base_code and el_base_code and dl_base_code == el_base_code)
)
if is_single_on_pole and is_prefix_code_match:
return 1000
@ -664,10 +753,12 @@ def calculate_lantern_match_score(dl, el, is_single_on_pole=False):
# 2. Category matching
if dl_cat == el_cat:
if dl_cat in ("tram", "bus", "pedestrian", "cyclist"):
if dl_cat in ("tram", "bus", "pedestrian", "cyclist", "cyclopieton"):
score += 300
else:
score += 200
elif {dl_cat, el_cat} in ({"cyclopieton", "pedestrian"}, {"cyclopieton", "cyclist"}):
score += 50
else:
# Category mismatch penalty: e.g. tram vs car, pedestrian vs car
score -= 600
@ -707,9 +798,9 @@ def calculate_lantern_match_score(dl, el, is_single_on_pole=False):
else:
score -= 60
# 8. Prefix match bonus
# 8. Prefix match bonus (e.g. LAN03 vs LAN03_bF on the same pole)
if is_prefix_code_match:
score += 30
score += 250
# 9. LAN index tie-breaker (e.g. LAN01 vs LAN01)
m_dl = re.search(r'LAN(\d+)', dl_code, re.IGNORECASE)

View file

@ -0,0 +1,190 @@
import math
from typing import Optional
# Coefficients allométriques carbone par genre botanique (t CO2 / (cm_circ * m_hauteur))
# Dérivés des tables de biomasse et densités ligneuses (i-Tree / Chave / Grality)
GENUS_CARBON_K = {
"aesculus": 0.02471, # Marronnier
"carpinus": 0.02370, # Charme
"quercus": 0.01952, # Chêne
"betula": 0.01952, # Bouleau
"fagus": 0.01882, # Hêtre
"prunus": 0.01813, # Cerisier / Prunier
"fraxinus": 0.01743, # Frêne
"acer": 0.01673, # Érable
"platanus": 0.01673, # Platane
"castanea": 0.01900, # Châtaignier
"robinia": 0.01850, # Robinier
"ulmus": 0.01750, # Orme
"cedrus": 0.01600, # Cèdre
"salix": 0.01550, # Saule
"populus": 0.01500, # Peuplier
"pinus": 0.01450, # Pin
"tilia": 0.01366, # Tilleul
"malus": 0.01800, # Pommier
"pyrus": 0.01800, # Poirier
}
# Indices écologiques de biodiversité par genre / essence
GENUS_BIODIVERSITY = {
"quercus": 0.92, # Chêne (très forte valeur d'accueil)
"fagus": 0.88, # Hêtre
"platanus": 0.85, # Platane
"fraxinus": 0.78, # Frêne
"tilia": 0.78, # Tilleul
"carpinus": 0.76, # Charme
"salix": 0.75, # Saule
"acer": 0.68, # Érable
"aesculus": 0.62, # Marronnier
"pinus": 0.62, # Pin
"populus": 0.60, # Peuplier
"ulmus": 0.60, # Orme
"malus": 0.53, # Pommier
"betula": 0.47, # Bouleau
"prunus": 0.44, # Cerisier / Prunus ornemental
}
# Constante d'énergie de refroidissement par an (kWh / m^2 de canopée projetée)
# Dérivée de l'évapotranspiration urbaine Grality : 296.357 * D^2
COOLING_ENERGY_PER_D2 = 296.357
# Valeur de base au cm² de section de tronc (Barème de l'Arbre / Plante & Cité)
BASE_AMENITY_VALUE_PER_CM2 = 3.20
def get_genus_carbon_k(botanical_str: Optional[str]) -> float:
"""Retourne le coefficient allométrique de carbone pour l'essence ou genre spécifié."""
if not botanical_str:
return 0.0180
b_lower = botanical_str.lower()
for genus, k in GENUS_CARBON_K.items():
if genus in b_lower:
return k
return 0.0180
def get_genus_biodiversity_index(botanical_str: Optional[str]) -> float:
"""Retourne l'indice de biodiversité pour l'essence ou genre spécifié."""
if not botanical_str:
return 0.60
b_lower = botanical_str.lower()
for genus, b in GENUS_BIODIVERSITY.items():
if genus in b_lower:
return b
return 0.60
def compute_tree_ecoservices(tree, overwrite_existing: bool = False) -> bool:
"""
Calcule et met à jour automatiquement les indicateurs écosystémiques et patrimoniaux
d'un arbre (NatureTree) :
1. Dimensions déduites si manquantes (circonférence et diamètre de houppier).
2. Valeur d'aménité (€) selon le Barème de l'Arbre (section du tronc et coefficients).
3. Stock de carbone (t CO2) selon l'allométrie dendrométrique et l'essence.
4. Énergie de refroidissement (kWh) et Indice de fraîcheur selon la canopée.
5. Indice de biodiversité selon l'essence.
Si overwrite_existing=False (par défaut), ne remplace pas les valeurs déjà renseignées
(ex: valeurs officielles Grality déjà enregistrées).
Retourne True si au moins un champ a été calculé/mis à jour.
"""
changed = False
# 1. Estimation / déduction des dimensions manquantes à partir de la hauteur ou circonférence
h = getattr(tree, 'height', None)
c = getattr(tree, 'circumference', None)
crown_d = getattr(tree, 'crown_diameter', None)
if (not c or c <= 0) and h and h > 0:
c = round(h * 7.5, 1)
tree.circumference = c
changed = True
if (not crown_d or crown_d <= 0):
if h and h > 0:
crown_d = round(max(2.0, min(18.0, h * 0.45)), 1)
tree.crown_diameter = crown_d
changed = True
elif c and c > 0:
estimated_h = c / 7.5
crown_d = round(max(2.0, min(18.0, estimated_h * 0.45)), 1)
tree.crown_diameter = crown_d
changed = True
if (not h or h <= 0) and c and c > 0:
h = round(c / 7.5, 1)
tree.height = h
changed = True
# 2. Coefficients de situation et plantation par défaut si absents
sit = getattr(tree, 'situation_coefficient', None)
if sit is None or overwrite_existing:
ctx = getattr(tree, 'implantation_context', 'street')
sit = 5.0 if ctx in ('park', 'square', 'isolated') else 4.0
tree.situation_coefficient = sit
changed = True
plant = getattr(tree, 'plantation_coefficient', None)
if plant is None or overwrite_existing:
plant = 5.0
tree.plantation_coefficient = plant
changed = True
# Statut sanitaire / vitalité
is_dead = bool(getattr(tree, 'is_dead', False))
vitality = getattr(tree, 'vitality', 'normal') or 'normal'
if is_dead or vitality == 'dead':
sa_coeff = 0.0
elif vitality == 'declining':
sa_coeff = 0.3
elif vitality == 'weak':
sa_coeff = 0.6
else:
sa_coeff = 1.0
# 3. Valeur d'aménité (€) — Barème de l'Arbre
if getattr(tree, 'amenity_value', None) is None or overwrite_existing:
if sa_coeff == 0.0:
tree.amenity_value = 0.0
changed = True
elif c and c > 0:
section_cm2 = (c ** 2) / (4.0 * math.pi)
tree.amenity_value = round(
section_cm2 * BASE_AMENITY_VALUE_PER_CM2 * (sit / 5.0) * (plant / 5.0) * sa_coeff,
2
)
changed = True
# 4. Énergie de refroidissement (kWh) et Indice de fraîcheur
if getattr(tree, 'cooling_energy_indicator', None) is None or overwrite_existing:
if is_dead or vitality == 'dead':
tree.cooling_energy_indicator = 0.0
changed = True
elif crown_d and crown_d > 0:
tree.cooling_energy_indicator = round(COOLING_ENERGY_PER_D2 * (crown_d ** 2), 2)
changed = True
if getattr(tree, 'cooling_indicator', None) is None or overwrite_existing:
if is_dead or vitality == 'dead':
tree.cooling_indicator = 0.0
changed = True
elif tree.cooling_energy_indicator is not None and tree.cooling_energy_indicator > 0:
tree.cooling_indicator = round(math.sqrt(tree.cooling_energy_indicator) / 6.75, 2)
changed = True
# 5. Stock de carbone (t CO2)
botanical = getattr(tree, 'scientific_name', '') or getattr(tree, 'genus', '') or getattr(tree, 'name_fr', '')
if getattr(tree, 'carbon_stock', None) is None or overwrite_existing:
if c and h and c > 0 and h > 0:
k = get_genus_carbon_k(botanical)
tree.carbon_stock = round(k * c * h, 3)
changed = True
# 6. Indice de biodiversité
if getattr(tree, 'biodiversity_index', None) is None or overwrite_existing:
tree.biodiversity_index = get_genus_biodiversity_index(botanical)
changed = True
return changed

View file

@ -1411,6 +1411,11 @@ CATEGORY_ASSET_MAPPING = {
'LI_poles': ('PublicLightingPole', 'PublicLightingPoleModel'),
'publiclighting_luminaire': ('PublicLightingLuminaire', 'PublicLightingLuminaireModel'),
'LI_lightfixtures': ('PublicLightingLightFixture', 'PublicLightingLightFixtureModel'),
# ITS categories
'its_bollard_system': ('ITSBollardSystem', 'ITSAssetModel'),
'bollard_system': ('ITSBollardSystem', 'ITSAssetModel'),
'its_bollard_plot': ('ITSBollardPlot', 'ITSAssetModel'),
'bollard_plot': ('ITSBollardPlot', 'ITSAssetModel'),
}
@ -1856,6 +1861,18 @@ def create_asset_api(request):
except Exception:
continue
# Handle bollard system (for bollard plots)
system_id = data.get('system_id') or data.get('bollard_system_id')
if system_id and _has_model_field(new_asset, 'system'):
from assets.models import ITSBollardSystem
try:
system_obj = ITSBollardSystem.objects.get(pk=system_id)
new_asset.system = system_obj
if not getattr(new_asset, 'location', None) and system_obj.location:
new_asset.location = system_obj.location
except ITSBollardSystem.DoesNotExist:
pass
# Set additional data
extra_data = data.get('data', {})
if isinstance(extra_data, dict):

View file

@ -1,9 +1,11 @@
import os
import mimetypes
import json
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db.models import Q, F
from django.http import JsonResponse, HttpResponseForbidden
from django.http import JsonResponse, HttpResponseForbidden, FileResponse, Http404
from django.urls import reverse
from django.utils.translation import gettext as _, get_language
from django.contrib.gis.geos import GEOSGeometry
@ -352,10 +354,62 @@ def build_location_node(loc):
}
@login_required
def publicbuilding_plan_file(request, pk):
"""
Sert directement le fichier de plan d'étage (SVG, PNG, JPG, PDF) associé à ce bâtiment ou niveau.
"""
from django.utils.encoding import smart_str
building = get_object_or_404(PublicBuilding, pk=pk)
file_field = None
if building.floor_plan and building.floor_plan.name:
file_field = building.floor_plan
else:
# Check direct floor document
doc = building.floor_documents.filter(document_type='floor_plan').first()
if not doc:
doc = building.documents.filter(document_type='floor_plan').first()
if doc and doc.file and doc.file.name:
file_field = doc.file
# Fallback to parent level if this is a room without direct plan
if not file_field and building.parent_location:
curr = building.parent_location
while curr and not file_field:
if curr.floor_plan and curr.floor_plan.name:
file_field = curr.floor_plan
break
pdoc = curr.floor_documents.filter(document_type='floor_plan').first()
if not pdoc:
pdoc = curr.documents.filter(document_type='floor_plan').first()
if pdoc and pdoc.file and pdoc.file.name:
file_field = pdoc.file
break
curr = curr.parent_location
if not file_field or not file_field.storage.exists(file_field.name):
raise Http404(_("Aucun plan d'étage disponible."))
content_type, _ = mimetypes.guess_type(file_field.name)
if not content_type:
if file_field.name.lower().endswith('.svg'):
content_type = 'image/svg+xml'
else:
content_type = 'application/octet-stream'
filename = os.path.basename(file_field.name)
response = FileResponse(file_field.open('rb'), content_type=content_type)
response['Content-Disposition'] = f'inline; filename="{smart_str(filename)}"'
response['X-Frame-Options'] = 'ALLOWALL'
return response
@login_required
def building_detail(request, pk):
"""
Vue détaillée d'une Localisation Bâtiment Public avec son arborescence unifiée de localisations et d'assets physiques.
Vue détaillée d'une Localisation Bâtiment Public avec son arborescence unifiée,
sa cartographie et sa navigation multi-niveaux / plans d'étage.
"""
from django.contrib.contenttypes.models import ContentType
from interventions.models import Intervention
@ -369,7 +423,7 @@ def building_detail(request, pk):
location_tree = build_location_node(building)
parent_location = building.parent_location
documents = building.documents.all()
documents = building.documents.all().select_related('floor_location')
content_type = ContentType.objects.get_for_model(PublicBuilding)
@ -394,6 +448,116 @@ def building_detail(request, pk):
pass
elif building.lon is not None and building.lat is not None:
center_lon, center_lat = building.lon, building.lat
elif building.parent_location:
# Fallback to parent coords if room/floor without specific coords
curr_p = building.parent_location
while curr_p and (center_lon is None or center_lat is None):
if curr_p.lon is not None and curr_p.lat is not None:
center_lon, center_lat = curr_p.lon, curr_p.lat
break
elif curr_p.geom:
try:
g4326 = curr_p.geom.clone()
g4326.transform(4326)
center_lon, center_lat = g4326.centroid.x, g4326.centroid.y
break
except Exception:
pass
curr_p = curr_p.parent_location
# Sécurisation des coordonnées JSON pour éviter les erreurs de syntaxe JS causées par la virgule en locale fr
center_lon_json = json.dumps(center_lon) if center_lon is not None else 'null'
center_lat_json = json.dumps(center_lat) if center_lat is not None else 'null'
# Détection du mode spécifique à un niveau / local
is_level = building.location_type in ('level', 'basement', 'attic', 'mezzanine')
is_sublocation = bool(building.parent_location is not None)
level_specific_mode = is_level or (is_sublocation and building.location_type not in ('site', 'building', 'wing'))
def serialize_floor_info(loc):
plan_url = loc.get_floor_plan_url()
rooms = []
for r in loc.child_locations.all():
rooms.append({
'id': r.id,
'code': r.code,
'name': r.name_fr or r.name or r.code,
'room_number': r.room_number or '',
'location_type_display': str(r.get_location_type_display()),
'url': r.get_absolute_url(),
})
return {
'id': loc.id,
'code': loc.code,
'name': loc.name_fr or loc.name or loc.code,
'level_number': loc.level_number if loc.level_number is not None else 0,
'location_type': loc.location_type,
'location_type_display': str(loc.get_location_type_display()),
'surface_m2': float(loc.surface_m2 or loc.usable_surface_m2 or 0) or None,
'has_plan': bool(plan_url),
'plan_url': plan_url or '',
'url': loc.get_absolute_url(),
'rooms': rooms,
'rooms_count': len(rooms),
'assets_count': loc.location_assets.count(),
}
floors_data = []
active_floor = None
if level_specific_mode:
# Dans le détail d'un niveau, on ne retrouve que le plan lié, qui est directement affiché
if is_level or not building.parent_location:
target_floor = building
else:
target_floor = building
curr = building.parent_location
while curr:
if curr.location_type in ('level', 'basement', 'attic', 'mezzanine', 'wing'):
target_floor = curr
break
curr = curr.parent_location
active_floor_info = serialize_floor_info(target_floor)
floors_data = [active_floor_info]
active_floor = active_floor_info
else:
# Vue du bâtiment dans son ensemble: collecte de tous les niveaux
candidates = list(building.child_locations.filter(
Q(location_type__in=['level', 'basement', 'attic', 'mezzanine', 'wing', 'room']) |
Q(level_number__isnull=False)
).select_related('parent_location').prefetch_related('child_locations', 'location_assets', 'documents', 'floor_documents'))
for cand in list(candidates):
if cand.location_type == 'wing':
sub_levels = cand.child_locations.filter(
Q(location_type__in=['level', 'basement', 'attic', 'mezzanine', 'room']) |
Q(level_number__isnull=False)
).select_related('parent_location').prefetch_related('child_locations', 'location_assets', 'documents', 'floor_documents')
candidates.extend(list(sub_levels))
seen_ids = set()
unique_floors = []
for f in candidates:
if f.id not in seen_ids:
seen_ids.add(f.id)
unique_floors.append(f)
unique_floors.sort(key=lambda x: (x.level_number if x.level_number is not None else 99, x.code))
floors_data = [serialize_floor_info(f) for f in unique_floors]
if floors_data:
with_plan = [f for f in floors_data if f['has_plan']]
if with_plan:
active_floor = with_plan[0]
else:
level_0 = [f for f in floors_data if f['level_number'] == 0]
active_floor = level_0[0] if level_0 else floors_data[0]
elif building.get_floor_plan_url():
b_info = serialize_floor_info(building)
floors_data = [b_info]
active_floor = b_info
has_any_plan = any(f['has_plan'] for f in floors_data)
context = {
'building': building,
@ -408,6 +572,14 @@ def building_detail(request, pk):
'building_geojson_json': json.dumps(building_geojson) if building_geojson else 'null',
'center_lon': center_lon,
'center_lat': center_lat,
'center_lon_json': center_lon_json,
'center_lat_json': center_lat_json,
'floors_data': floors_data,
'floors_data_json': json.dumps(floors_data),
'active_floor': active_floor,
'active_floor_json': json.dumps(active_floor) if active_floor else 'null',
'level_specific_mode': level_specific_mode,
'has_any_plan': has_any_plan,
}
return render(request, 'assets/buildings/publicbuilding_detail.html', context)

View file

@ -461,6 +461,8 @@ ELEMENT_TYPE_LABELS = {
'controlcentergeoasset': _('Équipement CdC'),
# --- ITS ---
'itsgeoasset': _('Équipement ITS'),
'itsbollardsystem': _('Système de bornes'),
'itsbollardplot': _('Plot de borne'),
# --- Propreté ---
'cleanlitterbin': _('Corbeille'),
'cleanglasscontainer': _('Conteneur verre'),
@ -470,6 +472,8 @@ ELEMENT_TYPE_LABELS = {
# --- Espaces plein air ---
'outdoorlocation': _('Espace public plein air'),
'outdoorasset': _('Équipement plein air'),
# --- Zones de gestion ---
'managementzone': _('Zone de gestion'),
}
@ -553,6 +557,7 @@ def build_thematic_config(thematics):
},
}
management_zones_url = reverse('assets:management_zones_geojson')
result = {}
for thematic in thematics:
code = thematic.code
@ -560,6 +565,7 @@ def build_thematic_config(thematics):
entry = dict(_THEMATIC_URLS[code])
entry['name'] = thematic.get_name() if hasattr(thematic, 'get_name') else str(thematic)
entry['icon'] = thematic.icon if hasattr(thematic, 'icon') else ''
entry['management_zones_geojson_url'] = management_zones_url
result[code] = entry
return result
@ -675,7 +681,7 @@ def build_conditional_filter_config(accessible_thematics, user=None):
# Roads / public lighting / sign / artworks: municipality on locations (only if more than 1 option)
if len(municipality_options) > 1:
for thematic_code in ['roads', 'publiclighting', 'sign', 'artworks']:
for thematic_code in ['roads', 'publiclighting', 'sign', 'artworks', 'nature']:
if thematic_code in accessible_codes:
config.setdefault(thematic_code, []).append({
'key': 'municipality',
@ -746,7 +752,114 @@ def build_conditional_filter_config(accessible_thematics, user=None):
# Nature
if 'nature' in accessible_codes:
config['nature'] = [
from assets.models import NatureTree
def _get_muni_region(m):
if not m:
return None
code = str(getattr(m, 'niscode', '') or '').strip()
if code.startswith('21'):
return 'BRU'
elif code[:2] in ('25', '51', '52', '53', '55', '56', '57', '61', '62', '63', '81', '82', '83', '84', '85', '91', '92', '93') or (code and code[0] in ('5', '6', '8', '9')):
return 'WAL'
elif (code and code[0] in ('1', '3', '4', '7')) or code[:2] in ('23', '24'):
return 'VLG'
name = (getattr(m, 'name_fr', '') or '').lower()
if any(b in name for b in ('bruxelles', 'watermael', 'boitsfort', 'berchem', 'ixelles', 'etterbeek', 'uccle', 'woluwe', 'schaerbeek', 'anderlecht', 'forest', 'jette', 'ganshoren', 'evere', 'saint-gilles', 'saint-josse', 'auderghem', 'molenbeek', 'koekelberg')):
return 'BRU'
return None
regional_labels = {
'BRU': _('Région (Bruxelles Mobilité)'),
'WAL': _('Région (Région wallonne)'),
'VLG': _('Région (Région flamande)'),
}
user_cfg = getattr(user, 'config', None)
user_is_admin = getattr(user, 'is_superuser', False) or (user_cfg and (user_cfg.has_role('admin') or user_cfg.has_role('top_manager')))
user_is_ext = (user_cfg is None) or (not user_cfg.is_intern)
user_must_limit = user_is_ext or (user_cfg and user_cfg.limit_assets_to_contracts)
visible_trees_qs = NatureTree.objects.exclude(status='removed')
if not user_is_admin and user_must_limit:
visible_trees_qs = visible_trees_qs.filter(location__municipality__in=municipalities)
tree_stats = list(visible_trees_qs.values('management_type', 'location__municipality_id').distinct())
has_trees = len(tree_stats) > 0
muni_list = list(municipalities)
management_type_options = []
if len(muni_list) == 1:
single_muni = muni_list[0]
reg = _get_muni_region(single_muni) or 'BRU'
reg_label = regional_labels.get(reg, _('Région'))
has_municipal = any(t['management_type'] == 'municipal' for t in tree_stats) if has_trees else True
has_regional = any(t['management_type'] == 'regional' for t in tree_stats) if has_trees else True
if has_municipal:
management_type_options.append({
'value': 'municipal',
'label': _('Commune (%(name)s)') % {'name': single_muni.name}
})
if has_regional:
management_type_options.append({
'value': 'regional',
'label': reg_label
})
elif len(muni_list) > 1:
muni_map = {m.id: m for m in muni_list}
# Municipal options for each municipality with municipal trees
for m in muni_list:
has_m_trees = any(
t['management_type'] == 'municipal' and t['location__municipality_id'] == m.id
for t in tree_stats
) if has_trees else True
if has_m_trees:
management_type_options.append({
'value': f'municipal_{m.id}',
'label': _('Commune (%(name)s)') % {'name': m.name}
})
# Regional options grouped by region
regions_with_regional_trees = set()
for t in tree_stats:
if t['management_type'] == 'regional':
mid = t['location__municipality_id']
m_obj = muni_map.get(mid)
if m_obj:
reg = _get_muni_region(m_obj)
if reg:
regions_with_regional_trees.add(reg)
if not has_trees:
for m in muni_list:
reg = _get_muni_region(m)
if reg:
regions_with_regional_trees.add(reg)
for reg_code, reg_val in [('BRU', 'regional_bruxelles'), ('WAL', 'regional_wallonie'), ('VLG', 'regional_flandre')]:
if reg_code in regions_with_regional_trees:
management_type_options.append({
'value': reg_val,
'label': regional_labels[reg_code]
})
else:
management_type_options = [
{'value': 'municipal', 'label': _('Communal')},
{'value': 'regional', 'label': _('Régional')},
]
nature_filters = []
if management_type_options:
nature_filters.append({
'key': 'management_type',
'label': _('Gestionnaire'),
'type': 'multi',
'options': management_type_options,
})
nature_filters.extend([
{
'key': 'location_type',
'label': _('Type de localisation'),
@ -762,7 +875,8 @@ def build_conditional_filter_config(accessible_thematics, user=None):
'type': 'checkbox',
'value': '1',
},
]
])
config['nature'] = nature_filters
# Water
if 'water' in accessible_codes:
@ -936,6 +1050,7 @@ def build_unified_add_modal_config(accessible_thematics, user=None):
from assets.permissions import (
can_edit_location_for_thematic,
get_allowed_actions_for_thematic,
can_edit_management_zones,
)
location_type_by_thematic = {
@ -948,6 +1063,7 @@ def build_unified_add_modal_config(accessible_thematics, user=None):
asset_thematics = []
location_thematics = []
management_zone_thematics = []
for thematic in accessible_thematics:
thematic_name = thematic.get_name() if hasattr(thematic, 'get_name') else str(thematic)
@ -974,9 +1090,18 @@ def build_unified_add_modal_config(accessible_thematics, user=None):
'location_label': location_meta['label'],
})
if can_edit_management_zones(user, thematic):
management_zone_thematics.append({
'code': thematic.code,
'name': thematic_name,
'icon': thematic_icon,
})
return {
'asset_thematics': asset_thematics,
'location_thematics': location_thematics,
'management_zone_thematics': management_zone_thematics,
'management_zone_create_url': reverse('assets:management_zone_create'),
}

View file

@ -10,7 +10,7 @@ from django.contrib.gis.db.models.functions import Transform
from django.contrib.gis.geos import GEOSGeometry, Point
from django.contrib.gis.db.models.functions import Distance
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q, Case, When, IntegerField, CharField
from django.db.models import Q, Case, When, IntegerField, CharField, Count
from django.db.models.functions import Lower
from django.db.models import Func, Prefetch
from django.db import connection
@ -25,7 +25,10 @@ from collections import defaultdict
from common.utils import check_thematic_access
from common.models import Thematic, UserConfig, UserContractAccess, UserThematics
from assets.models import ITSLocation, ITSLocationDocument, ITSAssetModel, ITSGeoAsset, AssetCategory
from assets.models import (
ITSLocation, ITSLocationDocument, ITSAssetModel, ITSGeoAsset, AssetCategory,
ITSBollardSystem, ITSBollardPlot, ITSBollardSystemAsset, ITSBollardSystemContract
)
from assets.permissions import can_edit_location_for_thematic, get_allowed_actions_for_thematic
from assets.forms import ITSLocationDocumentForm
@ -297,11 +300,19 @@ def its_assets_geojson(request):
if lon is None or lat is None:
location = getattr(obj, 'location', None)
system = getattr(obj, 'system', None)
if location is None and system is not None:
location = getattr(system, 'location', None)
if location is not None:
if lon is None:
lon = getattr(location, 'lon', None)
if lat is None:
lat = getattr(location, 'lat', None)
if (lon is None or lat is None) and system is not None:
if lon is None:
lon = getattr(system, 'lon', None)
if lat is None:
lat = getattr(system, 'lat', None)
if geometry is None and lon is not None and lat is not None:
geometry = {
@ -313,25 +324,42 @@ def its_assets_geojson(request):
def obj_to_feature(obj, asset_type=None):
geometry, lon, lat = build_geometry_and_coords(obj)
props = {
"code": obj.code,
"name": obj.get_name(),
"category": obj.category.get_name() if obj.category else None,
"category_id": obj.category_id,
"model": obj.model.get_name() if obj.model else None,
"model_id": obj.model_id,
"status": obj.status,
"validation_status": obj.validation_status,
"lon": lon,
"lat": lat,
"asset_type": asset_type,
"detail_url": obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
"element_type": get_element_type_label(asset_type or ''),
"thematic": "its",
"is_location": False,
}
if asset_type == "itsbollardsystem":
props["width"] = obj.width
props["direction"] = obj.get_direction_display() if hasattr(obj, 'get_direction_display') else obj.direction
props["plots_count"] = getattr(obj, 'plots_total', None)
if props["plots_count"] is None and hasattr(obj, 'plots'):
props["plots_count"] = obj.plots.count()
if obj.geom:
props["length"] = round(obj.geom.length, 2)
elif asset_type == "itsbollardplot":
props["position"] = obj.position
props["bollard_type"] = obj.get_bollard_type_display() if hasattr(obj, 'get_bollard_type_display') else obj.bollard_type
if getattr(obj, 'system', None):
props["system_code"] = obj.system.code
props["system_name"] = obj.system.get_name() if hasattr(obj.system, 'get_name') else (obj.system.name_fr or obj.system.name_nl or obj.system.code)
return {
"type": "Feature",
"id": obj.id,
"geometry": geometry,
"properties": {
"code": obj.code,
"name": obj.get_name(),
"category": obj.category.get_name() if obj.category else None,
"model": obj.model.get_name() if obj.model else None,
"status": obj.status,
"validation_status": obj.validation_status,
"lon": lon,
"lat": lat,
"asset_type": asset_type,
"detail_url": obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
"element_type": get_element_type_label(asset_type or ''),
"thematic": "its",
"is_location": False,
},
"properties": props,
}
def add_assets_to_features(queryset, asset_type, features, feature_count):
@ -348,16 +376,35 @@ def its_assets_geojson(request):
.select_related('location', 'category', 'model')
.all()
)
bollard_system_qs = (
ITSBollardSystem.objects
.select_related('location', 'category', 'model')
.annotate(plots_total=Count('plots'))
.all()
)
bollard_plot_qs = (
ITSBollardPlot.objects
.select_related('system', 'location', 'category', 'model')
.all()
)
# --------- Filtres métier existants
if its_location_ids:
its_asset_qs = its_asset_qs.filter(location__id__in=its_location_ids)
bollard_system_qs = bollard_system_qs.filter(location__id__in=its_location_ids)
bollard_plot_qs = bollard_plot_qs.filter(
Q(location__id__in=its_location_ids) | Q(system__location__id__in=its_location_ids)
)
if models:
its_asset_qs = its_asset_qs.filter(model__id__in=models)
bollard_system_qs = bollard_system_qs.filter(model__id__in=models)
bollard_plot_qs = bollard_plot_qs.filter(model__id__in=models)
if categories:
its_asset_qs = its_asset_qs.filter(category__id__in=categories)
bollard_system_qs = bollard_system_qs.filter(category__id__in=categories)
bollard_plot_qs = bollard_plot_qs.filter(category__id__in=categories)
if filter_geom:
its_asset_qs = its_asset_qs.filter(
@ -365,11 +412,24 @@ def its_assets_geojson(request):
Q(geom__isnull=True, location__geom__intersects=filter_geom) |
Q(geom__isnull=True, location__isnull=True)
)
bollard_system_qs = bollard_system_qs.filter(
Q(geom__intersects=filter_geom) |
Q(geom__isnull=True, location__geom__intersects=filter_geom) |
Q(geom__isnull=True, location__isnull=True)
)
bollard_plot_qs = bollard_plot_qs.filter(
Q(geom__intersects=filter_geom) |
Q(geom__isnull=True, system__geom__intersects=filter_geom) |
Q(geom__isnull=True, location__geom__intersects=filter_geom) |
Q(geom__isnull=True, system__isnull=True)
)
# Recherche plein texte
if request.method == 'POST':
from assets.views.core import apply_search
its_asset_qs = apply_search(its_asset_qs, data)
bollard_system_qs = apply_search(bollard_system_qs, data)
bollard_plot_qs = apply_search(bollard_plot_qs, data)
# --------- Limitation pour utilisateurs externes
user = request.user
@ -388,26 +448,32 @@ def its_assets_geojson(request):
can_view_assets=True
).values_list('contract_id', flat=True)
# Ne garder que les assets qui ont un contrat actif
# auquel l'utilisateur a accès
# Ne garder que les assets qui ont un contrat actif auquel l'utilisateur a accès
its_asset_qs = its_asset_qs.filter(
its_assets_contracts__status='active',
its_assets_contracts__contract_id__in=accessible_contract_ids
).distinct()
bollard_system_qs = bollard_system_qs.filter(
bollard_systems_contracts__status='active',
bollard_systems_contracts__contract_id__in=accessible_contract_ids
).distinct()
bollard_plot_qs = bollard_plot_qs.filter(
system__bollard_systems_contracts__status='active',
system__bollard_systems_contracts__contract_id__in=accessible_contract_ids
).distinct()
else:
# Non authentifié : ne rien exposer (plus sûr)
its_asset_qs = its_asset_qs.none()
# --------- Cap de sécurité
its_asset_qs = its_asset_qs[0:MAX_ASSET_FEATURES+1]
if len(its_asset_qs) >= MAX_ASSET_FEATURES:
return JsonResponse({"error": f"{_("Trop d'objets à afficher")} (> {MAX_ASSET_FEATURES})"}, status=413)
bollard_system_qs = bollard_system_qs.none()
bollard_plot_qs = bollard_plot_qs.none()
# --------- Construction GeoJSON
features = []
feature_count = 0
for qs, asset_type in [
(its_asset_qs, "itsgeoasset"),
(bollard_system_qs, "itsbollardsystem"),
(bollard_plot_qs, "itsbollardplot"),
]:
feature_count, limit_reached = add_assets_to_features(qs, asset_type, features, feature_count)
if limit_reached:
@ -603,7 +669,9 @@ def its_assets_autocomplete(request):
raw_query = raw_query_original.strip()
# CT ids (calculés une fois)
equipment_ct_id = ContentType.objects.get_for_model(ITSGeoAsset).id
geoasset_ct_id = ContentType.objects.get_for_model(ITSGeoAsset).id
bollard_system_ct_id = ContentType.objects.get_for_model(ITSBollardSystem).id
bollard_plot_ct_id = ContentType.objects.get_for_model(ITSBollardPlot).id
location_ct_id = ContentType.objects.get_for_model(ITSLocation).id
qn = strip_accents_lower(raw_query)
@ -626,68 +694,71 @@ def its_assets_autocomplete(request):
# en cas d'ID invalide ou autre, on ignore le filtre
location_ids = None
# Base queryset accent-insensible + actifs uniquement
qs = (
ITSGeoAsset.objects
.annotate(
code_u=Lower(Unaccent('code')),
name_fr_u=Lower(Unaccent('name_fr')),
name_nl_u=Lower(Unaccent('name_nl')),
def query_model(model_class, ct_id, extra_select=()):
q = (
model_class.objects
.annotate(
code_u=Lower(Unaccent('code')),
name_fr_u=Lower(Unaccent('name_fr')),
name_nl_u=Lower(Unaccent('name_nl')),
)
)
if has_search_term:
q = q.filter(
Q(code_u__contains=qn) |
Q(name_fr_u__contains=qn) |
Q(name_nl_u__contains=qn)
)
q = q.exclude(status='removed')
q = q.select_related('location', 'model', 'category', *extra_select)
if location_ids:
if hasattr(model_class, 'system'):
q = q.filter(Q(location_id__in=location_ids) | Q(system__location_id__in=location_ids))
else:
q = q.filter(location_id__in=location_ids)
if category_id is not None:
q = q.filter(
Q(category_id=category_id) |
Q(category__isnull=True, model__category_id=category_id)
)
if has_search_term:
priority = Case(
When(code_u=qn, then=0),
When(code_u__startswith=qn, then=1),
When(name_fr_u__startswith=qn, then=2),
When(name_nl_u__startswith=qn, then=3),
When(code_u__contains=qn, then=4),
When(name_fr_u__contains=qn, then=5),
When(name_nl_u__contains=qn, then=6),
default=7,
output_field=IntegerField(),
)
q = q.annotate(_prio=priority).order_by('_prio', 'code')[:50]
else:
q = q.order_by('code')[:50]
results = []
for obj in q:
results.append((obj, ct_id))
return results
all_results = (
query_model(ITSGeoAsset, geoasset_ct_id) +
query_model(ITSBollardSystem, bollard_system_ct_id) +
query_model(ITSBollardPlot, bollard_plot_ct_id, ('system', 'system__location'))
)
# Si un terme de recherche est fourni, filtrer dessus
if has_search_term:
qs = qs.filter(
Q(code_u__contains=qn) |
Q(name_fr_u__contains=qn) |
Q(name_nl_u__contains=qn)
)
qs = qs.exclude(status='removed') # <-- exclure les supprimés
qs = qs.select_related(
'location',
'model',
'category',
)
if location_ids:
qs = qs.filter(location_id__in=location_ids)
if category_id is not None:
qs = qs.filter(
Q(category_id=category_id) |
Q(category__isnull=True, model__category_id=category_id)
)
# Priorité : exact > code startswith > nom startswith > contient
if has_search_term:
priority = Case(
When(code_u=qn, then=0),
When(code_u__startswith=qn, then=1),
When(name_fr_u__startswith=qn, then=2),
When(name_nl_u__startswith=qn, then=3),
When(code_u__contains=qn, then=4),
When(name_fr_u__contains=qn, then=5),
When(name_nl_u__contains=qn, then=6),
default=7,
output_field=IntegerField(),
)
qs = qs.annotate(_prio=priority).order_by('_prio', 'code')[:100]
else:
# Sans terme de recherche, tri par code uniquement
qs = qs.order_by('code')[:100]
data = []
for a in qs:
# equipment_type depuis model/category
for a, ct_id in all_results[:100]:
equipment_type = None
if getattr(a, 'model', None):
equipment_type = getattr(a.model, 'name', None) or getattr(a.model, 'code', None)
if not equipment_type and getattr(a, 'category', None):
equipment_type = getattr(a.category, 'name', None)
# coordonnées: asset > geom(4326) > location
lon = a.lon
lat = a.lat
@ -698,24 +769,33 @@ def its_assets_autocomplete(request):
g = a.geom.transform(4326, clone=True)
coords = getattr(g, 'coords', None)
if coords:
if lon is None: lon = coords[0]
if lat is None: lat = coords[1]
if isinstance(coords[0], (int, float)):
if lon is None: lon = coords[0]
if lat is None: lat = coords[1]
elif isinstance(coords[0], (list, tuple)) and coords[0]:
# Pour LineString (ITSBollardSystem)
mid = len(coords) // 2
if lon is None: lon = coords[mid][0]
if lat is None: lat = coords[mid][1]
except Exception:
pass
if (lon is None or lat is None) and getattr(a, 'location', None):
if lon is None and getattr(a.location, 'lon', None) is not None:
lon = a.location.lon
if lat is None and getattr(a.location, 'lat', None) is not None:
lat = a.location.lat
loc = getattr(a, 'location', None)
if loc is None and hasattr(a, 'system') and a.system:
loc = getattr(a.system, 'location', None)
if (lon is None or lat is None) and loc:
if lon is None and getattr(loc, 'lon', None) is not None:
lon = loc.lon
if lat is None and getattr(loc, 'lat', None) is not None:
lat = loc.lat
equipment_label = f"{a.get_name()} ({a.code})" if a.code else a.get_name()
label = f"{a.code} - {equipment_label}" if a.code else equipment_label
location_label = (
f"{a.location.get_name()} ({a.location.code})"
if getattr(a, "location", None) else None
f"{loc.get_name()} ({loc.code})"
if loc else None
)
data.append({
@ -725,9 +805,9 @@ def its_assets_autocomplete(request):
"equipment_id": a.id,
"equipment_label": equipment_label,
"equipment_type": equipment_type,
"equipment_ct": equipment_ct_id,
"equipment_ct": ct_id,
"location_id": a.location.id if getattr(a, "location", None) else None,
"location_id": loc.id if loc else None,
"location_label": location_label,
"location_ct": location_ct_id,

View file

@ -0,0 +1,427 @@
import json
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse, HttpResponseForbidden
from django.shortcuts import render, get_object_or_404, redirect
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_GET, require_POST, require_http_methods
from common.models import Thematic, UserConfig
from assets.models import (
ManagementZone,
ManagementZoneType,
ManagementZoneDocument,
)
from assets.forms import ManagementZoneForm, ManagementZoneDocumentForm
from assets.permissions import (
can_view_management_zones,
can_edit_management_zones,
can_delete_management_zone,
get_editable_thematics_for_management_zones,
get_user_asset_thematic_access,
)
@login_required
@require_GET
def management_zones_geojson(request):
"""
Retourne la collection GeoJSON des zones de gestion actives avec géométrie.
Filtre optionnel par ?thematic=<thematic_code>.
Vérifie que l'utilisateur a accès en lecture aux assets de la thématique.
"""
thematic_code = request.GET.get('thematic')
user = request.user
if thematic_code:
thematic = Thematic.objects.filter(code=thematic_code).first()
if not thematic:
return JsonResponse({'type': 'FeatureCollection', 'features': []})
can_view, _can_edit = get_user_asset_thematic_access(user, thematic)
if not can_view:
return HttpResponseForbidden("Access denied to this thematic management zones.")
zones_qs = ManagementZone.objects.filter(
thematic=thematic,
is_active=True,
geom__isnull=False
).select_related('thematic', 'zone_type')
else:
if user.is_superuser:
zones_qs = ManagementZone.objects.filter(is_active=True, geom__isnull=False)
else:
try:
user_config = UserConfig.objects.get(user=user)
if user_config.roles.filter(name__in=['admin', 'operator', 'manager']).exists():
allowed_thematics = Thematic.objects.all()
else:
allowed_thematics = Thematic.objects.filter(
userthematics__user_config=user_config,
userthematics__can_view_assets=True
)
zones_qs = ManagementZone.objects.filter(
thematic__in=allowed_thematics,
is_active=True,
geom__isnull=False
)
except UserConfig.DoesNotExist:
return JsonResponse({'type': 'FeatureCollection', 'features': []})
zones_qs = zones_qs.select_related('thematic', 'zone_type')
features = []
for zone in zones_qs:
geom_json = zone.geojson_geometry
if not geom_json:
continue
centroid = zone.geom.centroid.clone() if zone.geom else None
if centroid:
if centroid.srid and centroid.srid != 4326:
centroid.transform(4326)
lon = round(centroid.x, 7)
lat = round(centroid.y, 7)
else:
lon = None
lat = None
area = round(zone.geom.area, 1) if getattr(zone.geom, 'area', None) is not None else None
category_name = zone.zone_type.get_name() if zone.zone_type else ""
features.append({
'type': 'Feature',
'id': zone.id,
'properties': {
'id': zone.id,
'code': zone.code,
'name': zone.get_name(),
'thematic': zone.thematic.code,
'thematic_name': zone.thematic.name,
'zone_type_code': zone.zone_type.code if zone.zone_type else None,
'zone_type_name': category_name,
'category': category_name,
'color': zone.get_color(),
'instructions': zone.instructions or "",
'description': zone.description or "",
'seasonality_notes': zone.seasonality_notes or "",
'target_frequency_days': zone.target_frequency_days,
'status': 'active' if zone.is_active else 'inactive',
'element_type': str(_('Zone de gestion')),
'asset_type': 'managementzone',
'detail_url': reverse('assets:management_zone_detail', kwargs={'pk': zone.pk}),
'lon': lon,
'lat': lat,
'area': area,
'is_location': False,
'is_management_zone': True,
},
'geometry': geom_json
})
return JsonResponse({
'type': 'FeatureCollection',
'features': features
})
@login_required
def management_zones_index(request):
"""
Page de listing et de consultation des zones de gestion avec filtres et carte de synthèse.
"""
user = request.user
if not can_view_management_zones(user):
return HttpResponseForbidden(_("Vous n'avez pas l'autorisation d'accéder aux zones de gestion."))
if user.is_superuser:
accessible_thematics = Thematic.objects.all()
else:
try:
user_config = UserConfig.objects.get(user=user)
if user_config.roles.filter(name__in=['admin', 'operator', 'manager']).exists():
accessible_thematics = Thematic.objects.all()
else:
accessible_thematics = Thematic.objects.filter(
userthematics__user_config=user_config,
userthematics__can_view_assets=True
)
except UserConfig.DoesNotExist:
accessible_thematics = Thematic.objects.none()
zones_qs = ManagementZone.objects.filter(
thematic__in=accessible_thematics
).select_related('thematic', 'zone_type', 'created_by')
# Filtre par thématique
thematic_code = request.GET.get('thematic', '').strip()
selected_thematic = None
if thematic_code:
selected_thematic = accessible_thematics.filter(code=thematic_code).first()
if selected_thematic:
zones_qs = zones_qs.filter(thematic=selected_thematic)
# Filtre par type
zone_type_id = request.GET.get('zone_type', '').strip()
if zone_type_id:
zones_qs = zones_qs.filter(zone_type_id=zone_type_id)
# Filtre par statut actif/inactif
status_filter = request.GET.get('status', '').strip()
if status_filter == 'active':
zones_qs = zones_qs.filter(is_active=True)
elif status_filter == 'inactive':
zones_qs = zones_qs.filter(is_active=False)
# Recherche plein texte
q = request.GET.get('q', '').strip()
if q:
from django.db.models import Q
zones_qs = zones_qs.filter(
Q(code__icontains=q) |
Q(name_fr__icontains=q) |
Q(name_nl__icontains=q) |
Q(description__icontains=q) |
Q(instructions__icontains=q) |
Q(seasonality_notes__icontains=q)
)
zones_qs = zones_qs.order_by('thematic__name_fr', 'code')
# Types disponibles pour le filtre
if selected_thematic:
available_types = ManagementZoneType.objects.filter(thematic=selected_thematic)
else:
available_types = ManagementZoneType.objects.filter(thematic__in=accessible_thematics)
can_create = can_edit_management_zones(user)
editable_thematics = get_editable_thematics_for_management_zones(user)
zones_list = []
for zone in zones_qs:
zones_list.append({
'zone': zone,
'can_edit': can_edit_management_zones(user, thematic=zone.thematic)
})
context = {
'zones_list': zones_list,
'accessible_thematics': accessible_thematics,
'selected_thematic': selected_thematic,
'selected_thematic_code': thematic_code,
'available_types': available_types,
'selected_type_id': zone_type_id,
'selected_status': status_filter,
'search_query': q,
'can_create': can_create,
'editable_thematics': editable_thematics,
'total_count': zones_qs.count(),
}
return render(request, 'assets/management_zones/management_zones_index.html', context)
@login_required
def management_zone_create(request):
"""
Création d'une nouvelle zone de gestion avec tracé géographique sur la carte.
"""
user = request.user
if not can_edit_management_zones(user):
return HttpResponseForbidden(_("Vous n'avez pas l'autorisation de créer des zones de gestion."))
editable_thematics = get_editable_thematics_for_management_zones(user)
if not editable_thematics.exists():
return HttpResponseForbidden(_("Aucune thématique disponible pour la création de zones."))
initial = {}
thematic_code = request.GET.get('thematic')
if thematic_code:
thematic = editable_thematics.filter(code=thematic_code).first()
if thematic:
initial['thematic'] = thematic
if request.method == 'POST':
form = ManagementZoneForm(request.POST, user=user)
if form.is_valid():
target_thematic = form.cleaned_data['thematic']
if not can_edit_management_zones(user, thematic=target_thematic):
return HttpResponseForbidden(_("Vous n'avez pas le droit de créer une zone pour cette thématique."))
zone = form.save(commit=False)
zone.created_by = user
zone.save()
messages.success(request, _("La zone de gestion « %(code)s » a été créée avec succès.") % {'code': zone.code})
return redirect('assets:management_zone_detail', pk=zone.pk)
else:
form = ManagementZoneForm(user=user, initial=initial)
return render(request, 'assets/management_zones/management_zones_form.html', {
'form': form,
'is_create': True,
'title': _("Nouvelle zone de gestion"),
})
@login_required
def management_zone_edit(request, pk):
"""
Modification d'une zone de gestion existante et de sa géométrie.
"""
user = request.user
zone = get_object_or_404(ManagementZone.objects.select_related('thematic'), pk=pk)
if not can_edit_management_zones(user, thematic=zone.thematic):
return HttpResponseForbidden(_("Vous n'avez pas l'autorisation de modifier cette zone de gestion."))
if request.method == 'POST':
form = ManagementZoneForm(request.POST, instance=zone, user=user)
if form.is_valid():
target_thematic = form.cleaned_data['thematic']
if not can_edit_management_zones(user, thematic=target_thematic):
return HttpResponseForbidden(_("Vous n'avez pas le droit d'assigner cette zone à cette thématique."))
form.save()
messages.success(request, _("La zone de gestion « %(code)s » a été mise à jour.") % {'code': zone.code})
return redirect('assets:management_zone_detail', pk=zone.pk)
else:
form = ManagementZoneForm(instance=zone, user=user)
return render(request, 'assets/management_zones/management_zones_form.html', {
'form': form,
'zone': zone,
'is_create': False,
'title': _("Modifier la zone de gestion %(code)s") % {'code': zone.code},
})
@login_required
def management_zone_detail(request, pk):
"""
Fiche détaillée d'une zone de gestion : informations, consignes, calendrier, carte, documents et équipements.
"""
user = request.user
zone = get_object_or_404(
ManagementZone.objects.select_related('thematic', 'zone_type', 'created_by'),
pk=pk
)
if not can_view_management_zones(user, thematic=zone.thematic):
return HttpResponseForbidden(_("Vous n'avez pas l'autorisation de consulter cette zone de gestion."))
can_edit = can_edit_management_zones(user, thematic=zone.thematic)
documents = zone.documents.select_related('uploaded_by').order_by('-upload_date')
explicit_assets = zone.explicit_assets.select_related('content_type').all()
doc_form = ManagementZoneDocumentForm() if can_edit else None
# Calcul de superficie indicative si géométrie présente (SRID 3812 en mètres)
area_m2 = None
area_ha = None
if zone.geom:
try:
area_m2 = round(zone.geom.area, 1)
area_ha = round(area_m2 / 10000, 2)
except Exception:
pass
return render(request, 'assets/management_zones/management_zones_detail.html', {
'zone': zone,
'can_edit': can_edit,
'documents': documents,
'explicit_assets': explicit_assets,
'doc_form': doc_form,
'area_m2': area_m2,
'area_ha': area_ha,
})
@login_required
@require_http_methods(["GET", "POST"])
def management_zone_delete(request, pk):
"""
Suppression d'une zone de gestion.
"""
user = request.user
zone = get_object_or_404(ManagementZone.objects.select_related('thematic'), pk=pk)
if not can_delete_management_zone(user, zone):
return HttpResponseForbidden(_("Vous n'avez pas l'autorisation de supprimer cette zone de gestion."))
if request.method == 'POST':
code = zone.code
thematic_code = zone.thematic.code
zone.delete()
messages.success(request, _("La zone de gestion « %(code)s » a été supprimée.") % {'code': code})
return redirect(f"{reverse('assets:management_zones_index')}?thematic={thematic_code}")
return render(request, 'assets/management_zones/management_zones_confirm_delete.html', {
'zone': zone,
})
@login_required
@require_POST
def management_zone_add_document(request, pk):
"""
Ajout de documents techniques ou de consignes à la zone de gestion.
"""
user = request.user
zone = get_object_or_404(ManagementZone.objects.select_related('thematic'), pk=pk)
if not can_edit_management_zones(user, thematic=zone.thematic):
return HttpResponseForbidden(_("Vous n'avez pas l'autorisation d'ajouter des documents à cette zone."))
form = ManagementZoneDocumentForm(request.POST, request.FILES)
if form.is_valid():
files = request.FILES.getlist('documents')
for f in files:
ManagementZoneDocument.objects.create(
zone=zone,
file=f,
uploaded_by=user
)
messages.success(request, _("%(count)d document(s) ajouté(s) avec succès.") % {'count': len(files)})
else:
messages.error(request, _("Erreur lors de l'ajout des documents."))
return redirect('assets:management_zone_detail', pk=zone.pk)
@login_required
@require_POST
def management_zone_delete_document(request, pk, document_id):
"""
Suppression d'un document attaché à la zone de gestion.
"""
user = request.user
zone = get_object_or_404(ManagementZone.objects.select_related('thematic'), pk=pk)
if not can_edit_management_zones(user, thematic=zone.thematic):
return HttpResponseForbidden(_("Vous n'avez pas l'autorisation de supprimer ce document."))
doc = get_object_or_404(ManagementZoneDocument, pk=document_id, zone=zone)
doc.delete()
messages.success(request, _("Le document a été supprimé avec succès."))
return redirect('assets:management_zone_detail', pk=zone.pk)
@login_required
@require_GET
def management_zone_types_ajax(request):
"""
Endpoint JSON retournant les types de zones pour une thématique donnée.
"""
thematic_id = request.GET.get('thematic_id')
thematic_code = request.GET.get('thematic_code')
qs = ManagementZoneType.objects.all()
if thematic_id:
qs = qs.filter(thematic_id=thematic_id)
elif thematic_code:
qs = qs.filter(thematic__code=thematic_code)
types = [
{
'id': t.id,
'name': t.get_name(),
'code': t.code,
'color': t.default_color
}
for t in qs
]
return JsonResponse({'types': types})

View file

@ -1,11 +1,11 @@
from django.shortcuts import render, get_object_or_404
from django.shortcuts import render, get_object_or_404, redirect
from django.http import JsonResponse, Http404
from django.contrib.gis.db.models.functions import Transform
from django.contrib.gis.geos import GEOSGeometry, Point, MultiPolygon
from django.contrib.gis.db.models.functions import Distance
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import get_language, gettext as _
from django.db.models import F, Func, Q, Case, When, CharField, IntegerField, Value
from django.db.models import F, Func, Q, Case, When, CharField, IntegerField, Value, Sum, Count
from django.db.models import Max
from django.db.models.functions import Lower
from django.views.decorators.http import require_GET
@ -23,94 +23,20 @@ from common.models import UserThematics
from assets.models import NatureLocation, NatureGreenSurface, NatureAssetModel, NatureTree, NatureShrub, NatureBench, AssetCategory, NatureRWIZ, NatureRWIAsset
from assets.models import GreenSurfacesComment, GreenSurfacesCommentPhoto
from interventions.models import InterventionLocation
from interventions.models import Intervention, InterventionLocation, InterventionAsset
from interventions.views.drafts import InterventionDraftManager
@check_thematic_access('nature')
def nature_index(request):
thematic = 'nature'
lang = get_language()
# Get filters from URL
statuses = request.GET.getlist('status')
highway = request.GET.get('highway') == "1"
selected_categories = request.GET.getlist('category')
selected_models = request.GET.getlist('model')
selected_location_type = request.GET.get('location_type', '')
# Get preventive draft ID (if any) for selecting assets
preventive_draft_id = request.GET.get('preventive_draft_id')
if preventive_draft_id:
drafts = InterventionDraftManager(request.session)
draft_data = drafts.get(preventive_draft_id)
form_data = draft_data.get('form_data', {})
preventive_element_type = form_data.get('type_of_selected_elements')
if preventive_element_type == 'assets':
selected_assets = draft_data.get('assets', []) or []
else:
selected_locations_ids = draft_data.get('locations', [])
thematic_choices_qs = (
UserThematics.objects
.filter(user_config=request.user.config, can_view_assets=True)
.select_related('thematic')
.order_by(f'thematic__{"name_nl" if lang == "nl" else "name_fr"}')
)
thematics = [tc.thematic for tc in thematic_choices_qs]
selected_thematic = {
"code": thematic,
"name": next((t.name for t in thematics if t.code == thematic), None),
"icon": next((t.icon for t in thematics if t.code == thematic), None),
}
categories = list(AssetCategory.objects.filter(thematic__code = 'nature'))
categories.sort(key=lambda m: m.get_name().lower())
category_choices = [(m.id, m.get_name()) for m in categories]
models = list(NatureAssetModel.objects.all())
models.sort(key=lambda m: (m.get_name() or "").lower())
model_choices = [(m.id, m.get_name()) for m in models]
category_model_map = defaultdict(list)
models = NatureAssetModel.objects.all()
for model in models:
if model.category:
display_name = model.get_name()
category_model_map[str(model.category_id)].append({
"key": str(model.id),
"label": display_name,
})
context = {
'thematics': thematics,
'category_choices': category_choices,
'model_choices': model_choices,
'category_model_map': dict(category_model_map),
'selected_thematic': selected_thematic,
'selected_statuses': statuses,
'selected_categories': selected_categories,
'selected_models': selected_models,
'highway': highway,
'selected_location_type': selected_location_type,
'preventive_draft_id': preventive_draft_id,
'preventive_element_type': preventive_element_type if 'preventive_element_type' in locals() else '',
'selected_locations_ids': selected_locations_ids if 'selected_locations_ids' in locals() else [],
"selected_assets_json": json.dumps(selected_assets) if 'selected_assets' in locals() else [],
'locations_tab_title': _("Voiries"),
'locations_assets_title': "Assets",
}
return render(request, "assets/nature_index.html", context)
query_params = request.GET.copy()
if 'thematic' not in query_params:
query_params['thematic'] = 'nature'
return redirect(f"{reverse('assets:index')}?{query_params.urlencode()}")
def nature_locations(request):
pass
return redirect(f"{reverse('assets:index')}?thematic=nature")
@check_thematic_access('nature')
@ -118,11 +44,304 @@ def nature_locations_detail(request, location_id):
section = get_object_or_404(NatureLocation, id=location_id)
content_type = ContentType.objects.get_for_model(section)
interventions = [obj.intervention for obj in InterventionLocation.objects.filter(content_type=content_type, object_id=section.id).select_related('intervention').order_by('-intervention__creation_time')]
# 1. Direct assets linked to this location (evaluated once)
trees = list(section.nature_trees.select_related('model', 'category').all())
green_surfaces = list(section.nature_green_surfaces.select_related('model', 'category').all())
shrubs = list(section.nature_shrubs.select_related('model', 'category').all())
benches = list(section.nature_benches.select_related('model', 'category').all())
# 2. Key figures & metrics
trees_count = len(trees)
trees_active_count = sum(1 for t in trees if getattr(t, 'status', None) == 'active')
trees_remarkable_count = sum(1 for t in trees if getattr(t, 'is_remarkable', False))
green_surfaces_count = len(green_surfaces)
total_green_surface = sum((gs.surface_area or 0.0) for gs in green_surfaces)
def is_massif_arbore(gs):
if not getattr(gs, 'model', None):
return False
n_fr = (gs.model.name_fr or '').lower()
n_nl = (gs.model.name_nl or '').lower()
code = (gs.model.code or '').upper()
return (
'arboré' in n_fr or 'arbore' in n_fr or
'boom' in n_nl or 'hout' in n_nl or
'ARBORE' in code
)
def is_pre_fleuri(gs):
if not getattr(gs, 'model', None):
return False
n_fr = (gs.model.name_fr or '').lower()
n_nl = (gs.model.name_nl or '').lower()
code = (gs.model.code or '').upper()
return (
'fleuri' in n_fr or 'fleur' in n_fr or
'bloem' in n_nl or
'FLEURI' in code
)
surface_massif_arbore = sum((gs.surface_area or 0.0) for gs in green_surfaces if is_massif_arbore(gs))
massif_arbore_count = sum(1 for gs in green_surfaces if is_massif_arbore(gs))
surface_pre_fleuri = sum((gs.surface_area or 0.0) for gs in green_surfaces if is_pre_fleuri(gs))
pre_fleuri_count = sum(1 for gs in green_surfaces if is_pre_fleuri(gs))
# Breakdown by green surface typology
type_map = {}
for gs in green_surfaces:
m_id = gs.model_id or 0
if m_id not in type_map:
m_name = gs.model.get_name() if getattr(gs, 'model', None) else str(_("Autre / Non défini"))
m_color = (gs.model.color if (getattr(gs, 'model', None) and gs.model.color) else '#4caf50')
type_map[m_id] = {
'name': m_name,
'color': m_color,
'count': 0,
'total_area': 0.0,
}
type_map[m_id]['count'] += 1
type_map[m_id]['total_area'] += (gs.surface_area or 0.0)
surfaces_by_type = sorted(type_map.values(), key=lambda x: x['total_area'], reverse=True)
for item in surfaces_by_type:
item['percent'] = round((item['total_area'] / total_green_surface * 100), 1) if total_green_surface > 0 else 0
shrubs_count = len(shrubs)
benches_count = len(benches)
total_assets_count = trees_count + green_surfaces_count + shrubs_count + benches_count
# 3. Interventions linked to location + linked to assets on this location
location_intervention_ids = set(InterventionLocation.objects.filter(
content_type=content_type, object_id=section.id
).values_list('intervention_id', flat=True))
tree_ids = [t.id for t in trees]
gs_ids = [gs.id for gs in green_surfaces]
shrub_ids = [s.id for s in shrubs]
bench_ids = [b.id for b in benches]
asset_tuples = [
(ContentType.objects.get_for_model(NatureTree), tree_ids),
(ContentType.objects.get_for_model(NatureGreenSurface), gs_ids),
(ContentType.objects.get_for_model(NatureShrub), shrub_ids),
(ContentType.objects.get_for_model(NatureBench), bench_ids),
]
for ct, ids in asset_tuples:
if ids:
int_ids = InterventionAsset.objects.filter(
content_type=ct, object_id__in=ids
).values_list('intervention_id', flat=True)
location_intervention_ids.update(int_ids)
interventions = list(Intervention.objects.filter(
id__in=location_intervention_ids
).select_related('contract').order_by('-creation_time'))
# 4. Documents
documents = []
try:
from documents.models import DocumentAttachment
attachments = DocumentAttachment.objects.filter(
content_type=content_type, object_id=section.id
).select_related('document').prefetch_related('document__versions')
for att in attachments:
doc = att.document
latest_version = doc.latest_version if hasattr(doc, 'latest_version') else None
documents.append({
'title': doc.title or getattr(latest_version, 'filename', str(doc)),
'date': doc.created_at,
'tag': doc.tag.name_fr if (hasattr(doc, 'tag') and doc.tag) else '',
'url': latest_version.file.url if (latest_version and latest_version.file) else None,
'thumbnail_url': latest_version.thumbnail.url if (latest_version and getattr(latest_version, 'thumbnail', None)) else None,
})
except Exception:
pass
if hasattr(section, 'documents'):
for doc in section.documents.all():
documents.append({
'title': doc.file.name if hasattr(doc, 'file') and doc.file else str(doc),
'date': getattr(doc, 'upload_date', None),
'tag': '',
'url': doc.file.url if (hasattr(doc, 'file') and doc.file) else None,
'thumbnail_url': doc.thumbnail.url if (hasattr(doc, 'thumbnail') and doc.thumbnail) else None,
})
# 5. Serialized GeoJSON for map display
assets_features = []
for gs in green_surfaces:
geom = None
if gs.geojson:
try:
geom = json.loads(gs.geojson)
except Exception:
geom = None
elif gs.geom:
try:
geom = json.loads(gs.geom.transform(4326, clone=True).geojson)
except Exception:
geom = None
if geom:
assets_features.append({
"type": "Feature",
"id": f"gs_{gs.id}",
"geometry": geom,
"properties": {
"id": gs.id,
"code": gs.code,
"name": gs.get_name(),
"model": gs.model.get_name() if gs.model else '',
"asset_type": "naturegreensurface",
"area": f"{gs.surface_area:.1f} m²" if gs.surface_area else '',
"color": gs.model.color if (gs.model and gs.model.color) else '#4caf50',
"status": gs.get_status_display(),
"lon": gs.lon,
"lat": gs.lat,
"detail_url": reverse('assets:nature_assets_detail', kwargs={'asset_model': 'naturegreensurface', 'asset_id': gs.id}),
}
})
for tree in trees:
geom = None
if tree.geojson:
try:
geom = json.loads(tree.geojson)
except Exception:
geom = None
elif tree.lon is not None and tree.lat is not None:
geom = {"type": "Point", "coordinates": [tree.lon, tree.lat]}
if geom:
assets_features.append({
"type": "Feature",
"id": f"tree_{tree.id}",
"geometry": geom,
"properties": {
"id": tree.id,
"code": tree.code,
"name": tree.get_name() or (tree.model.get_name() if tree.model else ''),
"model": tree.model.get_name() if tree.model else '',
"scientific_name": tree.model.scientific_name if (tree.model and tree.model.scientific_name) else '',
"asset_type": "naturetree",
"color": "#2e7d32",
"vitality": tree.get_vitality_display() if tree.vitality else '',
"stage": tree.get_development_stage_display() if tree.development_stage else '',
"height": f"{tree.height} m" if tree.height else '',
"status": tree.get_status_display(),
"is_remarkable": tree.is_remarkable,
"lon": tree.lon,
"lat": tree.lat,
"detail_url": reverse('assets:nature_assets_detail', kwargs={'asset_model': 'naturetree', 'asset_id': tree.id}),
}
})
for shrub in shrubs:
geom = None
if shrub.geojson:
try:
geom = json.loads(shrub.geojson)
except Exception:
geom = None
elif shrub.lon is not None and shrub.lat is not None:
geom = {"type": "Point", "coordinates": [shrub.lon, shrub.lat]}
if geom:
assets_features.append({
"type": "Feature",
"id": f"shrub_{shrub.id}",
"geometry": geom,
"properties": {
"id": shrub.id,
"code": shrub.code,
"model": shrub.model.get_name() if shrub.model else '',
"asset_type": "natureshrub",
"color": "#81c784",
"height": f"{shrub.height} m" if shrub.height else '',
"status": shrub.get_status_display(),
"lon": shrub.lon,
"lat": shrub.lat,
"detail_url": reverse('assets:nature_assets_detail', kwargs={'asset_model': 'natureshrub', 'asset_id': shrub.id}),
}
})
for bench in benches:
geom = None
if bench.geojson:
try:
geom = json.loads(bench.geojson)
except Exception:
geom = None
elif bench.lon is not None and bench.lat is not None:
geom = {"type": "Point", "coordinates": [bench.lon, bench.lat]}
if geom:
assets_features.append({
"type": "Feature",
"id": f"bench_{bench.id}",
"geometry": geom,
"properties": {
"id": bench.id,
"code": bench.code,
"model": bench.model.get_name() if bench.model else '',
"asset_type": "naturebench",
"color": "#8d6e63",
"material": bench.material or '',
"status": bench.get_status_display(),
"lon": bench.lon,
"lat": bench.lat,
"detail_url": reverse('assets:nature_assets_detail', kwargs={'asset_model': 'naturebench', 'asset_id': bench.id}),
}
})
assets_geojson = json.dumps({"type": "FeatureCollection", "features": assets_features})
# Location geometry for map
section_geojson = section.geojson
if not section_geojson and section.geom:
try:
section_geojson = section.geom.transform(4326, clone=True).geojson
except Exception:
section_geojson = None
feature_lon = section.lon
feature_lat = section.lat
if (feature_lon is None or feature_lat is None) and section.geom:
try:
pt = section.geom.transform(4326, clone=True).centroid
feature_lon = pt.x
feature_lat = pt.y
except Exception:
pass
context = {
'section': section,
'trees': trees,
'green_surfaces': green_surfaces,
'shrubs': shrubs,
'benches': benches,
'trees_count': trees_count,
'trees_active_count': trees_active_count,
'trees_remarkable_count': trees_remarkable_count,
'green_surfaces_count': green_surfaces_count,
'total_green_surface': total_green_surface,
'surface_massif_arbore': surface_massif_arbore,
'massif_arbore_count': massif_arbore_count,
'surface_pre_fleuri': surface_pre_fleuri,
'pre_fleuri_count': pre_fleuri_count,
'surfaces_by_type': surfaces_by_type,
'shrubs_count': shrubs_count,
'benches_count': benches_count,
'total_assets_count': total_assets_count,
'interventions': interventions,
'documents': documents,
'assets_geojson': assets_geojson,
'section_geojson': section_geojson,
'feature_lon': feature_lon,
'feature_lat': feature_lat,
}
return render(request, "assets/nature_locations_detail.html", context)
@ -159,10 +378,30 @@ def nature_locations_geojson(request):
filter_geom = None
from assets.permissions import get_user_accessible_municipalities
user = request.user
user_config = getattr(user, 'config', None)
is_admin = getattr(user, 'is_superuser', False) or (user_config and (user_config.has_role('admin') or user_config.has_role('top_manager')))
is_external = (user_config is None) or (not user_config.is_intern)
must_limit = is_external or (user_config and user_config.limit_assets_to_contracts)
municipalities = ensure_list(data.get('municipality')) if request.method == "POST" else request.GET.getlist('municipality')
include_naturelocation = location_type in ('', 'naturelocation')
nature_locations = NatureLocation.objects.all() if include_naturelocation else NatureLocation.objects.none()
if not is_admin and must_limit:
accessible_munis = get_user_accessible_municipalities(user)
accessible_muni_ids = list(accessible_munis.values_list('id', flat=True))
if municipalities:
valid_munis = [mid for mid in municipalities if (int(mid) if str(mid).isdigit() else mid) in accessible_muni_ids]
nature_locations = nature_locations.filter(municipality_id__in=valid_munis)
else:
nature_locations = nature_locations.filter(municipality_id__in=accessible_muni_ids)
elif municipalities:
nature_locations = nature_locations.filter(municipality_id__in=municipalities)
if highway:
nature_locations = nature_locations.filter(is_highway=highway)
@ -258,7 +497,7 @@ def ensure_list(value):
return [value]
MAX_ASSET_FEATURES = 5000 # limite imposée
MAX_ASSET_FEATURES = 10000 # limite imposée
@check_thematic_access('nature')
def nature_assets_geojson(request):
@ -277,6 +516,7 @@ def nature_assets_geojson(request):
models = ensure_list(data.get('model'))
highway = data.get('highway') == "1"
location_type = data.get('location_type', '')
management_types = ensure_list(data.get('management_type'))
# Récupération et parsing de la géométrie
filter_geom_raw = data.get("filter_geom")
@ -297,11 +537,12 @@ def nature_assets_geojson(request):
models = request.GET.getlist('model', [])
highway = request.GET.get('highway') == "1"
location_type = request.GET.get('location_type', '')
management_types = request.GET.getlist('management_type', [])
filter_geom = None
_search_q = (data.get('q') or '').strip() if request.method == 'POST' else ''
if not request.GET.get('unified') and not sections_ids and not categories and not models and not filter_geom and not _search_q and not highway and not location_type: # return empty geojson if no param with section_id
if not request.GET.get('unified') and not sections_ids and not categories and not models and not filter_geom and not _search_q and not highway and not location_type and not management_types: # return empty geojson if no param with section_id
geojson_data = {
"type": "FeatureCollection",
"features": [],
@ -371,6 +612,13 @@ def nature_assets_geojson(request):
"thematic": "nature",
"is_location": False,
"structure_type": obj.get('structure_type', ''),
"managing_authority": obj.get('managing_authority') or '',
"management_type": obj.get('management_type') or '',
"is_remarkable": obj.get('is_remarkable', False),
"scientific_name": obj.get('scientific_name') or '',
"vernacular_name": obj.get('vernacular_name') or '',
"is_dead": bool(obj.get('is_dead') or obj.get('vitality') == 'dead' or obj.get('status') in ('dead', 'archived')),
"vitality": obj.get('vitality') or '',
}
}
@ -397,6 +645,35 @@ def nature_assets_geojson(request):
NatureBench.objects.all()
)
from assets.permissions import get_user_accessible_municipalities
user = request.user
user_config = getattr(user, 'config', None)
is_admin = getattr(user, 'is_superuser', False) or (user_config and (user_config.has_role('admin') or user_config.has_role('top_manager')))
is_external = (user_config is None) or (not user_config.is_intern)
must_limit = is_external or (user_config and user_config.limit_assets_to_contracts)
municipalities = ensure_list(data.get('municipality')) if request.method == "POST" else request.GET.getlist('municipality')
if not is_admin and must_limit:
accessible_munis = get_user_accessible_municipalities(user)
accessible_muni_ids = list(accessible_munis.values_list('id', flat=True))
if municipalities:
valid_munis = [mid for mid in municipalities if (int(mid) if str(mid).isdigit() else mid) in accessible_muni_ids]
naturegreensurface_qs = naturegreensurface_qs.filter(location__municipality_id__in=valid_munis)
naturetree_qs = naturetree_qs.filter(location__municipality_id__in=valid_munis)
natureshrub_qs = natureshrub_qs.filter(location__municipality_id__in=valid_munis)
naturebench_qs = naturebench_qs.filter(location__municipality_id__in=valid_munis)
else:
naturegreensurface_qs = naturegreensurface_qs.filter(location__municipality_id__in=accessible_muni_ids)
naturetree_qs = naturetree_qs.filter(location__municipality_id__in=accessible_muni_ids)
natureshrub_qs = natureshrub_qs.filter(location__municipality_id__in=accessible_muni_ids)
naturebench_qs = naturebench_qs.filter(location__municipality_id__in=accessible_muni_ids)
elif municipalities:
naturegreensurface_qs = naturegreensurface_qs.filter(location__municipality_id__in=municipalities)
naturetree_qs = naturetree_qs.filter(location__municipality_id__in=municipalities)
natureshrub_qs = natureshrub_qs.filter(location__municipality_id__in=municipalities)
naturebench_qs = naturebench_qs.filter(location__municipality_id__in=municipalities)
include_naturelocation = location_type in ('', 'naturelocation')
if not include_naturelocation:
@ -405,7 +682,6 @@ def nature_assets_geojson(request):
natureshrub_qs = natureshrub_qs.none()
naturebench_qs = naturebench_qs.none()
if sections_ids:
naturegreensurface_qs = naturegreensurface_qs.filter(location__id__in=sections_ids)
naturetree_qs = naturetree_qs.filter(location__id__in=sections_ids)
@ -422,6 +698,45 @@ def nature_assets_geojson(request):
naturetree_qs = naturetree_qs.filter(model__id__in=models)
natureshrub_qs = natureshrub_qs.filter(model__id__in=models)
naturebench_qs = naturebench_qs.filter(model__id__in=models)
if management_types:
q_mgmt = Q()
for val in management_types:
val_str = str(val).strip()
if val_str == 'municipal':
q_mgmt |= Q(management_type='municipal')
elif val_str == 'regional':
q_mgmt |= Q(management_type='regional')
elif val_str == 'regional_bruxelles':
q_mgmt |= Q(management_type='regional') & (
Q(location__municipality__niscode__startswith='21') |
Q(location__municipality__name_fr__icontains='Bruxelles') |
Q(location__municipality__name_fr__icontains='Watermael') |
Q(location__municipality__name_fr__icontains='Berchem')
)
elif val_str == 'regional_wallonie':
q_mgmt |= Q(management_type='regional') & (
Q(location__municipality__niscode__startswith=('25', '5', '6', '8', '9')) |
Q(location__municipality__name_fr__icontains='Court-Saint')
)
elif val_str == 'regional_flandre':
q_mgmt |= Q(management_type='regional') & (
Q(location__municipality__niscode__startswith=('1', '3', '4', '7', '23', '24'))
)
elif val_str.startswith('municipal_'):
m_id = val_str.split('_', 1)[1]
if m_id.isdigit():
q_mgmt |= Q(management_type='municipal', location__municipality_id=int(m_id))
elif val_str.startswith('authority:'):
auth_name = val_str.split(':', 1)[1]
q_mgmt |= Q(managing_authority__iexact=auth_name)
else:
q_mgmt |= Q(managing_authority__icontains=val_str) | Q(management_type=val_str)
naturetree_qs = naturetree_qs.filter(q_mgmt)
naturegreensurface_qs = naturegreensurface_qs.none()
natureshrub_qs = natureshrub_qs.none()
naturebench_qs = naturebench_qs.none()
if categories:
naturegreensurface_qs = naturegreensurface_qs.filter(category__id__in=categories)
naturetree_qs = naturetree_qs.filter(category__id__in=categories)
@ -497,7 +812,10 @@ def nature_assets_geojson(request):
.values( # on limite strictement les colonnes
'id', 'code', 'name', 'status', 'validation_status', 'lon', 'lat',
'category_name', 'model_name', 'color', 'geojson',
'category_id', 'model_id', 'location_lon', 'location_lat'
'category_id', 'model_id', 'location_lon', 'location_lat',
'managing_authority', 'management_type', 'is_remarkable',
'scientific_name', 'vernacular_name',
'is_dead', 'vitality'
)
.order_by('code')
)[:MAX_ASSET_FEATURES + 1]
@ -569,12 +887,24 @@ def nature_locations_autocomplete(request):
if len(raw_query) < 2:
return JsonResponse([], safe=False)
user = request.user
user_config = getattr(user, 'config', None)
is_admin = getattr(user, 'is_superuser', False) or (user_config and (user_config.has_role('admin') or user_config.has_role('top_manager')))
is_external = (user_config is None) or (not user_config.is_intern)
must_limit = is_external or (user_config and user_config.limit_assets_to_contracts)
# Normalise la requête (équivalent de unaccent+lower côté Python)
qn = strip_accents_lower(raw_query)
# Prépare des colonnes "recherchables" unaccentées + minuscules
qs = NatureLocation.objects.all()
if not is_admin and must_limit:
from assets.permissions import get_user_accessible_municipalities
accessible_munis = get_user_accessible_municipalities(user)
qs = qs.filter(municipality__in=accessible_munis)
qs = (
NatureLocation.objects
qs
.annotate(
code_u=Lower(Unaccent('code')),
name_fr_u=Lower(Unaccent('name_fr')),
@ -639,13 +969,25 @@ def nature_assets_autocomplete(request):
location_filter = {'location_id': location_id}
user = request.user
user_config = getattr(user, 'config', None)
is_admin = getattr(user, 'is_superuser', False) or (user_config and (user_config.has_role('admin') or user_config.has_role('top_manager')))
is_external = (user_config is None) or (not user_config.is_intern)
must_limit = is_external or (user_config and user_config.limit_assets_to_contracts)
def base_qs(model, extra_filter=None):
ann = {
'code_u': Lower(Unaccent('code')),
'name_fr_u': Lower(Unaccent('name_fr')),
'name_nl_u': Lower(Unaccent('name_nl')),
}
q = model.objects.annotate(**ann)
q = model.objects.all()
if not is_admin and must_limit:
from assets.permissions import get_user_accessible_municipalities
accessible_munis = get_user_accessible_municipalities(user)
q = q.filter(location__municipality__in=accessible_munis)
q = q.annotate(**ann)
# Si un terme de recherche est fourni, filtrer dessus
if has_search_term:

View file

@ -61,7 +61,7 @@ def structures_index(request):
selected_locations_ids = draft_data.get('locations', [])
user_config, _ = UserConfig.objects.get_or_create(user=request.user)
user_config, _created = UserConfig.objects.get_or_create(user=request.user)
thematic_choices_qs = (
UserThematics.objects
.filter(user_config=user_config, can_view_assets=True)

View file

@ -26,6 +26,7 @@ from django.utils.translation import get_language, gettext as _
from django.utils.timezone import make_aware, get_current_timezone
import json
import re
from datetime import datetime
import unicodedata
from collections import defaultdict
@ -3744,6 +3745,77 @@ def update_staged_pole_position(request, intersection_id, pole_id):
})
def find_db_lantern_model(model_name):
"""
Finds the most suitable TrafficLightLanternModel from the database based on model_name.
Gracefully handles variants such as '2V200 cyclo-piéto + HP', '2V200 cyclo-piéton + HP',
'2V200 piéton+HP', tram, cyclist models, etc.
"""
if not model_name:
return TrafficLightLanternModel.objects.first()
m = TrafficLightLanternModel.objects.filter(name_fr__iexact=model_name).first()
if m:
return m
m_nl = TrafficLightLanternModel.objects.filter(name_nl__iexact=model_name).first()
if m_nl:
return m_nl
model_lower = str(model_name).lower()
if "cyclo" in model_lower:
qs = TrafficLightLanternModel.objects.filter(
Q(name_fr__icontains="cyclo") | Q(name_nl__icontains="cyclo") |
Q(name_fr__icontains="fietsers- en voetgangers") | Q(name_nl__icontains="fietsers- en voetgangers")
)
if "hp" in model_lower or "haut-parleur" in model_lower:
m = qs.filter(Q(name_fr__icontains="hp") | Q(name_fr__icontains="haut-parleur") | Q(name_nl__icontains="hp")).first()
else:
m = qs.exclude(Q(name_fr__icontains="hp") | Q(name_fr__icontains="haut-parleur") | Q(name_nl__icontains="hp")).first()
if m:
return m
if qs.exists():
return qs.first()
elif any(w in model_lower for w in ["piét", "piet", "voetganger"]):
qs = TrafficLightLanternModel.objects.filter(
Q(name_fr__icontains="piét") | Q(name_fr__icontains="piet") | Q(name_nl__icontains="voetganger")
).exclude(Q(name_fr__icontains="cyclo") | Q(name_nl__icontains="cyclo"))
if "hp" in model_lower or "haut-parleur" in model_lower:
m = qs.filter(Q(name_fr__icontains="hp") | Q(name_fr__icontains="haut-parleur") | Q(name_nl__icontains="hp")).first()
else:
m = qs.exclude(Q(name_fr__icontains="hp") | Q(name_fr__icontains="haut-parleur") | Q(name_nl__icontains="hp")).first()
if m:
return m
elif "tram" in model_lower:
if "300" in model_lower:
m = TrafficLightLanternModel.objects.filter(name_fr__icontains="tram").filter(name_fr__icontains="300").first()
if m:
return m
m = TrafficLightLanternModel.objects.filter(name_fr__icontains="tram").first()
if m:
return m
elif any(w in model_lower for w in ["cycliste", "cycl", "fiets"]):
m = TrafficLightLanternModel.objects.filter(
Q(name_fr__icontains="cycl") | Q(name_nl__icontains="fiets")
).exclude(Q(name_fr__icontains="piét") | Q(name_fr__icontains="piet") | Q(name_nl__icontains="voetganger")).first()
if m:
return m
return TrafficLightLanternModel.objects.first()
def is_stib_detector(detector_obj, code=""):
"""
Checks if a detector is a STIB/tram detection loop (e.g. dT1, dT2, DT1),
which is provided/managed by STIB and not present in cross plans.
"""
name = (getattr(detector_obj, "name", None) or getattr(detector_obj, "name_fr", None) or "").strip()
code_str = (code or getattr(detector_obj, "code", "") or "").strip()
if re.match(r'^dt[\s_\d]*$', name, re.IGNORECASE) or name.lower().startswith("dt"):
return True
if re.search(r'\bdt\d+\b', name, re.IGNORECASE) or "stib" in name.lower():
return True
if re.search(r'(?:^|_)dt\d*(?:_|$)', code_str, re.IGNORECASE) or "stib" in code_str.lower():
return True
return False
@check_thematic_access('trafficlights', edit_permission_required=True)
def import_cross_plan(request, intersection_id):
from django.shortcuts import render, get_object_or_404, redirect
@ -4329,6 +4401,15 @@ def import_cross_plan(request, intersection_id):
geom_modified = True
details_diff.append(_("Nouveau tracé déduit des poteaux"))
db_coords = []
if has_geom:
try:
gw = ec.geom.clone()
gw.transform(4326)
db_coords = [[round(c[0], 6), round(c[1], 6)] for c in gw.coords]
except Exception:
db_coords = []
status = 'modified' if (is_diff or geom_modified) else 'identical'
compared_cables.append({
'code': code,
@ -4342,6 +4423,7 @@ def import_cross_plan(request, intersection_id):
'has_db_geom': has_geom,
'geom_modified': geom_modified,
'db_geom_length': round(ec.geom.length, 1) if has_geom else None,
'db_coords': db_coords,
})
existing_cables.pop(code)
else:
@ -4360,9 +4442,18 @@ def import_cross_plan(request, intersection_id):
'has_db_geom': False,
'geom_modified': False,
'db_geom_length': None,
'db_coords': [],
})
for code, ec in existing_cables.items():
has_geom = ec.geom is not None
db_coords = []
if has_geom:
try:
gw = ec.geom.clone()
gw.transform(4326)
db_coords = [[round(c[0], 6), round(c[1], 6)] for c in gw.coords]
except Exception:
db_coords = []
compared_cables.append({
'code': code,
'name': ec.name_fr or code,
@ -4374,6 +4465,7 @@ def import_cross_plan(request, intersection_id):
'has_db_geom': has_geom,
'geom_modified': False,
'db_geom_length': round(ec.geom.length, 1) if has_geom else None,
'db_coords': db_coords,
})
# ── 3. LANTERNS COMPARISON ──
@ -4413,7 +4505,7 @@ def import_cross_plan(request, intersection_id):
for dl, el in matched_pairs:
code = dl["code"]
model_name = dl["model_name"]
db_model = TrafficLightLanternModel.objects.filter(name_fr__iexact=model_name).first() or TrafficLightLanternModel.objects.first()
db_model = find_db_lantern_model(model_name)
is_rename = (el.code != code)
is_model_diff = el.model_id != (db_model.id if db_model else None)
@ -4423,7 +4515,7 @@ def import_cross_plan(request, intersection_id):
if is_rename:
details.append(f"Code : {el.code} → {code}")
if is_model_diff:
details.append(f"Modèle : {el.model.name_fr if el.model else 'Aucun'} → {model_name}")
details.append(f"Modèle : {el.model.name_fr if el.model else 'Aucun'} → {db_model.name_fr if db_model else model_name}")
if is_model_diff and el.name_fr and el.model and el.name_fr.strip().lower() == el.model.name_fr.strip().lower():
display_name = dl['name']
@ -4434,7 +4526,7 @@ def import_cross_plan(request, intersection_id):
'code': code,
'name': display_name,
'status': status,
'deduced_model_name': model_name,
'deduced_model_name': db_model.name_fr if db_model else model_name,
'db_model_name': el.model.name_fr if el.model else '',
'db_id': el.id,
'pole_code': dl['pole_code'],
@ -4532,6 +4624,9 @@ def import_cross_plan(request, intersection_id):
'details_diff': []
})
for code, ed in existing_detectors.items():
is_stib = is_stib_detector(ed, code)
action_default = 'keep' if is_stib else 'archive'
diff_text = _("Détection STIB / Tram (fournie par la STIB, conservée par défaut)") if is_stib else _("Absent du fichier Excel (présent en DB)")
compared_detectors.append({
'code': code,
'name': ed.name or code,
@ -4540,7 +4635,9 @@ def import_cross_plan(request, intersection_id):
'db_model_name': ed.model.name_fr if ed.model else '',
'db_id': ed.id,
'pole_code': ed.pole.code if ed.pole else '',
'details_diff': [_("Absent du fichier Excel (présent en DB)")]
'details_diff': [diff_text],
'action_default': action_default,
'is_stib': is_stib,
})
# ── 5. OTHER ASSOCIATED ASSETS (NOT IN CROSS PLAN) ──
@ -4790,12 +4887,18 @@ def integrate_cross_plan(request, intersection_id):
from django.shortcuts import get_object_or_404, redirect
from django.contrib import messages
from django.db import transaction
import json
from django.db.models import ProtectedError
from assets.models.trafficlights import (
TrafficLightIntersection, TrafficLightPole, TrafficLightLantern,
TrafficLightDetector, TrafficLightCable, TrafficLightPoleModel,
TrafficLightLanternModel, TrafficLightDetectorModel, TrafficLightCableModel
TrafficLightLanternModel, TrafficLightDetectorModel, TrafficLightCableModel,
TrafficLightController, TrafficLightControllerHardware, TrafficLightNetworkHardware,
TrafficLightVoltageHardware, TrafficLightElectricalCabinet, TrafficLightAccessory,
TrafficLightRadar, TrafficLightProgramming
)
from assets.models.sign import SignPanel
from assets.models.its import ITSGeoAsset
from assets.models.publiclighting import PublicLightingLightFixture
intersection = get_object_or_404(TrafficLightIntersection, pk=intersection_id)
filename = request.POST.get('cross_plan_filename')
@ -4832,7 +4935,7 @@ def integrate_cross_plan(request, intersection_id):
return m
def resolve_lantern_model(name_fr):
m = TrafficLightLanternModel.objects.filter(name_fr__iexact=name_fr).first()
m = find_db_lantern_model(name_fr)
if not m:
m = TrafficLightLanternModel.objects.first()
if not m:
@ -5329,6 +5432,18 @@ def integrate_cross_plan(request, intersection_id):
PublicLightingLightFixture.objects.filter(traffic_light_pole=existing).update(traffic_light_pole=new_pole)
except Exception:
pass
elif action == "archive" and existing:
existing.status = 'archived'
existing.updated_by = request.user
existing.save()
elif action == "delete" and existing:
try:
existing.delete()
except ProtectedError:
existing.status = 'archived'
existing.updated_by = request.user
existing.save()
messages.warning(request, f"Le poteau {existing.code} n'a pas pu être supprimé car d'autres éléments y font référence ; il a été archivé à la place.")
else:
# 'keep' (Ne rien faire)
if existing:
@ -5347,6 +5462,17 @@ def integrate_cross_plan(request, intersection_id):
TrafficLightPole.objects.filter(intersection=intersection, code=code, status='active').update(
status='archived', updated_by=request.user
)
elif action == "delete":
db_id = request.POST.get(f"db_id_pole_{code}")
qs = TrafficLightPole.objects.filter(pk=db_id) if db_id else TrafficLightPole.objects.filter(intersection=intersection, code=code)
for pole_to_del in qs:
try:
pole_to_del.delete()
except ProtectedError:
pole_to_del.status = 'archived'
pole_to_del.updated_by = request.user
pole_to_del.save()
messages.warning(request, f"Le poteau {pole_to_del.code} n'a pas pu être supprimé car d'autres éléments y font référence ; il a été archivé à la place.")
# Fetch active poles to match parent relation for lanterns/detectors if not resolved above
active_poles = {p.code: p for p in TrafficLightPole.objects.filter(intersection=intersection, status='active')}
@ -5415,6 +5541,18 @@ def integrate_cross_plan(request, intersection_id):
new_cable.save()
existing.replaced_by = new_cable
existing.save()
elif action == "archive" and existing:
existing.status = 'archived'
existing.updated_by = request.user
existing.save()
elif action == "delete" and existing:
try:
existing.delete()
except ProtectedError:
existing.status = 'archived'
existing.updated_by = request.user
existing.save()
messages.warning(request, f"Le câble {existing.code} n'a pas pu être supprimé car il est référencé ; il a été archivé à la place.")
else:
# 'keep' (Ne rien faire)
if existing:
@ -5432,6 +5570,17 @@ def integrate_cross_plan(request, intersection_id):
TrafficLightCable.objects.filter(intersection=intersection, code=code, status='active').update(
status='archived', updated_by=request.user
)
elif action == "delete":
db_id = request.POST.get(f"db_id_cable_{code}")
qs = TrafficLightCable.objects.filter(pk=db_id) if db_id else TrafficLightCable.objects.filter(intersection=intersection, code=code)
for cable_to_del in qs:
try:
cable_to_del.delete()
except ProtectedError:
cable_to_del.status = 'archived'
cable_to_del.updated_by = request.user
cable_to_del.save()
messages.warning(request, f"Le câble {cable_to_del.code} n'a pas pu être supprimé car il est référencé ; il a été archivé à la place.")
# --- 3. Lanterns integration ---
for dl in deduced["lanterns"]:
@ -5493,6 +5642,18 @@ def integrate_cross_plan(request, intersection_id):
)
existing.replaced_by = new_lantern
existing.save()
elif action == "archive" and existing:
existing.status = 'archived'
existing.updated_by = request.user
existing.save()
elif action == "delete" and existing:
try:
existing.delete()
except ProtectedError:
existing.status = 'archived'
existing.updated_by = request.user
existing.save()
messages.warning(request, f"La lanterne {existing.code} n'a pas pu être supprimée car elle est référencée ; elle a été archivée à la place.")
else:
# 'keep'
if existing:
@ -5512,6 +5673,17 @@ def integrate_cross_plan(request, intersection_id):
TrafficLightLantern.objects.filter(pole__intersection=intersection, code=code, status='active').update(
status='archived', updated_by=request.user
)
elif action == "delete":
db_id = request.POST.get(f"db_id_lantern_{code}")
qs = TrafficLightLantern.objects.filter(pk=db_id) if db_id else TrafficLightLantern.objects.filter(pole__intersection=intersection, code=code)
for lan_to_del in qs:
try:
lan_to_del.delete()
except ProtectedError:
lan_to_del.status = 'archived'
lan_to_del.updated_by = request.user
lan_to_del.save()
messages.warning(request, f"La lanterne {lan_to_del.code} n'a pas pu être supprimée car elle est référencée ; elle a été archivée à la place.")
# --- 4. Detectors integration ---
for dd in deduced["detectors"]:
@ -5581,6 +5753,18 @@ def integrate_cross_plan(request, intersection_id):
)
existing.replaced_by = new_det
existing.save()
elif action == "archive" and existing:
existing.status = 'archived'
existing.updated_by = request.user
existing.save()
elif action == "delete" and existing:
try:
existing.delete()
except ProtectedError:
existing.status = 'archived'
existing.updated_by = request.user
existing.save()
messages.warning(request, f"Le détecteur {existing.code} n'a pas pu être supprimé car il est référencé ; il a été archivé à la place.")
else:
# 'keep'
if existing:
@ -5601,6 +5785,17 @@ def integrate_cross_plan(request, intersection_id):
TrafficLightDetector.objects.filter(intersection=intersection, code=code, status='active').update(
status='archived', updated_by=request.user
)
elif action == "delete":
db_id = request.POST.get(f"db_id_detector_{code}")
qs = TrafficLightDetector.objects.filter(pk=db_id) if db_id else TrafficLightDetector.objects.filter(intersection=intersection, code=code)
for det_to_del in qs:
try:
det_to_del.delete()
except ProtectedError:
det_to_del.status = 'archived'
det_to_del.updated_by = request.user
det_to_del.save()
messages.warning(request, f"Le détecteur {det_to_del.code} n'a pas pu être supprimé car il est référencé ; il a été archivé à la place.")
# --- 5. Other associated assets integration ---
for key in request.POST.keys():
@ -5610,36 +5805,39 @@ def integrate_cross_plan(request, intersection_id):
asset_type, asset_id_str = suffix.split("_", 1)
is_selected = request.POST.get(f"selected_other_{suffix}") == "on"
action = request.POST.get(key, "keep")
if is_selected and action == "archive":
if is_selected and action in ("archive", "delete"):
try:
asset_id = int(asset_id_str)
if asset_type == "signpanel":
from assets.models.sign import SignPanel
SignPanel.objects.filter(pk=asset_id).update(status='archived', updated_by=request.user)
elif asset_type == "itsasset":
from assets.models.its import ITSGeoAsset
ITSGeoAsset.objects.filter(pk=asset_id).update(status='archived', updated_by=request.user)
elif asset_type == "controller":
TrafficLightController.objects.filter(pk=asset_id).update(status='archived', updated_by=request.user)
from assets.models.trafficlights import TrafficLightControllerHardware
TrafficLightControllerHardware.objects.filter(controller_id=asset_id).update(status='archived', updated_by=request.user)
TrafficLightNetworkHardware.objects.filter(controller_id=asset_id).update(status='archived', updated_by=request.user)
TrafficLightVoltageHardware.objects.filter(controller_id=asset_id).update(status='archived', updated_by=request.user)
elif asset_type == "networkhardware":
TrafficLightNetworkHardware.objects.filter(pk=asset_id).update(status='archived', updated_by=request.user)
elif asset_type == "voltagehardware":
TrafficLightVoltageHardware.objects.filter(pk=asset_id).update(status='archived', updated_by=request.user)
elif asset_type == "electricalcabinet":
TrafficLightElectricalCabinet.objects.filter(pk=asset_id).update(status='archived', updated_by=request.user)
elif asset_type == "accessory":
TrafficLightAccessory.objects.filter(pk=asset_id).update(status='archived', updated_by=request.user)
elif asset_type == "radar":
TrafficLightRadar.objects.filter(pk=asset_id).update(status='archived', updated_by=request.user)
elif asset_type == "programming":
TrafficLightProgramming.objects.filter(pk=asset_id).update(status='archived', updated_by=request.user)
elif asset_type == "lightfixture":
from assets.models.publiclighting import PublicLightingLightFixture
PublicLightingLightFixture.objects.filter(pk=asset_id).update(status='archived', updated_by=request.user)
model_map = {
"signpanel": SignPanel,
"itsasset": ITSGeoAsset,
"controller": TrafficLightController,
"networkhardware": TrafficLightNetworkHardware,
"voltagehardware": TrafficLightVoltageHardware,
"electricalcabinet": TrafficLightElectricalCabinet,
"accessory": TrafficLightAccessory,
"radar": TrafficLightRadar,
"programming": TrafficLightProgramming,
"lightfixture": PublicLightingLightFixture,
}
model_cls = model_map.get(asset_type)
if model_cls:
if action == "archive":
model_cls.objects.filter(pk=asset_id).update(status='archived', updated_by=request.user)
if asset_type == "controller":
TrafficLightControllerHardware.objects.filter(controller_id=asset_id).update(status='archived', updated_by=request.user)
TrafficLightNetworkHardware.objects.filter(controller_id=asset_id).update(status='archived', updated_by=request.user)
TrafficLightVoltageHardware.objects.filter(controller_id=asset_id).update(status='archived', updated_by=request.user)
elif action == "delete":
obj = model_cls.objects.filter(pk=asset_id).first()
if obj:
try:
obj.delete()
except ProtectedError:
obj.status = 'archived'
obj.updated_by = request.user
obj.save()
messages.warning(request, f"L'équipement {obj} n'a pas pu être supprimé car il est référencé ; il a été archivé à la place.")
except (ValueError, TypeError, Exception):
pass

View file

@ -506,7 +506,7 @@ class UserThematicsInline(admin.TabularInline):
'thematic', 'can_view_assets', 'can_edit_assets', 'can_validate_assets', 'can_inspect',
'can_view_interventions', 'can_edit_interventions', 'can_edit_locations',
'can_process_observations',
'can_view_projects', 'can_edit_projects',
'can_view_projects', 'can_edit_projects', 'can_edit_management_zones',
'status_matrix',
]
readonly_fields = ['status_matrix']
@ -598,7 +598,7 @@ class UserAssetTypeAccessInline(admin.TabularInline):
@admin.register(UserThematics)
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_inspect', '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', 'can_edit_management_zones')
list_filter = ('thematic',)
search_fields = ('user_config__user__username', 'thematic__name_fr', 'thematic__name_nl')
raw_id_fields = ['user_config']
@ -620,8 +620,8 @@ 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")
search_fields = ("user__username", "contracts__contract_number")
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")
actions = ["duplicate_config_action", "apply_config_from_action", "assign_company_action"]
@ -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

@ -541,3 +541,20 @@ msgstr ""
#: common/views.py:644
msgid "Erreur lors de la lecture du document"
msgstr "Erreur lors de la lecture du document"
#: common/templates/common/components/document_viewer.html
msgid "Dézoomer"
msgstr "Zoom out"
#: common/templates/common/components/document_viewer.html
msgid "Zoomer"
msgstr "Zoom in"
#: common/templates/common/components/document_viewer.html
msgid "Réinitialiser"
msgstr "Reset"
#: common/views.py:683
msgid "Format de fond de plan invalide"
msgstr "Invalid basemap format"

View file

@ -133,3 +133,11 @@ msgstr "@"
#: loko/common/static/common/jquery-ui.min.js:6
msgid "!"
msgstr "!"
#: loko/common/static/common/address_search_control.js:148
msgid "Rechercher une adresse…"
msgstr "Search an address…"
msgid "Rechercher une adresse..."
msgstr "Search an address..."

View file

@ -527,3 +527,20 @@ msgstr "Accès interdit"
#: common/views.py:644
msgid "Erreur lors de la lecture du document"
msgstr "Erreur lors de la lecture du document"
#: common/templates/common/components/document_viewer.html
msgid "Dézoomer"
msgstr "Dézoomer"
#: common/templates/common/components/document_viewer.html
msgid "Zoomer"
msgstr "Zoomer"
#: common/templates/common/components/document_viewer.html
msgid "Réinitialiser"
msgstr "Réinitialiser"
#: common/views.py:683
msgid "Format de fond de plan invalide"
msgstr "Format de fond de plan invalide"

View file

@ -133,3 +133,11 @@ msgstr "@"
#: loko/common/static/common/jquery-ui.min.js:6
msgid "!"
msgstr "!"
#: loko/common/static/common/address_search_control.js:148
msgid "Rechercher une adresse…"
msgstr "Rechercher une adresse…"
msgid "Rechercher une adresse..."
msgstr "Rechercher une adresse..."

View file

@ -527,3 +527,20 @@ msgstr ""
#: common/views.py:644
msgid "Erreur lors de la lecture du document"
msgstr ""
#: common/templates/common/components/document_viewer.html
msgid "Dézoomer"
msgstr "Uitzoomen"
#: common/templates/common/components/document_viewer.html
msgid "Zoomer"
msgstr "Inzoomen"
#: common/templates/common/components/document_viewer.html
msgid "Réinitialiser"
msgstr "Herstellen"
#: common/views.py:683
msgid "Format de fond de plan invalide"
msgstr "Ongeldig basiskaartformaat"

Some files were not shown because too many files have changed in this diff Show more