ewoksdrac 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ewoksdrac-0.1.0/LICENSE.md +20 -0
- ewoksdrac-0.1.0/PKG-INFO +64 -0
- ewoksdrac-0.1.0/README.md +7 -0
- ewoksdrac-0.1.0/pyproject.toml +85 -0
- ewoksdrac-0.1.0/setup.cfg +4 -0
- ewoksdrac-0.1.0/src/ewoksdrac/__init__.py +0 -0
- ewoksdrac-0.1.0/src/ewoksdrac/tasks/__init__.py +0 -0
- ewoksdrac-0.1.0/src/ewoksdrac/tasks/base_tasks/__init__.py +0 -0
- ewoksdrac-0.1.0/src/ewoksdrac/tasks/base_tasks/drac_investigation_task.py +15 -0
- ewoksdrac-0.1.0/src/ewoksdrac/tasks/base_tasks/drac_investigation_task_inputs.py +10 -0
- ewoksdrac-0.1.0/src/ewoksdrac/tasks/base_tasks/drac_task.py +74 -0
- ewoksdrac-0.1.0/src/ewoksdrac/tasks/base_tasks/drac_task_inputs.py +15 -0
- ewoksdrac-0.1.0/src/ewoksdrac/tasks/base_tasks/drac_task_outputs.py +20 -0
- ewoksdrac-0.1.0/src/ewoksdrac/tasks/mail/__init__.py +0 -0
- ewoksdrac-0.1.0/src/ewoksdrac/tasks/mail/render.py +32 -0
- ewoksdrac-0.1.0/src/ewoksdrac/tasks/mail/send.py +58 -0
- ewoksdrac-0.1.0/src/ewoksdrac/tasks/mail/send_inputs.py +43 -0
- ewoksdrac-0.1.0/src/ewoksdrac/tasks/mail/smtp.py +35 -0
- ewoksdrac-0.1.0/src/ewoksdrac/tasks/mail/templates/base.html.j2 +17 -0
- ewoksdrac-0.1.0/src/ewoksdrac/tasks/mail/templates/mx_report.html.j2 +7 -0
- ewoksdrac-0.1.0/src/ewoksdrac/tasks/report/__init__.py +0 -0
- ewoksdrac-0.1.0/src/ewoksdrac/tasks/report/mx.py +55 -0
- ewoksdrac-0.1.0/src/ewoksdrac/tasks/report/mx_inputs.py +5 -0
- ewoksdrac-0.1.0/src/ewoksdrac/tasks/report/mx_outputs.py +17 -0
- ewoksdrac-0.1.0/src/ewoksdrac/tests/__init__.py +0 -0
- ewoksdrac-0.1.0/src/ewoksdrac/tests/conftest.py +9 -0
- ewoksdrac-0.1.0/src/ewoksdrac/tests/test_mx_report.py +100 -0
- ewoksdrac-0.1.0/src/ewoksdrac/tests/test_send_email.py +192 -0
- ewoksdrac-0.1.0/src/ewoksdrac/tests/test_smtp.py +65 -0
- ewoksdrac-0.1.0/src/ewoksdrac/tests/utils.py +26 -0
- ewoksdrac-0.1.0/src/ewoksdrac.egg-info/PKG-INFO +64 -0
- ewoksdrac-0.1.0/src/ewoksdrac.egg-info/SOURCES.txt +34 -0
- ewoksdrac-0.1.0/src/ewoksdrac.egg-info/dependency_links.txt +1 -0
- ewoksdrac-0.1.0/src/ewoksdrac.egg-info/entry_points.txt +3 -0
- ewoksdrac-0.1.0/src/ewoksdrac.egg-info/requires.txt +20 -0
- ewoksdrac-0.1.0/src/ewoksdrac.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
|
|
3
|
+
**Copyright (c) 2026 European Synchrotron Radiation Facility**
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
6
|
+
this software and associated documentation files (the "Software"), to deal in
|
|
7
|
+
the Software without restriction, including without limitation the rights to
|
|
8
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
|
9
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
|
10
|
+
subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
17
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
18
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
19
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
20
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
ewoksdrac-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ewoksdrac
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Ewoks tasks for the Data Portal
|
|
5
|
+
Author-email: ESRF <dau-pydev@esrf.fr>
|
|
6
|
+
License: # MIT License
|
|
7
|
+
|
|
8
|
+
**Copyright (c) 2026 European Synchrotron Radiation Facility**
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
11
|
+
this software and associated documentation files (the "Software"), to deal in
|
|
12
|
+
the Software without restriction, including without limitation the rights to
|
|
13
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
|
14
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
|
15
|
+
subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
22
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
23
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
24
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
25
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
26
|
+
|
|
27
|
+
Project-URL: Homepage, https://gitlab.esrf.fr/workflow/ewoksapps/ewoksdrac/
|
|
28
|
+
Project-URL: Documentation, https://ewoksdrac.readthedocs.io/
|
|
29
|
+
Project-URL: Repository, https://gitlab.esrf.fr/workflow/ewoksapps/ewoksdrac/
|
|
30
|
+
Project-URL: Issues, https://gitlab.esrf.fr/workflow/ewoksapps/ewoksdrac/issues
|
|
31
|
+
Project-URL: Changelog, https://gitlab.esrf.fr/workflow/ewoksapps/ewoksdrac/-/blob/main/CHANGELOG.md
|
|
32
|
+
Keywords: ewoks
|
|
33
|
+
Classifier: Intended Audience :: Science/Research
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Requires-Python: >=3.11
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
License-File: LICENSE.md
|
|
39
|
+
Requires-Dist: ewokscore
|
|
40
|
+
Requires-Dist: requests
|
|
41
|
+
Requires-Dist: pydantic
|
|
42
|
+
Requires-Dist: jinja2
|
|
43
|
+
Requires-Dist: minedrac>=1.1.0
|
|
44
|
+
Provides-Extra: test
|
|
45
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
46
|
+
Provides-Extra: dev
|
|
47
|
+
Requires-Dist: ewoksdrac[test]; extra == "dev"
|
|
48
|
+
Requires-Dist: ruff; extra == "dev"
|
|
49
|
+
Provides-Extra: doc
|
|
50
|
+
Requires-Dist: ewoksdrac[test]; extra == "doc"
|
|
51
|
+
Requires-Dist: sphinx>=4.5; extra == "doc"
|
|
52
|
+
Requires-Dist: sphinx-autodoc-typehints>=1.16; extra == "doc"
|
|
53
|
+
Requires-Dist: pydata-sphinx-theme; extra == "doc"
|
|
54
|
+
Requires-Dist: sphinx-copybutton; extra == "doc"
|
|
55
|
+
Requires-Dist: ewokssphinx>=2.0; extra == "doc"
|
|
56
|
+
Dynamic: license-file
|
|
57
|
+
|
|
58
|
+
# ewoksdrac
|
|
59
|
+
|
|
60
|
+
Ewoks tasks for the Data Portal
|
|
61
|
+
|
|
62
|
+
## Documentation
|
|
63
|
+
|
|
64
|
+
https://ewoksdrac.readthedocs.io/
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = [
|
|
3
|
+
"setuptools>=61",
|
|
4
|
+
]
|
|
5
|
+
build-backend = "setuptools.build_meta"
|
|
6
|
+
|
|
7
|
+
[project]
|
|
8
|
+
name = "ewoksdrac"
|
|
9
|
+
version = "0.1.0"
|
|
10
|
+
keywords = [
|
|
11
|
+
"ewoks"
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
authors = [{name = "ESRF", email = "dau-pydev@esrf.fr"}]
|
|
15
|
+
description = "Ewoks tasks for the Data Portal"
|
|
16
|
+
readme = {file = "README.md", content-type = "text/markdown"}
|
|
17
|
+
license = {file = "LICENSE.md"}
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Intended Audience :: Science/Research",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
]
|
|
23
|
+
requires-python = ">=3.11"
|
|
24
|
+
dependencies = [
|
|
25
|
+
"ewokscore",
|
|
26
|
+
"requests",
|
|
27
|
+
"pydantic",
|
|
28
|
+
"jinja2",
|
|
29
|
+
"minedrac >=1.1.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://gitlab.esrf.fr/workflow/ewoksapps/ewoksdrac/"
|
|
34
|
+
Documentation = "https://ewoksdrac.readthedocs.io/"
|
|
35
|
+
Repository = "https://gitlab.esrf.fr/workflow/ewoksapps/ewoksdrac/"
|
|
36
|
+
Issues = "https://gitlab.esrf.fr/workflow/ewoksapps/ewoksdrac/issues"
|
|
37
|
+
Changelog = "https://gitlab.esrf.fr/workflow/ewoksapps/ewoksdrac/-/blob/main/CHANGELOG.md"
|
|
38
|
+
|
|
39
|
+
[project.optional-dependencies]
|
|
40
|
+
test = [
|
|
41
|
+
"pytest >=7",
|
|
42
|
+
]
|
|
43
|
+
dev = [
|
|
44
|
+
"ewoksdrac[test]",
|
|
45
|
+
"ruff",
|
|
46
|
+
]
|
|
47
|
+
doc = [
|
|
48
|
+
"ewoksdrac[test]",
|
|
49
|
+
"sphinx >=4.5",
|
|
50
|
+
"sphinx-autodoc-typehints >=1.16",
|
|
51
|
+
"pydata-sphinx-theme",
|
|
52
|
+
"sphinx-copybutton",
|
|
53
|
+
"ewokssphinx >=2.0"
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
[tool.setuptools]
|
|
57
|
+
package-dir = {""= "src"}
|
|
58
|
+
|
|
59
|
+
[tool.setuptools.packages.find]
|
|
60
|
+
where = ["src"]
|
|
61
|
+
namespaces = false
|
|
62
|
+
|
|
63
|
+
[tool.setuptools.package-data]
|
|
64
|
+
"ewoksdrac.tests.data"= ["*/*"]
|
|
65
|
+
"ewoksdrac.tasks.mail"= ["templates/*.j2"]
|
|
66
|
+
|
|
67
|
+
[project.entry-points."ewoks.tasks.class"]
|
|
68
|
+
"ewoksdrac.tasks.report.*" = "ewoksdrac"
|
|
69
|
+
"ewoksdrac.tasks.mail.*" = "ewoksdrac"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
[tool.coverage.run]
|
|
73
|
+
omit = [
|
|
74
|
+
"*/tests/*"
|
|
75
|
+
]
|
|
76
|
+
[tool.ruff.lint]
|
|
77
|
+
select = ["E", "F", "I", "S"]
|
|
78
|
+
ignore = ["E203", "E501", "E701"]
|
|
79
|
+
|
|
80
|
+
[tool.ruff.lint.per-file-ignores]
|
|
81
|
+
# Ignore `S101` (assert used violations) in all test files
|
|
82
|
+
"*/test_*.py" = ["S101"]
|
|
83
|
+
|
|
84
|
+
[tool.ruff.lint.isort]
|
|
85
|
+
force-single-line = true
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .drac_investigation_task_inputs import DracInvestigationTaskInputs
|
|
2
|
+
from .drac_task import DracTask
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class DracInvestigationTask(
|
|
6
|
+
DracTask,
|
|
7
|
+
input_model=DracInvestigationTaskInputs,
|
|
8
|
+
):
|
|
9
|
+
"""
|
|
10
|
+
A task conceived to be run for a whole investigation (experiment session).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def investigation_id(self) -> int:
|
|
15
|
+
return self.inputs.investigation_id
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from pydantic import Field
|
|
2
|
+
|
|
3
|
+
from .drac_task_inputs import DracTaskInputs
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DracInvestigationTaskInputs(DracTaskInputs):
|
|
7
|
+
investigation_id: int = Field(
|
|
8
|
+
description="The id of the investigation (experiment session) this task operates on",
|
|
9
|
+
examples=[12345],
|
|
10
|
+
)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
from ewokscore import Task
|
|
5
|
+
|
|
6
|
+
from .drac_task_inputs import DracTaskInputs
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DracTask(Task, input_model=DracTaskInputs):
|
|
12
|
+
"""
|
|
13
|
+
Base class for a task launched from the Data Portal. It owns the run()
|
|
14
|
+
lifecycle and the status/log callback shared by every DRAC task.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def session_id(self) -> str:
|
|
19
|
+
return self.inputs.session_id
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def callback(self) -> str | None:
|
|
23
|
+
if self.inputs.callback is None:
|
|
24
|
+
return None
|
|
25
|
+
return str(self.inputs.callback)
|
|
26
|
+
|
|
27
|
+
def pass_on_session(self) -> None:
|
|
28
|
+
"""Output the session_id and callback, for a task whose output model
|
|
29
|
+
extends DracTaskOutputs."""
|
|
30
|
+
self.outputs.session_id = self.session_id
|
|
31
|
+
self.outputs.callback = self.callback
|
|
32
|
+
|
|
33
|
+
def process(self) -> None:
|
|
34
|
+
"""The main processing logic."""
|
|
35
|
+
raise NotImplementedError
|
|
36
|
+
|
|
37
|
+
def finalize(self) -> None:
|
|
38
|
+
"""Called after process() succeeds, e.g. to upload results to the catalogue."""
|
|
39
|
+
|
|
40
|
+
def run(self) -> None:
|
|
41
|
+
try:
|
|
42
|
+
self.set_status("STARTED")
|
|
43
|
+
self.set_status("RUNNING")
|
|
44
|
+
self.process()
|
|
45
|
+
self.finalize()
|
|
46
|
+
except Exception as ex:
|
|
47
|
+
self.log({"logs": {"error": str(ex)}})
|
|
48
|
+
self.set_status("TERMINATED", raise_on_error=True)
|
|
49
|
+
raise
|
|
50
|
+
else:
|
|
51
|
+
self.set_status("FINISHED", raise_on_error=True)
|
|
52
|
+
|
|
53
|
+
def set_status(self, status: str, raise_on_error: bool = False) -> None:
|
|
54
|
+
self.log({"status": status}, raise_on_error=raise_on_error)
|
|
55
|
+
|
|
56
|
+
def log(self, payload: dict, raise_on_error: bool = False) -> None:
|
|
57
|
+
"""
|
|
58
|
+
Sends a log payload to the callback URL if provided.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
payload (dict): The JSON payload to send.
|
|
62
|
+
raise_on_error (bool): Raise when the callback request fails instead of only logging it.
|
|
63
|
+
"""
|
|
64
|
+
logger.debug(payload)
|
|
65
|
+
if not self.callback:
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
response = requests.put(self.callback, json=payload, timeout=30)
|
|
70
|
+
response.raise_for_status()
|
|
71
|
+
except Exception as ex:
|
|
72
|
+
if raise_on_error:
|
|
73
|
+
raise
|
|
74
|
+
logger.error(f"Logging request failed: {ex}")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from ewokscore.model import BaseInputModel
|
|
2
|
+
from pydantic import Field
|
|
3
|
+
from pydantic import HttpUrl
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DracTaskInputs(BaseInputModel):
|
|
7
|
+
session_id: str = Field(
|
|
8
|
+
description="A unique identifier for the ICAT session. It is used as a token to identify the user",
|
|
9
|
+
examples=["xfthscs-234234sdfsd-fsfsd21"],
|
|
10
|
+
)
|
|
11
|
+
callback: HttpUrl | None = Field(
|
|
12
|
+
default=None,
|
|
13
|
+
description="A web endpoint to be called upon task completion or status updates. Usually a call to icat+",
|
|
14
|
+
examples=["https://icatplus.esrf.fr/callback"],
|
|
15
|
+
)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from ewokscore.model import BaseOutputModel
|
|
2
|
+
from pydantic import Field
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class DracTaskOutputs(BaseOutputModel):
|
|
6
|
+
"""Outputs of a DRAC task followed by another DRAC task in the workflow.
|
|
7
|
+
|
|
8
|
+
The Data Portal only gives the session_id and callback to the first task of a
|
|
9
|
+
workflow, so this task passes them on through the workflow links.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
session_id: str = Field(
|
|
13
|
+
description="The session_id input, passed on to the next task of the workflow",
|
|
14
|
+
examples=["xfthscs-234234sdfsd-fsfsd21"],
|
|
15
|
+
)
|
|
16
|
+
callback: str | None = Field(
|
|
17
|
+
default=None,
|
|
18
|
+
description="The callback input, passed on to the next task of the workflow",
|
|
19
|
+
examples=["https://icatplus.esrf.fr/callback"],
|
|
20
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from jinja2 import PackageLoader
|
|
5
|
+
from jinja2 import StrictUndefined
|
|
6
|
+
from jinja2 import select_autoescape
|
|
7
|
+
from jinja2.sandbox import SandboxedEnvironment
|
|
8
|
+
|
|
9
|
+
_ENVIRONMENT = SandboxedEnvironment(
|
|
10
|
+
loader=PackageLoader("ewoksdrac.tasks.mail", "templates"),
|
|
11
|
+
undefined=StrictUndefined,
|
|
12
|
+
autoescape=select_autoescape(["html.j2"]),
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def render_email(
|
|
17
|
+
subject: str, template: str, context: dict[str, Any]
|
|
18
|
+
) -> tuple[str, str]:
|
|
19
|
+
"""Render the subject string and the packaged HTML body template with the context.
|
|
20
|
+
|
|
21
|
+
The body template is ``templates/<template>.html.j2``. A variable missing from
|
|
22
|
+
the context raises instead of rendering as an empty string.
|
|
23
|
+
"""
|
|
24
|
+
variables = {
|
|
25
|
+
"data_portal_url": os.environ.get("DATA_PORTAL_URL", "https://data.esrf.fr"),
|
|
26
|
+
"contact_email": os.environ.get("CONTACT_EMAIL", "dataportalrequests@esrf.fr"),
|
|
27
|
+
"signature": os.environ.get("EMAIL_SIGNATURE", "The ESRF Data Portal"),
|
|
28
|
+
**context,
|
|
29
|
+
}
|
|
30
|
+
rendered_subject = _ENVIRONMENT.from_string(subject).render(variables)
|
|
31
|
+
body = _ENVIRONMENT.get_template(f"{template}.html.j2").render(variables)
|
|
32
|
+
return rendered_subject, body
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from minedrac.data.session import UserLookupError
|
|
4
|
+
from minedrac.data.session import get_user_email
|
|
5
|
+
|
|
6
|
+
from ..base_tasks.drac_task import DracTask
|
|
7
|
+
from .render import render_email
|
|
8
|
+
from .send_inputs import SendEmailInputs
|
|
9
|
+
from .smtp import send_email
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SendEmail(
|
|
13
|
+
DracTask,
|
|
14
|
+
input_model=SendEmailInputs,
|
|
15
|
+
):
|
|
16
|
+
"""Send an email, optionally with attachments, to users of the ICAT session"""
|
|
17
|
+
|
|
18
|
+
def process(self) -> None:
|
|
19
|
+
try:
|
|
20
|
+
self._send()
|
|
21
|
+
finally:
|
|
22
|
+
if self.inputs.delete_attachments:
|
|
23
|
+
_delete_files(self.inputs.attachments)
|
|
24
|
+
|
|
25
|
+
def _send(self) -> None:
|
|
26
|
+
try:
|
|
27
|
+
email = get_user_email(self.session_id)
|
|
28
|
+
except UserLookupError as ex:
|
|
29
|
+
raise RuntimeError(
|
|
30
|
+
f"Could not find the user of this session to email: {ex}"
|
|
31
|
+
) from ex
|
|
32
|
+
if not email:
|
|
33
|
+
raise RuntimeError("No email address found for the user of this session")
|
|
34
|
+
|
|
35
|
+
subject, body = render_email(
|
|
36
|
+
self.inputs.subject, self.inputs.template, self.inputs.context
|
|
37
|
+
)
|
|
38
|
+
attachments = self.inputs.attachments
|
|
39
|
+
self.log({"logs": {"msg": "About to send the email"}})
|
|
40
|
+
# TODO send through the email REST API once it is available
|
|
41
|
+
send_email([email], subject, body, attachments)
|
|
42
|
+
self.log(
|
|
43
|
+
{
|
|
44
|
+
"logs": {
|
|
45
|
+
"msg": "email sent",
|
|
46
|
+
"attachments": str([f.name for f in attachments]),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _delete_files(files: list[Path]) -> None:
|
|
53
|
+
for file in files:
|
|
54
|
+
file.unlink(missing_ok=True)
|
|
55
|
+
try:
|
|
56
|
+
file.parent.rmdir()
|
|
57
|
+
except OSError:
|
|
58
|
+
pass
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
from pydantic import field_validator
|
|
6
|
+
|
|
7
|
+
from ..base_tasks.drac_task_inputs import DracTaskInputs
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SendEmailInputs(DracTaskInputs):
|
|
11
|
+
subject: str = Field(
|
|
12
|
+
description="Subject of the email, a Jinja2 template rendered with the context",
|
|
13
|
+
examples=["MX report of investigation {{ investigation_id }}"],
|
|
14
|
+
)
|
|
15
|
+
template: str = Field(
|
|
16
|
+
description="Name of the packaged Jinja2 template of the HTML body, without the '.html.j2' extension",
|
|
17
|
+
examples=["mx_report"],
|
|
18
|
+
)
|
|
19
|
+
context: dict[str, Any] = Field(
|
|
20
|
+
default_factory=dict,
|
|
21
|
+
description="Variables available to the subject and body templates, usually the context output of the previous task",
|
|
22
|
+
examples=[{"investigation_id": 12345, "generated_at": "2026-10-14 10:00:00"}],
|
|
23
|
+
)
|
|
24
|
+
attachments: list[Path] = Field(
|
|
25
|
+
default_factory=list,
|
|
26
|
+
description="Paths of the files to attach to the email. A single path is also accepted",
|
|
27
|
+
examples=[
|
|
28
|
+
["/data/visitor/mx1234/reports/mx_report_12345.csv"],
|
|
29
|
+
"/data/visitor/mx1234/reports/mx_report_12345.csv",
|
|
30
|
+
],
|
|
31
|
+
)
|
|
32
|
+
delete_attachments: bool = Field(
|
|
33
|
+
default=False,
|
|
34
|
+
description="Delete the attachments, and their directory when it is left empty, once the email is sent or failed. For temporary files such as generated reports",
|
|
35
|
+
examples=[True],
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
@field_validator("attachments", mode="before")
|
|
39
|
+
@classmethod
|
|
40
|
+
def single_attachment(cls, value: Any) -> Any:
|
|
41
|
+
if isinstance(value, (str, Path)):
|
|
42
|
+
return [value]
|
|
43
|
+
return value
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import mimetypes
|
|
2
|
+
import os
|
|
3
|
+
import smtplib
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from email.message import EmailMessage
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
SMTP_TIMEOUT = 30 # seconds
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def send_email(
|
|
12
|
+
recipients: Sequence[str],
|
|
13
|
+
subject: str,
|
|
14
|
+
html_body: str,
|
|
15
|
+
attachments: Sequence[str | Path] = (),
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Send an HTML email through the SMTP server configured by the environment variables."""
|
|
18
|
+
message = EmailMessage()
|
|
19
|
+
message["Subject"] = subject
|
|
20
|
+
message["From"] = os.environ.get("SMTP_FROM", "dataportal@esrf.fr")
|
|
21
|
+
message["To"] = ", ".join(recipients)
|
|
22
|
+
message.set_content(html_body, subtype="html")
|
|
23
|
+
|
|
24
|
+
for attachment in attachments:
|
|
25
|
+
path = Path(attachment)
|
|
26
|
+
mime_type, _ = mimetypes.guess_type(path)
|
|
27
|
+
maintype, subtype = (mime_type or "application/octet-stream").split("/", 1)
|
|
28
|
+
message.add_attachment(
|
|
29
|
+
path.read_bytes(), maintype=maintype, subtype=subtype, filename=path.name
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
host = os.environ.get("SMTP_HOST", "localhost")
|
|
33
|
+
port = int(os.environ.get("SMTP_PORT", "25"))
|
|
34
|
+
with smtplib.SMTP(host, port, timeout=SMTP_TIMEOUT) as smtp:
|
|
35
|
+
smtp.send_message(message)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<html>
|
|
2
|
+
<head>
|
|
3
|
+
<meta charset="utf-8" />
|
|
4
|
+
<style>
|
|
5
|
+
.text-footer {
|
|
6
|
+
font-size: 0.8em;
|
|
7
|
+
color: #666;
|
|
8
|
+
}
|
|
9
|
+
</style>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<p>Hello,</p>
|
|
13
|
+
{% block content %}{% endblock %}
|
|
14
|
+
<p>Best regards,<br />{{ signature }}</p>
|
|
15
|
+
<p class="text-footer"><i>This email was automatically generated by the Data Portal. If you have any questions, please contact <a href="mailto:{{ contact_email }}">{{ contact_email }}</a></i></p>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{% extends "base.html.j2" %}
|
|
2
|
+
{% block content %}
|
|
3
|
+
{% set investigation_url = data_portal_url ~ "/investigation/" ~ investigation_id ~ "/datasets" %}
|
|
4
|
+
<p>Please find attached the MX report of your experiment session.</p>
|
|
5
|
+
<p>Generated on: {{ generated_at }}</p>
|
|
6
|
+
<p>The data can also be accessed online at: <a href="{{ investigation_url }}">{{ investigation_url }}</a></p>
|
|
7
|
+
{% endblock %}
|
|
File without changes
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import tempfile
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from minedrac.report.mx import generate_mx_report
|
|
6
|
+
|
|
7
|
+
from ..base_tasks.drac_investigation_task import DracInvestigationTask
|
|
8
|
+
from .mx_inputs import MXReportInputs
|
|
9
|
+
from .mx_outputs import MXReportOutputs
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MXReport(
|
|
13
|
+
DracInvestigationTask,
|
|
14
|
+
input_model=MXReportInputs,
|
|
15
|
+
output_model=MXReportOutputs,
|
|
16
|
+
):
|
|
17
|
+
"""Generate the MX report for an experiment session and output its file path"""
|
|
18
|
+
|
|
19
|
+
def process(self) -> None:
|
|
20
|
+
self.log(
|
|
21
|
+
{
|
|
22
|
+
"logs": {
|
|
23
|
+
"msg": "About to generate the report",
|
|
24
|
+
"investigation_id": str(self.investigation_id),
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
# The report must outlive the task to be emailed: SendEmail deletes it
|
|
29
|
+
# when its delete_attachments input is set
|
|
30
|
+
output_dir = tempfile.mkdtemp(prefix="mx_report_")
|
|
31
|
+
csv_path = Path(output_dir) / f"mx_report_{self.investigation_id}.csv"
|
|
32
|
+
report_files = generate_mx_report(
|
|
33
|
+
token=self.session_id,
|
|
34
|
+
investigation_ids=str(self.investigation_id),
|
|
35
|
+
csv_file=str(csv_path),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if not report_files:
|
|
39
|
+
raise RuntimeError(
|
|
40
|
+
f"No report was generated for investigation {self.investigation_id}"
|
|
41
|
+
)
|
|
42
|
+
self.outputs.report_file = Path(report_files[0])
|
|
43
|
+
self.outputs.context = {
|
|
44
|
+
"investigation_id": self.investigation_id,
|
|
45
|
+
"generated_at": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
46
|
+
}
|
|
47
|
+
self.pass_on_session()
|
|
48
|
+
self.log(
|
|
49
|
+
{
|
|
50
|
+
"logs": {
|
|
51
|
+
"msg": "report generated",
|
|
52
|
+
"report_file": Path(report_files[0]).name,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
|
|
6
|
+
from ..base_tasks.drac_task_outputs import DracTaskOutputs
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MXReportOutputs(DracTaskOutputs):
|
|
10
|
+
report_file: Path = Field(
|
|
11
|
+
description="Path of the generated report",
|
|
12
|
+
examples=["/tmp/mx_report_abcd1234/mx_report_12345.csv"], # noqa: S108
|
|
13
|
+
)
|
|
14
|
+
context: dict[str, Any] = Field(
|
|
15
|
+
description="Information about the report, for the email templates",
|
|
16
|
+
examples=[{"investigation_id": 12345, "generated_at": "2026-10-14 10:00:00"}],
|
|
17
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from unittest.mock import patch
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from ewoksdrac.tasks.report.mx import MXReport
|
|
7
|
+
from ewoksdrac.tests.utils import fail_on_status
|
|
8
|
+
from ewoksdrac.tests.utils import messages
|
|
9
|
+
from ewoksdrac.tests.utils import statuses
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def minedrac():
|
|
14
|
+
with patch("ewoksdrac.tasks.report.mx.generate_mx_report") as generate:
|
|
15
|
+
generate.side_effect = lambda **kwargs: [kwargs["csv_file"]]
|
|
16
|
+
yield generate
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def mkdtemp(tmp_path):
|
|
21
|
+
with patch("ewoksdrac.tasks.report.mx.tempfile.mkdtemp") as mkdtemp:
|
|
22
|
+
mkdtemp.return_value = str(tmp_path)
|
|
23
|
+
yield mkdtemp
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture
|
|
27
|
+
def inputs(mkdtemp):
|
|
28
|
+
return {
|
|
29
|
+
"investigation_id": "12345",
|
|
30
|
+
"session_id": "token",
|
|
31
|
+
"callback": "https://icatplus.esrf.fr/callback",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_report(callback, minedrac, inputs, tmp_path):
|
|
36
|
+
task = MXReport(inputs=inputs)
|
|
37
|
+
task.execute()
|
|
38
|
+
|
|
39
|
+
kwargs = minedrac.call_args.kwargs
|
|
40
|
+
assert kwargs["token"] == inputs["session_id"]
|
|
41
|
+
assert kwargs["investigation_ids"] == "12345"
|
|
42
|
+
assert kwargs["csv_file"] == str(tmp_path / "mx_report_12345.csv")
|
|
43
|
+
outputs = task.get_output_values()
|
|
44
|
+
assert outputs["report_file"] == Path(kwargs["csv_file"])
|
|
45
|
+
assert outputs["context"]["investigation_id"] == 12345
|
|
46
|
+
assert "generated_at" in outputs["context"]
|
|
47
|
+
assert outputs["session_id"] == "token"
|
|
48
|
+
assert outputs["callback"] == "https://icatplus.esrf.fr/callback"
|
|
49
|
+
assert statuses(callback) == ["STARTED", "RUNNING", "FINISHED"]
|
|
50
|
+
assert messages(callback) == [
|
|
51
|
+
"About to generate the report",
|
|
52
|
+
"report generated",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_report_nothing_generated(callback, minedrac, inputs):
|
|
57
|
+
minedrac.side_effect = None
|
|
58
|
+
minedrac.return_value = []
|
|
59
|
+
with pytest.raises(Exception, match="No report was generated"):
|
|
60
|
+
MXReport(inputs=inputs).execute()
|
|
61
|
+
assert statuses(callback) == ["STARTED", "RUNNING", "TERMINATED"]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_report_minedrac_fails(callback, minedrac, inputs):
|
|
65
|
+
minedrac.side_effect = ValueError("ICAT unreachable")
|
|
66
|
+
task = MXReport(inputs=inputs)
|
|
67
|
+
with pytest.raises(Exception, match="ICAT unreachable"):
|
|
68
|
+
task.execute()
|
|
69
|
+
assert statuses(callback) == ["STARTED", "RUNNING", "TERMINATED"]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.mark.parametrize("investigation_id", [12345, "12345"])
|
|
73
|
+
def test_report_investigation_id_int_or_str(
|
|
74
|
+
callback, minedrac, inputs, investigation_id
|
|
75
|
+
):
|
|
76
|
+
MXReport(inputs={**inputs, "investigation_id": investigation_id}).execute()
|
|
77
|
+
assert minedrac.call_args.kwargs["investigation_ids"] == "12345"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_report_invalid_callback(inputs):
|
|
81
|
+
task = MXReport(inputs={**inputs, "callback": "not a url"})
|
|
82
|
+
with pytest.raises(Exception, match="callback"):
|
|
83
|
+
task.execute()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_report_finished_callback_fails(callback, minedrac, inputs):
|
|
87
|
+
callback.side_effect = fail_on_status("FINISHED")
|
|
88
|
+
task = MXReport(inputs=inputs)
|
|
89
|
+
with pytest.raises(Exception, match="portal unreachable"):
|
|
90
|
+
task.execute()
|
|
91
|
+
assert statuses(callback) == ["STARTED", "RUNNING", "FINISHED"]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_report_terminated_callback_fails(callback, minedrac, inputs):
|
|
95
|
+
minedrac.side_effect = ValueError("ICAT unreachable")
|
|
96
|
+
callback.side_effect = fail_on_status("TERMINATED")
|
|
97
|
+
task = MXReport(inputs=inputs)
|
|
98
|
+
with pytest.raises(Exception, match="portal unreachable"):
|
|
99
|
+
task.execute()
|
|
100
|
+
assert statuses(callback) == ["STARTED", "RUNNING", "TERMINATED"]
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from unittest.mock import patch
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from minedrac.data.session import UserLookupError
|
|
6
|
+
|
|
7
|
+
from ewoksdrac.tasks.mail.send import SendEmail
|
|
8
|
+
from ewoksdrac.tests.utils import messages
|
|
9
|
+
from ewoksdrac.tests.utils import statuses
|
|
10
|
+
|
|
11
|
+
INPUTS = {
|
|
12
|
+
"session_id": "token",
|
|
13
|
+
"callback": "https://icatplus.esrf.fr/callback",
|
|
14
|
+
"subject": "MX report of investigation {{ investigation_id }}",
|
|
15
|
+
"template": "mx_report",
|
|
16
|
+
"context": {"investigation_id": 12345, "generated_at": "2026-10-14 10:00:00"},
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def user_email():
|
|
22
|
+
with patch("ewoksdrac.tasks.mail.send.get_user_email") as get:
|
|
23
|
+
get.return_value = "alice@esrf.fr"
|
|
24
|
+
yield get
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture
|
|
28
|
+
def send_email():
|
|
29
|
+
with patch("ewoksdrac.tasks.mail.send.send_email") as send:
|
|
30
|
+
yield send
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_send_email(callback, user_email, send_email):
|
|
34
|
+
SendEmail(inputs=INPUTS).execute()
|
|
35
|
+
|
|
36
|
+
user_email.assert_called_once_with("token")
|
|
37
|
+
send_email.assert_called_once()
|
|
38
|
+
recipients, subject, body, attachments = send_email.call_args.args
|
|
39
|
+
assert recipients == ["alice@esrf.fr"]
|
|
40
|
+
assert subject == "MX report of investigation 12345"
|
|
41
|
+
assert attachments == []
|
|
42
|
+
assert "<p>Hello,</p>" in body
|
|
43
|
+
assert "<p>Best regards,<br />The ESRF Data Portal</p>" in body
|
|
44
|
+
assert "Generated on: 2026-10-14 10:00:00" in body
|
|
45
|
+
assert '<a href="https://data.esrf.fr/investigation/12345/datasets">' in body
|
|
46
|
+
assert (
|
|
47
|
+
'<i>This email was automatically generated by the Data Portal. If you have any questions, please contact <a href="mailto:dataportalrequests@esrf.fr">dataportalrequests@esrf.fr</a></i>'
|
|
48
|
+
in body
|
|
49
|
+
)
|
|
50
|
+
assert statuses(callback) == ["STARTED", "RUNNING", "FINISHED"]
|
|
51
|
+
assert messages(callback) == ["About to send the email", "email sent"]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
REPORT = "/data/visitor/mx1234/reports/mx_report_12345.csv"
|
|
55
|
+
IMAGE = "/data/visitor/mx1234/reports/plot.png"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@pytest.mark.parametrize(
|
|
59
|
+
"attachments, expected",
|
|
60
|
+
[(REPORT, [REPORT]), ([REPORT], [REPORT]), ([REPORT, IMAGE], [REPORT, IMAGE])],
|
|
61
|
+
)
|
|
62
|
+
def test_send_email_with_attachments(
|
|
63
|
+
callback, user_email, send_email, attachments, expected
|
|
64
|
+
):
|
|
65
|
+
SendEmail(inputs={**INPUTS, "attachments": attachments}).execute()
|
|
66
|
+
|
|
67
|
+
assert send_email.call_args.args[3] == [Path(f) for f in expected]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _report(tmp_path):
|
|
71
|
+
report_dir = tmp_path / "mx_report_abcd"
|
|
72
|
+
report_dir.mkdir()
|
|
73
|
+
report = report_dir / "mx_report_12345.csv"
|
|
74
|
+
report.write_text("a,b\n1,2\n")
|
|
75
|
+
return report
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_send_email_keeps_attachments(callback, user_email, send_email, tmp_path):
|
|
79
|
+
report = _report(tmp_path)
|
|
80
|
+
SendEmail(inputs={**INPUTS, "attachments": str(report)}).execute()
|
|
81
|
+
|
|
82
|
+
assert report.exists()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_send_email_deletes_attachments(callback, user_email, send_email, tmp_path):
|
|
86
|
+
report = _report(tmp_path)
|
|
87
|
+
inputs = {**INPUTS, "attachments": str(report), "delete_attachments": True}
|
|
88
|
+
SendEmail(inputs=inputs).execute()
|
|
89
|
+
|
|
90
|
+
assert send_email.call_args.args[3] == [report]
|
|
91
|
+
assert not report.parent.exists()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_send_email_deletes_attachments_on_failure(
|
|
95
|
+
callback, user_email, send_email, tmp_path
|
|
96
|
+
):
|
|
97
|
+
report = _report(tmp_path)
|
|
98
|
+
send_email.side_effect = RuntimeError("SMTP unreachable")
|
|
99
|
+
inputs = {**INPUTS, "attachments": str(report), "delete_attachments": True}
|
|
100
|
+
with pytest.raises(Exception, match="SMTP unreachable"):
|
|
101
|
+
SendEmail(inputs=inputs).execute()
|
|
102
|
+
|
|
103
|
+
assert not report.parent.exists()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_send_email_keeps_non_empty_directory(
|
|
107
|
+
callback, user_email, send_email, tmp_path
|
|
108
|
+
):
|
|
109
|
+
report = _report(tmp_path)
|
|
110
|
+
other = report.parent / "other.txt"
|
|
111
|
+
other.write_text("keep me")
|
|
112
|
+
inputs = {**INPUTS, "attachments": str(report), "delete_attachments": True}
|
|
113
|
+
SendEmail(inputs=inputs).execute()
|
|
114
|
+
|
|
115
|
+
assert not report.exists()
|
|
116
|
+
assert other.exists()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@pytest.mark.parametrize("email", [None, ""])
|
|
120
|
+
def test_send_email_no_user_email(callback, user_email, send_email, email):
|
|
121
|
+
user_email.return_value = email
|
|
122
|
+
with pytest.raises(Exception, match="No email address"):
|
|
123
|
+
SendEmail(inputs=INPUTS).execute()
|
|
124
|
+
send_email.assert_not_called()
|
|
125
|
+
assert statuses(callback) == ["STARTED", "RUNNING", "TERMINATED"]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_send_email_user_lookup_fails(callback, user_email, send_email):
|
|
129
|
+
user_email.side_effect = UserLookupError("Could not get session info: expired")
|
|
130
|
+
with pytest.raises(Exception, match="Could not find the user.*expired"):
|
|
131
|
+
SendEmail(inputs=INPUTS).execute()
|
|
132
|
+
send_email.assert_not_called()
|
|
133
|
+
assert statuses(callback) == ["STARTED", "RUNNING", "TERMINATED"]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_send_email_fails(callback, user_email, send_email):
|
|
137
|
+
send_email.side_effect = RuntimeError("SMTP unreachable")
|
|
138
|
+
with pytest.raises(Exception, match="SMTP unreachable"):
|
|
139
|
+
SendEmail(inputs=INPUTS).execute()
|
|
140
|
+
assert statuses(callback) == ["STARTED", "RUNNING", "TERMINATED"]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_send_email_data_portal_url(callback, user_email, send_email, monkeypatch):
|
|
144
|
+
monkeypatch.setenv("DATA_PORTAL_URL", "https://data.example.org")
|
|
145
|
+
SendEmail(inputs=INPUTS).execute()
|
|
146
|
+
assert (
|
|
147
|
+
"https://data.example.org/investigation/12345/datasets"
|
|
148
|
+
in send_email.call_args.args[2]
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_send_email_contact_email(callback, user_email, send_email, monkeypatch):
|
|
153
|
+
monkeypatch.setenv("CONTACT_EMAIL", "help@example.org")
|
|
154
|
+
SendEmail(inputs=INPUTS).execute()
|
|
155
|
+
assert 'href="mailto:help@example.org"' in send_email.call_args.args[2]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_send_email_signature(callback, user_email, send_email, monkeypatch):
|
|
159
|
+
monkeypatch.setenv("EMAIL_SIGNATURE", "The Data Portal of the facility")
|
|
160
|
+
SendEmail(inputs=INPUTS).execute()
|
|
161
|
+
assert (
|
|
162
|
+
"<p>Best regards,<br />The Data Portal of the facility</p>"
|
|
163
|
+
in send_email.call_args.args[2]
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_send_email_escapes_context(callback, user_email, send_email):
|
|
168
|
+
context = {**INPUTS["context"], "generated_at": "<script>"}
|
|
169
|
+
SendEmail(inputs={**INPUTS, "context": context}).execute()
|
|
170
|
+
body = send_email.call_args.args[2]
|
|
171
|
+
assert "<script>" not in body
|
|
172
|
+
assert "<script>" in body
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_send_email_missing_context_variable(callback, user_email, send_email):
|
|
176
|
+
with pytest.raises(Exception, match="generated_at"):
|
|
177
|
+
SendEmail(inputs={**INPUTS, "context": {"investigation_id": 12345}}).execute()
|
|
178
|
+
send_email.assert_not_called()
|
|
179
|
+
assert statuses(callback) == ["STARTED", "RUNNING", "TERMINATED"]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_send_email_unknown_template(callback, user_email, send_email):
|
|
183
|
+
with pytest.raises(Exception, match="unknown"):
|
|
184
|
+
SendEmail(inputs={**INPUTS, "template": "unknown"}).execute()
|
|
185
|
+
send_email.assert_not_called()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@pytest.mark.parametrize("missing", ["subject", "template"])
|
|
189
|
+
def test_send_email_missing_input(missing):
|
|
190
|
+
inputs = {k: v for k, v in INPUTS.items() if k != missing}
|
|
191
|
+
with pytest.raises(Exception, match=missing):
|
|
192
|
+
SendEmail(inputs=inputs).execute()
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from unittest.mock import patch
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from ewoksdrac.tasks.mail.smtp import send_email
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture
|
|
9
|
+
def smtp():
|
|
10
|
+
with patch("ewoksdrac.tasks.mail.smtp.smtplib.SMTP") as smtp:
|
|
11
|
+
yield smtp
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def sent_message(smtp):
|
|
15
|
+
return smtp.return_value.__enter__.return_value.send_message.call_args.args[0]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_send_email(smtp, monkeypatch):
|
|
19
|
+
monkeypatch.setenv("SMTP_HOST", "smtp.esrf.fr")
|
|
20
|
+
monkeypatch.setenv("SMTP_PORT", "587")
|
|
21
|
+
monkeypatch.setenv("SMTP_FROM", "drac@esrf.fr")
|
|
22
|
+
send_email(["alice@esrf.fr", "bob@esrf.fr"], "MX report", "<p>Hello</p>")
|
|
23
|
+
|
|
24
|
+
smtp.assert_called_once_with("smtp.esrf.fr", 587, timeout=30)
|
|
25
|
+
message = sent_message(smtp)
|
|
26
|
+
assert message["Subject"] == "MX report"
|
|
27
|
+
assert message["From"] == "drac@esrf.fr"
|
|
28
|
+
assert message["To"] == "alice@esrf.fr, bob@esrf.fr"
|
|
29
|
+
assert message.get_content_type() == "text/html"
|
|
30
|
+
assert message.get_content().strip() == "<p>Hello</p>"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_send_email_defaults(smtp, monkeypatch):
|
|
34
|
+
for name in ("SMTP_HOST", "SMTP_PORT", "SMTP_FROM"):
|
|
35
|
+
monkeypatch.delenv(name, raising=False)
|
|
36
|
+
send_email(["alice@esrf.fr"], "MX report", "<p>Hello</p>")
|
|
37
|
+
|
|
38
|
+
smtp.assert_called_once_with("localhost", 25, timeout=30)
|
|
39
|
+
assert sent_message(smtp)["From"] == "dataportal@esrf.fr"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_send_email_with_attachments(smtp, tmp_path):
|
|
43
|
+
report = tmp_path / "mx_report_12345.csv"
|
|
44
|
+
report.write_text("a,b\n1,2\n")
|
|
45
|
+
image = tmp_path / "plot.png"
|
|
46
|
+
image.write_bytes(b"\x89PNG")
|
|
47
|
+
send_email(
|
|
48
|
+
["alice@esrf.fr"], "MX report", "<p>Hello</p>", [str(report), str(image)]
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
attachments = list(sent_message(smtp).iter_attachments())
|
|
52
|
+
assert [a.get_filename() for a in attachments] == [
|
|
53
|
+
"mx_report_12345.csv",
|
|
54
|
+
"plot.png",
|
|
55
|
+
]
|
|
56
|
+
assert [a.get_content_type() for a in attachments] == ["text/csv", "image/png"]
|
|
57
|
+
assert attachments[0].get_content() == "a,b\n1,2\n"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_send_email_missing_attachment(smtp, tmp_path):
|
|
61
|
+
with pytest.raises(FileNotFoundError):
|
|
62
|
+
send_email(
|
|
63
|
+
["alice@esrf.fr"], "MX report", "<p>Hello</p>", [str(tmp_path / "nope")]
|
|
64
|
+
)
|
|
65
|
+
smtp.assert_not_called()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from unittest.mock import MagicMock
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def statuses(callback):
|
|
5
|
+
return [
|
|
6
|
+
c.kwargs["json"]["status"]
|
|
7
|
+
for c in callback.call_args_list
|
|
8
|
+
if "status" in c.kwargs["json"]
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def messages(callback):
|
|
13
|
+
return [
|
|
14
|
+
c.kwargs["json"]["logs"]["msg"]
|
|
15
|
+
for c in callback.call_args_list
|
|
16
|
+
if "msg" in c.kwargs["json"].get("logs", {})
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def fail_on_status(status):
|
|
21
|
+
def put(url, json, timeout):
|
|
22
|
+
if json.get("status") == status:
|
|
23
|
+
raise ConnectionError("portal unreachable")
|
|
24
|
+
return MagicMock()
|
|
25
|
+
|
|
26
|
+
return put
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ewoksdrac
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Ewoks tasks for the Data Portal
|
|
5
|
+
Author-email: ESRF <dau-pydev@esrf.fr>
|
|
6
|
+
License: # MIT License
|
|
7
|
+
|
|
8
|
+
**Copyright (c) 2026 European Synchrotron Radiation Facility**
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
11
|
+
this software and associated documentation files (the "Software"), to deal in
|
|
12
|
+
the Software without restriction, including without limitation the rights to
|
|
13
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
|
14
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
|
15
|
+
subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
22
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
23
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
24
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
25
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
26
|
+
|
|
27
|
+
Project-URL: Homepage, https://gitlab.esrf.fr/workflow/ewoksapps/ewoksdrac/
|
|
28
|
+
Project-URL: Documentation, https://ewoksdrac.readthedocs.io/
|
|
29
|
+
Project-URL: Repository, https://gitlab.esrf.fr/workflow/ewoksapps/ewoksdrac/
|
|
30
|
+
Project-URL: Issues, https://gitlab.esrf.fr/workflow/ewoksapps/ewoksdrac/issues
|
|
31
|
+
Project-URL: Changelog, https://gitlab.esrf.fr/workflow/ewoksapps/ewoksdrac/-/blob/main/CHANGELOG.md
|
|
32
|
+
Keywords: ewoks
|
|
33
|
+
Classifier: Intended Audience :: Science/Research
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Requires-Python: >=3.11
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
License-File: LICENSE.md
|
|
39
|
+
Requires-Dist: ewokscore
|
|
40
|
+
Requires-Dist: requests
|
|
41
|
+
Requires-Dist: pydantic
|
|
42
|
+
Requires-Dist: jinja2
|
|
43
|
+
Requires-Dist: minedrac>=1.1.0
|
|
44
|
+
Provides-Extra: test
|
|
45
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
46
|
+
Provides-Extra: dev
|
|
47
|
+
Requires-Dist: ewoksdrac[test]; extra == "dev"
|
|
48
|
+
Requires-Dist: ruff; extra == "dev"
|
|
49
|
+
Provides-Extra: doc
|
|
50
|
+
Requires-Dist: ewoksdrac[test]; extra == "doc"
|
|
51
|
+
Requires-Dist: sphinx>=4.5; extra == "doc"
|
|
52
|
+
Requires-Dist: sphinx-autodoc-typehints>=1.16; extra == "doc"
|
|
53
|
+
Requires-Dist: pydata-sphinx-theme; extra == "doc"
|
|
54
|
+
Requires-Dist: sphinx-copybutton; extra == "doc"
|
|
55
|
+
Requires-Dist: ewokssphinx>=2.0; extra == "doc"
|
|
56
|
+
Dynamic: license-file
|
|
57
|
+
|
|
58
|
+
# ewoksdrac
|
|
59
|
+
|
|
60
|
+
Ewoks tasks for the Data Portal
|
|
61
|
+
|
|
62
|
+
## Documentation
|
|
63
|
+
|
|
64
|
+
https://ewoksdrac.readthedocs.io/
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
LICENSE.md
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/ewoksdrac/__init__.py
|
|
5
|
+
src/ewoksdrac.egg-info/PKG-INFO
|
|
6
|
+
src/ewoksdrac.egg-info/SOURCES.txt
|
|
7
|
+
src/ewoksdrac.egg-info/dependency_links.txt
|
|
8
|
+
src/ewoksdrac.egg-info/entry_points.txt
|
|
9
|
+
src/ewoksdrac.egg-info/requires.txt
|
|
10
|
+
src/ewoksdrac.egg-info/top_level.txt
|
|
11
|
+
src/ewoksdrac/tasks/__init__.py
|
|
12
|
+
src/ewoksdrac/tasks/base_tasks/__init__.py
|
|
13
|
+
src/ewoksdrac/tasks/base_tasks/drac_investigation_task.py
|
|
14
|
+
src/ewoksdrac/tasks/base_tasks/drac_investigation_task_inputs.py
|
|
15
|
+
src/ewoksdrac/tasks/base_tasks/drac_task.py
|
|
16
|
+
src/ewoksdrac/tasks/base_tasks/drac_task_inputs.py
|
|
17
|
+
src/ewoksdrac/tasks/base_tasks/drac_task_outputs.py
|
|
18
|
+
src/ewoksdrac/tasks/mail/__init__.py
|
|
19
|
+
src/ewoksdrac/tasks/mail/render.py
|
|
20
|
+
src/ewoksdrac/tasks/mail/send.py
|
|
21
|
+
src/ewoksdrac/tasks/mail/send_inputs.py
|
|
22
|
+
src/ewoksdrac/tasks/mail/smtp.py
|
|
23
|
+
src/ewoksdrac/tasks/mail/templates/base.html.j2
|
|
24
|
+
src/ewoksdrac/tasks/mail/templates/mx_report.html.j2
|
|
25
|
+
src/ewoksdrac/tasks/report/__init__.py
|
|
26
|
+
src/ewoksdrac/tasks/report/mx.py
|
|
27
|
+
src/ewoksdrac/tasks/report/mx_inputs.py
|
|
28
|
+
src/ewoksdrac/tasks/report/mx_outputs.py
|
|
29
|
+
src/ewoksdrac/tests/__init__.py
|
|
30
|
+
src/ewoksdrac/tests/conftest.py
|
|
31
|
+
src/ewoksdrac/tests/test_mx_report.py
|
|
32
|
+
src/ewoksdrac/tests/test_send_email.py
|
|
33
|
+
src/ewoksdrac/tests/test_smtp.py
|
|
34
|
+
src/ewoksdrac/tests/utils.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
ewokscore
|
|
2
|
+
requests
|
|
3
|
+
pydantic
|
|
4
|
+
jinja2
|
|
5
|
+
minedrac>=1.1.0
|
|
6
|
+
|
|
7
|
+
[dev]
|
|
8
|
+
ewoksdrac[test]
|
|
9
|
+
ruff
|
|
10
|
+
|
|
11
|
+
[doc]
|
|
12
|
+
ewoksdrac[test]
|
|
13
|
+
sphinx>=4.5
|
|
14
|
+
sphinx-autodoc-typehints>=1.16
|
|
15
|
+
pydata-sphinx-theme
|
|
16
|
+
sphinx-copybutton
|
|
17
|
+
ewokssphinx>=2.0
|
|
18
|
+
|
|
19
|
+
[test]
|
|
20
|
+
pytest>=7
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ewoksdrac
|