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