openstem-mcp 0.3.0__py3-none-any.whl
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.
- openstem/__init__.py +3 -0
- openstem/lms/__init__.py +8 -0
- openstem/lms/canvas.py +309 -0
- openstem/lms/client.py +625 -0
- openstem/lms/common.py +141 -0
- openstem/lms/feed.py +108 -0
- openstem/lms/ical.py +270 -0
- openstem/lms/moodle.py +259 -0
- openstem/lms/schools.py +258 -0
- openstem/mathexpr.py +416 -0
- openstem/output.py +58 -0
- openstem/prompts/cheatsheet.md +13 -0
- openstem/prompts/exam.md +26 -0
- openstem/prompts/figure.md +16 -0
- openstem/prompts/flashcards.md +16 -0
- openstem/prompts/lab_report.md +21 -0
- openstem/prompts/slides.md +18 -0
- openstem/prompts/solve.md +19 -0
- openstem/remote.py +512 -0
- openstem/render/__init__.py +0 -0
- openstem/render/convert.py +88 -0
- openstem/render/exam.lua +67 -0
- openstem/render/flashcards.lua +103 -0
- openstem/render/fonts.py +58 -0
- openstem/render/latex.py +352 -0
- openstem/render/pages.py +77 -0
- openstem/render/tabular.lua +39 -0
- openstem/render/tectonic_setup.py +366 -0
- openstem/sandbox.py +203 -0
- openstem/server.py +493 -0
- openstem/templates/circuits/half_wave_rectifier.tex +13 -0
- openstem/templates/circuits/opamp_inverting.tex +13 -0
- openstem/templates/circuits/opamp_noninverting.tex +12 -0
- openstem/templates/circuits/parallel_resistors.tex +13 -0
- openstem/templates/circuits/rc_series.tex +11 -0
- openstem/templates/circuits/rl_series.tex +11 -0
- openstem/templates/circuits/rlc_series_ac.tex +11 -0
- openstem/templates/circuits/two_loop_kirchhoff.tex +13 -0
- openstem/templates/circuits/voltage_divider.tex +14 -0
- openstem/templates/circuits/wheatstone_bridge.tex +19 -0
- openstem/templates/diagrams/concept_map.tex +27 -0
- openstem/templates/diagrams/feedback_control.tex +39 -0
- openstem/templates/diagrams/flowchart_decision.tex +36 -0
- openstem/templates/diagrams/hierarchy_tree.tex +26 -0
- openstem/templates/diagrams/neural_network.tex +26 -0
- openstem/templates/diagrams/pipeline_stages.tex +28 -0
- openstem/templates/diagrams/signal_chain.tex +38 -0
- openstem/templates/diagrams/state_machine.tex +26 -0
- openstem/templates/diagrams/timeline.tex +26 -0
- openstem/templates/diagrams/two_panel.tex +33 -0
- openstem/templates/math/definite_integral_area.tex +24 -0
- openstem/templates/math/function_graph.tex +26 -0
- openstem/templates/math/inscribed_angle.tex +28 -0
- openstem/templates/math/riemann_sum.tex +27 -0
- openstem/templates/math/right_triangle_trig.tex +23 -0
- openstem/templates/math/tangent_line.tex +25 -0
- openstem/templates/math/triangle_labeled.tex +20 -0
- openstem/templates/math/unit_circle.tex +27 -0
- openstem/templates/math/vectors_3d.tex +25 -0
- openstem/templates/math/venn_diagram.tex +22 -0
- openstem/templates/physics/atwood_machine.tex +37 -0
- openstem/templates/physics/hanging_two_ropes.tex +33 -0
- openstem/templates/physics/horizontal_pull_fbd.tex +31 -0
- openstem/templates/physics/incline_fbd.tex +36 -0
- openstem/templates/physics/projectile_motion.tex +36 -0
- openstem/templates/physics/simple_pendulum.tex +32 -0
- openstem/templates/physics/spring_mass.tex +28 -0
- openstem/templates/physics/uniform_circular_motion.tex +22 -0
- openstem/templates/physics/vector_addition.tex +22 -0
- openstem/templates/physics/vector_components.tex +25 -0
- openstem/texcheck.py +179 -0
- openstem/tools/__init__.py +24 -0
- openstem/tools/documents.py +174 -0
- openstem/tools/export.py +679 -0
- openstem/tools/lms.py +482 -0
- openstem/tools/molecule.py +79 -0
- openstem/tools/plot.py +149 -0
- openstem/tools/plotdata.py +179 -0
- openstem/tools/templates.py +119 -0
- openstem/tools/tikz.py +289 -0
- openstem/tools/units.py +112 -0
- openstem/tools/verify.py +276 -0
- openstem/workers/__init__.py +49 -0
- openstem/workers/convert.py +62 -0
- openstem/workers/documents.py +210 -0
- openstem/workers/molecule.py +338 -0
- openstem/workers/plot.py +580 -0
- openstem/workers/plotdata.py +635 -0
- openstem/workers/units.py +558 -0
- openstem/workers/verify.py +1489 -0
- openstem_mcp-0.3.0.dist-info/METADATA +240 -0
- openstem_mcp-0.3.0.dist-info/RECORD +95 -0
- openstem_mcp-0.3.0.dist-info/WHEEL +4 -0
- openstem_mcp-0.3.0.dist-info/entry_points.txt +2 -0
- openstem_mcp-0.3.0.dist-info/licenses/LICENSE +21 -0
openstem/__init__.py
ADDED
openstem/lms/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""학교 LMS(Canvas 계열·Moodle 계열) 읽기 전용 연결. 툴 쪽 입구는 openstem.tools.lms.
|
|
2
|
+
|
|
3
|
+
- client: 설정 읽기(학교 이름·주소·토큰·캘린더 주소), 토큰을 설정한 주소에만 싣는 HTTP(크기·시간 상한)
|
|
4
|
+
- canvas / moodle: 각 LMS의 API를 같은 모양의 dict 목록으로 바꾸는 어댑터
|
|
5
|
+
- schools: 조사한 학교별 LMS 목록(주소·플랫폼·포털 → API 주소)
|
|
6
|
+
- feed / ical: 토큰 없이 캘린더 구독 주소(.ics)로 마감 읽기
|
|
7
|
+
- common: HTML→글자, 시각 표기, 파일 이름 정리
|
|
8
|
+
"""
|
openstem/lms/canvas.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Canvas 계열 LMS(REST API v1, Bearer 토큰) 어댑터. 읽기(GET)만 한다.
|
|
2
|
+
|
|
3
|
+
에러 글은 학교마다 한국어로 바뀌어 있어("인증되지 않음", "사용자 인증 필요", "권한이 없음") 글자로 판단하지 않고
|
|
4
|
+
상태 코드·JSON 모양·WWW-Authenticate 헤더로 판단한다. 401은 두 가지다: 토큰이 틀렸거나 만료됨(Canvas는
|
|
5
|
+
WWW-Authenticate를 붙인다) / 토큰은 맞는데 그 과목·메뉴에 권한이 없음. 헤더가 없으면 /api/v1/users/self로
|
|
6
|
+
토큰이 살아 있는지 한 번 확인해 가른다.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import urljoin
|
|
15
|
+
|
|
16
|
+
from openstem.lms.client import Http, LmsError, Reply, how_to_connect
|
|
17
|
+
from openstem.lms.common import parse_time
|
|
18
|
+
|
|
19
|
+
KIND = "canvas"
|
|
20
|
+
MODULE_FILE_LOOKUPS = 200 # 모듈 대체 경로에서 파일 정보를 하나씩 물어보는 최대 개수
|
|
21
|
+
MODULE_LOOKUP_BUDGET = 25.0 # 그 조회에 쓰는 시간(초). 넘으면 이름만 돌려준다
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _zulu(dt: datetime) -> str:
|
|
25
|
+
return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _message(reply: Reply) -> str:
|
|
29
|
+
"""에러 본문의 첫 메시지(학교 언어 그대로, 판단에는 쓰지 않고 보여 주기만 한다)."""
|
|
30
|
+
data = reply.data
|
|
31
|
+
if isinstance(data, dict):
|
|
32
|
+
errors = data.get("errors")
|
|
33
|
+
if isinstance(errors, list) and errors and isinstance(errors[0], dict):
|
|
34
|
+
return str(errors[0].get("message") or "")[:200]
|
|
35
|
+
if isinstance(errors, dict):
|
|
36
|
+
return str(next(iter(errors.values()), ""))[:200]
|
|
37
|
+
return str(data.get("message") or data.get("status") or "")[:200]
|
|
38
|
+
return ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def canvas_error_body(reply: Reply) -> bool:
|
|
42
|
+
"""Canvas 에러 JSON 모양: {"errors": [...]} 또는 {"errors": {...}}."""
|
|
43
|
+
return isinstance(reply.data, dict) and isinstance(reply.data.get("errors"), (list, dict))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_CANVAS_HEADERS = ("x-canvas-meta", "x-request-context-id", "x-rate-limit-remaining")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _canvas_shaped(reply: Reply) -> bool:
|
|
50
|
+
"""Canvas 응답 모양: 사용자(dict, id), 과목 목록(course_code가 있거나 Canvas 헤더), 에러 JSON(4xx)."""
|
|
51
|
+
data = reply.data
|
|
52
|
+
if reply.status == 200:
|
|
53
|
+
if isinstance(data, dict):
|
|
54
|
+
return "id" in data
|
|
55
|
+
if isinstance(data, list):
|
|
56
|
+
courses = bool(data) and all(isinstance(c, dict) and "course_code" in c for c in data)
|
|
57
|
+
return courses or any(reply.header(h) for h in _CANVAS_HEADERS)
|
|
58
|
+
return False
|
|
59
|
+
return 400 <= reply.status < 500 and canvas_error_body(reply)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def detect(http: Http) -> bool:
|
|
63
|
+
"""Canvas면 True. 토큰이 틀려도 Canvas 에러 JSON(401·404)이면 True.
|
|
64
|
+
|
|
65
|
+
LearningX는 인증 안 된 /api/v1/users/self에 401이 아니라 404 JSON을 준다. 그 응답도 Canvas 모양이면 받고,
|
|
66
|
+
애매하면 /api/v1/courses로 한 번 더 본다.
|
|
67
|
+
"""
|
|
68
|
+
reply = http.request_json("GET", http.url("/api/v1/users/self"))
|
|
69
|
+
if _canvas_shaped(reply):
|
|
70
|
+
return True
|
|
71
|
+
reply = http.request_json("GET", http.url("/api/v1/courses"), params=[("per_page", "1")])
|
|
72
|
+
return _canvas_shaped(reply)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Canvas:
|
|
76
|
+
kind = KIND
|
|
77
|
+
|
|
78
|
+
def __init__(self, http: Http) -> None:
|
|
79
|
+
self.http = http
|
|
80
|
+
self.notes: list[str] = []
|
|
81
|
+
self._token_ok: bool | None = None
|
|
82
|
+
|
|
83
|
+
# ── 401 가르기 ──
|
|
84
|
+
|
|
85
|
+
def token_works(self) -> bool:
|
|
86
|
+
"""토큰이 살아 있는지(/api/v1/users/self가 200). 호출 한 번에 한 번만 묻는다."""
|
|
87
|
+
if self._token_ok is None:
|
|
88
|
+
reply = self.http.request_json("GET", self.http.url("/api/v1/users/self"))
|
|
89
|
+
self._token_ok = reply.status == 200 and isinstance(reply.data, dict) and "id" in reply.data
|
|
90
|
+
return self._token_ok
|
|
91
|
+
|
|
92
|
+
def token_rejected(self, reply: Reply) -> bool:
|
|
93
|
+
"""401이 "토큰이 틀림·만료"인지(권한 없음이 아니라). 글자가 아니라 헤더와 토큰 확인으로 가른다."""
|
|
94
|
+
if reply.status != 401:
|
|
95
|
+
return False
|
|
96
|
+
if reply.header("www-authenticate"):
|
|
97
|
+
return True
|
|
98
|
+
return not self.token_works()
|
|
99
|
+
|
|
100
|
+
def _fail(self, reply: Reply, what: str) -> LmsError:
|
|
101
|
+
config = self.http.config
|
|
102
|
+
if self.token_rejected(reply):
|
|
103
|
+
return LmsError("The LMS rejected the token (invalid, expired or deleted).",
|
|
104
|
+
how_to_connect(config.school, KIND), 401, "invalidtoken")
|
|
105
|
+
if reply.status in (401, 403):
|
|
106
|
+
return LmsError(f"No permission to read {what} in the LMS (the token works; this part is closed).",
|
|
107
|
+
status=reply.status)
|
|
108
|
+
if reply.status == 404:
|
|
109
|
+
return LmsError(f"The LMS has no {what} here (HTTP 404).", status=404)
|
|
110
|
+
detail = _message(reply)
|
|
111
|
+
return LmsError(f"The LMS failed to return {what} (HTTP {reply.status}){': ' + detail if detail else ''}.",
|
|
112
|
+
status=reply.status)
|
|
113
|
+
|
|
114
|
+
def _soft(self, reply: Reply) -> bool:
|
|
115
|
+
"""대체 경로로 넘어가도 되는 실패(권한 없음·메뉴 숨김·없는 API). 잘못된 토큰은 아니다."""
|
|
116
|
+
return reply.status in (401, 403, 404) and not self.token_rejected(reply)
|
|
117
|
+
|
|
118
|
+
def _get(self, path: str, what: str, params: list[tuple[str, str]] | None = None) -> Any:
|
|
119
|
+
reply = self.http.request_json("GET", self.http.url(path), params=params)
|
|
120
|
+
if reply.status != 200:
|
|
121
|
+
raise self._fail(reply, what)
|
|
122
|
+
return reply.data
|
|
123
|
+
|
|
124
|
+
def _list(self, path: str, what: str, params: list[tuple[str, str]], max_pages: int = 10) -> list[Any]:
|
|
125
|
+
items, reply = self.http.get_pages(path, params, max_pages)
|
|
126
|
+
if reply.status != 200:
|
|
127
|
+
raise self._fail(reply, what)
|
|
128
|
+
return [it for it in items if isinstance(it, dict)]
|
|
129
|
+
|
|
130
|
+
# ── 과목 ──
|
|
131
|
+
|
|
132
|
+
def courses(self) -> list[dict[str, Any]]:
|
|
133
|
+
params = [("enrollment_state", "active"), ("per_page", "50"), ("include[]", "term")]
|
|
134
|
+
out = []
|
|
135
|
+
for c in self._list("/api/v1/courses", "courses", params, max_pages=5):
|
|
136
|
+
if c.get("id") is None or not c.get("name") or c.get("access_restricted_by_date"):
|
|
137
|
+
continue
|
|
138
|
+
term = c.get("term") if isinstance(c.get("term"), dict) else {}
|
|
139
|
+
out.append({"id": c["id"], "name": str(c["name"]), "code": c.get("course_code"), "term": term.get("name")})
|
|
140
|
+
return out
|
|
141
|
+
|
|
142
|
+
# ── 마감 ──
|
|
143
|
+
|
|
144
|
+
def deadlines(self, start: datetime, end: datetime, course_id: Any = None) -> list[dict[str, Any]]:
|
|
145
|
+
params = [("start_date", _zulu(start)), ("end_date", _zulu(end)), ("per_page", "100")]
|
|
146
|
+
if course_id is not None:
|
|
147
|
+
params.append(("context_codes[]", f"course_{course_id}"))
|
|
148
|
+
items, reply = self.http.get_pages("/api/v1/planner/items", params, max_pages=3)
|
|
149
|
+
if reply.status == 200:
|
|
150
|
+
return [d for it in items if isinstance(it, dict) and (d := self._planner_item(it))]
|
|
151
|
+
if not self._soft(reply):
|
|
152
|
+
raise self._fail(reply, "deadlines")
|
|
153
|
+
# 플래너가 꺼진 학교: 다가오는 일정(기간 지정 없음, 대개 1~2주)
|
|
154
|
+
events = self._list("/api/v1/users/self/upcoming_events", "upcoming events", [], max_pages=1)
|
|
155
|
+
return [d for ev in events if (d := self._event_item(ev))]
|
|
156
|
+
|
|
157
|
+
def _abs_url(self, raw: Any) -> str | None:
|
|
158
|
+
if not isinstance(raw, str) or not raw:
|
|
159
|
+
return None
|
|
160
|
+
url = urljoin(self.http.config.base + "/", raw)
|
|
161
|
+
return url if self.http.same_origin(url) else None
|
|
162
|
+
|
|
163
|
+
def _planner_item(self, it: dict[str, Any]) -> dict[str, Any] | None:
|
|
164
|
+
kind = it.get("plannable_type")
|
|
165
|
+
if kind in ("announcement", "attendance"):
|
|
166
|
+
return None
|
|
167
|
+
p = it.get("plannable") if isinstance(it.get("plannable"), dict) else {}
|
|
168
|
+
due = parse_time(p.get("due_at") or p.get("todo_date") or it.get("plannable_date"))
|
|
169
|
+
if not due:
|
|
170
|
+
return None
|
|
171
|
+
subs = it.get("submissions")
|
|
172
|
+
submitted = bool(subs.get("submitted") or subs.get("excused")) if isinstance(subs, dict) else None
|
|
173
|
+
return {
|
|
174
|
+
"title": str(p.get("title") or p.get("name") or "(untitled)"),
|
|
175
|
+
"course": it.get("context_name"),
|
|
176
|
+
"course_id": it.get("course_id"),
|
|
177
|
+
"type": {"planner_note": "note", "discussion_topic": "discussion"}.get(kind, kind),
|
|
178
|
+
"due": due,
|
|
179
|
+
"submitted": submitted,
|
|
180
|
+
"url": self._abs_url(it.get("html_url")),
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
def _event_item(self, ev: dict[str, Any]) -> dict[str, Any] | None:
|
|
184
|
+
assignment = ev.get("assignment") if isinstance(ev.get("assignment"), dict) else {}
|
|
185
|
+
due = parse_time(assignment.get("due_at") or ev.get("start_at"))
|
|
186
|
+
if not due:
|
|
187
|
+
return None
|
|
188
|
+
code = str(ev.get("context_code") or "")
|
|
189
|
+
course_id = assignment.get("course_id") or (code[len("course_"):] if code.startswith("course_") else None)
|
|
190
|
+
submitted = assignment.get("has_submitted_submissions") if assignment else None
|
|
191
|
+
return {
|
|
192
|
+
"title": str(ev.get("title") or assignment.get("name") or "(untitled)"),
|
|
193
|
+
"course": ev.get("context_name"),
|
|
194
|
+
"course_id": course_id,
|
|
195
|
+
"type": "assignment" if assignment else str(ev.get("type") or "event"),
|
|
196
|
+
"due": due,
|
|
197
|
+
"submitted": submitted if isinstance(submitted, bool) else None,
|
|
198
|
+
"url": self._abs_url(ev.get("html_url")),
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
# ── 공지 ──
|
|
202
|
+
|
|
203
|
+
def announcements(self, courses: list[dict[str, Any]], start: datetime, end: datetime) -> list[dict[str, Any]]:
|
|
204
|
+
names = {f"course_{c['id']}": c["name"] for c in courses}
|
|
205
|
+
out: list[dict[str, Any]] = []
|
|
206
|
+
codes = list(names)
|
|
207
|
+
for i in range(0, len(codes), 20): # 주소가 너무 길어지지 않게 20과목씩
|
|
208
|
+
params = [("context_codes[]", code) for code in codes[i : i + 20]]
|
|
209
|
+
params += [("start_date", _zulu(start)), ("end_date", _zulu(end)), ("per_page", "50")]
|
|
210
|
+
for it in self._list("/api/v1/announcements", "announcements", params, max_pages=2):
|
|
211
|
+
author = it.get("author") if isinstance(it.get("author"), dict) else {}
|
|
212
|
+
out.append({
|
|
213
|
+
"course": names.get(str(it.get("context_code"))),
|
|
214
|
+
"title": str(it.get("title") or "(untitled)"),
|
|
215
|
+
"posted": parse_time(it.get("posted_at") or it.get("created_at")),
|
|
216
|
+
"author": author.get("display_name") or it.get("user_name"),
|
|
217
|
+
"html": it.get("message"),
|
|
218
|
+
"url": self._abs_url(it.get("html_url")),
|
|
219
|
+
})
|
|
220
|
+
return out
|
|
221
|
+
|
|
222
|
+
# ── 파일 ──
|
|
223
|
+
|
|
224
|
+
def files(self, course_id: Any) -> tuple[list[dict[str, Any]], str]:
|
|
225
|
+
"""(파일 목록, 출처 "files" | "modules"). 파일 메뉴를 숨긴 과목은 모듈에 걸린 파일로 대신한다."""
|
|
226
|
+
items, reply = self.http.get_pages(f"/api/v1/courses/{course_id}/files", [("per_page", "100")], 10)
|
|
227
|
+
if reply.status == 200:
|
|
228
|
+
folders = self._folders(course_id)
|
|
229
|
+
return [self._file(it, folders.get(it.get("folder_id"))) for it in items if isinstance(it, dict)], "files"
|
|
230
|
+
if not self._soft(reply):
|
|
231
|
+
raise self._fail(reply, "course files")
|
|
232
|
+
return self._files_from_modules(course_id), "modules"
|
|
233
|
+
|
|
234
|
+
def _folders(self, course_id: Any) -> dict[Any, str | None]:
|
|
235
|
+
items, reply = self.http.get_pages(f"/api/v1/courses/{course_id}/folders", [("per_page", "100")], 3)
|
|
236
|
+
if reply.status != 200:
|
|
237
|
+
return {}
|
|
238
|
+
out: dict[Any, str | None] = {}
|
|
239
|
+
for f in items:
|
|
240
|
+
if isinstance(f, dict) and f.get("id") is not None:
|
|
241
|
+
name = str(f.get("full_name") or f.get("name") or "")
|
|
242
|
+
if name.startswith("course files"):
|
|
243
|
+
name = name[len("course files") :].lstrip("/")
|
|
244
|
+
out[f["id"]] = name or None
|
|
245
|
+
return out
|
|
246
|
+
|
|
247
|
+
@staticmethod
|
|
248
|
+
def _file(it: dict[str, Any], folder: str | None) -> dict[str, Any]:
|
|
249
|
+
return {
|
|
250
|
+
"id": it.get("id"),
|
|
251
|
+
"name": str(it.get("display_name") or it.get("filename") or ""),
|
|
252
|
+
"size": it.get("size") if isinstance(it.get("size"), int) else None,
|
|
253
|
+
"updated": parse_time(it.get("updated_at") or it.get("modified_at") or it.get("created_at")),
|
|
254
|
+
"folder": folder,
|
|
255
|
+
"locked": bool(it.get("locked_for_user") or it.get("hidden_for_user")),
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
def _files_from_modules(self, course_id: Any) -> list[dict[str, Any]]:
|
|
259
|
+
params = [("include[]", "items"), ("per_page", "50")]
|
|
260
|
+
found: dict[Any, tuple[str, str | None]] = {}
|
|
261
|
+
modules, reply = self.http.get_pages(f"/api/v1/courses/{course_id}/modules", params, 5)
|
|
262
|
+
if reply.status != 200:
|
|
263
|
+
if self._soft(reply):
|
|
264
|
+
return [] # 파일 메뉴도 모듈도 안 보이는 과목
|
|
265
|
+
raise self._fail(reply, "course modules")
|
|
266
|
+
for module in (m for m in modules if isinstance(m, dict)):
|
|
267
|
+
items = module.get("items")
|
|
268
|
+
if not isinstance(items, list) and isinstance(module.get("id"), int):
|
|
269
|
+
items, reply = self.http.get_pages(
|
|
270
|
+
f"/api/v1/courses/{course_id}/modules/{module['id']}/items", [("per_page", "100")], 3
|
|
271
|
+
)
|
|
272
|
+
if reply.status != 200:
|
|
273
|
+
items = []
|
|
274
|
+
for item in items or []:
|
|
275
|
+
if isinstance(item, dict) and item.get("type") == "File" and isinstance(item.get("content_id"), int):
|
|
276
|
+
found.setdefault(item["content_id"], (str(item.get("title") or ""), module.get("name")))
|
|
277
|
+
out = []
|
|
278
|
+
deadline = time.monotonic() + MODULE_LOOKUP_BUDGET
|
|
279
|
+
for n, (file_id, (title, module_name)) in enumerate(found.items()):
|
|
280
|
+
info = None
|
|
281
|
+
if n < MODULE_FILE_LOOKUPS and time.monotonic() < deadline:
|
|
282
|
+
reply = self.http.request_json("GET", self.http.url(f"/api/v1/files/{file_id}"))
|
|
283
|
+
if reply.status == 200 and isinstance(reply.data, dict):
|
|
284
|
+
info = self._file(reply.data, module_name)
|
|
285
|
+
elif self.token_rejected(reply):
|
|
286
|
+
raise self._fail(reply, "files")
|
|
287
|
+
out.append(info or {"id": file_id, "name": title, "size": None, "updated": None,
|
|
288
|
+
"folder": module_name, "locked": False})
|
|
289
|
+
return out
|
|
290
|
+
|
|
291
|
+
def file_for_download(self, course_id: Any, file_id: str) -> dict[str, Any]:
|
|
292
|
+
reply = self.http.request_json("GET", self.http.url(f"/api/v1/courses/{course_id}/files/{file_id}"))
|
|
293
|
+
if reply.status in (401, 403) and not self.token_rejected(reply):
|
|
294
|
+
# 파일 메뉴를 숨긴 과목: 모듈에 걸린 파일은 파일 id로 직접 읽을 수 있다(404면 이 과목 파일이 아니다)
|
|
295
|
+
reply = self.http.request_json("GET", self.http.url(f"/api/v1/files/{file_id}"))
|
|
296
|
+
if reply.status != 200 or not isinstance(reply.data, dict):
|
|
297
|
+
if reply.status == 404:
|
|
298
|
+
raise LmsError(f"No file with id {file_id} in this course.",
|
|
299
|
+
'Call action="files" for this course and use an id from that list.', 404)
|
|
300
|
+
raise self._fail(reply, "this file")
|
|
301
|
+
info = reply.data
|
|
302
|
+
if info.get("locked_for_user") or not info.get("url"):
|
|
303
|
+
raise LmsError("This file is locked in the LMS (not available yet, or the teacher hid it).")
|
|
304
|
+
return {
|
|
305
|
+
"name": str(info.get("display_name") or info.get("filename") or f"file-{file_id}"),
|
|
306
|
+
"size": info.get("size") if isinstance(info.get("size"), int) else None,
|
|
307
|
+
"url": urljoin(self.http.config.base + "/", str(info["url"])),
|
|
308
|
+
"auth": True, # 같은 origin일 때만 Bearer 헤더(Http.download가 판단)
|
|
309
|
+
}
|