Compare commits

..

12 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
Matteo Rosati
686e4ff477 add limit 2026-01-16 08:55:18 +01:00
Matteo Rosati
c7b3f95439 use .env in app.py 2026-01-16 08:46:05 +01:00
Matteo Rosati
ad087b9547 add CORS headers 2026-01-15 15:19:36 +01:00
Matteo Rosati
e6e8cf0e42 add dist env file 2026-01-15 12:28:46 +01:00
Matteo Rosati
ce868e1807 add endpoint for mongo results (basic auth) 2026-01-15 12:28:32 +01:00
10 changed files with 763 additions and 245 deletions

15
.env.dist Normal file
View File

@@ -0,0 +1,15 @@
BASIC_AUTH_PASSWORD="your_password_here"
BASIC_AUTH_USER="your_username_here"
COLLECTION_NAME="ingress"
DB_NAME="ingress"
ENDPOINT_URL="http://web:7000/plexts"
INGRESS_COOKIE="csrftoken=...; sessionid=...; ingress.intelmap.zoom=10; ingress.intelmap.lat=45.80534988266492; ingress.intelmap.lng=12.471542358398438"
MAX_LAT="46422713"
MAX_LNG="14201202"
MIN_LAT="45181069"
MIN_LNG="10741882"
MONGO_URI="mongodb://user:password@mongodb:27017/?authSource=admin"
PORT=7000
PYTHONUNBUFFERED=1
TZ="Europe/Rome"
V="412c0ac7e784d6df783fc507bca30e23b3c58c55"

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.

295
app.py
View File

@@ -1,110 +1,209 @@
import os import os
import logging
from datetime import datetime
from zoneinfo import ZoneInfo
from flask import Flask, request, jsonify from flask import Flask, request, jsonify
from flask_cors import CORS
from ingress import IngressAPI from ingress import IngressAPI
from models import EventType, Plext from models import EventType
from pymongo import MongoClient
# Timezone configuration from pymongo.errors import PyMongoError
TIMEZONE = ZoneInfo("Europe/Rome") from dotenv import load_dotenv
from lib import (
# Configure logging check_basic_auth,
logging.basicConfig( basic_auth_required,
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" parse_timestamp,
plext_to_dict,
logger,
) )
logger = logging.getLogger(__name__)
load_dotenv()
app = Flask(__name__) app = Flask(__name__)
CORS(app)
def parse_timestamp(value: str) -> int: @app.route("/plexts/from-db", methods=["GET"])
@basic_auth_required
def get_plexts_from_db():
""" """
Parse timestamp from either milliseconds (int) or ISO 8601 string. Get plexts from MongoDB with optional filters.
Returns plexts sorted by timestamp (most recent first).
Args: Query Parameters:
value: Either integer milliseconds or ISO 8601 string player_name: Filter by player name (optional)
timestamp_from: Minimum timestamp in milliseconds (optional)
timestamp_to: Maximum timestamp in milliseconds (optional)
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:
Timestamp in milliseconds since epoch 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.
Raises:
ValueError: If format is invalid
""" """
# Try parsing as integer (milliseconds)
try: try:
return int(value) # Parse query parameters
except ValueError: player_name = request.args.get("player_name")
pass timestamp_from = request.args.get("timestamp_from")
timestamp_to = request.args.get("timestamp_to")
limit_param = request.args.get("limit")
center = request.args.get("center")
radius = request.args.get("radius")
# Try parsing as ISO 8601 datetime # Validate and convert timestamp parameters to integers if provided
try: if timestamp_from is not None:
# Handle 'Z' suffix (UTC) try:
iso_value = value.replace("Z", "+00:00") timestamp_from = int(timestamp_from)
except ValueError:
return jsonify({"error": "timestamp_from must be an integer"}), 400
# Handle timezone offset without colon (e.g., +0100 -> +01:00) if timestamp_to is not None:
# Match pattern like +0100 or -0100 at the end of the string try:
import re timestamp_to = int(timestamp_to)
except ValueError:
return jsonify({"error": "timestamp_to must be an integer"}), 400
match = re.search(r"([+-])(\d{2})(\d{2})$", iso_value) # Validate and convert limit parameter to integer if provided, otherwise default to 100
if match: if limit_param is not None:
sign, hours, minutes = match.groups() try:
iso_value = re.sub( limit = int(limit_param)
r"([+-])(\d{2})(\d{2})$", f"{sign}{hours}:{minutes}", iso_value if limit <= 0:
) return jsonify({"error": "limit must be a positive integer"}), 400
except ValueError:
return jsonify({"error": "limit must be an integer"}), 400
else:
limit = 100
dt = datetime.fromisoformat(iso_value) # Validate geospatial parameters
return int(dt.timestamp() * 1000) # Both center and radius must be provided together, or neither
except ValueError: if (center is not None) != (radius is not None):
raise ValueError( return jsonify({
f"Invalid timestamp format: {value}. " "error": "Both 'center' and 'radius' parameters must be provided together"
"Use milliseconds (e.g., 1736659200000) or " }), 400
"ISO 8601 format (e.g., '2026-01-12T10:00:00Z' or '2026-01-12T10:00:00+01:00')"
) # 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
filter_query = {}
if player_name:
filter_query["player_name"] = player_name
if timestamp_from is not None:
filter_query["timestamp"] = filter_query.get("timestamp", {})
filter_query["timestamp"]["$gte"] = timestamp_from
if timestamp_to is not None:
filter_query["timestamp"] = filter_query.get("timestamp", {})
filter_query["timestamp"]["$lte"] = timestamp_to
# Connect to MongoDB
mongo_uri = os.getenv("MONGO_URI")
db_name = os.getenv("DB_NAME")
collection_name = os.getenv("COLLECTION_NAME")
client = MongoClient(mongo_uri)
db = client[db_name]
collection = db[collection_name]
try:
# Ensure geospatial index exists
try:
collection.create_index([("coordinates", "2dsphere")], background=True)
except PyMongoError as e:
logger.warning(f"Could not create geospatial index: {e}")
# Execute query based on whether geospatial parameters are provided
if center_coords and radius_meters:
# Use $geoNear aggregation for geospatial query
lat, lng = center_coords
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
}
}
]
# Execute aggregation pipeline
plexts = list(collection.aggregate(pipeline))
else:
# Use standard find() query for non-geospatial queries
projection = {"_id": 0}
# 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:
logger.error(f"MongoDB error: {e}")
return jsonify({"error": "Database error"}), 500
finally:
client.close()
except Exception as e:
logger.exception("Unexpected error in get_plexts_from_db")
return jsonify({"error": "An error occurred"}), 500
def plext_to_dict(plext: Plext) -> dict: @app.route("/plexts/from-api", methods=["GET"])
""" @basic_auth_required
Convert a Plext object to a dictionary for JSON serialization. def get_plexts_from_api():
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", methods=["GET"])
def get_plexts():
""" """
Get plexts from the Ingress API. Get plexts from the Ingress API.
@@ -199,7 +298,7 @@ def index():
"name": "Ingress Intel API", "name": "Ingress Intel API",
"version": "1.0.0", "version": "1.0.0",
"endpoints": { "endpoints": {
"/plexts": { "/plexts/from-api": {
"method": "GET", "method": "GET",
"description": "Get plexts from the Ingress API", "description": "Get plexts from the Ingress API",
"parameters": { "parameters": {
@@ -213,7 +312,25 @@ def index():
"max_timestamp": "Maximum timestamp (milliseconds or ISO 8601 format, default: -1)", "max_timestamp": "Maximum timestamp (milliseconds or ISO 8601 format, default: -1)",
}, },
"event_types": [e.name for e in EventType], "event_types": [e.name for e in EventType],
} },
"/plexts/from-db": {
"method": "GET",
"description": "Get plexts from MongoDB with optional filters. Returns plexts sorted by timestamp (most recent first).",
"parameters": {
"player_name": "Filter by player name (optional)",
"timestamp_from": "Minimum timestamp in milliseconds (optional)",
"timestamp_to": "Maximum timestamp in milliseconds (optional)",
"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",
"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" },