Skip to content

Commit ca9dd4f

Browse files
committed
Added: [untested] read/write email config api routes (#284)
1 parent 3c33386 commit ca9dd4f

File tree

3 files changed

+214
-8
lines changed

3 files changed

+214
-8
lines changed

libreforms_fastapi/app/__init__.py

+118-3
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@
119119
RelationshipTypeModel,
120120
UserRelationshipModel,
121121
FormConfigUpdateRequest,
122+
EmailConfigUpdateRequest,
122123
SiteConfig,
123124
get_user_model,
124125
get_form_model,
@@ -139,7 +140,12 @@
139140
from libreforms_fastapi.utils.custom_tinydb import CustomEncoder
140141

141142
# Import to render more powerful email content
142-
from libreforms_fastapi.utils.jinja_emails import render_email_message_from_jinja
143+
from libreforms_fastapi.utils.jinja_emails import (
144+
render_email_message_from_jinja,
145+
write_email_config_yaml,
146+
test_email_config,
147+
get_email_yaml,
148+
)
143149

144150
# Here we set the application config using the get_config
145151
# factory pattern defined in libreforms_fastapi.utis.config.
@@ -4552,7 +4558,7 @@ async def api_admin_get_form_config(
45524558
raise HTTPException(status_code=404)
45534559

45544560

4555-
_form_config = _get_form_config_yaml(config_path=config.FORM_CONFIG_PATH)
4561+
_form_config = get_form_config_yaml(config_path=config.FORM_CONFIG_PATH)
45564562

45574563
# Write this query to the TransactionLog
45584564
if config.COLLECT_USAGE_STATISTICS:
@@ -4600,7 +4606,7 @@ async def api_admin_write_form_config(
46004606
raise HTTPException(status_code=404)
46014607

46024608

4603-
old_form_config_str = get_form_config_yaml(config_path=config.FORM_CONFIG_PATH).strip()
4609+
# old_form_config_str = get_form_config_yaml(config_path=config.FORM_CONFIG_PATH).strip()
46044610

46054611
try:
46064612
write_form_config_yaml(
@@ -4634,6 +4640,115 @@ async def api_admin_write_form_config(
46344640

46354641

46364642

4643+
# Get email yaml
4644+
@app.get(
4645+
"/api/admin/get_email_config",
4646+
dependencies=[Depends(api_key_auth)],
4647+
response_class=JSONResponse,
4648+
)
4649+
async def api_admin_get_email_config(
4650+
request: Request,
4651+
background_tasks: BackgroundTasks,
4652+
config = Depends(get_config_depends),
4653+
mailer = Depends(get_mailer),
4654+
session: SessionLocal = Depends(get_db),
4655+
key: str = Depends(X_API_KEY),
4656+
return_as_yaml_str: bool = False,
4657+
):
4658+
"""
4659+
Allows site administrators to view the site email config as yaml. This operation is logged for audit purposes. Set
4660+
`return_as_yaml_str` to True to receive back a string of the yaml config file.
4661+
"""
4662+
4663+
# Get the requesting user details
4664+
user = session.query(User).filter_by(api_key=key).first()
4665+
4666+
if not user or not user.site_admin:
4667+
raise HTTPException(status_code=404)
4668+
4669+
email_config = get_email_yaml(config_path=config.EMAIL_CONFIG_PATH, return_as_yaml_str=return_as_yaml_str)
4670+
4671+
# Write this query to the TransactionLog
4672+
if config.COLLECT_USAGE_STATISTICS:
4673+
4674+
endpoint = request.url.path
4675+
remote_addr = request.client.host
4676+
4677+
background_tasks.add_task(
4678+
write_api_call_to_transaction_log,
4679+
api_key=key,
4680+
endpoint=endpoint,
4681+
remote_addr=remote_addr,
4682+
query_params={},
4683+
)
4684+
4685+
return JSONResponse(
4686+
status_code=200,
4687+
content={"status": "success", "content": email_config},
4688+
)
4689+
4690+
4691+
# Update email config string
4692+
@app.post(
4693+
"/api/admin/write_email_config",
4694+
dependencies=[Depends(api_key_auth)],
4695+
response_class=JSONResponse,
4696+
)
4697+
async def api_admin_write_email_config(
4698+
request: Request,
4699+
_email_config: EmailConfigUpdateRequest,
4700+
background_tasks: BackgroundTasks,
4701+
config = Depends(get_config_depends),
4702+
mailer = Depends(get_mailer),
4703+
session: SessionLocal = Depends(get_db),
4704+
key: str = Depends(X_API_KEY)
4705+
):
4706+
"""
4707+
Allows site administrators to update the site email config as yaml. This operation is logged for audit purposes.
4708+
"""
4709+
4710+
# Get the requesting user details
4711+
user = session.query(User).filter_by(api_key=key).first()
4712+
4713+
if not user or not user.site_admin:
4714+
raise HTTPException(status_code=404)
4715+
4716+
# old_config_str = get_email_yaml(config_path=config.EMAIL_CONFIG_PATH, return_as_yaml_str=return_as_yaml_str).strip()
4717+
4718+
4719+
try:
4720+
write_email_config_yaml(
4721+
config_path=config.EMAIL_CONFIG_PATH,
4722+
email_config_str=_email_config.content,
4723+
env=config.ENVIRONMENT,
4724+
timezone=config.TIMEZONE,
4725+
config=config, user=user, # Add'l kwargs to validate with better data
4726+
)
4727+
except Exception as e:
4728+
raise HTTPException(status_code=422, detail=f"{e}")
4729+
4730+
# Write this query to the TransactionLog
4731+
if config.COLLECT_USAGE_STATISTICS:
4732+
4733+
endpoint = request.url.path
4734+
remote_addr = request.client.host
4735+
4736+
background_tasks.add_task(
4737+
write_api_call_to_transaction_log,
4738+
api_key=key,
4739+
endpoint=endpoint,
4740+
remote_addr=remote_addr,
4741+
query_params={},
4742+
)
4743+
4744+
return JSONResponse(
4745+
status_code=200,
4746+
content={"status": "success"},
4747+
)
4748+
4749+
4750+
4751+
46374752
# Update form config string
46384753
@app.post(
46394754
"/api/admin/update_site_config",

libreforms_fastapi/utils/jinja_emails.py

+67-5
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import yaml
2+
from zoneinfo import ZoneInfo
23
from jinja2 import Template, Undefined, Environment, make_logging_undefined, select_autoescape
34

45

@@ -117,8 +118,7 @@ def __str__(self):
117118

118119

119120

120-
121-
def get_message_jinja(message_type, config_path):
121+
def get_email_yaml(config_path, return_as_yaml_str=False):
122122

123123
# We want to add logic to read and overwrite
124124
# these template key-value pairs using yaml.
@@ -127,18 +127,35 @@ def get_message_jinja(message_type, config_path):
127127
try:
128128
assert(os.path.exists(config_path)) # If it doesn't exist, let's skip this rigamarole
129129
with open(config_path, 'r') as file:
130-
loaded_config = yaml.safe_load(file.read())
130+
131+
yaml_str = file.read()
132+
133+
if return_as_yaml_str:
134+
return yaml_str
135+
136+
loaded_config = yaml.safe_load(yaml_str)
131137

132138
except Exception as e:
139+
140+
if return_as_yaml_str:
141+
return EXAMPLE_EMAIL_CONFIG_YAML
142+
133143
loaded_config = {}
134144

135145
# Overwrite default config with values from the default path
136146
for key, value in loaded_config.items():
137147
default_config[key] = value
138148

149+
return default_config
150+
151+
152+
def get_message_jinja(message_type, config_path):
153+
154+
config = get_email_yaml(config_path)
155+
139156
# Retrieve the unrendered Jinja templates from the dictionary
140-
subj_template_str = default_config[message_type]['subj']
141-
cont_template_str = default_config[message_type]['cont']
157+
subj_template_str = config[message_type]['subj']
158+
cont_template_str = config[message_type]['cont']
142159

143160
return subj_template_str, cont_template_str
144161

@@ -157,6 +174,51 @@ def render_email_message_from_jinja(message_type, config_path, **kwargs):
157174

158175
return rendered_subj, rendered_cont
159176

177+
def write_email_config_yaml(
178+
config_path:str,
179+
email_config_str:str,
180+
env:str,
181+
timezone=ZoneInfo("America/New_York"),
182+
test_on_write:bool=True,
183+
**kwargs,
184+
):
185+
"""
186+
Here we write the string representation of the yaml email config.
187+
"""
188+
189+
if test_on_write:
190+
# Attempt to load the YAML string to check its validity
191+
_ = test_email_config(email_config_str, **kwargs)
192+
193+
# Ensure the base directory exists
194+
basedir = os.path.dirname(config_path)
195+
if not os.path.exists(basedir):
196+
os.makedirs(basedir)
197+
198+
# Create a backup of the current config
199+
config_backup_directory = Path(os.getcwd()) / 'instance' / f'{env}_email_config_backups'
200+
config_backup_directory.mkdir(parents=True, exist_ok=True)
201+
202+
datetime_format = datetime.now(timezone).strftime("%Y%m%d%H%M%S")
203+
config_file_name = Path(config_path).name
204+
backup_file_name = f"{config_file_name}.{datetime_format}"
205+
backup_file_path = config_backup_directory / backup_file_name
206+
207+
# Copy the existing config file to the backup location
208+
if os.path.exists(config_path):
209+
shutil.copy(config_path, backup_file_path)
210+
211+
# Write the cleaned YAML string to the config file
212+
try:
213+
with open(config_path, 'w') as file:
214+
# file.write(cleaned_form_config_str)
215+
file.write(email_config_str)
216+
except Exception as e:
217+
raise Exception(f"Failed to write the email config to {config_path}: {e}")
218+
219+
return True
220+
221+
160222

161223
def test_email_config(email_config_yaml, **kwargs):
162224
# Load the YAML configuration into a Python dictionary

libreforms_fastapi/utils/pydantic_models.py

+29
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@
2525
get_custom_loader,
2626
)
2727

28+
29+
# from libreforms_fastapi.utils.jinja_emails import (
30+
# test_email_config,
31+
# )
32+
33+
2834
class ImproperUsernameFormat(Exception):
2935
"""Raised when the username does not meet the regular expression defined in the app config"""
3036
pass
@@ -982,6 +988,29 @@ def validate_yaml(cls, v):
982988
raise ValueError(f"An error occurred while parsing YAML: {e}")
983989

984990

991+
class EmailConfigUpdateRequest(BaseModel):
992+
"""Another quick model for managing admin update email config API calls"""
993+
content: str
994+
995+
@validator('content')
996+
def validate_yaml(cls, v):
997+
998+
try:
999+
# Remove leading and trailing double and single quotes
1000+
v = v.strip('"\'')
1001+
1002+
# _ = test_email_config(v, **kwargs)
1003+
1004+
data = yaml.safe_load(v)
1005+
1006+
return v
1007+
except yaml.YAMLError as e:
1008+
raise ValueError(f"The content is not valid YAML: {e}")
1009+
except Exception as e:
1010+
raise ValueError(f"An error occurred while parsing YAML: {e}")
1011+
1012+
1013+
9851014
class GroupModel(BaseModel):
9861015
"""This model will be used for validating change to Groups through the admin API"""
9871016
# id: int = Field(None)

0 commit comments

Comments
 (0)