diff --git a/DataSources/dataSources.py b/DataSources/dataSources.py index 3a5a436..b969e25 100644 --- a/DataSources/dataSources.py +++ b/DataSources/dataSources.py @@ -4,9 +4,10 @@ from enum import Enum from pandas import read_csv, read_excel, DataFrame from additionalTableSetup import GroupContacts -from models import IModel, Contact +from models import DataImport, IModel, Contact import sqlalchemy as alchem import sqlalchemy.orm as orm +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.hybrid import hybrid_property class SupportedDbEngines(Enum): @@ -116,15 +117,33 @@ def LoadSavedState(self) -> None: continue IModel.run_loading = False + def Update(self, obj: IModel): + Session = orm.sessionmaker(bind=self.dbEngineInstance) + try: + with Session() as session: + session.merge(obj) + session.commit() + except IntegrityError as ie: + print(ie) + except Exception as e: + print(e) + finally: + self.dbEngineInstance.dispose() + def Save(self, obj: IModel | GroupContacts): Session = orm.sessionmaker(bind=self.dbEngineInstance) - with Session() as session: - session.add(obj) - session.commit() - session.refresh(obj) + try: + with Session() as session: + session.add(obj) + session.commit() + session.refresh(obj) + except IntegrityError as ie: + print(ie) + except Exception as e: + print(e) self.dbEngineInstance.dispose() - def DeleteEntry(self, obj: IModel | GroupContacts): + def DeleteEntry(self, obj: IModel | GroupContacts): Session = orm.sessionmaker(bind=self.dbEngineInstance) with Session() as session: session.delete(obj) @@ -168,6 +187,8 @@ class GapFillSource(): def __init__(self, source: IDataSource | IModel = Contact) -> None: if isinstance(source, IDataSource): self.iData: IDataSource = source + elif isinstance(source, DataImport) or isinstance(source, list): + self.model_source: IModel = source elif issubclass(source, IModel): self.model_source: IModel = source else: @@ -192,5 +213,16 @@ def get_possible_values(self): elif hasattr(self, "model_source"): if self.model_source == Contact: self.possible_values = { name: attr for name, attr in Contact.__dict__.items() if isinstance(attr, hybrid_property) and attr != "all_instances" } + elif isinstance(self.model_source, DataImport): + self.possible_values = self.model_source.getColumnPreview() else: raise AttributeError(f"{type(self.model_source)} isn't supported") + + @staticmethod + def getPreviewText(searched: str) -> str | None: + for g in GapFillSource.all_instances: + candidate = g.possible_values.get(searched, None) + if candidate == None: + continue + return candidate + return None diff --git a/Interface/AddContactWindow.py b/Interface/AddContactWindow.py index c36d854..8648394 100644 --- a/Interface/AddContactWindow.py +++ b/Interface/AddContactWindow.py @@ -14,7 +14,7 @@ class AddContactWindow(Toplevel): - def __init__(self, parent: Toplevel | ContactList) -> None: + def __init__(self, parent: Toplevel) -> None: super().__init__(parent) self.parent = parent diff --git a/Interface/AppUI.py b/Interface/AppUI.py index 5812df0..eb9b066 100644 --- a/Interface/AppUI.py +++ b/Interface/AppUI.py @@ -4,11 +4,14 @@ from typing import NoReturn from tkinter import Menu, simpledialog, Listbox, Tk, Frame, Label, Entry, Scrollbar from tkinter.constants import BOTH, RIDGE, END, LEFT, RIGHT, TOP, X, Y, INSERT -from models import IModel, Template, Group +from models import IModel, Message, Template, Group, User from tkhtmlview import HTMLLabel from .GroupEditor import GroupEditor from .Settings import Settings from .TemplateEditor import TemplateEditor +from MessagingService.senders import ISender +import MessagingService.smtp_data +from MessagingService.ethereal_demo import send_email def errorHandler(xd, exctype: type, excvalue: Exception, tb: TracebackType): @@ -35,6 +38,20 @@ def prepareInterface(self) -> None: self.__create_mailing_group_pane() self.__create_template_pane() self.__create_mail_input_pane() + self.populateInterface() + + + def populateInterface(self) -> None: + modelType_func_mapper = { + Template: self.add_template, + Group: self.add_group + } + + for (modelType, ui_func) in modelType_func_mapper.items(): + ui_func(modelType.all_instances) + + def setSender(self, new_sender: ISender): + self.sender = new_sender def add_periodic_task(self, period: int, func: Callable): # TODO można poprawić żeby się odpalało tylko przy dodaniu obiektu, @@ -69,6 +86,14 @@ def add_group(self, g: Group | Iterable[Group]): [self.grupy.append(i) for i in g if i not in self.grupy] self.__update_listbox(self.grupy_listbox, self.grupy) + def clearData(self): + self.grupy = [] + self.szablony = [] + + def update(self): + self.clearData() + self.populateInterface() + def __add_group_clicked(self): self.show_group_window() @@ -76,15 +101,26 @@ def show_group_window(self, g: Group | None = None): group_editor = GroupEditor(self, g) group_editor.prepareInterface() - def __send_clicked(event) -> None: - print("send mail") - pass - - def __importuj_clicked(self): - pass - - def __eksportuj_clicked(self): - pass + def __send_clicked(self) -> None: + # TODO: Jakoś trzeba ogarnąć multiple selection na template + group (albo zrobić jakiś hackment) + #tmp = self.grupy_listbox.curselection() + #if len(tmp) == 0: + # raise ValueError("Wybierz grupę!") + #else: + # selectedGroup: Group = tmp[0] + + #tmp = self.template_listbox.curselection() + #if len(tmp) == 0: + # raise ValueError("Wybierz templatkę!") + #else: + # selectedTemplate: Template = tmp[0] + + #self.sender.SendEmails(selectedGroup, selectedTemplate, User.GetCurrentUser()) + message = "Hello" + print(message) + #recipient = 'kuczynskimaciej1@poczta.onet.pl' + #self.sender.Send(self, MessagingService.smtp_data.smtp_host, MessagingService.smtp_data.smtp_port, MessagingService.smtp_data.email, MessagingService.smtp_data.password, message, recipient) + send_email() def __template_selection_changed(self, _event): selected = self.template_listbox.curselection() @@ -101,7 +137,9 @@ def __group_selection_changed(self, _event): selected: int = self.grupy_listbox.curselection() if len(selected) > 0: g: Group = self.grupy[selected[0]] - mails = [", ".join(x.email) for x in g.contacts] + mails = "" + for c in g.contacts: + mails += c.email + ", " self.entry_adres.delete(0, END) self.entry_adres.insert(INSERT, mails) @@ -124,9 +162,7 @@ def __update_listbox(lb: Listbox, content: Iterable[IModel] | dict[IModel]): lb.delete(0, END) [lb.insert(END, k) for k in content.keys()] else: - raise AttributeError( - f"Wrong type of 'content', expected dict or Iterable, got { - type(content)}") + raise AttributeError(f"Wrong type of 'content', expected dict or Iterable, got {type(content)}") def __add_template_clicked(self): self.show_template_window() @@ -134,11 +170,6 @@ def __add_template_clicked(self): def __create_menu(self): menubar = Menu(self.root) - file_menu = Menu(menubar, tearoff=0) - file_menu.add_command(label="Import", command=self.__importuj_clicked) - file_menu.add_command(label="Export", command=self.__eksportuj_clicked) - menubar.add_cascade(label="File", menu=file_menu) - edit_menu = Menu(menubar, tearoff=0) add_menu = Menu(edit_menu, tearoff=0) add_menu.add_command( @@ -147,8 +178,8 @@ def __create_menu(self): add_menu.add_command(label="Group", command=self.__add_group_clicked) edit_menu.add_cascade(label="Add...", menu=add_menu) menubar.add_cascade(label="Edit", menu=edit_menu) - menubar.add_command(label="Open Settings", command=self.logout) - menubar.add_command(label="Send", command=lambda: self.__send_clicked()) + menubar.add_command(label="Open Settings", command=self.__openSettings_clicked) + menubar.add_command(label="Send", command=self.__send_clicked) self.root.config(menu=menubar) @@ -171,6 +202,7 @@ def __create_mailing_group_pane(self): grupy_label.pack() self.grupy_listbox.pack(fill=BOTH, expand=True) + def __create_template_pane(self): templates_frame = Frame( self.root, bg="lightblue", width=200, height=100, relief=RIDGE, borderwidth=2) @@ -188,6 +220,7 @@ def __create_template_pane(self): szablony_label.pack() self.template_listbox.pack(fill=BOTH, expand=True) + def __create_mail_input_pane(self): entry_frame = Frame(self.root, bg="lightblue", relief=RIDGE, borderwidth=2) @@ -212,8 +245,7 @@ def show_template_window(self, obj: Template | None = None): self.template_window = TemplateEditor(self, self.root, obj) self.template_window.prepareInterface() - def logout(self): - + def __openSettings_clicked(self): root = Tk() # Otwórz ponownie okno logowania settings = Settings(root) settings.prepareInterface() diff --git a/Interface/ContactList.py b/Interface/ContactList.py index 7d7d4d9..c7f1859 100644 --- a/Interface/ContactList.py +++ b/Interface/ContactList.py @@ -11,11 +11,10 @@ from models import Contact, IModel, Template, Group from tkhtmlview import HTMLLabel, HTMLText from DataSources.dataSources import GapFillSource -from GroupEditor import GroupEditor -from AddContactWindow import AddContactWindow +from .AddContactWindow import AddContactWindow class ContactList(Toplevel): - def __init__(self, parent: Toplevel | GroupEditor, group: Group | None = None) -> None: + def __init__(self, parent: Toplevel, group: Group | None = None) -> None: super().__init__(parent) self.group = group self.parent = parent @@ -60,16 +59,15 @@ def update(self): self.populateWindow() def populateWindow(self): - shouldAddButton = self.parent != None and isinstance(self.parent, GroupEditor) - for idx, c in enumerate(Contact.all_instances): - self.create_contact_widget(c, idx, addBtn=shouldAddButton) - + shouldAddButton = self.parent != None if self.group: group_contacts = GroupController.get_contacts(self.group) group_emails = {contact.email for contact in group_contacts} - for idx, c in enumerate(Contact.all_instances): - added_to_group = c.email in group_emails - self.create_contact_widget(c, idx, added_to_group, addBtn=shouldAddButton) + + for idx, c in enumerate(Contact.all_instances): + shouldToggle = c.email in group_emails + self.create_contact_widget(c, idx, added_to_group=shouldToggle, addBtn=shouldAddButton) + def create_contact_widget(self, c: Contact, idx: int, added_to_group: bool = False, addBtn: bool = True): def toggle_checkbox(): @@ -91,15 +89,13 @@ def add_contact_to_group(self, c: Contact): try: GroupController.add_contact(self.group, c) - if isinstance(self.parent, GroupEditor): - self.parent.update() + self.parent.update() except IntegrityError: pass def remove_contact_from_group(self, c: Contact): GroupController.delete_connection(self.group, c) - if isinstance(self.parent, GroupEditor): - self.parent.update() + self.parent.update() def search_contact(self): search_criteria = self.search_entry.get().strip() diff --git a/Interface/ExternalSourceImportWindow.py b/Interface/ExternalSourceImportWindow.py new file mode 100644 index 0000000..8f573e2 --- /dev/null +++ b/Interface/ExternalSourceImportWindow.py @@ -0,0 +1,92 @@ +from os.path import basename +from tkinter import END, Misc, Tk, Toplevel +import tkinter.messagebox as msg +import tkinter.filedialog as fd +from tkinter.ttk import Button, Label, Combobox, Treeview, Scrollbar +from openpyxl import load_workbook +from models import DataImport, Template + +class ExternalSourceImportWindow(Toplevel): + def __init__(self, parent: Toplevel | Tk, master: Misc, template: Template) -> None: + super().__init__(master) + self.parent = parent + self.template = template + self.prepareInterface() + self.file_path = None + + def prepareInterface(self): + self.title("Importuj dane") + + self.grid_rowconfigure(2, weight=1) + self.grid_columnconfigure(1, weight=1) + self.grid_columnconfigure(3, weight=1) + + self.label = Label(self, text="Select an Excel file:") + self.select_button = Button(self, text="Browse", command=self.browse_file) + self.combobox_label = Label(self, text="Worksheet names:") + self.combobox = Combobox(self) + self.combobox.bind("<>", self.update_preview) + + self.treeview = Treeview(self, show="headings") + self.treeview_scroll = Scrollbar(self, orient="vertical", command=self.treeview.yview) + self.treeview.configure(yscrollcommand=self.treeview_scroll.set) + + self.add_button = Button(self, text="Add", command=self.add_data) + + self.label.grid(row=0, column=0, padx=10, pady=10) + self.select_button.grid(row=0, column=1, padx=10, pady=10) + self.combobox_label.grid(row=1, column=0, padx=10, pady=10) + self.combobox.grid(row=1, column=1, padx=10, pady=10, sticky="ew") + self.treeview.grid(row=2, column=0, columnspan=4, padx=10, pady=10, sticky="nsew") + self.treeview_scroll.grid(row=2, column=4, sticky="ns") + self.add_button.grid(row=3, column=0, columnspan=5, padx=10, pady=10) + + def browse_file(self): + self.file_path = fd.askopenfilename( + filetypes=[("Excel files", "*.xlsx;*.xlsm"), ("All files", "*.*")] + ) + + if self.file_path: + self.load_worksheets() + + def load_worksheets(self): + try: + workbook = load_workbook(self.file_path, read_only=True) + sheet_names = workbook.sheetnames + self.combobox['values'] = sheet_names + if sheet_names: + self.combobox.current(0) + self.update_preview() + except Exception as e: + msg.showerror("Error", f"Failed to read the Excel file: {e}") + + def update_preview(self, event=None): + selected_sheet = self.combobox.get() + if not selected_sheet or not self.file_path: + return + + try: + workbook = load_workbook(self.file_path, read_only=True) + sheet = workbook[selected_sheet] + + self.treeview.delete(*self.treeview.get_children()) + first_row = next(sheet.iter_rows(values_only=True)) + if "Email" not in first_row: + # TODO: Można zrobić jakiś label zamiast treeview i errora + raise ValueError("Arkusz musi mieć kolumnę 'Email', aby dało się go połączyć z danymi") + + self.treeview["columns"] = first_row + for col in first_row: + self.treeview.heading(col, text=col) + self.treeview.column(col, width=100) + + for row in sheet.iter_rows(min_row=2, values_only=True): + self.treeview.insert("", END, values=row) + except Exception as e: + msg.showerror("Error", f"Failed to read the selected worksheet: {e}") + + def add_data(self): + di = DataImport(_name=basename(self.file_path), _localPath=self.file_path) + self.template.dataimport = di + self.parent.update() + self.destroy() diff --git a/Interface/GroupEditor.py b/Interface/GroupEditor.py index 221ca9f..7bfd152 100644 --- a/Interface/GroupEditor.py +++ b/Interface/GroupEditor.py @@ -2,13 +2,14 @@ from tkinter.constants import END, INSERT, WORD from group_controller import GroupController from models import Contact, Group +from .ContactList import ContactList class GroupEditor(Toplevel): def __init__(self, parent: Toplevel | Tk, edited: Group | None = None): super().__init__(parent.root) self.parent = parent - self.currentGroup = edited + self.currentGroup = edited if edited != None else Group(_name="Nowa grupa" + str(len(Group.all_instances))) def prepareInterface(self): name_label = Label(self, text="Nazwa grupy:", bg="lightblue") @@ -55,13 +56,5 @@ def __save_group_clicked(self) -> None: self.currentGroup = Group(_name = self.name_entry.get()) else: self.currentGroup.name = self.name_entry.get() - txt = self.email_text.get(1.0, END).strip() - email_addresses = [address for address in txt.replace("\n", "").split(",") if address.strip()] - # TODO: Przy zmianie kontrolek w grupie będzie trzeba zmienić wywoływanie konstruktora - te kontakty powinny być zapisane wcześniej, bez możliwości dodawania ich od tak z palca - for mail in email_addresses: - try: - self.currentGroup._add_contact(Contact(_email=mail)) - except AttributeError as e: - raise e - self.parent.add_group(self.currentGroup) + self.parent.update() self.destroy() diff --git a/Interface/Settings.py b/Interface/Settings.py index 127d827..6f0fc84 100644 --- a/Interface/Settings.py +++ b/Interface/Settings.py @@ -8,9 +8,11 @@ from tkinter.ttk import Combobox from tkinter.constants import NORMAL, DISABLED, BOTH, RIDGE, END, LEFT, RIGHT, TOP, X, Y, INSERT, SEL, WORD from group_controller import GroupController -from models import Contact, IModel, Template, Group +from models import Contact, IModel, Template, Group, User from tkhtmlview import HTMLLabel, HTMLText from DataSources.dataSources import GapFillSource +from MessagingService.accountInfo import discover_email_settings +import MessagingService.smtp_data class Settings: @@ -21,8 +23,9 @@ def __init__(self, root): self.root.geometry("400x400") def prepareInterface(self): - # TODO: tutaj powinniśmy ładować wartości z User - example_emails = ["example1@example.com", "example2@example.com", "example3@example.com"] + created_users = [] + for u in User.all_instances: + created_users.append(u._email) label = Label( self.root, @@ -30,7 +33,9 @@ def prepareInterface(self): bg="lightblue", font=("Helvetica", 24)) - self.email_combobox = Combobox(self.root, values=example_emails) + self.email_combobox = Combobox(self.root, values=created_users) + + self.password_entry = Entry(self.root, show="*") connect_button = Button( self.root, @@ -55,14 +60,25 @@ def prepareInterface(self): label.pack(pady=20) self.email_combobox.pack(pady=5) + self.password_entry.pack(pady=5) connect_button.pack(pady=5) change_email_button.pack(pady=5) close_button.pack(pady=5) def connect(self): - email = self.email_combobox.get() + MessagingService.smtp_data.email = self.email_combobox.get() + MessagingService.smtp_data.password = self.password_entry.get() + # TODO: połączenie z pocztą - messagebox.showinfo("Połączenie", f"Połączono z {email}") + email_settings = discover_email_settings(MessagingService.smtp_data.email, MessagingService.smtp_data.password) + print(email_settings) + MessagingService.smtp_data.smtp_host = email_settings['smtp']['hostname'] + print(MessagingService.smtp_data.smtp_host) + MessagingService.smtp_data.smtp_port = email_settings['smtp']['port'] + print(MessagingService.smtp_data.smtp_port) + MessagingService.smtp_data.smtp_security = email_settings['smtp']['socket_type'] + print(MessagingService.smtp_data.smtp_security) + messagebox.showinfo("Połączenie", f"Połączono z {MessagingService.smtp_data.email}") def change_email(self): new_email = simpledialog.askstring( diff --git a/Interface/TemplateEditor.py b/Interface/TemplateEditor.py index 83fcff3..a040cbc 100644 --- a/Interface/TemplateEditor.py +++ b/Interface/TemplateEditor.py @@ -1,21 +1,29 @@ +import re from enum import Enum from tkinter import Event, Tk, Button, Label, Entry, Toplevel, Misc -from tkinter.ttk import Combobox +from tkinter.ttk import Combobox, Frame from tkinter.constants import END, INSERT, SEL, WORD from models import Template from tkhtmlview import HTMLLabel, HTMLText from DataSources.dataSources import GapFillSource +from .ExternalSourceImportWindow import ExternalSourceImportWindow class TemplateEditor(Toplevel): + placeholder_text = " " + def __init__(self, parent: Toplevel | Tk, master: Misc, obj: Template | None = None): super().__init__(master) self.parent = parent - self.current_combo: Combobox = None - self.currentTemplate = obj + self.combo_frame: Frame = None + self.currentTemplate = obj if obj is not None else Template() #fix + if self.currentTemplate and self.currentTemplate.dataimport: + GapFillSource(self.currentTemplate.dataimport) + self.update_combo_values() self.prepareInterface() + def prepareInterface(self): self.title("Stwórz szablon") @@ -29,38 +37,68 @@ def prepareInterface(self): self.template_text = HTMLText(self, bg="lightblue", fg="black", wrap=WORD) self.template_text.bind("", self.__on_html_key_clicked) self.template_text.bind("<>", self.__on_text_changed) + self.template_text.bind("", self.__show_placeholder_menu) # RMB self.template_preview = HTMLLabel(self, bg="lightblue", fg="black", wrap=WORD) btn_save = Button(self, text="Zapisz", bg="lightblue", fg="black", command=lambda: self.__save_template_clicked(name_entry.get(), self.template_text.get(1.0, END))) btn_insert_placeholder = Button(self, text="Wstaw lukę", bg="lightblue", fg="black", command=self.__template_window_insert_placeholder) + btn_add_external_source = Button(self, text="Dodaj zewnętrzne źródło", bg="lightblue", fg="black", + command=self.__add_external_source_clicked) name_label.grid(row=0, column=0, padx=5, pady=5) name_entry.grid(row=0, column=1, padx=5, pady=5, sticky="ew") self.template_text.grid(row=1, column=0, columnspan=2, padx=5, pady=5, sticky="nsew") self.template_preview.grid(row=1, column=3, columnspan=5, padx=5, pady=5, sticky="nsew") - btn_save.grid(row=2, column=2, padx=(50, 5), pady=5, sticky="e") - btn_insert_placeholder.grid(row=2, column=3, padx=(5, 50), pady=5, sticky="w") + + btn_add_external_source.grid(row=2, column=1, padx=(5, 50), pady=5, sticky="w") + btn_insert_placeholder.grid(row=2, column=2, padx=(5, 50), pady=5, sticky="w") + btn_save.grid(row=2, column=3, padx=(50, 5), pady=5, sticky="e") if self.currentTemplate: name_entry.insert(INSERT, self.currentTemplate.name if self.currentTemplate.name is not None else "") self.template_text.insert(INSERT, self.currentTemplate.content if self.currentTemplate.content is not None else "") self.template_text.event_generate("<>") + + def update_combo_values(self, placeholders: list[GapFillSource] = GapFillSource.all_instances): + self.combo_values = [key for placeholder in placeholders for key in placeholder.possible_values] + + + def __add_external_source_clicked(self): + ExternalSourceImportWindow(self, self.parent.root, self.currentTemplate) + + def update(self): + GapFillSource(self.currentTemplate.dataimport) + self.update_combo_values() + + def __on_html_key_clicked(self, event: Event): - if event.keycode not in NonAlteringKeyCodes: + if event.keycode not in [c.value for c in NonAlteringKeyCodes]: #python 3.11 fix self.template_text.event_generate("<>") - def __on_text_changed(self, event): - html_text = self.template_text.get("1.0", END) - mb_tag = "MailBuddyGap>" - replacement_text = '' - - html_text = html_text.replace("<" + mb_tag, replacement_text) - html_text = html_text.replace("") - self.template_preview.set_html(html_text) + def __on_text_changed(self, event): + def update_preview(): + html_text = self.template_text.get("1.0", END) + + color_span_text = '' + pattern = r"\s*([^<>\s][^<>]*)\s*" + matches = re.findall(pattern, html_text) + + preview_text = "" #TODO + for m in matches: + preview_text = GapFillSource.getPreviewText(m) + html_text = html_text.replace(f"{m}", color_span_text + preview_text + "") + + + html_text = html_text.replace("", color_span_text) + html_text = html_text.replace("", "") + self.template_preview.set_html(html_text) + + update_preview() + def __save_template_clicked(self, template_name: str, template_content: str) -> None: if template_name != "" and template_content != "": @@ -68,48 +106,52 @@ def __save_template_clicked(self, template_name: str, template_content: str) -> self.parent.add_template(self.currentTemplate) self.destroy() + def hide_combobox(self): - if self.current_combo: - self.current_combo.destroy() - - def __template_window_insert_placeholder(self, placeholders: list[GapFillSource] = GapFillSource.all_instances) -> None: - placeholder_text = " " - combo_values = [key for placeholder in placeholders for key in placeholder.possible_values] - - def on_placeholder_selection(event): - selected_placeholder = self.current_combo.get() - if selected_placeholder: - selected_text = self.template_text.tag_ranges(SEL) - if selected_text: - self.template_text.delete(selected_text[0], selected_text[1]) - self.template_text.insert(INSERT, placeholder_text.replace(" ", selected_placeholder)) - self.template_text.event_generate("<>") - - def show_placeholder_menu(event): - self.hide_combobox() - self.current_combo = Combobox(self.template_text, values=combo_values) - self.current_combo.bind("<>", on_placeholder_selection) - self.current_combo.place(x=event.x_root, y=event.y_root) - self.current_combo.focus_set() - - close_button = Button(self.current_combo, text="X", command=self.hide_combobox, bg="white") - close_button.place(relx=0.90, rely=0, anchor="ne") - - self.template_text.insert(INSERT, placeholder_text) - self.template_text.tag_configure("placeholder", background="lightgreen") + if self.combo_frame: + self.combo_frame.destroy() + - self.template_text.bind("", show_placeholder_menu) + def __template_window_insert_placeholder(self) -> None: + self.template_text.insert(INSERT, TemplateEditor.placeholder_text) + self.template_text.tag_configure("placeholder", background="lightgreen") start_index = "1.0" while True: - start_index = self.template_text.search(placeholder_text, start_index, stopindex=END) + start_index = self.template_text.search(TemplateEditor.placeholder_text, start_index, stopindex=END) if not start_index: break - end_index = self.template_text.index(f"{start_index}+{len(placeholder_text)}c") + end_index = self.template_text.index(f"{start_index}+{len(TemplateEditor.placeholder_text)}c") self.template_text.tag_add("placeholder", start_index, end_index) start_index = end_index + def __show_placeholder_menu(self, event): + self.hide_combobox() + self.combo_frame = Frame(self.template_text) + self.combo_frame.place(x=event.x+10, y=event.y+10) + current_combo = Combobox(self.combo_frame, values=self.combo_values) + current_combo.grid(row=0, column=0, sticky="nw") + current_combo.bind("<>", self.__on_placeholder_selection) + + current_combo.focus_set() + + close_button = Button(self.combo_frame, text="X", command=self.hide_combobox, bg="white") + close_button.grid(row=0, column=1, sticky="ne") + + + + def __on_placeholder_selection(self, event): + cb: Combobox = self.combo_frame.children['!combobox'] # type: ignore + selected_placeholder = cb.get() + if selected_placeholder: + selected_text = self.template_text.tag_ranges(SEL) + if selected_text: + self.template_text.delete(selected_text[0], selected_text[1]) + self.template_text.insert(INSERT, TemplateEditor.placeholder_text.replace(" ", selected_placeholder)) + self.template_text.event_generate("<>") + + class NonAlteringKeyCodes(Enum): # List is non-exhaustive, should be tested # via https://asawicki.info/nosense/doc/devices/keyboard/key_codes.html diff --git a/MailBuddy.pyproj b/MailBuddy.pyproj index b399d8d..1d812d8 100644 --- a/MailBuddy.pyproj +++ b/MailBuddy.pyproj @@ -13,6 +13,7 @@ Standard Python launcher Global|PythonCore|3.12 Pytest + True @@ -31,6 +32,7 @@ + diff --git a/MessagingService/accountInfo.py b/MessagingService/accountInfo.py new file mode 100644 index 0000000..3a8d0f5 --- /dev/null +++ b/MessagingService/accountInfo.py @@ -0,0 +1,175 @@ +from dns.resolver import resolve +import requests +import smtplib +import imaplib +import xml.etree.ElementTree as ET + + +default_settings = { + 'gmail.com': { + 'imap': {'hostname': 'imap.gmail.com', 'port': 993, 'socket_type': 'SSL'}, + 'smtp': {'hostname': 'smtp.gmail.com', 'port': 465, 'socket_type': 'SSL'} + }, + 'yahoo.com': { + 'imap': {'hostname': 'imap.mail.yahoo.com', 'port': 993, 'socket_type': 'SSL'}, + 'smtp': {'hostname': 'smtp.mail.yahoo.com', 'port': 465, 'socket_type': 'SSL'} + }, + 'outlook.com': { + 'imap': {'hostname': 'outlook.office365.com', 'port': 993, 'socket_type': 'SSL'}, + 'smtp': {'hostname': 'smtp.office365.com', 'port': 587, 'socket_type': 'STARTTLS'} + }, + 'poczta.onet.pl': { + 'imap': {'hostname': 'imap.poczta.onet.pl', 'port': 993, 'socket_type': 'SSL'}, + 'smtp': {'hostname': 'smtp.poczta.onet.pl', 'port': 465, 'socket_type': 'SSL'} + }, + 'onet.pl': { + 'imap': {'hostname': 'imap.poczta.onet.pl', 'port': 993, 'socket_type': 'SSL'}, + 'smtp': {'hostname': 'smtp.poczta.onet.pl', 'port': 465, 'socket_type': 'SSL'} + }, + 'wp.pl': { + 'imap': {'hostname': 'imap.wp.pl', 'port': 993, 'socket_type': 'SSL'}, + 'smtp': {'hostname': 'smtp.wp.pl', 'port': 465, 'socket_type': 'SSL'} + }, + 'interia.pl': { + 'imap': {'hostname': 'imap.poczta.interia.pl', 'port': 993, 'socket_type': 'SSL'}, + 'smtp': {'hostname': 'smtp.poczta.interia.pl', 'port': 465, 'socket_type': 'SSL'} + }, + 'pcz.pl': { + 'imap': {'hostname': 'imap.pcz.pl', 'port': 993, 'socket_type': 'SSL'}, + 'smtp': {'hostname': 'smtp.pcz.pl', 'port': 465, 'socket_type': 'SSL'} + }, + 'wimii.pcz.pl': { + 'imap': {'hostname': 'imap.wimii.pcz.pl', 'port': 993, 'socket_type': 'SSL'}, + 'smtp': {'hostname': 'smtp.wimii.pcz.pl', 'port': 465, 'socket_type': 'SSL'} + }, + + 'ethereal.email': { + 'imap': {'hostname': 'imap.ethereal.email', 'port': 993, 'socket_type': 'SSL'}, + 'smtp': {'hostname': 'smtp.ethereal.email', 'port': 587, 'socket_type': 'STARTTLS'} + } +} + + + + +def get_domain(email): + return email.split('@')[1] + + +def get_mx_records(domain): + try: + answers = resolve(domain, 'MX') + mx_records = [answer.exchange.to_text() for answer in answers] + return mx_records + except Exception as e: + print(f"DNS lookup failed: {e}") + return [] + + +def get_autodiscover_settings(domain): + try: + url = f'https://autoconfig.{domain}/mail/config-v1.1.xml' + response = requests.get(url) + if response.status_code == 200: + return response.text # Process XML to extract settings + else: + url = f'https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml' + response = requests.get(url) + if response.status_code == 200: + return response.text # Process XML to extract settings + except Exception as e: + print(f"Autodiscover failed: {e}") + return None + + + +def parse_email_settings(xml_data): + tree = ET.ElementTree(ET.fromstring(xml_data)) + root = tree.getroot() + email_provider = root.find('emailProvider') + + settings = { + 'imap': {}, + 'pop3': {}, + 'smtp': [] + } + + for server in email_provider.findall('incomingServer'): + server_type = server.get('type') + settings[server_type] = { + 'hostname': server.find('hostname').text, + 'port': int(server.find('port').text), + 'socket_type': server.find('socketType').text + } + + for server in email_provider.findall('outgoingServer'): + smtp_settings = { + 'hostname': server.find('hostname').text, + 'port': int(server.find('port').text), + 'socket_type': server.find('socketType').text + } + settings['smtp'].append(smtp_settings) + + return settings + + + +def test_imap_connection(imap_settings, email, password): + try: + if imap_settings == 'SSL': + connection = imaplib.IMAP4_SSL(imap_settings['hostname'], imap_settings['port']) + else: + connection = imaplib.IMAP4(imap_settings['hostname'], imap_settings['port']) + + connection.login(email, password) + connection.logout() + return True + except Exception as e: + print(f"IMAP connection failed: {e}") + return False + + +def test_smtp_connection(smtp_settings, email, password): + try: + if smtp_settings == 'SSL': + connection = smtplib.SMTP_SSL(smtp_settings['hostname'], smtp_settings['port']) + else: + connection = smtplib.SMTP(smtp_settings['hostname'], smtp_settings['port']) + if smtp_settings == 'STARTTLS': + connection.starttls() + + connection.login(email, password) + connection.quit() + return True + except Exception as e: + print(f"SMTP connection to {smtp_settings['hostname']} on port {smtp_settings['port']} failed: {e}") + return False + + +def discover_email_settings(email, password): + domain = get_domain(email) + + mx_records = get_mx_records(domain) + print(mx_records) + if mx_records: + pass + + settings_xml = get_autodiscover_settings(domain) + if settings_xml: + settings_xml = parse_email_settings(settings_xml) + pass + + if domain in default_settings: + settings_xml = default_settings[domain] + print(settings_xml) + return settings_xml + else: + print("No settings found for this domain.") + + #if test_imap_connection(settings_xml['imap'], email, password) and test_smtp_connection(settings_xml['smtp'], email, password): + #print("Check ok") + #print(settings_xml) + #return settings_xml + #else: + #print("Failed to connect with discovered settings.") + #return None \ No newline at end of file diff --git a/MessagingService/ethereal_demo.py b/MessagingService/ethereal_demo.py new file mode 100644 index 0000000..e590c7e --- /dev/null +++ b/MessagingService/ethereal_demo.py @@ -0,0 +1,35 @@ +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from email.mime.application import MIMEApplication +#import MessagingService.smtp_data + + +# Ethereal credentials +ETH_USER = "" +ETH_PASSWORD = "" + +def send_email(): + # Email content + sender_email = ETH_USER + receiver_email = "" + subject = "Example Email from Python" + body = "Hello." + + # Constructing the email + message = MIMEMultipart() + message["From"] = sender_email + message["To"] = receiver_email + message["Subject"] = subject + message.attach(MIMEText(body, "plain")) + + # Connecting to Ethereal SMTP server + with smtplib.SMTP_SSL("smtp.poczta.onet.pl", 465) as server: + #server.starttls() + server.login(ETH_USER, ETH_PASSWORD) + server.sendmail(sender_email, receiver_email, message.as_string()) + + print("Email sent successfully!") + +if __name__ == "__main__": + send_email() diff --git a/MessagingService/senders.py b/MessagingService/senders.py index c8d375c..98aac3d 100644 --- a/MessagingService/senders.py +++ b/MessagingService/senders.py @@ -1,5 +1,8 @@ from abc import ABCMeta, abstractmethod from smtplib import * +import MessagingService.smtp_data + +from models import Group, Template, User, Message class ISender(metaclass=ABCMeta): @abstractmethod @@ -12,16 +15,33 @@ def __subclasshook__(cls, __subclass: type) -> bool: if any("Send" in B.__dict__ for B in __subclass.__mro__): return True return NotImplemented + + @abstractmethod + def SendEmails(self, g: Group, t: Template, u: User) -> None: + # TODO: Tworzenie obiektów Message i wysyłka + raise AssertionError class SMTPSender(ISender): - def Send() -> None: - smtp_host = "" #hostname - smtp_port = 123 - server = SMTP_SSL(smtp_host, smtp_port) + + def SendEmails(self, g: Group, t: Template, u: User) -> None: + # TODO: Tworzenie obiektów Message i wysyłka + raise NotImplementedError + + def Send(self, host, port, email, password, message, recipient) -> None: + smtp_host = host + smtp_port = port + print("PASSWORD: " + password) + print("RECIPIENT: " + recipient) + print("HOST: " + str(smtp_host)) + print("PORT: " + str(smtp_port)) + server = SMTP(smtp_host, smtp_port) server.connect(smtp_host, smtp_port) + server.starttls() server.ehlo() - server.login() + server.login(email, password) + server.sendmail(email, recipient, message) + server.quit() # class MockSMTPSender(ISender): # def __init__(self) -> None: diff --git a/MessagingService/smtp_data.py b/MessagingService/smtp_data.py new file mode 100644 index 0000000..eb514f6 --- /dev/null +++ b/MessagingService/smtp_data.py @@ -0,0 +1,9 @@ +global smtp_host, smtp_port, smtp_security, email, password + +smtp_host = "smtp.ethereal.email" +smtp_port = 587 +smtp_security = "tls" +email = "" +password = "" +group = None +message = None \ No newline at end of file diff --git a/SMTPAutomationWithLogin.py b/SMTPAutomationWithLogin.py index 2ce330e..7848ecd 100644 --- a/SMTPAutomationWithLogin.py +++ b/SMTPAutomationWithLogin.py @@ -68,7 +68,7 @@ def modifyCell(CELL): def prepareMail(FROM, RECIPIENT, NAME, JOB, ATTACHMENT_PATH): MESSAGE = MIMEMultipart('mixed') - MESSAGE['Subject'] = "JoinThe.Space - networking offer" + MESSAGE['Subject'] = "Subject" MESSAGE['From'] = FROM MESSAGE['To'] = RECIPIENT[0] HTML = """\ diff --git a/Tests/ConfigExporter_test.py b/Tests/ConfigExporter_test.py index b903534..fb8f069 100644 --- a/Tests/ConfigExporter_test.py +++ b/Tests/ConfigExporter_test.py @@ -1,18 +1,17 @@ +from collections.abc import Callable import pytest import json from pathlib import Path -from DataSources.dataSources import DatabaseHandler from UserInfo.cfgExporter import ConfigExporter, ExportLocation from models import Contact -from dataGenerators import genContact # from models import __all__ as modelClassNames @pytest.fixture -def getSampleJsonPath(tmp_path) -> Path: +def getSampleJsonPath(tmp_path: Path) -> Path: return tmp_path / "test.json" -def test_json_exporter_factory(getSampleJsonPath): +def test_json_exporter_factory(getSampleJsonPath: Path): p = getSampleJsonPath exporter = ConfigExporter.ToJSON(p) assert isinstance(exporter, ConfigExporter) @@ -20,7 +19,7 @@ def test_json_exporter_factory(getSampleJsonPath): assert exporter.location == p -def test_export_to_json(getSampleJsonPath): +def test_export_to_json(getSampleJsonPath: Path): filename = getSampleJsonPath exporter = ConfigExporter.ToJSON(str(filename)) exporter.Export() @@ -31,7 +30,7 @@ def test_export_to_json(getSampleJsonPath): assert isinstance(r.read(), str) -def test_export_contact_to_json(getSampleJsonPath, genContact): +def test_export_contact_to_json(getSampleJsonPath: Path, genContact: Callable[[], Contact]): c1 = genContact() c2 = genContact() assert c1 != c2 diff --git a/group_controller.py b/group_controller.py index 9749a12..12347ff 100644 --- a/group_controller.py +++ b/group_controller.py @@ -27,5 +27,7 @@ def get_contacts(cls, g: Group) -> list[Contact]: # TODO: Wydajność? Wywołania tego na potencjalnie ogromnej tabeli to spory koszt, na pewno można to jakoś kiedyś ładnie zoptymalizować result = [] for entry in mapping: - result.append(*cls.dbh.GetData(Contact, email=entry.contact_id)) + data: list = cls.dbh.GetData(Contact, email=entry.contact_id) + if len(data) > 0: + result.append(*data) return result diff --git a/grupy.txt b/grupy.txt new file mode 100644 index 0000000..78e7b20 --- /dev/null +++ b/grupy.txt @@ -0,0 +1 @@ +1:kh@onet.pl, mk@gmail.com diff --git a/main.py b/main.py index 581ad72..0153803 100644 --- a/main.py +++ b/main.py @@ -3,72 +3,71 @@ from UserInfo.LoginService import * # from sys import platform from group_controller import GroupController -from models import IModel, Template, Attachment, Contact, Message, Group, User +from models import DataImport, IModel, Template, Attachment, Contact, Message, Group, User from Triggers.triggers import ITrigger from Interface.AppUI import AppUI from DataSources.dataSources import DatabaseHandler, GapFillSource, IDataSource from additionalTableSetup import GroupContacts, MessageAttachment, SendAttempt +from MessagingService.smtp_data import smtp_security, smtp_host, smtp_port - -mocking_enabled = True +mocking_enabled = False mock_name = "Russ" mock_lastname = "Connelly" mock_login = "russ.connelly30@ethereal.email" mock_pwd = "QQcGx1RmfVkaEMjzqZ" -smtp_host = "smtp.ethereal.email" -smtp_port = 587 -smtp_security = "tls" - dbname = "localSQLite.sqlite3" dbURL = f"sqlite:///{dbname}" -tables = [Template, Attachment, Contact, ITrigger, Message, Group, MessageAttachment, SendAttempt, GroupContacts] +tables = [Template, DataImport, Attachment, Contact, User, ITrigger, Message, Group, MessageAttachment, SendAttempt, GroupContacts] db: IDataSource = None - -def populateInterface(app: AppUI) -> None: - modelType_func_mapper = { - Template: app.add_template, - Group: app.add_group - } - - for (modelType, ui_func) in modelType_func_mapper.items(): - ui_func(modelType.all_instances) def pushQueuedInstances(): - if len(IModel.saveQueued) > 0: - for o in IModel.saveQueued: - # match type(o): - # case type(Group): - # GroupContacts.FromGroup(o) - # case _: + if len(IModel.addQueued) > 0: + for o in IModel.addQueued: db.Save(o) - IModel.saveQueued.remove(o) + IModel.addQueued.remove(o) + if len(IModel.updateQueued) > 0: + for o in IModel.updateQueued: + db.Update(o) + IModel.updateQueued.remove(o) + if len(IModel.retrieveAdditionalQueued) > 0: + for o in IModel.retrieveAdditionalQueued: + if isinstance(o, Template): + if o.dataimport_id: + di = db.GetData(DataImport, id=o.dataimport_id) + o.dataimport = di[0] + IModel.retrieveAdditionalQueued.remove(o) if __name__ == "__main__": db = DatabaseHandler(dbURL, tables) GroupController.setDbHandler(db) - ui = AppUI() - ui.prepareInterface() if db.checkIntegrity(): print("Database intact, proceeding") db.LoadSavedState() - populateInterface(ui) + + ui = AppUI() + ui.prepareInterface() + + _contact_fields = GapFillSource() if (mocking_enabled): try: - mock_user = User(email=mock_login, - first_name=mock_name, - last_name=mock_lastname, - password=mock_pwd) - sender = SMTPSender(mock_user) - except AttributeError as ae: - print(ae) + mock_user = User(_email=mock_login, + _first_name=mock_name, + _last_name=mock_lastname, + _password=mock_pwd) + sender = SMTPSender() + except Exception as e: + print(e) + + sender = SMTPSender() + ui.setSender(sender) ui.add_periodic_task(5000, pushQueuedInstances) ui.run() diff --git a/models.py b/models.py index 77e4384..46963f1 100644 --- a/models.py +++ b/models.py @@ -1,12 +1,9 @@ from __future__ import annotations from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText -from email.mime.application import MIMEApplication -from sqlalchemy import Column, Integer, String, LargeBinary, TIMESTAMP, func +from openpyxl import load_workbook +from sqlalchemy import BOOLEAN, Column, ForeignKey, Integer, String, LargeBinary, TIMESTAMP, func from sqlalchemy.orm import declarative_base, relationship from sqlalchemy.ext.hybrid import hybrid_property -from abc import ABCMeta, abstractmethod -from pathlib import Path import re @@ -16,26 +13,114 @@ class IModel(declarative_base()): __abstract__ = True run_loading = True - saveQueued: list[IModel] = [] + addQueued: list[IModel] = [] + updateQueued: list[IModel] = [] + retrieveAdditionalQueued: list[IModel] = [] @staticmethod def queueSave(child): if not IModel.run_loading: - IModel.saveQueued.append(child) + IModel.addQueued.append(child) + + @staticmethod + def queueToUpdate(child): + if not IModel.run_loading: + IModel.updateQueued.append(child) + + @staticmethod + def retrieveAdditionalData(child): + if isinstance(child, Template): + IModel.retrieveAdditionalQueued.append(child) + + +class DataImport(IModel): + all_instances: list[DataImport] = [] + __tablename__ = "DataImport" + + _id = Column("id", Integer, primary_key=True, autoincrement=True) + _name = Column("name", String(100)) + _localPath = Column("localPath", String(255), nullable=True) + _content = Column("content", LargeBinary, nullable=True) + + def __init__(self, **kwargs) -> None: + self.id = kwargs.pop('_id', None) + self.name = kwargs.pop('_name', None) + self.localPath = kwargs.pop('_localPath', None) + self.content = kwargs.pop('_content', None) + DataImport.all_instances.append(self) + IModel.queueSave(child=self) + + def getColumnPreview(self) -> dict | None: + workbook = load_workbook(self.localPath, read_only=True) + result = dict() + for sheet in workbook: + first_row = next(sheet.iter_rows(values_only=True)) + if "Email" not in first_row: + continue + + columns = first_row + dataPreviewRow = next(sheet.iter_rows(min_row=2, values_only=True)) + for idx, c in enumerate(columns): + result[c] = dataPreviewRow[idx] + return result if len(result) > 0 else None + + +# region Properties + @hybrid_property + def id(self): + return self._id + + @hybrid_property + def name(self): + return self._name + + @hybrid_property + def content(self): + return self._content + + @hybrid_property + def localPath(self): + return self._localPath + + @id.setter + def id(self, newValue: int): + self._id = newValue + + @name.setter + def name(self, value: str | None): + self._name = value + IModel.queueToUpdate(self) + + @content.setter + def content(self, value: object | None): + self._content = value + IModel.queueToUpdate(self) + + @localPath.setter + def localPath(self, value: str | None): + self._localPath = value + IModel.queueToUpdate(self) +#endregion class Template(IModel): all_instances: list[Template] = [] __tablename__ = "Templates" - _id = Column("id", Integer, primary_key=True) + _id = Column("id", Integer, primary_key=True, autoincrement=True) _name = Column("name", String(100), nullable=True) _content = Column("content", String, nullable=True) - + _dataimport_id = Column("dataimport_id", Integer, #ForeignKey("DataImport.id", ondelete='SET NULL'), + nullable=True) + + # dataImportRel = relationship(DataImport, foreign_keys=[DataImport._id]) + def __init__(self, **kwargs) -> None: - self.id = kwargs.pop('_id', None) - self.name = kwargs.pop('_name', None) - self.content = kwargs.pop('_content', None) + self.id: int = kwargs.pop('_id', None) + self.name: str = kwargs.pop('_name', None) + self.content: object = kwargs.pop('_content', None) + self.dataimport: DataImport = None + self.dataimport_id: int = kwargs.pop("_dataimport_id", None) Template.all_instances.append(self) IModel.queueSave(child=self) @@ -58,23 +143,34 @@ def name(self): @hybrid_property def content(self): return self._content + + @hybrid_property + def dataimport_id(self) -> DataImport: + return self._dataimport_id @id.setter def id(self, newValue: int): - if newValue: - self._id = newValue - else: - self._id = max((i.id for i in Template.all_instances), default=0) + 1 + # TODO: if initial setup / loading from db + self._id = newValue @name.setter def name(self, value: str | None): self._name = value + IModel.queueToUpdate(self) @content.setter def content(self, value: str | None): self._content = value + IModel.queueToUpdate(self) + + @dataimport_id.setter + def dataimport_id(self, value: int | None): + self._dataimport_id = value + IModel.queueToUpdate(self) + IModel.retrieveAdditionalData(self) #endregion + class Attachment(IModel): all_instances = [] __tablename__ = "Attachments" @@ -175,15 +271,37 @@ def last_name(self, value: str | None): #endregion -class User(): +class User(IModel): all_instances = [] + __tablename__ = "Users" + + _id = Column("_id", Integer, primary_key=True, autoincrement=True) + _email = Column("email", String(100), ForeignKey('Contacts.email'), unique=True) + _selected = Column("selected", BOOLEAN) + + contactRel = relationship(Contact, foreign_keys=[_email]) + - def __init__(self, first_name: str, last_name: str, - email: str, password: str) -> None: - self.contact = Contact(first_name=first_name, last_name=last_name, email=email) - self.password = password + def __init__(self, **kwargs) -> None: + self._email = kwargs.pop("_email") + self.password = kwargs.pop("_password", None) + self.contact = self.getExistingContact(kwargs.pop("_first_name", None), kwargs.pop("_last_name", None)) + self._selected = kwargs.pop("_selected", None) User.all_instances.append(self) IModel.queueSave(child=self) + + @staticmethod + def GetCurrentUser() -> User | None: + for u in User.all_instances: + if u._selected: + return u + return None + + def getExistingContact(self, first_name, last_name) -> Contact: + for c in Contact.all_instances: + if c.email == self._email: + return c + return Contact(_first_name=first_name, _last_name=last_name, _email=self._email) class Message(IModel, MIMEMultipart): @@ -222,6 +340,15 @@ def __init__(self, **kwargs): self.contacts: list[Contact] = kwargs.pop("_contacts", []) Group.all_instances.append(self) IModel.queueSave(self) + + @hybrid_property + def name(self): + return self._name + + @name.setter + def name(self, value: str | None): + self._name = value + IModel.queueToUpdate(self) def __str__(self): return f"{self.id}: {self.name}" @@ -258,4 +385,5 @@ def id(self, newValue: int): @name.setter def name(self, value: str | None): self._name = value + IModel.queueToUpdate(self) #endregion diff --git a/requirements.txt b/requirements.txt index 9b15506..0b3e161 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ openpyxl sqlalchemy pytest faker -tkhtmlview \ No newline at end of file +tkhtmlview +dnspython \ No newline at end of file