Skip to content
Open
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
2 changes: 2 additions & 0 deletions metrics/api/settings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
match config.APIENV:
case "LOCAL":
from .local import *
case "TEST":
from .test import *
case "STANDALONE":
from .standalone import *

Expand Down
22 changes: 22 additions & 0 deletions metrics/api/settings/test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import os

from metrics.api.settings import ROOT_LEVEL_BASE_DIR

DATA_UPLOAD_MAX_NUMBER_FIELDS = None

DEBUG = True

DATABASES = {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since this feields are exact replica, could you create it as a variable then reuse it

"default": {
"TIME_ZONE": "Europe/London",
"ENGINE": "django.db.backends.sqlite3",
"NAME": os.path.join(ROOT_LEVEL_BASE_DIR, "test.sqlite3"),
},
"test": {
"TIME_ZONE": "Europe/London",
"ENGINE": "django.db.backends.sqlite3",
"NAME": os.path.join(ROOT_LEVEL_BASE_DIR, "test.sqlite3"),
},
}

INTERNAL_IPS = ["127.0.0.1"]
2 changes: 1 addition & 1 deletion metrics/api/urls_construction.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ def construct_cms_admin_urlpatterns(
]


DEFAULT_PUBLIC_API_PREFIX = "api/public/timeseries/"
DEFAULT_PUBLIC_API_PREFIX = "api/public/"


def construct_public_api_urlpatterns(
Expand Down
34 changes: 32 additions & 2 deletions public_api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
GeographyTypeListViewV2,
MetricListViewV2,
PublicAPIRootViewV2,
SearchView,
SubThemeDetailViewV2,
SubThemeListViewV2,
ThemeDetailViewV2,
Expand All @@ -33,6 +34,9 @@
)
from public_api.views.timeseries_viewset import APITimeSeriesViewSet

TIMESERIES_PREFIX = "timeseries/"
SEARCH_PREFIX = "search/"


def construct_url_patterns_for_public_api(
*,
Expand All @@ -48,8 +52,12 @@ def construct_url_patterns_for_public_api(
set of versioned URLS.
"""
urls = []
urls.extend(_construct_version_one_urls(prefix=prefix))
urls.extend(_construct_version_two_urls(prefix=prefix))
# Timeseries API
urls.extend(_construct_version_one_urls(prefix=prefix + TIMESERIES_PREFIX))
urls.extend(_construct_version_two_urls(prefix=prefix + TIMESERIES_PREFIX))

# Search API
urls.extend(_construct_search_urls(prefix=prefix + SEARCH_PREFIX))

if MetricsPublicAPIInterface.is_auth_enabled():
urls.append(
Expand Down Expand Up @@ -222,3 +230,25 @@ def _construct_version_two_urls(
name="timeseries-list-v2",
),
]


def _construct_search_urls(
*,
prefix: str,
) -> list[resolvers.URLResolver]:
"""Returns a list of URLResolvers for the public search API

Args:
prefix: The prefix to add to the start of the url paths

Returns:
List of `URLResolver` objects each representing a
set of versioned URLS.
"""
return [
path(
f"{prefix}v1",
SearchView.as_view(),
name="search",
),
]
2 changes: 2 additions & 0 deletions public_api/version_02/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@
)

from .root_view import PublicAPIRootViewV2

from .search import SearchView
33 changes: 33 additions & 0 deletions public_api/version_02/views/search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from drf_spectacular.utils import extend_schema
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from wagtail.api.v2.serializers import get_serializer_class
from wagtail.models import Page

from cms.topic.models import TopicPage
from public_api.version_02.views.base import PUBLIC_API_TAG


@extend_schema(tags=[PUBLIC_API_TAG])
class SearchView(APIView):
"""This endpoint provides search results and could in the future provide
autocomplete suggestions etc.

"""

def get(self, request: Request):
search = request.GET.get("search")
limit = int(request.GET.get("limit", 0))
fields = request.GET.get("fields", ["title", "slug"])
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my understanding is that fields would be returned as a string. i would recommend not setting the default as a list but rather splitting the .get value before then doing an if check.

atm it seems like we're trying to handle str and list

meta = request.GET.get("meta", ["id"])
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above

topic_results = TopicPage.objects.all().search(search)
if not limit or topic_results.count() < limit:
# TODO: go get more

Check warning on line 26 in public_api/version_02/views/search.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=UKHSA-Internal_winter-pressures-api&issues=AZ4nn051wy91r1Qik4eS&open=AZ4nn051wy91r1Qik4eS&pullRequest=3193
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we may not need the extra count() db query for every search. i think you can check if limit is set then do the limit

# results = queryset
results = topic_results
else:
results = topic_results[0:limit]
print(f"AIDAN: returning {results}")
serialized = get_serializer_class(Page, fields, meta)(results, many=True)
return Response(serialized.data)
7 changes: 7 additions & 0 deletions scripts/_cache.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
echo
echo " flush-redis - flush and re-fill the redis (private api) cache"
echo " flush-redis-reserved-namespace - blue-green update the reserved namespace in the redis (private api) cache"
echo " flush-search - flush and re-build the wagtail search index"

return 0
}
Expand All @@ -20,6 +21,7 @@
case $verb in
"flush-redis") _cache_flush_redis $args ;;
"flush-redis-reserved-namespace") _cache_flush_redis_reserved_namespace $args ;;
"flush-search") _flush_search $args ;;

*) _cache_help ;;
esac
Expand All @@ -34,3 +36,8 @@
uhd venv activate
python manage.py hydrate_private_api_cache_reserved_namespace
}

function _flush_search() {

Check warning on line 40 in scripts/_cache.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add an explicit return statement at the end of the function.

See more on https://sonarcloud.io/project/issues?id=UKHSA-Internal_winter-pressures-api&issues=AZ4nn05swy91r1Qik4eR&open=AZ4nn05swy91r1Qik4eR&pullRequest=3193
uhd venv activate
python manage.py wagtail_update_index
}
5 changes: 4 additions & 1 deletion scripts/_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ function _tests_unit() {

function _tests_integration() {
uhd venv activate
python -m pytest tests/integration "$@"
rm -f test.sqlite3
uhd django migrate
uhd bootstrap all
pytest tests/integration "$@"
}

function _tests_system() {
Expand Down
12 changes: 12 additions & 0 deletions tests/factories/cms/page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import factory

from cms.common.models import CommonPage


class CommonPageFactory(factory.django.DjangoModelFactory):
"""
Factory for creating `CommonPage` instances for tests
"""

class Meta:
model = CommonPage
12 changes: 12 additions & 0 deletions tests/factories/cms/topic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import factory

from cms.topic.models import TopicPage


class TopicPageFactory(factory.django.DjangoModelFactory):
"""
Factory for creating `Topic` instances for tests
"""

class Meta:
model = TopicPage
44 changes: 44 additions & 0 deletions tests/integration/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
import pytest
from django.utils import timezone

from cms.common.models import UKHSAPage
from cms.topic.models import TopicPage
from tests.factories.cms.topic import TopicPageFactory
from tests.factories.cms.page import CommonPageFactory


from metrics.data.models.core_models import (
Age,
CoreHeadline,
Expand Down Expand Up @@ -105,6 +111,44 @@ def core_timeseries_example() -> list[CoreTimeSeries]:
]


@pytest.fixture
def topics() -> list[TopicPage]:
topics = []
for i in range(5):
title = f"title topic {i}"
if i % 2 == 0:
title += " rare"
topics = topics + [
TopicPageFactory.create(
path=f"topic-{i}",
depth=1,
title=title,
slug=f"slug-{i}",
seo_title=f"seo_title {i}",
body=f"body text {i}",
)
]
return topics


@pytest.fixture
def other_pages() -> list[UKHSAPage]:
pages = []
for i in range(5):
pages = pages + [
CommonPageFactory.create(
path=f"page-{i}",
depth=1,
title=f"title other {i}",
slug=f"slug-page{i}",
seo_title=f"seo_title {i}",
body=f"body text {i}",
)
]

return pages


@pytest.fixture
def patch_auth_enabled(monkeypatch):
monkeypatch.setenv("AUTH_ENABLED", "1")
Expand Down
92 changes: 92 additions & 0 deletions tests/integration/public_api/v2/views/test_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
from http import HTTPStatus
import os
import pytest

from rest_framework.test import RequestsClient


@pytest.mark.django_db
class TestSearchAPIView:

@property
def path(self) -> str:
return "/api/public/search/v1"

@property
def target_domain(self) -> str:
return os.environ.get("PUBLIC_API_TEST_DOMAIN", "http://testserver")

def test_search_finds_topics_only(self, topics, other_pages):
"""
Given a string that matches all topic pages
When the API is called with that query and a limit of 5
Then the results will include only topic pages
"""

# Given
limit = len(topics)
search = "topic" # All topics have a title with topic in them
query = f"search={search}&limit={limit}"

# When
client = RequestsClient()
url = f"{self.target_domain}{self.path}?{query}"
response: Response = client.get(url)

# Then
assert response.status_code == HTTPStatus.OK
response_data: list[dict] = response.json()
assert limit == len(response_data)
for page in topics:
target = {"title": page.title, "slug": page.slug}
assert response_data.index(target) > -1

def test_search_finds_topics_and_pages(self, topics, other_pages):
"""
Given a string that matches 2 topic page
When the API is called with that query and no limit
Then the results will include the matching topic pages and others with the topics first
"""

# Given
search = "rare" # All topics have a title, only even ones have rare in it
query = f"search={search}"
expected_results = [t for t in topics if "rare" in t.title]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it be best to use Django's __icontains filter here instead of a forloop?


# When
client = RequestsClient()
url = f"{self.target_domain}{self.path}?{query}"
response: Response = client.get(url)

# Then
assert response.status_code == HTTPStatus.OK
response_data: list[dict] = response.json()
assert len(expected_results) == len(response_data)
for page in expected_results:
target = {"title": page.title, "slug": page.slug}
assert response_data.index(target) > -1

def test_search_doesnt_find_unpublish_pages(self, topics, other_pages):

Check warning on line 69 in tests/integration/public_api/v2/views/test_search.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Update this function so that its implementation is not identical to test_search_finds_topics_and_pages on line 44.

See more on https://sonarcloud.io/project/issues?id=UKHSA-Internal_winter-pressures-api&issues=AZ5Ag1lSpRuBxicvYpl7&open=AZ5Ag1lSpRuBxicvYpl7&pullRequest=3193
"""
Given a string that matches 2 topic page
When the API is called with that query and no limit
Then the results will include the matching topic pages and others with the topics first
"""

# Given
search = "rare" # All topics have a title, only even ones have rare in it
query = f"search={search}"
expected_results = [t for t in topics if "rare" in t.title]

# When
client = RequestsClient()
url = f"{self.target_domain}{self.path}?{query}"
response: Response = client.get(url)

# Then
assert response.status_code == HTTPStatus.OK
response_data: list[dict] = response.json()
assert len(expected_results) == len(response_data)
for page in expected_results:
target = {"title": page.title, "slug": page.slug}
assert response_data.index(target) > -1
Loading