Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ pub mod regradings;
pub mod roles;
pub mod status;
pub mod teacher_grading_decisions;
pub mod time;
pub mod user_details;
pub mod users;

Expand Down Expand Up @@ -77,5 +78,6 @@ pub fn _add_routes(cfg: &mut ServiceConfig) {
.service(web::scope("/oauth").configure(oauth::_add_routes))
.service(web::scope("/chatbots").configure(chatbots::_add_routes))
.service(web::scope("/chatbot-models").configure(chatbot_models::_add_routes))
.service(web::scope("/time").configure(time::_add_routes))
.service(web::scope("/status").configure(status::_add_routes));
}
69 changes: 69 additions & 0 deletions services/headless-lms/server/src/controllers/main_frontend/time.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*!
Handlers for HTTP requests to `/api/v0/main-frontend/time`.
*/

use crate::prelude::*;
use chrono::{SecondsFormat, Utc};

/**
GET `/api/v0/main-frontend/time` Returns the server's current UTC time as an RFC3339 timestamp string.

Response body example:
`"2026-02-18T12:34:56.789Z"`
*/
pub async fn get_current_time() -> ControllerResult<HttpResponse> {
let server_time = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
let token = skip_authorize();

token.authorized_ok(
HttpResponse::Ok()
.insert_header((
"Cache-Control",
"no-store, no-cache, must-revalidate, max-age=0",
))
.insert_header(("Pragma", "no-cache"))
.insert_header(("Expires", "0"))
.json(server_time),
)
}

pub fn _add_routes(cfg: &mut ServiceConfig) {
cfg.route("", web::get().to(get_current_time));
}

#[cfg(test)]
mod tests {
use super::*;
use actix_web::{App, test, web};

#[actix_web::test]
async fn returns_non_cacheable_rfc3339_json_string() {
let app = test::init_service(App::new().service(web::scope("/api/v0").service(
web::scope("/main-frontend").service(web::scope("/time").configure(_add_routes)),
)))
.await;

let req = test::TestRequest::with_uri("/api/v0/main-frontend/time").to_request();
let resp = test::call_service(&app, req).await;

assert!(resp.status().is_success());
assert_eq!(
resp.headers().get("Cache-Control").unwrap(),
"no-store, no-cache, must-revalidate, max-age=0"
);
assert_eq!(resp.headers().get("Pragma").unwrap(), "no-cache");
assert_eq!(resp.headers().get("Expires").unwrap(), "0");

let body: String = test::read_body_json(resp).await;
assert!(chrono::DateTime::parse_from_rfc3339(&body).is_ok());

let parsed = chrono::DateTime::parse_from_rfc3339(&body).unwrap();
let server_utc = parsed.with_timezone(&chrono::Utc);
let now = chrono::Utc::now();
let diff = (server_utc - now).abs();
assert!(
diff < chrono::Duration::seconds(5),
"server time should be close to now"
);
}
}
223 changes: 163 additions & 60 deletions services/main-frontend/src/app/manage/exams/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,31 @@ import TextField from "@/shared-module/common/components/InputFields/TextField"
import Spinner from "@/shared-module/common/components/Spinner"
import { withSignedIn } from "@/shared-module/common/contexts/LoginStateContext"
import useToastMutation from "@/shared-module/common/hooks/useToastMutation"
import { baseTheme, headingFont, primaryFont, typography } from "@/shared-module/common/styles"
import {
manageCourseByIdRoute,
manageExamQuestionsRoute,
testExamRoute,
} from "@/shared-module/common/utils/routes"
import { humanReadableDateTime } from "@/shared-module/common/utils/time"
import withErrorBoundary from "@/shared-module/common/utils/withErrorBoundary"

const Organization: React.FC = () => {
const detailRow = css`
font-family: ${primaryFont};
font-size: 0.9375rem;
line-height: 1.5;
color: ${baseTheme.colors.gray[600]};
margin-bottom: 0.25rem;
`

const detailValue = css`
font-weight: 600;
color: ${baseTheme.colors.gray[700]};
`

const ManageExam: React.FC = () => {
const { id } = useParams<{ id: string }>()
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const getExam = useQuery({ queryKey: [`exam-${id}`], queryFn: () => fetchExam(id) })
const organizationId = useQuery({
queryKey: [`organizations-${id}`],
Expand Down Expand Up @@ -75,132 +90,220 @@ const Organization: React.FC = () => {
},
},
)

return (
<div
className={css`
margin-bottom: 1rem;
margin-bottom: 2rem;
`}
>
{getExam.isError && <ErrorBanner variant={"readOnly"} error={getExam.error} />}
{getExam.isLoading && <Spinner variant={"medium"} />}
{getExam.isSuccess && (
<>
<h1>
{getExam.data.name} {getExam.data.id}
<h1
className={css`
font-family: ${headingFont};
font-size: ${typography.h4};
font-weight: 700;
line-height: 1.2;
color: ${baseTheme.colors.gray[700]};
margin: 0 0 1rem 0;
`}
>
{getExam.data.name}
</h1>

<div
className={css`
padding-bottom: 1rem;
margin-bottom: 1rem;
border-bottom: 1px solid ${baseTheme.colors.clear[300]};
`}
>
<div className={detailRow}>
{t("label-starts-at")}:{" "}
<span className={detailValue}>
{/* eslint-disable-next-line i18next/no-literal-string */}
{humanReadableDateTime(getExam.data.starts_at, i18n.language) ?? "—"}
</span>
</div>
<div className={detailRow}>
{t("label-ends-at")}:{" "}
<span className={detailValue}>
{/* eslint-disable-next-line i18next/no-literal-string */}
{humanReadableDateTime(getExam.data.ends_at, i18n.language) ?? "—"}
</span>
</div>
<div className={detailRow}>
{t("label-duration")}:{" "}
<span className={detailValue}>
{getExam.data.time_minutes} {t("minutes")}
</span>
</div>
<div className={detailRow}>
{t("label-grade-exam-manually")}:{" "}
<span className={detailValue}>
{getExam.data.grade_manually ? t("yes") : t("no")}
</span>
</div>
<div className={detailRow}>
{t("label-minimum-points-threshold")}:{" "}
<span className={detailValue}>
{/* eslint-disable i18next/no-literal-string */}
{getExam.data.minimum_points_treshold > 0
? String(getExam.data.minimum_points_treshold)
: "—"}
{/* eslint-enable i18next/no-literal-string */}
</span>
</div>
<div className={detailRow}>
{t("label-language")}: <span className={detailValue}>{getExam.data.language}</span>
</div>
<Button
size="medium"
variant="primary"
disabled={!organizationId}
onClick={() => {
if (organizationId) {
setEditExamFormOpen(true)
}
}}
className={css`
margin-top: 0.75rem;
`}
>
{t("edit-exam")}
</Button>
</div>

{organizationId && (
<EditExamDialog
initialData={getExam.data}
examId={getExam.data.id}
organizationId={organizationId}
open={editExamFormOpen}
close={() => {
setEditExamFormOpen(false)
getExam.refetch()
}}
/>
)}

<ul
className={css`
list-style-type: none;
padding-left: 0;
margin: 0 0 1.5rem 0;
font-family: ${primaryFont};
font-size: 1rem;
`}
>
<li>
<a href={`/cms/pages/${getExam.data.page_id}`}>{t("manage-page")}</a> (
{getExam.data.page_id})
<li className={detailRow}>
<a href={`/cms/pages/${getExam.data.page_id}`}>{t("link-edit-exam-page")}</a>
</li>
<li>
<li className={detailRow}>
<Link
href={`/manage/exams/${getExam.data.id}/permissions`}
aria-label={`${t("link-manage-permissions")} ${getExam.data.name}`}
>
{t("link-manage-permissions")}
</Link>
</li>
<li>
<li className={detailRow}>
<a href={`/cms/exams/${getExam.data.id}/edit`}>{t("link-edit-exam-instructions")}</a>
</li>
<li>
<li className={detailRow}>
<a href={`/api/v0/main-frontend/exams/${getExam.data.id}/export-points`}>
{t("link-export-points")}
</a>
</li>
<li>
<li className={detailRow}>
<a href={`/api/v0/main-frontend/exams/${getExam.data.id}/export-submissions`}>
{t("link-export-submissions")}
</a>
</li>
<li>
<li className={detailRow}>
<Link href={manageExamQuestionsRoute(getExam.data.id)}>{t("grading")}</Link>
</li>
<li>
{organizationSlug && (
{organizationSlug && (
<li className={detailRow}>
<Link href={testExamRoute(organizationSlug, getExam.data.id)}>
{t("link-test-exam")}
</Link>
)}
</li>
<li>
<div
className={css`
margin-bottom: 1rem;
`}
>
<EditExamDialog
initialData={getExam.data || null}
examId={getExam.data.id}
organizationId={organizationId || ""}
open={editExamFormOpen}
close={() => {
setEditExamFormOpen(!setEditExamFormOpen)
getExam.refetch()
}}
/>
</div>
<Button
size="medium"
variant="primary"
onClick={() => setEditExamFormOpen(!editExamFormOpen)}
>
{t("edit-exam")}
</Button>
</li>
</li>
)}
</ul>
<h2>{t("courses")}</h2>

<h2
className={css`
font-family: ${headingFont};
font-size: ${typography.h5};
font-weight: 600;
color: ${baseTheme.colors.gray[700]};
margin: 0 0 0.5rem 0;
`}
>
{t("courses")}
</h2>
{getExam.data.courses.map((c) => (
<div key={c.id}>
<Link href={manageCourseByIdRoute(c.id)}>{c.name}</Link> ({c.id}){" "}
<div
key={c.id}
className={css`
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.5rem;
`}
>
<Link href={manageCourseByIdRoute(c.id)}>{c.name}</Link>
<Button
onClick={() => {
unsetCourseMutation.mutate({ examId: getExam.data.id, courseId: c.id })
unsetCourseMutation.mutate({
examId: getExam.data.id,
courseId: c.id,
})
}}
variant={"secondary"}
size={"medium"}
variant="secondary"
size="medium"
>
{t("button-text-remove")}
</Button>
</div>
))}
<TextField
label={t("add-course")}
onChange={(event) => {
setNewCourse(event.target.value)
}}
value={newCourse}
onChange={(event) => setNewCourse(event.target.value)}
placeholder={t("course-id")}
className={css`
margin-bottom: 1rem;
margin-bottom: 0.5rem;
`}
></TextField>

/>
<Button
onClick={() => {
setCourseMutation.mutate({ examId: getExam.data.id, courseId: newCourse })
setCourseMutation.mutate({
examId: getExam.data.id,
courseId: newCourse,
})
setNewCourse("")
}}
variant={"secondary"}
size={"medium"}
variant="secondary"
size="medium"
>
{t("add-course")}
</Button>
{setCourseMutation.isError && (
<ErrorBanner variant={"readOnly"} error={setCourseMutation.error} />
<ErrorBanner variant="readOnly" error={setCourseMutation.error} />
)}
{unsetCourseMutation.isError && (
<ErrorBanner variant={"readOnly"} error={unsetCourseMutation.error} />
<ErrorBanner variant="readOnly" error={unsetCourseMutation.error} />
)}
</>
)}
</div>
)
}

export default withErrorBoundary(withSignedIn(Organization))
export default withErrorBoundary(withSignedIn(ManageExam))
Loading
Loading