1use std::collections::HashMap;
2
3use futures::future::BoxFuture;
4use url::Url;
5use utoipa::ToSchema;
6
7use crate::{
8 exercise_services::{
9 ExerciseService, get_exercise_service_by_exercise_type, get_exercise_services,
10 },
11 prelude::*,
12};
13
14#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
15pub struct ExerciseServiceInfo {
16 pub exercise_service_id: Uuid,
17 pub created_at: DateTime<Utc>,
18 pub updated_at: DateTime<Utc>,
19 pub user_interface_iframe_path: String,
20 pub grade_endpoint_path: String,
21 pub public_spec_endpoint_path: String,
22 pub model_solution_spec_endpoint_path: String,
23 pub has_custom_view: bool,
25 pub csv_export_definitions_endpoint_path: Option<String>,
26 pub csv_export_answers_endpoint_path: Option<String>,
27 pub supports_native_client: bool,
28 pub produces_file_answers: bool,
29 pub declares_spec_files: bool,
30}
31
32#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
33pub struct PathInfo {
34 pub exercise_service_id: Uuid,
35 pub user_interface_iframe_path: String,
36 pub grade_endpoint_path: String,
37 pub public_spec_endpoint_path: String,
38 pub model_solution_spec_endpoint_path: String,
39 pub has_custom_view: bool,
41 pub supports_native_client: bool,
42 pub produces_file_answers: bool,
43 pub declares_spec_files: bool,
44}
45
46#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
47
48pub struct CourseMaterialExerciseServiceInfo {
49 pub exercise_iframe_url: String,
50}
51
52#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
53
54pub struct ExerciseServiceInfoApi {
55 pub service_name: String,
56 pub user_interface_iframe_path: String,
57 pub grade_endpoint_path: String,
58 pub public_spec_endpoint_path: String,
59 pub model_solution_spec_endpoint_path: String,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub has_custom_view: Option<bool>,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub csv_export_definitions_endpoint_path: Option<String>,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub csv_export_answers_endpoint_path: Option<String>,
66 #[serde(default)]
69 pub supports_native_client: bool,
70 #[serde(default)]
73 pub produces_file_answers: bool,
74 #[serde(default)]
82 pub declares_spec_files: bool,
83}
84
85pub async fn insert(
86 conn: &mut PgConnection,
87 exercise_service_info: &PathInfo,
88) -> ModelResult<ExerciseServiceInfo> {
89 let res = sqlx::query_as!(
90 ExerciseServiceInfo,
91 "
92INSERT INTO exercise_service_info (
93 exercise_service_id,
94 user_interface_iframe_path,
95 grade_endpoint_path,
96 public_spec_endpoint_path,
97 model_solution_spec_endpoint_path,
98 has_custom_view,
99 supports_native_client,
100 produces_file_answers,
101 declares_spec_files
102 )
103VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
104RETURNING *
105",
106 exercise_service_info.exercise_service_id,
107 exercise_service_info.user_interface_iframe_path,
108 exercise_service_info.grade_endpoint_path,
109 exercise_service_info.public_spec_endpoint_path,
110 exercise_service_info.model_solution_spec_endpoint_path,
111 exercise_service_info.has_custom_view,
112 exercise_service_info.supports_native_client,
113 exercise_service_info.produces_file_answers,
114 exercise_service_info.declares_spec_files
115 )
116 .fetch_one(conn)
117 .await?;
118 Ok(res)
119}
120
121pub async fn fetch_and_upsert_service_info(
122 conn: &mut PgConnection,
123 exercise_service: &ExerciseService,
124 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
125) -> ModelResult<ExerciseServiceInfo> {
126 let url = match exercise_service
127 .internal_url
128 .clone()
129 .map(|url| Url::parse(&url))
130 {
131 Some(Ok(url)) => url.to_string(),
132
133 Some(Err(e)) => {
134 warn!(
135 "Internal_url provided for {} is not a valid url. Using public_url instead. Error: {}",
136 exercise_service.name,
137 e.to_string()
138 );
139 exercise_service.public_url.clone()
140 }
141 None => exercise_service.public_url.clone(),
142 };
143 let fetched_info = fetch_service_info(url.parse()?).await?;
144 let res = upsert_service_info(conn, exercise_service.id, &fetched_info).await?;
145 Ok(res)
146}
147
148pub async fn upsert_service_info(
149 conn: &mut PgConnection,
150 exercise_service_id: Uuid,
151 update: &ExerciseServiceInfoApi,
152) -> ModelResult<ExerciseServiceInfo> {
153 let res = sqlx::query_as!(
154 ExerciseServiceInfo,
155 r#"
156INSERT INTO exercise_service_info(
157 exercise_service_id,
158 user_interface_iframe_path,
159 grade_endpoint_path,
160 public_spec_endpoint_path,
161 model_solution_spec_endpoint_path,
162 has_custom_view,
163 csv_export_definitions_endpoint_path,
164 csv_export_answers_endpoint_path,
165 supports_native_client,
166 produces_file_answers,
167 declares_spec_files
168 )
169VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
170ON CONFLICT(exercise_service_id) DO UPDATE
171SET user_interface_iframe_path = $2,
172 grade_endpoint_path = $3,
173 public_spec_endpoint_path = $4,
174 model_solution_spec_endpoint_path = $5,
175 has_custom_view = $6,
176 csv_export_definitions_endpoint_path = $7,
177 csv_export_answers_endpoint_path = $8,
178 supports_native_client = $9,
179 produces_file_answers = $10,
180 declares_spec_files = $11
181RETURNING *
182 "#,
183 exercise_service_id,
184 update.user_interface_iframe_path,
185 update.grade_endpoint_path,
186 update.public_spec_endpoint_path,
187 update.model_solution_spec_endpoint_path,
188 update.has_custom_view.unwrap_or_else(|| false),
189 update.csv_export_definitions_endpoint_path.as_deref(),
190 update.csv_export_answers_endpoint_path.as_deref(),
191 update.supports_native_client,
192 update.produces_file_answers,
193 update.declares_spec_files
194 )
195 .fetch_one(conn)
196 .await?;
197 Ok(res)
198}
199
200pub async fn get_service_info(
201 conn: &mut PgConnection,
202 exercise_service_id: Uuid,
203) -> ModelResult<ExerciseServiceInfo> {
204 let res = sqlx::query_as!(
205 ExerciseServiceInfo,
206 r#"
207SELECT *
208FROM exercise_service_info
209WHERE exercise_service_id = $1
210 "#,
211 exercise_service_id
212 )
213 .fetch_one(conn)
214 .await?;
215 Ok(res)
216}
217
218pub async fn get_service_info_by_exercise_type(
219 conn: &mut PgConnection,
220 exercise_type: &str,
221 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
222) -> ModelResult<ExerciseServiceInfo> {
223 let exercise_service = get_exercise_service_by_exercise_type(conn, exercise_type).await?;
224 let service_info =
225 get_upsert_service_info_by_exercise_service(conn, &exercise_service, fetch_service_info)
226 .await?;
227 Ok(service_info)
228}
229
230pub async fn get_all_exercise_services_by_type(
231 conn: &mut PgConnection,
232) -> ModelResult<HashMap<String, (ExerciseService, ExerciseServiceInfo)>> {
233 let mut exercise_services_by_type = HashMap::new();
234 for exercise_service in get_exercise_services(conn).await? {
235 match get_service_info_by_exercise_service(conn, &exercise_service).await {
236 Ok(Some(info)) => {
237 exercise_services_by_type
238 .insert(exercise_service.slug.clone(), (exercise_service, info));
239 }
240 _ => {
241 tracing::error!(
242 "No corresponding service info found for {} ({})",
243 exercise_service.name,
244 exercise_service.id
245 );
246 }
247 }
248 }
249 Ok(exercise_services_by_type)
250}
251
252pub async fn get_upsert_all_exercise_services_by_type(
253 conn: &mut PgConnection,
254 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
255) -> ModelResult<HashMap<String, (ExerciseService, ExerciseServiceInfo)>> {
256 let mut exercise_services_by_type = HashMap::new();
257 for exercise_service in get_exercise_services(conn).await? {
258 match get_upsert_service_info_by_exercise_service(
259 conn,
260 &exercise_service,
261 &fetch_service_info,
262 )
263 .await
264 {
265 Ok(info) => {
266 exercise_services_by_type
267 .insert(exercise_service.slug.clone(), (exercise_service, info));
268 }
269 _ => {
270 tracing::error!(
271 "No corresponding service info found for {} ({})",
272 exercise_service.name,
273 exercise_service.id
274 );
275 }
276 }
277 }
278 Ok(exercise_services_by_type)
279}
280
281pub async fn get_selected_exercise_services_by_type(
282 conn: &mut PgConnection,
283 slugs: &[String],
284 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
285) -> ModelResult<HashMap<String, (ExerciseService, ExerciseServiceInfo)>> {
286 let selected_services = sqlx::query_as!(
287 ExerciseService,
288 "
289SELECT *
290FROM exercise_services
291WHERE slug = ANY($1);",
292 slugs,
293 )
294 .fetch_all(&mut *conn)
295 .await?;
296 let mut exercise_services_by_type = HashMap::new();
297 for exercise_service in selected_services {
298 let info = get_upsert_service_info_by_exercise_service(
299 conn,
300 &exercise_service,
301 &fetch_service_info,
302 )
303 .await?;
304 exercise_services_by_type.insert(exercise_service.slug.clone(), (exercise_service, info));
305 }
306 Ok(exercise_services_by_type)
307}
308
309pub async fn get_upsert_service_info_by_exercise_service(
310 conn: &mut PgConnection,
311 exercise_service: &ExerciseService,
312 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
313) -> ModelResult<ExerciseServiceInfo> {
314 let res = get_service_info(conn, exercise_service.id).await;
315 let service_info = match res {
316 Ok(exercise_service_info) => exercise_service_info,
317 _ => {
318 warn!(
319 "Could not find service info for {} ({}). This is rare and only should happen when a background worker has not had the opportunity to complete their fetching task yet. Trying the fetching here in this worker so that we can continue.",
320 exercise_service.name, exercise_service.slug
321 );
322
323 fetch_and_upsert_service_info(conn, exercise_service, fetch_service_info).await?
324 }
325 };
326 Ok(service_info)
327}
328
329pub async fn get_service_info_by_exercise_service(
330 conn: &mut PgConnection,
331 exercise_service: &ExerciseService,
332) -> ModelResult<Option<ExerciseServiceInfo>> {
333 let res = get_service_info(conn, exercise_service.id).await;
334 let service_info = match res {
335 Ok(exercise_service_info) => exercise_service_info,
336 _ => {
337 warn!(
338 "Could not find service info for {} ({}). This is rare and only should happen when a background worker has not had the opportunity to complete their fetching task yet.",
339 exercise_service.name, exercise_service.slug
340 );
341 return Ok(None);
342 }
343 };
344 Ok(Some(service_info))
345}
346
347pub async fn get_course_material_service_info_by_exercise_type(
352 conn: &mut PgConnection,
353 exercise_type: &str,
354 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
355) -> ModelResult<Option<CourseMaterialExerciseServiceInfo>> {
356 match get_exercise_service_by_exercise_type(conn, exercise_type).await {
357 Ok(exercise_service) => {
358 let full_service_info = get_upsert_service_info_by_exercise_service(
359 conn,
360 &exercise_service,
361 fetch_service_info,
362 )
363 .await;
364 let service_info_option = match full_service_info {
365 Ok(o) => {
366 let mut url =
370 Url::parse(&exercise_service.public_url).map_err(|original_err| {
371 ModelError::new(
372 ModelErrorType::Generic,
373 original_err.to_string(),
374 Some(original_err.into()),
375 )
376 })?;
377 url.set_path(&o.user_interface_iframe_path);
378 url.set_query(None);
379 url.set_fragment(None);
380
381 Some(CourseMaterialExerciseServiceInfo {
382 exercise_iframe_url: url.to_string(),
383 })
384 }
385 _ => None,
386 };
387
388 Ok(service_info_option)
389 }
390 _ => Ok(None),
391 }
392}
393
394#[cfg(test)]
395mod test {
396 use super::*;
397 use crate::exercise_services::{ExerciseServiceNewOrUpdate, insert_exercise_service};
398 use crate::test_helper::*;
399
400 fn service_info_body(produces_file_answers: Option<bool>) -> serde_json::Value {
401 let mut body = serde_json::json!({
402 "service_name": "File answers",
403 "user_interface_iframe_path": "/iframe",
404 "grade_endpoint_path": "/grade",
405 "public_spec_endpoint_path": "/public-spec",
406 "model_solution_spec_endpoint_path": "/model-solution",
407 });
408 if let Some(declared) = produces_file_answers {
409 body["produces_file_answers"] = serde_json::json!(declared);
410 }
411 body
412 }
413
414 #[test]
417 fn a_service_info_body_declares_file_answers_or_defaults_to_not_producing_them() {
418 let omitted: ExerciseServiceInfoApi =
419 serde_json::from_value(service_info_body(None)).unwrap();
420 assert!(!omitted.produces_file_answers);
421
422 let declared: ExerciseServiceInfoApi =
423 serde_json::from_value(service_info_body(Some(true))).unwrap();
424 assert!(declared.produces_file_answers);
425 }
426
427 #[tokio::test]
430 async fn a_declared_file_answer_capability_survives_the_fetch_and_store_hop() {
431 insert_data!(:tx);
432 let slug = format!("file-answers-{}", Uuid::new_v4());
433 let service = insert_exercise_service(
434 tx.as_mut(),
435 &ExerciseServiceNewOrUpdate {
436 name: slug.clone(),
437 slug: slug.clone(),
438 public_url: "http://example.com/api/service".to_string(),
439 internal_url: None,
440 max_reprocessing_submissions_at_once: 1,
441 },
442 )
443 .await
444 .unwrap();
445 let declared: ExerciseServiceInfoApi =
446 serde_json::from_value(service_info_body(Some(true))).unwrap();
447
448 let fetched = get_service_info_by_exercise_type(tx.as_mut(), &slug, |_url| {
449 let declared = declared.clone();
450 Box::pin(async move { Ok(declared) })
451 })
452 .await
453 .unwrap();
454
455 assert!(fetched.produces_file_answers);
456 assert!(
457 get_service_info(tx.as_mut(), service.id)
458 .await
459 .unwrap()
460 .produces_file_answers
461 );
462 tx.rollback().await;
463 }
464}