Skip to content

Commit

Permalink
✨(certificate) add api to retrieve certificate
Browse files Browse the repository at this point in the history
Refactor Certificate model to generate document on demand then add an api
endpoint to list and get certificate.

#75
  • Loading branch information
jbpenrath committed Jan 25, 2022
1 parent c7059f9 commit ee7da32
Show file tree
Hide file tree
Showing 12 changed files with 553 additions and 63 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0),
and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [Unreleased]

### Added

- Add an api endpoint to retrieve and download certificates
- Add a computed "state" to Course and CourseRun models
- Add Payplug payment backend and related API routes
- Add Invoice and Transaction models for accounting purposes
Expand Down
49 changes: 49 additions & 0 deletions src/backend/joanie/core/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,3 +308,52 @@ def perform_create(self, serializer):
"""Create a new address for user authenticated"""
user = models.User.objects.get_or_create(username=self.request.user.username)[0]
serializer.save(owner=user)


class CertificateViewSet(
mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
"""
API views to get all certificates for a user
GET /api/certificates/:certificate_uid
Return list of all certificates for a user or one certificate if an uid is
provided.
GET /api/certificates/:certificate_uid/download
Return the certificate document in PDF format.
"""

lookup_field = "uid"
serializer_class = serializers.CertificateSerializer
permission_classes = [permissions.IsAuthenticated]

def get_queryset(self):
"""
Custom queryset to get user certificates
"""
user = models.User.objects.get_or_create(username=self.request.user.username)[0]
return models.Certificate.objects.filter(order__owner=user)

@action(detail=True, methods=["GET"])
def download(self, request, uid=None): # pylint: disable=no-self-use
"""
Retrieve a certificate through its uid if it is owned by the authenticated user.
"""
try:
certificate = models.Certificate.objects.get(
uid=uid,
order__owner__username=request.user.username,
)
except models.Certificate.DoesNotExist:
return Response(
{"detail": f"No certificate found with uid {uid}."}, status=404
)

response = HttpResponse(
certificate.document, content_type="application/pdf", status=200
)

response["Content-Disposition"] = f"attachment; filename={uid}.pdf;"

return response
10 changes: 9 additions & 1 deletion src/backend/joanie/core/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
from django.contrib.auth.hashers import make_password
from django.utils import timezone

import factory
import factory.fuzzy
import pytz
from djmoney.money import Money
Expand Down Expand Up @@ -283,3 +282,12 @@ class Meta:
first_name = factory.Faker("first_name")
last_name = factory.Faker("last_name")
owner = factory.SubFactory(UserFactory)


class CertificateFactory(factory.django.DjangoModelFactory):
"""A factory to create a certificate"""

class Meta:
model = models.Certificate

order = factory.SubFactory(OrderFactory)
18 changes: 13 additions & 5 deletions src/backend/joanie/core/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Generated by Django 3.2.9 on 2021-12-15 08:57
# Generated by Django 3.2.11 on 2022-01-21 08:59

import uuid
from decimal import Decimal
Expand Down Expand Up @@ -918,13 +918,21 @@ class Migration(migrations.Migration):
),
),
(
"attachment",
models.FileField(upload_to="", verbose_name="attachment"),
"uid",
models.UUIDField(
db_index=True, default=uuid.uuid4, editable=False, unique=True
),
),
(
"issued_on",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="issued on date"
models.DateTimeField(auto_now=True, verbose_name="issued on date"),
),
(
"localized_context",
models.JSONField(
editable=False,
help_text="Localized data that needs to be frozen on certificate creation",
verbose_name="context",
),
),
(
Expand Down
101 changes: 96 additions & 5 deletions src/backend/joanie/core/models/certifications.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
"""
Declare and configure the models for the certifications part
"""
import uuid

from django.conf import settings
from django.db import models
from django.utils import timezone
from django.utils.module_loading import import_string
from django.utils.translation import get_language
from django.utils.translation import gettext_lazy as _

from parler import models as parler_models
from parler.utils import get_language_settings

from joanie.core.utils import image_to_base64, merge_dict


class CertificateDefinition(parler_models.TranslatableModel):
Expand Down Expand Up @@ -40,21 +47,105 @@ class Certificate(models.Model):
Certificate represents and records all user certificates issued as part of an order
"""

uid = models.UUIDField(
default=uuid.uuid4, unique=True, editable=False, db_index=True
)
order = models.OneToOneField(
# disable=all is necessary to avoid an AstroidImportError because of our models structure
# Astroid is looking for a module models.py that does not exist
"core.Order", # pylint: disable=all
verbose_name=_("order"),
on_delete=models.PROTECT,
)
# attachment pdf will be generated with marion from certificate definition
attachment = models.FileField(_("attachment"))
issued_on = models.DateTimeField(_("issued on date"), default=timezone.now)
issued_on = models.DateTimeField(_("issued on date"), auto_now=True, editable=False)
localized_context = models.JSONField(
_("context"),
help_text=_("Localized data that needs to be frozen on certificate creation"),
editable=False,
)

class Meta:
db_table = "joanie_certificate"
verbose_name = _("Certificate")
verbose_name_plural = _("Certificates")

def __str__(self):
return f"Certificate for {self.order.owner}"
return f"{self.order.owner}'s certificate for course {self.order.course}"

@property
def document(self):
"""
Get the document related to the certificate instance.
"""
certificate_definition = self.order.product.certificate_definition
document_issuer = import_string(certificate_definition.template)
context = self.get_document_context()
document = document_issuer(identifier=self.uid, context_query=context)
return document.create(persist=False)

def _set_localized_context(self):
"""
Update or create the certificate context for all languages.
Saving is left to the caller.
"""
context = {}
related_product = self.order.product
organization = self.order.course.organization

for language, __ in settings.LANGUAGES:
context[language] = {
"course": {
"name": related_product.safe_translation_getter(
"title", language_code=language
),
"organization": {
"name": organization.safe_translation_getter(
"title", language_code=language
),
},
}
}

self.localized_context = context

def get_document_context(self, language_code=None):
"""
Build the certificate document context for the given language.
If no language_code is provided, we use the active language.
"""

language_settings = get_language_settings(language_code or get_language())
organization = self.order.course.organization
owner = self.order.owner

base_context = {
"creation_date": self.issued_on.isoformat(),
"student": {
"name": owner.get_full_name() or owner.username,
},
"course": {
"organization": {
"representative": organization.representative,
"signature": image_to_base64(organization.signature),
"logo": image_to_base64(organization.logo),
},
},
}

try:
localized_context = self.localized_context[language_settings["code"]]
except KeyError:
# - Otherwise use the first entry of the localized context
localized_context = list(self.localized_context.values())[0]

return merge_dict(base_context, localized_context)

def save(self, *args, **kwargs):
"""On creation, create a context for each active languages"""
is_new = self.pk is None

if is_new:
self._set_localized_context()

super().save(*args, **kwargs)
58 changes: 29 additions & 29 deletions src/backend/joanie/core/models/products.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,14 @@

from djmoney.models.fields import MoneyField
from djmoney.models.validators import MinMoneyValidator
from marion.models import DocumentRequest
from parler import models as parler_models

from joanie.core.exceptions import EnrollmentError
from joanie.core.models.certifications import Certificate
from joanie.lms_handler import LMSHandler

from .. import enums, utils
from .. import enums
from . import accounts as customers_models
from . import certifications as certifications_models
from . import courses as courses_models

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -317,32 +316,33 @@ def cancel(self):
self.is_canceled = True
self.save()

def generate_certificate(self):
"""Generate a pdf certificate for the order's owner"""
organization = self.course.organization
context_query = {
"student": {
"name": self.owner.get_full_name(),
},
"course": {
"name": self.product.title, # pylint: disable=no-member
"organization": {
"name": organization.title,
"representative": organization.representative,
"signature": utils.image_to_base64(organization.signature),
"logo": utils.image_to_base64(organization.logo),
},
},
}
document_request = DocumentRequest.objects.create(
issuer=self.product.certificate_definition.template, # pylint: disable=no-member
context_query=context_query,
)
certificate, _ = certifications_models.Certificate.objects.update_or_create(
order=self,
defaults={"attachment": document_request.get_document_path().name},
)
return certificate
def create_certificate(self):
"""
Create a certificate if the related product type is certifying and if one
has not been already created.
"""
if self.product.type not in PRODUCT_TYPE_CERTIFICATE_ALLOWED:
raise ValidationError(
_(
(
"Try to generate a certificate for "
f"a non-certifying product ({self.product})."
)
)
)

if Certificate.objects.filter(order=self).exists():
raise ValidationError(
_(
(
"A certificate has been already issued for " # pylint: disable=no-member
f"the order {self.uid} "
f"on {self.certificate.issued_on}."
)
)
)

Certificate.objects.create(order=self)


class OrderCourseRelation(models.Model):
Expand Down
13 changes: 13 additions & 0 deletions src/backend/joanie/core/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -451,3 +451,16 @@ class Meta:
read_only_fields = [
"id",
]


class CertificateSerializer(serializers.ModelSerializer):
"""
Certificate model serializer
"""

id = serializers.CharField(source="uid", read_only=True, required=False)

class Meta:
model = models.Certificate
fields = ["id"]
read_only_fields = ["id"]
20 changes: 10 additions & 10 deletions src/backend/joanie/payment/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Generated by Django 3.2.11 on 2022-01-07 12:24
# Generated by Django 3.2.11 on 2022-01-21 08:59

import uuid

Expand All @@ -15,8 +15,8 @@ class Migration(migrations.Migration):
initial = True

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("core", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
Expand Down Expand Up @@ -234,14 +234,6 @@ class Migration(migrations.Migration):
"db_table": "joanie_credit_card",
},
),
migrations.AddConstraint(
model_name="creditcard",
constraint=models.UniqueConstraint(
condition=models.Q(("is_main", True)),
fields=("owner",),
name="unique_main_credit_card_per_user",
),
),
migrations.AddConstraint(
model_name="invoice",
constraint=models.CheckConstraint(
Expand All @@ -261,4 +253,12 @@ class Migration(migrations.Migration):
name="only_one_invoice_without_parent_per_order",
),
),
migrations.AddConstraint(
model_name="creditcard",
constraint=models.UniqueConstraint(
condition=models.Q(("is_main", True)),
fields=("owner",),
name="unique_main_credit_card_per_user",
),
),
]
Loading

0 comments on commit ee7da32

Please sign in to comment.