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