diff --git a/.env.example b/.env.example index f2403634..ce26b89f 100644 --- a/.env.example +++ b/.env.example @@ -53,3 +53,5 @@ KEYCLOAK_CLIENT_ID= KEYCLOAK_CLIENT_SECRET= APISIX_SESSION_SECRET_KEY=must_be_at_least_16_chars + +MITOL_LEARN_API_URL=https://api.rc.learn.mit.edu/api/v1/ diff --git a/conftest.py b/conftest.py index 3b08f0f9..d645c0ea 100644 --- a/conftest.py +++ b/conftest.py @@ -1,6 +1,6 @@ -"""Project conftest""" - # pylint: disable=wildcard-import, unused-wildcard-import +from unittest.mock import Mock, patch + import pytest from fixtures.common import * # noqa: F403 @@ -8,6 +8,30 @@ from unified_ecommerce.exceptions import DoNotUseRequestException +@pytest.fixture(autouse=True) +def mock_requests_get(): + # Mock the response of requests.get + with patch("system_meta.tasks.requests.get") as mock_get: + # Create a mock response object + mock_response = Mock() + mock_response.raise_for_status = Mock() # Mock the raise_for_status method + mock_response.json.return_value = { + "results": [ + { + "image": { + "url": "http://example.com/image.jpg", + "alt": "Image alt text", + "description": "Image description", + } + } + ] + } + mock_get.return_value = ( + mock_response # Set the mock response to be returned by requests.get + ) + yield mock_get # This will be the mocked requests.get + + @pytest.fixture(autouse=True) def prevent_requests(mocker, request): # noqa: PT004 """Patch requests to error on request by default""" diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index 810226cb..0038d24c 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -1701,6 +1701,10 @@ components: format: decimal pattern: ^-?\d{0,5}(?:\.\d{0,2})?$ description: Price (decimal to two places) + image_metadata: + nullable: true + description: Image metadata including URL, alt text, and description (in + JSON). PaymentTypeEnum: enum: - marketing @@ -1762,6 +1766,10 @@ components: deleted_by_cascade: type: boolean readOnly: true + image_metadata: + nullable: true + description: Image metadata including URL, alt text, and description (in + JSON). required: - deleted_by_cascade - description @@ -1799,6 +1807,10 @@ components: format: decimal pattern: ^-?\d{0,5}(?:\.\d{0,2})?$ description: Price (decimal to two places) + image_metadata: + nullable: true + description: Image metadata including URL, alt text, and description (in + JSON). required: - description - name diff --git a/system_meta/factories.py b/system_meta/factories.py index 47fc451a..bd230902 100644 --- a/system_meta/factories.py +++ b/system_meta/factories.py @@ -47,6 +47,11 @@ class Meta: description = Faker("text") system = SubFactory(IntegratedSystemFactory) system_data = Faker("json") + image_metadata = { + "image_url": "http://example.com/image.jpg", + "alt_text": "Image alt text", + "description": "Image description", + } class ActiveProductFactory(ProductFactory): diff --git a/system_meta/management/commands/update_product_image_data.py b/system_meta/management/commands/update_product_image_data.py new file mode 100644 index 00000000..45f7bdc2 --- /dev/null +++ b/system_meta/management/commands/update_product_image_data.py @@ -0,0 +1,58 @@ +from django.core.management.base import BaseCommand + +from system_meta.models import Product +from system_meta.tasks import update_products + + +class Command(BaseCommand): + """ + A management command to update image_metadata for all Product objects + Example usage: python manage.py update_product_image_data --product_id 1 + """ + + help = "Update image_metadata for all Product objects" + + def add_arguments(self, parser): + parser.add_argument( + "--product-id", + type=int, + help="The ID of the product to update", + ) + parser.add_argument( + "--sku", + type=str, + help="The SKU of the product to update", + ) + parser.add_argument( + "--name", + type=str, + help="The name of the product to update", + ) + parser.add_argument( + "--system-name", + type=str, + help="The system name of the product to update", + ) + + def handle(self, *args, **kwargs): # noqa: ARG002 + product_id = kwargs.get("product_id") + sku = kwargs.get("sku") + name = kwargs.get("name") + system_name = kwargs.get("system_name") + + if product_id: + products = Product.objects.filter(id=product_id) + elif sku: + products = Product.objects.filter(sku=sku, system__name=system_name) + elif name: + products = Product.objects.filter(name=name) + else: + products = Product.objects.all() + + for product in products: + update_products.delay(product.id) + self.stdout.write( + self.style.SUCCESS( + f"Successfully updated image metadata for product {product.id}" + ) + ) diff --git a/system_meta/migrations/0007_product_image_metadata.py b/system_meta/migrations/0007_product_image_metadata.py new file mode 100644 index 00000000..dd607526 --- /dev/null +++ b/system_meta/migrations/0007_product_image_metadata.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.17 on 2024-12-10 16:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("system_meta", "0006_integratedsystemapikey"), + ] + + operations = [ + migrations.AddField( + model_name="product", + name="image_metadata", + field=models.JSONField( + blank=True, + help_text="Image metadata including URL, alt text, and description (in JSON).", + null=True, + ), + ), + ] diff --git a/system_meta/models.py b/system_meta/models.py index 99ff7541..3554e386 100644 --- a/system_meta/models.py +++ b/system_meta/models.py @@ -13,6 +13,7 @@ from safedelete.models import SafeDeleteModel from slugify import slugify +from system_meta.tasks import update_products from unified_ecommerce.utils import SoftDeleteActiveModel User = get_user_model() @@ -77,10 +78,22 @@ class Product(SafeDeleteModel, SoftDeleteActiveModel, TimestampedModel): null=True, help_text="System-specific data for the product (in JSON).", ) + image_metadata = models.JSONField( + blank=True, + null=True, + help_text="Image metadata including URL, alt text, and description (in JSON).", + ) objects = SafeDeleteManager() all_objects = models.Manager() + def save(self, *args, **kwargs): + # Retrieve image data from the API + created = not self.pk + super().save(*args, **kwargs) + if created: + update_products.delay(self.id) + class Meta: """Meta class for Product""" diff --git a/system_meta/serializers.py b/system_meta/serializers.py index 8aa8dda1..d344cc9e 100644 --- a/system_meta/serializers.py +++ b/system_meta/serializers.py @@ -41,4 +41,5 @@ class Meta: "system", "price", "deleted_by_cascade", + "image_metadata", ] diff --git a/system_meta/tasks.py b/system_meta/tasks.py new file mode 100644 index 00000000..2589bbcf --- /dev/null +++ b/system_meta/tasks.py @@ -0,0 +1,40 @@ +import logging +from typing import Optional + +import requests +from celery import shared_task +from django.conf import settings + + +@shared_task +def update_products(product_id: Optional[int] = None): + """ + Update all product's image metadata. If product_id is provided, only update the + product with that ID. Otherwise, update all products. + """ + from .models import Product + + log = logging.getLogger(__name__) + if product_id: + products = Product.objects.filter(id=product_id) + else: + products = Product.objects.all() + for product in products: + try: + response = requests.get( + f"{settings.MITOL_LEARN_API_URL}learning_resources/", + params={"platform": product.system.slug, "readable_id": product.sku}, + timeout=10, + ) + response.raise_for_status() + results_data = response.json() + course_data = results_data.get("results")[0] + image_data = course_data.get("image") + product.image_metadata = { + "image_url": image_data.get("url"), + "alt_text": image_data.get("alt"), + "description": image_data.get("description"), + } + product.save() + except requests.RequestException: + log.exception("Failed to retrieve image data for product %s", product.id) diff --git a/unified_ecommerce/settings.py b/unified_ecommerce/settings.py index 5f83bdf3..40bb0914 100644 --- a/unified_ecommerce/settings.py +++ b/unified_ecommerce/settings.py @@ -508,6 +508,8 @@ name="MITOL_UE_PAYMENT_BASKET_CHOOSER", default="/cart/" ) +MITOL_LEARN_API_URL = get_string(name="MITOL_LEARN_API_URL", default="") + import_settings_modules("mitol.payment_gateway.settings.cybersource") # Keycloak API settings diff --git a/unified_ecommerce/settings_celery.py b/unified_ecommerce/settings_celery.py index 009c2b96..98762f62 100644 --- a/unified_ecommerce/settings_celery.py +++ b/unified_ecommerce/settings_celery.py @@ -2,6 +2,8 @@ Django settings for celery. """ +from celery.schedules import crontab + from unified_ecommerce.envs import get_bool, get_int, get_string USE_CELERY = True @@ -18,7 +20,12 @@ "CELERY_WORKER_MAX_MEMORY_PER_CHILD", 250_000 ) -CELERY_BEAT_SCHEDULE = {} +CELERY_BEAT_SCHEDULE = { + "update-products-daily": { + "task": "system_meta.tasks.update_products", + "schedule": crontab(hour=0, minute=0), # Runs every day at midnight + }, +} CELERY_TASK_SERIALIZER = "json" CELERY_RESULT_SERIALIZER = "json"