medal/
contestreader_yaml.rs

1/*  medal                                                                                                            *\
2 *  Copyright (C) 2022  Bundesweite Informatikwettbewerbe, Robert Czechowski                                                            *
3 *                                                                                                                   *
4 *  This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero        *
5 *  General Public License as published  by the Free Software Foundation, either version 3 of the License, or (at    *
6 *  your option) any later version.                                                                                  *
7 *                                                                                                                   *
8 *  This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the       *
9 *  implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General Public      *
10 *  License for more details.                                                                                        *
11 *                                                                                                                   *
12 *  You should have received a copy of the GNU Affero General Public License along with this program.  If not, see   *
13\*  <http://www.gnu.org/licenses/>.                                                                                  */
14
15use db_objects::{Contest, Task, Taskgroup};
16
17use serde_yaml;
18use std::path::Path;
19
20extern crate time;
21
22/// `ContestYaml` defines the content of a YAML file describing a contest.
23///
24/// Example:
25///
26/// ```yaml
27/// name: "Funny contest"
28/// duration_minutes: 0
29/// public_listing: true
30/// protected: false
31/// requires_login: true
32/// requires_contest:
33///   - "Another contest"
34///   - "Yet another contest"
35/// participation_start: "2019-03-28T00:00:01+01:00",
36/// participation_end: "2019-04-05T23:59:59+01:00",
37/// review_start: "2019-03-28T00:00:01+01:00",
38/// review_end: "2019-04-05T23:59:59+01:00",
39/// min_grade: 1,
40/// max_grade: 8,
41/// team_participation: true,
42/// category: "contests",
43/// image: "funny.png",
44/// language: blockly,
45/// tags:
46///   - funny
47///   - contest
48///   - blockly
49/// message: "This is a <em>really</em> funny contest!"
50/// position: 1,
51/// secret: "telefonbuch",
52/// tasks:
53///   "Task 1": "task/for/three/stars"
54///   "Task 2":
55///     - "subtask/for/two/stars"
56///     - "subtask/for/three/stars"
57///   "Task 3":
58///     "task/for/four/stars": {stars: 4}
59///   "Task 4":
60///     "subtask/for/four/stars": {stars: 4}
61///     "subtask/for/five/stars": {}
62/// ```
63#[derive(Debug, Deserialize)]
64struct ContestYaml {
65    /// The name of the contest.
66    ///
67    /// This value must be set!
68    name: Option<String>,
69
70    /// The duration of the contest, in minutes. If set to 0, the contest can be
71    /// accessed indefinitely. If set to something else, the contest will require a
72    /// login to participate in order to enforce the time limit.
73    ///
74    /// This value must be set!
75    duration_minutes: Option<i32>,
76
77    /// Set to `true` to show the contest in the list of contest. Note that contests
78    /// might still be hidden immediately or later after `participation_end` has
79    /// passed.
80    public_listing: Option<bool>,
81
82    /// Set to `true` to protect contest participations. Protected contest
83    /// participations can not be deleted by teachers – only by admins.
84    protected: Option<bool>,
85
86    /// Set to `true` to require a login for participations. Useful for contests
87    /// with `duration_minutes` set to `0`.
88    requires_login: Option<bool>,
89
90    /// List of contests, of which one the users is required to have a participation
91    /// in, in order to participate in the contest defined by this very config.
92    requires_contest: Option<Vec<String>>,
93
94    /// Date and time of the start of the contest. If not set, the contest can be
95    /// started at any time before `participation_end`.
96    participation_start: Option<String>,
97
98    /// Date and time of the end of the contest. If not set, the contest can be
99    /// started at any time after `participation_start`. Started contests can stil
100    /// accessed during their duration even if `participation_end` is in the past.
101    participation_end: Option<String>,
102
103    /// Start of the review mode. Sensible values are the same value as
104    /// `participation_start` to allow users to review their submissions right
105    /// after participating, or the same value as `participation_end` to allow users
106    /// to review their submissions after the contest has ended. One of
107    /// `review_start` and `review_end` must be set to enable the review mode!
108    review_start: Option<String>,
109
110    /// End of the review mode. One of `review_start` and `review_end` must be set
111    /// to enable the review mode!
112    review_end: Option<String>,
113
114    /// The minimum grade of the user for this contest to participate in.
115    min_grade: Option<i32>,
116
117    /// The maximum grade of the user for this contest to participate in.
118    max_grade: Option<i32>,
119
120    /// Set to `true` to allow for team participations.
121    team_participation: Option<bool>,
122
123    /// The category for this contest to be shown in.
124    category: Option<String>,
125
126    /// An image to be shown for this contest in the list of contests.
127    image: Option<String>,
128
129    /// The language of the contest. Can be set to `blockly` or `python`.
130    language: Option<String>,
131
132    /// A list of tags to annotate the contest with. Contests can be filtered
133    /// by tag on the contest list pages.
134    tags: Option<Vec<String>>,
135
136    /// A message to be displayed at the contest (in a box or similar). Might
137    /// contain HTML, which will not be escaped by the platform.
138    message: Option<String>,
139
140    /// A number to order the contests by in the contest list. Contest will be
141    /// ordered by DESCENDING position number.
142    position: Option<i32>,
143
144    /// A password required to start a participation in this contest. Also allows
145    /// contests to be searched by this value, so this `secret` should be reused
146    /// between contests ONLY if the intended audience is exactly the same.
147    secret: Option<String>,
148
149    /// The mapping of task names to task information.
150    ///
151    /// A task information can be either:
152    ///  - A path of a task. The task will be worth 3 stars.
153    ///  - A list of paths to subtasks. The first subtask will be worth 2 stars,
154    ///    each additional subtask will be worth one additional star.
155    ///  - A mapping of paths to additional information. The additional
156    ///    information is a mapping that currently only has one pemitted key,
157    ///    `stars` with the value being the number of stars for this task/subtask.
158    ///
159    /// If `language` is not set, each path for an algorea-style task has to be
160    /// prefixed with either `B` or `P` in order to enable the algorea task wrapper
161    /// for Blockly or Python, respectively.
162    tasks: Option<serde_yaml::Mapping>,
163}
164
165/// `TaskYaml` defines the content of a YAML file describing a standalone task.
166///
167/// The file must be named `task.yaml`.
168///
169/// Example:
170/// ```yaml
171/// name: "Funny task"
172/// standalone: true
173/// public_listing: false
174/// image: "preview.png"
175/// languages:
176///   - blockly
177///   - python
178/// tags:
179///   - easy
180///   - task
181///   - blockly
182///   - python
183/// position: 1
184/// ```
185#[derive(Debug, Deserialize)]
186struct TaskYaml {
187    /// The name of the task.
188    ///
189    /// This value must be set!
190    name: Option<String>,
191
192    /// Set to `true` to define this task as a standalone task.
193    ///
194    /// This value must be set to `true`!
195    standalone: Option<bool>,
196
197    /// Set to `true` to show the task in the list of standalone tasks.
198    public_listing: Option<bool>,
199
200    /// An image to be shown for this task in the list of standalone tasks..
201    image: Option<String>,
202
203    /// A list of the languages that the task should be available in. Possible
204    /// values are `blockly` or `python`.
205    languages: Option<Vec<String>>,
206
207    /// A list of tags to annotate the contest with. Contests can be filtered
208    /// by tag on the contest list pages.
209    tags: Option<Vec<String>>,
210
211    /// A number to order the task by in the list of standalone tasks. Tasks will be
212    /// ordered by DESCENDING position number.
213    position: Option<i32>,
214
215    /// A password required to start a participation on this task. Also allows
216    /// contests to be searched by this value, so this `secret` should be reused
217    /// between contests ONLY if the intended audience is exactly the same.
218    secret: Option<String>,
219}
220
221use self::time::{strptime, Timespec};
222
223fn parse_timespec(time: String, key: &str, directory: &str, filename: &str) -> Timespec {
224    strptime(&time, &"%FT%T%z").map(|t| t.to_timespec())
225                               .unwrap_or_else(|_| {
226                                   panic!("Time value '{}' could not be parsed in {}{}", key, directory, filename)
227                               })
228}
229
230// The task path is stored relatively to the contest.yaml for easier identificationy
231// Concatenation happens in functions::show_task
232fn parse_contest_yaml(content: &str, filename: &str, directory: &str) -> Option<Vec<Contest>> {
233    let config: ContestYaml = match serde_yaml::from_str(&content) {
234        Ok(contest) => contest,
235        Err(e) => {
236            eprintln!();
237            eprintln!("{}", e);
238            eprintln!("Error loading contest YAML: {}{}", directory, filename);
239            panic!("Loading contest file")
240        }
241    };
242
243    let start: Option<Timespec> =
244        config.participation_start.map(|x| parse_timespec(x, "participation_start", directory, filename));
245    let end: Option<Timespec> =
246        config.participation_end.map(|x| parse_timespec(x, "participation_end", directory, filename));
247    let review_start: Option<Timespec> =
248        config.review_start.map(|x| parse_timespec(x, "review_start", directory, filename));
249    let review_end: Option<Timespec> = config.review_end.map(|x| parse_timespec(x, "review_end", directory, filename));
250
251    let review_start = if review_end.is_none() {
252        review_start
253    } else if let Some(end) = end {
254        Some(review_start.unwrap_or(end))
255    } else {
256        review_start
257    };
258
259    let mut contest =
260        Contest { id: None,
261                  location: directory.to_string(),
262                  filename: filename.to_string(),
263                  name: config.name.unwrap_or_else(|| panic!("'name' missing in {}{}", directory, filename)),
264                  duration:
265                      config.duration_minutes
266                            .unwrap_or_else(|| panic!("'duration_minutes' missing in {}{}", directory, filename)),
267                  public: config.public_listing.unwrap_or(false),
268                  start,
269                  end,
270                  review_start,
271                  review_end,
272                  min_grade: config.min_grade,
273                  max_grade: config.max_grade,
274                  max_teamsize: if config.team_participation.unwrap_or(false) { Some(2) } else { None },
275                  positionalnumber: config.position,
276                  protected: config.protected.unwrap_or(false),
277                  requires_login: config.requires_login,
278                  // Consumed by `let required_contests = contest.requires_contest.as_ref()?.split(',');` in core.rs
279                  requires_contest: config.requires_contest.map(|list| list.join(",")),
280                  secret: config.secret,
281                  message: config.message,
282                  image: config.image,
283                  language: config.language.clone(),
284                  category: config.category,
285                  standalone_task: None,
286                  tags: config.tags.unwrap_or_else(Vec::new),
287                  taskgroups: Vec::new() };
288    // TODO: Timeparsing should fail more pleasantly (-> Panic, thus shows message)
289
290    for (positionalnumber, (name, info)) in config.tasks?.into_iter().enumerate() {
291        if let serde_yaml::Value::String(name) = name {
292            let mut taskgroup = Taskgroup::new(name, Some(positionalnumber as i32));
293            match info {
294                serde_yaml::Value::String(taskdir) => {
295                    let task = Task::new(taskdir, config.language.clone(), 3);
296                    taskgroup.tasks.push(task);
297                }
298                serde_yaml::Value::Sequence(taskdirs) => {
299                    let mut stars = 2;
300                    for taskdir in taskdirs {
301                        if let serde_yaml::Value::String(taskdir) = taskdir {
302                            let task = Task::new(taskdir, config.language.clone(), stars);
303                            taskgroup.tasks.push(task);
304                        } else {
305                            panic!("Invalid contest YAML: {}{} (a)", directory, filename)
306                        }
307
308                        stars += 1;
309                    }
310                }
311                serde_yaml::Value::Mapping(taskdirs) => {
312                    let mut stars = 2;
313                    for (taskdir, taskinfo) in taskdirs {
314                        if let (serde_yaml::Value::String(taskdir), serde_yaml::Value::Mapping(taskinfo)) =
315                            (taskdir, taskinfo)
316                        {
317                            if let Some(serde_yaml::Value::Number(cstars)) =
318                                taskinfo.get(&serde_yaml::Value::String("stars".to_string()))
319                            {
320                                stars = cstars.as_u64().unwrap() as i32;
321                            }
322                            let task = Task::new(taskdir, config.language.clone(), stars);
323                            taskgroup.tasks.push(task);
324                            stars += 1;
325                        } else {
326                            panic!("Invalid contest YAML: {}{} (b)", directory, filename)
327                        }
328                    }
329                }
330                _ => panic!("Invalid contest YAML: {}{} (c)", directory, filename),
331            }
332            contest.taskgroups.push(taskgroup);
333        } else {
334            panic!("Invalid contest YAML: {}{} (d)", directory, filename)
335        }
336    }
337
338    Some(vec![contest])
339}
340
341#[derive(Debug)]
342enum ConfigError {
343    #[allow(dead_code)]
344    ParseError(serde_yaml::Error),
345    MissingField,
346}
347
348fn parse_task_yaml(content: &str, filename: &str, directory: &str) -> Result<Vec<Contest>, ConfigError> {
349    let config: TaskYaml = serde_yaml::from_str(&content).map_err(ConfigError::ParseError)?;
350
351    // Only handle tasks with standalone = true
352    if config.standalone != Some(true) {
353        return Err(ConfigError::MissingField);
354    }
355
356    let languages = config.languages.ok_or(ConfigError::MissingField)?;
357
358    if languages.len() == 0 {
359        return Err(ConfigError::MissingField);
360    }
361
362    let mut contests = Vec::new();
363
364    for language in languages {
365        let name = config.name.clone().unwrap_or_else(|| panic!("'name' missing in {}{}", directory, filename));
366        let mut contest = Contest { id: None,
367                                    location: directory.to_string(),
368                                    filename: format!("{}_{}", language, filename),
369                                    name: name.clone(),
370                                    // Task always are unlimited in time
371                                    duration: 0,
372                                    public: config.public_listing.unwrap_or(false),
373                                    start: None,
374                                    end: None,
375                                    review_start: None,
376                                    review_end: None,
377                                    min_grade: None,
378                                    max_grade: None,
379                                    max_teamsize: None,
380                                    positionalnumber: config.position,
381                                    protected: false,
382                                    requires_login: Some(false),
383                                    requires_contest: None,
384                                    secret: config.secret.clone(),
385                                    message: None,
386                                    image: config.image.clone(),
387                                    language: Some(language.clone()),
388                                    category: Some("standalone_task".to_string()),
389                                    standalone_task: Some(true),
390                                    tags: config.tags.clone().unwrap_or_else(Vec::new),
391                                    taskgroups: Vec::new() };
392
393        let mut taskgroup = Taskgroup::new(name, None);
394        let stars = 0;
395        let taskdir = ".".to_string();
396        let task = Task::new(taskdir, Some(language), stars);
397        taskgroup.tasks.push(task);
398        contest.taskgroups.push(taskgroup);
399
400        contests.push(contest);
401    }
402
403    Ok(contests)
404}
405
406fn read_task_or_contest(p: &Path) -> Option<Vec<Contest>> {
407    use std::fs::File;
408    use std::io::Read;
409
410    let mut file = File::open(p).unwrap();
411    let mut contents = String::new();
412    file.read_to_string(&mut contents).ok()?;
413
414    let filename: &str = p.file_name().to_owned()?.to_str()?;
415
416    if filename == "task.yaml" {
417        parse_task_yaml(&contents, filename, &format!("{}/", p.parent().unwrap().to_str()?)).ok()
418    } else {
419        parse_contest_yaml(&contents, filename, &format!("{}/", p.parent().unwrap().to_str()?))
420    }
421}
422
423use config::Config;
424
425pub fn get_all_contest_info(task_dir: &str, config: &Config) -> Vec<Contest> {
426    fn walk_me_recursively(p: &Path, contests: &mut Vec<Contest>, config: &Config) {
427        if let Ok(paths) = std::fs::read_dir(p) {
428            let mut paths: Vec<_> = paths.filter_map(|r| r.ok()).collect();
429            paths.sort_by_key(|dir| dir.path());
430            for path in paths {
431                let p = path.path();
432                walk_me_recursively(&p, contests, config);
433            }
434        }
435
436        let filename = p.file_name().unwrap().to_string_lossy().to_string();
437        if filename.ends_with(".yaml") {
438            {
439                use std::io::Write;
440                print!(".");
441                std::io::stdout().flush().unwrap();
442            }
443
444            let mut restricted = false;
445            config.restricted_task_directories.as_ref().map(|restricted_task_directories| {
446                let pathname = p.to_string_lossy().to_string();
447                restricted_task_directories.iter().for_each(|restricted_task_directory| {
448                    if pathname.starts_with(restricted_task_directory) {
449                        restricted = true;
450                    }
451                });
452            });
453
454            if let Some(cs) = read_task_or_contest(p) {
455                for c in cs {
456                    if restricted {
457                        if c.public {
458                            println!("\nWARNING: Skipping public contest defined in '{}' due to being in a restricted directory!", p.display());
459                            continue;
460                        }
461                        if c.secret.is_none() {
462                            println!("\nWARNING: Contest defined in '{}' has no secret, can only be reached via id!",
463                                     p.display());
464                        }
465                    }
466                    contests.push(c);
467                }
468            }
469        };
470    }
471
472    let mut contests = Vec::new();
473    match std::fs::read_dir(task_dir) {
474        Err(why) => eprintln!("Error opening tasks directory! {:?}", why.kind()),
475        Ok(paths) => {
476            for path in paths {
477                walk_me_recursively(&path.unwrap().path(), &mut contests, config);
478            }
479        }
480    };
481
482    contests
483}
484
485#[test]
486fn parse_contest_yaml_no_tasks() {
487    let contest_file_contents = r#"
488name: "JwInf 2020 Runde 1: Jgst. 3 – 6"
489duration_minutes: 60
490"#;
491
492    let contest = parse_contest_yaml(contest_file_contents, "", "");
493    assert!(contest.is_none());
494}
495
496#[test]
497fn parse_contest_yaml_dates() {
498    let contest_file_contents = r#"
499name: "JwInf 2020 Runde 1: Jgst. 3 – 6"
500participation_start: "2022-03-01T00:00:00+01:00"
501participation_end: "2022-03-31T22:59:59+01:00"
502duration_minutes: 60
503
504tasks: {}
505"#;
506
507    let contest = parse_contest_yaml(contest_file_contents, "", "");
508    assert!(contest.is_some());
509
510    //let contest = contest.unwrap();
511
512    // These tests are unfortunately dependent on the timezone the system is on. Skip them for now until we have found
513    // a better solution.
514
515    //assert_eq!(contest.start, Some(Timespec {sec: 1646089200, nsec: 0}));
516    //assert_eq!(contest.end, Some(Timespec {sec: 1648763999, nsec: 0}));
517
518    // Unix Timestamp 	1646089200
519    // GMT 	Mon Feb 28 2022 23:00:00 GMT+0000
520    // Your Time Zone 	Tue Mar 01 2022 00:00:00 GMT+0100 (Mitteleuropäische Normalzeit)
521
522    // Unix Timestamp 	1648764000
523    // GMT 	Thu Mar 31 2022 22:00:00 GMT+0000
524    // Your Time Zone 	Fri Apr 01 2022 00:00:00 GMT+0200 (Mitteleuropäische Sommerzeit)
525}