diff --git a/.envs/.local/.django b/.envs/.local/.django index 6e391215..4a113b70 100755 --- a/.envs/.local/.django +++ b/.envs/.local/.django @@ -19,4 +19,8 @@ CELERY_FLOWER_PASSWORD=QgScyefPrYhHgO6onW61u0nazc5xdBuP4sM7jMRrBBFuA2RjsFhZLp7xb FETCH_DATA_TIMEOUT=2 DJANGO_PROFILING_ENABLED=True -RUN_ASYNC=False \ No newline at end of file +RUN_ASYNC=False + +# ENDPOINT PARA ATUALIZAÇÃO DE COLEÇÕES +# ------------------------------------------------------------------------------ +ENDPOINT_COLLECTION="/api/v1/update_collection/" \ No newline at end of file diff --git a/.envs/.local/.jwt b/.envs/.local/.jwt new file mode 100644 index 00000000..86d5da92 --- /dev/null +++ b/.envs/.local/.jwt @@ -0,0 +1,6 @@ +JWT_ISS="https://api.seu-django.com" +JWT_AUD="seu-flask-servico" +JWT_EXP_SECONDS=600 +JWT_ALG="RS256" +# JWT_PRIVATE_KEY JA CORRESPONDENTE AO jwt_public OPAC_5 PARA TESTES. +JWT_PRIVATE_KEY_PATH= \ No newline at end of file diff --git a/collection/api/v1/serializers.py b/collection/api/v1/serializers.py index 303db021..25027d27 100644 --- a/collection/api/v1/serializers.py +++ b/collection/api/v1/serializers.py @@ -1,8 +1,12 @@ from rest_framework import serializers +from wagtail.models.sites import Site + from collection import models from core.api.v1.serializers import LanguageSerializer +from core.utils.utils import get_url_file_from_wagtail_images from organization.api.v1.serializers import OrganizationSerializer + class CollectionNameSerializer(serializers.ModelSerializer): """Serializer para nomes traduzidos da coleção""" language = LanguageSerializer(many=False, read_only=True) @@ -14,6 +18,32 @@ class Meta: "language", ] +class CollectionLogoListSerializer(serializers.ListSerializer): + """ + Agrupa os logos por 'purpose' e consolida por idioma, + eliminando duplicatas por (purpose, lang_code2). + """ + def to_representation(self, data): + items = super().to_representation(data) + grouped = {} + seen = set() + + for item in items: + purpose = item.get("purpose") + url = item.get("logo_url") + lang = item.get("language", {}) + code2 = lang.get("code2") + + if not purpose or not code2 or not url: + continue + + key = (purpose, code2) + if key in seen: + continue + seen.add(key) + + grouped.setdefault(purpose, {}).update({code2: url}) + return grouped class CollectionLogoSerializer(serializers.ModelSerializer): """Serializer para logos da coleção""" @@ -22,38 +52,19 @@ class CollectionLogoSerializer(serializers.ModelSerializer): class Meta: model = models.CollectionLogo + list_serializer_class = CollectionLogoListSerializer fields = [ - "logo", "logo_url", - "size", + "purpose", "language", ] def get_logo_url(self, obj): - """Retorna a URL do logo renderizado no tamanho apropriado""" if obj.logo: - # Ajusta o rendition baseado no tamanho - rendition_specs = { - 'small': 'fill-100x100', - 'medium': 'fill-200x200', - 'large': 'fill-400x400', - 'banner': 'width-1200', - 'thumbnail': 'fill-150x150', - 'header': 'height-80', - 'footer': 'height-60', - } - spec = rendition_specs.get(obj.size, 'fill-200x200') - rendition = obj.logo.get_rendition(spec) - - # Retorna URL completa se houver request no contexto - request = self.context.get('request') - if request: - return request.build_absolute_uri(rendition.url) - return rendition.url + return get_url_file_from_wagtail_images(obj.logo) return None - class SupportingOrganizationSerializer(serializers.ModelSerializer): """Serializer para organizações de suporte""" organization = OrganizationSerializer(read_only=True, many=False) @@ -93,7 +104,6 @@ class Meta: class CollectionSerializer(serializers.ModelSerializer): """Serializer principal para Collection com todos os relacionamentos""" - # Campos relacionados (read-only por padrão) collection_names = CollectionNameSerializer(source='collection_name', many=True, read_only=True) logos = CollectionLogoSerializer(many=True, read_only=True) supporting_organizations = SupportingOrganizationSerializer(source='supporting_organization', many=True, read_only=True) diff --git a/collection/apps.py b/collection/apps.py index 040c2d31..3cd73cc2 100755 --- a/collection/apps.py +++ b/collection/apps.py @@ -4,3 +4,6 @@ class CollectionConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "collection" + + def ready(self): + from . import signals \ No newline at end of file diff --git a/collection/choices.py b/collection/choices.py index 47e90b51..c2f6857c 100644 --- a/collection/choices.py +++ b/collection/choices.py @@ -14,6 +14,15 @@ ("books", _("Books")), ("data", _("Data repository")), ] + +LOGO_PURPOSE = [ + ("homepage", _("Homepage")), + ("header", _("Header")), + ("logo_drop_menu", _("Logo drop menu")), + ("footer", _("Footer")), + ("menu", _("Menu")), +] + PLATFORM_STATUS = [ ("classic", _("Classic")), ("new", _("New")), diff --git a/collection/migrations/0007_alter_collectionexecutingorganization_options_and_more.py b/collection/migrations/0007_alter_collectionexecutingorganization_options_and_more.py new file mode 100644 index 00000000..40dfd444 --- /dev/null +++ b/collection/migrations/0007_alter_collectionexecutingorganization_options_and_more.py @@ -0,0 +1,65 @@ +# Generated by Django 5.2.3 on 2025-09-25 17:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "collection", + "0006_alter_collection_options_alter_collection_acron2_and_more", + ), + ("core", "0007_alter_language_options_alter_license_options_and_more"), + ] + + operations = [ + migrations.AlterModelOptions( + name="collectionexecutingorganization", + options={ + "ordering": ["sort_order"], + "verbose_name": "Executing Organization", + "verbose_name_plural": "Executing Organizations", + }, + ), + migrations.AlterModelOptions( + name="collectionlogo", + options={ + "ordering": ["sort_order", "language"], + "verbose_name": "Collection Logo", + "verbose_name_plural": "Collection Logos", + }, + ), + migrations.AlterModelOptions( + name="collectionsupportingorganization", + options={ + "ordering": ["sort_order"], + "verbose_name": "Supporting Organization", + "verbose_name_plural": "Supporting Organizations", + }, + ), + migrations.AlterUniqueTogether( + name="collectionlogo", + unique_together={("collection", "language", "purpose")}, + ), + migrations.AddField( + model_name="collectionlogo", + name="purpose", + field=models.CharField( + blank=True, + choices=[ + ("homepage", "Homepage"), + ("header", "Header"), + ("logo_drop_menu", "Logo drop menu"), + ("footer", "Footer"), + ("menu", "Menu"), + ], + help_text="Select the purpose of this logo", + max_length=20, + ), + ), + migrations.RemoveField( + model_name="collectionlogo", + name="size", + ), + ] diff --git a/collection/migrations/0008_merge_20251028_0412.py b/collection/migrations/0008_merge_20251028_0412.py new file mode 100644 index 00000000..3b738628 --- /dev/null +++ b/collection/migrations/0008_merge_20251028_0412.py @@ -0,0 +1,13 @@ +# Generated by Django 5.2.3 on 2025-10-28 04:12 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("collection", "0007_alter_collectionexecutingorganization_options_and_more"), + ("collection", "0007_collection_platform_status"), + ] + + operations = [] diff --git a/collection/models.py b/collection/models.py index d150f4e4..15e34b96 100755 --- a/collection/models.py +++ b/collection/models.py @@ -7,6 +7,7 @@ from wagtail.admin.panels import FieldPanel, InlinePanel, ObjectList, TabbedInterface from wagtail.models import Orderable from wagtailautocomplete.edit_handlers import AutocompletePanel +from django.core.exceptions import ValidationError from core.forms import CoreAdminModelForm from core.models import ( @@ -321,6 +322,7 @@ def get_acronyms(cls, collection_acron_list): return queryset.filter(acron3__in=collection_acron_list).values_list("acron3", flat=True) + class CollectionSocialNetwork(Orderable, SocialNetwork): page = ParentalKey( Collection, @@ -352,6 +354,7 @@ class CollectionSupportingOrganization(Orderable, ClusterableModel, BaseHistory) class Meta: verbose_name = _("Supporting Organization") verbose_name_plural = _("Supporting Organizations") + ordering = ['sort_order'] # Ordena os icons no opac_5 def __str__(self): return str(self.organization) @@ -379,6 +382,7 @@ class CollectionExecutingOrganization(Orderable, ClusterableModel, BaseHistory): class Meta: verbose_name = _("Executing Organization") verbose_name_plural = _("Executing Organizations") + ordering = ['sort_order'] # Ordena os icons no opac_5 def __str__(self): return str(self.organization) @@ -396,14 +400,64 @@ class CollectionLogo(Orderable, BaseLogo): related_name="logos", verbose_name=_("Collection"), ) + purpose = models.CharField( + max_length=20, + choices=choices.LOGO_PURPOSE, + blank=True, + help_text=_("Select the purpose of this logo"), + ) + panels = [ + FieldPanel("logo"), + AutocompletePanel("language"), + FieldPanel("purpose"), + ] class Meta: verbose_name = _("Collection Logo") verbose_name_plural = _("Collection Logos") - ordering = ["sort_order", "language", "size"] + ordering = ['sort_order', 'language'] unique_together = [ - ("collection", "size", "language"), + ('collection', 'language', 'purpose'), ] + def clean(self): + validation_logo_dimensions = { + 'homepage': { + 'max_width': 200, + 'max_height': 100, + }, + 'header': { + 'max_width': 200, + 'max_height': 100, + }, + 'logo_drop_menu': { + 'max_width': 200, + 'max_height': 100, + }, + 'footer': { + 'max_width': 200, + 'max_height': 100, + }, + 'menu': { + 'max_width': 200, + 'max_height': 100, + }, + } + if self.logo: + self.validate_image_dimensions(self.logo, validation_logo_dimensions[self.purpose]) + + @staticmethod + def validate_image_dimensions(image, validation_logo_dimensions): + width = image.width + height = image.height + max_width = validation_logo_dimensions['max_width'] + max_height = validation_logo_dimensions['max_height'] + if width < max_width or height > max_height: + raise ValidationError({ + 'logo': _(f'Image dimensions ({width}x{height}px) exceed the maximum allowed size. ' + f' Width must be ≤ {max_width}px and height must be ≤ {max_height}px. ' + ) + }) + def __str__(self): - return f"{self.collection} - {self.language} ({self.size})" + return f"{self.collection} - {self.language} ({self.purpose})" diff --git a/collection/signals.py b/collection/signals.py new file mode 100644 index 00000000..ec16632e --- /dev/null +++ b/collection/signals.py @@ -0,0 +1,21 @@ +from django.db import transaction +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.conf import settings +from .models import Collection +from .tasks import build_collection_webhook + + +@receiver(post_save, sender=Collection, dispatch_uid="collection.signals.post_save") +def collection_post_save(sender, instance, created, **kwargs): + def _on_commit(): + if settings.ACTIVATE_UPDATE_COLLECTION_WEBHOOK: + event = "collection.created" if created else "collection.updated" + build_collection_webhook.apply_async( + kwargs=dict( + event=event, + collection_acron=instance.acron3, + # headers=headers, + ) + ) + transaction.on_commit(_on_commit) \ No newline at end of file diff --git a/collection/tasks.py b/collection/tasks.py index 264e9494..b5d9641a 100644 --- a/collection/tasks.py +++ b/collection/tasks.py @@ -1,10 +1,19 @@ +import logging +import sys + +import requests +from django.conf import settings from django.contrib.auth import get_user_model from django.utils.translation import gettext_lazy as _ from collection.models import Collection from config import celery_app +from core.utils.jwt import issue_jwt_for_flask + +from .api.v1.serializers import CollectionSerializer User = get_user_model() +from tracker.models import UnexpectedEvent @celery_app.task(bind=True) @@ -14,3 +23,90 @@ def task_load_collections(self, user_id=None, username=None): if username: user = User.objects.get(username=username) Collection.load(user) + + +def fetch_with_protocol_guess(host_or_url, timeout=10): + """ + Algumas coleções não possuem o protocolo no domínio, por isso é necessário + tentar os protocolos http e https para obter o resultado correto. + """ + if "://" in host_or_url: + return host_or_url + + for protocol in ["http", "https"]: + url = f"{protocol}://{host_or_url}" + try: + resp = requests.get(url, timeout=timeout) + resp.raise_for_status() + return url + except requests.exceptions.SSLError: + continue + except requests.exceptions.RequestException: + continue + return None + +def _send_payload(url, headers, payload): + resp = None + try: + resp = requests.post(url, json=payload, headers=headers, timeout=5) + resp.raise_for_status() + return resp.json() + except Exception as e: + if resp is not None: + logging.error(f"Erro ao enviar dados de coleção para {url}. Status: {resp.status_code}. Body: {resp.text}") + else: + logging.error(f"Erro ao enviar dados de coleção para {url}. Exception {e}") + exc_type, exc_value, exc_traceback = sys.exc_info() + UnexpectedEvent.create( + exception=e, + exc_traceback=exc_traceback, + detail={ + "task": "collection.tasks.build_collection_webhook", + "url": url, + "payload": payload, + "headers": headers, + }, + ) + + +@celery_app.task +def build_collection_webhook_for_all(event=False, headers=None): + collections = Collection.objects.filter(domain__isnull=False, is_active=True) + for collection in collections: + build_collection_webhook.apply_async( + kwargs=dict( + event=event, + collection_acron=collection.acron3, + headers=headers, + ) + ) + +@celery_app.task +def build_collection_webhook(event, collection_acron, headers=None): + collection = Collection.objects.get(acron3=collection_acron, is_active=True) + url_with_schema = fetch_with_protocol_guess(collection.domain) + + if not url_with_schema: + return None + + pattern_url = url_with_schema + settings.ENDPOINT_COLLECTION + + serializer = CollectionSerializer(collection) + payload = { + "event": event, + "results": serializer.data, + } + + if not headers: + token = issue_jwt_for_flask( + sub="service:django", + claims={"roles": ["m2m"], "scope": "ingest:write"} + ) + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + _send_payload(url=pattern_url, headers=headers, payload=payload) + + diff --git a/config/settings/base.py b/config/settings/base.py index 1699cd97..77363520 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -581,5 +581,16 @@ PROFILING_LOG_ALL = env.bool('DJANGO_PROFILING_LOG_ALL', default=True) +JWT_PRIVATE_KEY = env.str("JWT_PRIVATE_KEY", default=None) +JWT_ALG = "RS256" +JWT_ISS = env.str("JWT_ISS", default="https://api.seu-django.com") +JWT_AUD = env.str("JWT_AUD", default="seu-flask-servico") +JWT_EXP_SECONDS = env.int("JWT_EXP_SECONDS", default=3600) + +# ENDPOINT PARA ATUALIZAÇÃO DOS DADOS DE COLEÇÃO NO OPAC_5 +ENDPOINT_COLLECTION = env.str("ENDPOINT_COLLECTION", default="/api/v1/update_collection/") +ACTIVATE_UPDATE_COLLECTION_WEBHOOK = env.bool("ACTIVATE_UPDATE_COLLECTION_WEBHOOK", default=False) + # LINK TO OLD SCIELO -SCIELO_OLD_URL = env.str("SCIELO_OLD_URL", default="http://old.scielo.org/") \ No newline at end of file +SCIELO_OLD_URL = env.str("SCIELO_OLD_URL", default="http://old.scielo.org/") + diff --git a/core/choices.py b/core/choices.py index 7c84e9f3..03cab984 100644 --- a/core/choices.py +++ b/core/choices.py @@ -234,7 +234,6 @@ ("youtube", "Youtube"), ] - LOGO_SIZE_CHOICES = [ ("thumbnail", _("Thumbnail (150x150)")), ("small", _("Small (300x300)")), diff --git a/core/models.py b/core/models.py index 5f31cc20..f2dd94ab 100755 --- a/core/models.py +++ b/core/models.py @@ -636,15 +636,6 @@ class BaseLogo(models.Model): blank=True, verbose_name=_("Logo Image"), ) - - size = models.CharField( - _("Logo Size"), - max_length=20, - choices=choices.LOGO_SIZE_CHOICES, - default="medium", - help_text=_("Select the size/purpose of this logo"), - ) - language = models.ForeignKey( Language, verbose_name=_("Logo language"), @@ -656,16 +647,15 @@ class BaseLogo(models.Model): panels = [ FieldPanel("logo"), - FieldPanel("size"), FieldPanel("language"), ] class Meta: abstract = True - ordering = ["sort_order", "language", "size"] + ordering = ['sort_order', 'language'] def __str__(self): - return f"{self.collection} - {self.language} ({self.size})" + return f"{self.collection} - {self.language}" ICON_MAP = { diff --git a/core/utils/jwt.py b/core/utils/jwt.py new file mode 100644 index 00000000..31639bcd --- /dev/null +++ b/core/utils/jwt.py @@ -0,0 +1,45 @@ +import logging +import time + +import jwt +from django.conf import settings + + +def get_jwt_private_key(): + key = settings.JWT_PRIVATE_KEY + if not key: + logging.error("JWT_PRIVATE_KEY not found") + return None + if isinstance(key, bytes): + return key + if isinstance(key, str): + return key.encode() + logging.error("JWT_PRIVATE_KEY is not bytes or str") + return None + + +def issue_jwt_for_flask(sub="service:django", claims=None): + now = int(time.time()) + payload = { + "iss": settings.JWT_ISS, + "aud": settings.JWT_AUD, + "iat": now, + "nbf": now, + "exp": now + settings.JWT_EXP_SECONDS, + "sub": sub, + } + if claims: + payload.update(claims) + + private_key = get_jwt_private_key() + + if not private_key: + return None + + token = jwt.encode( + payload, + private_key, + algorithm="RS256", + headers={"alg": "RS256", "typ": "JWT"}, + ) + return token diff --git a/core/utils/utils.py b/core/utils/utils.py index 71e754f8..0eaabe23 100644 --- a/core/utils/utils.py +++ b/core/utils/utils.py @@ -10,7 +10,7 @@ stop_after_attempt, wait_exponential, ) -from urllib3.util import Retry +from wagtail.models.sites import Site from config.settings.base import FETCH_DATA_TIMEOUT @@ -113,3 +113,46 @@ def formated_date_api_params(query_params): except (ValueError, AttributeError): continue return formated_date + + +def get_default_site(): + try: + return Site.objects.get(is_default_site=True) + except Site.DoesNotExist: + return None + +def get_hostname(): + default_site = get_default_site() + if not default_site: + return None + return f"http://{default_site.hostname}" + + +def get_url_file_from_wagtail_images(file): + """ + Return the url of the file from Wagtail ImageField + Parameters: + file: File object + Returns: + URL of the file + """ + domain = get_hostname() + domain = "http://172.20.0.1:8009" + if domain: + return f"{domain}{file.file.url}" + return None + + +def get_url_file_from_image_field(file): + """ + Return the url of the file from ImageField + Parameters: + file: ImageField object + Returns: + URL of the file + """ + domain = get_hostname() + domain = "http://172.20.0.1:8009" + if domain: + return f"{domain}{file.url}" + return None \ No newline at end of file diff --git a/journal/api/v1/serializers.py b/journal/api/v1/serializers.py index b19c0b5d..3f1317c5 100644 --- a/journal/api/v1/serializers.py +++ b/journal/api/v1/serializers.py @@ -3,6 +3,7 @@ from core.api.v1.serializers import LanguageSerializer from journal import models +from core.utils.utils import get_url_file_from_wagtail_images class OfficialJournalSerializer(serializers.ModelSerializer): @@ -165,9 +166,7 @@ def get_title_in_database(self, obj): def get_url_logo(self, obj): if obj.logo: - domain = Site.objects.get(is_default_site=True).hostname - domain = f"http://{domain}" - return f"{domain}{obj.logo.file.url}" + return get_url_file_from_wagtail_images(obj.logo) return None def get_email(self, obj): diff --git a/local.yml b/local.yml index e5bbfdaf..9cec7c9d 100755 --- a/local.yml +++ b/local.yml @@ -20,6 +20,7 @@ services: env_file: - ./.envs/.local/.django - ./.envs/.local/.postgres + - ./.envs/.local/.jwt ports: - "8009:8000" command: /start diff --git a/organization/api/v1/serializers.py b/organization/api/v1/serializers.py index 4eabb42c..8074821c 100644 --- a/organization/api/v1/serializers.py +++ b/organization/api/v1/serializers.py @@ -1,9 +1,17 @@ from rest_framework import serializers from organization import models +from wagtail.models.sites import Site +from core.utils.utils import get_url_file_from_image_field class OrganizationSerializer(serializers.ModelSerializer): location = serializers.SerializerMethodField() + logo_url = serializers.SerializerMethodField() + + def get_logo_url(self, obj): + if obj.logo: + return get_url_file_from_image_field(obj.logo) + return None def get_location(self, obj): if obj.location: @@ -14,5 +22,7 @@ class Meta: fields = [ "name", "acronym", + "url", + "logo_url", "location", ] \ No newline at end of file diff --git a/pid_provider/models.py b/pid_provider/models.py index c0535811..e327a7c3 100644 --- a/pid_provider/models.py +++ b/pid_provider/models.py @@ -1,10 +1,8 @@ -import json import logging import os import sys -import traceback from datetime import datetime -from functools import lru_cache, cached_property +from functools import cached_property from zlib import crc32 from django.core.files.base import ContentFile @@ -16,8 +14,6 @@ from packtools.sps.pid_provider import v3_gen, xml_sps_adapter from packtools.sps.pid_provider.xml_sps_lib import XMLWithPre from wagtail.admin.panels import FieldPanel, InlinePanel, ObjectList, TabbedInterface -from wagtail.fields import RichTextField -from wagtail.models import Orderable from wagtailautocomplete.edit_handlers import AutocompletePanel from collection.models import Collection @@ -36,7 +32,7 @@ zero_to_none, QueryBuilderPidProviderXML, ) -from tracker.models import BaseEvent, EventSaveError, UnexpectedEvent +from tracker.models import UnexpectedEvent try: from django_prometheus.models import ExportModelOperationsMixin diff --git a/requirements/base.txt b/requirements/base.txt index d20bcb1b..1c0c3e0f 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -144,3 +144,10 @@ feedparser==6.0.12 # Xlwt # ------------------------------------------------------------------------------ xlwt==1.3.0 + +django-silk==5.3.2 + +# JWT +# ------------------------------------------------------------------------------ +cryptography==46.0.1 +PyJWT==2.8.0