From 2261f09a6803d304779d6c2145a8bb2660a9203c Mon Sep 17 00:00:00 2001 From: Bruno Lenzi Date: Mon, 9 Oct 2023 23:09:28 +0200 Subject: [PATCH 1/3] feat: fields and methods to set event type (wildfire or something else) - add options to EventType enum: domestic fire, chimney, cloud, other, undefined (default) - add type_set_by and type_set_ts to events table - add PUT /events/{event_id}/type endpoint --- src/app/api/endpoints/events.py | 39 ++++++++++++++++++++++-- src/app/models/event.py | 12 ++++++-- src/app/models/user.py | 1 + src/app/schemas/events.py | 25 ++++++++++++++- src/tests/routes/test_events.py | 54 +++++++++++++++++++++++++++++++-- src/tests/utils.py | 4 ++- 6 files changed, 125 insertions(+), 10 deletions(-) diff --git a/src/app/api/endpoints/events.py b/src/app/api/endpoints/events.py index 3e9fdb2d..8532faaf 100644 --- a/src/app/api/endpoints/events.py +++ b/src/app/api/endpoints/events.py @@ -3,6 +3,7 @@ # This program is licensed under the Apache License 2.0. # See LICENSE or go to for full license details. +from datetime import datetime from typing import List, cast from fastapi import APIRouter, Depends, Path, Security, status @@ -12,10 +13,21 @@ from app.api import crud from app.api.crud.authorizations import check_group_read, check_group_update, is_admin_access from app.api.crud.groups import get_entity_group_id -from app.api.deps import get_current_access, get_db +from app.api.deps import get_current_access, get_current_user, get_db from app.db import alerts, events -from app.models import Access, AccessType, Alert, Device, Event -from app.schemas import Acknowledgement, AcknowledgementOut, AlertOut, EventIn, EventOut, EventUpdate +from app.models import Access, AccessType, Alert, Device, Event, EventType +from app.schemas import ( + AccessRead, + Acknowledgement, + AcknowledgementOut, + AlertOut, + EventIn, + EventOut, + EventTypeSetting, + EventTypeSettingOut, + EventUpdate, + UserRead, +) router = APIRouter() @@ -153,3 +165,24 @@ async def fetch_alerts_for_event( requested_group_id = await get_entity_group_id(events, event_id) await check_group_read(requester.id, cast(int, requested_group_id)) return await crud.base.database.fetch_all(query=alerts.select().where(alerts.c.event_id == event_id)) + + +@router.put("/{event_id}/type", response_model=EventTypeSettingOut, summary="Redefine the type of an existing event") +async def update_event_type( + event_type: EventType, + event_id: int = Path(..., gt=0), + requester: AccessRead = Security(get_current_access, scopes=[AccessType.admin, AccessType.user]), + user: UserRead = Security(get_current_user, scopes=[AccessType.admin, AccessType.user]), +): + """ + Based on an event_id, redefine the type of the given event + """ + requested_group_id = await get_entity_group_id(events, event_id) + await check_group_update(requester.id, cast(int, requested_group_id)) + return await crud.update_entry( + events, + EventTypeSetting(type=event_type, type_set_by=user.id, type_set_ts=datetime.utcnow()) + if event_type != EventType.undefined + else EventTypeSetting(type=event_type, type_set_by=None, type_set_ts=None), + event_id, + ) diff --git a/src/app/models/event.py b/src/app/models/event.py index b3e9bab3..7c7423f7 100644 --- a/src/app/models/event.py +++ b/src/app/models/event.py @@ -5,7 +5,7 @@ import enum -from sqlalchemy import Boolean, Column, DateTime, Enum, Float, Integer +from sqlalchemy import Boolean, Column, DateTime, Enum, Float, ForeignKey, Integer from sqlalchemy.orm import RelationshipProperty, relationship from sqlalchemy.sql import func @@ -16,6 +16,11 @@ class EventType(str, enum.Enum): wildfire: str = "wildfire" + domestic_fire: str = "domestic fire" + chimney: str = "chimney" + cloud: str = "cloud" + other: str = "other" + undefined: str = "undefined" class Event(Base): @@ -24,13 +29,16 @@ class Event(Base): id = Column(Integer, primary_key=True) lat = Column(Float(4, asdecimal=True)) lon = Column(Float(4, asdecimal=True)) - type = Column(Enum(EventType), default=EventType.wildfire) + type = Column(Enum(EventType), default=EventType.undefined) start_ts = Column(DateTime, default=func.now()) end_ts = Column(DateTime, default=None, nullable=True) is_acknowledged = Column(Boolean, default=False) created_at = Column(DateTime, default=func.now()) + type_set_by = Column(Integer, ForeignKey("users.id")) + type_set_ts = Column(DateTime, default=None, nullable=True) alerts: RelationshipProperty = relationship("Alert", back_populates="event") + type_setter: RelationshipProperty = relationship("User", back_populates="type_set_events") def __repr__(self): return ( diff --git a/src/app/models/user.py b/src/app/models/user.py index 41194e06..092bb39c 100644 --- a/src/app/models/user.py +++ b/src/app/models/user.py @@ -23,6 +23,7 @@ class User(Base): access: RelationshipProperty = relationship("Access", uselist=False, back_populates="user") device: RelationshipProperty = relationship("Device", uselist=False, back_populates="owner") + type_set_events: RelationshipProperty = relationship("Event", back_populates="type_setter") def __repr__(self): return f"" diff --git a/src/app/schemas/events.py b/src/app/schemas/events.py index 322ca188..65abb8a3 100644 --- a/src/app/schemas/events.py +++ b/src/app/schemas/events.py @@ -12,7 +12,15 @@ from .base import _CreatedAt, _FlatLocation, _Id, validate_datetime_none -__all__ = ["EventIn", "EventOut", "EventUpdate", "Acknowledgement", "AcknowledgementOut"] +__all__ = [ + "EventIn", + "EventOut", + "EventUpdate", + "Acknowledgement", + "AcknowledgementOut", + "EventTypeSetting", + "EventTypeSettingOut", +] # Events @@ -25,9 +33,12 @@ class EventIn(_FlatLocation): None, description="timestamp of event end", example=datetime.utcnow().replace(tzinfo=None) ) is_acknowledged: bool = Field(False, description="whether the event has been acknowledged") + type_set_by: Optional[int] = Field(None, description="id of the user who defined the event type") + type_set_ts: Optional[datetime] = Field(None, description="event type definition timestamp") _validate_start_ts = validator("start_ts", pre=True, always=True, allow_reuse=True)(validate_datetime_none) _validate_end_ts = validator("end_ts", pre=True, always=True, allow_reuse=True)(validate_datetime_none) + _validate_type_ts = validator("type_set_ts", pre=True, always=True, allow_reuse=True)(validate_datetime_none) class EventOut(EventIn, _CreatedAt, _Id): @@ -42,6 +53,18 @@ class AcknowledgementOut(Acknowledgement, _Id): pass +class EventTypeSetting(BaseModel): + type: EventType = Field(..., description="event type") + type_set_by: Optional[int] = Field(None, description="id of the user who defined the event type") + type_set_ts: Optional[datetime] = Field(None, description="event type definition timestamp") + + _validate_type_ts = validator("type_set_ts", pre=True, always=True, allow_reuse=True)(validate_datetime_none) + + +class EventTypeSettingOut(EventTypeSetting, _Id): + pass + + class EventUpdate(_FlatLocation): type: EventType = Field(..., description="event type") start_ts: datetime = Field( diff --git a/src/tests/routes/test_events.py b/src/tests/routes/test_events.py index 1510c759..bd5f3f11 100644 --- a/src/tests/routes/test_events.py +++ b/src/tests/routes/test_events.py @@ -24,6 +24,8 @@ "start_ts": "2020-09-13T08:18:45.447773", "end_ts": "2020-09-13T08:18:45.447773", "is_acknowledged": True, + "type_set_by": 2, + "type_set_ts": "2020-10-13T08:18:45.447773", "created_at": "2020-10-13T08:18:45.447773", }, { @@ -34,16 +36,20 @@ "start_ts": "2020-09-13T08:18:45.447773", "end_ts": None, "is_acknowledged": True, + "type_set_by": 2, + "type_set_ts": "2020-10-13T08:18:45.447773", "created_at": "2020-09-13T08:18:45.447773", }, { "id": 3, "lat": -5.0, "lon": 3.0, - "type": "wildfire", + "type": "undefined", "start_ts": "2021-03-13T08:18:45.447773", "end_ts": "2021-03-13T10:18:45.447773", "is_acknowledged": False, + "type_set_by": None, + "type_set_ts": None, "created_at": "2020-09-13T08:18:45.447773", }, ] @@ -274,10 +280,12 @@ async def test_create_event(test_app_asyncio, init_test_db, test_db, access_idx, if response.status_code // 100 == 2: json_response = response.json() test_response = {"id": len(EVENT_TABLE) + 1, **payload, "end_ts": None, "is_acknowledged": False} - assert {k: v for k, v in json_response.items() if k not in ("created_at", "start_ts")} == test_response + assert { + k: v for k, v in json_response.items() if k not in ("created_at", "start_ts", "type_set_by", "type_set_ts") + } == test_response new_event_in_db = await get_entry(test_db, db.events, json_response["id"]) new_event_in_db = dict(**new_event_in_db) - assert new_event_in_db["created_at"] > utc_dt and new_event_in_db["created_at"] < datetime.utcnow() + assert utc_dt < new_event_in_db["created_at"] < datetime.utcnow() @pytest.mark.parametrize( @@ -559,3 +567,43 @@ async def test_fetch_alerts_for_event( assert response.json() == [ entry for entry in ALERT_TABLE if (entry["event_id"] == event_id and entry["id"] in alerts_group_id) ] + + +@pytest.mark.parametrize( + "access_idx, event_id, event_type, status_code, status_details", + [ + [None, 1, "undefined", 401, "Not authenticated"], + [0, 1, "wildfire", 200, None], + [1, 1, "domestic fire", 200, None], + [1, 1, "undefined", 200, None], + [1, 1, "lightning", 422, None], + [2, 1, "wildfire", 403, "Your access scope is not compatible with this operation."], + ], +) +@pytest.mark.asyncio +async def test_update_event_type( + test_app_asyncio, init_test_db, test_db, access_idx, event_id, event_type, status_code, status_details +): + # Create a custom access token + auth = None + if isinstance(access_idx, int): + auth = await pytest.get_token(ACCESS_TABLE[access_idx]["id"], ACCESS_TABLE[access_idx]["scope"].split()) + + utc_dt = datetime.utcnow() + response = await test_app_asyncio.put(f"/events/{event_id}/type?event_type={event_type}", headers=auth) + assert response.status_code == status_code + if isinstance(status_details, str): + assert response.json()["detail"] == status_details + + if response.status_code // 100 == 2: + updated_event = await get_entry(test_db, db.events, event_id) + updated_event = dict(**updated_event) + user_id = next(item["id"] for item in USER_TABLE if item["access_id"] == ACCESS_TABLE[access_idx]["id"]) + assert updated_event["type"] == event_type + if event_type != "undefined": + assert updated_event["type_set_by"] == user_id + assert utc_dt < updated_event["type_set_ts"] < datetime.utcnow() + else: + assert updated_event["type_set_by"] is None + assert updated_event["type_set_ts"] is None + diff --git a/src/tests/utils.py b/src/tests/utils.py index 07b81b1d..5e4934d6 100644 --- a/src/tests/utils.py +++ b/src/tests/utils.py @@ -6,7 +6,9 @@ def update_only_datetime(entity_as_dict: Dict[str, Any]): return { - k: parse_time(v) if isinstance(v, str) and k in ("created_at", "start_ts", "end_ts", "last_ping") else v + k: parse_time(v) + if isinstance(v, str) and k in ("created_at", "start_ts", "end_ts", "last_ping", "type_set_ts") + else v for k, v in entity_as_dict.items() } From d0f9564358c15547c19143a820b0c0b82304ef7d Mon Sep 17 00:00:00 2001 From: Bruno Lenzi Date: Mon, 9 Oct 2023 23:19:21 +0200 Subject: [PATCH 2/3] fix: black --- src/tests/routes/test_events.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/tests/routes/test_events.py b/src/tests/routes/test_events.py index bd5f3f11..d7882473 100644 --- a/src/tests/routes/test_events.py +++ b/src/tests/routes/test_events.py @@ -606,4 +606,3 @@ async def test_update_event_type( else: assert updated_event["type_set_by"] is None assert updated_event["type_set_ts"] is None - From 3abe13cd8b532c97eda6a1a08a5fa455d86133f7 Mon Sep 17 00:00:00 2001 From: Bruno Lenzi Date: Mon, 9 Oct 2023 23:39:24 +0200 Subject: [PATCH 3/3] feat: add method `set_event_type` to client --- client/pyroclient/client.py | 17 +++++++++++++++++ client/tests/test_client.py | 2 ++ 2 files changed, 19 insertions(+) diff --git a/client/pyroclient/client.py b/client/pyroclient/client.py index d6221219..1b7277bc 100644 --- a/client/pyroclient/client.py +++ b/client/pyroclient/client.py @@ -37,6 +37,7 @@ "get-unacknowledged-events": "/events/unacknowledged", "get-past-events": "/events/past", "acknowledge-event": "/events/{event_id}/acknowledge", + "set-event-type": "/events/{event_id}/type?event_type={event_type}", "get-alerts-for-event": "/events/{event_id}/alerts", ################# # INSTALLATIONS @@ -274,6 +275,22 @@ def acknowledge_event(self, event_id: int) -> Response: self.routes["acknowledge-event"].format(event_id=event_id), headers=self.headers, timeout=self.timeout ) + def set_event_type(self, event_id: int, event_type: str): + """Set the event type field value + + Args: + event_id: ID of the associated event entry + event_type: event type ("wildfire" or other) + + Returns: + HTTP response containing the updated event + """ + return requests.put( + self.routes["set-event-type"].format(event_id=event_id, event_type=event_type), + headers=self.headers, + timeout=self.timeout, + ) + def get_site_devices(self, site_id: int) -> Response: """Fetch the devices that are installed on a specific site diff --git a/client/tests/test_client.py b/client/tests/test_client.py index 11c1f772..dd7e6902 100644 --- a/client/tests/test_client.py +++ b/client/tests/test_client.py @@ -86,6 +86,8 @@ def test_client_user(setup, user_client, mock_img): events = _test_route_return(user_client.get_unacknowledged_events(), list) event = _test_route_return(user_client.acknowledge_event(events[0]["id"]), dict) assert event["is_acknowledged"] + type_info = _test_route_return(user_client.set_event_type(events[0]["id"], "wildfire"), dict) + assert type_info["type"] == "wildfire" _test_route_return(user_client.get_all_alerts(), list) _test_route_return(user_client.get_ongoing_alerts(), list) _test_route_return(user_client.get_alerts_for_event(events[0]["id"]), list)