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

Integrate API with database #35

Merged
merged 1 commit into from
May 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ and this project adheres to

## [Unreleased]

### Added

- Implement statique router endpoints

## [0.4.0] - 2024-04-23

### Added
Expand Down
4 changes: 3 additions & 1 deletion bin/kc-init
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ function kcadm() {
declare realm="qualicharge"
declare user="johndoe"
declare password="pass"
declare email="[email protected]"
declare client_id="api"
declare client_secret="super-secret"

Expand Down Expand Up @@ -48,9 +49,10 @@ echo "⚙️ Creating John Doe user…"
kcadm create users \
--target-realm "${realm}" \
--set username="${user}" \
--set email="${email}" \
--set enabled=true \
--output \
--fields id,username
--fields id,username,email

# Set test user password
echo "⚙️ Setting John Doe user password…"
Expand Down
109 changes: 98 additions & 11 deletions src/api/qualicharge/api/v1/routers/static.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,27 @@
"""QualiCharge API v1 statique router."""

import logging
from typing import Annotated, List
from typing import Annotated, List, Optional, cast

from annotated_types import Len
from fastapi import APIRouter, Path, status
from pydantic import BaseModel, computed_field
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, status
from pydantic import AnyHttpUrl, BaseModel, computed_field
from sqlalchemy import func
from sqlalchemy.schema import Column as SAColumn
from sqlmodel import Session, select

from qualicharge.conf import settings
from qualicharge.db import get_session
from qualicharge.exceptions import IntegrityError, ObjectDoesNotExist
from qualicharge.models.static import Statique
from qualicharge.schemas.static import PointDeCharge
from qualicharge.schemas.utils import (
build_statique,
list_statique,
save_statique,
save_statiques,
update_statique,
)

logger = logging.getLogger(__name__)

Expand All @@ -31,13 +44,56 @@ def size(self) -> int:
return len(self.items)


class PaginatedStatiqueListResponse(BaseModel):
"""Paginated statique list response."""

limit: int
offset: int
total: int
previous: Optional[AnyHttpUrl]
next: Optional[AnyHttpUrl]
items: List[Statique]

@computed_field # type: ignore[misc]
@property
def size(self) -> int:
"""The number of items created."""
return len(self.items)


BulkStatiqueList = Annotated[List[Statique], Len(2, settings.API_BULK_CREATE_MAX_SIZE)]


@router.get("/")
async def list() -> List[Statique]:
async def list(
request: Request,
offset: int = 0,
limit: int = Query(
default=settings.API_BULK_CREATE_MAX_SIZE, le=settings.API_BULK_CREATE_MAX_SIZE
),
session: Session = Depends(get_session),
) -> PaginatedStatiqueListResponse:
"""List statique items."""
return []
current_url = request.url
previous_url = next_url = None
total = session.exec(select(func.count(cast(SAColumn, PointDeCharge.id)))).one()
statiques = [statique for statique in list_statique(session, offset, limit)]

previous_offset = offset - limit if offset > limit else 0
if offset:
previous_url = str(current_url.include_query_params(offset=previous_offset))

if not limit > len(statiques) and total != offset + limit:
next_url = str(current_url.include_query_params(offset=offset + limit))

return PaginatedStatiqueListResponse(
total=total,
limit=limit,
offset=offset,
previous=previous_url,
next=next_url,
items=statiques,
)


@router.get("/{id_pdc_itinerance}")
Expand All @@ -51,12 +107,20 @@ async def read(
),
),
],
session: Session = Depends(get_session),
) -> Statique:
"""Read statique item (point de charge)."""
raise NotImplementedError
try:
statique = build_statique(session, id_pdc_itinerance)
except ObjectDoesNotExist as err:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Requested statique does not exist",
) from err
return statique


@router.put("/{id_pdc_itinerance}")
@router.put("/{id_pdc_itinerance}", status_code=status.HTTP_200_OK)
async def update(
id_pdc_itinerance: Annotated[
str,
Expand All @@ -68,18 +132,41 @@ async def update(
),
],
statique: Statique,
session: Session = Depends(get_session),
) -> Statique:
"""Update statique item (point de charge)."""
raise NotImplementedError
try:
update = update_statique(session, id_pdc_itinerance, statique)
except IntegrityError as err:
raise HTTPException(
status_code=status.HTTP_406_NOT_ACCEPTABLE,
detail="id_pdc_itinerance does not match request body",
) from err
except ObjectDoesNotExist as err:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Statique to update does not exist",
) from err
return update


@router.post("/", status_code=status.HTTP_201_CREATED)
async def create(statique: Statique) -> StatiqueItemsCreatedResponse:
async def create(
statique: Statique, session: Session = Depends(get_session)
) -> StatiqueItemsCreatedResponse:
"""Create a statique item."""
return StatiqueItemsCreatedResponse(items=[statique])
return StatiqueItemsCreatedResponse(items=[save_statique(session, statique)])


@router.post("/bulk", status_code=status.HTTP_201_CREATED)
async def bulk(statiques: BulkStatiqueList) -> StatiqueItemsCreatedResponse:
async def bulk(
statiques: BulkStatiqueList, session: Session = Depends(get_session)
) -> StatiqueItemsCreatedResponse:
"""Create a set of statique items."""
statiques = [statique for statique in save_statiques(session, statiques)]
if not len(statiques):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="All Statique entries already exist",
)
return StatiqueItemsCreatedResponse(items=statiques)
28 changes: 27 additions & 1 deletion src/api/qualicharge/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from pydantic import PostgresDsn
from sqlalchemy import Engine as SAEngine
from sqlalchemy import text
from sqlalchemy import event, text
from sqlalchemy.exc import OperationalError
from sqlmodel import Session as SMSession
from sqlmodel import create_engine
Expand Down Expand Up @@ -55,6 +55,32 @@ def get_session(self, engine: SAEngine) -> SMSession:
return self._session


class SAQueryCounter:
"""Context manager to count SQLALchemy queries.

Inspired by: https://stackoverflow.com/a/71337784
"""

def __init__(self, connection):
"""Initialize the counter for a given connection."""
self.connection = connection.engine
self.count = 0

def __enter__(self):
"""Start listening `before_cursor_execute` event."""
event.listen(self.connection, "before_cursor_execute", self.callback)
return self

def __exit__(self, *args, **kwargs):
"""Stop listening `before_cursor_execute` event."""
event.remove(self.connection, "before_cursor_execute", self.callback)

def callback(self, *args, **kwargs):
"""Increment the counter every time the `before_cursor_execute` event occurs."""
self.count += 1
logger.debug(f"Database query [{self.count=}] >> {args=} {kwargs=}")


def get_engine() -> SAEngine:
"""Get database engine."""
return Engine().get_engine(url=settings.DATABASE_URL, echo=settings.DEBUG)
Expand Down
20 changes: 20 additions & 0 deletions src/api/qualicharge/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,23 @@ class OIDCAuthenticationError(QualiChargeExceptionMixin, Exception):

class OIDCProviderException(QualiChargeExceptionMixin, Exception):
"""Raised when the OIDC provider does not behave as expected."""


class ModelSerializerException(QualiChargeExceptionMixin, Exception):
"""Raised when a custom model serialization occurs."""


class DatabaseQueryException(QualiChargeExceptionMixin, Exception):
"""Raised when a database query does not provide expected results."""


class DuplicateEntriesSubmitted(QualiChargeExceptionMixin, Exception):
"""Raised when submitted batch contains duplicated entries."""


class IntegrityError(QualiChargeExceptionMixin, Exception):
"""Raised when operation affects database integrity."""


class ObjectDoesNotExist(QualiChargeExceptionMixin, Exception):
"""Raised when queried object does not exist."""
51 changes: 47 additions & 4 deletions src/api/qualicharge/factories/static.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,54 @@
T = TypeVar("T")


class StatiqueFactory(ModelFactory[Statique]):
"""Statique model factory."""


class FrenchDataclassFactory(Generic[T], DataclassFactory[T]):
"""Dataclass factory using the french locale."""

__faker__ = Faker(locale="fr_FR")
__is_base_factory__ = True


class StatiqueFactory(ModelFactory[Statique]):
"""Statique model factory."""

contact_amenageur = Use(FrenchDataclassFactory.__faker__.ascii_company_email)
contact_operateur = Use(FrenchDataclassFactory.__faker__.ascii_company_email)
# FIXME
#
# Faker phone number factory randomly generates invalid data (as evaluated by the
# phonenumbers library). We choose to use a less valuable factory to avoid flaky
# tests.
#
# telephone_operateur = Use(FrenchDataclassFactory.__faker__.phone_number)
telephone_operateur = Use(
DataclassFactory.__random__.choice,
[
"+33144276350",
"+33.1 44 27 63 50",
"+33 (0)1 44 27 63 50",
"+33 1 44 27 63 50",
"0144276350",
"01 44 27 63 50",
"01-44-27-63-50",
"(01)44276350",
],
)
puissance_nominale = Use(
DataclassFactory.__faker__.pyfloat,
right_digits=2,
min_value=2.0,
max_value=100.0,
)
date_maj = Use(DataclassFactory.__faker__.past_date)
date_mise_en_service = Use(DataclassFactory.__faker__.past_date)
id_station_itinerance = Use(
FrenchDataclassFactory.__faker__.pystr_format, "FR###P######"
)
id_pdc_itinerance = Use(
FrenchDataclassFactory.__faker__.pystr_format, "FR###E######"
)


class TimestampedSQLModelFactory(Generic[T], SQLAlchemyFactory[T]):
"""A base factory for timestamped SQLModel.

Expand Down Expand Up @@ -93,6 +130,9 @@ class OperateurFactory(TimestampedSQLModelFactory[Operateur]):
class PointDeChargeFactory(TimestampedSQLModelFactory[PointDeCharge]):
"""PointDeCharge schema factory."""

id_pdc_itinerance = Use(
FrenchDataclassFactory.__faker__.pystr_format, "FR###E######"
)
puissance_nominale = Use(
DataclassFactory.__faker__.pyfloat,
right_digits=2,
Expand All @@ -104,6 +144,9 @@ class PointDeChargeFactory(TimestampedSQLModelFactory[PointDeCharge]):
class StationFactory(TimestampedSQLModelFactory[Station]):
"""Station schema factory."""

id_station_itinerance = Use(
FrenchDataclassFactory.__faker__.pystr_format, "FR###P######"
)
date_maj = Use(DataclassFactory.__faker__.past_date)
date_mise_en_service = Use(DataclassFactory.__faker__.past_date)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""add location address unique constraint

Revision ID: 8580168c2cef
Revises: da896549e09c
Create Date: 2024-04-29 17:23:43.423327

"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision: str = "8580168c2cef"
down_revision: Union[str, None] = "da896549e09c"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_unique_constraint(None, "localisation", ["adresse_station"])
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, "localisation", type_="unique")
# ### end Alembic commands ###
Loading