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.
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
The commented chunks of code are my attempts to achieve the goal.
If I uncomment those, this exception occurs:
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.
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 THREADBSControllers.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.