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.
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/PKG-INFO +1 -2
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/pyproject.toml +2 -2
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/__main__.py +2 -1
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/net/canvas/canvas_client.py +32 -46
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/net/self_authenticating.py +50 -4
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/ui/main_ui.py +27 -4
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/ui/setup_dialog.py +7 -7
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/ui/status_bar_reporter.py +4 -1
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/ui/viewer/page_list_viewer.py +6 -1
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/app_settings.py +1 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/course_indexer/data_manager.py +68 -27
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/course_indexer/resource_helpers.py +3 -2
- {qcanvas-0.0.5.1a0/qcanvas/util/linkscanner → qcanvas-0.0.5.3a0/qcanvas/util/link_scanner}/canvas_link_scanner.py +1 -1
- {qcanvas-0.0.5.1a0/qcanvas/util/linkscanner → qcanvas-0.0.5.3a0/qcanvas/util/link_scanner}/canvas_media_object_scanner.py +1 -1
- {qcanvas-0.0.5.1a0/qcanvas/util/linkscanner → qcanvas-0.0.5.3a0/qcanvas/util/link_scanner}/dropbox_scanner.py +1 -1
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/task_pool.py +4 -4
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/requirements.txt +1 -1
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/.gitignore +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/README.md +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/__init__.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/db/__init__.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/db/database.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/db/db_converter_helper.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/icons/__init__.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/icons/icons.qrc +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/icons/main_icon.svg +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/icons/rc_icons.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/net/__init__.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/net/canvas/__init__.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/net/canvas/legacy_canvas_types.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/net/custom_httpx_async_transport.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/queries/__init__.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/queries/all_courses.gql +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/queries/all_courses.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/queries/canvas_course_data.gql +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/queries/canvas_course_data.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/ui/__init__.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/ui/container_item.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/ui/menu_bar/__init__.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/ui/menu_bar/grouping_preferences_menu.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/ui/menu_bar/theme_selection_menu.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/ui/viewer/__init__.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/ui/viewer/course_list.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/ui/viewer/file_list.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/ui/viewer/file_view_tab.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/__init__.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/constants.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/course_indexer/__init__.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/course_indexer/conversion_helpers.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/download_pool.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/helpers/__init__.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/helpers/canvas_sanitiser.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/helpers/file_icon_helper.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/helpers/qaction_helper.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/helpers/theme_helper.py +0 -0
- {qcanvas-0.0.5.1a0/qcanvas/util/linkscanner → qcanvas-0.0.5.3a0/qcanvas/util/link_scanner}/__init__.py +0 -0
- {qcanvas-0.0.5.1a0/qcanvas/util/linkscanner → qcanvas-0.0.5.3a0/qcanvas/util/link_scanner}/resource_scanner.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/progress_reporter.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/self_updater.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/tree_util/__init__.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/tree_util/expanding_tree.py +0 -0
- {qcanvas-0.0.5.1a0 → qcanvas-0.0.5.3a0}/qcanvas/util/tree_util/model_helpers.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
20
|
-
from qcanvas.util.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
+
# Collect assignments from the courses
|
|
211
|
+
assignments = []
|
|
210
212
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
+
# Add course assignments to the list
|
|
220
|
+
assignments.extend(await conv_helper.create_assignments(g_course, session))
|
|
219
221
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
+
# Scan assignments for resources
|
|
223
|
+
await self._scan_assignments_for_resources(assignments, session, progress_reporter)
|
|
222
224
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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=
|
|
280
|
+
items=module_pages
|
|
271
281
|
),
|
|
272
282
|
session
|
|
273
283
|
)
|
|
274
284
|
|
|
275
|
-
#
|
|
276
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
8
|
+
from qcanvas.util.link_scanner import ResourceScanner
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class CanvasMediaObjectScanner(ResourceScanner):
|
|
@@ -225,9 +225,9 @@ class TaskPool(Generic[T]):
|
|
|
225
225
|
|
|
226
226
|
return list(filter(filter_func, self._results.values()))
|
|
227
227
|
|
|
228
|
-
def
|
|
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
|
-
|
|
249
|
+
return None
|
|
250
250
|
|
|
251
251
|
return result
|
|
252
252
|
else:
|
|
253
|
-
|
|
253
|
+
return None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|