drac-notifier 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.
- drac_notifier-0.1.0/LICENSE.md +20 -0
- drac_notifier-0.1.0/PKG-INFO +61 -0
- drac_notifier-0.1.0/README.md +7 -0
- drac_notifier-0.1.0/pyproject.toml +73 -0
- drac_notifier-0.1.0/setup.cfg +4 -0
- drac_notifier-0.1.0/src/drac_notifier/__init__.py +0 -0
- drac_notifier-0.1.0/src/drac_notifier/clients/__init__.py +0 -0
- drac_notifier-0.1.0/src/drac_notifier/clients/icat_plus.py +99 -0
- drac_notifier-0.1.0/src/drac_notifier/clients/smis.py +73 -0
- drac_notifier-0.1.0/src/drac_notifier/config.py +23 -0
- drac_notifier-0.1.0/src/drac_notifier/email_notifier/__init__.py +0 -0
- drac_notifier-0.1.0/src/drac_notifier/email_notifier/service.py +67 -0
- drac_notifier-0.1.0/src/drac_notifier/email_notifier/templates/__init__.py +0 -0
- drac_notifier-0.1.0/src/drac_notifier/email_notifier/templates/end_embargo.py +41 -0
- drac_notifier-0.1.0/src/drac_notifier/email_notifier/templates/templates.py +38 -0
- drac_notifier-0.1.0/src/drac_notifier/main.py +46 -0
- drac_notifier-0.1.0/src/drac_notifier/notifications/__init__.py +0 -0
- drac_notifier-0.1.0/src/drac_notifier/notifications/end_embargo.py +121 -0
- drac_notifier-0.1.0/src/drac_notifier/tests/__init__.py +0 -0
- drac_notifier-0.1.0/src/drac_notifier/tests/test_release_date.py +22 -0
- drac_notifier-0.1.0/src/drac_notifier.egg-info/PKG-INFO +61 -0
- drac_notifier-0.1.0/src/drac_notifier.egg-info/SOURCES.txt +23 -0
- drac_notifier-0.1.0/src/drac_notifier.egg-info/dependency_links.txt +1 -0
- drac_notifier-0.1.0/src/drac_notifier.egg-info/requires.txt +17 -0
- drac_notifier-0.1.0/src/drac_notifier.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.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: drac-notifier
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Service for sending user notifications from ICAT based on data lifecycle events
|
|
5
|
+
Author-email: ESRF <marjolaine.bodin@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/icat/drac-notifier/
|
|
28
|
+
Project-URL: Documentation, https://drac-notifier.readthedocs.io/
|
|
29
|
+
Project-URL: Repository, https://gitlab.esrf.fr/icat/drac-notifier/
|
|
30
|
+
Project-URL: Issues, https://gitlab.esrf.fr/icat/drac-notifier/issues
|
|
31
|
+
Project-URL: Changelog, https://gitlab.esrf.fr/icat/drac-notifier/-/blob/main/CHANGELOG.md
|
|
32
|
+
Keywords: icat
|
|
33
|
+
Classifier: Intended Audience :: Science/Research
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Requires-Python: >=3.10
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
License-File: LICENSE.md
|
|
39
|
+
Requires-Dist: icat-plus-client
|
|
40
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
41
|
+
Requires-Dist: zeep
|
|
42
|
+
Provides-Extra: test
|
|
43
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
44
|
+
Provides-Extra: dev
|
|
45
|
+
Requires-Dist: drac-notifier[test]; extra == "dev"
|
|
46
|
+
Requires-Dist: ruff; extra == "dev"
|
|
47
|
+
Provides-Extra: doc
|
|
48
|
+
Requires-Dist: drac-notifier[test]; extra == "doc"
|
|
49
|
+
Requires-Dist: sphinx>=4.5; extra == "doc"
|
|
50
|
+
Requires-Dist: sphinx-autodoc-typehints>=1.16; extra == "doc"
|
|
51
|
+
Requires-Dist: pydata-sphinx-theme; extra == "doc"
|
|
52
|
+
Requires-Dist: sphinx-copybutton; extra == "doc"
|
|
53
|
+
Dynamic: license-file
|
|
54
|
+
|
|
55
|
+
# drac-notifier
|
|
56
|
+
|
|
57
|
+
Service for sending user notifications from ICAT based on data lifecycle events.
|
|
58
|
+
|
|
59
|
+
## End of embargo email
|
|
60
|
+
|
|
61
|
+
An email is sent to all users linked to an experiment session to notify them of the end of the embargo period, 3 months and 1 month before the end date.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# drac-notifier
|
|
2
|
+
|
|
3
|
+
Service for sending user notifications from ICAT based on data lifecycle events.
|
|
4
|
+
|
|
5
|
+
## End of embargo email
|
|
6
|
+
|
|
7
|
+
An email is sent to all users linked to an experiment session to notify them of the end of the embargo period, 3 months and 1 month before the end date.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = [
|
|
3
|
+
"setuptools>=61",
|
|
4
|
+
]
|
|
5
|
+
build-backend = "setuptools.build_meta"
|
|
6
|
+
|
|
7
|
+
[project]
|
|
8
|
+
name = "drac-notifier"
|
|
9
|
+
version = "0.1.0"
|
|
10
|
+
keywords = [
|
|
11
|
+
"icat"
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
authors = [{name = "ESRF", email = "marjolaine.bodin@esrf.fr"}]
|
|
15
|
+
description = "Service for sending user notifications from ICAT based on data lifecycle events"
|
|
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.10"
|
|
24
|
+
dependencies = [
|
|
25
|
+
"icat-plus-client",
|
|
26
|
+
"python-dotenv>=1.0.0",
|
|
27
|
+
"zeep",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://gitlab.esrf.fr/icat/drac-notifier/"
|
|
32
|
+
Documentation = "https://drac-notifier.readthedocs.io/"
|
|
33
|
+
Repository = "https://gitlab.esrf.fr/icat/drac-notifier/"
|
|
34
|
+
Issues = "https://gitlab.esrf.fr/icat/drac-notifier/issues"
|
|
35
|
+
Changelog = "https://gitlab.esrf.fr/icat/drac-notifier/-/blob/main/CHANGELOG.md"
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
test = [
|
|
39
|
+
"pytest >=7",
|
|
40
|
+
]
|
|
41
|
+
dev = [
|
|
42
|
+
"drac-notifier[test]",
|
|
43
|
+
"ruff",
|
|
44
|
+
]
|
|
45
|
+
doc = [
|
|
46
|
+
"drac-notifier[test]",
|
|
47
|
+
"sphinx >=4.5",
|
|
48
|
+
"sphinx-autodoc-typehints >=1.16",
|
|
49
|
+
"pydata-sphinx-theme",
|
|
50
|
+
"sphinx-copybutton",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
[tool.setuptools]
|
|
54
|
+
package-dir = {""= "src"}
|
|
55
|
+
|
|
56
|
+
[tool.setuptools.packages.find]
|
|
57
|
+
where = ["src"]
|
|
58
|
+
namespaces = false
|
|
59
|
+
|
|
60
|
+
[tool.coverage.run]
|
|
61
|
+
omit = [
|
|
62
|
+
"*/tests/*"
|
|
63
|
+
]
|
|
64
|
+
[tool.ruff.lint]
|
|
65
|
+
select = ["E", "F", "I", "S"]
|
|
66
|
+
ignore = ["E203", "E501", "E701"]
|
|
67
|
+
|
|
68
|
+
[tool.ruff.lint.per-file-ignores]
|
|
69
|
+
# Ignore `S101` (assert used violations) in all test files
|
|
70
|
+
"*/test_*.py" = ["S101"]
|
|
71
|
+
|
|
72
|
+
[tool.ruff.lint.isort]
|
|
73
|
+
force-single-line = true
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import List
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from icat_plus_client import ApiClient
|
|
6
|
+
from icat_plus_client import CatalogueApi
|
|
7
|
+
from icat_plus_client import Configuration
|
|
8
|
+
from icat_plus_client import SessionApi
|
|
9
|
+
from icat_plus_client.models import Credentials
|
|
10
|
+
from icat_plus_client.models import Investigation
|
|
11
|
+
from icat_plus_client.models import Investigationusers
|
|
12
|
+
|
|
13
|
+
from drac_notifier.config import ICAT_PLUS_PASSWORD
|
|
14
|
+
from drac_notifier.config import ICAT_PLUS_PLUGIN
|
|
15
|
+
from drac_notifier.config import ICAT_PLUS_SERVER
|
|
16
|
+
from drac_notifier.config import ICAT_PLUS_USERNAME
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class IcatClient:
|
|
22
|
+
"""Client to interact with ICAT+"""
|
|
23
|
+
|
|
24
|
+
def __init__(self):
|
|
25
|
+
self.configuration = Configuration(host=ICAT_PLUS_SERVER)
|
|
26
|
+
self.username = ICAT_PLUS_USERNAME
|
|
27
|
+
self.password = ICAT_PLUS_PASSWORD
|
|
28
|
+
self.plugin = ICAT_PLUS_PLUGIN
|
|
29
|
+
self.session_id: Optional[str] = None
|
|
30
|
+
|
|
31
|
+
def login(self) -> Optional[str]:
|
|
32
|
+
"""Authenticate and store session_id"""
|
|
33
|
+
try:
|
|
34
|
+
with ApiClient(self.configuration) as api_client:
|
|
35
|
+
api_instance = SessionApi(api_client)
|
|
36
|
+
cred = Credentials(
|
|
37
|
+
username=self.username,
|
|
38
|
+
password=self.password,
|
|
39
|
+
plugin=self.plugin,
|
|
40
|
+
)
|
|
41
|
+
response = api_instance.session_post(cred)
|
|
42
|
+
self.session_id = response.session_id
|
|
43
|
+
logger.info("Logged in to ICAT+, session_id obtained")
|
|
44
|
+
return self.session_id
|
|
45
|
+
except Exception as exc:
|
|
46
|
+
logger.exception("Unexpected error during login: %s", exc)
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
def get_investigations(
|
|
50
|
+
self,
|
|
51
|
+
release_dates: Optional[str] = None,
|
|
52
|
+
) -> List[Investigation]:
|
|
53
|
+
"""Fetch investigations from ICAT+"""
|
|
54
|
+
if not self.session_id:
|
|
55
|
+
logger.error("No session_id. Please login first.")
|
|
56
|
+
return []
|
|
57
|
+
|
|
58
|
+
with ApiClient(self.configuration) as api_client:
|
|
59
|
+
api_instance = CatalogueApi(api_client)
|
|
60
|
+
try:
|
|
61
|
+
investigations = api_instance.catalogue_session_id_investigation_get(
|
|
62
|
+
session_id=self.session_id,
|
|
63
|
+
release_dates=release_dates,
|
|
64
|
+
)
|
|
65
|
+
logger.info(
|
|
66
|
+
"Fetched %d investigations",
|
|
67
|
+
len(investigations) if investigations else 0,
|
|
68
|
+
)
|
|
69
|
+
return investigations or []
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
logger.exception("Unexpected error fetching investigations: %s", exc)
|
|
72
|
+
return []
|
|
73
|
+
|
|
74
|
+
def get_investigation_users(
|
|
75
|
+
self,
|
|
76
|
+
investigation_id: str,
|
|
77
|
+
) -> List[Investigationusers]:
|
|
78
|
+
"""Fetch investigation users from ICAT+"""
|
|
79
|
+
if not self.session_id:
|
|
80
|
+
logger.error("No session_id. Please login first.")
|
|
81
|
+
return []
|
|
82
|
+
|
|
83
|
+
with ApiClient(self.configuration) as api_client:
|
|
84
|
+
api_instance = CatalogueApi(api_client)
|
|
85
|
+
try:
|
|
86
|
+
users = api_instance.catalogue_session_id_investigation_id_investigation_id_investigationusers_get(
|
|
87
|
+
session_id=self.session_id,
|
|
88
|
+
investigation_id=investigation_id,
|
|
89
|
+
)
|
|
90
|
+
logger.info(
|
|
91
|
+
"Fetched %d investigation users",
|
|
92
|
+
len(users) if users else 0,
|
|
93
|
+
)
|
|
94
|
+
return users or []
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
logger.exception(
|
|
97
|
+
"Unexpected error fetching investigation users: %s", exc
|
|
98
|
+
)
|
|
99
|
+
return []
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
from zeep import Client
|
|
6
|
+
from zeep.exceptions import Fault
|
|
7
|
+
from zeep.transports import Transport
|
|
8
|
+
from zeep.wsse.username import UsernameToken
|
|
9
|
+
|
|
10
|
+
from drac_notifier.config import SMIS_PASSWORD
|
|
11
|
+
from drac_notifier.config import SMIS_SERVER
|
|
12
|
+
from drac_notifier.config import SMIS_USERNAME
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SmisClient:
|
|
18
|
+
"""Client to interact with UserPortal"""
|
|
19
|
+
|
|
20
|
+
def __init__(self):
|
|
21
|
+
self.server = SMIS_SERVER
|
|
22
|
+
self.username = SMIS_USERNAME
|
|
23
|
+
self.password = SMIS_PASSWORD
|
|
24
|
+
|
|
25
|
+
session = requests.Session()
|
|
26
|
+
session.auth = (self.username, self.password)
|
|
27
|
+
transport = Transport(session=session)
|
|
28
|
+
|
|
29
|
+
user_wsdl_url = f"{self.server}/SMISServer-ejb3/UserLookupWebService/UserLookupWebServiceBean?wsdl"
|
|
30
|
+
self.user_client = Client(
|
|
31
|
+
user_wsdl_url,
|
|
32
|
+
transport=transport,
|
|
33
|
+
wsse=UsernameToken(self.username, self.password),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
smis_wsdl_url = (
|
|
37
|
+
f"{self.server}/SMISServer-ejb3/SMISWebService/SMISWebServiceBean?wsdl"
|
|
38
|
+
)
|
|
39
|
+
self.smis_client = Client(
|
|
40
|
+
smis_wsdl_url,
|
|
41
|
+
transport=transport,
|
|
42
|
+
wsse=UsernameToken(self.username, self.password),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def get_user_email(self, username: str) -> Optional[str]:
|
|
46
|
+
smis_username = username.split("/")[0]
|
|
47
|
+
try:
|
|
48
|
+
user_id = self.user_client.service.getUserIdForUserName(smis_username)
|
|
49
|
+
|
|
50
|
+
if not user_id:
|
|
51
|
+
logger.warning(f"No SMIS user ID found for {smis_username}")
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
user_light_vo = self.smis_client.service.findUserByScientistPk(user_id)
|
|
55
|
+
|
|
56
|
+
if not user_light_vo:
|
|
57
|
+
logger.warning(f"No SMIS user found for ID {user_id}")
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
email = getattr(user_light_vo, "scientistEmail", None)
|
|
61
|
+
return email
|
|
62
|
+
|
|
63
|
+
except Fault as fault:
|
|
64
|
+
logger.warning(
|
|
65
|
+
f"SMIS lookup failed for username={smis_username}: {fault.message}"
|
|
66
|
+
)
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
except Exception:
|
|
70
|
+
logger.exception(
|
|
71
|
+
f"Unexpected error while retrieving email for {smis_username}"
|
|
72
|
+
)
|
|
73
|
+
return None
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from dotenv import load_dotenv
|
|
4
|
+
|
|
5
|
+
load_dotenv(override=True) # loads .env
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_env(name: str) -> str:
|
|
9
|
+
value = os.environ.get(name)
|
|
10
|
+
if not value:
|
|
11
|
+
raise RuntimeError(f"Environment variable {name} is required but not set.")
|
|
12
|
+
return value
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
ICAT_PLUS_SERVER = get_env("ICAT_PLUS_SERVER")
|
|
16
|
+
ICAT_PLUS_USERNAME = get_env("ICAT_PLUS_USERNAME")
|
|
17
|
+
ICAT_PLUS_PASSWORD = get_env("ICAT_PLUS_PASSWORD")
|
|
18
|
+
ICAT_PLUS_PLUGIN = get_env("ICAT_PLUS_PLUGIN")
|
|
19
|
+
SMIS_SERVER = get_env("SMIS_SERVER")
|
|
20
|
+
SMIS_USERNAME = get_env("SMIS_USERNAME")
|
|
21
|
+
SMIS_PASSWORD = get_env("SMIS_PASSWORD")
|
|
22
|
+
BCC_EMAIL = get_env("BCC_EMAIL")
|
|
23
|
+
SENDER_EMAIL = get_env("SENDER_EMAIL")
|
|
File without changes
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import smtplib
|
|
3
|
+
from email.mime.multipart import MIMEMultipart
|
|
4
|
+
from email.mime.text import MIMEText
|
|
5
|
+
from typing import List
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EmailService:
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
smtp_host: str,
|
|
15
|
+
sender: str,
|
|
16
|
+
reply_to: str,
|
|
17
|
+
bcc: Optional[str] = None,
|
|
18
|
+
mode: str = "normal",
|
|
19
|
+
):
|
|
20
|
+
if mode not in {"normal", "dry-run", "test"}:
|
|
21
|
+
raise ValueError(f"Invalid mode: {mode}")
|
|
22
|
+
self.mode = mode
|
|
23
|
+
self.smtp_host = smtp_host
|
|
24
|
+
self.sender = sender
|
|
25
|
+
self.reply_to = reply_to
|
|
26
|
+
self.bcc = bcc
|
|
27
|
+
|
|
28
|
+
def send_html_email(
|
|
29
|
+
self, subject: str, html_body: str, to_emails: List[str]
|
|
30
|
+
) -> None:
|
|
31
|
+
if not to_emails:
|
|
32
|
+
logger.warning("[EMAIL][SKIP] No recipients")
|
|
33
|
+
return
|
|
34
|
+
recipients = list(dict.fromkeys(to_emails))
|
|
35
|
+
if self.mode == "dry-run":
|
|
36
|
+
logger.info(
|
|
37
|
+
f"[EMAIL][DRY_RUN] subject='{subject}' intended_recipients={recipients}"
|
|
38
|
+
)
|
|
39
|
+
return
|
|
40
|
+
msg = MIMEMultipart()
|
|
41
|
+
msg["Subject"] = subject
|
|
42
|
+
msg["From"] = self.sender
|
|
43
|
+
msg["Reply-To"] = self.reply_to
|
|
44
|
+
if self.mode == "test":
|
|
45
|
+
if not self.bcc:
|
|
46
|
+
logger.warning("[EMAIL][TEST_MODE] No BCC specified, skipping")
|
|
47
|
+
return
|
|
48
|
+
send_list = [self.bcc]
|
|
49
|
+
msg["To"] = self.bcc
|
|
50
|
+
logger.info(
|
|
51
|
+
f"[EMAIL][TEST] subject='{subject}' redirected_to_bcc={self.bcc} original_recipients={recipients}"
|
|
52
|
+
)
|
|
53
|
+
else:
|
|
54
|
+
send_list = recipients.copy()
|
|
55
|
+
msg["To"] = ", ".join(recipients)
|
|
56
|
+
if self.bcc:
|
|
57
|
+
send_list.append(self.bcc)
|
|
58
|
+
msg.attach(MIMEText(html_body, "html"))
|
|
59
|
+
try:
|
|
60
|
+
with smtplib.SMTP(self.smtp_host) as server:
|
|
61
|
+
server.sendmail(self.sender, send_list, msg.as_string())
|
|
62
|
+
|
|
63
|
+
logger.info(
|
|
64
|
+
f"[EMAIL][SENT] subject='{subject}' to={recipients} bcc={self.bcc}"
|
|
65
|
+
)
|
|
66
|
+
except Exception:
|
|
67
|
+
logger.exception("[EMAIL][ERROR] Failed to send email")
|
|
File without changes
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
EMBARGO_TEMPLATE = """
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>End of embargo email</title>
|
|
6
|
+
<style>
|
|
7
|
+
.text-footer {{
|
|
8
|
+
font-size: 0.8em;
|
|
9
|
+
color: #666;
|
|
10
|
+
}}
|
|
11
|
+
</style>
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<p>You are receiving this email because you have access to the ESRF data concerned.</p>
|
|
15
|
+
|
|
16
|
+
<p>
|
|
17
|
+
According to the <a href="{data_policy}">ESRF data policy</a>, the data concerned by the following experiment session will become public on the {release_date} given below:
|
|
18
|
+
</p>
|
|
19
|
+
|
|
20
|
+
<ul>
|
|
21
|
+
<li><b>Proposal ID:</b> {proposal}</li>
|
|
22
|
+
<li><b>Beamline:</b> {beamline}</li>
|
|
23
|
+
<li><b>Start date:</b> {start_date}</li>
|
|
24
|
+
<li><b>End date:</b> {end_date}</li>
|
|
25
|
+
<li><b>End of embargo date:</b> {release_date}</li>
|
|
26
|
+
</ul>
|
|
27
|
+
|
|
28
|
+
<p>
|
|
29
|
+
On this date, the data and metadata, including the electronic logbook and experiment session report, will all be made public via the session DOI landing page:
|
|
30
|
+
<a href="{doi_link}">{doi_link}</a>
|
|
31
|
+
</p>
|
|
32
|
+
<p>
|
|
33
|
+
You, as part of the proposal team (PI, CIs, and Users for this session), are responsible for the content of the data and metadata that will be made public. Please ensure that any sensitive and/or personal data are removed before this date, tidy the logbook and update the experiment report if required.
|
|
34
|
+
</p>
|
|
35
|
+
<p>
|
|
36
|
+
If an extension of the 3-year embargo period is required, the Principal Investigator must make the request by email to the Office of the Directors of Research (<a href="{mailto_link}">doroffice@esrf.fr</a>), clearly justifying the length of extension requested and why it is needed.
|
|
37
|
+
</p>
|
|
38
|
+
<p class="text-footer"><i>This email was automatically generated by the Data Portal.</i></p>
|
|
39
|
+
</body>
|
|
40
|
+
</html>
|
|
41
|
+
"""
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Tuple
|
|
3
|
+
from urllib.parse import quote
|
|
4
|
+
|
|
5
|
+
from icat_plus_client.models import Investigation
|
|
6
|
+
|
|
7
|
+
from .end_embargo import EMBARGO_TEMPLATE
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_embargo_email(inv: Investigation) -> Tuple[str, str]:
|
|
11
|
+
start_date = _format_date(inv.start_date)
|
|
12
|
+
end_date = _format_date(inv.end_date)
|
|
13
|
+
proposal = inv.name
|
|
14
|
+
beamline = inv.instrument.name
|
|
15
|
+
release_date = _format_date(inv.release_date)
|
|
16
|
+
proposal_subject = f"{proposal} on {beamline} From {start_date} to {end_date}"
|
|
17
|
+
mailto_subject = f"Embargo extension: {proposal_subject}"
|
|
18
|
+
mailto_subject_encoded = quote(mailto_subject)
|
|
19
|
+
mailto_link = f"mailto:doroffice@esrf.fr?subject={mailto_subject_encoded}"
|
|
20
|
+
subject = f"ESRF data end of embargo: {proposal_subject}"
|
|
21
|
+
|
|
22
|
+
html = EMBARGO_TEMPLATE.format(
|
|
23
|
+
proposal=proposal,
|
|
24
|
+
beamline=beamline,
|
|
25
|
+
start_date=start_date,
|
|
26
|
+
end_date=end_date,
|
|
27
|
+
release_date=release_date,
|
|
28
|
+
doi_link=f"https://doi.org/{inv.doi}",
|
|
29
|
+
data_policy="https://www.esrf.fr/files/live/sites/www/files/Infrastructure/Computing/ESRF-data-policy_20240101.pdf",
|
|
30
|
+
mailto_link=mailto_link,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
return subject, html
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _format_date(date_str: str) -> str:
|
|
37
|
+
dt = datetime.fromisoformat(date_str)
|
|
38
|
+
return dt.strftime("%Y-%m-%d")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import logging
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from drac_notifier.notifications.end_embargo import send_embargo_notifications
|
|
6
|
+
|
|
7
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s:%(message)s")
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main():
|
|
12
|
+
parser = argparse.ArgumentParser(
|
|
13
|
+
description="DRAC Notifier tasks",
|
|
14
|
+
epilog="Example: python -m drac_notifier.main embargo 2026-02-01 --mode dry-run",
|
|
15
|
+
)
|
|
16
|
+
parser.add_argument("task", choices=["embargo"], help="Task to run")
|
|
17
|
+
parser.add_argument(
|
|
18
|
+
"date",
|
|
19
|
+
nargs="?",
|
|
20
|
+
default=None,
|
|
21
|
+
help="Reference date in YYYY-MM-DD format (optional)",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--mode",
|
|
25
|
+
choices=["normal", "dry-run", "test"],
|
|
26
|
+
default="normal",
|
|
27
|
+
help="Email sending mode: normal (send), dry-run (no send), test (send only to bcc)",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
args = parser.parse_args()
|
|
31
|
+
|
|
32
|
+
if args.date:
|
|
33
|
+
try:
|
|
34
|
+
datetime.strptime(args.date, "%Y-%m-%d")
|
|
35
|
+
except ValueError:
|
|
36
|
+
logger.error("Invalid date format: %s. Expected YYYY-MM-DD", args.date)
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
if args.task == "embargo":
|
|
40
|
+
send_embargo_notifications(reference_date=args.date, mode=args.mode)
|
|
41
|
+
else:
|
|
42
|
+
logger.error("Unknown task: %s", args.task)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from datetime import date
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import List
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from typing import Set
|
|
7
|
+
|
|
8
|
+
from dateutil.relativedelta import relativedelta
|
|
9
|
+
from icat_plus_client.models import Investigation
|
|
10
|
+
from icat_plus_client.models import Investigationusers
|
|
11
|
+
|
|
12
|
+
from drac_notifier.clients.icat_plus import IcatClient
|
|
13
|
+
from drac_notifier.clients.smis import SmisClient
|
|
14
|
+
from drac_notifier.config import BCC_EMAIL
|
|
15
|
+
from drac_notifier.config import SENDER_EMAIL
|
|
16
|
+
from drac_notifier.email_notifier.service import EmailService
|
|
17
|
+
from drac_notifier.email_notifier.templates.templates import build_embargo_email
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def send_embargo_notifications(
|
|
23
|
+
reference_date: Optional[str], mode: str = "normal"
|
|
24
|
+
) -> None:
|
|
25
|
+
logger.info("START EMBARGO NOTIFICATIONS")
|
|
26
|
+
client = IcatClient()
|
|
27
|
+
client.login()
|
|
28
|
+
smis_client = SmisClient()
|
|
29
|
+
email_service = EmailService(
|
|
30
|
+
smtp_host="localhost",
|
|
31
|
+
sender=SENDER_EMAIL,
|
|
32
|
+
reply_to=SENDER_EMAIL,
|
|
33
|
+
bcc=BCC_EMAIL,
|
|
34
|
+
mode=mode,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
target_dates = _calculate_target_dates(reference_date)
|
|
38
|
+
release_dates = ",".join(target_dates)
|
|
39
|
+
logger.info(f"Release dates: {release_dates}")
|
|
40
|
+
|
|
41
|
+
investigations = client.get_investigations(release_dates=release_dates)
|
|
42
|
+
for inv in investigations:
|
|
43
|
+
logger.info(
|
|
44
|
+
f"[INVESTIGATION] id={inv.id} name={inv.name} release_date={inv.release_date}"
|
|
45
|
+
)
|
|
46
|
+
users = client.get_investigation_users(inv.id)
|
|
47
|
+
emails = set()
|
|
48
|
+
missing_users = []
|
|
49
|
+
for user in users:
|
|
50
|
+
email = smis_client.get_user_email(user.name)
|
|
51
|
+
|
|
52
|
+
if email:
|
|
53
|
+
emails.add(email)
|
|
54
|
+
logger.info(
|
|
55
|
+
f"[USER][OK] inv_id={inv.id} user={user.name} role={user.role} email={email}"
|
|
56
|
+
)
|
|
57
|
+
else:
|
|
58
|
+
missing_users.append(user.name)
|
|
59
|
+
logger.warning(
|
|
60
|
+
f"[USER][NO_EMAIL] inv_id={inv.id} user={user.name} role={user.role}"
|
|
61
|
+
)
|
|
62
|
+
# summary
|
|
63
|
+
skip = _log_summary_investigation(inv, users, emails, missing_users)
|
|
64
|
+
if skip:
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
subject, html = build_embargo_email(inv)
|
|
69
|
+
except Exception:
|
|
70
|
+
logger.exception(
|
|
71
|
+
f"[EMAIL][ERROR] Failed to build email for inv_id={inv.id}"
|
|
72
|
+
)
|
|
73
|
+
continue
|
|
74
|
+
logger.info(f"[EMAIL][PREPARE] inv_id={inv.id} recipients={len(emails)}")
|
|
75
|
+
email_service.send_html_email(
|
|
76
|
+
subject=subject,
|
|
77
|
+
html_body=html,
|
|
78
|
+
to_emails=list(emails),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
logger.info("=" * 60)
|
|
82
|
+
|
|
83
|
+
logger.info("END Embargo notifications sent successfully")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _log_summary_investigation(
|
|
87
|
+
inv: Investigation,
|
|
88
|
+
users: List[Investigationusers],
|
|
89
|
+
emails: Set[str],
|
|
90
|
+
missing_users: List[str],
|
|
91
|
+
) -> bool:
|
|
92
|
+
logger.info(
|
|
93
|
+
f"[SUMMARY] inv_id={inv.id} total_users={len(users)} "
|
|
94
|
+
f"emails_found={len(emails)} missing={len(missing_users)}"
|
|
95
|
+
)
|
|
96
|
+
if missing_users:
|
|
97
|
+
logger.warning(
|
|
98
|
+
f"[SUMMARY][MISSING_EMAILS] inv_id={inv.id} users={missing_users}"
|
|
99
|
+
)
|
|
100
|
+
if not emails:
|
|
101
|
+
logger.warning(f"[SKIP] No emails for investigation {inv.id} name={inv.name}")
|
|
102
|
+
return True
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _calculate_target_dates(reference_date: Optional[str] = None) -> List[str]:
|
|
107
|
+
"""
|
|
108
|
+
Returns the target dates for embargo notifications:
|
|
109
|
+
- 1 month after reference_date
|
|
110
|
+
- 3 months after reference_date
|
|
111
|
+
|
|
112
|
+
If reference_date is None, use today.
|
|
113
|
+
"""
|
|
114
|
+
if reference_date:
|
|
115
|
+
ref_date = datetime.strptime(reference_date, "%Y-%m-%d").date()
|
|
116
|
+
else:
|
|
117
|
+
ref_date = date.today()
|
|
118
|
+
one_month_date = ref_date + relativedelta(months=1)
|
|
119
|
+
three_month_date = ref_date + relativedelta(months=3)
|
|
120
|
+
|
|
121
|
+
return [one_month_date.isoformat(), three_month_date.isoformat()]
|
|
File without changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from datetime import date
|
|
2
|
+
|
|
3
|
+
from drac_notifier.notifications.end_embargo import _calculate_target_dates
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_calculate_target_dates_with_given_date():
|
|
7
|
+
result = _calculate_target_dates("2026-03-23")
|
|
8
|
+
assert result[0] == "2026-04-23"
|
|
9
|
+
assert result[1] == "2026-06-23"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_calculate_target_dates_with_default_today(monkeypatch):
|
|
13
|
+
class FakeDate(date):
|
|
14
|
+
@classmethod
|
|
15
|
+
def today(cls):
|
|
16
|
+
return cls(2026, 3, 23)
|
|
17
|
+
|
|
18
|
+
monkeypatch.setattr("drac_notifier.notifications.end_embargo.date", FakeDate)
|
|
19
|
+
|
|
20
|
+
result = _calculate_target_dates()
|
|
21
|
+
assert result[0] == "2026-04-23"
|
|
22
|
+
assert result[1] == "2026-06-23"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: drac-notifier
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Service for sending user notifications from ICAT based on data lifecycle events
|
|
5
|
+
Author-email: ESRF <marjolaine.bodin@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/icat/drac-notifier/
|
|
28
|
+
Project-URL: Documentation, https://drac-notifier.readthedocs.io/
|
|
29
|
+
Project-URL: Repository, https://gitlab.esrf.fr/icat/drac-notifier/
|
|
30
|
+
Project-URL: Issues, https://gitlab.esrf.fr/icat/drac-notifier/issues
|
|
31
|
+
Project-URL: Changelog, https://gitlab.esrf.fr/icat/drac-notifier/-/blob/main/CHANGELOG.md
|
|
32
|
+
Keywords: icat
|
|
33
|
+
Classifier: Intended Audience :: Science/Research
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Requires-Python: >=3.10
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
License-File: LICENSE.md
|
|
39
|
+
Requires-Dist: icat-plus-client
|
|
40
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
41
|
+
Requires-Dist: zeep
|
|
42
|
+
Provides-Extra: test
|
|
43
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
44
|
+
Provides-Extra: dev
|
|
45
|
+
Requires-Dist: drac-notifier[test]; extra == "dev"
|
|
46
|
+
Requires-Dist: ruff; extra == "dev"
|
|
47
|
+
Provides-Extra: doc
|
|
48
|
+
Requires-Dist: drac-notifier[test]; extra == "doc"
|
|
49
|
+
Requires-Dist: sphinx>=4.5; extra == "doc"
|
|
50
|
+
Requires-Dist: sphinx-autodoc-typehints>=1.16; extra == "doc"
|
|
51
|
+
Requires-Dist: pydata-sphinx-theme; extra == "doc"
|
|
52
|
+
Requires-Dist: sphinx-copybutton; extra == "doc"
|
|
53
|
+
Dynamic: license-file
|
|
54
|
+
|
|
55
|
+
# drac-notifier
|
|
56
|
+
|
|
57
|
+
Service for sending user notifications from ICAT based on data lifecycle events.
|
|
58
|
+
|
|
59
|
+
## End of embargo email
|
|
60
|
+
|
|
61
|
+
An email is sent to all users linked to an experiment session to notify them of the end of the embargo period, 3 months and 1 month before the end date.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
LICENSE.md
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/drac_notifier/__init__.py
|
|
5
|
+
src/drac_notifier/config.py
|
|
6
|
+
src/drac_notifier/main.py
|
|
7
|
+
src/drac_notifier.egg-info/PKG-INFO
|
|
8
|
+
src/drac_notifier.egg-info/SOURCES.txt
|
|
9
|
+
src/drac_notifier.egg-info/dependency_links.txt
|
|
10
|
+
src/drac_notifier.egg-info/requires.txt
|
|
11
|
+
src/drac_notifier.egg-info/top_level.txt
|
|
12
|
+
src/drac_notifier/clients/__init__.py
|
|
13
|
+
src/drac_notifier/clients/icat_plus.py
|
|
14
|
+
src/drac_notifier/clients/smis.py
|
|
15
|
+
src/drac_notifier/email_notifier/__init__.py
|
|
16
|
+
src/drac_notifier/email_notifier/service.py
|
|
17
|
+
src/drac_notifier/email_notifier/templates/__init__.py
|
|
18
|
+
src/drac_notifier/email_notifier/templates/end_embargo.py
|
|
19
|
+
src/drac_notifier/email_notifier/templates/templates.py
|
|
20
|
+
src/drac_notifier/notifications/__init__.py
|
|
21
|
+
src/drac_notifier/notifications/end_embargo.py
|
|
22
|
+
src/drac_notifier/tests/__init__.py
|
|
23
|
+
src/drac_notifier/tests/test_release_date.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
drac_notifier
|