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