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