wrolpi/modules/map/api.py
lrnselfreliance 3946e315fc
Channel and map subscription changes wait for a concurrent writer (#851)
PUT/DELETE /api/videos/channels/<id>, POST /api/videos/channels/<id>/tag,
and POST/DELETE /api/map/subscribe read, then wrote, in the request's
deferred transaction, so they failed with a 500 ("database is locked")
when another connection wrote in between.  They now write inside
request.ctx.db.write().

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:48:52 -06:00

162 lines
5.9 KiB
Python

from http import HTTPStatus
from sanic import Request, response, Blueprint
from sanic_ext import validate
from sanic_ext.extensions.openapi import openapi
from modules.map import lib, schema, search
from modules.map.pins import get_map_pins_config
from wrolpi.api_utils import json_response
from wrolpi.common import wrol_mode_check
from wrolpi.events import Events
map_bp = Blueprint('Map', '/api/map')
@map_bp.get('/files')
@openapi.description('List available PMTiles map files')
def get_files(_: Request):
files = lib.get_pmtiles_files()
return json_response(dict(files=files), HTTPStatus.OK)
@map_bp.delete('/files/<filename:str>')
@openapi.description('Delete a PMTiles map file')
@wrol_mode_check
async def delete_file(_: Request, filename: str):
try:
deleted = lib.delete_pmtiles_file(filename)
except ValueError as e:
return response.json({'error': str(e)}, HTTPStatus.BAD_REQUEST)
if deleted:
return response.empty()
return response.json({'error': 'File not found'}, HTTPStatus.NOT_FOUND)
@map_bp.get('/subscribe')
@openapi.description('Get map catalog and current subscriptions')
def get_subscriptions(request: Request):
catalog = lib.get_map_catalog()
subscriptions = lib.get_map_subscriptions(request.ctx.session)
return json_response(dict(catalog=catalog, subscriptions=subscriptions), HTTPStatus.OK)
@map_bp.post('/subscribe')
@openapi.description('Subscribe to a map region for automatic downloads')
@validate(schema.MapSubscribeRequest)
@wrol_mode_check
async def post_subscribe(request: Request, body: schema.MapSubscribeRequest):
try:
with request.ctx.db.write() as session:
await lib.subscribe(session, body.name, body.region)
except ValueError as e:
return response.json({'error': str(e)}, HTTPStatus.BAD_REQUEST)
Events.send_created(f'Map subscription created for {body.name}')
return response.empty(HTTPStatus.CREATED)
@map_bp.delete('/subscribe/<region:str>')
@openapi.description('Unsubscribe from a map region')
@wrol_mode_check
async def delete_subscription(request: Request, region: str):
try:
with request.ctx.db.write() as session:
await lib.unsubscribe(session, region)
except ValueError as e:
return response.json({'error': str(e)}, HTTPStatus.NOT_FOUND)
return response.empty(HTTPStatus.NO_CONTENT)
@map_bp.get('/pins')
@openapi.description('Get all map pins')
def get_pins(_: Request):
pins = get_map_pins_config().pins
return json_response(dict(pins=pins), HTTPStatus.OK)
@map_bp.post('/pins')
@openapi.description('Add a map pin')
@validate(schema.MapPinRequest)
async def add_pin(_: Request, body: schema.MapPinRequest):
pin = get_map_pins_config().add_pin(body.lat, body.lon, body.label, body.color)
return json_response(dict(pin=pin), HTTPStatus.CREATED)
@map_bp.delete('/pins/<pin_id:int>')
@openapi.description('Delete a map pin by ID')
async def delete_pin(_: Request, pin_id: int):
if get_map_pins_config().delete_pin(pin_id):
return response.empty(HTTPStatus.NO_CONTENT)
return response.json({'error': 'Pin not found'}, HTTPStatus.NOT_FOUND)
@map_bp.put('/pins/<pin_id:int>')
@openapi.description('Update a map pin by ID')
@validate(schema.MapPinUpdateRequest)
async def update_pin(_: Request, pin_id: int, body: schema.MapPinUpdateRequest):
if get_map_pins_config().update_pin(pin_id, body.label, body.color):
return response.empty(HTTPStatus.NO_CONTENT)
return response.json({'error': 'Pin not found'}, HTTPStatus.NOT_FOUND)
@map_bp.get('/search')
@openapi.description('Search for places in map search indexes')
async def search_places(request: Request):
q = request.args.get('q', '')
if not q or len(q.strip()) < 1:
return json_response({'error': 'Query parameter "q" is required'}, HTTPStatus.BAD_REQUEST)
try:
limit = int(request.args.get('limit', 12))
offset = int(request.args.get('offset', 0))
lat = float(request.args.get('lat')) if request.args.get('lat') is not None else None
lon = float(request.args.get('lon')) if request.args.get('lon') is not None else None
except (ValueError, TypeError):
return json_response({'error': 'Invalid query parameters'}, HTTPStatus.BAD_REQUEST)
data = search.search_places(q.strip(), limit=limit, offset=offset, lat=lat, lon=lon)
return json_response(data, HTTPStatus.OK)
@map_bp.get('/search/estimate')
@openapi.description('Get estimated count of map search results')
async def get_search_estimate(request: Request):
q = request.args.get('q', '')
if not q or len(q.strip()) < 1:
return json_response({'map_places': 0}, HTTPStatus.OK)
count = search.search_places_count(q.strip())
return json_response({'map_places': count}, HTTPStatus.OK)
@map_bp.get('/search/status')
@openapi.description('Get status of map search indexes')
async def get_search_status(_: Request):
status = search.get_search_status()
return json_response(status, HTTPStatus.OK)
@map_bp.post('/search/rebuild')
@openapi.description('Rebuild all map search indexes')
@wrol_mode_check
async def rebuild_search_indexes(_: Request):
started = search.rebuild_all_search_indexes()
if started:
return json_response({'message': 'Search index rebuild started'}, HTTPStatus.ACCEPTED)
return json_response({'error': 'No PMTiles files found'}, HTTPStatus.NOT_FOUND)
@map_bp.post('/search/rebuild/<filename:str>')
@openapi.description('Rebuild search index for a single PMTiles file')
@wrol_mode_check
async def rebuild_single_search_index(_: Request, filename: str):
try:
started = search.rebuild_search_index(filename)
except ValueError as e:
return response.json({'error': str(e)}, HTTPStatus.BAD_REQUEST)
if started:
return json_response({'message': f'Search index rebuild started for {filename}'}, HTTPStatus.ACCEPTED)
return json_response({'error': 'File not found'}, HTTPStatus.NOT_FOUND)