Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
<script>
// On document load we convert the skill levels and chart data into a query string and set it as the href of the export link.
document.addEventListener('DOMContentLoaded', function () {
const exportSkillProfileLink = document.querySelector('.export-skill-profile-link');
const exportSkillProfileLinkCopyButton = document.querySelector('.export-skill-profile-link-copy');
const exportSkillProfileLink = document.querySelector('#export-skill-profile-link');
const exportSkillProfileLinkCopyButton = document.querySelector('#export-skill-profile-link-copy');
const skillLevels = {{ skill_levels|safe }};
const charts = {{ chart_data|safe }};

Expand Down
41 changes: 34 additions & 7 deletions main/templates/main/snippets/export_user_skill_profile.html

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

(Not for this PR) I wonder if these snippets of JS scripts should be in a separate folder, or sub folder? They seem like they should be considered a separate category from the HTML snippets that are intended to be in this folder. Perhaps in main/templates/main/snippets/scripts or main/templates/main/scripts?

Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
<!-- This template implements an export button to export the current user's skill profile as a CSV file.
It works on the skill profile page.
<!-- This template implements an export button to export the current user's skill profile as a CSV or json file.


To use add a button with the class "export-skill-profile" to the page and include the following script at the end of the page.
"{ % include "main/snippets/export_user_skill_profile.html" %}"
To use add a button with the class `export-skill-profile-csv` and `export-skill-profile-json`
to the page and include the following script at the end of the page.
`{ % include "main/snippets/export_user_skill_profile.html" %}`

-->
<script>
const exportSkillProfileButton = document.querySelector('.export-skill-profile');
exportSkillProfileButton.addEventListener('click', function () {
<script id="export-skills-data-csv">
const exportSkillProfileCsvButton = document.querySelector('#export-skill-profile-csv');
exportSkillProfileCsvButton.addEventListener('click', function () {
// The skill levels and chart data are passed from the Django view
// We assume we receive the same data as the skill wheel.
const skillLevels = {{ skill_levels|safe }};
Expand All @@ -31,3 +31,30 @@
document.body.removeChild(link);
});
</script>
<script id="export-skills-data-json">
const exportSkillProfileJSONButton = document.querySelector('#export-skill-profile-json');
exportSkillProfileJSONButton.addEventListener('click', function () {
// The skill levels and chart data are passed from the Django view
// We assume we receive the same data as the skill wheel.
const skillLevels = {{ skill_levels|safe }};
const charts = {{ chart_data|safe }};

const jsonData = charts.map(({user_data, user_id}) => {
return user_data.map(({category, skill, skill_level, subcategory}) => {
return {
category,
subcategory,
skill,
level: skill_level
};
});
}).flat();
const jsonContent = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(jsonData, null, 2));
const link = document.createElement("a");
link.setAttribute("href", jsonContent);
link.setAttribute("download", "skill_profile.json");
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
</script>
35 changes: 23 additions & 12 deletions main/templates/main/snippets/share_skill_profile_dropdown.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
<!-- Share dropdown -->
<!-- Share dropdown

This template implements a share dropdown menu for the skill profile page.
It includes options to open a shareable link, copy the link, and export the skill profile as CSV or JSON.

-->
<div class="dropdown nav d-none d-sm-block order-lg-3 justify-content-end">
<li class="nav-item d-flex align-items-center justify-content-end">
<a class="nav-link dropdown-toggle justify-content-end"
Expand All @@ -8,27 +13,33 @@
aria-expanded="false">Share</a>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item export-skill-profile-link"
id="shareable-link"
<a class="dropdown-item"
id="export-skill-profile-link"
href="#"
target="_blank"
rel="noopener noreferrer">Open Link <i aria-label="Copy sharable link" class="fa-solid fa-link"></i></a>
</li>
<li>
<button type="button"
class="dropdown-item export-skill-profile-link-copy"
id="copy-link-button">
class="dropdown-item"
id="export-skill-profile-link-copy">
Copy Link <i aria-label="Copy sharable link"
class="fa-solid fa-copy"></i>
</button>
{% include "main/snippets/create_user_skill_profile_link.html" %}
</li>
<li>
<button type="button"
class="dropdown-item export-skill-profile"
href="#">Export as CSV</button>
{% include "main/snippets/export_user_skill_profile.html" %}
</li>
{% include "main/snippets/create_user_skill_profile_link.html" %}
{% if allow_export %}
<li>
<a class="dropdown-item"
href="{% url 'download-csv-data-link' %}"
id="download-csv-button">Export as CSV</a>
</li>
<li>
<a class="dropdown-item"
href="{% url 'download-json-data-link' %}"
id="download-json-button">Export as JSON</a>
</li>
{% endif %}
</ul>
</li>
</div>
10 changes: 10 additions & 0 deletions main/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
1 change: 1 addition & 0 deletions main/views/__init__.py
Original file line number Diff line number Diff line change
@@ -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
84 changes: 84 additions & 0 deletions main/views/account_data_views.py

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't think we need this file. You should be able to import the AuthenticatedHttpRequest and TermsAcceptedMixin straight into data_views.py and make these views require authentication by using that directly as a mixin.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I partly agree except for two points:

  • Importing between views doesn't seem right so perhaps we need a separate shared utils file?
  • We have a separate file for account views so it seemed to make sense to have a separate one for account data views too.

I wasn't sure about having the dataview endpoints redirect to the login page which is what the TermsAcceptedMixin does. This would only actually happen if someone tried to go to the download link directly. I can revert to using the existing mixins.

Original file line number Diff line number Diff line change
@@ -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"'
},
)
4 changes: 4 additions & 0 deletions main/views/account_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
7 changes: 7 additions & 0 deletions tests/main/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,10 @@ def event() -> Event:
blog="https://example.com/blog",
contributors="Test Contributor",
)


@pytest.fixture
def client_logged_in(client, user):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Didn't realise we didn't have one of these!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Let me know if you want me to pull out to another PR or just slide in with this one.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No, it's fine to be in this PR. Adding these kinds of thing as and when they're needed makes the most sense to me

"""Fixture for a client that is logged in as a user."""
client.force_login(user)
return client
93 changes: 93 additions & 0 deletions tests/main/test_account_data_views.py
Original file line number Diff line number Diff line change
@@ -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
Loading