diff --git a/client/pyroclient/client.py b/client/pyroclient/client.py index b36eb9ff..3a7af953 100644 --- a/client/pyroclient/client.py +++ b/client/pyroclient/client.py @@ -218,12 +218,12 @@ def fetch_detections(self) -> Response: timeout=self.timeout, ) - def label_sequence(self, sequence_id: int, is_wildfire: bool) -> Response: + def label_sequence(self, sequence_id: int, is_wildfire: str) -> Response: """Update the label of a sequence made by a camera >>> from pyroclient import client >>> api_client = Client("MY_USER_TOKEN") - >>> response = api_client.label_sequence(1, is_wildfire=True) + >>> response = api_client.label_sequence(1, is_wildfire="wildfire_smoke") Args: sequence_id: ID of the associated sequence entry diff --git a/client/tests/conftest.py b/client/tests/conftest.py index 5de6c59f..50524e1c 100644 --- a/client/tests/conftest.py +++ b/client/tests/conftest.py @@ -35,6 +35,8 @@ def cam_token(): "elevation": 1582, "lat": 44.765181, "lon": 4.51488, + "ip_address": "165.165.165.165", + "livestream_activated": False, "is_trustable": True, } response = requests.post(urljoin(API_URL, "cameras"), json=payload, headers=admin_headers, timeout=5) diff --git a/client/tests/test_client.py b/client/tests/test_client.py index c82c8b45..bb9ff1c1 100644 --- a/client/tests/test_client.py +++ b/client/tests/test_client.py @@ -54,7 +54,7 @@ def test_agent_workflow(test_cam_workflow, agent_token): agent_client = Client(agent_token, "http://localhost:5050", timeout=10) response = agent_client.fetch_latest_sequences().json() assert len(response) == 1 - response = agent_client.label_sequence(response[0]["id"], True) + response = agent_client.label_sequence(response[0]["id"], "wildfire_smoke") assert response.status_code == 200, response.__dict__ diff --git a/scripts/dbdiagram.txt b/scripts/dbdiagram.txt index 6d2af5d2..94ac99ee 100644 --- a/scripts/dbdiagram.txt +++ b/scripts/dbdiagram.txt @@ -37,7 +37,7 @@ Table "Sequence" as S { "id" int [not null] "camera_id" int [ref: > C.id, not null] "azimuth" float [not null] - "is_wildfire" bool + "is_wildfire" AnnotationType "started_at" timestamp [not null] "last_seen_at" timestamp [not null] Indexes { diff --git a/scripts/test_e2e.py b/scripts/test_e2e.py index f705327d..94e37a5b 100644 --- a/scripts/test_e2e.py +++ b/scripts/test_e2e.py @@ -78,6 +78,8 @@ def main(args): "lat": 44.7, "lon": 4.5, "azimuth": 110, + "ip_address": "165.165.165.165", + "livestream_activated": False, } cam_id = api_request("post", f"{args.endpoint}/cameras/", agent_auth, payload)["id"] @@ -152,7 +154,9 @@ def main(args): == 1 ) # Label the sequence - api_request("patch", f"{args.endpoint}/sequences/{sequence['id']}/label", agent_auth, {"is_wildfire": True}) + api_request( + "patch", f"{args.endpoint}/sequences/{sequence['id']}/label", agent_auth, {"is_wildfire": "wildfire_smoke"} + ) # Check the sequence's detections dets = api_request("get", f"{args.endpoint}/sequences/{sequence['id']}/detections", agent_auth) assert len(dets) == 3 diff --git a/src/app/models.py b/src/app/models.py index 455f5c8c..397e5624 100644 --- a/src/app/models.py +++ b/src/app/models.py @@ -27,6 +27,29 @@ class Role(str, Enum): USER = "user" +class AnnotationType(str, Enum): + WILDFIRE_SMOKE = "wildfire_smoke" + OTHER_SMOKE = "other_smoke" + ANTENNA = "antenna" + BUILDING = "building" + CLIFF = "cliff" + DARK = "dark" + DUST = "dust" + HIGH_CLOUD = "high_cloud" + LOW_CLOUD = "low_cloud" + LENS_FLARE = "lens_flare" + LENS_DROPLET = "lens_droplet" + LIGHT = "light" + RAIN = "rain" + TRAIL = "trail" + ROAD = "road" + SKY = "sky" + TREE = "tree" + WATER_BODY = "water_body" + DOUBT = "doubt" + OTHER = "other" + + class User(SQLModel, table=True): __tablename__ = "users" id: int = Field(None, primary_key=True) @@ -47,6 +70,8 @@ class Camera(SQLModel, table=True): elevation: float = Field(..., gt=0, lt=10000, nullable=False) lat: float = Field(..., gt=-90, lt=90) lon: float = Field(..., gt=-180, lt=180) + ip_address: str + livestream_activated: bool = False is_trustable: bool = True last_active_at: Union[datetime, None] = None last_image: Union[str, None] = None @@ -69,7 +94,7 @@ class Sequence(SQLModel, table=True): id: int = Field(None, primary_key=True) camera_id: int = Field(..., foreign_key="cameras.id", nullable=False) azimuth: float = Field(..., ge=0, lt=360) - is_wildfire: Union[bool, None] = None + is_wildfire: Union[AnnotationType, None] = None started_at: datetime = Field(..., nullable=False) last_seen_at: datetime = Field(..., nullable=False) diff --git a/src/app/schemas/cameras.py b/src/app/schemas/cameras.py index 006eb1ca..cf12b507 100644 --- a/src/app/schemas/cameras.py +++ b/src/app/schemas/cameras.py @@ -49,6 +49,8 @@ class CameraCreate(CameraEdit): description="angle between left and right camera view", json_schema_extra={"examples": [120.0]}, ) + ip_address: str + livestream_activated: bool = False is_trustable: bool = Field(True, description="whether the detection from this camera can be trusted") diff --git a/src/app/schemas/detections.py b/src/app/schemas/detections.py index 6bb9a32d..f30fa6c5 100644 --- a/src/app/schemas/detections.py +++ b/src/app/schemas/detections.py @@ -9,13 +9,13 @@ from pydantic import BaseModel, Field from app.core.config import settings -from app.models import Detection +from app.models import AnnotationType, Detection __all__ = ["Azimuth", "DetectionCreate", "DetectionLabel", "DetectionUrl"] class DetectionLabel(BaseModel): - is_wildfire: bool + is_wildfire: AnnotationType class Azimuth(BaseModel): diff --git a/src/app/schemas/sequences.py b/src/app/schemas/sequences.py index 635c83d4..382aedbc 100644 --- a/src/app/schemas/sequences.py +++ b/src/app/schemas/sequences.py @@ -7,7 +7,7 @@ from pydantic import BaseModel -from app.models import Sequence +from app.models import AnnotationType, Sequence __all__ = ["SequenceUpdate", "SequenceWithCone"] @@ -18,7 +18,7 @@ class SequenceUpdate(BaseModel): class SequenceLabel(BaseModel): - is_wildfire: bool + is_wildfire: AnnotationType class SequenceWithCone(Sequence): diff --git a/src/migrations/versions/2025_06_20_1945-42dzeg392dhu_fix_migration.py b/src/migrations/versions/2025_06_20_1945-42dzeg392dhu_fix_migration.py new file mode 100644 index 00000000..d4f8b081 --- /dev/null +++ b/src/migrations/versions/2025_06_20_1945-42dzeg392dhu_fix_migration.py @@ -0,0 +1,53 @@ +import sqlalchemy as sa +from alembic import op + +# Ajoute ton identifiant de révision et dépendance si besoin +revision = "42dzeg392dhu" +down_revision = "4265426f8438" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1. Créer la table sequences (doit précéder la FK) + op.create_table( + "sequences", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("camera_id", sa.Integer(), sa.ForeignKey("camera.id"), nullable=False), + sa.Column("azimuth", sa.Float(), nullable=False), + sa.Column("is_wildfire", sa.Boolean(), nullable=True), # sera modifié par la 4e migration + sa.Column("started_at", sa.DateTime(), nullable=False), + sa.Column("last_seen_at", sa.DateTime(), nullable=False), + ) + + # 2. Ajouter les colonnes manquantes + op.add_column("camera", sa.Column("last_image", sa.String(), nullable=True)) + op.add_column("organization", sa.Column("telegram_id", sa.String(), nullable=True)) + op.add_column("detection", sa.Column("sequence_id", sa.Integer(), nullable=True)) + op.add_column("detection", sa.Column("bboxes", sa.String(length=5000), nullable=False)) # adapter à settings + + # 3. Ajouter la contrainte FK après la création de la table sequences + op.create_foreign_key( + "fk_detection_sequence", + "detection", + "sequences", + ["sequence_id"], + ["id"], + ) + + # 4. Créer la table webhooks + op.create_table( + "webhooks", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("url", sa.String(), nullable=False, unique=True), + ) + + +def downgrade() -> None: + op.drop_table("webhooks") + op.drop_constraint("fk_detection_sequence", "detection", type_="foreignkey") + op.drop_column("detection", "bboxes") + op.drop_column("detection", "sequence_id") + op.drop_column("organization", "telegram_id") + op.drop_column("camera", "last_image") + op.drop_table("sequences") diff --git a/src/migrations/versions/2025_06_25_1720-2853acd1fc32_add_slack_hook.py b/src/migrations/versions/2025_06_25_1720-2853acd1fc32_add_slack_hook.py new file mode 100644 index 00000000..0c5925a4 --- /dev/null +++ b/src/migrations/versions/2025_06_25_1720-2853acd1fc32_add_slack_hook.py @@ -0,0 +1,25 @@ +"""Add Slack Hook +Revision ID: 2853acd1fc32 +Revises: 4265426f8438 +Create Date: 2025-06-25 17:20:14.959429 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +import sqlmodel +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "2853acd1fc32" +down_revision: Union[str, None] = "42dzeg392dhu" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("organization", sa.Column("slack_hook", sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("organization", "slack_hook") diff --git a/src/migrations/versions/2025_08_20_1647-307a1d6d490d_modify_is_wilfire_column.py b/src/migrations/versions/2025_08_20_1647-307a1d6d490d_modify_is_wilfire_column.py new file mode 100644 index 00000000..6fd81826 --- /dev/null +++ b/src/migrations/versions/2025_08_20_1647-307a1d6d490d_modify_is_wilfire_column.py @@ -0,0 +1,75 @@ +"""modify is_wilfire column + +Revision ID: 307a1d6d490d +Revises: 2853acd1fc32 +Create Date: 2025-08-20 16:47:05.346210 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "307a1d6d490d" +down_revision: Union[str, None] = "2853acd1fc32" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +# Define the new ENUM type +annotation_type_enum = sa.Enum( + "WILDFIRE_SMOKE", + "OTHER_SMOKE", + "ANTENNA", + "BUILDING", + "CLIFF", + "DARK", + "DUST", + "HIGH_CLOUD", + "LOW_CLOUD", + "LENS_FLARE", + "LENS_DROPLET", + "LIGHT", + "RAIN", + "TRAIL", + "ROAD", + "SKY", + "TREE", + "WATER_BODY", + "DOUBT", + "OTHER", + name="annotationtype", +) + + +def upgrade(): + # Create the enum type in the database + annotation_type_enum.create(op.get_bind(), checkfirst=True) + + # Use raw SQL with a CASE expression for the conversion + op.execute(""" + ALTER TABLE sequences + ALTER COLUMN is_wildfire + TYPE annotationtype + USING CASE + WHEN is_wildfire = TRUE THEN 'WILDFIRE_SMOKE'::annotationtype + ELSE 'OTHER'::annotationtype + END + """) + + +def downgrade(): + # Revert the column back to a boolean (or previous enum if applicable) + op.execute(""" + ALTER TABLE sequences + ALTER COLUMN is_wildfire + TYPE boolean + USING CASE + WHEN is_wildfire = 'WILDFIRE_SMOKE' THEN TRUE + ELSE FALSE + END + """) + + # Drop the enum type from the DB + annotation_type_enum.drop(op.get_bind(), checkfirst=True) diff --git a/src/migrations/versions/2025_08_28_1105-47005ff54a94_create_news_columns_in_cameras_table.py b/src/migrations/versions/2025_08_28_1105-47005ff54a94_create_news_columns_in_cameras_table.py new file mode 100644 index 00000000..62d7a4d9 --- /dev/null +++ b/src/migrations/versions/2025_08_28_1105-47005ff54a94_create_news_columns_in_cameras_table.py @@ -0,0 +1,29 @@ +"""create news columns in cameras table + +Revision ID: 47005ff54a94 +Revises: 307a1d6d490d +Create Date: 2025-08-28 11:05:46.058307 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +import sqlmodel +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "47005ff54a94" +down_revision: Union[str, None] = "307a1d6d490d" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("camera", sa.Column("ip_address", sqlmodel.sql.sqltypes.AutoString(), nullable=False)) + op.add_column("camera", sa.Column("livestream_activated", sa.Boolean(), nullable=False)) + + +def downgrade() -> None: + op.drop_column("camera", "ip_address") + op.drop_column("camera", "livestream_activated") diff --git a/src/tests/conftest.py b/src/tests/conftest.py index 56f950c5..f8553d34 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -74,6 +74,8 @@ "elevation": 110.6, "lat": 3.6, "lon": -45.2, + "ip_address": "165.165.165.165", + "livestream_activated": False, "is_trustable": True, "last_active_at": datetime.strptime("2023-11-07T15:07:19.226673", dt_format), "last_image": None, @@ -87,6 +89,8 @@ "elevation": 110.6, "lat": 3.6, "lon": -45.2, + "ip_address": "165.165.165.165", + "livestream_activated": True, "is_trustable": False, "last_active_at": None, "last_image": None, @@ -138,7 +142,7 @@ "id": 1, "camera_id": 1, "azimuth": 43.7, - "is_wildfire": True, + "is_wildfire": "wildfire_smoke", "started_at": datetime.strptime("2023-11-07T15:08:19.226673", dt_format), "last_seen_at": datetime.strptime("2023-11-07T15:28:19.226673", dt_format), }, diff --git a/src/tests/endpoints/test_cameras.py b/src/tests/endpoints/test_cameras.py index ab2061e9..fd60b11f 100644 --- a/src/tests/endpoints/test_cameras.py +++ b/src/tests/endpoints/test_cameras.py @@ -17,6 +17,8 @@ "elevation": 30.0, "lat": 3.5, "lon": 7.8, + "ip_address": "165.165.165.165", + "livestream_activated": False, }, 401, "Not authenticated", @@ -36,6 +38,8 @@ "elevation": 30.0, "lat": 3.5, "lon": 7.8, + "ip_address": "165.165.165.165", + "livestream_activated": False, }, 201, None, @@ -49,6 +53,8 @@ "elevation": 30.0, "lat": 3.5, "lon": 7.8, + "ip_address": "165.165.165.165", + "livestream_activated": False, }, 201, None, @@ -62,6 +68,8 @@ "elevation": 30.0, "lat": 3.5, "lon": 7.8, + "ip_address": "165.165.165.165", + "livestream_activated": False, }, 403, "Incompatible token scope.", diff --git a/src/tests/endpoints/test_sequences.py b/src/tests/endpoints/test_sequences.py index 795f5f56..bf9e8846 100644 --- a/src/tests/endpoints/test_sequences.py +++ b/src/tests/endpoints/test_sequences.py @@ -94,18 +94,18 @@ async def test_delete_sequence( @pytest.mark.parametrize( ("user_idx", "sequence_id", "payload", "status_code", "status_detail", "expected_idx"), [ - (None, 1, {"is_wildfire": True}, 401, "Not authenticated", None), - (0, 0, {"is_wildfire": True}, 422, None, None), - (0, 99, {"is_wildfire": True}, 404, None, None), - (0, 1, {"label": True}, 422, None, None), + (None, 1, {"is_wildfire": "wildfire_smoke"}, 401, "Not authenticated", None), + (0, 0, {"is_wildfire": "wildfire_smoke"}, 422, None, None), + (0, 99, {"is_wildfire": "wildfire_smoke"}, 404, None, None), + (0, 1, {"label": "wildfire_smoke"}, 422, None, None), (0, 1, {"is_wildfire": "hello"}, 422, None, None), # (0, 1, {"is_wildfire": "True"}, 422, None, None), # odd, this works - (0, 1, {"is_wildfire": True}, 200, None, 0), - (0, 2, {"is_wildfire": True}, 200, None, 1), - (1, 1, {"is_wildfire": True}, 200, None, 0), - (1, 2, {"is_wildfire": True}, 403, None, None), - (2, 1, {"is_wildfire": True}, 403, None, None), - (2, 2, {"is_wildfire": True}, 403, None, None), # User cannot label + (0, 1, {"is_wildfire": "wildfire_smoke"}, 200, None, 0), + (0, 2, {"is_wildfire": "wildfire_smoke"}, 200, None, 1), + (1, 1, {"is_wildfire": "wildfire_smoke"}, 200, None, 0), + (1, 2, {"is_wildfire": "wildfire_smoke"}, 403, None, None), + (2, 1, {"is_wildfire": "wildfire_smoke"}, 403, None, None), + (2, 2, {"is_wildfire": "wildfire_smoke"}, 403, None, None), # User cannot label ], ) @pytest.mark.asyncio