diff --git a/main/templates/main/snippets/create_user_skill_profile_link.html b/main/templates/main/snippets/create_user_skill_profile_link.html index 099b8142..7f60e10d 100644 --- a/main/templates/main/snippets/create_user_skill_profile_link.html +++ b/main/templates/main/snippets/create_user_skill_profile_link.html @@ -11,8 +11,8 @@ + diff --git a/main/templates/main/snippets/share_skill_profile_dropdown.html b/main/templates/main/snippets/share_skill_profile_dropdown.html index 77effa1d..1e70158e 100644 --- a/main/templates/main/snippets/share_skill_profile_dropdown.html +++ b/main/templates/main/snippets/share_skill_profile_dropdown.html @@ -1,4 +1,9 @@ - + diff --git a/main/urls.py b/main/urls.py index 1ffca584..6b0560a3 100644 --- a/main/urls.py +++ b/main/urls.py @@ -46,6 +46,16 @@ path("skills-profile/", views.SkillProfileView.as_view(), name="skills_profile"), path("profile/", views.UserUpdateView.as_view(), name="profile"), path("overview/", views.AccountOverviewView.as_view(), name="account-overview"), + path( + "download-skills-data/csv/", + views.DownloadUserSkillDataCsvView.as_view(), + name="download-csv-data-link", + ), + path( + "download-skills-data/json/", + views.DownloadUserSkillDataJSONView.as_view(), + name="download-json-data-link", + ), ] policies_patterns = [ diff --git a/main/views/__init__.py b/main/views/__init__.py index 751bc2f0..d67fe4ae 100644 --- a/main/views/__init__.py +++ b/main/views/__init__.py @@ -1,5 +1,6 @@ """Init in views.""" +from .account_data_views import * # noqa: F403 from .account_views import * # noqa: F403 from .data_views import * # noqa: F403 from .page_views import * # noqa: F403 diff --git a/main/views/account_data_views.py b/main/views/account_data_views.py new file mode 100644 index 00000000..3dcc5409 --- /dev/null +++ b/main/views/account_data_views.py @@ -0,0 +1,84 @@ +"""Views for getting account-related data from the framework database.""" + +import csv + +from django.http import ( + HttpResponse, + JsonResponse, +) +from django.views import View + +from ..models import User, UserSkill +from .account_views import AuthenticatedHttpRequest, TermsAcceptedMixin + + +def format_user_skill_data_for_download(user: User) -> list[dict[str, str | int]]: + """Format the user skill data for download.""" + user_skills = UserSkill.objects.filter(user=user.pk) + return [ + { + "skill": user_skill.skill.name, + "competency_domain": user_skill.skill.competency.competency_domain.name, + "competency": user_skill.skill.competency.name, + "skill_level": user_skill.skill_level.level, + } + for user_skill in user_skills + ] + + +class DownloadUserSkillDataCsvView(TermsAcceptedMixin, View): + """A view that returns the user skill data as a CSV file.""" + + def get(self, request: AuthenticatedHttpRequest) -> HttpResponse: + """Handle GET request to download user skill data as CSV. + + Args: + request: The HTTP request object. + + Returns: + An HttpResponse containing the user skill data as a CSV file + """ + user_skills_data = format_user_skill_data_for_download(request.user) + response = HttpResponse( + content_type="text/csv", + headers={"Content-Disposition": 'attachment; filename="skill_profile.csv"'}, + ) + writer = csv.writer(response) + writer.writerow(["skill", "competency_domain", "competency", "skill_level"]) + for user_skill in user_skills_data: + writer.writerow( + [ + user_skill["skill"], + user_skill["competency_domain"], + user_skill["competency"], + user_skill["skill_level"], + ] + ) + return response + + +class DownloadUserSkillDataJSONView(TermsAcceptedMixin, View): + """A view that returns the user skill data as a JSON file.""" + + def get(self, request: AuthenticatedHttpRequest) -> JsonResponse: + """Define the GET response. + + Args: + request: The HTTP request object. + + Returns: + A JsonResponse containing the user skill data in JSON format. + """ + user_skills_data = format_user_skill_data_for_download(request.user) + get_anonymous = request.GET.get("anonymous", "false").lower() == "true" + returned_data = { + "username": "anonymous" if get_anonymous else request.user.username, + "user_data": user_skills_data, + } + return JsonResponse( + returned_data, + json_dumps_params=dict(indent=2), + headers={ + "Content-Disposition": 'attachment; filename="skill_profile.json"' + }, + ) diff --git a/main/views/account_views.py b/main/views/account_views.py index fa26b89e..706413bc 100644 --- a/main/views/account_views.py +++ b/main/views/account_views.py @@ -104,6 +104,10 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context["skill_levels"] = dumps( list(SkillLevel.objects.values("level", "name")) ) + + context["allow_export"] = ( + True # Allow exporting skill profile data as CSV or JSON + ) return context diff --git a/tests/main/conftest.py b/tests/main/conftest.py index dfca97b4..d50340cb 100644 --- a/tests/main/conftest.py +++ b/tests/main/conftest.py @@ -119,3 +119,10 @@ def event() -> Event: blog="https://example.com/blog", contributors="Test Contributor", ) + + +@pytest.fixture +def client_logged_in(client, user): + """Fixture for a client that is logged in as a user.""" + client.force_login(user) + return client diff --git a/tests/main/test_account_data_views.py b/tests/main/test_account_data_views.py new file mode 100644 index 00000000..f5094b8e --- /dev/null +++ b/tests/main/test_account_data_views.py @@ -0,0 +1,93 @@ +"""Test suite for the account-related data views. + +This test module includes tests for data views of the app. +""" + +from http import HTTPStatus + +from django.urls import reverse + +from .test_main_views import LoginRequiredMixin + + +class DownloadUserSkillDataMixin(LoginRequiredMixin): + """Test suite for the GetUserSkillsDataMixin.""" + + _url: str + _content_type: str + + def test_authenticated_user_can_access(self, client_logged_in): + """Test that an authenticated user can access the view.""" + response = client_logged_in.get(self._get_url()) + assert response.status_code == HTTPStatus.OK + + def test_returns_correct_content_type(self, client_logged_in): + """Test that the response returns the correct content type.""" + response = client_logged_in.get(self._get_url()) + assert response.status_code == HTTPStatus.OK + assert response["Content-Type"] == self._content_type + + def test_return_type_should_be_file_attachement(self, client_logged_in): + """Test that the response has the correct file attachment header.""" + response = client_logged_in.get(self._get_url()) + assert response.status_code == HTTPStatus.OK + content_disposition = response["Content-Disposition"] + assert content_disposition.startswith("attachment;") + + def _get_url(self): + return reverse(self._url) + + +class TestDownloadUserSkillDataCsvView(DownloadUserSkillDataMixin): + """Test suite for the DownloadCSVDataLinkView.""" + + _url = "download-csv-data-link" + _content_type = "text/csv" + + def test_download_csv_data_link_view_csv_content( + self, client_logged_in, user_skill + ): + """Test that the CSV content is correct.""" + response = client_logged_in.get(self._get_url()) + content = response.content.decode("utf-8") + assert content.startswith("skill") + assert "competency_domain" in content + assert "competency" in content + assert "skill_level" in content + assert user_skill.skill.name in content + assert ( + content.count("\n") > 1 + ) # Ensure there is at least one row of data besides the header + + +class TestDownloadUserSkillDataJSONView(DownloadUserSkillDataMixin): + """Test suite for the DownloadUserSkillDataJSONView.""" + + _url = "download-json-data-link" + _content_type = "application/json" + + def test_download_json_data_link_view_json_content(self, client_logged_in): + """Test that the JSON content is correct.""" + response = client_logged_in.get(self._get_url()) + assert response.status_code == HTTPStatus.OK + content = response.json() + assert isinstance(content, dict) + assert "username" in content + assert "user_data" in content + assert isinstance(content["user_data"], list) + if content["user_data"]: + assert "skill" in content["user_data"][0] + assert "competency_domain" in content["user_data"][0] + assert "competency" in content["user_data"][0] + assert "skill_level" in content["user_data"][0] + + def test_returns_anonymous_username_when_anonymous_param_is_true( + self, client_logged_in + ): + """Assert username is 'anonymous' when anonymous URL para is set to true.""" + response = client_logged_in.get(self._get_url() + "?anonymous=true") + assert response.status_code == HTTPStatus.OK + content = response.json() + assert content["username"] == "anonymous" + # Also check that the user_data is still present + assert "user_data" in content