برنامه‌نویسی پایتون فهرست مطالب
فصل ۳۹

پروژه پایانی، Git و GitHub

همه دوره را در یک برنامه جمع می‌کنیم: یک نرم‌افزار دسکتاپ با پایگاه داده واقعی، معماری تمیز و ساختار پوشه‌بندی درست. و بعد یاد می‌گیریم آن را زیر کنترل نسخه ببریم و منتشرش کنیم.

در این فصل می‌خوانید
  1. معماری پروژه
  2. مسیرها
  3. مدل‌ها
  4. پایگاه داده و تراکنش
  5. لایه سرویس
  6. امانت دادن
  7. آمار
  8. لایه گرافیکی
  9. پنجره اصلی
  10. اجرا و بسته‌بندی
  11. Git: چرا و چطور
  12. gitignore پیش از اولین کامیت
  13. شاخه و تعارض
  14. GitHub و README

۱. معماری پروژه

library/
├── main.py                 starts the program, and nothing else
├── core/                   *** imports no PySide6 at all ***
│   ├── paths.py            where the files are (works frozen too)
│   ├── models.py           Book, Member, Borrow
│   ├── database.py         the engine, the session, session_scope()
│   └── services.py         the RULES of the library
├── gui/                    *** contains no SQL at all ***
│   ├── ui_loader.py        loading a .ui file, once
│   ├── dialogs.py          the three modal forms
│   └── main_window.py      the four tabs
└── ui/                     what Qt Designer produced
    ├── book_dialog.ui
    ├── member_dialog.ui
    └── lend_dialog.ui
تنها ایده‌ای که باید از این فصل ببرید

پوشه core/ هیچ چیز درباره پنجره نمی‌داند، و پوشه gui/ هیچ چیز درباره SQL. قانون «یک عضو نمی‌تواند بیش از پنج کتاب داشته باشد» یک بار نوشته شده است، در core/services.py. دکمه صدایش می‌زند. صفحه Flask فردا هم همان تابع را صدا می‌زند، و این دو هرگز نمی‌توانند با هم اختلاف پیدا کنند.

به همین دلیل است که سرویس‌ها به جای نشان دادن پنجره پیام، LibraryError پرتاب می‌کنند: core/ مسئله را بیان می‌کند و gui/ تصمیم می‌گیرد چطور نشانش دهد.

زبانهکارها
کتاب‌هاجست‌وجوی زنده، افزودن، ویرایش، حذف (تا وقتی کتاب بیرون است رد می‌شود)
اعضاجست‌وجو، افزودن، ویرایش، حذف (تا وقتی کتاب در دست دارند رد می‌شود)
امانت‌هاامانت دادن، پس گرفتن، تمدید؛ امانت دیرکرددار قرمز
آمارجمع‌ها و پنج عنوان پرامانت‌تر

۲. مسیرها

'''Where the files of the application live.

Every path in the program goes through this module, so there is exactly one
place to change when the layout moves -- and exactly one place that knows
about PyInstaller (session 36).

    resource_path()  what we READ and ship with the program (the .ui files)
    data_dir()       what we WRITE, and that must survive a restart
'''

def project_root() -> Path:
    '''The folder that holds `main.py` (or the .exe once frozen).'''
    if is_frozen():
        return Path(sys.executable).resolve().parent
    # paths.py -> core -> library
    return Path(__file__).resolve().parent.parent


def resource_path(relative: str) -> Path:
    '''A file that was SHIPPED with the program (read-only).'''
    if is_frozen() and hasattr(sys, '_MEIPASS'):
        # --add-data puts the files here (session 36).
        return Path(sys._MEIPASS) / relative
    return project_root() / relative


def data_dir() -> Path:
    '''Where the program WRITES. Created if it does not exist yet.'''
    folder = project_root() / 'data'
    folder.mkdir(parents=True, exist_ok=True)
    return folder


def database_path() -> Path:
    return data_dir() / 'library.db'

مسیرها: library/core/paths.py

این همان درس فصل سی و ششم است، ولی این بار از روز اول در معماری پروژه نشسته: یک ماژول که تنها جای آگاه از PyInstaller است. نتیجه‌اش این است که تبدیل برنامه به فایل اجرایی، هیچ جای دیگری از کد را دست نمی‌زند.

۳. مدل‌ها

'''The tables of the library, as SQLAlchemy 2.0 models.

    Member  1 --- n  Borrow  n --- 1  Book

A `Borrow` row is one loan. `return_date IS NULL` means the book is still out,
which is why we do not need a duplicated "available" flag on `Book`: the
answer is always one query away, and one fact stored in one place cannot
disagree with itself.

(Session 35 did keep such a flag, and its bug was exactly that: two places to
update, one transaction to get wrong.)
'''

class Book(Base):
    __tablename__ = 'books'

    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str] = mapped_column(String(160), nullable=False)
    author: Mapped[str] = mapped_column(String(120), default='')
    year: Mapped[int | None] = mapped_column(Integer, nullable=True)
    created_at: Mapped[datetime] = mapped_column(DateTime,
                                                 server_default=func.now())

    # cascade: deleting a book deletes its loan history with it.
    borrows: Mapped[list['Borrow']] = relationship(
        back_populates='book', cascade='all, delete-orphan')

    @property
    def is_out(self) -> bool:
        '''True while one of the loans has no return date.'''
        return any(borrow.return_date is None for borrow in self.borrows)

    def __str__(self) -> str:
        # __str__ must return a STRING. The original returned `self.member`,
        # an object, which raised TypeError the first time it was printed.
        return f'{self.title} ({self.author})' if self.author else self.title

مدل کتاب: library/core/models.py

class Borrow(Base):
    __tablename__ = 'borrows'

    id: Mapped[int] = mapped_column(primary_key=True)
    # ondelete=CASCADE is the rule inside the DATABASE; the `cascade=` above
    # is the rule inside SQLAlchemy. You want both, and SQLite only obeys the
    # first one when `PRAGMA foreign_keys = ON` is set (see database.py).
    book_id: Mapped[int] = mapped_column(
        ForeignKey('books.id', ondelete='CASCADE'), nullable=False)
    member_id: Mapped[int] = mapped_column(
        ForeignKey('members.id', ondelete='CASCADE'), nullable=False)

    # datetime.now, not datetime.utcnow: utcnow is deprecated since 3.12.
    borrow_date: Mapped[datetime] = mapped_column(DateTime,
                                                  default=datetime.now)
    due_date: Mapped[datetime] = mapped_column(DateTime, nullable=False)
    return_date: Mapped[datetime | None] = mapped_column(DateTime,
                                                         nullable=True)

    @property
    def is_late(self) -> bool:
        '''Out, and past the due date.'''
        return self.is_open and datetime.now() > self.due_date

مدل امانت

شرح مثال

۴. پایگاه داده و تراکنش

ENGINE = create_engine(f'sqlite:///{database_path()}', echo=False,
                       future=True)

# expire_on_commit=False is important here. By default, `commit()` marks every
# loaded attribute as "must be read again", and reading one after the session
# is closed raises DetachedInstanceError. The GUI keeps the objects it showed
# (to know which row is selected), so we need their values to stay usable
# after the transaction ends.
SessionLocal = sessionmaker(bind=ENGINE, autoflush=False, autocommit=False,
                            expire_on_commit=False, future=True)


@event.listens_for(ENGINE, 'connect')
def _enable_foreign_keys(dbapi_connection, connection_record) -> None:
    '''SQLite needs this on every connection, or ON DELETE does nothing.'''
    cursor = dbapi_connection.cursor()
    cursor.execute('PRAGMA foreign_keys=ON')
    cursor.close()


@contextmanager
def session_scope() -> Iterator[Session]:
    '''One transaction: commit on success, roll back on any exception.'''
    session = SessionLocal()
    try:
        yield session
        session.commit()
    except Exception:
        session.rollback()
        raise               # the caller still has to know
    finally:
        session.close()

موتور و تراکنش: library/core/database.py

شرح مثال

۵. لایه سرویس

'''The rules of the library.

Every operation the program can perform is a function here. The windows call
these functions and show what they return; they never write a query and they
never decide a rule.

Why that matters:

* the rule "a member may not have more than five books" is written ONCE, so
  the button and the future web page cannot disagree;
* this file can be read, reviewed and tested without starting a window;
* an operation that must not be interrupted (lending: two rows to change) is a
  single function with a single transaction.
'''

LOAN_DAYS = 14              # how long a book may be kept
MAX_OPEN_LOANS = 5          # how many books one member may hold at once


class LibraryError(Exception):
    '''A rule of the library was broken. The message is shown to the user.'''


def list_books(session: Session, search: str = '') -> list[Book]:
    '''Every book. `selectinload` fetches the loans in one extra query
    instead of one per book (the "N+1 queries" problem).'''
    query = select(Book).options(selectinload(Book.borrows)).order_by(Book.title)

    if search:
        pattern = f'%{search}%'
        # The % is inside a PARAMETER: no SQL injection (session 30).
        query = query.where(or_(Book.title.ilike(pattern),
                                Book.author.ilike(pattern),
                                Book.isbn.ilike(pattern)))

    return list(session.scalars(query))


def delete_book(session: Session, book: Book) -> None:
    if book.is_out:
        raise LibraryError('This book is lent out. Take it back first.')
    session.delete(book)

لایه سرویس: library/core/services.py

شرح مثال

۶. امانت دادن

این مهم‌ترین تابع کل پروژه است، چون همه قوانین یک امانت را یک‌جا و در یک تراکنش بررسی می‌کند.

def lend_book(session: Session, book: Book, member: Member,
              days: int = LOAN_DAYS) -> Borrow:
    '''Lend one book to one member. Every rule of a loan is checked here.'''
    # Ask the database, not the object: another window may have lent it
    # a second ago.
    already_out = session.scalar(
        select(Borrow).where(Borrow.book_id == book.id,
                             Borrow.return_date.is_(None)))
    if already_out is not None:
        raise LibraryError(f'"{book.title}" is already lent out.')

    open_count = session.scalar(
        select(func.count(Borrow.id))
        .where(Borrow.member_id == member.id, Borrow.return_date.is_(None)))
    if open_count and open_count >= MAX_OPEN_LOANS:
        raise LibraryError(f'{member.full_name} already has '
                           f'{MAX_OPEN_LOANS} books.')

    now = datetime.now()
    borrow = Borrow(book_id=book.id, member_id=member.id,
                    borrow_date=now, due_date=now + timedelta(days=days))
    session.add(borrow)
    session.flush()
    return borrow

امانت دادن: library/core/services.py

شرح مثال

۷. آمار

def statistics(session: Session) -> dict:
    '''One dictionary with everything the statistics tab shows.'''
    total_books = session.scalar(select(func.count(Book.id))) or 0
    total_members = session.scalar(select(func.count(Member.id))) or 0
    out = session.scalar(
        select(func.count(Borrow.id)).where(Borrow.return_date.is_(None))) or 0
    late = session.scalar(
        select(func.count(Borrow.id))
        .where(Borrow.return_date.is_(None),
               Borrow.due_date < datetime.now())) or 0

    # The five most borrowed titles: GROUP BY + ORDER BY (session 30).
    popular = session.execute(
        select(Book.title, func.count(Borrow.id).label('times'))
        .join(Borrow, Borrow.book_id == Book.id)
        .group_by(Book.id)
        .order_by(func.count(Borrow.id).desc())
        .limit(5)).all()

    return {'books': total_books,
            'members': total_members,
            'on_loan': out,
            'available': total_books - out,
            'late': late,
            'popular': [(title, times) for title, times in popular]}

آمار: library/core/services.py

یک تابع، یک دیکشنری. زبانه آمار فقط همین دیکشنری را می‌گیرد و می‌نویسدش. اگر فردا بخواهید همین اعداد را در یک صفحه وب یا در یک گزارش PDF نشان دهید، همین تابع کافی است. GROUP BY و ORDER BY و JOIN هم همان چیزهایی هستند که در فصل سی‌ام با SQL خام نوشتید.

۸. لایه گرافیکی

'''Loading the Qt Designer files -- written once, used by every dialog.

The first version of this course repeated the same fifteen lines of
`QUiLoader` code at the top of nine files. It is one function.
'''

def load_ui(file_name: str) -> QWidget:
    '''Load one of the files of `ui/` and return the widget it describes.'''
    path = ui_path(file_name)
    if not path.exists():
        raise FileNotFoundError(
            f'{path} is missing.\n'
            'When the program is frozen, the ui/ folder must be shipped with '
            'it:  pyinstaller --add-data "ui;ui" ...')

    widget = QUiLoader().load(str(path))
    if widget is None:
        raise RuntimeError(f'Qt could not read {path} (invalid XML?)')
    return widget


def find(parent: QWidget, widget_type: type[WidgetType],
         name: str) -> WidgetType:
    '''findChild that complains at once instead of returning None.'''
    widget = parent.findChild(widget_type, name)
    if widget is None:
        raise RuntimeError(f'No {widget_type.__name__} named {name!r} in the '
                           'ui file -- check the objectName in Qt Designer.')
    return widget

بارگذار: library/gui/ui_loader.py

class BaseDialog(QDialog):
    '''A QDialog whose content comes from a .ui file.'''

    UI_FILE = ''

    def __init__(self, parent: QWidget | None, title: str) -> None:
        super().__init__(parent)
        self.setWindowTitle(title)
        self.ui = load_ui(self.UI_FILE)
        ...
        self.buttons = find(self.ui, QDialogButtonBox, 'buttonBox')
        # `accepted` is emitted by Save/OK, `rejected` by Cancel.
        self.buttons.accepted.connect(self.on_accept)
        self.buttons.rejected.connect(self.reject)

        self.data: dict = {}

    def on_accept(self) -> None:
        '''Validate; call `self.accept()` only when everything is right.'''
        errors = self.collect()
        if errors:
            QMessageBox.warning(self, 'Please check the form',
                                '\n'.join(f'- {message}' for message in errors))
            return
        self.accept()

    def collect(self) -> list[str]:
        '''Fill `self.data` and return the list of problems.'''
        raise NotImplementedError

کلاس پایه دیالوگ‌ها: library/gui/dialogs.py

شرح مثال

۹. پنجره اصلی

'''The main window: four tabs, three tables, and not one line of SQL.

The two habits that keep this file honest:

1. Every screen is rebuilt by a `refresh_*` method, and an action calls
   `refresh_all()`. Trying to patch one row of a table by hand is how a
   display starts disagreeing with the database.
2. The table is a DISPLAY. The real row is remembered in
   `self.book_rows[table_row]`, never read back out of the widget. (That was
   the bug of session 35: a Treeview handed back the word "yes" for a number.)
'''

    def refresh_books(self) -> None:
        search = self.book_search.text().strip()
        self.books_table.setSortingEnabled(False)   # sorting while filling
        self.books_table.setRowCount(0)             # scrambles the rows
        self.book_rows.clear()

        with session_scope() as session:
            for row, book in enumerate(services.list_books(session, search)):
                self.books_table.insertRow(row)
                fill_row(self.books_table, row,
                         [book.id, book.title, book.author or '',
                          book.publisher or '', book.year or '',
                          'out' if book.is_out else 'on the shelf'])
                self.book_rows[row] = book

        self.books_table.setSortingEnabled(True)

بازسازی جدول: library/gui/main_window.py

    def delete_book(self) -> None:
        book = self.selected_book()
        if book is None:
            return
        if QMessageBox.question(
                self, 'Confirm',
                f'Delete "{book.title}"?\nIts loan history goes with it.'
        ) != QMessageBox.StandardButton.Yes:
            return
        try:
            with session_scope() as session:
                fresh = session.get(Book, book.id)
                if fresh is not None:
                    services.delete_book(session, fresh)
        except LibraryError as error:
            QMessageBox.warning(self, 'Cannot delete', str(error))
            return
        self.refresh_all()
        self.statusBar().showMessage('The book was deleted.')

یک عمل کامل، از دکمه تا پایگاه داده

شرح مثال

۱۰. اجرا و بسته‌بندی

def main() -> int:
    app = QApplication(sys.argv)

    try:
        init_database()
        with session_scope() as session:
            add_sample_data(session)
    except Exception as error:
        # A --windowed build has no console, so a start-up failure must be
        # shown in a box or nobody will ever see it (session 36).
        QMessageBox.critical(None, 'The database could not be opened',
                             f'{type(error).__name__}: {error}')
        return 1

    window = MainWindow()
    window.show()
    return app.exec()


if __name__ == '__main__':
    sys.exit(main())

نقطه شروع: library/main.py

python -m venv .venv
.venv\Scripts\activate              # Windows
pip install -r requirements.txt

cd library
python main.py
cd library
pyinstaller --onefile --windowed --add-data "ui;ui" --name kadoos-library main.py

گزینه --add-data "ui;ui" حیاتی است: بدون آن، QUiLoader داخل فایل اجرایی هیچ فایل .uiای پیدا نمی‌کند. ماژول core/paths.py از قبل در sys._MEIPASS جست‌وجو می‌کند و پایگاه داده را کنار خود فایل اجرایی می‌نویسد.

فصلکجای پروژه دیده می‌شود
۱۱ تا ۱۳ توابعهر سرویس یک تابع کوچک و نام‌دار است
۱۴ و ۱۵ ساختار دادهstatistics() یک دیکشنری برمی‌گرداند
۱۷ ماژول و بستهcore/ و gui/ و __init__.py
۱۹ استثناهاLibraryError و جایی که گرفته می‌شود
۲۱ تا ۲۴ شیءگراییمدل‌ها، کلاس پایه دیالوگ، @property، __str__
۲۵ و ۲۶ کد پایتونیجامعیت‌ها، @contextmanager، @property
۳۰ تا ۳۲ پایگاه داده و ORMمدل‌ها، پرس‌وجوها، تراکنش‌ها
۳۳ الگوهای طراحیلایه سرویس، و session_scope
۳۶ Qt و بسته‌بندیدیالوگ‌ها، paths.py، فرمان PyInstaller

۱۱. Git: چرا و چطور

نیمه دوم این جلسه، ابزاری است که هر پروژه‌ای از خط اولش به آن نیاز دارد. چرا؟ چون app_final.py و app_final2.py و app_final_REAL.py و app.sync-conflict-20260717-161647-AGS7SKO.py راهی برای نگه داشتن تاریخچه نیستند؛ و هر چهار فایل واقعاً در پوشه‌ای که این پروژه از آن بازنویسی شد وجود داشتند.

Git به چهار سؤال جواب می‌دهد که یک پوشه ساده نمی‌تواند:

git --version                 # is it there?

git config --global user.name  "Your Name"
git config --global user.email "you@example.com"
git config --global init.defaultBranch main

نام و ایمیل در هر کامیتی که می‌سازید نوشته می‌شوند، پس این کار را پیش از اولین کامیت انجام دهید.

   working folder  --git add-->  staging area  --git commit-->  history
    (your edits)                  (what will                    (permanent)
                                   go in next)
git status                    # where is everything? (use it constantly)
git add file.py               # stage one file
git add .                     # stage everything (look at status first!)
git commit -m "Add the lending rules"
git log --oneline --graph     # the history
git diff                      # what I changed and have not staged
git diff --staged             # what is about to be committed

پیام کامیت

۱۲. gitignore پیش از اولین کامیت

ترتیب مهم است

فایل .gitignore را پیش از اولین کامیت بنویسید. فایلی که یک بار کامیت شده باشد، با اضافه کردن نامش به .gitignore حذف نمی‌شود.

هرگز کامیت نکنیدچرا
.venv/صدها مگابایت که pip install -r requirements.txt دوباره می‌سازد
*.dbداده است نه کد؛ هر اجرا به یک تفاوت عظیم و ناخوانا تبدیل می‌شود
__pycache__/از روی فایل‌های .py تولید می‌شود
build/ و dist/ساخته PyInstaller
.env و توکن‌ها و کلیدهارمزی که کامیت شود، تا ابد در تاریخچه می‌ماند
*.sync-conflict-*کپی‌های تعارض Syncthing و Dropbox
git rm -r --cached .venv
git commit -m "Remove the virtual environment from the repository"

۱۳. شاخه و تعارض

شاخه یک خط کار جداگانه است. قاعده ساده: main همیشه کار می‌کند، و هر چیز دیگری جای دیگری اتفاق می‌افتد.

git switch -c feature/statistics    # create it and go there
...work, add, commit...
git switch main
git merge feature/statistics
git branch -d feature/statistics

تعارض وقتی رخ می‌دهد که دو شاخه همان خط‌ها را عوض کرده باشند. Git آنها را در فایل علامت می‌زند:

<<<<<<< HEAD
the version of main
=======
the version of the other branch
>>>>>>> feature/statistics

فایل را تا جایی که می‌خواهید ویرایش کنید، سه نشانه را پاک کنید، بعد git add و git commit. هیچ جادویی در کار نیست؛ فقط باید با دقت بخوانید.

git restore file.py                 # throw away my changes to that file
git restore --staged file.py        # unstage it, keep the changes
git commit --amend                  # fix the LAST commit (message or content)
git revert <hash>                   # a NEW commit that undoes an old one
git reset --hard <hash>             # go back and DESTROY what came after
revert در برابر reset

git revert امن و عمومی است: یک کامیت تازه می‌سازد که کار کامیت قدیمی را خنثی می‌کند. git reset --hard کار را دور می‌ریزد و هرگز نباید روی چیزی که قبلاً push و به اشتراک گذاشته‌اید به کار برود.

۱۴. GitHub و README

GitHub یک کپی از مخزن شما روی یک سرور است. Git بدون آن هم عالی کار می‌کند؛ GitHub پشتیبان و اشتراک‌گذاری و جایی برای خوانده شدن کدتان اضافه می‌کند.

# after creating an empty repository on github.com
git remote add origin https://github.com/USER/kadoos-library.git
git push -u origin main             # the first push
git push                            # afterwards
git pull                            # bring back what others pushed
git clone https://github.com/USER/kadoos-library.git    # get it elsewhere

احراز هویت با رمز عبور دیگر وجود ندارد: از یک توکن دسترسی شخصی یا یک کلید SSH استفاده کنید.

README بخشی از پروژه است

فایل README.md اولین چیزی است که هر کسی می‌بیند. باید به این ترتیب جواب بدهد:

  1. این برنامه چیست؟
  2. چطور نصبش کنم؟ (pip install -r requirements.txt)
  3. چطور اجرایش کنم؟ (python main.py)
  4. یک تصویر از برنامه، اگر پنجره دارد.
  5. مجوز.

کار با دیگران

  1. git switch -c my-feature
  2. کامیت کنید، بعد git push -u origin my-feature
  3. روی GitHub یک pull request باز کنید
  4. کسی آن را می‌خواند و نظر می‌دهد: بازبینی کد
  5. ادغام کنید و شاخه را پاک کنید

در پروژه‌ای که فقط مال شما نیست، هرگز مستقیم روی main پوش نکنید.

git status
git add <file>
git commit -m "message"
git log --oneline
git diff
git switch -c <branch>
git switch <branch>
git merge <branch>
git push
git pull

ده فرمانی که تقریباً همه کار را پوشش می‌دهند

فایل‌های این فصل

پوشه S39-capstone-project شامل پروژه در library/، فایل .gitignore و راهنمای کامل git_and_github.md است. پیش از اجرا، محیط مجازی بسازید و pip install -r requirements.txt بزنید. اولین اجرا، فایل library/data/library.db را می‌سازد و چند کتاب و عضو نمونه در آن می‌گذارد؛ برای شروع از صفر پاکش کنید.

خلاصه فصل

موضوعنکته کلیدی
معماریcore/ بدون پنجره، gui/ بدون SQL
paths.pyتنها جای آگاه از PyInstaller
مدل‌هایک واقعیت، یک جا. ستون تکراری نسازید
session_scope()یک تراکنش: commit، rollback، close
لایه سرویسهر قانون یک بار نوشته می‌شود
LibraryErrorcore مسئله را می‌گوید، gui نمایشش می‌دهد
جدول‌هانمایش‌اند، نه انبار داده
refresh_all()بازسازی کامل، به جای وصله زدن یک سطر
git init و .gitignoreپیش از اولین کامیت
git statusپرکاربردترین فرمان. مدام بزنیدش
کامیتیک ایده، پیام روشن، زمان حال
git switch -cهر کار تازه در شاخه خودش
revert در برابر reset --hardاولی امن، دومی نابودکننده
pull requestراه استاندارد کار گروهی
README.mdچیست، نصب، اجرا، تصویر، مجوز

تمرین‌های فصل سی و نه

  1. یک کلیک را تا انتها دنبال کنید: دکمه «پس گرفتن» را بزنید و به ترتیب همه توابعی را که اجرا می‌شوند بنویسید، تا لحظه‌ای که سطر از جدول ناپدید می‌شود.
  2. فایل core/services.py هیچ چیزی از gui/ وارد نمی‌کند. چرا مهم است، و اگر کسی چنین وارد کردنی اضافه کند چه چیزی خراب می‌شود؟
  3. چرا lend_book() از پایگاه داده می‌پرسد که کتاب بیرون است، در حالی که book.is_out همان‌جا هست؟
  4. کلاس Book ستون available ندارد. عبارت «روی قفسه» از کجا می‌آید و این طراحی جلوی چه مشکلی را می‌گیرد؟
  5. زبانه «تاریخچه امانت» بسازید که امانت‌های بسته‌شده را با تعداد روزهای نگهداری نشان دهد.
  6. جریمه اضافه کنید: روزی پنج هزار تومان دیرکرد، در زبانه امانت‌ها و در آمار. این قانون کجا باید نوشته شود؟
  7. گزینه «خروجی CSV از کتاب‌ها» را به منوی فایل اضافه کنید.
  8. مدت امانت را به یک تنظیم در فایل JSON تبدیل کنید، با یک دیالوگ تنظیمات برای تغییرش.
  9. به زبانه امانت‌ها جعبه جست‌وجو اضافه کنید که بر اساس کتاب یا عضو فیلتر کند.
  10. متن آمار را با یک نمودار واقعی matplotlib جایگزین کنید.
  11. پنجره ورود بسازید: یک مدل User با رمز درهم‌شده، و دکمه‌های حذف را از کاربری که مدیر نیست پنهان کنید.
  12. بازسازی همه زبانه‌ها بعد از هر عمل، کل پایگاه داده را دوباره می‌خواند. با پنج هزار کتاب اندازه‌اش بگیرید و بعد کندترین بخش را درست کنید. اول اندازه بگیرید، هرگز پیش از آن.
  13. همین برنامه را به شکل یک سایت Flask بنویسید و core/ را بدون تغییر دوباره استفاده کنید. چقدرش را مجبور شدید عوض کنید؟
  14. پروژه را با git init شروع کنید، اولین کامیت را بزنید، شاخه‌ای بسازید، تعارضی عمدی درست کنید و حلش کنید.
  15. فایلی با یک رمز عبور کامیت و پوش کرده‌اید. پاک کردن آن خط و کامیت دوباره کافی نیست. چرا، و واقعاً باید چه کار کنید؟
  16. نسخه خودتان از پروژه را با یک README.md و یک تصویر و یک مجوز روی GitHub منتشر کنید.
فصل قبل: وب با Flask فصل بعد: ارزشیابی پایانی