oedb-backend/oedb/resources/demo/demo_traffic.py
2025-09-21 13:35:01 +02:00

794 lines
No EOL
38 KiB
Python

"""
Traffic jam reporting resource for the OpenEventDatabase.
"""
import falcon
import os
import requests
from oedb.utils.logging import logger
from oedb.utils.db import load_env_from_file
class DemoTrafficResource:
"""
Resource for the traffic jam reporting page.
Handles the /demo/traffic endpoint.
"""
def on_get(self, req, resp):
"""
Handle GET requests to the /demo/traffic endpoint.
Returns an HTML page with a form for reporting traffic jams.
Args:
req: The request object.
resp: The response object.
"""
logger.info("Processing GET request to /demo/traffic")
try:
# Set content type to HTML
resp.content_type = 'text/html'
# Load environment variables from .env file
load_env_from_file()
# Get OAuth2 configuration parameters
client_id = os.getenv("CLIENT_ID", "")
client_secret = os.getenv("CLIENT_SECRET", "")
client_authorizations = os.getenv("CLIENT_AUTORIZATIONS", "read_prefs")
client_redirect = os.getenv("CLIENT_REDIRECT", "")
# Check if we have an authorization code in the query parameters
auth_code = req.params.get('code', None)
auth_state = req.params.get('state', None)
# Variables to track authentication state
is_authenticated = False
osm_username = ""
osm_user_id = ""
# If we have an authorization code, exchange it for an access token
if auth_code:
logger.info(f"Received authorization code: {auth_code}")
try:
# Exchange authorization code for access token
token_url = "https://www.openstreetmap.org/oauth2/token"
token_data = {
"grant_type": "authorization_code",
"code": auth_code,
"redirect_uri": client_redirect,
"client_id": client_id,
"client_secret": client_secret
}
token_response = requests.post(token_url, data=token_data)
token_response.raise_for_status()
token_info = token_response.json()
access_token = token_info.get("access_token")
if access_token:
# Use access token to get user information
user_url = "https://api.openstreetmap.org/api/0.6/user/details.json"
headers = {"Authorization": f"Bearer {access_token}"}
user_response = requests.get(user_url, headers=headers)
user_response.raise_for_status()
user_info = user_response.json()
# Extract user information
user = user_info.get("user", {})
osm_username = user.get("display_name", "")
osm_user_id = user.get("id", "")
if osm_username:
is_authenticated = True
logger.info(f"User authenticated: {osm_username} (ID: {osm_user_id})")
else:
logger.error("Failed to get OSM username from user details")
else:
logger.error("Failed to get access token from token response")
except Exception as e:
logger.error(f"Error during OAuth2 token exchange: {e}")
# Create HTML response with form
# Start with the common HTML header
html_header = f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Report Traffic Jam - OpenEventDatabase</title>
<script src="https://unpkg.com/maplibre-gl@3.0.0/dist/maplibre-gl.js"></script>
<link href="https://unpkg.com/maplibre-gl@3.0.0/dist/maplibre-gl.css" rel="stylesheet" />
<style>
body {{
margin: 0;
padding: 20px;
font-family: Arial, sans-serif;
background-color: #f5f5f5;
}}
.container {{
max-width: 1000px;
margin: 0 auto;
background-color: white;
padding: 20px;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}}
h1 {{
margin-top: 0;
color: #333;
}}
.form-group {{
margin-bottom: 15px;
}}
label {{
display: block;
margin-bottom: 5px;
font-weight: bold;
}}
input[type="text"],
input[type="datetime-local"],
select,
textarea {{
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
font-size: 14px;
}}
.required:after {{
content: " *";
color: red;
}}
.form-row {{
display: flex;
gap: 15px;
}}
.form-row .form-group {{
flex: 1;
}}
button {{
background-color: #0078ff;
color: white;
border: none;
padding: 10px 15px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}}
button:hover {{
background-color: #0056b3;
}}
.note {{
font-size: 12px;
color: #666;
margin-top: 5px;
}}
#map {{
width: 100%;
height: 300px;
margin-bottom: 15px;
border-radius: 4px;
}}
#result {{
margin-top: 20px;
padding: 10px;
border-radius: 4px;
display: none;
}}
#result.success {{
background-color: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
}}
#result.error {{
background-color: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
}}
.nav-links {{
margin-bottom: 20px;
}}
.nav-links a {{
color: #0078ff;
text-decoration: none;
margin-right: 15px;
}}
.nav-links a:hover {{
text-decoration: underline;
}}
.geolocation-btn {{
background-color: #28a745;
margin-bottom: 10px;
}}
.geolocation-btn:hover {{
background-color: #218838;
}}
.loading {{
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(255,255,255,.3);
border-radius: 50%;
border-top-color: #fff;
animation: spin 1s ease-in-out infinite;
margin-right: 10px;
vertical-align: middle;
}}
@keyframes spin {{
to {{ transform: rotate(360deg); }}
}}
.auth-section {{
background-color: #f8f9fa;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
border: 1px solid #e9ecef;
}}
.auth-section h3 {{
margin-top: 0;
margin-bottom: 10px;
}}
.auth-info {{
display: flex;
align-items: center;
gap: 10px;
}}
.auth-info img {{
width: 40px;
height: 40px;
border-radius: 50%;
}}
.osm-login-btn {{
background-color: #7ebc6f;
display: inline-flex;
align-items: center;
gap: 8px;
}}
.osm-login-btn:hover {{
background-color: #6ba75e;
}}
.osm-logo {{
width: 20px;
height: 20px;
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm-1.66 4.322c.944 0 1.71.766 1.71 1.71s-.766 1.71-1.71 1.71-1.71-.766-1.71-1.71.766-1.71 1.71-1.71zm6.482 13.356H4.322v-1.71h12.5v1.71zm0-3.322H4.322v-1.71h12.5v1.71zm0-3.322H4.322v-1.71h12.5v1.71z"/></svg>');
background-repeat: no-repeat;
background-position: center;
}}
.issue-buttons {{
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 20px;
}}
.issue-button {{
flex: 1;
min-width: 120px;
text-align: center;
padding: 15px 10px;
border-radius: 5px;
font-weight: bold;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
}}
.issue-button i {{
font-size: 24px;
}}
.issue-button.pothole {{
background-color: #ff9800;
color: white;
}}
.issue-button.obstacle {{
background-color: #f44336;
color: white;
}}
.issue-button.vehicle {{
background-color: #2196f3;
color: white;
}}
.issue-button.danger {{
background-color: #9c27b0;
color: white;
}}
.issue-button:hover {{
transform: translateY(-3px);
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
}}
.view-saved-events {{
display: block;
text-align: center;
margin-top: 20px;
padding: 10px;
background-color: #f8f9fa;
border-radius: 5px;
text-decoration: none;
color: #333;
border: 1px solid #ddd;
}}
.view-saved-events:hover {{
background-color: #e9ecef;
}}
</style>
<script defer src="https://use.fontawesome.com/releases/v5.15.4/js/all.js"></script>
</head>
<body>
<div class="container">
<div class="nav-links">
<a href="/demo">&larr; Back to Map</a>
<a href="/">API Information</a>
<a href="/event">View Events</a>
<a href="/demo/view-events">View Saved Events</a>
</div>
<h1>Report Road Issue</h1>
<div class="auth-section">
<h3>OpenStreetMap Authentication</h3>
"""
# Add authentication section based on authentication status
if is_authenticated:
auth_section = f"""
<div class="auth-info">
<div>
<p>Logged in as <strong>{osm_username}</strong></p>
<p><a href="https://www.openstreetmap.org/user/{osm_username}" target="_blank">View OSM Profile</a></p>
<input type="hidden" id="osmUsername" value="{osm_username}">
<input type="hidden" id="osmUserId" value="{osm_user_id}">
</div>
</div>
"""
else:
auth_section = f"""
<p>Authenticate with your OpenStreetMap account to include your username in the traffic report.</p>
<a href="https://www.openstreetmap.org/oauth2/authorize?client_id={client_id}&redirect_uri={client_redirect}&response_type=code&scope={client_authorizations}" class="osm-login-btn button">
<span class="osm-logo"></span>
Login with OpenStreetMap
</a>
"""
# Add the rest of the HTML template
html_footer = """
</div>
<h3>Select Issue Type</h3>
<div class="issue-buttons">
<div class="issue-button pothole" onclick="fillForm('pothole')">
<i class="fas fa-dot-circle"></i>
Pothole
</div>
<div class="issue-button obstacle" onclick="fillForm('obstacle')">
<i class="fas fa-exclamation-triangle"></i>
Obstacle
</div>
<div class="issue-button vehicle" onclick="fillForm('vehicle')">
<i class="fas fa-car"></i>
Vehicle on Side
</div>
<div class="issue-button danger" onclick="fillForm('danger')">
<i class="fas fa-skull-crossbones"></i>
Danger
</div>
</div>
<button id="geolocateBtn" class="geolocation-btn">
<span id="geolocateSpinner" class="loading" style="display: none;"></span>
Get My Current Location
</button>
<form id="trafficForm">
<div class="form-group">
<label for="label" class="required">Issue Description</label>
<input type="text" id="label" name="label" placeholder="e.g., Large pothole on Highway A1" required>
</div>
<input type="hidden" id="issueType" name="issueType" value="traffic.jam">
<div class="form-row">
<div class="form-group">
<label for="severity" class="required">Severity</label>
<select id="severity" name="severity" required>
<option value="low">Low (Minor issue)</option>
<option value="medium" selected>Medium (Moderate issue)</option>
<option value="high">High (Severe issue)</option>
</select>
</div>
<div class="form-group">
<label for="cause">Additional Details</label>
<input type="text" id="cause" name="cause" placeholder="e.g., Size, specific location details">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="start" class="required">Report Time</label>
<input type="datetime-local" id="start" name="start" required value="">
</div>
<div class="form-group">
<label for="stop" class="required">Estimated Clear Time</label>
<input type="datetime-local" id="stop" name="stop" required value="">
</div>
</div>
<div class="form-group">
<label for="where">Road/Location Name</label>
<input type="text" id="where" name="where" placeholder="e.g., Highway A1, Main Street">
</div>
<div class="form-group">
<label class="required">Location</label>
<div id="map"></div>
<div class="note">Click on the map to set the issue location or use the "Get My Current Location" button</div>
</div>
<button type="submit">Report Issue</button>
</form>
<div id="result"></div>
<a href="/demo/view-events" class="view-saved-events">
<i class="fas fa-map-marked-alt"></i> View All Saved Events on Map
</a>
</div>
<script>
// Set default date values (current time and +1 hour)
function setDefaultDates() {
const now = new Date();
const nowISO = now.toISOString().slice(0, 16); // Format: YYYY-MM-DDThh:mm
// Set start time to current time
document.getElementById('start').value = nowISO;
// Set end time to current time + 1 hour
const oneHourLater = new Date(now.getTime() + 60 * 60 * 1000);
document.getElementById('stop').value = oneHourLater.toISOString().slice(0, 16);
}
// Call function to set default dates
setDefaultDates();
// Initialize the map
const map = new maplibregl.Map({
container: 'map',
style: 'https://demotiles.maplibre.org/style.json',
center: [2.2137, 46.2276], // Default center (center of metropolitan France)
zoom: 5
});
// Add navigation controls
map.addControl(new maplibregl.NavigationControl());
// Add marker for issue location
let marker = new maplibregl.Marker({
draggable: true,
color: '#ff3860' // Red color for traffic jam
});
// Add marker on map click
map.on('click', function(e) {
marker.setLngLat(e.lngLat).addTo(map);
});
// Function to fill the form based on issue type
function fillForm(issueType) {
const labelInput = document.getElementById('label');
const issueTypeInput = document.getElementById('issueType');
const severitySelect = document.getElementById('severity');
switch(issueType) {
case 'pothole':
labelInput.value = 'Pothole in the road';
issueTypeInput.value = 'road.hazard.pothole';
severitySelect.value = 'medium';
marker.setColor('#ff9800');
break;
case 'obstacle':
labelInput.value = 'Obstacle on the road';
issueTypeInput.value = 'road.hazard.obstacle';
severitySelect.value = 'high';
marker.setColor('#f44336');
break;
case 'vehicle':
labelInput.value = 'Vehicle on the side of the road';
issueTypeInput.value = 'road.hazard.vehicle';
severitySelect.value = 'low';
marker.setColor('#2196f3');
break;
case 'danger':
labelInput.value = 'Dangerous situation on the road';
issueTypeInput.value = 'road.hazard.danger';
severitySelect.value = 'high';
marker.setColor('#9c27b0');
break;
default:
labelInput.value = '';
issueTypeInput.value = 'traffic.jam';
severitySelect.value = 'medium';
marker.setColor('#ff3860');
}
}
// 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('Current location detected successfully', 'success');
// Try to get address using reverse geocoding
fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}`)
.then(response => response.json())
.then(data => {
if (data && data.address) {
// Extract road name or location
let location = '';
if (data.address.road) {
location = data.address.road;
if (data.address.city) {
location += `, ${data.address.city}`;
}
} else if (data.address.suburb) {
location = data.address.suburb;
if (data.address.city) {
location += `, ${data.address.city}`;
}
}
if (location) {
document.getElementById('where').value = location;
}
}
})
.catch(error => {
console.error('Error getting address:', error);
});
},
// Error callback
function(error) {
// Hide loading spinner
document.getElementById('geolocateSpinner').style.display = 'none';
document.getElementById('geolocateBtn').disabled = false;
// Show error message
let errorMsg = 'Unable to get your location. ';
switch(error.code) {
case error.PERMISSION_DENIED:
errorMsg += 'You denied the request for geolocation.';
break;
case error.POSITION_UNAVAILABLE:
errorMsg += 'Location information is unavailable.';
break;
case error.TIMEOUT:
errorMsg += 'The request to get your location timed out.';
break;
case error.UNKNOWN_ERROR:
errorMsg += 'An unknown error occurred.';
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('Geolocation is not supported by your browser', 'error');
}
});
// Handle form submission
document.getElementById('trafficForm').addEventListener('submit', function(e) {
e.preventDefault();
// Get form values
const label = document.getElementById('label').value;
const issueType = document.getElementById('issueType').value;
const severity = document.getElementById('severity').value;
const cause = document.getElementById('cause').value;
const start = document.getElementById('start').value;
const stop = document.getElementById('stop').value;
const where = document.getElementById('where').value;
// Check if marker is set
if (!marker.getLngLat()) {
showResult('Please set a location by clicking on the map or using the geolocation button', 'error');
return;
}
// Get marker coordinates
const lngLat = marker.getLngLat();
// Create event object
const event = {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [lngLat.lng, lngLat.lat]
},
properties: {
label: label,
type: 'unscheduled', // Road issues are typically unscheduled
what: issueType, // Category for the issue
'issue:severity': severity, // Custom property for severity
start: start,
stop: stop
}
};
// Add optional properties if provided
if (cause) {
event.properties['issue:details'] = cause;
}
if (where) {
event.properties.where = where;
}
// Add OSM username if authenticated
const osmUsername = document.getElementById('osmUsername');
if (osmUsername && osmUsername.value) {
event.properties['reporter:osm'] = osmUsername.value;
console.log(`Including OSM username in report: ${osmUsername.value}`);
}
// Save event to localStorage
saveEventToLocalStorage(event);
// Submit event to API
fetch('/event', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(event)
})
.then(response => {
if (response.ok) {
return response.json();
} else {
return response.text().then(text => {
throw new Error(text || response.statusText);
});
}
})
.then(data => {
// Update the event in localStorage with the server-assigned ID
if (data.id) {
updateEventInLocalStorage(event, data.id);
}
showResult(`Issue reported successfully with ID: ${data.id}`, 'success');
// Add links to view the event
const resultElement = document.getElementById('result');
resultElement.innerHTML += `
<p>
<a href="/event/${data.id}" target="_blank">View Report on Server</a> |
<a href="/demo/view-events" target="_blank">View Saved Reports</a> |
<a href="/demo">Back to Map</a>
</p>`;
// Reset form
document.getElementById('trafficForm').reset();
// Set default dates again
setDefaultDates();
// Remove marker
marker.remove();
})
.catch(error => {
showResult(`Error reporting issue: ${error.message}`, 'error');
});
});
// Function to save event to localStorage
function saveEventToLocalStorage(event) {
// Get existing events from localStorage
let savedEvents = JSON.parse(localStorage.getItem('oedb_events') || '[]');
// Add timestamp to event for sorting
event.timestamp = new Date().toISOString();
// Add event to array
savedEvents.push(event);
// Save back to localStorage
localStorage.setItem('oedb_events', JSON.stringify(savedEvents));
console.log('Event saved to localStorage:', event);
}
// Function to update event in localStorage with server ID
function updateEventInLocalStorage(event, serverId) {
// Get existing events from localStorage
let savedEvents = JSON.parse(localStorage.getItem('oedb_events') || '[]');
// Find the event by its timestamp (assuming it was just added)
const eventIndex = savedEvents.findIndex(e =>
e.timestamp === event.timestamp &&
e.geometry.coordinates[0] === event.geometry.coordinates[0] &&
e.geometry.coordinates[1] === event.geometry.coordinates[1]);
if (eventIndex !== -1) {
// Add server ID to the event
savedEvents[eventIndex].properties.id = serverId;
// Save back to localStorage
localStorage.setItem('oedb_events', JSON.stringify(savedEvents));
console.log('Event updated in localStorage with server ID:', serverId);
}
}
// Show result message
function showResult(message, type) {
const resultElement = document.getElementById('result');
resultElement.textContent = message;
resultElement.className = type;
resultElement.style.display = 'block';
// Scroll to result
resultElement.scrollIntoView({ behavior: 'smooth' });
}
</script>
</body>
</html>
"""
# Concatenate the HTML parts to form the complete template
html = html_header + auth_section + html_footer
# Set the response body and status
resp.text = html
resp.status = falcon.HTTP_200
logger.success("Successfully processed GET request to /demo/traffic")
except Exception as e:
logger.error(f"Error processing GET request to /demo/traffic: {e}")
resp.status = falcon.HTTP_500
resp.text = f"Error: {str(e)}"
# Create a global instance of DemoTrafficResource
demo_traffic = DemoTrafficResource()