cornellGrading 2.25.0__tar.gz → 2.27.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 (27) hide show
  1. {cornellgrading-2.25.0/cornellGrading.egg-info → cornellgrading-2.27.0}/PKG-INFO +1 -1
  2. {cornellgrading-2.25.0 → cornellgrading-2.27.0}/cornellGrading/__init__.py +2 -1
  3. {cornellgrading-2.25.0 → cornellgrading-2.27.0}/cornellGrading/cornellGrading.py +3 -7
  4. {cornellgrading-2.25.0 → cornellgrading-2.27.0}/cornellGrading/cornellQualtrics.py +79 -55
  5. cornellgrading-2.27.0/cornellGrading/simplelists.py +242 -0
  6. {cornellgrading-2.25.0 → cornellgrading-2.27.0/cornellGrading.egg-info}/PKG-INFO +1 -1
  7. {cornellgrading-2.25.0 → cornellgrading-2.27.0}/cornellGrading.egg-info/SOURCES.txt +1 -0
  8. {cornellgrading-2.25.0 → cornellgrading-2.27.0}/LICENSE +0 -0
  9. {cornellgrading-2.25.0 → cornellgrading-2.27.0}/MANIFEST.in +0 -0
  10. {cornellgrading-2.25.0 → cornellgrading-2.27.0}/README.md +0 -0
  11. {cornellgrading-2.25.0 → cornellgrading-2.27.0}/cornellGrading/cornellInterface.py +0 -0
  12. {cornellgrading-2.25.0 → cornellgrading-2.27.0}/cornellGrading/dueDatesFromCSV.py +0 -0
  13. {cornellgrading-2.25.0 → cornellgrading-2.27.0}/cornellGrading/pandocHTMLParser.py +0 -0
  14. {cornellgrading-2.25.0 → cornellgrading-2.27.0}/cornellGrading/quizQuestionBank.py +0 -0
  15. {cornellgrading-2.25.0 → cornellgrading-2.27.0}/cornellGrading/uploadPollEvScores.py +0 -0
  16. {cornellgrading-2.25.0 → cornellgrading-2.27.0}/cornellGrading/upload_MC_questions.py +0 -0
  17. {cornellgrading-2.25.0 → cornellgrading-2.27.0}/cornellGrading/utils.py +0 -0
  18. {cornellgrading-2.25.0 → cornellgrading-2.27.0}/cornellGrading.egg-info/dependency_links.txt +0 -0
  19. {cornellgrading-2.25.0 → cornellgrading-2.27.0}/cornellGrading.egg-info/entry_points.txt +0 -0
  20. {cornellgrading-2.25.0 → cornellgrading-2.27.0}/cornellGrading.egg-info/requires.txt +0 -0
  21. {cornellgrading-2.25.0 → cornellgrading-2.27.0}/cornellGrading.egg-info/top_level.txt +0 -0
  22. {cornellgrading-2.25.0 → cornellgrading-2.27.0}/pyproject.toml +0 -0
  23. {cornellgrading-2.25.0 → cornellgrading-2.27.0}/scripts/canvastex.py +0 -0
  24. {cornellgrading-2.25.0 → cornellgrading-2.27.0}/scripts/matlabgrader.py +0 -0
  25. {cornellgrading-2.25.0 → cornellgrading-2.27.0}/scripts/setupSelfGrading.py +0 -0
  26. {cornellgrading-2.25.0 → cornellgrading-2.27.0}/setup.cfg +0 -0
  27. {cornellgrading-2.25.0 → cornellgrading-2.27.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cornellGrading
3
- Version: 2.25.0
3
+ Version: 2.27.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,5 @@
1
1
  name = "cornellGrading"
2
- __version__ = "2.25.0"
2
+ __version__ = "2.27.0"
3
3
  from .cornellGrading import cornellGrading
4
4
  from .cornellQualtrics import cornellQualtrics
5
+ from .simplelists import simplelists
@@ -803,7 +803,7 @@ class cornellGrading:
803
803
  # check that this one doesn't already exist
804
804
  res = self.qualtrics.getMailingLists()
805
805
  assert self.coursename not in [
806
- el["name"] for el in res.json()["result"]["elements"]
806
+ el["name"] for el in res
807
807
  ], "Mailing list already exists for this course."
808
808
 
809
809
  names = np.array([n.split(", ") for n in self.names])
@@ -1011,9 +1011,7 @@ class cornellGrading:
1011
1011
  <p><a class="survey-link ng-binding" href="{0}" target="_blank">{0}
1012
1012
  </a></p>
1013
1013
  <p>Be sure to enter your correct netid or you will not receive
1014
- credit.</p>""".format(
1015
- link
1016
- )
1014
+ credit.</p>""".format(link)
1017
1015
 
1018
1016
  _ = self.createAssignment(
1019
1017
  assname,
@@ -1611,9 +1609,7 @@ class cornellGrading:
1611
1609
  """data-api-endpoint="{2}" data-api-returntype="File">{0}</a></p>
1612
1610
  <p>Grade yourself against the rubric in the syllabus and enter your
1613
1611
  scores for each problem by following the link in the comments on
1614
- your original submission.</p>""".format(
1615
- solfname, solurl, solepoint
1616
- )
1612
+ your original submission.</p>""".format(solfname, solurl, solepoint)
1617
1613
  )
1618
1614
 
1619
1615
  if injectText:
@@ -80,6 +80,10 @@ class cornellQualtrics:
80
80
  self.listSurveys()
81
81
  print("Connected to Qualtrics.")
82
82
 
83
+ # set directoryId and userId
84
+ self.directoryId = self.getDirectoryId()
85
+ self.whoami()
86
+
83
87
  def setupHeaders(self):
84
88
  """Generate standard headers
85
89
 
@@ -105,6 +109,37 @@ class cornellQualtrics:
105
109
  "x-api-token": self.apiToken,
106
110
  }
107
111
 
112
+ def whoami(self):
113
+ """Populate userId attribute"""
114
+
115
+ baseUrl = f"https://{self.dataCenter}{self.qualtricsapi}whoami"
116
+ response = requests.get(baseUrl, headers=self.headers_tokenOnly)
117
+
118
+ self.userId = response.json()["result"]["userId"]
119
+
120
+ def expandResponse(self, response):
121
+ """
122
+
123
+ Args:
124
+ response (requests.models.Response):
125
+ Requests response from any relevant endpoint accessed with GET request
126
+
127
+ Returns:
128
+ list:
129
+ list of all result elements
130
+
131
+ """
132
+
133
+ out = response.json()["result"]["elements"]
134
+
135
+ while response.json()["result"]["nextPage"] is not None:
136
+ response = requests.get(
137
+ response.json()["result"]["nextPage"], headers=self.headers_tokenOnly
138
+ )
139
+ out += response.json()["result"]["elements"]
140
+
141
+ return out
142
+
108
143
  def listSurveys(self, baseUrl=None):
109
144
  """Grab and store all available Qualtrics surveys
110
145
 
@@ -214,25 +249,34 @@ class cornellQualtrics:
214
249
 
215
250
  return response
216
251
 
217
- def getMailingLists(self):
252
+ def getMailingLists(self, includeShared=False):
218
253
  """Grab all available Qualtrics mailing lists
219
254
 
220
255
  Args:
221
- None
256
+ includeShared (bool):
257
+ If True, include all mailing lists accessible to user. If False
258
+ (defualt) include only those owned by user.
222
259
 
223
260
  Returns:
224
- requests.models.Response:
225
- response object with all mailing lists
261
+ list:
262
+ all mailing list definitions
226
263
 
227
264
  """
228
265
 
229
- baseUrl = "https://{0}{1}mailinglists".format(
230
- self.dataCenter,
231
- self.qualtricsapi,
266
+ baseUrl = (
267
+ f"https://{self.dataCenter}{self.qualtricsapi}directories/"
268
+ f"{self.directoryId}/mailinglists"
232
269
  )
233
- response = requests.get(baseUrl, headers=self.headers_tokenOnly)
270
+ if includeShared:
271
+ response = requests.get(baseUrl, headers=self.headers_tokenOnly)
272
+ else:
273
+ response = requests.get(
274
+ baseUrl, headers=self.headers_tokenOnly, params={"ownerId": self.userId}
275
+ )
234
276
 
235
- return response
277
+ out = self.expandResponse(response)
278
+
279
+ return out
236
280
 
237
281
  def getMailingListId(self, listName):
238
282
  """Find qualtrics mailinglist id by name. Matching is exact.
@@ -249,9 +293,9 @@ class cornellQualtrics:
249
293
 
250
294
  res = self.getMailingLists()
251
295
  mailinglistid = None
252
- for el in res.json()["result"]["elements"]:
296
+ for el in res:
253
297
  if el["name"] == listName:
254
- mailinglistid = el["id"]
298
+ mailinglistid = el["mailingListId"]
255
299
  break
256
300
  assert mailinglistid, "Couldn't find this mailing list."
257
301
 
@@ -263,8 +307,8 @@ class cornellQualtrics:
263
307
  Args:
264
308
  listName (str):
265
309
  List name
266
- libraryId (str, optional):
267
- Library ID. If None, will attempt to identify it automatically
310
+ libraryId (str, deprecated):
311
+ DEPRECATED and ignored. Left only for backwards compatibility.
268
312
 
269
313
  Returns:
270
314
  str:
@@ -272,23 +316,13 @@ class cornellQualtrics:
272
316
 
273
317
  """
274
318
 
275
- # first we need to figure out what our personal library id is
276
- tmp = requests.get(
277
- "https://{0}{1}libraries".format(self.dataCenter, self.qualtricsapi),
278
- headers=self.headers_tokenOnly,
319
+ data = {"name": listName}
320
+ baseUrl = (
321
+ f"https://{self.dataCenter}{self.qualtricsapi}directories/"
322
+ f"{self.directoryId}/mailinglists"
279
323
  )
280
-
281
- if libraryId is None:
282
- for el in tmp.json()["result"]["elements"]:
283
- if "UR_" in el["libraryId"] or "URH_" in el["libraryId"]:
284
- libraryId = el["libraryId"]
285
- break
286
- assert libraryId is not None, "Could not identify library id."
287
-
288
- data = {"libraryId": libraryId, "name": listName}
289
-
290
324
  response = requests.post(
291
- "https://{0}{1}mailinglists".format(self.dataCenter, self.qualtricsapi),
325
+ baseUrl,
292
326
  headers=self.headers_post,
293
327
  json=data,
294
328
  )
@@ -304,8 +338,8 @@ class cornellQualtrics:
304
338
  Mailing list name
305
339
  contacts (pandas.DataFrame):
306
340
  DataFrame containing the columns: 'First Name', 'Last Name', and 'Email'
307
- libraryId (str, optional):
308
- Library ID. If None, will attempt to identify it automatically
341
+ libraryId (str, dprecated):
342
+ DEPRECATED and ignored. Left only for backwards compatibility.
309
343
 
310
344
 
311
345
  Returns:
@@ -367,26 +401,18 @@ class cornellQualtrics:
367
401
 
368
402
 
369
403
  """
370
- response = requests.get(
371
- "https://{0}{2}mailinglists/{1}/contacts".format(
372
- self.dataCenter,
373
- mailingListId,
374
- self.qualtricsapi,
375
- ),
376
- headers=self.headers_tokenOnly,
404
+ baseUrl = (
405
+ f"https://{self.dataCenter}{self.qualtricsapi}directories/"
406
+ f"{self.directoryId}/mailinglists/{mailingListId}/contacts"
377
407
  )
378
408
 
409
+ response = requests.get(baseUrl, headers=self.headers_tokenOnly)
410
+
379
411
  assert (
380
412
  response.status_code == 200
381
413
  ), "Could not get contacts for list {}.".format(mailingListId)
382
414
 
383
- out = response.json()["result"]["elements"]
384
-
385
- while response.json()["result"]["nextPage"] is not None:
386
- response = requests.get(
387
- response.json()["result"]["nextPage"], headers=self.headers_tokenOnly
388
- )
389
- out += response.json()["result"]["elements"]
415
+ out = self.expandResponse(response)
390
416
 
391
417
  return out
392
418
 
@@ -417,7 +443,7 @@ class cornellQualtrics:
417
443
  for el in contacts:
418
444
  fn.append(el["firstName"])
419
445
  ln.append(el["lastName"])
420
- cid.append(el["id"])
446
+ cid.append(el["contactLookupId"])
421
447
  email.append(el["email"])
422
448
 
423
449
  contacts = pandas.DataFrame(
@@ -453,8 +479,9 @@ class cornellQualtrics:
453
479
 
454
480
  """
455
481
 
456
- baseUrl = "https://{0}{2}mailinglists/{1}/contacts".format(
457
- self.dataCenter, mailingListId, self.qualtricsapi
482
+ baseUrl = (
483
+ f"https://{self.dataCenter}{self.qualtricsapi}directories/"
484
+ f"{self.directoryId}/mailinglists/{mailingListId}/contacts"
458
485
  )
459
486
 
460
487
  data = {
@@ -484,15 +511,12 @@ class cornellQualtrics:
484
511
 
485
512
  """
486
513
 
487
- response = requests.delete(
488
- "https://{0}{3}mailinglists/{1}/contacts/{2}".format(
489
- self.dataCenter,
490
- mailingListId,
491
- contactId,
492
- self.qualtricsapi,
493
- ),
494
- headers=self.headers_post,
514
+ baseUrl = (
515
+ f"https://{self.dataCenter}{self.qualtricsapi}directories/"
516
+ f"{self.directoryId}/mailinglists/{mailingListId}/contacts/{contactId}"
495
517
  )
518
+
519
+ response = requests.delete(baseUrl, headers=self.headers_post)
496
520
  assert response.status_code == 200, "Could not remove contact from list."
497
521
 
498
522
  def genDistribution(self, surveyId, mailingListId):
@@ -0,0 +1,242 @@
1
+ """Wrapper class for interacting with the SimpleLists v2 REST API.
2
+
3
+ Provides authentication against per-list API keys stored in a local JSON
4
+ credentials file, plus basic contact operations (retrieve, create, delete)
5
+ against SimpleLists mailing lists"""
6
+
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ import requests
13
+
14
+
15
+ class simplelists:
16
+ """Class for io methods for the SimpleLists v2 API."""
17
+
18
+ def __init__(self, server: str = "https://lists.cornell.edu/api/2/") -> None:
19
+ """Load per-list API keys and validate them against the live API.
20
+
21
+ Args:
22
+ server (str):
23
+ Base URL of the SimpleLists API, including trailing slash
24
+ and version path. Defaults to
25
+ 'https://lists.cornell.edu/api/2/'.
26
+ """
27
+
28
+ self.baseurl = server
29
+
30
+ cdir = "simplelists"
31
+
32
+ if os.name == "nt":
33
+ config_dir = Path(os.environ["APPDATA"], cdir)
34
+ else:
35
+ config_dir = Path(Path.home(), ".config", cdir)
36
+
37
+ credfile = config_dir / "credentials.json"
38
+
39
+ assert credfile.exists(), f"Cannot locate credentials file at {credfile}."
40
+ assert os.access(credfile, os.R_OK), (
41
+ f"Credentials file at {credfile} exists but is not readable."
42
+ )
43
+
44
+ self.keys: Dict[str, str] = json.loads(credfile.read_text())
45
+
46
+ for account, apikey in self.keys.items():
47
+ server_response = requests.get(
48
+ f"{self.baseurl}contacts/", params={"limit": 1}, auth=(apikey, "")
49
+ )
50
+ assert server_response.status_code == 200, (
51
+ f"Could not authenticate SimpleLists API key for '{account}'."
52
+ )
53
+
54
+ def _get_api_key(self, list_name: str) -> str:
55
+ """Look up the API key associated with a mailing list name.
56
+
57
+ Args:
58
+ list_name (str):
59
+ Name of the list (e.g. 'MAEFIELDFACULTY-L'), matching the
60
+ list-name portion of a '<list_name>-account' key in the
61
+ loaded credentials file.
62
+
63
+ Returns:
64
+ str:
65
+ API key for the requested list.
66
+ """
67
+
68
+ account = f"{list_name}-account"
69
+ assert account in self.keys, (
70
+ f"No API key found for list '{list_name}' "
71
+ f"(expected key '{account}' in credentials.json)."
72
+ )
73
+
74
+ return self.keys[account]
75
+
76
+ def get_contacts(self, list_name: str, limit: int = 1000) -> List[Dict[str, Any]]:
77
+ """Retrieve all contacts belonging to a mailing list.
78
+
79
+ Args:
80
+ list_name (str):
81
+ Name of the list to query (e.g. 'MAEFIELDFACULTY-L').
82
+ limit (int):
83
+ Maximum number of records to request per page (SimpleLists
84
+ API allows up to 1000). Defaults to 1000.
85
+
86
+ Returns:
87
+ list:
88
+ Raw contact objects (as returned by the API, one dict per
89
+ contact) for every contact on the list.
90
+ """
91
+
92
+ apikey = self._get_api_key(list_name)
93
+ url = f"{self.baseurl}contacts/"
94
+
95
+ records: List[Dict[str, Any]] = []
96
+ page = 1
97
+ while True:
98
+ server_response = requests.get(
99
+ url,
100
+ params={"list": list_name, "page": page, "limit": limit},
101
+ auth=(apikey, ""),
102
+ )
103
+ assert server_response.status_code == 200, (
104
+ f"Could not retrieve contacts for list '{list_name}' (page {page})."
105
+ )
106
+ payload = server_response.json()
107
+ records.extend(payload["data"])
108
+
109
+ if not payload["data"] or page * limit >= payload["count"]:
110
+ break
111
+ page += 1
112
+
113
+ return records
114
+
115
+ def create_contact(
116
+ self,
117
+ list_name: str,
118
+ email: str,
119
+ firstname: Optional[str] = None,
120
+ surname: Optional[str] = None,
121
+ notes: Optional[str] = None,
122
+ ) -> Dict[str, Any]:
123
+ """Create a new contact and add them to a mailing list.
124
+
125
+ Args:
126
+ list_name (str):
127
+ Name of the list to add the new contact to.
128
+ email (str):
129
+ Primary email address for the new contact.
130
+ firstname (str, optional):
131
+ Contact's first name. Defaults to None (omitted).
132
+ surname (str, optional):
133
+ Contact's surname. Defaults to None (omitted).
134
+ notes (str, optional):
135
+ Free-text notes for the contact. Defaults to None (omitted).
136
+
137
+ Returns:
138
+ dict:
139
+ Parsed JSON response describing the created contact.
140
+ """
141
+
142
+ apikey = self._get_api_key(list_name)
143
+ url = f"{self.baseurl}contacts/"
144
+
145
+ payload: Dict[str, Any] = {
146
+ "emails": email,
147
+ "lists": list_name,
148
+ }
149
+ if firstname is not None:
150
+ payload["firstname"] = firstname
151
+ if surname is not None:
152
+ payload["surname"] = surname
153
+ if notes is not None:
154
+ payload["notes"] = notes
155
+
156
+ server_response = requests.post(url, data=payload, auth=(apikey, ""))
157
+ assert server_response.status_code == 200, (
158
+ f"Could not create contact '{email}' on list '{list_name}'."
159
+ )
160
+
161
+ return server_response.json()
162
+
163
+ def update_contact(
164
+ self,
165
+ list_name: str,
166
+ contact_id: int,
167
+ firstname: Optional[str] = None,
168
+ surname: Optional[str] = None,
169
+ notes: Optional[str] = None,
170
+ email: Optional[str] = None,
171
+ ) -> Dict[str, Any]:
172
+ """Update an existing contact.
173
+
174
+ Args:
175
+ list_name (str):
176
+ Name of the list the API key belongs to (used only to
177
+ resolve the API key; SimpleLists contact ids are global,
178
+ not scoped to a single list).
179
+ contact_id (int):
180
+ SimpleLists internal id of the contact to update.
181
+ firstname (str, optional):
182
+ New first name. Defaults to None (left unchanged).
183
+ surname (str, optional):
184
+ New surname. Defaults to None (left unchanged).
185
+ notes (str, optional):
186
+ New free-text notes. Defaults to None (left unchanged).
187
+ email (str, optional):
188
+ New list of email addresses. Defaults to None (left
189
+ unchanged).
190
+
191
+ Returns:
192
+ dict:
193
+ Parsed JSON response describing the updated contact.
194
+ """
195
+
196
+ apikey = self._get_api_key(list_name)
197
+ url = f"{self.baseurl}contacts/{contact_id}/"
198
+
199
+ payload: Dict[str, Any] = {}
200
+ if firstname is not None:
201
+ payload["firstname"] = firstname
202
+ if surname is not None:
203
+ payload["surname"] = surname
204
+ if notes is not None:
205
+ payload["notes"] = notes
206
+ if email is not None:
207
+ payload["emails"] = email
208
+
209
+ assert payload, "At least one contact property must be provided to update."
210
+
211
+ server_response = requests.put(url, data=payload, auth=(apikey, ""))
212
+ assert server_response.status_code == 200, (
213
+ f"Could not update contact id {contact_id} (list '{list_name}')."
214
+ )
215
+
216
+ return server_response.json()
217
+
218
+ def delete_contact(self, list_name: str, contact_id: int) -> bool:
219
+ """Delete a contact from SimpleLists.
220
+
221
+ Args:
222
+ list_name (str):
223
+ Name of the list the API key belongs to (used only to
224
+ resolve the API key; SimpleLists contact ids are global,
225
+ not scoped to a single list).
226
+ contact_id (int):
227
+ SimpleLists internal id of the contact to delete.
228
+
229
+ Returns:
230
+ bool:
231
+ True if the API reported successful deletion.
232
+ """
233
+
234
+ apikey = self._get_api_key(list_name)
235
+ url = f"{self.baseurl}contacts/{contact_id}/"
236
+
237
+ server_response = requests.delete(url, auth=(apikey, ""))
238
+ assert server_response.status_code == 200, (
239
+ f"Could not delete contact id {contact_id} (list '{list_name}')."
240
+ )
241
+
242
+ return server_response.json().get("success", False)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cornellGrading
3
- Version: 2.25.0
3
+ Version: 2.27.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
@@ -11,6 +11,7 @@ cornellGrading/cornellQualtrics.py
11
11
  cornellGrading/dueDatesFromCSV.py
12
12
  cornellGrading/pandocHTMLParser.py
13
13
  cornellGrading/quizQuestionBank.py
14
+ cornellGrading/simplelists.py
14
15
  cornellGrading/uploadPollEvScores.py
15
16
  cornellGrading/upload_MC_questions.py
16
17
  cornellGrading/utils.py
File without changes