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}