Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FC-0004] API to manage certificate signatures #1837

Closed
11 changes: 11 additions & 0 deletions credentials/apps/api/v2/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,14 @@ class CanReplaceUsername(permissions.BasePermission):

def has_permission(self, request, view):
return request.user.username == settings.USERNAME_REPLACEMENT_WORKER


class IsAdminUserOrReadOnly(permissions.BasePermission):
"""
Grants access to edit only the staff.
Grants read access to all users.
"""

def has_permission(self, request, view):
is_admin_user = request.user and (request.user.is_superuser or request.user.is_staff)
return is_admin_user or request.method in permissions.SAFE_METHODS
86 changes: 84 additions & 2 deletions credentials/apps/api/v2/serializers.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
"""
Serializers for data manipulated by the credentials service APIs.
"""
import ast
import logging
from copy import copy
from pathlib import Path
from typing import Dict, Generator, List, Optional, Union

from django.core.exceptions import ObjectDoesNotExist
from django.core.files.uploadedfile import InMemoryUploadedFile
from django.utils.datastructures import MultiValueDict
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_framework.reverse import reverse
Expand All @@ -14,6 +20,7 @@
from credentials.apps.credentials.models import (
CourseCertificate,
ProgramCertificate,
Signatory,
UserCredential,
UserCredentialAttribute,
UserCredentialDateOverride,
Expand Down Expand Up @@ -285,8 +292,72 @@ def create(self, validated_data):
return grade


class SignatorySerializer(serializers.ModelSerializer):
"""Serializer for Signatory objects."""

organization = serializers.CharField(
max_length=255, source="organization_name_override", required=False, allow_blank=True
)

class Meta:
model = Signatory
fields = (
"image",
"name",
"organization",
"title",
)

def validate_image(self, value):
if value:
extension = Path(value.name).suffix[1:].lower()
if extension != "png":
raise ValidationError("Only PNG files can be uploaded. Please select a file ending in .png to upload.")
return value


class SignatoryListField(serializers.ListField):
"""
Serializes a list of strings into a list of dicts.
Populate signatories images in dict files instead of strings with their filenames.
"""

def to_representation(self, data):
return super().to_representation(data.all())

def to_internal_value(self, data: List[str]) -> List[Optional[Dict[str, Union[str, InMemoryUploadedFile]]]]:
"""
Converts a list of dict that are a string to a list of dicts.
"""
try:
jsonified_data = list(map(ast.literal_eval, copy(data)))
except ValueError:
return []
else:
_data = self.populate_signatories(
jsonified_data, self.context["request"].FILES # pylint: disable=no-member
)
return super().to_internal_value(_data)

@staticmethod
def populate_signatories(
signatories_data: List[Dict[str, str]],
files: MultiValueDict,
) -> Generator[Dict[str, Union[str, InMemoryUploadedFile]], None, None]:
"""
Populates a list of signature data with files.
"""
for signatory_data in signatories_data:
if signatory_file := files.get(signatory_data.get("image"), None):
signatory_data["image"] = signatory_file
yield signatory_data


class CourseCertificateSerializer(serializers.ModelSerializer):
course_run = CourseRunField(read_only=True)
certificate_available_date = serializers.DateTimeField(required=False, allow_null=True)
signatories = SignatoryListField(child=SignatorySerializer(), required=False)
title = serializers.CharField(required=False, allow_null=True)

class Meta:
model = CourseCertificate
Expand All @@ -298,10 +369,12 @@ class Meta:
"certificate_type",
"certificate_available_date",
"is_active",
"signatories",
"title",
)
read_only_fields = ("id", "course_run", "site")

def create(self, validated_data):
def create(self, validated_data) -> CourseCertificate:
site = self.context["request"].site
# A course run may not exist, but if it does, we want to find it. There may be times where course
# staff will change the date on a newly created course before credentials has pulled it from the catalog.
Expand All @@ -322,7 +395,16 @@ def create(self, validated_data):
defaults={
"is_active": validated_data["is_active"],
"course_run": course_run,
"certificate_available_date": validated_data["certificate_available_date"],
"certificate_available_date": validated_data.get("certificate_available_date"),
"title": validated_data.get("title"),
},
)

signatories_data = validated_data.get("signatories", [])
if signatories_data:
cert.signatories.clear()
for signatory_data in signatories_data:
signatory = Signatory.objects.create(**signatory_data)
cert.signatories.add(signatory)

return cert
114 changes: 106 additions & 8 deletions credentials/apps/api/v2/tests/test_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
from credentials.apps.api.v2.serializers import (
CourseCertificateSerializer,
CredentialField,
SignatoryListField,
SignatorySerializer,
UserCredentialAttributeSerializer,
UserCredentialCreationSerializer,
UserCredentialSerializer,
Expand All @@ -25,8 +27,10 @@
from credentials.apps.credentials.tests.factories import (
CourseCertificateFactory,
ProgramCertificateFactory,
SignatoryFactory,
UserCredentialAttributeFactory,
UserCredentialFactory,
create_image,
)
from credentials.apps.records.tests.factories import UserGradeFactory

Expand Down Expand Up @@ -295,6 +299,73 @@ def test_course_credential(self):
self.assertEqual(actual, expected)


class SignatorySerializerTests(SiteMixin, TestCase):
def test_create_signatory(self):
signatory = SignatoryFactory()
actual = SignatorySerializer(signatory).data
expected = {
"name": signatory.name,
"title": signatory.title,
"organization": signatory.organization_name_override,
"image": signatory.image.url,
}
self.assertEqual(actual, expected)

def test_create_signatory_with_missing_image(self):
signatory = SignatoryFactory(image=None)
actual = SignatorySerializer(signatory).data
expected = {
"name": signatory.name,
"title": signatory.title,
"organization": signatory.organization_name_override,
"image": None,
}
self.assertEqual(actual, expected)

def test_validation(self):
png_image = create_image("png")
data = {"image": png_image, "name": "signatory 1", "organization": "edX", "title": "title"}
actual = SignatorySerializer(data=data).is_valid()
self.assertTrue(actual)

def test_validation_with_wrong_image_extension(self):
jpg_image = create_image("jpg")
data = {"image": jpg_image, "name": "signatory 1", "organization": "edX", "title": "title"}
actual = SignatorySerializer(data=data).is_valid()

self.assertFalse(actual)


class SignatoryListFieldTests(SiteMixin, TestCase):
def test_populate_signatories(self):
png_image = create_image("png")
signatories_data = [
{
"image": "/asset-v1:edX+DemoX+Demo_Course+type@asset+block@images_course_image.png",
"name": "signatory 1",
"organization": "edX",
"title": "title",
},
{
"image": "/asset-v1:edX+DemoX+Demo_Course+type@asset+block@images_course_image+1.png",
"name": "signatory 2",
"organization": "edX",
"title": "title",
},
]
files = {
"/asset-v1:edX+DemoX+Demo_Course+type@asset+block@images_course_image.png": png_image,
"/asset-v1:edX+DemoX+Demo_Course+type@asset+block@images_course_image+1.png": png_image,
}
actual = SignatoryListField.populate_signatories(signatories_data, files)
expected = [
{"image": png_image, "name": "signatory 1", "organization": "edX", "title": "title"},
{"image": png_image, "name": "signatory 2", "organization": "edX", "title": "title"},
]

self.assertEqual(list(actual), expected)


class CourseCertificateSerializerTests(SiteMixin, TestCase):
def test_create_course_certificate(self):
course_run = CourseRunFactory()
Expand All @@ -309,6 +380,30 @@ def test_create_course_certificate(self):
"certificate_type": course_certificate.certificate_type,
"certificate_available_date": course_certificate.certificate_available_date,
"is_active": course_certificate.is_active,
"signatories": [],
"title": course_certificate.title,
}
self.assertEqual(actual, expected)

def test_create_course_certificate_with_signatories(self):
course_run = CourseRunFactory()
course_certificate = CourseCertificateFactory(site=self.site, course_run=course_run)
signatory = SignatoryFactory()
course_certificate.signatories.add(signatory)
request = APIRequestFactory(site=self.site).get("/")

actual = CourseCertificateSerializer(course_certificate, context={"request": request}).data
expected_signatories_data = [SignatorySerializer(signatory, context={"request": request}).data]
expected = {
"id": course_certificate.id,
"site": self.site.id,
"course_id": course_certificate.course_id,
"course_run": course_certificate.course_run.key,
"certificate_type": course_certificate.certificate_type,
"certificate_available_date": course_certificate.certificate_available_date,
"is_active": course_certificate.is_active,
"signatories": expected_signatories_data,
"title": course_certificate.title,
}
self.assertEqual(actual, expected)

Expand All @@ -325,19 +420,22 @@ def test_missing_course_run(self):
"certificate_type": course_certificate.certificate_type,
"certificate_available_date": course_certificate.certificate_available_date,
"is_active": course_certificate.is_active,
"signatories": [],
"title": course_certificate.title,
}
self.assertEqual(actual, expected)

def test_create_without_course_run_raises_warning(self):
# even though you can create an entry without a course run,
# we want to make sure we are logging a warning when it is missing
data = {
"course_id": "DemoCourse0",
"certificate_type": "verified",
"is_active": True,
"certificate_available_date": None,
}
with self.assertLogs(level=WARNING):
Request = namedtuple("Request", ["site"])
CourseCertificateSerializer(context={"request": Request(site=self.site)}).create(
validated_data={
"course_id": "DemoCourse0",
"certificate_type": "verified",
"is_active": True,
"certificate_available_date": None,
}
Request = namedtuple("Request", ["site", "data", "FILES"])
CourseCertificateSerializer(context={"request": Request(site=self.site, data=data, FILES=None)}).create(
validated_data=data
)
Loading