From 8285beebc78554f523ace459a7112ea3342f1f18 Mon Sep 17 00:00:00 2001 From: Witold Date: Tue, 3 Dec 2024 17:02:41 +0100 Subject: [PATCH 1/3] chore: bumps python version to 3.12.7 chore: remove runtime.txt and add .python-version for Python 3.12.7 --- .github/workflows/test.yaml | 2 +- .python-version | 1 + runtime.txt | 1 - 3 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 .python-version delete mode 100644 runtime.txt diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 07111519d..f6ecb36f9 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -13,7 +13,7 @@ env: EMAIL_USE_TLS: false DEFAULT_FROM_EMAIL: h@ll.o SITE_URL: http://localhost:8000 - PYTHON_VERSION: 3.11.2 + PYTHON_VERSION: 3.12.7 jobs: python-checks: diff --git a/.python-version b/.python-version new file mode 100644 index 000000000..450178b3c --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12.7 \ No newline at end of file diff --git a/runtime.txt b/runtime.txt deleted file mode 100644 index 04d03e383..000000000 --- a/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.11.2 From e6791b8e07aa8827a1ab3fd4971c1cf8fd6afe28 Mon Sep 17 00:00:00 2001 From: Witold Date: Tue, 3 Dec 2024 17:06:34 +0100 Subject: [PATCH 2/3] chore: disable cyclic import error on pylint --- .pylintrc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.pylintrc b/.pylintrc index b1b5e5226..00f8d2b0b 100644 --- a/.pylintrc +++ b/.pylintrc @@ -85,7 +85,8 @@ disable=raw-checker-failed, R0801, E5110, consider-using-f-string, - too-many-positional-arguments + too-many-positional-arguments, + cyclic-import # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option From ed17f88531a1152acbfd7a021f98a41a411b5ec9 Mon Sep 17 00:00:00 2001 From: Witold Date: Tue, 3 Dec 2024 16:43:58 +0100 Subject: [PATCH 3/3] feat: export notebook to pdf --- .env.example | 1 + .github/workflows/test.yaml | 1 + euphro_tools/download_urls.py | 30 +- euphro_tools/exceptions.py | 1 + euphrosyne/settings.py | 3 + lab/objects/c2rmf.py | 27 ++ lab/objects/models.py | 29 ++ lab/objects/tests/test_c2mrf.py | 30 ++ .../tests/test_run_object_group_image.py | 17 + lab/permissions.py | 13 +- lab_notebook/pdf_export/__init__.py | 0 lab_notebook/pdf_export/pdf.py | 391 ++++++++++++++++++ lab_notebook/pdf_export/views.py | 118 ++++++ lab_notebook/templates/notebook/notebook.html | 4 +- lab_notebook/tests/__init__.py | 0 lab_notebook/tests/pdf_export/__init__.py | 0 lab_notebook/tests/pdf_export/test_pdf.py | 291 +++++++++++++ lab_notebook/tests/pdf_export/test_views.py | 94 +++++ lab_notebook/urls.py | 6 + locale/fr/LC_MESSAGES/django.mo | Bin 27293 -> 28000 bytes locale/fr/LC_MESSAGES/django.po | 52 ++- requirements/base.txt | 2 + 22 files changed, 1102 insertions(+), 8 deletions(-) create mode 100644 lab/objects/tests/test_run_object_group_image.py create mode 100644 lab_notebook/pdf_export/__init__.py create mode 100644 lab_notebook/pdf_export/pdf.py create mode 100644 lab_notebook/pdf_export/views.py create mode 100644 lab_notebook/tests/__init__.py create mode 100644 lab_notebook/tests/pdf_export/__init__.py create mode 100644 lab_notebook/tests/pdf_export/test_pdf.py create mode 100644 lab_notebook/tests/pdf_export/test_views.py diff --git a/.env.example b/.env.example index 67b196a2f..a21f5201c 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,7 @@ EMAIL_USE_TLS=false ELASTICSEARCH_HOST=http://localhost:9200 ELASTICSEARCH_USERNAME= ELASTICSEARCH_PASSWORD= +EROS_BASE_IMAGE_URL= EROS_HTTP_TOKEN= EUPHROSYNE_TOOLS_API_URL=http://localhost:8001 DEFAULT_FROM_EMAIL=alexandre.hajjar@beta.gouv.fr diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index f6ecb36f9..e70dd0d41 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -14,6 +14,7 @@ env: DEFAULT_FROM_EMAIL: h@ll.o SITE_URL: http://localhost:8000 PYTHON_VERSION: 3.12.7 + EUPHROSYNE_TOOLS_API_URL: http://localhost:8001 jobs: python-checks: diff --git a/euphro_tools/download_urls.py b/euphro_tools/download_urls.py index 1b9f879ec..beea746b3 100644 --- a/euphro_tools/download_urls.py +++ b/euphro_tools/download_urls.py @@ -1,8 +1,8 @@ """Everything related to getting signed download link from euphro tools.""" import os +import typing from datetime import datetime -from typing import Literal import requests @@ -11,7 +11,7 @@ from .exceptions import EuphroToolsException from .utils import get_run_data_path -DataType = Literal["raw_data", "processed_data"] +DataType = typing.Literal["raw_data", "processed_data"] def generate_download_url( @@ -54,3 +54,29 @@ def fetch_token_for_run_data( except (requests.HTTPError, requests.ConnectionError) as error: raise EuphroToolsException from error return request.json()["token"] + + +class GetUrlAndTokenForProjectImagesResponse(typing.TypedDict): + base_url: str + token: str + + +def get_storage_info_for_project_images( + project_slug: str, +) -> GetUrlAndTokenForProjectImagesResponse: + """Get a download URL and token for a project's images.""" + url = ( + os.environ["EUPHROSYNE_TOOLS_API_URL"] + + f"/images/projects/{project_slug}/signed-url" + ) + token = EuphroToolsAPIToken.for_euphrosyne().access_token + try: + request = requests.get( + url, + timeout=5, + headers={"Authorization": f"Bearer {token}"}, + ) + request.raise_for_status() + except (requests.HTTPError, requests.ConnectionError) as error: + raise EuphroToolsException from error + return request.json() diff --git a/euphro_tools/exceptions.py b/euphro_tools/exceptions.py index bd112bb7f..015fa118f 100644 --- a/euphro_tools/exceptions.py +++ b/euphro_tools/exceptions.py @@ -1,2 +1,3 @@ +# pylint: disable=cyclic-import class EuphroToolsException(Exception): pass diff --git a/euphrosyne/settings.py b/euphrosyne/settings.py index 1ad8aaf96..7f414da09 100644 --- a/euphrosyne/settings.py +++ b/euphrosyne/settings.py @@ -311,6 +311,9 @@ def build_development_db_name(base_db_name): }, } +EUPHROSYNE_TOOLS_API_URL = os.environ["EUPHROSYNE_TOOLS_API_URL"] +EROS_BASE_IMAGE_URL = os.getenv("EROS_BASE_IMAGE_URL") + S3_BUCKET_NAME = os.getenv("S3_BUCKET_NAME") S3_BUCKET_REGION_NAME = os.getenv("S3_BUCKET_REGION_NAME") S3_ENDPOINT_URL = os.getenv("S3_ENDPOINT_URL") diff --git a/lab/objects/c2rmf.py b/lab/objects/c2rmf.py index a8030cb53..6f36adc51 100644 --- a/lab/objects/c2rmf.py +++ b/lab/objects/c2rmf.py @@ -4,6 +4,7 @@ from typing import Any import requests +from django.conf import settings from lab.thesauri.models import Era @@ -99,3 +100,29 @@ def fetch_full_objectgroup_from_eros( updated_og.inventory = data.get("inv") or "" updated_og.materials = (data.get("support") or "").split(" / ") return updated_og + + +def construct_image_url_from_eros_path(path: str) -> str: + # pylint: disable=import-outside-toplevel + from euphro_auth.jwt.tokens import EuphroToolsAPIToken + + c2rmf_id, image_id = path.split("/") + if c2rmf_id.startswith("C2RMF"): + image_category = f"pyr-{c2rmf_id[:6]}" + elif c2rmf_id.startswith("F"): + image_category = f"pyr-{c2rmf_id[:2]}" + else: + image_category = "pyr-FZ" + + eros_base_url = ( + settings.EROS_BASE_IMAGE_URL or f"{settings.EUPHROSYNE_TOOLS_API_URL}/eros" + ) + + url = f"{eros_base_url}/iiif/{image_category}/{c2rmf_id}/{image_id}.tif/full/500,/0/default.jpg" # pylint: disable=line-too-long + + # Add token to the URL if using EROS direct URL. Else we use the EuphroTools API + # proxy which includes the token in the request headers. + if settings.EROS_BASE_IMAGE_URL: + token = EuphroToolsAPIToken.for_euphrosyne().access_token + return f"{url}?token={token}" + return url diff --git a/lab/objects/models.py b/lab/objects/models.py index 2d07d1977..92d192707 100644 --- a/lab/objects/models.py +++ b/lab/objects/models.py @@ -138,3 +138,32 @@ class Meta: name="run_object_group_image_unique_path_transform_perrun_object_group", ), ] + + @property + def file_name(self) -> str: + if ( + self.path.startswith("C2RMF") + or self.path.startswith("FZ") + or self.path.startswith("F") + ) and len(self.path.split("/")) == 2: + image_id = self.path.split("/")[1] + return f"{image_id}.tiff" + return self.path.rsplit("/", maxsplit=1)[-1].split("?")[0] + + +def construct_image_url_from_path( + path: str, storage_base_url: str, storage_token: str | None = None +) -> str: + # pylint: disable=import-outside-toplevel + from lab.objects.c2rmf import construct_image_url_from_eros_path + + if ( + path.startswith("C2RMF") or path.startswith("FZ") or path.startswith("F") + ) and len(path.split("/")) == 2: + return construct_image_url_from_eros_path(path) + + return ( + f"{storage_base_url}{path}?{storage_token}" + if storage_token + else f"{storage_base_url}{path}" + ) diff --git a/lab/objects/tests/test_c2mrf.py b/lab/objects/tests/test_c2mrf.py index af02f726d..4d0ed6db1 100644 --- a/lab/objects/tests/test_c2mrf.py +++ b/lab/objects/tests/test_c2mrf.py @@ -1,6 +1,7 @@ from unittest import mock from ..c2rmf import ( + construct_image_url_from_eros_path, fetch_full_objectgroup_from_eros, fetch_partial_objectgroup_from_eros, ) @@ -43,3 +44,32 @@ def test_fetch_full_objectgroup_from_eros(_): assert og.dating_era.label == "1500" assert og.inventory == "ODUT 01107" assert og.materials == ["terre cuite"] + + +@mock.patch("lab.objects.c2rmf.settings") +def test_construct_image_url_from_eros_path(mock_settings): + + mock_settings.EROS_BASE_IMAGE_URL = "http://example.com" + mock_settings.EUPHROSYNE_TOOLS_API_URL = "http://tools.example.com" + + path = "C2RMF12345/67890" + expected_url = "http://example.com/iiif/pyr-C2RMF1/C2RMF12345/67890.tif/full/500,/0/default.jpg?token=" # pylint: disable=line-too-long + assert construct_image_url_from_eros_path(path).startswith(expected_url) + + path = "F12345/67890" + expected_url = ( + "http://example.com/iiif/pyr-F1/F12345/67890.tif/full/500,/0/default.jpg?token=" + ) + assert construct_image_url_from_eros_path(path).startswith(expected_url) + + path = "Z12345/67890" + expected_url = ( + "http://example.com/iiif/pyr-FZ/Z12345/67890.tif/full/500,/0/default.jpg?token=" + ) + assert construct_image_url_from_eros_path(path).startswith(expected_url) + + # No token when EROS_BASE_IMAGE_URL is None + mock_settings.EROS_BASE_IMAGE_URL = None + path = "C2RMF12345/67890" + expected_url = "http://tools.example.com/eros/iiif/pyr-C2RMF1/C2RMF12345/67890.tif/full/500,/0/default.jpg" # pylint: disable=line-too-long + assert construct_image_url_from_eros_path(path) == expected_url diff --git a/lab/objects/tests/test_run_object_group_image.py b/lab/objects/tests/test_run_object_group_image.py new file mode 100644 index 000000000..30d5a6293 --- /dev/null +++ b/lab/objects/tests/test_run_object_group_image.py @@ -0,0 +1,17 @@ +from django.test import SimpleTestCase + +from ..models import RunObjetGroupImage + + +class TestRunObjectGroupImage(SimpleTestCase): + def test_run_object_group_image_file_name(self): + + assert RunObjetGroupImage(path="C2RMF77463/KOA68").file_name == "KOA68.tiff" + assert RunObjetGroupImage(path="FZ77463/KOA68").file_name == "KOA68.tiff" + assert RunObjetGroupImage(path="FBLABLA/KOA68").file_name == "KOA68.tiff" + assert ( + RunObjetGroupImage( + path="/project-test/images/object-groups/163/abcdefghif.jpg" + ).file_name + == "abcdefghif.jpg" + ) diff --git a/lab/permissions.py b/lab/permissions.py index 8965631f9..c175a9580 100644 --- a/lab/permissions.py +++ b/lab/permissions.py @@ -1,4 +1,5 @@ import enum +import typing from functools import wraps from django.contrib.auth.models import AnonymousUser @@ -6,9 +7,11 @@ from django.http.request import HttpRequest from euphro_auth.models import User -from lab.models import Project from shared.view_mixins import StaffUserRequiredMixin +if typing.TYPE_CHECKING: + from lab.models import Project + class LabRole(enum.IntEnum): ANONYMOUS = 0 @@ -18,7 +21,7 @@ class LabRole(enum.IntEnum): LAB_ADMIN = 4 -def get_user_permission_group(request: HttpRequest, project: Project) -> LabRole: +def get_user_permission_group(request: HttpRequest, project: "Project") -> LabRole: if request.user.is_anonymous: return LabRole.ANONYMOUS user: User = request.user @@ -41,7 +44,7 @@ def is_lab_admin(user: User | AnonymousUser) -> bool: return bool(user.is_superuser or getattr(user, "is_lab_admin", None)) -def is_project_leader(user: User, project: Project) -> bool: +def is_project_leader(user: User, project: "Project") -> bool: return project.participation_set.filter(user=user, is_leader=True).exists() @@ -50,6 +53,8 @@ class ProjectMembershipRequiredMixin(StaffUserRequiredMixin): def dispatch( self, request: HttpRequest, project_id: int, *args, **kwargs ) -> HttpResponse: + from lab.models import Project # pylint: disable=import-outside-toplevel + response = super().dispatch(request, *args, **kwargs) if request.user.is_anonymous: return self.handle_no_permission() @@ -65,6 +70,8 @@ def dispatch( def project_membership_required(view_func): @wraps(view_func) def _wrapped_view(request, project_id: int, *args, **kwargs): + from lab.models import Project # pylint: disable=import-outside-toplevel + try: project = Project.objects.get(pk=project_id) except Project.DoesNotExist: diff --git a/lab_notebook/pdf_export/__init__.py b/lab_notebook/pdf_export/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lab_notebook/pdf_export/pdf.py b/lab_notebook/pdf_export/pdf.py new file mode 100644 index 000000000..8bbad041c --- /dev/null +++ b/lab_notebook/pdf_export/pdf.py @@ -0,0 +1,391 @@ +import tempfile +import typing +from io import BytesIO + +from django.utils.translation import gettext as _ +from PIL import Image as PILImage +from PIL import ImageDraw, ImageFont +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import inch +from reportlab.platypus import ( + Flowable, + HRFlowable, + Image, + PageBreak, + Paragraph, + SimpleDocTemplate, + Spacer, + Table, + TableStyle, +) +from reportlab.platypus.tableofcontents import TableOfContents + +if typing.TYPE_CHECKING: + from lab.methods.dto import MethodDTO + +styles = getSampleStyleSheet() + + +# Define TypedDicts for type hinting +class Transform(typing.TypedDict): + width: float + height: float + x: float + y: float + + +class PointLocation(typing.TypedDict): + x: float + y: float + width: float + height: float + + +class NotebookImage(typing.TypedDict): + object_group_label: str + file_name: str + url: str + transform: Transform | None + point_locations: list[tuple[str, PointLocation]] # (name, point_location) + content: BytesIO + + +class MeasuringPointObjectGroup(typing.TypedDict): + label: str + + +class MeasuringPointStandard(typing.TypedDict): + label: str + + +class MeasuringPoint(typing.TypedDict): + name: str + comments: str + object_group: MeasuringPointObjectGroup | None + standard: MeasuringPointStandard | None + + +class Project(typing.TypedDict): + slug: str + name: str + + +class Notebook(typing.TypedDict): + comments: str + + +class Run(typing.TypedDict): + + label: str + project: Project + particle_type: str + energy_in_keV: int | None + beamline: str + run_notebook: Notebook + + +class MeasuringPointTitle(Paragraph): + measuring_point_name: str + analysis_type: typing.Literal["object_group", "standard"] | None + analysed_entity_label: str | None + + def __init__( + self, + text, + *args, + analysis_type: typing.Literal["object_group", "standard"] | None = None, + analysed_entity_label: str | None = None, + **kwargs, + ): + self.measuring_point_name = text + self.analysed_entity_label = analysed_entity_label + self.analysis_type = analysis_type + super().__init__(text, *args, **kwargs) + + +class CustomDocTemplate(SimpleDocTemplate): + def afterFlowable(self, flowable): + # Registers Table of Content entries + if isinstance(flowable, MeasuringPointTitle): + toc_entry_text = flowable.measuring_point_name + if flowable.analysed_entity_label: + toc_entry_text += f" - {flowable.analysed_entity_label}" + if flowable.analysis_type: + text = "OBJ" if flowable.analysis_type == "object_group" else "STD" + toc_entry_text += f" [{text}]" + + key = f"measuring-point-{flowable.measuring_point_name}" + self.canv.bookmarkPage(key) + self.notify("TOCEntry", (0, toc_entry_text, self.page, key)) + + +def create_pdf( + path: str, + run: Run, + run_methods: list["MethodDTO"], + measuring_points: list[MeasuringPoint], + images: list[NotebookImage], +): + with tempfile.TemporaryDirectory() as tmpdirname: + doc = CustomDocTemplate( + path, title=f"{run['label']} - {run['project']['slug']}", pagesize=A4 + ) + story: list[Flowable] = [] + + add_project_and_run_info(story, run) + story.extend(generate_experimental_conditions_story(run, run_methods)) + story.append(Spacer(1, 0.2 * inch)) + add_comments(story, run["run_notebook"]["comments"]) + story.append(Spacer(1, 0.2 * inch)) + story.extend(generate_images_with_points_story(images, tmpdirname)) + story.append(PageBreak()) + add_table_of_contents(story) + story.append(PageBreak()) + story.extend( + generate_measuring_points_story( + measuring_points, notebook_images=images, images_temp_dir=tmpdirname + ) + ) + + doc.multiBuild(story) + + +def add_project_and_run_info(story, run: Run): + story.append(Paragraph(_("Project: %s") % run["project"]["name"], styles["Title"])) + story.append(Paragraph(_("Run: %s") % run["label"], styles["Title"])) + + +def add_comments(story, comments: str): + story.append(Paragraph(_("Comments"), styles["Heading2"])) + story.append(Paragraph(comments or "-", styles["Normal"])) + + +def add_table_of_contents(story): + story.append(Paragraph(_("Table of contents"), styles["Heading1"])) + story.append(TableOfContents()) + + +def generate_experimental_conditions_story(run: Run, run_methods: list["MethodDTO"]): + story = [] + story.append(Paragraph(_("Experimental conditions"), styles["Heading2"])) + story.append( + Paragraph( + _("Particle type: %s") % (run["particle_type"] or "-"), styles["Normal"] + ) + ) + story.append( + Paragraph(_("Energy: %s") % (run["energy_in_keV"] or "-"), styles["Normal"]) + ) + story.append( + Paragraph(_("Beamline: %s") % (run["beamline"] or "-"), styles["Normal"]) + ) + + for method in run_methods: + story.append(Paragraph(_("Method: %s") % method.name, styles["Heading3"])) + for detector in method.detectors: + story.append(Paragraph(_("Detector: %s") % detector.name, styles["Normal"])) + for detector_filter in detector.filters: + filter_style = ParagraphStyle( + name="Indented", + parent=styles["Normal"], + leftIndent=10, + ) + story.append(Paragraph(_("Filter: %s") % detector_filter, filter_style)) + return story + + +def generate_images_with_points_story( + images: list[NotebookImage], images_temp_dir: str +): + story = [] + story.append(Paragraph(_("Run images with point locations"), styles["Heading2"])) + max_width = 3 * inch + max_height = 3 * inch + + rendered_images = [] + for image in images: + file_name = image["file_name"] + output_path = f"{images_temp_dir}/{file_name}" + draw_image_with_points(image=image, output_path=output_path) + + img = Image(output_path) + img.drawWidth, img.drawHeight = resize_image(img, max_width, max_height) + + image_label = Paragraph(image["object_group_label"], styles["Normal"]) + + rendered_images.append((img, image_label)) + + images_per_row = 2 + images_rows = [] + for i in range(0, len(rendered_images), images_per_row): + images_rows.append(rendered_images[i : i + images_per_row]) # noqa: E203 + + table = Table(images_rows) + story.append(table) + + return story + + +def generate_measuring_points_story( # pylint: disable=too-many-locals + measuring_points: list[MeasuringPoint], + notebook_images: list[NotebookImage], + images_temp_dir: str, +): + measuring_point_images: dict[str, NotebookImage] = {} + for image in notebook_images: + for location in image["point_locations"]: + measuring_point_images[location[0]] = image + + story = [] + story.append(Paragraph(_("Measuring points"), styles["Heading2"])) + for measuring_point in measuring_points: + analysis_type, analysed_entity_label, analysis_type_label = get_analysis_info( + measuring_point + ) + + point_title = MeasuringPointTitle( + measuring_point["name"], + styles["Heading3"], + analysis_type=analysis_type, + analysed_entity_label=analysed_entity_label, + ) + story.append(point_title) + + cols = [] + left_col = [] + left_col.append(Paragraph(_("Analysis type"), styles["Heading4"])) + left_col.append(Paragraph(analysis_type_label, styles["Normal"])) + + if analysed_entity_label: + left_col.append( + Paragraph( + _("Reference"), + styles["Heading4"], + ) + ) + left_col.append( + Paragraph( + analysed_entity_label, + styles["Normal"], + ) + ) + cols.append(left_col) + + if measuring_point["name"] in measuring_point_images: + img = generate_image_with_points( + measuring_point, measuring_point_images, images_temp_dir + ) + cols.append([img]) + + table = Table([cols]) + table.setStyle( + TableStyle( + [ + ("VALIGN", (0, 0), (1, 1), "TOP"), + ] + ) + ) + story.append(table) + + add_comments(story, measuring_point["comments"]) + + story.append( + HRFlowable( + width="60%", + thickness=1, + lineCap="round", + color="#ededed", + spaceBefore=0.3 * inch, + spaceAfter=0.5 * inch, + ) + ) + + return story + + +def get_analysis_info(measuring_point: MeasuringPoint): + analysis_type = None + analysed_entity_label = None + analysis_type_label = "-" + if measuring_point["object_group"]: + analysis_type = "object_group" + analysis_type_label = _("Object group") + analysed_entity_label = measuring_point["object_group"]["label"] + if measuring_point["standard"]: + analysis_type = "standard" + analysis_type_label = _("Standard") + analysed_entity_label = measuring_point["standard"]["label"] + return analysis_type, analysed_entity_label, analysis_type_label + + +def generate_image_with_points( + measuring_point: MeasuringPoint, + measuring_point_images: dict[str, NotebookImage], + images_temp_dir: str, + max_width: float = 3 * inch, + max_height: float = 3 * inch, +): + image = measuring_point_images[measuring_point["name"]] + file_name = image["file_name"] + output_path = f"{images_temp_dir}/{measuring_point['name']}-{file_name}" + draw_image_with_points(image=image, output_path=output_path) + img = Image(output_path) + img.drawWidth, img.drawHeight = resize_image(img, max_width, max_height) + return img + + +def resize_image(img: Image, max_width: float, max_height: float): + aspect_ratio = img.drawWidth / img.drawHeight + if img.drawWidth > max_width or img.drawHeight > max_height: + if aspect_ratio > 1: + # Landscape orientation + width = max_width + height = max_width / aspect_ratio + else: + # Portrait orientation + height = max_height + width = max_height * aspect_ratio + else: + width = img.drawWidth + height = img.drawHeight + return width, height + + +def draw_image_with_points(image: NotebookImage, output_path: str): + pil_image: PILImage.ImageFile.ImageFile | PILImage.Image = PILImage.open( + image["content"] + ) + if image["transform"]: + pil_image = pil_image.crop( + ( + image["transform"]["x"], + image["transform"]["y"], + image["transform"]["x"] + image["transform"]["width"], + image["transform"]["y"] + image["transform"]["height"], + ) + ) + draw = ImageDraw.Draw(pil_image) + + ratio = pil_image.width / 1000 + + for point_name, location in image["point_locations"]: + x, y = location["x"], location["y"] + if not (location.get("width") and location.get("height")): + r = 10 * ratio + left_up_point = (x - r, y - r) + right_down_point = (x + r, y + r) + draw.ellipse([left_up_point, right_down_point], fill="red") + else: + left_right_point = (x, y) + right_down_point = (x + location["width"], y + location["height"]) + draw.rectangle([left_right_point, right_down_point], outline="red") + font_size = 35 * ratio + font = ImageFont.load_default(size=font_size if font_size > 1 else 1) + draw.text( + (location["x"] + (20 * ratio), location["y"] - (40 * ratio)), + point_name, + fill="red", + font=font, + ) + pil_image.save(output_path) diff --git a/lab_notebook/pdf_export/views.py b/lab_notebook/pdf_export/views.py new file mode 100644 index 000000000..9895abf45 --- /dev/null +++ b/lab_notebook/pdf_export/views.py @@ -0,0 +1,118 @@ +import concurrent.futures +import tempfile +import typing +from io import BytesIO + +import requests +from django.http import HttpRequest, HttpResponse +from django.shortcuts import get_object_or_404 +from slugify import slugify + +from euphro_tools.download_urls import get_storage_info_for_project_images +from lab import models as lab_models +from lab.measuring_points.models import MeasuringPoint +from lab.methods.dto import method_model_to_dto +from lab.objects.models import RunObjetGroupImage, construct_image_url_from_path +from lab.permissions import is_lab_admin + +from .pdf import MeasuringPoint as MeasuringPointMapping +from .pdf import NotebookImage, PointLocation, create_pdf + + +def _get_image_content(image_url: str): + return BytesIO(requests.get(image_url, timeout=10).content) + + +# pylint: disable=too-many-locals +def export_notebook_to_pdf_view(request: HttpRequest, run_id: str): + run = get_object_or_404(lab_models.Run, pk=run_id) + + run_object_group_images = ( + RunObjetGroupImage.objects.filter(run_object_group__run=run) + .prefetch_related("measuring_point_images") + .select_related("run_object_group", "run_object_group__objectgroup") + .all() + ) + storage_info = get_storage_info_for_project_images(run.project.slug) + images: list[NotebookImage] = [] + + with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor: + futures = [ + executor.submit( + _get_image_content, + construct_image_url_from_path( + image.path, storage_info["base_url"], storage_info["token"] + ), + ) + for image in run_object_group_images + ] + concurrent.futures.wait(futures) + images_content = [future.result() for future in futures] + + for image, content in zip(run_object_group_images, images_content): + image_url = construct_image_url_from_path( + image.path, storage_info["base_url"], storage_info["token"] + ) + locations: list[tuple[str, PointLocation]] = [] + for name, location in image.measuring_point_images.values_list( + "measuring_point__name", "point_location" + ): + if location: + locations.append((name, typing.cast(PointLocation, location))) + + images.append( + { + "file_name": image.file_name, + "url": image_url, + "transform": image.transform, + "point_locations": locations, + "content": content, + "object_group_label": image.run_object_group.objectgroup.label, + } + ) + + measuring_points: list[MeasuringPointMapping] = [ + { + "name": point.name, + "comments": point.comments, + "object_group": ( + {"label": point.object_group.label} if point.object_group else None + ), + "standard": ( + {"label": point.standard.standard.label} + if hasattr(point, "standard") + else None + ), + } + for point in MeasuringPoint.objects.filter(run=run) + .select_related("object_group", "standard") + .order_by("name") + ] + + if not ( + is_lab_admin(request.user) + or run.project.members.filter(id=request.user.id).exists() + ): + return HttpResponse(status=403) + with tempfile.NamedTemporaryFile(delete_on_close=False) as fp: + create_pdf( + path=fp.name, + run={ + "label": run.label, + "project": {"slug": run.project.slug, "name": run.project.name}, + "particle_type": run.particle_type, + "energy_in_keV": run.energy_in_keV, + "beamline": run.beamline, + "run_notebook": {"comments": run.run_notebook.comments}, + }, + run_methods=method_model_to_dto(run), + measuring_points=measuring_points, + images=images, + ) + fp.close() + with open(fp.name, mode="rb") as f: + response = HttpResponse(f.read(), content_type="application/pdf") + response["Content-Disposition"] = ( + f"attachment; filename={slugify(run.label)}_{run.project.slug}.pdf" + ) + return response diff --git a/lab_notebook/templates/notebook/notebook.html b/lab_notebook/templates/notebook/notebook.html index c700f7c31..fd558e88b 100644 --- a/lab_notebook/templates/notebook/notebook.html +++ b/lab_notebook/templates/notebook/notebook.html @@ -31,9 +31,9 @@

{{ run.label }}

- + diff --git a/lab_notebook/tests/__init__.py b/lab_notebook/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lab_notebook/tests/pdf_export/__init__.py b/lab_notebook/tests/pdf_export/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lab_notebook/tests/pdf_export/test_pdf.py b/lab_notebook/tests/pdf_export/test_pdf.py new file mode 100644 index 000000000..03895dafa --- /dev/null +++ b/lab_notebook/tests/pdf_export/test_pdf.py @@ -0,0 +1,291 @@ +import base64 +import tempfile +from io import BytesIO +from unittest import mock + +from django.utils.translation import gettext as _ +from PIL import Image +from reportlab.lib.pagesizes import A4 +from reportlab.pdfgen.canvas import Canvas +from reportlab.platypus import HRFlowable, Paragraph, Table + +from ...pdf_export.pdf import ( + MeasuringPointTitle, + create_pdf, + draw_image_with_points, + generate_image_with_points, + generate_measuring_points_story, + get_analysis_info, +) + +# pylint: disable=line-too-long +BASE_64_IMAGE = "iVBORw0KGgoAAAANSUhEUgAAAPAAAADwCAQAAACUXCEZAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QA/4ePzL8AAAAHdElNRQfoBhUMIihFWXb8AAABqUlEQVR42u3RoQ0CQQAAwf3POTQJJUA5r2kRQR0YNBXQAZKnCNxltoSdpa1jmrO9++jWq48XU3buO1q69vRiyh6tqwtzBxiwAAuwAAuwAAswYAEWYAEWYAEWYMACLMACLMACLMACDFiABViABViABRiwAAuwAAuwAAswYAEWYAEWYAEWYAEGLMACLMACLMACDFiABViABViABRiwBYAFWIAFWIAFWIABC7AAC7AAC7AAAxZgARZgARZgARZgwAIswAIswAIswIAFWIAFWIAFWIABC7AAC7AAC7AACzBgARZgARZgARZgwAIswAIswAIswIAFWIAFWIAFWIAFGLAAC7AAC7AACzBgARZgARZgARZgAQYswAIswAIswAIMWIAFWIAFWIAFGLAAC7AAC7AAC7AAAxZgARZgARZgAQYswAIswAIswAIMWIAFWIAFWIAFWIABC7AAC7AAC7AAAxZgARZgARZgARZgwAIswAIswAIswIAFWIAFWIAFWIABC7AAC7AAC7AACzBgARZgARZgARZgwAIswAIswPq/UV0wT9qhlt6dnJi0ve0HP9AMF2SGvsYAAAAldEVYdGRhdGU6Y3JlYXRlADIwMjQtMDYtMjFUMTI6MzQ6NDArMDA6MDBepWMLAAAAJXRFWHRkYXRlOm1vZGlmeQAyMDI0LTA2LTIxVDEyOjM0OjQwKzAwOjAwL/jbtwAAAABJRU5ErkJggg==" +BASE_NOTEBOOK_IMAGE = { + "object_group_label": "test", + "file_name": "test.png", + "url": "http://test.com/test.png", + "transform": None, + "point_locations": [("test", {"x": 0, "y": 0, "width": 10, "height": 10})], + "content": BytesIO(base64.b64decode(BASE_64_IMAGE)), +} + + +def test_draw_image_with_points__creates_image(): + with tempfile.NamedTemporaryFile("w+b", suffix=".png", delete_on_close=False) as f: + draw_image_with_points( + BASE_NOTEBOOK_IMAGE, + f.name, + ) + f.close() + + assert Image.open(f.name) + + +def test_draw_image_with_points__crops(): + with tempfile.NamedTemporaryFile("w+b", suffix=".png", delete_on_close=False) as f: + draw_image_with_points( + { + **BASE_NOTEBOOK_IMAGE, + "transform": {"x": 0, "y": 0, "width": 100, "height": 100}, + }, + f.name, + ) + f.close() + + image = Image.open(f.name) + assert image.size == (100, 100) + + +def test_draw_image_with_points__very_small_image_font(): + with tempfile.NamedTemporaryFile("w+b", suffix=".png", delete_on_close=False) as f: + draw_image_with_points( + { + **BASE_NOTEBOOK_IMAGE, + "transform": {"x": 0, "y": 0, "width": 1, "height": 1}, + }, + f.name, + ) + f.close() + + assert Image.open(f.name) + + +def test_draw_image_with_points__add_points(): + with tempfile.NamedTemporaryFile("w+b", suffix=".png") as f: + draw_mock = mock.MagicMock() + with mock.patch( + "lab_notebook.pdf_export.pdf.ImageDraw.Draw", + mock.MagicMock(return_value=draw_mock), + ): + draw_image_with_points( + { + **BASE_NOTEBOOK_IMAGE, + "point_locations": [ + ("test-1", {"x": 0, "y": 0, "width": 10, "height": 10}), + ("test-2", {"x": 15, "y": 15}), + ], + }, + f.name, + ) + draw_mock.ellipse.assert_called_once() + draw_mock.rectangle.assert_called_once() + draw_mock.text.assert_called() + + +def test_generate_image_with_points(): + with tempfile.TemporaryDirectory() as temp_dir: + measuring_point = { + "name": "point-test", + "comments": "test", + "object_group": { + "label": "object test", + }, + } + measuring_point_images = { + "point-test": BASE_NOTEBOOK_IMAGE, + } + + pdf_image = generate_image_with_points( + measuring_point, measuring_point_images, temp_dir, max_height=1, max_width=1 + ) + + assert Image.open(f"{temp_dir}/point-test-{BASE_NOTEBOOK_IMAGE['file_name']}") + assert (pdf_image.drawWidth, pdf_image.drawHeight) == (1, 1) + + +def test_get_analysis_info__object_group(): + measuring_point = { + "name": "point-test", + "comments": "test", + "object_group": { + "label": "object test", + }, + "standard": None, + } + analysis_type, analysed_entity_label, analysis_type_label = get_analysis_info( + measuring_point + ) + assert analysis_type == "object_group" + assert analysed_entity_label == "object test" + assert analysis_type_label == _("Object group") + + +def test_get_analysis_info__standard(): + measuring_point = { + "name": "point-test", + "comments": "test", + "object_group": None, + "standard": { + "label": "standard test", + }, + } + analysis_type, analysed_entity_label, analysis_type_label = get_analysis_info( + measuring_point + ) + assert analysis_type == "standard" + assert analysed_entity_label == "standard test" + assert analysis_type_label == _("Standard") + + +def test_get_analysis_info__none(): + measuring_point = { + "name": "point-test", + "comments": "test", + "object_group": None, + "standard": None, + } + analysis_type, analysed_entity_label, analysis_type_label = get_analysis_info( + measuring_point + ) + assert analysis_type is None + assert analysed_entity_label is None + assert analysis_type_label == "-" + + +def test_generate_measuring_points_story(): + measuring_points = [ + { + "name": "point-test-1", + "comments": "test comment 1", + "object_group": {"label": "object test 1"}, + "standard": None, + }, + { + "name": "point-test-2", + "comments": "test comment 2", + "object_group": None, + "standard": {"label": "standard test 2"}, + }, + ] + notebook_images = [ + { + "object_group_label": "test", + "file_name": "test.png", + "url": "http://test.com/test.png", + "transform": None, + "point_locations": [ + ("point-test-1", {"x": 0, "y": 0, "width": 10, "height": 10}) + ], + "content": BytesIO(base64.b64decode(BASE_64_IMAGE)), + } + ] + + with tempfile.TemporaryDirectory() as temp_dir: + story = generate_measuring_points_story( + measuring_points, notebook_images, temp_dir + ) + + assert isinstance(story, list) + assert len(story) > 0 + assert isinstance(story[0], Paragraph) + assert story[0].text == _("Measuring points") + + # Check first measuring point + assert isinstance(story[1], MeasuringPointTitle) + assert story[1].measuring_point_name == "point-test-1" + assert story[1].analysis_type == "object_group" + assert story[1].analysed_entity_label == "object test 1" + + assert isinstance(story[2], Table) + # pylint: disable=protected-access + assert len(story[2]._cellvalues[0]) == 2 # Two columns: analysis info and image + + assert isinstance(story[3], Paragraph) + assert story[3].text == _("Comments") + + assert isinstance(story[4], Paragraph) + assert story[4].text == "test comment 1" + + assert isinstance(story[5], HRFlowable) + + # Check second measuring point + assert isinstance(story[6], MeasuringPointTitle) + assert story[6].measuring_point_name == "point-test-2" + assert story[6].analysis_type == "standard" + assert story[6].analysed_entity_label == "standard test 2" + + assert isinstance(story[7], Table) + # pylint: disable=protected-access + assert len(story[7]._cellvalues[0]) == 1 # One column: analysis info + + assert isinstance(story[8], Paragraph) + assert story[8].text == _("Comments") + + assert isinstance(story[9], Paragraph) + assert story[9].text == "test comment 2" + + assert isinstance(story[10], HRFlowable) + + +def test_create_pdf(): + run = { + "label": "test-run", + "project": {"slug": "test-project", "name": "Test Project"}, + "particle_type": "electron", + "energy_in_keV": "100", + "beamline": "test-beamline", + "run_notebook": {"comments": "Test comments"}, + } + run_methods = [ + mock.Mock( + name="Test Method", + detectors=[ + mock.Mock( + name="Test Detector", + filters=["Test Filter"], + ) + ], + ) + ] + measuring_points = [ + { + "name": "point-test-1", + "comments": "test comment 1", + "object_group": {"label": "object test 1"}, + "standard": None, + }, + { + "name": "point-test-2", + "comments": "test comment 2", + "object_group": None, + "standard": {"label": "standard test 2"}, + }, + ] + images = [ + { + "object_group_label": "test", + "file_name": "test.png", + "url": "http://test.com/test.png", + "transform": None, + "point_locations": [ + ("point-test-1", {"x": 0, "y": 0, "width": 10, "height": 10}) + ], + "content": BytesIO(base64.b64decode(BASE_64_IMAGE)), + } + ] + + with tempfile.NamedTemporaryFile("w+b", suffix=".pdf", delete=False) as f: + create_pdf(f.name, run, run_methods, measuring_points, images) + f.close() + + assert Canvas(f.name, pagesize=A4) diff --git a/lab_notebook/tests/pdf_export/test_views.py b/lab_notebook/tests/pdf_export/test_views.py new file mode 100644 index 000000000..4e9861935 --- /dev/null +++ b/lab_notebook/tests/pdf_export/test_views.py @@ -0,0 +1,94 @@ +from unittest import mock + +import pytest +from django.test import RequestFactory +from slugify import slugify + +from euphro_auth.tests import factories as auth_factories +from lab.measuring_points.models import MeasuringPoint, MeasuringPointImage +from lab.objects.models import RunObjectGroup, RunObjetGroupImage +from lab.tests import factories as lab_factories +from lab_notebook.pdf_export.views import export_notebook_to_pdf_view + + +@pytest.mark.django_db +def test_export_notebook_to_pdf_view__permission_denied(): + run = lab_factories.RunFactory() + + request = RequestFactory() + request.user = auth_factories.StaffUserFactory() + + response = export_notebook_to_pdf_view(request, run.id) + + assert response.status_code == 403 + + +@pytest.mark.django_db +@mock.patch("lab_notebook.pdf_export.views._get_image_content") +@mock.patch("lab_notebook.pdf_export.views.create_pdf") +def test_export_notebook_to_pdf_view__run_not_found( + create_pdf_mock: mock.MagicMock, get_image_content_mock: mock.MagicMock +): + get_image_content_mock.return_value = b"une image" + + request = RequestFactory() + request.user = auth_factories.LabAdminUserFactory() # type: ignore + + run = lab_factories.RunFactory() + og = lab_factories.ObjectGroupFactory() + + rog = RunObjectGroup.objects.create(run=run, objectgroup=og) + rogi = RunObjetGroupImage.objects.create( + run_object_group=rog, + path="path/image.png", + transform={"width:": 100, "height": 100, "x": 0, "y": 0}, + ) + + point = MeasuringPoint.objects.create( + name="001", run=run, object_group=og, comments="comments" + ) + MeasuringPointImage.objects.create( + measuring_point=point, + run_object_group_image=rogi, + point_location={"width:": 55, "height": 55, "x": 2, "y": 3}, + ) + + response = export_notebook_to_pdf_view(request, run.id) # type: ignore + + assert response.headers["Content-Type"] == "application/pdf" + assert response.headers["Content-Disposition"] == ( + f"attachment; filename={slugify(run.label)}_{run.project.slug}.pdf" + ) + + assert get_image_content_mock.call_count == 1 + assert "path/image.png" in get_image_content_mock.call_args[0][0] + + assert create_pdf_mock.call_count == 1 + assert create_pdf_mock.call_args[1]["path"] + assert create_pdf_mock.call_args[1]["run"] == { + "label": run.label, + "project": {"slug": run.project.slug, "name": run.project.name}, + "particle_type": run.particle_type, + "energy_in_keV": run.energy_in_keV, + "beamline": run.beamline, + "run_notebook": {"comments": run.run_notebook.comments}, + } + assert "run_methods" in create_pdf_mock.call_args[1] + assert create_pdf_mock.call_args[1]["measuring_points"] == [ + { + "name": "001", + "comments": "comments", + "object_group": {"label": og.label}, + "standard": None, + } + ] + assert create_pdf_mock.call_args[1]["images"] == [ + { + "file_name": "image.png", + "url": get_image_content_mock.call_args[0][0], + "transform": {"width:": 100, "height": 100, "x": 0, "y": 0}, + "point_locations": [("001", {"width:": 55, "height": 55, "x": 2, "y": 3})], + "content": b"une image", + "object_group_label": og.label, + } + ] diff --git a/lab_notebook/urls.py b/lab_notebook/urls.py index c25ce3895..9c97540a3 100644 --- a/lab_notebook/urls.py +++ b/lab_notebook/urls.py @@ -2,6 +2,7 @@ from django.urls import path from . import views +from .pdf_export.views import export_notebook_to_pdf_view urlpatterns = [ path( @@ -9,4 +10,9 @@ site.admin_view(views.NotebookView.as_view()), # type: ignore[type-var] name="lab_run_notebook", ), + path( + "lab/run//notebook/export-pdf", + site.admin_view(export_notebook_to_pdf_view), # type: ignore[type-var] + name="lab_run_notebook_export_pdf", + ), ] diff --git a/locale/fr/LC_MESSAGES/django.mo b/locale/fr/LC_MESSAGES/django.mo index 9868bea98e143842fa84dd4bc3bb547d3a0e8987..0d6be5ee8f1c5aa8009262b2e1584ab6f03409ba 100644 GIT binary patch delta 8015 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 delta 7380 zcmZYD30PKD9>?+XvdW@>fZ&1xvIwFeF1W9_gJP(eiW-S#<}#qRxV)~-q?iktWs6&z zWv0DpW962nW!a22TDhi~lV+M)NjBKH8B+)61{<@B{9=@9jrqB@G0}Jh8(<(8rC=6PW$wjTEW>$NjtjARoH3hm z6{h0Ay2gay3{-pbv8FL@vx=l174IYWnxhzq*U=YmV?6qDk#0=FL~M;Mun4Q+(^wVP z+VUn$r@Rw0@kdO<+VRFjU{{Q!e{(NMbt=Z9PRy__M%AyuD!AFY9hoGv&z28i0Oe!W zGpK=_N8NuNc~TRc;FyG=l-r{}{hQt-YOo)wqoMYK5)7wYihA-nsF`^V)$nT6lfR2G zxZBnrv-OuSlKPw02&PT4>hH)p&B}bdh(;F zCp?YX3xA;6iK_2hmyTNdZ0v&VQTNTR&;0AcC#legR-!i9I;0JA2(=`?q8bQHV#+WQ zHNcvvnM*|tC>!-at*24a%jwhh{osz`-YtxleQ3KbZZrq6-aSyWpjDIrI zhDrQTgS}8wUWjUNEEZs?E$>DRY(MJy!?t`9^`PfbGk(oYQb^*HLgP3Pb-`3r!}Cxh zEl1t32DK+Pp=RVGOv5iwuVbY>AC>CdpNzUb12seKuswD~Evb7h$wZP@QJXP_`PZwz z90PH^Ex(Idls`pvd;>MG0D9Me>!3PHLEWE&+7o#=8b@Imeu29GB+|ayTp-b!T}6Mq zjcUlBofd>OunNXw0H&d?&$8uqs6CR4x_%&z!V$K-5A`4it>4)C(^y51{3A&>F8Brg zuuVg!gO2D!IUm(<0W!O0Fls4gVK6R64d8j~h;N|=d-M}~hVuY5P%{ySp_=kW zB-+ibQB&6gHMK>kDJ;W&xDeIhDby2PKwb=U6`Npq4*9~#T}>t z9zeHt@evX|`6bj8Uqe074Gh81#?C-uQ6HXcT!3A%BOb(ftj>3~0GnbIsTb10HM3rKtO+VmK~9eF0xU z&Co{F44y%?`%@P4uZkN~sAGSw()%BUTJu=c`9y4hjZhs9M1ARsP%}5hIu*5qGf^|Q z3^jn&r~z+Ab-W$b{%39yHFy{`^5durFIs=K^|w%KSuNX{sfMWQn%Z((Th2v3DJCD= zVi{^LY(#aq1@%BXQP;UYBN<0>1+|%mH+2Ry2X(`IOvI-#1>dvvC-F7PKcF`2;%3g1 zzKYc;zmI8n05y>7I2oHYXK?riGU;wpNs>oJ8o$gJ;S|&-Hz3Ct_LWIS4Xi&#;G;MR z=cA_hHfrfY*l{{v3&Sx3Ct^Enh#OE#brdxNr!Y+K{{<4=a04|}A+4NU8-cnZ4Yg*C zQA^bZ$6#lqZ?hgB!?U;$hqZQQ?jmY0{f1F^17~Ac8|Q(R;zYgwTS&%ZD)U5YoWvjd9ln| zWS^TOsJ#%{)p_6qH;F28P*a(Q8ptTr2IbTw)XJ*W8R^+QRjpZHVkY3LBA;ubqT9W#x z0X0GGg;wZ`Jy7@Mp$0Yxb=`E-gU-W5`Zudcw2OD!3y$Fk%HQb%%`G!25A8s)Gi3&TE%tZH21uj3L+ywIl=4J%!{U61|3}Q6G*wtUZ<_f9^`UFhhxwmFGOiEniC0j&|KYw)15cuM z>r&K`Y(hQ3SyYEVqZ$q>aEwAdNIlejEpRLju=TsFU!!K^asl(NsklysMjX=5*}YMy zHB3j(mEnTrKKaORT=b{EyiR#Gr9y_DfXjHrP+$4o04Nx6UL%j_% zZF!k3KZhNt-+wL-o^;rfokU^<1~~(1 zjnR~QVSwKM5#EGNG#-j${M$OC;)Y`AKZa}T+Hq^jBMm^!@sQZqirv4J@KG%KD zw?7>90Nqgi3`XzY|6@tCTOUR3f#s+MpGOVgO-#nEs18qI8lFRalEVi(GZl^XC?{Y| z?1VLN2x_yIqB@>t%L~!_`+qr!E?i?zY(;gv8?^@xqIc7w)-HI6V+?AEl59B(SyR&v zxzvnA4R{Bt=B2{py7FdOr+3(iD+2lgQQ+GMd)wQ1)Lr}qMq{Z#ybwMRIAEc)?1RmWMVZ+8)D z)6B+c_$=zlql=xHOF#{{C2BxDaTbomws;P8efk_nP@az~aXqGE-YE71x*s7KPjUhK z;C-W=--54WJ<6w1Q|5Zm`BQ2DCR47!6x@m$=(nhjzQ;ri9OEoSIz~}$Z_9#~oa@6-9mHc5Y>YveW9vJi zUeCVh{rz7|qP3Wc!MGG%sM*LPz9e+iCq5xQP=RBugZH!B-R2^QoBxK@k$vl3mP0yW zk95>hG$g!#|HrF0j(DO8`6|>C#*lmPBcclVCe(3>m_qRSdwuB4kYyHEXJ%cY25!5e6?Uk;?-w6)4>1JzG4q^k)D2QJZsDH2->C?i{Hk7YSZtvxcZ|8z40= z>j%dcqC53Jq90DcYM4j7K(3=6c^3@umj3@oW6n3F+I@>3pV{*n)}Qb=aoLuq<3l$0 z$6DO1qo!54UNs#)#P{CJ`B4r_h;4V(-AS9dn16mtdjD0c$|gih8r_NmRl(8ePA3>k zIm(v*L!P4xIF1r;5pNPfgpOQ$-7xa`-Wp>bzyg~;g^Qg1&u5mxb3{HdmS{_SO61Up zJ{I+e4DxEYoroiEg^?JCIu1LSRO%*?_ajos^%3h!q?5mn6~u5t#{#W?bK+5=Gtruh zLNJnOM?M4>5=n%P5yT84i@0&a`W3*AigGa1X7pjq<{W=maG9WjrfPw zzdaZAvZo)$2%8_Vw!|Gou`P?|@5&2p{!ctj{QItR9q-Ch$Zy+nAU2}k5H~*_r%;u6 zm&hfS5qFNSNTP|e#CJp`af1luJ{{MHPTq|F!pAzqQlc~GI}lxn1C);u?-MT(I$YEz z63I23viD~e75CWkd-w#=L1mABT&7<|=Y%z$D+zmjJbRLYT^0AI*796SPI7q?Qv?0| z#ub;8dU|G5`gjU6SNK%?-Xy9bCVOSYk?dSgV$<3#Pq$_xU7in{Uk|VNsM`t8=I*O~ zJte*SxGL84iT9k!8}ITA>hq<4Kz_-%;?fdN-~ONYdKwM7>JwXL3Q8wTnlRqvP8dIa o_qvj@_~O#ZlS;}e`V84w(Q0UKPx;UpE>GmJuUwv6!;7o_7jH``00000 diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 8991c0c3f..b41d11e5c 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-11-15 18:14+0100\n" +"POT-Creation-Date: 2024-12-03 17:20+0100\n" "PO-Revision-Date: 2021-09-09 19:04+0200\n" "Language: \n" "MIME-Version: 1.0\n" @@ -1101,6 +1101,56 @@ msgstr "" "Seuls les lettres, chiffres, tirets bas, traits d'union et espaces sont des " "caractères valides" +#, python-format +msgid "Project: %s" +msgstr "Projet: %s" + +#, python-format +msgid "Run: %s" +msgstr "Run: %s" + +msgid "Table of contents" +msgstr "Table des matières" + +#, python-format +msgid "Particle type: %s" +msgstr "Type de particule: %s" + +#, python-format +msgid "Energy: %s" +msgstr "Energie: %s" + +#, python-format +msgid "Beamline: %s" +msgstr "Ligne de faisceau: %s" + +#, python-format +msgid "Method: %s" +msgstr "Méthode: %s" + +#, python-format +msgid "Detector: %s" +msgstr "Détecteur: %s" + +#, python-format +msgid "Filter: %s" +msgstr "Filtre: %s" + +msgid "Run images with point locations" +msgstr "Images du run avec localisation des points" + +msgid "Measuring points" +msgstr "Points de mesure" + +msgid "Analysis type" +msgstr "Type d'analyse" + +msgid "Reference" +msgstr "Référence" + +msgid "Standard" +msgstr "Standard" + msgid "Notebook" msgstr "Cahier de laboratoire" diff --git a/requirements/base.txt b/requirements/base.txt index a33a8fcde..b789e23f6 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -12,11 +12,13 @@ graphene-django==3.2.2 gunicorn==23.0.0 markdown==3.7 opensearch-py==2.8.0 +pillow==11.0.0 psycopg2==2.9.10 python-dotenv==1.0.1 python3-openid==3.2.0 python-slugify==8.0.4 pyyaml==6.0.2 +reportlab==4.2.5 sentry-sdk==2.19.0 social-auth-app-django==5.4.2 wheel==0.45.1