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

Feature/pdct 451 add summary endpoint #17

Merged
merged 11 commits into from
Oct 9, 2023
30 changes: 21 additions & 9 deletions app/api/api_v1/routers/analytics.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
"""Endpoints for managing the Analytics service."""
import logging
from fastapi import APIRouter
from fastapi import APIRouter, HTTPException, status
from app.errors import RepositoryError

import app.service.analytics as analytics_service
from app.model.analytics import SummaryDTO

analytics_router = r = APIRouter()

Expand All @@ -9,18 +13,26 @@

@r.get(
"/analytics/summary",
response_model=dict[str, int],
response_model=SummaryDTO,
)
async def get_analytics_summary() -> dict[str, int]:
async def get_analytics_summary() -> SummaryDTO:
"""
Returns an analytics summary.

:return dict[str, int]: returns a dictionary of the summarised analytics
data in key (str): value (int) form.
"""
return {
"n_documents": 1000,
"n_families": 800,
"n_collections": 10,
"n_events": 50,
}
try:
summary = analytics_service.summary()
except RepositoryError as e:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=e.message
)

if summary is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Analytics summary not found",
)

return summary
1 change: 1 addition & 0 deletions app/api/api_v1/routers/collection.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Endpoints for managing the Collection entity."""
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException, status
from app.errors import RepositoryError, ValidationError

Expand Down
10 changes: 10 additions & 0 deletions app/model/analytics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from pydantic import BaseModel


class SummaryDTO(BaseModel):
"""Representation of an Analytics Summary."""

n_documents: int
n_families: int
n_collections: int
n_events: int
16 changes: 16 additions & 0 deletions app/repository/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,19 @@ def delete(db: Session, import_id: str) -> bool:
result = db.execute(c)

return result.rowcount > 0 # type: ignore


def count(db: Session) -> Optional[int]:
"""
Counts the number of collections in the repository.

:param db Session: the database connection
:return Optional[int]: The number of collections in the repository or none.
"""
try:
n_collections = _get_query(db).count()
except Exception as e:
_LOGGER.error(e)
return

return n_collections
16 changes: 16 additions & 0 deletions app/repository/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,3 +306,19 @@ def delete(db: Session, import_id: str) -> bool:
raise RepositoryError(msg)

return True


def count(db: Session) -> Optional[int]:
"""
Counts the number of documents in the repository.

:param db Session: the database connection
:return Optional[int]: The number of documents in the repository or none.
"""
try:
n_documents = _get_query(db).count()
except NoResultFound as e:
_LOGGER.error(e)
return

return n_documents
16 changes: 16 additions & 0 deletions app/repository/family.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,3 +310,19 @@ def get_organisation(db: Session, family_import_id: str) -> Optional[Organisatio

# TODO - can this be improved - we get warnings on integration tests ?
return db.query(Organisation).select_from(family_org).one_or_none()


def count(db: Session) -> Optional[int]:
"""
Counts the number of families in the repository.

:param db Session: the database connection
:return Optional[int]: The number of families in the repository or none.
"""
try:
n_families = _get_query(db).count()
except NoResultFound as e:
_LOGGER.error(e)
return

return n_families
51 changes: 51 additions & 0 deletions app/service/analytics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""
Analytics Service

This layer uses the document, family, and collection repos to handle querying
the count of available entities.
"""
import logging
from typing import Optional

from pydantic import ConfigDict, validate_call
from app.errors import RepositoryError
from app.model.analytics import SummaryDTO
from app.repository import collection_repo, family_repo, document_repo
import app.clients.db.session as db_session
from sqlalchemy import exc

from app.model.analytics import SummaryDTO


_LOGGER = logging.getLogger(__name__)


@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def summary() -> Optional[SummaryDTO]:
"""
Gets an analytics summary from the repository.

:return SummaryDTO: The analytics summary found.
"""
try:
with db_session.get_db() as db:
n_collections = collection_repo.count(db)
n_families = family_repo.count(db)
n_documents = document_repo.count(db)
n_events = 0

if any(
katybaulch marked this conversation as resolved.
Show resolved Hide resolved
item is None for item in [n_collections, n_families, n_documents, n_events]
):
return

return SummaryDTO(
n_documents=n_documents,
n_families=n_families,
n_collections=n_collections,
n_events=n_events,
)

except exc.SQLAlchemyError as e:
_LOGGER.error(e)
raise RepositoryError(str(e))
15 changes: 15 additions & 0 deletions app/service/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,18 @@ def delete(import_id: str, db: Session = db_session.get_db()) -> bool:
"""
id.validate(import_id)
return collection_repo.delete(db, import_id)


@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
katybaulch marked this conversation as resolved.
Show resolved Hide resolved
def count() -> Optional[int]:
"""
Gets a count of collections from the repository.

:return Optional[int]: The number of collections in the repository or none.
"""
try:
with db_session.get_db() as db:
return collection_repo.count(db)
except exc.SQLAlchemyError as e:
_LOGGER.error(e)
raise RepositoryError(str(e))
15 changes: 15 additions & 0 deletions app/service/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,18 @@ def delete(import_id: str, db: Session = db_session.get_db()) -> bool:
"""
id.validate(import_id)
return document_repo.delete(db, import_id)


@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
katybaulch marked this conversation as resolved.
Show resolved Hide resolved
def count() -> Optional[int]:
"""
Gets a count of documents from the repository.

:return Optional[int]: The number of documents in the repository or none.
"""
try:
with db_session.get_db() as db:
return document_repo.count(db)
except exc.SQLAlchemyError as e:
_LOGGER.error(e)
raise RepositoryError(str(e))
16 changes: 16 additions & 0 deletions app/service/family.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,19 @@ def delete(import_id: str, db: Session = db_session.get_db()) -> bool:
msg = f"Unable to delete family {import_id}"
_LOGGER.exception(msg)
raise RepositoryError(msg)
return family_repo.delete(db, import_id)


@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
katybaulch marked this conversation as resolved.
Show resolved Hide resolved
def count() -> Optional[int]:
"""
Gets a count of families from the repository.

:return Optional[int]: The number of families in the repository or none.
"""
try:
with db_session.get_db() as db:
return family_repo.count(db)
except exc.SQLAlchemyError as e:
_LOGGER.error(e)
raise RepositoryError(str(e))
Empty file.
112 changes: 112 additions & 0 deletions integration_tests/analytics/test_get.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
from fastapi.testclient import TestClient
from fastapi import status
from sqlalchemy.orm import Session

from integration_tests.setup_db import (
EXPECTED_ANALYTICS_SUMMARY,
EXPECTED_ANALYTICS_SUMMARY_KEYS,
setup_db,
)


# --- GET ALL


def test_get_all_analytics(client: TestClient, test_db: Session, user_header_token):
setup_db(test_db)
response = client.get(
"/api/v1/analytics",
headers=user_header_token,
)
assert response.status_code == status.HTTP_404_NOT_FOUND


def test_get_all_analytics_when_not_authenticated(client: TestClient, test_db: Session):
setup_db(test_db)
response = client.get(
"/api/v1/analytics",
)
assert response.status_code == status.HTTP_404_NOT_FOUND


# --- GET SUMMARY


def test_get_analytics_summary(client: TestClient, test_db: Session, user_header_token):
setup_db(test_db)
response = client.get(
"/api/v1/analytics/summary",
headers=user_header_token,
)
assert response.status_code == status.HTTP_200_OK

data = response.json()
assert type(data) is dict
assert list(data.keys()) == EXPECTED_ANALYTICS_SUMMARY_KEYS

assert data["n_events"] == 0
assert dict(sorted(data.items())) == dict(
sorted(EXPECTED_ANALYTICS_SUMMARY.items())
)


def test_get_analytics_summary_when_not_authenticated(
client: TestClient, test_db: Session
):
setup_db(test_db)
response = client.get(
"/api/v1/analytics/summary",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED


def test_get_analytics_summary_when_not_found(
client: TestClient, test_db: Session, bad_analytics_service, user_header_token
):
setup_db(test_db)
response = client.get(
"/api/v1/analytics/summary",
headers=user_header_token,
)
assert response.status_code == status.HTTP_404_NOT_FOUND
data = response.json()
assert data["detail"] == "Analytics summary not found"


def test_get_analytics_when_collection_db_error(
client: TestClient, test_db: Session, bad_collection_repo, user_header_token
):
setup_db(test_db)
response = client.get(
"/api/v1/analytics/summary",
headers=user_header_token,
)
assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
data = response.json()
assert data["detail"] == "Bad Repo"


def test_get_analytics_when_family_db_error(
client: TestClient, test_db: Session, bad_family_repo, user_header_token
):
setup_db(test_db)
response = client.get(
"/api/v1/analytics/summary",
headers=user_header_token,
)
assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
data = response.json()
assert data["detail"] == "Bad Repo"


def test_get_analytics_when_document_db_error(
client: TestClient, test_db: Session, bad_document_repo, user_header_token
):
setup_db(test_db)
response = client.get(
"/api/v1/analytics/summary",
headers=user_header_token,
)
assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
data = response.json()
assert data["detail"] == "Bad Repo"
15 changes: 12 additions & 3 deletions integration_tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
from sqlalchemy.orm import sessionmaker
import app.clients.db.session as db_session
from app.main import app
from integration_tests.mocks.bad_family_repo import mock_bad_family_repo
from integration_tests.mocks.bad_collection_repo import mock_bad_collection_repo
from integration_tests.mocks.bad_document_repo import mock_bad_document_repo
from integration_tests.mocks.bad_family_repo import mock_bad_family_repo, mock_family_count_none
from integration_tests.mocks.bad_collection_repo import mock_bad_collection_repo, mock_collection_count_none
from integration_tests.mocks.bad_document_repo import mock_bad_document_repo, mock_document_count_none
from integration_tests.mocks.rollback_collection_repo import (
mock_rollback_collection_repo,
)
Expand Down Expand Up @@ -89,6 +89,15 @@ def bad_document_repo(monkeypatch, mocker):
yield document_repo


@pytest.fixture
def bad_analytics_service(monkeypatch, mocker):
"""Mocks the service for a single test."""
mock_document_count_none(document_repo, monkeypatch, mocker)
mock_family_count_none(family_repo, monkeypatch, mocker)
mock_collection_count_none(collection_repo, monkeypatch, mocker)
yield None


@pytest.fixture
def rollback_family_repo(monkeypatch, mocker):
"""Mocks the repository for a single test."""
Expand Down
Loading
Loading