diff --git a/CHANGELOG.md b/CHANGELOG.md index 51cbc013db..e2168d9a55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/backend/joanie/core/api.py b/src/backend/joanie/core/api.py index 73e8f56494..54cef55aae 100644 --- a/src/backend/joanie/core/api.py +++ b/src/backend/joanie/core/api.py @@ -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 diff --git a/src/backend/joanie/core/factories.py b/src/backend/joanie/core/factories.py index af55ca321d..4a6ae40d26 100644 --- a/src/backend/joanie/core/factories.py +++ b/src/backend/joanie/core/factories.py @@ -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 @@ -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) diff --git a/src/backend/joanie/core/migrations/0001_initial.py b/src/backend/joanie/core/migrations/0001_initial.py index 4872672298..5d521975b8 100644 --- a/src/backend/joanie/core/migrations/0001_initial.py +++ b/src/backend/joanie/core/migrations/0001_initial.py @@ -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 @@ -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", ), ), ( diff --git a/src/backend/joanie/core/models/certifications.py b/src/backend/joanie/core/models/certifications.py index 564440f93e..2a84200d03 100644 --- a/src/backend/joanie/core/models/certifications.py +++ b/src/backend/joanie/core/models/certifications.py @@ -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): @@ -40,6 +47,9 @@ 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 @@ -47,9 +57,12 @@ class Certificate(models.Model): 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" @@ -57,4 +70,82 @@ class Meta: 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) diff --git a/src/backend/joanie/core/models/products.py b/src/backend/joanie/core/models/products.py index 14bc805955..885342bf47 100644 --- a/src/backend/joanie/core/models/products.py +++ b/src/backend/joanie/core/models/products.py @@ -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__) @@ -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): diff --git a/src/backend/joanie/core/serializers.py b/src/backend/joanie/core/serializers.py index 9cf4d3dfb8..e500e6a633 100644 --- a/src/backend/joanie/core/serializers.py +++ b/src/backend/joanie/core/serializers.py @@ -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"] diff --git a/src/backend/joanie/payment/migrations/0001_initial.py b/src/backend/joanie/payment/migrations/0001_initial.py index 5ce8f66533..3b3de2a5f9 100644 --- a/src/backend/joanie/payment/migrations/0001_initial.py +++ b/src/backend/joanie/payment/migrations/0001_initial.py @@ -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 @@ -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 = [ @@ -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( @@ -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", + ), + ), ] diff --git a/src/backend/joanie/tests/test_api_certificate.py b/src/backend/joanie/tests/test_api_certificate.py new file mode 100644 index 0000000000..70b20e36f7 --- /dev/null +++ b/src/backend/joanie/tests/test_api_certificate.py @@ -0,0 +1,215 @@ +"""Tests for the Certificate API""" +import json +import uuid +from io import BytesIO + +from pdfminer.high_level import extract_text as pdf_extract_text + +from joanie.core.enums import PRODUCT_TYPE_CERTIFICATE +from joanie.core.factories import ( + CertificateDefinitionFactory, + CertificateFactory, + OrderFactory, + ProductFactory, + UserFactory, +) +from joanie.tests.base import BaseAPITestCase + + +class CertificateApiTest(BaseAPITestCase): + """Certificate API test case.""" + + def test_api_certificate_read_list_anonymous(self): + """It should not be possible to retrieve the list of certificates for anonymous user""" + CertificateFactory.create_batch(2) + response = self.client.get("/api/certificates/") + + self.assertEqual(response.status_code, 401) + + content = json.loads(response.content) + self.assertEqual( + content, {"detail": "Authentication credentials were not provided."} + ) + + def test_api_certificate_read_list_authenticated(self): + """ + When an authenticated user retrieves the list of certificates, + it should return only theirs. + """ + CertificateFactory.create_batch(5) + user = UserFactory() + order = OrderFactory(owner=user) + certificate = CertificateFactory(order=order) + + token = self.get_user_token(user.username) + + response = self.client.get( + "/api/certificates/", HTTP_AUTHORIZATION=f"Bearer {token}" + ) + + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content) + self.assertEqual(content, [{"id": str(certificate.uid)}]) + + def test_api_certificate_read_anonymous(self): + """ + An anonymous user should be not able to retrieve a certificate + """ + certificate = CertificateFactory() + + response = self.client.get(f"/api/certificates/{certificate.uid}/") + + self.assertEqual(response.status_code, 401) + + content = json.loads(response.content) + self.assertEqual( + content, {"detail": "Authentication credentials were not provided."} + ) + + def test_api_certificate_read_authenticated(self): + """ + An authenticated user should be able to retrieve a certificate only if it owns. + """ + not_owned_certificate = CertificateFactory() + user = UserFactory() + order = OrderFactory(owner=user) + certificate = CertificateFactory(order=order) + + token = self.get_user_token(user.username) + + # - Try to retrieve a not owned certificate should return a 404 + response = self.client.get( + f"/api/certificates/{not_owned_certificate.uid}/", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + self.assertEqual(response.status_code, 404) + + content = json.loads(response.content) + self.assertEqual(content, {"detail": "Not found."}) + + # - Try to retrieve an owned certificate should return the certificate id + response = self.client.get( + f"/api/certificates/{certificate.uid}/", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content) + self.assertEqual(content, {"id": str(certificate.uid)}) + + def test_api_certificate_download_anonymous(self): + """ + An anonymous user should not be able to download a certificate. + """ + certificate = CertificateFactory() + + response = self.client.get(f"/api/certificates/{certificate.uid}/download/") + + self.assertEqual(response.status_code, 401) + + content = json.loads(response.content) + self.assertEqual( + content, {"detail": "Authentication credentials were not provided."} + ) + + def test_api_certificate_download_authenticated(self): + """ + An authenticated user should be able to download a certificate only if it owns. + """ + not_owned_certificate = CertificateFactory() + user = UserFactory() + certificate_definition = CertificateDefinitionFactory() + product = ProductFactory( + title="Graded product", + type=PRODUCT_TYPE_CERTIFICATE, + certificate_definition=certificate_definition, + ) + order = OrderFactory( + owner=user, product=product, course=product.courses.first() + ) + certificate = CertificateFactory(order=order) + + token = self.get_user_token(user.username) + + # - Try to retrieve a not owned certificate should return a 404 + response = self.client.get( + f"/api/certificates/{not_owned_certificate.uid}/download/", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + self.assertEqual(response.status_code, 404) + + content = json.loads(response.content) + self.assertEqual( + content, + {"detail": f"No certificate found with uid {not_owned_certificate.uid}."}, + ) + + # - Try to retrieve an owned certificate should return the certificate id + response = self.client.get( + f"/api/certificates/{certificate.uid}/download/", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers["Content-Type"], "application/pdf") + self.assertEqual( + response.headers["Content-Disposition"], + f"attachment; filename={certificate.uid}.pdf;", + ) + + document_text = pdf_extract_text(BytesIO(response.content)).replace("\n", "") + self.assertRegex(document_text, r"CERTIFICATE") + + def test_api_certificate_create(self): + """ + Create a certificate should be not allowed even if user is admin + """ + user = UserFactory(is_staff=True, is_superuser=True) + token = self.get_user_token(user.username) + response = self.client.post( + "/api/certificates/", + {"id": uuid.uuid4()}, + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + self.assertEqual(response.status_code, 405) + + content = json.loads(response.content) + self.assertEqual(content, {"detail": 'Method "POST" not allowed.'}) + + def test_api_certificate_update(self): + """ + Update a certificate should be not allowed even if user is admin + """ + user = UserFactory(is_staff=True, is_superuser=True) + token = self.get_user_token(user.username) + certificate = CertificateFactory() + response = self.client.put( + f"/api/certificates/{certificate.uid}/", + {"id": uuid.uuid4()}, + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + self.assertEqual(response.status_code, 405) + + content = json.loads(response.content) + self.assertEqual(content, {"detail": 'Method "PUT" not allowed.'}) + + def test_api_certificate_delete(self): + """ + Delete a certificate should be not allowed even if user is admin + """ + user = UserFactory(is_staff=True, is_superuser=True) + token = self.get_user_token(user.username) + certificate = CertificateFactory() + response = self.client.delete( + f"/api/certificates/{certificate.uid}/", + {"id": uuid.uuid4()}, + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + self.assertEqual(response.status_code, 405) + + content = json.loads(response.content) + self.assertEqual(content, {"detail": 'Method "DELETE" not allowed.'}) diff --git a/src/backend/joanie/tests/test_models_certificate.py b/src/backend/joanie/tests/test_models_certificate.py new file mode 100644 index 0000000000..d303050f24 --- /dev/null +++ b/src/backend/joanie/tests/test_models_certificate.py @@ -0,0 +1,107 @@ +"""Test suite for Certificate Model""" +from io import BytesIO + +from django.conf import settings +from django.test import TestCase + +from parler.utils.context import switch_language +from pdfminer.high_level import extract_text as pdf_extract_text + +from joanie.core.enums import PRODUCT_TYPE_CERTIFICATE +from joanie.core.factories import ( + CertificateDefinitionFactory, + CertificateFactory, + CourseFactory, + OrderFactory, + OrganizationFactory, + ProductFactory, +) + + +class CertificateModelTestCase(TestCase): + """Certificate model test case.""" + + def test_models_certificate_localized_context(self): + """ + When a certificate is created, localized contexts in each enabled languages + should be created. + """ + certificate = CertificateFactory() + languages = settings.LANGUAGES + + self.assertEqual(len(list(certificate.localized_context)), len(languages)) + + def test_models_certificate_get_document_context(self): + """ + We should get the document context in the provided language. If the translation + does not exist, we should gracefully fallback to the default language defined + through parler settings ("en-us" in our case). + """ + organization = OrganizationFactory(title="Organization 1") + course = CourseFactory(organization=organization) + product = ProductFactory(title="Graded product", courses=[course]) + + # - Add French translations + organization.translations.create(language_code="fr-fr", title="Établissement 1") + product.translations.create(language_code="fr-fr", title="Produit certifiant") + + order = OrderFactory(product=product) + certificate = CertificateFactory(order=order) + + context = certificate.get_document_context("en-us") + self.assertEqual(context["course"]["name"], "Graded product") + self.assertEqual(context["course"]["organization"]["name"], "Organization 1") + + context = certificate.get_document_context("fr-fr") + self.assertEqual(context["course"]["name"], "Produit certifiant") + self.assertEqual(context["course"]["organization"]["name"], "Établissement 1") + + # When translation for the given language does not exist, + # we should get the fallback language translation. + context = certificate.get_document_context("de-de") + self.assertEqual(context["course"]["name"], "Graded product") + self.assertEqual(context["course"]["organization"]["name"], "Organization 1") + + def test_models_certificate_document(self): + """ + Certificate document property should generate a document + into the active language. + """ + organization = OrganizationFactory( + title="University X", representative="Joanie Cunningham" + ) + course = CourseFactory(organization=organization) + certificate_definition = CertificateDefinitionFactory() + product = ProductFactory( + title="Graded product", + courses=[course], + type=PRODUCT_TYPE_CERTIFICATE, + certificate_definition=certificate_definition, + ) + + # - Add French translations + organization.translations.create(language_code="fr-fr", title="Université X") + product.translations.create(language_code="fr-fr", title="Produit certifiant") + + order = OrderFactory(product=product) + certificate = CertificateFactory(order=order) + + document_text = pdf_extract_text(BytesIO(certificate.document)).replace( + "\n", "" + ) + self.assertRegex( + document_text, r"Joanie Cunningham.*University X.*Graded product" + ) + + with switch_language(product, "fr-fr"): + document_text = pdf_extract_text(BytesIO(certificate.document)).replace( + "\n", "" + ) + self.assertRegex(document_text, r"Joanie Cunningham.*Université X") + + with switch_language(product, "de-de"): + # - Finally, unknown language should use the default language as fallback + document_text = pdf_extract_text(BytesIO(certificate.document)).replace( + "\n", "" + ) + self.assertRegex(document_text, r"Joanie Cunningham.*University X") diff --git a/src/backend/joanie/tests/test_models_product.py b/src/backend/joanie/tests/test_models_product.py index 89bf49a99b..03eb79d424 100644 --- a/src/backend/joanie/tests/test_models_product.py +++ b/src/backend/joanie/tests/test_models_product.py @@ -7,10 +7,10 @@ from django.test import TestCase from djmoney.money import Money -from marion.models import DocumentRequest from moneyed import EUR from joanie.core import factories, models +from joanie.core.enums import PRODUCT_TYPE_CERTIFICATE class ProductModelsTestCase(TestCase): @@ -47,32 +47,30 @@ def test_models_product_course_runs_relation_sorted_by_position(self): ordered_courses = list(product.target_courses.order_by("product_relations")) self.assertEqual(ordered_courses, expected_courses) - def test_model_order_generate_certificate(self): + def test_model_order_create_certificate(self): """Generate a certificate for a product order""" course = factories.CourseFactory() product = factories.ProductFactory( courses=[course], + type=PRODUCT_TYPE_CERTIFICATE, certificate_definition=factories.CertificateDefinitionFactory(), ) order = factories.OrderFactory(product=product) - certificate = order.generate_certificate() - self.assertEqual(DocumentRequest.objects.count(), 1) - document_request = DocumentRequest.objects.get() + order.create_certificate() + self.assertEqual(models.Certificate.objects.count(), 1) + certificate = models.Certificate.objects.first() + document_context = certificate.get_document_context() blue_square_base64 = ( "data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgY" "PgPAAEDAQAIicLsAAAAAElFTkSuQmCC" ) self.assertEqual( - document_request.context["course"]["organization"]["logo"], + document_context["course"]["organization"]["logo"], blue_square_base64, ) self.assertEqual( - document_request.context["course"]["organization"]["signature"], + document_context["course"]["organization"]["signature"], blue_square_base64, ) - self.assertEqual( - certificate.attachment.name, - f"{DocumentRequest.objects.get().document_id}.pdf", - ) diff --git a/src/backend/joanie/urls.py b/src/backend/joanie/urls.py index 156c3aa11a..15d2e87a5c 100644 --- a/src/backend/joanie/urls.py +++ b/src/backend/joanie/urls.py @@ -26,6 +26,7 @@ router = DefaultRouter() router.register("addresses", api.AddressViewSet, basename="addresses") +router.register("certificates", api.CertificateViewSet, basename="certificates") router.register("courses", api.CourseViewSet, basename="courses") router.register("enrollments", api.EnrollmentViewSet, basename="enrollments") router.register("orders", api.OrderViewSet, basename="orders") @@ -33,7 +34,6 @@ urlpatterns = [ path("admin/", admin.site.urls), path("api/", include([*router.urls, *lms_urlpatterns, *payment_urlpatterns])), - path("api/documents/", include("marion.urls")), ] if settings.DEBUG: