Skip to content
Draft
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
160 changes: 160 additions & 0 deletions src/nytid/cli/courses.nw
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ from typing_extensions import Annotated

<<subcommand imports>>
<<constants>>
<<user role enum>>

cli = typer.Typer(name="courses",
help="Manage courses")
Expand Down Expand Up @@ -824,6 +825,27 @@ cli.add_typer(minecli)
MINE = "mine"
@

\section{User roles}\label{userroles}

We define an enum for the different user roles in a course.
There are three types of users:
\begin{itemize}
\item Teachers: responsible for the course, always treated like amanuenses,
have higher prep time factor (3 for lectures instead of 0 for TAs).
\item Amanuenses: TAs with amanuensis contracts, have slightly different
prep time calculation.
\item Hourly: TAs paid by the hour.
\end{itemize}
<<user role enum>>=
from enum import Enum

class UserRole(str, Enum):
"""User role in a course"""
TEACHER = "teacher"
AMANUENSIS = "amanuensis"
HOURLY = "hourly"
@

The subcommands of [[mine]] will be similar to those of [[registry]].
We want to list them, add them and remove them.
We will do this by maintaining a special register named [[MINE]] that will
Expand Down Expand Up @@ -1131,3 +1153,141 @@ def complete_course_regex(ctx: typer.Context, regex: str) -> typing.List[str]:
registers = registers_regex(register_regex)

return courses_regex(regex, registers)
@


\section{Managing course users, the \texttt{users} subcommands}\label{users}

We want to manage users (teachers and TAs) for each course.
This allows us to control access to course data and differentiate between
teachers and TAs in terms of prep time and employment contracts.

<<subcommands>>=
userscli = typer.Typer(name="users",
help="Manage course users (teachers and TAs)")

cli.add_typer(userscli)

<<users subcommands>>
@

\subsection{Adding a user to a course}

We add a user to a course by storing their username and role in the course
configuration.
<<users subcommands>>=
@userscli.command(name="add")
def users_add(course: Annotated[str, course_arg_autocomplete],
username: Annotated[str, username_arg],
role: Annotated[UserRole, role_opt] = UserRole.HOURLY,
register: Annotated[str, register_arg] = None):
"""
Adds a user to a course with a specific role.
"""
<<get course config>>
<<add user to course config with role>>
<<grant user access to course storage>>
logging.info(f"Added {username} as {role.value} to {course}")
<<argument and option definitions>>=
username_arg = typer.Argument(help="Username (e.g., KTH username)")
role_opt = typer.Option(help="User role: teacher, amanuensis, or hourly")
@

To get the course config, we need to determine which register to use.
<<get course config>>=
if not register:
<<set [[register]] to which register [[course]] is in>>

try:
course_config = courses.get_course_config(course, register)
except Exception as err:
logging.error(f"Can't access course {course}: {err}")
sys.exit(1)
@

Now we add the user to the course configuration.
We store users in a dictionary with username as key and role as value.
<<add user to course config with role>>=
try:
users_dict = course_config.get("users")
if not isinstance(users_dict, dict):
users_dict = {}
except (KeyError, TypeError):
users_dict = {}

users_dict[username] = role.value
courses.set_course_config(course, register, "users", users_dict)
@

We also need to grant the user access to the course's storage.
This is handled by the storage module.
<<grant user access to course storage>>=
try:
register_path = registry.get(register)
with storage.open_root(f"{register_path}/{course}") as root:
root.add_user(username)
except Exception as err:
logging.warning(f"Couldn't grant storage access to {username}: {err}")
@

\subsection{Listing users in a course}

We list all users and their roles for a course.
<<users subcommands>>=
@userscli.command(name="list")
def users_list(course: Annotated[str, course_arg_autocomplete],
register: Annotated[str, register_arg] = None):
"""
Lists all users and their roles for a course.
"""
<<get course config>>

try:
users_dict = course_config.get("users")
if not users_dict or not isinstance(users_dict, dict):
logging.info(f"No users configured for {course}")
return
except KeyError:
logging.info(f"No users configured for {course}")
return

print(f"Users in {course}:")
for username, role in users_dict.items():
print(f" {username}\t{role}")
@

\subsection{Removing a user from a course}

We remove a user from a course by removing them from the course configuration.
<<users subcommands>>=
@userscli.command(name="rm")
def users_rm(course: Annotated[str, course_arg_autocomplete],
username: Annotated[str, username_arg],
register: Annotated[str, register_arg] = None):
"""
Removes a user from a course.
"""
<<get course config>>

try:
users_dict = course_config.get("users")
if not users_dict or username not in users_dict:
logging.error(f"User {username} not found in {course}")
sys.exit(1)
except KeyError:
logging.error(f"No users configured for {course}")
sys.exit(1)

del users_dict[username]
courses.set_course_config(course, register, "users", users_dict)

<<revoke user access to course storage>>
logging.info(f"Removed {username} from {course}")
<<revoke user access to course storage>>=
try:
register_path = registry.get(register)
with storage.open_root(f"{register_path}/{course}") as root:
root.remove_user(username)
except Exception as err:
logging.warning(f"Couldn't revoke storage access from {username}: {err}")
@
36 changes: 36 additions & 0 deletions src/nytid/cli/hr.nw
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,42 @@ except Exception as err:
_canvas_session = None
@

\subsection{Getting user roles from course config}

We need a helper function to get a user's role from the course configuration.
This allows us to determine if they are a teacher, amanuensis, or hourly TA.
<<helper functions>>=
def get_user_role(username, course, register=None):
"""
Gets the role for a user from the course configuration.

Returns a UserRole enum value, or None if the user is not in the course config.
Falls back to checking contracts if no explicit role is configured.
"""
try:
from nytid.signup.hr import UserRole
course_config = courseutils.get_course_config(course, register)
users_dict = course_config.get("users")

if users_dict and isinstance(users_dict, dict):
if username in users_dict:
role_str = users_dict[username]
return UserRole(role_str) if isinstance(role_str, str) else role_str
except (KeyError, ValueError) as err:
logging.debug(f"Couldn't get role for {username} from config: {err}")

# Fallback: check if they have amanuensis contracts
try:
contracts = get_user_contracts(username)
if contracts:
return UserRole.AMANUENSIS
except Exception:
pass

# Default to hourly if no information found
return UserRole.HOURLY
@

\subsection{Looking up usernames in Canvas and LADOK}

Now that we have a (hopefully) working [[canvas_session]] and
Expand Down
31 changes: 31 additions & 0 deletions src/nytid/courses/init.nw
Original file line number Diff line number Diff line change
Expand Up @@ -464,4 +464,35 @@ def get_course_data(course: str,
"""
course_conf = get_course_config(course, register, config)
return storage.open_root(course_conf.get("data_path"))
@


\section{Setting course configuration values}

We want to be able to set values in a course's configuration.
<<functions>>=
def set_course_config(course: str,
register: str = None,
key: str = None,
value = None,
<<config arg>>):
"""
Sets a value in the course's configuration.

`course` identifies the course in the `register`. If `register` is None,
search through all registers in the registry, use the first one found
(undefined in which order duplicates are sorted).

`key` is the configuration key to set.
`value` is the value to set for the key.

The default `config` is the default config of the `typerconf` package.
"""
course_conf = get_course_config(course, register, config)
course_conf.set(key, value)

# Force write back by accessing the config's internal file path
if hasattr(course_conf, 'conf_file') and course_conf.conf_file:
with open(course_conf.conf_file, 'w') as f:
course_conf.write_config(f)
@
Loading