added Browser tracking and TextQuestion type

This commit is contained in:
Faraphel 2023-12-22 23:37:43 +01:00
parent 13246538fc
commit a44e61b83e
12 changed files with 270 additions and 124 deletions

View file

@ -1,6 +1,7 @@
from typing import Any from typing import Any
from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QFont
from PyQt6.QtWidgets import QFrame, QVBoxLayout, QLabel, QRadioButton, QButtonGroup from PyQt6.QtWidgets import QFrame, QVBoxLayout, QLabel, QRadioButton, QButtonGroup
from source.survey.base import BaseSurvey from source.survey.base import BaseSurvey
@ -20,6 +21,11 @@ class ChoiceQuestion(BaseSurvey):
self.label_question.setText(title) self.label_question.setText(title)
self.label_question.setAlignment(Qt.AlignmentFlag.AlignCenter) self.label_question.setAlignment(Qt.AlignmentFlag.AlignCenter)
font_title = self.label_question.font()
font_title.setPointSize(24)
font_title.setWeight(QFont.Weight.Bold)
self.label_question.setFont(font_title)
# responses # responses
self.frame_responses = QFrame() self.frame_responses = QFrame()
self._layout.addWidget(self.frame_responses) self._layout.addWidget(self.frame_responses)

View file

@ -1,7 +1,7 @@
from typing import Any from typing import Any
from PyQt6.QtCore import pyqtSignal from PyQt6.QtCore import pyqtSignal
from survey.base import BaseSurvey from source.survey.base import BaseSurvey
class Empty(BaseSurvey): class Empty(BaseSurvey):

View file

@ -4,7 +4,7 @@ from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QFont from PyQt6.QtGui import QFont
from PyQt6.QtWidgets import QVBoxLayout, QLabel from PyQt6.QtWidgets import QVBoxLayout, QLabel
from survey.base import BaseSurvey from source.survey.base import BaseSurvey
class Text(BaseSurvey): class Text(BaseSurvey):

View file

@ -0,0 +1,54 @@
from typing import Any
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QFont
from PyQt6.QtWidgets import QFrame, QVBoxLayout, QLabel, QRadioButton, QButtonGroup, QTextEdit
from source.survey.base import BaseSurvey
class TextQuestion(BaseSurvey):
def __init__(self, title: str, signals: dict[str, pyqtSignal]):
super().__init__()
self.signals = signals
# 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)
font_title = self.label_question.font()
font_title.setPointSize(24)
font_title.setWeight(QFont.Weight.Bold)
self.label_question.setFont(font_title)
# response
self.entry_response = QTextEdit()
self._layout.addWidget(self.entry_response)
@classmethod
def from_dict(cls, data: dict[str, Any], signals: dict[str, pyqtSignal]) -> "TextQuestion":
return cls(
title=data["title"],
signals=signals,
)
# events
def on_show(self) -> None:
# immediately mark the survey as successful
if "success" in self.signals:
self.signals["success"].emit() # NOQA: emit exist
# data collection
def get_collected_data(self) -> dict:
return {
"choice": self.entry_response.toPlainText()
}

View file

@ -1,12 +1,12 @@
import time import time
from typing import Optional, Any from typing import Optional, Any
from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QUrl from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QUrl, QEvent, QObject
from PyQt6.QtGui import QFont from PyQt6.QtGui import QFont, QMouseEvent, QResizeEvent
from PyQt6.QtWidgets import QLabel, QVBoxLayout, QSizePolicy from PyQt6.QtWidgets import QLabel, QVBoxLayout, QSizePolicy
from survey.base import BaseSurvey from source.survey.base import BaseSurvey
from source.widget import DecoratedWebEngineView from source.widget import Browser
class WebMission(BaseSurvey): class WebMission(BaseSurvey):
@ -15,15 +15,15 @@ class WebMission(BaseSurvey):
self.check_condition = check_condition self.check_condition = check_condition
self.default_url = url self.default_url = url
self.signals = signals # TODO: default None ? self.signals = signals if signals is not None else {}
# set layout # set layout
self._layout = QVBoxLayout() self._layout = QVBoxLayout()
self.setLayout(self._layout) self.setLayout(self._layout)
# data collection # data collection
self.initial_time = time.time() self.start_time = time.time()
self.collect_urls: list[tuple[float, str]] = [] # list of urls that the user went by self._collected_events: list[dict[str, Any]] = []
# mission title # mission title
self.label_title = QLabel() self.label_title = QLabel()
@ -37,11 +37,12 @@ class WebMission(BaseSurvey):
self.label_title.setFont(font_title) self.label_title.setFont(font_title)
# web page # web page
self.web_view = DecoratedWebEngineView() self.browser = Browser()
self._layout.addWidget(self.web_view) self._layout.addWidget(self.browser)
self.web_view.urlChanged.connect(self._on_url_changed) # NOQA: connect exist self.browser.web.focusProxy().installEventFilter(self) # capture the event in eventFilter
self.browser.web.urlChanged.connect(self._on_url_changed) # NOQA: connect exist
self.web_view.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self.browser.web.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
# setup the timer for the check # setup the timer for the check
if self.check_condition is not None: if self.check_condition is not None:
@ -59,31 +60,92 @@ class WebMission(BaseSurvey):
signals=signals signals=signals
) )
# events
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
if obj is self.browser.web.focusProxy():
# if the object is the content of the web engine widget
match event.type():
case QEvent.Type.MouseMove:
# if this is a mouse movement
event: QMouseEvent
position = event.position()
self._save_event(
type="mouse_move",
position=(position.x(), position.y())
)
case QEvent.Type.MouseButtonPress:
# if this is a mouse click press
event: QMouseEvent
position = event.position()
self._save_event(
type="mouse_press",
position=(position.x(), position.y()),
button=event.button(),
)
case QEvent.Type.MouseButtonRelease:
# if this is a mouse click release
event: QMouseEvent
position = event.position()
self._save_event(
type="mouse_release",
position=(position.x(), position.y()),
button=event.button(),
)
case QEvent.Type.MouseButtonDblClick:
# if this is a mouse double click
event: QMouseEvent
position = event.position()
self._save_event(
type="mouse_double_click",
position=(position.x(), position.y()),
)
case QEvent.Type.Resize:
# if the window got resized
event: QResizeEvent
size = event.size()
self._save_event(
type="resize",
size=(size.width(), size.height()),
)
return super().eventFilter(obj, event)
def on_show(self) -> None: def on_show(self) -> None:
self.web_view.setUrl(QUrl(self.default_url)) # initialize the start time
self.start_time = time.time()
# set the web view to the default url
self.browser.web.setUrl(QUrl(self.default_url))
if self.check_condition is not None: if self.check_condition is not None:
# enable the timer # enable the timer
self.timer_check.start() self.timer_check.start()
else: else:
# call directly the success signal self._success() # call directly the success method
if "success" in self.signals:
self.signals["success"].emit() # NOQA: emit exist
def on_hide(self) -> None: def on_hide(self) -> None:
self.timer_check.stop() self.timer_check.stop()
# data collection def _success(self):
# TODO: animation or notification to clearly mark mission as succeeded
def get_collected_data(self) -> dict: # mark the success in the events
# TODO: more data to collect self._save_event(type="check")
return {
"collect_urls": self.collect_urls
}
def _on_url_changed(self): # emit on the success signal
self.collect_urls.append((time.time() - self.initial_time, self.web_view.url())) if "success" in self.signals:
self.signals["success"].emit() # NOQA: emit exist
# condition # condition
@ -93,8 +155,27 @@ class WebMission(BaseSurvey):
""" """
def check_callback(result: bool): def check_callback(result: bool):
if result and "success" in self.signals: if result:
self.signals["success"].emit() # NOQA: emit exist self._success()
page = self.web_view.page() page = self.browser.web.page()
page.runJavaScript(self.check_condition, resultCallback=check_callback) page.runJavaScript(self.check_condition, resultCallback=check_callback)
# data collection
def _save_event(self, **data) -> None:
# save the data of the event and add the current time
data["time"] = round(time.time() - self.start_time, 3)
self._collected_events.append(data)
def get_collected_data(self) -> dict:
return {
"event": self._collected_events,
}
def _on_url_changed(self):
# log the new url
self._save_event(
type="url",
url=self.browser.web.url().toString()
)

View file

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

View file

@ -2,14 +2,18 @@ from typing import Type
from PyQt6.QtCore import pyqtSignal from PyQt6.QtCore import pyqtSignal
from . import Text, ChoiceQuestion, WebMission, Empty from . import Text, ChoiceQuestion, WebMission, Empty, TextQuestion
from .base import BaseSurvey from .base import BaseSurvey
all_survey: dict[str, Type[BaseSurvey]] = { all_survey: dict[str, Type[BaseSurvey]] = {
# base
"empty": Empty, "empty": Empty,
"text": Text, "text": Text,
# questions
"question-choice": ChoiceQuestion, "question-choice": ChoiceQuestion,
"question-text": TextQuestion,
# missions
"mission-web": WebMission, "mission-web": WebMission,
} }

88
source/widget/Browser.py Normal file
View file

@ -0,0 +1,88 @@
from typing import Optional
from PyQt6.QtCore import QUrl, QObject, QEvent
from PyQt6.QtGui import QMouseEvent
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWidgets import QVBoxLayout, QWidget, QProgressBar, QStyle, QToolBar
class Browser(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.navigation = QToolBar()
self._layout.addWidget(self.navigation, 0)
style = self.style()
self._action_back = self.navigation.addAction(
style.standardIcon(QStyle.StandardPixmap.SP_ArrowBack),
"Back"
)
self._action_reload = self.navigation.addAction(
style.standardIcon(QStyle.StandardPixmap.SP_BrowserReload),
"Reload"
)
self._action_forward = self.navigation.addAction(
style.standardIcon(QStyle.StandardPixmap.SP_ArrowForward),
"Forward"
)
# web widget
self.web = QWebEngineView()
self._layout.addWidget(self.web, 1)
if url is not None:
self.web.load(QUrl(url))
# loading bar
self.progress = QProgressBar()
self._layout.addWidget(self.progress, 0)
self.progress.setFixedHeight(6)
self.progress.setTextVisible(False)
self.progress.setMaximum(100)
self.progress.hide()
# connect the signals
self.web.loadStarted.connect(self._load_started) # NOQA: connect exist
self.web.loadProgress.connect(self._load_progress) # NOQA: connect exist
self.web.loadFinished.connect(self._load_finished) # NOQA: connect exist
self._action_back.triggered.connect(self.web.back) # NOQA: connect exist
self._action_reload.triggered.connect(self.web.reload) # NOQA: connect exist
self._action_forward.triggered.connect(self.web.forward) # NOQA: connect exist
# finalize the initialisation
self.refresh_navigation_actions()
# graphical methods
def _load_started(self):
# update the progress bar
self.progress.setValue(0)
self.progress.show()
def _load_progress(self, value: int):
# update the progress bar
self.progress.setValue(value)
def _load_finished(self):
# update the progress bar
self.progress.hide()
# refresh the navigation buttons
self.refresh_navigation_actions()
def refresh_navigation_actions(self):
history = self.web.history()
# enable the navigation button depending on the history
self._action_back.setEnabled(history.canGoBack())
self._action_forward.setEnabled(history.canGoForward())

View file

@ -1,93 +0,0 @@
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

@ -58,7 +58,7 @@ class FrameSurvey(QFrame):
def load_file(self, survey_path: Path | str): def load_file(self, survey_path: Path | str):
# load the surveys screens # load the surveys screens
with open(survey_path) as file: with open(survey_path, encoding="utf-8") as file:
surveys_data = json.load(file) surveys_data = json.load(file)
self.survey_screens = [ self.survey_screens = [

View file

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

View file

@ -29,5 +29,10 @@
"title": "Rendez-vous sur la boutique des points.", "title": "Rendez-vous sur la boutique des points.",
"url": "https://steampowered.com/", "url": "https://steampowered.com/",
"check": "true" "check": "true"
},
"question-experience": {
"type": "question-text",
"title": "Qu'avez vous pensé de l'interface de Steam ?"
} }
} }