Compare commits
10 Commits
e6e8cf0e42
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de53b760c4 | ||
|
|
6cd8997516 | ||
|
|
dac7c2dbcf | ||
|
|
e6c37f38f5 | ||
|
|
4f3919d02d | ||
|
|
9c1d0b9b32 | ||
|
|
fdaf72f103 | ||
|
|
686e4ff477 | ||
|
|
c7b3f95439 | ||
|
|
ad087b9547 |
@@ -19,6 +19,7 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application files
|
||||
COPY app.py .
|
||||
COPY lib.py .
|
||||
COPY ingress.py .
|
||||
COPY models.py .
|
||||
|
||||
|
||||
75
README.md
75
README.md
@@ -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"
|
||||
```
|
||||
|
||||
##### 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×tamp_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×tamp_from=17366592000000"
|
||||
|
||||
# Combine all filters
|
||||
curl "http://localhost:7000/plexts/from-db?center=45.573661,12.365208&radius=1000&player_name=Albicocca×tamp_from=17366592000000&limit=10"
|
||||
```
|
||||
|
||||
**Authentication:** Basic Auth required
|
||||
|
||||
### Scheduler
|
||||
|
||||
The scheduler automatically collects Ingress events every minute and stores them in MongoDB.
|
||||
|
||||
285
app.py
285
app.py
@@ -1,174 +1,52 @@
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
from functools import wraps
|
||||
from flask import Flask, request, jsonify, Response
|
||||
from flask import Flask, request, jsonify
|
||||
from flask_cors import CORS
|
||||
from ingress import IngressAPI
|
||||
from models import EventType, Plext
|
||||
from models import EventType
|
||||
from pymongo import MongoClient
|
||||
from pymongo.errors import PyMongoError
|
||||
|
||||
# Timezone configuration
|
||||
TIMEZONE = ZoneInfo("Europe/Rome")
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
from dotenv import load_dotenv
|
||||
from lib import (
|
||||
check_basic_auth,
|
||||
basic_auth_required,
|
||||
parse_timestamp,
|
||||
plext_to_dict,
|
||||
logger,
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
load_dotenv()
|
||||
|
||||
app = Flask(__name__)
|
||||
CORS(app)
|
||||
|
||||
|
||||
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
|
||||
],
|
||||
}
|
||||
|
||||
@app.route("/plexts/from-db", methods=["GET"])
|
||||
@basic_auth_required
|
||||
def get_plexts_from_db():
|
||||
"""
|
||||
Get plexts from MongoDB with optional filters.
|
||||
Returns plexts sorted by timestamp (most recent first).
|
||||
|
||||
Query 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)
|
||||
|
||||
Returns:
|
||||
JSON response with list of plexts (without _id field)
|
||||
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.
|
||||
"""
|
||||
try:
|
||||
# Parse query parameters
|
||||
player_name = request.args.get("player_name")
|
||||
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")
|
||||
|
||||
# Validate and convert timestamp parameters to integers if provided
|
||||
if timestamp_from is not None:
|
||||
@@ -183,6 +61,55 @@ def get_plexts_from_db():
|
||||
except ValueError:
|
||||
return jsonify({"error": "timestamp_to must be an integer"}), 400
|
||||
|
||||
# Validate and convert limit parameter to integer if provided, otherwise default to 100
|
||||
if limit_param is not None:
|
||||
try:
|
||||
limit = int(limit_param)
|
||||
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
|
||||
|
||||
# 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
|
||||
filter_query = {}
|
||||
|
||||
@@ -207,22 +134,61 @@ def get_plexts_from_db():
|
||||
collection = db[collection_name]
|
||||
|
||||
try:
|
||||
# Projection to exclude _id field
|
||||
# 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)
|
||||
cursor = collection.find(
|
||||
filter=filter_query,
|
||||
projection=projection
|
||||
).sort("timestamp", -1)
|
||||
# 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
|
||||
})
|
||||
return jsonify({"count": len(plexts), "plexts": plexts}), 200
|
||||
|
||||
except PyMongoError as e:
|
||||
logger.error(f"MongoDB error: {e}")
|
||||
@@ -234,6 +200,7 @@ def get_plexts_from_db():
|
||||
logger.exception("Unexpected error in get_plexts_from_db")
|
||||
return jsonify({"error": "An error occurred"}), 500
|
||||
|
||||
|
||||
@app.route("/plexts/from-api", methods=["GET"])
|
||||
@basic_auth_required
|
||||
def get_plexts_from_api():
|
||||
@@ -348,13 +315,21 @@ def index():
|
||||
},
|
||||
"/plexts/from-db": {
|
||||
"method": "GET",
|
||||
"description": "Get plexts from MongoDB with optional filters",
|
||||
"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",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
286
example_api_response.md
Normal 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
147
lib.py
Normal 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
|
||||
],
|
||||
}
|
||||
@@ -12,4 +12,5 @@ dependencies = [
|
||||
"pymongo>=4.10.0",
|
||||
"apscheduler>=3.10.0",
|
||||
"ipython>=9.9.0",
|
||||
"flask-cors>=6.0.2",
|
||||
]
|
||||
|
||||
@@ -1,19 +1,36 @@
|
||||
apscheduler==3.11.2
|
||||
asttokens==3.0.1
|
||||
blinker==1.9.0
|
||||
certifi==2026.1.4
|
||||
charset-normalizer==3.4.4
|
||||
click==8.3.1
|
||||
decorator==5.2.1
|
||||
dnspython==2.8.0
|
||||
executing==2.2.1
|
||||
flask==3.1.2
|
||||
flask-cors==6.0.2
|
||||
gunicorn==23.0.0
|
||||
idna==3.11
|
||||
ipython==9.9.0
|
||||
ipython-pygments-lexers==1.1.1
|
||||
itsdangerous==2.2.0
|
||||
jedi==0.19.2
|
||||
jinja2==3.1.6
|
||||
markupsafe==3.0.3
|
||||
matplotlib-inline==0.2.1
|
||||
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
|
||||
python-dotenv==1.2.1
|
||||
requests==2.32.5
|
||||
stack-data==0.6.3
|
||||
traitlets==5.14.3
|
||||
tzlocal==5.3.1
|
||||
urllib3==2.6.3
|
||||
wcwidth==0.2.14
|
||||
werkzeug==3.1.5
|
||||
|
||||
15
uv.lock
generated
15
uv.lock
generated
@@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "gunicorn"
|
||||
version = "23.0.0"
|
||||
@@ -175,6 +188,7 @@ source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "apscheduler" },
|
||||
{ name = "flask" },
|
||||
{ name = "flask-cors" },
|
||||
{ name = "gunicorn" },
|
||||
{ name = "ipython" },
|
||||
{ name = "pymongo" },
|
||||
@@ -186,6 +200,7 @@ dependencies = [
|
||||
requires-dist = [
|
||||
{ name = "apscheduler", specifier = ">=3.10.0" },
|
||||
{ name = "flask", specifier = ">=3.1.2" },
|
||||
{ name = "flask-cors", specifier = ">=6.0.2" },
|
||||
{ name = "gunicorn", specifier = ">=23.0.0" },
|
||||
{ name = "ipython", specifier = ">=9.9.0" },
|
||||
{ name = "pymongo", specifier = ">=4.10.0" },
|
||||
|
||||
Reference in New Issue
Block a user