Skip to content
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

New API Endpoint: users/upsert #25

Merged
merged 18 commits into from
Sep 26, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion autograder/api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ def get_argument_parser(
'Only show results from users with this role (all roles if unknown (default)).',
required = False,
parser_options = {'action': 'store', 'default': 'unknown',
'choices': autograder.api.constants.ROLES})
'choices': autograder.api.constants.COURSE_ROLES})

PARAM_FORCE = APIParam('force',
'Force the operation, overwriting and existing resources.',
Expand Down
9 changes: 8 additions & 1 deletion autograder/api/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@
API_RESPONSE_KEY_MESSAGE = 'message'
API_RESPONSE_KEY_CONTENT = API_REQUEST_JSON_KEY

ROLES = [
SERVER_ROLES = [
'user',
'creator',
'admin',
'owner',
]

COURSE_ROLES = [
'unknown',
'other',
'student',
Expand Down
40 changes: 40 additions & 0 deletions autograder/api/users/upsert.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import autograder.api.common
import autograder.api.config

API_ENDPOINT = 'users/upsert'
API_PARAMS = [
autograder.api.config.PARAM_USER_EMAIL,
autograder.api.config.PARAM_USER_PASS,

autograder.api.config.PARAM_DRY_RUN,

autograder.api.config.APIParam('skip-inserts',
'Skip inserts (default: False).',
required = False,
parser_options = {'action': 'store_true', 'default': False}),

autograder.api.config.APIParam('skip-updates',
'Skip updates (default: False).',
required = False,
parser_options = {'action': 'store_true', 'default': False}),

autograder.api.config.APIParam('send-emails',
'Send any emails.',
required = True, cli_param = False),

autograder.api.config.APIParam('raw-users',
'A list of users to upsert.',
required = True, cli_param = False),
]

DESCRIPTION = 'Upsert one or more users to the server (update if exists, insert otherwise).'

def send(arguments, **kwargs):
return autograder.api.common.handle_api_request(arguments, API_PARAMS, API_ENDPOINT, **kwargs)

def _get_parser():
parser = autograder.api.config.get_argument_parser(
description = DESCRIPTION,
params = API_PARAMS)

return parser
61 changes: 60 additions & 1 deletion autograder/cli/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,36 @@
COURSE_USER_INFO_TYPE = 'course'
SERVER_USER_INFO_TYPE = 'server'

INDENT = ' '

SYNC_USERS_KEYS = [
('add-users', 'Added', 'add'),
('mod-users', 'Modified', 'mod'),
('del-users', 'Deleted', 'delete'),
('skip-users', 'Skipped', 'skip'),
]

USER_OP_KEYS = [
('added', 'Added'),
('modified', 'Modified'),
('removed', 'Removed'),
('skipped', 'Skipped'),
('not-exists', 'Not Exists'),
('emailed', 'Emailed'),
('enrolled', 'Enrolled'),
('dopped', 'Dropped'),
]

USER_OP_ERROR_KEYS = [
('validation-error', 'Validation Error'),
('system-error', 'System Error'),
('communication-error', 'System Error'),
]

ALL_USER_OP_KEYS = [
('email', 'Email'),
] + USER_OP_KEYS + USER_OP_ERROR_KEYS

# Set course_users to True if listing course users, False for server users.
# An error will be raised if a user of a different type is found.
def list_users(users, course_users, table = False, normalize = False):
Expand Down Expand Up @@ -150,13 +173,49 @@ def _list_sync_users_table(sync_users):
def list_add_users(result, table = False):
errors = result['errors']
if ((errors is not None) and (len(errors) > 0)):
print("Encounted %d errors." % (len(errors)))
print("Encountered %d errors." % (len(errors)))
for error in errors:
print(" Index: %d, Email: '%s', Message: '%s'." % (
error['index'], error['email'], error['message']))

list_sync_users(result, table = table)

def _list_user_op_responses(results):
error_count = 0
for result in results:
print(result['email'])
for op_key, label in USER_OP_KEYS:
if (result.get(op_key, None) is not None):
print(INDENT + label)
if (isinstance(result[op_key], list)):
for value in result[op_key]:
print(INDENT + INDENT + value)

for error_key, label in USER_OP_ERROR_KEYS:
if (result.get(error_key, None) is not None):
error_count += 1
print(INDENT + label)
print(INDENT + INDENT + result[error_key]["message"])

print()
print("Processed %d users. Encountered %d errors." % (len(results), error_count))

def _list_user_op_responses_table(results, header = True, keys = ALL_USER_OP_KEYS):
rows = []
for result in results:
for error_key in USER_OP_ERROR_KEYS:
result[error_key] = result[error_key]["message"]

rows.append([result.get(key, '') for key, _ in keys])

_print_tsv(rows, header, [header_key for _, header_key in keys])

def list_user_op_responses(results, table = False):
if (table):
_list_user_op_responses_table(results)
else:
_list_user_op_responses(results)

def _print_tsv(rows, header, header_keys):
lines = []
if (header):
Expand Down
120 changes: 120 additions & 0 deletions autograder/cli/users/upsert-file.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import sys

import autograder.api.constants
import autograder.api.users.upsert
import autograder.cli.common
import autograder.util.hash

def run(arguments):
arguments = vars(arguments)

arguments['raw-users'] = _load_users(arguments['path'])
arguments['send-emails'] = not arguments['skip-emails']

result = autograder.api.users.upsert.send(arguments, exit_on_error = True)

autograder.cli.common.list_user_op_responses(result['results'], table = arguments['table'])
return 0

def _load_users(path):
users = []

with open(path, 'r') as file:
lineno = 0
for line in file:
lineno += 1

line = line.strip()
if (line == ""):
continue

parts = line.split("\t")
Lucas-Ellenberger marked this conversation as resolved.
Show resolved Hide resolved
parts = [part.strip() for part in parts]

if (len(parts) > 7):
raise ValueError(
"File ('%s') line (%d) has too many values. Max is 7, found %d." % (
path, lineno, len(parts)))

email = parts.pop(0)

password = ''
if (len(parts) > 0):
password = parts.pop(0)
if (password != ''):
password = autograder.util.hash.sha256_hex(password)

name = ''
if (len(parts) > 0):
name = parts.pop(0)

role = 'user'
if (len(parts) > 0):
role = parts.pop(0)
Lucas-Ellenberger marked this conversation as resolved.
Show resolved Hide resolved
role = role.lower()

if (role not in autograder.api.constants.SERVER_ROLES):
raise ValueError(
"File ('%s') line (%d) has an invalid role '%s'." % (
path, lineno, role))

course = ''
if (len(parts) > 0):
course = parts.pop(0)

course_role = 'unknown'
if (len(parts) > 0):
course_role = parts.pop(0)
course_role = course_role.lower()

if (course_role not in autograder.api.constants.COURSE_ROLES):
raise ValueError(
"File ('%s') line (%d) has an invalid course role '%s'." % (
path, lineno, course_role))

course_lms_id = ''
if (len(parts) > 0):
course_lms_id = parts.pop(0)

users.append({
'email': email,
'pass': password,
'name': name,
'role': role,
'course': course,
'course-role': course_role,
'course-lms-id': course_lms_id,
})

return users

def main():
return run(_get_parser().parse_args())

def _get_parser():
parser = autograder.api.users.upsert._get_parser()

parser.description = ('Upsert users to the course from a TSV file.'
+ ' (Update if exists, otherwiese insert).')

parser.add_argument('path', metavar = 'PATH',
action = 'store', type = str,
help = 'Path to a TSV file where each line contains up to seven columns:'
+ ' [email, pass, name, role, course, course-role, lms-id].'
+ ' Only the email is required. Leading and trailing whitespace is stripped'
+ ' from all fields, including pass. If pass is empty, a password will be'
+ ' randomly generated and emailed to the user.')

parser.add_argument('--skip-emails', dest = 'skip-emails',
action = 'store_true', default = False,
help = 'Skip sending any emails. Be aware that this may result in inaccessible'
+ ' information (default: %(default)s).')

parser.add_argument('--table', dest = 'table',
action = 'store_true', default = False,
help = 'Output the results as a TSV table with a header (default: %(default)s).')

return parser

if (__name__ == '__main__'):
sys.exit(main())
80 changes: 80 additions & 0 deletions autograder/cli/users/upsert.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import sys

import autograder.api.config
import autograder.api.users.upsert
import autograder.cli.common

def run(arguments):
arguments = vars(arguments)

password = arguments['new-pass']
if (password != ''):
password = autograder.util.hash.sha256_hex(password)

arguments['raw-users'] = [{
'email': arguments['new-email'],
'name': arguments['new-name'],
'role': arguments['new-role'],
'pass': password,
'course': arguments['new-course'],
'course-role': arguments['new-course-role'],
'course-lms-id': arguments['new-lms-id'],
}]

arguments['send-emails'] = not arguments['skip-emails']

result = autograder.api.users.upsert.send(arguments, exit_on_error = True)

autograder.cli.common.list_user_op_responses(result['results'], table = arguments['table'])
return 0

def main():
return run(_get_parser().parse_args())

def _get_parser():
parser = autograder.api.users.upsert._get_parser()

parser.add_argument('--skip-emails', dest = 'skip-emails',
action = 'store_true', default = False,
help = 'Skip sending any emails. Be aware that this may result in inaccessible'
+ ' information.')
Lucas-Ellenberger marked this conversation as resolved.
Show resolved Hide resolved

parser.add_argument('--new-email', dest = 'new-email',
action = 'store', type = str, required = True,
help = 'The email of the user to upsert.')

parser.add_argument('--new-name', dest = 'new-name',
action = 'store', type = str, default = '',
help = 'The name of the user to upsert.')

parser.add_argument('--new-role', dest = 'new-role',
action = 'store', type = str, default = 'user',
choices = autograder.api.constants.SERVER_ROLES,
help = 'The role of the user to upsert (default: %(default)s).')

parser.add_argument('--new-pass', dest = 'new-pass',
action = 'store', type = str, default = '',
help = 'The password of the user to upsert.'
+ ' If empty, the server will generate and email a password.')

parser.add_argument('--new-course', dest = 'new-course',
action = 'store', type = str, default = '',
help = 'The course of the user to upsert.')

parser.add_argument('--new-course-role', dest = 'new-course-role',
action = 'store', type = str, default = 'student',
choices = autograder.api.constants.COURSE_ROLES,
help = 'The course role of the user to upsert (default: %(default)s).')

parser.add_argument('--new-lms-id', dest = 'new-lms-id',
action = 'store', type = str, default = '',
help = 'The lms id of the user to upsert.')

parser.add_argument('--table', dest = 'table',
action = 'store_true', default = False,
help = 'Output the results as a TSV table with a header (default: %(default)s).')

return parser

if (__name__ == '__main__'):
sys.exit(main())
31 changes: 31 additions & 0 deletions tests/api/testdata/users_upsert_add.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"module": "autograder.api.users.upsert",
"arguments": {
"user": "[email protected]",
"pass": "server-admin",
"send-emails": true,
"skip-inserts": false,
"skip-updates": false,
"dry-run": false,
"raw-users": [
{
"email": "[email protected]",
"name": "",
"role": "user",
"pass": "",
"course": "",
"course-role": "student",
"course-lms-id": ""
}
]
},
"output": {
"results": [
{
"email": "[email protected]",
"added": true,
"emailed": true
}
]
}
}
Loading