Skip to main content

headless_lms_server/controllers/main_frontend/
agent_changesets.rs

1//! Controllers for requests starting with `/api/v0/main-frontend/agent-changesets`: the review
2//! page of the changes a user's AI agent staged.
3
4use std::sync::Arc;
5
6use headless_lms_authorization::AuthorizationToken;
7use models::{
8    agent_changesets::{self, AgentChangeset, AgentChangesetCounts},
9    library::agent_changesets::{
10        self as changesets, ChangesetReview, ChangesetSummary, DiscardOutcome, PublishOptions,
11        PublishOutcome,
12    },
13};
14use utoipa::{IntoParams, OpenApi, ToSchema};
15
16use crate::{
17    domain::{
18        authorization::is_user_global_admin,
19        models_requests::{self, JwtKey},
20        request_id::RequestId,
21    },
22    prelude::*,
23};
24
25#[derive(OpenApi)]
26#[openapi(paths(
27    get_changesets,
28    get_changeset_counts,
29    get_changeset,
30    publish_changeset,
31    discard_changeset,
32    create_preview_link
33))]
34pub(crate) struct MainFrontendAgentChangesetsApiDoc;
35
36#[derive(Debug, Deserialize, IntoParams)]
37#[into_params(parameter_in = Query)]
38pub struct AgentChangesetsQuery {
39    pub course_id: Uuid,
40}
41
42/// A login-free link to one page of a changeset as staged.
43#[derive(Debug, Serialize, Deserialize, ToSchema)]
44pub struct AgentPreviewLink {
45    /// Path of the preview page in the main frontend. It stops working after a few minutes.
46    pub preview_path: String,
47}
48
49/// A publish or discard of the changeset as the user reviewed it.
50#[derive(Debug, Serialize, Deserialize, ToSchema)]
51pub struct ReviewedRevisionRequest {
52    /// `changeset.revision` of the review the user acted on.
53    pub reviewed_revision: i32,
54}
55
56/// Loads the changeset and checks that it is the user's and that they may still edit its course.
57pub(super) async fn authorize_own_changeset(
58    conn: &mut PgConnection,
59    user_id: Uuid,
60    changeset_id: Uuid,
61) -> Result<(AgentChangeset, AuthorizationToken), ControllerError> {
62    let changeset = agent_changesets::get_by_id_for_user(conn, changeset_id, user_id).await?;
63    let token = authorize(
64        conn,
65        Act::Edit,
66        Some(user_id),
67        Res::Course(changeset.course_id),
68    )
69    .await?;
70    Ok((changeset, token))
71}
72
73/**
74GET `/api/v0/main-frontend/agent-changesets?course_id=` - The signed-in user's agent changesets in
75the course, newest first, with what each changes.
76*/
77#[instrument(skip(pool))]
78#[utoipa::path(
79    get,
80    path = "",
81    operation_id = "getAgentChangesets",
82    tag = "agent_changesets",
83    params(AgentChangesetsQuery),
84    responses(
85        (status = 200, description = "The user's changesets in the course", body = [ChangesetSummary])
86    )
87)]
88async fn get_changesets(
89    pool: web::Data<PgPool>,
90    query: web::Query<AgentChangesetsQuery>,
91    user: AuthUser,
92) -> ControllerResult<web::Json<Vec<ChangesetSummary>>> {
93    let mut conn = pool.acquire().await?;
94    let token = authorize(
95        &mut conn,
96        Act::Edit,
97        Some(user.id),
98        Res::Course(query.course_id),
99    )
100    .await?;
101    let summaries = changesets::get_summaries_for_user(&mut conn, user.id, query.course_id).await?;
102    token.authorized_ok(web::Json(summaries))
103}
104
105/**
106GET `/api/v0/main-frontend/agent-changesets/counts?course_id=` - Whether the signed-in user has agent
107changesets in the course, and how many entities their open one stages, for the course management
108tab and its badge.
109*/
110#[instrument(skip(pool))]
111#[utoipa::path(
112    get,
113    path = "/counts",
114    operation_id = "getAgentChangesetCounts",
115    tag = "agent_changesets",
116    params(AgentChangesetsQuery),
117    responses(
118        (status = 200, description = "The user's changeset counts in the course", body = AgentChangesetCounts)
119    )
120)]
121async fn get_changeset_counts(
122    pool: web::Data<PgPool>,
123    query: web::Query<AgentChangesetsQuery>,
124    user: AuthUser,
125) -> ControllerResult<web::Json<AgentChangesetCounts>> {
126    let mut conn = pool.acquire().await?;
127    let token = authorize(
128        &mut conn,
129        Act::Edit,
130        Some(user.id),
131        Res::Course(query.course_id),
132    )
133    .await?;
134    let counts = agent_changesets::get_counts(&mut conn, user.id, query.course_id).await?;
135    token.authorized_ok(web::Json(counts))
136}
137
138/**
139GET `/api/v0/main-frontend/agent-changesets/{changeset_id}` - One of the user's changesets for the
140review page: what each staged entity changes, field by field and for pages as students see them,
141its risk reasons, the conflicts and the agent calls that staged the changes.
142*/
143#[instrument(skip(pool))]
144#[utoipa::path(
145    get,
146    path = "/{changeset_id}",
147    operation_id = "getAgentChangeset",
148    tag = "agent_changesets",
149    params(
150        ("changeset_id" = Uuid, Path, description = "Changeset id")
151    ),
152    responses(
153        (status = 200, description = "The changeset with its entities and calls", body = ChangesetReview)
154    )
155)]
156async fn get_changeset(
157    pool: web::Data<PgPool>,
158    changeset_id: web::Path<Uuid>,
159    user: AuthUser,
160) -> ControllerResult<web::Json<ChangesetReview>> {
161    let mut conn = pool.acquire().await?;
162    let (changeset, token) = authorize_own_changeset(&mut conn, user.id, *changeset_id).await?;
163    let review = changesets::get_review(&mut conn, changeset).await?;
164    token.authorized_ok(web::Json(review))
165}
166
167/**
168POST `/api/v0/main-frontend/agent-changesets/{changeset_id}/publish` - Publishes the user's open
169changeset, risky changes included. Rejected without changes if the changeset is no longer open or no
170longer the reviewed revision, if any staged page or entity changed live since it was staged, or if
171saving a staged entity fails.
172*/
173#[instrument(skip(pool, app_conf, jwt_key))]
174#[utoipa::path(
175    post,
176    path = "/{changeset_id}/publish",
177    operation_id = "publishAgentChangeset",
178    tag = "agent_changesets",
179    params(
180        ("changeset_id" = Uuid, Path, description = "Changeset id")
181    ),
182    request_body = ReviewedRevisionRequest,
183    responses(
184        (status = 200, description = "Published, or why it was not", body = PublishOutcome)
185    )
186)]
187async fn publish_changeset(
188    request_id: RequestId,
189    pool: web::Data<PgPool>,
190    changeset_id: web::Path<Uuid>,
191    payload: web::Json<ReviewedRevisionRequest>,
192    user: AuthUser,
193    app_conf: web::Data<ApplicationConfiguration>,
194    jwt_key: web::Data<JwtKey>,
195) -> ControllerResult<web::Json<PublishOutcome>> {
196    let mut conn = pool.acquire().await?;
197    let (changeset, token) = authorize_own_changeset(&mut conn, user.id, *changeset_id).await?;
198    let can_add_global_admin_only_exercise_services =
199        is_user_global_admin(&mut conn, user.id).await?;
200    let outcome = changesets::publish(
201        &mut conn,
202        changeset.id,
203        PublishOptions {
204            can_add_global_admin_only_exercise_services,
205            reviewed_revision: Some(payload.reviewed_revision),
206            seen_revision: None,
207        },
208        &app_conf,
209        models_requests::make_spec_fetcher(
210            app_conf.base_url.clone(),
211            request_id.0,
212            Arc::clone(&jwt_key),
213        ),
214        models_requests::fetch_service_info,
215    )
216    .await?;
217    token.authorized_ok(web::Json(outcome))
218}
219
220/**
221POST `/api/v0/main-frontend/agent-changesets/{changeset_id}/discard` - Drops the user's open
222changeset. Nothing of it reaches the live course. Rejected without changes if the changeset is no
223longer open or no longer the reviewed revision.
224*/
225#[instrument(skip(pool))]
226#[utoipa::path(
227    post,
228    path = "/{changeset_id}/discard",
229    operation_id = "discardAgentChangeset",
230    tag = "agent_changesets",
231    params(
232        ("changeset_id" = Uuid, Path, description = "Changeset id")
233    ),
234    request_body = ReviewedRevisionRequest,
235    responses(
236        (status = 200, description = "Discarded, or why it was not", body = DiscardOutcome)
237    )
238)]
239async fn discard_changeset(
240    pool: web::Data<PgPool>,
241    changeset_id: web::Path<Uuid>,
242    payload: web::Json<ReviewedRevisionRequest>,
243    user: AuthUser,
244) -> ControllerResult<web::Json<DiscardOutcome>> {
245    let mut conn = pool.acquire().await?;
246    let (changeset, token) = authorize_own_changeset(&mut conn, user.id, *changeset_id).await?;
247    let outcome =
248        changesets::discard(&mut conn, changeset.id, Some(payload.reviewed_revision)).await?;
249    token.authorized_ok(web::Json(outcome))
250}
251
252/**
253POST `/api/v0/main-frontend/agent-changesets/{changeset_id}/preview-link/{page_id}` - Mints a
254short-lived preview link for a page of the user's changeset, so the review page can open the staged
255page as students would see it.
256*/
257#[instrument(skip(pool))]
258#[utoipa::path(
259    post,
260    path = "/{changeset_id}/preview-link/{page_id}",
261    operation_id = "createAgentChangesetPreviewLink",
262    tag = "agent_changesets",
263    params(
264        ("changeset_id" = Uuid, Path, description = "Changeset id"),
265        ("page_id" = Uuid, Path, description = "Page id")
266    ),
267    responses(
268        (status = 200, description = "The preview link", body = AgentPreviewLink)
269    )
270)]
271async fn create_preview_link(
272    pool: web::Data<PgPool>,
273    path: web::Path<(Uuid, Uuid)>,
274    user: AuthUser,
275) -> ControllerResult<web::Json<AgentPreviewLink>> {
276    let (changeset_id, page_id) = path.into_inner();
277    let mut conn = pool.acquire().await?;
278    let (changeset, token) = authorize_own_changeset(&mut conn, user.id, changeset_id).await?;
279    let preview = changesets::mint_preview_token(
280        &mut conn,
281        changeset.course_id,
282        Some(changeset.id),
283        Some(page_id),
284        user.id,
285    )
286    .await?;
287    token.authorized_ok(web::Json(AgentPreviewLink {
288        preview_path: changesets::preview_path(&preview.token),
289    }))
290}
291
292pub fn _add_routes(cfg: &mut ServiceConfig) {
293    cfg.route("", web::get().to(get_changesets))
294        .route("/counts", web::get().to(get_changeset_counts))
295        .route("/{changeset_id}", web::get().to(get_changeset))
296        .route("/{changeset_id}/publish", web::post().to(publish_changeset))
297        .route("/{changeset_id}/discard", web::post().to(discard_changeset))
298        .route(
299            "/{changeset_id}/preview-link/{page_id}",
300            web::post().to(create_preview_link),
301        );
302}