From d7050fed23dbc28271ec0975ba29e5dd47e04d2b Mon Sep 17 00:00:00 2001
From: Robert Czechowski <robert@code-intelligence.com>
Date: Sun, 23 Jun 2024 17:02:56 +0200
Subject: [PATCH] Add new task preview that allows standalone tasks to be
 viewed without a session / without cookies set

---
 src/core.rs                 | 81 +++++++++++++++++++++++++++++++++++++
 src/tests.rs                | 69 +++++++++++++++++++++++++++++++
 src/webfw_iron.rs           | 19 +++++++++
 templates/default/wtask.hbs | 13 +++++-
 4 files changed, 181 insertions(+), 1 deletion(-)

diff --git a/src/core.rs b/src/core.rs
index 1583d6a5..f6820606 100644
--- a/src/core.rs
+++ b/src/core.rs
@@ -1210,6 +1210,87 @@ pub fn review_task<T: MedalConnection>(conn: &T, task_id: i32, session_token: &s
     Ok(Ok((template, data)))
 }
 
+pub fn preview_task<T: MedalConnection>(conn: &T, task_id: i32) -> MedalResult<Result<MedalValue, i32>> {
+    let (t, tg, contest) = conn.get_task_by_id_complete(task_id).ok_or(MedalError::UnknownId)?;
+
+    // Require a public, accessible standalone task
+    if !contest.public
+       || contest.duration != 0
+       || contest.requires_contest.is_some()
+       || contest.requires_login == Some(true)
+       || contest.standalone_task != Some(true)
+    {
+        return Err(MedalError::UnknownId);
+    }
+
+    let time_info = ContestTimeInfo { passed_secs_total: 0,
+                                      left_secs_total: 0,
+                                      left_mins_total: 0,
+                                      left_hour: 0,
+                                      left_min: 0,
+                                      left_sec: 0,
+                                      has_timelimit: contest.duration != 0,
+                                      is_time_left: false,
+                                      exempt_from_timelimit: true,
+                                      can_still_compete: false,
+                                      review_has_timelimit: false,
+                                      has_future_review: false,
+                                      has_review_end: false,
+                                      is_review: true,
+                                      can_still_compete_or_review: true,
+
+                                      until_review_start_day: 0,
+                                      until_review_start_hour: 0,
+                                      until_review_start_min: 0,
+
+                                      until_review_end_day: 0,
+                                      until_review_end_hour: 0,
+                                      until_review_end_min: 0 };
+
+    let mut data = json_val::Map::new();
+
+    data.insert("time_info".to_string(), to_json(&time_info));
+
+    data.insert("time_left_mh_formatted".to_string(),
+                to_json(&format!("{}:{:02}", time_info.left_hour, time_info.left_min)));
+    data.insert("time_left_sec_formatted".to_string(), to_json(&format!(":{:02}", time_info.left_sec)));
+
+    data.insert("auto_save_interval_ms".to_string(), to_json(&0));
+
+    data.insert("contestname".to_string(), to_json(&contest.name));
+    data.insert("name".to_string(), to_json(&tg.name));
+    data.insert("title".to_string(), to_json(&format!("Aufgabe „{}“ in {}", &tg.name, &contest.name)));
+    data.insert("taskid".to_string(), to_json(&task_id));
+    data.insert("contestid".to_string(), to_json(&contest.id));
+    data.insert("readonly".to_string(), to_json(&time_info.is_review));
+    data.insert("preview".to_string(), to_json(&true));
+
+    let (template, tasklocation) = if let Some(language) = t.language {
+        match language.as_str() {
+            "blockly" => ("wtask".to_owned(), t.location.as_str()),
+            "python" => {
+                data.insert("tasklang".to_string(), to_json(&"python"));
+                ("wtask".to_owned(), t.location.as_str())
+            }
+            _ => ("task".to_owned(), t.location.as_str()),
+        }
+    } else {
+        match t.location.chars().next() {
+            Some('B') => ("wtask".to_owned(), &t.location[1..]),
+            Some('P') => {
+                data.insert("tasklang".to_string(), to_json(&"python"));
+                ("wtask".to_owned(), &t.location[1..])
+            }
+            _ => ("task".to_owned(), t.location.as_str()),
+        }
+    };
+
+    let taskpath = format!("{}{}", contest.location, &tasklocation);
+    data.insert("taskpath".to_string(), to_json(&taskpath));
+
+    Ok(Ok((template, data)))
+}
+
 #[derive(Serialize, Deserialize)]
 pub struct GroupInfo {
     pub id: i32,
diff --git a/src/tests.rs b/src/tests.rs
index 681c6542..731b03c3 100644
--- a/src/tests.rs
+++ b/src/tests.rs
@@ -302,6 +302,37 @@ fn run<P, F>(p: P, f: F)
         contest.taskgroups.push(taskgroup);
         contest.save(&conn);
 
+        // ID: 8
+        let mut contest = Contest { id: None,
+                                    location: "directory".to_string(),
+                                    filename: "standalone.yaml".to_string(),
+                                    name: "StandaloneTaskName".to_string(),
+                                    duration: 0, // Time: Unlimited
+                                    public: true,
+                                    start: None,
+                                    end: None,
+                                    review_start: None,
+                                    review_end: None,
+                                    min_grade: None,
+                                    max_grade: None,
+                                    positionalnumber: None,
+                                    protected: false,
+                                    requires_login: None,
+                                    requires_contest: None,
+                                    secret: None,
+                                    message: None,
+                                    image: None,
+                                    language: None,
+                                    category: None,
+                                    standalone_task: Some(true),
+                                    tags: Vec::new(),
+                                    taskgroups: Vec::new() };
+        let mut taskgroup = Taskgroup::new("StandaloneTaskName".to_string(), None);
+        let task = Task::new("standalonetaskdir".to_string(), None, 3); // ID: 15
+        taskgroup.tasks.push(task);
+        contest.taskgroups.push(taskgroup);
+        contest.save(&conn);
+
         let mut config = config::read_config_from_file(Path::new("thisfileshoudnotexist.json"));
 
         let port = {
@@ -2014,3 +2045,41 @@ fn check_group_csv_upload_update() {
             assert_eq!(logincode2, logincode);
         })
 }
+
+#[test]
+fn check_preview_standalone_task() {
+    run(|_| {},
+        |port| {
+            let client = reqwest::Client::builder().cookie_store(false)
+                                                   .redirect(reqwest::RedirectPolicy::none())
+                                                   .build()
+                                                   .unwrap();
+
+            let resp = client.pget(port, "preview/1").send().unwrap();
+            assert_eq!(resp.status(), StatusCode::NOT_FOUND);
+
+            let resp = client.pget(port, "preview/3").send().unwrap();
+            assert_eq!(resp.status(), StatusCode::NOT_FOUND);
+
+            let resp = client.pget(port, "preview/5").send().unwrap();
+            assert_eq!(resp.status(), StatusCode::NOT_FOUND);
+
+            let mut resp = client.pget(port, "preview/15").send().unwrap();
+            assert_eq!(resp.status(), StatusCode::OK);
+
+            let content = resp.text().unwrap();
+            assert!(content.contains("<em>Review-Modus</em>"));
+
+            let resp = client.pget(port, "task/1").send().unwrap();
+            assert_eq!(resp.status(), StatusCode::FOUND);
+
+            let resp = client.pget(port, "task/3").send().unwrap();
+            assert_eq!(resp.status(), StatusCode::FOUND);
+
+            let resp = client.pget(port, "task/5").send().unwrap();
+            assert_eq!(resp.status(), StatusCode::FOUND);
+
+            let resp = client.pget(port, "task/15").send().unwrap();
+            assert_eq!(resp.status(), StatusCode::FOUND);
+        })
+}
diff --git a/src/webfw_iron.rs b/src/webfw_iron.rs
index 810b251d..53fbcbca 100644
--- a/src/webfw_iron.rs
+++ b/src/webfw_iron.rs
@@ -813,6 +813,24 @@ fn review<C>(req: &mut Request) -> IronResult<Response>
     }
 }
 
+fn preview<C>(req: &mut Request) -> IronResult<Response>
+    where C: MedalConnection + std::marker::Send + 'static {
+    let task_id = req.expect_int::<i32>("taskid")?;
+
+    match with_conn![core::preview_task, C, req, task_id].aug(req)? {
+        Ok((template, data)) => {
+            let mut resp = Response::new();
+            resp.set_mut(Template::new(&template, data)).set_mut(status::Ok);
+            Ok(resp)
+        }
+        Err(contest_id) => {
+            // Idea: Append task, and if contest can be started immediately, we can just redirect again!
+            Ok(Response::with((status::Found,
+                               Redirect(url_for!(req, "contest", "contestid" => format!("{}",contest_id))))))
+        }
+    }
+}
+
 fn groups<C>(req: &mut Request) -> IronResult<Response>
     where C: MedalConnection + std::marker::Send + 'static {
     let session_token = req.require_session_token()?;
@@ -1648,6 +1666,7 @@ pub fn start_server<C>(conn: C, config: Config) -> iron::error::HttpResult<iron:
         profile_post: post "/profile/:userid" => user_post::<C>,
         task: get "/task/:taskid" => task::<C>,
         task_review_solution: get "/task/:taskid/:submissionid" => review::<C>,
+        preview_task: get "/preview/:taskid" => preview::<C>,
         teacher: get "/teacher" => teacherinfos::<C>,
         admin: get "/admin" => admin::<C>,
         admin_users: post "/admin/user/" => admin_users::<C>,
diff --git a/templates/default/wtask.hbs b/templates/default/wtask.hbs
index ee8cf5b3..8cf30df1 100644
--- a/templates/default/wtask.hbs
+++ b/templates/default/wtask.hbs
@@ -34,7 +34,11 @@ margin:0px;
 background: #8ca405;
 color:white;
 font-size:12pt;
+{{#if preview}}
+height: 0px;
+{{else}}
 height: 35px;
+{{/if}}
 overflow-y:hidden;
 }
 
@@ -97,8 +101,12 @@ background: #f5fbe8;
 iframe {
 width: 100vw;
 border: 0px;
+{{#if preview}}
+min-height: 100vh; /* Fallback for browsers that do not support Custom Properties */
+{{else}}
 min-height: calc(100vh - 35px); /* Fallback for browsers that do not support Custom Properties */
 min-height: calc(var(--vh) - 35px);
+{{/if}}
 }
 
 a {
@@ -354,7 +362,10 @@ function getTaskProxyCallback(task) {
     {{#if submission}}
       window.load_submission_object({{submission}}, load_task_callback, load_task_error);
     {{else}}
-      window.load_task_object(load_task_callback, load_task_error);
+      {{#if preview}}
+      {{else}}
+        window.load_task_object(load_task_callback, load_task_error);
+      {{/if}}
     {{/if}} //
   }
 
-- 
GitLab