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
41 changes: 34 additions & 7 deletions main/templates/main/snippets/export_user_skill_profile.html
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", encodedUri);
Comment on lines +52 to +54

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.

This encodedUri variable isn't defined in this function. Should it be?

Suggested change
const jsonContent = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(jsonData, null, 2));
const link = document.createElement("a");
link.setAttribute("href", encodedUri);
const jsonContent = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(jsonData, null, 2));
const encodedUri = encodeURI(jsonContent);
const link = document.createElement("a");
link.setAttribute("href", encodedUri);

Note: I made this change and it fixed the issue about it not downloading anything. But the resultant file is not a JSON file. It is equivalent to the URL query. So, I got:

%5B%0A%20%20%7B%0A%20%20%20%20%22category%22%3A%20%22Communication%22%2C%0A%20%20%20%20%22subcategory%22%3A%20%22Community%20and%20outreach%22%2C%0A%20%20%20%20%22skill%22%3A%20%22Community%20governance%20and%20management%22%2C%0A%20%20%20%20%22level%22%3A%203%0A%20%20%7D%2C%0A%20%20%7B%0A%20%20%20%20%22category%22%3A%20%22Communication%22%2C%0A%20%20%20%20%22subcategory%22%3A%20%22Community%20and%20outreach%22%2C%0A%20%20%20%20%22skill%22%3A%20%22Open%20project%20culture%2C%20structure%20and%20practices%20%22%2C%0A%20%20%20%20%22level%22%3A%202%0A%20%20%7D%2C%0A%20%20%7B%0A%20%20%20%20%22category%22%3A%20%22Communication%22%2C%0A%20%20%20%20%22subcategory%22%3A%20%22Verbal%20and%20written%20communication%22%2C%0A%20%20%20%20%22skill%22%3A%20%22Multimedia%20and%20social%20content%20production%22%2C%0A%20%20%20%20%22level%22%3A%202%0A%20%20%7D%2C%0A%20%20%7B%0A%20%20%20%20%22category%22%3A%20%22Communication%22%2C%0A%20%20%20%20%22subcategory%22%3A%20%22Verbal%20and%20written%20communication%22%2C%0A%20%20%20%20%22skill%22%3A%20%22Providing%20positive%20and%20constructive%20feedback%22%2C%0A%20%20%20%20%22level%22%3A%202%0A%20%20%7D%2C%0A%20%20%7B%0A%20%20%20%20%22category%22%3A%20%22Computing%20infrastructures%20and%20systems%22%2C%0A%20%20%20%20%22subcategory%22%3A%20%22Cloud%20infrastructure%22%2C%0A%20%20%20%20%22skill%22%3A%20%22Serverless%20computing%22%2C%0A%20%20%20%20%22level%22%3A%202%0A%20%20%7D%2C%0A%20%20%7B%0A%20%20%20%20%22category%22%3A%20%22Computing%20infrastructures%20and%20systems%22%2C%0A%20%20%20%20%22subcategory%22%3A%20%22General%20systems%20infrastructure%22%2C%0A%20%20%20%20%22skill%22%3A%20%22Network%20communication%20protocols%22%2C%0A%20%20%20%20%22level%22%3A%201%0A%20%20%7D%2C%0A%20%20%7B%0A%20%20%20%20%22category%22%3A%20%22Computing%20infrastructures%20and%20systems%22%2C%0A%20%20%20%20%22subcategory%22%3A%20%22General%20systems%20infrastructure%22%2C%0A%20%20%20%20%22skill%22%3A%20%22Storage%20systems%22%2C%0A%20%20%20%20%22level%22%3A%203%0A%20%20%7D%2C%0A%20%20%7B%0A%20%20%20%20%22category%22%3A%20%22Computing%20infrastructures%20and%20systems%22%2C%0A%20%20%20%20%22subcategory%22%3A%20%22General%20systems%20infrastructure%22%2C%0A%20%20%20%20%22skill%22%3A%20%22Systems%20architecture%20and%20infrastructure%20design%22%2C%0A%20%20%20%20%22level%22%3A%203%0A%20%20%7D%2C%0A%20%20%7B%0A%20%20%20%20%22category%22%3A%20%22Communication%22%2C%0A%20%20%20%20%22subcategory%22%3A%20%22Community%20and%20outreach%22%2C%0A%20%20%20%20%22skill%22%3A%20%22Networking%20and%20relationship%20building%22%2C%0A%20%20%20%20%22level%22%3A%200%0A%20%20%7D%2C%0A%20%20%7B%0A%20%20%20%20%22category%22%3A%20%22Domain%20expertise%20and%20research%22%2C%0A%20%20%20%20%22subcategory%22%3A%20%22Research%20skills%22%2C%0A%20%20%20%20%22skill%22%3A%20%22Ethical%20responsibility%22%2C%0A%20%20%20%20%22level%22%3A%201%0A%20%20%7D%2C%0A%20%20%7B%0A%20%20%20%20%22category%22%3A%20%22Domain%20expertise%20and%20research%22%2C%0A%20%20%20%20%22subcategory%22%3A%20%22Research%20skills%22%2C%0A%20%20%20%20%22skill%22%3A%20%22Legal%20and%20regulatory%20responsibility%22%2C%0A%20%20%20%20%22level%22%3A%202%0A%20%20%7D%2C%0A%20%20%7B%0A%20%20%20%20%22category%22%3A%20%22Domain%20expertise%20and%20research%22%2C%0A%20%20%20%20%22subcategory%22%3A%20%22Research%20skills%22%2C%0A%20%20%20%20%22skill%22%3A%20%22Research%20methodologies%22%2C%0A%20%20%20%20%22level%22%3A%203%0A%20%20%7D%0A%5D

link.setAttribute("download", "skill_profile.json");
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
</script>
25 changes: 19 additions & 6 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 @@ -19,12 +24,20 @@
id="copy-link-button">
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 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 export-skill-profile-csv"
href="{% url 'download-csv-data-link' %}"
id="copy-link-button">Export as CSV</a>
</li>
<li>
<a class="dropdown-item export-skill-profile-json"
href="{% url 'download-json-data-link' %}"
id="copy-link-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 @@ -36,6 +36,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
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
84 changes: 83 additions & 1 deletion main/views/data_views.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
"""Views for getting data from the framework database."""

from django.http import HttpRequest, JsonResponse
import csv

from django.http import HttpRequest, HttpResponse, JsonResponse
from django.views import View

from ..io_resources import export_framework
from ..models import UserSkill


class FrameworkView(View):
Expand All @@ -23,3 +26,82 @@ def get(self, request: HttpRequest) -> JsonResponse:
- skill_levels: A list of the Skill Levels
"""
return JsonResponse(export_framework(), json_dumps_params=dict(indent=2))


class DownloadUserSkillDataCsvView(View):
"""A view that returns the user skill data as a CSV file."""

def get(self, request: HttpRequest) -> HttpResponse:
"""Define the GET response.

Args:
request: A GET request from the user.

Returns:
A HttpResponse containing the user skill data in CSV format.
"""
user_skills = UserSkill.objects.filter(user=request.user.pk)
user_skills_data = [
{
"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
]
context = {}
context["chart_data"] = [
{
"user_id": "root",
"user_data": user_skills_data,
}
]

response = HttpResponse(
content_type="text/csv",
headers={"Content-Disposition": 'attachment; filename="downloaded_.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(View):
"""A view that returns the user skill data as a JSON file."""

def get(self, request: HttpRequest) -> JsonResponse:
"""Define the GET response.

Args:
request: A GET request from the user.

Returns:
A JsonResponse containing the user skill data in JSON format.
"""
user_skills = UserSkill.objects.filter(user=request.user.pk)
# get_anonymous url param
get_anonymous = request.GET.get("anonymous", "false").lower() == "true"
user_skills_data = [
{
"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
]
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))
7 changes: 7 additions & 0 deletions tests/main/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,10 @@ def skill_level() -> SkillLevel:
def user_skill(user, skill: Skill, skill_level: SkillLevel) -> UserSkill:
"""Fixture for creating a UserSkill instance."""
return UserSkill.objects.create(user=user, skill=skill, skill_level=skill_level)


@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
78 changes: 78 additions & 0 deletions tests/main/test_data_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,81 @@ def test_framework_view_can_only_get(self, client):
assert response.status_code == HTTPStatus.METHOD_NOT_ALLOWED
response = client.trace(self._get_url())
assert response.status_code == HTTPStatus.METHOD_NOT_ALLOWED


class TestDownloadCSVDataLinkView:
"""Test suite for the DownloadCSVDataLinkView."""

def _get_url(self):
return reverse("download-csv-data-link")

def test_download_csv_data_link_view_get(self, client_logged_in):
"""Test the GET returns the correct CSV data link."""
response = client_logged_in.get(self._get_url())
assert response.status_code == HTTPStatus.OK

def test_download_csv_data_link_view_returns_a_csv_file(self, client_logged_in):
"""Test that the GET request returns a CSV file."""
response = client_logged_in.get(self._get_url())
assert response.status_code == HTTPStatus.OK
assert response["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())
assert response.status_code == HTTPStatus.OK
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:
"""Test suite for the DownloadUserSkillDataJSONView."""

def _get_url(self):
return reverse("download-json-data-link")

def test_download_json_data_link_view_get(self, client_logged_in):
"""Test the GET returns the correct JSON data link."""
response = client_logged_in.get(self._get_url())
assert response.status_code == HTTPStatus.OK

def test_download_json_data_link_view_returns_a_json_file(self, client_logged_in):
"""Test that the GET request returns a JSON file."""
response = client_logged_in.get(self._get_url())
assert response.status_code == HTTPStatus.OK
assert response["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