Skip to content

Commit

Permalink
feat(models): add an organization table (#332)
Browse files Browse the repository at this point in the history
* feat: first commit for Site table creation

* fix: error when launching backend

* fix: sites tests

* fix mypy

* fix tests

* fix: add site_id in user table

* feat: implement new security behavior using site_id

* feat : add CRUD endpoints for the site path + tests

* refactor: Site -> Organization

* feat: refactor security behavior

* feat: fix tests

* fix e2e tests

* fix client tests

* fix "role" column

* fix: remove useless security in case of create_detection

* fix: resolve first comments

* fix error in detection endpoint

* take feedback into account

* feat: add crud function to avoid for loop

* feedback PR

* fix lint

* fix linting

---------

Co-authored-by: Ronan <[email protected]>
  • Loading branch information
RonanMorgan and Ronan authored Jul 15, 2024
1 parent 2dcab5b commit ca23c40
Show file tree
Hide file tree
Showing 38 changed files with 709 additions and 124 deletions.
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ S3_BUCKET_NAME=bucket
# Initialization
SUPERADMIN_LOGIN='pyroadmin'
SUPERADMIN_PWD='LetsProtectForests!'
SUPERADMIN_ORG='pyronear'

# Optional variables
JWT_SECRET=
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/builds.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ jobs:
env:
SUPERADMIN_LOGIN: dummy_login
SUPERADMIN_PWD: dummy&P@ssw0rd!
SUPERADMIN_ORG: dummyorga
POSTGRES_USER: dummy_pg_user
POSTGRES_PASSWORD: dummy_pg_pwd
POSTGRES_DB: dummy_pg_db
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ None :)
- `POSTGRES_PASSWORD`: a password for the PostgreSQL database
- `SUPERADMIN_LOGIN`: the login of the initial admin user
- `SUPERADMIN_PWD`: the password of the initial admin user
- `SUPERADMIN_ORG`: the organization of the initial admin user

#### Other optional values
- `JWT_SECRET`: if set, tokens can be reused between sessions. All instances sharing the same secret key can use the same token.
Expand Down
11 changes: 8 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,22 @@ stop:
docker compose down

# Run tests for the library
# the "-" are used to launch the next command even if a command fail
test:
poetry export -f requirements.txt --without-hashes --with test --output requirements.txt
docker compose -f docker-compose.dev.yml up -d --build --wait
docker compose exec -T backend pytest --cov=app
- docker compose exec -T backend pytest --cov=app
docker compose -f docker-compose.dev.yml down

build-client:
pip install -e client/.

# Run tests for the Python client
# the "-" are used to launch the next command even if a command fail
test-client:
poetry export -f requirements.txt --without-hashes --output requirements.txt
docker compose -f docker-compose.dev.yml up -d --build --wait
cd client && pytest --cov=pyroclient tests/ && cd ..
- cd client && pytest --cov=pyroclient tests/ && cd ..
docker compose -f docker-compose.dev.yml down

# Check that docs can build for client
Expand All @@ -49,5 +54,5 @@ docs-client:
e2e:
poetry export -f requirements.txt --without-hashes --output requirements.txt
docker compose -f docker-compose.dev.yml up -d --build --wait
python scripts/test_e2e.py
- python scripts/test_e2e.py
docker compose -f docker-compose.dev.yml down
62 changes: 20 additions & 42 deletions client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,52 +32,30 @@ Client for the [Alert management API](https://github.com/pyronear/pyro-api)
General users can use the API client to request available data within their respective scope (i.e. as a private individual, you won't have access to the data from devices of firefighters; however you will have access to all the data related to your devices). You can find several examples below:

```python
API_URL = "http://pyronear-api.herokuapp.com"
USER_LOGIN = "George Abitbol"
USER_PASSWORD = "AStrong Password"
api_client = client.Client(API_URL, USER_LOGIN, USER_PASSWORD)

# List your registered devices
devices = api_client.get_my_devices().json()
# List sites accessible in your scope
sites = api_client.get_sites().json()
# List all past events in your scope
events = api_client.get_past_events().json()
# List all alerts in your scope
alerts = api_client.get_alerts().json()
API_URL = os.getenv("API_URL", "http://localhost:5050/api/v1/")
login = os.getenv("USER_LOGIN", "superadmin_login")
pwd = os.getenv("USER_PWD", "superadmin_pwd")
token = requests.post(
urljoin(API_URL, "login/creds"),
data={"username": login, "password": pwd},
timeout=5,
).json()["access_token"]
api_client = Client(token, "http://localhost:5050", timeout=10)

# List organizations accessible in your scope
organizations = api_client.fetch_organizations()
# Get the url of the image of a detection
url = api_client.get_detection_url(detection_id)
```

### Using the client for your local device

If you have a registered device, there are several different interactions (some client methods are restricted to specific access type):

```python
API_URL = "http://pyronear-api.herokuapp.com"
DEVICE_LOGIN = "R2D2"
DEVICE_PASSWORD = "C3POIsTheBest"
api_client = client.Client(API_URL, DEVICE_LOGIN, DEVICE_PASSWORD)

# Retrieve the registered information about your device
api_client.get_my_device()
# Notify the instance that your device is still active
api_client.heartbeat()
## Create an event
event_id = api_client.create_event(lat=10, lon=10).json()["id"]
## Create a media
media_id = api_client.create_media_from_device().json()["id"]
## Create an alert linked to the media and the event
api_client.send_alert_from_device(lat=10, lon=10, event_id=event_id, media_id=media_id)

## Upload an image linked to the media
dummy_image = "https://ec.europa.eu/jrc/sites/jrcsh/files/styles/normal-responsive/" \
+ "public/growing-risk-future-wildfires_adobestock_199370851.jpeg"
image_data = requests.get(dummy_image).content
api_client.upload_media(media_id=media_id, image_data=image_data)

## Update your position:
api_client.update_my_location(lat=1, lon=2, elevation=50, azimuth=30, pitch=3)
# Update your software hash
api_client.update_my_hash("MyNewHash")
cam_token = requests.post(urljoin(API_URL, f"cameras/{cam_id}/token"), headers=admin_headers, timeout=5).json()[
"access_token"
]

camera_client = Client(cam_token, "http://localhost:5050", timeout=10)
response = cam_client.create_detection(image, 123.2)
```

## Installation
Expand Down
2 changes: 1 addition & 1 deletion client/docs/source/conf.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright (C) 2020-2024, Pyronear.
# Copyright (C) 2024, Pyronear.

# This program is licensed under the Apache License 2.0.
# See LICENSE or go to <https://opensource.org/licenses/Apache-2.0> for full license details.
Expand Down
1 change: 1 addition & 0 deletions client/pyroclient/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
from .client import *
from .exceptions import *
from .version import __version__
22 changes: 22 additions & 0 deletions client/pyroclient/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@
"detections-label": "/detections/{det_id}/label",
"detections-fetch": "/detections",
"detections-url": "/detections/{det_id}/url",
#################
# ORGS
#################
"organizations-fetch": "/organizations",
}


Expand Down Expand Up @@ -182,3 +186,21 @@ def fetch_detections(self) -> Response:
headers=self.headers,
timeout=self.timeout,
)

# ORGANIZATIONS

def fetch_organizations(self) -> Response:
"""List the organizations accessible to the authenticated user
>>> from pyroclient import client
>>> api_client = Client("MY_USER_TOKEN")
>>> response = api_client.fetch_organizations()
Returns:
HTTP response
"""
return requests.get(
self.routes["organizations-fetch"],
headers=self.headers,
timeout=self.timeout,
)
7 changes: 6 additions & 1 deletion client/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
SUPERADMIN_LOGIN = os.getenv("SUPERADMIN_LOGIN", "superadmin_login")
SUPERADMIN_PWD = os.getenv("SUPERADMIN_PWD", "superadmin_pwd")
SUPERADMIN_TOKEN = requests.post(
urljoin(API_URL, "login/creds"), data={"username": SUPERADMIN_LOGIN, "password": SUPERADMIN_PWD}, timeout=5
urljoin(API_URL, "login/creds"),
data={"username": SUPERADMIN_LOGIN, "password": SUPERADMIN_PWD},
timeout=5,
).json()["access_token"]


Expand All @@ -28,6 +30,7 @@ def cam_token():
admin_headers = {"Authorization": f"Bearer {SUPERADMIN_TOKEN}"}
payload = {
"name": "pyro-camera-01",
"organization_id": 1,
"angle_of_view": 120,
"elevation": 1582,
"lat": 44.765181,
Expand All @@ -51,6 +54,7 @@ def agent_token():
"role": "agent",
"login": agent_login,
"password": agent_pwd,
"organization_id": 1,
}
response = requests.post(urljoin(API_URL, "users"), json=payload, headers=admin_headers, timeout=5)
assert response.status_code == 201
Expand All @@ -68,6 +72,7 @@ def user_token():
"role": "user",
"login": user_login,
"password": user_pwd,
"organization_id": 1,
}
response = requests.post(urljoin(API_URL, "users"), json=payload, headers=admin_headers, timeout=5)
assert response.status_code == 201
Expand Down
1 change: 1 addition & 0 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ services:
- POSTGRES_URL=postgresql+asyncpg://dummy_pg_user:dummy_pg_pwd@db/dummy_pg_db
- SUPERADMIN_LOGIN=superadmin_login
- SUPERADMIN_PWD=superadmin_pwd
- SUPERADMIN_ORG=superadmin_org
- JWT_SECRET=${JWT_SECRET}
- SUPPORT_EMAIL=${SUPPORT_EMAIL}
- DEBUG=true
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ services:
- POSTGRES_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db/${POSTGRES_DB}
- SUPERADMIN_LOGIN=${SUPERADMIN_LOGIN}
- SUPERADMIN_PWD=${SUPERADMIN_PWD}
- SUPERADMIN_ORG=${SUPERADMIN_ORG}
- JWT_SECRET=${JWT_SECRET}
- SUPPORT_EMAIL=${SUPPORT_EMAIL}
- DEBUG=true
Expand Down
12 changes: 10 additions & 2 deletions scripts/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def main(args):
user_pwd = "my_pwd" # noqa S105

# create a user
payload = {"login": user_login, "password": user_pwd, "role": "agent"}
payload = {"organization_id": 1, "login": user_login, "password": user_pwd, "role": "agent"}
user_id = api_request("post", f"{args.endpoint}/users/", superuser_auth, payload)["id"]
agent_auth = {
"Authorization": f"Bearer {get_token(args.endpoint, user_login, user_pwd)}",
Expand All @@ -65,7 +65,15 @@ def main(args):

# Create a camera (as admin until #79 is closed)
camera_name = "my_device"
payload = {"name": camera_name, "angle_of_view": 70.0, "elevation": 100, "lat": 44.7, "lon": 4.5, "azimuth": 110}
payload = {
"name": camera_name,
"organization_id": 1,
"angle_of_view": 70.0,
"elevation": 100,
"lat": 44.7,
"lon": 4.5,
"azimuth": 110,
}
cam_id = api_request("post", f"{args.endpoint}/cameras/", agent_auth, payload)["id"]

cam_token = requests.post(
Expand Down
20 changes: 13 additions & 7 deletions src/app/api/api_v1/endpoints/cameras.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from datetime import datetime
from typing import List, cast

from fastapi import APIRouter, Depends, Path, Security, status
from fastapi import APIRouter, Depends, HTTPException, Path, Security, status

from app.api.dependencies import get_camera_crud, get_jwt
from app.core.config import settings
Expand All @@ -27,6 +27,8 @@ async def register_camera(
token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT]),
) -> Camera:
telemetry_client.capture(token_payload.sub, event="cameras-create", properties={"device_login": payload.name})
if token_payload.organization_id != payload.organization_id and UserRole.ADMIN not in token_payload.scopes:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.")
return await cameras.create(payload)


Expand All @@ -37,7 +39,10 @@ async def get_camera(
token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT, UserRole.USER]),
) -> Camera:
telemetry_client.capture(token_payload.sub, event="cameras-get", properties={"camera_id": camera_id})
return cast(Camera, await cameras.get(camera_id, strict=True))
camera = cast(Camera, await cameras.get(camera_id, strict=True))
if token_payload.organization_id != camera.organization_id and UserRole.ADMIN not in token_payload.scopes:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.")
return camera


@router.get("/", status_code=status.HTTP_200_OK, summary="Fetch all the cameras")
Expand All @@ -46,7 +51,10 @@ async def fetch_cameras(
token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT, UserRole.USER]),
) -> List[Camera]:
telemetry_client.capture(token_payload.sub, event="cameras-fetch")
return [elt for elt in await cameras.fetch_all()]
all_cameras = [elt for elt in await cameras.fetch_all()]
if UserRole.ADMIN in token_payload.scopes:
return all_cameras
return [camera for camera in all_cameras if camera.organization_id == token_payload.organization_id]


@router.patch("/heartbeat", status_code=status.HTTP_200_OK, summary="Update last ping of a camera")
Expand All @@ -55,7 +63,6 @@ async def heartbeat(
token_payload: TokenPayload = Security(get_jwt, scopes=[Role.CAMERA]),
) -> Camera:
# telemetry_client.capture(f"camera|{token_payload.sub}", event="cameras-heartbeat", properties={"camera_id": camera_id})
await cameras.get(token_payload.sub, strict=True)
return await cameras.update(token_payload.sub, LastActive(last_active_at=datetime.utcnow()))


Expand All @@ -66,10 +73,9 @@ async def create_camera_token(
token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN]),
) -> Token:
telemetry_client.capture(token_payload.sub, event="cameras-token", properties={"camera_id": camera_id})
# Verify camera
await cameras.get(camera_id, strict=True)
camera = cast(Camera, await cameras.get(camera_id, strict=True))
# create access token using user user_id/user_scopes
token_data = {"sub": str(camera_id), "scopes": ["camera"]}
token_data = {"sub": str(camera_id), "scopes": ["camera"], "organization_id": camera.organization_id}
token = create_access_token(token_data, settings.JWT_UNLIMITED)
return Token(access_token=token, token_type="bearer") # noqa S106

Expand Down
Loading

0 comments on commit ca23c40

Please sign in to comment.