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}