Skip to main content

canvas_lms_api/resources/
discussion_topic.rs

1use crate::{
2    error::{CanvasError, Result},
3    http::Requester,
4    pagination::PageStream,
5    params::wrap_params,
6};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::sync::Arc;
10
11/// Parameters for creating or updating a Canvas discussion topic.
12#[derive(Debug, Default, Clone, Serialize)]
13pub struct UpdateDiscussionParams {
14    #[serde(skip_serializing_if = "Option::is_none")]
15    pub title: Option<String>,
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub message: Option<String>,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub discussion_type: Option<String>,
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub published: Option<bool>,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub delayed_post_at: Option<DateTime<Utc>>,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub locked: Option<bool>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub pinned: Option<bool>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub require_initial_post: Option<bool>,
30}
31
32/// Parameters for posting an entry on a discussion topic.
33#[derive(Debug, Default, Clone, Serialize)]
34pub struct PostEntryParams {
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub message: Option<String>,
37}
38
39/// A Canvas discussion topic (announcement or discussion board post).
40#[derive(Debug, Clone, Deserialize, Serialize, canvas_lms_api_derive::CanvasResource)]
41pub struct DiscussionTopic {
42    pub id: u64,
43    pub course_id: Option<u64>,
44    pub title: Option<String>,
45    pub message: Option<String>,
46    pub html_url: Option<String>,
47    pub posted_at: Option<DateTime<Utc>>,
48    pub last_reply_at: Option<DateTime<Utc>>,
49    pub require_initial_post: Option<bool>,
50    pub user_can_see_posts: Option<bool>,
51    pub discussion_subentry_count: Option<u64>,
52    pub read_state: Option<String>,
53    pub unread_count: Option<u64>,
54    pub subscribed: Option<bool>,
55    pub discussion_type: Option<String>,
56    pub published: Option<bool>,
57    pub locked: Option<bool>,
58    pub pinned: Option<bool>,
59    pub locked_for_user: Option<bool>,
60    pub assignment_id: Option<u64>,
61    pub delayed_post_at: Option<DateTime<Utc>>,
62    pub due_at: Option<DateTime<Utc>>,
63
64    #[serde(skip)]
65    pub(crate) requester: Option<Arc<Requester>>,
66    #[serde(skip)]
67    pub course_id_ctx: Option<u64>,
68    #[serde(skip)]
69    pub group_id: Option<u64>,
70}
71
72impl DiscussionTopic {
73    fn parent_prefix(&self) -> Result<String> {
74        if let Some(id) = self.course_id.or(self.course_id_ctx) {
75            Ok(format!("courses/{id}"))
76        } else if let Some(id) = self.group_id {
77            Ok(format!("groups/{id}"))
78        } else {
79            Err(CanvasError::BadRequest {
80                message: "DiscussionTopic has no course_id or group_id".to_string(),
81                errors: vec![],
82            })
83        }
84    }
85
86    fn propagate(&self, topic: &mut DiscussionTopic) {
87        topic.requester = self.requester.clone();
88        topic.course_id_ctx = self.course_id.or(self.course_id_ctx);
89        topic.group_id = self.group_id;
90    }
91
92    fn make_entry(&self, entry: DiscussionEntry) -> DiscussionEntry {
93        let mut e = entry;
94        e.requester = self.requester.clone();
95        e.course_id = self.course_id.or(self.course_id_ctx);
96        e.group_id = self.group_id;
97        e.topic_id = Some(self.id);
98        e
99    }
100
101    /// Update this discussion topic.
102    ///
103    /// # Canvas API
104    /// `PUT /api/v1/courses/:course_id/discussion_topics/:id`
105    pub async fn update(&self, params: UpdateDiscussionParams) -> Result<DiscussionTopic> {
106        let prefix = self.parent_prefix()?;
107        let form = wrap_params("discussion_topic", &params);
108        let mut topic: DiscussionTopic = self
109            .req()
110            .put(&format!("{prefix}/discussion_topics/{}", self.id), &form)
111            .await?;
112        self.propagate(&mut topic);
113        Ok(topic)
114    }
115
116    /// Delete this discussion topic.
117    ///
118    /// # Canvas API
119    /// `DELETE /api/v1/courses/:course_id/discussion_topics/:id`
120    pub async fn delete(&self) -> Result<()> {
121        let prefix = self.parent_prefix()?;
122        self.req()
123            .delete_void(&format!("{prefix}/discussion_topics/{}", self.id))
124            .await
125    }
126
127    /// Post a new top-level entry to this discussion topic.
128    ///
129    /// # Canvas API
130    /// `POST /api/v1/courses/:course_id/discussion_topics/:id/entries`
131    pub async fn post_entry(&self, params: PostEntryParams) -> Result<DiscussionEntry> {
132        let prefix = self.parent_prefix()?;
133        let form = wrap_params("discussion_entry", &params);
134        let entry: DiscussionEntry = self
135            .req()
136            .post(
137                &format!("{prefix}/discussion_topics/{}/entries", self.id),
138                &form,
139            )
140            .await?;
141        Ok(self.make_entry(entry))
142    }
143
144    /// Stream all top-level entries of this topic.
145    ///
146    /// # Canvas API
147    /// `GET /api/v1/courses/:course_id/discussion_topics/:id/entries`
148    pub fn get_topic_entries(&self) -> PageStream<DiscussionEntry> {
149        let course_id = self.course_id.or(self.course_id_ctx);
150        let group_id = self.group_id;
151        let topic_id = self.id;
152        let prefix = if let Some(id) = course_id {
153            format!("courses/{id}")
154        } else if let Some(id) = group_id {
155            format!("groups/{id}")
156        } else {
157            String::new()
158        };
159        PageStream::new_with_injector(
160            Arc::clone(self.req()),
161            &format!("{prefix}/discussion_topics/{topic_id}/entries"),
162            vec![],
163            move |mut e: DiscussionEntry, req| {
164                e.requester = Some(Arc::clone(&req));
165                e.course_id = course_id;
166                e.group_id = group_id;
167                e.topic_id = Some(topic_id);
168                e
169            },
170        )
171    }
172
173    /// Fetch specific entries by ID.
174    ///
175    /// # Canvas API
176    /// `GET /api/v1/courses/:course_id/discussion_topics/:id/entry_list?ids[]=...`
177    pub async fn get_entries(&self, ids: &[u64]) -> Result<Vec<DiscussionEntry>> {
178        let prefix = self.parent_prefix()?;
179        let params: Vec<(String, String)> = ids
180            .iter()
181            .map(|id| ("ids[]".to_string(), id.to_string()))
182            .collect();
183        let entries: Vec<DiscussionEntry> = self
184            .req()
185            .get(
186                &format!("{prefix}/discussion_topics/{}/entry_list", self.id),
187                &params,
188            )
189            .await?;
190        Ok(entries.into_iter().map(|e| self.make_entry(e)).collect())
191    }
192
193    /// Mark this topic as read.
194    ///
195    /// # Canvas API
196    /// `PUT /api/v1/courses/:course_id/discussion_topics/:id/read`
197    pub async fn mark_as_read(&self) -> Result<()> {
198        let prefix = self.parent_prefix()?;
199        self.req()
200            .put_void(&format!("{prefix}/discussion_topics/{}/read", self.id))
201            .await
202    }
203
204    /// Mark this topic as unread.
205    ///
206    /// # Canvas API
207    /// `DELETE /api/v1/courses/:course_id/discussion_topics/:id/read`
208    pub async fn mark_as_unread(&self) -> Result<()> {
209        let prefix = self.parent_prefix()?;
210        self.req()
211            .delete_void(&format!("{prefix}/discussion_topics/{}/read", self.id))
212            .await
213    }
214
215    /// Mark all entries as read.
216    ///
217    /// # Canvas API
218    /// `PUT /api/v1/courses/:course_id/discussion_topics/:id/read_all`
219    pub async fn mark_entries_as_read(&self, forced: bool) -> Result<()> {
220        let prefix = self.parent_prefix()?;
221        let params = if forced {
222            vec![("forced_read_state".to_string(), "true".to_string())]
223        } else {
224            vec![]
225        };
226        let _ = params; // Canvas ignores this in practice; just issue the PUT
227        self.req()
228            .put_void(&format!("{prefix}/discussion_topics/{}/read_all", self.id))
229            .await
230    }
231
232    /// Mark all entries as unread.
233    ///
234    /// # Canvas API
235    /// `DELETE /api/v1/courses/:course_id/discussion_topics/:id/read_all`
236    pub async fn mark_entries_as_unread(&self, forced: bool) -> Result<()> {
237        let prefix = self.parent_prefix()?;
238        let _ = forced;
239        self.req()
240            .delete_void(&format!("{prefix}/discussion_topics/{}/read_all", self.id))
241            .await
242    }
243
244    /// Subscribe to this topic.
245    ///
246    /// # Canvas API
247    /// `PUT /api/v1/courses/:course_id/discussion_topics/:id/subscribed`
248    pub async fn subscribe(&self) -> Result<()> {
249        let prefix = self.parent_prefix()?;
250        self.req()
251            .put_void(&format!(
252                "{prefix}/discussion_topics/{}/subscribed",
253                self.id
254            ))
255            .await
256    }
257
258    /// Unsubscribe from this topic.
259    ///
260    /// # Canvas API
261    /// `DELETE /api/v1/courses/:course_id/discussion_topics/:id/subscribed`
262    pub async fn unsubscribe(&self) -> Result<()> {
263        let prefix = self.parent_prefix()?;
264        self.req()
265            .delete_void(&format!(
266                "{prefix}/discussion_topics/{}/subscribed",
267                self.id
268            ))
269            .await
270    }
271}
272
273/// A single entry (post) within a Canvas discussion topic.
274#[derive(Debug, Clone, Deserialize, Serialize, canvas_lms_api_derive::CanvasResource)]
275pub struct DiscussionEntry {
276    pub id: u64,
277    pub user_id: Option<u64>,
278    pub discussion_id: Option<u64>,
279    pub parent_id: Option<u64>,
280    pub message: Option<String>,
281    pub created_at: Option<DateTime<Utc>>,
282    pub updated_at: Option<DateTime<Utc>>,
283
284    #[serde(skip)]
285    pub(crate) requester: Option<Arc<Requester>>,
286    #[serde(skip)]
287    pub course_id: Option<u64>,
288    #[serde(skip)]
289    pub group_id: Option<u64>,
290    #[serde(skip)]
291    pub topic_id: Option<u64>,
292}
293
294impl DiscussionEntry {
295    fn parent_prefix(&self) -> Result<String> {
296        if let Some(id) = self.course_id {
297            Ok(format!("courses/{id}"))
298        } else if let Some(id) = self.group_id {
299            Ok(format!("groups/{id}"))
300        } else {
301            Err(CanvasError::BadRequest {
302                message: "DiscussionEntry has no course_id or group_id".to_string(),
303                errors: vec![],
304            })
305        }
306    }
307
308    fn topic_id_or_err(&self) -> Result<u64> {
309        self.topic_id.ok_or_else(|| CanvasError::BadRequest {
310            message: "DiscussionEntry has no topic_id".to_string(),
311            errors: vec![],
312        })
313    }
314
315    fn propagate(&self, entry: &mut DiscussionEntry) {
316        entry.requester = self.requester.clone();
317        entry.course_id = self.course_id;
318        entry.group_id = self.group_id;
319        entry.topic_id = self.topic_id;
320    }
321
322    /// Update this entry's message.
323    ///
324    /// # Canvas API
325    /// `PUT /api/v1/courses/:course_id/discussion_topics/:topic_id/entries/:id`
326    pub async fn update(&self, message: &str) -> Result<DiscussionEntry> {
327        let prefix = self.parent_prefix()?;
328        let topic_id = self.topic_id_or_err()?;
329        let params = vec![("message".to_string(), message.to_string())];
330        let mut entry: DiscussionEntry = self
331            .req()
332            .put(
333                &format!("{prefix}/discussion_topics/{topic_id}/entries/{}", self.id),
334                &params,
335            )
336            .await?;
337        self.propagate(&mut entry);
338        Ok(entry)
339    }
340
341    /// Delete this entry.
342    ///
343    /// # Canvas API
344    /// `DELETE /api/v1/courses/:course_id/discussion_topics/:topic_id/entries/:id`
345    pub async fn delete(&self) -> Result<()> {
346        let prefix = self.parent_prefix()?;
347        let topic_id = self.topic_id_or_err()?;
348        self.req()
349            .delete_void(&format!(
350                "{prefix}/discussion_topics/{topic_id}/entries/{}",
351                self.id
352            ))
353            .await
354    }
355
356    /// Post a reply to this entry.
357    ///
358    /// # Canvas API
359    /// `POST /api/v1/courses/:course_id/discussion_topics/:topic_id/entries/:id/replies`
360    pub async fn post_reply(&self, message: &str) -> Result<DiscussionEntry> {
361        let prefix = self.parent_prefix()?;
362        let topic_id = self.topic_id_or_err()?;
363        let params = vec![("message".to_string(), message.to_string())];
364        let mut entry: DiscussionEntry = self
365            .req()
366            .post(
367                &format!(
368                    "{prefix}/discussion_topics/{topic_id}/entries/{}/replies",
369                    self.id
370                ),
371                &params,
372            )
373            .await?;
374        self.propagate(&mut entry);
375        Ok(entry)
376    }
377
378    /// Stream replies to this entry.
379    ///
380    /// # Canvas API
381    /// `GET /api/v1/courses/:course_id/discussion_topics/:topic_id/entries/:id/replies`
382    pub fn get_replies(&self) -> PageStream<DiscussionEntry> {
383        let course_id = self.course_id;
384        let group_id = self.group_id;
385        let topic_id = self.topic_id.unwrap_or(0);
386        let entry_id = self.id;
387        let prefix = if let Some(id) = course_id {
388            format!("courses/{id}")
389        } else if let Some(id) = group_id {
390            format!("groups/{id}")
391        } else {
392            String::new()
393        };
394        PageStream::new_with_injector(
395            Arc::clone(self.req()),
396            &format!("{prefix}/discussion_topics/{topic_id}/entries/{entry_id}/replies"),
397            vec![],
398            move |mut e: DiscussionEntry, req| {
399                e.requester = Some(Arc::clone(&req));
400                e.course_id = course_id;
401                e.group_id = group_id;
402                e.topic_id = Some(topic_id);
403                e
404            },
405        )
406    }
407
408    /// Mark this entry as read.
409    ///
410    /// # Canvas API
411    /// `PUT /api/v1/courses/:course_id/discussion_topics/:topic_id/entries/:id/read`
412    pub async fn mark_as_read(&self) -> Result<()> {
413        let prefix = self.parent_prefix()?;
414        let topic_id = self.topic_id_or_err()?;
415        self.req()
416            .put_void(&format!(
417                "{prefix}/discussion_topics/{topic_id}/entries/{}/read",
418                self.id
419            ))
420            .await
421    }
422
423    /// Mark this entry as unread.
424    ///
425    /// # Canvas API
426    /// `DELETE /api/v1/courses/:course_id/discussion_topics/:topic_id/entries/:id/read`
427    pub async fn mark_as_unread(&self) -> Result<()> {
428        let prefix = self.parent_prefix()?;
429        let topic_id = self.topic_id_or_err()?;
430        self.req()
431            .delete_void(&format!(
432                "{prefix}/discussion_topics/{topic_id}/entries/{}/read",
433                self.id
434            ))
435            .await
436    }
437
438    /// Rate this entry (0 = unrate, 1 = rate).
439    ///
440    /// # Canvas API
441    /// `POST /api/v1/courses/:course_id/discussion_topics/:topic_id/entries/:id/rating`
442    pub async fn rate(&self, rating: u8) -> Result<()> {
443        if rating > 1 {
444            return Err(CanvasError::BadRequest {
445                message: "rating must be 0 or 1".to_string(),
446                errors: vec![],
447            });
448        }
449        let prefix = self.parent_prefix()?;
450        let topic_id = self.topic_id_or_err()?;
451        let params = vec![("rating".to_string(), rating.to_string())];
452        self.req()
453            .post_void_with_params(
454                &format!(
455                    "{prefix}/discussion_topics/{topic_id}/entries/{}/rating",
456                    self.id
457                ),
458                &params,
459            )
460            .await
461    }
462}