17 Commits

Author SHA1 Message Date
6a22ecb591 moved tests out of src, added more test cases 2025-05-08 13:31:24 +02:00
233f81c33e switched to model instead of connector, extracts config from parent 2025-05-08 13:30:49 +02:00
e939c97474 moved connector to model, instead injecting model for easier testing 2025-05-08 13:29:50 +02:00
7028c7ce69 refactored for easier testing 2025-05-08 13:28:16 +02:00
2a25111c0a refactored for easier testing 2025-05-08 13:27:25 +02:00
8125d88695 move connecter selection to model 2025-05-08 13:24:20 +02:00
b8581b34cc first test 2025-05-08 12:25:49 +02:00
3224c1c2aa Merge pull request 'fix_model_structure' (#13) from fix_model_structure into main
Reviewed-on: #13
2025-05-04 16:33:22 +02:00
ca88c4d229 compiled 0.1.0b beta version for release 2025-05-04 16:30:50 +02:00
12d0d7034f refactored model, fixed utf-8 export json 2025-05-04 16:22:56 +02:00
2d840d6ad4 Merge pull request 'Statuszeile und filtern von aktiven Adressen' (#9) from statuszeile_und_filter into main
Reviewed-on: #9
2025-04-30 20:04:41 +02:00
0ae2c7e559 new version 0.7a, and update json filename for new format 2025-04-30 20:00:20 +02:00
96995f1e80 fixed keybindings, refactoring and table focus 2025-04-30 19:58:16 +02:00
ec130fcee9 removed all "debug" print commands 2025-04-28 00:24:00 +02:00
95ed06df45 typo in label 2025-04-28 00:23:19 +02:00
9ea44933a6 recreated json file handling as connector model, fixing #10 and introducing #8 and #7 2025-04-28 00:18:22 +02:00
dc27197129 handles data storage 2025-04-27 22:56:14 +02:00
9 changed files with 284 additions and 111 deletions

View File

@@ -1,5 +1,5 @@
Package: brovski-adressetiketten Package: brovski-adressetiketten
Version: 0.6a Version: 0.1.0b
Maintainer: Ovski Maintainer: Ovski
Architecture: all Architecture: all
Description: manage csv files for glables address labels Description: manage csv files for glables address labels

View File

@@ -5,10 +5,10 @@ import tkinter as tk
from configparser import NoSectionError, NoOptionError from configparser import NoSectionError, NoOptionError
from tkinter import messagebox from tkinter import messagebox
from tkinter import ttk from tkinter import ttk
import json
from config import Config from config import Config
from windows import SettingsWindow, EditRecord, Window, show_error from model import Model
from windows import SettingsWindow, EditRecord, show_error
class Application: class Application:
@@ -18,7 +18,7 @@ class Application:
y_offset = 200 y_offset = 200
width = 1050 width = 1050
height = 700 height = 700
VERSION = '0.6a' VERSION = '0.1.0b'
title = f"Brovski Adress-Etiketten Verwaltung {VERSION}" title = f"Brovski Adress-Etiketten Verwaltung {VERSION}"
self.root = tk.Tk(className="BrovskiAdressEtiketten") self.root = tk.Tk(className="BrovskiAdressEtiketten")
@@ -35,13 +35,14 @@ class Application:
self.last_sort_field = "#3" self.last_sort_field = "#3"
self.filter_active = tk.BooleanVar(value=False) self.filter_active = tk.BooleanVar(value=False)
# model connector
self.model = Model(self.config)
# init paths to json and csv file # init paths to json and csv file
self.json_file_name = "brovski-adress-etiketten-verwaltung.json" self.json_file_name = "brovski-adress-etiketten-verwaltung.json"
self.csv_file_name = "brovski-adress-etiketten.csv" self.csv_file_name = "brovski-adress-etiketten.csv"
self.json_path = ""
self.csv_path = "" self.csv_path = ""
self.load_config() self.load_config()
self.json_file = os.path.join(self.json_path, self.json_file_name)
self.csv_file = os.path.join(self.csv_path, self.csv_file_name) self.csv_file = os.path.join(self.csv_path, self.csv_file_name)
# status bar content # status bar content
@@ -71,7 +72,8 @@ class Application:
tk.Button(top_frame, text="Delete", command=self.delete_record, width=button_width).pack(side=tk.LEFT) tk.Button(top_frame, text="Delete", command=self.delete_record, width=button_width).pack(side=tk.LEFT)
tk.Button(top_frame, text="Export CSV", command=self.export_csv, width=button_width).pack(side=tk.LEFT) tk.Button(top_frame, text="Export CSV", command=self.export_csv, width=button_width).pack(side=tk.LEFT)
tk.Button(top_frame, text="Toggle Aktiv", command=self.toggle_active, width=button_width).pack(side=tk.LEFT) tk.Button(top_frame, text="Toggle Aktiv", command=self.toggle_active, width=button_width).pack(side=tk.LEFT)
tk.Checkbutton(top_frame, text="Filter aktive", variable=self.filter_active, command=self.populate_table).pack(side=tk.LEFT) tk.Checkbutton(top_frame, text="Filter aktiv", variable=self.filter_active, command=self.populate_table).pack(
side=tk.LEFT)
tk.Button(top_frame, text="Quit", command=self.on_close, width=button_width).pack(side=tk.RIGHT) tk.Button(top_frame, text="Quit", command=self.on_close, width=button_width).pack(side=tk.RIGHT)
tk.Button(top_frame, text="Settings", command=self.show_settings, width=button_width).pack(side=tk.RIGHT) tk.Button(top_frame, text="Settings", command=self.show_settings, width=button_width).pack(side=tk.RIGHT)
@@ -90,23 +92,22 @@ class Application:
scrollbar.pack(side=tk.LEFT, fill=tk.Y) scrollbar.pack(side=tk.LEFT, fill=tk.Y)
self.table.bind("<ButtonRelease-1>", self.mouse_click) self.table.bind("<ButtonRelease-1>", self.mouse_click)
self.table.bind("<Return>", self.mouse_click_double) self.table.bind("<Return>", self.enter_button)
self.table.bind("<Double-1>", self.mouse_click_double) self.table.bind("<Double-1>", self.mouse_click_double)
self.root.bind("<Up>", self.focus_table)
self.root.bind("<Down>", self.focus_table)
# bottom status bar # bottom status bar
tk.Label(bottom_frame, textvariable=self.statusbar).pack(side=tk.LEFT) tk.Label(bottom_frame, textvariable=self.statusbar).pack(side=tk.LEFT)
self._load_json_file()
self.first_sort_after_start() self.first_sort_after_start()
self.root.mainloop() self.root.mainloop()
def load_config(self): def load_config(self):
try: try:
self.json_path = self.config.get("json", "path")
self.csv_path = self.config.get("csv", "path") self.csv_path = self.config.get("csv", "path")
if self.json_path == "" or self.csv_path == "": if self.csv_path == "":
raise ValueError("Empty JSON or CSV path") raise ValueError("Empty JSON or CSV path")
self.config_good = True self.config_good = True
except NoSectionError: except NoSectionError:
@@ -132,55 +133,55 @@ class Application:
settings.wait_window() settings.wait_window()
def insert_record(self): def insert_record(self):
values = [ values = {
"x", "aktiv": "x",
"Firma", "firma": "Firma",
"Name", "name": "Name",
"Strasse", "strasse": "Strasse",
"Plz/Ort", "plzort": "Plz/Ort"
] }
last_iid = self.table.get_children()[-1] self.model.create_new(values)
next_iid = int(last_iid) + 1 self.populate_table()
self.table.insert('', 'end', iid=next_iid, values=values)
self._save_json_file()
self.deselect_tree() self.deselect_tree()
def delete_record(self): def delete_record(self):
if self.current_record is None: if self.current_record is None:
return return
if len(self.table.selection()) > 1:
show_error(message_title="Mehrere Adressen ausgewählt",
message="Es können nur einzelne Adressen gelöscht werden",
parent=self.root)
return
if messagebox.askyesno( if messagebox.askyesno(
"Eintrag löschen?", "Eintrag löschen?",
"Willst du diesen Eintrag wirklich löschen?\nDies kann nicht rückgängig gemacht werden"): "Willst du diesen Eintrag wirklich löschen?\nDies kann nicht rückgängig gemacht werden"):
self.table.delete(self.current_record) self.model.delete_by_id(self.current_record)
self.deselect_tree() self.deselect_tree()
self._save_json_file() self.populate_table()
def update_record(self, data: list): def update_record(self, record: dict):
if self.current_record is None: if self.current_record is None:
return return
values = {} self.model.update_record(record)
for idx, value in enumerate(data): self.populate_table()
values[str(idx)] = value.get()
for key, value in values.items():
self.table.set(self.current_record, key, value)
self._save_json_file()
def toggle_active(self): def toggle_active(self):
items = self.table.selection() selection = self.table.selection()
if len(items) == 0: if len(selection) == 0:
return return
for record_id in items: item_list = [int(x) for x in selection]
values = self.table.item(record_id, "values") for address in self.address_list:
active = values[0] record_id = address.get("record_id")
new_active = "x" if active == "" else "" if record_id in item_list:
self.table.set(record_id, "0", new_active) address["aktiv"] = "x" if address["aktiv"] == "" else ""
self.model.update_record(address)
self.table.set(record_id, "0", address["aktiv"])
self._save_json_file() self.update_status_bar()
def deselect_tree(self): def deselect_tree(self):
while len(self.table.selection()) > 0: while len(self.table.selection()) > 0:
@@ -188,7 +189,11 @@ class Application:
self.current_record = None self.current_record = None
def mouse_click(self, event): def mouse_click(self, event):
self.current_record = self.table.focus() id_string = self.table.focus()
if id_string is not None and id_string != "":
self.current_record = int(self.table.focus())
else:
self.current_record = None
region = self.table.identify("region", event.x, event.y) region = self.table.identify("region", event.x, event.y)
match region: match region:
case "heading": case "heading":
@@ -198,7 +203,10 @@ class Application:
region = self.table.identify("region", event.x, event.y) region = self.table.identify("region", event.x, event.y)
match region: match region:
case "cell": case "cell":
self.click_on_cell() self.edit_selected_record()
def enter_button(self, event):
self.edit_selected_record()
def click_on_header(self, event): def click_on_header(self, event):
column = self.table.identify_column(event.x) column = self.table.identify_column(event.x)
@@ -207,70 +215,65 @@ class Application:
else: else:
self.sort_order = False self.sort_order = False
self.last_sort_field = column self.last_sort_field = column
self.address_list.sort(key=lambda x: (x[int(column[-1]) - 1]), reverse=self.sort_order)
# special case company sort match column:
if column == "#2": case "#1":
self.address_list.sort(key=lambda x: (x[1], x[2]), reverse=self.sort_order) field = "aktiv"
self.populate_table() case "#2":
field = "firma"
case "#3":
field = "name"
case "#4":
field = "strasse"
case "#5":
field = "plzort"
case _:
field = "name"
def click_on_cell(self): self.address_list = self.model.get_all_sorted_by(field, self.sort_order)
self.current_record = self.table.focus() self.populate_table(reload=False)
values = self.table.item(self.current_record, "values")
self.open_window_edit_records(values)
def _load_json_file(self): def edit_selected_record(self):
try: if self.table.focus() is None or self.table.focus() == "":
with open(self.json_file, "r", encoding="utf-8") as f: return
self.address_list = json.load(f) self.current_record = int(self.table.focus())
self.address_list.sort(key=lambda x: (x[0], x[1])) self.open_window_edit_records(self.current_record)
except FileNotFoundError:
show_error(
message_title="Datei nicht gefunden",
message=f"{self.json_file_name} nicht gefunden, erstelle leere Datei unter {self.json_path}",
parent=self.root
)
self.address_list = [["", "firma", "name", "adresse", "plz/ort"]]
with open(self.json_file, "w", encoding="utf-8") as f:
json.dump(self.address_list, f)
self.populate_table()
def _save_json_file(self):
self.export_table_to_address_list()
try:
with open(self.json_file, "w", encoding="utf-8") as f:
json.dump(self.address_list, f, indent=4, sort_keys=True)
except FileNotFoundError:
show_error(
message_title="Unexpected Error: File not found?!",
message=f"{self.json_file_name} not found",
parent=self.root
)
def export_csv(self): def export_csv(self):
try: try:
with open(self.csv_file, "w", encoding="utf-8") as f: with open(self.csv_file, "w", encoding="utf-8") as f:
writer = csv.writer(f, delimiter=",") writer = csv.writer(f, delimiter=",")
for address in self.address_list: for address in self.address_list:
if address[0] != "x": if address["aktiv"] != "x":
continue continue
if address[1] == "": line = []
writer.writerow(address[2:]) if address["firma"] != "":
else: line.append(address["firma"])
writer.writerow(address[1:]) line.append(address["name"])
line.append(address["strasse"])
line.append(address["plzort"])
writer.writerow(line)
except FileNotFoundError: except FileNotFoundError:
show_error(message_title="Unexpected error", show_error(message_title="Unexpected error",
message=f"Could not write file {self.csv_file}", message=f"Could not write file {self.csv_file}",
parent=self.root parent=self.root
) )
def populate_table(self): def populate_table(self, reload=True):
if reload:
self.address_list = self.model.get_all()
if len(self.address_list) == 0:
return
self.delete_all_table_items() self.delete_all_table_items()
for index, item in enumerate(self.address_list): for address in self.address_list:
# skip inactive records if filter is true # skip inactive records if filter is true
if self.filter_active.get() and item[0] != "x": if self.filter_active.get() and address["aktiv"] != "x":
continue continue
self.table.insert('', 'end', iid=index, values=item) self.table.insert('', 'end', iid=address["record_id"],
values=(address["aktiv"], address["firma"], address["name"], address["strasse"],
address["plzort"])
)
self.update_status_bar() self.update_status_bar()
def export_table_to_address_list(self): def export_table_to_address_list(self):
@@ -292,8 +295,8 @@ class Application:
window.grab_set() window.grab_set()
return window return window
def open_window_edit_records(self, data): def open_window_edit_records(self, record_id: int):
window = EditRecord(self, self.root, data) window = EditRecord(self, self.root, record_id)
window.wm_transient(self.root) window.wm_transient(self.root)
window.wait_visibility() window.wait_visibility()
window.grab_set() window.grab_set()
@@ -306,14 +309,24 @@ class Application:
self.length_address_list = len(self.address_list) self.length_address_list = len(self.address_list)
count = 0 count = 0
for address in self.address_list: for address in self.address_list:
if address[0] == "x": if address["aktiv"] == "x":
count += 1 count += 1
self.length_address_list_active = count self.length_address_list_active = count
def first_sort_after_start(self): def first_sort_after_start(self):
self.address_list.sort(key=lambda x: (x[1], x[2])) self.address_list.sort(key=lambda x: (x["firma"], x["name"]))
self.populate_table() self.populate_table()
def focus_table(self, event):
first = self.table.get_children()[0]
last = self.table.get_children()[-1]
goto = last if event.keysym == "Up" else first
if self.table.focus() == "":
self.table.selection_set(goto)
self.table.focus(goto)
self.table.see(goto)
self.table.focus_force()
if __name__ == '__main__': if __name__ == '__main__':
Application() Application()

View File

@@ -1,11 +1,12 @@
import configparser
import os import os
from configparser import ConfigParser from configparser import ConfigParser, DuplicateSectionError
class Config: class Config:
parser: ConfigParser parser: ConfigParser
def __init__(self): def __init__(self, path: str = None, filename: str = "config.ini"):
""" """
Config parser reading config.ini Config parser reading config.ini
@@ -14,12 +15,18 @@ class Config:
self.__filename: Path and name to the config file self.__filename: Path and name to the config file
""" """
self.parser = ConfigParser() self.parser = ConfigParser()
self.path = path
self.filename = filename
if self.path is None:
home_path = os.environ["HOME"] home_path = os.environ["HOME"]
full_path = os.path.join(home_path, ".config", "brovski-adress-etiketten" ) full_path = os.path.join(home_path, ".config", "brovski-adress-etiketten" )
else:
full_path = self.path
if not os.path.exists(full_path): if not os.path.exists(full_path):
os.makedirs(full_path) os.makedirs(full_path)
self.config_file = os.path.join(full_path, "config.ini") self.config_file = os.path.join(full_path, self.filename)
self._load() self._load()
@@ -32,11 +39,15 @@ class Config:
def add_section(self, section): def add_section(self, section):
self._load() self._load()
try:
self.parser.add_section(section) self.parser.add_section(section)
except DuplicateSectionError:
pass
self._save() self._save()
def set(self, section: str, option: str, value: str): def set(self, section: str, option: str, value: str):
self._load() self._load()
self.add_section(section)
self.parser.set(section, option, value) self.parser.set(section, option, value)
self._save() self._save()

84
src/connector.py Normal file
View File

@@ -0,0 +1,84 @@
import json
import os
from abc import ABC, abstractmethod
from config import Config
class Connector(ABC):
def __init__(self, config: Config):
self.config = config
@abstractmethod
def get_all(self) -> list:
pass
@abstractmethod
def get_by_id(self, record_id: int) -> dict:
pass
@abstractmethod
def delete_by_id(self, record_id: int):
pass
@abstractmethod
def update_record(self, updated_record: dict):
pass
@abstractmethod
def create_new(self, values: dict) -> int:
pass
@abstractmethod
def get_all_sorted_by(self, field: str, reverse: bool = False) -> list:
pass
class JSONConnector(Connector):
def __init__(self, config: Config, ):
super().__init__(config)
self.json_path = self.config.get("json", "path")
self.json_file = os.path.join(self.json_path, "brovski-adress-etiketten-verwaltung-v7.json")
def get_all(self) -> list:
with open(self.json_file, "r") as f:
return json.load(f)
def get_all_sorted_by(self, field: str, reverse=False) -> list:
with open(self.json_file, "r") as f:
data = json.load(f)
return sorted(data, key=lambda k: k[field], reverse=reverse)
def get_by_id(self, record_id: int) -> dict:
data = self.get_all()
for record in data:
if record["record_id"] == record_id:
return record
def delete_by_id(self, record_id: int):
data = self.get_all()
for idx, record in enumerate(data):
if record["record_id"] == record_id:
del data[idx]
self._write_to_file(data)
def update_record(self, new_record: dict):
data = self.get_all()
for idx, record in enumerate(data):
if record["record_id"] == new_record["record_id"]:
data[idx] = new_record
self._write_to_file(data)
def create_new(self, record: dict) -> int:
data = self.get_all()
if len(data) == 0:
next_id = 0
else:
next_id = max(data, key=lambda x: x["record_id"])["record_id"] + 1
record["record_id"] = next_id
data.append(record)
self._write_to_file(data)
return next_id
def _write_to_file(self, data):
with open(self.json_file, "w", encoding="UTF-8") as f:
json.dump(data, f, indent=4, ensure_ascii=False)

24
src/model.py Normal file
View File

@@ -0,0 +1,24 @@
from connector import Connector, JSONConnector
from config import Config
class Model:
def __init__(self, config: Config):
self.connector = JSONConnector(config)
def get_all(self):
return self.connector.get_all()
def get_all_sorted_by(self, field: str, reverse: bool = False):
return self.connector.get_all_sorted_by(field=field, reverse=reverse)
def get_by_id(self, record_id: int):
return self.connector.get_by_id(record_id)
def delete_by_id(self, record_id: int):
self.connector.delete_by_id(record_id)
def update_record(self, new_record: dict):
self.connector.update_record(new_record)
def create_new(self, record: dict):
self.connector.create_new(record)

View File

@@ -3,6 +3,7 @@ from configparser import NoSectionError, NoOptionError
from tkinter import font, filedialog, messagebox from tkinter import font, filedialog, messagebox
from config import Config from config import Config
from model import Model
def show_error(message_title: str, message: str, parent: tk.Tk | tk.Toplevel): def show_error(message_title: str, message: str, parent: tk.Tk | tk.Toplevel):
@@ -17,6 +18,7 @@ class Window(tk.Toplevel):
def __init__(self, parent, root: tk.Tk): def __init__(self, parent, root: tk.Tk):
super().__init__(root) super().__init__(root)
self.parent = parent self.parent = parent
self.config = parent.config
self.root = root self.root = root
self.protocol("WM_DELETE_WINDOW", self.close_window) self.protocol("WM_DELETE_WINDOW", self.close_window)
self.bind("<Escape>", self.close_window) self.bind("<Escape>", self.close_window)
@@ -25,27 +27,28 @@ class Window(tk.Toplevel):
self._destroy_window() self._destroy_window()
def _destroy_window(self): def _destroy_window(self):
self.parent.populate_table()
self.root.update() self.root.update()
self.destroy() self.destroy()
class EditRecord(Window): class EditRecord(Window):
def __init__(self, parent, root: tk.Tk, data: tuple): def __init__(self, parent, root: tk.Tk, record_id: int):
super().__init__(parent, root) super().__init__(parent, root)
self.bind("<Return>", self._update) self.bind("<Return>", self._update)
self.data = data self.model = Model(self.config)
record = self.model.get_by_id(record_id)
button_width = 8 button_width = 8
self.title("Adresse bearbeiten") self.title("Adresse bearbeiten")
self.aktiv = tk.StringVar() self.record_id = tk.IntVar(value=record_id)
self.firma = tk.StringVar() self.aktiv = tk.StringVar(value=record.get("aktiv"))
self.name = tk.StringVar() self.firma = tk.StringVar(value=record.get("firma"))
self.strasse = tk.StringVar() self.name = tk.StringVar(value=record.get("name"))
self.plz_ort = tk.StringVar() self.strasse = tk.StringVar(value=record.get("strasse"))
self.plz_ort = tk.StringVar(value=record.get("plzort"))
self.field_list = [self.aktiv, self.firma, self.name, self.strasse, self.plz_ort]
for argument, field in zip(self.data, self.field_list):
field.set(argument)
edit_frame = tk.Frame(self) edit_frame = tk.Frame(self)
edit_frame.pack(side=tk.TOP, fill=tk.X, pady=20, padx=20) edit_frame.pack(side=tk.TOP, fill=tk.X, pady=20, padx=20)
@@ -71,7 +74,15 @@ class EditRecord(Window):
tk.Button(button_frame, text="Abbrechen", command=self.close_window, width=button_width).pack(side=tk.LEFT) tk.Button(button_frame, text="Abbrechen", command=self.close_window, width=button_width).pack(side=tk.LEFT)
def _update(self, event = None): def _update(self, event = None):
self.parent.update_record(self.field_list) new_record = {
"record_id": self.record_id.get(),
"aktiv": self.aktiv.get(),
"firma": self.firma.get(),
"name": self.name.get(),
"strasse": self.strasse.get(),
"plzort": self.plz_ort.get(),
}
self.model.update_record(new_record)
self.close_window() self.close_window()

0
tests/__init__.py Normal file
View File

30
tests/test_config.py Normal file
View File

@@ -0,0 +1,30 @@
import os
from typing import assert_type
import pytest
from src.config import Config
@pytest.fixture
def config() -> Config:
config = Config(path="testfiles", filename="configtest.ini")
return config
def teardown_config():
print("tearing down config test")
def test_construction(config):
assert_type(config, Config)
def test_file_creation(config):
config._save()
assert os.path.isfile(os.path.join(config.path, config.filename))
def test_add_section(config):
config.add_section("test_section")
assert "test_section" in config.parser.sections()
def test_set_and_get(config):
config.set(section="section", option="option", value="value")
assert config.get(section="section", option="option") == "value"

View File

@@ -1 +1 @@
0.6a 0.1.0b