#!/usr/bin/env python3 # This script was contributed by Timothée Loyck Andres. import argparse import json import os.path import pathlib import shutil import ssl import tempfile from email import encoders from email.mime.base import MIMEBase from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from getpass import getpass from smtplib import SMTP_SSL, SMTPAuthenticationError, SMTPDataError from typing import List, Optional, Any, Union # ========================= CONFIG VARIABLES ========================= SMTP_SERVER = 'mail.epfl.ch' BOT_EMAIL_ADDRESS = 'lamp.fos.bot@gmail.com' # The minimum number of people in a group MIN_SCIPERS = 1 # The maximum number of people in a group MAX_SCIPERS = 3 # The number of the project (set to None to prompt user) PROJECT_NUMBER = None # The name of the file in which the submission configuration is stored CONFIG_FILE_NAME = '.submission_info.json' # The name of the folder to be zipped SRC_FOLDER = 'src' # ==================================================================== project_path = pathlib.Path(__file__).parent.resolve() config_path = f'{project_path}/{CONFIG_FILE_NAME}' src_path = f'{project_path}/{SRC_FOLDER}' tmp_folder = tempfile.gettempdir() class ConfigData: def __init__(self, data_values: Optional[dict] = None, **kwargs): """ Creates a new data object with the given values. A dictionary may be passed, or keyword arguments for each data piece. Contains: email address, username, SCIPER numbers of the group's members, project number :argument data_values: an optional dictionary containing the required data :keyword email: the email of the user :keyword username: the GASPAR id of the user :keyword scipers: the list of SCIPER numbers of the group's members :keyword project_num: the project's number """ if data_values is not None: data = data_values elif len(kwargs) > 0: data = kwargs else: data = dict() self.email: str = data.get('email') self.username: str = data.get('username') self.scipers: List[str] = data.get('scipers') self.project_num: int = data.get('project_num') def get_config_data(self) -> dict: return { 'email': self.email, 'username': self.username, 'scipers': self.scipers, 'project_num': self.project_num } def get_scipers() -> List: """ Retrieves the group's SCIPER numbers. :return: a list containing the SCIPER numbers of all the members of the FoS group """ def is_sciper(string: str) -> bool: try: return len(string) == 6 and int(string) > 0 except TypeError: return False num_scipers = None scipers: List[str] = [] while num_scipers is None: num_scipers = get_sanitized_input( "Number of people in the group: ", int, predicate=lambda n: MIN_SCIPERS <= n <= MAX_SCIPERS, predicate_error_msg=f"The number of people must be between {MIN_SCIPERS} and {MAX_SCIPERS} included." ) for i in range(num_scipers): sciper = None while sciper is None: sciper = get_sanitized_input( f"SCIPER {i + 1}: ", predicate=is_sciper, predicate_error_msg="Invalid SCIPER number. Please try again." ) scipers.append(sciper) return scipers def get_sanitized_input(prompt: str, value_type: type = str, **kwargs) -> Optional[Any]: """ Sanitizes the user's input. :param prompt: the message to be displayed for the user :param value_type: the type of value that we expect, for example str or int :keyword allow_empty: allow the input to be empty. The returned string may be the empty string :keyword predicate: a function that, when applied to the sanitized input, checks if it is valid :keyword predicate_error_msg: a message to be displayed if the predicate returns false on the input :return: the input as the passed type, or None if the input contained only whitespaces or if the type cast failed """ str_value = input(prompt).strip() if len(str_value) > 0 or kwargs.get('allow_empty'): try: value = value_type(str_value) p = kwargs.get('predicate') if p is not None and not p(value): if kwargs.get('predicate_error_msg') is None: print("Invalid value. Please try again.") elif len(kwargs.get('predicate_error_msg')) > 0: print(kwargs.get('predicate_error_msg')) return None return value except TypeError: raise TypeError(f"Incorrect value type: {value_type}") except ValueError: print(f"The value could not be interpreted as type {value_type.__name__}. Please try again.") return None def get_config(from_file: bool = True) -> ConfigData: """ Retrieves the configuration for sending the email. It may be fetched from a configuration file, or if it does not exist or is incomplete, it will ask the user for the data, then write it to the config file. :param from_file: whether to retrieve the configuration from the config file if it exists. Default is True :return: the configuration to use for the email """ data = ConfigData() # Set project number if it is already specified data.project_num = PROJECT_NUMBER if from_file and not os.path.isfile(config_path): print('Please provide data that will be used to submit your project.') print(f'This information (sans the password) will be saved in: ./{CONFIG_FILE_NAME}') if from_file and os.path.isfile(config_path): with open(config_path, 'r') as config_file: config = json.load(config_file) if type(config) is dict: data = ConfigData(config) if data.scipers is None: data.scipers = get_scipers() while data.email is None: data.email = get_sanitized_input("Email address: ", predicate=lambda address: '@' in address) while data.username is None: data.username = get_sanitized_input("Gaspar ID: ") while data.project_num is None: data.project_num = get_sanitized_input("Project number: ", int, predicate=lambda n: n > 0) set_config(data) return data def set_config(data: ConfigData) -> None: """ Saves the configuration in the config file. :param data: the data to be saved """ with open(config_path, 'w') as config_file: json.dump(data.get_config_data(), config_file) def create_email(frm: str, to: str, subject: str, content: Optional[str] = None, attachments: Optional[Union[str, List[str]]] = None) -> MIMEMultipart: """ Creates an email. :param frm: the address from which the email is sent :param to: the address to which send the email :param subject: the subject of the email :param content: the content of the email. Can be empty :param attachments: the attachments of the email. Can be a path or a list of paths """ message = MIMEMultipart() message['From'] = frm message['To'] = to message['Subject'] = subject if content is not None: # Add content into body of message message.attach(MIMEText(content, 'plain')) if attachments is not None: if type(attachments) is str: attachments = [attachments] for attachment_path in attachments: part = MIMEBase("application", "octet-stream") with open(attachment_path, 'rb') as attachment: part.set_payload(attachment.read()) encoders.encode_base64(part) part.add_header("Content-Disposition", f"attachment; filename={os.path.basename(attachment_path)}") message.attach(part) return message if __name__ == '__main__': arg_parser = argparse.ArgumentParser(description="Submits the project to the bot for grading.", allow_abbrev=False) arg_parser.add_argument('-r', '--reset', action='store_true', help="ask for the submission data even if previously " "specified") arg_parser.add_argument('-s', '--self', action='store_true', help="send the mail to yourself instead of the bot") if not os.path.isdir(src_path): arg_parser.exit(1, f"No {SRC_FOLDER} folder found. Aborting.\n") args = arg_parser.parse_args() config: ConfigData = get_config(from_file=not args.reset) password: str = getpass("Gaspar password: ") recipient = config.email if args.self else BOT_EMAIL_ADDRESS mail = create_email( config.email, recipient, f"Project {config.project_num} ({', '.join(config.scipers)})", attachments=shutil.make_archive(f'{tmp_folder}/{SRC_FOLDER}', 'zip', root_dir=project_path, base_dir=f'{SRC_FOLDER}') ) with SMTP_SSL(SMTP_SERVER, context=ssl.create_default_context()) as server: try: server.login(config.username, password) server.sendmail(config.email, recipient, mail.as_string()) print(f"Submission sent to {recipient}.") except SMTPAuthenticationError as e: if e.smtp_code == 535: print(f"Wrong GASPAR ID ({config.username}) or password. Your ID will be asked for again on the next" " run.") # Remove (potentially) incorrect ID from config config.username = None set_config(config) exit(2) else: raise except SMTPDataError as e: if e.smtp_code == 550: print("You email address seems to be incorrect. It will be asked for again on the next run.") # Remove incorrect address from config config.email = None set_config(config) exit(2) else: raise