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

🐛(backend) generate certificates with product type certificate #1065

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,14 @@ and this project adheres to

### Changed

- Remove `generate_certificates` action in django admin views
- Remove `owner` and `is_main` in CreditCard model permanently
- Remove `is_active` on order group client serializer

## Fixed

- BO: generate certificates for orders with product of type certificate

## [2.16.0] - 2025-02-13

### Added
Expand Down
112 changes: 5 additions & 107 deletions src/backend/joanie/core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,18 @@
from django.contrib.admin.options import csrf_protect_m
from django.contrib.auth import admin as auth_admin
from django.contrib.sites.models import Site
from django.http import HttpResponseRedirect
from django.urls import re_path, reverse
from django.urls import reverse
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext_lazy

from admin_auto_filters.filters import AutocompleteFilter
from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin
from django_object_actions import DjangoObjectActions, takes_instance_or_queryset
from django_object_actions import DjangoObjectActions
from parler.admin import TranslatableAdmin, TranslatableStackedInline

from joanie.core import forms, helpers, models
from joanie.core.enums import PRODUCT_TYPE_CERTIFICATE_ALLOWED
from joanie.core import forms, models

ACTION_NAME_GENERATE_CERTIFICATES = "generate_certificates"
ACTION_NAME_CANCEL = "cancel"


Expand Down Expand Up @@ -275,9 +272,7 @@ class CourseCourseRunsInline(admin.TabularInline):
class CourseAdmin(DjangoObjectActions, TranslatableAdmin):
"""Admin class for the Course model"""

actions = (ACTION_NAME_GENERATE_CERTIFICATES,)
autocomplete_fields = ["organizations"]
change_actions = (ACTION_NAME_GENERATE_CERTIFICATES,)
change_form_template = "joanie/admin/translatable_change_form_with_actions.html"
list_display = ("code", "title", "state")
readonly_fields = ("course_runs",)
Expand Down Expand Up @@ -305,18 +300,6 @@ class CourseAdmin(DjangoObjectActions, TranslatableAdmin):
)
search_fields = ["code", "translations__title"]

@takes_instance_or_queryset
def generate_certificates(self, request, queryset): # pylint: disable no-self-use
"""
Custom action to generate certificates for a collection of courses
passed as a queryset
"""
certificate_generated_count = helpers.generate_certificates_for_orders(
models.Order.objects.filter(course__in=queryset)
)

summarize_certification_to_user(request, certificate_generated_count)


@admin.register(models.CourseRun)
class CourseRunAdmin(TranslatableAdmin):
Expand Down Expand Up @@ -479,67 +462,8 @@ class ProductAdmin(
"id",
"related_courses",
)
actions = (ACTION_NAME_GENERATE_CERTIFICATES,)
change_actions = (ACTION_NAME_GENERATE_CERTIFICATES,)
search_fields = ["translations__title"]

def get_change_actions(self, request, object_id, form_url):
"""
Remove the generate_certificates action from list of actions
if the product instance is not certifying
"""
actions = super().get_change_actions(request, object_id, form_url)
actions = list(actions)

if not self.model.objects.filter(
pk=object_id, type__in=PRODUCT_TYPE_CERTIFICATE_ALLOWED
).exists():
actions.remove(ACTION_NAME_GENERATE_CERTIFICATES)

return actions

def get_urls(self):
"""
Add url to trigger certificate generation for a course - product couple.
"""
url_patterns = super().get_urls()

return [
re_path(
r"^(?P<product_id>.+)/generate-certificates/(?P<course_code>.+)/$",
self.admin_site.admin_view(self.generate_certificates_for_course),
name=ACTION_NAME_GENERATE_CERTIFICATES,
)
] + url_patterns

@takes_instance_or_queryset
def generate_certificates(self, request, queryset): # pylint: disable=no-self-use
"""
Custom action to generate certificates for a collection of products
passed as a queryset
"""
certificate_generated_count = helpers.generate_certificates_for_orders(
models.Order.objects.filter(product__in=queryset)
)

summarize_certification_to_user(request, certificate_generated_count)

def generate_certificates_for_course(self, request, product_id, course_code): # pylint: disable=no-self-use
"""
A custom action to generate certificates for a course - product couple.
"""
certificate_generated_count = helpers.generate_certificates_for_orders(
models.Order.objects.filter(
product__id=product_id, course__code=course_code
)
)

summarize_certification_to_user(request, certificate_generated_count)

return HttpResponseRedirect(
reverse("admin:core_product_change", args=(product_id,))
)

@admin.display(description="Related courses")
def related_courses(self, obj): # pylint: disable=no-self-use
"""
Expand All @@ -553,7 +477,6 @@ def get_related_courses_as_html(obj): # pylint: disable=no-self-use
Get the html representation of the product's related courses
"""
related_courses = obj.courses.all()
is_certifying = obj.type in PRODUCT_TYPE_CERTIFICATE_ALLOWED

if related_courses:
items = []
Expand All @@ -562,26 +485,11 @@ def get_related_courses_as_html(obj): # pylint: disable=no-self-use
"admin:core_course_change",
args=(course.id,),
)

raw_html = (
'<li style="margin-bottom: 1rem">'
f"<a href='{change_course_url}'>{course.code} | {course.title}</a>"
"</li>"
)

if is_certifying:
# Add a button to generate certificate
generate_certificates_url = reverse(
f"admin:{ACTION_NAME_GENERATE_CERTIFICATES}",
kwargs={"product_id": obj.id, "course_code": course.code},
)

raw_html += (
f'<a style="margin-left: 1rem" class="button" href="{generate_certificates_url}">' # pylint: disable=line-too-long
f"{_('Generate certificates')}"
"</a>"
)

raw_html += "</li>"
items.append(raw_html)

return format_html(f"<ul style='margin: 0'>{''.join(items)}</ul>")
Expand All @@ -601,9 +509,8 @@ class DiscountAdmin(admin.ModelAdmin):
class OrderAdmin(DjangoObjectActions, admin.ModelAdmin):
"""Admin class for the Order model"""

actions = (ACTION_NAME_CANCEL, ACTION_NAME_GENERATE_CERTIFICATES)
actions = (ACTION_NAME_CANCEL,)
autocomplete_fields = ["course", "enrollment", "organization", "owner", "product"]
change_actions = (ACTION_NAME_GENERATE_CERTIFICATES,)
list_display = ("id", "created_on", "organization", "owner", "product", "state")
list_filter = [OwnerFilter, OrganizationFilter, ProductFilter, "state"]
readonly_fields = (
Expand All @@ -621,15 +528,6 @@ def cancel(self, request, queryset): # pylint: disable=no-self-use
for order in queryset:
order.flow.cancel()

@takes_instance_or_queryset
def generate_certificates(self, request, queryset): # pylint: disable=no-self-use
"""
Custom action to launch generate_certificates management commands
over the order selected
"""
certificate_generated_count = helpers.generate_certificates_for_orders(queryset)
summarize_certification_to_user(request, certificate_generated_count)

def invoice(self, obj): # pylint: disable=no-self-use
"""Retrieve the root invoice related to the order."""
invoice = obj.invoices.get(parent__isnull=True)
Expand Down
16 changes: 13 additions & 3 deletions src/backend/joanie/core/utils/course_product_relation.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
"""Utility methods to get all orders and/or certificates from a course product relation."""

from django.db.models import Q

from joanie.core.enums import ORDER_STATE_COMPLETED, PRODUCT_TYPE_CERTIFICATE_ALLOWED
from joanie.core.models import Certificate, Order


def get_orders(course_product_relation):
"""
Returns a list of all validated orders ids for a course product relation.
Returns a list of all completed orders ids for a course product relation.
"""
return [
str(order_id)
for order_id in Order.objects.filter(
course=course_product_relation.course,
Q(course=course_product_relation.course, enrollment__isnull=True)
| Q(
course__isnull=True,
enrollment__course_run__course=course_product_relation.course,
),
product=course_product_relation.product,
product__type__in=PRODUCT_TYPE_CERTIFICATE_ALLOWED,
state=ORDER_STATE_COMPLETED,
Expand All @@ -27,8 +33,12 @@ def get_generated_certificates(course_product_relation):
Return certificates that were published for a course product relation.
"""
return Certificate.objects.filter(
Q(order__course=course_product_relation.course, order__enrollment__isnull=True)
| Q(
order__course__isnull=True,
order__enrollment__course_run__course=course_product_relation.course,
),
order__product=course_product_relation.product,
order__course=course_product_relation.course,
order__certificate__isnull=False,
order__state=ORDER_STATE_COMPLETED,
)
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from django.utils import timezone

from joanie.core import enums, factories
from joanie.core.models import Certificate, CourseProductRelation
from joanie.core.models import Certificate, CourseProductRelation, CourseState
from joanie.lms_handler.backends.dummy import DummyLMSBackend


Expand Down Expand Up @@ -683,3 +683,75 @@ def test_api_admin_course_product_relation_check_certificates_generation_complet
# Verify that certificates were generated
for order in orders:
self.assertTrue(Certificate.objects.filter(order=order).exists())

@mock.patch("joanie.core.api.admin.generate_certificates_task")
@mock.patch(
"joanie.lms_handler.backends.dummy.DummyLMSBackend.get_grades",
return_value={"passed": True},
)
def test_api_admin_course_product_relation_generate_certificate_product_certificate(
self, _mock_get_grades, mock_generate_certificates_task
):
"""
Authenticated admin user should be able to generate certificate for order with products
of type certificate. The task should be called with the orders that are eligible to get
their certificate generated.
"""
admin = factories.UserFactory(is_staff=True, is_superuser=True)
self.client.login(username=admin.username, password="password")

course_run = factories.CourseRunFactory(
is_listed=True, state=CourseState.ONGOING_OPEN
)
enrollments = factories.EnrollmentFactory.create_batch(7, course_run=course_run)
product = factories.ProductFactory(
price=0,
type=enums.PRODUCT_TYPE_CERTIFICATE,
)
relation = factories.CourseProductRelationFactory(
product=product, course=enrollments[0].course_run.course
)

# Create 3 orders where the certificate will be generated
order_ids = []
for enrollment in enrollments[:3]:
order_ids.append(
factories.OrderFactory(
product=relation.product,
enrollment=enrollment,
course=None,
state=enums.ORDER_STATE_COMPLETED,
).id
)
# Create 4 orders where the certificate has been generated
for enrollment in enrollments[3:]:
factories.OrderCertificateFactory(
order=factories.OrderFactory(
product=relation.product,
course=None,
enrollment=enrollment,
state=enums.ORDER_STATE_COMPLETED,
)
)

response = self.client.post(
f"/api/v1.0/admin/course-product-relations/{relation.id}/generate_certificates/",
content_type="application/json",
)

self.assertEqual(response.status_code, HTTPStatus.CREATED)
self.assertDictEqual(
response.json(),
{
"course_product_relation_id": str(relation.id),
"count_certificate_to_generate": 3,
"count_exist_before_generation": 4,
},
)
self.assertTrue(mock_generate_certificates_task.delay.called)
self.assertTrue(
mock_generate_certificates_task.delay.called_with(
order_ids=order_ids,
cache_key=f"celery_certificate_generation_{relation.id}",
)
)
7 changes: 0 additions & 7 deletions src/backend/joanie/tests/core/test_admin_course.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,3 @@ def test_admin_course_use_translatable_change_form_with_actions_template(self):
# Django parler tabs should be displayed
parler_tabs = html.cssselect(".parler-language-tabs span")
self.assertEqual(len(parler_tabs), len(settings.LANGUAGES))

# Django object actions should be displayed
object_actions = html.cssselect(".objectaction-item")
self.assertEqual(len(object_actions), 1)
self.assertEqual(
object_actions[0].attrib["data-tool-name"], "generate_certificates"
)
Loading