diff --git a/.readthedocs.yml b/.readthedocs.yml index e12ef61..fd65564 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -15,10 +15,16 @@ build: python: "3.11" apt_packages: - graphviz - -python: - install: - - requirements: requirements/doc.txt + jobs: + pre_create_environment: + - asdf plugin add uv + - asdf install uv latest + - asdf global uv latest + create_environment: + - uv venv "${READTHEDOCS_VIRTUALENV_PATH}" + install: + - UV_PROJECT_ENVIRONMENT="${READTHEDOCS_VIRTUALENV_PATH}" uv sync --frozen --group doc + - UV_PROJECT_ENVIRONMENT="${READTHEDOCS_VIRTUALENV_PATH}" uv pip install --no-deps -e . formats: - epub diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f89e1db..08ffb95 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,39 @@ Unreleased * +0.4.1 - 2025-12-31 +****************** + +Added +===== + +* New ``text_elements`` format for PDF credential generation with flexible text positioning and placeholder support. +* Support for custom text elements with ``{name}``, ``{context_name}``, and ``{issue_date}`` placeholders. +* Global ``defaults`` configuration for font, color, and character spacing. + +Modified +======== + +* Migrated generator options from flat format (``name_y``, ``context_name_color``, etc.) to structured ``text_elements`` format. + +0.3.1 - 2025-12-15 +****************** + +Added +===== + +* Support for defining the course name using the ``cert_name_long`` field (in Studio's Advanced Settings). +* Support for specifying individual fonts for PDF text elements. +* Support for \n in learning context names in PDF certificates. +* Options for uppercase name and issue date in PDF certificates. +* Option for defining character spacing for issue date in PDF certificates. +* Option for defining the horizontal offset of the issue date from its centered position (``issue_date_x``). + +Modified +======== + +* Replaced ``template_two_lines`` with ``template_multiline``. + 0.3.0 - 2025-09-17 ****************** diff --git a/docs/how-tos/configure-pdf-credentials.rst b/docs/how-tos/configure-pdf-credentials.rst new file mode 100644 index 0000000..f85e174 --- /dev/null +++ b/docs/how-tos/configure-pdf-credentials.rst @@ -0,0 +1,347 @@ +Configure PDF Credential Generation +#################################### + +This guide explains how to configure the PDF credential generator using the ``text_elements`` format. + +Overview +******** + +The PDF credential generator renders text elements onto a PDF template. Three standard elements +are rendered by default: + +- **name**: The learner's name +- **context**: The course or Learning Path name +- **date**: The issue date + +Each element can be customized, hidden, or supplemented with custom text elements. + +Basic Configuration +******************* + +A minimal configuration only requires a template: + +.. code-block:: json + + { + "template": "certificate-template" + } + +This renders all three standard elements with default positioning. + +Configuration Options +********************* + +Top-Level Options +================= + +.. list-table:: + :header-rows: 1 + :widths: 20 15 65 + + * - Option + - Required + - Description + * - ``template`` + - Yes + - The slug of the PDF template asset (from CredentialAsset). + * - ``template_multiline`` + - No + - Alternative template for multiline context names (when using ``\n``). + * - ``defaults`` + - No + - Global defaults for all text elements (see below). + * - ``text_elements`` + - No + - Configuration for individual text elements (see below). + +Global Defaults +=============== + +The ``defaults`` object sets default values for all text elements: + +.. code-block:: json + + { + "defaults": { + "font": "CustomFont", + "color": "#333333", + "size": 14, + "char_space": 0.5, + "uppercase": false, + "line_height": 1.2 + } + } + +.. list-table:: + :header-rows: 1 + :widths: 20 15 65 + + * - Property + - Default + - Description + * - ``font`` + - Helvetica + - Font name (must be a CredentialAsset slug for custom fonts). + * - ``color`` + - #000 + - Hex color code (3 or 6 characters, with or without ``#``). + * - ``size`` + - 12 + - Font size in points. + * - ``char_space`` + - 0 + - Character spacing in points. + * - ``uppercase`` + - false + - Convert text to uppercase. + * - ``line_height`` + - 1.1 + - Line height multiplier for multiline text. + +Text Elements +============= + +The ``text_elements`` object configures individual elements. Standard elements (``name``, +``context``, ``date``) have defaults and can be partially overridden: + +.. code-block:: json + + { + "text_elements": { + "name": {"y": 300, "uppercase": true}, + "context": {"size": 24}, + "date": {"color": "#666666"} + } + } + +Element Properties +------------------ + +.. list-table:: + :header-rows: 1 + :widths: 20 15 65 + + * - Property + - Default + - Description + * - ``text`` + - varies + - Text content with placeholder substitution. + * - ``y`` + - varies + - Vertical position (PDF coordinates from bottom). + * - ``size`` + - (inherited) + - Font size in points (inherited from ``defaults.size``). + * - ``font`` + - (inherited) + - Font name (inherited from ``defaults.font``). + * - ``color`` + - (inherited) + - Hex color code (inherited from ``defaults.color``). + * - ``char_space`` + - (inherited) + - Character spacing (inherited from ``defaults.char_space``). + * - ``uppercase`` + - (inherited) + - Convert text to uppercase (inherited from ``defaults.uppercase``). + * - ``line_height`` + - (inherited) + - Line height multiplier for multiline text (inherited from ``defaults.line_height``). + +Standard Element Defaults +------------------------- + +.. list-table:: + :header-rows: 1 + :widths: 15 25 10 10 + + * - Element + - Text + - Y + - Size + * - ``name`` + - ``{name}`` + - 290 + - 32 + * - ``context`` + - ``{context_name}`` + - 220 + - 28 + * - ``date`` + - ``{issue_date}`` + - 120 + - 12 + +Django Settings +--------------- + +Some element defaults can be overridden globally via Django settings: + +.. list-table:: + :header-rows: 1 + :widths: 45 15 40 + + * - Setting + - Default + - Description + * - ``LEARNING_CREDENTIALS_NAME_UPPERCASE`` + - ``False`` + - Convert name to uppercase by default. + * - ``LEARNING_CREDENTIALS_DATE_UPPERCASE`` + - ``False`` + - Convert date to uppercase by default. + * - ``LEARNING_CREDENTIALS_DATE_CHAR_SPACE`` + - ``0`` + - Default character spacing for date element. + +These settings are applied before per-credential configuration, allowing you to set +organization-wide defaults while still permitting overrides in individual credentials. + +Placeholders +============ + +Text content supports placeholder substitution using ``{placeholder}`` syntax: + +- ``{name}`` - The learner's display name +- ``{context_name}`` - The course or Learning Path name +- ``{issue_date}`` - The localized issue date + +To include literal braces, use ``{{`` and ``}}``: + +.. code-block:: json + + { + "text": "Score: {{95%}}" + } + +Hiding Elements +=============== + +Standard elements can be hidden by setting their configuration to ``false``: + +.. code-block:: json + + { + "text_elements": { + "date": false + } + } + +Custom Elements +=============== + +Add custom text elements by using any key other than ``name``, ``context``, or ``date``. +Custom elements require both ``text`` and ``y`` properties: + +.. code-block:: json + + { + "text_elements": { + "award_line": { + "text": "Awarded on {issue_date}", + "y": 140, + "size": 14 + }, + "institution": { + "text": "Example University", + "y": 80, + "size": 10, + "color": "#666666" + } + } + } + +Complete Example +**************** + +.. code-block:: json + + { + "template": "certificate-template", + "template_multiline": "certificate-multiline", + "defaults": { + "font": "OpenSans", + "color": "#333333" + }, + "text_elements": { + "name": { + "y": 300, + "size": 36, + "uppercase": true + }, + "context": { + "text": "Custom Course Name", + "y": 230, + "size": 24 + }, + "date": false, + "award_line": { + "text": "Awarded on {issue_date}", + "y": 150, + "size": 12, + "color": "#666666" + } + } + } + +This configuration: + +1. Uses ``OpenSans`` font and ``#333333`` color for all elements +2. Renders the name at y=300, size 36, in uppercase +3. Renders the context with custom text at y=230, size 24 +4. Hides the default date element +5. Adds a custom "Awarded on [date]" line at y=150 + +Migration from Legacy Format +**************************** + +The legacy flat format (``name_y``, ``context_name_color``, etc.) has been migrated to the +new ``text_elements`` format. Existing configurations were automatically converted by +migration ``0007_migrate_to_text_elements_format``. + +Legacy to New Format Mapping +============================ + +.. list-table:: + :header-rows: 1 + :widths: 40 60 + + * - Legacy Option + - New Location + * - ``font`` + - ``defaults.font`` + * - ``context_name`` + - ``text_elements.context.text`` + * - ``name_y`` + - ``text_elements.name.y`` + * - ``name_color`` + - ``text_elements.name.color`` + * - ``name_size`` + - ``text_elements.name.size`` + * - ``name_font`` + - ``text_elements.name.font`` + * - ``name_uppercase`` + - ``text_elements.name.uppercase`` + * - ``context_name_y`` + - ``text_elements.context.y`` + * - ``context_name_color`` + - ``text_elements.context.color`` + * - ``context_name_size`` + - ``text_elements.context.size`` + * - ``context_name_font`` + - ``text_elements.context.font`` + * - ``issue_date_y`` + - ``text_elements.date.y`` + * - ``issue_date_color`` + - ``text_elements.date.color`` + * - ``issue_date_size`` + - ``text_elements.date.size`` + * - ``issue_date_font`` + - ``text_elements.date.font`` + * - ``issue_date_char_space`` + - ``text_elements.date.char_space`` + * - ``issue_date_uppercase`` + - ``text_elements.date.uppercase`` + * - ``template_two_lines`` + - ``template_multiline`` diff --git a/docs/how-tos/index.rst b/docs/how-tos/index.rst index 5147f80..9ce5623 100644 --- a/docs/how-tos/index.rst +++ b/docs/how-tos/index.rst @@ -1,2 +1,7 @@ How-tos ####### + +.. toctree:: + :maxdepth: 2 + + configure-pdf-credentials diff --git a/learning_credentials/compat.py b/learning_credentials/compat.py index dfd7d55..40a4666 100644 --- a/learning_credentials/compat.py +++ b/learning_credentials/compat.py @@ -52,10 +52,13 @@ def get_course_grading_policy(course_id: CourseKey) -> dict: def _get_course_name(course_id: CourseKey) -> str: """Get the course name from Open edX.""" # noinspection PyUnresolvedReferences,PyPackageRequirements - from openedx.core.djangoapps.content.learning_sequences.api import get_course_outline + from openedx.core.djangoapps.content.course_overviews.api import get_course_overview_or_none - course_outline = get_course_outline(course_id) - return (course_outline and course_outline.title) or str(course_id) + name = str(course_id) + if course_overview := get_course_overview_or_none(course_id): + name = course_overview.cert_name_long or course_overview.display_name or name + + return name def _get_learning_path_name(learning_path_key: LearningPathKey) -> str: diff --git a/learning_credentials/generators.py b/learning_credentials/generators.py index 3c90a16..d0cc064 100644 --- a/learning_credentials/generators.py +++ b/learning_credentials/generators.py @@ -9,8 +9,10 @@ from __future__ import annotations +import copy import io import logging +import re import secrets from typing import TYPE_CHECKING, Any @@ -19,11 +21,12 @@ from django.core.files.storage import FileSystemStorage, default_storage from pypdf import PdfReader, PdfWriter from pypdf.constants import UserAccessPermissions -from reportlab.pdfbase import pdfmetrics -from reportlab.pdfbase.ttfonts import TTFont -from reportlab.pdfgen import canvas +from reportlab.pdfbase.pdfmetrics import FontError, FontNotFoundError, registerFont +from reportlab.pdfbase.ttfonts import TTFError, TTFont +from reportlab.pdfgen.canvas import Canvas from .compat import get_default_storage_url, get_learning_context_name, get_localized_credential_date +from .exceptions import AssetNotFoundError from .models import CredentialAsset log = logging.getLogger(__name__) @@ -33,6 +36,49 @@ from django.contrib.auth.models import User from opaque_keys.edx.keys import CourseKey + from pypdf import PageObject + + +def _get_defaults() -> tuple[dict[str, Any], dict[str, dict[str, Any]]]: + """ + Get default styling and text element configurations. + + Evaluated lazily to avoid accessing Django settings at import time. + + :returns: A tuple of (default_styling, default_text_elements). + """ + default_styling = { + 'font': 'Helvetica', + 'color': '#000', + 'size': 12, + 'char_space': 0, + 'uppercase': False, + 'line_height': 1.1, + } + + default_text_elements = { + 'name': { + 'text': '{name}', + 'y': 290, + 'size': 32, + 'uppercase': getattr(settings, 'LEARNING_CREDENTIALS_NAME_UPPERCASE', False), + }, + 'context': { + 'text': '{context_name}', + 'y': 220, + 'size': 28, + 'line_height': 1.1, + }, + 'date': { + 'text': '{issue_date}', + 'y': 120, + 'size': 12, + 'uppercase': getattr(settings, 'LEARNING_CREDENTIALS_DATE_UPPERCASE', False), + 'char_space': getattr(settings, 'LEARNING_CREDENTIALS_DATE_CHAR_SPACE', 0), + }, + } + + return default_styling, default_text_elements def _get_user_name(user: User) -> str: @@ -45,81 +91,193 @@ def _get_user_name(user: User) -> str: return user.profile.name or f"{user.first_name} {user.last_name}" -def _register_font(options: dict[str, Any]) -> str: +def _register_font(pdf_canvas: Canvas, font_name: str) -> str: """ - Register a custom font if specified in options. If not specified, use the default font (Helvetica). + Register a custom font if not already available. - :param options: A dictionary containing the font. - :returns: The font name. + Built-in fonts (like Helvetica) are already available and don't need registration. + Custom fonts are loaded from CredentialAsset. + + :param pdf_canvas: The canvas to check available fonts on. + :param font_name: The name of the font to register. + :returns: The font name if available, otherwise use 'Helvetica' as fallback. """ - if font := options.get('font'): - pdfmetrics.registerFont(TTFont(font, CredentialAsset.get_asset_by_slug(font))) + # Check if font is already available (built-in or previously registered). + if font_name in pdf_canvas.getAvailableFonts(): + return font_name + + try: + registerFont(TTFont(font_name, CredentialAsset.get_asset_by_slug(font_name))) + except AssetNotFoundError: + log.warning("Font asset not found: %s", font_name) + except (FontError, FontNotFoundError, TTFError): + log.exception("Error registering font %s", font_name) + else: + return font_name - return font or 'Helvetica' + return 'Helvetica' -def _write_text_on_template(template: any, font: str, username: str, context_name: str, options: dict[str, Any]) -> any: +def _hex_to_rgb(hex_color: str) -> tuple[float, float, float]: """ - Prepare a new canvas and write the user and course name onto it. + Convert a hexadecimal color code to an RGB tuple with floating-point values. - :param template: Pdf template. - :param font: Font name. - :param username: The name of the user to generate the credential for. - :param context_name: The name of the learning context. - :param options: A dictionary documented in the `generate_pdf_credential` function. - :returns: A canvas with written data. + :param hex_color: A hexadecimal color string, which can start with '#' and be either 3 or 6 characters long. + :returns: A tuple representing the RGB color as (red, green, blue), with each value ranging from 0.0 to 1.0. """ + hex_color = hex_color.lstrip('#') + # Expand shorthand form (e.g. "158" to "115588") + if len(hex_color) == 3: + hex_color = ''.join([c * 2 for c in hex_color]) - def hex_to_rgb(hex_color: str) -> tuple[float, float, float]: - """ - Convert a hexadecimal color code to an RGB tuple with floating-point values. + # noinspection PyTypeChecker + return tuple(int(hex_color[i : i + 2], 16) / 255 for i in range(0, 6, 2)) - :param hex_color: A hexadecimal color string, which can start with '#' and be either 3 or 6 characters long. - :returns: A tuple representing the RGB color as (red, green, blue), with each value ranging from 0.0 to 1.0. - """ - hex_color = hex_color.lstrip('#') - # Expand shorthand form (e.g. "158" to "115588") - if len(hex_color) == 3: - hex_color = ''.join([c * 2 for c in hex_color]) - # noinspection PyTypeChecker - return tuple(int(hex_color[i : i + 2], 16) / 255 for i in range(0, 6, 2)) +def _substitute_placeholders(text: str, placeholders: dict[str, str]) -> str: + """ + Substitute placeholders in text using {placeholder} syntax. - template_width, template_height = template.mediabox[2:] - pdf_canvas = canvas.Canvas(io.BytesIO(), pagesize=(template_width, template_height)) + Supports escaping with {{ for literal braces. - # Write the learner name. - pdf_canvas.setFont(font, options.get('name_size', 32)) - name_color = options.get('name_color', '#000') - pdf_canvas.setFillColorRGB(*hex_to_rgb(name_color)) + :param text: The text containing placeholders. + :param placeholders: A dictionary mapping placeholder names to their values. + :returns: The text with placeholders substituted. + """ - name_x = (template_width - pdf_canvas.stringWidth(username)) / 2 - name_y = options.get('name_y', 290) - pdf_canvas.drawString(name_x, name_y, username) + def replace_placeholder(match: re.Match) -> str: + key = match.group(1) + return placeholders.get(key, match.group(0)) - # Write the learning context name. - pdf_canvas.setFont(font, options.get('context_name_size', 28)) - context_name_color = options.get('context_name_color', '#000') - pdf_canvas.setFillColorRGB(*hex_to_rgb(context_name_color)) + # Use negative lookbehind to skip escaped braces ({{). + # Match {word} but not {{word}. + text = re.sub(r'(? dict[str, dict[str, Any]]: + """ + Build the final text elements configuration by merging defaults with user options. + + Standard elements (name, context, date) use defaults that are deep-merged with user overrides. + Custom elements (any other key) must provide at least 'text' and 'y'. + + :param options: The options dictionary from the credential configuration. + :returns: A dictionary of element configurations ready for rendering. + """ + default_styling, default_text_elements = _get_defaults() + user_elements = options.get('text_elements', {}) + defaults_config = {**default_styling, **options.get('defaults', {})} + result = {} + + # Process standard elements (they have defaults). + for key, default_config in default_text_elements.items(): + user_config = user_elements.get(key, {}) + + if user_config is False: + continue + + # Merge: element defaults -> global defaults -> user config. + element_config = {**copy.deepcopy(default_config), **defaults_config, **user_config} + result[key] = element_config - issue_date_x = (template_width - pdf_canvas.stringWidth(issue_date)) / 2 - issue_date_y = options.get('issue_date_y', 120) - pdf_canvas.drawString(issue_date_x, issue_date_y, issue_date) + # Process custom elements (non-standard keys). + for key, user_config in user_elements.items(): + if key in default_text_elements: + continue + + # Skip disabled elements. + if user_config is False: + continue + + if not isinstance(user_config, dict): + log.warning("Invalid custom element configuration for key '%s': expected dict", key) + continue + + # Custom elements must have 'text' and 'y'. + if 'text' not in user_config or 'y' not in user_config: + log.warning("Custom element '%s' must have 'text' and 'y' properties", key) + continue + + # Merge with global defaults only. + element_config = {**defaults_config, **user_config} + result[key] = element_config + + return result + + +def _render_text_element( + pdf_canvas: Canvas, + template_width: float, + config: dict[str, Any], + placeholders: dict[str, str], +) -> None: + """ + Render a single text element on the canvas. + + :param pdf_canvas: The canvas to draw on. + :param template_width: Width of the template for centering. + :param config: The element configuration (all defaults are already merged). + :param placeholders: Dictionary of placeholder values. + """ + text = _substitute_placeholders(config['text'], placeholders) + + if config['uppercase']: + text = text.upper() + + font_name = _register_font(pdf_canvas, config['font']) + pdf_canvas.setFont(font_name, config['size']) + + pdf_canvas.setFillColorRGB(*_hex_to_rgb(config['color'])) + + y = config['y'] + char_space = config['char_space'] + line_height = config['line_height'] + size = config['size'] + + # Handle multiline text (for context element). + lines = text.split('\n') + for line_number, line in enumerate(lines): + text_width = pdf_canvas.stringWidth(line) + (char_space * max(0, len(line) - 1)) + line_x = (template_width - text_width) / 2 + line_y = y - (line_number * size * line_height) + pdf_canvas.drawString(line_x, line_y, line, charSpace=char_space) + + +def _write_text_on_template( + template: PageObject, + username: str, + context_name: str, + issue_date: str, + options: dict[str, Any], +) -> Canvas: + """ + Prepare a new canvas and write text elements onto it. + + :param template: PDF template. + :param username: The name of the user to generate the credential for. + :param context_name: The name of the learning context. + :param issue_date: The formatted issue date string. + :param options: A dictionary documented in the ``generate_pdf_credential`` function. + :returns: A canvas with written data. + """ + template_width, template_height = template.mediabox[2:] + pdf_canvas = Canvas(io.BytesIO(), pagesize=(template_width, template_height)) + + # Build placeholder values. + placeholders = { + 'name': username, + 'context_name': context_name, + 'issue_date': issue_date, + } + + # Build and render text elements. + elements = _build_text_elements(options) + + for config in elements.values(): + _render_text_element(pdf_canvas, template_width, config, placeholders) return pdf_canvas @@ -168,7 +326,7 @@ def generate_pdf_credential( credential_uuid: UUID, options: dict[str, Any], ) -> str: - """ + r""" Generate a PDF credential. :param learning_context_key: The ID of the course or learning path the credential is for. @@ -178,34 +336,66 @@ def generate_pdf_credential( :returns: The URL of the saved credential. Options: - - template: The path to the PDF template file. - - template_two_lines: The path to the PDF template file for two-line context names. - A two-line context name is specified by using a semicolon as a separator. - - font: The name of the font to use. - - name_y: The Y coordinate of the name on the credential (vertical position on the template). - - name_color: The color of the name on the credential (hexadecimal color code). - - name_size: The font size of the name on the credential. The default value is 32. - - context_name: Specify the custom course or Learning Path name. - - context_name_y: The Y coordinate of the context name on the credential (vertical position on the template). - - context_name_color: The color of the context name on the credential (hexadecimal color code). - - context_name_size: The font size of the context name on the credential. The default value is 28. - - issue_date_y: The Y coordinate of the issue date on the credential (vertical position on the template). - - issue_date_color: The color of the issue date on the credential (hexadecimal color code). + + - template (required): The slug of the PDF template asset. + - template_multiline: Alternative template for multiline context names (when using '\n'). + - defaults: Global defaults for all text elements. + - font: Font name (asset slug). Default: Helvetica. + - color: Hex color code. Default: #000. + - size: Font size in points. Default: 12. + - char_space: Character spacing. Default: 0. + - uppercase: Convert text to uppercase. Default: false. + - line_height: Line height multiplier for multiline text. Default: 1.1. + - text_elements: Configuration for text elements. Standard elements (name, context, date) have + defaults and render automatically. Set to false to hide. + Custom elements require 'text' and 'y' properties. + Element properties: + - text: Text content with {placeholder} substitution. Available: {name}, {context_name}, {issue_date}. + - y: Vertical position (PDF coordinates from bottom). + - size: Font size (inherited from defaults.size). + - font: Font name (inherited from defaults.font). + - color: Hex color (inherited from defaults.color). + - char_space: Character spacing (inherited from defaults.char_space). + - uppercase: Convert text to uppercase (inherited from defaults.uppercase). + - line_height: Line height multiplier for multiline text (inherited from defaults.line_height). + + Example:: + + { + "template": "certificate-template", + "defaults": {"font": "CustomFont", "color": "#333"}, + "text_elements": { + "name": {"y": 300, "uppercase": true}, + "context": {"text": "Custom Course Name"}, + "date": false, + "award_line": {"text": "Awarded on {issue_date}", "y": 140, "size": 14} + } + } """ log.info("Starting credential generation for user %s", user.id) username = _get_user_name(user) - context_name = options.get('context_name') or get_learning_context_name(learning_context_key) + + # Handle multiline context name. + context_name = get_learning_context_name(learning_context_key) + custom_context_name = '' + custom_context_text_element = options.get('text_elements', {}).get('context', {}) + if isinstance(custom_context_text_element, dict): + custom_context_name = custom_context_text_element.get('text', '') + + template_path = options.get('template') + if '\n' in context_name or '\n' in custom_context_name: + template_path = options.get('template_multiline', template_path) + + if not template_path: + msg = "Template path must be specified in options." + raise ValueError(msg) # Get template from the CredentialAsset. - # HACK: We support two-line strings by using a semicolon as a separator. - if ';' in context_name and (template_path := options.get('template_two_lines')): - template_file = CredentialAsset.get_asset_by_slug(template_path) - context_name = context_name.replace(';', '\n') - else: - template_file = CredentialAsset.get_asset_by_slug(options['template']) + template_file = CredentialAsset.get_asset_by_slug(template_path) - font = _register_font(options) + # Get the issue date. + issue_date = get_localized_credential_date() # Load the PDF template. with template_file.open('rb') as template_file: @@ -213,8 +403,8 @@ def generate_pdf_credential( credential = PdfWriter() - # Create a new canvas, prepare the page and write the data - pdf_canvas = _write_text_on_template(template, font, username, context_name, options) + # Create a new canvas, prepare the page and write the data. + pdf_canvas = _write_text_on_template(template, username, context_name, issue_date, options) overlay_pdf = PdfReader(io.BytesIO(pdf_canvas.getpdfdata())) template.merge_page(overlay_pdf.pages[0]) diff --git a/learning_credentials/migrations/0007_migrate_to_text_elements_format.py b/learning_credentials/migrations/0007_migrate_to_text_elements_format.py new file mode 100644 index 0000000..0d3ee6f --- /dev/null +++ b/learning_credentials/migrations/0007_migrate_to_text_elements_format.py @@ -0,0 +1,138 @@ +"""Migration to convert credential options from flat format to text_elements format.""" + +from django.db import migrations + +# Mapping from old option names to new text_elements structure. +# Format: (old_key, element_key, property_name) +_OPTION_MAPPINGS = [ + # Name element mappings. + ('name_y', 'name', 'y'), + ('name_color', 'name', 'color'), + ('name_size', 'name', 'size'), + ('name_font', 'name', 'font'), + ('name_uppercase', 'name', 'uppercase'), + # Context element mappings. + ('context_name', 'context', 'text'), + ('context_name_y', 'context', 'y'), + ('context_name_color', 'context', 'color'), + ('context_name_size', 'context', 'size'), + ('context_name_font', 'context', 'font'), + # Date element mappings. + ('issue_date_y', 'date', 'y'), + ('issue_date_color', 'date', 'color'), + ('issue_date_size', 'date', 'size'), + ('issue_date_font', 'date', 'font'), + ('issue_date_char_space', 'date', 'char_space'), + ('issue_date_uppercase', 'date', 'uppercase'), +] + + +def _convert_to_text_elements(options): + """ + Convert old flat options format to new text_elements format in-place. + + :param options: The options dictionary to convert. + """ + if not options: + return + + # If already in new format, skip conversion. + if 'text_elements' in options or 'defaults' in options: + return + + text_elements = {} + + # Handle template_two_lines -> template_multiline rename. + if 'template_two_lines' in options: + template_two_lines = options.pop('template_two_lines') + # Only set template_multiline if it doesn't already exist. + if 'template_multiline' not in options: + options['template_multiline'] = template_two_lines + + # Handle global font -> defaults.font. + if 'font' in options: + options['defaults'] = {'font': options.pop('font')} + + # Convert element-specific options by popping them from the options dict. + for old_key, element_key, prop_name in _OPTION_MAPPINGS: + if old_key in options: + if element_key not in text_elements: + text_elements[element_key] = {} + text_elements[element_key][prop_name] = options.pop(old_key) + + # Only add text_elements if we have any. + if text_elements: + options['text_elements'] = text_elements + + +def _convert_to_flat_format(options): + """ + Convert new text_elements format back to old flat options format in-place. + + :param options: The options dictionary to convert. + """ + if not options: + return + + # If not in new format, skip conversion. + if 'text_elements' not in options and 'defaults' not in options: + return + + # Handle template_multiline -> template_two_lines for backward compatibility. + if 'template_multiline' in options: + options['template_two_lines'] = options.pop('template_multiline') + + # Handle defaults.font -> font. + defaults = options.pop('defaults', {}) + if 'font' in defaults: + options['font'] = defaults['font'] + + # Convert text_elements back to flat format. + text_elements = options.pop('text_elements', {}) + + for old_key, element_key, prop_name in _OPTION_MAPPINGS: + element_config = text_elements.get(element_key, {}) + if isinstance(element_config, dict) and prop_name in element_config: + options[old_key] = element_config[prop_name] + + +def _migrate_all_options(apps, convert_func): + """ + Apply a conversion function to all credential configurations. + + :param apps: Django apps registry. + :param convert_func: Function to apply to each custom_options dict. + """ + CredentialType = apps.get_model('learning_credentials', 'CredentialType') + CredentialConfiguration = apps.get_model('learning_credentials', 'CredentialConfiguration') + + for credential_type in CredentialType.objects.all(): + if credential_type.custom_options: + convert_func(credential_type.custom_options) + credential_type.save() + + for config in CredentialConfiguration.objects.all(): + if config.custom_options: + convert_func(config.custom_options) + config.save() + + +def _migrate_forward(apps, schema_editor): + """Convert all credential configurations to the new text_elements format.""" + _migrate_all_options(apps, _convert_to_text_elements) + + +def _migrate_backward(apps, schema_editor): + """Convert all credential configurations back to the old flat format.""" + _migrate_all_options(apps, _convert_to_flat_format) + + +class Migration(migrations.Migration): + + dependencies = [ + ('learning_credentials', '0006_cleanup_openedx_certificates_tables'), + ] + + operations = [ + migrations.RunPython(_migrate_forward, _migrate_backward), + ] diff --git a/pyproject.toml b/pyproject.toml index 5d263b9..d909cb6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "learning-credentials" -version = "0.3.0" +version = "0.4.1rc5" description = "A pluggable service for preparing Open edX credentials." dynamic = ["readme"] requires-python = ">=3.11" @@ -74,6 +74,7 @@ dev = [ { include-group = "ci" }, { include-group = "quality" }, { include-group = "doc" }, + { include-group = "django42" }, "diff-cover", "edx-i18n-tools", "ty", # Type checker. @@ -187,6 +188,7 @@ ignore = [ 'RUF018', # assignment-in-assert 'ARG002', # unused-method-argument 'PLR0913', # too-many-arguments + 'FBT001', # flake8-boolean-trap ] [tool.ruff.lint.flake8-annotations] diff --git a/tests/test_generators.py b/tests/test_generators.py index b46aa0d..2adcd6d 100644 --- a/tests/test_generators.py +++ b/tests/test_generators.py @@ -3,7 +3,7 @@ from __future__ import annotations import io -from unittest.mock import Mock, call, patch +from unittest.mock import Mock, patch from uuid import uuid4 import pytest @@ -16,10 +16,16 @@ from pypdf import PdfWriter from pypdf.constants import UserAccessPermissions +from learning_credentials.exceptions import AssetNotFoundError from learning_credentials.generators import ( + FontError, + _build_text_elements, + _get_defaults, _get_user_name, + _hex_to_rgb, _register_font, _save_credential, + _substitute_placeholders, _write_text_on_template, generate_pdf_credential, ) @@ -38,121 +44,367 @@ def test_get_user_name(): assert _get_user_name(user) == "First Last" +@patch("learning_credentials.generators.CredentialAsset.get_asset_by_slug") +def test_register_font_already_available(mock_get_asset_by_slug: Mock): + """Test that _register_font returns True when font is already available.""" + mock_canvas = Mock(getAvailableFonts=Mock(return_value=['Helvetica', 'Times-Roman'])) + + assert _register_font(mock_canvas, 'Times-Roman') == 'Times-Roman' + mock_canvas.getAvailableFonts.assert_called_once() + mock_get_asset_by_slug.assert_not_called() + + @patch("learning_credentials.generators.CredentialAsset.get_asset_by_slug") def test_register_font_without_custom_font(mock_get_asset_by_slug: Mock): """Test the _register_font falls back to the default font when no custom font is specified.""" - options = {} - assert _register_font(options) == "Helvetica" - mock_get_asset_by_slug.assert_not_called() + mock_canvas = Mock(getAvailableFonts=Mock(return_value=['Helvetica', 'Times-Roman'])) + assert _register_font(mock_canvas, '') == "Helvetica" + mock_get_asset_by_slug.assert_called_once() @patch("learning_credentials.generators.CredentialAsset.get_asset_by_slug") @patch('learning_credentials.generators.TTFont') -@patch("learning_credentials.generators.pdfmetrics.registerFont") +@patch("learning_credentials.generators.registerFont") def test_register_font_with_custom_font(mock_register_font: Mock, mock_font_class: Mock, mock_get_asset_by_slug: Mock): - """Test the _register_font registers the custom font when specified.""" + """Test that _register_font registers a custom font when not already available.""" + mock_canvas = Mock(getAvailableFonts=Mock(return_value=[])) custom_font = "MyFont" - options = {"font": custom_font} - mock_get_asset_by_slug.return_value = "font_path" - assert _register_font(options) == custom_font + assert _register_font(mock_canvas, custom_font) == custom_font mock_get_asset_by_slug.assert_called_once_with(custom_font) mock_font_class.assert_called_once_with(custom_font, mock_get_asset_by_slug.return_value) mock_register_font.assert_called_once_with(mock_font_class.return_value) +@patch("learning_credentials.generators.CredentialAsset.get_asset_by_slug") +@patch('learning_credentials.generators.TTFont', side_effect=FontError("Font registration failed")) +@patch("learning_credentials.generators.registerFont") +def test_register_font_with_registration_failure( + mock_register_font: Mock, mock_font_class: Mock, mock_get_asset_by_slug: Mock +): + """Test that _register_font returns False when font registration fails.""" + mock_canvas = Mock(getAvailableFonts=Mock(return_value=[])) + custom_font = "MyFont" + mock_get_asset_by_slug.return_value = "font_path" + + assert _register_font(mock_canvas, custom_font) == 'Helvetica' + mock_get_asset_by_slug.assert_called_once_with(custom_font) + mock_font_class.assert_called_once_with(custom_font, mock_get_asset_by_slug.return_value) + mock_register_font.assert_not_called() + + +@patch( + "learning_credentials.generators.CredentialAsset.get_asset_by_slug", + side_effect=AssetNotFoundError("Font not found"), +) +def test_register_font_with_asset_not_found(mock_get_asset_by_slug: Mock): + """Test that _register_font returns False when font asset is not found.""" + mock_canvas = Mock(getAvailableFonts=Mock(return_value=[])) + custom_font = "MissingFont" + + assert _register_font(mock_canvas, custom_font) == 'Helvetica' + mock_get_asset_by_slug.assert_called_once_with(custom_font) + + +@pytest.mark.parametrize( + ("hex_color", "expected"), + [ + ('#000', (0, 0, 0)), + ('#fff', (1, 1, 1)), + ('#000000', (0, 0, 0)), + ('#ffffff', (1, 1, 1)), + ('123', (17 / 255, 34 / 255, 51 / 255)), + ('#9B192A', (155 / 255, 25 / 255, 42 / 255)), + ('#f59a8e', (245 / 255, 154 / 255, 142 / 255)), + ], +) +def test_hex_to_rgb(hex_color: str, expected: tuple[float, float, float]): + """Test the _hex_to_rgb function.""" + result = _hex_to_rgb(hex_color) + assert result == pytest.approx(expected) + + @pytest.mark.parametrize( - ("context_name", "options", "expected"), + ("text", "placeholders", "expected"), [ - ('Programming 101', {}, {}), # No options - use default coordinates and colors. + ('Hello {name}!', {'name': 'John'}, 'Hello John!'), + ('{name} earned {context_name}', {'name': 'Jane', 'context_name': 'Python 101'}, 'Jane earned Python 101'), + ('Issued on {issue_date}', {'issue_date': 'April 1, 2021'}, 'Issued on April 1, 2021'), + ('No placeholders', {}, 'No placeholders'), + ('Unknown {unknown}', {'name': 'John'}, 'Unknown {unknown}'), # Unknown placeholder kept as-is. + ('Escaped {{braces}}', {}, 'Escaped {braces}'), # Escaped braces. + ('{{name}} is {name}', {'name': 'John'}, '{name} is John'), # Mixed escaped and real. + ], +) +def test_substitute_placeholders(text: str, placeholders: dict[str, str], expected: str): + """Test the _substitute_placeholders function.""" + assert _substitute_placeholders(text, placeholders) == expected + + +def test_build_text_elements_defaults(): + """Test that _build_text_elements returns default elements when no options specified.""" + elements = _build_text_elements({}) + _, default_text_elements = _get_defaults() + + assert 'name' in elements + assert 'context' in elements + assert 'date' in elements + assert elements['name']['y'] == default_text_elements['name']['y'] + assert elements['context']['y'] == default_text_elements['context']['y'] + assert elements['date']['y'] == default_text_elements['date']['y'] + + +def test_build_text_elements_with_overrides(): + """Test that _build_text_elements merges user overrides with defaults.""" + options = { + 'text_elements': { + 'name': {'y': 300, 'uppercase': True}, + 'context': {'size': 24}, + }, + } + elements = _build_text_elements(options) + _, default_text_elements = _get_defaults() + + assert elements['name']['y'] == 300 + assert elements['name']['uppercase'] is True + assert elements['name']['text'] == '{name}' # Default preserved. + assert elements['context']['size'] == 24 + assert elements['context']['y'] == default_text_elements['context']['y'] # Default preserved. + + +def test_build_text_elements_hidden(): + """Test that _build_text_elements excludes hidden elements.""" + options = { + 'text_elements': { + 'date': False, + 'name': False, + }, + } + elements = _build_text_elements(options) + + assert 'date' not in elements + assert 'name' not in elements + assert 'context' in elements + + +def test_build_text_elements_custom(): + """Test that _build_text_elements includes custom elements.""" + options = { + 'defaults': {'color': '#333'}, + 'text_elements': { + 'award_line': {'text': 'Awarded on {issue_date}', 'y': 140}, + }, + } + elements = _build_text_elements(options) + + assert 'award_line' in elements + assert elements['award_line']['text'] == 'Awarded on {issue_date}' + assert elements['award_line']['y'] == 140 + assert elements['award_line']['color'] == '#333' # Inherited from defaults. + + +def test_build_text_elements_custom_missing_required(): + """Test that _build_text_elements logs warning for custom elements missing required properties.""" + options = { + 'text_elements': { + 'invalid': {'text': 'No y coordinate'}, # Missing 'y'. + }, + } + elements = _build_text_elements(options) + + assert 'invalid' not in elements # Should be skipped. + + +def test_build_text_elements_custom_disabled(): + """Test that _build_text_elements skips custom elements set to False.""" + options = { + 'text_elements': { + 'custom_element': False, # Disabled custom element. + }, + } + elements = _build_text_elements(options) + + assert 'custom_element' not in elements # Should be skipped. + + +def test_build_text_elements_custom_invalid_type(): + """Test that _build_text_elements logs warning for custom elements with invalid type.""" + options = { + 'text_elements': { + 'invalid_string': 'not a dict', # String instead of dict. + 'invalid_number': 42, # Number instead of dict. + }, + } + elements = _build_text_elements(options) + + assert 'invalid_string' not in elements # Should be skipped. + assert 'invalid_number' not in elements # Should be skipped. + + +# Tests for _write_text_on_template. + + +@pytest.mark.parametrize( + ("context_name", "options"), + [ + ('Programming 101', {}), # No options - use defaults. ( 'Programming 101', { - 'name_y': 250, - 'context_name_y': 200, - 'issue_date_y': 150, - 'name_color': '123', - 'context_name_color': '#9B192A', - 'issue_date_color': '#f59a8e', - 'context_name_size': 20, - 'name_size': 24, - }, - { - 'name_color': (17 / 255, 34 / 255, 51 / 255), - 'context_name_color': (155 / 255, 25 / 255, 42 / 255), - 'issue_date_color': (245 / 255, 154 / 255, 142 / 255), + 'text_elements': { + 'name': {'y': 250, 'color': '123', 'size': 24}, + 'context': {'y': 200, 'color': '#9B192A', 'size': 20}, + 'date': {'y': 150, 'color': '#f59a8e'}, + }, }, ), # Custom coordinates and colors. - ('Programming\n101\nAdvanced Programming', {}, {}), # Multiline course name. + ('Programming\n101\nAdvanced Programming', {}), # Multiline context name. ], ) -@patch('learning_credentials.generators.canvas.Canvas', return_value=Mock(stringWidth=Mock(return_value=10))) -def test_write_text_on_template(mock_canvas_class: Mock, context_name: str, options: dict[str, int], expected: dict): +@patch('learning_credentials.generators._register_font', return_value="Helvetica") +@patch('learning_credentials.generators.Canvas', return_value=Mock(stringWidth=Mock(return_value=10))) +def test_write_text_on_template(mock_canvas_class: Mock, mock_register_font: Mock, context_name: str, options: dict): """Test the _write_text_on_template function.""" username = 'John Doe' - context_name = 'Programming 101' template_height = 300 template_width = 200 font = 'Helvetica' - string_width = mock_canvas_class.return_value.stringWidth.return_value test_date = 'April 1, 2021' - # Reset the mock to discard calls list from previous tests + # Reset the mock to discard calls list from previous tests. mock_canvas_class.reset_mock() template_mock = Mock() template_mock.mediabox = [0, 0, template_width, template_height] - # Call the function with test parameters and mocks - with patch('learning_credentials.generators.get_localized_credential_date', return_value=test_date): - _write_text_on_template(template_mock, font, username, context_name, options) + # Call the function with test parameters and mocks. + _write_text_on_template(template_mock, username, context_name, test_date, options) - # Verifying that Canvas was the correct pagesize. - # Use `call_args_list` to ignore the first argument, which is an instance of io.BytesIO. + # Verifying that Canvas was created with the correct pagesize. assert mock_canvas_class.call_args_list[0][1]['pagesize'] == (template_width, template_height) - # Mock Canvas object retrieved from Canvas constructor call + # Mock Canvas object retrieved from Canvas constructor call. canvas_object = mock_canvas_class.return_value - # Expected coordinates for drawString method, based on fixed stringWidth - expected_name_x = (template_width - string_width) / 2 - expected_name_y = options.get('name_y', 290) - expected_context_name_x = (template_width - string_width) / 2 - expected_context_name_y = options.get('context_name_y', 220) - expected_issue_date_x = (template_width - string_width) / 2 - expected_issue_date_y = options.get('issue_date_y', 120) + # The number of calls to drawString: 1 (name) + lines in context + 1 (date). + context_lines = context_name.count('\n') + 1 + expected_draw_count = 1 + context_lines + 1 + assert canvas_object.drawString.call_count == expected_draw_count - # Expected colors for setFillColorRGB method - expected_name_color = expected.get('name_color', (0, 0, 0)) - expected_context_name_color = expected.get('context_name_color', (0, 0, 0)) - expected_issue_date_color = expected.get('issue_date_color', (0, 0, 0)) + # Check that setFont was called for each element. + assert canvas_object.setFont.call_count == 3 # name, context, date - # The number of calls to drawString should be 2 (name and issue date) + number of lines in course name. - assert canvas_object.drawString.call_count == 3 + context_name.count('\n') + # Check font was set correctly (default Helvetica). + for font_call in canvas_object.setFont.call_args_list: + assert font_call[0][0] == font - # Check the calls to setFont, setFillColorRGB and drawString methods on Canvas object - assert canvas_object.setFont.call_args_list[0] == call(font, options.get('name_size', 32)) - assert canvas_object.setFillColorRGB.call_args_list[0] == call(*expected_name_color) - assert canvas_object.drawString.call_args_list[0] == call(expected_name_x, expected_name_y, username) - assert mock_canvas_class.return_value.stringWidth.mock_calls[0][1] == (username,) + # Check that _register_font was called for each element with the default font (second argument). + assert all(call[0][1] == font for call in mock_register_font.call_args_list) - assert canvas_object.setFont.call_args_list[1] == call(font, options.get('context_name_size', 28)) - assert canvas_object.setFillColorRGB.call_args_list[1] == call(*expected_context_name_color) - assert canvas_object.setFont.call_args_list[2] == call(font, 12) - assert canvas_object.setFillColorRGB.call_args_list[2] == call(*expected_issue_date_color) +@pytest.mark.parametrize( + ("uppercase_option", "expected_text"), + [ + (False, "John Doe"), # Lowercase. + (True, "JOHN DOE"), # Uppercase. + ], +) +@patch('learning_credentials.generators._register_font', return_value=True) +@patch('learning_credentials.generators.Canvas', return_value=Mock(stringWidth=Mock(return_value=10))) +def test_write_text_on_template_uppercase( + mock_canvas_class: Mock, + mock_register_font: Mock, + uppercase_option: bool, + expected_text: str, +): + """Test the _write_text_on_template function with uppercase option.""" + mock_canvas_class.reset_mock() - for line_number, line in enumerate(context_name.split('\n')): - assert mock_canvas_class.return_value.stringWidth.mock_calls[line_number + 1][1] == (line,) - assert canvas_object.drawString.mock_calls[1 + line_number][1] == ( - expected_context_name_x, - expected_context_name_y - (line_number * 28 * 1.1), - line, - ) + username = "John Doe" + context_name = "Programming 101" + test_date = "April 1, 2021" + template_mock = Mock(mediabox=[0, 0, 300, 200]) + options = { + 'text_elements': { + 'name': {'uppercase': uppercase_option}, + }, + } - assert mock_canvas_class.return_value.stringWidth.mock_calls[-1][1] == (test_date,) - assert canvas_object.drawString.mock_calls[-1][1] == (expected_issue_date_x, expected_issue_date_y, test_date) + _write_text_on_template(template_mock, username, context_name, test_date, options) + + # Find the drawString call that contains the name text. + drawn_texts = [call[1][2] for call in mock_canvas_class.return_value.drawString.mock_calls] + assert expected_text in drawn_texts + + assert mock_register_font.call_count == 3 + + +@pytest.mark.parametrize( + ("char_space", "expected_char_space"), + [ + (2, 2), # Custom value. + (0.5, 0.5), # Float value. + ], +) +@patch('learning_credentials.generators._register_font', return_value=True) +@patch('learning_credentials.generators.Canvas', return_value=Mock(stringWidth=Mock(return_value=10))) +def test_write_text_on_template_char_space( + mock_canvas_class: Mock, + mock_register_font: Mock, + char_space: float, + expected_char_space: float, +): + """Test the _write_text_on_template function with char_space option.""" + mock_canvas_class.reset_mock() + + username = "John Doe" + context_name = "Programming 101" + test_date = "April 1, 2021" + template_mock = Mock(mediabox=[0, 0, 300, 200]) + options = { + 'text_elements': { + 'date': {'char_space': char_space}, + }, + } + + _write_text_on_template(template_mock, username, context_name, test_date, options) + + date_calls = [call for call in mock_canvas_class.return_value.drawString.mock_calls if call[1][2] == test_date] + assert len(date_calls) == 1 + assert date_calls[0][2]['charSpace'] == expected_char_space + + assert mock_register_font.call_count == 3 + + +@patch('learning_credentials.generators._register_font', return_value="Helvetica") +@patch('learning_credentials.generators.Canvas', return_value=Mock(stringWidth=Mock(return_value=10))) +def test_write_text_on_template_custom_element(mock_canvas_class: Mock, mock_register_font: Mock): + """Test the _write_text_on_template function with a custom text element.""" + username = "John Doe" + context_name = "Programming 101" + test_date = "April 1, 2021" + template_mock = Mock(mediabox=[0, 0, 300, 200]) + options = { + 'text_elements': { + 'date': False, # Hide the default date element. + 'award_line': {'text': 'Awarded on {issue_date}', 'y': 140, 'size': 14}, + }, + } + + _write_text_on_template(template_mock, username, context_name, test_date, options) + + canvas_object = mock_canvas_class.return_value + + assert canvas_object.drawString.call_count == 3 + + # Verify the custom award_line was rendered with substituted date. + drawn_texts = [call[1][2] for call in canvas_object.drawString.mock_calls] + assert f'Awarded on {test_date}' in drawn_texts + # Verify the default date was not rendered. + assert test_date not in drawn_texts + + assert mock_register_font.call_count == 3 @override_settings(LMS_ROOT_URL="https://example.com", MEDIA_URL="media/") @@ -222,20 +474,33 @@ def test_save_credential(mock_contentfile: Mock, mock_token_hex: Mock, storage: ("context_name", "options", "expected_template_slug", "expected_context_name"), [ # Default. - ('Test Course', {'template': 'template_slug'}, 'template_slug', 'Test Course'), - # Specify a different template for two-line course names and replace semicolon with newline in course name. + ('Test Course', {'template': 'default', 'template_multiline': 'multiline'}, 'default', 'Test Course'), + # Specify a different template for multiline course names and replace \n with newline. + ('Test\nCourse', {'template': 'default', 'template_multiline': 'multiline'}, 'multiline', 'Test\nCourse'), + # Ensure that the default template is used when no multiline template is specified. + ('Test\nCourse', {'template': 'default'}, 'default', 'Test\nCourse'), + # Custom context text with newlines uses multiline template. + ( + 'Single Line', + {'template_multiline': 'multiline', 'text_elements': {'context': {'text': 'Line 1\nLine 2'}}}, + 'multiline', + 'Single Line', + ), + # Custom context text without newlines still uses multiline template if the original context name has newlines. + # This allows using the `context_name` placeholder in the `context` text element. ( - 'Test Course; Test Course', - {'template': 'template_slug', 'template_two_lines': 'template_two_lines_slug'}, - 'template_two_lines_slug', - 'Test Course\n Test Course', + 'Multi\nLine', + {'template_multiline': 'multiline', 'text_elements': {'context': {'text': 'Single Line'}}}, + 'multiline', + 'Multi\nLine', + ), + # Disabled context element falls back to learning context name for template selection. + ( + 'Multi\nLine', + {'template_multiline': 'multiline', 'text_elements': {'context': False}}, + 'multiline', + 'Multi\nLine', ), - # Do not replace semicolon with newline when the `template_two_lines` option is not specified. - ('Test Course; Test Course', {'template': 'template_slug'}, 'template_slug', 'Test Course; Test Course'), - # Override course name. - ('Test Course', {'template': 'template_slug', 'context_name': 'Override'}, 'template_slug', 'Override'), - # Ignore empty course name override. - ('Test Course', {'template': 'template_slug', 'context_name': ''}, 'template_slug', 'Test Course'), ], ) @patch( @@ -251,7 +516,7 @@ def test_save_credential(mock_contentfile: Mock, mock_token_hex: Mock, storage: ) @patch('learning_credentials.generators._get_user_name') @patch('learning_credentials.generators.get_learning_context_name') -@patch('learning_credentials.generators._register_font') +@patch('learning_credentials.generators.get_localized_credential_date', return_value='April 1, 2021') @patch('learning_credentials.generators.PdfReader') @patch('learning_credentials.generators.PdfWriter') @patch( @@ -264,7 +529,7 @@ def test_generate_pdf_credential( mock_write_text_on_template: Mock, mock_pdf_writer: Mock, mock_pdf_reader: Mock, - mock_register_font: Mock, + mock_get_date: Mock, mock_get_learning_context_name: Mock, mock_get_user_name: Mock, mock_get_asset_by_slug: Mock, @@ -283,17 +548,29 @@ def test_generate_pdf_credential( assert result == 'credential_url' mock_get_asset_by_slug.assert_called_with(expected_template_slug) mock_get_user_name.assert_called_once_with(user) - if options.get('context_name'): - mock_get_learning_context_name.assert_not_called() - else: - mock_get_learning_context_name.assert_called_once_with(course_id) - mock_register_font.assert_called_once_with(options) + mock_get_learning_context_name.assert_called_once_with(course_id) assert mock_pdf_reader.call_count == 2 mock_pdf_writer.assert_called_once_with() mock_write_text_on_template.assert_called_once() _, args, _kwargs = mock_write_text_on_template.mock_calls[0] - assert args[-2] == expected_context_name - assert args[-1] == options + assert args[2] == expected_context_name + assert args[3] == mock_get_date.return_value + assert args[4] == options mock_save_credential.assert_called_once() + + +@patch('learning_credentials.generators.get_learning_context_name') +@patch('learning_credentials.generators._get_user_name') +def test_generate_pdf_credential_no_template(mock_get_user_name: Mock, mock_get_learning_context_name: Mock): + """Test that generate_pdf_credential raises ValueError when no template is specified.""" + course_id = CourseKey.from_string('course-v1:edX+DemoX+Demo_Course') + user = Mock() + options = {} # No template specified. + + with pytest.raises(ValueError, match=r"Template path must be specified in options."): + generate_pdf_credential(course_id, user, Mock(), options) + + mock_get_user_name.assert_called_once_with(user) + mock_get_learning_context_name.assert_called_once_with(course_id) diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..be1179f --- /dev/null +++ b/tests/test_migrations.py @@ -0,0 +1,269 @@ +"""Tests for the data migrations.""" + +from __future__ import annotations + +import copy +import importlib +from typing import Any + +import pytest + +# Import the migration module using importlib since it starts with a number. +migration_0007 = importlib.import_module('learning_credentials.migrations.0007_migrate_to_text_elements_format') +_convert_to_text_elements = migration_0007._convert_to_text_elements +_convert_to_flat_format = migration_0007._convert_to_flat_format + +# Type alias for options dictionary. +OptionsDict = dict[str, Any] | None + + +class TestMigration0007: + """Tests for migration 0007: migrate_to_text_elements_format.""" + + @pytest.mark.parametrize( + ("old_options", "expected"), + [ + # Empty options. + ({}, {}), + (None, None), + # Template only. + ({'template': 'cert.pdf'}, {'template': 'cert.pdf'}), + # Template with multiline. + ( + {'template': 'cert.pdf', 'template_two_lines': 'cert2.pdf'}, + {'template': 'cert.pdf', 'template_multiline': 'cert2.pdf'}, + ), + # Global font. + ( + {'template': 'cert.pdf', 'font': 'Arial'}, + {'template': 'cert.pdf', 'defaults': {'font': 'Arial'}}, + ), + # Name options. + ( + {'template': 'cert.pdf', 'name_y': 300, 'name_color': '#333', 'name_size': 24, 'name_uppercase': True}, + { + 'template': 'cert.pdf', + 'text_elements': { + 'name': {'y': 300, 'color': '#333', 'size': 24, 'uppercase': True}, + }, + }, + ), + # Context name options. + ( + {'template': 'cert.pdf', 'context_name_y': 200, 'context_name_color': '#666', 'context_name_size': 20}, + { + 'template': 'cert.pdf', + 'text_elements': { + 'context': {'y': 200, 'color': '#666', 'size': 20}, + }, + }, + ), + # Issue date options. + ( + { + 'template': 'cert.pdf', + 'issue_date_y': 100, + 'issue_date_color': '#999', + 'issue_date_char_space': 2, + }, + { + 'template': 'cert.pdf', + 'text_elements': { + 'date': {'y': 100, 'color': '#999', 'char_space': 2}, + }, + }, + ), + # context_name option. + ( + {'template': 'cert.pdf', 'context_name': 'Custom Course Name'}, + { + 'template': 'cert.pdf', + 'text_elements': { + 'context': {'text': 'Custom Course Name'}, + }, + }, + ), + # Mixed options. + ( + { + 'template': 'cert.pdf', + 'font': 'Arial', + 'name_y': 300, + 'context_name_y': 200, + 'issue_date_y': 100, + 'context_name': 'Custom Course Name', + }, + { + 'template': 'cert.pdf', + 'defaults': {'font': 'Arial'}, + 'text_elements': { + 'name': {'y': 300}, + 'context': {'text': 'Custom Course Name', 'y': 200}, + 'date': {'y': 100}, + }, + }, + ), + # Processor options should be preserved. + ( + { + 'template': 'cert.pdf', + 'name_y': 300, + 'required_grades': {'exam': 0.8}, + 'required_completion': 0.9, + 'steps': {'step1': {'required_completion': 1.0}}, + }, + { + 'template': 'cert.pdf', + 'required_grades': {'exam': 0.8}, + 'required_completion': 0.9, + 'steps': {'step1': {'required_completion': 1.0}}, + 'text_elements': { + 'name': {'y': 300}, + }, + }, + ), + # Already in new format (should not be converted). + ( + { + 'template': 'cert.pdf', + 'text_elements': {'name': {'y': 300}}, + }, + { + 'template': 'cert.pdf', + 'text_elements': {'name': {'y': 300}}, + }, + ), + # template_multiline should not be overridden if it already exists. + ( + { + 'template': 'cert.pdf', + 'template_two_lines': 'old.pdf', + 'template_multiline': 'new.pdf', + }, + { + 'template': 'cert.pdf', + 'template_multiline': 'new.pdf', + }, + ), + ], + ) + def test_convert_to_text_elements(self, old_options: OptionsDict, expected: OptionsDict): + """Test conversion from old flat format to new text_elements format.""" + # Make a copy since the function modifies in-place. + options = copy.deepcopy(old_options) + _convert_to_text_elements(options) + assert options == expected + + @pytest.mark.parametrize( + ("new_options", "expected"), + [ + # Empty options. + ({}, {}), + (None, None), + # Template only (no new format markers, returned as-is). + ({'template': 'cert.pdf'}, {'template': 'cert.pdf'}), + # Template with multiline and text_elements (triggers conversion). + ( + {'template': 'cert.pdf', 'template_multiline': 'cert2.pdf', 'text_elements': {}}, + {'template': 'cert.pdf', 'template_two_lines': 'cert2.pdf'}, + ), + # Global font. + ( + {'template': 'cert.pdf', 'defaults': {'font': 'Arial'}}, + {'template': 'cert.pdf', 'font': 'Arial'}, + ), + # Name options. + ( + { + 'template': 'cert.pdf', + 'text_elements': { + 'name': {'y': 300, 'color': '#333', 'size': 24, 'uppercase': True}, + }, + }, + {'template': 'cert.pdf', 'name_y': 300, 'name_color': '#333', 'name_size': 24, 'name_uppercase': True}, + ), + # context.text option. + ( + { + 'template': 'cert.pdf', + 'text_elements': { + 'context': {'text': 'Custom Course Name'}, + }, + }, + {'template': 'cert.pdf', 'context_name': 'Custom Course Name'}, + ), + # Processor options should be preserved. + ( + { + 'template': 'cert.pdf', + 'required_grades': {'exam': 0.8}, + 'required_completion': 0.9, + 'steps': {'step1': {'required_completion': 1.0}}, + 'text_elements': { + 'name': {'y': 300}, + }, + }, + { + 'template': 'cert.pdf', + 'required_grades': {'exam': 0.8}, + 'required_completion': 0.9, + 'steps': {'step1': {'required_completion': 1.0}}, + 'name_y': 300, + }, + ), + # Already in old format (should not be converted). + ( + {'template': 'cert.pdf', 'name_y': 300}, + {'template': 'cert.pdf', 'name_y': 300}, + ), + ], + ) + def test_convert_to_flat_format(self, new_options: OptionsDict, expected: OptionsDict): + """Test conversion from new text_elements format back to old flat format.""" + # Make a copy since the function modifies in-place. + options = copy.deepcopy(new_options) + _convert_to_flat_format(options) + assert options == expected + + def test_round_trip_conversion(self): + """Test that converting to new format and back preserves the data.""" + original = { + 'template': 'cert.pdf', + 'font': 'Arial', + 'name_y': 300, + 'name_color': '#333', + 'context_name_y': 200, + 'context_name': 'Custom Course Name', + 'issue_date_y': 100, + 'issue_date_char_space': 2, + } + expected = copy.deepcopy(original) + + _convert_to_text_elements(original) + _convert_to_flat_format(original) + + # The restored options should match the original. + assert original == expected + + def test_round_trip_conversion_with_processor_options(self): + """Test that processor options are preserved through round-trip conversion.""" + original = { + 'template': 'cert.pdf', + 'name_y': 300, + 'required_grades': {'exam': 0.8, 'quiz': 0.7}, + 'required_completion': 0.9, + 'steps': {'step1': {'required_completion': 1.0}}, + } + expected = copy.deepcopy(original) + + _convert_to_text_elements(original) + + # Verify processor options are still present after forward conversion. + assert original['required_grades'] == expected['required_grades'] + assert original['required_completion'] == expected['required_completion'] + assert original['steps'] == expected['steps'] + + _convert_to_flat_format(original) + + # The restored options should match the original. + assert original == expected diff --git a/tests/test_processors.py b/tests/test_processors.py index 32cf473..d13fc9f 100644 --- a/tests/test_processors.py +++ b/tests/test_processors.py @@ -137,7 +137,7 @@ def test_are_grades_passing_criteria( user_grades: dict[str, float], required_grades: dict[str, float], category_weights: dict[str, float], - expected: bool, # noqa: FBT001 + expected: bool, ): """Test that the user grades are compared to the required grades correctly.""" assert _are_grades_passing_criteria(user_grades, required_grades, category_weights) == expected diff --git a/uv.lock b/uv.lock index 09a989b..b15a42c 100644 --- a/uv.lock +++ b/uv.lock @@ -10,6 +10,9 @@ conflicts = [[ { package = "learning-credentials", group = "dev" }, { package = "learning-credentials", group = "django42" }, { package = "learning-credentials", group = "django52" }, +], [ + { package = "learning-credentials", group = "dev" }, + { package = "learning-credentials", group = "django52" }, ]] [manifest] @@ -631,8 +634,8 @@ name = "dj-inmemorystorage" version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52')" }, - { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra != 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, + { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, { name = "six" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bb/5b/89366ae96a0213437fd40b2e76791e8adcf790eda597a879bd2595bc41a5/dj-inmemorystorage-2.1.0.tar.gz", hash = "sha256:1771801613414262803a1a1e97dafd2b7a563e78fbcbfa2b6f841c9d8e7b872a", size = 6963, upload-time = "2020-03-30T17:49:12.045Z" } @@ -650,9 +653,9 @@ resolution-markers = [ "python_full_version < '3.13'", ] dependencies = [ - { name = "asgiref", marker = "extra == 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52')" }, - { name = "sqlparse", marker = "extra == 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52')" }, - { name = "tzdata", marker = "(sys_platform == 'win32' and extra == 'group-20-learning-credentials-django42') or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52') or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52')" }, + { name = "asgiref", marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "sqlparse", marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "tzdata", marker = "(sys_platform == 'win32' and extra == 'group-20-learning-credentials-dev') or (sys_platform == 'win32' and extra == 'group-20-learning-credentials-django42') or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52') or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ce/ff/6aa5a94b85837af893ca82227301ac6ddf4798afda86151fb2066d26ca0a/django-4.2.27.tar.gz", hash = "sha256:b865fbe0f4a3d1ee36594c5efa42b20db3c8bbb10dff0736face1c6e4bda5b92", size = 10432781, upload-time = "2025-12-02T14:01:49.006Z" } wheels = [ @@ -669,9 +672,9 @@ resolution-markers = [ "python_full_version < '3.13'", ] dependencies = [ - { name = "asgiref" }, - { name = "sqlparse" }, - { name = "tzdata", marker = "(sys_platform == 'win32' and extra == 'group-20-learning-credentials-dev') or (sys_platform == 'win32' and extra != 'group-20-learning-credentials-django42') or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52') or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, + { name = "asgiref", marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, + { name = "sqlparse", marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, + { name = "tzdata", marker = "(sys_platform == 'win32' and extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42') or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52') or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/1c/188ce85ee380f714b704283013434976df8d3a2df8e735221a02605b6794/django-5.2.9.tar.gz", hash = "sha256:16b5ccfc5e8c27e6c0561af551d2ea32852d7352c67d452ae3e76b4f6b2ca495", size = 10848762, upload-time = "2025-12-02T14:01:08.418Z" } wheels = [ @@ -685,8 +688,8 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "celery" }, { name = "cron-descriptor" }, - { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52')" }, - { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra != 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, + { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, { name = "django-timezone-field" }, { name = "python-crontab" }, { name = "tzdata" }, @@ -701,8 +704,8 @@ name = "django-config-models" version = "2.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52')" }, - { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra != 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, + { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, { name = "djangorestframework" }, { name = "edx-django-utils" }, ] @@ -717,8 +720,8 @@ version = "3.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage" }, - { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52')" }, - { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra != 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, + { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a4/d7/4e104b50911d1328e5cc26e89feca60ed0f12ea9b5f7e8ce776ce26d84c8/django_coverage_plugin-3.2.0.tar.gz", hash = "sha256:0e1460294ecd4b192bd09788ab9ad9380d9b8c9b45925b408ce6c620ac352585", size = 29252, upload-time = "2025-10-05T22:42:05.337Z" } wheels = [ @@ -730,8 +733,8 @@ name = "django-crum" version = "0.7.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52')" }, - { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra != 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, + { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/34/1d/c56588f67130aeef8828e47535e8551337d2ae02f91f1414da61bc5e49fb/django-crum-0.7.9.tar.gz", hash = "sha256:65e9bc0f070a663fafc4d9e357f45fd4e6f01838b20a9e2fb7670f5706754288", size = 5168, upload-time = "2020-11-10T17:15:35.124Z" } wheels = [ @@ -744,8 +747,8 @@ version = "0.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, - { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52')" }, - { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra != 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, + { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bd/e5/aeb7c9f763d879006d33814b457677fa141ad6def17e57d8273df527ab4b/django_fernet_fields_v2-0.9.tar.gz", hash = "sha256:998476968be1ded3eabb4fa51b27ceddcae295c8227c304f117a3348ece8cbda", size = 7525, upload-time = "2023-08-15T13:44:17.787Z" } wheels = [ @@ -757,8 +760,8 @@ name = "django-model-utils" version = "5.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52')" }, - { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra != 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, + { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/81/60/5e232c32a2c977cc1af8c70a38ef436598bc649ad89c2c4568454edde2c9/django_model_utils-5.0.0.tar.gz", hash = "sha256:041cdd6230d2fbf6cd943e1969318bce762272077f4ecd333ab2263924b4e5eb", size = 80559, upload-time = "2024-09-04T11:35:22.858Z" } wheels = [ @@ -779,8 +782,8 @@ name = "django-push-notifications" version = "3.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52')" }, - { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra != 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, + { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/89/85/7fb804fcab89f0b00d816d2359516f444fbb9117dadb7b7d242d205aede4/django_push_notifications-3.3.0.tar.gz", hash = "sha256:fed8b48be2f956c72567e8713a1865b4a1b1d56d22193328b1f04fbcf169b9f5", size = 64488, upload-time = "2025-11-16T06:45:51.92Z" } wheels = [ @@ -792,8 +795,8 @@ name = "django-redis" version = "6.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52')" }, - { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra != 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, + { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, { name = "redis" }, ] sdist = { url = "https://files.pythonhosted.org/packages/08/53/dbcfa1e528e0d6c39947092625b2c89274b5d88f14d357cee53c4d6dbbd4/django_redis-6.0.0.tar.gz", hash = "sha256:2d9cb12a20424a4c4dde082c6122f486628bae2d9c2bee4c0126a4de7fda00dd", size = 56904, upload-time = "2025-06-17T18:15:46.376Z" } @@ -812,8 +815,8 @@ name = "django-timezone-field" version = "7.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52')" }, - { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra != 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, + { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/da/05/9b93a66452cdb8a08ab26f08d5766d2332673e659a8b2aeb73f2a904d421/django_timezone_field-7.2.1.tar.gz", hash = "sha256:def846f9e7200b7b8f2a28fcce2b78fb2d470f6a9f272b07c4e014f6ba4c6d2e", size = 13096, upload-time = "2025-12-06T23:50:44.591Z" } wheels = [ @@ -837,8 +840,8 @@ name = "django-waffle" version = "5.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52')" }, - { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra != 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, + { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/22/e1/6f533da0d4ac89f427dfd9410e39bfc14ae3a23335ecd549d76be4b2a834/django_waffle-5.0.0.tar.gz", hash = "sha256:62f9d00eedf68dafb82657beab56e601bddedc1ea1ccfef91d83df8658708509", size = 37761, upload-time = "2025-06-12T07:38:54.895Z" } wheels = [ @@ -850,8 +853,8 @@ name = "djangorestframework" version = "3.16.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52')" }, - { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra != 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, + { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8a/95/5376fe618646fde6899b3cdc85fd959716bb67542e273a76a80d9f326f27/djangorestframework-3.16.1.tar.gz", hash = "sha256:166809528b1aced0a17dc66c24492af18049f2c9420dbd0be29422029cfc3ff7", size = 1089735, upload-time = "2025-08-06T17:50:53.251Z" } wheels = [ @@ -896,8 +899,8 @@ name = "drf-jwt" version = "1.19.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52')" }, - { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra != 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, + { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, { name = "djangorestframework" }, { name = "pyjwt", extra = ["crypto"] }, ] @@ -911,8 +914,8 @@ name = "drf-yasg" version = "1.21.11" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52')" }, - { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra != 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, + { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, { name = "djangorestframework" }, { name = "inflection" }, { name = "packaging" }, @@ -931,8 +934,8 @@ version = "1.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, - { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52')" }, - { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra != 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, + { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, { name = "django-push-notifications" }, { name = "edx-django-utils" }, { name = "firebase-admin" }, @@ -951,8 +954,8 @@ name = "edx-api-doc-tools" version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52')" }, - { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra != 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, + { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, { name = "djangorestframework" }, { name = "drf-yasg" }, { name = "setuptools" }, @@ -981,8 +984,8 @@ version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "celery" }, - { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52')" }, - { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra != 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, + { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, { name = "django-model-utils" }, { name = "jsonfield" }, ] @@ -996,8 +999,8 @@ name = "edx-completion" version = "4.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52')" }, - { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra != 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, + { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, { name = "django-model-utils" }, { name = "djangorestframework" }, { name = "edx-drf-extensions" }, @@ -1019,8 +1022,8 @@ version = "8.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, - { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52')" }, - { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra != 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, + { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, { name = "django-crum" }, { name = "django-waffle" }, { name = "psutil" }, @@ -1037,8 +1040,8 @@ name = "edx-drf-extensions" version = "10.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52')" }, - { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra != 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, + { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, { name = "django-waffle" }, { name = "djangorestframework" }, { name = "drf-jwt" }, @@ -1059,8 +1062,8 @@ version = "9.3.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "apache-libcloud" }, - { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52')" }, - { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra != 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, + { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, { name = "django-config-models" }, { name = "django-fernet-fields-v2" }, { name = "django-redis" }, @@ -1087,7 +1090,7 @@ name = "edx-i18n-tools" version = "1.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" } }, + { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" } }, { name = "lxml", extra = ["html-clean"], marker = "extra == 'group-20-learning-credentials-dev' or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, { name = "path" }, { name = "polib" }, @@ -1118,8 +1121,8 @@ version = "5.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "code-annotations" }, - { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52')" }, - { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra != 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, + { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, { name = "django-crum" }, { name = "django-waffle" }, { name = "edx-django-utils" }, @@ -1135,8 +1138,8 @@ version = "3.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "celery" }, - { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52')" }, - { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra != 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, + { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, { name = "edx-django-utils" }, { name = "edx-toggles" }, { name = "openedx-events" }, @@ -1581,7 +1584,7 @@ name = "importlib-metadata" version = "8.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "python_full_version < '3.13' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52') or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } wheels = [ @@ -1677,8 +1680,8 @@ name = "jsonfield" version = "3.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52')" }, - { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra != 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, + { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fa/e9/537e105246dba81d898853dbbe17eb3edd23d47a35074b99fd4add6f1662/jsonfield-3.2.0.tar.gz", hash = "sha256:ca53871bc3308ae4f4cddc3b4f99ed5c6fc6abb1832fbfb499bc6da566c70e4a", size = 17156, upload-time = "2025-07-04T23:06:24.883Z" } wheels = [ @@ -1720,12 +1723,12 @@ wheels = [ [[package]] name = "learning-credentials" -version = "0.3.0" +version = "0.4.1rc5" source = { virtual = "." } dependencies = [ { name = "celery" }, - { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52')" }, - { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra != 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, + { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, { name = "django-celery-beat" }, { name = "django-model-utils" }, { name = "django-object-actions" }, @@ -1750,7 +1753,7 @@ dev = [ { name = "code-annotations" }, { name = "diff-cover" }, { name = "dj-inmemorystorage" }, - { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" } }, + { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" } }, { name = "django-coverage-plugin" }, { name = "django-types" }, { name = "doc8" }, @@ -1821,6 +1824,7 @@ dev = [ { name = "diff-cover" }, { name = "dj-inmemorystorage" }, { name = "django", specifier = "<6.0" }, + { name = "django", specifier = ">=4.2,<5.0" }, { name = "django-coverage-plugin" }, { name = "django-types" }, { name = "doc8" }, @@ -1863,8 +1867,8 @@ name = "learning-paths-plugin" version = "0.3.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52')" }, - { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra != 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, + { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, { name = "django-model-utils" }, { name = "django-object-actions" }, { name = "djangorestframework" }, @@ -2224,8 +2228,8 @@ version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "celery" }, - { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52')" }, - { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra != 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, + { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, { name = "django-model-utils" }, { name = "djangorestframework" }, { name = "edx-celeryutils" }, @@ -2248,8 +2252,8 @@ version = "10.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, - { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52')" }, - { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra != 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, + { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, { name = "edx-ccx-keys" }, { name = "edx-django-utils" }, { name = "edx-opaque-keys" }, @@ -2266,8 +2270,8 @@ name = "openedx-filters" version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52')" }, - { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra != 'group-20-learning-credentials-django42' or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, + { name = "django", version = "4.2.27", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, { name = "edx-opaque-keys" }, { name = "setuptools" }, ]