Compare commits

..

7 Commits

Author SHA1 Message Date
Matteo Rosati
de53b760c4 add center and radius params 2026-01-18 11:41:55 +01:00
Matteo Rosati
6cd8997516 add example api response 2026-01-18 00:38:53 +01:00
Matteo Rosati
dac7c2dbcf update copy in dockerfile 2026-01-18 00:32:32 +01:00
Matteo Rosati
e6c37f38f5 move util functions to lib file 2026-01-18 00:30:49 +01:00
Matteo Rosati
4f3919d02d move util functions to lib file 2026-01-18 00:30:39 +01:00
Matteo Rosati
9c1d0b9b32 remove old script 2026-01-18 00:26:57 +01:00
Matteo Rosati
fdaf72f103 use flask cors 2026-01-16 10:18:06 +01:00
9 changed files with 663 additions and 348 deletions

View File

@@ -19,6 +19,7 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy application files # Copy application files
COPY app.py . COPY app.py .
COPY lib.py .
COPY ingress.py . COPY ingress.py .
COPY models.py . COPY models.py .

View File

@@ -401,6 +401,81 @@ curl "http://localhost:7000/plexts?min_timestamp=2026-01-12T10:00:00Z&max_timest
curl "http://localhost:7000/plexts?event_types=PORTAL_CAPTURED&player_name=Albicocca&min_timestamp=2026-01-12T10:00:00Z" curl "http://localhost:7000/plexts?event_types=PORTAL_CAPTURED&player_name=Albicocca&min_timestamp=2026-01-12T10:00:00Z"
``` ```
##### GET /plexts/from-db
Get plexts from MongoDB with optional filters. Returns plexts sorted by timestamp (most recent first).
**Query Parameters:**
| Parameter | Type | Default | Description |
| --------------- | ------- | -------- | --------------------------------------------------- |
| `player_name` | String | None | Player name to filter by |
| `timestamp_from` | Integer | None | Minimum timestamp in milliseconds (optional) |
| `timestamp_to` | Integer | None | Maximum timestamp in milliseconds (optional) |
| `limit` | Integer | 100 | Maximum number of results to return |
| `center` | String | None | Geographic center point in format "latitude,longitude" (optional, requires radius) |
| `radius` | Integer | None | Search radius in meters (optional, requires center) |
**Geospatial Query:**
When both `center` and `radius` parameters are provided, the endpoint returns plexts within the specified radius from the center point. The response includes a `distance` field (in meters) for each result.
**Response with geospatial query:**
```json
{
"count": 5,
"plexts": [
{
"id": "abc123",
"timestamp": 1736659207000,
"timestamp_formatted": "2026-01-12 11:00:07",
"text": "Albicocca captured L' Arboreto",
"team": "RESISTANCE",
"plext_type": "PLAYER_GENERATED",
"categories": 0,
"event_type": "PORTAL_CAPTURED",
"player_name": "Albicocca",
"portal_name": "L' Arboreto",
"coordinates": {
"type": "Point",
"coordinates": [12.365208, 45.573661]
},
"distance": 125.5,
"markup": [...]
}
]
}
```
**Note:** Both `center` and `radius` parameters must be provided together. If neither is provided, the endpoint returns all plexts matching other filters without geospatial filtering.
**Examples:**
```bash
# Get all plexts from MongoDB
curl "http://localhost:7000/plexts/from-db"
# Filter by player name
curl "http://localhost:7000/plexts/from-db?player_name=Albicocca"
# Filter by time range
curl "http://localhost:7000/plexts/from-db?timestamp_from=17366592000000&timestamp_to=1736745600000"
# Geospatial query: Get plexts within 1km of a specific location
curl "http://localhost:7000/plexts/from-db?center=45.573661,12.365208&radius=1000"
# Geospatial query with player filter
curl "http://localhost:7000/plexts/from-db?center=45.573661,12.365208&radius=1000&player_name=Albicocca"
# Geospatial query with time filter
curl "http://localhost:7000/plexts/from-db?center=45.573661,12.365208&radius=1000&timestamp_from=17366592000000"
# Combine all filters
curl "http://localhost:7000/plexts/from-db?center=45.573661,12.365208&radius=1000&player_name=Albicocca&timestamp_from=17366592000000&limit=10"
```
**Authentication:** Basic Auth required
### Scheduler ### Scheduler
The scheduler automatically collects Ingress events every minute and stores them in MongoDB. The scheduler automatically collects Ingress events every minute and stores them in MongoDB.

313
app.py
View File

@@ -1,161 +1,26 @@
import os import os
import logging from flask import Flask, request, jsonify
from datetime import datetime from flask_cors import CORS
from zoneinfo import ZoneInfo
from functools import wraps
from flask import Flask, request, jsonify, Response
from ingress import IngressAPI from ingress import IngressAPI
from models import EventType, Plext from models import EventType
from pymongo import MongoClient from pymongo import MongoClient
from pymongo.errors import PyMongoError from pymongo.errors import PyMongoError
from dotenv import load_dotenv from dotenv import load_dotenv
from lib import (
check_basic_auth,
basic_auth_required,
parse_timestamp,
plext_to_dict,
logger,
)
load_dotenv() load_dotenv()
# Timezone configuration
TIMEZONE = ZoneInfo("Europe/Rome")
# Configure logging
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
app = Flask(__name__) app = Flask(__name__)
CORS(app)
def check_basic_auth(username: str, password: str) -> bool: @app.route("/plexts/from-db", methods=["GET"])
"""
Check if the provided username and password match the configured credentials.
Args:
username: Username from the request
password: Password from the request
Returns:
True if credentials match, False otherwise
"""
expected_username = os.getenv("BASIC_AUTH_USER")
expected_password = os.getenv("BASIC_AUTH_PASSWORD")
if not expected_username or not expected_password:
logger.warning("BASIC_AUTH_USER or BASIC_AUTH_PASSWORD not configured")
return False
return username == expected_username and password == expected_password
def basic_auth_required(f):
"""
Decorator to require basic authentication for an endpoint.
Returns:
401 Unauthorized if authentication fails or is missing
"""
@wraps(f)
def decorated(*args, **kwargs):
auth = request.authorization
if not auth or not check_basic_auth(auth.username, auth.password):
return Response(
"Could not verify your access level for that URL.\n"
"You have to login with proper credentials",
401,
{"WWW-Authenticate": 'Basic realm="Login Required"'},
)
return f(*args, **kwargs)
return decorated
def parse_timestamp(value: str) -> int:
"""
Parse timestamp from either milliseconds (int) or ISO 8601 string.
Args:
value: Either integer milliseconds or ISO 8601 string
Returns:
Timestamp in milliseconds since epoch
Raises:
ValueError: If format is invalid
"""
# Try parsing as integer (milliseconds)
try:
return int(value)
except ValueError:
pass
# Try parsing as ISO 8601 datetime
try:
# Handle 'Z' suffix (UTC)
iso_value = value.replace("Z", "+00:00")
# Handle timezone offset without colon (e.g., +0100 -> +01:00)
# Match pattern like +0100 or -0100 at the end of the string
import re
match = re.search(r"([+-])(\d{2})(\d{2})$", iso_value)
if match:
sign, hours, minutes = match.groups()
iso_value = re.sub(
r"([+-])(\d{2})(\d{2})$", f"{sign}{hours}:{minutes}", iso_value
)
dt = datetime.fromisoformat(iso_value)
return int(dt.timestamp() * 1000)
except ValueError:
raise ValueError(
f"Invalid timestamp format: {value}. "
"Use milliseconds (e.g., 1736659200000) or "
"ISO 8601 format (e.g., '2026-01-12T10:00:00Z' or '2026-01-12T10:00:00+01:00')"
)
def plext_to_dict(plext: Plext) -> dict:
"""
Convert a Plext object to a dictionary for JSON serialization.
Args:
plext: Plext object to convert
Returns:
Dictionary representation of the plext
"""
coords = plext.get_event_coordinates()
# Convert timestamp from milliseconds to datetime in Europe/Rome timezone
dt = datetime.fromtimestamp(plext.timestamp / 1000.0, tz=TIMEZONE)
return {
"id": plext.id,
"timestamp": plext.timestamp,
"timestamp_formatted": dt.strftime("%Y-%m-%d %H:%M:%S"),
"text": plext.text,
"team": plext.team,
"plext_type": plext.plext_type,
"categories": plext.categories,
"event_type": plext.get_event_type().name,
"player_name": plext.get_player_name(),
"portal_name": plext.get_portal_name(),
"coordinates": {"lat": coords[0], "lng": coords[1]} if coords else None,
"markup": [
{
"type": m.type,
"plain": m.plain,
"team": m.team,
"name": m.name,
"address": m.address,
"latE6": m.latE6,
"lngE6": m.lngE6,
}
for m in plext.markup
],
}
@app.route("/plexts/from-db", methods=["GET", "OPTIONS"])
@basic_auth_required @basic_auth_required
def get_plexts_from_db(): def get_plexts_from_db():
""" """
@@ -167,68 +32,84 @@ def get_plexts_from_db():
timestamp_from: Minimum timestamp in milliseconds (optional) timestamp_from: Minimum timestamp in milliseconds (optional)
timestamp_to: Maximum timestamp in milliseconds (optional) timestamp_to: Maximum timestamp in milliseconds (optional)
limit: Maximum number of results to return (optional, default: 100) limit: Maximum number of results to return (optional, default: 100)
center: Geographic center point in format "latitude,longitude" (optional, requires radius)
radius: Search radius in meters (optional, requires center)
Returns: Returns:
JSON response with list of plexts (without _id field), limited by the limit parameter JSON response with list of plexts (without _id field), limited by the limit parameter.
When using geospatial query (center and radius), includes a 'distance' field in meters.
""" """
# CORS headers
cors_headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
}
# Handle OPTIONS preflight request
if request.method == "OPTIONS":
return "", 200, cors_headers
try: try:
# Parse query parameters # Parse query parameters
player_name = request.args.get("player_name") player_name = request.args.get("player_name")
timestamp_from = request.args.get("timestamp_from") timestamp_from = request.args.get("timestamp_from")
timestamp_to = request.args.get("timestamp_to") timestamp_to = request.args.get("timestamp_to")
limit_param = request.args.get("limit") limit_param = request.args.get("limit")
center = request.args.get("center")
radius = request.args.get("radius")
# Validate and convert timestamp parameters to integers if provided # Validate and convert timestamp parameters to integers if provided
if timestamp_from is not None: if timestamp_from is not None:
try: try:
timestamp_from = int(timestamp_from) timestamp_from = int(timestamp_from)
except ValueError: except ValueError:
return ( return jsonify({"error": "timestamp_from must be an integer"}), 400
jsonify({"error": "timestamp_from must be an integer"}),
400,
cors_headers,
)
if timestamp_to is not None: if timestamp_to is not None:
try: try:
timestamp_to = int(timestamp_to) timestamp_to = int(timestamp_to)
except ValueError: except ValueError:
return ( return jsonify({"error": "timestamp_to must be an integer"}), 400
jsonify({"error": "timestamp_to must be an integer"}),
400,
cors_headers,
)
# Validate and convert limit parameter to integer if provided, otherwise default to 100 # Validate and convert limit parameter to integer if provided, otherwise default to 100
if limit_param is not None: if limit_param is not None:
try: try:
limit = int(limit_param) limit = int(limit_param)
if limit <= 0: if limit <= 0:
return ( return jsonify({"error": "limit must be a positive integer"}), 400
jsonify({"error": "limit must be a positive integer"}),
400,
cors_headers,
)
except ValueError: except ValueError:
return ( return jsonify({"error": "limit must be an integer"}), 400
jsonify({"error": "limit must be an integer"}),
400,
cors_headers,
)
else: else:
limit = 100 limit = 100
# Validate geospatial parameters
# Both center and radius must be provided together, or neither
if (center is not None) != (radius is not None):
return jsonify({
"error": "Both 'center' and 'radius' parameters must be provided together"
}), 400
# Parse and validate center parameter if provided
center_coords = None
if center is not None:
try:
# Parse center in format "latitude,longitude"
lat_str, lng_str = center.split(',')
lat = float(lat_str)
lng = float(lng_str)
# Validate latitude and longitude ranges
if not (-90 <= lat <= 90):
return jsonify({"error": "Latitude must be between -90 and 90"}), 400
if not (-180 <= lng <= 180):
return jsonify({"error": "Longitude must be between -180 and 180"}), 400
center_coords = (lat, lng)
except ValueError:
return jsonify({
"error": "Invalid center format. Use format: 'latitude,longitude' (e.g., '45.573661,12.365208')"
}), 400
# Parse and validate radius parameter if provided
radius_meters = None
if radius is not None:
try:
radius_meters = int(radius)
if radius_meters <= 0:
return jsonify({"error": "Radius must be a positive integer"}), 400
except ValueError:
return jsonify({"error": "Radius must be an integer"}), 400
# Build MongoDB filter query # Build MongoDB filter query
filter_query = {} filter_query = {}
@@ -253,30 +134,71 @@ def get_plexts_from_db():
collection = db[collection_name] collection = db[collection_name]
try: try:
# Projection to exclude _id field # Ensure geospatial index exists
projection = {"_id": 0} try:
collection.create_index([("coordinates", "2dsphere")], background=True)
except PyMongoError as e:
logger.warning(f"Could not create geospatial index: {e}")
# Execute query with sorting (timestamp DESC - most recent first) and dynamic limit # Execute query based on whether geospatial parameters are provided
cursor = ( if center_coords and radius_meters:
collection.find(filter=filter_query, projection=projection) # Use $geoNear aggregation for geospatial query
.sort("timestamp", -1) lat, lng = center_coords
.limit(limit) pipeline = [
) {
"$geoNear": {
"near": {
"type": "Point",
"coordinates": [lng, lat] # GeoJSON uses [longitude, latitude]
},
"distanceField": "distance", # Add distance to results
"maxDistance": radius_meters, # in meters
"spherical": True # Use spherical geometry
}
},
{
"$match": filter_query # Apply other filters
},
{
"$sort": {"timestamp": -1} # Sort by timestamp (most recent first)
},
{
"$limit": limit
},
{
"$project": {
"_id": 0 # Exclude _id field
}
}
]
# Convert cursor to list # Execute aggregation pipeline
plexts = list(cursor) plexts = list(collection.aggregate(pipeline))
else:
# Use standard find() query for non-geospatial queries
projection = {"_id": 0}
return jsonify({"count": len(plexts), "plexts": plexts}), 200, cors_headers # Execute query with sorting (timestamp DESC - most recent first) and dynamic limit
cursor = (
collection.find(filter=filter_query, projection=projection)
.sort("timestamp", -1)
.limit(limit)
)
# Convert cursor to list
plexts = list(cursor)
return jsonify({"count": len(plexts), "plexts": plexts}), 200
except PyMongoError as e: except PyMongoError as e:
logger.error(f"MongoDB error: {e}") logger.error(f"MongoDB error: {e}")
return jsonify({"error": "Database error"}), 500, cors_headers return jsonify({"error": "Database error"}), 500
finally: finally:
client.close() client.close()
except Exception as e: except Exception as e:
logger.exception("Unexpected error in get_plexts_from_db") logger.exception("Unexpected error in get_plexts_from_db")
return jsonify({"error": "An error occurred"}), 500, cors_headers return jsonify({"error": "An error occurred"}), 500
@app.route("/plexts/from-api", methods=["GET"]) @app.route("/plexts/from-api", methods=["GET"])
@@ -399,8 +321,15 @@ def index():
"timestamp_from": "Minimum timestamp in milliseconds (optional)", "timestamp_from": "Minimum timestamp in milliseconds (optional)",
"timestamp_to": "Maximum timestamp in milliseconds (optional)", "timestamp_to": "Maximum timestamp in milliseconds (optional)",
"limit": "Maximum number of results to return (optional, default: 100)", "limit": "Maximum number of results to return (optional, default: 100)",
"center": "Geographic center point in format 'latitude,longitude' (optional, requires radius)",
"radius": "Search radius in meters (optional, requires center)",
}, },
"authentication": "Basic Auth required", "authentication": "Basic Auth required",
"geospatial_query": {
"description": "When both center and radius are provided, returns plexts within the specified radius from the center point. If neither is provided, returns all plexts matching other filters.",
"example": "center=45.573661,12.365208&radius=1000",
"response_field": "distance (meters) - included in results when using geospatial query",
},
}, },
}, },
} }

View File

@@ -1,156 +0,0 @@
#!/usr/bin/env python3
"""
Script to convert geographic data in MongoDB from E6 format to GeoJSON Point format.
This script updates all documents in the ingress collection to use proper MongoDB GeoJSON format.
"""
from pymongo import MongoClient
from pymongo.errors import PyMongoError
import sys
def convert_e6_to_degrees(e6_value):
"""
Convert E6 format to degrees.
Args:
e6_value: Integer value in E6 format (multiply by 1e-6 to get degrees)
Returns:
Float value in degrees
"""
return e6_value / 1_000_000 if e6_value else 0.0
def create_geojson_point(lat_e6, lng_e6):
"""
Create a GeoJSON Point from E6 coordinates.
Args:
lat_e6: Latitude in E6 format
lng_e6: Longitude in E6 format
Returns:
GeoJSON Point object or None if coordinates are invalid
"""
if lat_e6 == 0 and lng_e6 == 0:
return None
lat = convert_e6_to_degrees(lat_e6)
lng = convert_e6_to_degrees(lng_e6)
# GeoJSON Point format: [longitude, latitude]
return {
"type": "Point",
"coordinates": [lng, lat]
}
def convert_document(doc):
"""
Convert a single document to use GeoJSON format.
Args:
doc: MongoDB document
Returns:
Updated document with GeoJSON coordinates
"""
updated = False
updates = {}
# Convert coordinates field
if "coordinates" in doc and isinstance(doc["coordinates"], dict):
coords = doc["coordinates"]
if "lat" in coords and "lng" in coords:
geojson = create_geojson_point(coords["lat"], coords["lng"])
if geojson:
updates["coordinates"] = geojson
updated = True
# Convert markup array
if "markup" in doc and isinstance(doc["markup"], list):
new_markup = []
for item in doc["markup"]:
new_item = item.copy()
if "latE6" in item and "lngE6" in item:
geojson = create_geojson_point(item["latE6"], item["lngE6"])
if geojson:
new_item["location"] = geojson
updated = True
new_markup.append(new_item)
if updated:
updates["markup"] = new_markup
return updates if updated else None
def main():
"""
Main function to convert all documents in the collection.
"""
# Connect to MongoDB
mongo_uri = "mongodb://root:root@localhost:27017/?authSource=admin"
db_name = "ingress"
collection_name = "ingress"
print(f"Connecting to MongoDB at {mongo_uri}...")
print(f"Database: {db_name}, Collection: {collection_name}")
try:
client = MongoClient(mongo_uri)
db = client[db_name]
collection = db[collection_name]
# Count total documents
total_docs = collection.count_documents({})
print(f"Total documents in collection: {total_docs}")
if total_docs == 0:
print("No documents found. Exiting.")
return
# Process documents in batches
batch_size = 100
processed = 0
updated = 0
skipped = 0
print(f"\nProcessing documents in batches of {batch_size}...")
cursor = collection.find({})
for doc in cursor:
processed += 1
# Convert document
updates = convert_document(doc)
if updates:
# Update the document
collection.update_one(
{"_id": doc["_id"]},
{"$set": updates}
)
updated += 1
else:
skipped += 1
# Progress indicator
if processed % batch_size == 0:
print(f"Processed: {processed}/{total_docs} | Updated: {updated} | Skipped: {skipped}")
print(f"\nConversion complete!")
print(f"Total processed: {processed}")
print(f"Total updated: {updated}")
print(f"Total skipped: {skipped}")
except PyMongoError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
finally:
client.close()
if __name__ == "__main__":
main()

286
example_api_response.md Normal file
View File

@@ -0,0 +1,286 @@
# Example API Response
**URL**: http://root:root@127.0.0.1:7001/plexts/from-db?limit=5
```json
{
"count": 5,
"plexts": [
{
"categories": 1,
"coordinates": {
"coordinates": [
12.365208,
45.573661
],
"type": "Point"
},
"event_type": "RESONATOR_DEPLOYED",
"id": "e3696bc7d78d41168a58a7133de10df4.d",
"markup": [
{
"address": "",
"latE6": 0,
"lngE6": 0,
"name": "",
"plain": "TerminateThis",
"team": "RESISTANCE",
"type": "PLAYER"
},
{
"address": "",
"latE6": 0,
"lngE6": 0,
"name": "",
"plain": " deployed a Resonator on ",
"team": "",
"type": "TEXT"
},
{
"address": "Viale della Resistenza, 20, 30020 Quarto D'altino VE, Italy",
"latE6": 45573661,
"lngE6": 12365208,
"location": {
"coordinates": [
12.365208,
45.573661
],
"type": "Point"
},
"name": "Murales Del Plaza",
"plain": "Murales Del Plaza (Viale della Resistenza, 20, 30020 Quarto D'altino VE, Italy)",
"team": "RESISTANCE",
"type": "PORTAL"
}
],
"player_name": "TerminateThis",
"plext_type": "SYSTEM_BROADCAST",
"portal_name": "Murales Del Plaza",
"team": "RESISTANCE",
"text": "TerminateThis deployed a Resonator on Murales Del Plaza (Viale della Resistenza, 20, 30020 Quarto D'altino VE, Italy)",
"timestamp": 1768548977306,
"timestamp_formatted": "2026-01-16 08:36:17"
},
{
"categories": 1,
"coordinates": {
"coordinates": [
12.365208,
45.573661
],
"type": "Point"
},
"event_type": "PORTAL_CAPTURED",
"id": "65f356117c654a06aea7f78b37c8291c.d",
"markup": [
{
"address": "",
"latE6": 0,
"lngE6": 0,
"name": "",
"plain": "TerminateThis",
"team": "RESISTANCE",
"type": "PLAYER"
},
{
"address": "",
"latE6": 0,
"lngE6": 0,
"name": "",
"plain": " captured ",
"team": "",
"type": "TEXT"
},
{
"address": "Viale della Resistenza, 20, 30020 Quarto D'altino VE, Italy",
"latE6": 45573661,
"lngE6": 12365208,
"location": {
"coordinates": [
12.365208,
45.573661
],
"type": "Point"
},
"name": "Murales Del Plaza",
"plain": "Murales Del Plaza (Viale della Resistenza, 20, 30020 Quarto D'altino VE, Italy)",
"team": "RESISTANCE",
"type": "PORTAL"
}
],
"player_name": "TerminateThis",
"plext_type": "SYSTEM_BROADCAST",
"portal_name": "Murales Del Plaza",
"team": "RESISTANCE",
"text": "TerminateThis captured Murales Del Plaza (Viale della Resistenza, 20, 30020 Quarto D'altino VE, Italy)",
"timestamp": 1768548977306,
"timestamp_formatted": "2026-01-16 08:36:17"
},
{
"categories": 1,
"coordinates": {
"coordinates": [
10.298689,
45.515071
],
"type": "Point"
},
"event_type": "PORTAL_CAPTURED",
"id": "6aec6787b5a84393a1aa2dec5dabc9d2.d",
"markup": [
{
"address": "",
"latE6": 0,
"lngE6": 0,
"name": "",
"plain": "Sca29",
"team": "RESISTANCE",
"type": "PLAYER"
},
{
"address": "",
"latE6": 0,
"lngE6": 0,
"name": "",
"plain": " captured ",
"team": "",
"type": "TEXT"
},
{
"address": "SS 11 Continente, 25086 Rezzato BS, Italy",
"latE6": 45515071,
"lngE6": 10298689,
"location": {
"coordinates": [
10.298689,
45.515071
],
"type": "Point"
},
"name": "Colli dei Longobardi - percorso della Strada del Vino",
"plain": "Colli dei Longobardi - percorso della Strada del Vino (SS 11 Continente, 25086 Rezzato BS, Italy)",
"team": "RESISTANCE",
"type": "PORTAL"
}
],
"player_name": "Sca29",
"plext_type": "SYSTEM_BROADCAST",
"portal_name": "Colli dei Longobardi - percorso della Strada del Vino",
"team": "RESISTANCE",
"text": "Sca29 captured Colli dei Longobardi - percorso della Strada del Vino (SS 11 Continente, 25086 Rezzato BS, Italy)",
"timestamp": 1768548962208,
"timestamp_formatted": "2026-01-16 08:36:02"
},
{
"categories": 1,
"coordinates": {
"coordinates": [
10.298689,
45.515071
],
"type": "Point"
},
"event_type": "RESONATOR_DEPLOYED",
"id": "e7c15d8d04bd4b43a276241c3c63337e.d",
"markup": [
{
"address": "",
"latE6": 0,
"lngE6": 0,
"name": "",
"plain": "Sca29",
"team": "RESISTANCE",
"type": "PLAYER"
},
{
"address": "",
"latE6": 0,
"lngE6": 0,
"name": "",
"plain": " deployed a Resonator on ",
"team": "",
"type": "TEXT"
},
{
"address": "SS 11 Continente, 25086 Rezzato BS, Italy",
"latE6": 45515071,
"lngE6": 10298689,
"location": {
"coordinates": [
10.298689,
45.515071
],
"type": "Point"
},
"name": "Colli dei Longobardi - percorso della Strada del Vino",
"plain": "Colli dei Longobardi - percorso della Strada del Vino (SS 11 Continente, 25086 Rezzato BS, Italy)",
"team": "RESISTANCE",
"type": "PORTAL"
}
],
"player_name": "Sca29",
"plext_type": "SYSTEM_BROADCAST",
"portal_name": "Colli dei Longobardi - percorso della Strada del Vino",
"team": "RESISTANCE",
"text": "Sca29 deployed a Resonator on Colli dei Longobardi - percorso della Strada del Vino (SS 11 Continente, 25086 Rezzato BS, Italy)",
"timestamp": 1768548962208,
"timestamp_formatted": "2026-01-16 08:36:02"
},
{
"categories": 1,
"coordinates": {
"coordinates": [
12.364941,
45.574634
],
"type": "Point"
},
"event_type": "PORTAL_CAPTURED",
"id": "a604bddc4ca34d838fd2eb7e965aea23.d",
"markup": [
{
"address": "",
"latE6": 0,
"lngE6": 0,
"name": "",
"plain": "TerminateThis",
"team": "RESISTANCE",
"type": "PLAYER"
},
{
"address": "",
"latE6": 0,
"lngE6": 0,
"name": "",
"plain": " captured ",
"team": "",
"type": "TEXT"
},
{
"address": "Via Stazione, 155, 30020 Quarto D'altino Venice, Italy",
"latE6": 45574634,
"lngE6": 12364941,
"location": {
"coordinates": [
12.364941,
45.574634
],
"type": "Point"
},
"name": "Quarto D'Altino - Stazione FS",
"plain": "Quarto D'Altino - Stazione FS (Via Stazione, 155, 30020 Quarto D'altino Venice, Italy)",
"team": "RESISTANCE",
"type": "PORTAL"
}
],
"player_name": "TerminateThis",
"plext_type": "SYSTEM_BROADCAST",
"portal_name": "Quarto D'Altino - Stazione FS",
"team": "RESISTANCE",
"text": "TerminateThis captured Quarto D'Altino - Stazione FS (Via Stazione, 155, 30020 Quarto D'altino Venice, Italy)",
"timestamp": 1768548949420,
"timestamp_formatted": "2026-01-16 08:35:49"
}
]
}
```

147
lib.py Normal file
View File

@@ -0,0 +1,147 @@
import os
import logging
from datetime import datetime
from zoneinfo import ZoneInfo
from functools import wraps
from flask import request, Response
from models import Plext
# Timezone configuration
TIMEZONE = ZoneInfo("Europe/Rome")
# Configure logging
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
def check_basic_auth(username: str, password: str) -> bool:
"""
Check if the provided username and password match the configured credentials.
Args:
username: Username from the request
password: Password from the request
Returns:
True if credentials match, False otherwise
"""
expected_username = os.getenv("BASIC_AUTH_USER")
expected_password = os.getenv("BASIC_AUTH_PASSWORD")
if not expected_username or not expected_password:
logger.warning("BASIC_AUTH_USER or BASIC_AUTH_PASSWORD not configured")
return False
return username == expected_username and password == expected_password
def basic_auth_required(f):
"""
Decorator to require basic authentication for an endpoint.
Returns:
401 Unauthorized if authentication fails or is missing
"""
@wraps(f)
def decorated(*args, **kwargs):
auth = request.authorization
if not auth or not check_basic_auth(auth.username, auth.password):
return Response(
"Could not verify your access level for that URL.\n"
"You have to login with proper credentials",
401,
{"WWW-Authenticate": 'Basic realm="Login Required"'},
)
return f(*args, **kwargs)
return decorated
def parse_timestamp(value: str) -> int:
"""
Parse timestamp from either milliseconds (int) or ISO 8601 string.
Args:
value: Either integer milliseconds or ISO 8601 string
Returns:
Timestamp in milliseconds since epoch
Raises:
ValueError: If format is invalid
"""
# Try parsing as integer (milliseconds)
try:
return int(value)
except ValueError:
pass
# Try parsing as ISO 8601 datetime
try:
# Handle 'Z' suffix (UTC)
iso_value = value.replace("Z", "+00:00")
# Handle timezone offset without colon (e.g., +0100 -> +01:00)
# Match pattern like +0100 or -0100 at the end of the string
import re
match = re.search(r"([+-])(\d{2})(\d{2})$", iso_value)
if match:
sign, hours, minutes = match.groups()
iso_value = re.sub(
r"([+-])(\d{2})(\d{2})$", f"{sign}{hours}:{minutes}", iso_value
)
dt = datetime.fromisoformat(iso_value)
return int(dt.timestamp() * 1000)
except ValueError:
raise ValueError(
f"Invalid timestamp format: {value}. "
"Use milliseconds (e.g., 1736659200000) or "
"ISO 8601 format (e.g., '2026-01-12T10:00:00Z' or '2026-01-12T10:00:00+01:00')"
)
def plext_to_dict(plext: Plext) -> dict:
"""
Convert a Plext object to a dictionary for JSON serialization.
Args:
plext: Plext object to convert
Returns:
Dictionary representation of the plext
"""
coords = plext.get_event_coordinates()
# Convert timestamp from milliseconds to datetime in Europe/Rome timezone
dt = datetime.fromtimestamp(plext.timestamp / 1000.0, tz=TIMEZONE)
return {
"id": plext.id,
"timestamp": plext.timestamp,
"timestamp_formatted": dt.strftime("%Y-%m-%d %H:%M:%S"),
"text": plext.text,
"team": plext.team,
"plext_type": plext.plext_type,
"categories": plext.categories,
"event_type": plext.get_event_type().name,
"player_name": plext.get_player_name(),
"portal_name": plext.get_portal_name(),
"coordinates": {"lat": coords[0], "lng": coords[1]} if coords else None,
"markup": [
{
"type": m.type,
"plain": m.plain,
"team": m.team,
"name": m.name,
"address": m.address,
"latE6": m.latE6,
"lngE6": m.lngE6,
}
for m in plext.markup
],
}

View File

@@ -12,4 +12,5 @@ dependencies = [
"pymongo>=4.10.0", "pymongo>=4.10.0",
"apscheduler>=3.10.0", "apscheduler>=3.10.0",
"ipython>=9.9.0", "ipython>=9.9.0",
"flask-cors>=6.0.2",
] ]

View File

@@ -1,19 +1,36 @@
apscheduler==3.11.2 apscheduler==3.11.2
asttokens==3.0.1
blinker==1.9.0 blinker==1.9.0
certifi==2026.1.4 certifi==2026.1.4
charset-normalizer==3.4.4 charset-normalizer==3.4.4
click==8.3.1 click==8.3.1
decorator==5.2.1
dnspython==2.8.0 dnspython==2.8.0
executing==2.2.1
flask==3.1.2 flask==3.1.2
flask-cors==6.0.2
gunicorn==23.0.0 gunicorn==23.0.0
idna==3.11 idna==3.11
ipython==9.9.0
ipython-pygments-lexers==1.1.1
itsdangerous==2.2.0 itsdangerous==2.2.0
jedi==0.19.2
jinja2==3.1.6 jinja2==3.1.6
markupsafe==3.0.3 markupsafe==3.0.3
matplotlib-inline==0.2.1
packaging==25.0 packaging==25.0
parso==0.8.5
pexpect==4.9.0
prompt-toolkit==3.0.52
ptyprocess==0.7.0
pure-eval==0.2.3
pygments==2.19.2
pymongo==4.16.0 pymongo==4.16.0
python-dotenv==1.2.1 python-dotenv==1.2.1
requests==2.32.5 requests==2.32.5
stack-data==0.6.3
traitlets==5.14.3
tzlocal==5.3.1 tzlocal==5.3.1
urllib3==2.6.3 urllib3==2.6.3
wcwidth==0.2.14
werkzeug==3.1.5 werkzeug==3.1.5

15
uv.lock generated
View File

@@ -147,6 +147,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" }, { url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" },
] ]
[[package]]
name = "flask-cors"
version = "6.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/70/74/0fc0fa68d62f21daef41017dafab19ef4b36551521260987eb3a5394c7ba/flask_cors-6.0.2.tar.gz", hash = "sha256:6e118f3698249ae33e429760db98ce032a8bf9913638d085ca0f4c5534ad2423", size = 13472, upload-time = "2025-12-12T20:31:42.861Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/af/72ad54402e599152de6d067324c46fe6a4f531c7c65baf7e96c63db55eaf/flask_cors-6.0.2-py3-none-any.whl", hash = "sha256:e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a", size = 13257, upload-time = "2025-12-12T20:31:41.3Z" },
]
[[package]] [[package]]
name = "gunicorn" name = "gunicorn"
version = "23.0.0" version = "23.0.0"
@@ -175,6 +188,7 @@ source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "apscheduler" }, { name = "apscheduler" },
{ name = "flask" }, { name = "flask" },
{ name = "flask-cors" },
{ name = "gunicorn" }, { name = "gunicorn" },
{ name = "ipython" }, { name = "ipython" },
{ name = "pymongo" }, { name = "pymongo" },
@@ -186,6 +200,7 @@ dependencies = [
requires-dist = [ requires-dist = [
{ name = "apscheduler", specifier = ">=3.10.0" }, { name = "apscheduler", specifier = ">=3.10.0" },
{ name = "flask", specifier = ">=3.1.2" }, { name = "flask", specifier = ">=3.1.2" },
{ name = "flask-cors", specifier = ">=6.0.2" },
{ name = "gunicorn", specifier = ">=23.0.0" }, { name = "gunicorn", specifier = ">=23.0.0" },
{ name = "ipython", specifier = ">=9.9.0" }, { name = "ipython", specifier = ">=9.9.0" },
{ name = "pymongo", specifier = ">=4.10.0" }, { name = "pymongo", specifier = ">=4.10.0" },