3v324v23 commited on
Commit
5d06ea2
·
1 Parent(s): 854d36e

feat: enhance video library management with renaming and ordering functionality

Browse files
Files changed (5) hide show
  1. README.md +9 -1
  2. app/auth.py +19 -1
  3. app/main.py +34 -2
  4. static/index.html +35 -3
  5. 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 they were added: id, level (a level id), title,
 
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 they were added."""
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; הם מוצגים בסדר שבו נוספו.</p>
 
 
 
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> ליד הסרטון ומאשרים. הסרטון נעלם לכל התלמידים. אין עריכה של שם או קישור: כדי לשנות, מוחקים ומוסיפים מחדש.</p>
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
- acts.append(see,del); row.append(mark("","▶︎","i"),text,acts); list.append(row);
 
 
 
 
 
 
 
 
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