diff --git a/assets/browse_reports_dark.png b/assets/browse_reports_dark.png new file mode 100644 index 0000000..f04b153 Binary files /dev/null and b/assets/browse_reports_dark.png differ diff --git a/assets/browse_reports_light.png b/assets/browse_reports_light.png new file mode 100644 index 0000000..253badd Binary files /dev/null and b/assets/browse_reports_light.png differ diff --git a/frames/AnydeskFrame.py b/frames/AnydeskFrame.py index c30f2e1..47703f1 100644 --- a/frames/AnydeskFrame.py +++ b/frames/AnydeskFrame.py @@ -152,7 +152,7 @@ def print_logs_to_textbox(self, log_filename_with_path: str): if log_entries is not None: self.textbox.insert("insert", f'Fetching logs from {log_filename_with_path}: \n\n') if len(log_entries) < 1: - self.textbox.insert("insert", "No IP logs found inside file!") + self.textbox.insert("insert", "No IP logs found inside file! \n\n") else: for entry in log_entries: self.textbox.insert("insert", entry + " - " + log_entries[entry] + "\n\n") @@ -207,16 +207,14 @@ def search_filesystem_callback(self, search_location: str): # Display a message if no files were found in search location # Generate a report with a message if no files were found in search location if number_of_found_files == 0: - self.after(500, - func=self.textbox.insert("insert", f'\n---- No files were found in {search_location}! ----\n\n')) + self.open_report_button.grid() + self.textbox.insert("insert", f'\n---- No files were found in {search_location}! ----\n\n') with open(os.path.join(report_folder_path, "report.txt"), "a") as report_file: report_file.write(f'---- No files were found in {search_location} ----\n\n') - # Display a message if search is finished else: - self.after(500, self.textbox.insert("insert", "\n---- Searching for files finished! ----\n\n")) - - self.open_report_button.grid() + self.open_report_button.grid() + self.textbox.insert("insert", "\n---- Searching for files finished! ----\n\n") def generate_and_present_search_results(self): """A function that updates the textbox with new logs found by the search function @@ -237,7 +235,7 @@ def generate_and_present_search_results(self): except IndexError: pass if not search_finished: - self.after(500, self.generate_and_present_search_results) + self.after(200, self.generate_and_present_search_results) @staticmethod def turn_off_switches(switches_list: list[tkinter.BooleanVar]): diff --git a/frames/BrowseReportsFrame.py b/frames/BrowseReportsFrame.py new file mode 100644 index 0000000..7ca8aa4 --- /dev/null +++ b/frames/BrowseReportsFrame.py @@ -0,0 +1,123 @@ +import csv +import os +import customtkinter + +from utils.file_operations import split_computer_datetime_dirname, get_reports_folder_list +from utils.widget_utils import add_widgets + + +class BrowseReportsFrame(customtkinter.CTkScrollableFrame): + """Browse Reports frame class holding the list of generated reports.""" + + def __init__(self, master, **kwargs): + super().__init__(master, **kwargs) + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(0, weight=1) + self.configure(fg_color="transparent") + # add widgets onto the frame... + refresh(self) + + +class Report_Frame(customtkinter.CTkFrame): + """A class representing a single report in the list of reports. + It shows the date, time and computer name of the report. + It also shows the number of files and IP addresses found in the report. + Button located in frame opens the report folder. + """ + + def __init__(self, master, **kwargs): + super().__init__(master) + self.report_name = kwargs.get("report_name") + self.report_path = kwargs.get("report_path") + report_name_details = split_computer_datetime_dirname(self.report_name) + report_details = get_report_file_and_ip_numbers(report_path=self.report_path) + self.grid_columnconfigure(0, weight=1) + + if report_name_details: + self.label = customtkinter.CTkLabel(self, text=report_name_details["date"] + " - " + + report_name_details["time"] + " - " + report_name_details[ + "computer_name"], + text_color=("#333", "#ccc") + ) + else: + self.label = customtkinter.CTkLabel(self, text=self.report_name, + text_color=("#333", "#ccc") + ) + self.label.grid(row=0, column=0, sticky="ew", padx=20, pady=[10, 0]) + self.label2 = customtkinter.CTkLabel(self, text="Files: " + str(report_details["number_of_files"]) + " - " + + "IP Addresses: " + str( + report_details["number_of_ip_addresses"]), + text_color=("#333", "#ccc") + ) + self.label2.grid(row=0, column=1, sticky="ew", padx=20, pady=[10, 0]) + + self.report_button = Report_Button(self, report_name=self.report_name, + report_path=os.path.join(os.getcwd(), "REPORTS", self.report_path)) + + +class Report_Button(customtkinter.CTkButton): + """A class representing a button that opens the report folder.""" + + def __init__(self, master, **kwargs): + super().__init__(master) + + self.report_name = kwargs.get("report_name") + self.report_path = kwargs.get("report_path") + + self.configure(text='Open Report folder', command=self.open_report, fg_color=("gray75", "gray25"), text_color=( + "#333", "#ccc"), hover_color=("#6ca9d4", "#1c3b50")) + self.grid(row=1, column=0, columnspan=2, sticky="ew", padx=20, pady=10) + + def open_report(self): + os.startfile(self.report_path) + + +def refresh(browse_reports_frame_instance: customtkinter.CTkScrollableFrame): + """Function that refreshes the browse reports frame. + It deletes all widgets from the frame and adds new widgets. + It is used when new report is created so that the list of reports is updated. + + :param browse_reports_frame_instance: browse reports frame instance that will be refreshed with new data + """ + + # delete all widgets from frame + for widget in browse_reports_frame_instance.winfo_children(): + widget.destroy() + + reports_list = {} + # Get new list of reports and add dict entries representing a pair of: name of report and a frame with report + # details + for report in get_reports_folder_list(): + reports_list[report] = Report_Frame(browse_reports_frame_instance, report_name=report, + report_path=os.path.join(os.getcwd(), "REPORTS", report)) + + # Add a top label to the list of reports + browse_reports_frame_instance.label = customtkinter.CTkLabel(browse_reports_frame_instance, text="Reports List", + text_color=("#333", "#ccc"), + font=customtkinter.CTkFont(size=15, weight="bold")) + browse_reports_frame_instance.label.grid(row=0, column=0, padx=20) + # Add all reports to the frame + add_widgets(reports_list, rowstart=1, columnstart=0, orientation="vertical") + + +def get_report_file_and_ip_numbers(report_path): + """Function that returns the number of files and IP addresses found in the report by analyzing the report.csv file. + + :param report_path: path to the report folder + :return: dict with number of files and IP addresses found in the report + """ + + number_of_ip_addresses = 0 + found_files = set() + try: + with open(os.path.join(report_path, 'report.csv'), 'r') as file: + reader = csv.reader(file) + next(reader, None) + for row in reader: + if not row[0] == "No Anydesk logs found!": + number_of_ip_addresses += 1 + found_files.add(row[2]) + except FileNotFoundError: + pass + + return {"number_of_ip_addresses": number_of_ip_addresses, "number_of_files": len(found_files)} diff --git a/main.py b/main.py index e569406..a0230d2 100644 --- a/main.py +++ b/main.py @@ -3,6 +3,7 @@ from PIL import Image from frames.AnydeskFrame import AnydeskFrame +from frames.BrowseReportsFrame import BrowseReportsFrame, refresh from frames.HomeFrame import HomeFrame customtkinter.set_appearance_mode("System") @@ -43,6 +44,11 @@ def __init__(self): dark_image=Image.open(os.path.join(image_path, "anydesk_dark.png")), size=(20, 20)) + self.browse_reports_image = customtkinter.CTkImage( + light_image=Image.open(os.path.join(image_path, "browse_reports_light.png")), + dark_image=Image.open(os.path.join(image_path, "browse_reports_dark.png")), + size=(20, 20)) + # create navigation frame self.navigation_frame = customtkinter.CTkFrame(self, corner_radius=0) self.navigation_frame.grid(row=0, column=0, sticky="nsew") @@ -70,6 +76,14 @@ def __init__(self): command=self.frame_2_button_event) self.frame_2_button.grid(row=2, column=0, sticky="ew") + self.browse_reports_frame_button = customtkinter.CTkButton(self.navigation_frame, corner_radius=0, height=40, + border_spacing=10, text="Browse Reports", + fg_color="transparent", text_color=("#333", "#ccc"), + hover_color=("gray70", "gray30"), + image=self.browse_reports_image, anchor="w", + command=self.browse_reports_frame_button_event) + self.browse_reports_frame_button.grid(row=5, column=0, sticky="ew") + self.appearance_mode_menu = customtkinter.CTkOptionMenu(self.navigation_frame, text_color=("#eee", "#ccc"), values=["Light", "Dark", "System"], @@ -83,6 +97,9 @@ def __init__(self): # create anydesk frame self.anydesk_frame = AnydeskFrame(self, corner_radius=0, fg_color="transparent") + # create browse reports frame + self.browse_reports_frame = BrowseReportsFrame(self, corner_radius=0, fg_color="transparent") + # select default frame self.select_frame_by_name("home") @@ -91,6 +108,8 @@ def select_frame_by_name(self, name): # set button color for selected button self.home_button.configure(fg_color=("gray75", "gray25") if name == "home" else "transparent") self.frame_2_button.configure(fg_color=("gray75", "gray25") if name == "anydesk_frame" else "transparent") + self.browse_reports_frame_button.configure( + fg_color=("gray75", "gray25") if name == "browse_reports_frame" else "transparent") # show selected frame if name == "home": @@ -101,6 +120,10 @@ def select_frame_by_name(self, name): self.anydesk_frame.grid(row=0, column=1, sticky="nsew") else: self.anydesk_frame.grid_forget() + if name == "browse_reports_frame": + self.browse_reports_frame.grid(row=0, column=1, sticky="nsew") + else: + self.browse_reports_frame.grid_forget() def home_button_event(self): """Home button event handler.""" @@ -110,6 +133,11 @@ def frame_2_button_event(self): """Frame 2 button event handler.""" self.select_frame_by_name("anydesk_frame") + def browse_reports_frame_button_event(self): + """Browse Reports button event handler.""" + self.select_frame_by_name("browse_reports_frame") + refresh(browse_reports_frame_instance=self.browse_reports_frame) + if __name__ == "__main__": app = App() diff --git a/tests/test_file_operations.py b/tests/test_file_operations.py index c1869ae..9908415 100644 --- a/tests/test_file_operations.py +++ b/tests/test_file_operations.py @@ -2,8 +2,10 @@ import hashlib import shutil from datetime import datetime + from utils.file_operations import get_anydesk_logs, create_timestamped_directory, create_folders_from_path, \ - generate_md5_file_checksum, copy_and_generate_checksum, generate_txt_report, generate_csv_report + generate_md5_file_checksum, copy_and_generate_checksum, generate_txt_report, generate_csv_report, \ + split_computer_datetime_dirname, get_reports_folder_list import os import re import sys @@ -126,7 +128,7 @@ def test_generate_md5_file_checksum(self): assert generate_md5_file_checksum(test_file) == test_file_hash os.remove('test_file.txt') - def test_copy_and_generate_checksum(self): + def test_copy_and_generate_checksum(self, capsys): test_file = TestGetAnydeskLogs.create_empty_file('test_file.txt') test_file_hash = hashlib.md5(open('test_file.txt', 'rb').read()).hexdigest() cwd = os.getcwd() @@ -139,6 +141,13 @@ def test_copy_and_generate_checksum(self): shutil.rmtree(os.path.join(os.getcwd(), 'REPORTS')) os.remove('test_file.txt') + # Test IOError + nonexistent_file = 'nonexistent_file.txt' + path_to_nonexistent_file = os.path.join(os.getcwd(), nonexistent_file) + copy_and_generate_checksum(path_to_nonexistent_file, os.getcwd()) + captured_error_print = capsys.readouterr() + assert captured_error_print.out == 'Error occurred when trying to copy\n' + class TestGeneratingTextReport: def test_generate_txt_report_with_header(self): @@ -230,3 +239,40 @@ def test_generate_empty_csv_report(self): # assert assert actual_output == expected_output os.remove(os.path.join(report_directory_path, 'report.csv')) + + +def test_split_computer_datetime_dirname(): + assert split_computer_datetime_dirname('computer_11-03-2023_19-09-48') == {'computer_name': 'computer', + 'date': '11-03-2023', 'time': '19:09:48'} + + assert split_computer_datetime_dirname('test_computer--203;lk;asdfj_11-03-2023_19-09-48') == { + 'computer_name': 'test_computer--203;lk;asdfj', + 'date': '11-03-2023', 'time': '19:09:48'} + + assert split_computer_datetime_dirname('1234567890_11-03-2023_19-09-48') == { + 'computer_name': '1234567890', + 'date': '11-03-2023', 'time': '19:09:48'} + + assert split_computer_datetime_dirname('__11-03-2023_19-09-48') == {'computer_name': '_', + 'date': '11-03-2023', 'time': '19:09:48'} + + assert split_computer_datetime_dirname('asfdfasd') is None + assert split_computer_datetime_dirname('asfdfasd_11-03-2023_12-dd-22') is None + assert split_computer_datetime_dirname('asfdfasd_11-03-2023_bb-dd-ee') is None + + +def test_get_reports_folder_list(tmp_path): + reports_folder = os.path.join(tmp_path, 'REPORTS') + # Assert that the reports folder does not exist + assert not os.path.isdir(reports_folder) + # Assert that after running the function the reports folder is created and is empty + assert get_reports_folder_list(reports_folder) == [] + # Assert that the reports folder exists + assert os.path.isdir(reports_folder) + assert os.path.exists(os.path.join(tmp_path, 'REPORTS')) + os.mkdir(os.path.join(reports_folder, 'computer_11-03-2023_19-09-48')) + os.mkdir(os.path.join(reports_folder, 'computer_11-03-2023_19-09-49')) + os.mkdir(os.path.join(reports_folder, 'computer_11-03-2023_19-09-50')) + assert sorted(get_reports_folder_list(reports_folder)) == sorted(['computer_11-03-2023_19-09-48', + 'computer_11-03-2023_19-09-49', + 'computer_11-03-2023_19-09-50']) diff --git a/utils/file_operations.py b/utils/file_operations.py index 21a49ed..bb216ed 100644 --- a/utils/file_operations.py +++ b/utils/file_operations.py @@ -101,8 +101,6 @@ def generate_md5_file_checksum(filename: str) -> str: file_hash = hashlib.md5() while chunk := f.read(8192): file_hash.update(chunk) - # print(file_hash.digest()) - # print(file_hash.hexdigest()) # to get a printable str instead of bytes return file_hash.hexdigest() @@ -155,3 +153,28 @@ def generate_csv_report(report_directory_path: str, write_header: bool = True, a else: for entry in anydesk_logs_dict: writer.writerow([entry, anydesk_logs_dict[entry], filename]) + + +def split_computer_datetime_dirname(dirname): + """A function that splits a generated directory name into computer name, date and time""" + result = re.search(r"(.*)_(\d{2}-\d{2}-\d{4})_(\d{2}-\d{2}-\d{2}$)", dirname) + if result: + return { + "computer_name": result.group(1), + "date": result.group(2), + "time": result.group(3).replace("-", ":") + } + else: + return None + + +def get_reports_folder_list(reports_directory='REPORTS'): + """A function that returns a list of all directories in the current working directory/reports_directory folder + If the folder does not exist, it will be created + :param reports_directory: a name of reports folder inside current working directory + """ + try: + return os.listdir(os.path.join(os.getcwd(), reports_directory)) + except FileNotFoundError: + os.mkdir(os.path.join(os.getcwd(), reports_directory)) + return os.listdir(os.path.join(os.getcwd(), reports_directory)) diff --git a/utils/widget_utils.py b/utils/widget_utils.py new file mode 100644 index 0000000..af70e10 --- /dev/null +++ b/utils/widget_utils.py @@ -0,0 +1,17 @@ +from typing import Literal + + +def add_widgets(widget_dict, rowstart: int = 0, columnstart: int = 0, orientation: Literal["vertical", "horizontal"] = +"vertical"): + """ A function that adds widgets to a frame. + :param widget_dict: A dictionary containing the widgets to be added. + :param rowstart: The row number where the widgets will be added. + :param columnstart: The column number where the widgets will be added. + :param orientation: The orientation of the widgets. "vertical" or "horizontal". + """ + + for i, (key, value) in enumerate(widget_dict.items()): + if orientation == "vertical": + value.grid(row=rowstart + i, column=columnstart, sticky="ew", padx=20, pady=10) + elif orientation == "horizontal": + value.grid(row=rowstart + i, column=columnstart + i, sticky="ew", padx=20, pady=10)