add live page

This commit is contained in:
Tykayn 2025-09-26 11:57:54 +02:00 committed by tykayn
parent 114bcca24e
commit eb8c42d0c0
19 changed files with 2759 additions and 199 deletions

62
LANCER_SERVEUR.md Normal file
View file

@ -0,0 +1,62 @@
# Lancement du serveur OEDB avec WebSockets
Ce document explique comment lancer le serveur OEDB avec le support des WebSockets intégré via uWSGI.
## Prérequis
Assurez-vous d'avoir installé les dépendances nécessaires :
```bash
pip install uwsgi
```
## Lancement du serveur
Pour lancer le serveur avec le support WebSocket, vous avez plusieurs options :
### Utiliser la commande make
```bash
make websocket
```
Ou pour lancer en arrière-plan (mode démon) :
```bash
make websocket-daemon
```
### Lancer manuellement avec uWSGI
```bash
uwsgi --ini uwsgi.ini
```
Ces commandes démarreront le serveur sur le port 8080 avec le support WebSocket activé sur la route `/ws`.
## Configuration en production
En production, vous pouvez utiliser uWSGI avec Nginx. Voici un exemple de configuration Nginx :
```nginx
server {
listen 80;
server_name votre-domaine.com;
location / {
include uwsgi_params;
uwsgi_pass 127.0.0.1:8080;
}
location /ws {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
Cette configuration permet d'utiliser Nginx comme proxy inverse pour les requêtes HTTP normales et les connexions WebSocket.

View file

@ -8,3 +8,12 @@ LOGFILE ?= uwsgi.log
start:
python3 -m venv venv
. venv/bin/activate && pip install -r requirements.txt && uwsgi --http :$(PORT) --wsgi-file backend.py --callable app
websocket:
python3 -m venv venv
. venv/bin/activate && pip install -r requirements.txt && pip install websockets && uwsgi --ini uwsgi.ini
# Version en arrière-plan (démon)
websocket-daemon:
python3 -m venv venv
. venv/bin/activate && pip install -r requirements.txt && pip install websockets && uwsgi --ini uwsgi.ini --daemonize $(LOGFILE)

View file

@ -25,6 +25,8 @@ from oedb.resources.stats import StatsResource
from oedb.resources.search import EventSearch
from oedb.resources.root import root
from oedb.resources.demo import demo, demo_stats
from oedb.resources.live import live
from oedb.resources.rss import rss_latest, rss_by_family
from oedb.resources.event_form import event_form
def create_app():
@ -86,9 +88,13 @@ def create_app():
app.add_route('/demo/by-what', demo, suffix='by_what') # Handle events by type page
app.add_route('/demo/map-by-what', demo, suffix='map_by_what') # Handle map by event type page
app.add_route('/demo/edit/{id}', demo, suffix='edit') # Handle event editing page
app.add_route('/demo/by_id/{id}', demo, suffix='by_id') # Handle view single event by id
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/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
logger.success("Application initialized successfully")
return app

View file

@ -3,18 +3,35 @@
OSM Calendar Extractor for the OpenEventDatabase.
This script fetches events from the OpenStreetMap Calendar RSS feed
and adds them to the OpenEventDatabase if they don't already exist.
and adds them to the OpenEventDatabase via the API.
For events that don't have geographic coordinates in the RSS feed but have a link
to an OSM Calendar event (https://osmcal.org/event/...), the script will fetch
the iCal version of the event and extract the coordinates and location from there.
RSS Feed URL: https://osmcal.org/events.rss
API Endpoint: https://api.openeventdatabase.org/event
Usage:
python osm_cal.py [--max-events MAX_EVENTS] [--offset OFFSET]
Arguments:
--max-events MAX_EVENTS Maximum number of events to insert (default: 1)
--offset OFFSET Number of events to skip from the beginning of the RSS feed (default: 0)
Examples:
# Insert the first event from the RSS feed
python osm_cal.py
# Insert up to 5 events from the RSS feed
python osm_cal.py --max-events 5
# Skip the first 3 events and insert the next 2
python osm_cal.py --offset 3 --max-events 2
Environment Variables:
DB_NAME: The name of the database (default: "oedb")
DB_HOST: The hostname of the database server (default: "localhost")
DB_USER: The username to connect to the database (default: "")
POSTGRES_PASSWORD: The password to connect to the database (default: None)
These environment variables can be set in the system environment or in a .env file
in the project root directory.
These environment variables can be set in the system environment or in a .env file
in the project root directory.
"""
import json
@ -34,6 +51,8 @@ from oedb.utils.logging import logger
# RSS Feed URL for OSM Calendar
RSS_URL = "https://osmcal.org/events.rss"
# Base URL for OSM Calendar events
OSMCAL_EVENT_BASE_URL = "https://osmcal.org/event/"
def fetch_osm_calendar_data():
"""
@ -179,6 +198,73 @@ def parse_event_dates(description):
now = datetime.now()
return (now.isoformat(), (now + timedelta(days=1)).isoformat())
def fetch_ical_data(event_url):
"""
Fetch and parse iCal data for an OSM Calendar event.
Args:
event_url (str): The URL of the OSM Calendar event.
Returns:
tuple: A tuple containing (location_name, coordinates).
"""
try:
# Check if the URL is an OSM Calendar event URL
if not event_url.startswith(OSMCAL_EVENT_BASE_URL):
logger.warning(f"Not an OSM Calendar event URL: {event_url}")
return ("Unknown Location", [0, 0])
# Extract the event ID from the URL
event_id_match = re.search(r'event/(\d+)', event_url)
if not event_id_match:
logger.warning(f"Could not extract event ID from URL: {event_url}")
return ("Unknown Location", [0, 0])
event_id = event_id_match.group(1)
# Construct the iCal URL
ical_url = f"{OSMCAL_EVENT_BASE_URL}{event_id}.ics"
# Fetch the iCal content
logger.info(f"Fetching iCal data from: {ical_url}")
response = requests.get(ical_url)
if not response.ok:
logger.warning(f"Failed to fetch iCal data: {response.status_code}")
return ("Unknown Location", [0, 0])
# Parse the iCal content
ical_content = response.text
# Extract GEO information
geo_match = re.search(r'GEO:([-+]?\d+\.\d+);([-+]?\d+\.\d+)', ical_content)
if geo_match:
# GEO format is latitude;longitude
latitude = float(geo_match.group(2))
longitude = float(geo_match.group(1))
coordinates = [longitude, latitude] # GeoJSON uses [longitude, latitude]
logger.info(f"Extracted coordinates from iCal: {coordinates}")
else:
logger.warning(f"No GEO information found in iCal data for event: {event_id}")
coordinates = [0, 0]
# Extract LOCATION information
location_match = re.search(r'LOCATION:(.+?)(?:\r\n|\n|\r)', ical_content)
if location_match:
location_name = location_match.group(1).strip()
# Unescape backslash-escaped characters (e.g., \, becomes ,)
location_name = re.sub(r'\\(.)', r'\1', location_name)
logger.info(f"Extracted location from iCal: {location_name}")
else:
logger.warning(f"No LOCATION information found in iCal data for event: {event_id}")
location_name = "Unknown Location"
return (location_name, coordinates)
except Exception as e:
logger.error(f"Error fetching or parsing iCal data: {e}")
return ("Unknown Location", [0, 0])
def extract_location(description):
"""
Extract location information from the event description.
@ -239,9 +325,25 @@ def create_event(item):
# Parse dates from the description
start_date, end_date = parse_event_dates(description)
# Extract location information
# Extract location information from the description
location_name, coordinates = extract_location(description)
# If we don't have coordinates and the link is to an OSM Calendar event,
# try to get coordinates and location from the iCal file
if coordinates == [0, 0] and link and link.startswith(OSMCAL_EVENT_BASE_URL):
logger.info(f"No coordinates found in description, trying to get from iCal: {link}")
ical_location_name, ical_coordinates = fetch_ical_data(link)
# Use iCal coordinates if available
if ical_coordinates != [0, 0]:
coordinates = ical_coordinates
logger.info(f"Using coordinates from iCal: {coordinates}")
# Use iCal location name if available and better than what we have
if ical_location_name != "Unknown Location":
location_name = ical_location_name
logger.info(f"Using location name from iCal: {location_name}")
# Create a descriptive label
label = title
@ -325,7 +427,7 @@ def event_exists(db, properties):
def submit_event(event):
"""
Submit an event to the OpenEventDatabase.
Submit an event to the OpenEventDatabase using the API.
Args:
event: A GeoJSON Feature representing the event.
@ -334,129 +436,53 @@ def submit_event(event):
bool: True if the event was successfully submitted, False otherwise.
"""
try:
# Connect to the database
db = db_connect()
# Extract event properties
# Extract event properties for logging
properties = event['properties']
# Check if the event already exists
if event_exists(db, properties):
logger.info(f"Skipping event '{properties.get('label')}' as it already exists")
db.close()
return False
# API endpoint for OpenEventDatabase
api_url = "https://api.openeventdatabase.org/event"
cur = db.cursor()
geometry = json.dumps(event['geometry'])
# Make the API request
logger.info(f"Submitting event '{properties.get('label')}' to API")
response = requests.post(
api_url,
headers={"Content-Type": "application/json"},
data=json.dumps(event)
)
print('event: ', event)
# Insert the geometry into the geo table
cur.execute("""
INSERT INTO geo
SELECT geom, md5(st_astext(geom)) as hash, st_centroid(geom) as geom_center FROM
(SELECT st_setsrid(st_geomfromgeojson(%s),4326) as geom) as g
WHERE ST_IsValid(geom)
ON CONFLICT DO NOTHING RETURNING hash;
""", (geometry,))
# Check if the request was successful
if response.status_code == 200 or response.status_code == 201:
# Parse the response to get the event ID
response_data = response.json()
event_id = response_data.get('id')
# Get the geometry hash
hash_result = cur.fetchone()
if hash_result is None:
# If the hash is None, check if the geometry already exists in the database
cur.execute("""
SELECT hash FROM geo
WHERE hash = md5(st_astext(st_setsrid(st_geomfromgeojson(%s),4326)));
""", (geometry,))
existing_hash = cur.fetchone()
if existing_hash:
# Geometry already exists in the database, use its hash
geo_hash = existing_hash[0]
logger.info(f"Using existing geometry with hash: {geo_hash}")
if event_id:
logger.success(f"Event created with ID: {event_id}")
return True
else:
# Geometry doesn't exist, try to insert it directly
cur.execute("""
SELECT md5(st_astext(geom)) as hash,
ST_IsValid(geom),
ST_IsValidReason(geom) from (SELECT st_setsrid(st_geomfromgeojson(%s),4326) as geom) as g;
""", (geometry,))
hash_result = cur.fetchone()
if hash_result is None or not hash_result[1]:
logger.error(f"Invalid geometry for event: {properties.get('label')}")
if hash_result and len(hash_result) > 2:
logger.error(f"Reason: {hash_result[2]}")
db.close()
return False
geo_hash = hash_result[0]
# Now insert the geometry explicitly
cur.execute("""
INSERT INTO geo (geom, hash, geom_center)
VALUES (
st_setsrid(st_geomfromgeojson(%s),4326),
%s,
st_centroid(st_setsrid(st_geomfromgeojson(%s),4326))
)
ON CONFLICT (hash) DO NOTHING;
""", (geometry, geo_hash, geometry))
# Verify the geometry was inserted
cur.execute("SELECT 1 FROM geo WHERE hash = %s", (geo_hash,))
if cur.fetchone() is None:
logger.error(f"Failed to insert geometry with hash: {geo_hash}")
db.close()
return False
logger.info(f"Inserted new geometry with hash: {geo_hash}")
logger.warning(f"Event created but no ID returned in response")
return True
else:
geo_hash = hash_result[0]
# Determine the bounds for the time range
bounds = '[]' if properties['start'] == properties['stop'] else '[)'
# Insert the event into the database
cur.execute("""
INSERT INTO events (events_type, events_what, events_when, events_tags, events_geo)
VALUES (%s, %s, tstzrange(%s, %s, %s), %s, %s)
ON CONFLICT DO NOTHING RETURNING events_id;
""", (
properties['type'],
properties['what'],
properties['start'],
properties['stop'],
bounds,
json.dumps(properties),
geo_hash
))
# Get the event ID
event_id = cur.fetchone()
if event_id:
logger.success(f"Event created with ID: {event_id[0]}")
db.commit()
db.close()
return True
else:
logger.warning(f"Failed to create event: {properties.get('label')}")
db.close()
logger.warning(f"Failed to create event: {properties.get('label')}. Status code: {response.status_code}")
logger.warning(f"Response: {response.text}")
return False
except Exception as e:
logger.error(f"Error submitting event: {e}")
return False
def main():
def main(max_events=1, offset=0):
"""
Main function to fetch OSM Calendar events and add them to the database.
Main function to fetch OSM Calendar events and add them to the OpenEventDatabase API.
Args:
max_events (int): Maximum number of events to insert (default: 1)
offset (int): Number of events to skip from the beginning of the RSS feed (default: 0)
The function will exit if the .env file doesn't exist, as it's required
for database connection parameters.
for environment variables.
"""
logger.info("Starting OSM Calendar extractor")
logger.info(f"Starting OSM Calendar extractor (max_events={max_events}, offset={offset})")
# Load environment variables from .env file and check if it exists
if not load_env_from_file():
@ -472,20 +498,42 @@ def main():
logger.warning("No events found, exiting")
return
# Apply offset and limit
if offset >= len(items):
logger.warning(f"Offset {offset} is greater than or equal to the number of events {len(items)}, no events to process")
return
# Slice the items list according to offset and max_events
items_to_process = items[offset:offset + max_events]
logger.info(f"Processing {len(items_to_process)} events (offset={offset}, max_events={max_events})")
# Process each item
success_count = 0
for item in items:
for item in items_to_process:
# Create an event from the item
event = create_event(item)
if not event:
continue
# Submit the event to the database
# Submit the event to the API
if submit_event(event):
success_count += 1
logger.success(f"Successfully added {success_count} out of {len(items)} events to the database")
logger.success(f"Successfully added {success_count} out of {len(items_to_process)} events to the OpenEventDatabase")
if __name__ == "__main__":
main()
import argparse
# Set up command line argument parsing
parser = argparse.ArgumentParser(description='OSM Calendar Extractor for the OpenEventDatabase')
parser.add_argument('--max-events', type=int, default=1,
help='Maximum number of events to insert (default: 1)')
parser.add_argument('--offset', type=int, default=0,
help='Number of events to skip from the beginning of the RSS feed (default: 0)')
# Parse arguments
args = parser.parse_args()
# Run the main function with the provided arguments
main(max_events=args.max_events, offset=args.offset)

View file

@ -59,6 +59,8 @@ class DemoResource:
<title>Edit Event - 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" />
<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">
<style>
body {{
@ -231,7 +233,10 @@ class DemoResource:
<div class="note">Click on the map to set the event location</div>
</div>
<button type="submit">Update Event</button>
<div style="display: flex; gap: 10px;">
<button type="submit">Update Event</button>
<button type="button" id="deleteButton" style="background-color: #dc3545;">Delete Event</button>
</div>
</form>
<div id="result"></div>
@ -406,6 +411,50 @@ class DemoResource:
showResult(`Error: ${{error.message}}`, 'error');
}});
}});
// Handle delete button click
document.getElementById('deleteButton').addEventListener('click', function() {{
// Get event ID
const eventId = document.getElementById('eventId').value;
// Show confirmation dialog
if (confirm('Are you sure you want to delete this event? This action cannot be undone.')) {{
// Submit delete request to API
fetch(`/event/${{eventId}}`, {{
method: 'DELETE',
headers: {{
'Content-Type': 'application/json'
}}
}})
.then(response => {{
if (response.ok) {{
showResult('Event deleted successfully', 'success');
// Add link to go back to map
const resultElement = document.getElementById('result');
resultElement.innerHTML += `<p><a href="/demo">Back to Map</a></p>`;
// Disable form controls
const formElements = document.querySelectorAll('#eventForm input, #eventForm select, #eventForm button');
formElements.forEach(element => {{
element.disabled = true;
}});
// Redirect to demo page after 2 seconds
setTimeout(() => {{
window.location.href = '/demo';
}}, 2000);
}} else {{
return response.text().then(text => {{
throw new Error(text || response.statusText);
}});
}}
}})
.catch(error => {{
showResult(`Error deleting event: ${{error.message}}`, 'error');
}});
}}
}});
</script>
</body>
</html>
@ -1940,5 +1989,76 @@ class DemoResource:
"""
return demo_view_events.on_get(req, resp)
def on_get_by_id(self, req, resp, id):
"""
Handle GET requests to /demo/by_id/{id}.
Show a map with the event location and a table of its properties.
"""
import requests
logger.info(f"Processing GET request to /demo/by_id/{id}")
try:
resp.content_type = 'text/html'
r = requests.get(f"https://api.openeventdatabase.org/event/{id}")
r.raise_for_status()
feature = r.json()
html = f"""
<!DOCTYPE html>
<html lang=\"fr\">
<head>
<meta charset=\"UTF-8\">
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">
<title>Event {id} - 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; font-family: Arial, sans-serif; }}
.container {{ max-width: 1100px; margin: 0 auto; padding: 12px; }}
#map {{ width:100%; height: 360px; border:1px solid #ddd; border-radius:4px; }}
table {{ width:100%; border-collapse: collapse; margin-top:12px; }}
th, td {{ padding: 6px 8px; border-bottom: 1px solid #eee; text-align:left; }}
th {{ background:#f9fafb; }}
.nav a {{ margin-right: 10px; }}
</style>
</head>
<body>
<div class=\"container\">
<div class=\"nav\">
<a href=\"/demo\">← Retour à la démo</a>
<a href=\"/demo/traffic\">Signaler trafic</a>
<a href=\"/demo/view-events\">Voir événements</a>
</div>
<h1>Évènement {id}</h1>
<div id=\"map\"></div>
<table>
<thead><tr><th>Clé</th><th>Valeur</th></tr></thead>
<tbody>
{''.join([f'<tr><td>{k}</td><td>{(v if not isinstance(v, dict) else str(v))}</td></tr>' for k,v in sorted((feature.get('properties') or {{}}).items())])}
</tbody>
</table>
</div>
<script>
const f = {feature};
const map = new maplibregl.Map({
container: 'map',
style: 'https://tiles.openfreemap.org/styles/liberty',
center: f.geometry && f.geometry.coordinates ? f.geometry.coordinates : [2.3522,48.8566],
zoom: 12
});
map.addControl(new maplibregl.NavigationControl());
if (f.geometry && f.geometry.type === 'Point') {
new maplibregl.Marker().setLngLat(f.geometry.coordinates).addTo(map);
}
</script>
</body>
</html>
"""
resp.text = html
resp.status = falcon.HTTP_200
logger.success(f"Successfully processed GET request to /demo/by_id/{id}")
except Exception as e:
logger.error(f"Error processing GET request to /demo/by_id/{id}: {e}")
resp.status = falcon.HTTP_500
resp.text = f"Error: {str(e)}"
# Create a global instance of DemoResource
demo = DemoResource()

View file

@ -50,6 +50,8 @@ class DemoMainResource:
<link rel="stylesheet" href="/static/demo_styles.css">
<script defer src="https://use.fontawesome.com/releases/v5.15.4/js/all.js"></script>
<script src="/static/demo_auth.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script src="/static/social.js"></script>
<style>
body { margin: 0; padding: 0; font-family: Arial, sans-serif; }
.logo{
@ -243,72 +245,43 @@ class DemoMainResource:
<h2>
<img src="/static/oedb.png" class="logo" />
OpenEventDatabase Demo</h2>
<p>This map shows current events from the OpenEventDatabase.</p>
<!-- Event addition buttons - always visible -->
<p><a href="/demo/traffic" class="add-event-btn" style="display: block; text-align: center; margin-top: 15px; padding: 8px; background-color: #0078ff; color: white; border-radius: 4px; font-weight: bold;">+ Traffic event</a></p>
<p><a href="/demo/add" class="add-event-btn" style="display: block; text-align: center; margin-top: 15px; padding: 8px; background-color: #0078ff; color: white; border-radius: 4px; font-weight: bold;">+ Any Event</a></p>
<p><a href="/demo/live" class="live-event-btn" style="display: block; text-align: center; margin-top: 15px; padding: 8px; background-color: #0078ff; color: white; border-radius: 4px; font-weight: bold;"> Live</a></p>
<!-- Collapsible information section -->
<h3 id="info_panel_header" class="collapsible-header">Information Panel <span class="toggle-icon"></span></h3>
<div id="info_panel_content" class="collapsible-content">
<!-- User Information Panel -->
<div id="user-info-panel" class="user-info-panel" style="display: none; background-color: #f5f5f5; border-radius: 4px; padding: 10px; margin: 10px 0; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<h3 style="margin-top: 0; margin-bottom: 10px; color: #333;">User Information</h3>
<p>Username: <strong id="username-display">Anonymous</strong></p>
<p>Points: <span id="points-display" style="font-weight: bold; color: #0078ff;">0</span></p>
<br/>
<br/>
<!-- Filtres pour les événements -->
<div class="event-filters" id="filters_panel" style="margin-top: 15px; padding: 10px; background-color: #f5f5f5; border-radius: 4px; display:none;">
<h3 id="filters_header" style="margin-top: 0; color: #0078ff; cursor:pointer;">Filtres</h3>
<div style="margin-top: 10px;">
<label style="display: block; margin-bottom: 5px;">Type d'événement:</label>
<select id="event-type-filter" style="width: 100%; padding: 5px; border-radius: 4px; border: 1px solid #ddd;">
<option value="">Tous</option>
<option value="traffic">Traffic</option>
<option value="weather">Météo</option>
<option value="gathering">Rassemblement</option>
<option value="incident">Incident</option>
</select>
</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">
<li><a href="/" >/ - API Information</a></li>
<li><a href="/event" >/event - Get Events</a></li>
<li><a href="/stats" >/stats - Database Statistics</a></li>
</ul>
<h3 id="demo_pages_list_header">Demo Pages:</h3>
<ul id="demo_pages_list">
<li><a href="/demo/search" >/demo/search - Advanced Search</a></li>
<li><a href="/demo/by-what" >/demo/by-what - Events by Type</a></li>
<li><a href="/demo/map-by-what" >/demo/map-by-what - Map by Event Type</a></li>
<li><a href="/demo/traffic" >/demo/traffic - Report Traffic Jam</a></li>
<li><a href="/demo/view-events" >/demo/view-events - View Saved Events</a></li>
<li><a href="/event?what=music" >Search Music Events</a></li>
<li><a href="/event?what=sport" >Search Sport Events</a></li>
</ul>
<p class="sources" style="text-align: center; margin-top: 10px;">
<a href="https://source.cipherbliss.com/tykayn/oedb-backend" title="View Source Code on Cipherbliss" style="font-size: 24px;">
<i class="fas fa-code-branch"></i> sources
</a>
</p>
<div style="margin-top:12px; display:flex; align-items:center; gap:8px;">
<input type="checkbox" id="autoRefreshToggle" checked>
<label for="autoRefreshToggle" style="margin:0;">Rafraîchissement automatique (30s)</label>
</div>
</div>
<div class="event-filters" style="margin-top: 10px; padding: 10px; background-color: #fff; border: 1px solid #e5e7eb; border-radius: 4px;">
<h3 style="margin-top: 0; color: #0078ff;">Histogramme des évènements</h3>
<canvas id="eventsHistogram" style="width:100%; height:220px;"></canvas>
</div>
</div>
<script>
@ -320,6 +293,8 @@ class DemoMainResource:
const demoPagesList = document.getElementById('demo_pages_list');
const infoPanelHeader = document.getElementById('info_panel_header');
const infoPanelContent = document.getElementById('info_panel_content');
const filtersPanel = document.getElementById('filters_panel');
const filtersHeader = document.getElementById('filters_header');
// Fonction pour basculer l'affichage d'une liste ou section
function toggleList(header, list) {
@ -343,8 +318,98 @@ class DemoMainResource:
toggleList(endpointsHeader, endpointsList);
toggleList(demoPagesHeader, demoPagesList);
toggleList(infoPanelHeader, infoPanelContent);
// Toggle pour le panneau de filtres via le titre "Filtres"
if (filtersHeader && filtersPanel) {
filtersHeader.addEventListener('click', function() {
if (filtersPanel.style.display === 'none' || filtersPanel.style.display === '') {
filtersPanel.style.display = 'block';
} else {
filtersPanel.style.display = 'none';
}
});
}
});
// Variable globale pour stocker les marqueurs d'événements
window.eventMarkers = [];
function addEventsToMap(geojsonData) {
if (!geojsonData || !geojsonData.features) return;
geojsonData.features.forEach(feature => {
// Créer un élément HTML pour le marqueur
const el = document.createElement('div');
el.className = 'event-marker';
el.style.width = '20px';
el.style.height = '20px';
el.style.borderRadius = '50%';
// Déterminer la couleur selon le type d'événement
let color = '#0078ff';
const eventType = feature.properties.what;
if (eventType) {
if (eventType.includes('traffic')) color = '#F44336';
else if (eventType.includes('weather')) color = '#4CAF50';
else if (eventType.includes('gathering')) color = '#FF9800';
else if (eventType.includes('incident')) color = '#9C27B0';
}
el.style.backgroundColor = color;
el.style.border = '2px solid white';
el.style.boxShadow = '0 0 5px rgba(0,0,0,0.3)';
el.style.cursor = 'pointer';
// Créer le contenu de la popup
const popupContent = createEventPopupContent(feature);
// Créer la popup
const popup = new maplibregl.Popup({
closeButton: true,
closeOnClick: true
}).setHTML(popupContent);
// Créer et ajouter le marqueur
const marker = new maplibregl.Marker(el)
.setLngLat(feature.geometry.coordinates)
.setPopup(popup)
.addTo(map);
// Ajouter à la liste des marqueurs
window.eventMarkers.push(marker);
});
}
function createEventPopupContent(feature) {
const properties = feature.properties;
// Extraire les informations principales
const title = 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
return `
<div class="event-popup">
<h3 style="margin-top: 0; color: #0078ff;">${title}</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>
</div>
`;
}
function formatDate(dateString) {
try {
const date = new Date(dateString);
return date.toLocaleString();
} catch (e) {
return dateString;
}
}
// Map style URLs
const mapStyles = {
default: 'https://tiles.openfreemap.org/styles/liberty',
@ -401,10 +466,50 @@ class DemoMainResource:
// Style switcher functionality
let currentStyle = 'default';
let eventsData = null;
let histogramChart = null;
let refreshIntervalId = null;
// Array to store markers so they can be removed on refresh
// Store markers with their family/type for filtering
let currentMarkers = [];
function getFamily(what) {
if (!what) return 'unknown';
const s = String(what);
const dot = s.indexOf('.');
return dot === -1 ? s : s.slice(0, dot);
}
function applyTypeFilter() {
const sel = document.getElementById('event-type-filter');
const val = sel ? sel.value : '';
currentMarkers.forEach(rec => {
const el = rec.marker.getElement();
if (!val) {
el.style.display = '';
} else {
el.style.display = (rec.family === val) ? '' : 'none';
}
});
// Also filter vector circle layer if present
try {
if (!val) {
map.setFilter('events-circle', null);
} else {
const len = val.length;
// Show features where what starts with selected family
const filter = [
"any",
["!", ["has", "what"]],
["==", ["slice", ["get", "what"], 0, len], val]
];
map.setFilter('events-circle', filter);
}
} catch (e) {
// Layer may not be ready yet; ignore
}
}
// Fetch events when the map is loaded and every 30 seconds thereafter
@ -413,11 +518,19 @@ class DemoMainResource:
fetchEvents();
// Set up interval to fetch events every 30 seconds
setInterval(fetchEvents, 30000);
setupAutoRefresh();
console.log('Event refresh interval set: events will update every 30 seconds');
});
function setupAutoRefresh() {
const cb = document.getElementById('autoRefreshToggle');
const start = () => { if (!refreshIntervalId) { refreshIntervalId = setInterval(fetchEvents, 30000); } };
const stop = () => { if (refreshIntervalId) { clearInterval(refreshIntervalId); refreshIntervalId = null; } };
if (cb && cb.checked) start(); else stop();
if (cb) cb.addEventListener('change', () => { if (cb.checked) start(); else stop(); });
}
// Function to fetch events from the API
function fetchEvents() {
// Fetch events from the API - using the local API endpoint
@ -427,6 +540,8 @@ class DemoMainResource:
if (data.features && data.features.length > 0) {
// Add events to the map
addEventsToMap(data);
// Render histogram for retrieved events
try { renderEventsHistogram(data.features); } catch(e) { console.warn('Histogram error', e); }
// Fit map to events bounds
fitMapToBounds(data);
@ -441,11 +556,48 @@ class DemoMainResource:
});
}
function bucket10(dateStr) {
const d = new Date(dateStr);
if (isNaN(d.getTime())) return null;
d.setSeconds(0,0);
const m = d.getMinutes();
d.setMinutes(m - (m % 10));
return d.toISOString();
}
function renderEventsHistogram(features) {
const counts = new Map();
features.forEach(f => {
const p = f.properties || {};
const t = p.createdate || p.start || p.lastupdate;
const b = bucket10(t);
if (!b) return;
counts.set(b, (counts.get(b) || 0) + 1);
});
const labels = Array.from(counts.keys()).sort();
const data = labels.map(k => counts.get(k));
const ctx = document.getElementById('eventsHistogram');
if (!ctx) return;
if (histogramChart) histogramChart.destroy();
histogramChart = new Chart(ctx, {
type: 'bar',
data: { labels, datasets: [{ label:'Évènements / 10 min', data, backgroundColor:'#3b82f6' }] },
options: {
// maintainAspectRatio: false,
scales: {
x: { ticks: { callback: (v,i) => new Date(labels[i]).toLocaleString() } },
y: { beginAtZero: true }
}
}
});
}
// Function to add events to the map
function addEventsToMap(geojson) {
// Remove all existing markers
if (currentMarkers.length > 0) {
currentMarkers.forEach(marker => marker.remove());
currentMarkers.forEach(rec => rec.marker.remove());
currentMarkers = [];
console.log('Removed existing markers');
}
@ -569,7 +721,12 @@ class DemoMainResource:
let iconColor = '#0078ff'; // Default color
// Map event types to icons
if (eventType.startsWith('weather')) {
// Travaux detection (label or what)
const labelLower = String(properties.label || '').toLowerCase();
if (labelLower.includes('travaux') || eventType.includes('roadwork')) {
iconClass = 'hard-hat';
iconColor = '#ff9800';
} else if (eventType.startsWith('weather')) {
iconClass = 'cloud';
iconColor = '#00d1b2'; // Teal
} else if (eventType.startsWith('traffic')) {
@ -608,9 +765,12 @@ class DemoMainResource:
.setPopup(popup)
.addTo(map);
// Store marker reference for later removal
currentMarkers.push(marker);
// Store marker with its family for filtering
currentMarkers.push({ marker, family: getFamily(eventType) });
});
// Re-apply current filter on fresh markers
applyTypeFilter();
}
// Function to calculate relative time (e.g., "2 hours 30 minutes ago")
@ -862,6 +1022,12 @@ class DemoMainResource:
// Initialisation des gestionnaires d'événements pour le toast d'erreur
initErrorToast();
// Hook filters
const typeSel = document.getElementById('event-type-filter');
const applyBtn = document.getElementById('apply-filters');
if (typeSel) typeSel.addEventListener('change', applyTypeFilter);
if (applyBtn) applyBtn.addEventListener('click', applyTypeFilter);
});
// Fonction pour initialiser le toast d'erreur
@ -892,7 +1058,21 @@ class DemoMainResource:
}, 6000);
}
// Initialiser automatiquement le mode social quand la carte est chargée
map.on('load', function() {
// Vérifier si l'objet social existe
if (window.oedbSocial) {
console.log('Initialisation automatique du mode social...');
setTimeout(() => {
// Trouver le bouton d'activation du mode social et simuler un clic
const socialButton = document.querySelector('.toggle-social-btn');
if (socialButton) {
socialButton.click();
console.log('Mode social activé automatiquement');
}
}, 2000); // Attendre 2 secondes pour que tout soit bien chargé
}
});
</script>
</body>
</html>

View file

@ -108,6 +108,9 @@ button:hover {
.nav-links {
margin-bottom: 20px;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.nav-links a {
@ -120,6 +123,99 @@ button:hover {
text-decoration: underline;
}
/* Navigation container */
.nav-container {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
}
/* Hamburger menu for mobile */
.menu-toggle {
display: none;
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #0078ff;
padding: 5px;
}
/* Responsive styles */
@media (max-width: 768px) {
.nav-container {
flex-direction: column;
align-items: flex-start;
}
.menu-toggle {
display: block;
align-self: flex-end;
margin-bottom: 10px;
}
.nav-links {
display: none;
flex-direction: column;
width: 100%;
}
.nav-links.active {
display: flex;
}
.nav-links a {
padding: 10px 0;
border-bottom: 1px solid #eee;
margin-right: 0;
}
}
/* Collapsible panel styles */
.collapsible-panel {
margin-bottom: 15px;
}
.collapsible-header {
background-color: #f8f9fa;
padding: 10px 15px;
border-radius: 5px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
border: 1px solid #e9ecef;
}
.collapsible-header h3 {
margin: 0;
font-size: 16px;
}
.collapsible-header .toggle-icon {
transition: transform 0.3s ease;
}
.collapsible-header.active .toggle-icon {
transform: rotate(180deg);
}
.collapsible-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
border-left: 1px solid #e9ecef;
border-right: 1px solid #e9ecef;
border-bottom: 1px solid #e9ecef;
border-radius: 0 0 5px 5px;
}
.collapsible-content.active {
max-height: 1000px;
}
/* Authentication section styles */
.auth-section {
background-color: #f8f9fa;
@ -524,3 +620,9 @@ select:invalid {
float: left;
width: 130px;
}
button{
padding: 1rem 0.5rem;
border-radius: 5px;
background-color: #79a2d1;
}

View file

@ -0,0 +1 @@
# Ce fichier est un MP3 binaire qui contiendra le son 'pouet pouet'.

View file

@ -0,0 +1,750 @@
// Fonctionnalités sociales pour OEDB
class OEDBSocial {
constructor() {
this.socket = null;
this.position = null;
this.username = '';
this.friends = [];
this.markers = {};
this.map = null;
this.lastPouetTime = 0;
this.showOnlyFriends = false;
// Charger les amis depuis le localStorage
this.loadFriends();
// Boutons pour l'interface sociale
this.createSocialUI();
}
// Initialiser la connexion WebSocket
init(map, username) {
this.map = map;
this.username = username || localStorage.getItem('oedb_social_username') || '';
if (!this.username) {
this.promptForUsername();
} else {
localStorage.setItem('oedb_social_username', this.username);
}
// Créer la connexion WebSocket
// Utiliser l'URL relative au serveur actuel
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const wsUrl = `${wsProtocol}${window.location.host}/ws`;
this.socket = new WebSocket(wsUrl);
this.socket.onopen = () => {
console.log('Connexion WebSocket établie');
this.startSendingPosition();
};
this.socket.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleSocketMessage(data);
};
this.socket.onclose = () => {
console.log('Connexion WebSocket fermée');
// Tentative de reconnexion après 5 secondes
setTimeout(() => this.init(this.map, this.username), 5000);
};
this.socket.onerror = (error) => {
console.error('Erreur WebSocket:', error);
this.showToast(`Erreur de connexion au serveur WebSocket. Vérifiez que le serveur est en cours d'exécution sur le port 8765.`, 'error');
};
}
// Demander le pseudo à l'utilisateur
promptForUsername() {
// Créer une boîte de dialogue modale pour demander le pseudo
const modalOverlay = document.createElement('div');
modalOverlay.className = 'modal-overlay';
modalOverlay.style.position = 'fixed';
modalOverlay.style.top = '0';
modalOverlay.style.left = '0';
modalOverlay.style.width = '100%';
modalOverlay.style.height = '100%';
modalOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
modalOverlay.style.zIndex = '1000';
modalOverlay.style.display = 'flex';
modalOverlay.style.justifyContent = 'center';
modalOverlay.style.alignItems = 'center';
const modalContent = document.createElement('div');
modalContent.className = 'modal-content';
modalContent.style.backgroundColor = '#fff';
modalContent.style.padding = '20px';
modalContent.style.borderRadius = '5px';
modalContent.style.maxWidth = '400px';
modalContent.style.width = '80%';
const title = document.createElement('h3');
title.textContent = 'Choisissez un pseudo';
title.style.marginBottom = '15px';
const form = document.createElement('form');
form.onsubmit = (e) => {
e.preventDefault();
const input = document.getElementById('username-input');
const username = input.value.trim();
if (username) {
this.username = username;
localStorage.setItem('oedb_social_username', username);
document.body.removeChild(modalOverlay);
this.startSendingPosition();
}
};
const input = document.createElement('input');
input.type = 'text';
input.id = 'username-input';
input.placeholder = 'Votre pseudo';
input.style.width = '100%';
input.style.padding = '8px';
input.style.marginBottom = '15px';
input.style.borderRadius = '4px';
input.style.border = '1px solid #ddd';
const button = document.createElement('button');
button.type = 'submit';
button.textContent = 'Valider';
button.style.padding = '8px 15px';
button.style.backgroundColor = '#0078ff';
button.style.color = 'white';
button.style.border = 'none';
button.style.borderRadius = '4px';
button.style.cursor = 'pointer';
form.appendChild(input);
form.appendChild(button);
modalContent.appendChild(title);
modalContent.appendChild(form);
modalOverlay.appendChild(modalContent);
document.body.appendChild(modalOverlay);
input.focus();
}
// Commencer à envoyer sa position
startSendingPosition() {
if (!this.username || !this.socket) return;
// Obtenir la position actuelle
this.getCurrentPosition();
// Mettre à jour la position toutes les 5 secondes
setInterval(() => {
this.getCurrentPosition();
}, 5000);
}
// Obtenir la position GPS actuelle
getCurrentPosition() {
if (navigator.geolocation && this.socket && this.socket.readyState === WebSocket.OPEN) {
navigator.geolocation.getCurrentPosition(
(position) => {
this.position = {
lat: position.coords.latitude,
lng: position.coords.longitude
};
// Envoyer la position au serveur WebSocket
this.socket.send(JSON.stringify({
type: 'position',
username: this.username,
position: this.position,
timestamp: new Date().toISOString(),
showOnlyToFriends: this.showOnlyFriends
}));
},
(error) => {
console.error('Erreur lors de la récupération de la position:', error);
}
);
}
}
// Traiter les messages reçus par WebSocket
handleSocketMessage(data) {
switch (data.type) {
case 'position':
this.updateUserPosition(data);
break;
case 'pouet':
this.receivePouet(data);
break;
case 'friendRequest':
this.receiveFriendRequest(data);
break;
case 'users':
this.updateAllUsers(data.users);
break;
}
}
// Mettre à jour la position d'un utilisateur sur la carte
updateUserPosition(data) {
// Ignorer les mises à jour de notre propre position
if (data.username === this.username) return;
// Vérifier si l'utilisateur est visible uniquement pour ses amis
if (data.showOnlyToFriends && !this.friends.includes(data.username)) return;
// Supprimer l'ancien marqueur s'il existe
if (this.markers[data.username]) {
this.markers[data.username].remove();
}
// Créer un élément HTML personnalisé pour le marqueur
const el = document.createElement('div');
el.className = 'user-marker';
// Styles de base pour le marqueur
el.style.width = '40px';
el.style.height = '40px';
el.style.borderRadius = '50%';
el.style.backgroundColor = this.friends.includes(data.username) ? '#4CAF50' : '#0078ff';
el.style.border = '2px solid white';
el.style.boxShadow = '0 0 5px rgba(0,0,0,0.3)';
el.style.display = 'flex';
el.style.justifyContent = 'center';
el.style.alignItems = 'center';
el.style.color = 'white';
el.style.fontWeight = 'bold';
el.style.fontSize = '12px';
el.style.cursor = 'pointer';
// Ajouter les initiales de l'utilisateur
const initials = data.username.substring(0, 2).toUpperCase();
el.textContent = initials;
// Ajouter un tooltip avec le nom complet
el.title = data.username;
// Créer le popup
const popupContent = `
<div class="user-popup" style="padding: 10px; max-width: 200px;">
<h3 style="margin-top: 0;">${data.username}</h3>
<p style="margin-bottom: 10px;">Position mise à jour: ${this.formatTimestamp(data.timestamp)}</p>
<div style="display: flex; justify-content: space-between;">
<button class="pouet-btn" style="padding: 5px 10px; background-color: #FFC107; border: none; border-radius: 3px; cursor: pointer;">Pouet Pouet!</button>
${!this.friends.includes(data.username) ? `
<button class="add-friend-btn" style="padding: 5px 10px; background-color: #4CAF50; color: white; border: none; border-radius: 3px; cursor: pointer;">Ajouter ami</button>
` : ''}
</div>
</div>
`;
const popup = new maplibregl.Popup({
closeButton: true,
closeOnClick: true
}).setHTML(popupContent);
// Ajouter des gestionnaires d'événements au popup
popup.on('open', () => {
// Gérer le clic sur le bouton Pouet Pouet
setTimeout(() => {
const pouetBtn = document.querySelector('.pouet-btn');
if (pouetBtn) {
pouetBtn.addEventListener('click', () => {
this.sendPouet(data.username);
});
}
// Gérer le clic sur le bouton Ajouter ami
const addFriendBtn = document.querySelector('.add-friend-btn');
if (addFriendBtn) {
addFriendBtn.addEventListener('click', () => {
this.sendFriendRequest(data.username);
});
}
}, 100);
});
// Créer le marqueur et l'ajouter à la carte
const marker = new maplibregl.Marker(el)
.setLngLat([data.position.lng, data.position.lat])
.setPopup(popup)
.addTo(this.map);
// Stocker le marqueur pour pouvoir le supprimer plus tard
this.markers[data.username] = marker;
}
// Mettre à jour tous les utilisateurs actifs
updateAllUsers(users) {
// Supprimer les marqueurs des utilisateurs qui ne sont plus actifs
Object.keys(this.markers).forEach(username => {
if (!users.find(user => user.username === username)) {
this.markers[username].remove();
delete this.markers[username];
}
});
}
// Envoyer un pouet à un utilisateur
sendPouet(username) {
const now = Date.now();
// Vérifier si on peut envoyer un pouet (limité à 1 toutes les 10 secondes)
if (now - this.lastPouetTime < 10000) {
const remainingTime = Math.ceil((10000 - (now - this.lastPouetTime)) / 1000);
this.showToast(`Merci d'attendre encore ${remainingTime} secondes avant d'envoyer un autre pouet!`, 'warning');
return;
}
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({
type: 'pouet',
from: this.username,
to: username,
timestamp: new Date().toISOString()
}));
this.lastPouetTime = now;
this.showToast(`Pouet pouet envoyé à ${username}!`, 'success');
}
}
// Recevoir un pouet
receivePouet(data) {
this.showToast(`${data.from} vous a envoyé un pouet pouet!`, 'info');
// Jouer un son
const audio = new Audio('/static/pouet.mp3');
audio.play().catch(e => console.log('Erreur lors de la lecture du son:', e));
}
// Envoyer une demande d'ami
sendFriendRequest(username) {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({
type: 'friendRequest',
from: this.username,
to: username,
timestamp: new Date().toISOString()
}));
this.showToast(`Demande d'ami envoyée à ${username}!`, 'success');
}
}
// Recevoir une demande d'ami
receiveFriendRequest(data) {
// Créer une boîte de dialogue modale pour la demande d'ami
const modalOverlay = document.createElement('div');
modalOverlay.className = 'modal-overlay';
modalOverlay.style.position = 'fixed';
modalOverlay.style.top = '0';
modalOverlay.style.left = '0';
modalOverlay.style.width = '100%';
modalOverlay.style.height = '100%';
modalOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
modalOverlay.style.zIndex = '1000';
modalOverlay.style.display = 'flex';
modalOverlay.style.justifyContent = 'center';
modalOverlay.style.alignItems = 'center';
const modalContent = document.createElement('div');
modalContent.className = 'modal-content';
modalContent.style.backgroundColor = '#fff';
modalContent.style.padding = '20px';
modalContent.style.borderRadius = '5px';
modalContent.style.maxWidth = '400px';
modalContent.style.width = '80%';
const title = document.createElement('h3');
title.textContent = 'Demande d\'ami';
title.style.marginBottom = '15px';
const message = document.createElement('p');
message.textContent = `${data.from} souhaite vous ajouter à sa liste d'amis.`;
message.style.marginBottom = '20px';
const buttonsContainer = document.createElement('div');
buttonsContainer.style.display = 'flex';
buttonsContainer.style.justifyContent = 'space-between';
const acceptButton = document.createElement('button');
acceptButton.textContent = 'Accepter';
acceptButton.style.padding = '8px 15px';
acceptButton.style.backgroundColor = '#4CAF50';
acceptButton.style.color = 'white';
acceptButton.style.border = 'none';
acceptButton.style.borderRadius = '4px';
acceptButton.style.cursor = 'pointer';
acceptButton.onclick = () => {
this.addFriend(data.from);
document.body.removeChild(modalOverlay);
};
const rejectButton = document.createElement('button');
rejectButton.textContent = 'Refuser';
rejectButton.style.padding = '8px 15px';
rejectButton.style.backgroundColor = '#f44336';
rejectButton.style.color = 'white';
rejectButton.style.border = 'none';
rejectButton.style.borderRadius = '4px';
rejectButton.style.cursor = 'pointer';
rejectButton.onclick = () => {
document.body.removeChild(modalOverlay);
};
buttonsContainer.appendChild(acceptButton);
buttonsContainer.appendChild(rejectButton);
modalContent.appendChild(title);
modalContent.appendChild(message);
modalContent.appendChild(buttonsContainer);
modalOverlay.appendChild(modalContent);
document.body.appendChild(modalOverlay);
}
// Ajouter un ami à la liste d'amis
addFriend(username) {
if (!this.friends.includes(username)) {
this.friends.push(username);
this.saveFriends();
this.showToast(`${username} a été ajouté à votre liste d'amis!`, 'success');
// Mettre à jour le marqueur de cet ami s'il est visible
if (this.markers[username]) {
const position = this.markers[username].getLngLat();
this.markers[username].remove();
delete this.markers[username];
// Simuler une mise à jour de position pour recréer le marqueur
this.updateUserPosition({
username: username,
position: {
lng: position.lng,
lat: position.lat
},
timestamp: new Date().toISOString()
});
}
}
}
// Sauvegarder la liste d'amis dans le localStorage
saveFriends() {
localStorage.setItem('oedb_social_friends', JSON.stringify(this.friends));
}
// Charger la liste d'amis depuis le localStorage
loadFriends() {
try {
const friendsJson = localStorage.getItem('oedb_social_friends');
if (friendsJson) {
this.friends = JSON.parse(friendsJson);
}
} catch (e) {
console.error('Erreur lors du chargement des amis:', e);
this.friends = [];
}
}
// Créer l'interface utilisateur pour les fonctionnalités sociales
createSocialUI() {
// Conteneur principal pour les contrôles sociaux
const socialContainer = document.createElement('div');
socialContainer.className = 'social-controls';
socialContainer.style.position = 'absolute';
socialContainer.style.top = '10px';
socialContainer.style.right = '10px';
socialContainer.style.backgroundColor = 'rgba(255, 255, 255, 0.9)';
socialContainer.style.padding = '10px';
socialContainer.style.borderRadius = '5px';
socialContainer.style.boxShadow = '0 0 10px rgba(0, 0, 0, 0.1)';
socialContainer.style.zIndex = '10';
socialContainer.style.display = 'flex';
socialContainer.style.flexDirection = 'column';
socialContainer.style.gap = '10px';
// Titre
const title = document.createElement('h3');
title.textContent = 'Mode Social';
title.style.margin = '0 0 10px 0';
title.style.textAlign = 'center';
// Bouton pour activer/désactiver le mode social
const toggleButton = document.createElement('button');
toggleButton.className = 'toggle-social-btn';
toggleButton.textContent = 'Activer le mode social';
toggleButton.style.padding = '8px';
toggleButton.style.backgroundColor = '#0078ff';
toggleButton.style.color = 'white';
toggleButton.style.border = 'none';
toggleButton.style.borderRadius = '4px';
toggleButton.style.cursor = 'pointer';
toggleButton.style.fontWeight = 'bold';
let socialActive = false;
toggleButton.addEventListener('click', () => {
socialActive = !socialActive;
if (socialActive) {
toggleButton.textContent = 'Désactiver le mode social';
toggleButton.style.backgroundColor = '#f44336';
this.init(this.map);
} else {
toggleButton.textContent = 'Activer le mode social';
toggleButton.style.backgroundColor = '#0078ff';
// Fermer la connexion WebSocket
if (this.socket) {
this.socket.close();
this.socket = null;
}
// Supprimer tous les marqueurs
Object.values(this.markers).forEach(marker => marker.remove());
this.markers = {};
}
// Afficher/masquer les options supplémentaires
optionsContainer.style.display = socialActive ? 'block' : 'none';
});
// Conteneur pour les options supplémentaires
const optionsContainer = document.createElement('div');
optionsContainer.className = 'social-options';
optionsContainer.style.display = 'none';
// Bouton pour changer de pseudo
const changeUsernameBtn = document.createElement('button');
changeUsernameBtn.textContent = 'Changer de pseudo';
changeUsernameBtn.style.width = '100%';
changeUsernameBtn.style.padding = '8px';
changeUsernameBtn.style.backgroundColor = '#FFC107';
changeUsernameBtn.style.border = 'none';
changeUsernameBtn.style.borderRadius = '4px';
changeUsernameBtn.style.marginBottom = '10px';
changeUsernameBtn.style.cursor = 'pointer';
changeUsernameBtn.addEventListener('click', () => {
this.promptForUsername();
});
// Case à cocher pour la visibilité uniquement aux amis
const visibilityContainer = document.createElement('div');
visibilityContainer.style.display = 'flex';
visibilityContainer.style.alignItems = 'center';
visibilityContainer.style.marginBottom = '10px';
const visibilityCheckbox = document.createElement('input');
visibilityCheckbox.type = 'checkbox';
visibilityCheckbox.id = 'visibility-checkbox';
visibilityCheckbox.checked = this.showOnlyFriends;
const visibilityLabel = document.createElement('label');
visibilityLabel.htmlFor = 'visibility-checkbox';
visibilityLabel.textContent = 'Visible uniquement par mes amis';
visibilityLabel.style.marginLeft = '5px';
visibilityContainer.appendChild(visibilityCheckbox);
visibilityContainer.appendChild(visibilityLabel);
visibilityCheckbox.addEventListener('change', () => {
this.showOnlyFriends = visibilityCheckbox.checked;
this.getCurrentPosition(); // Mettre à jour immédiatement avec le nouveau paramètre
});
// Gestionnaire d'amis
const friendsManager = document.createElement('div');
friendsManager.style.marginTop = '10px';
const friendsTitle = document.createElement('h4');
friendsTitle.textContent = 'Mes amis';
friendsTitle.style.margin = '0 0 5px 0';
const friendsList = document.createElement('ul');
friendsList.style.listStyle = 'none';
friendsList.style.padding = '0';
friendsList.style.margin = '0';
friendsList.style.maxHeight = '150px';
friendsList.style.overflowY = 'auto';
friendsList.style.border = '1px solid #ddd';
friendsList.style.borderRadius = '4px';
friendsList.style.padding = '5px';
// Fonction pour mettre à jour la liste d'amis
const updateFriendsList = () => {
friendsList.innerHTML = '';
if (this.friends.length === 0) {
const emptyItem = document.createElement('li');
emptyItem.textContent = 'Aucun ami pour l\'instant';
emptyItem.style.fontStyle = 'italic';
emptyItem.style.padding = '5px';
friendsList.appendChild(emptyItem);
} else {
this.friends.forEach(friend => {
const listItem = document.createElement('li');
listItem.style.display = 'flex';
listItem.style.justifyContent = 'space-between';
listItem.style.alignItems = 'center';
listItem.style.padding = '5px';
listItem.style.borderBottom = '1px solid #eee';
const friendName = document.createElement('span');
friendName.textContent = friend;
const removeBtn = document.createElement('button');
removeBtn.textContent = 'X';
removeBtn.style.backgroundColor = '#f44336';
removeBtn.style.color = 'white';
removeBtn.style.border = 'none';
removeBtn.style.borderRadius = '50%';
removeBtn.style.width = '20px';
removeBtn.style.height = '20px';
removeBtn.style.fontSize = '10px';
removeBtn.style.cursor = 'pointer';
removeBtn.style.display = 'flex';
removeBtn.style.justifyContent = 'center';
removeBtn.style.alignItems = 'center';
removeBtn.addEventListener('click', () => {
this.friends = this.friends.filter(f => f !== friend);
this.saveFriends();
updateFriendsList();
});
listItem.appendChild(friendName);
listItem.appendChild(removeBtn);
friendsList.appendChild(listItem);
});
}
};
// Initialiser la liste d'amis
updateFriendsList();
friendsManager.appendChild(friendsTitle);
friendsManager.appendChild(friendsList);
// Ajouter tous les éléments au conteneur d'options
optionsContainer.appendChild(changeUsernameBtn);
optionsContainer.appendChild(visibilityContainer);
optionsContainer.appendChild(friendsManager);
// Ajouter tous les éléments au conteneur principal
socialContainer.appendChild(title);
socialContainer.appendChild(toggleButton);
socialContainer.appendChild(optionsContainer);
// Ajouter le conteneur au document après le chargement du DOM
document.addEventListener('DOMContentLoaded', () => {
document.body.appendChild(socialContainer);
});
}
// Afficher un toast (message flottant)
showToast(message, type = 'info') {
// Créer le conteneur de toast s'il n'existe pas
let toastContainer = document.getElementById('toast-container');
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.id = 'toast-container';
toastContainer.style.position = 'fixed';
toastContainer.style.top = '20px';
toastContainer.style.left = '50%';
toastContainer.style.transform = 'translateX(-50%)';
toastContainer.style.zIndex = '1000';
toastContainer.style.display = 'flex';
toastContainer.style.flexDirection = 'column';
toastContainer.style.alignItems = 'center';
toastContainer.style.gap = '10px';
document.body.appendChild(toastContainer);
}
// Créer le toast
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.style.padding = '10px 15px';
toast.style.borderRadius = '5px';
toast.style.boxShadow = '0 2px 10px rgba(0, 0, 0, 0.2)';
toast.style.minWidth = '250px';
toast.style.textAlign = 'center';
toast.style.animation = 'fadeIn 0.3s, fadeOut 0.3s 2.7s';
toast.style.opacity = '0';
toast.style.maxWidth = '80vw';
// Définir la couleur en fonction du type
switch (type) {
case 'success':
toast.style.backgroundColor = '#4CAF50';
toast.style.color = 'white';
break;
case 'warning':
toast.style.backgroundColor = '#FFC107';
toast.style.color = 'black';
break;
case 'error':
toast.style.backgroundColor = '#f44336';
toast.style.color = 'white';
break;
default: // info
toast.style.backgroundColor = '#0078ff';
toast.style.color = 'white';
}
toast.textContent = message;
// Ajouter le style d'animation s'il n'existe pas
if (!document.getElementById('toast-style')) {
const style = document.createElement('style');
style.id = 'toast-style';
style.textContent = `
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeOut {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-20px); }
}
`;
document.head.appendChild(style);
}
// Ajouter le toast au conteneur
toastContainer.appendChild(toast);
// Animer l'entrée
setTimeout(() => {
toast.style.opacity = '1';
toast.style.transform = 'translateY(0)';
}, 10);
// Supprimer le toast après 3 secondes
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateY(-20px)';
setTimeout(() => {
toastContainer.removeChild(toast);
}, 300);
}, 3000);
}
// Formatter un timestamp en heure locale
formatTimestamp(timestamp) {
const date = new Date(timestamp);
return date.toLocaleTimeString();
}
}
// Initialiser l'objet social lorsque le DOM est chargé
document.addEventListener('DOMContentLoaded', () => {
// Créer l'instance sociale
window.oedbSocial = new OEDBSocial();
});

View file

@ -7,6 +7,23 @@ let existingMarkers = [];
const PANORAMAX_TOKEN_STORAGE_KEY = 'oedb_panoramax_token';
let mediaStream = null;
// Fonction pour créer un marqueur personnalisé avec emoji
function createCustomMarker(emoji, backgroundColor) {
const markerElement = document.createElement('div');
markerElement.className = 'custom-marker';
markerElement.style.width = '30px';
markerElement.style.height = '30px';
markerElement.style.borderRadius = '50%';
markerElement.style.backgroundColor = backgroundColor;
markerElement.style.display = 'flex';
markerElement.style.justifyContent = 'center';
markerElement.style.alignItems = 'center';
markerElement.style.fontSize = '16px';
markerElement.style.boxShadow = '0 2px 4px rgba(0,0,0,0.3)';
markerElement.innerHTML = emoji;
return markerElement;
}
function setDefaultDates() {
const now = new Date();
const nowISO = now.toISOString().slice(0, 16);
@ -70,8 +87,22 @@ function fetchExistingTrafficEvents() {
if (event.geometry && event.geometry.type === 'Point') {
const coords = event.geometry.coordinates;
const needsRealityCheck = checkIfNeedsRealityCheck(event);
const markerColor = needsRealityCheck ? '#ff9800' : '#888888';
const em = new maplibregl.Marker({ color: markerColor }).setLngLat(coords).addTo(map);
let markerColor = needsRealityCheck ? '#ff9800' : '#888888';
let markerOptions = { color: markerColor };
// Check if event title contains "vélo" or "travaux"
const eventTitle = event.properties.label || '';
if (eventTitle.toLowerCase().includes('vélo')) {
markerOptions = {
element: createCustomMarker('🚲', markerColor)
};
} else if (eventTitle.toLowerCase().includes('travaux')) {
markerOptions = {
element: createCustomMarker('🚧', markerColor)
};
}
const em = new maplibregl.Marker(markerOptions).setLngLat(coords).addTo(map);
let popupContent = `\n<h3>${event.properties.label || 'Traffic Event'}</h3>\n<p>Type: ${event.properties.what || 'Unknown'}</p>\n<p>Start: ${event.properties.start || 'Unknown'}</p>\n<p>End: ${event.properties.stop || 'Unknown'}</p>`;
if (needsRealityCheck) {
popupContent += `\n<div class="reality-check">\n<p>Is this traffic event still present?</p>\n<div class="reality-check-buttons">\n<button class="confirm-btn" onclick="confirmEvent('${event.properties.id}', true)">Yes, still there</button>\n<button class="deny-btn" onclick="confirmEvent('${event.properties.id}', false)">No, it's gone</button>\n</div>\n</div>`;
@ -590,11 +621,24 @@ function updateUserInfoDisplay() {
userInfoPanel.innerHTML = `\n<h3>User Information</h3>\n<p>Username: <strong>${username}</strong></p>\n<p>Points: <span class="user-points">${points}</span></p>`;
}
// Initialize collapsible panels
function initCollapsiblePanels() {
const headers = document.querySelectorAll('.collapsible-header');
headers.forEach(header => {
header.addEventListener('click', function() {
this.classList.toggle('active');
const content = this.nextElementSibling;
content.classList.toggle('active');
});
});
}
document.addEventListener('DOMContentLoaded', function() {
setDefaultDates();
initTabs();
initMap();
updateUserInfoDisplay();
initCollapsiblePanels();
});
// Contrôles Caméra

View file

@ -1,8 +1,26 @@
<div class="nav-links">
<a href="/demo">← Retour à la démo</a>
<a href="/demo/traffic">Signaler trafic</a>
<a href="/demo/view-events">Voir événements</a>
<a href="/demo/map-by-what">Carte par type</a>
<a href="/demo/stats">Stats</a>
<div class="nav-container">
<button class="menu-toggle" aria-label="Toggle menu">
<i class="fas fa-bars"></i>
</button>
<div class="nav-links">
<a href="/demo">← Retour à la démo</a>
<a href="/demo/traffic">Signaler trafic</a>
<a href="/demo/view-events">Voir événements</a>
<a href="/demo/map-by-what">Carte par type</a>
<a href="/demo/stats">Stats</a>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const menuToggle = document.querySelector('.menu-toggle');
const navLinks = document.querySelector('.nav-links');
if (menuToggle) {
menuToggle.addEventListener('click', function() {
navLinks.classList.toggle('active');
});
}
});
</script>

View file

@ -0,0 +1,337 @@
import json
import time
import threading
import asyncio
import websockets
from oedb.utils.logging import logger
class WebSocketManager:
"""
Gestionnaire de WebSockets pour les fonctionnalités sociales d'OEDB.
Gère les connexions WebSocket des utilisateurs et distribue les messages.
Peut être utilisé soit de façon autonome, soit intégré avec uWSGI.
"""
def __init__(self):
self.clients = {}
self.positions = {}
self.lock = threading.Lock()
self.server = None
async def handle_connection(self, websocket, path):
"""
Gère une connexion WebSocket entrante.
Args:
websocket: La connexion WebSocket.
path: Le chemin de la demande.
"""
client_id = id(websocket)
logger.debug(f"Tentative de connexion WebSocket reçue: {client_id} - {path}")
try:
logger.info(f"Nouvelle connexion WebSocket: {client_id} - {path}")
# Ajouter le client à la liste
with self.lock:
self.clients[client_id] = {
'websocket': websocket,
'username': None,
'position': None,
'last_seen': time.time(),
'show_only_to_friends': False,
}
# Envoyer la liste des utilisateurs connectés
await self.send_users_list(websocket)
async for message in websocket:
await self.handle_message(client_id, message)
except websockets.exceptions.ConnectionClosed:
logger.info(f"Connexion WebSocket fermée: {client_id}")
except Exception as e:
logger.error(f"Erreur WebSocket: {e}")
finally:
# Supprimer le client de la liste
with self.lock:
if client_id in self.clients:
username = self.clients[client_id].get('username')
if username and username in self.positions:
del self.positions[username]
del self.clients[client_id]
# Informer les autres clients de la déconnexion
await self.broadcast_users_list()
async def handle_message(self, client_id, message):
"""
Traite un message WebSocket reçu.
Args:
client_id: L'ID du client qui a envoyé le message.
message: Le message JSON reçu.
"""
try:
data = json.loads(message)
message_type = data.get('type')
if message_type == 'position':
await self.handle_position_update(client_id, data)
elif message_type == 'pouet':
await self.handle_pouet(data)
elif message_type == 'friendRequest':
await self.handle_friend_request(data)
except json.JSONDecodeError:
logger.error(f"Message JSON invalide: {message}")
except Exception as e:
logger.error(f"Erreur de traitement du message: {e}")
async def handle_position_update(self, client_id, data):
"""
Traite une mise à jour de position d'un utilisateur.
Args:
client_id: L'ID du client qui a envoyé la mise à jour.
data: Les données de position.
"""
username = data.get('username')
position = data.get('position')
show_only_to_friends = data.get('showOnlyToFriends', False)
if not username or not position:
return
# Mettre à jour les informations du client
with self.lock:
if client_id in self.clients:
self.clients[client_id]['username'] = username
self.clients[client_id]['position'] = position
self.clients[client_id]['last_seen'] = time.time()
self.clients[client_id]['show_only_to_friends'] = show_only_to_friends
# Mettre à jour la position dans le dictionnaire des positions
self.positions[username] = {
'position': position,
'timestamp': data.get('timestamp'),
'show_only_to_friends': show_only_to_friends
}
# Diffuser la position à tous les autres clients
await self.broadcast_position(username, position, data.get('timestamp'), show_only_to_friends)
# Envoyer la liste mise à jour des utilisateurs
await self.broadcast_users_list()
async def handle_pouet(self, data):
"""
Traite un 'pouet pouet' envoyé d'un utilisateur à un autre.
Args:
data: Les données du pouet pouet.
"""
from_user = data.get('from')
to_user = data.get('to')
if not from_user or not to_user:
return
# Trouver le client destinataire
recipient_client_id = None
with self.lock:
for client_id, client_info in self.clients.items():
if client_info.get('username') == to_user:
recipient_client_id = client_id
break
if recipient_client_id and recipient_client_id in self.clients:
# Envoyer le pouet au destinataire
try:
await self.clients[recipient_client_id]['websocket'].send(json.dumps({
'type': 'pouet',
'from': from_user,
'timestamp': data.get('timestamp')
}))
logger.info(f"Pouet pouet envoyé de {from_user} à {to_user}")
except Exception as e:
logger.error(f"Erreur d'envoi de pouet pouet: {e}")
async def handle_friend_request(self, data):
"""
Traite une demande d'ami d'un utilisateur à un autre.
Args:
data: Les données de la demande d'ami.
"""
from_user = data.get('from')
to_user = data.get('to')
if not from_user or not to_user:
return
# Trouver le client destinataire
recipient_client_id = None
with self.lock:
for client_id, client_info in self.clients.items():
if client_info.get('username') == to_user:
recipient_client_id = client_id
break
if recipient_client_id and recipient_client_id in self.clients:
# Envoyer la demande d'ami au destinataire
try:
await self.clients[recipient_client_id]['websocket'].send(json.dumps({
'type': 'friendRequest',
'from': from_user,
'timestamp': data.get('timestamp')
}))
logger.info(f"Demande d'ami envoyée de {from_user} à {to_user}")
except Exception as e:
logger.error(f"Erreur d'envoi de demande d'ami: {e}")
async def broadcast_position(self, username, position, timestamp, show_only_to_friends):
"""
Diffuse la position d'un utilisateur à tous les autres utilisateurs.
Args:
username: Le nom d'utilisateur.
position: La position de l'utilisateur.
timestamp: L'horodatage de la mise à jour.
show_only_to_friends: Indique si la position est visible uniquement par les amis.
"""
message = json.dumps({
'type': 'position',
'username': username,
'position': position,
'timestamp': timestamp,
'showOnlyToFriends': show_only_to_friends
})
with self.lock:
for client_id, client_info in self.clients.items():
# Ne pas envoyer à l'utilisateur lui-même
if client_info.get('username') == username:
continue
try:
await client_info['websocket'].send(message)
except Exception as e:
logger.error(f"Erreur d'envoi de broadcast de position: {e}")
async def send_users_list(self, websocket):
"""
Envoie la liste des utilisateurs connectés à un client spécifique.
Args:
websocket: La connexion WebSocket du client.
"""
users = []
with self.lock:
for client_info in self.clients.values():
if client_info.get('username'):
users.append({
'username': client_info['username'],
'timestamp': time.time()
})
try:
await websocket.send(json.dumps({
'type': 'users',
'users': users
}))
except Exception as e:
logger.error(f"Erreur d'envoi de liste d'utilisateurs: {e}")
async def broadcast_users_list(self):
"""
Diffuse la liste des utilisateurs connectés à tous les clients.
"""
users = []
with self.lock:
for client_info in self.clients.values():
if client_info.get('username'):
users.append({
'username': client_info['username'],
'timestamp': time.time()
})
message = json.dumps({
'type': 'users',
'users': users
})
with self.lock:
for client_info in self.clients.values():
try:
await client_info['websocket'].send(message)
except Exception as e:
logger.error(f"Erreur de broadcast de liste d'utilisateurs: {e}")
async def cleanup_inactive_clients(self):
"""
Nettoie les clients inactifs (pas de mise à jour depuis plus de 5 minutes).
"""
inactive_clients = []
with self.lock:
current_time = time.time()
for client_id, client_info in self.clients.items():
if current_time - client_info['last_seen'] > 300: # 5 minutes
inactive_clients.append(client_id)
for client_id in inactive_clients:
username = self.clients[client_id].get('username')
if username and username in self.positions:
del self.positions[username]
del self.clients[client_id]
if inactive_clients:
logger.info(f"Nettoyage de {len(inactive_clients)} clients inactifs")
await self.broadcast_users_list()
async def cleanup_task(self):
"""
Tâche périodique pour nettoyer les clients inactifs.
"""
while True:
await asyncio.sleep(60) # Exécuter toutes les minutes
await self.cleanup_inactive_clients()
async def start_server(self, host='0.0.0.0', port=8765):
"""
Démarre le serveur WebSocket.
Args:
host: L'hôte à écouter.
port: Le port à écouter.
"""
self.server = await websockets.serve(self.handle_connection, host, port)
logger.info(f"Serveur WebSocket démarré sur {host}:{port}")
# Démarrer la tâche de nettoyage
asyncio.create_task(self.cleanup_task())
# Garder le serveur en cours d'exécution
await asyncio.Future()
def start(self, host='0.0.0.0', port=8765):
"""
Démarre le serveur WebSocket dans un thread séparé.
Args:
host: L'hôte à écouter.
port: Le port à écouter.
"""
def run_server():
asyncio.run(self.start_server(host, port))
server_thread = threading.Thread(target=run_server, daemon=True)
server_thread.start()
logger.info(f"Serveur WebSocket démarré dans un thread séparé sur {host}:{port}")
# Créer une instance du gestionnaire WebSocket
ws_manager = WebSocketManager()
# Démarrer automatiquement le serveur WebSocket
ws_manager.start(host='127.0.0.1', port=8765)

View file

@ -36,6 +36,8 @@ class EventFormResource:
<title>Add Event - 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" />
<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" />
<style>
body {
margin: 0;

406
oedb/resources/live.py Normal file
View file

@ -0,0 +1,406 @@
"""
Live page: shows last 7 days events from public OEDB API, refreshes every minute,
displays 10-minute bucket histogram and a table of events.
"""
import falcon
from oedb.utils.logging import logger
class LiveResource:
def on_get(self, req, resp):
logger.info("Processing GET request to /live")
resp.content_type = 'text/html'
html = """
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OEDB Live - derniers événements</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"></script>
<link href="https://unpkg.com/maplibre-gl@3.0.0/dist/maplibre-gl.css" rel="stylesheet" />
<style>
body { margin: 0; padding: 16px; font-family: Arial, sans-serif; background: #f6f7f9; }
.container { max-width: 1200px; margin: 0 auto; }
h1 { margin: 0 0 12px; }
.controls { display: flex; align-items: center; gap: 8px; margin: 8px 0 16px; }
.card { background: #fff; border: 1px solid #e5e7eb; border-radius: 6px; padding: 12px; margin-bottom: 16px; }
#chart { width: 100%; height: 320px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 6px 8px; border-bottom: 1px solid #eee; text-align: left; font-size: 13px; }
th { background: #fafafa; }
.muted { color: #6b7280; }
</style>
</head>
<body>
<div class="container">
<h1>
<a href="/demo">OEDB</a> Live
</h1>
<div class="controls">
<span>Période: 7 jours (rafraîchit chaque minute)</span>
<button id="refreshBtn">Rafraîchir</button>
<span id="lastUpdate" class="muted"></span>
</div>
<div class="card" style="display:flex; gap:12px; align-items:flex-start;">
<div style="flex:1 1 auto; min-width: 0;">
<canvas id="chart"></canvas>
</div>
<div style="flex:0 0 240px; max-height: 360px; overflow:auto; border-left:1px solid #eee; padding-left:12px;">
<h3 style="margin:0 0 8px">Filtrer par type</h3>
<div style="margin-bottom:8px">
<button id="selectAllBtn">Tout cocher</button>
<button id="clearAllBtn">Tout décocher</button>
</div>
<div style="margin-bottom:8px">
<label style="display:flex; align-items:center; gap:6px;">
<input type="checkbox" id="onlyRealityCheck">
<span>Seulement avec reality_check</span>
</label>
</div>
<div id="filters"></div>
</div>
</div>
<div>
<div id="info_panel_content" class="">
<!-- User Information Panel -->
<div id="user-info-panel" class="user-info-panel" style="display: none; background-color: #f5f5f5; border-radius: 4px; padding: 10px; margin: 10px 0; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<h3 style="margin-top: 0; margin-bottom: 10px; color: #333;">User Information</h3>
<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">
<li><a href="/" >/ - API Information</a></li>
<li><a href="/event" >/event - Get Events</a></li>
<li><a href="/stats" >/stats - Database Statistics</a></li>
</ul>
<h3 id="demo_pages_list_header">Demo Pages:</h3>
<ul id="demo_pages_list">
<li><a href="/demo/search" >/demo/search - Advanced Search</a></li>
<li><a href="/demo/by-what" >/demo/by-what - Events by Type</a></li>
<li><a href="/demo/map-by-what" >/demo/map-by-what - Map by Event Type</a></li>
<li><a href="/demo/traffic" >/demo/traffic - Report Traffic Jam</a></li>
<li><a href="/demo/view-events" >/demo/view-events - View Saved Events</a></li>
<li><a href="/event?what=music" >Search Music Events</a></li>
<li><a href="/event?what=sport" >Search Sport Events</a></li>
</ul>
<p class="sources" style="text-align: center; margin-top: 10px;">
<a href="https://source.cipherbliss.com/tykayn/oedb-backend" title="View Source Code on Cipherbliss" style="font-size: 24px;">
<i class="fas fa-code-branch"></i> sources
</a>
</p>
</div>
</div>
<div class="card">
<h2 style="margin:0 0 8px">Arbre des familles d'évènements</h2>
<div id="familiesGraph" style="width:100%; height:360px; border:1px solid #eee; border-radius:4px;"></div>
</div>
<div class="card">
<h2 style="margin:0 0 8px">Derniers évènements</h2>
<div style="overflow:auto; max-height: 50vh;">
<table id="eventsTable">
<thead>
<tr>
<th></th>
<th>ID</th>
<th>What</th>
<th>Label</th>
<th>Start</th>
<th>Stop</th>
<th>Lon</th>
<th>Lat</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
<script>
const API_URL = 'https://api.openeventdatabase.org/event?when=last7days&limit=2000';
let chart;
let allFeatures = [];
let familySet = new Set();
const familyToColor = new Map();
const pastel = ['#cce5ff','#c2f0f0','#d5f5e3','#fde2cf','#f6d5ff','#ffd6e7','#e3f2fd','#e8f5e9','#fff3e0','#f3e5f5','#f1f8e9','#e0f7fa','#fff8e1','#ede7f6','#fce4ec'];
function bucket10min(date) {
const d = new Date(date);
if (isNaN(d.getTime())) return null;
// round down to 10 minute
d.setSeconds(0, 0);
const m = d.getMinutes();
d.setMinutes(m - (m % 10));
return d.toISOString();
}
function getFamily(p) {
const w = (p && p.what) ? String(p.what) : '';
return w.split('.')[0] || 'other';
}
function ensureColors() {
let i = 0;
for (const fam of familySet) {
if (!familyToColor.has(fam)) {
familyToColor.set(fam, pastel[i % pastel.length]);
i++;
}
}
}
function buildStackedHistogram(features, enabledFamilies, onlyReality) {
const allBuckets = new Set();
const famBuckets = new Map();
for (const f of features) {
const fam = getFamily(f.properties);
if (enabledFamilies && !enabledFamilies.has(fam)) continue;
if (onlyReality && !(f.properties && f.properties['reality_check'])) continue;
const t = f.properties && (f.properties.createdate || f.properties.start || f.properties.lastupdate);
const bucket = bucket10min(t);
if (!bucket) continue;
allBuckets.add(bucket);
if (!famBuckets.has(fam)) famBuckets.set(fam, new Map());
const m = famBuckets.get(fam);
m.set(bucket, (m.get(bucket) || 0) + 1);
}
const labels = Array.from(allBuckets).sort();
const datasets = [];
for (const [fam, mapCounts] of famBuckets.entries()) {
const data = labels.map(k => mapCounts.get(k) || 0);
datasets.push({ label: fam, data, backgroundColor: familyToColor.get(fam) || '#ddd', stack: 'events' });
}
return { labels, datasets };
}
function renderChart(labels, datasets) {
const ctx = document.getElementById('chart');
if (chart) chart.destroy();
chart = new Chart(ctx, {
type: 'bar',
data: { labels, datasets },
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: { stacked: true, ticks: { callback: (v, i) => new Date(labels[i]).toLocaleString() } },
y: { stacked: true, beginAtZero: true }
}
}
});
}
function renderTable(features, enabledFamilies) {
const tbody = document.querySelector('#eventsTable tbody');
tbody.innerHTML = '';
for (const f of features) {
const p = f.properties || {};
const fam = getFamily(p);
if (enabledFamilies && !enabledFamilies.has(fam)) continue;
const onlyReality = document.getElementById('onlyRealityCheck')?.checked;
if (onlyReality && !p['reality_check']) continue;
const tr = document.createElement('tr');
tr.style.background = (familyToColor.get(fam) || '#fff');
let iconClass = 'info-circle';
let iconColor = '#0078ff';
const eventType = String(p.what || '');
const labelLower = String(p.label || '').toLowerCase();
if (labelLower.includes('travaux') || eventType.includes('roadwork')) { iconClass = 'hard-hat'; iconColor = '#ff9800'; }
else if (eventType.startsWith('weather')) { iconClass = 'cloud'; iconColor = '#00d1b2'; }
else if (eventType.startsWith('traffic')) { iconClass = 'car'; iconColor = '#ff3860'; }
else if (eventType.startsWith('sport')) { iconClass = 'futbol'; iconColor = '#3273dc'; }
else if (eventType.startsWith('culture')) { iconClass = 'theater-masks'; iconColor = '#ffdd57'; }
else if (eventType.startsWith('health')) { iconClass = 'heartbeat'; iconColor = '#ff3860'; }
else if (eventType.startsWith('education')) { iconClass = 'graduation-cap'; iconColor = '#3273dc'; }
else if (eventType.startsWith('politics')) { iconClass = 'landmark'; iconColor = '#209cee'; }
else if (eventType.startsWith('nature')) { iconClass = 'leaf'; iconColor = '#23d160'; }
const idHtml = p.id ? `<a href="/demo/by_id/${p.id}">${p.id}</a>` : '';
tr.innerHTML = `
<td style="width:28px;text-align:center"><i class="fas fa-${iconClass}" style="color:${iconColor}"></i></td>
<td>${idHtml}</td>
<td>${p.what || ''}</td>
<td>${(p.label || '').toString().slice(0,120)}</td>
<td>${p.start || ''}</td>
<td>${p.stop || ''}</td>
<td>${f.geometry && f.geometry.coordinates ? f.geometry.coordinates[0] : ''}</td>
<td>${f.geometry && f.geometry.coordinates ? f.geometry.coordinates[1] : ''}</td>
`;
tbody.appendChild(tr);
}
}
async function loadData() {
try {
const res = await fetch(API_URL);
const data = await res.json();
allFeatures = (data && data.features) ? data.features : [];
familySet = new Set(allFeatures.map(f => getFamily(f.properties)));
ensureColors();
buildFilters();
applyFiltersAndRender();
try { renderFamiliesGraph(allFeatures); } catch(e) { console.warn('Graph error', e); }
document.getElementById('lastUpdate').textContent = 'Mise à jour: ' + new Date().toLocaleString();
} catch (e) {
console.error(e);
}
}
function buildFilters() {
const cont = document.getElementById('filters');
cont.innerHTML = '';
const sorted = Array.from(familySet).sort();
for (const fam of sorted) {
const id = 'fam_' + fam.replace(/[^a-z0-9]/gi, '_');
const wrap = document.createElement('div');
wrap.style.marginBottom = '6px';
wrap.innerHTML = `
<label style="display:flex; align-items:center; gap:6px;">
<input type="checkbox" id="${id}" checked>
<span style="display:inline-block; width:12px; height:12px; background:${familyToColor.get(fam)}; border:1px solid #ddd"></span>
<span>${fam}</span>
</label>
`;
cont.appendChild(wrap);
document.getElementById(id).addEventListener('change', applyFiltersAndRender);
}
document.getElementById('selectAllBtn').onclick = () => { cont.querySelectorAll('input[type=checkbox]').forEach(c => { c.checked = true; }); applyFiltersAndRender(); };
document.getElementById('clearAllBtn').onclick = () => { cont.querySelectorAll('input[type=checkbox]').forEach(c => { c.checked = false; }); applyFiltersAndRender(); };
}
function getEnabledFamilies() {
const cont = document.getElementById('filters');
const enabled = new Set();
cont.querySelectorAll('input[type=checkbox]').forEach(c => {
const lbl = c.closest('label');
const name = lbl && lbl.querySelector('span:last-child') ? lbl.querySelector('span:last-child').textContent : '';
if (c.checked && name) enabled.add(name);
});
return enabled;
}
function applyFiltersAndRender() {
const enabled = getEnabledFamilies();
const onlyReality = document.getElementById('onlyRealityCheck')?.checked;
const res = buildStackedHistogram(allFeatures, enabled, onlyReality);
renderChart(res.labels, res.datasets);
renderTable(allFeatures, enabled);
}
function buildFamilyGraph(features) {
const seen = new Set();
const nodes = [];
const links = [];
function addNode(name) {
if (!seen.has(name)) {
seen.add(name);
const top = name.split('.')[0];
nodes.push({ id: name, group: top });
}
}
const whats = new Set();
features.forEach(f => { const w = f.properties && f.properties.what; if (w) whats.add(String(w)); });
whats.forEach(w => {
const parts = w.split('.');
let cur = '';
for (let i = 0; i < parts.length; i++) {
cur = i === 0 ? parts[0] : cur + '.' + parts[i];
addNode(cur);
if (i > 0) {
const parent = cur.slice(0, cur.lastIndexOf('.'));
addNode(parent);
links.push({ source: parent, target: cur });
}
}
});
return { nodes, links };
}
let familiesSim = null;
function renderFamiliesGraph(features) {
const { nodes, links } = buildFamilyGraph(features);
const container = document.getElementById('familiesGraph');
const width = container.clientWidth || 800;
const height = container.clientHeight || 360;
container.innerHTML = '';
const svg = d3.select(container).append('svg').attr('width', width).attr('height', height);
const color = d => familyToColor.get(d.group) || '#bbb';
const link = svg.append('g').attr('stroke', '#aaa').attr('stroke-opacity', 0.7)
.selectAll('line').data(links).join('line').attr('stroke-width', 1.5);
const node = svg.append('g').attr('stroke', '#fff').attr('stroke-width', 1.5)
.selectAll('circle').data(nodes).join('circle')
.attr('r', d => d.id.indexOf('.') === -1 ? 8 : 5)
.attr('fill', color)
.call(d3.drag()
.on('start', (event, d) => { if (!event.active) familiesSim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
.on('drag', (event, d) => { d.fx = event.x; d.fy = event.y; })
.on('end', (event, d) => { if (!event.active) familiesSim.alphaTarget(0); d.fx = null; d.fy = null; }));
const labels = svg.append('g').selectAll('text').data(nodes).join('text')
.text(d => d.id)
.attr('font-size', '10px')
.attr('fill', '#333');
familiesSim = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id).distance(d => d.target.id.indexOf('.') === -1 ? 40 : 25).strength(0.8))
.force('charge', d3.forceManyBody().strength(-120))
.force('center', d3.forceCenter(width / 2, height / 2))
.on('tick', () => {
link.attr('x1', d => d.source.x).attr('y1', d => d.source.y).attr('x2', d => d.target.x).attr('y2', d => d.target.y);
node.attr('cx', d => d.x).attr('cy', d => d.y);
labels.attr('x', d => d.x + 10).attr('y', d => d.y + 3);
});
const zoom = d3.zoom().scaleExtent([0.5, 5]).on('zoom', (event) => {
svg.selectAll('g').attr('transform', event.transform);
});
svg.call(zoom);
}
document.getElementById('refreshBtn').addEventListener('click', loadData);
loadData();
setInterval(loadData, 60 * 1000);
</script>
</body>
</html>
"""
resp.text = html
resp.status = falcon.HTTP_200
live = LiveResource()

86
oedb/resources/rss.py Normal file
View file

@ -0,0 +1,86 @@
"""
RSS feeds for recent events and by family.
"""
import falcon
import html
from datetime import datetime
from oedb.utils.logging import logger
from oedb.utils.db import db_connect
def _rss_header(title: str, link: str, desc: str) -> str:
return f"""<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>{html.escape(title)}</title>
<link>{html.escape(link)}</link>
<description>{html.escape(desc)}</description>
"""
def _rss_footer() -> str:
return """
</channel>
</rss>
"""
def _row_to_item(row) -> str:
events_id, events_tags, createdate = row
title = html.escape(str(events_tags.get('label') or events_tags.get('what') or f"event {events_id}"))
link = f"https://api.openeventdatabase.org/event/{events_id}"
guid = link
pubdate = datetime.fromisoformat(str(createdate)).strftime('%a, %d %b %Y %H:%M:%S %z') if createdate else ''
description = html.escape(str(events_tags))
return f"""
<item>
<title>{title}</title>
<link>{link}</link>
<guid>{guid}</guid>
<pubDate>{pubdate}</pubDate>
<description>{description}</description>
</item>
"""
class RSSLatestResource:
def on_get(self, req, resp):
logger.info("Processing GET /rss")
resp.content_type = 'application/rss+xml; charset=utf-8'
db = db_connect()
cur = db.cursor()
cur.execute("""
SELECT events_id, events_tags, createdate
FROM events
ORDER BY createdate DESC
LIMIT 200
""")
items = ''.join(_row_to_item(r) for r in cur.fetchall())
xml = _rss_header('OEDB - Latest events', 'https://api.openeventdatabase.org/event', 'Latest 200 events') + items + _rss_footer()
resp.text = xml
class RSSByFamilyResource:
def on_get(self, req, resp, family: str):
logger.info(f"Processing GET /rss/by/{family}")
resp.content_type = 'application/rss+xml; charset=utf-8'
db = db_connect()
cur = db.cursor()
like = family + '%'
cur.execute("""
SELECT events_id, events_tags, createdate
FROM events
WHERE events_what LIKE %s
ORDER BY createdate DESC
LIMIT 200
""", (like,))
items = ''.join(_row_to_item(r) for r in cur.fetchall())
xml = _rss_header(f'OEDB - {family}', f'https://api.openeventdatabase.org/event?what={family}', f'Latest 200 events in {family}') + items + _rss_footer()
resp.text = xml
rss_latest = RSSLatestResource()
rss_by_family = RSSByFamilyResource()

48
test_osm_cal_api.py Normal file
View file

@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""
Test script for the OSM Calendar extractor with different parameter combinations.
This script tests the functionality of the osm_cal.py script with various
combinations of max_events and offset parameters.
"""
import sys
import os
from extractors.osm_cal import main as osm_cal_main
def run_test(max_events, offset):
"""
Run the OSM Calendar extractor with the specified parameters.
Args:
max_events (int): Maximum number of events to insert
offset (int): Number of events to skip from the beginning of the RSS feed
"""
print(f"\n=== Testing with max_events={max_events}, offset={offset} ===")
osm_cal_main(max_events=max_events, offset=offset)
print("=== Test completed ===\n")
def main():
"""
Run tests with different parameter combinations.
"""
print("Starting OSM Calendar API tests...")
# Test 1: Default parameters (max_events=1, offset=0)
run_test(1, 0)
# Test 2: Multiple events (max_events=3, offset=0)
run_test(3, 0)
# Test 3: With offset (max_events=2, offset=2)
run_test(2, 2)
# Test 4: Large offset (max_events=1, offset=10)
run_test(1, 10)
# Test 5: Large max_events (max_events=10, offset=0)
run_test(10, 0)
print("All tests completed.")
if __name__ == "__main__":
main()

128
test_osm_cal_ical.py Normal file
View file

@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""
Test script for the OSM Calendar extractor's iCal functionality.
This script tests the ability of the osm_cal.py script to extract
geographic coordinates and location information from iCal files
for events that don't have this information in the RSS feed.
"""
import sys
import os
from extractors.osm_cal import fetch_ical_data, OSMCAL_EVENT_BASE_URL
from oedb.utils.logging import logger
def test_fetch_ical_data():
"""
Test the fetch_ical_data function with a real OSM Calendar event.
"""
print("\n=== Testing fetch_ical_data function ===")
# Test with a real OSM Calendar event URL
# Note: This event ID might not exist in the future, so the test might fail
event_url = f"{OSMCAL_EVENT_BASE_URL}3973"
print(f"Fetching iCal data for event: {event_url}")
location_name, coordinates = fetch_ical_data(event_url)
print(f"Location name: {location_name}")
print(f"Coordinates: {coordinates}")
if coordinates != [0, 0]:
print("✅ Successfully extracted coordinates from iCal")
else:
print("❌ Failed to extract coordinates from iCal")
if location_name != "Unknown Location":
print("✅ Successfully extracted location name from iCal")
else:
print("❌ Failed to extract location name from iCal")
print("=== Test completed ===\n")
return coordinates != [0, 0] and location_name != "Unknown Location"
def test_with_invalid_url():
"""
Test the fetch_ical_data function with an invalid URL.
"""
print("\n=== Testing fetch_ical_data with invalid URL ===")
# Test with an invalid event URL
event_url = f"{OSMCAL_EVENT_BASE_URL}999999999"
print(f"Fetching iCal data for invalid event: {event_url}")
location_name, coordinates = fetch_ical_data(event_url)
print(f"Location name: {location_name}")
print(f"Coordinates: {coordinates}")
if coordinates == [0, 0]:
print("✅ Correctly returned default coordinates for invalid URL")
else:
print("❌ Unexpectedly returned coordinates for invalid URL")
if location_name == "Unknown Location":
print("✅ Correctly returned default location name for invalid URL")
else:
print("❌ Unexpectedly returned location name for invalid URL")
print("=== Test completed ===\n")
return coordinates == [0, 0] and location_name == "Unknown Location"
def test_with_non_osmcal_url():
"""
Test the fetch_ical_data function with a non-OSM Calendar URL.
"""
print("\n=== Testing fetch_ical_data with non-OSM Calendar URL ===")
# Test with a non-OSM Calendar URL
event_url = "https://example.com/event/123"
print(f"Fetching iCal data for non-OSM Calendar URL: {event_url}")
location_name, coordinates = fetch_ical_data(event_url)
print(f"Location name: {location_name}")
print(f"Coordinates: {coordinates}")
if coordinates == [0, 0]:
print("✅ Correctly returned default coordinates for non-OSM Calendar URL")
else:
print("❌ Unexpectedly returned coordinates for non-OSM Calendar URL")
if location_name == "Unknown Location":
print("✅ Correctly returned default location name for non-OSM Calendar URL")
else:
print("❌ Unexpectedly returned location name for non-OSM Calendar URL")
print("=== Test completed ===\n")
return coordinates == [0, 0] and location_name == "Unknown Location"
def main():
"""
Run all tests.
"""
print("Starting OSM Calendar iCal tests...")
# Run tests
test1 = test_fetch_ical_data()
test2 = test_with_invalid_url()
test3 = test_with_non_osmcal_url()
# Print summary
print("\n=== Test Summary ===")
print(f"Test 1 (fetch_ical_data): {'PASSED' if test1 else 'FAILED'}")
print(f"Test 2 (invalid URL): {'PASSED' if test2 else 'FAILED'}")
print(f"Test 3 (non-OSM Calendar URL): {'PASSED' if test3 else 'FAILED'}")
if test1 and test2 and test3:
print("\n✅ All tests PASSED")
else:
print("\n❌ Some tests FAILED")
print("All tests completed.")
if __name__ == "__main__":
main()

19
uwsgi.ini Normal file
View file

@ -0,0 +1,19 @@
[uwsgi]
http = :8080
http-websockets = true
wsgi-file = wsgi_websocket.py
chdir = .
virtualenv = venv
async = 100
ugreen = true
processes = 2
threads = 4
master = true
vacuum = true
die-on-term = true
pythonpath = .
buffer-size = 65535
harakiri = 30
max-requests = 1000
socket-timeout = 120
log-date = true

194
wsgi_websocket.py Normal file
View file

@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""
Configuration uWSGI pour l'intégration des WebSockets avec OEDB
"""
import os
import sys
import json
import time
import asyncio
from oedb.utils.logging import logger
# Import l'application Falcon principale
sys.path.insert(0, os.path.dirname(__file__))
from backend import app
# Import le gestionnaire WebSocket
from oedb.resources.demo.websocket import WebSocketManager
# Créer une instance du gestionnaire WebSocket
ws_manager = WebSocketManager()
# Gestionnaire de WebSocket pour uWSGI
def websocket_handler(environ, start_response):
"""Gestionnaire pour les connexions WebSocket."""
path = environ.get('PATH_INFO', '')
# Vérifier si c'est une requête WebSocket et qu'elle est sur le bon chemin
if path == '/ws' and environ.get('HTTP_UPGRADE', '').lower() == 'websocket':
try:
# Importer uwsgi ici car il n'est disponible qu'à l'exécution dans l'environnement uWSGI
import uwsgi
# Passer la requête au gestionnaire de WebSocket uWSGI
uwsgi.websocket_handshake()
logger.info(f"Nouvelle connexion WebSocket établie")
client_id = id(environ)
# Simuler la fonction handle_connection pour ce client
try:
# Ajouter le client à la liste
with ws_manager.lock:
ws_manager.clients[client_id] = {
'websocket': environ, # Utiliser environ comme proxy
'username': None,
'position': None,
'last_seen': time.time(),
'show_only_to_friends': False,
}
# Envoyer la liste des utilisateurs connectés
users_list = []
with ws_manager.lock:
for client_info in ws_manager.clients.values():
if client_info.get('username'):
users_list.append({
'username': client_info['username'],
'timestamp': time.time()
})
# Envoyer la liste des utilisateurs
uwsgi.websocket_send(json.dumps({
'type': 'users',
'users': users_list
}).encode('utf-8'))
# Boucle de traitement des messages
while True:
msg = uwsgi.websocket_recv()
data = json.loads(msg.decode('utf-8'))
message_type = data.get('type')
if message_type == 'position':
# Traiter la mise à jour de position
username = data.get('username')
position = data.get('position')
timestamp = data.get('timestamp')
show_only_to_friends = data.get('showOnlyToFriends', False)
# Mettre à jour les informations du client
with ws_manager.lock:
if client_id in ws_manager.clients:
ws_manager.clients[client_id]['username'] = username
ws_manager.clients[client_id]['position'] = position
ws_manager.clients[client_id]['last_seen'] = time.time()
ws_manager.clients[client_id]['show_only_to_friends'] = show_only_to_friends
# Mettre à jour dans le dictionnaire des positions
ws_manager.positions[username] = {
'position': position,
'timestamp': timestamp,
'show_only_to_friends': show_only_to_friends
}
# Diffuser à tous les autres clients
broadcast_position(username, position, timestamp, show_only_to_friends)
elif message_type in ['pouet', 'friendRequest']:
# Rediriger ces messages vers les destinataires
handle_direct_message(data, message_type)
except Exception as e:
logger.error(f"Erreur WebSocket: {e}")
finally:
# Supprimer le client de la liste
with ws_manager.lock:
if client_id in ws_manager.clients:
username = ws_manager.clients[client_id].get('username')
if username and username in ws_manager.positions:
del ws_manager.positions[username]
del ws_manager.clients[client_id]
except Exception as e:
logger.error(f"Erreur de connexion WebSocket: {e}")
return ['500 Internal Server Error', [], [b'WebSocket Error']]
# Si ce n'est pas une requête WebSocket, passer à l'application WSGI
return app(environ, start_response)
# Fonctions utilitaires pour les WebSockets uWSGI
def broadcast_position(username, position, timestamp, show_only_to_friends):
"""Diffuse la position d'un utilisateur à tous les autres utilisateurs."""
import uwsgi
message = json.dumps({
'type': 'position',
'username': username,
'position': position,
'timestamp': timestamp,
'showOnlyToFriends': show_only_to_friends
}).encode('utf-8')
with ws_manager.lock:
for client_id, client_info in ws_manager.clients.items():
# Ne pas envoyer à l'utilisateur lui-même
if client_info.get('username') == username:
continue
try:
# Dans uWSGI, nous devons utiliser environ pour identifier le socket
if 'websocket' in client_info:
uwsgi.websocket_send(message)
except Exception as e:
logger.error(f"Erreur d'envoi de broadcast de position: {e}")
def handle_direct_message(data, message_type):
"""Traite un message direct (pouet ou demande d'ami)."""
import uwsgi
from_user = data.get('from')
to_user = data.get('to')
if not from_user or not to_user:
return
# Trouver le client destinataire
recipient_client_id = None
with ws_manager.lock:
for client_id, client_info in ws_manager.clients.items():
if client_info.get('username') == to_user:
recipient_client_id = client_id
break
if recipient_client_id and recipient_client_id in ws_manager.clients:
# Préparer le message selon le type
if message_type == 'pouet':
msg = json.dumps({
'type': 'pouet',
'from': from_user,
'timestamp': data.get('timestamp')
}).encode('utf-8')
logger.info(f"Pouet pouet envoyé de {from_user} à {to_user}")
elif message_type == 'friendRequest':
msg = json.dumps({
'type': 'friendRequest',
'from': from_user,
'timestamp': data.get('timestamp')
}).encode('utf-8')
logger.info(f"Demande d'ami envoyée de {from_user} à {to_user}")
# Envoyer le message
try:
uwsgi.websocket_send(msg)
except Exception as e:
logger.error(f"Erreur d'envoi de message direct: {e}")
# Appliquer le patch au démarrage
patch_websocket_manager()
# Application WSGI pour uWSGI
application = websocket_handler
# Remarque: Pour utiliser ce fichier, démarrez uWSGI avec:
# uwsgi --http :8080 --http-websockets --wsgi-file wsgi_websocket.py --async 100 --ugreen