Initial commit

This commit is contained in:
Faraphel 2023-12-21 16:46:53 +01:00
commit b4dff9a2e7
16 changed files with 563 additions and 0 deletions

13
main.py Normal file
View file

@ -0,0 +1,13 @@
import sys
from PyQt6.QtWidgets import QApplication
from source.widget import MyMainWindow
if __name__ == "__main__":
application = QApplication(sys.argv)
window = MyMainWindow()
window.show()
application.exec()

2
requirements.txt Normal file
View file

@ -0,0 +1,2 @@
PyQt6
PyQt6-WebEngine

0
source/__init__.py Normal file
View file

View file

@ -0,0 +1,59 @@
from typing import Any
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtWidgets import QFrame, QVBoxLayout, QLabel, QRadioButton, QButtonGroup
from source.survey.base import BaseSurvey
class ChoiceQuestion(BaseSurvey):
def __init__(self, title: str, choices: dict[Any, str], signals: dict[str, pyqtSignal]):
super().__init__()
# set layout
self._layout = QVBoxLayout()
self.setLayout(self._layout)
# question title
self.label_question = QLabel()
self._layout.addWidget(self.label_question)
self.label_question.setText(title)
self.label_question.setAlignment(Qt.AlignmentFlag.AlignCenter)
# responses
self.frame_responses = QFrame()
self._layout.addWidget(self.frame_responses)
self._layout_responses = QVBoxLayout()
self.frame_responses.setLayout(self._layout_responses)
self.group_responses = QButtonGroup()
if "success" in signals:
# checking any button allow the user to go to the next step
self.group_responses.buttonClicked.connect(signals["success"].emit) # NOQA: connect and emit exists
for choice_id, choice_text in choices.items():
# create a radio button for that choice
button = QRadioButton()
button.setText(choice_text)
# add the button to the frame
self._layout_responses.addWidget(button)
# add the button to the group
self.group_responses.addButton(button, int(choice_id))
@classmethod
def from_dict(cls, data: dict[str, Any], signals: dict[str, pyqtSignal]) -> "ChoiceQuestion":
return cls(
title=data["title"],
choices=data["choices"],
signals=signals,
)
def get_collected_data(self) -> dict:
return {
"choice": self.group_responses.checkedId()
}

10
source/survey/Empty.py Normal file
View file

@ -0,0 +1,10 @@
from typing import Any
from PyQt6.QtCore import pyqtSignal
from survey.base import BaseSurvey
class Empty(BaseSurvey):
@classmethod
def from_dict(cls, data: dict[str, Any], signals: dict[str, pyqtSignal]) -> "BaseSurvey":
return Empty()

52
source/survey/Text.py Normal file
View file

@ -0,0 +1,52 @@
from typing import Optional, Any
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QFont
from PyQt6.QtWidgets import QVBoxLayout, QLabel
from survey.base import BaseSurvey
class Text(BaseSurvey):
def __init__(self, title: str, description: Optional[str], signals: dict[str, pyqtSignal]):
super().__init__()
self.signals = signals
# set the layout
self._layout = QVBoxLayout()
self.setLayout(self._layout)
# prepare the title
self.label_title = QLabel()
self._layout.addWidget(self.label_title)
self.label_title.setText(title)
self.label_title.setAlignment(Qt.AlignmentFlag.AlignCenter)
font_title = self.label_title.font()
font_title.setPointSize(32)
font_title.setWeight(QFont.Weight.Bold)
self.label_title.setFont(font_title)
if description is not None:
# prepare the description
self.label_description = QLabel()
self._layout.addWidget(self.label_description)
self.label_description.setText(description)
self.label_description.setAlignment(Qt.AlignmentFlag.AlignCenter)
@classmethod
def from_dict(cls, data: dict[str, Any], signals: dict[str, pyqtSignal]) -> "Text":
return cls(
title=data["title"],
description=data.get("description"),
signals=signals
)
def on_show(self) -> None:
if "success" in self.signals:
# the user can skip a text whenever he wants to, directly signal a success
self.signals["success"].emit() # NOQA: emit exist

100
source/survey/WebMission.py Normal file
View file

@ -0,0 +1,100 @@
import time
from typing import Optional, Any
from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QUrl
from PyQt6.QtGui import QFont
from PyQt6.QtWidgets import QLabel, QVBoxLayout, QSizePolicy
from survey.base import BaseSurvey
from source.widget import DecoratedWebEngineView
class WebMission(BaseSurvey):
def __init__(self, title: str, url: str, signals: dict[str, pyqtSignal], check_condition: Optional[str] = None):
super().__init__()
self.check_condition = check_condition
self.default_url = url
self.signals = signals # TODO: default None ?
# set layout
self._layout = QVBoxLayout()
self.setLayout(self._layout)
# data collection
self.initial_time = time.time()
self.collect_urls: list[tuple[float, str]] = [] # list of urls that the user went by
# mission title
self.label_title = QLabel()
self._layout.addWidget(self.label_title)
self.label_title.setText(title)
self.label_title.setAlignment(Qt.AlignmentFlag.AlignCenter)
font_title = self.label_title.font()
font_title.setPointSize(24)
font_title.setWeight(QFont.Weight.Bold)
self.label_title.setFont(font_title)
# web page
self.web_view = DecoratedWebEngineView()
self._layout.addWidget(self.web_view)
self.web_view.urlChanged.connect(self._on_url_changed) # NOQA: connect exist
self.web_view.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
# setup the timer for the check
if self.check_condition is not None:
self.timer_check = QTimer()
self.timer_check.setInterval(1000)
self.timer_check.timeout.connect(self.check) # NOQA: connect exist
@classmethod
def from_dict(cls, data: dict[str, Any], signals: dict[str, pyqtSignal]) -> "WebMission":
return cls(
title=data["title"],
url=data.get("url"),
check_condition=data.get("check"),
signals=signals
)
def on_show(self) -> None:
self.web_view.setUrl(QUrl(self.default_url))
if self.check_condition is not None:
# enable the timer
self.timer_check.start()
else:
# call directly the success signal
if "success" in self.signals:
self.signals["success"].emit() # NOQA: emit exist
def on_hide(self) -> None:
self.timer_check.stop()
# data collection
def get_collected_data(self) -> dict:
# TODO: more data to collect
return {
"collect_urls": self.collect_urls
}
def _on_url_changed(self):
self.collect_urls.append((time.time() - self.initial_time, self.web_view.url()))
# condition
def check(self) -> None:
"""
Check if the checking condition have been completed
"""
def check_callback(result: bool):
if result and "success" in self.signals:
self.signals["success"].emit() # NOQA: emit exist
page = self.web_view.page()
page.runJavaScript(self.check_condition, resultCallback=check_callback)

View file

@ -0,0 +1,6 @@
from .Empty import Empty
from .Text import Text
from .ChoiceQuestion import ChoiceQuestion
from .WebMission import WebMission
from .get import survey_get

View file

@ -0,0 +1,38 @@
from abc import abstractmethod
from typing import Optional, Any
from PyQt6.QtCore import pyqtSignal
from PyQt6.QtWidgets import QFrame
class BaseSurvey(QFrame):
"""
A type of survey survey that can be in the user interface
"""
@classmethod
@abstractmethod
def from_dict(cls, data: dict[str, Any], signals: dict[str, pyqtSignal]) -> "BaseSurvey":
"""
Create an instance from a configuration dictionary
:return: the instance
"""
def on_show(self) -> None:
"""
Called when the survey is shown
"""
def on_hide(self) -> None:
"""
Called when the survey is hidden
:return:
"""
def get_collected_data(self) -> Optional[dict]:
"""
Return the data collected for the survey
:return: the data collected for the survey
"""
return None

View file

@ -0,0 +1 @@
from .BaseSurvey import BaseSurvey

26
source/survey/get.py Normal file
View file

@ -0,0 +1,26 @@
from typing import Type
from PyQt6.QtCore import pyqtSignal
from . import Text, ChoiceQuestion, WebMission, Empty
from .base import BaseSurvey
all_survey: dict[str, Type[BaseSurvey]] = {
"empty": Empty,
"text": Text,
"question-choice": ChoiceQuestion,
"mission-web": WebMission,
}
def survey_get(data: dict[str, ...], signals: dict[str, pyqtSignal]) -> BaseSurvey:
"""
Return a Survey object from the data
:param data: the data of the survey
:param signals: signal that the survey survey can react to
:return: a Survey object
"""
survey_class = all_survey[data["type"]]
return survey_class.from_dict(data, signals=signals)

View file

@ -0,0 +1,93 @@
from typing import Optional
from PyQt6.QtCore import QUrl
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWidgets import QVBoxLayout, QWidget, QProgressBar, QFrame, QHBoxLayout, QPushButton, QStyle
class DecoratedWebEngineView(QWidget):
"""
A version of the QWebEngineView class with integrated progress bar and navigations bar.
"""
def __init__(self, url: Optional[QUrl] = None):
super().__init__()
# layout
self._layout = QVBoxLayout()
self.setLayout(self._layout)
# navigation bar
self.frame_navigation = QFrame()
self._layout.addWidget(self.frame_navigation, 0)
self._layout_navigation = QHBoxLayout()
self.frame_navigation.setLayout(self._layout_navigation)
self.button_back = QPushButton()
self._layout_navigation.addWidget(self.button_back)
self.button_back.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowBack))
self.button_reload = QPushButton()
self._layout_navigation.addWidget(self.button_reload)
self.button_reload.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload))
self.button_forward = QPushButton()
self._layout_navigation.addWidget(self.button_forward)
self.button_forward.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowForward))
# force the navigation buttons to be on the left by adding a stretching element
self._layout_navigation.addStretch(0)
# web widget
self.web_view = QWebEngineView()
self._layout.addWidget(self.web_view, 1)
if url is not None:
self.web_view.load(QUrl(url))
# loading bar
self.progress_bar = QProgressBar()
self._layout.addWidget(self.progress_bar, 0)
self.progress_bar.setFixedHeight(6)
self.progress_bar.setTextVisible(False)
self.progress_bar.setMaximum(100)
self.progress_bar.hide()
# connect the signals
self.web_view.loadStarted.connect(self._load_started) # NOQA: connect exist
self.web_view.loadProgress.connect(self._load_progress) # NOQA: connect exist
self.web_view.loadFinished.connect(self._load_finished) # NOQA: connect exist
self.button_back.clicked.connect(self.back) # NOQA: connect exist
self.button_reload.clicked.connect(self.reload) # NOQA: connect exist
self.button_forward.clicked.connect(self.forward) # NOQA: connect exist
# finalize the initialisation
self.refresh_navigation_actions()
def __getattr__(self, name):
# if the member is not found in the class, look in the web view directly
return getattr(self.web_view, name)
# graphical methods
def _load_started(self):
# update the progress bar
self.progress_bar.setValue(0)
self.progress_bar.show()
def _load_progress(self, value: int):
# update the progress bar
self.progress_bar.setValue(value)
def _load_finished(self):
# update the progress bar
self.progress_bar.hide()
# refresh the navigation buttons
self.refresh_navigation_actions()
def refresh_navigation_actions(self):
history = self.web_view.history()
# enable the navigation button depending on the history
self.button_back.setEnabled(history.canGoBack())
self.button_forward.setEnabled(history.canGoForward())

View file

@ -0,0 +1,117 @@
import json
from pathlib import Path
from PyQt6.QtCore import pyqtSignal
from PyQt6.QtWidgets import QFrame, QVBoxLayout, QHBoxLayout, QPushButton
from source.survey.base import BaseSurvey
from source.survey import Empty, survey_get
class FrameSurvey(QFrame):
signal_success = pyqtSignal()
def __init__(self, survey_path: Path | str):
super().__init__()
# signals
self.signal_success.connect(self._on_signal_success) # NOQA: connect exist
# prepare the survey screen data
self.survey_screens: list[tuple[str, BaseSurvey]] = []
self.current_survey_index = 0
# prepare the survey collected data
self.collected_datas: dict[str, dict] = {}
# set the layout
self._layout = QVBoxLayout()
self.setLayout(self._layout)
# prepare the frame for the survey elements
self.frame_survey: BaseSurvey = Empty()
self._layout.addWidget(self.frame_survey)
# navigations actions
self.frame_navigation = QFrame()
self._layout.addWidget(self.frame_navigation)
self._layout_navigation = QHBoxLayout()
self.frame_navigation.setLayout(self._layout_navigation)
self._layout_navigation.addStretch(0)
self.button_forward = QPushButton()
self._layout_navigation.addWidget(self.button_forward)
self.button_forward.setText("Suivant")
self.button_forward.clicked.connect(self.next_survey) # NOQA: connect exist
# load the survey configuration file
self.load_file(survey_path)
# finalize the initialisation
self.update_survey()
def _on_signal_success(self):
# on success, show the button to go forward
self.button_forward.show()
def load_file(self, survey_path: Path | str):
# load the surveys screens
with open(survey_path) as file:
surveys_data = json.load(file)
self.survey_screens = [
(
survey_id,
survey_get(
survey_data,
signals={"success": self.signal_success}
)
)
for survey_id, survey_data in surveys_data.items()
]
self.current_survey_index = 0
def next_survey(self):
# get the collected data from the survey
collected_data = self.frame_survey.get_collected_data()
if collected_data is not None:
# if there is data, get the current survey id
survey_id, survey = self.survey_screens[self.current_survey_index]
# save the response in the data
self.collected_datas[survey_id] = collected_data
print(collected_data)
self.current_survey_index += 1
if self.current_survey_index < len(self.survey_screens):
self.update_survey()
else:
self.finish_survey()
def update_survey(self):
# disable the forward button
self.button_forward.hide()
# mark the actual survey as the old one
old_frame_survey = self.frame_survey
# call the old survey event
old_frame_survey.on_hide()
# get the currently selected survey
survey_id, survey = self.survey_screens[self.current_survey_index]
# update it to the new one
self.frame_survey = survey
# change the widget on the layout
self._layout.replaceWidget(old_frame_survey, self.frame_survey)
# call the new survey event
survey.on_show()
# delete the old frame
old_frame_survey.deleteLater()
def finish_survey(self):
# TODO: send the collected data as a file somewhere
print(self.collected_datas)
self.window().close()

View file

@ -0,0 +1,10 @@
from PyQt6.QtWidgets import QMainWindow
from source import widget
class MyMainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setCentralWidget(widget.FrameSurvey("./surveys.json"))

View file

@ -0,0 +1,3 @@
from .DecoratedWebEngineView import DecoratedWebEngineView
from .FrameSurvey import FrameSurvey
from .MyMainWindow import MyMainWindow

33
surveys.json Normal file
View file

@ -0,0 +1,33 @@
{
"text-welcome": {
"type": "text",
"title": "Bienvenue !",
"description": "Nous réalisons une étude sur le logiciel Steam afin de déterminer son ergonomie.\nVous serez invité à naviguer sur la plateforme Steam d'une page à l'autre."
},
"question-usage": {
"type": "question-choice",
"title": "A quel point êtes-vous familier avec Steam ?",
"choices": {
"0": "Ne connait pas",
"1": "Connait de nom",
"2": "Utilisation rare",
"3": "Utilisation moyenne",
"4": "Utilisation fréquente"
}
},
"web-language": {
"type": "mission-web",
"title": "Changer la langue en français.",
"url": "https://steampowered.com/",
"check": "document.getElementsByTagName('html')[0].lang == 'fr'"
},
"web-point-shop": {
"type": "mission-web",
"title": "Rendez-vous sur la boutique des points.",
"url": "https://steampowered.com/",
"check": "true"
}
}