cornellGrading 2.20.1__tar.gz → 2.22.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {cornellgrading-2.20.1/cornellGrading.egg-info → cornellgrading-2.22.0}/PKG-INFO +1 -1
  2. {cornellgrading-2.20.1 → cornellgrading-2.22.0}/cornellGrading/__init__.py +1 -1
  3. {cornellgrading-2.20.1 → cornellgrading-2.22.0}/cornellGrading/cornellGrading.py +176 -13
  4. {cornellgrading-2.20.1 → cornellgrading-2.22.0}/cornellGrading/cornellQualtrics.py +74 -1
  5. {cornellgrading-2.20.1 → cornellgrading-2.22.0/cornellGrading.egg-info}/PKG-INFO +1 -1
  6. {cornellgrading-2.20.1 → cornellgrading-2.22.0}/LICENSE +0 -0
  7. {cornellgrading-2.20.1 → cornellgrading-2.22.0}/MANIFEST.in +0 -0
  8. {cornellgrading-2.20.1 → cornellgrading-2.22.0}/README.md +0 -0
  9. {cornellgrading-2.20.1 → cornellgrading-2.22.0}/cornellGrading/cornellInterface.py +0 -0
  10. {cornellgrading-2.20.1 → cornellgrading-2.22.0}/cornellGrading/dueDatesFromCSV.py +0 -0
  11. {cornellgrading-2.20.1 → cornellgrading-2.22.0}/cornellGrading/pandocHTMLParser.py +0 -0
  12. {cornellgrading-2.20.1 → cornellgrading-2.22.0}/cornellGrading/upload_MC_questions.py +0 -0
  13. {cornellgrading-2.20.1 → cornellgrading-2.22.0}/cornellGrading/utils.py +0 -0
  14. {cornellgrading-2.20.1 → cornellgrading-2.22.0}/cornellGrading.egg-info/SOURCES.txt +0 -0
  15. {cornellgrading-2.20.1 → cornellgrading-2.22.0}/cornellGrading.egg-info/dependency_links.txt +0 -0
  16. {cornellgrading-2.20.1 → cornellgrading-2.22.0}/cornellGrading.egg-info/entry_points.txt +0 -0
  17. {cornellgrading-2.20.1 → cornellgrading-2.22.0}/cornellGrading.egg-info/requires.txt +0 -0
  18. {cornellgrading-2.20.1 → cornellgrading-2.22.0}/cornellGrading.egg-info/top_level.txt +0 -0
  19. {cornellgrading-2.20.1 → cornellgrading-2.22.0}/pyproject.toml +0 -0
  20. {cornellgrading-2.20.1 → cornellgrading-2.22.0}/scripts/canvastex.py +0 -0
  21. {cornellgrading-2.20.1 → cornellgrading-2.22.0}/scripts/matlabgrader.py +0 -0
  22. {cornellgrading-2.20.1 → cornellgrading-2.22.0}/scripts/setupSelfGrading.py +0 -0
  23. {cornellgrading-2.20.1 → cornellgrading-2.22.0}/setup.cfg +0 -0
  24. {cornellgrading-2.20.1 → cornellgrading-2.22.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cornellGrading
3
- Version: 2.20.1
3
+ Version: 2.22.0
4
4
  Summary: Routines for interacting with Cornell installations of Canvas and Qualtrics
5
5
  Home-page: https://github.com/dsavransky/grading
6
6
  Author: Dmitry Savransky
@@ -1,4 +1,4 @@
1
1
  name = "cornellGrading"
2
- __version__ = "2.20.1"
2
+ __version__ = "2.22.0"
3
3
  from .cornellGrading import cornellGrading
4
4
  from .cornellQualtrics import cornellQualtrics
@@ -284,9 +284,9 @@ class cornellGrading:
284
284
  """Locate folder by name
285
285
 
286
286
  Args:
287
- folderName (str):
287
+ folderName (str):
288
288
  Name of folder to return. Must be exact match.
289
- To see all assignments do:
289
+ To see all folders do:
290
290
  >> for a in c.course.get_folders(): print(a.name)
291
291
 
292
292
  Returns:
@@ -2086,6 +2086,7 @@ class cornellGrading:
2086
2086
  preamble="",
2087
2087
  selfGradeDueDelta=7,
2088
2088
  selfGradeReleasedDelta=3,
2089
+ extraQuestions=[],
2089
2090
  ):
2090
2091
  """Create a page with reference solutions and a New Quiz linking to the page for
2091
2092
  student self-assemssent.
@@ -2108,6 +2109,8 @@ class cornellGrading:
2108
2109
  selfGradeReleasedDelta (float):
2109
2110
  Days after initial hw duedate that self-grading (and solutions) are
2110
2111
  released.
2112
+ extraQuestions (list(dict)):
2113
+ List of dicts defining additional questions to ask
2111
2114
 
2112
2115
  Returns:
2113
2116
  None
@@ -2167,9 +2170,13 @@ class cornellGrading:
2167
2170
  for j in range(nprobs):
2168
2171
  tmp = item.copy()
2169
2172
  tmp["position"] = j + 1
2170
- tmp["entry"]["title"] = f"Problem {j+1} score"
2173
+ tmp["entry"]["title"] = f"Problem {j + 1} score"
2171
2174
  self.addNewQuizItem(nq.id, tmp)
2172
2175
 
2176
+ for j, q in enumerate(extraQuestions):
2177
+ q["position"] = nprobs + j + 1
2178
+ self.addNewQuizItem(nq.id, q)
2179
+
2173
2180
  # publish quiz
2174
2181
  ass = self.getAssignment(nq.title)
2175
2182
  ass.edit(assignment={"published": True})
@@ -2288,7 +2295,7 @@ class cornellGrading:
2288
2295
 
2289
2296
  return submittedScoreNoAssignment, submittedAssignmentNoScore
2290
2297
 
2291
- def genNpointNewQuizItem(self, n, item_body=None, title=None, position=0):
2298
+ def genNpointNewQuizItem(self, n, item_body=None, title=None, position=0):
2292
2299
  """Generate a New Quiz multiple choice, variable point question with answers
2293
2300
  ranging from 0 to n and each answer worth the equivalent number of points.
2294
2301
 
@@ -2357,8 +2364,69 @@ class cornellGrading:
2357
2364
 
2358
2365
  return q
2359
2366
 
2367
+ def genBinaryNewQuizItem(self, n, item_body, title, position=0):
2368
+ """Generate a New Quiz multiple choice, variable point question with answers
2369
+ 'Yes' (earning n points) and 'No' (earning 0 points).
2370
+
2371
+ Args:
2372
+ n (int):
2373
+ Number of points possible. Question will have n+1 options (from 0 to n)
2374
+ with each response worth the equivalent number of points
2375
+ item_body (str):
2376
+ Question text (html formatted). This will automatically be wrapped in
2377
+ <p>...</p>
2378
+ title (str):
2379
+ Question title
2380
+ position (int):
2381
+ Position of question in quiz. Defaults to 0
2382
+
2383
+ Returns:
2384
+ dict:
2385
+ New Quiz Multiple choice question definition
2386
+
2387
+ """
2388
+
2389
+ # generate UUIDs
2390
+ uuids = [uuid.uuid4() for _ in range(2)]
2391
+
2392
+ # create choices and values dict list
2393
+ choices = []
2394
+ values = []
2395
+ for j, val in enumerate(["Yes", "No"]):
2396
+ choices.append(
2397
+ {"id": f"{uuids[j]}", "position": j + 1, "item_body": f"<p>{val}</p>"}
2398
+ )
2399
+ values.append({"value": f"{uuids[j]}", "points": int(val == "Yes") * n})
2400
+
2401
+ q = {
2402
+ "position": position,
2403
+ "points_possible": float(n),
2404
+ "entry_type": "Item",
2405
+ "status": "immutable",
2406
+ "entry": {
2407
+ "title": title,
2408
+ "item_body": f"<p>{item_body}</p>",
2409
+ "calculator_type": "none",
2410
+ "interaction_data": {"choices": choices},
2411
+ "properties": {
2412
+ "shuffle_rules": {"choices": {"to_lock": [], "shuffled": False}},
2413
+ "vary_points_by_answer": True,
2414
+ },
2415
+ "scoring_data": {
2416
+ "value": f"{uuids[0]}",
2417
+ "values": values,
2418
+ },
2419
+ "answer_feedback": {},
2420
+ "scoring_algorithm": "VaryPointsByAnswer",
2421
+ "interaction_type_slug": "choice",
2422
+ "feedback": {},
2423
+ },
2424
+ }
2425
+
2426
+ return q
2427
+
2360
2428
  def genNewQuizMultipleChoice(
2361
- self, question, options, correct_ind, points=1, position=0
2429
+ self, question, options, correct_ind, points=1, position=0, fightml=None
2362
2430
  ):
2363
2431
  """Generate a new quiz-style multiple choice question dictionary
2364
2432
 
@@ -2373,6 +2441,8 @@ class cornellGrading:
2373
2441
  Number of points for correct answer (defaults to 1).
2374
2442
  position (int):
2375
2443
  Position of question in quiz. Defaults to 0
2444
+ fightml (str, optional):
2445
+ HTML string from figure upload. Defaults to None.
2376
2446
 
2377
2447
  Returns:
2378
2448
  dict:
@@ -2394,6 +2464,10 @@ class cornellGrading:
2394
2464
  }
2395
2465
  )
2396
2466
 
2467
+ qtxt = f"<p>{convalllatex(question)}</p>"
2468
+ if fightml is not None:
2469
+ qtxt = f"{qtxt}\n<p>{fightml}</p>"
2470
+
2397
2471
  q = {
2398
2472
  "position": position,
2399
2473
  "points_possible": float(points),
@@ -2404,7 +2478,7 @@ class cornellGrading:
2404
2478
  "status": "mutable",
2405
2479
  "entry": {
2406
2480
  "title": question,
2407
- "item_body": f"<p>{convalllatex(question)}</p>",
2481
+ "item_body": qtxt,
2408
2482
  "calculator_type": "none",
2409
2483
  "interaction_data": {"choices": choices},
2410
2484
  "properties": {
@@ -2422,7 +2496,7 @@ class cornellGrading:
2422
2496
  return q
2423
2497
 
2424
2498
  def genQuizMultipleChoice(
2425
- self, question, options, correct_ind, points=1, position=0
2499
+ self, question, options, correct_ind, points=1, position=0, fightml=None
2426
2500
  ):
2427
2501
  """ "Generate a classic quiz-style multiple choice question dictionary
2428
2502
 
@@ -2437,6 +2511,8 @@ class cornellGrading:
2437
2511
  Number of points for correct answer (defaults to 1).
2438
2512
  position (int):
2439
2513
  Position of question in quiz. Defaults to 0
2514
+ fightml (str, optional):
2515
+ HTML string from figure upload. Defaults to None.
2440
2516
 
2441
2517
  Returns:
2442
2518
  dict:
@@ -2454,9 +2530,13 @@ class cornellGrading:
2454
2530
  }
2455
2531
  )
2456
2532
 
2533
+ qtxt = f"<p>{convalllatex(question)}</p>"
2534
+ if fightml is not None:
2535
+ qtxt = f"{qtxt}\n<p>{fightml}</p>"
2536
+
2457
2537
  q = {
2458
2538
  "question_name": question,
2459
- "question_text": f"<p>{convalllatex(question)}</p>",
2539
+ "question_text": qtxt,
2460
2540
  "question_type": "multiple_choice_question",
2461
2541
  "position": 0,
2462
2542
  "points_possible": points,
@@ -2465,7 +2545,7 @@ class cornellGrading:
2465
2545
 
2466
2546
  return q
2467
2547
 
2468
- def genQuizFromPolev(self, allqs, quiz):
2548
+ def genQuizFromPollEv(self, allqs, quiz, imagePath=None, imageFolder="QuizImages"):
2469
2549
  """Generate quiz items from a PollEv formatted CSV input file
2470
2550
 
2471
2551
  Args:
@@ -2473,6 +2553,11 @@ class cornellGrading:
2473
2553
  Table of questions and answers, formatted in PolEV CSV upload style
2474
2554
  quiz (canvasapi.new_quiz.NewQuiz or canvasapi.quiz.Quiz):
2475
2555
  Quiz object
2556
+ imagePath (str, optional):
2557
+ Full path to location on disk of any image files to use in questions.
2558
+ imageFolder (str):
2559
+ Name of Canvas folder to upload images to. Defaults to 'QuizImages'. If
2560
+ folder does not exist, it will be created as a hidden folder.
2476
2561
 
2477
2562
  Returns:
2478
2563
  None
@@ -2480,6 +2565,10 @@ class cornellGrading:
2480
2565
  .. note::
2481
2566
  Poll Everywhere upload format documentation is available here:
2482
2567
  https://support.polleverywhere.com/hc/en-us/articles/1260801546530-Import-questions
2568
+ The format is expanded by adding a 'Figure' column. The contents of this
2569
+ column should be the filename (without extension) of a figure associated
2570
+ with the question. If any figures are present, input `imagePath` must be
2571
+ set. Images must be in png format with extension .png.
2483
2572
 
2484
2573
  """
2485
2574
 
@@ -2493,11 +2582,22 @@ class cornellGrading:
2493
2582
  if col.startswith("Option"):
2494
2583
  optcols.append(col)
2495
2584
 
2585
+ # check for any figures and make sure we have enough information to upload them
2586
+ if np.any(~allqs["Figure"].isna()):
2587
+ assert (
2588
+ imagePath is not None
2589
+ ), "imagePath must be set if questions include figures."
2590
+ try:
2591
+ figFolder = self.getFolder(imageFolder)
2592
+ except AssertionError:
2593
+ figFolder = self.createFolder(imageFolder, hidden=True)
2594
+
2496
2595
  # iterate through rows, creating the questions
2497
- for j, row in allqs.iterrows():
2596
+ for k, row in allqs.iterrows():
2597
+ # process question and responses
2498
2598
  question = row.Title
2499
2599
  opts = row[optcols].values
2500
- opts = opts[~pandas.isna(opts)]
2600
+ opts = opts[~pandas.isna(opts)].astype(str)
2501
2601
  correct_ind = [
2502
2602
  j
2503
2603
  for j, s in enumerate(opts)
@@ -2507,15 +2607,78 @@ class cornellGrading:
2507
2607
  correct_ind = correct_ind[0]
2508
2608
  opts[correct_ind] = opts[correct_ind].strip("***")
2509
2609
 
2610
+ # process figure (if any)
2611
+ if not (pandas.isna(row.Figure)):
2612
+ impath = os.path.join(imagePath, f"{row.Figure}.png")
2613
+ fightml = self.uploadFigure(impath, figFolder)
2614
+ else:
2615
+ fightml = None
2616
+
2510
2617
  # create and add the question depending on quiz type
2511
2618
  if isinstance(quiz, canvasapi.new_quiz.NewQuiz):
2512
2619
  q = self.genNewQuizMultipleChoice(
2513
- question, opts.astype(str), correct_ind, position=j + 1
2620
+ question,
2621
+ opts.astype(str),
2622
+ correct_ind,
2623
+ position=k + 1,
2624
+ fightml=fightml,
2514
2625
  )
2515
2626
 
2516
2627
  self.addNewQuizItem(quiz.id, q)
2517
2628
  else:
2518
2629
  q = self.genQuizMultipleChoice(
2519
- question, opts.astype(str), correct_ind, position=j + 1
2630
+ question,
2631
+ opts.astype(str),
2632
+ correct_ind,
2633
+ position=k + 1,
2634
+ fightml=fightml,
2520
2635
  )
2521
2636
  quiz.create_question(question=q)
2637
+
2638
+ def uploadFigure(self, impath, figFolder):
2639
+ """Upload a figure and generate an HTML string for embedding it. If filename
2640
+ already exists in the folder, use that rather than overwriting.
2641
+
2642
+ Args:
2643
+ impath (str):
2644
+ Full path of disk to figure file
2645
+ figFolder (canvasapi.folder.Folder):
2646
+ Canvas folder to upload to
2647
+
2648
+ Returns:
2649
+ str:
2650
+ HTML string representing the figure
2651
+ """
2652
+
2653
+ filename = os.path.split(impath)[-1]
2654
+ haveFile = False
2655
+ for file in figFolder.get_files():
2656
+ if file.filename == filename:
2657
+ haveFile = True
2658
+ fig = {
2659
+ "id": file.id,
2660
+ "uuid": file.uuid,
2661
+ "display_name": file.display_name,
2662
+ }
2663
+ break
2664
+
2665
+ if not haveFile:
2666
+ assert os.path.exists(impath), f"Can not locate {impath}."
2667
+ status, fig = figFolder.upload(impath)
2668
+ assert status, f"Failed to upload {impath}."
2669
+
2670
+ figsrc = (
2671
+ f"{self.canvas._Canvas__requester.original_url}/courses/"
2672
+ f"{self.course.id}/files/{fig['id']}/preview?verifier={fig['uuid']}"
2673
+ )
2674
+ apisrc = (
2675
+ f"{self.canvas._Canvas__requester.base_url}courses/"
2676
+ f"{self.course.id}/files/{fig['id']}"
2677
+ )
2678
+
2679
+ fightml = (
2680
+ f'<img id="{fig["id"]}" src="{figsrc}" alt="{fig["display_name"]}" '
2681
+ f'width="600" data-api-endpoint="{apisrc}" data-api-returntype="File">'
2682
+ )
2683
+
2684
+ return fightml
@@ -690,6 +690,79 @@ class cornellQualtrics:
690
690
 
691
691
  return response.json()["result"]
692
692
 
693
+ def exportUploadedFiles(self, surveyId, DataExportTags, saveDir=None):
694
+ """Download all user uploaded files associated with specific questions from one
695
+ survey
696
+
697
+ Args:
698
+ surveyId (str):
699
+ Unique id string of survey. Get either from web interface or via
700
+ getSurveyId
701
+ DataExportTags (list):
702
+ Data Export Tags for all questions with uploaded files
703
+ saveDir (str, optional):
704
+ Full path to save location. If None (default) uses system tmp dir
705
+
706
+ Returns:
707
+ str:
708
+ Full path to directory where the survey results and all files are saved.
709
+ The survey results will be a CSV file with a filename that is the same
710
+ as the survey name, except with any colons replaced with underscores.
711
+ The files will be named as: ResponseId-FileId-fileName where ResponseId
712
+ and FileId match the entries in the survey results and fileName is the
713
+ original fileName of the upload.
714
+
715
+ Notes:
716
+ https://api.qualtrics.com/1071635f5a615-retrieve-a-user-uploaded-file
717
+
718
+ """
719
+
720
+ # get survey name from ID
721
+ if surveyId not in self.surveyIds:
722
+ self.listSurveys()
723
+ assert surveyId in self.surveyIds, f"Survey ID {surveyId} not found."
724
+
725
+ surveyname = self.surveyNames[self.surveyIds == surveyId][0]
726
+
727
+ # synthesize base URL
728
+ baseUrl = (
729
+ f"https://{self.dataCenter}{self.qualtricsapi}surveys/{surveyId}/responses/"
730
+ )
731
+
732
+ # export survey results and read output
733
+ tmpdir = self.exportSurvey(surveyId, saveDir=saveDir)
734
+ if ":" in surveyname:
735
+ surveyname = surveyname.replace(":", "_")
736
+ surveyfile = os.path.join(tmpdir, surveyname + ".csv")
737
+ assert os.path.isfile(surveyfile), "Survey results not where expected."
738
+ surveyres = pandas.read_csv(surveyfile, header=[0, 1, 2])
739
+
740
+ # get all response IDs
741
+ responseIds = surveyres["ResponseId"].values.flatten()
742
+
743
+ # loop through each data export tag and download the associated file for each
744
+ # response
745
+ for DataExportTag in DataExportTags:
746
+ # get all ids and filenames for current tag
747
+ fids = surveyres[f"{DataExportTag}_Id"].values.flatten()
748
+ fnames = surveyres[f"{DataExportTag}_Name"].values.flatten()
749
+
750
+ # loop through everything
751
+ for rid, fid, fname in zip(responseIds, fids, fnames):
752
+ durl = f"{baseUrl}{rid}/uploaded-files/{fid}"
753
+ requestDownload = requests.request(
754
+ "GET", durl, headers=self.headers_post, stream=True
755
+ )
756
+ assert (
757
+ requestDownload.status_code == 200
758
+ ), f"Download failed for file {fid} on response {rid}."
759
+
760
+ outname = os.path.join(tmpdir, f"{rid}-{fid}-{fname}")
761
+ with open(outname, "wb") as ff:
762
+ ff.write(requestDownload.content)
763
+
764
+ return tmpdir
765
+
693
766
  def exportSurvey(self, surveyId, fileFormat="csv", useLabels="true", saveDir=None):
694
767
  """Download and extract survey results
695
768
 
@@ -701,7 +774,7 @@ class cornellQualtrics:
701
774
  Format to download (must be csv, tsv, or spss)
702
775
  useLabels (str):
703
776
  Use choice labels ("true" or "false", defaults "true")
704
- saveDir (str):
777
+ saveDir (str, optional):
705
778
  Full path to save location. If None (default) uses system tmp dir
706
779
 
707
780
  Returns:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cornellGrading
3
- Version: 2.20.1
3
+ Version: 2.22.0
4
4
  Summary: Routines for interacting with Cornell installations of Canvas and Qualtrics
5
5
  Home-page: https://github.com/dsavransky/grading
6
6
  Author: Dmitry Savransky
File without changes