headless_lms_mcp/
access.rs1use headless_lms_authorization::{Action, fetch_user_roles, is_permitted_on_course};
5use headless_lms_models::{
6 courses, courses::Course, error::TryToOptional, page_language_groups, roles::Role,
7};
8use sqlx::{PgConnection, Postgres, pool::PoolConnection};
9use uuid::Uuid;
10
11use crate::engine::PageDocument;
12use crate::engine::read::ReadState;
13use crate::snapshots::{self, PageFacts};
14use crate::state::McpState;
15use crate::tools::{RequestContext, ToolError};
16use headless_lms_models::library::agent_changesets::{
17 course_state::{CourseParts, CourseState, CourseView, load_view},
18 risk::load_student_activity,
19};
20
21pub async fn authorize_course(
23 conn: &mut PgConnection,
24 user_id: Uuid,
25 course_id: Uuid,
26) -> Result<Course, ToolError> {
27 let course = courses::get_course(conn, course_id)
28 .await
29 .optional()?
30 .ok_or_else(|| ToolError::NotFound {
31 message: format!("no course {course_id}; list_courses shows the courses you can edit"),
32 })?;
33 let roles = fetch_user_roles(conn, Some(user_id)).await?;
34 if !is_permitted_on_course(&roles, Action::Edit, course.id, course.organization_id) {
35 return Err(ToolError::no_edit_rights());
36 }
37 Ok(course)
38}
39
40pub fn editable_courses(roles: &[Role], courses: Vec<Course>) -> Vec<Course> {
43 courses
44 .into_iter()
45 .filter(|c| is_permitted_on_course(roles, Action::Edit, c.id, c.organization_id))
46 .collect()
47}
48
49pub fn state_of(view: &CourseView, read_state: ReadState) -> &CourseState {
51 match read_state {
52 ReadState::Staged => &view.staged,
53 ReadState::Published => &view.live,
54 }
55}
56
57pub async fn connection(state: &McpState) -> Result<PoolConnection<Postgres>, ToolError> {
59 state.pool.acquire().await.map_err(|e| {
60 ToolError::retryable(
61 format!("the database is busy ({e}); the same call can be retried"),
62 1,
63 )
64 })
65}
66
67pub async fn open_course(
71 state: &McpState,
72 ctx: &RequestContext,
73 course_id: Uuid,
74) -> Result<CourseView, ToolError> {
75 open_course_parts(state, ctx, course_id, CourseParts::All).await
76}
77
78pub async fn load_page_facts(state: &McpState, course_id: Uuid) -> Result<PageFacts, ToolError> {
80 let mut conn = connection(state).await?;
81 let activity = load_student_activity(&mut conn, course_id).await?;
82 Ok(PageFacts {
83 pages_with_submissions: activity.pages_with_submissions,
84 page_language_groups: page_language_groups::get_by_course_id(&mut conn, course_id).await?,
85 })
86}
87
88pub async fn open_course_parts(
90 state: &McpState,
91 ctx: &RequestContext,
92 course_id: Uuid,
93 parts: CourseParts,
94) -> Result<CourseView, ToolError> {
95 let mut conn = connection(state).await?;
96 let course = authorize_course(&mut conn, ctx.principal.actor.user_id, course_id).await?;
97 Ok(load_view(&mut conn, ctx.principal.actor.user_id, course, parts).await?)
98}
99
100pub fn page_in_course(view: &CourseView, page_id: Uuid) -> Result<Uuid, ToolError> {
102 if view.staged.page(page_id).is_some() || view.live.page(page_id).is_some() {
103 Ok(page_id)
104 } else {
105 Err(ToolError::NotFound {
106 message: format!(
107 "course {} has no page {page_id}; get_course_structure lists its pages",
108 view.course.id
109 ),
110 })
111 }
112}
113
114pub fn staged_page_document(
116 staged: &CourseState,
117 page_id: Uuid,
118) -> Result<PageDocument, ToolError> {
119 snapshots::page_document(staged, page_id).ok_or_else(|| deleted_in_staged_changes(page_id))
120}
121
122pub fn deleted_in_staged_changes(page_id: Uuid) -> ToolError {
124 ToolError::NotFound {
125 message: format!("page {page_id} is deleted in your staged changes"),
126 }
127}