qcanvas 0.0.5.1a0__tar.gz → 0.0.5.3a0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of qcanvas might be problematic. Click here for more details.

Files changed (63) hide show
  1. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/PKG-INFO +1 -2
  2. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/pyproject.toml +2 -2
  3. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/__main__.py +2 -1
  4. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/net/canvas/canvas_client.py +32 -46
  5. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/net/self_authenticating.py +50 -4
  6. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/ui/main_ui.py +27 -4
  7. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/ui/setup_dialog.py +7 -7
  8. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/ui/status_bar_reporter.py +4 -1
  9. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/ui/viewer/page_list_viewer.py +6 -1
  10. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/app_settings.py +1 -0
  11. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/course_indexer/data_manager.py +68 -27
  12. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/course_indexer/resource_helpers.py +3 -2
  13. {qcanvas-0.0.5.1a0/qcanvas/util/linkscanner → qcanvas-0.0.5.3a0/qcanvas/util/link_scanner}/canvas_link_scanner.py +1 -1
  14. {qcanvas-0.0.5.1a0/qcanvas/util/linkscanner → qcanvas-0.0.5.3a0/qcanvas/util/link_scanner}/canvas_media_object_scanner.py +1 -1
  15. {qcanvas-0.0.5.1a0/qcanvas/util/linkscanner → qcanvas-0.0.5.3a0/qcanvas/util/link_scanner}/dropbox_scanner.py +1 -1
  16. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/task_pool.py +4 -4
  17. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/requirements.txt +1 -1
  18. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/.gitignore +0 -0
  19. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/README.md +0 -0
  20. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/__init__.py +0 -0
  21. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/db/__init__.py +0 -0
  22. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/db/database.py +0 -0
  23. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/db/db_converter_helper.py +0 -0
  24. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/icons/__init__.py +0 -0
  25. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/icons/icons.qrc +0 -0
  26. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/icons/main_icon.svg +0 -0
  27. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/icons/rc_icons.py +0 -0
  28. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/net/__init__.py +0 -0
  29. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/net/canvas/__init__.py +0 -0
  30. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/net/canvas/legacy_canvas_types.py +0 -0
  31. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/net/custom_httpx_async_transport.py +0 -0
  32. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/queries/__init__.py +0 -0
  33. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/queries/all_courses.gql +0 -0
  34. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/queries/all_courses.py +0 -0
  35. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/queries/canvas_course_data.gql +0 -0
  36. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/queries/canvas_course_data.py +0 -0
  37. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/ui/__init__.py +0 -0
  38. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/ui/container_item.py +0 -0
  39. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/ui/menu_bar/__init__.py +0 -0
  40. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/ui/menu_bar/grouping_preferences_menu.py +0 -0
  41. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/ui/menu_bar/theme_selection_menu.py +0 -0
  42. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/ui/viewer/__init__.py +0 -0
  43. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/ui/viewer/course_list.py +0 -0
  44. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/ui/viewer/file_list.py +0 -0
  45. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/ui/viewer/file_view_tab.py +0 -0
  46. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/__init__.py +0 -0
  47. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/constants.py +0 -0
  48. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/course_indexer/__init__.py +0 -0
  49. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/course_indexer/conversion_helpers.py +0 -0
  50. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/download_pool.py +0 -0
  51. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/helpers/__init__.py +0 -0
  52. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/helpers/canvas_sanitiser.py +0 -0
  53. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/helpers/file_icon_helper.py +0 -0
  54. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/helpers/qaction_helper.py +0 -0
  55. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/helpers/theme_helper.py +0 -0
  56. {qcanvas-0.0.5.1a0/qcanvas/util/linkscanner → qcanvas-0.0.5.3a0/qcanvas/util/link_scanner}/__init__.py +0 -0
  57. {qcanvas-0.0.5.1a0/qcanvas/util/linkscanner → qcanvas-0.0.5.3a0/qcanvas/util/link_scanner}/resource_scanner.py +0 -0
  58. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/progress_reporter.py +0 -0
  59. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/self_updater.py +0 -0
  60. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/tree_util/__init__.py +0 -0
  61. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/tree_util/expanding_tree.py +0 -0
  62. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/tree_util/model_helpers.py +0 -0
  63. {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/tree_util/tree_model.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: qcanvas
3
- Version: 0.0.5.1a0
3
+ Version: 0.0.5.3a0
4
4
  Summary: A canvas client
5
5
  Author: QCanvas
6
6
  Classifier: Operating System :: OS Independent
@@ -8,7 +8,6 @@ Classifier: Programming Language :: Python :: 3
8
8
  Requires-Python: <=3.12,>=3.11
9
9
  Requires-Dist: aiosqlite-custom~=0.19.0
10
10
  Requires-Dist: beautifulsoup4~=4.12.3
11
- Requires-Dist: bs4~=0.0.1
12
11
  Requires-Dist: gql~=3.6.0b0
13
12
  Requires-Dist: httpx~=0.26.0
14
13
  Requires-Dist: packaging~=23.2
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name="qcanvas"
7
- version = "0.0.5.1a"
7
+ version = "0.0.5.3a"
8
8
  authors= [ {name="QCanvas"} ]
9
9
  description="A canvas client"
10
10
  requires-python=">=3.11,<=3.12"
@@ -24,7 +24,7 @@ dependencies=[
24
24
  "qenerate-custom~=0.6.3",
25
25
  "aiosqlite-custom~=0.19.0",
26
26
  "qasync~=0.27.1",
27
- "bs4~=0.0.1",
27
+ "beautifulsoup4~=4.12.3",
28
28
  "pyqtdarktheme~=2.1.0",
29
29
  "packaging~=23.2"
30
30
  ]
@@ -58,8 +58,9 @@ class LoaderWindow(QMainWindow):
58
58
  @asyncSlot()
59
59
  async def on_init(self) -> None:
60
60
  try:
61
+ all_set = None not in [settings.api_key, settings.canvas_url, settings.panopto_url]
61
62
  # Verify that the canvas urls and api key are valid
62
- if not await CanvasClient.verify_config(settings.canvas_url, settings.api_key):
63
+ if not all_set or not await CanvasClient.verify_config(settings.canvas_url, settings.api_key):
63
64
  # Show the setup dialog
64
65
  self.setup.emit()
65
66
  else:
@@ -1,7 +1,7 @@
1
1
  import asyncio
2
2
  import json
3
3
  import logging
4
- from typing import BinaryIO, AsyncIterator
4
+ from typing import BinaryIO, AsyncIterator, Any
5
5
 
6
6
  import gql
7
7
  import httpx
@@ -13,7 +13,7 @@ from tenacity import retry, wait_exponential, wait_random, stop_after_attempt, w
13
13
  import qcanvas.db as db
14
14
  from qcanvas.net.canvas.legacy_canvas_types import LegacyFile, LegacyPage
15
15
  from qcanvas.net.custom_httpx_async_transport import CustomHTTPXAsyncTransport
16
- from qcanvas.net.self_authenticating import SelfAuthenticating, AuthenticationException
16
+ from qcanvas.net.self_authenticating import SelfAuthenticatingWithHttpClient, AuthenticationException
17
17
 
18
18
 
19
19
  class RatelimitedException(Exception):
@@ -48,11 +48,7 @@ def detect_ratelimit_and_raise(response: Response) -> Response:
48
48
  return response
49
49
 
50
50
 
51
- def detect_authentication_needed(response: Response):
52
- return response.url.path == "/login/canvas" or response.status_code == 401
53
-
54
-
55
- class CanvasClient(SelfAuthenticating):
51
+ class CanvasClient(SelfAuthenticatingWithHttpClient):
56
52
  _logger = logging.getLogger("canvas_client")
57
53
  _net_op_sem = asyncio.Semaphore(20)
58
54
 
@@ -73,11 +69,9 @@ class CanvasClient(SelfAuthenticating):
73
69
  return response.is_success
74
70
 
75
71
  def __init__(self, canvas_url: URL, api_key: str, client: httpx.AsyncClient | None = None):
76
- super().__init__()
72
+ super().__init__(client=client or httpx.AsyncClient(timeout=60))
77
73
  self.api_key = api_key
78
74
  self.canvas_url = canvas_url
79
- self.client = client or httpx.AsyncClient(timeout=60)
80
- self.max_retries = 3
81
75
 
82
76
  def get_headers(self) -> dict[str, dict]:
83
77
  return {"headers": {"Authorization": f"Bearer {self.api_key}"}}
@@ -132,18 +126,33 @@ class CanvasClient(SelfAuthenticating):
132
126
 
133
127
  return LegacyFile.from_dict(json.loads(response.text))
134
128
 
129
+ @retry(
130
+ wait=wait_exponential(exp_base=1.2, max=10) + wait_random(0, 1),
131
+ retry=retry_if_exception_type(RatelimitedException),
132
+ stop=stop_after_attempt(8)
133
+ )
134
+ async def get_temp_session_link(self) -> str | None:
135
+ """
136
+ Gets the link which will authenticate a browser/open canvas using a 'legacy session token'
137
+ Returns
138
+ -------
139
+ str | None
140
+ The url as a string if the request succeeded, None if it didn't
141
+ """
142
+
143
+ token_response = await self.client.get(self.canvas_url.join("login/session_token"), **self.get_headers())
144
+
145
+ if token_response.is_success:
146
+ return json.loads(token_response.text)["session_url"]
147
+ else:
148
+ return None
149
+
135
150
  @retry(
136
151
  stop=stop_after_attempt(3),
137
152
  wait=wait_fixed(5) + wait_random(0, 1),
138
153
  retry=retry_if_exception_type(TransportQueryError)
139
154
  )
140
- async def do_graphql_query(self, query: gql.client.DocumentNode, **kwargs):
141
- """
142
- Executes a graphql query and reauthenticates the client if needed
143
- :param query:
144
- :param operation: The operation to execute
145
- :return: The result
146
- """
155
+ async def do_graphql_query(self, query: gql.client.DocumentNode, **kwargs) -> dict[str, Any]:
147
156
  async with self._net_op_sem:
148
157
  gql_transport = CustomHTTPXAsyncTransport(self.client, self.canvas_url.join("api/graphql"),
149
158
  **self.get_headers())
@@ -172,36 +181,13 @@ class CanvasClient(SelfAuthenticating):
172
181
  self._logger.warning("Gave up download of %s", resource.url)
173
182
  raise
174
183
 
175
- async def do_request_and_retry_if_unauthenticated(self, url: URL):
176
- """
177
- Executes a http request or reauthenticate and retries if needed
178
- :param url: The url of the request
179
- :return:
180
- """
181
- retries = 0
182
-
183
- # Make the initial request
184
- response = await self.client.get(url, **self.get_headers())
185
-
186
- # Retry if canvas is trying to get us to reauthenticate
187
- while (await self.reauthenticate_if_needed(response)) and retries < self.max_retries:
188
- response = await self.client.get(url, **self.get_headers())
189
- retries += 1
190
-
191
- return response.text
192
-
193
- async def reauthenticate_if_needed(self, response: Response):
194
- """
195
- Inspects a response and activates reauthentication if the response indicates we need to
196
- :param response: The response to inspect
197
- :return: True if reauthentication was activated, false if not
198
- """
199
-
200
- if detect_authentication_needed(response):
201
- await self.reauthenticate()
202
- return True
184
+ async def do_request_and_retry_if_unauthenticated(self, url: URL, method: str, **kwargs) -> httpx.Response:
185
+ # Add API token to the request
186
+ return await super().do_request_and_retry_if_unauthenticated(url, method, **kwargs, **self.get_headers())
203
187
 
204
- return False
188
+ def detect_authentication_needed(self, response: Response):
189
+ # Canvas will silently redirect to the login page or give a 401 if we are not authenticated
190
+ return response.url.path == "/login/canvas" or response.status_code == 401
205
191
 
206
192
  async def _authenticate(self):
207
193
  token_response = await self.client.get(self.canvas_url.join("login/session_token"), **self.get_headers())
@@ -1,6 +1,10 @@
1
1
  import logging
2
+ from abc import ABC, abstractmethod
2
3
  from asyncio import Lock, Event
3
4
 
5
+ import httpx
6
+ from httpx import URL
7
+
4
8
 
5
9
  class AuthenticationException(Exception):
6
10
  pass
@@ -9,14 +13,16 @@ class AuthenticationException(Exception):
9
13
  # httpx does have an authentication flow mechanism that allows you to also make other requests but I don't know if it
10
14
  # will behave the same way as this does. I also finished this before I found out that existed.
11
15
 
12
- class SelfAuthenticating:
16
+ class SelfAuthenticatingWithHttpClient(ABC):
13
17
  _logger = logging.getLogger(__name__)
14
18
 
15
- def __init__(self):
19
+ def __init__(self, max_retires: int = 3, client: httpx.AsyncClient = httpx.AsyncClient()):
16
20
  self.authentication_lock = Lock()
21
+ self.client = client
17
22
  self.authentication_in_progress: Event | None = None
23
+ self.max_retries = max_retires
18
24
 
19
- async def reauthenticate(self):
25
+ async def reauthenticate(self) -> None:
20
26
  """
21
27
  Re-authenticates the client
22
28
  """
@@ -55,8 +61,48 @@ class SelfAuthenticating:
55
61
  await event.wait()
56
62
  self._logger.info(f"Finished waiting for {self.__class__.__name__}")
57
63
 
64
+ async def do_request_and_retry_if_unauthenticated(self, url: URL, method: str, **kwargs) -> httpx.Response:
65
+ """
66
+ Executes a http request or reauthenticate and retries if needed
67
+ :param url: The url of the request
68
+ :param method: The method the request should use (post, get, etc)
69
+ :return:
70
+ """
71
+ retries = 0
72
+
73
+ async def make_request():
74
+ return await self.client.request(url=url, method=method, **kwargs)
75
+
76
+ # Make the initial request
77
+ response = await make_request()
78
+
79
+ # Retry if canvas is trying to get us to reauthenticate
80
+ while (await self.reauthenticate_if_needed(response)) and retries < self.max_retries:
81
+ response = await make_request()
82
+ retries += 1
83
+
84
+ return response
85
+
86
+ async def reauthenticate_if_needed(self, response: httpx.Response) -> bool:
87
+ """
88
+ Inspects a response and activates reauthentication if the response indicates we need to
89
+ :param response: The response to inspect
90
+ :return: True if reauthentication was activated, false if not
91
+ """
92
+
93
+ if self.detect_authentication_needed(response):
94
+ await self.reauthenticate()
95
+ return True
96
+
97
+ return False
98
+
99
+ @abstractmethod
100
+ def detect_authentication_needed(self, response: httpx.Response) -> bool:
101
+ ...
102
+
103
+ @abstractmethod
58
104
  async def _authenticate(self) -> None:
59
105
  """
60
106
  Authenticates the client
61
107
  """
62
- raise NotImplemented
108
+ ...
@@ -3,8 +3,8 @@ import sys
3
3
  import traceback
4
4
  from typing import Sequence, Optional
5
5
 
6
- from PySide6.QtCore import Slot, Signal, Qt, QUrl
7
- from PySide6.QtGui import QDesktopServices
6
+ from PySide6.QtCore import Slot, Signal, Qt, QUrl, QObject
7
+ from PySide6.QtGui import QDesktopServices, QKeySequence
8
8
  from PySide6.QtWidgets import *
9
9
  from qasync import asyncSlot
10
10
 
@@ -20,6 +20,7 @@ from qcanvas.util import self_updater
20
20
  from qcanvas.util.app_settings import settings
21
21
  from qcanvas.util.constants import app_name
22
22
  from qcanvas.util.course_indexer import DataManager
23
+ from qcanvas.util.helpers.qaction_helper import create_qaction
23
24
 
24
25
  _aux_settings = settings.auxiliary
25
26
  _no_course_selected_text = "No course selected"
@@ -103,9 +104,11 @@ class AppMainWindow(QMainWindow):
103
104
  def setup_menu_bar(self):
104
105
  menu_bar = self.menuBar()
105
106
 
106
- menu_bar.addMenu(ThemeSelectionMenu())
107
- view_menu = menu_bar.addMenu("View")
107
+ app_menu: QMenu = menu_bar.addMenu("App")
108
+ view_menu: QMenu = menu_bar.addMenu("View")
108
109
 
110
+ app_menu.addAction(self.setup_quick_authentication_action(app_menu))
111
+ app_menu.addMenu(ThemeSelectionMenu())
109
112
  view_menu.addMenu(self.setup_group_by_menu())
110
113
 
111
114
  def setup_group_by_menu(self) -> QMenu:
@@ -115,6 +118,22 @@ class AppMainWindow(QMainWindow):
115
118
 
116
119
  return file_grouping_menu
117
120
 
121
+ def setup_quick_authentication_action(self, parent: QObject):
122
+ return create_qaction(
123
+ name="Quick canvas login",
124
+ shortcut=QKeySequence("Ctrl+O"),
125
+ triggered=self.open_quick_auth_in_browser,
126
+ parent=parent
127
+ )
128
+
129
+ @asyncSlot()
130
+ async def open_quick_auth_in_browser(self):
131
+ opening_progress_dialog = QProgressDialog("Opening canvas", None, 0, 0, self)
132
+ opening_progress_dialog.setWindowTitle("Please wait")
133
+ opening_progress_dialog.show()
134
+ QDesktopServices.openUrl(await self.data_manager.client.get_temp_session_link())
135
+ opening_progress_dialog.close()
136
+
118
137
  def closeEvent(self, event):
119
138
  settings.geometry = self.saveGeometry()
120
139
  settings.window_state = self.saveState()
@@ -132,6 +151,8 @@ class AppMainWindow(QMainWindow):
132
151
 
133
152
  await self.data_manager.download_resource(resource)
134
153
  QDesktopServices.openUrl(QUrl.fromLocalFile(resource.download_location.absolute()))
154
+ else:
155
+ QDesktopServices.openUrl(url)
135
156
 
136
157
  @asyncSlot(QTreeWidgetItem, int)
137
158
  async def download_file_from_file_pane(self, item: QTreeWidgetItem, _: int):
@@ -219,6 +240,8 @@ class AppMainWindow(QMainWindow):
219
240
  else:
220
241
  self.selected_course = None
221
242
  self.file_viewer.clear()
243
+ self.pages_viewer.clear()
244
+ self.assignment_viewer.clear()
222
245
  self.course_name_label.setText(_no_course_selected_text)
223
246
 
224
247
  @asyncSlot(db.CoursePreferences)
@@ -51,12 +51,12 @@ class SetupDialog(QDialog):
51
51
 
52
52
  # Line edits for the different properties
53
53
  self.canvas_url = QLineEdit(settings.canvas_url or "")
54
- # self.panopto_url = QLineEdit()
54
+ self.panopto_url = QLineEdit()
55
55
  self.canvas_api_key = QLineEdit(settings.api_key or "")
56
56
 
57
57
  # Add the line edits to the dialog
58
58
  self._row("Canvas URL", self.canvas_url)
59
- # self._row("Painopto URL", self.panopto_url)
59
+ self._row("Painopto URL", self.panopto_url)
60
60
  self._row("Canvas API key", self.canvas_api_key)
61
61
 
62
62
  # Add the activity indicator to the dialog
@@ -93,15 +93,15 @@ class SetupDialog(QDialog):
93
93
  if self._operation_sem.acquire(False):
94
94
  try:
95
95
  canvas_url_text = self.ensure_protocol(self.canvas_url.text().strip())
96
- # panopto_url_text = ensure_protocol(self.panopto_url.text().strip())
96
+ panopto_url_text = self.ensure_protocol(self.panopto_url.text().strip())
97
97
  canvas_api_key_text = self.canvas_api_key.text().strip()
98
98
 
99
99
  if not (len(canvas_url_text) > 0 and QUrl(canvas_url_text).isValid()):
100
100
  self._show_invalid_msgbox("Canvas URL")
101
101
  return
102
- # if not (len(panopto_url_text) > 0 and QUrl(panopto_url_text).isValid()):
103
- # invalid("Panopto URL")
104
- # return
102
+ if not (len(panopto_url_text) > 0 and QUrl(panopto_url_text).isValid()):
103
+ self._show_invalid_msgbox("Panopto URL")
104
+ return
105
105
  elif not len(canvas_api_key_text) > 0:
106
106
  self._show_invalid_msgbox("API key")
107
107
  elif not (await self._verify_canvas_config(canvas_url_text, canvas_api_key_text)):
@@ -116,7 +116,7 @@ class SetupDialog(QDialog):
116
116
  # If nothing was wrong, everything should be fine
117
117
  # Save the url and api key
118
118
  settings.canvas_url = canvas_url_text
119
- # settings.panoptp_url = panopto_url_text
119
+ settings.panopto_url = panopto_url_text
120
120
  settings.api_key = canvas_api_key_text
121
121
 
122
122
  self.accept()
@@ -34,4 +34,7 @@ class StatusBarReporter(ProgressReporter):
34
34
  self.status_bar.showMessage("Finished", 5000)
35
35
 
36
36
  def errored(self, context: Any) -> None:
37
- pass
37
+ if self.status_bar.parent() is not None:
38
+ self.status_bar.removeWidget(self.progress_bar)
39
+ self.progress_bar = None
40
+ self.status_bar.showMessage("Synchronisation error!!", 5000)
@@ -11,7 +11,7 @@ from qcanvas.ui.container_item import ContainerItem
11
11
  from qcanvas.util.constants import default_assignments_module_names
12
12
  from qcanvas.util.course_indexer import resource_helpers
13
13
  from qcanvas.util.helpers import canvas_sanitiser
14
- from qcanvas.util.linkscanner import ResourceScanner
14
+ from qcanvas.util.link_scanner import ResourceScanner
15
15
 
16
16
 
17
17
  class LinkTransformer:
@@ -79,6 +79,11 @@ class PageLikeViewer(QWidget):
79
79
  self.model.setHorizontalHeaderLabels([self.header_name])
80
80
  self.tree.expandAll()
81
81
 
82
+ def clear(self):
83
+ self.model.clear()
84
+ self.viewer.clear()
85
+ self.model.setHorizontalHeaderLabels([self.header_name])
86
+
82
87
  @abstractmethod
83
88
  def _internal_fill_tree(self, data: db.Course):
84
89
  ...
@@ -85,6 +85,7 @@ class _AppSettings:
85
85
  auxiliary = QSettings("QCanvas", "ui")
86
86
 
87
87
  canvas_url: MappedSetting[str] = MappedSetting(settings, "canvas_url")
88
+ panopto_url: MappedSetting[str] = MappedSetting(settings, "panopto_url")
88
89
  api_key: MappedSetting[str] = MappedSetting(settings, "api_key")
89
90
 
90
91
  ignored_update: MappedSetting[Version] = MappedSetting(auxiliary, "ignored_update")
@@ -7,6 +7,7 @@ from typing import Sequence
7
7
 
8
8
  from gql import gql
9
9
  from sqlalchemy import select
10
+ from sqlalchemy.dialects.sqlite import insert as sqlite_upsert
10
11
  from sqlalchemy.ext.asyncio.session import async_sessionmaker as AsyncSessionMaker, AsyncSession
11
12
  from sqlalchemy.orm import selectin_polymorphic, selectinload
12
13
 
@@ -16,8 +17,8 @@ import qcanvas.util.course_indexer.conversion_helpers as conv_helper
16
17
  import qcanvas.util.course_indexer.resource_helpers as resource_helper
17
18
  from qcanvas.net.canvas import CanvasClient
18
19
  from qcanvas.util.download_pool import DownloadPool
19
- from qcanvas.util.linkscanner.canvas_link_scanner import canvas_resource_id_prefix
20
- from qcanvas.util.linkscanner.resource_scanner import ResourceScanner
20
+ from qcanvas.util.link_scanner.canvas_link_scanner import canvas_resource_id_prefix
21
+ from qcanvas.util.link_scanner.resource_scanner import ResourceScanner
21
22
  from qcanvas.util.progress_reporter import ProgressReporter, noop_reporter
22
23
  from qcanvas.util.task_pool import TaskPool
23
24
 
@@ -83,7 +84,7 @@ class DataManager:
83
84
  sessionmaker: AsyncSessionMaker,
84
85
  link_scanners: Sequence[ResourceScanner]):
85
86
 
86
- self._client = client
87
+ self.client = client
87
88
  self._link_scanners = link_scanners
88
89
  self._session_maker = sessionmaker
89
90
 
@@ -188,7 +189,7 @@ class DataManager:
188
189
 
189
190
  async def synchronize_with_canvas(self, progress_reporter: ProgressReporter = noop_reporter):
190
191
  section = progress_reporter.section("Loading index", 0)
191
- raw_query = (await self._client.do_graphql_query(gql(queries.all_courses.DEFINITION), detailed=True))
192
+ raw_query = (await self.client.do_graphql_query(gql(queries.all_courses.DEFINITION), detailed=True))
192
193
  section.increment_progress()
193
194
 
194
195
  await self.load_courses_data(queries.AllCoursesQueryData(**raw_query).all_courses, progress_reporter)
@@ -201,28 +202,32 @@ class DataManager:
201
202
  if not self._init_called:
202
203
  raise Exception("Init was not called")
203
204
 
204
- async with self._session_maker.begin() as session:
205
- # Load module pages/files for the courses
206
- await self._load_module_items(g_courses, session, progress_reporter)
205
+ try:
206
+ async with self._session_maker.begin() as session:
207
+ # Load module pages/files for the courses
208
+ await self._load_module_items(g_courses, session, progress_reporter)
207
209
 
208
- # Collect assignments from the courses
209
- assignments = []
210
+ # Collect assignments from the courses
211
+ assignments = []
210
212
 
211
- for g_course in g_courses:
212
- # Create needed data in the session
213
- term = await conv_helper.create_term(g_course, session)
214
- await conv_helper.create_course(g_course, session, term)
215
- await conv_helper.create_modules(g_course, session)
213
+ for g_course in g_courses:
214
+ # Create needed data in the session
215
+ term = await conv_helper.create_term(g_course, session)
216
+ await conv_helper.create_course(g_course, session, term)
217
+ await conv_helper.create_modules(g_course, session)
216
218
 
217
- # Add course assignments to the list
218
- assignments.extend(await conv_helper.create_assignments(g_course, session))
219
+ # Add course assignments to the list
220
+ assignments.extend(await conv_helper.create_assignments(g_course, session))
219
221
 
220
- # Scan assignments for resources
221
- await self._scan_assignments_for_resources(assignments, session, progress_reporter)
222
+ # Scan assignments for resources
223
+ await self._scan_assignments_for_resources(assignments, session, progress_reporter)
222
224
 
223
- # Add all resources back into the session
224
- session.add_all(self._resource_pool.results())
225
- progress_reporter.finished()
225
+ # Add all resources back into the session
226
+ session.add_all(self._resource_pool.results())
227
+ progress_reporter.finished()
228
+ except BaseException as e:
229
+ traceback.print_exc()
230
+ progress_reporter.errored(e)
226
231
 
227
232
  async def _scan_assignments_for_resources(self, assignments: Sequence[db.Assignment], session: AsyncSession,
228
233
  progress_reporter: ProgressReporter):
@@ -256,8 +261,13 @@ class DataManager:
256
261
 
257
262
  # Filter out pages that don't need updating
258
263
  pages_to_update = _prepare_out_of_date_pages_for_loading(g_courses, existing_pages)
264
+
265
+ if len(pages_to_update) == 0:
266
+ return
267
+
259
268
  # Load the content for all the pages that need updating
260
- module_items = await self._load_content_for_pages(pages_to_update, progress_reporter)
269
+ module_items: list[db.ModuleItem] = await self._load_content_for_pages(pages_to_update, progress_reporter)
270
+ module_pages = [item for item in module_items if isinstance(item, db.ModulePage)]
261
271
 
262
272
  # Link the resources found to the pages they were found on and add them to the database
263
273
  await resource_helper.create_module_item_resource_relations(
@@ -267,13 +277,43 @@ class DataManager:
267
277
  resource_pool=self._resource_pool,
268
278
  progress_reporter=progress_reporter,
269
279
  # Collect just the module pages for scanning
270
- items=[item for item in module_items if isinstance(item, db.ModulePage)]
280
+ items=module_pages
271
281
  ),
272
282
  session
273
283
  )
274
284
 
275
- # Add all the module items to the session
276
- session.add_all(module_items)
285
+ # empty inserts/upserts causes an sql error. don't do them
286
+ if len(module_pages) > 0:
287
+ # Add all the module items to the session
288
+ # shitty bandaid fix
289
+ upsert_item = sqlite_upsert(db.ModuleItem).values([self.moduleitem_dict(item) for item in module_pages])
290
+ upsert_item = upsert_item.on_conflict_do_update(
291
+ index_elements=[db.ModuleItem.id],
292
+ set_=dict(name=upsert_item.excluded.name, updated_at=upsert_item.excluded.updated_at,
293
+ position=upsert_item.excluded.position),
294
+
295
+ )
296
+
297
+ upsert_page = sqlite_upsert(db.ModulePage).values([self.page_dict(item) for item in module_pages])
298
+ upsert_page = upsert_page.on_conflict_do_update(
299
+ index_elements=[db.ModulePage.id],
300
+ set_=dict(content=upsert_page.excluded.content)
301
+ )
302
+
303
+ await session.execute(upsert_item)
304
+ await session.execute(upsert_page)
305
+
306
+ session.add_all([item for item in module_items if isinstance(item, db.ModuleFile)])
307
+
308
+ @staticmethod
309
+ def page_dict(page: db.ModulePage) -> dict[str, object]:
310
+ return {"id": page.id, "content": page.content}
311
+
312
+ @staticmethod
313
+ def moduleitem_dict(page: db.ModuleItem) -> dict[str, object]:
314
+ return {"id": page.id, "name": page.name, "updated_at": page.updated_at, "position": page.position,
315
+ "module_id": page.module_id, "course_id": page.course_id, "type": page.type,
316
+ "created_at": page.created_at}
277
317
 
278
318
  def _add_resources_and_pages_to_taskpool(self, existing_pages: Sequence[db.ModuleItem],
279
319
  existing_resources: Sequence[db.Resource]):
@@ -293,6 +333,7 @@ class DataManager:
293
333
  The pages to load
294
334
  Returns
295
335
  -------
336
+ list
296
337
  The list of complete pages with page content loaded.
297
338
  """
298
339
  progress = progress_reporter.section("Loading page content", len(pages))
@@ -343,7 +384,7 @@ class DataManager:
343
384
  Fetches information about the specified file from canvas
344
385
  """
345
386
  _logger.debug(f"Fetching file (for module file) %s %s", file.m_id, file.display_name)
346
- result = await self._client.get_file(file.m_id, course_id)
387
+ result = await self.client.get_file(file.m_id, course_id)
347
388
  resource = db.convert_file(file, result.size)
348
389
  resource.id = f"{canvas_resource_id_prefix}:{resource.id}"
349
390
  resource.course_id = course_id
@@ -369,7 +410,7 @@ class DataManager:
369
410
 
370
411
  try:
371
412
  # Get the page
372
- result = await self._client.get_page(page.m_id, course_id)
413
+ result = await self.client.get_page(page.m_id, course_id)
373
414
  except BaseException as e:
374
415
  # Handle any errors
375
416
  _logger.error(e)
@@ -7,11 +7,11 @@ from bs4 import Tag, BeautifulSoup
7
7
  from sqlalchemy.ext.asyncio import AsyncSession
8
8
 
9
9
  import qcanvas.db as db
10
- from qcanvas.util.linkscanner import ResourceScanner
10
+ from qcanvas.util.link_scanner import ResourceScanner
11
11
  from qcanvas.util.progress_reporter import ProgressReporter
12
12
  from qcanvas.util.task_pool import TaskPool
13
13
 
14
- _logger = logging.getLogger()
14
+ _logger = logging.getLogger(__name__)
15
15
 
16
16
  resource_elements = ["a", "iframe", "img"]
17
17
 
@@ -63,6 +63,7 @@ async def create_assignment_resource_relations(relations: Sequence[TransientReso
63
63
  )
64
64
 
65
65
 
66
+ # todo change resource system to think of resources as links on a page with a shallow id (that may be the same as the deep id) which links to one or more deep ids
66
67
  async def find_resources_in_pages(link_scanners: Sequence[ResourceScanner], resource_pool: TaskPool[db.Resource],
67
68
  items: Sequence[db.PageLike], progress_reporter: ProgressReporter) -> list[
68
69
  TransientResourceToPageLink]:
@@ -3,7 +3,7 @@ from httpx import URL
3
3
 
4
4
  from qcanvas import db as db
5
5
  from qcanvas.net.canvas import CanvasClient
6
- from qcanvas.util.linkscanner.resource_scanner import ResourceScanner
6
+ from qcanvas.util.link_scanner.resource_scanner import ResourceScanner
7
7
 
8
8
  canvas_resource_id_prefix = "canvas_file"
9
9
 
@@ -5,7 +5,7 @@ from bs4 import Tag, BeautifulSoup
5
5
  from httpx import AsyncClient
6
6
 
7
7
  from qcanvas import db as db
8
- from qcanvas.util.linkscanner import ResourceScanner
8
+ from qcanvas.util.link_scanner import ResourceScanner
9
9
 
10
10
 
11
11
  class CanvasMediaObjectScanner(ResourceScanner):
@@ -3,7 +3,7 @@ from bs4 import Tag
3
3
  from httpx import URL
4
4
 
5
5
  from qcanvas import db as db
6
- from qcanvas.util.linkscanner import ResourceScanner
6
+ from qcanvas.util.link_scanner import ResourceScanner
7
7
 
8
8
 
9
9
  # from httpx import URL
@@ -225,9 +225,9 @@ class TaskPool(Generic[T]):
225
225
 
226
226
  return list(filter(filter_func, self._results.values()))
227
227
 
228
- def get_completed_result(self, task_id: object) -> T | None:
228
+ def get_completed_result_or_nothing(self, task_id: object, default: T | None = None) -> T | None:
229
229
  """
230
- Returns the result of an already completed task
230
+ Returns the result of an already completed task, or nothing at all if result with that id exists or is still in progress
231
231
 
232
232
  Returns
233
233
  -------
@@ -246,8 +246,8 @@ class TaskPool(Generic[T]):
246
246
  result = self._results[task_id]
247
247
 
248
248
  if isinstance(result, asyncio.Event):
249
- raise ValueError(f"{task_id} is still in progress")
249
+ return None
250
250
 
251
251
  return result
252
252
  else:
253
- raise KeyError(f"{task_id} has not been started")
253
+ return None
@@ -10,6 +10,6 @@ beautifulsoup4~=4.12.3
10
10
  qenerate-custom~=0.6.3
11
11
  aiosqlite-custom~=0.19.0
12
12
  qasync~=0.27.1
13
- bs4~=0.0.1
13
+ beautifulsoup4~=4.12.3
14
14
  pyqtdarktheme~=2.1.0
15
15
  packaging~=23.2
File without changes
File without changes