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