Python Forum
[PyQt] MVC implementation issue
Thread Rating:
  • 0 Vote(s) - 0 Average
  • 1
  • 2
  • 3
  • 4
  • 5
[PyQt] MVC implementation issue
#1
Good Evening everyone. I am working on an Hotel reservation system, that basically consists of a webscraper that picks rates from an hotel website and present them in a view for further manipulation. Pretty basic but sufficient, as it will be used by the hotel employees themselves. But I want it to be scalable and reusable for future developments, so I opted for PyQt5 and a MVC design pattern.

This is the final result I want to achieve:
   

Each component of the interface must be connected to a model and a controller. main.py is my 'entrypoint'.
No problem for the BSCalendarWidget and OccupationView, but I am not able to connect the BSStaySelectionWidget.
Here follows the code of the classes involved. Please ignore the inconsistent naming conventions, it's a work-in-progress.

BSWidgets.py
class BSStaySelectionView(QWidget):
    """
    A form that includes check-in and check-out date selectors.

    Args:
        mode (str): Selects the appearance of the date selection form.
            - 'default': shows the calendar date picker and text form;
            - 'calendar': shows only the calendar;
            - 'text': shows text fields only
    """

    # Signals
    check_in_changed = pyqtSignal(QDate)
    nights_count_changed = pyqtSignal(int)

    def __init__(self, mode: str = 'default'):  #controller=None
        super().__init__()
        # self.controller = controller
        # if self.controller is not None:
        #     self.connect_signals()

        self.checkin_fld = QDateEdit()  # was: QLineEdit()
        self.checkin_fld.setDisplayFormat('dd.MM.yy')
        self.checkout_fld = QDateEdit()  # was: QLineEdit()
        self.checkout_fld.setDisplayFormat('dd.MM.yy')

        self.nights_fld = QSpinBox()
        self.nights_fld.setMinimum(1)

        self.calendar_wgt = BSCalendarWidget(self)

        # self.checkin_fld.dateChanged.connect(self.controller.update_check_in)
        # self.nights_fld.valueChanged.connect(self.controller.update_nights_count)
        # self.calendar_wgt.date_range_selected.connect(self.controller.update_date_range)

        self.layout = QVBoxLayout()
        self.setup_ui()
        self.set_mode(mode)  # ui must be constructed entirely before customizing the mode

    def setup_ui(self):
        self.layout.addWidget(QLabel('Check-in'))
        self.layout.addWidget(self.checkin_fld)
        self.layout.addWidget(QLabel('Check-out'))
        self.layout.addWidget(self.checkout_fld)
        self.layout.addWidget(QLabel('Nights'))
        self.layout.addWidget(self.nights_fld)
        self.layout.addWidget(self.calendar_wgt)

        # New code
        self.checkin_fld.dateChanged.connect(self.check_in_changed.emit)
        self.nights_fld.valueChanged.connect(self.nights_count_changed.emit)

        self.setLayout(self.layout)

    def set_mode(self, mode: str):
        if mode == 'default':
            pass

        elif mode == 'calendar':
            for i in range(self.layout.count()):
                component = self.layout.itemAt(i).widget()
                if component is not None and isinstance(component, QLabel):
                    component.setHidden(True)
                elif component is not None and isinstance(component, QLineEdit):
                    component.setHidden(True)
                else:
                    pass

        elif mode == 'text':
            self.calendar_wgt.hide()

        else:
            warnings.warn(
                f'Invalid mode for StaySelectorForm. accepted options: "default", "calendar", "text" - entered "{mode}" instead'
            )
            pass

    def update_nights(self):
        # Calculate the number of nights and update the nights QLineEdit
        date_format = 'dd.MM.yy'
        from_date = QDate.fromString(self.checkin_fld.text(), date_format)
        to_date = QDate.fromString(self.checkout_fld.text(), date_format)
        self.nights_fld.setText(str(from_date.daysTo(to_date)))

    def update_check_out_date(self):
        # Add the number of nights to the check-in date and update the check-out date
        date_format = 'dd.MM.yy'
        from_date = QDate.fromString(self.checkin_fld.text(), date_format)

        try:
            num_nights = int(self.nights_fld.text())
        except ValueError:
            num_nights = 1  # When is invalid or, more likely, empty

        to_date = from_date.addDays(num_nights)
        self.checkout_fld.setDate(to_date)

    # def connect_signals(self):
    #     self.checkin_fld.dateChanged.connect(self.controller.update_check_in)
    #     self.nights_fld.valueChanged.connect(self.controller.update_nights_count)
    #     self.calendar_wgt.date_range_selected.connect(self.controller.update_date_range)


# TODO DEBUG
class TestWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Availability Request - ***** *** ******")
        data = [('2 adt, 0 ch', RoomOccupation(2, []))]

        # Central widget
        main_wgt = QWidget()
        lo = QHBoxLayout()

        # Dates form
        input_section = QWidget()
        input_section_layout = QVBoxLayout()

        occupation_view = OccupationView(data)

        begin_btn = QPushButton(QIcon('assets/search_ico.png'), 'Search')
        begin_btn.setMinimumHeight(40)
        begin_btn.setStyleSheet("background-color : #FFF59D")  # TODO We will deal with this with a proper CSS in real world
        begin_btn.clicked.connect(self.load)

        # try to implement the controller
        stay_selector = BSStaySelectionView('default')
        # stay_selector_model = StaySelectorModel()
        # stay_selector_controller = ReservationController(stay_selector_model, stay_selector)
        # stay_selector.set_controller(stay_selector_controller)

        input_section_layout.addWidget(stay_selector)
        input_section_layout.addWidget(occupation_view)
        input_section_layout.addStretch()
        input_section_layout.addWidget(begin_btn)

        input_section.setLayout(input_section_layout)
        input_section.setContentsMargins(0, 0, 0, 0)
        input_section.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)

        lo.addWidget(input_section)

        # Output ("Response")
        response_stack_widget = QWidget()
        self.response_stack_layout = QStackedLayout()

        output_view = AvailabilityView()
        dialog_view = DialogView(
            'Welcome!',
            'To check availability, please enter some dates, select occupancy and click "SEARCH"',
            links={'*** Website': 'http://www.***.it',
             'User Guide': 'http://www.google.com'}
        )
        completion_view = CompletionView()

        self.response_stack_layout.addWidget(completion_view)  # 0 = Loading
        self.response_stack_layout.addWidget(dialog_view)  # 1 = Dialog
        self.response_stack_layout.addWidget(output_view)  # 2 = Results selection_view

        response_stack_widget.setLayout(self.response_stack_layout)
        response_stack_widget.setContentsMargins(0, 0, 0, 0)

        lo.addWidget(response_stack_widget)
        main_wgt.setLayout(lo)

        self.response_stack_layout.setCurrentIndex(1)
        self.setCentralWidget(main_wgt)

    def load(self):
        # Completion handler
        self.response_stack_layout.setCurrentIndex(0)
        try:
            self.data_thread = BSNetworking.FetchDataThread()
            self.data_thread.data_fetched.connect(self.on_data_fetched)
            self.data_thread.start()
        except ConnectionError:
            self.response_stack_layout.setCurrentIndex(1)

    def on_data_fetched(self, data):
        print(data)
        self.response_stack_layout.setCurrentIndex(2)
BSModels.py
class StaySelectorModel:
    def __init__(self):
        self.check_in_date = QDate.currentDate()
        self.check_out_date = self.check_in_date.addDays(1)
        self.nights_count = 1

    # def set_check_in(self, date):
    #     self.check_in_date = date
    #     self.check_out_date = self.check_in_date.addDays(self.nights_count)
    #
    # def set_nights_count(self, nights):
    #     self.nights_count = nights
    #     self.check_out_date = self.check_in_date.addDays(self.nights_count)
    #
    # def set_check_in_and_nights(self, check_in_date, nights):
    #     self.set_check_in(check_in_date)
    #     self.set_nights_count(nights)

    def set_check_in(self, date):
        self.check_in_date = date
        self.check_out_date = self.check_in_date.addDays(self.nights_count)

    def set_nights_count(self, nights):
        self.nights_count = nights
        self.check_out_date = self.check_in_date.addDays(self.nights_count)

    def set_check_in_and_nights(self, check_in_date, nights):
        self.set_check_in(check_in_date)
        self.set_nights_count(nights)

class RoomOccupationModel(QAbstractListModel):
    """
    RoomListModel represents a linear model for storing RoomOccupation objects
    and managing the data.
    The model maintains a list of RoomOccupation objects and implements several methods
    to communicate with a RoomOccupationView.

    Attributes:
    -----------
    _data : list
        (Private) A list of RoomOccupation objects that the model stores.

    Methods:
    --------
    rowCount(self, parent):
        Returns the number of rows under the given parent. When the parent is valid,
        this class returns 0.

    data(self, index, role):
        Returns the data stored under the given role for the item referred to by the index.

    addData(self, room):
        Adds a new RoomOccupation object to the list. It begins by signaling the
        selection_view about the upcoming change with beginInsertRows(), then performs the changes
        on its data, and ends by signaling the selection_view with endInsertRows().

    deleteData(self, row):
        Deletes a RoomOccupation object from the list at the specified row index.
        It follows the same pattern of signaling the selection_view about the changes.

    editData(self, row, new_room):
        Edits a RoomOccupation object at the specified row index with a new RoomOccupation object.
        After the change, it emits a dataChanged signal to update the selection_view.

    duplicateData(self, row):
        Duplicates a RoomOccupation object at the specified row index.
    """
    # THIS WORKS FINE SO NO NEED TO COPY THE CODE IN THIS FORUM THREAD
BSControllers.py
class ReservationController(QObject):
    def __init__(self, model, view):
        super().__init__()
        self.model = model
        self.view = view

        # self.selection_view.check_in_changed.connect(self.update_check_in)
        # self.selection_view.nights_count_changed.connect(self.update_nights_count)
        # self.selection_view.calendar_wgt.date_range_selected.connect(self.update_date_range)

        # Calendar specific
        self.click_tracker = 0
        self.from_date = None
        self.to_date = None
        self.view.calendar_wgt.clicked.connect(self.calendar_clicked)

        self.refresh_view()

    def refresh_view(self):
        self.view.checkin_fld.setDate(self.model.check_in_date)
        self.view.checkout_fld.setDate(self.model.check_out_date)
        self.view.nights_fld.setValue(self.model.nights_count)

        self.view.calendar_wgt.select_date_range(
            self.model.check_in_date,
            self.model.check_out_date
        )

    # @pyqtSlot(QDate)
    # def update_check_in(self, date):
    #     self.model.set_check_in(date)
    #     self.refresh_view()
    #
    # @pyqtSlot(int)
    # def update_nights_count(self, nights):
    #     self.model.set_nights_count(nights)
    #     self.refresh_view()
    #
    # @pyqtSlot(QDate, QDate)
    # def update_date_range(self, from_date, to_date):
    #     self.model.set_check_in(from_date)
    #     self.model.set_nights_count(from_date.daysTo(to_date))
    #     self.refresh_view()

    def update_check_in(self, date):
        self.model.set_check_in(date)
        self.refresh_view()

    def update_nights_count(self, nights):
        self.model.set_nights_count(nights)
        self.refresh_view()

    def update_date_range(self, from_date, to_date):
        self.model.set_check_in_and_nights(from_date, from_date.daysTo(to_date))
        self.refresh_view()

    def calendar_clicked(self, clicked_date):
        self.click_tracker += 1

        # Using 1-based count to avoid confusion
        # On the first click of a pair, set the "from_date" as the start; otherwise, to_date
        if self.click_tracker % 2 == 1:
            self.from_date = clicked_date
            self.to_date = None
        else:
            self.to_date = clicked_date

        # Only emit if both from_date and to_date are set (otherwise, it would pass None and raise a TypeError)
        if self.from_date is not None and self.to_date is not None:
            self.view.calendar_wgt.date_range_selected.emit(self.from_date, self.to_date)
main.py (entrypoint)
if __name__ == "__main__":
    app = QApplication(sys.argv)

    test_window = TestWindow()
    app.setStyle('fusion')
    test_window.show()

    sys.exit(app.exec_())
What the problem is
The commented chunks of code are my attempts to achieve the goal.
If I uncomment those, this exception occurs:
Error:
Traceback (most recent call last): File "C:\Users\franc\PycharmProjects\pythonProject\main.py", line 13, in <module> test_window = TestWindow() File "C:\Users\franc\PycharmProjects\pythonProject\BSWidgets.py", line 610, in __init__ stay_selector = BSStaySelectionView('default') File "C:\Users\franc\PycharmProjects\pythonProject\BSWidgets.py", line 515, in __init__ self.checkin_fld.dateChanged.connect(self.controller.update_check_in) AttributeError: 'NoneType' object has no attribute 'update_check_in'
I had to comment those sections in order to be able to do the screenshot.

What I have tried so far
As a preface, I must admit I relied on chatGPT to implement the model and controller class. As it used some constructs I am not familiar with, it is somehow messed up in a way I can't control.
I used MVC also on the other components on my UI and did it myself, and didn't experience all this problems.

Since it's a NoneType exception and controller is passed as default as None, I thought that was the problem. Therefore, I tried to workaround this in various way (initializing a set_controller() method within BSStaySelector, and others) but nothing seems to work.

I also tried to initialize the model and controller from within the class, but this isn't MVC-compliant imho because the view should be responsible only to present the data, no logic (right?). And anyway, it didn't work anyways (circular import).

It seems like there is an approach error in the project, and I can't figure our what.

I will greatly appreciate any help, and also general suggestions on how to implement MVC in a Python w/ PyQt5 project - all examples online are very basic, when it comes to real code it's much different.
Reply


Messages In This Thread
MVC implementation issue - by gradlon93 - Aug-26-2023, 10:49 PM
RE: MVC implementation issue - by menator01 - Aug-27-2023, 05:02 AM
RE: MVC implementation issue - by gradlon93 - Sep-02-2023, 08:47 AM
RE: MVC implementation issue - by deanhystad - Aug-28-2023, 04:37 PM
RE: MVC implementation issue - by gradlon93 - Sep-03-2023, 06:25 AM
RE: MVC implementation issue - by deanhystad - Sep-03-2023, 02:24 PM

Forum Jump:

User Panel Messages

Announcements
Announcement #1 8/1/2020
Announcement #2 8/2/2020
Announcement #3 8/6/2020