diff --git a/home/service/details_csv.py b/home/service/details_csv.py new file mode 100644 index 00000000..1675712d --- /dev/null +++ b/home/service/details_csv.py @@ -0,0 +1,65 @@ +from home.service.details import ( + DashboardDetailsService, + DatabaseDetailsService, + DatasetDetailsService, +) + + +class DatasetDetailsCsvFormatter: + def __init__(self, details_service: DatasetDetailsService): + self.details_service = details_service + + def data(self): + return [ + ( + column.name, + column.display_name, + column.type, + column.description, + ) + for column in self.details_service.table_metadata.column_details + ] + + def headers(self): + return [ + "name", + "display_name", + "type", + "description", + ] + + +class DatabaseDetailsCsvFormatter: + def __init__(self, details_service: DatabaseDetailsService): + self.details_service = details_service + + def data(self): + return [ + ( + table.entity_ref.urn, + table.entity_ref.display_name, + table.description, + ) + for table in self.details_service.entities_in_database + ] + + def headers(self): + return [ + "urn", + "display_name", + "description", + ] + + +class DashboardDetailsCsvFormatter: + def __init__(self, details_service: DashboardDetailsService): + self.details_service = details_service + + def data(self): + return [ + (chart.entity_ref.urn, chart.entity_ref.display_name, chart.description) + for chart in self.details_service.children + ] + + def headers(self): + return ["urn", "display_name", "description"] diff --git a/home/tests.py b/home/tests.py deleted file mode 100644 index a79ca8be..00000000 --- a/home/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -# from django.test import TestCase - -# Create your tests here. diff --git a/home/urls.py b/home/urls.py index 45d7fc54..0cafd655 100644 --- a/home/urls.py +++ b/home/urls.py @@ -13,6 +13,11 @@ views.metadata_specification_view, name="metadata_specification", ), + path( + "details//.csv", + views.details_view_csv, + name="details_csv", + ), path( "details//", views.details_view, diff --git a/home/views.py b/home/views.py index e5dc68a5..0e9fdbd4 100644 --- a/home/views.py +++ b/home/views.py @@ -1,9 +1,10 @@ +import csv from urllib.parse import urlparse from data_platform_catalogue.client.exceptions import EntityDoesNotExist from data_platform_catalogue.search_types import DomainOption from django.conf import settings -from django.http import Http404, HttpResponseBadRequest +from django.http import Http404, HttpResponse, HttpResponseBadRequest from django.shortcuts import render from django.utils.translation import gettext as _ from django.views.decorators.cache import cache_control @@ -15,6 +16,11 @@ DatabaseDetailsService, DatasetDetailsService, ) +from home.service.details_csv import ( + DashboardDetailsCsvFormatter, + DatabaseDetailsCsvFormatter, + DatasetDetailsCsvFormatter, +) from home.service.domain_fetcher import DomainFetcher from home.service.glossary import GlossaryService from home.service.metadata_specification import MetadataSpecificationService @@ -33,60 +39,56 @@ def home_view(request): @cache_control(max_age=300, private=True) def details_view(request, result_type, urn): - if result_type == "table": - service = dataset_service(urn) - return render(request, service.template, service.context) - if result_type == "database": - context = database_details(urn) - return render(request, "details_database.html", context) - if result_type == "chart": - context = chart_details(urn) - return render(request, "details_chart.html", context) - if result_type == "dashboard": - context = dashboard_details(urn) - return render(request, "details_dashboard.html", context) - - -def database_details(urn): try: - service = DatabaseDetailsService(urn) - except EntityDoesNotExist: - raise Http404("Asset does not exist") - - context = service.context - - return context - + if result_type == "table": + service = DatasetDetailsService(urn) + template = service.template + elif result_type == "database": + service = DatabaseDetailsService(urn) + template = "details_database.html" + elif result_type == "chart": + service = ChartDetailsService(urn) + template = "details_chart.html" + elif result_type == "dashboard": + service = DashboardDetailsService(urn) + template = "details_dashboard.html" + else: + raise Http404("Invalid result type") + + return render(request, template, service.context) -def dataset_service(urn): - try: - service = DatasetDetailsService(urn) except EntityDoesNotExist: - raise Http404("Asset does not exist") - - return service + raise Http404(f"{result_type} '{urn}' does not exist") -def chart_details(urn): - try: - service = ChartDetailsService(urn) - except EntityDoesNotExist: - raise Http404("Asset does not exist") - - context = service.context - - return context - - -def dashboard_details(urn): - try: +@cache_control(max_age=300, private=True) +def details_view_csv(request, result_type, urn) -> HttpResponse: + if result_type == "table": + service = DatasetDetailsService(urn) + csv_formatter = DatasetDetailsCsvFormatter(service) + elif result_type == "database": + service = DatabaseDetailsService(urn) + csv_formatter = DatabaseDetailsCsvFormatter(service) + elif result_type == "dashboard": service = DashboardDetailsService(urn) - except EntityDoesNotExist: - raise Http404("Asset does not exist") + csv_formatter = DashboardDetailsCsvFormatter(service) + else: + raise Http404("CSV not available") + + # In case there are any quotes in the filename, remove them in order to + # not to break the header. + unsavoury_characters = str.maketrans({'"': ""}) + filename = urn.translate(unsavoury_characters) + ".csv" - context = service.context + response = HttpResponse( + content_type="text/csv", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + writer = csv.writer(response) + writer.writerow(csv_formatter.headers()) + writer.writerows(csv_formatter.data()) - return context + return response @cache_control(max_age=60, private=True) diff --git a/lib/datahub-client/data_platform_catalogue/entities.py b/lib/datahub-client/data_platform_catalogue/entities.py index 6f187776..002a6ca8 100644 --- a/lib/datahub-client/data_platform_catalogue/entities.py +++ b/lib/datahub-client/data_platform_catalogue/entities.py @@ -265,7 +265,7 @@ class EntitySummary(BaseModel): ) description: str = Field(description="A description of the entity") entity_type: str = Field( - description="indicates the tpye of entity that is summarised" + description="indicates the type of entity that is summarised" ) tags: list[TagRef] = Field(description="Any tags associated with the entity") diff --git a/templates/details_dashboard.html b/templates/details_dashboard.html index 5e180fce..73aa5b9b 100644 --- a/templates/details_dashboard.html +++ b/templates/details_dashboard.html @@ -33,6 +33,11 @@ {% endfor %} +
+ +
{% else %}

{% translate "Dashboard content" %}

{% translate "This dashboard is missing chart information." %}

diff --git a/templates/details_database.html b/templates/details_database.html index 260f6300..15a7efd4 100644 --- a/templates/details_database.html +++ b/templates/details_database.html @@ -39,6 +39,11 @@ {% endfor %} +
+ +
{% else %}

{% translate "Database content" %}

{% translate "This database is missing table information." %}

diff --git a/templates/details_table.html b/templates/details_table.html index f257c8ef..7def2117 100644 --- a/templates/details_table.html +++ b/templates/details_table.html @@ -40,6 +40,12 @@ {% endfor %} + +
+ +
{% else %}

{% translate "Table schema" %}

{% translate "The schema for this table is not available." %}

diff --git a/tests/home/service/test_details_csv.py b/tests/home/service/test_details_csv.py new file mode 100644 index 00000000..b61226d6 --- /dev/null +++ b/tests/home/service/test_details_csv.py @@ -0,0 +1,116 @@ +from unittest.mock import MagicMock + +from data_platform_catalogue.entities import Column, EntityRef, EntitySummary + +from home.service.details import ( + DashboardDetailsService, + DatabaseDetailsService, + DatasetDetailsService, +) +from home.service.details_csv import ( + DashboardDetailsCsvFormatter, + DatabaseDetailsCsvFormatter, + DatasetDetailsCsvFormatter, +) + + +def test_dataset_details_csv_formatter(example_table): + details_service = MagicMock(spec=DatasetDetailsService) + columns = [ + Column( + name="foo", + display_name="Foo", + type="string", + description="an example", + nullable=False, + is_primary_key=True, + ), + Column( + name="bar", + display_name="Bar", + type="integer", + description="another example", + nullable=True, + is_primary_key=False, + ), + ] + details_service.table_metadata = example_table + example_table.column_details = columns + csv_formatter = DatasetDetailsCsvFormatter(details_service) + + assert csv_formatter.headers() == [ + "name", + "display_name", + "type", + "description", + ] + assert csv_formatter.data() == [ + ( + "foo", + "Foo", + "string", + "an example", + ), + ( + "bar", + "Bar", + "integer", + "another example", + ), + ] + + +def test_database_details_csv_formatter(example_database): + tables = [ + EntitySummary( + entity_ref=EntityRef(display_name="foo", urn="urn:foo"), + description="an example", + entity_type="Table", + tags=[], + ), + EntitySummary( + entity_ref=EntityRef(display_name="bar", urn="urn:bar"), + description="another example", + entity_type="Table", + tags=[], + ), + ] + + details_service = MagicMock(spec=DatabaseDetailsService) + details_service.entities_in_database = tables + + csv_formatter = DatabaseDetailsCsvFormatter(details_service) + + assert csv_formatter.headers() == ["urn", "display_name", "description"] + assert csv_formatter.data() == [ + ("urn:foo", "foo", "an example"), + ("urn:bar", "bar", "another example"), + ] + + +def test_dashboard_details_csv_formatter(example_dashboard): + charts = [ + EntitySummary( + entity_ref=EntityRef(display_name="foo", urn="urn:foo"), + description="an example", + entity_type="Chart", + tags=[], + ), + EntitySummary( + entity_ref=EntityRef(display_name="bar", urn="urn:bar"), + description="another example", + entity_type="Chart", + tags=[], + ), + ] + + details_service = MagicMock(spec=DashboardDetailsService) + details_service.children = charts + + csv_formatter = DashboardDetailsCsvFormatter(details_service) + + assert csv_formatter.headers() == ["urn", "display_name", "description"] + assert csv_formatter.data() == [ + ("urn:foo", "foo", "an example"), + ("urn:bar", "bar", "another example"), + ] diff --git a/tests/test_views.py b/tests/test_views.py index 0a0000e5..703adf6c 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -51,9 +51,65 @@ def test_table(self, client, switch_bool): response = client.get( reverse("home:details", kwargs={"urn": "fake", "result_type": "table"}) ) + assert response.status_code == 200 assert response.headers["Cache-Control"] == "max-age=300, private" + @pytest.mark.django_db + def test_csv_output(self, client): + response = client.get( + reverse( + "home:details_csv", + kwargs={"urn": "fake", "result_type": "table"}, + ) + ) + assert response.status_code == 200 + assert ( + response.headers["Content-Disposition"] == 'attachment; filename="fake.csv"' + ) + assert response.content == ( + b"name,display_name,type,description\r\n" + + b"urn,urn,string,description **with markdown**\r\n" + ) + + +class TestDatabaseView: + @pytest.mark.django_db + def test_csv_output(self, client): + response = client.get( + reverse( + "home:details_csv", + kwargs={"urn": "fake", "result_type": "database"}, + ) + ) + assert response.status_code == 200 + assert ( + response.headers["Content-Disposition"] == 'attachment; filename="fake.csv"' + ) + assert response.content == ( + b"urn,display_name,description\r\n" + + b"urn:li:dataset:fake_table,fake_table,table description\r\n" + ) + + +class TestDashboardView: + @pytest.mark.django_db + def test_csv_output(self, client): + response = client.get( + reverse( + "home:details_csv", + kwargs={"urn": "fake", "result_type": "dashboard"}, + ) + ) + assert response.status_code == 200 + assert ( + response.headers["Content-Disposition"] == 'attachment; filename="fake.csv"' + ) + assert response.content == ( + b"urn,display_name,description\r\n" + + b"urn:li:chart:fake_chart,fake_chart,chart description\r\n" + ) + class TestChartView: def test_chart(self, client):