refactor: implement extensible architecture for streetup-core with hooks and reporting services

This commit is contained in:
Karl Determe 2026-07-22 21:03:26 +02:00
parent 7e74a1f8c0
commit b871c17fbc
18 changed files with 315 additions and 448 deletions

View file

@ -10,3 +10,9 @@ class CommonConfig(AppConfig):
from django.contrib.auth.signals import user_logged_in from django.contrib.auth.signals import user_logged_in
from .middleware import set_login_time from .middleware import set_login_time
user_logged_in.connect(set_login_time) user_logged_in.connect(set_login_time)
# Charger les extensions conditionnelles
try:
from common import bm_extensions
except ImportError:
pass

106
streetup/common/hooks.py Normal file
View file

@ -0,0 +1,106 @@
from django.db.models import Q
class ExtensionHooks:
def __init__(self):
self._form_cleaners = []
self._search_annotators = []
self._search_filters = []
self._pdf_fields_extractors = []
self._export_headers = []
self._export_row_extractors = []
self._custom_allowed_fields = []
self._custom_export_fields = []
self._inline_validators = []
self._custom_source_ref_value_extractor = None
def register_custom_source_ref_value_extractor(self, func):
self._custom_source_ref_value_extractor = func
return func
def get_custom_source_ref_value(self, intervention):
if self._custom_source_ref_value_extractor:
return self._custom_source_ref_value_extractor(intervention)
return None
def register_custom_export_field(self, label, extractor):
self._custom_export_fields.append((label, extractor))
return (label, extractor)
def get_custom_export_fields(self):
return self._custom_export_fields
def register_inline_validator(self, func):
self._inline_validators.append(func)
return func
def run_inline_edit_validator(self, field, new_value, intervention):
for validator in self._inline_validators:
res = validator(field, new_value, intervention)
if res[0] is not None:
return res
return (None, None)
def register_form_cleaner(self, func):
self._form_cleaners.append(func)
return func
def register_search_annotator(self, func):
self._search_annotators.append(func)
return func
def register_search_filter(self, func):
self._search_filters.append(func)
return func
def register_pdf_fields_extractor(self, func):
self._pdf_fields_extractors.append(func)
return func
def register_export_headers(self, headers_list):
self._export_headers.extend(headers_list)
def register_export_row_extractor(self, func):
self._export_row_extractors.append(func)
return func
def register_custom_allowed_field(self, field_name_or_list):
if isinstance(field_name_or_list, list):
self._custom_allowed_fields.extend(field_name_or_list)
else:
self._custom_allowed_fields.append(field_name_or_list)
def run_form_cleaners(self, form, cleaned_data):
for cleaner in self._form_cleaners:
cleaned_data = cleaner(form, cleaned_data)
return cleaned_data
def apply_search_annotations(self, queryset):
for annotator in self._search_annotators:
queryset = annotator(queryset)
return queryset
def get_search_filters(self, word):
q_obj = Q()
for filter_func in self._search_filters:
q_obj |= filter_func(word)
return q_obj
def extract_pdf_fields(self, intervention):
fields = []
for extractor in self._pdf_fields_extractors:
fields.extend(extractor(intervention))
return fields
def get_export_headers(self):
return self._export_headers
def extract_export_row(self, intervention):
row_data = []
for extractor in self._export_row_extractors:
row_data.extend(extractor(intervention))
return row_data
def get_custom_allowed_fields(self):
return self._custom_allowed_fields
hooks = ExtensionHooks()

View file

@ -106,10 +106,13 @@ def _collect_report_data(companies, report_date):
# Colonnes # Colonnes
COL_KEYS = [ COL_KEYS = [
'damage', 'defect', 'demand', 'damage', 'defect', 'demand',
'observation', 'prego', 'observation',
'finished', 'other_status',
'total',
] ]
dynamic_cols = getattr(settings, 'DAILY_REPORT_SOURCE_COLUMNS', {})
for key in dynamic_cols.keys():
if key not in COL_KEYS:
COL_KEYS.append(key)
COL_KEYS.extend(['finished', 'other_status', 'total'])
by_thematic = defaultdict(lambda: {k: 0 for k in COL_KEYS}) by_thematic = defaultdict(lambda: {k: 0 for k in COL_KEYS})
by_person = defaultdict(int) by_person = defaultdict(int)
@ -142,9 +145,9 @@ def _collect_report_data(companies, report_date):
if sc_name == 'observation': if sc_name == 'observation':
row['observation'] += 1 row['observation'] += 1
totals['observation'] += 1 totals['observation'] += 1
elif sc_name == 'prego': elif sc_name in dynamic_cols:
row['prego'] += 1 row[sc_name] += 1
totals['prego'] += 1 totals[sc_name] += 1
# --- Statut --- # --- Statut ---
if itv.status_order >= STATUS_ORDERS['finished']: if itv.status_order >= STATUS_ORDERS['finished']:
@ -179,26 +182,37 @@ COLUMN_HEADERS = [
('defect', 'Défaut/Panne'), ('defect', 'Défaut/Panne'),
('demand', 'Demande'), ('demand', 'Demande'),
('observation', 'StreetUp'), ('observation', 'StreetUp'),
('prego', 'Issu de Prego'), ]
# Add dynamic source columns from settings
dynamic_cols = getattr(settings, 'DAILY_REPORT_SOURCE_COLUMNS', {})
for key, cfg in dynamic_cols.items():
COLUMN_HEADERS.append((key, cfg['label']))
COLUMN_HEADERS.extend([
('finished', 'Résolu'), ('finished', 'Résolu'),
('other_status', 'En cours'), ('other_status', 'En cours'),
('total', 'Total'), ('total', 'Total'),
] ])
# Couleurs spéciales (R, G, B) pour l'en-tête et la ligne totale de certaines colonnes # Couleurs spéciales (R, G, B) pour l'en-tête et la ligne totale de certaines colonnes
# Les colonnes absentes de ce dict héritent de la couleur par défaut. # Les colonnes absentes de ce dict héritent de la couleur par défaut.
COLUMN_SPECIAL_COLORS_RGB = { COLUMN_SPECIAL_COLORS_RGB = {
'prego': (255, 102, 0), # Orange
'finished': (112, 173, 71), # Vert 'finished': (112, 173, 71), # Vert
'other_status': ( 0, 176, 240), # Bleu ciel 'other_status': ( 0, 176, 240), # Bleu ciel
} }
# Version hex pour Excel (AARRGGBB sans alpha, format RRGGBB) # Version hex pour Excel (AARRGGBB sans alpha, format RRGGBB)
COLUMN_SPECIAL_COLORS_HEX = { COLUMN_SPECIAL_COLORS_HEX = {
'prego': 'FF6600',
'finished': '70AD47', 'finished': '70AD47',
'other_status': '00B0F0', 'other_status': '00B0F0',
} }
for key, cfg in dynamic_cols.items():
if 'color_rgb' in cfg:
COLUMN_SPECIAL_COLORS_RGB[key] = cfg['color_rgb']
if 'color_hex' in cfg:
COLUMN_SPECIAL_COLORS_HEX[key] = cfg['color_hex']
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# PDF generation # PDF generation

View file

@ -353,8 +353,6 @@ class Intervention(models.Model):
source_category = models.ForeignKey(SourceCategory, on_delete=models.SET_NULL, null=True, blank=True) source_category = models.ForeignKey(SourceCategory, on_delete=models.SET_NULL, null=True, blank=True)
source_detail = models.CharField(null=True,blank=True) source_detail = models.CharField(null=True,blank=True)
source_ref = models.CharField(null=True,blank=True) source_ref = models.CharField(null=True,blank=True)
origin_prego_nbr = models.IntegerField(null=True, blank=True)
origin_fms_nbr = models.IntegerField(null=True, blank=True)
pmo_number = models.IntegerField(null=True, blank=True, verbose_name=_("Numéro PMO")) pmo_number = models.IntegerField(null=True, blank=True, verbose_name=_("Numéro PMO"))
asset_category = models.ForeignKey(AssetCategory, on_delete=models.SET_NULL, null=True, blank=True) asset_category = models.ForeignKey(AssetCategory, on_delete=models.SET_NULL, null=True, blank=True)
symptom = models.ForeignKey(Symptom, on_delete=models.SET_NULL, null=True, blank=True) symptom = models.ForeignKey(Symptom, on_delete=models.SET_NULL, null=True, blank=True)

View file

@ -427,7 +427,9 @@ def get_allowed_update_fields(user, intervention):
# Source, Référence, Détail, FMS, Type d'origine et Symptôme # Source, Référence, Détail, FMS, Type d'origine et Symptôme
if intervention.status_order < STATUS_ORDERS.get('to_be_processed') and user_config.is_intern: if intervention.status_order < STATUS_ORDERS.get('to_be_processed') and user_config.is_intern:
allowed_fields.update(['source_category', 'source_ref', 'source_detail', 'origin_fms_nbr', 'origin_type', 'symptom']) allowed_fields.update(['source_category', 'source_ref', 'source_detail', 'origin_type', 'symptom'])
from common.hooks import hooks
allowed_fields.update(hooks.get_custom_allowed_fields())
# Priorité : modifiable par les internes tant que le statut n'est pas 'démarré' (in_progress) # Priorité : modifiable par les internes tant que le statut n'est pas 'démarré' (in_progress)
if intervention.status_order < STATUS_ORDERS.get('in_progress') and user_config.is_intern: if intervention.status_order < STATUS_ORDERS.get('in_progress') and user_config.is_intern:

View file

@ -108,48 +108,55 @@ def generate_interventions_excel(start_date=None, end_date=None, output_format='
ws = wb.active ws = wb.active
ws.title = "Interventions" ws.title = "Interventions"
# Définir les en-têtes # Définir les en-têtes et les largeurs de colonne de manière dynamique
headers = [ column_definitions = [
'Code', ('Code', 15),
'Titre', ('Titre', 30),
'Date de création', ('Date de création', 20),
'Date de transmission', ('Date de transmission', 20),
'Date début prévue', ('Date début prévue', 20),
'Date fin prévue', ('Date fin prévue', 20),
'Date début planifiée', ('Date début planifiée', 20),
'Date fin planifiée', ('Date fin planifiée', 20),
'Date de début', ('Date de début', 20),
'Date de fin', ('Date de fin', 20),
'Date appel initial confirmé', ('Date appel initial confirmé', 20),
'Type d\'origine', ('Type d\'origine', 15),
'Priorité', ('Priorité', 12),
'Statut', ('Statut', 15),
'Symptôme', ('Symptôme', 30),
'Contrat', ('Contrat', 30),
'Source catégorie', ('Source catégorie', 20),
'Numéro PREGO',
'Numéro FMS',
'Documents joints',
'Notes',
'Créé par',
'Équipe (créateur)',
'Thématique (code)',
'Thématique (nom)',
'Commune',
'Localisation code 1',
'Localisation label 1',
'Localisation code 2',
'Localisation label 2',
'Localisation code 3',
'Localisation label 3',
'Localisation code 4',
'Localisation label 4',
'Catégorie (code)',
'Catégorie (nom)',
'Modèle (code)',
'Modèle (nom)',
] ]
from common.hooks import hooks
for header in hooks.get_export_headers():
column_definitions.append((header, 15))
column_definitions.extend([
('Documents joints', 50),
('Notes', 50),
('Créé par', 20),
('Équipe (créateur)', 25),
('Thématique (code)', 15),
('Thématique (nom)', 25),
('Commune', 20),
('Localisation code 1', 15),
('Localisation label 1', 25),
('Localisation code 2', 15),
('Localisation label 2', 25),
('Localisation code 3', 15),
('Localisation label 3', 25),
('Localisation code 4', 15),
('Localisation label 4', 25),
('Equipement code', 15),
('Equipement nom', 25),
('Modèle code', 15),
('Modèle nom', 30),
])
headers = [col[0] for col in column_definitions]
# Écrire les en-têtes avec style # Écrire les en-têtes avec style
for col_num, header in enumerate(headers, 1): for col_num, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_num) cell = ws.cell(row=1, column=col_num)
@ -159,49 +166,10 @@ def generate_interventions_excel(start_date=None, end_date=None, output_format='
cell.alignment = Alignment(horizontal="center", vertical="center") cell.alignment = Alignment(horizontal="center", vertical="center")
# Ajuster la largeur des colonnes # Ajuster la largeur des colonnes
column_widths = { from openpyxl.utils import get_column_letter
'A': 15, # Code for i, col_def in enumerate(column_definitions, start=1):
'B': 40, # Titre col_letter = get_column_letter(i)
'C': 20, # Date création ws.column_dimensions[col_letter].width = col_def[1]
'D': 20, # Date transmission
'E': 20, # Date début prévue
'F': 20, # Date fin prévue
'G': 20, # Date début planifiée
'H': 20, # Date fin planifiée
'I': 20, # Date début
'J': 20, # Date fin
'K': 20, # Date appel initial confirmé
'L': 15, # Type origine
'M': 12, # Priorité
'N': 15, # Statut
'O': 30, # Symptôme
'P': 30, # Contrat
'Q': 20, # Source catégorie
'R': 15, # PREGO
'S': 15, # FMS
'T': 50, # Documents joints
'U': 50, # Notes
'V': 20, # Créé par
'W': 25, # Équipe (créateur)
'X': 15, # Thématique code
'Y': 25, # Thématique nom
'Z': 20, # Commune
'AA': 15, # Loc code 1
'AB': 25, # Loc label 1
'AC': 15, # Loc code 2
'AD': 25, # Loc label 2
'AE': 15, # Loc code 3
'AF': 25, # Loc label 3
'AG': 15, # Loc code 4
'AH': 25, # Loc label 4
'AI': 15, # Cat code
'AJ': 25, # Cat nom
'AK': 15, # Model code
'AL': 30, # Model nom
}
for col, width in column_widths.items():
ws.column_dimensions[col].width = width
# Forcer la langue française pour l'export # Forcer la langue française pour l'export
with override('fr'): with override('fr'):
@ -348,8 +316,7 @@ def generate_interventions_excel(start_date=None, end_date=None, output_format='
symptom_name, symptom_name,
contrat_info, contrat_info,
source_category_name, source_category_name,
intervention.origin_prego_nbr or '', *hooks.extract_export_row(intervention),
intervention.origin_fms_nbr or '',
documents_str, documents_str,
notes_str, notes_str,
created_by, created_by,

View file

@ -1,147 +1,54 @@
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
const sourceCategorySelect = document.getElementById('id_source_category'); const sourceCategorySelect = document.getElementById('id_source_category');
const sourceDetailContainer = document.getElementById('source-detail-container'); const sourceDetailContainer = document.getElementById('source-detail-container');
const sourceRefContainer = document.getElementById('source-ref-container'); // PREGO const sourceRefContainer = document.getElementById('source-ref-container');
const sourceRefGenericContainer = document.getElementById('source-ref-generic-container'); // OBSERVATION
const sourceRefInput = document.getElementById('id_source_ref');
const noFmsCheckbox = document.getElementById('id_no_origin_fms');
const fmsInput = document.getElementById('id_origin_fms_nbr');
// Fonction pour afficher/masquer le champ détail de la source function handleSourceChange() {
function toggleSourceDetail() { if (!sourceCategorySelect) return;
const selectedValue = sourceCategorySelect.value; const selectedValue = sourceCategorySelect.value;
// Rechercher la catégorie sélectionnée dans sourceCategoriesData const selectedOption = sourceCategorySelect.options[sourceCategorySelect.selectedIndex];
const selectedCategory = sourceCategoriesData.find(sc => sc.id == selectedValue); const selectedText = selectedOption ? selectedOption.text : '';
if (selectedCategory && selectedCategory.has_reference && !['PREGO','OBSERVATION','OBSERVATIE'].includes(selectedCategory.name)) { let selectedCategory = null;
sourceDetailContainer.classList.remove('d-none'); // Afficher if (typeof sourceCategoriesData !== 'undefined' && Array.isArray(sourceCategoriesData)) {
} else { selectedCategory = sourceCategoriesData.find(sc => sc.id == selectedValue);
sourceDetailContainer.classList.add('d-none'); // Masquer }
// Dispatch a CustomEvent for forks (like Bruxelles Mobilité) to run custom logic
const event = new CustomEvent('streetup:source-changed', {
detail: {
value: selectedValue,
text: selectedText,
category: selectedCategory
}
});
document.dispatchEvent(event);
// Standard core behavior:
// 1. Toggle source detail visibility
if (sourceDetailContainer) {
if (selectedCategory && selectedCategory.has_reference) {
// If it is observation, the core template has it as read-only.
// We show detail container if category requires detail and has no reference.
// Normally, we show detail if has_reference is true.
sourceDetailContainer.classList.remove('d-none');
} else {
sourceDetailContainer.classList.add('d-none');
}
}
// 2. Toggle source reference container visibility
if (sourceRefContainer) {
if (selectedCategory && selectedCategory.has_reference) {
sourceRefContainer.classList.remove('d-none');
} else {
sourceRefContainer.classList.add('d-none');
}
} }
} }
// Fonction pour afficher/masquer le champ référence de la source if (sourceCategorySelect) {
function syncNoFmsState() { handleSourceChange();
if (!noFmsCheckbox || !fmsInput) { sourceCategorySelect.addEventListener('change', handleSourceChange);
return;
}
if (noFmsCheckbox.checked) {
fmsInput.value = '';
fmsInput.setAttribute('disabled', 'disabled');
} else {
fmsInput.removeAttribute('disabled');
}
} }
function toggleSourceRef() {
const selectedText = sourceCategorySelect.options[sourceCategorySelect.selectedIndex].text || '';
const selectedUpper = selectedText.trim().toUpperCase();
const mntPrefix = document.getElementById('mnt-prefix');
const pregoInput = document.getElementById('id_origin_prego_nbr');
// Reset visibility by default
if (sourceRefContainer) sourceRefContainer.classList.add('d-none');
if (sourceRefGenericContainer) sourceRefGenericContainer.classList.add('d-none');
if (mntPrefix) mntPrefix.style.display = 'none';
if (sourceRefInput) {
sourceRefInput.removeAttribute('readonly');
sourceRefInput.classList.remove('bg-light');
sourceRefInput.setAttribute('title', '');
sourceRefInput.removeAttribute('aria-readonly');
}
// PREGO -> show PREGO block with MNT prefix + FMS
if (selectedUpper === 'PREGO') {
if (sourceRefContainer) sourceRefContainer.classList.remove('d-none');
if (mntPrefix) mntPrefix.style.display = 'inline-block';
if (pregoInput) {
pregoInput.setAttribute('pattern', '\\d{1,6}');
pregoInput.setAttribute('maxlength', '6');
pregoInput.setAttribute('title', gettext('Veuillez entrer un entier de maximum 6 chiffres'));
pregoInput.addEventListener('input', function () {
this.value = this.value.replace(/\D/g, '').slice(0, 6);
});
}
if (fmsInput) {
fmsInput.removeAttribute('disabled');
fmsInput.setAttribute('pattern', '\\d{1,9}');
fmsInput.setAttribute('maxlength', '9');
fmsInput.addEventListener('input', function () {
this.value = this.value.replace(/\D/g, '').slice(0, 9);
});
}
}
// OBSERVATION (FR/NL) -> show generic source_ref only
else if (selectedUpper === 'OBSERVATION' || selectedUpper === 'OBSERVATIE') {
if (sourceRefGenericContainer) sourceRefGenericContainer.classList.remove('d-none');
// Make the reference read-only but still submitted
if (sourceRefInput) {
sourceRefInput.setAttribute('readonly', 'readonly');
sourceRefInput.setAttribute('aria-readonly', 'true');
// subtle visual cue it cannot be edited
sourceRefInput.classList.add('bg-light');
const v = sourceRefInput.value || '';
if (v) {
sourceRefInput.setAttribute('title', gettext('Référence non modifiable'));
}
}
// Clean PREGO/FMS constraints
if (pregoInput) {
pregoInput.removeAttribute('pattern');
pregoInput.removeAttribute('maxlength');
pregoInput.removeAttribute('title');
}
if (fmsInput) {
fmsInput.value = '';
fmsInput.setAttribute('disabled', 'disabled');
fmsInput.removeAttribute('pattern');
fmsInput.removeAttribute('maxlength');
}
if (noFmsCheckbox) {
noFmsCheckbox.checked = false;
}
}
// Others -> nothing specific
else {
// ensure PREGO constraints removed
if (pregoInput) {
pregoInput.removeAttribute('pattern');
pregoInput.removeAttribute('maxlength');
pregoInput.removeAttribute('title');
}
if (fmsInput) {
fmsInput.removeAttribute('disabled');
fmsInput.removeAttribute('pattern');
fmsInput.removeAttribute('maxlength');
}
if (noFmsCheckbox) {
noFmsCheckbox.checked = false;
}
}
syncNoFmsState();
}
// Exécuter une première fois si besoin à l'initialisation :
if (sourceCategorySelect !== null) {
toggleSourceDetail();
toggleSourceRef();
// Événement pour surveiller les changements sur le champ Catgégorie de la source
sourceCategorySelect.addEventListener('change', toggleSourceDetail);
sourceCategorySelect.addEventListener('change', toggleSourceRef);
}
if (noFmsCheckbox) {
noFmsCheckbox.addEventListener('change', syncNoFmsState);
}
window.toggleSourceDetail = toggleSourceDetail;
window.syncNoFmsState = syncNoFmsState;
window.toggleSourceRef = toggleSourceRef;
}); });

View file

@ -179,52 +179,11 @@ document.addEventListener('DOMContentLoaded', function () {
}); });
} }
// --- Règles spécifiques à la source PREGO --- // Dispatch CustomEvent to allow custom validation rules injection (e.g., PREGO/FMS validations in forks)
const sourceSel = document.getElementById('id_source_category'); const customRulesEvent = new CustomEvent('streetup:collect-validation-rules', {
const pregoEl = document.getElementById('id_origin_prego_nbr'); detail: { rules: rules, isVisible: isVisible, gettext: gettext }
const fmsEl = document.getElementById('id_origin_fms_nbr'); });
const noFmsEl = document.getElementById('id_no_origin_fms'); document.dispatchEvent(customRulesEvent);
const docsEl = document.getElementById('id_documents');
const isPrego = (() => {
if (!sourceSel) return false;
const opt = sourceSel.options[sourceSel.selectedIndex];
const val = (opt?.value || '').trim().toLowerCase();
const txt = (opt?.text || '').trim().toLowerCase();
return val === 'prego' || txt === 'prego';
})();
if (isPrego) {
// 1️⃣ Numéro PREGO obligatoire
rules.push({
el: pregoEl,
test: () => pregoEl && pregoEl.value.trim() !== '',
message: gettext('Veuillez indiquer un numéro PREGO.')
});
// 2️⃣ Numéro FMS ou case "Pas de FMS" obligatoire
rules.push({
el: fmsEl,
test: () => {
const hasNoFms = !!noFmsEl?.checked;
const fmsVal = (fmsEl?.value || '').trim();
return hasNoFms || fmsVal !== '';
},
message: gettext('Veuillez indiquer un numéro FMS ou cocher « Pas de FMS ».')
});
// 3️⃣ Si FMS présent → fichier obligatoire
rules.push({
el: docsEl,
test: () => {
const fmsVal = (fmsEl?.value || '').trim();
if (!fmsVal) return true; // pas de FMS => pas de contrainte
const files = Array.from(docsEl?.files || []);
return files.length > 0;
},
message: gettext('Veuillez joindre au moins un document pour une référence FMS.')
});
}
if (!['artworks', 'nature', 'roads', 'its', 'clean'].includes(thematic)) { if (!['artworks', 'nature', 'roads', 'its', 'clean'].includes(thematic)) {
@ -340,8 +299,12 @@ document.addEventListener('DOMContentLoaded', function () {
// avertissements via modale (FMS, équipement structures, et statuts des assets/locations) // avertissements via modale (FMS, équipement structures, et statuts des assets/locations)
const submitWarnings = []; const submitWarnings = [];
const fmsWarnMsg = getFmsWarningMessage();
if (fmsWarnMsg) submitWarnings.push(fmsWarnMsg); // Dispatch CustomEvent to collect custom warnings (e.g., FMS file validation in forks)
const customWarningsEvent = new CustomEvent('streetup:collect-submit-warnings', {
detail: { warnings: submitWarnings, gettext: gettext }
});
document.dispatchEvent(customWarningsEvent);
// Vérifier les statuts des assets/locations // Vérifier les statuts des assets/locations
const assetLocationWarnings = await getAssetLocationStatusWarningsAsync(); const assetLocationWarnings = await getAssetLocationStatusWarningsAsync();
@ -460,31 +423,7 @@ document.addEventListener('DOMContentLoaded', function () {
// --- Helper: faut-il avertir que aucun fichier ne contient le n° FMS ? ---
function getFmsWarningMessage() {
const sourceSel = document.getElementById('id_source_category');
const fmsEl = document.getElementById('id_origin_fms_nbr');
const docsEl = document.getElementById('id_documents');
// seulement si corrective & source PREGO
const opt = sourceSel?.options[sourceSel.selectedIndex];
const val = (opt?.value || '').trim().toLowerCase();
const txt = (opt?.text || '').trim().toLowerCase();
const isPrego = (val === 'prego' || txt === 'prego');
if (getMaintainType() !== 'corrective' || !isPrego) return null;
const fmsVal = (fmsEl?.value || '').trim();
if (!fmsVal) return null; // pas de FMS → pas d’avertissement
const files = Array.from(docsEl?.files || []);
if (files.length === 0) return null; // règle "fichier obligatoire si FMS" s’en charge
const hasMatch = files.some(f => String(f.name || '').includes(fmsVal));
if (hasMatch) return null;
// Aucun fichier ne contient le n° FMS → message pour la modale
return gettext(`Aucun fichier joint ne contient le n° FMS saisi (${fmsVal}). Voulez-vous envoyer quand même ?`);
}
// --- Modale d’avertissement FMS (création lazy) --- // --- Modale d’avertissement FMS (création lazy) ---
// --- Helper: check for inactive assets/locations (corrective maintenance only) --- // --- Helper: check for inactive assets/locations (corrective maintenance only) ---
@ -604,6 +543,5 @@ function ensureFmsWarningModal() {
window.focusFirstFormError = focusFirstFormError; window.focusFirstFormError = focusFirstFormError;
window.getFmsWarningMessage = getFmsWarningMessage;
window.getAssetLocationStatusWarningsAsync = getAssetLocationStatusWarningsAsync; window.getAssetLocationStatusWarningsAsync = getAssetLocationStatusWarningsAsync;
window.ensureFmsWarningModal = ensureFmsWarningModal; window.ensureFmsWarningModal = ensureFmsWarningModal;

View file

@ -292,29 +292,11 @@
<label for="id_source_detail" class="form-label">{% translate "Détail de la source" %} <span class="text-danger">*</span></label> <label for="id_source_detail" class="form-label">{% translate "Détail de la source" %} <span class="text-danger">*</span></label>
{{ form.source_detail }} {{ form.source_detail }}
</div> </div>
<!-- Référence générique (utilisée par ex. pour OBSERVATION) --> <!-- Référence de la source -->
<div class="mb-3 d-none" id="source-ref-generic-container"> <div class="mb-3 d-none" id="source-ref-container">
<label for="id_source_ref" class="form-label">{% translate "Référence de la source" %} <span class="text-danger">*</span></label> <label for="id_source_ref" class="form-label">{% translate "Référence de la source" %} <span class="text-danger">*</span></label>
{{ form.source_ref }} {{ form.source_ref }}
</div> </div>
<div class="mb-3 d-none" id="source-ref-container">
<label for="id_source_ref" class="form-label">{% translate "Référence de la source" %}<span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text" id="mnt-prefix" style="display: none;">MNT-</span>
{{ form.origin_prego_nbr }}
{{ form.origin_prego_nbr.errors }}
</div>
<div class="form-text mt-3">
<label for="id_origin_fms_nbr" class="form-label">{% translate "Numéro FMS" %}</label>
{{ form.origin_fms_nbr }}
{{ form.origin_fms_nbr.errors }}
<div class="form-check mt-2">
{{ form.no_origin_fms }}
<label class="form-check-label" for="id_no_origin_fms">{% translate "Pas de FMS" %}</label>
</div>
{{ form.no_origin_fms.errors }}
</div>
</div>
</div> </div>
</div> </div>

View file

@ -81,14 +81,12 @@
</div> </div>
</div> </div>
{% if intervention.source_ref or intervention.origin_prego_nbr or 'source_ref' in allowed_update_fields %} {% if intervention.source_ref or 'source_ref' in allowed_update_fields %}
<div class="d-flex align-items-center editable-field cms-conditional-field {% if not intervention.source_ref and not intervention.origin_prego_nbr %}d-none{% endif %}" data-field="source_ref" data-can-edit="{% if 'source_ref' in allowed_update_fields %}true{% else %}false{% endif %}" data-has-value="{% if intervention.source_ref or intervention.origin_prego_nbr %}true{% else %}false{% endif %}"> <div class="d-flex align-items-center editable-field cms-conditional-field {% if not intervention.source_ref %}d-none{% endif %}" data-field="source_ref" data-can-edit="{% if 'source_ref' in allowed_update_fields %}true{% else %}false{% endif %}" data-has-value="{% if intervention.source_ref %}true{% else %}false{% endif %}">
<span class="label mx-2">{% translate "Référence" %}:</span> <span class="label mx-2">{% translate "Référence" %}:</span>
<div class="field-display"> <div class="field-display">
<span class="field-value"> <span class="field-value">
{% if intervention.source_category.name_fr == 'PREGO' and intervention.origin_prego_nbr %} {% if source_observation %}
<a href="https://jira.sprb-gob.brussels/browse/MNT-{{ intervention.origin_prego_nbr }}" target="_blank">MNT-{{ intervention.origin_prego_nbr }}</a>
{% elif source_observation %}
<a href="{% url 'observations:observation_detail' source_observation.id %}">{{ source_observation.code }}</a> <a href="{% url 'observations:observation_detail' source_observation.id %}">{{ source_observation.code }}</a>
{% elif intervention.source_ref %} {% elif intervention.source_ref %}
{{ intervention.source_ref }} {{ intervention.source_ref }}
@ -103,24 +101,6 @@
</div> </div>
{% endif %} {% endif %}
{% if intervention.origin_fms_nbr or 'origin_fms_nbr' in allowed_update_fields %}
<div class="d-flex align-items-center editable-field cms-conditional-field {% if not intervention.origin_fms_nbr %}d-none{% endif %}" data-field="origin_fms_nbr" data-can-edit="{% if 'origin_fms_nbr' in allowed_update_fields %}true{% else %}false{% endif %}" data-has-value="{% if intervention.origin_fms_nbr %}true{% else %}false{% endif %}">
<span class="label mx-2">FMS N°:</span>
<div class="field-display">
<span class="field-value">
{% if intervention.origin_fms_nbr %}
<a href="https://fixmystreet.brussels/{{ intervention.origin_fms_nbr }}" target="_blank">{{ intervention.origin_fms_nbr }}</a>
{% else %}
-
{% endif %}
</span>
</div>
<div class="field-edit d-none" style="flex: 1;">
<input type="number" class="form-control form-control-sm" value="{{ intervention.origin_fms_nbr|default:'' }}" placeholder="FMS N°">
</div>
</div>
{% endif %}
{% if intervention.source_detail or 'source_detail' in allowed_update_fields %} {% if intervention.source_detail or 'source_detail' in allowed_update_fields %}
<div class="d-flex align-items-center editable-field cms-conditional-field {% if not intervention.source_detail %}d-none{% endif %}" data-field="source_detail" data-can-edit="{% if 'source_detail' in allowed_update_fields %}true{% else %}false{% endif %}" data-has-value="{% if intervention.source_detail %}true{% else %}false{% endif %}"> <div class="d-flex align-items-center editable-field cms-conditional-field {% if not intervention.source_detail %}d-none{% endif %}" data-field="source_detail" data-can-edit="{% if 'source_detail' in allowed_update_fields %}true{% else %}false{% endif %}" data-has-value="{% if intervention.source_detail %}true{% else %}false{% endif %}">
<span class="label mx-2">{% translate "Détail" %}:</span> <span class="label mx-2">{% translate "Détail" %}:</span>

View file

@ -259,7 +259,7 @@
</div> </div>
<div class="calendar-toolbar__actions"> <div class="calendar-toolbar__actions">
<div class="input-group input-group-sm me-2" style="width: 380px;"> <div class="input-group input-group-sm me-2" style="width: 380px;">
<input type="text" name="q" id="calendar-search-input" class="form-control" placeholder="{% translate 'Titre, code, localisation, contrat, Prego, FMS…' %}" value="{{ selected_search|default:'' }}"> <input type="text" name="q" id="calendar-search-input" class="form-control" placeholder="{% translate 'Titre, code, localisation, contrat…' %}" value="{{ selected_search|default:'' }}">
<button class="btn btn-outline-secondary" type="button" id="calendar-search-clear" title="{% translate 'Effacer la recherche' %}" {% if not selected_search %}style="display:none;"{% endif %}> <button class="btn btn-outline-secondary" type="button" id="calendar-search-clear" title="{% translate 'Effacer la recherche' %}" {% if not selected_search %}style="display:none;"{% endif %}>
<i class="bi bi-x-lg"></i> <i class="bi bi-x-lg"></i>
</button> </button>

View file

@ -833,7 +833,7 @@
<div style="position:relative; display:flex;"> <div style="position:relative; display:flex;">
<input type="text" id="interventions-search-input" class="form-control form-control-sm" <input type="text" id="interventions-search-input" class="form-control form-control-sm"
style="width:320px; border-radius:20px 0 0 20px; border-color:#dee2e6; padding-right:1.6rem;" style="width:320px; border-radius:20px 0 0 20px; border-color:#dee2e6; padding-right:1.6rem;"
placeholder="{% translate 'Titre, code, localisation, contrat, Prego, FMS…' %}"> placeholder="{% translate 'Titre, code, localisation, contrat…' %}">
<button type="button" id="interventions-search-clear" <button type="button" id="interventions-search-clear"
style="position:absolute; right:4px; top:50%; transform:translateY(-50%); style="position:absolute; right:4px; top:50%; transform:translateY(-50%);
background:none; border:none; padding:0 2px; line-height:1; background:none; border:none; padding:0 2px; line-height:1;

View file

@ -403,19 +403,17 @@ def interventions_events(request):
if selected_search: if selected_search:
words = selected_search.split() words = selected_search.split()
interventions = interventions.annotate( from common.hooks import hooks
_prego_str=Cast('origin_prego_nbr', output_field=CharField()), interventions = hooks.apply_search_annotations(interventions)
_fms_str=Cast('origin_fms_nbr', output_field=CharField()),
)
def _word_q(word): def _word_q(word):
return ( q_base = (
Q(title__icontains=word) Q(title__icontains=word)
| Q(code__icontains=word) | Q(code__icontains=word)
| Q(location_code__icontains=word) | Q(location_code__icontains=word)
| Q(contract__contract_number__icontains=word) | Q(contract__contract_number__icontains=word)
| Q(_prego_str__icontains=word)
| Q(_fms_str__icontains=word)
) )
q_base |= hooks.get_search_filters(word)
return q_base
combined = Q() combined = Q()
for w in words: for w in words:
combined &= _word_q(w) combined &= _word_q(w)

View file

@ -36,7 +36,6 @@ class InterventionForm(forms.ModelForm):
documents = MultipleFileField(required=False, label=_("Documents")) documents = MultipleFileField(required=False, label=_("Documents"))
project = forms.ModelChoiceField(required=False, label=_("Projet"), queryset = Project.get_active_projects()) project = forms.ModelChoiceField(required=False, label=_("Projet"), queryset = Project.get_active_projects())
no_origin_fms = forms.BooleanField(required=False, label=_("Pas de FMS"))
def __init__(self, *args, thematic=None, user=None, **kwargs): def __init__(self, *args, thematic=None, user=None, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -87,7 +86,6 @@ class InterventionForm(forms.ModelForm):
self.fields['documents'].widget.attrs.update({'class': 'form-control'}) self.fields['documents'].widget.attrs.update({'class': 'form-control'})
self.fields['project'].widget.attrs.update({'class': 'form-select'}) self.fields['project'].widget.attrs.update({'class': 'form-select'})
self.fields['no_origin_fms'].widget.attrs.update({'class': 'form-check-input'})
# Limit status choices to statuses up to 'pris en charge' (assigned) # Limit status choices to statuses up to 'pris en charge' (assigned)
allowed_statuses = ['in_preparation', 'to_be_approved', 'to_be_planned', 'to_be_processed', 'assigned'] allowed_statuses = ['in_preparation', 'to_be_approved', 'to_be_planned', 'to_be_processed', 'assigned']
@ -172,7 +170,7 @@ class InterventionForm(forms.ModelForm):
'code', 'title', 'status', 'priority', 'init_description', 'code', 'title', 'status', 'priority', 'init_description',
'maintain_type', 'type', 'origin_type', 'begin_time', 'end_time', 'maintain_type', 'type', 'origin_type', 'begin_time', 'end_time',
'asset_category', 'symptom', 'asset_category', 'symptom',
'source_category', 'source_detail', 'source_ref', 'origin_prego_nbr', 'origin_fms_nbr', 'source_category', 'source_detail', 'source_ref',
'lat', 'lon', 'geom', 'address', 'lat', 'lon', 'geom', 'address',
'reason_for_correction', 'correction_details', 'reason_for_correction', 'correction_details',
'documents', 'project' 'documents', 'project'
@ -185,8 +183,6 @@ class InterventionForm(forms.ModelForm):
'source_category': forms.Select(attrs={'class': 'form-select'}), 'source_category': forms.Select(attrs={'class': 'form-select'}),
'source_detail': forms.TextInput(attrs={'class': 'form-control'}), 'source_detail': forms.TextInput(attrs={'class': 'form-control'}),
'source_ref': forms.TextInput(attrs={'class': 'form-control'}), 'source_ref': forms.TextInput(attrs={'class': 'form-control'}),
'origin_prego_nbr': forms.NumberInput(attrs={'class': 'form-control', 'placeholder': _('Numéro PREGO')}),
'origin_fms_nbr': forms.NumberInput(attrs={'class': 'form-control', 'placeholder': _('Numéro FMS'), 'min': 0, 'max': 2147483647}),
'asset_category': forms.Select(attrs={'class': 'form-select'}), 'asset_category': forms.Select(attrs={'class': 'form-select'}),
'symptom': forms.Select(attrs={'class': 'form-select'}), 'symptom': forms.Select(attrs={'class': 'form-select'}),
@ -210,15 +206,6 @@ class InterventionForm(forms.ModelForm):
'correction_details': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), 'correction_details': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
} }
def _is_prego_source(self, source_category):
if not source_category:
return False
possible_names = [
getattr(source_category, attr, "") or ""
for attr in ("name_fr", "name_nl")
]
return any(name.strip().lower() == "prego" for name in possible_names if isinstance(name, str))
def _is_observation_source(self, source_category): def _is_observation_source(self, source_category):
"""Return True when the selected source category corresponds to OBSERVATION (FR/NL).""" """Return True when the selected source category corresponds to OBSERVATION (FR/NL)."""
if not source_category: if not source_category:
@ -258,9 +245,6 @@ class InterventionForm(forms.ModelForm):
if not cleaned_data.get('origin_type'): if not cleaned_data.get('origin_type'):
cleaned_data['origin_type'] = 'demand' cleaned_data['origin_type'] = 'demand'
source_category = cleaned_data.get('source_category') source_category = cleaned_data.get('source_category')
origin_prego_nbr = cleaned_data.get('origin_prego_nbr')
origin_fms_nbr = cleaned_data.get('origin_fms_nbr')
no_origin_fms = cleaned_data.get('no_origin_fms')
documents = cleaned_data.get('documents') or [] documents = cleaned_data.get('documents') or []
# Ensure consistent list of uploaded files # Ensure consistent list of uploaded files
@ -297,27 +281,6 @@ class InterventionForm(forms.ModelForm):
_("Un équipement est obligatoire pour ce symptôme."), _("Un équipement est obligatoire pour ce symptôme."),
) )
if maintain_type == 'corrective' and self._is_prego_source(source_category):
if not origin_prego_nbr:
self.add_error(
'origin_prego_nbr',
_("Veuillez indiquer un numéro PREGO."),
)
if not origin_fms_nbr and not no_origin_fms:
self.add_error(
'origin_fms_nbr',
_("Veuillez indiquer un numéro FMS ou cocher \"Pas de FMS\"."),
)
if origin_fms_nbr:
if not documents:
self.add_error(
'documents',
_("Veuillez joindre au moins un document pour une référence FMS."),
)
elif no_origin_fms:
cleaned_data['origin_fms_nbr'] = None
# OBSERVATION: require a simple source reference (we use the observation id by convention) # OBSERVATION: require a simple source reference (we use the observation id by convention)
if maintain_type == 'corrective' and self._is_observation_source(source_category): if maintain_type == 'corrective' and self._is_observation_source(source_category):
source_ref = (cleaned_data.get('source_ref') or "").strip() source_ref = (cleaned_data.get('source_ref') or "").strip()
@ -334,6 +297,10 @@ class InterventionForm(forms.ModelForm):
_("La référence doit être un identifiant numérique (ID d'observation)."), _("La référence doit être un identifiant numérique (ID d'observation)."),
) )
# Validation spécifique externe via les hooks
from common.hooks import hooks
cleaned_data = hooks.run_form_cleaners(self, cleaned_data)
return cleaned_data return cleaned_data

View file

@ -510,19 +510,17 @@ def interventions_geojson(request):
if search_q: if search_q:
words = search_q.split() words = search_q.split()
qs = qs.annotate( from common.hooks import hooks
_prego_str=Cast('origin_prego_nbr', output_field=CharField()), qs = hooks.apply_search_annotations(qs)
_fms_str=Cast('origin_fms_nbr', output_field=CharField()),
)
def _word_q(word): def _word_q(word):
return ( q_base = (
Q(title__icontains=word) Q(title__icontains=word)
| Q(code__icontains=word) | Q(code__icontains=word)
| Q(location_code__icontains=word) | Q(location_code__icontains=word)
| Q(contract__contract_number__icontains=word) | Q(contract__contract_number__icontains=word)
| Q(_prego_str__icontains=word)
| Q(_fms_str__icontains=word)
) )
q_base |= hooks.get_search_filters(word)
return q_base
combined = Q() combined = Q()
for w in words: for w in words:
combined &= _word_q(w) combined &= _word_q(w)
@ -648,19 +646,17 @@ def interventions_geojson(request):
search_mode = 'and' search_mode = 'and'
if search_q: if search_q:
words = search_q.split() words = search_q.split()
qs = qs.annotate( from common.hooks import hooks
_prego_str=Cast('origin_prego_nbr', output_field=CharField()), qs = hooks.apply_search_annotations(qs)
_fms_str=Cast('origin_fms_nbr', output_field=CharField()),
)
def _word_q(word): def _word_q(word):
return ( q_base = (
Q(title__icontains=word) Q(title__icontains=word)
| Q(code__icontains=word) | Q(code__icontains=word)
| Q(location_code__icontains=word) | Q(location_code__icontains=word)
| Q(contract__contract_number__icontains=word) | Q(contract__contract_number__icontains=word)
| Q(_prego_str__icontains=word)
| Q(_fms_str__icontains=word)
) )
q_base |= hooks.get_search_filters(word)
return q_base
combined = Q() combined = Q()
if search_mode == 'and': if search_mode == 'and':
for _w in words: for _w in words:
@ -779,21 +775,19 @@ def interventions_search(request):
base_qs = ( base_qs = (
filter_viewable_interventions_for_user(request.user) filter_viewable_interventions_for_user(request.user)
.select_related('contract', 'contract__company') .select_related('contract', 'contract__company')
.annotate(
prego_str=Cast('origin_prego_nbr', output_field=CharField()),
fms_str=Cast('origin_fms_nbr', output_field=CharField()),
)
) )
from common.hooks import hooks
base_qs = hooks.apply_search_annotations(base_qs)
def word_filter(word): def word_filter(word):
return ( q_base = (
Q(title__icontains=word) Q(title__icontains=word)
| Q(code__icontains=word) | Q(code__icontains=word)
| Q(location_code__icontains=word) | Q(location_code__icontains=word)
| Q(contract__contract_number__icontains=word) | Q(contract__contract_number__icontains=word)
| Q(prego_str__icontains=word)
| Q(fms_str__icontains=word)
) )
q_base |= hooks.get_search_filters(word)
return q_base
combined = Q() combined = Q()
if mode == 'and': if mode == 'and':
@ -807,16 +801,17 @@ def interventions_search(request):
results = [] results = []
for itv in qs: for itv in qs:
results.append({ item = {
'id': itv.id, 'id': itv.id,
'code': itv.code or '', 'code': itv.code or '',
'title': itv.title or '', 'title': itv.title or '',
'location_code': itv.location_code or '', 'location_code': itv.location_code or '',
'status': itv.status or '', 'status': itv.status or '',
'contract_number': itv.contract.contract_number if itv.contract else '', 'contract_number': itv.contract.contract_number if itv.contract else '',
'origin_prego_nbr': itv.origin_prego_nbr, }
'origin_fms_nbr': itv.origin_fms_nbr, for key in hooks.get_custom_allowed_fields():
}) item[key] = getattr(itv, key, None)
results.append(item)
return JsonResponse({'results': results}) return JsonResponse({'results': results})
@ -938,8 +933,12 @@ def export_interventions(request):
(_("Catégorie source"), lambda i: (i.source_category.getName() if i.source_category else '')), (_("Catégorie source"), lambda i: (i.source_category.getName() if i.source_category else '')),
(_("Détail source"), lambda i: i.source_detail or ''), (_("Détail source"), lambda i: i.source_detail or ''),
(_("Référence source"), lambda i: i.source_ref or ''), (_("Référence source"), lambda i: i.source_ref or ''),
(_("Numéro Prego"), lambda i: i.origin_prego_nbr or ''), ]
(_("Numéro FMS"), lambda i: i.origin_fms_nbr or ''),
from common.hooks import hooks
columns.extend(hooks.get_custom_export_fields())
columns.extend([
(_("Fournisseur assigné"), lambda i: (i.assigned_provider.name if i.assigned_provider else '')), (_("Fournisseur assigné"), lambda i: (i.assigned_provider.name if i.assigned_provider else '')),
(_("Equipe assignée"), lambda i: (i.assigned_team.name if i.assigned_team else '')), (_("Equipe assignée"), lambda i: (i.assigned_team.name if i.assigned_team else '')),
(_("Membre assigné"), lambda i: (i.assigned_member.name if i.assigned_member else '')), (_("Membre assigné"), lambda i: (i.assigned_member.name if i.assigned_member else '')),
@ -957,7 +956,7 @@ def export_interventions(request):
(_("Adresse"), lambda i: i.address or ''), (_("Adresse"), lambda i: i.address or ''),
(_("Longitude"), lambda i: i.lon if i.lon is not None else ''), (_("Longitude"), lambda i: i.lon if i.lon is not None else ''),
(_("Latitude"), lambda i: i.lat if i.lat is not None else ''), (_("Latitude"), lambda i: i.lat if i.lat is not None else ''),
] ])
user_config = getattr(request.user, 'config', None) user_config = getattr(request.user, 'config', None)
# If user has role 'operator', include name of files attached to intervention by the creator of the intervention # If user has role 'operator', include name of files attached to intervention by the creator of the intervention

View file

@ -1775,19 +1775,17 @@ def apply_intervention_filters_from_querydict(qs, params, user=None):
if search_mode not in ('and', 'or'): if search_mode not in ('and', 'or'):
search_mode = 'and' search_mode = 'and'
words = search_q.split() words = search_q.split()
qs = qs.annotate( from common.hooks import hooks
_prego_str=Cast('origin_prego_nbr', output_field=CharField()), qs = hooks.apply_search_annotations(qs)
_fms_str=Cast('origin_fms_nbr', output_field=CharField()),
)
def _word_q(word): def _word_q(word):
return ( q_base = (
Q(title__icontains=word) Q(title__icontains=word)
| Q(code__icontains=word) | Q(code__icontains=word)
| Q(location_code__icontains=word) | Q(location_code__icontains=word)
| Q(contract__contract_number__icontains=word) | Q(contract__contract_number__icontains=word)
| Q(_prego_str__icontains=word)
| Q(_fms_str__icontains=word)
) )
q_base |= hooks.get_search_filters(word)
return q_base
combined = Q() combined = Q()
if search_mode == 'and': if search_mode == 'and':
for w in words: for w in words:
@ -2414,6 +2412,11 @@ def _update_intervention_field(intervention, field, new_value, user_config=None)
new_value: La nouvelle valeur new_value: La nouvelle valeur
user_config: La configuration utilisateur (optionnel, utilisé pour vérifier les permissions) user_config: La configuration utilisateur (optionnel, utilisé pour vérifier les permissions)
""" """
from common.hooks import hooks
success, error = hooks.run_inline_edit_validator(field, new_value, intervention)
if success is not None:
return success, error
if field == "title": if field == "title":
if len(new_value) <= 200: if len(new_value) <= 200:
intervention.title = new_value intervention.title = new_value
@ -2625,14 +2628,6 @@ def _update_intervention_field(intervention, field, new_value, user_config=None)
return True, None return True, None
return False, _('Value for source_detail must be limited to 255 characters') return False, _('Value for source_detail must be limited to 255 characters')
elif field == "origin_fms_nbr":
if new_value == "":
intervention.origin_fms_nbr = None
return True, None
if new_value.isdigit():
intervention.origin_fms_nbr = int(new_value)
return True, None
return False, _('Value for FMS number must be a positive integer')
elif field == "description": elif field == "description":
if len(new_value) <= 1024: if len(new_value) <= 1024:

View file

@ -686,10 +686,12 @@ class InterventionPDF(FPDF):
if intervention.source_category and intervention.source_category.has_reference: if intervention.source_category and intervention.source_category.has_reference:
label_fr = "Référence:" label_fr = "Référence:"
label_nl = "Referentie:" label_nl = "Referentie:"
if is_observation_source and intervention.source_ref: from common.hooks import hooks
custom_ref = hooks.get_custom_source_ref_value(intervention)
if custom_ref is not None:
text_value = custom_ref
elif is_observation_source and intervention.source_ref:
text_value = f"Observation {intervention.source_ref}" text_value = f"Observation {intervention.source_ref}"
elif intervention.source_category.name_fr == "PREGO":
text_value = f"MNT-{intervention.origin_prego_nbr}" if intervention.origin_prego_nbr else ""
else: else:
text_value = intervention.source_ref or "" text_value = intervention.source_ref or ""
else: else:
@ -1515,11 +1517,9 @@ class InterventionReportPDF(FPDF):
if intervention.source_detail: if intervention.source_detail:
self._add_field(_("Détail source"), intervention.source_detail) self._add_field(_("Détail source"), intervention.source_detail)
if intervention.origin_prego_nbr: from common.hooks import hooks
self._add_field("PREGO", f"MNT-{intervention.origin_prego_nbr}") for label, value in hooks.extract_pdf_fields(intervention):
self._add_field(label, value)
if intervention.origin_fms_nbr:
self._add_field("Fix My Street", intervention.origin_fms_nbr)
if intervention.init_call_confirmed_time: if intervention.init_call_confirmed_time:
self._add_field(_("Appel confirmé"), self._add_field(_("Appel confirmé"),

View file

@ -391,6 +391,14 @@ if 'test' in sys.argv:
PASSWORD_HASHERS = [ PASSWORD_HASHERS = [
'django.contrib.auth.hashers.MD5PasswordHasher', 'django.contrib.auth.hashers.MD5PasswordHasher',
] ]
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
}
if '--recreate-db' in sys.argv: if '--recreate-db' in sys.argv:
sys.argv.remove('--recreate-db') sys.argv.remove('--recreate-db')
os.environ['RECREATE_DB'] = '1' os.environ['RECREATE_DB'] = '1'