feat: enhance video library management with renaming and ordering functionality
Browse files- README.md +9 -1
- app/auth.py +19 -1
- app/main.py +34 -2
- static/index.html +35 -3
- tests/test_scoring.py +15 -0
README.md
CHANGED
|
@@ -249,7 +249,7 @@ alter table public.dailies enable row level security;
|
|
| 249 |
|
| 250 |
### Video library
|
| 251 |
|
| 252 |
-
Apart from those there is one library of videos for the whole course: they are not part of the path, need no date, and are the same for every level and class. In **תוכן** the card "ספריה דיגיטלית" comes first, above the levels; the teacher adds a video (a name and a link) and deletes one there. Every student finds them in the "ספריה דיגיטלית" tab of the bar at the screen's foot: a list, and a video plays in the same box as the path's videos. The ones opened are marked (on that device). Create the table once (its `level` column stays empty):
|
| 253 |
|
| 254 |
```sql
|
| 255 |
create table public.library (
|
|
@@ -257,11 +257,19 @@ create table public.library (
|
|
| 257 |
level text not null, -- a level id, e.g. 1
|
| 258 |
title text not null,
|
| 259 |
url text not null,
|
|
|
|
| 260 |
created_at timestamptz not null default now()
|
| 261 |
);
|
| 262 |
alter table public.library enable row level security;
|
| 263 |
```
|
| 264 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
For the video to play inside the page, its Vimeo privacy setting has to allow embedding (on this site's domain, or anywhere); a YouTube video has to allow embedding too (it does unless its owner turned that off), and plays from `youtube-nocookie.com`.
|
| 266 |
|
| 267 |
Keeping the link from being passed on is done in Vimeo, not here: the page has to give the browser the player's address, so a student who looks for it finds it (the server only keeps the link itself, `url`, for teachers). In the video's privacy settings on Vimeo, hide it from vimeo.com and allow embedding on specific domains only, naming the app's own domain (on Hugging Face, `<owner>-<space>.hf.space`). The video then plays inside the app and nowhere else: its link, opened or embedded anywhere else, shows nothing. Domain-level privacy needs a paid Vimeo plan.
|
|
|
|
| 249 |
|
| 250 |
### Video library
|
| 251 |
|
| 252 |
+
Apart from those there is one library of videos for the whole course: they are not part of the path, need no date, and are the same for every level and class. In **תוכן** the card "ספריה דיגיטלית" comes first, above the levels; the teacher adds a video (a name and a link), renames one, moves one up or down the list with its arrows, and deletes one there. Every student finds them in the "ספריה דיגיטלית" tab of the bar at the screen's foot: a list, and a video plays in the same box as the path's videos. The ones opened are marked (on that device). Create the table once (its `level` column stays empty):
|
| 253 |
|
| 254 |
```sql
|
| 255 |
create table public.library (
|
|
|
|
| 257 |
level text not null, -- a level id, e.g. 1
|
| 258 |
title text not null,
|
| 259 |
url text not null,
|
| 260 |
+
ord int, -- its turn in the list; none until the teacher moves a video
|
| 261 |
created_at timestamptz not null default now()
|
| 262 |
);
|
| 263 |
alter table public.library enable row level security;
|
| 264 |
```
|
| 265 |
|
| 266 |
+
A library made before its videos could be moved has no `ord` column; until it is added, moving a video is refused with this command in the message (renaming works without it):
|
| 267 |
+
|
| 268 |
+
```sql
|
| 269 |
+
alter table public.library add column if not exists ord int;
|
| 270 |
+
notify pgrst, 'reload schema';
|
| 271 |
+
```
|
| 272 |
+
|
| 273 |
For the video to play inside the page, its Vimeo privacy setting has to allow embedding (on this site's domain, or anywhere); a YouTube video has to allow embedding too (it does unless its owner turned that off), and plays from `youtube-nocookie.com`.
|
| 274 |
|
| 275 |
Keeping the link from being passed on is done in Vimeo, not here: the page has to give the browser the player's address, so a student who looks for it finds it (the server only keeps the link itself, `url`, for teachers). In the video's privacy settings on Vimeo, hide it from vimeo.com and allow embedding on specific domains only, naming the app's own domain (on Hugging Face, `<owner>-<space>.hf.space`). The video then plays inside the app and nowhere else: its link, opened or embedded anywhere else, shows nothing. Domain-level privacy needs a paid Vimeo plan.
|
app/auth.py
CHANGED
|
@@ -341,7 +341,8 @@ class SupabaseAuth:
|
|
| 341 |
self._videos = None
|
| 342 |
|
| 343 |
def level_videos(self, table: str, fresh: bool = False) -> list[dict]:
|
| 344 |
-
"""A level's videos that stand outside the path, in the order
|
|
|
|
| 345 |
url (the Vimeo or YouTube link). `table` is "dailies" (the daily videos) or "library" (the video library,
|
| 346 |
which is one for every level: its `level` is empty). Kept for CLASSES_TTL. Without the secret key, or before the table is created, there are none."""
|
| 347 |
assert table in ("dailies", "library")
|
|
@@ -354,6 +355,7 @@ class SupabaseAuth:
|
|
| 354 |
if self.secret:
|
| 355 |
try:
|
| 356 |
rows = self._admin(f"/rest/v1/{table}?select=*&order=id.asc")
|
|
|
|
| 357 |
except AuthUnavailable as e:
|
| 358 |
if "answered 404" not in str(e):
|
| 359 |
raise
|
|
@@ -372,6 +374,22 @@ class SupabaseAuth:
|
|
| 372 |
self._admin(f"/rest/v1/{table}?id=eq.{int(id)}", "DELETE")
|
| 373 |
self._level_videos.pop(table, None)
|
| 374 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
def ranked(self, fresh: bool = False) -> frozenset[str] | None:
|
| 376 |
"""The lower-case emails the teacher gave the tutor's rank (the table `tutors`). Kept for CLASSES_TTL, as each
|
| 377 |
sign-in check asks. None before the table is created; without the secret key there are none."""
|
|
|
|
| 341 |
self._videos = None
|
| 342 |
|
| 343 |
def level_videos(self, table: str, fresh: bool = False) -> list[dict]:
|
| 344 |
+
"""A level's videos that stand outside the path, in the order the teacher gave them (`ord`, see
|
| 345 |
+
`order_level_videos`) and then, those never moved, in the order they were added: id, level (a level id), title,
|
| 346 |
url (the Vimeo or YouTube link). `table` is "dailies" (the daily videos) or "library" (the video library,
|
| 347 |
which is one for every level: its `level` is empty). Kept for CLASSES_TTL. Without the secret key, or before the table is created, there are none."""
|
| 348 |
assert table in ("dailies", "library")
|
|
|
|
| 355 |
if self.secret:
|
| 356 |
try:
|
| 357 |
rows = self._admin(f"/rest/v1/{table}?select=*&order=id.asc")
|
| 358 |
+
rows.sort(key=lambda r: (r.get("ord") is None, r.get("ord") or 0, r["id"]))
|
| 359 |
except AuthUnavailable as e:
|
| 360 |
if "answered 404" not in str(e):
|
| 361 |
raise
|
|
|
|
| 374 |
self._admin(f"/rest/v1/{table}?id=eq.{int(id)}", "DELETE")
|
| 375 |
self._level_videos.pop(table, None)
|
| 376 |
|
| 377 |
+
def rename_level_video(self, table: str, id: int, title: str) -> None:
|
| 378 |
+
assert table in ("dailies", "library")
|
| 379 |
+
try:
|
| 380 |
+
self._admin(f"/rest/v1/{table}?id=eq.{int(id)}", "PATCH", {"title": title})
|
| 381 |
+
finally:
|
| 382 |
+
self._level_videos.pop(table, None)
|
| 383 |
+
|
| 384 |
+
def order_level_videos(self, table: str, ids: list[int]) -> None:
|
| 385 |
+
"""Puts the table's videos in this order: each one's `ord` is its turn."""
|
| 386 |
+
assert table in ("dailies", "library")
|
| 387 |
+
try:
|
| 388 |
+
for k, id in enumerate(ids):
|
| 389 |
+
self._admin(f"/rest/v1/{table}?id=eq.{int(id)}", "PATCH", {"ord": k})
|
| 390 |
+
finally:
|
| 391 |
+
self._level_videos.pop(table, None)
|
| 392 |
+
|
| 393 |
def ranked(self, fresh: bool = False) -> frozenset[str] | None:
|
| 394 |
"""The lower-case emails the teacher gave the tutor's rank (the table `tutors`). Kept for CLASSES_TTL, as each
|
| 395 |
sign-in check asks. None before the table is created; without the secret key there are none."""
|
app/main.py
CHANGED
|
@@ -64,9 +64,11 @@ CREATE_VIDEOS = ("create table public.videos (id bigint generated always as iden
|
|
| 64 |
MIGRATE_VIDEOS = "alter table public.videos add column if not exists pos int, add column if not exists ord int not null default 0;"
|
| 65 |
# The daily videos: a library by level; a class gives a video its day (the class's `dailies`: [{"video", "date"}])
|
| 66 |
# The library: other videos, which every student browses, whatever the level and the class (its `level` is empty)
|
|
|
|
| 67 |
CREATE_LEVEL_VIDEOS = {t: (f"create table public.{t} (id bigint generated always as identity primary key, level text not null, "
|
| 68 |
-
"title text not null, url text not null, created_at timestamptz not null default now()); "
|
| 69 |
f"alter table public.{t} enable row level security;") for t in ("dailies", "library")}
|
|
|
|
| 70 |
LEVEL_IDS = [v["id"] for v in LEVELS]
|
| 71 |
LESSON_LEVEL = {l["id"]: l["level"] for l in LESSONS}
|
| 72 |
MAX_DAILIES = 400
|
|
@@ -727,6 +729,8 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 727 |
if "answered 404" in str(e):
|
| 728 |
return (f"הטבלה {table} לא קיימת ב-Supabase. הריצו ב-SQL Editor: "
|
| 729 |
f"{CREATE_LEVEL_VIDEOS[table]} אם כבר נוצרה, הריצו שם: notify pgrst, 'reload schema';")
|
|
|
|
|
|
|
| 730 |
return f"Supabase לא זמין: {e}"
|
| 731 |
|
| 732 |
@app.get("/api/dailies")
|
|
@@ -742,7 +746,7 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 742 |
@app.get("/api/library")
|
| 743 |
def library(user: dict | None = Depends(signed_in)):
|
| 744 |
"""The video library, which stands outside the path and is one for every level and class: id, title, url
|
| 745 |
-
(for a teacher only) and embed (its player) of each, in the order
|
| 746 |
if not user:
|
| 747 |
return {"library": []}
|
| 748 |
try:
|
|
@@ -794,6 +798,34 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
|
|
| 794 |
def delete_library(video_id: int, a: SupabaseAuth = Depends(teacher_only)):
|
| 795 |
return delete_level_video("library", video_id, a)
|
| 796 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 797 |
@app.get("/api/classmates")
|
| 798 |
def classmates(user: dict | None = Depends(signed_in)):
|
| 799 |
"""The signed-in student's classes: each one's name and its students, each by the nickname the teacher gave
|
|
|
|
| 64 |
MIGRATE_VIDEOS = "alter table public.videos add column if not exists pos int, add column if not exists ord int not null default 0;"
|
| 65 |
# The daily videos: a library by level; a class gives a video its day (the class's `dailies`: [{"video", "date"}])
|
| 66 |
# The library: other videos, which every student browses, whatever the level and the class (its `level` is empty)
|
| 67 |
+
# The library's videos stand in the order the teacher gave them (`ord`; one never moved has none, and comes after those)
|
| 68 |
CREATE_LEVEL_VIDEOS = {t: (f"create table public.{t} (id bigint generated always as identity primary key, level text not null, "
|
| 69 |
+
f"title text not null, url text not null, {'ord int, ' if t == 'library' else ''}created_at timestamptz not null default now()); "
|
| 70 |
f"alter table public.{t} enable row level security;") for t in ("dailies", "library")}
|
| 71 |
+
MIGRATE_LIBRARY = "alter table public.library add column if not exists ord int;"
|
| 72 |
LEVEL_IDS = [v["id"] for v in LEVELS]
|
| 73 |
LESSON_LEVEL = {l["id"]: l["level"] for l in LESSONS}
|
| 74 |
MAX_DAILIES = 400
|
|
|
|
| 729 |
if "answered 404" in str(e):
|
| 730 |
return (f"הטבלה {table} לא קיימת ב-Supabase. הריצו ב-SQL Editor: "
|
| 731 |
f"{CREATE_LEVEL_VIDEOS[table]} אם כבר נוצרה, הריצו שם: notify pgrst, 'reload schema';")
|
| 732 |
+
if "'ord'" in str(e) or "ord" in str(e) and "column" in str(e): # a library made before its videos could be moved
|
| 733 |
+
return f"לטבלה {table} חסרה עמודה. הריצו ב-SQL Editor של Supabase: {MIGRATE_LIBRARY} notify pgrst, 'reload schema';"
|
| 734 |
return f"Supabase לא זמין: {e}"
|
| 735 |
|
| 736 |
@app.get("/api/dailies")
|
|
|
|
| 746 |
@app.get("/api/library")
|
| 747 |
def library(user: dict | None = Depends(signed_in)):
|
| 748 |
"""The video library, which stands outside the path and is one for every level and class: id, title, url
|
| 749 |
+
(for a teacher only) and embed (its player) of each, in the teacher's order (see `order_library`)."""
|
| 750 |
if not user:
|
| 751 |
return {"library": []}
|
| 752 |
try:
|
|
|
|
| 798 |
def delete_library(video_id: int, a: SupabaseAuth = Depends(teacher_only)):
|
| 799 |
return delete_level_video("library", video_id, a)
|
| 800 |
|
| 801 |
+
@app.put("/api/library/order")
|
| 802 |
+
def order_library(body: dict = Body(...), a: SupabaseAuth = Depends(teacher_only)):
|
| 803 |
+
"""Puts the library's videos in the teacher's order: {"order": [id]}, every video of the library once."""
|
| 804 |
+
order = body.get("order")
|
| 805 |
+
if not isinstance(order, list) or not all(isinstance(i, int) and not isinstance(i, bool) for i in order):
|
| 806 |
+
raise HTTPException(400, "the order is a list of video ids")
|
| 807 |
+
try:
|
| 808 |
+
if len(set(order)) != len(order) or set(order) != {v["id"] for v in a.level_videos("library", fresh=True)}: # added or deleted meanwhile, in another tab
|
| 809 |
+
raise HTTPException(409, "הספריה השתנתה. רעננו ונסו שוב")
|
| 810 |
+
a.order_level_videos("library", order)
|
| 811 |
+
except AuthUnavailable as e:
|
| 812 |
+
raise HTTPException(503, level_store_error("library", e))
|
| 813 |
+
return {"ok": True}
|
| 814 |
+
|
| 815 |
+
@app.put("/api/library/{video_id}")
|
| 816 |
+
def rename_library(video_id: int, body: dict = Body(...), a: SupabaseAuth = Depends(teacher_only)):
|
| 817 |
+
"""Gives a video of the library another name: {"title"}."""
|
| 818 |
+
title = " ".join(str(body.get("title", "")).split())
|
| 819 |
+
if not title or len(title) > MAX_TITLE:
|
| 820 |
+
raise HTTPException(400, f"לסרטון צריך שם (עד {MAX_TITLE} תווים)")
|
| 821 |
+
try:
|
| 822 |
+
if all(v["id"] != video_id for v in a.level_videos("library", fresh=True)):
|
| 823 |
+
raise HTTPException(404, "הסרטון כבר לא בספריה. רעננו ונסו שוב")
|
| 824 |
+
a.rename_level_video("library", video_id, title)
|
| 825 |
+
except AuthUnavailable as e:
|
| 826 |
+
raise HTTPException(503, level_store_error("library", e))
|
| 827 |
+
return {"ok": True}
|
| 828 |
+
|
| 829 |
@app.get("/api/classmates")
|
| 830 |
def classmates(user: dict | None = Depends(signed_in)):
|
| 831 |
"""The signed-in student's classes: each one's name and its students, each by the nickname the teacher gave
|
static/index.html
CHANGED
|
@@ -908,7 +908,10 @@ body.quiet :is(#mic,#playMine,#driveBtn,.phone-btn),body.driving :is(#quietBtn,#
|
|
| 908 |
<li>בכרטיס <b>הסרטונים שבספריה</b> לוחצים על <b>+ הוספת סרטון לספריה</b>.</li>
|
| 909 |
<li>ממלאים <b>שם הסרטון</b> (עד 80 תווים) ו<b>קישור ל-Vimeo או ל-YouTube</b>.</li>
|
| 910 |
<li>לוחצים <b>הוספה</b>.</li></ol>
|
| 911 |
-
<p>הסרטון מופיע ברשימה של הכרטיס. כפתור <b>צפייה</b> שלידו מאפשר לבדוק שהוא נפתח כמו שצריך. כדי להוסיף סרטונים נוספים חוזרים על שלבים 3 עד 5;
|
|
|
|
|
|
|
|
|
|
| 912 |
<h3>מה התלמידים רואים</h3>
|
| 913 |
<ul><li>התלמידים מגיעים לסרטונים דרך הלשונית <b>ספריה דיגיטלית</b> שבתחתית המסך.</li>
|
| 914 |
<li>כל תלמיד רואה את כל הסרטונים, בלי קשר לשלב או לכיתה שלו.</li>
|
|
@@ -916,9 +919,10 @@ body.quiet :is(#mic,#playMine,#driveBtn,.phone-btn),body.driving :is(#quietBtn,#
|
|
| 916 |
<h3>אם משהו לא עובד</h3>
|
| 917 |
<ul><li><b>"זה לא קישור לסרטון":</b> בודקים שהקישור הוא של Vimeo או YouTube ושהועתק במלואו.</li>
|
| 918 |
<li><b>הודעה שהטבלה <code>library</code> לא קיימת:</b> מריצים פעם אחת ב-SQL Editor של Supabase את הפקודה שמופיעה בהודעה.</li>
|
|
|
|
| 919 |
<li><b>תלמיד לא רואה סרטון חדש:</b> שירענן את הדף; הרשימה מתעדכנת אצל התלמידים תוך כדקה.</li></ul>
|
| 920 |
<h3>מחיקה</h3>
|
| 921 |
-
<p>ב<b>תוכן</b>, בכרטיס <b>הסרטונים שבספריה</b>, לוחצים <b>מחיקה</b> ליד הסרטון ומאשרים. הסרטון נעלם לכל התלמידים. א
|
| 922 |
</article>
|
| 923 |
</dialog>
|
| 924 |
<div id="whyTip" role="tooltip" hidden></div>
|
|
@@ -2586,7 +2590,15 @@ function drawContent(){
|
|
| 2586 |
const see=mark("btn","צפייה","button"), del=mark("btn danger","מחיקה","button");
|
| 2587 |
t.textContent=v.title; title.append(t); link.dir="ltr"; link.textContent=v.url.replace("https://",""); text.append(title,link);
|
| 2588 |
see.onclick=()=>playVideo(v,true); del.onclick=()=>deleteItem(table,v.id,`את הסרטון ${v.title}`,gone);
|
| 2589 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2590 |
}
|
| 2591 |
adder(adds,`+ הוספת ${one}`,[field("שם הסרטון","title",{maxLength:80,placeholder:sample}),
|
| 2592 |
field("קישור ל-Vimeo או ל-YouTube","url",{inputMode:"url",placeholder:"https://vimeo.com/1233611297/d46ac373c8"})],hint,
|
|
@@ -2684,6 +2696,26 @@ async function moveItem(l,items,at,step,list){ // a video or a text changes pl
|
|
| 2684 |
const me=id(items[at]), moved=[...$("mContent").querySelectorAll(".crow.added")].find(r=>r.dataset.id===me.kind+me.id); // the keyboard stays on what moved
|
| 2685 |
const again=moved&&[...moved.querySelectorAll(".move")].find((b,k)=>k===(step<0?0:1)&&!b.disabled)||moved&&moved.querySelector(".move:not(:disabled)"); if(again) again.focus({preventScroll:true});
|
| 2686 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2687 |
async function deleteItem(table,id,what,more){
|
| 2688 |
if(!confirm(`למחוק ${what}? ${table==="videos"||table==="texts"?"זה יירד מהמסלול של התלמידים.":"התלמידים לא יראו אותו עוד."}${more}`)) return;
|
| 2689 |
try{
|
|
|
|
| 908 |
<li>בכרטיס <b>הסרטונים שבספריה</b> לוחצים על <b>+ הוספת סרטון לספריה</b>.</li>
|
| 909 |
<li>ממלאים <b>שם הסרטון</b> (עד 80 תווים) ו<b>קישור ל-Vimeo או ל-YouTube</b>.</li>
|
| 910 |
<li>לוחצים <b>הוספה</b>.</li></ol>
|
| 911 |
+
<p>הסרטון מופיע ברשימה של הכרטיס. כפתור <b>צפייה</b> שלידו מאפשר לבדוק שהוא נפתח כמו שצריך. כדי להוסיף סרטונים נוספים חוזרים על שלבים 3 עד 5; סרטון חדש נוסף בסוף הרשימה.</p>
|
| 912 |
+
<h3>שינוי שם ושינוי סדר</h3>
|
| 913 |
+
<ul><li><b>שינוי שם:</b> לוחצים <b>שינוי שם</b> ליד הסרטון, מקלידים את השם החדש ומאשרים.</li>
|
| 914 |
+
<li><b>שינוי סדר:</b> בחיצים <b>↑</b> ו-<b>↓</b> שבשורה של הסרטון מזיזים אותו מקום אחד למעלה או למטה. התלמידים רואים את הסרטונים באותו סדר.</li></ul>
|
| 915 |
<h3>מה התלמידים רואים</h3>
|
| 916 |
<ul><li>התלמידים מגיעים לסרטונים דרך הלשונית <b>ספריה דיגיטלית</b> שבתחתית המסך.</li>
|
| 917 |
<li>כל תלמיד רואה את כל הסרטונים, בלי קשר לשלב או לכיתה שלו.</li>
|
|
|
|
| 919 |
<h3>אם משהו לא עובד</h3>
|
| 920 |
<ul><li><b>"זה לא קישור לסרטון":</b> בודקים שהקישור הוא של Vimeo או YouTube ושהועתק במלואו.</li>
|
| 921 |
<li><b>הודעה שהטבלה <code>library</code> לא קיימת:</b> מריצים פעם אחת ב-SQL Editor של Supabase את הפקודה שמופיעה בהודעה.</li>
|
| 922 |
+
<li><b>הודעה שלטבלה <code>library</code> חסרה עמודה (בהזזת סרטון):</b> מריצים פעם אחת ב-SQL Editor של Supabase את הפקודה שמופיעה בהודעה.</li>
|
| 923 |
<li><b>תלמיד לא רואה סרטון חדש:</b> שירענן את הדף; הרשימה מתעדכנת אצל התלמידים תוך כדקה.</li></ul>
|
| 924 |
<h3>מחיקה</h3>
|
| 925 |
+
<p>ב<b>תוכן</b>, בכרטיס <b>הסרטונים שבספריה</b>, לוחצים <b>מחיקה</b> ליד הסרטון ומאשרים. הסרטון נעלם לכל התלמידים. את הקישור אי אפשר לערוך: כדי לשנות אותו, מוחקים ומוסיפים מחדש.</p>
|
| 926 |
</article>
|
| 927 |
</dialog>
|
| 928 |
<div id="whyTip" role="tooltip" hidden></div>
|
|
|
|
| 2590 |
const see=mark("btn","צפייה","button"), del=mark("btn danger","מחיקה","button");
|
| 2591 |
t.textContent=v.title; title.append(t); link.dir="ltr"; link.textContent=v.url.replace("https://",""); text.append(title,link);
|
| 2592 |
see.onclick=()=>playVideo(v,true); del.onclick=()=>deleteItem(table,v.id,`את הסרטון ${v.title}`,gone);
|
| 2593 |
+
if(table==="library"){ // the library's videos stand in the teacher's order, and take another name
|
| 2594 |
+
const at=mine.indexOf(v), up=mark("btn move","↑","button"), down=mark("btn move","↓","button"), name=mark("btn","שינוי שם","button");
|
| 2595 |
+
row.dataset.id="library"+v.id; up.title="הזזה למעלה: מוקדם יותר בספריה"; down.title="הזזה למטה: מאוחר יותר בספריה";
|
| 2596 |
+
up.setAttribute("aria-label",`${up.title}, ${v.title}`); down.setAttribute("aria-label",`${down.title}, ${v.title}`);
|
| 2597 |
+
up.disabled=!at; down.disabled=at===mine.length-1;
|
| 2598 |
+
up.onclick=()=>moveShelf(mine,at,-1,list); down.onclick=()=>moveShelf(mine,at,1,list); name.onclick=()=>renameShelf(v);
|
| 2599 |
+
acts.append(up,down,see,name,del);
|
| 2600 |
+
}else acts.append(see,del);
|
| 2601 |
+
row.append(mark("","▶︎","i"),text,acts); list.append(row);
|
| 2602 |
}
|
| 2603 |
adder(adds,`+ הוספת ${one}`,[field("שם הסרטון","title",{maxLength:80,placeholder:sample}),
|
| 2604 |
field("קישור ל-Vimeo או ל-YouTube","url",{inputMode:"url",placeholder:"https://vimeo.com/1233611297/d46ac373c8"})],hint,
|
|
|
|
| 2696 |
const me=id(items[at]), moved=[...$("mContent").querySelectorAll(".crow.added")].find(r=>r.dataset.id===me.kind+me.id); // the keyboard stays on what moved
|
| 2697 |
const again=moved&&[...moved.querySelectorAll(".move")].find((b,k)=>k===(step<0?0:1)&&!b.disabled)||moved&&moved.querySelector(".move:not(:disabled)"); if(again) again.focus({preventScroll:true});
|
| 2698 |
}
|
| 2699 |
+
async function moveShelf(all,at,step,list){ // a video of the library changes places with its neighbour
|
| 2700 |
+
const order=all.map(v=>v.id), me=order[at]; [order[at],order[at+step]]=[order[at+step],order[at]];
|
| 2701 |
+
list.querySelectorAll("button").forEach(b=>b.disabled=true);
|
| 2702 |
+
try{
|
| 2703 |
+
const res=await send("/api/library/order","PUT",{order});
|
| 2704 |
+
if(!res.ok) alert(await refused(res,"ההזזה"));
|
| 2705 |
+
}catch{ alert("השרת לא זמין."); }
|
| 2706 |
+
await loadContent(); drawContent();
|
| 2707 |
+
const moved=[...$("mContent").querySelectorAll(".crow.added")].find(r=>r.dataset.id==="library"+me); // the keyboard stays on what moved
|
| 2708 |
+
const again=moved&&[...moved.querySelectorAll(".move")].find((b,k)=>k===(step<0?0:1)&&!b.disabled)||moved&&moved.querySelector(".move:not(:disabled)"); if(again) again.focus({preventScroll:true});
|
| 2709 |
+
}
|
| 2710 |
+
async function renameShelf(v){
|
| 2711 |
+
const title=(prompt("שם הסרטון (עד 80 תווים):",v.title)||"").trim();
|
| 2712 |
+
if(!title||title===v.title) return;
|
| 2713 |
+
try{
|
| 2714 |
+
const res=await send(`/api/library/${v.id}`,"PUT",{title});
|
| 2715 |
+
if(!res.ok) alert(await refused(res,"שינוי השם"));
|
| 2716 |
+
}catch{ alert("השרת לא זמין."); }
|
| 2717 |
+
await loadContent(); drawContent();
|
| 2718 |
+
}
|
| 2719 |
async function deleteItem(table,id,what,more){
|
| 2720 |
if(!confirm(`למחוק ${what}? ${table==="videos"||table==="texts"?"זה יירד מהמסלול של התלמידים.":"התלמידים לא יראו אותו עוד."}${more}`)) return;
|
| 2721 |
try{
|
tests/test_scoring.py
CHANGED
|
@@ -1008,6 +1008,12 @@ def test_sign_in_guards_the_practice_endpoints():
|
|
| 1008 |
def delete_level_video(self, table, id):
|
| 1009 |
self.days[table] = [v for v in self.days[table] if v["id"] != id]
|
| 1010 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1011 |
def add_video(self, lesson, title, url, pos, ord):
|
| 1012 |
self.vids.append({"id": len(self.vids) + 1, "lesson": lesson, "title": title, "url": url, "pos": pos, "ord": ord})
|
| 1013 |
|
|
@@ -1222,9 +1228,18 @@ def test_sign_in_guards_the_practice_endpoints():
|
|
| 1222 |
assert c.delete("/api/library/3", headers=good).status_code == 200
|
| 1223 |
shelves = c.get("/api/library", headers=good).json()["library"]
|
| 1224 |
assert [(v["id"], v["title"], v["url"]) for v in shelves] == [(1, "two", link.split("?")[0]), (2, "one", link.split("?")[0])]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1225 |
c.app.state.auth.teachers = frozenset()
|
| 1226 |
assert c.get("/api/library", headers=good).json()["library"] == [{k: v for k, v in s.items() if k != "url"} for s in shelves] # all of it, without the links
|
| 1227 |
assert shelf(title="x", url=link) == 403 and c.delete("/api/library/1", headers=good).status_code == 403
|
|
|
|
| 1228 |
assert c.get("/api/dailies", headers=good).status_code == 403 and daily(level=L1, title="x", url=link) == 403 and c.delete("/api/dailies/2", headers=good).status_code == 403
|
| 1229 |
c.app.state.auth.cls, c.app.state.auth.teachers = [k for k in c.app.state.auth.cls if k["name"] != "D"], frozenset({"a@b.c"})
|
| 1230 |
new_class = {"name": " October 2026 ", "emails": "S@b.c, new@b.c\nnew@b.c דנה \nx@b.c\n", # an optional nickname
|
|
|
|
| 1008 |
def delete_level_video(self, table, id):
|
| 1009 |
self.days[table] = [v for v in self.days[table] if v["id"] != id]
|
| 1010 |
|
| 1011 |
+
def rename_level_video(self, table, id, title):
|
| 1012 |
+
next(v for v in self.days[table] if v["id"] == id)["title"] = title
|
| 1013 |
+
|
| 1014 |
+
def order_level_videos(self, table, ids):
|
| 1015 |
+
self.days[table].sort(key=lambda v: ids.index(v["id"]))
|
| 1016 |
+
|
| 1017 |
def add_video(self, lesson, title, url, pos, ord):
|
| 1018 |
self.vids.append({"id": len(self.vids) + 1, "lesson": lesson, "title": title, "url": url, "pos": pos, "ord": ord})
|
| 1019 |
|
|
|
|
| 1228 |
assert c.delete("/api/library/3", headers=good).status_code == 200
|
| 1229 |
shelves = c.get("/api/library", headers=good).json()["library"]
|
| 1230 |
assert [(v["id"], v["title"], v["url"]) for v in shelves] == [(1, "two", link.split("?")[0]), (2, "one", link.split("?")[0])]
|
| 1231 |
+
# a video of the library is renamed, and the library is put in the teacher's order
|
| 1232 |
+
rename = lambda id, **body: c.put(f"/api/library/{id}", json=body, headers=good).status_code
|
| 1233 |
+
order = lambda ids: c.put("/api/library/order", json={"order": ids}, headers=good).status_code
|
| 1234 |
+
assert rename(1, title=" the first ") == 200 and rename(1, title="") == 400 and rename(1, title="x" * 200) == 400 and rename(9, title="x") == 404
|
| 1235 |
+
assert order([2]) == 409 and order([2, 2]) == 409 and order([2, 1, 9]) == 409 and order("x") == 400 and order(["2", "1"]) == 400
|
| 1236 |
+
assert order([2, 1]) == 200
|
| 1237 |
+
shelves = c.get("/api/library", headers=good).json()["library"]
|
| 1238 |
+
assert [(v["id"], v["title"]) for v in shelves] == [(2, "one"), (1, "the first")]
|
| 1239 |
c.app.state.auth.teachers = frozenset()
|
| 1240 |
assert c.get("/api/library", headers=good).json()["library"] == [{k: v for k, v in s.items() if k != "url"} for s in shelves] # all of it, without the links
|
| 1241 |
assert shelf(title="x", url=link) == 403 and c.delete("/api/library/1", headers=good).status_code == 403
|
| 1242 |
+
assert rename(1, title="x") == 403 and order([1, 2]) == 403
|
| 1243 |
assert c.get("/api/dailies", headers=good).status_code == 403 and daily(level=L1, title="x", url=link) == 403 and c.delete("/api/dailies/2", headers=good).status_code == 403
|
| 1244 |
c.app.state.auth.cls, c.app.state.auth.teachers = [k for k in c.app.state.auth.cls if k["name"] != "D"], frozenset({"a@b.c"})
|
| 1245 |
new_class = {"name": " October 2026 ", "emails": "S@b.c, new@b.c\nnew@b.c דנה \nx@b.c\n", # an optional nickname
|