diff --git a/app/lib_general.py b/app/lib_general.py index 773d172..abc774f 100644 --- a/app/lib_general.py +++ b/app/lib_general.py @@ -1,7 +1,15 @@ from __future__ import annotations -import datetime, jwt, os, pandas, pathlib, pytz, redis, time +import datetime, html2text, jwt, os, pandas, pathlib, pytz, redis, time from passlib.hash import argon2 +# Import smtplib for the actual sending function +import smtplib, ssl + +# Import the email package modules needed +from email.message import EmailMessage +from email.headerregistry import Address +from email.utils import make_msgid + from fastapi import APIRouter, Depends, Header, HTTPException, Response, status from pydantic import BaseModel, EmailStr, Field from typing import Dict, List, Optional, Set, Union @@ -163,3 +171,90 @@ def create_export_file( return tmp_file_path # True # ### END ### API Lib General ### create_export() ### + + +# ### BEGIN ### API Lib General ### send_email() ### +# Updated 2021-12-02 +@logger_reset +def send_email( + from_email:str, + to_email: str, + subject: str, + body_html: str, + from_name: str = '', + reply_to_email: str = '', + reply_to_name: str = '', + to_name: str = '', + cc_email: str = '', + cc_name: str = '', + bcc_email: str = '', + bcc_name: str = '', + body_text: str = '', + ): + log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL + log.debug(locals()) + + message = EmailMessage() + if subject: + message['Subject'] = subject + else: + return False + if from_email: + #message['From'] = Address(display_name=from_name, username=from_email.split('@')[0], domain=from_email.split('@')[1]) + message['From'] = Address(display_name=from_name, addr_spec=from_email) + else: + return False + if reply_to_email: + message['Reply-To'] = Address(display_name=reply_to_name, addr_spec=reply_to_email) + if to_email: + message['To'] = Address(display_name=to_name, addr_spec=to_email) + else: + return False + if cc_email: + message['Cc'] = Address(display_name=cc_name, addr_spec=cc_email) + if bcc_email: + message['Bcc'] = Address(display_name=bcc_name, addr_spec=bcc_email) + + html_version = """\ + + +
+ """+body_html+""" +
+ + + """ + + if body_text: + text_version = body_text + else: + text_version = html2text.html2text(html_version) + + message.set_content(text_version) + + message.add_alternative(html_version, subtype='html') + + log.info('Sending email...') + log.debug(settings.SMTP) + + log.info(f'Subject: {subject}') + log.info(f'From: {from_email} Reply To: {reply_to_email} To: {to_email} CC: {cc_email} BCC: {bcc_email}') + + log.debug('Message:') + log.debug(message.as_string()) + + log.info('Creating SMTP SSL connection...') + context = ssl.create_default_context() + + log.info('SMTP configuration, connect, and send') + try: + with smtplib.SMTP_SSL(settings.SMTP['server_name'], settings.SMTP['server_port'], context=context) as server: + server.login(settings.SMTP['username'], settings.SMTP['password']) + server.send_message(message) + log.info('Email sent') + return True + except: + #except SMTPException: + log.error('Error: unable to send email') + return False +# ### END ### API Lib General ### send_email() ### diff --git a/app/methods/event_methods.py b/app/methods/event_methods.py index 6d336ef..8a13d66 100644 --- a/app/methods/event_methods.py +++ b/app/methods/event_methods.py @@ -13,7 +13,7 @@ from app.methods.event_cfg_methods import create_update_event_cfg_obj_v4, load_e from app.methods.event_location_methods import get_event_location_rec_list, load_event_location_obj from app.methods.event_session_methods import get_event_session_rec_list, load_event_session_obj from app.methods.person_methods import create_update_person_obj_v4b, load_person_obj -from app.methods.user_methods import create_user_obj, load_user_obj, update_user_obj +# from app.methods.user_methods import create_user_obj, load_user_obj, update_user_obj from app.models.common_field_schema import default_num_bytes from app.models.event_models import Event_Base @@ -736,6 +736,7 @@ def update_event_obj( if event_obj.user_id and event_obj.user: event_outline['user_id'] = None log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL + from app.methods.user_methods import update_user_obj user_id = event_obj.user_id user_obj_up = event_obj.user log.debug(user_id) @@ -751,6 +752,7 @@ def update_event_obj( return False elif event_obj.user and not event_obj.user.id: # NOTE: This will blindly create a new user even if there was one associated but the event.user_id was not found. + from app.methods.user_methods import create_user_obj user_obj_in = event_obj.user log.debug(user_obj_in) if user_obj_in_result := create_user_obj(account_id=account_id, user_obj_new=user_obj_in): diff --git a/app/methods/event_person_methods.py b/app/methods/event_person_methods.py index 330f4fc..4a11ffa 100644 --- a/app/methods/event_person_methods.py +++ b/app/methods/event_person_methods.py @@ -18,7 +18,7 @@ from app.methods.event_person_profile_methods import load_event_person_profile_o # from app.methods.event_session_methods import load_event_session_obj # from app.methods.event_track_methods import load_event_track_obj from app.methods.person_methods import create_person_obj_v3, load_person_obj, update_person_obj -from app.methods.user_methods import create_user_obj, load_user_obj, update_user_obj +# from app.methods.user_methods import create_user_obj, load_user_obj, update_user_obj from app.models.common_field_schema import default_num_bytes from app.models.event_person_models import Event_Person_New_Base, Event_Person_Base @@ -170,6 +170,7 @@ def load_event_person_obj( if inc_user: log.info('Need to include user data...') # log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL + from app.methods.user_methods import load_user_obj if user_obj := load_user_obj( user_id = user_id ): @@ -876,6 +877,7 @@ def update_event_person_obj( if event_person_obj_up.user_id and event_person_obj_up.user: # log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL + from app.methods.user_methods import update_user_obj user_id = event_person_obj_up.user_id user_obj_up = event_person_obj_up.user log.debug(user_id) @@ -891,6 +893,7 @@ def update_event_person_obj( return False elif event_person_obj_up.user and not event_person_obj_up.user.id: # NOTE: This will blindly create a new user even if there was one associated but the event_person.user_id was not found. + from app.methods.user_methods import create_user_obj user_obj_in = event_person_obj_up.user log.debug(user_obj_in) if user_obj_in_result := create_user_obj(account_id=account_id, user_obj_new=user_obj_in): diff --git a/app/methods/user_methods.py b/app/methods/user_methods.py index 74ca0ce..67a33be 100644 --- a/app/methods/user_methods.py +++ b/app/methods/user_methods.py @@ -1,12 +1,13 @@ from __future__ import annotations -import datetime, random +import datetime, random, secrets from typing import Dict, List, Optional, Set, Union from pydantic import BaseModel, EmailStr, Field, PrivateAttr, ValidationError, validator from app.db_sql import redis_lookup_id_random, sql_insert, sql_select, sql_update -from app.lib_general import log, logging +from app.lib_general import log, logging, send_email +# from app.methods.account_methods import load_account_cfg_obj from app.methods.contact_methods import load_contact_obj, update_contact_obj # from app.methods.event_methods import get_event_rec_list from app.methods.order_methods import load_order_obj, get_order_rec_list @@ -15,6 +16,7 @@ from app.methods.person_methods import create_person_obj_v3, load_person_obj, up from app.methods.post_methods import get_post_rec_list, load_post_obj from app.methods.user_role_methods import get_user_role_rec_list, load_user_role_obj +from app.models.common_field_schema import default_num_bytes from app.models.user_models import User_Base, User_New_Base, User_Out_Base @@ -482,3 +484,120 @@ def get_user_rec_list( return user_rec_li # ### END ### API User Methods ### get_user_rec_list() ### + + +# ### BEGIN ### User Methods ### email_user_auth_key_url() ### +# This emails the actual one time use sign in URL for a user. +# Updated 2021-12-02 +def email_user_auth_key_url( + account_id: int|str, + user_id: int|str, + root_url: str, + ): + log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL + log.debug(locals()) + + if account_id := redis_lookup_id_random(record_id_random=account_id, table_name='account'): pass + else: return False + + if user_id := redis_lookup_id_random(record_id_random=user_id, table_name='user'): pass + else: return False + + if user_obj := load_user_obj( + user_id = user_id, + ): + log.info('User object loaded') + if user_obj.enable and user_obj.allow_auth_key: + new_auth_key = secrets.token_urlsafe(default_num_bytes) + update_user_data = {} + update_user_data['auth_key'] = new_auth_key + + if user_rec_update_result := sql_update( + table_name = 'user', + record_id = user_id, + data = update_user_data + ): + log.info('The user record was updated with a new auth_key') + else: + log.warning('The user record was not updated with a new auth_key') + return False + else: + log.warning('The user record was not enabled or auth_key is not allowed') + return False + else: return False + log.debug(user_obj) + + from app.methods.account_cfg_methods import load_account_cfg_obj + if account_cfg := load_account_cfg_obj( + account_id = account_id, + ): + log.info('Account config loaded') + else: return False + log.debug(account_cfg) + + user_id_random = user_obj.id_random # NOTE: Not user_id_random because of alias + + from_email = account_cfg.default_no_reply_email + from_name = account_cfg.default_no_reply_name + + to_name = user_obj.name + to_email = user_obj.email + + bcc_email = account_cfg.confirm_email + bcc_name = account_cfg.confirm_name + + help_tech_email = account_cfg.help_tech_email + help_tech_name = account_cfg.help_tech_name + + account_short_name = account_cfg.account_short_name + + username = user_obj.username + enable = user_obj.enable + if enable_from := user_obj.enable_from: + # enable_from_datetime = datetime.datetime.fromisoformat(enable_from).replace(tzinfo=datetime.timezone.utc) + enable_from_datetime = enable_from.replace(tzinfo=datetime.timezone.utc) + enable_from_datetime = enable_from + enable_from_str = enable_from_datetime.strftime('%A, %B %-d, %Y %-I:%M %p %Z') + else: enable_from_str = '-- Not Set --' + if enable_to := user_obj.enable_to: + # enable_to_datetime = datetime.datetime.fromisoformat(enable_to).replace(tzinfo=datetime.timezone.utc) + enable_to_datetime = enable_to.replace(tzinfo=datetime.timezone.utc) + enable_to_str = enable_to_datetime.strftime('%A, %B %-d, %Y %-I:%M %p %Z') + else: enable_to_str = '-- Not Set --' + auth_key = user_obj.auth_key + + user_login_url = f'{root_url}user/login?username={username}' + user_login_auth_key_url = f'{root_url}?user_id={user_id_random}&auth_key={new_auth_key}' + + subject = f'{account_short_name}: One Time Use Sign In Link ({new_auth_key})' + + body_html = f""" +

{to_name},

+ +

If you did not request this sign in link, please delete this email. It is suggested that you delete this email after the sign in link has been used or if a new link has been requested.

+ +

The link below can only be used to sign in once. If you would like to sign in again using this method, you must request a new sign in link. If you request multiple links, only the newest link will sign you in.

+ +

Click to Sign In With One Time Use Link

+ +

Or copy and paste the link:
+ {user_login_auth_key_url}

+ +

Your username is: {username}
+ User account enabled: {enable}
+ User account enabled from: {enable_from_str}
+ User account enabled to: {enable_to_str}
+ Current one time use auth key for user account: {new_auth_key}

+ +

If you have questions about this email or trouble with this one time use link, you can email {help_tech_name} ({help_tech_email}).

+ +

Thank you!

+ """ + + if send_email(from_email=from_email, from_name=from_name, to_email=to_email, to_name=to_name, bcc_email=bcc_email, bcc_name=bcc_name, subject=subject, body_text=None, body_html=body_html): + log.info(f'An email with a one time use sign in link was sent to {to_email}.') + return True + else: + log.info(f'An email with a one time use sign in link was not sent to {to_email}.') + return False +# ### END ### User ### email_user_auth_key_url() ### diff --git a/app/models/account_cfg_models.py b/app/models/account_cfg_models.py index 77162bb..0b5ab13 100644 --- a/app/models/account_cfg_models.py +++ b/app/models/account_cfg_models.py @@ -117,7 +117,7 @@ class Account_Cfg_Base(BaseModel): @validator('account_id', always=True) def account_id_lookup(cls, v, values, **kwargs): - log.setLevel(logging.WARNING) + log.setLevel(logging.DEBUG) log.debug(locals()) if values['account_id_random']: @@ -126,6 +126,7 @@ class Account_Cfg_Base(BaseModel): class Config: underscore_attrs_are_private = True + allow_population_by_field_name = True fields = base_fields #Account_Cfg_Base.update_forward_refs() diff --git a/app/routers/person.py b/app/routers/person.py index d6c231f..5448c80 100644 --- a/app/routers/person.py +++ b/app/routers/person.py @@ -394,9 +394,9 @@ async def lookup_email( if account_id := redis_lookup_id_random(record_id_random=x_account_id, table_name='account'): pass else: return mk_resp(data=None, status_code=404, response=response) - import time + # import time - time.sleep(1) + # time.sleep(1) if person_rec_list_result := get_person_rec_list( #account_id = account_id, diff --git a/app/routers/user.py b/app/routers/user.py index 32c4719..58968b8 100644 --- a/app/routers/user.py +++ b/app/routers/user.py @@ -10,7 +10,7 @@ from app.db_sql import sql_insert, sql_update, sql_insert_or_update, sql_select, from app.routers.api_crud import delete_obj_template, get_obj_template, get_obj_li_template, patch_obj_template, post_obj_template from app.methods.order_methods import get_order_rec_list, load_order_obj -from app.methods.user_methods import create_user_obj, get_user_rec_list, load_user_obj +from app.methods.user_methods import create_user_obj, email_user_auth_key_url, get_user_rec_list, load_user_obj from app.models.common_field_schema import default_num_bytes from app.models.response_models import Resp_Body_Base, mk_resp @@ -806,6 +806,42 @@ async def lookup_username( return mk_resp(data=data, response=response) +# ### BEGIN ### API User ### email_auth_key_url() ### +# Updated 2021-12-02 +# @router.get('/user/email_auth_key_url', response_model=Resp_Body_Base) +@router.get('/user/{user_id}/email_auth_key_url', response_model=Resp_Body_Base) +async def email_auth_key_url( + user_id: Optional[str] = Query(None, min_length=11, max_length=22), + root_url: Optional[str] = Query(None, min_length=10, max_length=100), # Absolute min = 7 + x_account_id: Optional[str] = Header(..., ), + return_obj: bool = False, + by_alias: bool = True, + exclude_unset: bool = True, + response: Response = Response, + ): + log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL + log.debug(locals()) + + if account_id := redis_lookup_id_random(record_id_random=x_account_id, table_name='account'): pass + else: return mk_resp(data=False, status_code=404, response=response) # Not Found + + if user_id := redis_lookup_id_random(record_id_random=user_id, table_name='user'): pass + else: return mk_resp(data=False, status_code=404, response=response) # Not Found + + if result := email_user_auth_key_url( + account_id = account_id, + user_id = user_id, + root_url = root_url, + ): + log.info('Email with auth key log in URL was sent.') + return mk_resp(data=True, response=response) + else: + log.warning('Email with auth key log in URL was not sent.') + return mk_resp(data=False, status_code=500, response=response) +# ### END ### API User ### email_auth_key_url() ### + + + # ### BEGIN ### API User ### get_user_obj() ### # Working well as of 2021-06-25. Using as a template for other routes. @router.get('/user/{user_id}', response_model=Resp_Body_Base)