Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Replace encode/databases with psycopg & pydantic model validation #128

Merged
merged 74 commits into from
Aug 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
74 commits
Select commit Hold shift + click to select a range
da5de1a
build: strip out unecessary backend requirements
spwoodcock Aug 6, 2024
1216d1c
fix(backend): attach lifespan to fastapi for startup events
spwoodcock Aug 6, 2024
3ac8a5c
refactor: use psycopg connection pool
spwoodcock Aug 6, 2024
e6b0a3c
build: correctly use alembic binary directly (no pdm required)
spwoodcock Aug 6, 2024
ca53e51
build: bind port for local db, add initdb args for locale
spwoodcock Aug 6, 2024
cf150d4
build: update fastapi, pydantic, add email-validator as pydantic extras
spwoodcock Aug 6, 2024
65fdcc8
build: update drone-flight plan --> v0.2.3
spwoodcock Aug 6, 2024
dab56a8
fix: log level using text level instead of number levels
spwoodcock Aug 6, 2024
9412c7d
refactor: update geojson parsing logic, add slugify to utils
spwoodcock Aug 6, 2024
99c036c
feat: working project routes using psycopg + pydantic models
spwoodcock Aug 6, 2024
492a9d9
refactor: rename create_project route to POST on /projects
spwoodcock Aug 6, 2024
8ebec65
refactor: rename outline_geojson & outline_no_fly_zones
spwoodcock Aug 6, 2024
5aa3787
refactor: replace all refs to Database --> Connection (psycopg)
spwoodcock Aug 6, 2024
b5a4b77
style: update import sorting for project_schemas
spwoodcock Aug 7, 2024
c0d5ede
refractor: Refactor database interactions to use psycopg. Updated que…
Pradip-p Aug 9, 2024
fd803f8
refractor: refractor the user profile update
Pradip-p Aug 9, 2024
e91599f
refractor: refractor over user module
Pradip-p Aug 9, 2024
388b5cc
feat: DbDrone schema with crud functions
nrjadkry Aug 9, 2024
326da39
drone crud with psycopg and pydantic schemas
nrjadkry Aug 9, 2024
52c863e
remove create_drone function from drones
nrjadkry Aug 9, 2024
e402893
feat: drone deps to get one drone
nrjadkry Aug 9, 2024
04e9284
fix: drone schemas to delete drone
nrjadkry Aug 9, 2024
538a0f4
delete drone schemas
nrjadkry Aug 9, 2024
11f0a4e
update: drone cruds
nrjadkry Aug 9, 2024
62ff03b
get a single drone
nrjadkry Aug 9, 2024
81b6d7d
update: drone retrieve api output model
nrjadkry Aug 9, 2024
2685d9b
remove redundancy in drone schemas
nrjadkry Aug 9, 2024
fc700b0
Merge pull request #137 from hotosm/refactor-psycopg-drone-module
spwoodcock Aug 9, 2024
4ac534e
refractor: refactor UserProfile schemas by introducing a shared BaseU…
Pradip-p Aug 10, 2024
f82fc17
fix: added base class UserProfileIn & DbUserProfile
Pradip-p Aug 10, 2024
4b32fbe
refractor: refactor user update profile functions
Pradip-p Aug 10, 2024
5c2820d
refactor: Moved dependency functions to _deps.py and retained CRUD lo…
Pradip-p Aug 11, 2024
6272c05
refactor: Moved dependency functions to _crud.py & _deps.py
Pradip-p Aug 11, 2024
fb8e19c
refactor: user schams & remove fixme code
Pradip-p Aug 12, 2024
76f2120
fix: import error from user_crud * user_routes
Pradip-p Aug 12, 2024
3fbe4cd
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Aug 12, 2024
5148c49
Merge pull request #136 from hotosm/feat/psycopg-user-module
nrjadkry Aug 12, 2024
648ed14
refactor: refractor task module using pyscopg and pydantic
Pradip-p Aug 12, 2024
5fc9afd
feat: shift the _shemas to _crud.py
Pradip-p Aug 12, 2024
e7c3d8e
fix: changes update to update_task_state
Pradip-p Aug 12, 2024
6f68ee8
refractor: update request mapping functions
Pradip-p Aug 13, 2024
a6870d1
fix: rename functions name in pending tasks
Pradip-p Aug 13, 2024
99f12dc
Fix: Access project details and drone operator.
Pradip-p Aug 13, 2024
7a2280a
Merge pull request #141 from hotosm/feat/psycopg-task-module
nrjadkry Aug 13, 2024
a22c37c
fix: reslove merge conflict with main
Pradip-p Aug 13, 2024
df0df44
refractor: refractor the statistics & read_task with pyscopg
Pradip-p Aug 13, 2024
855539b
refractor: changes waypoints module with pyscopg
Pradip-p Aug 13, 2024
b38b58c
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Aug 13, 2024
1ac7ec8
fix: fixes the project creator issues
Pradip-p Aug 13, 2024
c3a4edb
Merge branch 'feat/psycopg-pydantic' of github.com:hotosm/drone-tm in…
Pradip-p Aug 13, 2024
f59dfcd
fix(frontend): fixes task boundary api fail
Pradip-p Aug 13, 2024
74f544e
fix: fixes the task listing issues
Pradip-p Aug 13, 2024
af7a555
refractor: changes task module with pyscopg
Pradip-p Aug 13, 2024
3887f43
fix: get task waypoints
Pradip-p Aug 14, 2024
6ffebf1
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Aug 14, 2024
8481dfe
Refactor: Rename to across modules
Pradip-p Aug 14, 2024
09c172c
fix: no fly zone cliping isuues
Pradip-p Aug 14, 2024
ea75e65
fix: upload project task boundaries
Pradip-p Aug 14, 2024
15462e2
remove: remove unnecessary code
Pradip-p Aug 14, 2024
ceec01c
fix: remove pending task api endpoint
Pradip-p Aug 14, 2024
ac27402
feat: update request function for auto approval
Pradip-p Aug 15, 2024
0774cd9
fix: task statistics endpoint
Pradip-p Aug 15, 2024
20eb00e
fix: resolve the merge conflict with main
Pradip-p Aug 20, 2024
c59b044
fix: issues slove in list tasks
Pradip-p Aug 20, 2024
4037b3a
fix: issues slove in get project details
Pradip-p Aug 20, 2024
cb1ced4
fix: refine the project get all
Pradip-p Aug 20, 2024
1f4b3ae
fix: remove xxx_crud.py from all module
Pradip-p Aug 20, 2024
1967805
fix: issues slove in get project endpoint
Pradip-p Aug 20, 2024
8b47c6d
fix: update user profile dashboard
Pradip-p Aug 20, 2024
47d1a27
fix: issues request_mapping & update state
Pradip-p Aug 20, 2024
53d277d
fix: issues on read task endpoint
Pradip-p Aug 20, 2024
696caa4
feat: Add no-fly-zones on project details
Pradip-p Aug 21, 2024
0b36027
fix(pdm.lock): slove merge conflict with main branch
Pradip-p Aug 21, 2024
588f1eb
fix: comment the unsed var
Pradip-p Aug 21, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ db.sqlite3

# ignore python environments
venv
fmtm-env

# project related
temp_webmaps/local_only
Expand Down
6 changes: 4 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ services:
env_file: .env
environment:
LANG: en-GB.utf8
# POSTGRES_INITDB_ARGS: "--locale-provider=icu --icu-locale=en-GB"
POSTGRES_INITDB_ARGS: "--locale-provider=icu --icu-locale=en-GB"
ports:
- "5467:5432"
networks:
- dtm-network
restart: unless-stopped
Expand Down Expand Up @@ -101,5 +103,5 @@ services:
- .env
networks:
- dtm-network
entrypoint: ["pdm", "run", "alembic", "upgrade", "head"]
entrypoint: ["alembic", "upgrade", "head"]
restart: "no"
81 changes: 61 additions & 20 deletions src/backend/app/db/database.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,73 @@
"""Config for the DTM database connection."""

from databases import Database
from typing import AsyncGenerator
from fastapi import Request
from psycopg import Connection
from psycopg_pool import AsyncConnectionPool
from app.config import settings


class DatabaseConnection:
"""Manages database connection (sqlalchemy & encode databases)"""
async def get_db_connection_pool() -> AsyncConnectionPool:
"""Get the connection pool for psycopg."""
return AsyncConnectionPool(conninfo=settings.DTM_DB_URL.unicode_string())

def __init__(self):
self.database = Database(
settings.DTM_DB_URL.unicode_string(),
min_size=5,
max_size=20,
)

async def connect(self):
"""Connect to the database."""
await self.database.connect()
async def get_db(request: Request) -> AsyncGenerator[Connection, None]:
"""Get a connection from the psycopg pool.

async def disconnect(self):
"""Disconnect from the database."""
await self.database.disconnect()
Info on connections vs cursors:
https://www.psycopg.org/psycopg3/docs/advanced/async.html

Here we are getting a connection from the pool, which will be returned
after the session ends / endpoint finishes processing.

db_connection = DatabaseConnection()
In summary:
- Connection is created on endpoint call.
- Cursors are used to execute commands throughout endpoint.
Note it is possible to create multiple cursors from the connection,
but all will be executed in the same db 'transaction'.
- Connection is closed on endpoint finish.

-----------------------------------
To use the connection in endpoints:
-----------------------------------

async def get_db():
"""Get the encode database connection"""
await db_connection.connect()
yield db_connection.database
@app.get("/something/")
async def do_stuff(db = Depends(get_db)):
async with db.cursor() as cursor:
await cursor.execute("SELECT * FROM items")
result = await cursor.fetchall()
return result

-----------------------------------
Additionally, the connection could be passed through to a function to
utilise the Pydantic model serialisation on the cursor:
-----------------------------------

from psycopg.rows import class_row
async def get_user_by_id(db: Connection, id: int):
async with conn.cursor(row_factory=class_row(User)) as cur:
await cur.execute(
'''
SELECT id, first_name, last_name, dob
FROM (VALUES
(1, 'John', 'Doe', '2000-01-01'::date),
(2, 'Jane', 'White', NULL)
) AS data (id, first_name, last_name, dob)
WHERE id = %(id)s;
''',
{"id": id},
)
obj = await cur.fetchone()

# reveal_type(obj) would return 'Optional[User]' here

if not obj:
raise KeyError(f"user {id} not found")

# reveal_type(obj) would return 'User' here

return obj
"""
async with request.app.state.db_pool.connection() as conn:
yield conn
20 changes: 20 additions & 0 deletions src/backend/app/drones/drone_deps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from typing import Annotated
from fastapi import Depends, HTTPException, Path
from psycopg import Connection
from app.db import database
from app.drones.drone_schemas import DbDrone
from app.models.enums import HTTPStatus


async def get_drone_by_id(
drone_id: Annotated[
int,
Path(description="Drone ID."),
],
db: Annotated[Connection, Depends(database.get_db)],
) -> DbDrone:
"""Get a single project by id."""
try:
return await DbDrone.one(db, drone_id)
except KeyError as e:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND) from e
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
from app.drones import drone_schemas
from app.models.enums import HTTPStatus
from databases import Database
from loguru import logger as log
from fastapi import HTTPException
from asyncpg import UniqueViolationError
from psycopg import Connection

# from asyncpg import UniqueViolationError
from typing import List
from app.drones.drone_schemas import DroneOut


async def read_all_drones(db: Database) -> List[DroneOut]:
async def read_all_drones(db: Connection) -> List[DroneOut]:
"""
Retrieves all drone records from the database.

Expand All @@ -32,7 +32,7 @@ async def read_all_drones(db: Database) -> List[DroneOut]:
) from e


async def delete_drone(db: Database, drone_id: int) -> bool:
async def delete_drone(db: Connection, drone_id: int) -> bool:
"""
Deletes a drone record from the database, along with associated drone flights.

Expand Down Expand Up @@ -63,7 +63,7 @@ async def delete_drone(db: Database, drone_id: int) -> bool:
) from e


async def get_drone(db: Database, drone_id: int):
async def get_drone(db: Connection, drone_id: int):
"""
Retrieves a drone record from the database.

Expand All @@ -87,44 +87,3 @@ async def get_drone(db: Database, drone_id: int):
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Retrieval failed"
) from e


async def create_drone(db: Database, drone_info: drone_schemas.DroneIn):
"""
Creates a new drone record in the database.

Args:
db (Database): The database connection object.
drone (drone_schemas.DroneIn): The schema object containing drone details.

Returns:
The ID of the newly created drone record.
"""
try:
insert_query = """
INSERT INTO drones (
model, manufacturer, camera_model, sensor_width, sensor_height,
max_battery_health, focal_length, image_width, image_height,
max_altitude, max_speed, weight, created
) VALUES (
:model, :manufacturer, :camera_model, :sensor_width, :sensor_height,
:max_battery_health, :focal_length, :image_width, :image_height,
:max_altitude, :max_speed, :weight, CURRENT_TIMESTAMP
)
RETURNING id
"""
result = await db.execute(insert_query, drone_info.__dict__)
return result

except UniqueViolationError as e:
log.exception("Unique constraint violation: %s", e)
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail="A drone with this model already exists",
)

except Exception as e:
log.exception(e)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Drone creation failed"
) from e
90 changes: 33 additions & 57 deletions src/backend/app/drones/drone_routes.py
Original file line number Diff line number Diff line change
@@ -1,45 +1,48 @@
from typing import Annotated
from app.users.user_deps import login_required
from app.users.user_schemas import AuthUser
from app.models.enums import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException
from app.db.database import get_db
from app.db import database
from app.config import settings
from app.drones import drone_schemas
from databases import Database
from app.drones import drone_crud
from typing import List
from app.drones import drone_schemas, drone_deps
from psycopg import Connection


router = APIRouter(
prefix=f"{settings.API_PREFIX}/drones",
tags=["Drones"],
responses={404: {"description": "Not found"}},
)


@router.get("/", tags=["Drones"], response_model=List[drone_schemas.DroneOut])
@router.get("/", response_model=list[drone_schemas.DroneOut])
async def read_drones(
db: Database = Depends(get_db),
user_data: AuthUser = Depends(login_required),
db: Annotated[Connection, Depends(database.get_db)],
):
"""
Retrieves all drone records from the database.
"""Get all drones."""
try:
return await drone_schemas.DbDrone.all(db)
except KeyError as e:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND) from e

Args:
db (Database, optional): The database session object.
user_data (AuthUser, optional): The authenticated user data.

Returns:
List[drone_schemas.DroneOut]: A list of all drone records.
"""
drones = await drone_crud.read_all_drones(db)
return drones
@router.post("/create_drone")
async def create_drone(
drone_info: drone_schemas.DroneIn,
db: Annotated[Connection, Depends(database.get_db)],
user_data: Annotated[AuthUser, Depends(login_required)],
):
"""Create a new drone in database"""
drone_id = await drone_schemas.DbDrone.create(db, drone_info)
return {"message": "Drone created successfully", "drone_id": drone_id}


@router.delete("/{drone_id}", tags=["Drones"])
@router.delete("/{drone_id}")
async def delete_drone(
drone_id: int,
db: Database = Depends(get_db),
user_data: AuthUser = Depends(login_required),
drone: Annotated[drone_schemas.DbDrone, Depends(drone_deps.get_drone_by_id)],
db: Annotated[Connection, Depends(database.get_db)],
user_data: Annotated[AuthUser, Depends(login_required)],
):
"""
Deletes a drone record from the database.
Expand All @@ -52,42 +55,18 @@ async def delete_drone(
Returns:
dict: A success message if the drone was deleted.
"""
success = await drone_crud.delete_drone(db, drone_id)
if not success:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Drone not found")
return {"message": "Drone deleted successfully"}


@router.post("/create_drone", tags=["Drones"])
async def create_drone(
drone_info: drone_schemas.DroneIn,
db: Database = Depends(get_db),
user_data: AuthUser = Depends(login_required),
):
"""
Creates a new drone record in the database.

Args:
drone_info (drone_schemas.DroneIn): The schema object containing drone details.
db (Database, optional): The database session object.
user_data (AuthUser, optional): The authenticated user data.

Returns:
dict: A dictionary containing a success message and the ID of the newly created drone.
"""
drone_id = await drone_crud.create_drone(db, drone_info)
if not drone_id:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Drone creation failed"
)
return {"message": "Drone created successfully", "drone_id": drone_id}
# TODO: Check user role, Admin can only do this.
# After user roles introduction
drone_id = await drone_schemas.DbDrone.delete(db, drone.id)
return {"message": f"Drone successfully deleted {drone_id}"}


@router.get("/{drone_id}", tags=["Drones"], response_model=drone_schemas.DroneOut)
@router.get("/{drone_id}", response_model=drone_schemas.DbDrone)
async def read_drone(
drone_id: int,
db: Database = Depends(get_db),
user_data: AuthUser = Depends(login_required),
drone: Annotated[drone_schemas.DbDrone, Depends(drone_deps.get_drone_by_id)],
db: Annotated[Connection, Depends(database.get_db)],
user_data: Annotated[AuthUser, Depends(login_required)],
):
"""
Retrieves a drone record from the database.
Expand All @@ -100,7 +79,4 @@ async def read_drone(
Returns:
dict: The drone record if found.
"""
drone = await drone_crud.get_drone(db, drone_id)
if not drone:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Drone not found")
return drone
Loading