Frigate HTTP API using FastAPI (#13871)

* POC: Added FastAPI with one endpoint (get /logs/service)

* POC: Revert error_log

* POC: Converted preview related endpoints to FastAPI

* POC: Converted two more endpoints to FastAPI

* POC: lint

* Convert all media endpoints to FastAPI. Added /media prefix (/media/camera && media/events && /media/preview)

* Convert all notifications API endpoints to FastAPI

* Convert first review API endpoints to FastAPI

* Convert remaining review API endpoints to FastAPI

* Convert export endpoints to FastAPI

* Fix path parameters

* Convert events endpoints to FastAPI

* Use body for multiple events endpoints

* Use body for multiple events endpoints (create and end event)

* Convert app endpoints to FastAPI

* Convert app endpoints to FastAPI

* Convert auth endpoints to FastAPI

* Removed flask app in favour of FastAPI app. Implemented FastAPI middleware to check CSRF, connect and disconnect from DB. Added middleware x-forwared-for headers

* Added starlette plugin to expose custom headers

* Use slowapi as the limiter

* Use query parameters for the frame latest endpoint

* Use query parameters for the media snapshot.jpg endpoint

* Use query parameters for the media MJPEG feed endpoint

* Revert initial nginx.conf change

* Added missing even_id for /events/search endpoint

* Removed left over comment

* Use FastAPI TestClient

* severity query parameter should be a string

* Use the same pattern for all tests

* Fix endpoint

* Revert media routers to old names. Order routes to make sure the dynamic ones from media.py are only used whenever there's no match on auth/etc

* Reverted paths for media on tsx files

* Deleted file

* Fix test_http to use TestClient

* Formatting

* Bind timeline to DB

* Fix http tests

* Replace filename with pathvalidate

* Fix latest.ext handling and disable uvicorn access logs

* Add cosntraints to api provided values

* Formatting

* Remove unused

* Remove unused

* Get rate limiter working

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
This commit is contained in:
Rui Alves
2024-09-24 07:05:30 -06:00
committed by GitHub
co-authored by Nicolas Mowen
parent dc54981784
commit cffc431bf0
24 changed files with 1654 additions and 1321 deletions
+269 -267
View File
@@ -4,24 +4,32 @@ import base64
import io
import logging
import os
from datetime import datetime
from functools import reduce
from pathlib import Path
from urllib.parse import unquote
import cv2
import numpy as np
from flask import (
Blueprint,
current_app,
jsonify,
make_response,
request,
)
from fastapi import APIRouter, Request
from fastapi.params import Depends
from fastapi.responses import JSONResponse
from peewee import JOIN, DoesNotExist, fn, operator
from PIL import Image
from playhouse.shortcuts import model_to_dict
from frigate.api.defs.events_body import (
EventsCreateBody,
EventsDescriptionBody,
EventsEndBody,
EventsSubLabelBody,
)
from frigate.api.defs.events_query_parameters import (
DEFAULT_TIME_RANGE,
EventsQueryParams,
EventsSearchQueryParams,
EventsSummaryQueryParams,
)
from frigate.api.defs.tags import Tags
from frigate.const import (
CLIPS_DIR,
)
@@ -33,57 +41,55 @@ from frigate.util.builtin import get_tz_modifiers
logger = logging.getLogger(__name__)
EventBp = Blueprint("events", __name__)
DEFAULT_TIME_RANGE = "00:00,24:00"
router = APIRouter(tags=[Tags.events])
@EventBp.route("/events")
def events():
camera = request.args.get("camera", "all")
cameras = request.args.get("cameras", "all")
@router.get("/events")
def events(params: EventsQueryParams = Depends()):
camera = params.camera
cameras = params.cameras
# handle old camera arg
if cameras == "all" and camera != "all":
cameras = camera
label = unquote(request.args.get("label", "all"))
labels = request.args.get("labels", "all")
label = unquote(params.label)
labels = params.labels
# handle old label arg
if labels == "all" and label != "all":
labels = label
sub_label = request.args.get("sub_label", "all")
sub_labels = request.args.get("sub_labels", "all")
sub_label = params.sub_label
sub_labels = params.sub_labels
# handle old sub_label arg
if sub_labels == "all" and sub_label != "all":
sub_labels = sub_label
zone = request.args.get("zone", "all")
zones = request.args.get("zones", "all")
zone = params.zone
zones = params.zones
# handle old label arg
if zones == "all" and zone != "all":
zones = zone
limit = request.args.get("limit", 100)
after = request.args.get("after", type=float)
before = request.args.get("before", type=float)
time_range = request.args.get("time_range", DEFAULT_TIME_RANGE)
has_clip = request.args.get("has_clip", type=int)
has_snapshot = request.args.get("has_snapshot", type=int)
in_progress = request.args.get("in_progress", type=int)
include_thumbnails = request.args.get("include_thumbnails", default=1, type=int)
favorites = request.args.get("favorites", type=int)
min_score = request.args.get("min_score", type=float)
max_score = request.args.get("max_score", type=float)
is_submitted = request.args.get("is_submitted", type=int)
min_length = request.args.get("min_length", type=float)
max_length = request.args.get("max_length", type=float)
limit = params.limit
after = params.after
before = params.before
time_range = params.time_range
has_clip = params.has_clip
has_snapshot = params.has_snapshot
in_progress = params.in_progress
include_thumbnails = params.include_thumbnails
favorites = params.favorites
min_score = params.min_score
max_score = params.max_score
is_submitted = params.is_submitted
min_length = params.min_length
max_length = params.max_length
sort = request.args.get("sort", type=str)
sort = params.sort
clauses = []
@@ -163,7 +169,7 @@ def events():
if time_range != DEFAULT_TIME_RANGE:
# get timezone arg to ensure browser times are used
tz_name = request.args.get("timezone", default="utc", type=str)
tz_name = params.timezone
hour_modifier, minute_modifier, _ = get_tz_modifiers(tz_name)
times = time_range.split(",")
@@ -248,13 +254,11 @@ def events():
.iterator()
)
return jsonify(list(events))
return JSONResponse(content=list(events))
@EventBp.route("/events/explore")
def events_explore():
limit = request.args.get("limit", 10, type=int)
@router.get("/events/explore")
def events_explore(limit: int = 10):
subquery = Event.select(
Event.id,
Event.camera,
@@ -316,69 +320,68 @@ def events_explore():
for event in events
]
return jsonify(processed_events)
return JSONResponse(content=processed_events)
@EventBp.route("/event_ids")
def event_ids():
idString = request.args.get("ids")
ids = idString.split(",")
@router.get("/event_ids")
def event_ids(ids: str):
ids = ids.split(",")
if not ids:
return make_response(
jsonify({"success": False, "message": "Valid list of ids must be sent"}),
400,
return JSONResponse(
content=({"success": False, "message": "Valid list of ids must be sent"}),
status_code=400,
)
try:
events = Event.select().where(Event.id << ids).dicts().iterator()
return jsonify(list(events))
return JSONResponse(list(events))
except Exception:
return make_response(
jsonify({"success": False, "message": "Events not found"}), 400
return JSONResponse(
content=({"success": False, "message": "Events not found"}), status_code=400
)
@EventBp.route("/events/search")
def events_search():
query = request.args.get("query", type=str)
search_type = request.args.get("search_type", "thumbnail,description", type=str)
include_thumbnails = request.args.get("include_thumbnails", default=1, type=int)
limit = request.args.get("limit", 50, type=int)
@router.get("/events/search")
def events_search(request: Request, params: EventsSearchQueryParams = Depends()):
query = params.query
search_type = params.search_type
include_thumbnails = params.include_thumbnails
limit = params.limit
# Filters
cameras = request.args.get("cameras", "all", type=str)
labels = request.args.get("labels", "all", type=str)
zones = request.args.get("zones", "all", type=str)
after = request.args.get("after", type=float)
before = request.args.get("before", type=float)
cameras = params.cameras
labels = params.labels
zones = params.zones
after = params.after
before = params.before
# for similarity search
event_id = request.args.get("event_id", type=str)
event_id = params.event_id
if not query and not event_id:
return make_response(
jsonify(
return JSONResponse(
content=(
{
"success": False,
"message": "A search query must be supplied",
}
),
400,
status_code=400,
)
if not current_app.frigate_config.semantic_search.enabled:
return make_response(
jsonify(
if not request.app.frigate_config.semantic_search.enabled:
return JSONResponse(
content=(
{
"success": False,
"message": "Semantic search is not enabled",
}
),
400,
status_code=400,
)
context: EmbeddingsContext = current_app.embeddings
context: EmbeddingsContext = request.app.embeddings
selected_columns = [
Event.id,
@@ -437,14 +440,14 @@ def events_search():
try:
search_event: Event = Event.get(Event.id == event_id)
except DoesNotExist:
return make_response(
jsonify(
return JSONResponse(
content=(
{
"success": False,
"message": "Event not found",
}
),
404,
status_code=404,
)
thumbnail = base64.b64decode(search_event.thumbnail)
img = np.array(Image.open(io.BytesIO(thumbnail)).convert("RGB"))
@@ -504,7 +507,7 @@ def events_search():
}
if not results:
return jsonify([])
return JSONResponse(content=[])
# Get the event data
events = (
@@ -537,15 +540,15 @@ def events_search():
]
events = sorted(events, key=lambda x: x["search_distance"])[:limit]
return jsonify(events)
return JSONResponse(content=events)
@EventBp.route("/events/summary")
def events_summary():
tz_name = request.args.get("timezone", default="utc", type=str)
@router.get("/events/summary")
def events_summary(params: EventsSummaryQueryParams = Depends()):
tz_name = params.timezone
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(tz_name)
has_clip = request.args.get("has_clip", type=int)
has_snapshot = request.args.get("has_snapshot", type=int)
has_clip = params.has_clip
has_snapshot = params.has_snapshot
clauses = []
@@ -582,47 +585,49 @@ def events_summary():
)
)
return jsonify([e for e in groups.dicts()])
return JSONResponse(content=[e for e in groups.dicts()])
@EventBp.route("/events/<id>", methods=("GET",))
def event(id):
@router.get("/events/{event_id}")
def event(event_id: str):
try:
return model_to_dict(Event.get(Event.id == id))
return model_to_dict(Event.get(Event.id == event_id))
except DoesNotExist:
return "Event not found", 404
return JSONResponse(content="Event not found", status_code=404)
@EventBp.route("/events/<id>/retain", methods=("POST",))
def set_retain(id):
@router.post("/events/{event_id}/retain")
def set_retain(event_id: str):
try:
event = Event.get(Event.id == id)
event = Event.get(Event.id == event_id)
except DoesNotExist:
return make_response(
jsonify({"success": False, "message": "Event " + id + " not found"}), 404
return JSONResponse(
content=({"success": False, "message": "Event " + event_id + " not found"}),
status_code=404,
)
event.retain_indefinitely = True
event.save()
return make_response(
jsonify({"success": True, "message": "Event " + id + " retained"}), 200
return JSONResponse(
content=({"success": True, "message": "Event " + event_id + " retained"}),
status_code=200,
)
@EventBp.route("/events/<id>/plus", methods=("POST",))
def send_to_plus(id):
if not current_app.frigate_config.plus_api.is_active():
@router.post("/events/{event_id}/plus")
def send_to_plus(request: Request, event_id: str):
if not request.app.frigate_config.plus_api.is_active():
message = "PLUS_API_KEY environment variable is not set"
logger.error(message)
return make_response(
jsonify(
return JSONResponse(
content=(
{
"success": False,
"message": message,
}
),
400,
status_code=400,
)
include_annotation = (
@@ -630,11 +635,13 @@ def send_to_plus(id):
)
try:
event = Event.get(Event.id == id)
event = Event.get(Event.id == event_id)
except DoesNotExist:
message = f"Event {id} not found"
message = f"Event {event_id} not found"
logger.error(message)
return make_response(jsonify({"success": False, "message": message}), 404)
return JSONResponse(
content=({"success": False, "message": message}), status_code=404
)
# events from before the conversion to relative dimensions cant include annotations
if event.data.get("box") is None:
@@ -642,20 +649,22 @@ def send_to_plus(id):
if event.end_time is None:
logger.error(f"Unable to load clean png for in-progress event: {event.id}")
return make_response(
jsonify(
return JSONResponse(
content=(
{
"success": False,
"message": "Unable to load clean png for in-progress event",
}
),
400,
status_code=400,
)
if event.plus_id:
message = "Already submitted to plus"
logger.error(message)
return make_response(jsonify({"success": False, "message": message}), 400)
return JSONResponse(
content=({"success": False, "message": message}), status_code=400
)
# load clean.png
try:
@@ -663,29 +672,29 @@ def send_to_plus(id):
image = cv2.imread(os.path.join(CLIPS_DIR, filename))
except Exception:
logger.error(f"Unable to load clean png for event: {event.id}")
return make_response(
jsonify(
return JSONResponse(
content=(
{"success": False, "message": "Unable to load clean png for event"}
),
400,
status_code=400,
)
if image is None or image.size == 0:
logger.error(f"Unable to load clean png for event: {event.id}")
return make_response(
jsonify(
return JSONResponse(
content=(
{"success": False, "message": "Unable to load clean png for event"}
),
400,
status_code=400,
)
try:
plus_id = current_app.frigate_config.plus_api.upload_image(image, event.camera)
plus_id = request.app.frigate_config.plus_api.upload_image(image, event.camera)
except Exception as ex:
logger.exception(ex)
return make_response(
jsonify({"success": False, "message": "Error uploading image"}),
400,
return JSONResponse(
content=({"success": False, "message": "Error uploading image"}),
status_code=400,
)
# store image id in the database
@@ -696,7 +705,7 @@ def send_to_plus(id):
box = event.data["box"]
try:
current_app.frigate_config.plus_api.add_annotation(
request.app.frigate_config.plus_api.add_annotation(
event.plus_id,
box,
event.label,
@@ -704,59 +713,67 @@ def send_to_plus(id):
except ValueError:
message = "Error uploading annotation, unsupported label provided."
logger.error(message)
return make_response(
jsonify({"success": False, "message": message}),
400,
return JSONResponse(
content=({"success": False, "message": message}),
status_code=400,
)
except Exception as ex:
logger.exception(ex)
return make_response(
jsonify({"success": False, "message": "Error uploading annotation"}),
400,
return JSONResponse(
content=({"success": False, "message": "Error uploading annotation"}),
status_code=400,
)
return make_response(jsonify({"success": True, "plus_id": plus_id}), 200)
return JSONResponse(
content=({"success": True, "plus_id": plus_id}), status_code=200
)
@EventBp.route("/events/<id>/false_positive", methods=("PUT",))
def false_positive(id):
if not current_app.frigate_config.plus_api.is_active():
@router.put("/events/{event_id}/false_positive")
def false_positive(request: Request, event_id: str):
if not request.app.frigate_config.plus_api.is_active():
message = "PLUS_API_KEY environment variable is not set"
logger.error(message)
return make_response(
jsonify(
return JSONResponse(
content=(
{
"success": False,
"message": message,
}
),
400,
status_code=400,
)
try:
event = Event.get(Event.id == id)
event = Event.get(Event.id == event_id)
except DoesNotExist:
message = f"Event {id} not found"
message = f"Event {event_id} not found"
logger.error(message)
return make_response(jsonify({"success": False, "message": message}), 404)
return JSONResponse(
content=({"success": False, "message": message}), status_code=404
)
# events from before the conversion to relative dimensions cant include annotations
if event.data.get("box") is None:
message = "Events prior to 0.13 cannot be submitted as false positives"
logger.error(message)
return make_response(jsonify({"success": False, "message": message}), 400)
return JSONResponse(
content=({"success": False, "message": message}), status_code=400
)
if event.false_positive:
message = "False positive already submitted to Frigate+"
logger.error(message)
return make_response(jsonify({"success": False, "message": message}), 400)
return JSONResponse(
content=({"success": False, "message": message}), status_code=400
)
if not event.plus_id:
plus_response = send_to_plus(id)
plus_response = send_to_plus(event_id)
if plus_response.status_code != 200:
return plus_response
# need to refetch the event now that it has a plus_id
event = Event.get(Event.id == id)
event = Event.get(Event.id == event_id)
region = event.data["region"]
box = event.data["box"]
@@ -769,7 +786,7 @@ def false_positive(id):
)
try:
current_app.frigate_config.plus_api.add_false_positive(
request.app.frigate_config.plus_api.add_false_positive(
event.plus_id,
region,
box,
@@ -782,92 +799,65 @@ def false_positive(id):
except ValueError:
message = "Error uploading false positive, unsupported label provided."
logger.error(message)
return make_response(
jsonify({"success": False, "message": message}),
400,
return JSONResponse(
content=({"success": False, "message": message}),
status_code=400,
)
except Exception as ex:
logger.exception(ex)
return make_response(
jsonify({"success": False, "message": "Error uploading false positive"}),
400,
return JSONResponse(
content=({"success": False, "message": "Error uploading false positive"}),
status_code=400,
)
event.false_positive = True
event.save()
return make_response(jsonify({"success": True, "plus_id": event.plus_id}), 200)
return JSONResponse(
content=({"success": True, "plus_id": event.plus_id}), status_code=200
)
@EventBp.route("/events/<id>/retain", methods=("DELETE",))
def delete_retain(id):
@router.delete("/events/{event_id}/retain")
def delete_retain(event_id: str):
try:
event = Event.get(Event.id == id)
event = Event.get(Event.id == event_id)
except DoesNotExist:
return make_response(
jsonify({"success": False, "message": "Event " + id + " not found"}), 404
return JSONResponse(
content=({"success": False, "message": "Event " + event_id + " not found"}),
status_code=404,
)
event.retain_indefinitely = False
event.save()
return make_response(
jsonify({"success": True, "message": "Event " + id + " un-retained"}), 200
return JSONResponse(
content=({"success": True, "message": "Event " + event_id + " un-retained"}),
status_code=200,
)
@EventBp.route("/events/<id>/sub_label", methods=("POST",))
def set_sub_label(id):
@router.post("/events/{event_id}/sub_label")
def set_sub_label(
request: Request,
event_id: str,
body: EventsSubLabelBody,
):
try:
event: Event = Event.get(Event.id == id)
event: Event = Event.get(Event.id == event_id)
except DoesNotExist:
return make_response(
jsonify({"success": False, "message": "Event " + id + " not found"}), 404
return JSONResponse(
content=({"success": False, "message": "Event " + event_id + " not found"}),
status_code=404,
)
json: dict[str, any] = request.get_json(silent=True) or {}
new_sub_label = json.get("subLabel")
new_score = json.get("subLabelScore")
if new_sub_label is None:
return make_response(
jsonify(
{
"success": False,
"message": "A sub label must be supplied",
}
),
400,
)
if new_sub_label and len(new_sub_label) > 100:
return make_response(
jsonify(
{
"success": False,
"message": new_sub_label
+ " exceeds the 100 character limit for sub_label",
}
),
400,
)
if new_score is not None and (new_score > 1.0 or new_score < 0):
return make_response(
jsonify(
{
"success": False,
"message": new_score
+ " does not fit within the expected bounds 0 <= score <= 1.0",
}
),
400,
)
new_sub_label = body.subLabel
new_score = body.subLabelScore
if not event.end_time:
# update tracked object
tracked_obj: TrackedObject = (
current_app.detected_frames_processor.camera_states[
request.app.detected_frames_processor.camera_states[
event.camera
].tracked_objects.get(event.id)
)
@@ -878,7 +868,7 @@ def set_sub_label(id):
# update timeline items
Timeline.update(
data=Timeline.data.update({"sub_label": (new_sub_label, new_score)})
).where(Timeline.source_id == id).execute()
).where(Timeline.source_id == event_id).execute()
event.sub_label = new_sub_label
@@ -888,70 +878,78 @@ def set_sub_label(id):
event.data = data
event.save()
return make_response(
jsonify(
return JSONResponse(
content=(
{
"success": True,
"message": "Event " + id + " sub label set to " + new_sub_label,
"message": "Event " + event_id + " sub label set to " + new_sub_label,
}
),
200,
status_code=200,
)
@EventBp.route("/events/<id>/description", methods=("POST",))
def set_description(id):
@router.post("/events/{event_id}/description")
def set_description(
request: Request,
event_id: str,
body: EventsDescriptionBody,
):
try:
event: Event = Event.get(Event.id == id)
event: Event = Event.get(Event.id == event_id)
except DoesNotExist:
return make_response(
jsonify({"success": False, "message": "Event " + id + " not found"}), 404
return JSONResponse(
content=({"success": False, "message": "Event " + event_id + " not found"}),
status_code=404,
)
json: dict[str, any] = request.get_json(silent=True) or {}
new_description = json.get("description")
new_description = body.description
if new_description is None or len(new_description) == 0:
return make_response(
jsonify(
return JSONResponse(
content=(
{
"success": False,
"message": "description cannot be empty",
}
),
400,
status_code=400,
)
event.data["description"] = new_description
event.save()
# If semantic search is enabled, update the index
if current_app.frigate_config.semantic_search.enabled:
context: EmbeddingsContext = current_app.embeddings
if request.app.frigate_config.semantic_search.enabled:
context: EmbeddingsContext = request.app.embeddings
context.embeddings.description.upsert(
documents=[new_description],
metadatas=[get_metadata(event)],
ids=[id],
ids=[event_id],
)
return make_response(
jsonify(
return JSONResponse(
content=(
{
"success": True,
"message": "Event " + id + " description set to " + new_description,
"message": "Event "
+ event_id
+ " description set to "
+ new_description,
}
),
200,
status_code=200,
)
@EventBp.route("/events/<id>", methods=("DELETE",))
def delete_event(id):
@router.delete("/events/{event_id}")
def delete_event(request: Request, event_id: str):
try:
event = Event.get(Event.id == id)
event = Event.get(Event.id == event_id)
except DoesNotExist:
return make_response(
jsonify({"success": False, "message": "Event " + id + " not found"}), 404
return JSONResponse(
content=({"success": False, "message": "Event " + event_id + " not found"}),
status_code=404,
)
media_name = f"{event.camera}-{event.id}"
@@ -965,82 +963,86 @@ def delete_event(id):
media.unlink(missing_ok=True)
event.delete_instance()
Timeline.delete().where(Timeline.source_id == id).execute()
Timeline.delete().where(Timeline.source_id == event_id).execute()
# If semantic search is enabled, update the index
if current_app.frigate_config.semantic_search.enabled:
context: EmbeddingsContext = current_app.embeddings
context.embeddings.thumbnail.delete(ids=[id])
context.embeddings.description.delete(ids=[id])
return make_response(
jsonify({"success": True, "message": "Event " + id + " deleted"}), 200
if request.app.frigate_config.semantic_search.enabled:
context: EmbeddingsContext = request.app.embeddings
context.embeddings.thumbnail.delete(ids=[event_id])
context.embeddings.description.delete(ids=[event_id])
return JSONResponse(
content=({"success": True, "message": "Event " + event_id + " deleted"}),
status_code=200,
)
@EventBp.route("/events/<camera_name>/<label>/create", methods=["POST"])
def create_event(camera_name, label):
if not camera_name or not current_app.frigate_config.cameras.get(camera_name):
return make_response(
jsonify(
@router.post("/events/{camera_name}/{label}/create")
def create_event(
request: Request,
camera_name: str,
label: str,
body: EventsCreateBody = None,
):
if not camera_name or not request.app.frigate_config.cameras.get(camera_name):
return JSONResponse(
content=(
{"success": False, "message": f"{camera_name} is not a valid camera."}
),
404,
status_code=404,
)
if not label:
return make_response(
jsonify({"success": False, "message": f"{label} must be set."}), 404
return JSONResponse(
content=({"success": False, "message": f"{label} must be set."}),
status_code=404,
)
json: dict[str, any] = request.get_json(silent=True) or {}
try:
frame = current_app.detected_frames_processor.get_current_frame(camera_name)
frame = request.app.detected_frames_processor.get_current_frame(camera_name)
event_id = current_app.external_processor.create_manual_event(
event_id = request.app.external_processor.create_manual_event(
camera_name,
label,
json.get("source_type", "api"),
json.get("sub_label", None),
json.get("score", 0),
json.get("duration", 30),
json.get("include_recording", True),
json.get("draw", {}),
body.source_type,
body.sub_label,
body.score,
body.duration,
body.include_recording,
body.draw,
frame,
)
except Exception as e:
logger.error(e)
return make_response(
jsonify({"success": False, "message": "An unknown error occurred"}),
500,
return JSONResponse(
content=({"success": False, "message": "An unknown error occurred"}),
status_code=500,
)
return make_response(
jsonify(
return JSONResponse(
content=(
{
"success": True,
"message": "Successfully created event.",
"event_id": event_id,
}
),
200,
status_code=200,
)
@EventBp.route("/events/<event_id>/end", methods=["PUT"])
def end_event(event_id):
json: dict[str, any] = request.get_json(silent=True) or {}
@router.put("/events/{event_id}/end")
def end_event(request: Request, event_id: str, body: EventsEndBody):
try:
end_time = json.get("end_time", datetime.now().timestamp())
current_app.external_processor.finish_manual_event(event_id, end_time)
end_time = body.end_time
request.app.external_processor.finish_manual_event(event_id, end_time)
except Exception:
return make_response(
jsonify(
return JSONResponse(
content=(
{"success": False, "message": f"{event_id} must be set and valid."}
),
404,
status_code=404,
)
return make_response(
jsonify({"success": True, "message": "Event successfully ended."}), 200
return JSONResponse(
content=({"success": True, "message": "Event successfully ended."}),
status_code=200,
)