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)中查找并执行。
  • 显示时间。

程序运行截图

首次运行时用户注册界面

用户登录界面

修改密码界面

权限不足

创建条目界面

程序主界面

表格视图下以商品数量作为排序依据

命令托盘

日志界面

白天模式下的作者界面

日志文件 IMA.log、数据文件 data.tsv、密码存储文件 logininfo 权限均为 600

程序代码

   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 在程序缺少文件读写权限时运行,用于提醒用户权限不足并拒绝操作。

问题及未来改进

  1. 完善功能,尤其是数据处理方面的功能。作者在学习相应内容以及编写程序时时侧重于对该 TUI 模块的运用而非对数据的处理上,因此程序只有最基本的处理数据的功能,而更复杂的数据处理功能也因为时间问题和作者自身能力问题没有被实现。
  2. 界面及代码优化。由于作者能力有限以及 Textual 模块自身的不完善,有些代码显得格外冗余(例如 Binding() 的重复使用。在作者完成代码编写时,这样重复仍然是修改不同 ScreenFooter 组件所对应 BINDINGS唯一方法),也有些功能没有实现(例如作者曾尝试在登录失败时弹出一个 AlertModalScreen 但是 ModalScreen 类中并没有 push_screen() 方法;尝试使用回调函数实现的时候,ItemApp 类的 compose 方法定义为了一个异步方法,又由于作者对多线程编程了解甚少,最终的尝试也以失败告终)。未来作者希望通过学习消除这些遗憾。
  3. 增加多用户模式。目前程序只允许一位用户使用,在增加多用户模式会使程序的演示效果更强,这需要添加权限管理模块以及数据存储模式、日志记录模式和登录逻辑的全面重写。
  4. 添加自动保存功能。其实这个功能并不复杂,只需在用户每次执行完增加、修改、删除操作后将文件存储到一个 autosave 文件中去。但是这样做,日志的作用就会大大降低,并且在 ItemApp 类的异步方法 compose() 中重复调用 push_screen_wait() 方法会使程序出现意想不到的 bug(只有在将 compose() 方法声明为一个异步方法才能够调用 push_screen_wait() 方法,这些 bug 也许是因为作者对多线程的了解过于浅薄导致的)。
  5. 优化项目管理措施。就本项目来说,毕竟这是一项作业,将所有代码都放在一个脚本中无可厚非。但是在实际生产环境中,这样做有悖于模块化编程的原则,是一种极其愚蠢的做法:此行为不利于后期代码维护。当作者把代码写到 600 行左右(作者甚至把控制样式的 CSS 塞了进去,然而这完全没有必要,还会显得臃肿)时就已经感受到这个问题了,当作者需要添加或者修改某一个功能时,就要在文件中不停上下翻找。虽然 VSCode 可以拆分编辑器,但是为程序添加一个功能可能需要对多个模块进行修改(例如添加日志记录功能时,数据读写、登录等模块的代码都需要做出修改),依然会降低效率。这样做对于 debug 和程序出现问题后版本回溯也极不友好。

相关链接