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 45 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 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
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 and cr.role == :student)
|> 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
3 changes: 2 additions & 1 deletion lib/cadet/accounts/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ defmodule Cadet.Accounts.User do
field(:provider, :string)
field(:super_admin, :boolean)
field(:email, :string)
field(:is_paused, :boolean)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why this is linked to the user and not their course_registration? Isn't exam mode related to their course.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Initially I thought linking this to the user would allow for the enforcing of the pause beyond the course so that if a user is paused due to opening other app / using dev tool (which is our plan), the user will have to settle the problem with the course admin / coordinator. But, now that you pointed out, maybe this should not affect the user through all their courses. Should I move this course_registration?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@RichDom2185 thoughts?


belongs_to(:latest_viewed_course, Course)
has_many(:courses, CourseRegistration)
Expand All @@ -29,7 +30,7 @@ defmodule Cadet.Accounts.User do
end

@required_fields ~w(username provider)a
@optional_fields ~w(name latest_viewed_course_id super_admin)a
@optional_fields ~w(name latest_viewed_course_id super_admin is_paused)a

def changeset(user, params \\ %{}) do
user
Expand Down
47 changes: 45 additions & 2 deletions lib/cadet/courses/course.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
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 @@
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,51 @@
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
has_resume_code = params |> Map.has_key?(:resume_code)

resume_code_params =
params
|> Map.get(:resume_code, "")
|> String.trim()

resume_code =

Check warning on line 70 in lib/cadet/courses/course.ex

View workflow job for this annotation

GitHub Actions / Run CI

variable "resume_code" is unused (if the variable is not meant to be used, prefix it with an underscore)
changeset
|> get_field(: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, has_resume_code, resume_code_params} do
{_, _, true, ""} ->
add_error(
changeset,
:resume_code,
"Resume code must not be empty."
)

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

_ ->
changeset
end
end

# Validates combination of Source chapter and variant
Expand Down
10 changes: 8 additions & 2 deletions lib/cadet/courses/courses.ex
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@ defmodule Cadet.Courses do

@doc """
Creates a new course configuration, course registration, and sets
the user's latest course id to the newly created course.
the user's latest course id to the newly created course. A 4-digit
course resume code will be randomly generated.
"""
def create_course_config(params, user) do
Multi.new()
|> Multi.insert(:course, Course.changeset(%Course{}, params))
|> Multi.insert(:course, Course.changeset(%Course{}, set_default_resume_code(params)))
|> Multi.run(:course_reg, fn _repo, %{course: course} ->
CourseRegistrations.enroll_course(%{
course_id: course.id,
Expand Down Expand Up @@ -82,6 +83,11 @@ defmodule Cadet.Courses do
end
end

defp set_default_resume_code(params) do
params
|> Map.put(:resume_code, Integer.to_string(:rand.uniform(9000) + 999))
end

def get_all_course_ids do
Course
|> select([c], c.id)
Expand Down
32 changes: 32 additions & 0 deletions lib/cadet/focus_logs/focus_log.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
defmodule Cadet.FocusLogs.FocusLog do
@moduledoc """
The FocusLog entity represents a log of user's browser focus
while using Source Academy under exam mode.
"""
use Cadet, :model

alias Cadet.Accounts.User
alias Cadet.Courses.Course

@type t :: %__MODULE__{
user: User.t(),
course: Course.t(),
focus_type: integer()
}

schema "user_browser_focus_log" do
belongs_to(:user, User)
belongs_to(:course, Course)
field(:time, :naive_datetime)
field(:focus_type, :integer)
end

@required_fields ~w(user_id course_id time focus_type)a

def changeset(focus_log, params) do
focus_log
|> cast(params, @required_fields)
|> add_belongs_to_id_from_model([:user, :course], params)
|> validate_required(@required_fields)
end
end
26 changes: 26 additions & 0 deletions lib/cadet/focus_logs/focus_logs.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
defmodule Cadet.FocusLogs do
@moduledoc """
Contains logic to manage user's browser focus log
such as insertion
"""
alias Cadet.FocusLogs.FocusLog

use Cadet, [:context, :display]

def insert_log(user_id, course_id, focus_type) do
insert_result =
%FocusLog{}
|> FocusLog.changeset(%{
user_id: user_id,
course_id: course_id,
time: DateTime.utc_now(),
focus_type: focus_type
})
|> Repo.insert()

case insert_result do
{:ok, log} -> {:ok, log}
{:error, changeset} -> {:error, full_error_messages(changeset)}
end
end
end
14 changes: 13 additions & 1 deletion lib/cadet_web/admin_controllers/admin_courses_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,14 @@ 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 that students that attempt to open developer tools will be prompted to enter"
)

sublanguage(:body, Schema.ref(:AdminSublanguage), "sublanguage object")
module_help_text(:body, :string, "Module help text")
end
Expand Down Expand Up @@ -143,7 +151,11 @@ defmodule CadetWeb.AdminCoursesController do
title("AdminSublanguage")

properties do
chapter(:integer, "Chapter number from 1 to 4", required: true, minimum: 1, maximum: 4)
chapter(:integer, "Chapter number from 1 to 4",
required: true,
minimum: 1,
maximum: 4
)

variant(Schema.ref(:SourceVariant), "Variant name", required: true)
end
Expand Down
38 changes: 37 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 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,36 @@ defmodule CadetWeb.CoursesController do
end
end

defp check_resume_code(course_id, resume_code) do
case Courses.get_course_config(course_id) do
{:ok, config} -> {:ok, config.resume_code == resume_code}
{:error, {status_code, message}} -> {:error, {status_code, message}}
end
end

defp unpause_user(conn, user) do
update_result =
user
|> Cadet.Accounts.User.changeset(%{is_paused: false})
|> Cadet.Repo.update()

case update_result do
{:ok, _} -> conn |> send_resp(:ok, "")
{:error, _} -> conn |> send_resp(500, "")
end
end

def try_unpause_user(conn, %{"course_id" => course_id}) when is_ecto_id(course_id) do
user = conn.assigns.current_user
resume_code = Map.get(conn.body_params, "resume_code", nil)

case check_resume_code(course_id, resume_code) do
{:ok, true} -> unpause_user(conn, user)
{:ok, false} -> conn |> send_resp(:forbidden, "")
{:error, {status_code, message}} -> conn |> send_resp(status_code, message)
end
end

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

Expand All @@ -56,6 +90,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 +132,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
Loading
Loading