agent2learn 0.1.2__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.
- agent2learn/__init__.py +3 -0
- agent2learn/_release.py +19 -0
- agent2learn/aipolicy.py +182 -0
- agent2learn/api.py +590 -0
- agent2learn/audit.py +358 -0
- agent2learn/auth/__init__.py +282 -0
- agent2learn/auth/cdp.py +1067 -0
- agent2learn/auth/paste.py +378 -0
- agent2learn/calendar.py +525 -0
- agent2learn/calibrate.py +347 -0
- agent2learn/check.py +1091 -0
- agent2learn/cli.py +2039 -0
- agent2learn/clock.py +39 -0
- agent2learn/config.py +205 -0
- agent2learn/console.py +229 -0
- agent2learn/convert.py +1223 -0
- agent2learn/doctor.py +1167 -0
- agent2learn/errors.py +32 -0
- agent2learn/ground.py +735 -0
- agent2learn/index.py +614 -0
- agent2learn/ingest.py +3229 -0
- agent2learn/locations.py +247 -0
- agent2learn/outlines.py +754 -0
- agent2learn/paths.py +683 -0
- agent2learn/pipeline.py +392 -0
- agent2learn/privacy.py +1123 -0
- agent2learn/schools/__init__.py +29 -0
- agent2learn/schools/_base.py +194 -0
- agent2learn/schools/generic.py +78 -0
- agent2learn/schools/uwaterloo.py +66 -0
- agent2learn/session.py +373 -0
- agent2learn/skills.py +1081 -0
- agent2learn/snapshot.py +399 -0
- agent2learn/submit.py +1047 -0
- agent2learn/transactions.py +157 -0
- agent2learn/upgrade.py +288 -0
- agent2learn/vault.py +1134 -0
- agent2learn-0.1.2.data/data/a2l-coursework/SKILL.md +52 -0
- agent2learn-0.1.2.data/data/a2l-setup/SKILL.md +27 -0
- agent2learn-0.1.2.data/data/a2l-study/SKILL.md +27 -0
- agent2learn-0.1.2.data/data/a2l-sync/SKILL.md +30 -0
- agent2learn-0.1.2.dist-info/METADATA +186 -0
- agent2learn-0.1.2.dist-info/RECORD +46 -0
- agent2learn-0.1.2.dist-info/WHEEL +4 -0
- agent2learn-0.1.2.dist-info/entry_points.txt +3 -0
- agent2learn-0.1.2.dist-info/licenses/LICENSE +202 -0
agent2learn/submit.py
ADDED
|
@@ -0,0 +1,1047 @@
|
|
|
1
|
+
"""Upload one finished local file to a selected LEARN Dropbox, after the human confirms it.
|
|
2
|
+
|
|
3
|
+
This is the only mutating LEARN action in Agent2Learn, and it is disabled in this build. Two
|
|
4
|
+
independent gates stand in front of it, and neither is sufficient alone:
|
|
5
|
+
|
|
6
|
+
1. ``a2l enable-submit`` — a one-time local acknowledgement that stores ``submit_enabled`` and
|
|
7
|
+
uploads nothing.
|
|
8
|
+
2. A fresh, per-file confirmation typed at a controlling terminal, containing a random code shown
|
|
9
|
+
only in that preview and the exact filename.
|
|
10
|
+
|
|
11
|
+
On top of both, :data:`agent2learn._release.SUBMISSION_AVAILABLE` gates the whole feature at build
|
|
12
|
+
time. Nothing here reads an environment variable, accepts a ``--yes``/``--force`` style flag, or
|
|
13
|
+
consumes piped input as consent, and there is no automatic retry: one confirmation authorises at
|
|
14
|
+
most one POST, and the code is consumed whether that POST succeeds or fails.
|
|
15
|
+
|
|
16
|
+
**What the terminal gate does and does not prove.** A controlling TTY proves interactivity, not
|
|
17
|
+
human identity. Hostile software that already controls the terminal can synthesise keystrokes.
|
|
18
|
+
The gate prevents accidental and ordinary unattended mutation; what keeps an *agent* from
|
|
19
|
+
submitting on the student's behalf is the skill contract requiring it to stop at the preview and
|
|
20
|
+
hand back control. This is not cryptographic proof of a human action, and must never be described
|
|
21
|
+
as one.
|
|
22
|
+
|
|
23
|
+
**Only a proven route.** v0.1 uses the documented individual-student route
|
|
24
|
+
``…/dropbox/folders/{folder}/submissions/mysubmissions/`` and nothing else. There is no
|
|
25
|
+
``mypost``, and no fallback between mutating endpoints: an instance whose LE version does not
|
|
26
|
+
support the route family keeps submission disabled. A group Dropbox is recognised only so the
|
|
27
|
+
preview can name it and refuse before any confirmation.
|
|
28
|
+
|
|
29
|
+
**Verification, not optimism.** After the single POST the submissions list is read back and must
|
|
30
|
+
contain exactly one record matching the folder, filename, byte size, and a timestamp after the
|
|
31
|
+
confirmation. A stale prior file, a duplicate name, a size mismatch, an ambiguous list, or an
|
|
32
|
+
unreadable read-back is a failed verification that reports uncertainty and sends the student to
|
|
33
|
+
LEARN — it never retries.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import re
|
|
41
|
+
import secrets
|
|
42
|
+
import stat
|
|
43
|
+
import string
|
|
44
|
+
import tempfile
|
|
45
|
+
from collections.abc import Iterator
|
|
46
|
+
from dataclasses import dataclass, replace
|
|
47
|
+
from datetime import UTC, datetime, timedelta
|
|
48
|
+
from hashlib import sha256
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
from typing import Protocol
|
|
51
|
+
|
|
52
|
+
from agent2learn import _release, clock, config, paths
|
|
53
|
+
from agent2learn import index as course_index
|
|
54
|
+
from agent2learn.errors import A2LError
|
|
55
|
+
from agent2learn.vault import Vault
|
|
56
|
+
|
|
57
|
+
SUBMISSION_RECEIPT_VERSION = 1
|
|
58
|
+
CONFIRMATION_TTL_SECONDS = 300
|
|
59
|
+
# Built from its intent rather than written out: the ambiguous glyphs are excluded so a user
|
|
60
|
+
# reading a code aloud or off a screen cannot confuse I/1 or O/0. Writing the 32 characters as
|
|
61
|
+
# a literal also reads as a high-entropy secret to scanners, which it is not.
|
|
62
|
+
CODE_ALPHABET = "".join(
|
|
63
|
+
character for character in string.ascii_uppercase + string.digits if character not in "IO01"
|
|
64
|
+
)
|
|
65
|
+
CODE_LENGTH = 6
|
|
66
|
+
MIN_LE_VERSION = (1, 82)
|
|
67
|
+
STAGING_DIRNAME = "submit-staging"
|
|
68
|
+
RECEIPTS_DIRNAME = "submissions"
|
|
69
|
+
|
|
70
|
+
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
|
|
71
|
+
_FILENAME_ESCAPE = {'"': "%22", "\\": "%5C", "\r": "", "\n": ""}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class SubmissionRefused(A2LError):
|
|
75
|
+
"""A submission was refused before any mutating request was attempted."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SubmissionUnverified(A2LError):
|
|
79
|
+
"""A POST was attempted but read-back could not prove the exact expected record."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class SubmissionCapability:
|
|
84
|
+
"""Explicit permission for this process to attempt a mutating upload."""
|
|
85
|
+
|
|
86
|
+
available: bool
|
|
87
|
+
reason: str
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def release_capability() -> SubmissionCapability:
|
|
91
|
+
"""Return the capability this build ships with."""
|
|
92
|
+
|
|
93
|
+
return SubmissionCapability(
|
|
94
|
+
available=_release.SUBMISSION_AVAILABLE,
|
|
95
|
+
reason="upload is disabled in this build until the supervised release gate passes",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class SubmissionTransport(Protocol):
|
|
100
|
+
"""The narrow transport surface a submission needs."""
|
|
101
|
+
|
|
102
|
+
def get_json(self, path: str) -> object: ...
|
|
103
|
+
|
|
104
|
+
def get_json_once(self, path: str) -> object: ...
|
|
105
|
+
|
|
106
|
+
def post_once(
|
|
107
|
+
self, path: str, body: bytes | SubmissionBody, *, content_type: str
|
|
108
|
+
) -> object: ...
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class SubmissionBody:
|
|
113
|
+
"""A repeatable multipart body streamed from the private staging file.
|
|
114
|
+
|
|
115
|
+
The body exposes its exact byte length for the API client's explicit ``Content-Length`` while
|
|
116
|
+
keeping the potentially large file payload out of memory. Iteration is repeatable so a test
|
|
117
|
+
transport can inspect it and Requests can consume it once for the actual POST.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
path: Path
|
|
121
|
+
filename: str
|
|
122
|
+
size: int
|
|
123
|
+
boundary: str
|
|
124
|
+
root: Path | None = None
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def content_length(self) -> int:
|
|
128
|
+
return len(self._prefix()) + self.size + len(self._suffix())
|
|
129
|
+
|
|
130
|
+
def __iter__(self) -> Iterator[bytes]:
|
|
131
|
+
yield self._prefix()
|
|
132
|
+
if self.root is not None and paths.has_link_component(self.path, root=self.root):
|
|
133
|
+
raise SubmissionRefused("the staged path contains a symlink or junction")
|
|
134
|
+
if paths.is_link(self.path):
|
|
135
|
+
raise SubmissionRefused("the staged path contains a symlink or junction")
|
|
136
|
+
descriptor = os.open(
|
|
137
|
+
os.fspath(paths.long_path(self.path)), os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
|
138
|
+
)
|
|
139
|
+
try:
|
|
140
|
+
file_stat = os.fstat(descriptor)
|
|
141
|
+
if not stat.S_ISREG(file_stat.st_mode) or getattr(file_stat, "st_nlink", 1) != 1:
|
|
142
|
+
raise SubmissionRefused("the staged file is no longer a private regular file")
|
|
143
|
+
with os.fdopen(descriptor, "rb") as handle:
|
|
144
|
+
descriptor = -1
|
|
145
|
+
yield from iter(lambda: handle.read(1024 * 1024), b"")
|
|
146
|
+
finally:
|
|
147
|
+
if descriptor >= 0:
|
|
148
|
+
os.close(descriptor)
|
|
149
|
+
yield self._suffix()
|
|
150
|
+
|
|
151
|
+
def _prefix(self) -> bytes:
|
|
152
|
+
safe = safe_filename(self.filename)
|
|
153
|
+
descriptor = json.dumps({"Text": "", "HTML": None}, separators=(",", ":"))
|
|
154
|
+
marker = f"--{self.boundary}".encode()
|
|
155
|
+
return (
|
|
156
|
+
b"\r\n".join(
|
|
157
|
+
[
|
|
158
|
+
marker,
|
|
159
|
+
b'Content-Disposition: form-data; name=""',
|
|
160
|
+
b"Content-Type: application/json",
|
|
161
|
+
b"",
|
|
162
|
+
descriptor.encode("utf-8"),
|
|
163
|
+
marker,
|
|
164
|
+
f'Content-Disposition: form-data; name=""; filename="{safe}"'.encode(),
|
|
165
|
+
b"Content-Type: application/octet-stream",
|
|
166
|
+
b"",
|
|
167
|
+
]
|
|
168
|
+
)
|
|
169
|
+
+ b"\r\n"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def _suffix(self) -> bytes:
|
|
173
|
+
return f"\r\n--{self.boundary}--\r\n".encode()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass(frozen=True)
|
|
177
|
+
class SubmissionTarget:
|
|
178
|
+
"""A resolved Dropbox folder and the exact routes that would be used."""
|
|
179
|
+
|
|
180
|
+
course: str
|
|
181
|
+
course_code: str
|
|
182
|
+
org_unit_id: int
|
|
183
|
+
folder_id: int
|
|
184
|
+
folder_name: str
|
|
185
|
+
endpoint: str
|
|
186
|
+
readback: str
|
|
187
|
+
group_submission: bool
|
|
188
|
+
group_note: str | None
|
|
189
|
+
upload_verified: bool
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass(frozen=True)
|
|
193
|
+
class StagedFile:
|
|
194
|
+
"""A private copy of the exact bytes that would be sent."""
|
|
195
|
+
|
|
196
|
+
path: Path
|
|
197
|
+
filename: str
|
|
198
|
+
sha256: str
|
|
199
|
+
size: int
|
|
200
|
+
staged_at: str
|
|
201
|
+
expires_at: str
|
|
202
|
+
selected_display: str
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass
|
|
206
|
+
class SubmissionPreview:
|
|
207
|
+
"""A complete preview plus the one-shot confirmation it authorises."""
|
|
208
|
+
|
|
209
|
+
target: SubmissionTarget
|
|
210
|
+
staged: StagedFile
|
|
211
|
+
code: str
|
|
212
|
+
consumed: bool = False
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def phrase(self) -> str:
|
|
216
|
+
"""The exact phrase the human must type: the shown code and the exact filename."""
|
|
217
|
+
return f"UPLOAD {self.code} {self.staged.filename}"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@dataclass(frozen=True)
|
|
221
|
+
class SubmissionReceipt:
|
|
222
|
+
"""A minimal, privacy-bounded record of one attempt, verified or not."""
|
|
223
|
+
|
|
224
|
+
course: str
|
|
225
|
+
folder_id: int
|
|
226
|
+
filename: str
|
|
227
|
+
sha256: str
|
|
228
|
+
size: int
|
|
229
|
+
location: str
|
|
230
|
+
selected_path: str | None
|
|
231
|
+
confirmed_at: str
|
|
232
|
+
post_attempted: bool
|
|
233
|
+
post_at: str | None
|
|
234
|
+
readback_at: str | None
|
|
235
|
+
http_status_class: str
|
|
236
|
+
completed_at: str
|
|
237
|
+
status: str
|
|
238
|
+
outcome: str
|
|
239
|
+
receipt_version: int = SUBMISSION_RECEIPT_VERSION
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def require_available(
|
|
243
|
+
cfg: config.Config | None = None, *, capability: SubmissionCapability | None = None
|
|
244
|
+
) -> None:
|
|
245
|
+
"""Refuse an upload before anything else happens, including authentication.
|
|
246
|
+
|
|
247
|
+
Checked first so a build with uploads disabled never sends the user through a sign-in flow for
|
|
248
|
+
a feature it will refuse anyway: "run: a2l auth" would be a false next action.
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
permission = capability or release_capability()
|
|
252
|
+
if not permission.available:
|
|
253
|
+
raise SubmissionRefused(permission.reason)
|
|
254
|
+
if cfg is not None and not cfg.submit_enabled:
|
|
255
|
+
raise SubmissionRefused("submission is not acknowledged · run: a2l enable-submit")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def enable_submit(
|
|
259
|
+
cfg: config.Config, *, capability: SubmissionCapability | None = None
|
|
260
|
+
) -> config.Config:
|
|
261
|
+
"""Record the one-time local acknowledgement. This never uploads anything."""
|
|
262
|
+
|
|
263
|
+
require_available(capability=capability)
|
|
264
|
+
updated = replace(cfg, submit_enabled=True)
|
|
265
|
+
config.save(updated)
|
|
266
|
+
return updated
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def build_submission_body(filename: str, payload: bytes, *, boundary: str) -> bytes:
|
|
270
|
+
"""Build the documented ``multipart/mixed`` body: JSON RichText part first, then the file.
|
|
271
|
+
|
|
272
|
+
The empty ``name=""`` on the file part is what D2L documents, not an oversight. A filename
|
|
273
|
+
containing a quote or a backslash is percent-escaped rather than passed through, and a control
|
|
274
|
+
character is refused outright: a header a server parses must never be attacker-shaped by a
|
|
275
|
+
filename on disk.
|
|
276
|
+
"""
|
|
277
|
+
|
|
278
|
+
if not isinstance(payload, bytes):
|
|
279
|
+
raise ValueError("submission payload must be bytes")
|
|
280
|
+
# Reuse the streaming framing so the legacy helper and the production upload have byte-for-byte
|
|
281
|
+
# identical multipart syntax. ``SubmissionBody`` only opens its path during iteration; this
|
|
282
|
+
# in-memory compatibility helper supplies a temporary one-shot payload instead.
|
|
283
|
+
descriptor = json.dumps({"Text": "", "HTML": None}, separators=(",", ":"))
|
|
284
|
+
marker = f"--{boundary}".encode()
|
|
285
|
+
safe_name = safe_filename(filename)
|
|
286
|
+
prefix = b"\r\n".join(
|
|
287
|
+
[
|
|
288
|
+
marker,
|
|
289
|
+
b'Content-Disposition: form-data; name=""',
|
|
290
|
+
b"Content-Type: application/json",
|
|
291
|
+
b"",
|
|
292
|
+
descriptor.encode("utf-8"),
|
|
293
|
+
marker,
|
|
294
|
+
(f'Content-Disposition: form-data; name=""; filename="{safe_name}"'.encode()),
|
|
295
|
+
b"Content-Type: application/octet-stream",
|
|
296
|
+
b"",
|
|
297
|
+
]
|
|
298
|
+
)
|
|
299
|
+
return prefix + b"\r\n" + payload + f"\r\n--{boundary}--\r\n".encode()
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def safe_filename(filename: str) -> str:
|
|
303
|
+
"""Return a filename safe to place in a multipart header, refusing control characters."""
|
|
304
|
+
|
|
305
|
+
if not isinstance(filename, str) or not filename.strip():
|
|
306
|
+
raise SubmissionRefused("submission filename must not be empty")
|
|
307
|
+
if _CONTROL.search(filename):
|
|
308
|
+
raise SubmissionRefused("submission filename contains a control character")
|
|
309
|
+
escaped = filename
|
|
310
|
+
for character, replacement in _FILENAME_ESCAPE.items():
|
|
311
|
+
escaped = escaped.replace(character, replacement)
|
|
312
|
+
return escaped
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def prepare(
|
|
316
|
+
vault: Vault,
|
|
317
|
+
client: SubmissionTransport,
|
|
318
|
+
cfg: config.Config,
|
|
319
|
+
*,
|
|
320
|
+
course: str,
|
|
321
|
+
item: str,
|
|
322
|
+
file: Path,
|
|
323
|
+
le_version: str | None,
|
|
324
|
+
capability: SubmissionCapability | None = None,
|
|
325
|
+
) -> SubmissionPreview:
|
|
326
|
+
"""Resolve the exact target, stage the exact bytes, and build the complete preview.
|
|
327
|
+
|
|
328
|
+
Staging happens before the preview so the bytes a human approves are the bytes that would be
|
|
329
|
+
sent. Replacing or editing the original afterwards cannot change them.
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
require_available(cfg, capability=capability)
|
|
333
|
+
# Preparation is a lifecycle boundary: stale opaque parts are removed before a new preview,
|
|
334
|
+
# and a linked staging directory fails closed instead of redirecting bytes outside the vault.
|
|
335
|
+
cleanup_staging(vault)
|
|
336
|
+
|
|
337
|
+
selected = Path(file).expanduser()
|
|
338
|
+
if not paths.long_path(selected).is_file():
|
|
339
|
+
raise SubmissionRefused("the file to submit does not exist")
|
|
340
|
+
if paths.is_link(selected):
|
|
341
|
+
raise SubmissionRefused("refusing to submit through a symlink")
|
|
342
|
+
# Validate the header identity before creating any staging bytes; a control character in a
|
|
343
|
+
# local filename must be a pre-upload refusal, not an attempted request that later fails while
|
|
344
|
+
# the multipart body is being streamed.
|
|
345
|
+
safe_filename(selected.name)
|
|
346
|
+
|
|
347
|
+
course_dir = course_index.resolve_course(vault, course)
|
|
348
|
+
if paths.has_link_component(course_dir, root=vault.root):
|
|
349
|
+
raise SubmissionRefused("course path contains a symlink or junction")
|
|
350
|
+
target = _resolve_target(client, vault, course_dir, item, le_version)
|
|
351
|
+
if target.group_submission:
|
|
352
|
+
raise SubmissionRefused(
|
|
353
|
+
f"{target.group_note} · group submission is unsupported in v0.1; submit in LEARN"
|
|
354
|
+
)
|
|
355
|
+
staged = _stage(vault, selected)
|
|
356
|
+
try:
|
|
357
|
+
return SubmissionPreview(target=target, staged=staged, code=_code())
|
|
358
|
+
except BaseException:
|
|
359
|
+
# Keep the post-staging lifecycle closed even if code generation or preview construction
|
|
360
|
+
# fails before a ``SubmissionPreview`` exists for the caller to discard.
|
|
361
|
+
_discard(staged)
|
|
362
|
+
raise
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def render_preview(preview: SubmissionPreview) -> str:
|
|
366
|
+
"""Render the complete preview, including exactly what the human must type."""
|
|
367
|
+
|
|
368
|
+
target = preview.target
|
|
369
|
+
staged = preview.staged
|
|
370
|
+
route = (
|
|
371
|
+
"resolved and upload-verified"
|
|
372
|
+
if target.upload_verified
|
|
373
|
+
else "resolved, not upload-verified"
|
|
374
|
+
)
|
|
375
|
+
return "\n".join(
|
|
376
|
+
[
|
|
377
|
+
"Submission preview — nothing has been uploaded yet.",
|
|
378
|
+
"",
|
|
379
|
+
f"course: {target.course_code} ({target.course})",
|
|
380
|
+
f"folder: {target.folder_name} (id {target.folder_id})",
|
|
381
|
+
f"file: {staged.filename}",
|
|
382
|
+
f"selected: {staged.selected_display}",
|
|
383
|
+
f"size: {staged.size} bytes",
|
|
384
|
+
f"sha256: {staged.sha256}",
|
|
385
|
+
f"endpoint: POST {target.endpoint} [{route}]",
|
|
386
|
+
f"read-back: GET {target.readback}",
|
|
387
|
+
f"staged: {staged.staged_at} (expires {staged.expires_at})",
|
|
388
|
+
"",
|
|
389
|
+
"This is the only action in Agent2Learn that changes anything in LEARN. It uploads",
|
|
390
|
+
"exactly the staged bytes above, once. It does not submit on your behalf again, and it",
|
|
391
|
+
"never retries by itself.",
|
|
392
|
+
"",
|
|
393
|
+
"If you are an agent: stop here and return control to the student. Do not type, relay,",
|
|
394
|
+
"or synthesise the phrase below.",
|
|
395
|
+
"",
|
|
396
|
+
f"To upload, type exactly: {preview.phrase}",
|
|
397
|
+
"",
|
|
398
|
+
]
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def confirm_and_upload(
|
|
403
|
+
vault: Vault,
|
|
404
|
+
client: SubmissionTransport,
|
|
405
|
+
preview: SubmissionPreview,
|
|
406
|
+
*,
|
|
407
|
+
phrase: str,
|
|
408
|
+
interactive: bool,
|
|
409
|
+
capability: SubmissionCapability | None = None,
|
|
410
|
+
) -> SubmissionReceipt:
|
|
411
|
+
"""Consume the one-shot confirmation and, only then, attempt exactly one POST."""
|
|
412
|
+
|
|
413
|
+
permission = capability or release_capability()
|
|
414
|
+
try:
|
|
415
|
+
if not permission.available:
|
|
416
|
+
raise SubmissionRefused(permission.reason)
|
|
417
|
+
if not interactive:
|
|
418
|
+
raise SubmissionRefused(
|
|
419
|
+
"refusing to upload without a controlling terminal; the preview above is final"
|
|
420
|
+
)
|
|
421
|
+
if preview.consumed:
|
|
422
|
+
raise SubmissionRefused("this confirmation code was already used; run submit again")
|
|
423
|
+
if _expired(preview.staged):
|
|
424
|
+
raise SubmissionRefused("this confirmation expired; run submit again")
|
|
425
|
+
if not isinstance(phrase, str) or phrase.strip() != preview.phrase:
|
|
426
|
+
raise SubmissionRefused("confirmation phrase did not match; nothing was uploaded")
|
|
427
|
+
except SubmissionRefused:
|
|
428
|
+
preview.consumed = True
|
|
429
|
+
_discard(preview.staged)
|
|
430
|
+
raise
|
|
431
|
+
|
|
432
|
+
preview.consumed = True
|
|
433
|
+
confirmed_at = clock.stamp()
|
|
434
|
+
attempted = False
|
|
435
|
+
post_at: str | None = None
|
|
436
|
+
readback_at: str | None = None
|
|
437
|
+
http_status_class = "unknown"
|
|
438
|
+
outcome: str | None = None
|
|
439
|
+
try:
|
|
440
|
+
_validate_staged(vault, preview.staged)
|
|
441
|
+
boundary = _boundary()
|
|
442
|
+
body = SubmissionBody(
|
|
443
|
+
path=preview.staged.path,
|
|
444
|
+
filename=preview.staged.filename,
|
|
445
|
+
size=preview.staged.size,
|
|
446
|
+
boundary=boundary,
|
|
447
|
+
root=vault.root,
|
|
448
|
+
)
|
|
449
|
+
post_at = clock.stamp()
|
|
450
|
+
attempted = True
|
|
451
|
+
response = client.post_once(
|
|
452
|
+
preview.target.endpoint,
|
|
453
|
+
body,
|
|
454
|
+
content_type=f"multipart/mixed; boundary={boundary}",
|
|
455
|
+
)
|
|
456
|
+
try:
|
|
457
|
+
status = getattr(response, "status_code", 0)
|
|
458
|
+
http_status_class = _status_class(status)
|
|
459
|
+
if not 200 <= int(status) < 300:
|
|
460
|
+
raise SubmissionUnverified(
|
|
461
|
+
f"upload returned status {status}; check LEARN before trying again"
|
|
462
|
+
)
|
|
463
|
+
finally:
|
|
464
|
+
close = getattr(response, "close", None)
|
|
465
|
+
if callable(close):
|
|
466
|
+
close()
|
|
467
|
+
readback_at = clock.stamp()
|
|
468
|
+
outcome = _verify(client, preview, confirmed_at)
|
|
469
|
+
except SubmissionUnverified as exc:
|
|
470
|
+
if attempted:
|
|
471
|
+
receipt = _receipt(
|
|
472
|
+
preview,
|
|
473
|
+
confirmed_at,
|
|
474
|
+
post_attempted=True,
|
|
475
|
+
post_at=post_at,
|
|
476
|
+
readback_at=readback_at,
|
|
477
|
+
http_status_class=http_status_class,
|
|
478
|
+
status="verification_unknown",
|
|
479
|
+
outcome=str(exc),
|
|
480
|
+
)
|
|
481
|
+
_write_receipt(vault, receipt)
|
|
482
|
+
raise
|
|
483
|
+
except BaseException as exc:
|
|
484
|
+
if attempted:
|
|
485
|
+
receipt = _receipt(
|
|
486
|
+
preview,
|
|
487
|
+
confirmed_at,
|
|
488
|
+
post_attempted=True,
|
|
489
|
+
post_at=post_at,
|
|
490
|
+
readback_at=readback_at,
|
|
491
|
+
http_status_class=http_status_class,
|
|
492
|
+
status="verification_unknown",
|
|
493
|
+
outcome=(
|
|
494
|
+
"upload attempt outcome could not be verified; inspect LEARN before "
|
|
495
|
+
"resubmitting"
|
|
496
|
+
),
|
|
497
|
+
)
|
|
498
|
+
_write_receipt(vault, receipt)
|
|
499
|
+
raise SubmissionUnverified(
|
|
500
|
+
"upload was attempted but could not verify the outcome; inspect LEARN before "
|
|
501
|
+
"resubmitting"
|
|
502
|
+
) from exc
|
|
503
|
+
raise
|
|
504
|
+
finally:
|
|
505
|
+
_discard(preview.staged)
|
|
506
|
+
|
|
507
|
+
assert outcome is not None
|
|
508
|
+
receipt = _receipt(
|
|
509
|
+
preview,
|
|
510
|
+
confirmed_at,
|
|
511
|
+
post_attempted=True,
|
|
512
|
+
post_at=post_at,
|
|
513
|
+
readback_at=readback_at,
|
|
514
|
+
http_status_class=http_status_class,
|
|
515
|
+
status="verified",
|
|
516
|
+
outcome=outcome,
|
|
517
|
+
)
|
|
518
|
+
_write_receipt(vault, receipt)
|
|
519
|
+
return receipt
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def cleanup_staging(vault: Vault, *, now: datetime | None = None) -> int:
|
|
523
|
+
"""Remove expired staged files, scanning only the exact staging directory."""
|
|
524
|
+
|
|
525
|
+
directory = _staging_dir(vault)
|
|
526
|
+
if paths.has_link_component(directory, root=vault.root):
|
|
527
|
+
raise A2LError("submission staging path contains a symlink or junction")
|
|
528
|
+
moment = now or clock.now()
|
|
529
|
+
removed = 0
|
|
530
|
+
try:
|
|
531
|
+
entries = sorted(paths.long_path(directory).iterdir())
|
|
532
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
533
|
+
return 0
|
|
534
|
+
except OSError as exc:
|
|
535
|
+
raise A2LError("submission staging is unreadable") from exc
|
|
536
|
+
for entry in entries:
|
|
537
|
+
if entry.parent != paths.long_path(directory) or entry.is_dir():
|
|
538
|
+
continue
|
|
539
|
+
if paths.is_link(entry):
|
|
540
|
+
continue
|
|
541
|
+
try:
|
|
542
|
+
age = moment.timestamp() - entry.stat().st_mtime
|
|
543
|
+
except OSError:
|
|
544
|
+
continue
|
|
545
|
+
if age >= CONFIRMATION_TTL_SECONDS:
|
|
546
|
+
_remove(entry)
|
|
547
|
+
removed += 1
|
|
548
|
+
return removed
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _resolve_target(
|
|
552
|
+
client: SubmissionTransport,
|
|
553
|
+
vault: Vault,
|
|
554
|
+
course_dir: Path,
|
|
555
|
+
item: str,
|
|
556
|
+
le_version: str | None,
|
|
557
|
+
) -> SubmissionTarget:
|
|
558
|
+
org_unit_id = _org_unit(course_dir)
|
|
559
|
+
_require_supported_le(le_version)
|
|
560
|
+
folders = client.get_json(f"/d2l/api/le/{le_version}/{org_unit_id}/dropbox/folders/")
|
|
561
|
+
if not isinstance(folders, list):
|
|
562
|
+
raise SubmissionRefused("the Dropbox folder list was not readable")
|
|
563
|
+
wanted = _compact(item)
|
|
564
|
+
matches = [
|
|
565
|
+
folder
|
|
566
|
+
for folder in folders
|
|
567
|
+
if isinstance(folder, dict) and _compact(str(folder.get("Name") or "")) == wanted
|
|
568
|
+
]
|
|
569
|
+
if len(matches) != 1:
|
|
570
|
+
raise SubmissionRefused(
|
|
571
|
+
"name the Dropbox folder exactly; it did not match exactly one folder"
|
|
572
|
+
)
|
|
573
|
+
folder = matches[0]
|
|
574
|
+
folder_id = folder.get("Id")
|
|
575
|
+
if isinstance(folder_id, bool) or not isinstance(folder_id, int):
|
|
576
|
+
raise SubmissionRefused("the Dropbox folder has no usable id")
|
|
577
|
+
group_type = folder.get("GroupTypeId")
|
|
578
|
+
group = group_type is not None
|
|
579
|
+
code, _name = _course_identity(course_dir)
|
|
580
|
+
return SubmissionTarget(
|
|
581
|
+
course=paths.rel_posix(course_dir, vault.root),
|
|
582
|
+
course_code=code,
|
|
583
|
+
org_unit_id=org_unit_id,
|
|
584
|
+
folder_id=folder_id,
|
|
585
|
+
folder_name=str(folder.get("Name") or f"folder {folder_id}"),
|
|
586
|
+
endpoint=(
|
|
587
|
+
f"/d2l/api/le/{le_version}/{org_unit_id}/dropbox/folders/{folder_id}"
|
|
588
|
+
"/submissions/mysubmissions/"
|
|
589
|
+
),
|
|
590
|
+
# The ``mysubmissions`` projection is the only route that proves the current user's
|
|
591
|
+
# upload. The unqualified ``submissions`` route enumerates entities and is not sufficient
|
|
592
|
+
# for attribution.
|
|
593
|
+
readback=(
|
|
594
|
+
f"/d2l/api/le/{le_version}/{org_unit_id}/dropbox/folders/{folder_id}"
|
|
595
|
+
"/submissions/mysubmissions/"
|
|
596
|
+
),
|
|
597
|
+
group_submission=group,
|
|
598
|
+
group_note=(
|
|
599
|
+
f"group Dropbox (group type {group_type}), visible to your group" if group else None
|
|
600
|
+
),
|
|
601
|
+
upload_verified=False,
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _require_supported_le(le_version: str | None) -> None:
|
|
606
|
+
if not isinstance(le_version, str) or not le_version:
|
|
607
|
+
raise SubmissionRefused("calibration is required before submission · run: a2l courses")
|
|
608
|
+
try:
|
|
609
|
+
parts = tuple(int(part) for part in le_version.split(".")[:2])
|
|
610
|
+
except ValueError:
|
|
611
|
+
raise SubmissionRefused("the discovered LE version is unreadable") from None
|
|
612
|
+
if len(parts) < 2 or parts < MIN_LE_VERSION:
|
|
613
|
+
raise SubmissionRefused(
|
|
614
|
+
"this LEARN instance does not advertise a supported submission route"
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _stage(vault: Vault, selected: Path) -> StagedFile:
|
|
619
|
+
directory = _staging_dir(vault)
|
|
620
|
+
try:
|
|
621
|
+
paths.ensure_dir(directory, root=vault.root)
|
|
622
|
+
except ValueError as exc:
|
|
623
|
+
raise SubmissionRefused("submission staging path contains a symlink or junction") from exc
|
|
624
|
+
if paths.has_link_component(directory, root=vault.root):
|
|
625
|
+
raise SubmissionRefused("submission staging path contains a symlink or junction")
|
|
626
|
+
file_descriptor, raw_destination = tempfile.mkstemp(
|
|
627
|
+
prefix=".a2l-", suffix=".part", dir=os.fspath(paths.long_path(directory))
|
|
628
|
+
)
|
|
629
|
+
destination = paths.plain_path(Path(raw_destination))
|
|
630
|
+
try:
|
|
631
|
+
if paths.has_link_component(destination, root=vault.root):
|
|
632
|
+
raise SubmissionRefused("submission staging path contains a symlink or junction")
|
|
633
|
+
except BaseException:
|
|
634
|
+
os.close(file_descriptor)
|
|
635
|
+
_remove(destination)
|
|
636
|
+
raise
|
|
637
|
+
digest = sha256()
|
|
638
|
+
size = 0
|
|
639
|
+
descriptor_open = True
|
|
640
|
+
try:
|
|
641
|
+
with os.fdopen(file_descriptor, "wb") as staged:
|
|
642
|
+
descriptor_open = False
|
|
643
|
+
with open(os.fspath(paths.long_path(selected)), "rb") as source:
|
|
644
|
+
# ``mkstemp`` creates the part as 0600 before this copy starts. Keep the explicit
|
|
645
|
+
# tightening for platforms/filesystems that apply a different inherited mode.
|
|
646
|
+
_tighten(destination)
|
|
647
|
+
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
|
648
|
+
digest.update(chunk)
|
|
649
|
+
size += len(chunk)
|
|
650
|
+
staged.write(chunk)
|
|
651
|
+
staged.flush()
|
|
652
|
+
os.fsync(staged.fileno())
|
|
653
|
+
except BaseException:
|
|
654
|
+
if descriptor_open:
|
|
655
|
+
os.close(file_descriptor)
|
|
656
|
+
_remove(destination)
|
|
657
|
+
raise
|
|
658
|
+
_tighten(destination)
|
|
659
|
+
staged_at = clock.now()
|
|
660
|
+
return StagedFile(
|
|
661
|
+
path=destination,
|
|
662
|
+
filename=selected.name,
|
|
663
|
+
sha256=digest.hexdigest(),
|
|
664
|
+
size=size,
|
|
665
|
+
staged_at=clock.stamp(),
|
|
666
|
+
expires_at=(staged_at + timedelta(seconds=CONFIRMATION_TTL_SECONDS))
|
|
667
|
+
.isoformat()
|
|
668
|
+
.replace("+00:00", "Z"),
|
|
669
|
+
selected_display=_selected_display(vault, selected),
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _verify(client: SubmissionTransport, preview: SubmissionPreview, confirmed_at: str) -> str:
|
|
674
|
+
try:
|
|
675
|
+
records = client.get_json_once(preview.target.readback)
|
|
676
|
+
except Exception as exc: # noqa: BLE001 - any read-back failure is unverified, never a retry
|
|
677
|
+
raise SubmissionUnverified(
|
|
678
|
+
"upload was sent but read-back failed; open LEARN to confirm before resubmitting"
|
|
679
|
+
) from exc
|
|
680
|
+
if not isinstance(records, list):
|
|
681
|
+
raise SubmissionUnverified("read-back was not a submission list; confirm in LEARN")
|
|
682
|
+
|
|
683
|
+
matches = []
|
|
684
|
+
for entry in _iter_files(records, expected_user_id=_current_user_id(client)):
|
|
685
|
+
filename = entry.get("FileName")
|
|
686
|
+
if (
|
|
687
|
+
isinstance(filename, str)
|
|
688
|
+
and filename == preview.staged.filename
|
|
689
|
+
and _folder_matches(entry, preview.target.folder_id)
|
|
690
|
+
):
|
|
691
|
+
matches.append(entry)
|
|
692
|
+
if not matches:
|
|
693
|
+
raise SubmissionUnverified("read-back did not list the uploaded file; confirm in LEARN")
|
|
694
|
+
confirmed = _parse_instant(confirmed_at)
|
|
695
|
+
if confirmed is None:
|
|
696
|
+
raise SubmissionUnverified("confirmation timestamp was unreadable; confirm in LEARN")
|
|
697
|
+
fresh = []
|
|
698
|
+
for entry in matches:
|
|
699
|
+
submitted = _parse_instant(entry.get("SubmissionDate"))
|
|
700
|
+
if submitted is not None and submitted > confirmed:
|
|
701
|
+
fresh.append(entry)
|
|
702
|
+
if len(fresh) != 1:
|
|
703
|
+
raise SubmissionUnverified(
|
|
704
|
+
"read-back was ambiguous or matched an earlier upload; confirm in LEARN"
|
|
705
|
+
)
|
|
706
|
+
reported = fresh[0].get("Size")
|
|
707
|
+
if (
|
|
708
|
+
isinstance(reported, bool)
|
|
709
|
+
or not isinstance(reported, int)
|
|
710
|
+
or reported != preview.staged.size
|
|
711
|
+
):
|
|
712
|
+
raise SubmissionUnverified(
|
|
713
|
+
"read-back size did not match the staged bytes; confirm in LEARN"
|
|
714
|
+
)
|
|
715
|
+
return "read-back matched folder, filename, size, and a timestamp after confirmation"
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _iter_files(
|
|
719
|
+
records: list[object], *, expected_user_id: str | None = None
|
|
720
|
+
) -> list[dict[str, object]]:
|
|
721
|
+
files: list[dict[str, object]] = []
|
|
722
|
+
for record in records:
|
|
723
|
+
if not isinstance(record, dict):
|
|
724
|
+
continue
|
|
725
|
+
entity = record.get("Entity")
|
|
726
|
+
# The documented ``mysubmissions`` GET response is an EntityDropbox array. Do not
|
|
727
|
+
# support a convenient flat compatibility shape here: the Entity/SubmittedBy agreement is
|
|
728
|
+
# the local proof that a surprising server response cannot attribute someone else's file
|
|
729
|
+
# to the current user.
|
|
730
|
+
if not isinstance(entity, dict) or entity.get("EntityType") != "User":
|
|
731
|
+
continue
|
|
732
|
+
entity_id = entity.get("EntityId")
|
|
733
|
+
if isinstance(entity_id, bool) or not isinstance(entity_id, int):
|
|
734
|
+
continue
|
|
735
|
+
entity_user_id = str(entity_id)
|
|
736
|
+
if expected_user_id is not None and entity_user_id != expected_user_id:
|
|
737
|
+
continue
|
|
738
|
+
submissions: object = record.get("Submissions")
|
|
739
|
+
if not isinstance(submissions, list):
|
|
740
|
+
continue
|
|
741
|
+
for submission in submissions:
|
|
742
|
+
if not isinstance(submission, dict):
|
|
743
|
+
continue
|
|
744
|
+
submitted_by = submission.get("SubmittedBy")
|
|
745
|
+
if not isinstance(submitted_by, dict):
|
|
746
|
+
continue
|
|
747
|
+
submitted_id = submitted_by.get("Id")
|
|
748
|
+
if (
|
|
749
|
+
not isinstance(submitted_id, str)
|
|
750
|
+
or not submitted_id
|
|
751
|
+
or submitted_id != entity_user_id
|
|
752
|
+
or (expected_user_id is not None and submitted_id != expected_user_id)
|
|
753
|
+
):
|
|
754
|
+
continue
|
|
755
|
+
candidates = submission.get("Files")
|
|
756
|
+
if not isinstance(candidates, list):
|
|
757
|
+
continue
|
|
758
|
+
for candidate in candidates:
|
|
759
|
+
if isinstance(candidate, dict):
|
|
760
|
+
merged = dict(candidate)
|
|
761
|
+
merged.setdefault("SubmissionDate", submission.get("SubmissionDate"))
|
|
762
|
+
folder_values = [
|
|
763
|
+
container[key]
|
|
764
|
+
for container in (record, submission, candidate)
|
|
765
|
+
for key in ("FolderId", "DropboxFolderId")
|
|
766
|
+
if key in container
|
|
767
|
+
]
|
|
768
|
+
valid_folder_values = [
|
|
769
|
+
value
|
|
770
|
+
for value in folder_values
|
|
771
|
+
if isinstance(value, int) and not isinstance(value, bool)
|
|
772
|
+
]
|
|
773
|
+
if len(valid_folder_values) != len(folder_values):
|
|
774
|
+
merged["_a2l_invalid_folder_identifier"] = True
|
|
775
|
+
if len(set(valid_folder_values)) > 1:
|
|
776
|
+
merged["_a2l_conflicting_folder_identifiers"] = True
|
|
777
|
+
for key in ("FolderId", "DropboxFolderId"):
|
|
778
|
+
if key not in merged:
|
|
779
|
+
if key in submission:
|
|
780
|
+
merged[key] = submission[key]
|
|
781
|
+
elif key in record:
|
|
782
|
+
merged[key] = record[key]
|
|
783
|
+
files.append(merged)
|
|
784
|
+
return files
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _folder_matches(entry: dict[str, object], folder_id: int) -> bool:
|
|
788
|
+
"""Accept an omitted folder field only because the current-user route scopes it."""
|
|
789
|
+
|
|
790
|
+
if entry.get("_a2l_invalid_folder_identifier") or entry.get(
|
|
791
|
+
"_a2l_conflicting_folder_identifiers"
|
|
792
|
+
):
|
|
793
|
+
return False
|
|
794
|
+
supplied: list[int] = []
|
|
795
|
+
for key in ("FolderId", "DropboxFolderId"):
|
|
796
|
+
value = entry.get(key)
|
|
797
|
+
if value is None:
|
|
798
|
+
continue
|
|
799
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
800
|
+
return False
|
|
801
|
+
supplied.append(value)
|
|
802
|
+
return not supplied or all(value == folder_id for value in supplied)
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def _current_user_id(client: SubmissionTransport) -> str | None:
|
|
806
|
+
"""Return the verified session identity when the transport exposes one.
|
|
807
|
+
|
|
808
|
+
The ``mysubmissions`` route is already scoped by D2L to the current user. The additional
|
|
809
|
+
session check is deliberately opportunistic because the narrow transport protocol used by
|
|
810
|
+
tests and integrations need not expose session internals; when available, it catches a
|
|
811
|
+
response whose entity or submitter disagrees with the identity verified during authentication.
|
|
812
|
+
"""
|
|
813
|
+
|
|
814
|
+
session = getattr(client, "session", None)
|
|
815
|
+
value = getattr(session, "user_id", None)
|
|
816
|
+
return value if isinstance(value, str) and value else None
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _receipt(
|
|
820
|
+
preview: SubmissionPreview,
|
|
821
|
+
confirmed_at: str,
|
|
822
|
+
*,
|
|
823
|
+
post_attempted: bool,
|
|
824
|
+
post_at: str | None,
|
|
825
|
+
readback_at: str | None,
|
|
826
|
+
http_status_class: str,
|
|
827
|
+
status: str,
|
|
828
|
+
outcome: str,
|
|
829
|
+
) -> SubmissionReceipt:
|
|
830
|
+
staged = preview.staged
|
|
831
|
+
inside = staged.selected_display != "external"
|
|
832
|
+
return SubmissionReceipt(
|
|
833
|
+
course=preview.target.course,
|
|
834
|
+
folder_id=preview.target.folder_id,
|
|
835
|
+
filename=staged.filename,
|
|
836
|
+
sha256=staged.sha256,
|
|
837
|
+
size=staged.size,
|
|
838
|
+
location="vault" if inside else "external",
|
|
839
|
+
selected_path=staged.selected_display if inside else None,
|
|
840
|
+
confirmed_at=confirmed_at,
|
|
841
|
+
post_attempted=post_attempted,
|
|
842
|
+
post_at=post_at,
|
|
843
|
+
readback_at=readback_at,
|
|
844
|
+
http_status_class=http_status_class,
|
|
845
|
+
completed_at=clock.stamp(),
|
|
846
|
+
status=status,
|
|
847
|
+
outcome=outcome,
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def _write_receipt(vault: Vault, receipt: SubmissionReceipt) -> Path:
|
|
852
|
+
directory = vault.state() / RECEIPTS_DIRNAME
|
|
853
|
+
try:
|
|
854
|
+
paths.ensure_dir(directory, root=vault.root)
|
|
855
|
+
except ValueError as exc:
|
|
856
|
+
raise A2LError("submission receipt path contains a symlink or junction") from exc
|
|
857
|
+
name = f"{receipt.completed_at.replace(':', '').replace('-', '')}-{receipt.folder_id}.json"
|
|
858
|
+
payload = {
|
|
859
|
+
"receipt_version": receipt.receipt_version,
|
|
860
|
+
"course": receipt.course,
|
|
861
|
+
"folder_id": receipt.folder_id,
|
|
862
|
+
"filename": receipt.filename,
|
|
863
|
+
"sha256": receipt.sha256,
|
|
864
|
+
"size": receipt.size,
|
|
865
|
+
"location": receipt.location,
|
|
866
|
+
"selected_path": receipt.selected_path,
|
|
867
|
+
"confirmed_at": receipt.confirmed_at,
|
|
868
|
+
"post_attempted": receipt.post_attempted,
|
|
869
|
+
"post_at": receipt.post_at,
|
|
870
|
+
"readback_at": receipt.readback_at,
|
|
871
|
+
"http_status_class": receipt.http_status_class,
|
|
872
|
+
"completed_at": receipt.completed_at,
|
|
873
|
+
"status": receipt.status,
|
|
874
|
+
"outcome": receipt.outcome,
|
|
875
|
+
}
|
|
876
|
+
destination = directory / name
|
|
877
|
+
paths.atomic_write_text(
|
|
878
|
+
destination,
|
|
879
|
+
json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
|
880
|
+
root=vault.root,
|
|
881
|
+
)
|
|
882
|
+
return destination
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def _staging_dir(vault: Vault) -> Path:
|
|
886
|
+
return vault.state() / STAGING_DIRNAME
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def _selected_display(vault: Vault, selected: Path) -> str:
|
|
890
|
+
try:
|
|
891
|
+
return paths.rel_posix(selected, vault.root)
|
|
892
|
+
except (ValueError, OSError):
|
|
893
|
+
return "external"
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def _course_identity(course_dir: Path) -> tuple[str, str]:
|
|
897
|
+
rows = course_index.read_content_map(course_dir)["topics"]
|
|
898
|
+
if isinstance(rows, list):
|
|
899
|
+
for row in rows:
|
|
900
|
+
if not isinstance(row, dict):
|
|
901
|
+
continue
|
|
902
|
+
code = row.get("course_code")
|
|
903
|
+
if isinstance(code, str) and code:
|
|
904
|
+
name = row.get("course_name")
|
|
905
|
+
return code, name if isinstance(name, str) and name else code
|
|
906
|
+
return course_dir.name, course_dir.name
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def _org_unit(course_dir: Path) -> int:
|
|
910
|
+
rows = course_index.read_content_map(course_dir)["topics"]
|
|
911
|
+
if isinstance(rows, list):
|
|
912
|
+
for row in rows:
|
|
913
|
+
if not isinstance(row, dict):
|
|
914
|
+
continue
|
|
915
|
+
value = row.get("course_org_unit_id")
|
|
916
|
+
if isinstance(value, int):
|
|
917
|
+
return value
|
|
918
|
+
source_key = row.get("source_key")
|
|
919
|
+
if isinstance(source_key, str):
|
|
920
|
+
parts = source_key.split(":")
|
|
921
|
+
if len(parts) > 1 and parts[1].isdigit():
|
|
922
|
+
return int(parts[1])
|
|
923
|
+
raise SubmissionRefused("this course has no known LEARN org unit; run: a2l sync")
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def _code() -> str:
|
|
927
|
+
return "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LENGTH))
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _boundary() -> str:
|
|
931
|
+
return f"a2l{secrets.token_hex(16)}"
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def _validate_staged(vault: Vault, staged: StagedFile) -> None:
|
|
935
|
+
"""Validate staged metadata without materialising the file in memory."""
|
|
936
|
+
|
|
937
|
+
if paths.has_link_component(staged.path, root=vault.root):
|
|
938
|
+
raise SubmissionRefused("the staged path contains a symlink or junction")
|
|
939
|
+
try:
|
|
940
|
+
file_stat = os.lstat(os.fspath(paths.long_path(staged.path)))
|
|
941
|
+
except OSError as exc:
|
|
942
|
+
raise SubmissionRefused("the staged file is no longer readable") from exc
|
|
943
|
+
if not stat.S_ISREG(file_stat.st_mode) or getattr(file_stat, "st_nlink", 1) != 1:
|
|
944
|
+
raise SubmissionRefused("the staged file is no longer a private regular file")
|
|
945
|
+
digest = sha256()
|
|
946
|
+
size = 0
|
|
947
|
+
try:
|
|
948
|
+
with open(os.fspath(paths.long_path(staged.path)), "rb") as handle:
|
|
949
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
950
|
+
digest.update(chunk)
|
|
951
|
+
size += len(chunk)
|
|
952
|
+
except OSError as exc:
|
|
953
|
+
raise SubmissionRefused("the staged file is no longer readable") from exc
|
|
954
|
+
if size != staged.size or digest.hexdigest() != staged.sha256:
|
|
955
|
+
raise SubmissionRefused("the staged bytes changed after the preview; run submit again")
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def _expired(staged: StagedFile) -> bool:
|
|
959
|
+
try:
|
|
960
|
+
deadline = datetime.fromisoformat(staged.expires_at.replace("Z", "+00:00"))
|
|
961
|
+
except ValueError:
|
|
962
|
+
return True
|
|
963
|
+
return clock.now() >= deadline
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def _discard(staged: StagedFile) -> None:
|
|
967
|
+
_remove(staged.path)
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def discard_preview(preview: SubmissionPreview) -> None:
|
|
971
|
+
"""Discard an unconfirmed preview and its staged bytes."""
|
|
972
|
+
|
|
973
|
+
preview.consumed = True
|
|
974
|
+
_discard(preview.staged)
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def _parse_instant(value: object) -> datetime | None:
|
|
978
|
+
if not isinstance(value, str) or not value:
|
|
979
|
+
return None
|
|
980
|
+
try:
|
|
981
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
982
|
+
except ValueError:
|
|
983
|
+
return None
|
|
984
|
+
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
|
985
|
+
return None
|
|
986
|
+
return parsed.astimezone(UTC)
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _status_class(status: object) -> str:
|
|
990
|
+
if isinstance(status, bool) or not isinstance(status, int):
|
|
991
|
+
return "unknown"
|
|
992
|
+
if 200 <= status < 300:
|
|
993
|
+
return "2xx"
|
|
994
|
+
if 300 <= status < 400:
|
|
995
|
+
return "3xx"
|
|
996
|
+
if 400 <= status < 500:
|
|
997
|
+
return "4xx"
|
|
998
|
+
if 500 <= status < 600:
|
|
999
|
+
return "5xx"
|
|
1000
|
+
return "unknown"
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def _remove(path: Path) -> None:
|
|
1004
|
+
try:
|
|
1005
|
+
paths.long_path(path).unlink()
|
|
1006
|
+
except (FileNotFoundError, IsADirectoryError, OSError):
|
|
1007
|
+
return
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def _tighten(path: Path) -> None:
|
|
1011
|
+
if os.name == "nt": # pragma: no cover - POSIX permission bits only
|
|
1012
|
+
return
|
|
1013
|
+
try:
|
|
1014
|
+
paths.long_path(path).chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
1015
|
+
except OSError:
|
|
1016
|
+
return
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _compact(value: str) -> str:
|
|
1020
|
+
return "".join(re.findall(r"[a-z0-9]+", value.casefold()))
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
__all__ = [
|
|
1024
|
+
"CODE_LENGTH",
|
|
1025
|
+
"CONFIRMATION_TTL_SECONDS",
|
|
1026
|
+
"MIN_LE_VERSION",
|
|
1027
|
+
"SUBMISSION_RECEIPT_VERSION",
|
|
1028
|
+
"StagedFile",
|
|
1029
|
+
"SubmissionBody",
|
|
1030
|
+
"SubmissionCapability",
|
|
1031
|
+
"SubmissionPreview",
|
|
1032
|
+
"SubmissionReceipt",
|
|
1033
|
+
"SubmissionRefused",
|
|
1034
|
+
"SubmissionTarget",
|
|
1035
|
+
"SubmissionTransport",
|
|
1036
|
+
"SubmissionUnverified",
|
|
1037
|
+
"build_submission_body",
|
|
1038
|
+
"cleanup_staging",
|
|
1039
|
+
"confirm_and_upload",
|
|
1040
|
+
"discard_preview",
|
|
1041
|
+
"enable_submit",
|
|
1042
|
+
"prepare",
|
|
1043
|
+
"release_capability",
|
|
1044
|
+
"require_available",
|
|
1045
|
+
"render_preview",
|
|
1046
|
+
"safe_filename",
|
|
1047
|
+
]
|