Skip to content

Added Exam mode #1236

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 47 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
fc72d29
added enable_exam_mode field to all tests, models, and controllers
iZUMi-kyouka Feb 14, 2025
2fc38ca
force student whose courses has exam mode to only access the particul…
iZUMi-kyouka Feb 15, 2025
9953b9c
fixed failure to switch course even when no exam mode is enabled for …
iZUMi-kyouka Feb 15, 2025
f450e85
fixed unintended changes to previous migration; added migration file …
iZUMi-kyouka Mar 3, 2025
0bb9857
added is_official_course field to models; added logic to prevent stud…
iZUMi-kyouka Mar 3, 2025
d7ad8b3
removed unnecessary changes in course_registrations.ex
iZUMi-kyouka Mar 4, 2025
f216d1b
removed unnecessary changes from user_controller.ex
iZUMi-kyouka Mar 4, 2025
48fff28
sync fork
iZUMi-kyouka Mar 4, 2025
b5f2722
Merge branch 'master' into exam_mode
RichDom2185 Mar 6, 2025
bc4c48c
added resume code functionality
iZUMi-kyouka Mar 11, 2025
46c2ff0
Merge remote-tracking branch 'refs/remotes/origin/exam_mode' into exa…
iZUMi-kyouka Mar 11, 2025
fe80662
added resume code checking endpoint and its handler
iZUMi-kyouka Mar 12, 2025
f3c9ea9
minor change to resume_code handler
iZUMi-kyouka Mar 12, 2025
665ad2a
restore accidental deletion of function
iZUMi-kyouka Mar 13, 2025
abb1014
renamed resume_code to check_resume_code
iZUMi-kyouka Mar 13, 2025
82afebf
added validation for enabling exam mode and setting resume code; dele…
iZUMi-kyouka Mar 13, 2025
1db4420
added is_paused column and setting functionality to mitigate students…
iZUMi-kyouka Mar 18, 2025
d88eeba
added migrations for is_paused_column
iZUMi-kyouka Mar 18, 2025
d9a97e3
Merge branch 'master' into exam_mode
GabrielCWT Mar 31, 2025
40ea386
Remove unused tree
RichDom2185 Mar 31, 2025
7ae6f14
Fix format
RichDom2185 Mar 31, 2025
e62fcef
Merge branch 'master' into exam_mode
RichDom2185 Mar 31, 2025
e0330f2
Redate migrations to maintain total ordering
RichDom2185 Mar 31, 2025
d971fcd
fixed failing tests due to missing fields in factory method, and expe…
iZUMi-kyouka Apr 1, 2025
40ce982
Merge branch 'exam_mode_user_paused_flag' into exam_mode
iZUMi-kyouka Apr 1, 2025
0302764
makes latest course retrieval logic more concise
iZUMi-kyouka Apr 1, 2025
7fcbc89
ran mix format on courses and user controller
iZUMi-kyouka Apr 1, 2025
cad39c8
added new endpoint to report lost/regain of user focus
iZUMi-kyouka Apr 2, 2025
46af8d7
removed filters for supplying exam_mode_course to renderer function
iZUMi-kyouka Apr 3, 2025
6531376
renamed check_resume_code to try_unpause_user; split up the logic int…
iZUMi-kyouka Apr 3, 2025
3dcc288
removed unnecessary admin config renderer in user_view and admin_user…
iZUMi-kyouka Apr 3, 2025
31bbf5a
excludes staff and admins from exam_mode restriction on courses retur…
iZUMi-kyouka Apr 3, 2025
48c1b10
excludes staff and admins from exam_mode restriction on courses retur…
iZUMi-kyouka Apr 3, 2025
e0758fb
Revert "excludes staff and admins from exam_mode restriction on cours…
iZUMi-kyouka Apr 3, 2025
9154177
Merge branch 'exam_mode' into exam_mode_user_focus_logging
iZUMi-kyouka Apr 4, 2025
7bd79fd
created new user_browser_focus_log; and created its type definition, …
iZUMi-kyouka Apr 4, 2025
ed16cb4
removed redundant logic from focus logging controller
iZUMi-kyouka Apr 4, 2025
22a5704
sets a default resume_code in migration; add random resume code gener…
iZUMi-kyouka Apr 5, 2025
6cba5f5
fixed resume code validation to consider whitespace; formatting
iZUMi-kyouka Apr 5, 2025
f6ab358
added default resume_code value to schema definition in course.ex; ma…
iZUMi-kyouka Apr 8, 2025
b99a82d
improved swagger help text for resume_code field; fix formatting
iZUMi-kyouka Apr 8, 2025
604b61b
fixed bug in resume_code validation; added tests for exam_mode, is_of…
iZUMi-kyouka Apr 8, 2025
ca082c9
formatting
iZUMi-kyouka Apr 9, 2025
398bee0
formatting
iZUMi-kyouka Apr 9, 2025
1c3d787
add moduledoc for focus log
iZUMi-kyouka Apr 9, 2025
434d21c
Merge branch 'master' into exam_mode
RichDom2185 Jun 13, 2025
58906d7
Merge branch 'master' into exam_mode
iZUMi-kyouka Jun 19, 2025
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
14 changes: 14 additions & 0 deletions lib/cadet/accounts/course_registrations.ex
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,20 @@ defmodule Cadet.Accounts.CourseRegistrations do
|> Repo.all()
end

def get_exam_mode_course(%User{id: id}) do
CourseRegistration
|> where([cr], cr.user_id == ^id)
|> join(:inner, [cr], c in assoc(cr, :course),
on: c.enable_exam_mode == true and c.is_official_course == true
)
|> join(:left, [cr, c], ac in assoc(c, :assessment_config))
|> preload([cr, c, ac],
course: {c, assessment_config: ^from(ac in AssessmentConfig, order_by: [asc: ac.order])}
)
|> preload(:group)
|> Repo.one()
end

def get_admin_courses_count(%User{id: id}) do
CourseRegistration
|> where(user_id: ^id)
Expand Down
40 changes: 38 additions & 2 deletions lib/cadet/courses/course.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ defmodule Cadet.Courses.Course do
enable_achievements: boolean(),
enable_sourcecast: boolean(),
enable_stories: boolean(),
enable_exam_mode: boolean(),
resume_code: string(),
is_official_course: boolean(),
source_chapter: integer(),
source_variant: String.t(),
module_help_text: String.t(),
Expand All @@ -28,6 +31,9 @@ defmodule Cadet.Courses.Course do
field(:enable_achievements, :boolean, default: true)
field(:enable_sourcecast, :boolean, default: true)
field(:enable_stories, :boolean, default: false)
field(:enable_exam_mode, :boolean, default: false)
field(:resume_code, :string)
field(:is_official_course, :boolean, default: false)
field(:source_chapter, :integer)
field(:source_variant, :string)
field(:module_help_text, :string)
Expand All @@ -41,14 +47,44 @@ defmodule Cadet.Courses.Course do
end

@required_fields ~w(course_name viewable enable_game
enable_achievements enable_sourcecast enable_stories source_chapter source_variant)a
@optional_fields ~w(course_short_name module_help_text)a
enable_exam_mode enable_achievements enable_sourcecast enable_stories source_chapter source_variant)a
@optional_fields ~w(course_short_name module_help_text resume_code)a

def changeset(course, params) do
course
|> cast(params, @required_fields ++ @optional_fields)
|> validate_required(@required_fields)
|> validate_sublanguage_combination(params)
|> validate_exam_mode(params)
end

# Validates combination of exam mode, resume code, and official course state
defp validate_exam_mode(changeset, params) do
resume_code = Map.get(params, :resume_code, "")
enable_exam_mode = Map.get(params, :enable_exam_mode, false)
is_official_course = get_field(changeset, :is_official_course, false)

case {enable_exam_mode, is_official_course, resume_code} do
{false, _, _} ->
changeset

{true, false, _} ->
add_error(
changeset,
:enable_exam_mode,
"Exam mode is only available for official institution course."
)

{true, true, ""} ->
add_error(
changeset,
:resume_code,
"Resume code must be set to non-empty value upon enabling of exam mode."
)

{_, _, _} ->
changeset
end
end

# Validates combination of Source chapter and variant
Expand Down
2 changes: 1 addition & 1 deletion lib/cadet/courses/courses.ex
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
"""
@spec get_course_config(integer) ::
{:ok, Course.t()} | {:error, {:bad_request, String.t()}}
def get_course_config(course_id) when is_ecto_id(course_id) do
def get_course_config(course_id, is_admin \\ false) when is_ecto_id(course_id) do

Check warning on line 47 in lib/cadet/courses/courses.ex

View workflow job for this annotation

GitHub Actions / Run CI

variable "is_admin" is unused (if the variable is not meant to be used, prefix it with an underscore)
case retrieve_course(course_id) do
nil ->
{:error, {:bad_request, "Invalid course id"}}
Expand Down
2 changes: 2 additions & 0 deletions lib/cadet_web/admin_controllers/admin_courses_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ defmodule CadetWeb.AdminCoursesController do
enable_achievements(:body, :boolean, "Enable achievements")
enable_sourcecast(:body, :boolean, "Enable sourcecast")
enable_stories(:body, :boolean, "Enable stories")
enable_exam_mode(:body, :boolean, "Enable exam mode")
resume_code(:body, :string, "Resume code when attempt to open DevTool is detected")
sublanguage(:body, Schema.ref(:AdminSublanguage), "sublanguage object")
module_help_text(:body, :string, "Module help text")
end
Expand Down
3 changes: 1 addition & 2 deletions lib/cadet_web/admin_controllers/admin_user_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ defmodule CadetWeb.AdminUserController do
use PhoenixSwagger

import Ecto.Query

alias Cadet.Repo
alias Cadet.{Accounts, Assessments, Courses}
alias Cadet.Accounts.{CourseRegistrations, CourseRegistration, Role}
Expand All @@ -14,7 +13,7 @@ defmodule CadetWeb.AdminUserController do
users =
filter |> try_keywordise_string_keys() |> Accounts.get_users_by(conn.assigns.course_reg)

render(conn, "users.json", users: users)
render(conn, "users.json", users: users, is_admin: false)
end

def combined_total_xp(conn, %{"course_reg_id" => course_reg_id}) do
Expand Down
28 changes: 27 additions & 1 deletion lib/cadet_web/controllers/courses_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ defmodule CadetWeb.CoursesController do
def index(conn, %{"course_id" => course_id}) when is_ecto_id(course_id) do
case Courses.get_course_config(course_id) do
{:ok, config} ->
render(conn, "config.json", config: config)
if conn.assigns.course_reg.role == :admin || conn.assigns.course_reg.role == "admin" do
render(conn, "config_admin.json", config: config)
else
render(conn, "config.json", config: config)
end

# coveralls-ignore-start
# no course error will not happen here
Expand Down Expand Up @@ -40,6 +44,26 @@ defmodule CadetWeb.CoursesController do
end
end

def check_resume_code(conn, %{"course_id" => course_id}) when is_ecto_id(course_id) do
params = conn.body_params
resume_code = Map.get(params, "resume_code", nil)

case Courses.get_course_config(course_id) do
{:ok, config} ->
if config.resume_code == resume_code do
send_resp(conn, 200, "Resume code is correct.")
else
send_resp(conn, 403, "Resume code is wrong.")
end

# coveralls-ignore-start
# no course error will not happen here
{:error, {status, message}} ->
send_resp(conn, status, message)
# coveralls-ignore-stop
end
end

swagger_path :create do
post("/config/create")

Expand All @@ -56,6 +80,7 @@ defmodule CadetWeb.CoursesController do
enable_achievements(:body, :boolean, "Enable achievements", required: true)
enable_sourcecast(:body, :boolean, "Enable sourcecast", required: true)
enable_stories(:body, :boolean, "Enable stories", required: true)
enable_exam_mode(:body, :boolean, "Enable exam mode", required: true)
source_chapter(:body, :number, "Default source chapter", required: true)

source_variant(:body, Schema.ref(:SourceVariant), "Default source variant name",
Expand Down Expand Up @@ -97,6 +122,7 @@ defmodule CadetWeb.CoursesController do
enable_achievements(:boolean, "Enable achievements", required: true)
enable_sourcecast(:boolean, "Enable sourcecast", required: true)
enable_stories(:boolean, "Enable stories", required: true)
enable_exam_mode(:boolean, "Enable exam mode", required: true)
source_chapter(:integer, "Source Chapter number from 1 to 4", required: true)
source_variant(Schema.ref(:SourceVariant), "Source Variant name", required: true)
module_help_text(:string, "Module help text", required: true)
Expand Down
94 changes: 62 additions & 32 deletions lib/cadet_web/controllers/user_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,45 +12,71 @@ defmodule CadetWeb.UserController do
def index(conn, _) do
user = conn.assigns.current_user
courses = CourseRegistrations.get_courses(conn.assigns.current_user)

if user.latest_viewed_course_id do
latest = CourseRegistrations.get_user_course(user.id, user.latest_viewed_course_id)
xp = Assessments.assessments_total_xp(latest)
max_xp = Assessments.user_max_xp(latest)
story = Assessments.user_current_story(latest)

render(
conn,
"index.json",
user: user,
courses: courses,
latest: latest,
max_xp: max_xp,
story: story,
xp: xp
)
else
render(conn, "index.json",
user: user,
courses: courses,
latest: nil,
max_xp: nil,
story: nil,
xp: nil
)
exam_mode_course = CourseRegistrations.get_exam_mode_course(conn.assigns.current_user)

cond do
exam_mode_course ->
IO.puts("Course #{exam_mode_course.course_id} is under exam mode.")
xp = Assessments.assessments_total_xp(exam_mode_course)
max_xp = Assessments.user_max_xp(exam_mode_course)
story = Assessments.user_current_story(exam_mode_course)

render(
conn,
"index.json",
user: user,
courses: courses |> Enum.filter(fn c -> c.course_id == exam_mode_course.course_id end),
latest: exam_mode_course,
max_xp: max_xp,
story: story,
xp: xp
)

user.latest_viewed_course_id ->
latest = CourseRegistrations.get_user_course(user.id, user.latest_viewed_course_id)
xp = Assessments.assessments_total_xp(latest)
max_xp = Assessments.user_max_xp(latest)
story = Assessments.user_current_story(latest)

render(
conn,
"index.json",
user: user,
courses: courses,
latest: latest,
max_xp: max_xp,
story: story,
xp: xp
)

true ->
render(conn, "index.json",
user: user,
courses: courses,
latest: nil,
max_xp: nil,
story: nil,
xp: nil
)
end
end

def get_latest_viewed(conn, _) do
user = conn.assigns.current_user
exam_mode_course = CourseRegistrations.get_exam_mode_course(conn.assigns.current_user)

latest =
case user.latest_viewed_course_id do
nil -> nil
_ -> CourseRegistrations.get_user_course(user.id, user.latest_viewed_course_id)
end
if exam_mode_course do
latest = CourseRegistrations.get_user_course(user.id, exam_mode_course.course_id)
get_course_reg_config(conn, latest)
else
latest =
case user.latest_viewed_course_id do
nil -> nil
_ -> CourseRegistrations.get_user_course(user.id, user.latest_viewed_course_id)
end

get_course_reg_config(conn, latest)
get_course_reg_config(conn, latest)
end
end

defp get_course_reg_config(conn, course_reg) when is_nil(course_reg) do
Expand Down Expand Up @@ -317,6 +343,8 @@ defmodule CadetWeb.UserController do
enable_achievements(:boolean, "Enable achievements", required: true)
enable_sourcecast(:boolean, "Enable sourcecast", required: true)
enable_stories(:boolean, "Enable stories", required: true)
enable_exam_mode(:boolean, "Enable exam mode", required: true)
is_official_course(:boolean, "Course status (official institution course)")
source_chapter(:integer, "Source Chapter number from 1 to 4", required: true)
source_variant(Schema.ref(:SourceVariant), "Source Variant name", required: true)
module_help_text(:string, "Module help text", required: true)
Expand All @@ -332,6 +360,8 @@ defmodule CadetWeb.UserController do
enable_achievements: true,
enable_sourcecast: true,
enable_stories: false,
enable_exam_mode: false,
is_official_course: true,
source_chapter: 1,
source_variant: "default",
module_help_text: "Help text",
Expand Down
1 change: 1 addition & 0 deletions lib/cadet_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ defmodule CadetWeb.Router do
put("/user/research_agreement", UserController, :update_research_agreement)

get("/config", CoursesController, :index)
post("/resume_code", CoursesController, :check_resume_code)

get("/team/:assessmentid", TeamController, :index)
end
Expand Down
25 changes: 25 additions & 0 deletions lib/cadet_web/views/courses_view.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,31 @@ defmodule CadetWeb.CoursesView do
enableAchievements: :enable_achievements,
enableSourcecast: :enable_sourcecast,
enableStories: :enable_stories,
enableExamMode: :enable_exam_mode,
isOfficialCourse: :is_official_course,
sourceChapter: :source_chapter,
sourceVariant: :source_variant,
moduleHelpText: :module_help_text,
assessmentTypes: :assessment_configs,
assetsPrefix: :assets_prefix
})
}
end

def render("config_admin.json", %{config: config}) do
%{
config:
transform_map_for_view(config, %{
courseName: :course_name,
courseShortName: :course_short_name,
viewable: :viewable,
enableGame: :enable_game,
enableAchievements: :enable_achievements,
enableSourcecast: :enable_sourcecast,
enableStories: :enable_stories,
enableExamMode: :enable_exam_mode,
isOfficialCourse: :is_official_course,
resumeCode: :resume_code,
sourceChapter: :source_chapter,
sourceVariant: :source_variant,
moduleHelpText: :module_help_text,
Expand Down
Loading
Loading