QA page
This commit is contained in:
parent
dea71fc6b3
commit
11cd3236c5
13 changed files with 1952 additions and 71 deletions
|
@ -29,6 +29,7 @@ from oedb.resources.live import live
|
|||
from oedb.resources.rss import rss_latest, rss_by_family
|
||||
from oedb.resources.event_form import event_form
|
||||
from oedb.resources.db_dump import db_dump_list, db_dump_create
|
||||
from oedb.resources.quality_assurance import quality_assurance
|
||||
|
||||
def create_app():
|
||||
"""
|
||||
|
@ -84,6 +85,7 @@ def create_app():
|
|||
app.add_route('/event/{id}', event) # Handle single event requests
|
||||
app.add_route('/event', event) # Handle event collection requests
|
||||
app.add_route('/stats', stats) # Handle stats requests
|
||||
app.add_route('/quality_assurance', quality_assurance) # Handle quality assurance requests
|
||||
app.add_route('/demo', demo) # Handle demo page requests
|
||||
app.add_route('/demo/add', event_form) # Handle event submission form
|
||||
app.add_route('/demo/by-what', demo, suffix='by_what') # Handle events by type page
|
||||
|
@ -94,6 +96,7 @@ def create_app():
|
|||
app.add_route('/demo/traffic', demo, suffix='traffic') # Handle traffic jam reporting page
|
||||
app.add_route('/demo/view-events', demo, suffix='view_events') # Handle view saved events page
|
||||
app.add_route('/demo/stats', demo_stats) # Handle stats by what page
|
||||
app.add_route('/demo/property-stats', demo, suffix='property_stats') # Handle property statistics page
|
||||
app.add_route('/demo/live', live) # Live page
|
||||
app.add_route('/rss', rss_latest) # RSS latest 200
|
||||
app.add_route('/rss/by/{family}', rss_by_family) # RSS by family
|
||||
|
|
|
@ -487,19 +487,25 @@ class DemoMainResource:
|
|||
const properties = feature.properties;
|
||||
|
||||
// Extraire les informations principales
|
||||
const title = properties.title || 'Événement sans titre';
|
||||
const title = properties.label || properties.title || 'Événement sans titre';
|
||||
const what = properties.what || 'Non spécifié';
|
||||
const when = properties.when ? formatDate(properties.when) : 'Date inconnue';
|
||||
const description = properties.description || 'Aucune description disponible';
|
||||
|
||||
// Créer le HTML de la popup
|
||||
// Créer le HTML de la popup avec titre cliquable pour édition
|
||||
const editLink = properties.id ? `/demo/edit/${properties.id}` : '#';
|
||||
|
||||
return `
|
||||
<div class="event-popup">
|
||||
<h3 style="margin-top: 0; color: #0078ff;">${title}</h3>
|
||||
<h3 style="margin-top: 0;">
|
||||
<a href="${editLink}" style="color: #0078ff; text-decoration: none;" title="Cliquer pour modifier cet événement">
|
||||
${title}
|
||||
</a>
|
||||
</h3>
|
||||
<p><strong>Type:</strong> ${what}</p>
|
||||
<p><strong>Date:</strong> ${when}</p>
|
||||
<p><strong>Description:</strong> ${description}</p>
|
||||
<p><a href="/demo/view/${properties.id}" style="color: #0078ff; font-weight: bold;">Voir détails</a></p>
|
||||
${properties.id ? `<p><a href="/demo/edit/${properties.id}" style="color: #0078ff; font-weight: bold;">✏️ Modifier l'événement</a></p>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
@ -786,7 +792,9 @@ class DemoMainResource:
|
|||
|
||||
// Create popup content
|
||||
let popupContent = '<div class="event-popup">';
|
||||
popupContent += `<h3>${properties.label || 'Event'}</h3>`;
|
||||
const eventTitle = properties.label || 'Event';
|
||||
const editLink = properties.id ? `/demo/edit/${properties.id}` : '#';
|
||||
popupContent += `<h3><a href="${editLink}" style="color: #0078ff; text-decoration: none;" title="Cliquer pour modifier cet événement">${eventTitle}</a></h3>`;
|
||||
|
||||
// Display all properties
|
||||
popupContent += '<div style="max-height: 300px; overflow-y: auto;">';
|
||||
|
|
501
oedb/resources/demo/static/event-types.js
Normal file
501
oedb/resources/demo/static/event-types.js
Normal file
|
@ -0,0 +1,501 @@
|
|||
// Configuration partagée des types d'événements avec leurs emojis et descriptions
|
||||
window.EVENT_TYPES = {
|
||||
// Community / OSM
|
||||
'community.osm.event': {
|
||||
emoji: '🗺️',
|
||||
label: 'Événement OpenStreetMap',
|
||||
category: 'Communauté',
|
||||
description: 'Événement lié à la communauté OpenStreetMap'
|
||||
},
|
||||
|
||||
// Culture / Arts
|
||||
'culture.arts': {
|
||||
emoji: '🎨',
|
||||
label: 'Arts et culture',
|
||||
category: 'Culture',
|
||||
description: 'Événement artistique et culturel'
|
||||
},
|
||||
'culture.geek': {
|
||||
emoji: '🤓',
|
||||
label: 'Culture geek',
|
||||
category: 'Culture',
|
||||
description: 'Événement geek, technologie, gaming'
|
||||
},
|
||||
'culture.music': {
|
||||
emoji: '🎵',
|
||||
label: 'Musique',
|
||||
category: 'Culture',
|
||||
description: 'Événement musical général'
|
||||
},
|
||||
|
||||
// Music specific
|
||||
'music.festival': {
|
||||
emoji: '🎪',
|
||||
label: 'Festival de musique',
|
||||
category: 'Musique',
|
||||
description: 'Festival musical'
|
||||
},
|
||||
|
||||
// Power / Energy
|
||||
'power.production.unavail': {
|
||||
emoji: '⚡',
|
||||
label: 'Production électrique indisponible',
|
||||
category: 'Énergie',
|
||||
description: 'Arrêt ou réduction de production électrique'
|
||||
},
|
||||
|
||||
// Sale / Commerce
|
||||
'sale': {
|
||||
emoji: '🛒',
|
||||
label: 'Vente / Commerce',
|
||||
category: 'Commerce',
|
||||
description: 'Événement commercial, vente, marché'
|
||||
},
|
||||
|
||||
// Time / Temporal
|
||||
'time.daylight.summer': {
|
||||
emoji: '☀️',
|
||||
label: 'Heure d\'été',
|
||||
category: 'Temps',
|
||||
description: 'Passage à l\'heure d\'été'
|
||||
},
|
||||
|
||||
// Tourism
|
||||
'tourism.exhibition': {
|
||||
emoji: '🖼️',
|
||||
label: 'Exposition',
|
||||
category: 'Tourisme',
|
||||
description: 'Exposition, salon, foire'
|
||||
},
|
||||
|
||||
// Traffic / Transportation
|
||||
'traffic.accident': {
|
||||
emoji: '💥',
|
||||
label: 'Accident',
|
||||
category: 'Circulation',
|
||||
description: 'Accident de la circulation'
|
||||
},
|
||||
'traffic.incident': {
|
||||
emoji: '⚠️',
|
||||
label: 'Incident de circulation',
|
||||
category: 'Circulation',
|
||||
description: 'Incident sur la route'
|
||||
},
|
||||
'traffic.obstacle': {
|
||||
emoji: '🚧',
|
||||
label: 'Obstacle',
|
||||
category: 'Circulation',
|
||||
description: 'Obstacle sur la voie'
|
||||
},
|
||||
'traffic.partially_closed': {
|
||||
emoji: '🚦',
|
||||
label: 'Voie partiellement fermée',
|
||||
category: 'Circulation',
|
||||
description: 'Fermeture partielle de voie'
|
||||
},
|
||||
'traffic.roadwork': {
|
||||
emoji: '⛑️',
|
||||
label: 'Travaux routiers',
|
||||
category: 'Circulation',
|
||||
description: 'Travaux sur la chaussée'
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction pour obtenir les suggestions d'autocomplétion
|
||||
function getEventTypeSuggestions(input) {
|
||||
const inputLower = input.toLowerCase();
|
||||
const suggestions = [];
|
||||
|
||||
for (const [key, config] of Object.entries(window.EVENT_TYPES)) {
|
||||
// Recherche dans la clé, le label et la catégorie
|
||||
const searchableText = `${key} ${config.label} ${config.category}`.toLowerCase();
|
||||
|
||||
if (searchableText.includes(inputLower)) {
|
||||
suggestions.push({
|
||||
value: key,
|
||||
label: `${config.emoji} ${config.label}`,
|
||||
category: config.category,
|
||||
fullText: `${key} - ${config.label}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Trier par pertinence (correspondance exacte en premier)
|
||||
suggestions.sort((a, b) => {
|
||||
const aExact = a.value.toLowerCase().startsWith(inputLower);
|
||||
const bExact = b.value.toLowerCase().startsWith(inputLower);
|
||||
|
||||
if (aExact && !bExact) return -1;
|
||||
if (!aExact && bExact) return 1;
|
||||
|
||||
return a.value.localeCompare(b.value);
|
||||
});
|
||||
|
||||
return suggestions.slice(0, 10); // Limiter à 10 suggestions
|
||||
}
|
||||
|
||||
// Fonction pour initialiser l'autocomplétion sur un champ
|
||||
function initializeEventTypeAutocomplete(inputElement, onSelect) {
|
||||
if (!inputElement) return;
|
||||
|
||||
let suggestionsContainer = null;
|
||||
let currentSuggestions = [];
|
||||
let selectedIndex = -1;
|
||||
let selectorButton = null;
|
||||
|
||||
// Créer le bouton de sélection
|
||||
function createSelectorButton() {
|
||||
selectorButton = document.createElement('button');
|
||||
selectorButton.type = 'button';
|
||||
selectorButton.className = 'event-type-selector-btn';
|
||||
selectorButton.innerHTML = '📋 Types d\'événements';
|
||||
selectorButton.style.cssText = `
|
||||
margin-top: 5px;
|
||||
padding: 6px 12px;
|
||||
background-color: #0078ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
selectorButton.addEventListener('click', function() {
|
||||
showAllEventTypes();
|
||||
});
|
||||
|
||||
const parent = inputElement.parentElement;
|
||||
parent.appendChild(selectorButton);
|
||||
}
|
||||
|
||||
// Créer le conteneur de suggestions
|
||||
function createSuggestionsContainer() {
|
||||
suggestionsContainer = document.createElement('div');
|
||||
suggestionsContainer.className = 'autocomplete-suggestions';
|
||||
suggestionsContainer.style.cssText = `
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
margin-top: 2px;
|
||||
`;
|
||||
|
||||
// Positionner le conteneur parent en relatif
|
||||
const parent = inputElement.parentElement;
|
||||
if (parent.style.position !== 'relative') {
|
||||
parent.style.position = 'relative';
|
||||
}
|
||||
parent.appendChild(suggestionsContainer);
|
||||
}
|
||||
|
||||
// Afficher tous les types d'événements dans un beau sélecteur
|
||||
function showAllEventTypes() {
|
||||
const allTypes = Object.entries(window.EVENT_TYPES).map(([key, config]) => ({
|
||||
value: key,
|
||||
emoji: config.emoji,
|
||||
label: config.label,
|
||||
category: config.category,
|
||||
description: config.description || '',
|
||||
fullText: `${key} - ${config.label}`
|
||||
}));
|
||||
|
||||
showEventTypeSelector(allTypes);
|
||||
}
|
||||
|
||||
// Afficher le sélecteur de types d'événements
|
||||
function showEventTypeSelector(types) {
|
||||
if (!suggestionsContainer) return;
|
||||
|
||||
currentSuggestions = types;
|
||||
selectedIndex = -1;
|
||||
|
||||
// Grouper par catégorie
|
||||
const groupedTypes = {};
|
||||
types.forEach(type => {
|
||||
if (!groupedTypes[type.category]) {
|
||||
groupedTypes[type.category] = [];
|
||||
}
|
||||
groupedTypes[type.category].push(type);
|
||||
});
|
||||
|
||||
let html = `
|
||||
<div style="padding: 10px; border-bottom: 1px solid #eee; background-color: #f8f9fa; border-radius: 8px 8px 0 0;">
|
||||
<strong style="color: #0078ff;">🏷️ Types d'événements disponibles</strong>
|
||||
<button onclick="this.parentElement.parentElement.style.display='none'" style="float: right; background: none; border: none; font-size: 16px; cursor: pointer;">✕</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Afficher chaque catégorie
|
||||
Object.entries(groupedTypes).forEach(([category, categoryTypes]) => {
|
||||
html += `
|
||||
<div style="padding: 8px 0;">
|
||||
<div style="padding: 6px 12px; background-color: #e9ecef; font-weight: bold; color: #495057; font-size: 13px;">
|
||||
${category}
|
||||
</div>
|
||||
`;
|
||||
|
||||
categoryTypes.forEach((type, index) => {
|
||||
const globalIndex = types.indexOf(type);
|
||||
html += `
|
||||
<div class="suggestion-item" data-index="${globalIndex}" style="
|
||||
padding: 10px 15px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f1f3f4;
|
||||
transition: background-color 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
" onmouseover="this.style.backgroundColor='#f0f8ff'" onmouseout="this.style.backgroundColor='white'">
|
||||
<span style="font-size: 20px; min-width: 25px;">${type.emoji}</span>
|
||||
<div style="flex: 1;">
|
||||
<div style="font-weight: 500; color: #212529;">${type.label}</div>
|
||||
<div style="font-size: 12px; color: #6c757d; font-family: monospace;">${type.value}</div>
|
||||
${type.description ? `<div style="font-size: 11px; color: #868e96; font-style: italic;">${type.description}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
suggestionsContainer.innerHTML = html;
|
||||
suggestionsContainer.style.display = 'block';
|
||||
|
||||
// Ajouter les gestionnaires d'événements
|
||||
const suggestionItems = suggestionsContainer.querySelectorAll('.suggestion-item');
|
||||
suggestionItems.forEach((item, index) => {
|
||||
const dataIndex = parseInt(item.dataset.index);
|
||||
item.addEventListener('click', () => selectSuggestion(dataIndex));
|
||||
});
|
||||
}
|
||||
|
||||
// Afficher les suggestions
|
||||
function showSuggestions(suggestions) {
|
||||
if (!suggestionsContainer) return;
|
||||
|
||||
currentSuggestions = suggestions;
|
||||
selectedIndex = -1;
|
||||
|
||||
if (suggestions.length === 0) {
|
||||
suggestionsContainer.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
suggestionsContainer.innerHTML = suggestions.map((suggestion, index) => `
|
||||
<div class="suggestion-item" data-index="${index}" style="
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #eee;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
">
|
||||
<span style="font-size: 16px;">${suggestion.label.split(' ')[0]}</span>
|
||||
<span style="flex: 1;">${suggestion.fullText}</span>
|
||||
<small style="color: #666;">${suggestion.category}</small>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
suggestionsContainer.style.display = 'block';
|
||||
|
||||
// Ajouter les gestionnaires d'événements aux suggestions
|
||||
const suggestionItems = suggestionsContainer.querySelectorAll('.suggestion-item');
|
||||
suggestionItems.forEach((item, index) => {
|
||||
item.addEventListener('click', () => selectSuggestion(index));
|
||||
item.addEventListener('mouseenter', () => highlightSuggestion(index));
|
||||
});
|
||||
}
|
||||
|
||||
// Mettre en surbrillance une suggestion
|
||||
function highlightSuggestion(index) {
|
||||
const items = suggestionsContainer.querySelectorAll('.suggestion-item');
|
||||
items.forEach((item, i) => {
|
||||
item.style.backgroundColor = i === index ? '#f0f8ff' : 'white';
|
||||
});
|
||||
selectedIndex = index;
|
||||
}
|
||||
|
||||
// Sélectionner une suggestion
|
||||
function selectSuggestion(index) {
|
||||
if (index >= 0 && index < currentSuggestions.length) {
|
||||
const suggestion = currentSuggestions[index];
|
||||
inputElement.value = suggestion.value;
|
||||
suggestionsContainer.style.display = 'none';
|
||||
|
||||
if (onSelect) {
|
||||
onSelect(suggestion);
|
||||
}
|
||||
|
||||
// Déclencher l'événement input pour les validations
|
||||
inputElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
|
||||
// Masquer les suggestions
|
||||
function hideSuggestions() {
|
||||
if (suggestionsContainer) {
|
||||
suggestionsContainer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Créer le conteneur de suggestions et le bouton
|
||||
createSuggestionsContainer();
|
||||
createSelectorButton();
|
||||
|
||||
// Gestionnaire d'événements pour l'input
|
||||
inputElement.addEventListener('input', function(e) {
|
||||
const value = e.target.value.trim();
|
||||
|
||||
if (value.length === 0) {
|
||||
showAllEventTypes();
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.length < 2) {
|
||||
hideSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
const suggestions = getEventTypeSuggestions(value);
|
||||
showFilteredSuggestions(suggestions);
|
||||
});
|
||||
|
||||
// Afficher l'autocomplétion au focus
|
||||
inputElement.addEventListener('focus', function(e) {
|
||||
const value = e.target.value.trim();
|
||||
|
||||
if (value.length === 0) {
|
||||
showAllEventTypes();
|
||||
} else if (value.length >= 2) {
|
||||
const suggestions = getEventTypeSuggestions(value);
|
||||
showFilteredSuggestions(suggestions);
|
||||
}
|
||||
});
|
||||
|
||||
// Afficher l'autocomplétion au keyup
|
||||
inputElement.addEventListener('keyup', function(e) {
|
||||
// Ignorer les touches de navigation
|
||||
if (['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(e.key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = e.target.value.trim();
|
||||
|
||||
if (value.length === 0) {
|
||||
showAllEventTypes();
|
||||
} else if (value.length >= 2) {
|
||||
const suggestions = getEventTypeSuggestions(value);
|
||||
showFilteredSuggestions(suggestions);
|
||||
}
|
||||
});
|
||||
|
||||
// Fonction pour afficher les suggestions filtrées
|
||||
function showFilteredSuggestions(suggestions) {
|
||||
if (!suggestionsContainer) return;
|
||||
|
||||
currentSuggestions = suggestions;
|
||||
selectedIndex = -1;
|
||||
|
||||
if (suggestions.length === 0) {
|
||||
suggestionsContainer.innerHTML = `
|
||||
<div style="padding: 15px; text-align: center; color: #6c757d;">
|
||||
Aucun type d'événement trouvé
|
||||
</div>
|
||||
`;
|
||||
suggestionsContainer.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `
|
||||
<div style="padding: 8px 12px; border-bottom: 1px solid #eee; background-color: #f8f9fa; font-size: 12px; color: #6c757d;">
|
||||
${suggestions.length} résultat${suggestions.length > 1 ? 's' : ''} trouvé${suggestions.length > 1 ? 's' : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
suggestions.forEach((suggestion, index) => {
|
||||
const config = window.EVENT_TYPES[suggestion.value] || {};
|
||||
html += `
|
||||
<div class="suggestion-item" data-index="${index}" style="
|
||||
padding: 10px 15px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f1f3f4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
" onmouseover="this.style.backgroundColor='#f0f8ff'" onmouseout="this.style.backgroundColor='white'">
|
||||
<span style="font-size: 20px;">${config.emoji || '📍'}</span>
|
||||
<div style="flex: 1;">
|
||||
<div style="font-weight: 500;">${config.label || suggestion.value}</div>
|
||||
<div style="font-size: 12px; color: #6c757d; font-family: monospace;">${suggestion.value}</div>
|
||||
</div>
|
||||
<small style="color: #6c757d;">${config.category || ''}</small>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
suggestionsContainer.innerHTML = html;
|
||||
suggestionsContainer.style.display = 'block';
|
||||
|
||||
// Ajouter les gestionnaires d'événements aux suggestions
|
||||
const suggestionItems = suggestionsContainer.querySelectorAll('.suggestion-item');
|
||||
suggestionItems.forEach((item, index) => {
|
||||
item.addEventListener('click', () => selectSuggestion(index));
|
||||
});
|
||||
}
|
||||
|
||||
// Gestionnaire d'événements pour le clavier
|
||||
inputElement.addEventListener('keydown', function(e) {
|
||||
if (!suggestionsContainer || suggestionsContainer.style.display === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
const nextIndex = selectedIndex < currentSuggestions.length - 1 ? selectedIndex + 1 : 0;
|
||||
highlightSuggestion(nextIndex);
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
const prevIndex = selectedIndex > 0 ? selectedIndex - 1 : currentSuggestions.length - 1;
|
||||
highlightSuggestion(prevIndex);
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (selectedIndex >= 0) {
|
||||
selectSuggestion(selectedIndex);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
hideSuggestions();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Masquer les suggestions quand on clique ailleurs
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!inputElement.contains(e.target) && !suggestionsContainer?.contains(e.target)) {
|
||||
hideSuggestions();
|
||||
}
|
||||
});
|
||||
|
||||
// Masquer les suggestions quand le champ perd le focus
|
||||
inputElement.addEventListener('blur', function() {
|
||||
// Délai pour permettre le clic sur une suggestion
|
||||
setTimeout(hideSuggestions, 200);
|
||||
});
|
||||
}
|
|
@ -12,11 +12,30 @@
|
|||
<script src="https://unpkg.com/@mapbox/mapbox-gl-draw@1.4.3/dist/mapbox-gl-draw.js"></script>
|
||||
<link rel="stylesheet" href="https://unpkg.com/@mapbox/mapbox-gl-draw@1.4.3/dist/mapbox-gl-draw.css" type="text/css" />
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
|
||||
<script src="/static/event-types.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}Edit Event{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="properties-section" style="margin-bottom: 30px;">
|
||||
<h3>Propriétés actuelles de l'événement</h3>
|
||||
<div class="properties-table-container" style="max-height: 300px; overflow-y: auto; border: 1px solid #ddd; border-radius: 4px;">
|
||||
<table id="propertiesTable" class="properties-table" style="width: 100%; border-collapse: collapse; font-family: monospace; font-size: 14px;">
|
||||
<thead style="background-color: #f8f9fa; position: sticky; top: 0;">
|
||||
<tr>
|
||||
<th style="padding: 8px; text-align: left; border-bottom: 1px solid #ddd; font-weight: bold;">Propriété</th>
|
||||
<th style="padding: 8px; text-align: left; border-bottom: 1px solid #ddd; font-weight: bold;">Valeur</th>
|
||||
<th style="padding: 8px; text-align: left; border-bottom: 1px solid #ddd; font-weight: bold;">Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="propertiesTableBody">
|
||||
<!-- Les propriétés seront ajoutées par JavaScript -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="eventForm">
|
||||
<input type="hidden" id="eventId" value="{{ id }}">
|
||||
|
||||
|
@ -38,7 +57,10 @@
|
|||
<div class="form-group">
|
||||
<label for="what" class="required">What</label>
|
||||
<input type="text" id="what" name="what" placeholder="e.g., sport.match.football" required>
|
||||
<div class="note">Category of the event (e.g., sport.match.football, culture.festival)</div>
|
||||
<div class="note">
|
||||
Category of the event (e.g., sport.match.football, culture.festival)<br>
|
||||
<small style="color: #0078ff;">💡 Tapez au moins 2 caractères pour voir les suggestions avec emojis</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -72,6 +94,14 @@
|
|||
<label class="required">Location</label>
|
||||
<div id="map"></div>
|
||||
<div class="note">Click on the map to set the event location</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<button type="button" id="swapCoordinatesButton" style="background-color: #17a2b8; font-size: 14px; padding: 6px 12px;">
|
||||
🔄 Inverser coordonnées (lat ↔ lon)
|
||||
</button>
|
||||
<small style="color: #666; margin-left: 10px;">
|
||||
Utile si les coordonnées longitude/latitude ont été inversées
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 10px;">
|
||||
|
|
|
@ -23,4 +23,3 @@
|
|||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
<script defer src="https://use.fontawesome.com/releases/v5.15.4/js/all.js"></script>
|
||||
<link rel="stylesheet" href="/static/traffic.css">
|
||||
<script src="/static/demo_auth.js"></script>
|
||||
<script src="/static/event-types.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
@ -187,11 +188,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<button id="geolocateBtn" class="geolocation-btn">
|
||||
<span id="geolocateSpinner" class="loading" style="display: none;"></span>
|
||||
Obtenir ma position actuelle
|
||||
</button>
|
||||
<span id="gpsStatus" class="gps-status" title="État GPS">GPS: inconnu</span>
|
||||
|
||||
<form id="trafficForm">
|
||||
<div class="form-group">
|
||||
|
@ -269,7 +265,13 @@
|
|||
<div class="form-group">
|
||||
<label class="required">Location</label>
|
||||
<div id="map"></div>
|
||||
<div class="note">Cliquez sur la carte pour définir la localisation du problème ou utilisez le bouton "Obtenir ma position actuelle"</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<button type="button" id="geolocateMapBtn" class="geolocation-btn" style="background-color: #28a745; color: white; padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer;">
|
||||
📍 Obtenir ma position actuelle
|
||||
</button>
|
||||
<span id="geolocateStatus" class="gps-status" style="margin-left: 10px; font-size: 0.9em; color: #666;">Cliquez pour vous géolocaliser</span>
|
||||
</div>
|
||||
<div class="note">Cliquez sur la carte pour définir la localisation du problème ou utilisez le bouton de géolocalisation</div>
|
||||
</div>
|
||||
|
||||
<button id="report_issue_button" type="submit" disabled>Signaler le problème</button>
|
||||
|
@ -622,10 +624,14 @@
|
|||
}
|
||||
|
||||
// Handle geolocation button click
|
||||
document.getElementById('geolocateBtn').addEventListener('click', function() {
|
||||
// Show loading spinner
|
||||
document.getElementById('geolocateSpinner').style.display = 'inline-block';
|
||||
document.getElementById('geolocateMapBtn').addEventListener('click', function() {
|
||||
// Update button text and disable it
|
||||
const originalText = this.textContent;
|
||||
this.textContent = '📍 Localisation en cours...';
|
||||
this.disabled = true;
|
||||
|
||||
const statusElement = document.getElementById('geolocateStatus');
|
||||
statusElement.textContent = 'Localisation en cours...';
|
||||
|
||||
// Check if geolocation is available
|
||||
if (navigator.geolocation) {
|
||||
|
@ -644,12 +650,17 @@
|
|||
zoom: 14
|
||||
});
|
||||
|
||||
// Hide loading spinner
|
||||
document.getElementById('geolocateSpinner').style.display = 'none';
|
||||
document.getElementById('geolocateBtn').disabled = false;
|
||||
|
||||
// Reset button
|
||||
document.getElementById('geolocateMapBtn').textContent = '📍 Position obtenue';
|
||||
document.getElementById('geolocateMapBtn').disabled = false;
|
||||
document.getElementById('geolocateMapBtn').style.backgroundColor = '#28a745';
|
||||
|
||||
const statusElement = document.getElementById('geolocateStatus');
|
||||
statusElement.textContent = `Position: ${lat.toFixed(4)}, ${lng.toFixed(4)}`;
|
||||
statusElement.style.color = '#28a745';
|
||||
|
||||
// Show success message
|
||||
showResult('Current location detected successfully', 'success');
|
||||
showResult('Position actuelle détectée avec succès', 'success');
|
||||
|
||||
// Validate form after setting marker
|
||||
validateForm();
|
||||
|
@ -684,26 +695,34 @@
|
|||
},
|
||||
// Error callback
|
||||
function(error) {
|
||||
// Hide loading spinner
|
||||
document.getElementById('geolocateSpinner').style.display = 'none';
|
||||
document.getElementById('geolocateBtn').disabled = false;
|
||||
|
||||
// Reset button
|
||||
document.getElementById('geolocateMapBtn').textContent = '📍 Réessayer la géolocalisation';
|
||||
document.getElementById('geolocateMapBtn').disabled = false;
|
||||
document.getElementById('geolocateMapBtn').style.backgroundColor = '#dc3545';
|
||||
|
||||
const statusElement = document.getElementById('geolocateStatus');
|
||||
|
||||
// Show error message
|
||||
let errorMsg = 'Unable to get your location. ';
|
||||
let errorMsg = 'Impossible d\'obtenir votre position. ';
|
||||
switch(error.code) {
|
||||
case error.PERMISSION_DENIED:
|
||||
errorMsg += 'You denied the request for geolocation.';
|
||||
errorMsg += 'Vous avez refusé la demande de géolocalisation.';
|
||||
statusElement.textContent = 'Permission refusée';
|
||||
break;
|
||||
case error.POSITION_UNAVAILABLE:
|
||||
errorMsg += 'Location information is unavailable.';
|
||||
errorMsg += 'Les informations de position ne sont pas disponibles.';
|
||||
statusElement.textContent = 'Position indisponible';
|
||||
break;
|
||||
case error.TIMEOUT:
|
||||
errorMsg += 'The request to get your location timed out.';
|
||||
errorMsg += 'La demande de géolocalisation a expiré.';
|
||||
statusElement.textContent = 'Temps dépassé';
|
||||
break;
|
||||
case error.UNKNOWN_ERROR:
|
||||
errorMsg += 'An unknown error occurred.';
|
||||
errorMsg += 'Une erreur inconnue s\'est produite.';
|
||||
statusElement.textContent = 'Erreur inconnue';
|
||||
break;
|
||||
}
|
||||
statusElement.style.color = '#dc3545';
|
||||
showResult(errorMsg, 'error');
|
||||
},
|
||||
// Options
|
||||
|
@ -714,12 +733,17 @@
|
|||
}
|
||||
);
|
||||
} else {
|
||||
// Hide loading spinner
|
||||
document.getElementById('geolocateSpinner').style.display = 'none';
|
||||
document.getElementById('geolocateBtn').disabled = false;
|
||||
|
||||
// Reset button
|
||||
document.getElementById('geolocateMapBtn').textContent = '📍 Non supporté';
|
||||
document.getElementById('geolocateMapBtn').disabled = true;
|
||||
document.getElementById('geolocateMapBtn').style.backgroundColor = '#6c757d';
|
||||
|
||||
const statusElement = document.getElementById('geolocateStatus');
|
||||
statusElement.textContent = 'Géolocalisation non supportée';
|
||||
statusElement.style.color = '#dc3545';
|
||||
|
||||
// Show error message
|
||||
showResult('Geolocation is not supported by your browser', 'error');
|
||||
showResult('La géolocalisation n\'est pas supportée par votre navigateur', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -260,7 +260,7 @@
|
|||
<div class="note">Cliquez sur la carte pour définir la localisation du problème ou utilisez le bouton "Obtenir ma position actuelle"</div>
|
||||
</div>
|
||||
|
||||
<button id="report_issue_button" type="submit">Signaler le problème</button>
|
||||
<button id="report_issue_button" type="submit" disabled>Signaler le problème</button>
|
||||
</form>
|
||||
|
||||
<div id="result"></div>
|
||||
|
|
|
@ -38,6 +38,7 @@ class EventFormResource:
|
|||
<link href="https://unpkg.com/maplibre-gl@3.0.0/dist/maplibre-gl.css" rel="stylesheet" />
|
||||
<script src="https://unpkg.com/@mapbox/mapbox-gl-draw@1.4.3/dist/mapbox-gl-draw.js"></script>
|
||||
<link rel="stylesheet" href="https://unpkg.com/@mapbox/mapbox-gl-draw@1.4.3/dist/mapbox-gl-draw.css" type="text/css" />
|
||||
<script src="/static/event-types.js"></script>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
|
@ -167,8 +168,11 @@ class EventFormResource:
|
|||
|
||||
<div class="form-group">
|
||||
<label for="what" >What</label>
|
||||
<input type="text" id="what" name="what" placeholder="e.g., sport.match.football" >
|
||||
<div class="note">Category of the event (e.g., sport.match.football, culture.festival)</div>
|
||||
<input type="text" id="what" name="what" placeholder="e.g., traffic.accident, culture.music" >
|
||||
<div class="note">
|
||||
Catégorie de l'événement - Cliquez dans le champ ou utilisez le bouton pour voir les suggestions<br>
|
||||
<small style="color: #0078ff;">💡 Tapez au moins 2 caractères pour filtrer les suggestions</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -201,7 +205,13 @@ class EventFormResource:
|
|||
<div class="form-group">
|
||||
<label >Location</label>
|
||||
<div id="map"></div>
|
||||
<div class="note">Click on the map to set the event location</div>
|
||||
<div style="margin-top: 10px; text-align: center;">
|
||||
<button type="button" id="geolocateBtn" style="background-color: #28a745; margin-bottom: 10px;">
|
||||
<span id="geolocateSpinner" style="display: none;">⏳</span>
|
||||
📍 Obtenir ma position actuelle
|
||||
</button>
|
||||
</div>
|
||||
<div class="note">Click on the map to set the event location or use the geolocation button above</div>
|
||||
</div>
|
||||
|
||||
<button type="submit">Create Event</button>
|
||||
|
@ -219,8 +229,11 @@ class EventFormResource:
|
|||
// Set start time to current time
|
||||
document.getElementById('start').value = today;
|
||||
|
||||
// Set end time to current time
|
||||
document.getElementById('stop').value = today;
|
||||
// Set end time to current time + 1 hour
|
||||
const oneHourLater = new Date(now);
|
||||
oneHourLater.setHours(oneHourLater.getHours() + 1);
|
||||
const oneHourLaterStr = oneHourLater.toISOString().slice(0, 16); // Format: YYYY-MM-DDThh:mm
|
||||
document.getElementById('stop').value = oneHourLaterStr;
|
||||
}
|
||||
|
||||
// Call function to set default dates
|
||||
|
@ -249,7 +262,89 @@ class EventFormResource:
|
|||
map.on('click', function(e) {
|
||||
marker.setLngLat(e.lngLat).addTo(map);
|
||||
});
|
||||
|
||||
// Handle geolocation button click
|
||||
document.getElementById('geolocateBtn').addEventListener('click', function() {
|
||||
// Show loading spinner
|
||||
document.getElementById('geolocateSpinner').style.display = 'inline-block';
|
||||
this.disabled = true;
|
||||
|
||||
// Check if geolocation is available
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
// Success callback
|
||||
function(position) {
|
||||
const lat = position.coords.latitude;
|
||||
const lng = position.coords.longitude;
|
||||
|
||||
// Set marker at current location
|
||||
marker.setLngLat([lng, lat]).addTo(map);
|
||||
|
||||
// Center map on current location
|
||||
map.flyTo({
|
||||
center: [lng, lat],
|
||||
zoom: 14
|
||||
});
|
||||
|
||||
// Hide loading spinner
|
||||
document.getElementById('geolocateSpinner').style.display = 'none';
|
||||
document.getElementById('geolocateBtn').disabled = false;
|
||||
|
||||
// Show success message
|
||||
showResult('Position actuelle détectée avec succès', 'success');
|
||||
},
|
||||
// Error callback
|
||||
function(error) {
|
||||
// Hide loading spinner
|
||||
document.getElementById('geolocateSpinner').style.display = 'none';
|
||||
document.getElementById('geolocateBtn').disabled = false;
|
||||
|
||||
// Show error message
|
||||
let errorMsg = 'Impossible d\'obtenir votre position. ';
|
||||
switch(error.code) {
|
||||
case error.PERMISSION_DENIED:
|
||||
errorMsg += 'Vous avez refusé la demande de géolocalisation.';
|
||||
break;
|
||||
case error.POSITION_UNAVAILABLE:
|
||||
errorMsg += 'Les informations de localisation ne sont pas disponibles.';
|
||||
break;
|
||||
case error.TIMEOUT:
|
||||
errorMsg += 'La demande de géolocalisation a expiré.';
|
||||
break;
|
||||
case error.UNKNOWN_ERROR:
|
||||
errorMsg += 'Une erreur inconnue s\'est produite.';
|
||||
break;
|
||||
}
|
||||
showResult(errorMsg, 'error');
|
||||
},
|
||||
// Options
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
maximumAge: 0
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Hide loading spinner
|
||||
document.getElementById('geolocateSpinner').style.display = 'none';
|
||||
document.getElementById('geolocateBtn').disabled = false;
|
||||
|
||||
// Show error message
|
||||
showResult('La géolocalisation n\'est pas supportée par votre navigateur', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize autocomplete for "what" field
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const whatInput = document.getElementById('what');
|
||||
if (whatInput && window.initializeEventTypeAutocomplete) {
|
||||
initializeEventTypeAutocomplete(whatInput, function(suggestion) {
|
||||
console.log('Type d\'événement sélectionné:', suggestion);
|
||||
});
|
||||
console.log('✅ Autocomplétion initialisée pour le champ "what"');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
document.getElementById('eventForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
|
|
@ -39,6 +39,59 @@ class LiveResource:
|
|||
<h1>
|
||||
<a href="/demo">OEDB</a> Live
|
||||
</h1>
|
||||
|
||||
<nav class="demo-nav" style="
|
||||
background-color: #f8f9fa;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
border-left: 4px solid #0078ff;
|
||||
">
|
||||
<div style="display: flex; align-items: center; gap: 20px; flex-wrap: wrap;">
|
||||
<!-- Logo et titre -->
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<img src="/static/oedb.png" style="width: 24px; height: 24px;" alt="OEDB" />
|
||||
<strong style="color: #0078ff;">OpenEventDatabase</strong>
|
||||
</div>
|
||||
|
||||
<!-- Liens de navigation -->
|
||||
<div style="display: flex; gap: 15px; flex-wrap: wrap; font-size: 14px;">
|
||||
<a href="/demo" style="color: #0078ff; text-decoration: none; padding: 4px 8px; border-radius: 4px;"
|
||||
onmouseover="this.style.backgroundColor='#e3f2fd'" onmouseout="this.style.backgroundColor='transparent'">
|
||||
🗺️ Carte principale
|
||||
</a>
|
||||
<a href="/demo/add" style="color: #0078ff; text-decoration: none; padding: 4px 8px; border-radius: 4px;"
|
||||
onmouseover="this.style.backgroundColor='#e3f2fd'" onmouseout="this.style.backgroundColor='transparent'">
|
||||
➕ Ajouter un événement
|
||||
</a>
|
||||
<a href="/demo/traffic" style="color: #0078ff; text-decoration: none; padding: 4px 8px; border-radius: 4px;"
|
||||
onmouseover="this.style.backgroundColor='#e3f2fd'" onmouseout="this.style.backgroundColor='transparent'">
|
||||
🚗 Signaler un problème
|
||||
</a>
|
||||
<a href="/demo/live" style="color: #0078ff; text-decoration: none; padding: 4px 8px; border-radius: 4px;"
|
||||
onmouseover="this.style.backgroundColor='#e3f2fd'" onmouseout="this.style.backgroundColor='transparent'">
|
||||
🔴 Live
|
||||
</a>
|
||||
<a href="/demo/view-events" style="color: #0078ff; text-decoration: none; padding: 4px 8px; border-radius: 4px;"
|
||||
onmouseover="this.style.backgroundColor='#e3f2fd'" onmouseout="this.style.backgroundColor='transparent'">
|
||||
📋 Voir les événements
|
||||
</a>
|
||||
<a href="/demo/property-stats" style="color: #0078ff; text-decoration: none; padding: 4px 8px; border-radius: 4px;"
|
||||
onmouseover="this.style.backgroundColor='#e3f2fd'" onmouseout="this.style.backgroundColor='transparent'">
|
||||
📊 Statistiques
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Lien vers l'API -->
|
||||
<div style="margin-left: auto;">
|
||||
<a href="/" style="color: #6c757d; text-decoration: none; font-size: 13px;"
|
||||
onmouseover="this.style.color='#0078ff'" onmouseout="this.style.color='#6c757d'">
|
||||
📡 API
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="controls">
|
||||
<span>Période: 7 jours (rafraîchit chaque minute)</span>
|
||||
<button id="refreshBtn">Rafraîchir</button>
|
||||
|
@ -71,34 +124,7 @@ class LiveResource:
|
|||
<p>Username: <strong id="username-display">Anonymous</strong></p>
|
||||
<p>Points: <span id="points-display" style="font-weight: bold; color: #0078ff;">0</span></p>
|
||||
</div>
|
||||
|
||||
<!-- Authentication section -->
|
||||
<!--
|
||||
# <div id="auth-section" class="auth-section">
|
||||
# <h3>OpenStreetMap Authentication</h3>
|
||||
#
|
||||
<a href="https://www.openstreetmap.org/oauth2/authorize?client_id={client_id}&redirect_uri={client_redirect}&response_type=code&scope=read_prefs" class="osm-login-btn">
|
||||
<span class="osm-logo"></span>
|
||||
Login with OpenStreetMap
|
||||
</a>
|
||||
<script>
|
||||
# // Replace server-side auth section with JavaScript-rendered version if available
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
fetchEvents();
|
||||
|
||||
if (window.osmAuth) {
|
||||
const clientId = document.getElementById('osmClientId').value;
|
||||
const redirectUri = document.getElementById('osmRedirectUri').value;
|
||||
const authSection = document.getElementById('auth-section');
|
||||
|
||||
// Only replace if osmAuth is loaded and has renderAuthSection method
|
||||
if (osmAuth.renderAuthSection) {
|
||||
authSection.innerHTML = osmAuth.renderAuthSection(clientId, redirectUri);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</div> -->
|
||||
|
||||
|
||||
<h3 id="endpoints_list_header">API Endpoints:</h3>
|
||||
<ul id="endpoints_list">
|
||||
|
|
493
oedb/resources/quality_assurance.py
Normal file
493
oedb/resources/quality_assurance.py
Normal file
|
@ -0,0 +1,493 @@
|
|||
"""
|
||||
Quality Assurance resource for the OpenEventDatabase.
|
||||
"""
|
||||
|
||||
import json
|
||||
import falcon
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from oedb.models.event import BaseEvent
|
||||
from oedb.utils.logging import logger
|
||||
|
||||
class QualityAssuranceResource(BaseEvent):
|
||||
"""
|
||||
Resource for quality assurance checks on events.
|
||||
Handles the /quality_assurance endpoint.
|
||||
"""
|
||||
|
||||
def on_get(self, req, resp):
|
||||
"""
|
||||
Handle GET requests to the /quality_assurance endpoint.
|
||||
Lists problematic events from the last 1000 events.
|
||||
|
||||
Args:
|
||||
req: The request object.
|
||||
resp: The response object.
|
||||
"""
|
||||
logger.info("Processing GET request to /quality_assurance")
|
||||
|
||||
try:
|
||||
# Set content type to HTML for the interface
|
||||
resp.content_type = 'text/html'
|
||||
|
||||
# Get problematic events
|
||||
problematic_events = self.get_problematic_events()
|
||||
|
||||
# Create HTML response with filtering capabilities
|
||||
html = self.create_qa_html(problematic_events)
|
||||
|
||||
resp.text = html
|
||||
resp.status = falcon.HTTP_200
|
||||
logger.success("Successfully processed GET request to /quality_assurance")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing GET request to /quality_assurance: {e}")
|
||||
resp.status = falcon.HTTP_500
|
||||
resp.text = f"Erreur: {str(e)}"
|
||||
|
||||
def get_problematic_events(self):
|
||||
"""
|
||||
Get events from the OEDB API and identify problematic ones.
|
||||
|
||||
Returns:
|
||||
list: List of problematic events with their issues.
|
||||
"""
|
||||
logger.info("Fetching events from OEDB API for quality assurance")
|
||||
|
||||
try:
|
||||
# Fetch events from the OEDB API
|
||||
api_url = "https://api.openeventdatabase.org/event?"
|
||||
|
||||
with urllib.request.urlopen(api_url) as response:
|
||||
data = json.loads(response.read().decode('utf-8'))
|
||||
|
||||
if not data or 'features' not in data:
|
||||
logger.warning("No features found in API response")
|
||||
return []
|
||||
|
||||
events = data['features']
|
||||
logger.info(f"Retrieved {len(events)} events from API")
|
||||
|
||||
# Analyze events for problems
|
||||
problematic_events = []
|
||||
|
||||
for feature in events:
|
||||
issues = []
|
||||
|
||||
# Extract event data
|
||||
event_data = {
|
||||
'id': feature.get('properties', {}).get('id'),
|
||||
'properties': feature.get('properties', {}),
|
||||
'geometry': feature.get('geometry'),
|
||||
'coordinates': feature.get('geometry', {}).get('coordinates', []),
|
||||
'createdate': feature.get('properties', {}).get('createdate'),
|
||||
'lastupdate': feature.get('properties', {}).get('lastupdate')
|
||||
}
|
||||
|
||||
# Extract coordinates
|
||||
if event_data['coordinates'] and len(event_data['coordinates']) >= 2:
|
||||
event_data['longitude'] = float(event_data['coordinates'][0])
|
||||
event_data['latitude'] = float(event_data['coordinates'][1])
|
||||
else:
|
||||
event_data['longitude'] = None
|
||||
event_data['latitude'] = None
|
||||
|
||||
# Check for various issues
|
||||
issues.extend(self.check_coordinate_issues(event_data))
|
||||
issues.extend(self.check_geometry_issues(event_data))
|
||||
issues.extend(self.check_property_issues(event_data))
|
||||
|
||||
# Only add to list if there are issues
|
||||
if issues:
|
||||
event_data['issues'] = issues
|
||||
problematic_events.append(event_data)
|
||||
|
||||
logger.info(f"Found {len(problematic_events)} problematic events out of {len(events)} total")
|
||||
return problematic_events
|
||||
|
||||
except urllib.error.URLError as e:
|
||||
logger.error(f"Error fetching events from API: {e}")
|
||||
return []
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Error decoding JSON response from API: {e}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error fetching events: {e}")
|
||||
return []
|
||||
|
||||
def check_coordinate_issues(self, event_data):
|
||||
"""Check for coordinate-related issues."""
|
||||
issues = []
|
||||
|
||||
# Check for null or zero coordinates
|
||||
if event_data['longitude'] is None or event_data['latitude'] is None:
|
||||
issues.append({
|
||||
'type': 'missing_coordinates',
|
||||
'severity': 'high',
|
||||
'description': 'Coordonnées manquantes (longitude ou latitude null)'
|
||||
})
|
||||
elif event_data['longitude'] == 0 and event_data['latitude'] == 0:
|
||||
issues.append({
|
||||
'type': 'zero_coordinates',
|
||||
'severity': 'high',
|
||||
'description': 'Coordonnées nulles (0,0) - probablement invalides'
|
||||
})
|
||||
|
||||
# Check for unrealistic coordinates
|
||||
if event_data['longitude'] and event_data['latitude']:
|
||||
if abs(event_data['longitude']) > 180:
|
||||
issues.append({
|
||||
'type': 'invalid_longitude',
|
||||
'severity': 'high',
|
||||
'description': f'Longitude invalide: {event_data["longitude"]} (doit être entre -180 et 180)'
|
||||
})
|
||||
|
||||
if abs(event_data['latitude']) > 90:
|
||||
issues.append({
|
||||
'type': 'invalid_latitude',
|
||||
'severity': 'high',
|
||||
'description': f'Latitude invalide: {event_data["latitude"]} (doit être entre -90 et 90)'
|
||||
})
|
||||
|
||||
return issues
|
||||
|
||||
def check_geometry_issues(self, event_data):
|
||||
"""Check for geometry-related issues."""
|
||||
issues = []
|
||||
|
||||
geometry = event_data.get('geometry')
|
||||
if not geometry:
|
||||
issues.append({
|
||||
'type': 'missing_geometry',
|
||||
'severity': 'high',
|
||||
'description': 'Géométrie manquante'
|
||||
})
|
||||
elif geometry.get('type') not in ['Point', 'LineString', 'Polygon', 'MultiPoint', 'MultiLineString', 'MultiPolygon']:
|
||||
issues.append({
|
||||
'type': 'invalid_geometry_type',
|
||||
'severity': 'medium',
|
||||
'description': f'Type de géométrie invalide: {geometry.get("type")}'
|
||||
})
|
||||
elif geometry.get('type') == 'Point' and not geometry.get('coordinates'):
|
||||
issues.append({
|
||||
'type': 'empty_point_coordinates',
|
||||
'severity': 'high',
|
||||
'description': 'Point sans coordonnées'
|
||||
})
|
||||
|
||||
return issues
|
||||
|
||||
def check_property_issues(self, event_data):
|
||||
"""Check for property-related issues."""
|
||||
issues = []
|
||||
|
||||
properties = event_data.get('properties', {})
|
||||
|
||||
# Check for missing essential properties
|
||||
if not properties.get('what'):
|
||||
issues.append({
|
||||
'type': 'missing_what',
|
||||
'severity': 'medium',
|
||||
'description': 'Propriété "what" manquante (type d\'événement)'
|
||||
})
|
||||
|
||||
if not properties.get('label') and not properties.get('name'):
|
||||
issues.append({
|
||||
'type': 'missing_label',
|
||||
'severity': 'low',
|
||||
'description': 'Aucun libellé ou nom pour l\'événement'
|
||||
})
|
||||
|
||||
# Check for very short or empty descriptions
|
||||
description = properties.get('description', '')
|
||||
if isinstance(description, str) and len(description.strip()) < 5:
|
||||
issues.append({
|
||||
'type': 'short_description',
|
||||
'severity': 'low',
|
||||
'description': 'Description trop courte ou vide'
|
||||
})
|
||||
|
||||
return issues
|
||||
|
||||
def create_qa_html(self, problematic_events):
|
||||
"""Create HTML interface for quality assurance."""
|
||||
|
||||
# Count issues by type
|
||||
issue_counts = {}
|
||||
for event in problematic_events:
|
||||
for issue in event['issues']:
|
||||
issue_type = issue['type']
|
||||
if issue_type not in issue_counts:
|
||||
issue_counts[issue_type] = 0
|
||||
issue_counts[issue_type] += 1
|
||||
|
||||
html = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Contrôle Qualité - OpenEventDatabase</title>
|
||||
<link rel="icon" type="image/png" href="/static/oedb.png">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
|
||||
<script defer src="https://use.fontawesome.com/releases/v5.15.4/js/all.js"></script>
|
||||
<script src="/static/event-types.js"></script>
|
||||
<style>
|
||||
.issue-high { border-left: 4px solid #ff3860; }
|
||||
.issue-medium { border-left: 4px solid #ffdd57; }
|
||||
.issue-low { border-left: 4px solid #23d160; }
|
||||
.event-card { margin-bottom: 1rem; }
|
||||
.filter-panel {
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
.coordinates-display {
|
||||
font-family: monospace;
|
||||
background: #f5f5f5;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.edit-link {
|
||||
background: #0078ff;
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.edit-link:hover {
|
||||
background: #0056b3;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<h1 class="title">
|
||||
<i class="fas fa-search"></i>
|
||||
Contrôle Qualité des Événements
|
||||
</h1>
|
||||
<p class="subtitle">
|
||||
Analyse des 1000 derniers événements pour détecter les problèmes divers
|
||||
</p>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-3">
|
||||
<div class="filter-panel">
|
||||
<h3 class="title is-5">Filtres</h3>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Type de problème</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select id="issue-type-filter">
|
||||
<option value="">Tous les types</option>
|
||||
"""
|
||||
|
||||
# Add filter options based on found issues
|
||||
for issue_type, count in sorted(issue_counts.items()):
|
||||
issue_label = issue_type.replace('_', ' ').title()
|
||||
html += f'<option value="{issue_type}">{issue_label} ({count})</option>'
|
||||
|
||||
html += """
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Sévérité</label>
|
||||
<div class="control">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" id="severity-high" checked>
|
||||
Élevée
|
||||
</label>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" id="severity-medium" checked>
|
||||
Moyenne
|
||||
</label>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" id="severity-low" checked>
|
||||
Faible
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<button class="button is-primary is-fullwidth" onclick="applyFilters()">
|
||||
<i class="fas fa-filter"></i>
|
||||
Appliquer les filtres
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Statistiques</label>
|
||||
<div class="content">
|
||||
<p><strong>Total événements problématiques:</strong> """ + str(len(problematic_events)) + """</p>
|
||||
"""
|
||||
|
||||
for issue_type, count in sorted(issue_counts.items(), key=lambda x: x[1], reverse=True):
|
||||
issue_label = issue_type.replace('_', ' ').title()
|
||||
html += f"<p><strong>{issue_label}:</strong> {count}</p>"
|
||||
|
||||
html += """
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-9">
|
||||
<div id="events-container">
|
||||
"""
|
||||
|
||||
# Add events
|
||||
for event in problematic_events:
|
||||
properties = event.get('properties', {})
|
||||
event_title = properties.get('label', properties.get('name', f'Événement #{event["id"]}'))
|
||||
event_what = properties.get('what', 'Non spécifié')
|
||||
|
||||
# Get severity classes for the card
|
||||
max_severity = 'low'
|
||||
for issue in event['issues']:
|
||||
if issue['severity'] == 'high':
|
||||
max_severity = 'high'
|
||||
break
|
||||
elif issue['severity'] == 'medium' and max_severity == 'low':
|
||||
max_severity = 'medium'
|
||||
|
||||
html += f"""
|
||||
<div class="card event-card issue-{max_severity}" data-event-id="{event['id']}">
|
||||
<div class="card-content">
|
||||
<div class="level">
|
||||
<div class="level-left">
|
||||
<div>
|
||||
<h4 class="title is-5">{event_title}</h4>
|
||||
<p class="subtitle is-6">
|
||||
Type: {event_what} |
|
||||
Coordonnées: <span class="coordinates-display">
|
||||
{event.get('latitude', 'N/A')}, {event.get('longitude', 'N/A')}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<a href="/demo/edit/{event['id']}" class="edit-link">
|
||||
✏️ Modifier
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<h6 class="title is-6">Problèmes détectés:</h6>
|
||||
<ul>
|
||||
"""
|
||||
|
||||
for issue in event['issues']:
|
||||
severity_icon = {
|
||||
'high': '🔴',
|
||||
'medium': '🟡',
|
||||
'low': '🟢'
|
||||
}.get(issue['severity'], '⚪')
|
||||
|
||||
html += f"""
|
||||
<li class="issue-item" data-issue-type="{issue['type']}" data-severity="{issue['severity']}">
|
||||
{severity_icon} <strong>{issue['type'].replace('_', ' ').title()}:</strong>
|
||||
{issue['description']}
|
||||
</li>
|
||||
"""
|
||||
|
||||
html += """
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<small class="has-text-grey">
|
||||
Créé: """ + str(event.get('createdate', 'N/A')) + """ |
|
||||
Modifié: """ + str(event.get('lastupdate', 'N/A')) + """
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
if not problematic_events:
|
||||
html += """
|
||||
<div class="notification is-success">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
Aucun problème détecté dans les 1000 derniers événements ! 🎉
|
||||
</div>
|
||||
"""
|
||||
|
||||
html += """
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
function applyFilters() {
|
||||
const issueTypeFilter = document.getElementById('issue-type-filter').value;
|
||||
const severityHigh = document.getElementById('severity-high').checked;
|
||||
const severityMedium = document.getElementById('severity-medium').checked;
|
||||
const severityLow = document.getElementById('severity-low').checked;
|
||||
|
||||
const eventCards = document.querySelectorAll('.event-card');
|
||||
|
||||
eventCards.forEach(card => {
|
||||
let shouldShow = false;
|
||||
const issueItems = card.querySelectorAll('.issue-item');
|
||||
|
||||
issueItems.forEach(item => {
|
||||
const issueType = item.dataset.issueType;
|
||||
const severity = item.dataset.severity;
|
||||
|
||||
// Check if this issue matches the filters
|
||||
const typeMatches = !issueTypeFilter || issueType === issueTypeFilter;
|
||||
const severityMatches = (
|
||||
(severity === 'high' && severityHigh) ||
|
||||
(severity === 'medium' && severityMedium) ||
|
||||
(severity === 'low' && severityLow)
|
||||
);
|
||||
|
||||
if (typeMatches && severityMatches) {
|
||||
shouldShow = true;
|
||||
}
|
||||
});
|
||||
|
||||
card.style.display = shouldShow ? 'block' : 'none';
|
||||
});
|
||||
|
||||
// Update visible count
|
||||
const visibleCards = document.querySelectorAll('.event-card[style="display: block"], .event-card:not([style*="display: none"])').length;
|
||||
console.log(`Filtered: ${visibleCards} events visible`);
|
||||
}
|
||||
|
||||
// Initialize filters
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
applyFilters();
|
||||
|
||||
// Add event listeners for auto-filtering
|
||||
document.getElementById('issue-type-filter').addEventListener('change', applyFilters);
|
||||
document.getElementById('severity-high').addEventListener('change', applyFilters);
|
||||
document.getElementById('severity-medium').addEventListener('change', applyFilters);
|
||||
document.getElementById('severity-low').addEventListener('change', applyFilters);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return html
|
||||
|
||||
# Create a global instance
|
||||
quality_assurance = QualityAssuranceResource()
|
191
static/edit.js
191
static/edit.js
|
@ -19,6 +19,9 @@ function initializeForm() {
|
|||
const properties = window.eventData.properties || {};
|
||||
const geometry = window.eventData.geometry || {};
|
||||
|
||||
// Remplir le tableau des propriétés
|
||||
populatePropertiesTable();
|
||||
|
||||
// Remplir les champs du formulaire
|
||||
document.getElementById('label').value = properties.label || '';
|
||||
document.getElementById('type').value = properties.type || 'scheduled';
|
||||
|
@ -102,6 +105,54 @@ function initializeMap() {
|
|||
console.log('✅ Carte initialisée');
|
||||
}
|
||||
|
||||
function populatePropertiesTable() {
|
||||
if (!window.eventData || !window.eventData.properties) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tableBody = document.getElementById('propertiesTableBody');
|
||||
const properties = window.eventData.properties;
|
||||
|
||||
// Vider le tableau
|
||||
tableBody.innerHTML = '';
|
||||
|
||||
// Trier les propriétés alphabétiquement
|
||||
const sortedKeys = Object.keys(properties).sort();
|
||||
|
||||
// Ajouter chaque propriété au tableau
|
||||
sortedKeys.forEach(key => {
|
||||
const value = properties[key];
|
||||
const valueType = typeof value;
|
||||
|
||||
const row = document.createElement('tr');
|
||||
row.style.borderBottom = '1px solid #eee';
|
||||
|
||||
// Formater la valeur pour l'affichage
|
||||
let displayValue = '';
|
||||
if (value === null) {
|
||||
displayValue = '<em style="color: #999;">null</em>';
|
||||
} else if (value === undefined) {
|
||||
displayValue = '<em style="color: #999;">undefined</em>';
|
||||
} else if (typeof value === 'object') {
|
||||
displayValue = `<pre style="margin: 0; white-space: pre-wrap; font-size: 12px;">${JSON.stringify(value, null, 2)}</pre>`;
|
||||
} else if (typeof value === 'string' && value.length > 50) {
|
||||
displayValue = `<span title="${value}">${value.substring(0, 50)}...</span>`;
|
||||
} else {
|
||||
displayValue = String(value);
|
||||
}
|
||||
|
||||
row.innerHTML = `
|
||||
<td style="padding: 8px; font-weight: bold; background-color: #f8f9fa;">${key}</td>
|
||||
<td style="padding: 8px; word-break: break-word; max-width: 300px;">${displayValue}</td>
|
||||
<td style="padding: 8px; color: #666; font-style: italic;">${valueType}</td>
|
||||
`;
|
||||
|
||||
tableBody.appendChild(row);
|
||||
});
|
||||
|
||||
console.log('✅ Tableau des propriétés rempli');
|
||||
}
|
||||
|
||||
function setupEventHandlers() {
|
||||
// Gestionnaire de soumission du formulaire
|
||||
const form = document.getElementById('eventForm');
|
||||
|
@ -114,6 +165,34 @@ function setupEventHandlers() {
|
|||
if (deleteButton) {
|
||||
deleteButton.addEventListener('click', handleDelete);
|
||||
}
|
||||
|
||||
// Gestionnaire du bouton d'inversion des coordonnées
|
||||
const swapButton = document.getElementById('swapCoordinatesButton');
|
||||
if (swapButton) {
|
||||
swapButton.addEventListener('click', handleSwapCoordinates);
|
||||
|
||||
// Vérifier si c'est un Point pour activer/désactiver le bouton
|
||||
const geometry = window.eventData?.geometry;
|
||||
if (!geometry || geometry.type !== 'Point') {
|
||||
swapButton.disabled = true;
|
||||
swapButton.textContent = '🔄 Non disponible (pas un Point)';
|
||||
swapButton.style.backgroundColor = '#6c757d';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialiser l'autocomplétion pour le champ "what"
|
||||
const whatInput = document.getElementById('what');
|
||||
if (whatInput && window.initializeEventTypeAutocomplete) {
|
||||
initializeEventTypeAutocomplete(whatInput, function(suggestion) {
|
||||
console.log('Type d\'événement sélectionné:', suggestion);
|
||||
|
||||
// Émettre un événement de validation du formulaire
|
||||
setTimeout(() => {
|
||||
whatInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}, 100);
|
||||
});
|
||||
console.log('✅ Autocomplétion initialisée pour le champ "what"');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFormSubmit(e) {
|
||||
|
@ -230,3 +309,115 @@ async function handleDelete() {
|
|||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSwapCoordinates() {
|
||||
const eventId = document.getElementById('eventId').value;
|
||||
const resultElement = document.getElementById('result');
|
||||
const geometry = window.eventData?.geometry;
|
||||
|
||||
// Vérifications de sécurité
|
||||
if (!geometry || geometry.type !== 'Point') {
|
||||
resultElement.innerHTML = `
|
||||
<div style="color: #dc3545; padding: 15px; background: #f8d7da; border: 1px solid #f5c6cb; border-radius: 4px;">
|
||||
<strong>Erreur:</strong> L'inversion des coordonnées n'est disponible que pour les événements de type Point.
|
||||
</div>
|
||||
`;
|
||||
resultElement.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
const currentCoords = geometry.coordinates;
|
||||
if (!Array.isArray(currentCoords) || currentCoords.length < 2) {
|
||||
resultElement.innerHTML = `
|
||||
<div style="color: #dc3545; padding: 15px; background: #f8d7da; border: 1px solid #f5c6cb; border-radius: 4px;">
|
||||
<strong>Erreur:</strong> Coordonnées invalides détectées.
|
||||
</div>
|
||||
`;
|
||||
resultElement.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
// Afficher les coordonnées actuelles et futures
|
||||
const [currentLon, currentLat] = currentCoords;
|
||||
const newCoords = [currentLat, currentLon]; // Inverser longitude et latitude
|
||||
|
||||
const confirmMessage = `Voulez-vous inverser les coordonnées ?
|
||||
|
||||
Actuelles: [${currentLon.toFixed(6)}, ${currentLat.toFixed(6)}] (lon, lat)
|
||||
Nouvelles: [${newCoords[0].toFixed(6)}, ${newCoords[1].toFixed(6)}] (lat→lon, lon→lat)
|
||||
|
||||
Cette action sera sauvegardée immédiatement sur le serveur.`;
|
||||
|
||||
if (!confirm(confirmMessage)) {
|
||||
return;
|
||||
}
|
||||
|
||||
resultElement.innerHTML = '<div style="color: #17a2b8;">⏳ Inversion des coordonnées en cours...</div>';
|
||||
resultElement.style.display = 'block';
|
||||
|
||||
try {
|
||||
// Créer l'événement modifié avec coordonnées inversées
|
||||
const updatedEvent = {
|
||||
...window.eventData,
|
||||
geometry: {
|
||||
...geometry,
|
||||
coordinates: newCoords
|
||||
}
|
||||
};
|
||||
|
||||
// Envoyer la mise à jour au serveur
|
||||
const response = await fetch(`https://api.openeventdatabase.org/event/${eventId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(updatedEvent)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Mettre à jour les données locales
|
||||
window.eventData = updatedEvent;
|
||||
|
||||
// Mettre à jour le marqueur sur la carte
|
||||
if (currentMarker) {
|
||||
currentMarker.setLngLat(newCoords);
|
||||
}
|
||||
|
||||
// Centrer la carte sur les nouvelles coordonnées
|
||||
if (map) {
|
||||
map.flyTo({
|
||||
center: newCoords,
|
||||
zoom: Math.max(map.getZoom(), 12)
|
||||
});
|
||||
}
|
||||
|
||||
// Mettre à jour le tableau des propriétés
|
||||
populatePropertiesTable();
|
||||
|
||||
resultElement.innerHTML = `
|
||||
<div style="color: #28a745; padding: 15px; background: #d4edda; border: 1px solid #c3e6cb; border-radius: 4px;">
|
||||
<i class="fas fa-check"></i>
|
||||
<strong>Succès:</strong> Les coordonnées ont été inversées avec succès.<br>
|
||||
<small>Anciennes: [${currentLon.toFixed(6)}, ${currentLat.toFixed(6)}]<br>
|
||||
Nouvelles: [${newCoords[0].toFixed(6)}, ${newCoords[1].toFixed(6)}]</small>
|
||||
</div>
|
||||
`;
|
||||
|
||||
console.log('✅ Coordonnées inversées avec succès:', {
|
||||
avant: [currentLon, currentLat],
|
||||
après: newCoords
|
||||
});
|
||||
} else {
|
||||
const errorData = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${errorData}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'inversion des coordonnées:', error);
|
||||
resultElement.innerHTML = `
|
||||
<div style="color: #dc3545; padding: 15px; background: #f8d7da; border: 1px solid #f5c6cb; border-radius: 4px;">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<strong>Erreur:</strong> Impossible d'inverser les coordonnées. ${error.message}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
501
static/event-types.js
Normal file
501
static/event-types.js
Normal file
|
@ -0,0 +1,501 @@
|
|||
// Configuration partagée des types d'événements avec leurs emojis et descriptions
|
||||
window.EVENT_TYPES = {
|
||||
// Community / OSM
|
||||
'community.osm.event': {
|
||||
emoji: '🗺️',
|
||||
label: 'Événement OpenStreetMap',
|
||||
category: 'Communauté',
|
||||
description: 'Événement lié à la communauté OpenStreetMap'
|
||||
},
|
||||
|
||||
// Culture / Arts
|
||||
'culture.arts': {
|
||||
emoji: '🎨',
|
||||
label: 'Arts et culture',
|
||||
category: 'Culture',
|
||||
description: 'Événement artistique et culturel'
|
||||
},
|
||||
'culture.geek': {
|
||||
emoji: '🤓',
|
||||
label: 'Culture geek',
|
||||
category: 'Culture',
|
||||
description: 'Événement geek, technologie, gaming'
|
||||
},
|
||||
'culture.music': {
|
||||
emoji: '🎵',
|
||||
label: 'Musique',
|
||||
category: 'Culture',
|
||||
description: 'Événement musical général'
|
||||
},
|
||||
|
||||
// Music specific
|
||||
'music.festival': {
|
||||
emoji: '🎪',
|
||||
label: 'Festival de musique',
|
||||
category: 'Musique',
|
||||
description: 'Festival musical'
|
||||
},
|
||||
|
||||
// Power / Energy
|
||||
'power.production.unavail': {
|
||||
emoji: '⚡',
|
||||
label: 'Production électrique indisponible',
|
||||
category: 'Énergie',
|
||||
description: 'Arrêt ou réduction de production électrique'
|
||||
},
|
||||
|
||||
// Sale / Commerce
|
||||
'sale': {
|
||||
emoji: '🛒',
|
||||
label: 'Vente / Commerce',
|
||||
category: 'Commerce',
|
||||
description: 'Événement commercial, vente, marché'
|
||||
},
|
||||
|
||||
// Time / Temporal
|
||||
'time.daylight.summer': {
|
||||
emoji: '☀️',
|
||||
label: 'Heure d\'été',
|
||||
category: 'Temps',
|
||||
description: 'Passage à l\'heure d\'été'
|
||||
},
|
||||
|
||||
// Tourism
|
||||
'tourism.exhibition': {
|
||||
emoji: '🖼️',
|
||||
label: 'Exposition',
|
||||
category: 'Tourisme',
|
||||
description: 'Exposition, salon, foire'
|
||||
},
|
||||
|
||||
// Traffic / Transportation
|
||||
'traffic.accident': {
|
||||
emoji: '💥',
|
||||
label: 'Accident',
|
||||
category: 'Circulation',
|
||||
description: 'Accident de la circulation'
|
||||
},
|
||||
'traffic.incident': {
|
||||
emoji: '⚠️',
|
||||
label: 'Incident de circulation',
|
||||
category: 'Circulation',
|
||||
description: 'Incident sur la route'
|
||||
},
|
||||
'traffic.obstacle': {
|
||||
emoji: '🚧',
|
||||
label: 'Obstacle',
|
||||
category: 'Circulation',
|
||||
description: 'Obstacle sur la voie'
|
||||
},
|
||||
'traffic.partially_closed': {
|
||||
emoji: '🚦',
|
||||
label: 'Voie partiellement fermée',
|
||||
category: 'Circulation',
|
||||
description: 'Fermeture partielle de voie'
|
||||
},
|
||||
'traffic.roadwork': {
|
||||
emoji: '⛑️',
|
||||
label: 'Travaux routiers',
|
||||
category: 'Circulation',
|
||||
description: 'Travaux sur la chaussée'
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction pour obtenir les suggestions d'autocomplétion
|
||||
function getEventTypeSuggestions(input) {
|
||||
const inputLower = input.toLowerCase();
|
||||
const suggestions = [];
|
||||
|
||||
for (const [key, config] of Object.entries(window.EVENT_TYPES)) {
|
||||
// Recherche dans la clé, le label et la catégorie
|
||||
const searchableText = `${key} ${config.label} ${config.category}`.toLowerCase();
|
||||
|
||||
if (searchableText.includes(inputLower)) {
|
||||
suggestions.push({
|
||||
value: key,
|
||||
label: `${config.emoji} ${config.label}`,
|
||||
category: config.category,
|
||||
fullText: `${key} - ${config.label}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Trier par pertinence (correspondance exacte en premier)
|
||||
suggestions.sort((a, b) => {
|
||||
const aExact = a.value.toLowerCase().startsWith(inputLower);
|
||||
const bExact = b.value.toLowerCase().startsWith(inputLower);
|
||||
|
||||
if (aExact && !bExact) return -1;
|
||||
if (!aExact && bExact) return 1;
|
||||
|
||||
return a.value.localeCompare(b.value);
|
||||
});
|
||||
|
||||
return suggestions.slice(0, 10); // Limiter à 10 suggestions
|
||||
}
|
||||
|
||||
// Fonction pour initialiser l'autocomplétion sur un champ
|
||||
function initializeEventTypeAutocomplete(inputElement, onSelect) {
|
||||
if (!inputElement) return;
|
||||
|
||||
let suggestionsContainer = null;
|
||||
let currentSuggestions = [];
|
||||
let selectedIndex = -1;
|
||||
let selectorButton = null;
|
||||
|
||||
// Créer le bouton de sélection
|
||||
function createSelectorButton() {
|
||||
selectorButton = document.createElement('button');
|
||||
selectorButton.type = 'button';
|
||||
selectorButton.className = 'event-type-selector-btn';
|
||||
selectorButton.innerHTML = '📋 Types d\'événements';
|
||||
selectorButton.style.cssText = `
|
||||
margin-top: 5px;
|
||||
padding: 6px 12px;
|
||||
background-color: #0078ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
selectorButton.addEventListener('click', function() {
|
||||
showAllEventTypes();
|
||||
});
|
||||
|
||||
const parent = inputElement.parentElement;
|
||||
parent.appendChild(selectorButton);
|
||||
}
|
||||
|
||||
// Créer le conteneur de suggestions
|
||||
function createSuggestionsContainer() {
|
||||
suggestionsContainer = document.createElement('div');
|
||||
suggestionsContainer.className = 'autocomplete-suggestions';
|
||||
suggestionsContainer.style.cssText = `
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
margin-top: 2px;
|
||||
`;
|
||||
|
||||
// Positionner le conteneur parent en relatif
|
||||
const parent = inputElement.parentElement;
|
||||
if (parent.style.position !== 'relative') {
|
||||
parent.style.position = 'relative';
|
||||
}
|
||||
parent.appendChild(suggestionsContainer);
|
||||
}
|
||||
|
||||
// Afficher tous les types d'événements dans un beau sélecteur
|
||||
function showAllEventTypes() {
|
||||
const allTypes = Object.entries(window.EVENT_TYPES).map(([key, config]) => ({
|
||||
value: key,
|
||||
emoji: config.emoji,
|
||||
label: config.label,
|
||||
category: config.category,
|
||||
description: config.description || '',
|
||||
fullText: `${key} - ${config.label}`
|
||||
}));
|
||||
|
||||
showEventTypeSelector(allTypes);
|
||||
}
|
||||
|
||||
// Afficher le sélecteur de types d'événements
|
||||
function showEventTypeSelector(types) {
|
||||
if (!suggestionsContainer) return;
|
||||
|
||||
currentSuggestions = types;
|
||||
selectedIndex = -1;
|
||||
|
||||
// Grouper par catégorie
|
||||
const groupedTypes = {};
|
||||
types.forEach(type => {
|
||||
if (!groupedTypes[type.category]) {
|
||||
groupedTypes[type.category] = [];
|
||||
}
|
||||
groupedTypes[type.category].push(type);
|
||||
});
|
||||
|
||||
let html = `
|
||||
<div style="padding: 10px; border-bottom: 1px solid #eee; background-color: #f8f9fa; border-radius: 8px 8px 0 0;">
|
||||
<strong style="color: #0078ff;">🏷️ Types d'événements disponibles</strong>
|
||||
<button onclick="this.parentElement.parentElement.style.display='none'" style="float: right; background: none; border: none; font-size: 16px; cursor: pointer;">✕</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Afficher chaque catégorie
|
||||
Object.entries(groupedTypes).forEach(([category, categoryTypes]) => {
|
||||
html += `
|
||||
<div style="padding: 8px 0;">
|
||||
<div style="padding: 6px 12px; background-color: #e9ecef; font-weight: bold; color: #495057; font-size: 13px;">
|
||||
${category}
|
||||
</div>
|
||||
`;
|
||||
|
||||
categoryTypes.forEach((type, index) => {
|
||||
const globalIndex = types.indexOf(type);
|
||||
html += `
|
||||
<div class="suggestion-item" data-index="${globalIndex}" style="
|
||||
padding: 10px 15px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f1f3f4;
|
||||
transition: background-color 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
" onmouseover="this.style.backgroundColor='#f0f8ff'" onmouseout="this.style.backgroundColor='white'">
|
||||
<span style="font-size: 20px; min-width: 25px;">${type.emoji}</span>
|
||||
<div style="flex: 1;">
|
||||
<div style="font-weight: 500; color: #212529;">${type.label}</div>
|
||||
<div style="font-size: 12px; color: #6c757d; font-family: monospace;">${type.value}</div>
|
||||
${type.description ? `<div style="font-size: 11px; color: #868e96; font-style: italic;">${type.description}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
suggestionsContainer.innerHTML = html;
|
||||
suggestionsContainer.style.display = 'block';
|
||||
|
||||
// Ajouter les gestionnaires d'événements
|
||||
const suggestionItems = suggestionsContainer.querySelectorAll('.suggestion-item');
|
||||
suggestionItems.forEach((item, index) => {
|
||||
const dataIndex = parseInt(item.dataset.index);
|
||||
item.addEventListener('click', () => selectSuggestion(dataIndex));
|
||||
});
|
||||
}
|
||||
|
||||
// Afficher les suggestions
|
||||
function showSuggestions(suggestions) {
|
||||
if (!suggestionsContainer) return;
|
||||
|
||||
currentSuggestions = suggestions;
|
||||
selectedIndex = -1;
|
||||
|
||||
if (suggestions.length === 0) {
|
||||
suggestionsContainer.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
suggestionsContainer.innerHTML = suggestions.map((suggestion, index) => `
|
||||
<div class="suggestion-item" data-index="${index}" style="
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #eee;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
">
|
||||
<span style="font-size: 16px;">${suggestion.label.split(' ')[0]}</span>
|
||||
<span style="flex: 1;">${suggestion.fullText}</span>
|
||||
<small style="color: #666;">${suggestion.category}</small>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
suggestionsContainer.style.display = 'block';
|
||||
|
||||
// Ajouter les gestionnaires d'événements aux suggestions
|
||||
const suggestionItems = suggestionsContainer.querySelectorAll('.suggestion-item');
|
||||
suggestionItems.forEach((item, index) => {
|
||||
item.addEventListener('click', () => selectSuggestion(index));
|
||||
item.addEventListener('mouseenter', () => highlightSuggestion(index));
|
||||
});
|
||||
}
|
||||
|
||||
// Mettre en surbrillance une suggestion
|
||||
function highlightSuggestion(index) {
|
||||
const items = suggestionsContainer.querySelectorAll('.suggestion-item');
|
||||
items.forEach((item, i) => {
|
||||
item.style.backgroundColor = i === index ? '#f0f8ff' : 'white';
|
||||
});
|
||||
selectedIndex = index;
|
||||
}
|
||||
|
||||
// Sélectionner une suggestion
|
||||
function selectSuggestion(index) {
|
||||
if (index >= 0 && index < currentSuggestions.length) {
|
||||
const suggestion = currentSuggestions[index];
|
||||
inputElement.value = suggestion.value;
|
||||
suggestionsContainer.style.display = 'none';
|
||||
|
||||
if (onSelect) {
|
||||
onSelect(suggestion);
|
||||
}
|
||||
|
||||
// Déclencher l'événement input pour les validations
|
||||
inputElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
|
||||
// Masquer les suggestions
|
||||
function hideSuggestions() {
|
||||
if (suggestionsContainer) {
|
||||
suggestionsContainer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Créer le conteneur de suggestions et le bouton
|
||||
createSuggestionsContainer();
|
||||
createSelectorButton();
|
||||
|
||||
// Gestionnaire d'événements pour l'input
|
||||
inputElement.addEventListener('input', function(e) {
|
||||
const value = e.target.value.trim();
|
||||
|
||||
if (value.length === 0) {
|
||||
showAllEventTypes();
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.length < 2) {
|
||||
hideSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
const suggestions = getEventTypeSuggestions(value);
|
||||
showFilteredSuggestions(suggestions);
|
||||
});
|
||||
|
||||
// Afficher l'autocomplétion au focus
|
||||
inputElement.addEventListener('focus', function(e) {
|
||||
const value = e.target.value.trim();
|
||||
|
||||
if (value.length === 0) {
|
||||
showAllEventTypes();
|
||||
} else if (value.length >= 2) {
|
||||
const suggestions = getEventTypeSuggestions(value);
|
||||
showFilteredSuggestions(suggestions);
|
||||
}
|
||||
});
|
||||
|
||||
// Afficher l'autocomplétion au keyup
|
||||
inputElement.addEventListener('keyup', function(e) {
|
||||
// Ignorer les touches de navigation
|
||||
if (['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(e.key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = e.target.value.trim();
|
||||
|
||||
if (value.length === 0) {
|
||||
showAllEventTypes();
|
||||
} else if (value.length >= 2) {
|
||||
const suggestions = getEventTypeSuggestions(value);
|
||||
showFilteredSuggestions(suggestions);
|
||||
}
|
||||
});
|
||||
|
||||
// Fonction pour afficher les suggestions filtrées
|
||||
function showFilteredSuggestions(suggestions) {
|
||||
if (!suggestionsContainer) return;
|
||||
|
||||
currentSuggestions = suggestions;
|
||||
selectedIndex = -1;
|
||||
|
||||
if (suggestions.length === 0) {
|
||||
suggestionsContainer.innerHTML = `
|
||||
<div style="padding: 15px; text-align: center; color: #6c757d;">
|
||||
Aucun type d'événement trouvé
|
||||
</div>
|
||||
`;
|
||||
suggestionsContainer.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `
|
||||
<div style="padding: 8px 12px; border-bottom: 1px solid #eee; background-color: #f8f9fa; font-size: 12px; color: #6c757d;">
|
||||
${suggestions.length} résultat${suggestions.length > 1 ? 's' : ''} trouvé${suggestions.length > 1 ? 's' : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
suggestions.forEach((suggestion, index) => {
|
||||
const config = window.EVENT_TYPES[suggestion.value] || {};
|
||||
html += `
|
||||
<div class="suggestion-item" data-index="${index}" style="
|
||||
padding: 10px 15px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f1f3f4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
" onmouseover="this.style.backgroundColor='#f0f8ff'" onmouseout="this.style.backgroundColor='white'">
|
||||
<span style="font-size: 20px;">${config.emoji || '📍'}</span>
|
||||
<div style="flex: 1;">
|
||||
<div style="font-weight: 500;">${config.label || suggestion.value}</div>
|
||||
<div style="font-size: 12px; color: #6c757d; font-family: monospace;">${suggestion.value}</div>
|
||||
</div>
|
||||
<small style="color: #6c757d;">${config.category || ''}</small>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
suggestionsContainer.innerHTML = html;
|
||||
suggestionsContainer.style.display = 'block';
|
||||
|
||||
// Ajouter les gestionnaires d'événements aux suggestions
|
||||
const suggestionItems = suggestionsContainer.querySelectorAll('.suggestion-item');
|
||||
suggestionItems.forEach((item, index) => {
|
||||
item.addEventListener('click', () => selectSuggestion(index));
|
||||
});
|
||||
}
|
||||
|
||||
// Gestionnaire d'événements pour le clavier
|
||||
inputElement.addEventListener('keydown', function(e) {
|
||||
if (!suggestionsContainer || suggestionsContainer.style.display === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
const nextIndex = selectedIndex < currentSuggestions.length - 1 ? selectedIndex + 1 : 0;
|
||||
highlightSuggestion(nextIndex);
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
const prevIndex = selectedIndex > 0 ? selectedIndex - 1 : currentSuggestions.length - 1;
|
||||
highlightSuggestion(prevIndex);
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (selectedIndex >= 0) {
|
||||
selectSuggestion(selectedIndex);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
hideSuggestions();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Masquer les suggestions quand on clique ailleurs
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!inputElement.contains(e.target) && !suggestionsContainer?.contains(e.target)) {
|
||||
hideSuggestions();
|
||||
}
|
||||
});
|
||||
|
||||
// Masquer les suggestions quand le champ perd le focus
|
||||
inputElement.addEventListener('blur', function() {
|
||||
// Délai pour permettre le clic sur une suggestion
|
||||
setTimeout(hideSuggestions, 200);
|
||||
});
|
||||
}
|
|
@ -240,13 +240,23 @@ function createPopupContent(properties) {
|
|||
const title = properties.label || properties.name || 'Événement';
|
||||
const what = properties.what || 'Non spécifié';
|
||||
const description = properties.description || 'Aucune description disponible';
|
||||
const eventId = properties.id;
|
||||
|
||||
return `
|
||||
<div class="event-popup">
|
||||
<h3 style="margin-top: 0; color: #0078ff;">${title}</h3>
|
||||
${eventId ?
|
||||
`<h3 style="margin-top: 0; color: #0078ff; cursor: pointer; text-decoration: underline;" onclick="window.location.href='/demo/edit/${eventId}'" title="Cliquer pour modifier l'événement">${title}</h3>` :
|
||||
`<h3 style="margin-top: 0; color: #0078ff;">${title}</h3>`
|
||||
}
|
||||
<p><strong>Type:</strong> ${what}</p>
|
||||
<p><strong>Description:</strong> ${description}</p>
|
||||
${properties.id ? `<p><a href="/demo/edit/${properties.id}" style="color: #0078ff; font-weight: bold;">Modifier</a></p>` : ''}
|
||||
${eventId ?
|
||||
`<div style="margin-top: 10px;">
|
||||
<a href="/demo/edit/${eventId}" style="color: #0078ff; font-weight: bold; margin-right: 10px;">✏️ Modifier</a>
|
||||
<a href="/demo/by_id/${eventId}" style="color: #0078ff; font-weight: bold;">👁️ Détails</a>
|
||||
</div>` :
|
||||
`<p><small style="color: #666;">ID non disponible</small></p>`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue