diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..73f69e0 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..6649a8c --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..ffb90a4 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/penngrader.iml b/.idea/penngrader.iml new file mode 100644 index 0000000..0baffc0 --- /dev/null +++ b/.idea/penngrader.iml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Backend/grader_lambda.py b/Backend/grader_lambda.py index 45776b5..95a6a71 100644 --- a/Backend/grader_lambda.py +++ b/Backend/grader_lambda.py @@ -1,3 +1,20 @@ +""" + The grader_lambda is the AWS lambda class that will handle test-related events. Its primary responsibility + is to take in an event, parse out key details, such as which homework it's relevant to, which student, + which test case, etc., then handle the actual testing. + + The basic flow is: + + event happens => lambda_handler is invoked => dynamo DB is updated w/ submission & score + > Examine event + > Get submission details + > Import any libraries + > Test submission + > Log the resulting score +""" + + + import sys sys.path.append('/opt') import os @@ -21,6 +38,14 @@ def lambda_handler(event, context): + """ This lambda handler is meant to take in an event and context, then: + + 1. Parse out the test details: homework_id, student_id, test_case_id, answer + 2. Get all of the associated libraries and the test function: test_case, libraries + 3. Import those required libraries + 4. Score the students work against the test_case: student_score, max_score + 5. Log the submission in dynamo + """ try: homework_id, student_id, test_case_id, answer = parse_event(event) test_case, libraries = get_test_and_libraries(homework_id, test_case_id) @@ -33,6 +58,9 @@ def lambda_handler(event, context): def parse_event(event): + """ Simple helper method to parse an event as a dict. + Returns: (homework_id, student_id, test_case_id, answer) + """ try: body = ast.literal_eval(event['body']) return body['homework_id'], \ @@ -44,6 +72,10 @@ def parse_event(event): def get_test_and_libraries(homework_id, test_case_id): + """ Retrieve the test function and associated libraries required. This pulls down from dynamo, + then passes back a tuple of (test_case, libraries). It can then be used to import the libraries + and test the student submission + """ try: response = dynamo.get_item(TableName = TEST_CASES_TABLE, Key={'homework_id': {'S': homework_id}}) return deserialize(response['Item']['test_cases']['S'])[test_case_id], \ @@ -53,6 +85,10 @@ def get_test_and_libraries(homework_id, test_case_id): def import_libraries(libraries): # TO-FINISH # + """ Apparently unfinished, but imports required libraries for a given test. Tests consist of + the function that actually performs the testing and the required libraries to run that function + + the function being tested. This takes care of importing those libraries + """ try: packages = libraries['packages'] imports = libraries['imports'] @@ -78,6 +114,10 @@ def import_libraries(libraries): # TO-FINISH # def grade(test_case, answer): + """ Generate a grade for a given test case. test_case is a function that is passed in. The function + gets executed here using answer as its param. This will catch any errors in the code and returns + a tuple of (score, max_score) + """ try: return test_case(answer) except Exception as exception: @@ -87,6 +127,16 @@ def grade(test_case, answer): def store_submission(student_score, max_score, homework_id, test_case_id, student_id): + """ Log the student submission in the database. + params: + - student_score: the number of points the student received on a problem + - max_score: the maximum score on that problem + - homeword_id: the key for this particular homework + - test_case_id: the key for this particular test case (problem) + - student_id: the key for the student + + This will be passed to the dynamo DB as a new record + """ try: db_entry = { 'TableName': GRADEBOOK_TABLE, @@ -116,25 +166,37 @@ def store_submission(student_score, max_score, homework_id, test_case_id, studen def serialize(obj): + """ Simple helper method to encode a serialized object + """ byte_serialized = dill.dumps(obj, recurse = True) return base64.b64encode(byte_serialized).decode("utf-8") def deserialize(obj): + """ Simple helper method to decode a base64 encoded object + """ byte_decoded = base64.b64decode(obj) return dill.loads(byte_decoded) -def build_response_message(student_score, max_score): +def build_response_message(student_score, max_score, msg=None): + """ Build the string response message + """ + out = "" if student_score == max_score: - return 'Correct! You earned {}/{} points. You are a star!\n\n'.format(student_score, max_score) + \ + out = 'Correct! You earned {}/{} points. You are a star!\n\n'.format(student_score, max_score) + \ 'Your submission has been successfully recorded in the gradebook.' else: - return 'You earned {}/{} points.\n\n'.format(student_score, max_score) + \ + out = 'You earned {}/{} points.\n\n'.format(student_score, max_score) + \ 'But, don\'t worry you can re-submit and we will keep only your latest score.' + if msg is not None: + out += "\n\n{}".format(msg) + return out def build_http_response(status_code, message): + """ Build formatted http response as string + """ return { 'statusCode': status_code, 'body': str(message), diff --git a/Backend/grades_lambda.py b/Backend/grades_lambda.py index 24b1725..a42f34f 100644 --- a/Backend/grades_lambda.py +++ b/Backend/grades_lambda.py @@ -1,3 +1,17 @@ +""" + The grades_lambda is the AWS lambda class that will handle grade-related events. Its primary responsibility + is to take in an event, parse out key details, such as which homework it's relevant to and whether an + individual student or all students, then return grading details for that homework. + + The basic flow is: + + event happens => lambda_handler is invoked => dynamo DB is updated w/ submission & score + > Examine event + > Determine scope (single vs. + all students) + > Return http response +""" + import sys sys.path.append('/opt') import os @@ -31,6 +45,16 @@ def lambda_handler(event, context): + """ This lambda handler is meant to take in an event and context, then: + + 1. Parse out the event details, then get the ID of the homework: body, homework_id + 2. Get all associated metadata for the homework: deadline, max_daily_submissions, max_score + 3. Check the request type: + > ALL_STUDENTS_REQUEST: validate the secret, then retrieve the grades and return http response of + (all_grades, deadline) + > STUDENT_REQUEST: retrieve a single student's grade for an assignment, return http response of + (grades, deadline, max_daily_submissions, max_score) + """ try: body = parse_event(event) homework_id = body['homework_id'] @@ -51,6 +75,9 @@ def lambda_handler(event, context): def parse_event(event): + """ Simple helper method to parse an event as a dict. + Returns: entire event + """ try: return ast.literal_eval(event['body']) except: @@ -58,6 +85,8 @@ def parse_event(event): def validate_secret_key(secret_key): + """ Simple method to confirm that the secret_key passed in is valid + """ try: response = dynamo.get_item(TableName = 'Classes', Key={'secret_key': {'S': secret_key}}) return response['Item']['course_id']['S'] @@ -66,6 +95,8 @@ def validate_secret_key(secret_key): def get_homework_metadata(homework_id): + """ Retrieves the metadata for a homework, including deadline, max_daily_subimssions, and the total_score + """ try: response = dynamo.get_item(TableName = METADATA_TABLE, Key={'homework_id': {'S': homework_id}}) return response['Item']['deadline']['S'], \ @@ -76,6 +107,11 @@ def get_homework_metadata(homework_id): def get_grades(homework_id, student_id = None): + """ Retrieve the grades for a given homework using the student's ID. This will hit the + dynamo DB and pull down the student's current score on a particular assignment. + + Note: if student_id is None, grades for all students are returned + """ table = dynamo_resource.Table(GRADEBOOK_TABLE) if student_id is not None: filtering_exp = Key('homework_id').eq(homework_id) & Attr('student_submission_id').begins_with(student_id) @@ -87,11 +123,21 @@ def get_grades(homework_id, student_id = None): def serialize(obj): + """ Simple helper method to encode a serialized object + + Code is duplicated here. Possibly because the other file may not always be + accessible? + """ byte_serialized = dill.dumps(obj, recurse = True) return base64.b64encode(byte_serialized).decode("utf-8") def build_http_response(status_code, message): + """ Build formatted http response as string + + Code is duplicated here. Possibly because the other file may not always be + accessible? + """ return { 'statusCode': status_code, 'body': str(message), diff --git a/Backend/homework_config_lambda.py b/Backend/homework_config_lambda.py index 69061a6..4fe4b98 100644 --- a/Backend/homework_config_lambda.py +++ b/Backend/homework_config_lambda.py @@ -1,3 +1,17 @@ +""" + The grades_lambda is the AWS lambda class that will handle grade-related events. Its primary responsibility + is to take in an event, parse out key details, such as which homework it's relevant to and whether an + individual student or all students, then return grading details for that homework. + + The basic flow is: + + event happens => lambda_handler is invoked => dynamo DB is updated w/ submission & score + > Examine event + > Determine intent + > Attempt to act on it + > Provide status response +""" + import sys sys.path.append('/opt') import os @@ -23,9 +37,19 @@ HOMEWORK_ID_REQUEST = 'GET_HOMEWORK_ID' UPDATE_METADATA_REQUEST = 'UPDATE_METADATA' UPDATE_TESTS_REQUEST = 'UPDATE_TESTS' +ADD_NEW_COURSE_REQUEST = 'ADD_NEW_COURSE' def lambda_handler(event, context): + """ This lambda handler is meant to take in an event and context, then: + + 1. Parse out the homework details: homework_number, secret_key, request_type, payload + 2. Overwrite secret_key and homework_id with their textual versions + 3. Determine which type of request is inbound + > HOMEWORK_ID_REQUEST: return the homework ID in string form + > UPDATE_METADATA_REQUEST: update the assignment metadata in the dynamo db + > UPDATE_TESTS_REQUEST: update the test cases in the dynamo db + """ try: homework_number, secret_key, request_type, payload = parse_event(event) secret_key = get_course_id(secret_key) @@ -44,23 +68,33 @@ def lambda_handler(event, context): test_cases = payload['test_cases'] update_tests(homework_id, test_cases, libraries) response = 'Success: Test cases updated successfully.' + elif request_type == ADD_NEW_COURSE_REQUEST: + add_course(payload['secret_key'], payload['course_id']) + response = 'Success: Course created successfully.' return build_http_response(SUCCESS, response) except Exception as exception: return build_http_response(ERROR, exception) def parse_event(event): + """ Simple helper method to parse an event as a dict. + Returns: (homework_number, secret_key, request_type, payload) + + Payload should contain metadata + """ try: body = ast.literal_eval(event['body']) return body['homework_number'], \ body['secret_key'], \ body['request_type'], \ - deserialize(body['payload']) + deserialize(body['payload']) except: raise Exception('Malformed payload.') - - + + def get_course_id(secret_key): + """ Returns the course ID based on a secret key. Response would look something like: CIS545_Spring_2019 + """ try: response = dynamo.get_item(TableName = CLASSES_TABLE, Key={'secret_key': {'S': secret_key}}) return response['Item']['course_id']['S'] @@ -69,10 +103,45 @@ def get_course_id(secret_key): def get_homework_id(course_id, homework_number): + """ Helper function to get the unique key of a given homework based on where it falls in the + assignment schedule of a course. Gets appended to the course_id, then returned in long string + in the fashion of: CIS545_Spring_2019_HW1 + """ return '{}_HW{}'.format(course_id, homework_number) +def add_course(secret_key, course_id): + """ Function to create a new course with an ID and secret + """ + try: + db_entry = { + 'TableName': CLASSES_TABLE, + 'Item': { + 'secret_key': { + 'S': secret_key + }, + 'course_id': { + 'S': course_id + } + } + } + dynamo.put_item(**db_entry) + except Exception as e: + raise Exception('ERROR: Could not create new course. {}'.format(e)) + + def update_metadata(homework_id, payload): + """ Function to configure the metadata of a homework. The actual setup + of the metadata is contained in the payload, but must be in the form: + + { + max_daily_submissions: -1, + total_score: -1, + deadline: ... + } + + Once parsed, it gets added to the DB or updated if already in existence + """ try: db_entry = { 'TableName': METADATA_TABLE, @@ -98,6 +167,8 @@ def update_metadata(homework_id, payload): def update_tests(homework_id, test_cases, libraries): + """ This adds a test case to the dynamo DB to be returned later in grading + """ try: db_entry = { 'TableName': TEST_CASES_TABLE, @@ -119,6 +190,8 @@ def update_tests(homework_id, test_cases, libraries): def get_additional_libraries(libraries): # TO-FINISH # + """ Appears unfinished per comment, but imports libraries required for use in grading + """ try: packages = libraries['packages'] imports = libraries['imports'] @@ -145,16 +218,31 @@ def get_additional_libraries(libraries): # TO-FINISH # def serialize(obj): + """ Simple helper method to encode a serialized object + + Code is duplicated here. Possibly because the other file may not always be + accessible? + """ byte_serialized = dill.dumps(obj, recurse = True) return base64.b64encode(byte_serialized).decode("utf-8") def deserialize(obj): + """ Simple helper method to decode a base64 encoded object + + Code is duplicated here. Possibly because the other file may not always be + accessible? + """ byte_decoded = base64.b64decode(obj) return dill.loads(byte_decoded) def build_http_response(status_code, message): + """ Build formatted http response as string + + Code is duplicated here. Possibly because the other file may not always be + accessible? + """ return { 'statusCode': status_code, 'body': str(message), diff --git a/pip/penngrader/backend.py b/pip/penngrader/backend.py index ae69b71..0e08452 100644 --- a/pip/penngrader/backend.py +++ b/pip/penngrader/backend.py @@ -15,6 +15,7 @@ UPDATE_METADATA_REQUEST = 'UPDATE_METADATA' UPDATE_TESTS_REQUEST = 'UPDATE_TESTS' GRADES_REQUEST = 'ALL_STUDENTS_GRADES' +ADD_NEW_COURSE_REQUEST = 'ADD_NEW_COURSE' # Lambda endpoints config_api_url = 'https://uhbuar7r8e.execute-api.us-east-1.amazonaws.com/default/HomeworkConfig' @@ -23,21 +24,32 @@ grades_api_key = 'lY1O5NDRML9zEyRvWhf0c1GeEYFe3BE710Olbh3R' def is_function(val): + """ Simple helper to confirm an arg is a function + """ return type(val) == types.FunctionType def is_module(val): + """ Simple helper to confirm an arg is a module + """ return type(val) == types.ModuleType def is_external(name): + """ Simple helper to determine whether the module is already installed + """ return name not in ['__builtin__','__builtins__', 'penngrader','_sh', '__main__'] and 'penngrader' not in name class PennGraderBackend: + """ The backened is responsible for initializing test cases, homeworks, courses, etc. This manages those things + that a student using the tool would not have access to. Only those with the secret key can manage the backend + """ def __init__(self, secret_key, homework_number): + """ Initialization function to start up the backend via grades_lambda for a particular homework in a course + """ self.secret_key = secret_key self.homework_number = homework_number self.homework_id = self._get_homework_id() @@ -47,8 +59,24 @@ def __init__(self, secret_key, homework_number): print(response) else: print(self.homework_id) + + + def create_new_course(self, valid_current_secret, new_secret, course_id): + request = { + 'secret_key': valid_current_secret, + 'request_type': ADD_NEW_COURSE_REQUEST, + 'payload': self._serialize({ + 'course_id': course_id, + 'secret_key': new_secret + }) + } + print(self._send_request(request, config_api_url, config_api_key)) + def update_metadata(self, deadline, total_score, max_daily_submissions): + """ Updates the metadata of an assignment, including deadline, total score, + and maximum number of daily submissions, if desired + """ request = { 'homework_number' : self.homework_number, 'secret_key' : self.secret_key, @@ -63,6 +91,9 @@ def update_metadata(self, deadline, total_score, max_daily_submissions): def update_test_cases(self): + """ Updates the test cases in an assignment broadly across the entire + assignment + """ request = { 'homework_number' : self.homework_number, 'secret_key' : self.secret_key, @@ -76,6 +107,9 @@ def update_test_cases(self): def get_raw_grades(self, with_deadline = False): + """ Retrieve all of the grades for all students that have attempted a given homework. Returns + a dataframe containing the results. Helper function leveraged by the get_grades func + """ request = { 'homework_id' : self.homework_id, 'secret_key' : self.secret_key, @@ -93,6 +127,8 @@ def get_raw_grades(self, with_deadline = False): return pd.DataFrame(grades) def get_grades(self): + """ Retrieves grade details for the current assignment + """ grades_df, deadline = self.get_raw_grades(with_deadline = True) if grades_df is not None: @@ -131,6 +167,8 @@ def get_grades(self): def _get_homework_id(self): + """ Hits the homework_config_lambda for the homework id: ex. CIS545_Spring_2019_HW1 + """ request = { 'homework_number' : self.homework_number, 'secret_key' : self.secret_key, @@ -139,8 +177,10 @@ def _get_homework_id(self): } return self._send_request(request, config_api_url, config_api_key) - + def _send_request(self, request, api_url, api_key): + """ Function to send all requests, regardless of type, to their appropriate lambda + """ params = json.dumps(request).encode('utf-8') headers = {'content-type': 'application/json', 'x-api-key': api_key} request = urllib.request.Request(api_url, data=params, headers=headers) @@ -148,10 +188,12 @@ def _send_request(self, request, api_url, api_key): response = urllib.request.urlopen(request) return '{}'.format(response.read().decode('utf-8')) except HTTPError as error: - return 'Error: {}'.format(error.read().decode("utf-8")) + return 'Error: {}'.format(error.read().decode("utf-8")) + - def _get_imported_libraries(self): + """ Returns a list of all packages, imports, functions currently imported + """ # Get all externally imported base packages packages = set() # (package, shortname) for shortname, val in list(globals().items()): @@ -180,8 +222,10 @@ def _get_imported_libraries(self): 'functions' : list(functions) } - + def _get_test_cases(self): + """ Gets all test cases that are currently on file + """ # Get all function imports test_cases = {} for shortname, val in list(globals().items()): @@ -192,13 +236,16 @@ def _get_test_cases(self): pass return test_cases - + def _serialize(self, obj): - '''Dill serializes Python object into a UTF-8 string''' + """ Dill serializes Python object into a UTF-8 string + """ byte_serialized = dill.dumps(obj, recurse = False) return base64.b64encode(byte_serialized).decode("utf-8") - + def _deserialize(self, obj): + """ Dill deserializes UTF-8 string into python object + """ byte_decoded = base64.b64decode(obj) return dill.loads(byte_decoded) \ No newline at end of file diff --git a/pip/penngrader/grader.py b/pip/penngrader/grader.py index d243e34..74374bd 100644 --- a/pip/penngrader/grader.py +++ b/pip/penngrader/grader.py @@ -14,8 +14,13 @@ STUDENT_GRADE_REQUEST = 'STUDENT_GRADE' class PennGrader: + """ The grader is responsible for the actual testing. This is what the student will use + to test their code + """ def __init__(self, homework_id, student_id): + """ Initialization function to start up the grader instance + """ if '_' in str(student_id): raise Exception("Student ID cannot contain '_'") self.homework_id = homework_id @@ -25,6 +30,8 @@ def __init__(self, homework_id, student_id): def grade(self, test_case_id, answer): + """ This function will hit the grader_lambda to test a student's function + """ request = { 'homework_id' : self.homework_id, 'student_id' : self.student_id, @@ -35,6 +42,8 @@ def grade(self, test_case_id, answer): print(response) def _send_request(self, request, api_url, api_key): + """ Function to send requests, regardless of type + """ params = json.dumps(request).encode('utf-8') headers = {'content-type': 'application/json', 'x-api-key': api_key} request = urllib.request.Request(api_url, data=params, headers=headers) @@ -45,5 +54,7 @@ def _send_request(self, request, api_url, api_key): return 'Error: {}'.format(error.read().decode("utf-8")) def _serialize(self, obj): + """ Encodes an object to UTF-8 + """ byte_serialized = dill.dumps(obj, recurse = True) return base64.b64encode(byte_serialized).decode("utf-8") \ No newline at end of file