Python 课程设计项目报告
文章目录
项目情况
项目介绍
本项目为一个商品管理应用程序,主要功能是创建、编辑、存储商品信息,同时程序还支持图表视图浏览、密码保护、日志记录等功能。程序使用 Textual 模块创建 TUI(Text-based User Interface,基于文本的用户界面) 程序,用户使用 TUI 与程序交互。我们选择使用 TUI 的理由如下:
- 相比于 GUI 程序,TUI 程序可以在没有图形化界面的计算机系统内运行,有较强可移植性;相比于 CLI 程序,TUI 程序更易于用户操作。
- 用户只需键盘作为输入设备即可与程序交互,若用户使用鼠标则可以获得更佳交互体验。
- 使用 TUI 作为程序界面的人少之又少,而用于创建 TUI 程序的模块 Textual 的资料更是稀缺。因此,制作 TUI 程序可以体现作者完成这一项目的过程即是学习的过程。
- 作者个人情怀
程序功能的详细介绍如下:
- 权限异常提示。当用户以无读写文件权限的普通用户身份执行程序时,程序会通过
AlertModalScreen
类创建弹窗提醒用户权限不足。此外,为了确保数据安全,程序使用的数据文件、密码存储文件、日志文件的权限均设为 600。 - 登录模块。登录界面的会根据用户登录状态的不同而显示不同内容(登录界面的可复用性极强,下面三种不同状态下的登录界面都是基于
LoginModalScreen
类创建的):用户首次登录时,由于没有可用账户,程序会提示用户创建新的账户;有可用账户时,程序会在运行时提示用户登录,登录成功后数据才会加载;用户登录成功后,可以修改密码,修改密码后下次登录使用旧密码则会登录失败。用户名和密码输入框都限制了无法输入空格。用户的用户名及密码使用 PBKDF2 与 SHA256 算法(通过hashlib.pbkdf2_hmac()
函数实现)进行保护,并加入随机密码盐确保密码不会遭到彩虹表攻击。无论用户在何时注册或者修改密码,也无论用户使用何用户名密码,存储到logininfo
密码文件中的数据几乎没有重复的可能性。 - 数据处理模块。这一部分是程序的核心部分。用户在登录后会自动加载已保存的数据并且以组件的形式显示在屏幕上。数据包含商品名称、商品单价、商品数量、商品分区以及商品编号,用户可以编辑每一个商品的相关信息或者删除这些信息。其中,商品名称一栏尽可填写大小写字母、数字以及空格;商品单价一栏仅可输入浮点类型或者整型数据,商品数量一栏仅可输入整型数据;商品分区为一个下拉菜单,其中有十种类别可以选择。编辑后的数据需要用户手动保存,数据存储为 TSV 格式。
- 日志模块。创建用户、登录、登录失败以及保存文件这四种操作及操作时间会被记录并存储到日志文件中。
- 以表格视图浏览数据。表格视图可以浏览用户实时编辑的数据而非从已保存的文件中读取数据。在表格视图中,用户可以以商品名称、商品单价、商品数量、商品分区以及商品编号为排序依据对数据进行排序。
- 切换亮/暗显示模式。
- 命令托盘。保存数据、修改密码、切换亮/暗显示模式、退出程序、联系作者等命令可以在命令托盘(Command Palette)中查找并执行。
- 显示时间。
程序运行截图
程序代码
1from textual.screen import ModalScreen, Screen
2from textual.widgets import Label, Button, Input, Select, Header, Footer, DataTable, RichLog
3from textual.containers import Container, Horizontal, Center
4import csv
5import binascii
6import hashlib
7from os import urandom, chmod
8from itertools import cycle
9from rich.text import Text
10from rich.syntax import Syntax
11from textual.widget import Widget
12from textual.reactive import reactive
13from textual.message import Message
14from textual import on, work
15from time import asctime
16from textual.command import Provider, Hit
17from textual.app import App
18from textual.binding import Binding
19from os.path import getsize, exists
20from functools import partial
21
22
23class LoginStatus:
24
25 LOGIN_SUCCESS = 1
26 REQUEST_LOGIN = 2
27 FIRST_TIME_LOGIN = 5
28 REQUEST_CHANGE_PASSWORD = 6
29 EXIT_PROGRAM = 0
30
31 class REQUEST_LOGIN_ALT_TEXT:
32
33 input_box = "Password"
34 password = True
35 label = "Welcome to ItemManagementApp. Please login."
36 button = "Login"
37 button_id = "login"
38
39 class FIRST_TIME_LOGIN_ALT_TEXT:
40
41 input_box = "Password"
42 password = False
43 label = "No available user detected. Please sign up."
44 button = "Sign Up"
45 button_id = "first_time_login"
46
47 class REQUEST_CHANGE_PASSWORD_ALT_TEXT:
48
49 input_box = "New Password"
50 password = False
51 label = "You are changing your password."
52 button = "Submit"
53 button_id = "change_password"
54
55 login_status_alt_text = {
56 REQUEST_LOGIN: REQUEST_LOGIN_ALT_TEXT,
57 FIRST_TIME_LOGIN: FIRST_TIME_LOGIN_ALT_TEXT,
58 REQUEST_CHANGE_PASSWORD: REQUEST_CHANGE_PASSWORD_ALT_TEXT
59 }
60
61
62class AlertModalScreen(ModalScreen):
63
64 DEFAULT_CSS = """
65 AlertModalScreen {
66 align: center middle;
67 }
68
69 AlertModalScreen > #label {
70 align: center bottom;
71 }
72
73 AlertModalScreen > #button {
74 align: center top;
75 }
76 """
77
78 def __init__(self, alert_message):
79 super().__init__()
80 self.alert_message = alert_message
81
82 def compose(self):
83 with Container(id="label"):
84 yield Label(self.alert_message)
85 yield Label(" ")
86 with Container(id="button"):
87 yield Button("OK")
88
89 def on_button_pressed(self):
90 self.dismiss()
91
92
93class ItemModalScreen(ModalScreen):
94
95 DEFAULT_CSS = """
96 ItemModalScreen {
97 align: center middle;
98 }
99
100 ItemModalScreen > Container {
101 border: thick $background;
102 background: $boost;
103 width: 50%;
104 height: 76%;
105 }
106
107 ItemModalScreen > Container > Label {
108 width: auto;
109 padding-left: 1;
110 padding-right: 1;
111 }
112
113 ItemModalScreen > Container > * {
114 margin: 1;
115 }
116
117 ItemModalScreen > Container > Horizontal {
118 width: 100%;
119 height: auto;
120 dock: bottom;
121 padding-left: 1;
122 padding-right: 1;
123 margin: 0;
124 }
125
126 ItemModalScreen > Container > Horizontal > #submit {
127 align: left middle;
128 width: 1fr;
129 }
130
131 ItemModalScreen > Container > Horizontal > #cancel {
132 align: right middle;
133 width: 1fr;
134 }
135 """
136
137 def compose(self):
138 self.name_input = Input(placeholder="Name", restrict=r"^[a-zA-Z0-9\s]+$")
139 self.unit_price_input = Input(placeholder="Unit Price", type="number")
140 self.quantity_input = Input(placeholder="Quantity", type="integer")
141 self.section_select = Select([
142 ("Food", "Food"),
143 ("Clothing", "Clothing"),
144 ("Shoes & Hats", "Shoes & Hats"),
145 ("Daily Necessities", "Daily Necessities"),
146 ("Furniture", "Furniture"),
147 ("Household Appliances", "Household Appliances"),
148 ("Textiles", "Textiles"),
149 ("Hardware Materials", "Hardware Materials"),
150 ("Electric Materials", "Electric Materials"),
151 ("Kitchenware", "Kitchenware")
152 ])
153 self.id_number_label = Input(placeholder="ID")
154 with Container():
155 yield Label("You are adding a new item to the list.")
156 yield Label("Name")
157 yield self.name_input
158 yield Label("Unit Price")
159 yield self.unit_price_input
160 yield Label("Quantity")
161 yield self.quantity_input
162 yield Label("Section")
163 yield self.section_select
164 yield Label("ID")
165 yield self.id_number_label
166 with Horizontal():
167 yield Button("Submit", id="submit", variant="success")
168 yield Label(" ")
169 yield Button("Cancel", id="cancel", variant="error")
170
171 @on(Button.Pressed, "#submit")
172 def submit_request(self):
173 try:
174 data = (
175 self.name_input.value,
176 str(round(float(self.unit_price_input.value), 2)),
177 self.quantity_input.value,
178 self.section_select.value,
179 self.id_number_label.value
180 )
181 self.dismiss(data)
182
183 except Exception:
184 self.dismiss()
185
186 @on(Button.Pressed, "#cancel")
187 def cancel_request(self):
188 self.dismiss()
189
190
191class LoginModalScreen(ModalScreen):
192
193 DEFAULT_CSS = """
194 LoginModalScreen {
195 align: center middle;
196 }
197
198 LoginModalScreen > Container {
199 border: thick $background;
200 background: $boost;
201 width: 50%;
202 height: 50%;
203 }
204
205 LoginModalScreen > Container > Label {
206 width: 100%;
207 padding-left: 1;
208 padding-right: 1;
209 }
210
211 LoginModalScreen > Container > * {
212 margin: 1;
213 }
214
215 LoginModalScreen > Container > Horizontal {
216 width: 100%;
217 height: auto;
218 dock: bottom;
219 padding-left: 1;
220 padding-right: 1;
221 margin: 0;
222 }
223
224 LoginModalScreen > Container > Horizontal > #login {
225 align: left middle;
226 width: 1fr;
227 }
228
229 LoginModalScreen > Container > Horizontal > #first_time_login {
230 align: left middle;
231 width: 1fr;
232 }
233
234 LoginModalScreen > Container > Horizontal > #change_password {
235 align: left middle;
236 width: 1fr;
237 }
238
239 LoginModalScreen > Container > Horizontal > #cancel {
240 align: left middle;
241 width: 1fr;
242 }
243 """
244
245 def __init__(self, status):
246 super().__init__()
247 self.status = status
248 self.status_alt_text = LoginStatus.login_status_alt_text.get(status)
249
250 def compose(self):
251 self.username_input = Input(placeholder="Username", restrict=r"^[^\s]*$")
252 self.password_input = Input(placeholder=self.status_alt_text.input_box, password=self.status_alt_text.password, restrict=r"^[^\s]*$")
253 with Container():
254 yield Label(self.status_alt_text.label)
255 yield Label("Username")
256 yield self.username_input
257 yield Label(self.status_alt_text.input_box)
258 yield self.password_input
259 with Horizontal():
260 yield Button(self.status_alt_text.button, id=self.status_alt_text.button_id)
261 yield Label(" ")
262 yield Button("Cancel", id="cancel", variant="error")
263
264 @on(Button.Pressed, "#login")
265 def login_request(self):
266 try:
267 with open("logininfo", 'r', newline="") as logininfo:
268 reader = csv.reader(logininfo, delimiter=':')
269 hashdata = list(reader)[0]
270 if self.__login_hash(hashdata, self.username_input.value, self.password_input.value):
271 with open("IMA.log", 'a', newline="") as logfile:
272 logfile.write(f"Login at {asctime()}\n")
273 self.dismiss(LoginStatus.LOGIN_SUCCESS)
274 else:
275 with open("IMA.log", 'a', newline="") as logfile:
276 logfile.write(f"Attempt to login at {asctime()}\n")
277
278 except Exception:
279 pass
280
281 @on(Button.Pressed, "#first_time_login")
282 def first_time_login_requsest(self):
283 try:
284 hashdata = self.__first_time_login_hash(self.username_input.value, self.password_input.value)
285 with open("logininfo", 'w', newline="") as logininfo:
286 writer = csv.writer(logininfo, delimiter=':')
287 writer.writerow(hashdata)
288 with open("IMA.log", 'a', newline="") as logfile:
289 logfile.write(f"Sign up at {asctime()}\n")
290 self.dismiss(LoginStatus.LOGIN_SUCCESS)
291
292 except Exception:
293 pass
294
295 @on(Button.Pressed, "#change_password")
296 def change_password_request(self):
297 try:
298 with open("logininfo", 'r', newline="") as logininfo:
299 reader = csv.reader(logininfo, delimiter=':')
300 init_hashdata = list(reader)[0]
301 hashdata = self.__change_password_hash(init_hashdata, self.username_input.value, self.password_input.value)
302 if hashdata:
303 with open("logininfo", 'w', newline="") as logininfo:
304 writer = csv.writer(logininfo, delimiter=':')
305 writer.writerow(hashdata)
306 with open("IMA.log", 'a', newline="") as logfile:
307 logfile.write(f"Change password at {asctime()}\n")
308 self.dismiss(LoginStatus.LOGIN_SUCCESS)
309
310 except Exception:
311 pass
312
313 @on(Button.Pressed, "#cancel")
314 def cancel_request(self):
315 if self.status != LoginStatus.REQUEST_CHANGE_PASSWORD:
316 self.dismiss(LoginStatus.EXIT_PROGRAM)
317 else:
318 self.dismiss()
319
320 def __login_hash(self, hashdata, *args):
321 calcdata = []
322 pbkdf2data = []
323 inputdata = list(args)
324 for iter in range(2):
325 salt = binascii.unhexlify(hashdata[iter * 2])
326 pbkdf2data.append(hashdata[iter * 2 + 1])
327 calcdata.append(binascii.hexlify(hashlib.pbkdf2_hmac("sha256", inputdata[iter].encode(), salt, 16)).decode())
328 return calcdata == pbkdf2data
329
330 def __first_time_login_hash(self, *args):
331 hashdata = []
332 for raw in args:
333 salt = urandom(16)
334 hashdata.append(binascii.hexlify(salt).decode())
335 hashdata.append(binascii.hexlify(hashlib.pbkdf2_hmac("sha256", raw.encode(), salt, 16)).decode())
336 return hashdata
337
338 def __change_password_hash(self, init_hashdata, *args):
339 salt = binascii.unhexlify(init_hashdata[0])
340 if init_hashdata[1] == binascii.hexlify(hashlib.pbkdf2_hmac("sha256", self.username_input.value.encode(), salt, 16)).decode():
341 hashdata = []
342 for raw in args:
343 salt = urandom(16)
344 hashdata.append(binascii.hexlify(salt).decode())
345 hashdata.append(binascii.hexlify(hashlib.pbkdf2_hmac("sha256", raw.encode(), salt, 16)).decode())
346 return hashdata
347 else:
348 return False
349
350
351class DataTableScreen(Screen):
352
353 DEFAULT_CSS = """
354 DataTableScreen {
355 align: center middle;
356 }
357
358 DataTableScreen > Container {
359 align: center top;
360 width: auto;
361 margin: 1;
362 }
363
364 DataTableScreen > Container > DataTable {
365 align: center top;
366 width: auto;
367 }
368 """
369
370 TITLE = "Item Management App"
371 SUB_TITLE = "Data Table View"
372 BINDINGS = [
373 Binding(key='n', action="sort_by_name", description="Sort By Name", key_display='N', priority=True),
374 Binding(key='N', action="sort_by_name", show=False, priority=True),
375 Binding(key='u', action="sort_by_unit_price", description="Sort By Unit Price", key_display='U'),
376 Binding(key='U', action="sort_by_unit_price", show=False),
377 Binding(key='y', action="sort_by_quantity", description="Sort By Quantity", key_display='Y'),
378 Binding(key='Y', action="sort_by_quantity", show=False),
379 Binding(key='e', action="sort_by_section", description="Sort By Section", key_display='E', priority=True),
380 Binding(key='E', action="sort_by_section", show=False, priority=True),
381 Binding(key='i', action="sort_by_id", description="Sort By ID", key_display='I'),
382 Binding(key='I', action="sort_by_id", show=False),
383 Binding(key='q', action="quit", description="Quit", key_display='Q'),
384 Binding(key='Q', action="quit", show=False),
385 Binding(key='a', action="new_item", description="New", key_display='A', show=False),
386 Binding(key='s', action="save_data", description="Save", key_display='S', show=False),
387 Binding(key='v', action="toggle_datatable_view", description="Data Table View", key_display='V', show=False),
388 Binding(key='c', action="change_password", description="Change Password", key_display='C', show=False),
389 Binding(key='l', action="read_log_file", description="Read Log File", key_display='L', show=False),
390 Binding(key='t', action="credits", description="Credits", key_display='T', show=False),
391 Binding(key='A', action="new_item", show=False),
392 Binding(key='S', action="save_data", show=False),
393 Binding(key='V', action="toggle_datatable_view", show=False),
394 Binding(key='C', action="change_password", show=False),
395 Binding(key='L', action="read_log_file", show=False),
396 Binding(key='T', action="credits", show=False),
397 ]
398
399 cursors = cycle(["cell", "row", "column"])
400 current_sorts = set()
401
402 def __init__(self, data):
403 super().__init__()
404 self.rows = data
405
406 def on_mount(self) -> None:
407 table = self.query_one(DataTable)
408 for col in self.rows[0]:
409 table.add_column(col, key=col)
410 table.add_rows(self.rows[1:])
411 table.zebra_stripes = True
412
413 def compose(self):
414 yield Header(show_clock=True)
415 yield Footer()
416 with Container():
417 yield DataTable()
418
419 def sort_reverse(self, sort_type):
420 reverse = sort_type in self.current_sorts
421 if reverse:
422 self.current_sorts.remove(sort_type)
423 else:
424 self.current_sorts.add(sort_type)
425 return reverse
426
427 def action_sort_by_name(self):
428 table = self.query_one(DataTable)
429 table.sort(
430 "Name",
431 key=lambda name: name,
432 reverse=self.sort_reverse("Name")
433 )
434
435 def action_sort_by_unit_price(self):
436 table = self.query_one(DataTable)
437 table.sort(
438 "Unit Price",
439 key=lambda unit_price: float(unit_price),
440 reverse=self.sort_reverse("Unit Price")
441 )
442
443 def action_sort_by_quantity(self):
444 table = self.query_one(DataTable)
445 table.sort(
446 "Quantity",
447 key=lambda qty: int(qty),
448 reverse=self.sort_reverse("Quantity")
449 )
450
451 def action_sort_by_section(self):
452 table = self.query_one(DataTable)
453 table.sort(
454 "Section",
455 key=lambda section: section,
456 reverse=self.sort_reverse("Section")
457 )
458
459 def action_sort_by_id(self):
460 table = self.query_one(DataTable)
461 table.sort(
462 "ID",
463 key=lambda id_num: id_num,
464 reverse=self.sort_reverse("ID")
465 )
466
467 def action_quit(self):
468 self.dismiss()
469
470 def key_space(self):
471 table = self.query_one(DataTable)
472 table.cursor_type = next(self.cursors)
473
474
475class LogScreen(Screen):
476
477 DEFAULT_CSS = """
478 LogScreen {
479 align: center middle;
480 }
481
482 LogScreen > Container {
483 width: 90%;
484 height: 90%;
485 }
486
487 LogScreen > Container > Button {
488 dock: bottom;
489 }
490 """
491
492 TITLE = "Item Management App"
493 SUB_TITLE = "View Log File"
494 BINDINGS = [
495 Binding(key='a', action="new_item", description="New", key_display='A', show=False),
496 Binding(key='s', action="save_data", description="Save", key_display='S', show=False),
497 Binding(key='v', action="toggle_datatable_view", description="Data Table View", key_display='V', show=False),
498 Binding(key='c', action="change_password", description="Change Password", key_display='C', show=False),
499 Binding(key='l', action="read_log_file", description="Read Log File", key_display='L', show=False),
500 Binding(key='t', action="credits", description="Credits", key_display='T', show=False),
501 Binding(key='A', action="new_item", show=False),
502 Binding(key='S', action="save_data", show=False),
503 Binding(key='V', action="toggle_datatable_view", show=False),
504 Binding(key='C', action="change_password", show=False),
505 Binding(key='L', action="read_log_file", show=False),
506 Binding(key='T', action="credits", show=False),
507 Binding(key='q', action="quit", description="Quit", key_display='Q'),
508 Binding(key='Q', action="quit", show=False)
509 ]
510
511 def on_mount(self):
512 with open("IMA.log", 'r', newline="") as log_file:
513 log = log_file.read()
514 log_richlog = self.query_one(RichLog)
515 log_richlog.write(log)
516
517 def compose(self):
518 yield Footer()
519 yield Header(show_clock=True)
520 with Container():
521 yield RichLog()
522
523 def action_quit(self):
524 self.dismiss()
525
526
527class CreditsScreen(Screen):
528
529 DEFAULT_CSS = """
530 CreditsScreen {
531 align: center middle;
532 }
533
534 CreditsScreen > Center {
535 width: 90%;
536 height: 90%;
537 }
538
539 CreditsScreen > Center > Label {
540 align: center middle;
541 }
542 """
543
544 TITLE = "Item Management App"
545 SUB_TITLE = "Credits"
546 BINDINGS = [
547 Binding(key='a', action="new_item", description="New", key_display='A', show=False),
548 Binding(key='s', action="save_data", description="Save", key_display='S', show=False),
549 Binding(key='v', action="toggle_datatable_view", description="Data Table View", key_display='V', show=False),
550 Binding(key='c', action="change_password", description="Change Password", key_display='C', show=False),
551 Binding(key='l', action="read_log_file", description="Read Log File", key_display='L', show=False),
552 Binding(key='t', action="credits", description="Credits", key_display='T', show=False),
553 Binding(key='A', action="new_item", show=False),
554 Binding(key='S', action="save_data", show=False),
555 Binding(key='V', action="toggle_datatable_view", show=False),
556 Binding(key='C', action="change_password", show=False),
557 Binding(key='L', action="read_log_file", show=False),
558 Binding(key='T', action="credits", show=False),
559 Binding(key='q', action="quit", description="Quit", key_display='Q'),
560 Binding(key='Q', action="quit", show=False)
561 ]
562
563 banner = r"""
564 ___ _ __ __ _ _
565|_ _| |_ ___ _ __ ___ | \/ | __ _ _ __ __ _ __ _ ___ _ __ ___ ___ _ __ | |_ / \ _ __ _ __
566 | || __/ _ \ '_ ` _ \ | |\/| |/ _` | '_ \ / _` |/ _` |/ _ \ '_ ` _ \ / _ \ '_ \| __| / _ \ | '_ \| '_ \
567 | || || __/ | | | | | | | | | (_| | | | | (_| | (_| | __/ | | | | | __/ | | | |_ / ___ \| |_) | |_) |
568|___|\__\___|_| |_| |_| |_| |_|\__,_|_| |_|\__,_|\__, |\___|_| |_| |_|\___|_| |_|\__| /_/ \_\ .__/| .__/
569 |___/ |_| |_|
570
571
572
573
574 _ _ _ _____ _ _ ___ ____ ____
575 / \ | | | |_ _| | | |/ _ \| _ \/ ___| _
576 / _ \| | | | | | | |_| | | | | |_) \___ \ (_)
577 / ___ \ |_| | | | | _ | |_| | _ < ___) | _
578/_/ \_\___/ |_| |_| |_|\___/|_| \_\____/ (_)
579
580
581 _ _ ______
582 | | (_) |__ / |__ ___ _ __ __ _ _ _ __ _ ___
583 | | | | / /| '_ \ / _ \| '_ \ / _` | | | |/ _` |/ _ \
584 | |___| | / /_| | | | (_) | | | | (_| | |_| | (_| | (_) |
585 |_____|_| /____|_| |_|\___/|_| |_|\__, |\__, |\__,_|\___/
586 |___/ |___/
587
588
589 _ _ __ __ _
590 | | (_) \ \ / /__ _ __ ___| |__ ___
591 | | | | \ \ /\ / / _ \ '_ \|_ / '_ \ / _ \
592 | |___| | \ V V / __/ | | |/ /| | | | __/
593 |_____|_| \_/\_/ \___|_| |_/___|_| |_|\___|
594
595
596 _ _ _ _ _
597 | | (_) | | | | __ _ ___ __| | ___ _ __ __ _
598 | | | | | |_| |/ _` |/ _ \ / _` |/ _ \| '_ \ / _` |
599 | |___| | | _ | (_| | (_) | (_| | (_) | | | | (_| |
600 |_____|_| |_| |_|\__,_|\___/ \__,_|\___/|_| |_|\__, |
601 |___/
602 """
603
604 def compose(self):
605 yield Header(show_clock=True)
606 yield Footer()
607 with Center():
608 yield Label(self.banner)
609
610 def action_quit(self):
611 self.dismiss()
612
613
614class SourceCodeScreen(Screen):
615
616 DEFAULT_CSS = """
617 SourceCodeScreen {
618 align: center middle;
619 }
620
621 SourceCodeScreen > Container {
622 align: center middle;
623 height: auto;
624 width: 100%;
625 }
626
627 SourceCodeScreen > Container > RichLog {
628 width: auto;
629 }
630 """
631
632 BINDINGS = [
633 Binding(key='a', action="new_item", description="New", key_display='A', show=False),
634 Binding(key='s', action="save_data", description="Save", key_display='S', show=False),
635 Binding(key='v', action="toggle_datatable_view", description="Data Table View", key_display='V', show=False),
636 Binding(key='c', action="change_password", description="Change Password", key_display='C', show=False),
637 Binding(key='l', action="read_log_file", description="Read Log File", key_display='L', show=False),
638 Binding(key='t', action="credits", description="Credits", key_display='T', show=False),
639 Binding(key='A', action="new_item", show=False),
640 Binding(key='S', action="save_data", show=False),
641 Binding(key='V', action="toggle_datatable_view", show=False),
642 Binding(key='C', action="change_password", show=False),
643 Binding(key='L', action="read_log_file", show=False),
644 Binding(key='T', action="credits", show=False),
645 Binding(key='q', action="quit", description="Quit", key_display='Q'),
646 Binding(key='Q', action="quit", show=False)
647 ]
648
649 def __init__(self, file_size):
650 super().__init__()
651 self.file_size = file_size
652
653 def on_mount(self):
654 with open("ItemManagementApp.py", 'r', newline="") as code_file:
655 code = code_file.read()
656 code_richlog = self.query_one(RichLog)
657 code_richlog.write(Syntax(code, "python", indent_guides=True, code_width=160, line_numbers=True), scroll_end=True)
658
659 def compose(self):
660 yield Header(show_clock=True)
661 yield Footer()
662 with Container():
663 yield RichLog(highlight=True, markup=True)
664 yield Label(f"Total {self.file_size} KiB.")
665
666 def action_quit(self):
667 self.dismiss()
668
669
670class GDNModalScreen(ModalScreen):
671
672 DEFAULT_CSS = """
673 GDNModalScreen {
674 align: center middle;
675 }
676
677 GDNModalScreen > Container {
678 border: thick $background;
679 background: $boost;
680 width: 25%;
681 height: 25%;
682 }
683
684 GDNModalScreen > Container > Label {
685 margin: 1
686 }
687
688 GDNModalScreen > Container > #url {
689 background: pink;
690 }
691
692 GDNModalScreen > Container > Button {
693 width: 100%;
694 height: auto;
695 dock: bottom;
696 margin: 1;
697 }
698 """
699
700 def compose(self):
701 with Container():
702 yield Label("Please visit my blog at")
703 yield Label("https://jackgdn.github.io", id="url")
704 yield Button("OK!", variant="success")
705
706 def on_button_pressed(self):
707 self.dismiss()
708
709
710class ItemWidget(Widget):
711
712 DEFAULT_CSS = """
713 ItemWidget {
714 align: center middle;
715 height: 3;
716 margin: 1;
717 }
718 """
719
720 name = reactive("")
721 unit_price = reactive("")
722 quantity = reactive("")
723 section = reactive("")
724 id_number = reactive("")
725
726 class Edit(Message):
727 def __init__(self, item):
728 super().__init__()
729 self.item = item
730
731 class Delete(Message):
732 def __init__(self, item):
733 super().__init__()
734 self.item = item
735
736 def __init__(self):
737 super().__init__()
738 self.name_label = Label(id="name")
739 self.unit_price_label = Label(id="unit_price")
740 self.quantity_label = Label(id="quantity")
741 self.section_label = Label(id="section")
742 self.id_number_label = Label(id="ID")
743
744 def compose(self):
745 with Horizontal():
746 yield Button("Edit", id="edit", variant="success")
747 yield Label(" ")
748 yield Button("Delete", id="delete", variant="error")
749 yield Label(" ")
750 with Container():
751 yield Label(Text("Name: ", style="italic"))
752 yield self.name_label
753 with Container():
754 yield Label(Text("Unit Price: ", style="italic"))
755 yield self.unit_price_label
756 with Container():
757 yield Label(Text("Qty.: ", style="italic"))
758 yield self.quantity_label
759 with Container():
760 yield Label(Text("Section: ", style="italic"))
761 yield self.section_label
762 with Container():
763 yield Label(Text("ID: ", style="italic"))
764 yield self.id_number_label
765
766 def watch_name(self, name):
767 self.name_label.update(name)
768
769 def watch_unit_price(self, unit_price):
770 self.unit_price_label.update(unit_price)
771
772 def watch_quantity(self, quantity):
773 self.quantity_label.update(quantity)
774
775 def watch_section(self, section):
776 try:
777 self.section_label.update(section)
778
779 except Exception:
780 self.post_message(self.Delete(self))
781
782 def watch_id_number(self, id_number):
783 self.id_number_label.update(id_number)
784
785 @on(Button.Pressed, "#edit")
786 def edit_request(self):
787 self.post_message(self.Edit(self))
788
789 @on(Button.Pressed, "#delete")
790 def delete_request(self):
791 self.post_message(self.Delete(self))
792
793
794class CommandProvider(Provider):
795
796 async def search(self, query):
797 app = self.app
798 commands = {
799 "Save Data": ("Save data NOW in case you forget to do so.", app.action_save_data),
800 "Change Password": ("Change your password as you want.", app.action_change_password),
801 "Toggle Light/Dark Mode": ("Your eyes are valuable.", app.action_toggle_light_dark_mode),
802 "Exit Program": ("Say goodbye to IMA.", app.exit),
803 "View Source Code": ("Incredible!", app.view_source_code),
804 "Visit Author's Blog": ("Welcome to visit author's blog @ https://jackgdn.github.io", app.visit_my_blog)
805 }
806
807 matcher = self.matcher(query)
808 for command in list(commands.keys()):
809 score = matcher.match(command)
810 if score > 0:
811 yield Hit(
812 score,
813 matcher.highlight(command),
814 commands[command][1],
815 help=commands[command][0]
816 )
817
818
819class ExceptionAppCommandProvider(Provider):
820
821 async def search(self, query):
822 app = self.app
823 commands = {
824 "Toggle Light/Dark Mode": ("Your eyes are valuable.", app.action_toggle_light_dark_mode),
825 "Exit Program": ("Say goodbye to IMA.", app.exit),
826 "View Source Code": ("Incredible!", app.view_source_code),
827 "Visit Author's Blog": ("Welcome to visit author's blog @ https://jackgdn.github.io", app.visit_my_blog)
828 }
829
830 matcher = self.matcher(query)
831 for command in list(commands.keys()):
832 score = matcher.match(command)
833 if score > 0:
834 yield Hit(
835 score,
836 matcher.highlight(command),
837 commands[command][1],
838 help=commands[command][0]
839 )
840
841
842class ItemApp(App):
843
844 COMMANDS = {CommandProvider}
845 BINDINGS = [
846 Binding(key='a', action="new_item", description="New", key_display='A'),
847 Binding(key='s', action="save_data", description="Save", key_display='S'),
848 Binding(key='v', action="toggle_datatable_view", description="Data Table View", key_display='V'),
849 Binding(key='c', action="change_password", description="Change Password", key_display='C'),
850 Binding(key='l', action="read_log_file", description="Read Log File", key_display='L'),
851 Binding(key='d', action="toggle_light_dark_mode", description="Light/Dark Mode", key_display='D'),
852 Binding(key='t', action="credits", description="Credits", key_display='T'),
853 Binding(key='A', action="new_item", show=False),
854 Binding(key='S', action="save_data", show=False),
855 Binding(key='V', action="toggle_datatable_view", show=False),
856 Binding(key='C', action="change_password", show=False),
857 Binding(key='L', action="read_log_file", show=False),
858 Binding(key='D', action="toggle_light_dark_mode", show=False),
859 Binding(key='T', action="credits", show=False),
860 Binding(key="CTRL+C", action="exit_program", description="Exit", key_display="^C")
861 ]
862
863 @work
864 async def on_mount(self):
865 if getsize("logininfo") < 1:
866 if await self.push_screen_wait(LoginModalScreen(LoginStatus.FIRST_TIME_LOGIN)):
867 self.load_data()
868 else:
869 self.exit()
870 else:
871 if await self.push_screen_wait(LoginModalScreen(LoginStatus.REQUEST_LOGIN)):
872 self.load_data()
873 else:
874 self.exit()
875
876 def compose(self):
877 yield Header(show_clock=True)
878 yield Footer()
879
880 def action_new_item(self):
881 self.push_screen(ItemModalScreen(), self.new_item_callback)
882
883 def action_save_data(self):
884 data_dump = [("Name", "Unit Price", "Quantity", "Section", "ID")] + \
885 [(item.name, item.unit_price, item.quantity, item.section, item.id_number) for item in self.query(ItemWidget)]
886 try:
887 with open("data.tsv", 'w', newline="") as data:
888 writer = csv.writer(data, delimiter='\t')
889 for row in data_dump:
890 writer.writerow(row)
891 self.push_screen(AlertModalScreen("File saved!"))
892 with open("IMA.log", 'a', newline="") as logfile:
893 logfile.write(f"Save data at {asctime()}\n")
894
895 except Exception:
896 self.push_screen(AlertModalScreen("Failed to save data."))
897
898 @work(thread=True)
899 def load_data(self):
900 self.file_size = getsize("ItemManagementApp.py")
901 try:
902 data_load = []
903 with open("data.tsv", 'r', newline="") as data:
904 reader = csv.reader(data, delimiter='\t')
905 for row in reader:
906 data_load.append(row)
907 data_load = data_load[1:]
908 for name, unit_price, quantity, section, id_number in data_load:
909 item = ItemWidget()
910 item.name = name
911 item.unit_price = unit_price
912 item.quantity = quantity
913 item.section = section
914 item.id_number = id_number
915 self.call_from_thread(self.mount, item)
916
917 except Exception:
918 self.push_screen(AlertModalScreen("Fialed to load data."))
919
920 def action_toggle_datatable_view(self):
921 data_dump = [("Name", "Unit Price", "Quantity", "Section", "ID")] + \
922 [(item.name, item.unit_price, item.quantity, item.section, item.id_number) for item in self.query(ItemWidget)]
923 self.push_screen(DataTableScreen(data_dump))
924
925 def action_change_password(self):
926 self.push_screen(LoginModalScreen(LoginStatus.REQUEST_CHANGE_PASSWORD))
927
928 def action_read_log_file(self):
929 self.push_screen(LogScreen())
930
931 def action_credits(self):
932 self.push_screen(CreditsScreen())
933
934 def action_toggle_light_dark_mode(self):
935 self.dark = not self.dark
936
937 def action_exit_program(self):
938 self.exit()
939
940 def new_item_callback(self, data):
941 item = ItemWidget()
942 name, unit_price, quantity, section, id_number = data
943 item.name = name
944 item.unit_price = unit_price
945 item.quantity = quantity
946 item.section = section
947 item.id_number = id_number
948 self.mount(item)
949
950 def edit_item_callback(self, item, data):
951 name, unit_price, quantity, section, id_number = data
952 item.name = name
953 item.unit_price = unit_price
954 item.quantity = quantity
955 item.section = section
956 item.id_number = id_number
957
958 def on_item_widget_edit(self, message):
959 self.push_screen(ItemModalScreen(), partial(self.edit_item_callback, message.item))
960
961 def on_item_widget_delete(self, message):
962 message.item.remove()
963
964 def view_source_code(self):
965 self.push_screen(SourceCodeScreen(round(int(self.file_size) / 1024, 2)))
966
967 def visit_my_blog(self):
968 self.push_screen(GDNModalScreen())
969
970
971class ExceptionApp(App):
972
973 COMMANDS = {ExceptionAppCommandProvider}
974 BINDINGS = [
975 Binding(key='d', action="toggle_light_dark_mode", description="Light/Dark Mode", key_display='D'),
976 Binding(key='t', action="credits", description="Credits", key_display="T"),
977 Binding(key="CTRL+C", action="exit_program", description="Exit", key_display="^C"),
978 Binding(key='V', action="toggle_datatable_view", show=False),
979 Binding(key='T', action="credits", show=False)
980 ]
981
982 def on_mount(self):
983 self.file_size = getsize("ItemManagementApp.py")
984 self.push_screen(AlertModalScreen("Failed to load data. Please check if you have permission."))
985
986 def compose(self):
987 yield Header(show_clock=True)
988 yield Footer()
989
990 def action_toggle_light_dark_mode(self):
991 self.dark = not self.dark
992
993 def action_credits(self):
994 self.push_screen(CreditsScreen())
995
996 def action_exit_program(self):
997 self.exit()
998
999 def view_source_code(self):
1000 self.push_screen(SourceCodeScreen(round(int(self.file_size) / 1024, 2)))
1001
1002 def visit_my_blog(self):
1003 self.push_screen(GDNModalScreen())
1004
1005
1006def main():
1007 try:
1008 if not exists("data.tsv"):
1009 with open("data.tsv", 'a', newline="") as _:
1010 pass
1011 chmod("data.tsv", 0o600)
1012 if not exists("logininfo"):
1013 with open("logininfo", 'a', newline="") as _:
1014 pass
1015 chmod("logininfo", 0o600)
1016 if not exists("IMA.log"):
1017 with open("IMA.log", 'a', newline="") as _:
1018 pass
1019 chmod("IMA.log", 0o600)
1020 chmod("ItemManagementApp.py", 0o644)
1021 app = ItemApp()
1022 app.title = "Item Management App"
1023 app.run()
1024
1025 except Exception:
1026 app = ExceptionApp()
1027 app.title = "Item Management App"
1028 app.sub_title = "Encountered Errors"
1029 app.run()
1030
1031
1032if __name__ == '__main__':
1033 main()
代码解读
类 | 功能 |
---|---|
LoginStatus |
存储登录状态以及不同登录状态下 LoginModalScreen 显示内容的常量。 |
AlertModalScreen |
显示弹窗,接收一个参数 alert_message 存储显示的消息。 |
ItemModalScreen |
添加或修改商品信息的界面。 |
LoginModalScreen |
注册、登录及修改密码共同使用的界面。显示的提示信息存储在 LoginStatus 类中。 |
DataTableScreen |
将数据以表格视图显示,可以以不同列为依据对数据排序。 |
LogScreen |
日志界面。 |
CreditsScreen |
作者名单。 |
ItemWidget |
在主界面展示商品信息的组件。 |
CommandProvider |
存储命令托盘中的命令,允许用户搜索命令。 |
ExceptionAppCommandProvider |
ExceptionApp 下的命令托盘。 |
ItemApp |
程序的主界面,调控整个程序运行的核心。 |
ExceptionApp |
在程序缺少文件读写权限时运行,用于提醒用户权限不足并拒绝操作。 |
问题及未来改进
- 完善功能,尤其是数据处理方面的功能。作者在学习相应内容以及编写程序时时侧重于对该 TUI 模块的运用而非对数据的处理上,因此程序只有最基本的处理数据的功能,而更复杂的数据处理功能也因为时间问题和作者自身能力问题没有被实现。
- 界面及代码优化。由于作者能力有限以及
Textual
模块自身的不完善,有些代码显得格外冗余(例如Binding()
的重复使用。在作者完成代码编写时,这样重复仍然是修改不同Screen
中Footer
组件所对应BINDINGS
的唯一方法),也有些功能没有实现(例如作者曾尝试在登录失败时弹出一个AlertModalScreen
但是ModalScreen
类中并没有push_screen()
方法;尝试使用回调函数实现的时候,ItemApp
类的compose
方法定义为了一个异步方法,又由于作者对多线程编程了解甚少,最终的尝试也以失败告终)。未来作者希望通过学习消除这些遗憾。 - 增加多用户模式。目前程序只允许一位用户使用,在增加多用户模式会使程序的演示效果更强,这需要添加权限管理模块以及数据存储模式、日志记录模式和登录逻辑的全面重写。
- 添加自动保存功能。其实这个功能并不复杂,只需在用户每次执行完增加、修改、删除操作后将文件存储到一个 autosave 文件中去。但是这样做,日志的作用就会大大降低,并且在
ItemApp
类的异步方法compose()
中重复调用push_screen_wait()
方法会使程序出现意想不到的 bug(只有在将compose()
方法声明为一个异步方法才能够调用push_screen_wait()
方法,这些 bug 也许是因为作者对多线程的了解过于浅薄导致的)。 - 优化项目管理措施。就本项目来说,毕竟这是一项作业,将所有代码都放在一个脚本中无可厚非。但是在实际生产环境中,这样做有悖于模块化编程的原则,是一种极其愚蠢的做法:此行为不利于后期代码维护。当作者把代码写到 600 行左右(作者甚至把控制样式的 CSS 塞了进去,然而这完全没有必要,还会显得臃肿)时就已经感受到这个问题了,当作者需要添加或者修改某一个功能时,就要在文件中不停上下翻找。虽然 VSCode 可以拆分编辑器,但是为程序添加一个功能可能需要对多个模块进行修改(例如添加日志记录功能时,数据读写、登录等模块的代码都需要做出修改),依然会降低效率。这样做对于 debug 和程序出现问题后版本回溯也极不友好。
相关链接
- Python3 Tetxual
>>
textual · PyPI - Textual 文档
>>
Textual - Textual 讲解视频
>>
Textualize - Youtube - Pandoc
>>
Pandoc - index