├── .DS_Store ├── .gitignore ├── .pylintrc ├── README.md ├── app ├── .DS_Store ├── __init__.py ├── assignments.py ├── auth.py ├── courses.py ├── errors.py ├── forms.py ├── models.py ├── outcomes.py ├── routes.py ├── static │ ├── .DS_Store │ ├── img │ │ ├── alignments.png │ │ ├── dashboard.png │ │ └── scores.png │ ├── js │ │ └── index.js │ └── styles │ │ └── main.css └── templates │ ├── 404.html │ ├── 500.html │ ├── _formhelpers.html │ ├── about.html │ ├── assignments.html │ ├── base.html │ ├── course.html │ ├── dashboard.html │ ├── index.html │ ├── login.html │ └── temp.html ├── config-example.py ├── htmlcov ├── app___init___py.html ├── app_assignments_py.html ├── app_auth_py.html ├── app_courses_py.html ├── app_errors_py.html ├── app_forms_py.html ├── app_models_py.html ├── app_outcomes_py.html ├── app_routes_py.html ├── coverage_html.js ├── index.html ├── jquery.ba-throttle-debounce.min.js ├── jquery.hotkeys.js ├── jquery.isonscreen.js ├── jquery.min.js ├── jquery.tablesorter.min.js ├── keybd_closed.png ├── keybd_open.png ├── status.json └── style.css ├── lmgapp.py ├── migration.py ├── migrations ├── README ├── alembic.ini ├── env.py ├── script.py.mako └── versions │ ├── 88510f7d95e7_initial.py │ ├── ba0d3f6d8cc9_really_remove_last_section_key.py │ ├── bc4519596133_create_new_sequential_id_for_outcome_.py │ └── ee57394e52d2_remove_last_section_key_for_now.py ├── requirements.txt ├── setup.cfg └── tests ├── .coverage ├── __init__.py ├── test_assignments.py ├── test_auth.py ├── test_courses.py ├── test_outcomes.py ├── test_routes.py ├── test_users.py └── utils.py /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bennettscience/canvas-learning-mastery/13a3f3f57e6fb5831e65333d5795f875d3722478/.DS_Store -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | venv 3 | /logs 4 | config.py 5 | app.db 6 | .flaskenv 7 | classTest.py 8 | .vscode 9 | migrations/versions/__pycache__ 10 | poolTest.py 11 | keysecret.txt 12 | lti.xml 13 | tmp/ 14 | tests/settings.py 15 | htmlcov/ 16 | .coverage -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # Specify a configuration file. 4 | #rcfile= 5 | 6 | # Python code to execute, usually for sys.path manipulation such as 7 | # pygtk.require(). 8 | #init-hook= 9 | 10 | # Add files or directories to the blacklist. They should be base names, not 11 | # paths. 12 | ignore=third_party 13 | 14 | # Add files or directories matching the regex patterns to the blacklist. The 15 | # regex matches against base names, not paths. 16 | ignore-patterns=object_detection_grpc_client.py,prediction_pb2.py,prediction_pb2_grpc.py,mnist_DDP.py,mnistddpserving.py 17 | 18 | # Pickle collected data for later comparisons. 19 | persistent=no 20 | 21 | # List of plugins (as comma separated values of python modules names) to load, 22 | # usually to register additional checkers. 23 | load-plugins= 24 | 25 | # Use multiple processes to speed up Pylint. 26 | jobs=4 27 | 28 | # Allow loading of arbitrary C extensions. Extensions are imported into the 29 | # active Python interpreter and may run arbitrary code. 30 | unsafe-load-any-extension=no 31 | 32 | # A comma-separated list of package or module names from where C extensions may 33 | # be loaded. Extensions are loading into the active Python interpreter and may 34 | # run arbitrary code 35 | extension-pkg-whitelist= 36 | 37 | 38 | [MESSAGES CONTROL] 39 | 40 | # Only show warnings with the listed confidence levels. Leave empty to show 41 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 42 | confidence= 43 | 44 | # Enable the message, report, category or checker with the given id(s). You can 45 | # either give multiple identifier separated by comma (,) or put this option 46 | # multiple time (only on the command line, not in the configuration file where 47 | # it should appear only once). See also the "--disable" option for examples. 48 | #enable= 49 | 50 | # Disable the message, report, category or checker with the given id(s). You 51 | # can either give multiple identifiers separated by comma (,) or put this 52 | # option multiple times (only on the command line, not in the configuration 53 | # file where it should appear only once).You can also use "--disable=all" to 54 | # disable everything first and then reenable specific checks. For example, if 55 | # you want to run only the similarities checker, you can use "--disable=all 56 | # --enable=similarities". If you want to run only the classes checker, but have 57 | # no Warning level messages displayed, use"--disable=all --enable=classes 58 | # --disable=W" 59 | disable=import-star-module-level,old-octal-literal,oct-method,print-statement,unpacking-in-except,parameter-unpacking,backtick,old-raise-syntax,old-ne-operator,long-suffix,dict-view-method,dict-iter-method,metaclass-assignment,next-method-called,raising-string,indexing-exception,raw_input-builtin,long-builtin,file-builtin,execfile-builtin,coerce-builtin,cmp-builtin,buffer-builtin,basestring-builtin,apply-builtin,filter-builtin-not-iterating,using-cmp-argument,useless-suppression,range-builtin-not-iterating,suppressed-message,missing-docstring,no-absolute-import,old-division,cmp-method,reload-builtin,zip-builtin-not-iterating,intern-builtin,unichr-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,input-builtin,round-builtin,hex-method,nonzero-method,map-builtin-not-iterating,relative-import,invalid-name,bad-continuation,no-member,locally-disabled,fixme,import-error,too-many-locals 60 | 61 | 62 | [REPORTS] 63 | 64 | # Set the output format. Available formats are text, parseable, colorized, msvs 65 | # (visual studio) and html. You can also give a reporter class, eg 66 | # mypackage.mymodule.MyReporterClass. 67 | output-format=text 68 | 69 | # Put messages in a separate file for each module / package specified on the 70 | # command line instead of printing them on stdout. Reports (if any) will be 71 | # written in a file name "pylint_global.[txt|html]". This option is deprecated 72 | # and it will be removed in Pylint 2.0. 73 | files-output=no 74 | 75 | # Tells whether to display a full report or only the messages 76 | reports=no 77 | 78 | # Python expression which should return a note less than 10 (10 is the highest 79 | # note). You have access to the variables errors warning, statement which 80 | # respectively contain the number of errors / warnings messages and the total 81 | # number of statements analyzed. This is used by the global evaluation report 82 | # (RP0004). 83 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 84 | 85 | # Template used to display messages. This is a python new-style format string 86 | # used to format the message information. See doc for all details 87 | #msg-template= 88 | 89 | 90 | [BASIC] 91 | 92 | # Good variable names which should always be accepted, separated by a comma 93 | good-names=i,j,k,ex,Run,_ 94 | 95 | # Bad variable names which should always be refused, separated by a comma 96 | bad-names=foo,bar,baz,toto,tutu,tata 97 | 98 | # Colon-delimited sets of names that determine each other's naming style when 99 | # the name regexes allow several styles. 100 | name-group= 101 | 102 | # Include a hint for the correct naming format with invalid-name 103 | include-naming-hint=no 104 | 105 | # List of decorators that produce properties, such as abc.abstractproperty. Add 106 | # to this list to register other decorators that produce valid properties. 107 | property-classes=abc.abstractproperty 108 | 109 | # Regular expression matching correct function names 110 | function-rgx=[a-z_][a-z0-9_]{2,30}$ 111 | 112 | # Naming hint for function names 113 | function-name-hint=[a-z_][a-z0-9_]{2,30}$ 114 | 115 | # Regular expression matching correct variable names 116 | variable-rgx=[a-z_][a-z0-9_]{2,30}$ 117 | 118 | # Naming hint for variable names 119 | variable-name-hint=[a-z_][a-z0-9_]{2,30}$ 120 | 121 | # Regular expression matching correct constant names 122 | const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 123 | 124 | # Naming hint for constant names 125 | const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 126 | 127 | # Regular expression matching correct attribute names 128 | attr-rgx=[a-z_][a-z0-9_]{2,30}$ 129 | 130 | # Naming hint for attribute names 131 | attr-name-hint=[a-z_][a-z0-9_]{2,30}$ 132 | 133 | # Regular expression matching correct argument names 134 | argument-rgx=[a-z_][a-z0-9_]{2,30}$ 135 | 136 | # Naming hint for argument names 137 | argument-name-hint=[a-z_][a-z0-9_]{2,30}$ 138 | 139 | # Regular expression matching correct class attribute names 140 | class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 141 | 142 | # Naming hint for class attribute names 143 | class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 144 | 145 | # Regular expression matching correct inline iteration names 146 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 147 | 148 | # Naming hint for inline iteration names 149 | inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ 150 | 151 | # Regular expression matching correct class names 152 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 153 | 154 | # Naming hint for class names 155 | class-name-hint=[A-Z_][a-zA-Z0-9]+$ 156 | 157 | # Regular expression matching correct module names 158 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 159 | 160 | # Naming hint for module names 161 | module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 162 | 163 | # Regular expression matching correct method names 164 | method-rgx=[a-z_][a-z0-9_]{2,30}$ 165 | 166 | # Naming hint for method names 167 | method-name-hint=[a-z_][a-z0-9_]{2,30}$ 168 | 169 | # Regular expression which should only match function or class names that do 170 | # not require a docstring. 171 | no-docstring-rgx=^_ 172 | 173 | # Minimum line length for functions/classes that require docstrings, shorter 174 | # ones are exempt. 175 | docstring-min-length=-1 176 | 177 | 178 | [ELIF] 179 | 180 | # Maximum number of nested blocks for function / method body 181 | max-nested-blocks=5 182 | 183 | 184 | [TYPECHECK] 185 | 186 | # Tells whether missing members accessed in mixin class should be ignored. A 187 | # mixin class is detected if its name ends with "mixin" (case insensitive). 188 | ignore-mixin-members=yes 189 | 190 | # List of module names for which member attributes should not be checked 191 | # (useful for modules/projects where namespaces are manipulated during runtime 192 | # and thus existing member attributes cannot be deduced by static analysis. It 193 | # supports qualified module names, as well as Unix pattern matching. 194 | ignored-modules= 195 | 196 | # List of class names for which member attributes should not be checked (useful 197 | # for classes with dynamically set attributes). This supports the use of 198 | # qualified names. 199 | ignored-classes=optparse.Values,thread._local,_thread._local 200 | 201 | # List of members which are set dynamically and missed by pylint inference 202 | # system, and so shouldn't trigger E1101 when accessed. Python regular 203 | # expressions are accepted. 204 | generated-members= 205 | 206 | # List of decorators that produce context managers, such as 207 | # contextlib.contextmanager. Add to this list to register other decorators that 208 | # produce valid context managers. 209 | contextmanager-decorators=contextlib.contextmanager 210 | 211 | 212 | [FORMAT] 213 | 214 | # Maximum number of characters on a single line. 215 | max-line-length=150 216 | 217 | # Regexp for a line that is allowed to be longer than the limit. 218 | ignore-long-lines=^\s*(# )??$ 219 | 220 | # Allow the body of an if to be on the same line as the test if there is no 221 | # else. 222 | single-line-if-stmt=no 223 | 224 | # List of optional constructs for which whitespace checking is disabled. `dict- 225 | # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. 226 | # `trailing-comma` allows a space between comma and closing bracket: (a, ). 227 | # `empty-line` allows space-only lines. 228 | no-space-check=trailing-comma,dict-separator 229 | 230 | # Maximum number of lines in a module 231 | max-module-lines=1000 232 | 233 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 234 | # tab). 235 | # Use 2 spaces consistent with TensorFlow style. 236 | indent-string=' ' 237 | 238 | # Number of spaces of indent required inside a hanging or continued line. 239 | indent-after-paren=4 240 | 241 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 242 | expected-line-ending-format= 243 | 244 | 245 | [MISCELLANEOUS] 246 | 247 | # List of note tags to take in consideration, separated by a comma. 248 | notes=FIXME,XXX,TODO 249 | 250 | 251 | [VARIABLES] 252 | 253 | # Tells whether we should check for unused import in __init__ files. 254 | init-import=no 255 | 256 | # A regular expression matching the name of dummy variables (i.e. expectedly 257 | # not used). 258 | dummy-variables-rgx=(_+[a-zA-Z0-9]*?$)|dummy 259 | 260 | # List of additional names supposed to be defined in builtins. Remember that 261 | # you should avoid to define new builtins when possible. 262 | additional-builtins= 263 | 264 | # List of strings which can identify a callback function by name. A callback 265 | # name must start or end with one of those strings. 266 | callbacks=cb_,_cb 267 | 268 | # List of qualified module names which can have objects that can redefine 269 | # builtins. 270 | redefining-builtins-modules=six.moves,future.builtins 271 | 272 | 273 | [LOGGING] 274 | 275 | # Logging modules to check that the string format arguments are in logging 276 | # function parameter format 277 | logging-modules=logging 278 | 279 | 280 | [SIMILARITIES] 281 | 282 | # Minimum lines number of a similarity. 283 | min-similarity-lines=4 284 | 285 | # Ignore comments when computing similarities. 286 | ignore-comments=yes 287 | 288 | # Ignore docstrings when computing similarities. 289 | ignore-docstrings=yes 290 | 291 | # Ignore imports when computing similarities. 292 | ignore-imports=no 293 | 294 | 295 | [SPELLING] 296 | 297 | # Spelling dictionary name. Available dictionaries: none. To make it working 298 | # install python-enchant package. 299 | spelling-dict= 300 | 301 | # List of comma separated words that should not be checked. 302 | spelling-ignore-words= 303 | 304 | # A path to a file that contains private dictionary; one word per line. 305 | spelling-private-dict-file= 306 | 307 | # Tells whether to store unknown words to indicated private dictionary in 308 | # --spelling-private-dict-file option instead of raising a message. 309 | spelling-store-unknown-words=no 310 | 311 | 312 | [IMPORTS] 313 | 314 | # Deprecated modules which should not be used, separated by a comma 315 | deprecated-modules=regsub,TERMIOS,Bastion,rexec 316 | 317 | # Create a graph of every (i.e. internal and external) dependencies in the 318 | # given file (report RP0402 must not be disabled) 319 | import-graph= 320 | 321 | # Create a graph of external dependencies in the given file (report RP0402 must 322 | # not be disabled) 323 | ext-import-graph= 324 | 325 | # Create a graph of internal dependencies in the given file (report RP0402 must 326 | # not be disabled) 327 | int-import-graph= 328 | 329 | # Force import order to recognize a module as part of the standard 330 | # compatibility libraries. 331 | known-standard-library= 332 | 333 | # Force import order to recognize a module as part of a third party library. 334 | known-third-party=enchant 335 | 336 | # Analyse import fallback blocks. This can be used to support both Python 2 and 337 | # 3 compatible code, which means that the block might have code that exists 338 | # only in one or another interpreter, leading to false positives when analysed. 339 | analyse-fallback-blocks=no 340 | 341 | 342 | [DESIGN] 343 | 344 | # Maximum number of arguments for function / method 345 | max-args=7 346 | 347 | # Argument names that match this expression will be ignored. Default to name 348 | # with leading underscore 349 | ignored-argument-names=_.* 350 | 351 | # Maximum number of locals for function / method body 352 | max-locals=15 353 | 354 | # Maximum number of return / yield for function / method body 355 | max-returns=6 356 | 357 | # Maximum number of branch for function / method body 358 | max-branches=12 359 | 360 | # Maximum number of statements in function / method body 361 | max-statements=50 362 | 363 | # Maximum number of parents for a class (see R0901). 364 | max-parents=7 365 | 366 | # Maximum number of attributes for a class (see R0902). 367 | max-attributes=7 368 | 369 | # Minimum number of public methods for a class (see R0903). 370 | min-public-methods=0 371 | 372 | # Maximum number of public methods for a class (see R0904). 373 | max-public-methods=20 374 | 375 | # Maximum number of boolean expressions in a if statement 376 | max-bool-expr=5 377 | 378 | 379 | [CLASSES] 380 | 381 | # List of method names used to declare (i.e. assign) instance attributes. 382 | defining-attr-methods=__init__,__new__,setUp 383 | 384 | # List of valid names for the first argument in a class method. 385 | valid-classmethod-first-arg=cls 386 | 387 | # List of valid names for the first argument in a metaclass class method. 388 | valid-metaclass-classmethod-first-arg=mcs 389 | 390 | # List of member names, which should be excluded from the protected access 391 | # warning. 392 | exclude-protected=_asdict,_fields,_replace,_source,_make 393 | 394 | 395 | [EXCEPTIONS] 396 | 397 | # Exceptions that will emit a warning when being caught. Defaults to 398 | # "Exception" 399 | overgeneral-exceptions=Exception 400 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Learning Mastery Grades Calculator 2 | 3 | This Flask application hooks into Canvas to associate Outcome scores with actual assignments. 4 | 5 | This project is no longer being updated. I was just learning how to develop complex applications when I made 6 | this version, but it quickly became unsustainable. Instead of trying to untangle everything here, I've started 7 | a new, more robust, more sustainable version of the helper [over here](https://github.com/bennettscience/canvas-lms-mastery-helper). 8 | 9 | 10 | ## What does it do? 11 | 12 | If you want to use Outcomes to dictate assignment scores, you have to do that one by one by hand in the gradebook. This means either using a dual-monitor setup to open both at the same time or clicking back and forth for each and every assignment. This app creates a relationship between an assignment and an outcome and then uses the Canvas API to update the gradebook in bulk. 13 | 14 | Here's a practical example: 15 | 16 | Outcome 1.1 defines a skill students should have. An Outcome score of 3.0 is considered 'passing.' Assignment 1.1 in the traditional gradebook is set to 1/1 when a 3.0 is reached on the Outcome. Currently, you have to update this score by hand. 17 | 18 | This app allows the instructor to set a relationship between a single Outcome and an Assignment. When the mastery score is reached on the Outcome, the Assignment is automatically toggled to full credit for each student. 19 | 20 | ## What about student data? 21 | 22 | No student data is stored in the app. The database is used to map an Outcome to an Assignment (and vice versa) using the item ID from Canvas. When the app runs, the stored IDs are queried and loaded into the dashboard for the user. Student scores are updated in real time via the API. As soon as the user logs out, the student data is cleared from the session. 23 | 24 | ## Dependencies 25 | 26 | `pip install -r requirements.txt` 27 | 28 | ## Config 29 | 30 | Add a Developer Key for the application in your Canvas instance. 31 | 32 | Set up your config with `cp config-example.py config.py` in your directory. Update your config file with your Canvas Developer Key specifics. Make sure you edit your URL root in each of the URLs listed. 33 | 34 | The API calls to Canvas are all done with [UCF Open's CanvasAPI library](https://github.com/ucfopen/canvasapi/tree/master). 35 | 36 | ## Canvas Course Structure 37 | 38 | The settings are not configurable right now. To use the application, you should have the following: 39 | 40 | - Outcomes imported into your Canvas course. 41 | - Assignments assessing the Outcomes (either quizzes or rubrics) 42 | - Assignments to be updated by the app. **These assingments should be worth one point each.** 43 | 44 | When a student reaches master (Outcome aggregate = 3 or higher) the linked Assignment will be toggled to 1/1. If it is below 3, the Assignment is toggled to 0/1. 45 | 46 | ## TODO 47 | 48 | ### Backend 49 | 50 | - [x] Database models 51 | - [x] outcomes 52 | - [x] assignments 53 | - [x] Link assignment to outcome 54 | - [ ] Per-student reporting 55 | - [x] Routing 56 | - [X] Login required view 57 | - [X] standardize `id` structures 58 | - ~~Student view?~~ 59 | - [x] Update assignment scores on Canvas 60 | - [ ] Flask-Session for server-side key storage 61 | - [ ] Flask-Cache to reduce API calls 62 | - [ ] Config 63 | - [ ] User-defined Mastery score 64 | - [ ] User-defined Assignment toggle 65 | 66 | ### Authentication 67 | 68 | - [X] OAuth2 Login 69 | - [x] Refresh OAuth session 70 | - [X] `Canvas` object for API calls 71 | 72 | ### Frontend 73 | 74 | - [x] navbar 75 | - [ ] App config settings 76 | - [ ] Config Canvas URL 77 | - [x] Dashboard 78 | - [X] Course picker 79 | - [ ] Course 80 | - [x] Define assignment category ID 81 | - [x] Fetch assignments in the group 82 | - [x] Import course outcomes by group 83 | - [ ] Separate rosters by course section 84 | 85 | ### Screenshots 86 | 87 | #### Active courses 88 | 89 | ![Active courses](./app/static/img/dashboard.png) 90 | 91 | #### Align Outcomes to Assignments 92 | 93 | ![Alignments](./app/static/img/alignments.png) 94 | 95 | #### Student scores 96 | 97 | ![Student scores](./app/static/img/scores.png) 98 | -------------------------------------------------------------------------------- /app/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bennettscience/canvas-learning-mastery/13a3f3f57e6fb5831e65333d5795f875d3722478/app/.DS_Store -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | from logging.handlers import RotatingFileHandler 4 | import sentry_sdk 5 | from sentry_sdk.integrations.flask import FlaskIntegration 6 | from flask import Flask, session 7 | from flask_sqlalchemy import SQLAlchemy 8 | from flask_migrate import Migrate 9 | from config import Config 10 | from flask_debugtoolbar import DebugToolbarExtension 11 | from flask_login import LoginManager, UserMixin, login_user, logout_user, current_user 12 | from flask_bootstrap import Bootstrap 13 | from flask_cors import CORS 14 | 15 | os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1' 16 | 17 | app = Flask(__name__) 18 | app.config.from_object(Config) 19 | app.config['CORS_HEADERS'] = 'Content-Type' 20 | db = SQLAlchemy(app) 21 | login = LoginManager(app) 22 | login.login_view = 'index' 23 | migrate = Migrate(app, db) 24 | bootstrap = Bootstrap(app) 25 | CORS(app, resources={r"/student*": {"origins": "https://elkhart.instructure.com/*"}}) 26 | 27 | from app import app, routes 28 | 29 | # Connect to Sentry 30 | # sentry_sdk.init( 31 | # dsn=app.config['SENTRY_DSN'], 32 | # integrations=[FlaskIntegration()], 33 | # send_default_pii=True, 34 | # release="canvas-mastery@0.2.0" 35 | # ) 36 | -------------------------------------------------------------------------------- /app/assignments.py: -------------------------------------------------------------------------------- 1 | import json 2 | from app import db 3 | from app.models import Assignment, Outcome 4 | 5 | 6 | class Assignments: 7 | def __init__(self, canvas, course_id): 8 | """ Instantiate an Assignment to work with 9 | 10 | :param canvas: object to work with the API 11 | :type canvas: instance of 12 | 13 | :param course_id: Valid Canvas course ID 14 | :type course_id: int 15 | """ 16 | self.canvas = canvas 17 | self.course_id = course_id 18 | 19 | @staticmethod 20 | def get_all_assignment_scores(canvas, course_id, **kwargs): 21 | """ Request current scores for students in the course 22 | :type canvas: Object 23 | :param canvas: Canvas object 24 | 25 | :type course_id: Int 26 | :param course_id: Canvas course ID 27 | 28 | :raises: 29 | 30 | :rtype: Object json_data 31 | """ 32 | # make a couple lists to hold processed data 33 | assignment_list = [] 34 | outcome_list = [] 35 | json_data = [] 36 | 37 | course = canvas.get_course(course_id) 38 | 39 | if "section_id" in kwargs: 40 | course = course.get_section(kwargs.get("section_id")) 41 | 42 | query = Assignment.query.filter( 43 | Assignment.course_id == course_id, Assignment.outcome_id.isnot(None) 44 | ) 45 | 46 | if query.all(): 47 | 48 | for item in query: 49 | outcome_list.append(item.__dict__) 50 | assignment_list.append(item.id) 51 | 52 | enrollments = Assignments.build_enrollment_list(course) 53 | 54 | # Request the submissions from Canvas sorted by user in a 55 | # single call to speed it up. 56 | all_submissions = course.get_multiple_submissions( 57 | assignment_ids=assignment_list, 58 | student_ids=enrollments, 59 | include=("user", "assignment"), 60 | grouped=True, 61 | ) 62 | 63 | # GroupedSubmission objects are organized by student_id. Each object 64 | # has a list of Submissions that need to be processed individually. 65 | for student in all_submissions: 66 | 67 | submissions = [] 68 | items = student.submissions 69 | 70 | for item in items: 71 | 72 | # Check that the user is still active in the course 73 | if item.user["id"] in enrollments: 74 | 75 | canvas_id = canvas_id = item.user["id"] 76 | sis_id = item.user["login_id"] 77 | user_name = item.user["sortable_name"] 78 | 79 | submissions.append( 80 | Assignments.process_enrollment_submissions( 81 | canvas, course_id, canvas_id, item 82 | ) 83 | ) 84 | 85 | else: 86 | continue 87 | 88 | json_data.append( 89 | { 90 | "canvas_id": canvas_id, 91 | "sis_id": sis_id, 92 | "user_name": user_name, 93 | "submissions": submissions, 94 | } 95 | ) 96 | 97 | else: 98 | return None 99 | 100 | return json_data 101 | 102 | @classmethod 103 | def build_enrollment_list(self, course): 104 | """ Request a list of enrollments from the Canvas API for a course 105 | 106 | :param course: instance 107 | :type course: Class 108 | 109 | :return: List of student IDs 110 | :rtype: list of int 111 | """ 112 | student_list = [] 113 | 114 | enrollments = course.get_enrollments(role="StudentEnrollment", state="active") 115 | 116 | for e in enrollments: 117 | item = json.loads(e.to_json()) 118 | student_list.append(item["user"]["id"]) 119 | 120 | return student_list 121 | 122 | @classmethod 123 | def process_enrollment_submissions(self, canvas, course_id, student_id, item): 124 | """ Process a student submission object 125 | 126 | :param item: Submission dict 127 | :returns submission: dict 128 | """ 129 | # Get the outcome ID if it matches the assignment ID 130 | outcome_id = Outcome.query.get( 131 | Assignment.query.get(item.assignment_id).outcome_id 132 | ).outcome_id 133 | 134 | outcome_rollup = canvas.get_course(course_id).get_outcome_result_rollups( 135 | user_ids=student_id, outcome_ids=outcome_id 136 | ) 137 | 138 | if len(outcome_rollup["rollups"][0]["scores"]) > 0: 139 | score = outcome_rollup["rollups"][0]["scores"][0]["score"] 140 | else: 141 | score = None 142 | 143 | submission = { 144 | outcome_id: { 145 | "assignment_id": item.assignment_id, 146 | "assignment_name": item.assignment["name"], 147 | "assignment_score": item.score, 148 | "outcome_id": outcome_id, 149 | "current_outcome_score": score, 150 | } 151 | } 152 | 153 | return submission 154 | 155 | @staticmethod 156 | def get_course_assignments(canvas, course_id): 157 | """ Get all assignments for a Canvas course 158 | 159 | :param canvas: Instance of 160 | :type canvas: Class 161 | 162 | :param course_id: Valid Canvas course ID 163 | :type course_id: int 164 | 165 | :return: List of assignment IDs 166 | :rtype: list of int 167 | """ 168 | course = canvas.get_course(course_id) 169 | 170 | assignments = list(course.get_assignments()) 171 | 172 | assignment_list = [ 173 | {"id": assignment.id, "name": assignment.name} 174 | for assignment in assignments 175 | if hasattr(assignment, "rubric") 176 | ] 177 | 178 | return assignment_list 179 | 180 | @classmethod 181 | def build_assignment_rubric_results(self, canvas, course_id, assignment_id): 182 | """ Look up rubric results for a specific Canvas assignment 183 | 184 | :param canvas: instance 185 | :type canvas: Class 186 | 187 | :param course_id: Valid Canvas course ID 188 | :type course_id: int 189 | 190 | :param assignment_id: Valid Canvas assignment ID 191 | :type assignment_id: int 192 | 193 | :return: Named dictionary of outcomes and rubric results for an assignment 194 | :rtype: dict of list of ints 195 | """ 196 | course = canvas.get_course(course_id) 197 | assignment = course.get_assignment(assignment_id) 198 | 199 | rubric = assignment.rubric 200 | 201 | # build a list to use as headers in the view 202 | columns = [] 203 | 204 | for criteria in rubric: 205 | if "outcome_id" in criteria: 206 | 207 | column = {} 208 | column["id"] = criteria["id"] 209 | column["name"] = criteria["description"] 210 | column["outcome_id"] = criteria["outcome_id"] 211 | columns.append(column) 212 | 213 | # Create a list to store all results 214 | student_results = self.get_assignment_scores(assignment) 215 | 216 | return {"columns": columns, "student_results": student_results} 217 | 218 | @classmethod 219 | def get_assignment_scores(self, assignment): 220 | """ Request assignment scores from Canvas 221 | 222 | :param assignment: instance 223 | :type assignment: Class 224 | 225 | :return: A list of student dicts with results for the assigment 226 | :rtype: list of dict 227 | """ 228 | student_results = [] 229 | 230 | # Get submissions for the assignment to get rubric evaluation 231 | submissions = assignment.get_submissions(include=("rubric_assessment", "user")) 232 | 233 | for submission in list(submissions): 234 | 235 | student_result = {} 236 | student_result["id"] = submission.user_id 237 | student_result["name"] = submission.user["sortable_name"] 238 | student_result["score"] = submission.score 239 | if hasattr(submission, "rubric_assessment"): 240 | student_result["rubric"] = submission.rubric_assessment 241 | student_results.append(student_result) 242 | 243 | student_results = sorted(student_results, key=lambda x: x["name"].split(" ")) 244 | 245 | return student_results 246 | 247 | def save_assignment_data(canvas, course_id, assignment_group_id): 248 | """ Save course assignments to the database. 249 | :param canvas: canvasapi Canvas object 250 | :ptype canvas: object 251 | 252 | :param course_id: Canvas course ID 253 | :ptype course_id: int 254 | 255 | :param assignment_group_id: Canvas assignment group ID 256 | :ptype assignment_group_id: int 257 | """ 258 | 259 | assignment_commits = [] 260 | 261 | course = canvas.get_course(course_id) 262 | assignment_group = course.get_assignment_group( 263 | assignment_group_id, include=["assignments"] 264 | ) 265 | 266 | for a in assignment_group.assignments: 267 | query = Assignment.query.get(a["id"]) 268 | 269 | if query is None: 270 | assignment = Assignment( 271 | id=a["id"], title=a["name"], course_id=course_id 272 | ) 273 | 274 | assignment_commits.append(assignment) 275 | 276 | db.session.bulk_save_objects(assignment_commits) 277 | db.session.commit() 278 | -------------------------------------------------------------------------------- /app/auth.py: -------------------------------------------------------------------------------- 1 | from canvasapi import Canvas 2 | from requests_oauthlib import OAuth2Session 3 | from app import app 4 | from flask import request, session 5 | import time 6 | 7 | 8 | class Auth: 9 | """ Manage OAuth flow. """ 10 | 11 | oauth = OAuth2Session( 12 | app.config["OAUTH_CREDENTIALS"]["canvas"]["id"], 13 | redirect_uri=app.config["OAUTH_CREDENTIALS"]["canvas"]["redirect_url"], 14 | ) 15 | 16 | auth_url = oauth.authorization_url( 17 | app.config["OAUTH_CREDENTIALS"]["canvas"]["authorization_url"] 18 | ) 19 | 20 | def __init__(self): 21 | pass 22 | 23 | @classmethod 24 | def init_canvas(self, token): 25 | """ Launch a new Canvas object 26 | :type token: Str 27 | :param token: OAuth token 28 | 29 | :returns canvas: Canvas instance 30 | :rtype: Object 31 | """ 32 | expire = token["expires_at"] 33 | 34 | if time.time() > expire: 35 | # get a new access token and store it 36 | client_id = app.config["OAUTH_CREDENTIALS"]["canvas"]["id"] 37 | refresh_url = app.config["OAUTH_CREDENTIALS"]["canvas"]["token_url"] 38 | 39 | extra = { 40 | "client_id": client_id, 41 | "client_secret": app.config["OAUTH_CREDENTIALS"]["canvas"]["secret"], 42 | "refresh_token": token["refresh_token"], 43 | } 44 | 45 | oauth_refresh = OAuth2Session(client_id, token=token) 46 | session["oauth_token"] = oauth_refresh.refresh_token(refresh_url, **extra) 47 | 48 | canvas = Canvas( 49 | "https://elkhart.instructure.com", session["oauth_token"]["access_token"] 50 | ) 51 | return canvas 52 | 53 | @classmethod 54 | def login(self): 55 | """Log the user in.""" 56 | return self.auth_url 57 | 58 | @classmethod 59 | def get_token(self): 60 | """ Retrieve an access token from Canvas. """ 61 | token = self.oauth.fetch_token( 62 | app.config["OAUTH_CREDENTIALS"]["canvas"]["token_url"], 63 | client_secret=app.config["OAUTH_CREDENTIALS"]["canvas"]["secret"], 64 | authorization_response=request.url, 65 | state=session["oauth_state"], 66 | replace_tokens=True, 67 | ) 68 | 69 | return token 70 | -------------------------------------------------------------------------------- /app/courses.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from app.models import Assignment 4 | 5 | 6 | class Course(object): 7 | def __init__(self, course): 8 | self.course = course 9 | 10 | def process_course(course): 11 | """ Find aligned Outcomes for a requested course 12 | Take in a Canvas Course object and pare it down to a simple dictionary. This 13 | is used to load only the necessary data on the user dashboard. 14 | 15 | :param course: canvasapi Course 16 | :ptype: object 17 | 18 | :returns: processed dict 19 | """ 20 | 21 | query = Assignment.query.filter(Assignment.course_id == course.id).filter( 22 | Assignment.outcome_id.isnot(None) 23 | ) 24 | 25 | processed = {} 26 | processed["id"] = course.id 27 | processed["name"] = course.name 28 | processed["outcomes"] = query.count() 29 | 30 | if course.start_at is not None: 31 | processed["term"] = datetime.strptime( 32 | course.start_at, "%Y-%m-%dT%H:%M:%SZ" 33 | ).year 34 | else: 35 | processed["term"] = datetime.strptime( 36 | course.created_at, "%Y-%m-%dT%H:%M:%SZ" 37 | ).year 38 | 39 | return processed 40 | -------------------------------------------------------------------------------- /app/errors.py: -------------------------------------------------------------------------------- 1 | from flask import render_template 2 | from app import app, db 3 | 4 | 5 | class FailedJob(Exception): 6 | pass 7 | 8 | @app.errorhandler(404) 9 | def not_found(error): 10 | return render_template('404.html'), 404 11 | 12 | @app.errorhandler(500) 13 | def internal_error(error): 14 | db.session.rollback() 15 | return render_template('500.html'), 500 16 | -------------------------------------------------------------------------------- /app/forms.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from wtforms import StringField, PasswordField, SelectField, BooleanField, SubmitField, HiddenField 3 | from wtforms.validators import DataRequired 4 | from app.models import Outcome 5 | 6 | 7 | class LoginForm(FlaskForm): 8 | username = StringField('Username', validators=[DataRequired()]) 9 | password = PasswordField('Password', validators=[DataRequired()]) 10 | remember_me = BooleanField('Remember me') 11 | submit = SubmitField('Sign In') 12 | 13 | 14 | class StoreOutcomesForm(FlaskForm): 15 | id = HiddenField('course_id') 16 | assignment_groups = SelectField('Assignment Group', coerce=int, choices=[]) 17 | submit = SubmitField('Import Assignments') 18 | 19 | 20 | class SelectSectionForm(FlaskForm): 21 | id = HiddenField('course_id') 22 | sections = SelectField('Course Section', coerce=int, choices=[]) 23 | -------------------------------------------------------------------------------- /app/models.py: -------------------------------------------------------------------------------- 1 | from app import db, login 2 | from flask_login import UserMixin 3 | 4 | 5 | class User(UserMixin, db.Model): 6 | """ User Object 7 | Store the User object for authentication and matching courses 8 | id : unique int 9 | canvas_id : Canvas LMS user ID 10 | name : user name 11 | token : Canvas OAuth access token 12 | expiration : Timestamp expiration of the token 13 | refresh_token : sent if the expiration is past 14 | """ 15 | 16 | __tablename__ = "Users" 17 | id = db.Column(db.Integer, primary_key=True) 18 | canvas_id = db.Column(db.String(64), nullable=False, unique=True) 19 | name = db.Column(db.String(64), nullable=False) 20 | token = db.Column(db.String(255), nullable=False) 21 | expiration = db.Column(db.Integer) 22 | refresh_token = db.Column(db.String(255)) 23 | 24 | def __repr__(self): 25 | return "User: {} | {} ".format(self.name, self.canvas_id) 26 | 27 | 28 | @login.user_loader 29 | def load_user(id): 30 | return User.query.get(id) 31 | 32 | 33 | class Outcome(db.Model): 34 | """ Outcome object 35 | id : unique int 36 | title : string name 37 | outcome_id: matches Canvas outcome ID 38 | course_id: course aligned with outcome 39 | assignment_id: relationship to a single assignment 40 | """ 41 | 42 | id = db.Column(db.Integer, primary_key=True) 43 | outcome_id = db.Column(db.Integer) 44 | title = db.Column(db.String(64)) 45 | course_id = db.Column(db.Integer) 46 | assignment_id = db.relationship( 47 | "Assignment", uselist=False, back_populates="outcome" 48 | ) 49 | 50 | def align(self, assignment): 51 | if self.is_aligned(): 52 | 53 | if assignment is not None: 54 | self.assignment.remove(self.assignment[0]) 55 | self.assignment.append(assignment) 56 | db.session.commit() 57 | return "Updated alignment to {}".format(assignment.title) 58 | else: 59 | self.assignment.remove(self.assignment[0]) 60 | db.session.commit() 61 | else: 62 | try: 63 | self.assignment.append(assignment) 64 | db.session.commit() 65 | return "Aligned {}".format(assignment.title) 66 | except Exception as e: 67 | return e 68 | 69 | # Check that it isn't aleady aligned to the Assignment 70 | def is_aligned(self): 71 | return self.assignment_id is not None 72 | 73 | def __repr__(self): 74 | return "< {} || {} || Assignment: {} >".format( 75 | self.title, self.id, self.assignment_id 76 | ) 77 | 78 | 79 | class Assignment(db.Model): 80 | """ Assignment object model 81 | id : Canvas Assignment ID 82 | title : Canvas Assignment title 83 | course_id : Parent course for the assignment 84 | outcome_id : Linked outcome for updating scores 85 | """ 86 | 87 | id = db.Column(db.Integer, primary_key=True) 88 | title = db.Column(db.String(128)) 89 | course_id = db.Column(db.Integer) 90 | outcome_id = db.Column(db.Integer, db.ForeignKey("outcome.id"), nullable=True) 91 | 92 | outcome = db.relationship( 93 | "Outcome", backref="assignment", passive_updates=False, uselist=False 94 | ) 95 | 96 | def __repr__(self): 97 | return "< {} || {} || {} || {}>".format( 98 | self.id, self.title, self.course_id, self.outcome_id 99 | ) 100 | -------------------------------------------------------------------------------- /app/outcomes.py: -------------------------------------------------------------------------------- 1 | import concurrent.futures 2 | from functools import partial 3 | 4 | from app import db 5 | from app.models import Outcome, Assignment 6 | from app.errors import FailedJob 7 | 8 | 9 | class Outcomes: 10 | """ Methods for working with Canvas outcomes """ 11 | 12 | def __init__(self): 13 | pass 14 | 15 | @classmethod 16 | def align_assignment_to_outcome(self, course_id, outcome_id, assignment_id): 17 | """ 18 | Aligns an assignment ID to an outcome ID 19 | :param course_id: Canvas course ID 20 | :ptype: int 21 | 22 | :param outcome_id: Canvas Outcome ID 23 | :ptype: int 24 | 25 | :param assignment_id: Canvas Assignment ID 26 | :ptype: int 27 | 28 | :raises Exception: exception object 29 | :rtype: None 30 | """ 31 | assignment_id = None if assignment_id == 'None' else assignment_id 32 | 33 | outcome = Outcome.query.filter_by( 34 | outcome_id=outcome_id, course_id=course_id 35 | ).first() 36 | 37 | try: 38 | if assignment_id is not None: 39 | 40 | assignment = Assignment.query.filter_by( 41 | id=assignment_id, course_id=course_id 42 | ).first() 43 | 44 | if all(v is not None for v in [outcome, assignment]): 45 | outcome.align(assignment) 46 | db.session.commit() 47 | else: 48 | raise AttributeError( 49 | f"{assignment_id} is not a valid assignment ID for this course." 50 | ) 51 | else: 52 | outcome.align(None) 53 | db.session.commit() 54 | 55 | except Exception as e: 56 | return e 57 | 58 | @staticmethod 59 | def save_outcome_data(canvas, course_id): 60 | """ Get Outcomes from Canvas for the course and store them in the database 61 | 62 | :param canvas: Canvas object 63 | :type canvas: Object 64 | 65 | :param course_id: Canvas course ID 66 | :type course_id: int 67 | 68 | :param assignment_group_id: Assignment group to update 69 | :type assignment_group_id: Int 70 | 71 | :returns data: List of all assignments stored from the assignment group 72 | :rtype: List data 73 | """ 74 | course = canvas.get_course(course_id) 75 | outcome_groups = course.get_outcome_groups_in_context() 76 | 77 | outcome_commits = [] 78 | for group in outcome_groups: 79 | 80 | outcomes = group.get_linked_outcomes() 81 | 82 | for o in outcomes: 83 | outcome_data = o.outcome 84 | query = Outcome.query.filter_by( 85 | outcome_id=outcome_data["id"], course_id=course_id 86 | ) 87 | 88 | if query.first() is None: 89 | outcome = Outcome( 90 | outcome_id=outcome_data["id"], 91 | title=outcome_data["title"], 92 | course_id=course_id, 93 | ) 94 | 95 | outcome_commits.append(outcome) 96 | 97 | db.session.bulk_save_objects(outcome_commits) 98 | db.session.commit() 99 | 100 | @classmethod 101 | def process_submissions(self, student_id, course, outcome_ids): 102 | """ Process student Outcome and Assignment scores 103 | :type student_id: Int 104 | :param student_id: Canvas ID of current student 105 | 106 | :type course: {Object} 107 | :param course: Instantiated Canvas Course object 108 | 109 | :raises: 110 | 111 | :rtype: {Object} obj 112 | """ 113 | 114 | obj = {} 115 | obj["outcomes"] = [] 116 | obj["student_id"] = student_id 117 | 118 | # Request all outcome rollups from Canvas for each student 119 | request = course.get_outcome_result_rollups( 120 | user_ids=student_id, 121 | outcome_ids=outcome_ids, 122 | ) 123 | 124 | # Limit to scores only 125 | scores = request["rollups"][0]["scores"] 126 | 127 | for outcome in scores: 128 | outcome_id = int(outcome["links"]["outcome"]) 129 | 130 | # Find the matched assignment in the database 131 | query = Outcome.query.filter_by( 132 | outcome_id=outcome_id, course_id=course.id 133 | ).first() 134 | 135 | if query is not None: 136 | assignment_id = query.assignment[0].id 137 | 138 | assignment = course.get_assignment(assignment_id) 139 | submission = assignment.get_submission(student_id) 140 | 141 | item = { 142 | "outcome_id": outcome_id, 143 | "outcome_score": outcome['score'], 144 | "assignment_id": assignment_id, 145 | } 146 | 147 | # Set a None score to 0 148 | if submission.score is None: 149 | submission.score = 0 150 | 151 | # Check the conditions and update the Canvas gradebook 152 | if outcome["score"] >= 2.80 and submission.score == 0: 153 | item["assignment_score"] = 1 154 | submission.edit(submission={"posted_grade": 1}) 155 | elif outcome["score"] < 2.80 and submission.score >= 1: 156 | item["assignment_score"] = 0 157 | submission.edit(submission={"posted_grade": 0}) 158 | elif outcome["score"] < 2.80 and submission.score == 0: 159 | item["assignment_score"] = 0 160 | submission.edit(submission={"posted_grade": 0}) 161 | else: 162 | item["assignment_score"] = submission.score 163 | 164 | obj["outcomes"].append(item) 165 | else: 166 | pass 167 | 168 | db.session.close() 169 | return obj 170 | 171 | @classmethod 172 | def request_score_update(self, student_id, course, outcome_ids): 173 | """ Post grade updates to Canvas for a student 174 | :param student_id: Valid Canvas student ID 175 | :ptype: int 176 | 177 | :param course: canvasapi object 178 | :ptype: 179 | 180 | :param outcome_ids: Outcome IDs to request from Canvas for processing 181 | :ptype: list of int 182 | 183 | :raises: Exception 184 | 185 | :returns update: outcome ID, assignment ID, and updated score 186 | :rtype: dict 187 | """ 188 | try: 189 | update = self.process_submissions(student_id, course, outcome_ids) 190 | return update 191 | 192 | except Exception as ex: 193 | raise FailedJob(ex) 194 | 195 | @classmethod 196 | def update_student_scores(self, canvas, course_id, student_ids, outcome_ids): 197 | """ Worker to process assignment scores for students 198 | :param cls: Outcomes class 199 | :ptype cls: instance 200 | 201 | :param canvas: Canvas object 202 | :ptype canvas: instance 203 | 204 | :param course_id: Current course ID 205 | :ptype course_id: Int 206 | 207 | :param student_ids: Student IDs to iterate 208 | :ptype student_ids: list of int 209 | 210 | :raises: 211 | 212 | :returns data: List of assignment update objects for a student 213 | :rtype: list of dicts 214 | """ 215 | # start = time.perf_counter() 216 | course = canvas.get_course(course_id) 217 | 218 | job = partial(self.request_score_update, course=course, outcome_ids=outcome_ids) 219 | 220 | data = [] 221 | with concurrent.futures.ThreadPoolExecutor() as executor: 222 | results = executor.map(job, student_ids) 223 | 224 | for result in results: 225 | data.append(result) 226 | 227 | return data 228 | 229 | @classmethod 230 | def get_student_rollups(self, course_id, student_id): 231 | """ Request student outcome rollups from Canvas 232 | 233 | :param course_id: Valid Canvas course ID 234 | :type course_id: int 235 | 236 | :param student_id: Valid Canvas ID for a student 237 | :type student_id: int 238 | 239 | :return: Outcome result rollups from Canvas 240 | :rtype: list of dict 241 | """ 242 | course = self.canvas.get_course(course_id) 243 | 244 | data = course.get_outcome_result_rollups(user_ids=student_id) 245 | return data 246 | -------------------------------------------------------------------------------- /app/routes.py: -------------------------------------------------------------------------------- 1 | import time 2 | from flask import json, jsonify, redirect, render_template, request, session, url_for 3 | from flask_login import login_required 4 | from requests_oauthlib import OAuth2Session 5 | from app.models import Outcome, Assignment, User 6 | from app.forms import StoreOutcomesForm 7 | from app.assignments import Assignments 8 | from app.outcomes import Outcomes 9 | from app.courses import Course 10 | from app.auth import Auth 11 | from flask_login import current_user, login_user, logout_user 12 | from canvasapi import Canvas, exceptions 13 | from app import app, db 14 | 15 | 16 | @app.route("/", methods=["GET", "POST"]) 17 | @app.route("/index", methods=["GET", "POST"]) 18 | def index(): 19 | """ App entrance. 20 | If user is logged in, load the dashboard. Otherwise, load the login screen 21 | """ 22 | 23 | if not current_user.is_anonymous and session["_fresh"]: 24 | expire = session["oauth_token"]["expires_at"] 25 | 26 | if time.time() > expire: 27 | # get a new access token and store it 28 | token = session["oauth_token"] 29 | client_id = app.config["OAUTH_CREDENTIALS"]["canvas"]["id"] 30 | refresh_url = app.config["OAUTH_CREDENTIALS"]["canvas"]["token_url"] 31 | 32 | extra = { 33 | "client_id": client_id, 34 | "client_secret": app.config["OAUTH_CREDENTIALS"]["canvas"]["secret"], 35 | "refresh_token": token["refresh_token"], 36 | } 37 | 38 | oauth_refresh = OAuth2Session(client_id, token=token) 39 | session["oauth_token"] = oauth_refresh.refresh_token(refresh_url, **extra) 40 | return redirect(url_for("dashboard")) 41 | else: 42 | session.clear() 43 | logout_user() 44 | return render_template("login.html", title="Canvas Mastery Doctor") 45 | 46 | 47 | @app.route("/login", methods=["GET", "POST"]) 48 | def login(): 49 | """ Log in to the app via OAuth through Canvas 50 | :methods: GET 51 | :responses: 52 | 200: 53 | description: Route to callback for final authentication 54 | 400: 55 | description: Bad request. 56 | """ 57 | authorization_url, state = Auth.login() 58 | session["oauth_state"] = state 59 | 60 | return redirect(authorization_url) 61 | 62 | 63 | @app.route("/logout", methods=["GET"]) 64 | def logout(): 65 | """ Log the current user out.""" 66 | session.clear() 67 | logout_user() 68 | 69 | return redirect(url_for("index")) 70 | 71 | 72 | @app.route("/callback", methods=["GET"]) 73 | def callback(): 74 | """ Perform final authorization of the user 75 | :methods: GET 76 | :responses: 77 | 200: 78 | description: Successful authentication 79 | 400: 80 | description: Bad request 81 | """ 82 | 83 | token = Auth.get_token() 84 | 85 | session["oauth_token"] = token 86 | 87 | user_id = str(session["oauth_token"]["user"]["id"]) 88 | user_name = session["oauth_token"]["user"]["name"] 89 | 90 | # Query the DB for an existing user 91 | user = User.query.filter_by(canvas_id=user_id).first() 92 | 93 | if user: 94 | # Update the user token 95 | if user.token != session["oauth_token"]["access_token"]: 96 | user.token = session["oauth_token"]["access_token"] 97 | db.session.commit() 98 | else: 99 | # User doesn't exist, create a new one 100 | user = User( 101 | canvas_id=user_id, 102 | name=user_name, 103 | token=session["oauth_token"]["access_token"], 104 | expiration=session["oauth_token"]["expires_at"], 105 | refresh_token=session["oauth_token"]["refresh_token"], 106 | ) 107 | db.session.add(user) 108 | db.session.commit() 109 | 110 | login_user(user, True) 111 | 112 | return redirect(url_for("dashboard")) 113 | 114 | 115 | @app.route("/dashboard", methods=["GET"]) 116 | @login_required 117 | def dashboard(): 118 | """ Display the logged-in user's courses. """ 119 | canvas = Auth.init_canvas(session["oauth_token"]) 120 | user = canvas.get_current_user() 121 | 122 | all_courses = user.get_courses( 123 | state=["available"], 124 | enrollment_state=["active"], 125 | enrollment_type="teacher", 126 | include="total_students", 127 | ) 128 | 129 | courses = [] 130 | for c in all_courses: 131 | courses.append(Course.process_course(c)) 132 | 133 | courses = sorted(courses, key=lambda course: course["term"], reverse=True) 134 | 135 | return render_template("dashboard.html", title="Dashboard", courses=courses), 200 136 | 137 | 138 | @app.route("/course/", methods=["GET"]) 139 | @login_required 140 | def course(course_id): 141 | """ Single course view 142 | :param course_id: Canvas course ID 143 | :type course_id: Int 144 | 145 | :methods: GET 146 | 147 | :rtype: 148 | """ 149 | # Instantiate a new Canvas object 150 | canvas = Auth.init_canvas(session["oauth_token"]) 151 | 152 | # Get the assignment groups from Canvas 153 | course = canvas.get_course(course_id) 154 | sections = course.get_sections() 155 | 156 | query = course.get_assignment_groups() 157 | assignment_groups = [(str(a.id), a.name) for a in query] 158 | 159 | # Populate assignment_group_ids into the Outcomes fetch form dynamically 160 | form = StoreOutcomesForm(request.values, id=course_id) 161 | form.assignment_groups.choices = assignment_groups 162 | 163 | # Look only in the current course 164 | assignments = Assignment.query.filter_by(course_id=course_id) 165 | 166 | if not assignments: 167 | assignments = [] 168 | 169 | # Look up any existing Outcomes by course ID 170 | outcomes = Outcome.query.filter(Outcome.course_id == course_id) 171 | 172 | if not outcomes: 173 | outcomes = None 174 | 175 | return render_template( 176 | "course.html", 177 | title="Canvas course", 178 | outcomes=outcomes, 179 | assignments=assignments, 180 | sections=sections, 181 | form=form, 182 | ) 183 | 184 | 185 | @app.route("/section", methods=["POST"]) 186 | def section(): 187 | """ Show single section. """ 188 | data = request.json 189 | 190 | canvas = Auth.init_canvas(session["oauth_token"]) 191 | 192 | # Look only in the current course 193 | assignments = Assignment.query.filter_by(course_id=data["course_id"]).all() 194 | 195 | if not assignments: 196 | assignments = [] 197 | scores = [] 198 | else: 199 | try: 200 | scores = Assignments.get_all_assignment_scores( 201 | canvas, data["course_id"], section_id=data["section_id"] 202 | ) 203 | except Exception as e: 204 | return ( 205 | jsonify(message=f"{e}"), 206 | 500, 207 | ) 208 | 209 | # Sort the scores array by student last name before returning 210 | if scores is not None: 211 | scores = sorted(scores, key=lambda x: x["user_name"].split(" ")) 212 | else: 213 | return ( 214 | jsonify(message="Please import assignments in the 'Alignments' tab."), 215 | 500, 216 | ) 217 | 218 | return jsonify(scores) 219 | 220 | 221 | @app.route("/course//assignments", methods=["GET"]) 222 | def get_course_assignments(course_id): 223 | 224 | canvas = Auth.init_canvas(session["oauth_token"]) 225 | 226 | data = Assignments.get_course_assignments(canvas, course_id) 227 | 228 | return jsonify({"success": data}) 229 | 230 | 231 | @app.route("/course//assignments//rubric", methods=["GET"]) 232 | def get_assignment_rubric(course_id, assignment_id): 233 | canvas = Auth.init_canvas(session["oauth_token"]) 234 | 235 | data = Assignments.build_assignment_rubric_results(canvas, course_id, assignment_id) 236 | 237 | return jsonify({"success": data}) 238 | 239 | 240 | @app.route("/save", methods=["POST"]) 241 | def save_outcomes(): 242 | """ Save Outcomes from the course into the database 243 | :methods: POST 244 | 245 | :rtype: 246 | """ 247 | # Get the data from the form submission 248 | data = request.values 249 | 250 | # Instantiate a new Canvas object 251 | canvas = Auth.init_canvas(session["oauth_token"]) 252 | 253 | # Store the course Outcomes 254 | Outcomes.save_outcome_data(canvas, data["id"]) 255 | Assignments.save_assignment_data(canvas, data["id"], data["assignment_groups"]) 256 | 257 | # Reload the page 258 | return redirect(url_for("course", course_id=data["id"])) 259 | 260 | 261 | @app.route("/align", methods=["POST"]) 262 | def align_assignment_to_outcome(): 263 | """ Align an Assignment to an Outcome 264 | :methods: POST 265 | """ 266 | data = request.json 267 | 268 | try: 269 | Outcomes.align_assignment_to_outcome( 270 | data["course_id"], data["outcome_id"], data["assignment_id"] 271 | ) 272 | return jsonify({"success": [data["outcome_id"], data["assignment_id"]]}) 273 | except Exception as e: 274 | print(f'Received an error {e}') 275 | return e, 400 276 | 277 | 278 | @app.route("/sync", methods=["POST"]) 279 | def sync_outcome_scores(): 280 | """ Get the Outcomes for the students. """ 281 | # Instantiate a Canvas object 282 | canvas = Auth.init_canvas(session["oauth_token"]) 283 | 284 | json = request.json 285 | 286 | # Send the outcome_list in prep for querying a smaller set 287 | data = Outcomes.update_student_scores( 288 | canvas, json["course_id"], json["student_id_list"], json["outcome_id_list"] 289 | ) 290 | 291 | return jsonify({"success": data}) 292 | 293 | 294 | @app.route("/student", methods=["GET"]) 295 | def student(): 296 | canvas = Canvas( 297 | app.config["OAUTH_CREDENTIALS"]["canvas"]["base_url"], 298 | app.config["API"]["canvas"]["token"], 299 | ) 300 | 301 | data = request.args 302 | 303 | try: 304 | rollups = Outcomes.get_student_rollups( 305 | canvas, data.get("course_id"), data.get("student_id") 306 | ) 307 | return jsonify(rollups) 308 | except exceptions.BadRequest as e: 309 | app.logger.debug(e) 310 | return json.dumps({"success": False}), 400, {"Content-Type": "application/json"} 311 | # raise BadRequest('Outcomes cannot be requested for teachers', 400, payload=e) 312 | 313 | 314 | @app.route("/rubric//", methods=["GET"]) 315 | def rubric(section_id, assignment_id): 316 | return {"msg": f"Received {section_id} and {assignment_id}"} 317 | 318 | 319 | @app.errorhandler(500) 320 | def internal_error(exception): 321 | app.logger.error(exception) 322 | return render_template("500.html"), 500 323 | -------------------------------------------------------------------------------- /app/static/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bennettscience/canvas-learning-mastery/13a3f3f57e6fb5831e65333d5795f875d3722478/app/static/.DS_Store -------------------------------------------------------------------------------- /app/static/img/alignments.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bennettscience/canvas-learning-mastery/13a3f3f57e6fb5831e65333d5795f875d3722478/app/static/img/alignments.png -------------------------------------------------------------------------------- /app/static/img/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bennettscience/canvas-learning-mastery/13a3f3f57e6fb5831e65333d5795f875d3722478/app/static/img/dashboard.png -------------------------------------------------------------------------------- /app/static/img/scores.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bennettscience/canvas-learning-mastery/13a3f3f57e6fb5831e65333d5795f875d3722478/app/static/img/scores.png -------------------------------------------------------------------------------- /app/static/js/index.js: -------------------------------------------------------------------------------- 1 | let sectionLoaded = false; 2 | 3 | const getCourseId = function() { 4 | var re = /(course)\/(\d+)/gi; 5 | var url = window.location.href; 6 | var courseId = re.exec(url)[2]; 7 | return courseId; 8 | } 9 | 10 | const getSectionAssignments = function(sectionId) { 11 | 12 | const courseId = getCourseId(); 13 | const assignmentSelect = document.querySelector('#rubric-assignment-select'); 14 | 15 | $.ajax({ 16 | type: 'GET', 17 | url: `../course/${courseId}/assignments`, 18 | success: function(result) { 19 | 20 | assignmentSelect.firstElementChild.innerText = "Select Assignment" 21 | 22 | for(var r=0; r thead > tr') 53 | header.appendChild(document.createElement('th')); 54 | header.firstElementChild.innerText = "Student"; 55 | header.appendChild(document.createElement('th')); 56 | header.lastElementChild.innerText = "Score"; 57 | 58 | // Add a header column in the table 59 | result.columns.forEach(function(el) { 60 | var th = document.createElement('th'); 61 | th.setAttribute('data-outcome', el['id']); 62 | th.setAttribute('class', 'th-outcome'); 63 | th.innerText = el['name']; 64 | header.appendChild(th); 65 | }) 66 | 67 | table.appendChild(document.createElement('tbody')) 68 | 69 | let container = document.querySelector('#student-rubric-table > tbody'); 70 | 71 | result.student_results.forEach(function(student) { 72 | // Set the variable of each row == student[canvas_id] 73 | // This makes processing the table easier 74 | let rubric = student.rubric; 75 | let tr = document.createElement('tr'); 76 | tr.setAttribute('id', student['id']); 77 | tr.setAttribute('class', 'trow'); 78 | var name = document.createElement('td'); 79 | var score = document.createElement('td'); 80 | name.innerText = `${student['name']}`; 81 | score.innerText = `${student['score']}` 82 | tr.appendChild(name); 83 | tr.appendChild(score); 84 | 85 | // Loop through the submissions array for each student 86 | result.columns.forEach((item) => { 87 | var td = document.createElement('td'); 88 | td.setAttribute('data-outcome', item['id']) 89 | if(student.rubric && rubric[item['id']]['points']) { 90 | td.innerText = `${rubric[item['id']]['points']}`; 91 | } else { 92 | td.innerText = ' - ' 93 | } 94 | 95 | // Append the cell to the row 96 | tr.appendChild(td); 97 | }) 98 | 99 | // Add that row to the table 100 | container.appendChild(tr); 101 | }) 102 | } 103 | }) 104 | 105 | 106 | 107 | 108 | } 109 | 110 | const changeSection = function(sectionId) { 111 | 112 | courseId = getCourseId(); 113 | 114 | // Set a reload action for the current ID 115 | document.querySelector("#sectionReload").setAttribute('data-section', sectionId); 116 | let table = document.querySelector('#student-table'); 117 | 118 | // Clear the inside of the table for a clean reload 119 | table.innerHTML = ""; 120 | 121 | $.ajax({ 122 | type: "POST", 123 | url: "/section", 124 | data: JSON.stringify({ 125 | course_id: courseId, 126 | section_id: sectionId, 127 | }), 128 | contentType: "application/json;charset=UTF-8", 129 | success: function(scores) { 130 | // console.log(scores) 131 | // When the data comes back in enable the reload button 132 | sectionLoaded = true; 133 | 134 | if(sectionLoaded) { 135 | $("#sectionReload").attr('disabled', false); 136 | } 137 | 138 | // If there are scores returned, rebuild the table 139 | if(scores) { 140 | // Start with the table header 141 | let thead = document.createElement('thead'); 142 | let row = document.createElement('tr'); 143 | thead.appendChild(row); 144 | table.appendChild(thead) 145 | 146 | // Grab the header to append column headings 147 | var header = document.querySelector('#student-table > thead > tr') 148 | header.appendChild(document.createElement('th')); 149 | header.firstElementChild.innerText = "Student"; 150 | 151 | // Loop through the first submission object to get the Assignment titles 152 | // This is hacky, but it works. 153 | scores[0]['submissions'].forEach((assignment) => { 154 | var th = document.createElement('th'); 155 | var outcomeId = Object.keys(assignment) 156 | th.setAttribute('data-outcome', outcomeId); 157 | th.setAttribute('class', 'th-outcome'); 158 | th.innerText = assignment[outcomeId]['assignment_name']; 159 | header.appendChild(th); 160 | }) 161 | } 162 | 163 | // Now, build the body of the table 164 | table.appendChild(document.createElement('tbody')) 165 | 166 | let container = document.querySelector("#student-table > tbody") 167 | let headers = document.querySelectorAll("#student-table > thead > tr > th"); 168 | 169 | if(scores) { 170 | scores.forEach((student) => { 171 | var tr = document.createElement('tr'); 172 | tr.setAttribute('id', student['canvas_id']); 173 | tr.setAttribute('class', 'trow'); 174 | var name = document.createElement('td'); 175 | name.innerText = `${student['user_name']}`; 176 | tr.appendChild(name); 177 | 178 | // Use the headers to index the submissions cells to 179 | // make sure the correct score is in the correct column. 180 | headers.forEach((header) => { 181 | if(header.dataset.outcome) { 182 | let submissions = student.submissions; 183 | let outcome = header.dataset.outcome; 184 | var td = document.createElement('td'); 185 | td.setAttribute('data-outcome', outcome); 186 | var item = submissions.find(item => Object.keys(item) == outcome) 187 | var score = (item[outcome]['assignment_score'] === null) ? `-` : item[outcome]['assignment_score']; 188 | td.innerText = score 189 | 190 | // Check the current outcome score against the current gradebook score and highlight appropriately 191 | if(item[outcome]['assignment_score'] === 1 && item[outcome]['current_outcome_score'] < 2.8) { 192 | td.classList.add('drop') 193 | } else if(item[outcome]['assignment_score'] === 0 && item[outcome]['current_outcome_score'] >= 2.8) { 194 | td.classList.add('rise') 195 | } 196 | 197 | tr.appendChild(td); 198 | } 199 | }) 200 | container.appendChild(tr); 201 | }); 202 | 203 | } else { 204 | let row = document.createElement('tr'); 205 | let msg = document.createElement('td'); 206 | 207 | msg.innerText = 'Please align an outcome in the Alignments tab'; 208 | row.appendChild(msg); 209 | container.appendChild(row); 210 | } 211 | }, error: function(request, status, message) { 212 | let container = document.querySelector(".msg"); 213 | // console.log(request.responseJSON.message); 214 | let row = document.createElement('tr'); 215 | let msg = document.createElement('td'); 216 | 217 | msg.innerText = request.responseJSON.message; 218 | row.appendChild(msg); 219 | container.appendChild(row); 220 | } 221 | }) 222 | } 223 | 224 | const changeHandler = function(e) { 225 | var elem = e; 226 | 227 | var courseId = getCourseId(); 228 | var assignmentId = e.target.value; 229 | var outcomeId = $(e.target) 230 | .closest("tr") 231 | .attr("id"); 232 | 233 | $.ajax({ 234 | type: "POST", 235 | url: "/align", 236 | data: JSON.stringify({ 237 | assignment_id: assignmentId, 238 | outcome_id: outcomeId, 239 | course_id: courseId, 240 | }), 241 | contentType: "application/json;charset=UTF-8", 242 | success: function(resp) { 243 | // console.log(resp.success) 244 | var id = resp.success[0]; 245 | 246 | $(`#${id} td:last`) 247 | .animate( 248 | { 249 | opacity: 1 250 | }, 251 | 100 252 | ) 253 | .animate( 254 | { 255 | opacity: 0 256 | }, 257 | 2000 258 | ); 259 | 260 | // setTimeout(location.reload(true), 2200); 261 | }, 262 | failure: function(resp) { 263 | var id = resp.failure[0]; 264 | document.querySelector(`#${id} td:last`) 265 | .animate( 266 | {opacity: 1}, 100 267 | ).animate( 268 | {opacity: 0}, 2000 269 | ).innerText = `U+1F5F4, ${resp[1]}` 270 | console.error(resp); 271 | } 272 | }); 273 | }; 274 | 275 | 276 | const processTable = function() { 277 | 278 | var courseId = getCourseId(); 279 | var arr = new Array(); 280 | var outcomeId = new Array(); 281 | 282 | // Collect specific outcome IDs 283 | // https://community.canvaslms.com/thread/36750 284 | $('th.th-outcome').each(function(i, el) { 285 | var head = $(el); 286 | outcomeId.push(head.data("outcome")); 287 | }) 288 | 289 | $("tr.trow").each(function(i, el) { 290 | var row = $(el); 291 | var studentId = row.attr("id"); 292 | arr.push(studentId); 293 | }); 294 | 295 | $.ajax({ 296 | type: "POST", 297 | url: "/sync", 298 | data: JSON.stringify({ 299 | student_id_list: arr, 300 | course_id: courseId, 301 | outcome_id_list: outcomeId 302 | }), 303 | contentType: "application/json", 304 | success: function(resp) { 305 | 306 | for (var i = 0; i < resp.success.length; i++) { 307 | var student = resp.success[i]; 308 | 309 | var studentId = student.student_id; 310 | var array = student.outcomes; 311 | 312 | $(`#${studentId}`) 313 | .children("td") 314 | .each(function(i, el) { 315 | // console.log(el) 316 | array.filter(function(item) { 317 | if ($(el).attr("data-outcome") == item.outcome_id) { 318 | $(el).text(item.assignment_score); 319 | $(el).removeClass() 320 | } 321 | }); 322 | }); 323 | } 324 | }, 325 | failure: function(resp) { 326 | console.log(resp); 327 | } 328 | }); 329 | }; 330 | 331 | // Set the event listeners 332 | document.querySelector("#alignment-table").addEventListener("change", changeHandler, false); 333 | document.querySelector("#section").addEventListener("change", function(e) { 334 | var sectionId = e.target.value; 335 | changeSection(sectionId); 336 | }); 337 | 338 | document.querySelector("#load-assignment-rubrics-btn").addEventListener("click", function(e) { 339 | const sectionId = e.target.value; 340 | getSectionAssignments(sectionId) 341 | }) 342 | 343 | document.querySelector("#rubric-assignment-select").addEventListener("change", function(e) { 344 | // const sectionId = document.querySelector("#rubric-section").value; 345 | const courseId = getCourseId() 346 | const assignmentId = e.target.value; 347 | 348 | getAssignmentRubrics(courseId, assignmentId); 349 | }) 350 | 351 | document.querySelector("#sectionReload").addEventListener('click', function(e) { 352 | var sectionId = this.dataset.section; 353 | changeSection(sectionId); 354 | }) 355 | 356 | // Toggle the loader animation 357 | $(document).ajaxStart(function() { 358 | // console.log("started ajax"); 359 | $(".loader-wrap").show(); 360 | }); 361 | 362 | $(document).ajaxStop(function() { 363 | // console.log("stopped ajax"); 364 | $(".loader-wrap").hide(); 365 | }); -------------------------------------------------------------------------------- /app/static/styles/main.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | margin: 0; 3 | padding: 0; 4 | height: 100%; 5 | width: 100%; 6 | } 7 | 8 | body { 9 | font-family: Helvetica, Arial, sans-serif; 10 | box-sizing: border-box; 11 | background-color: #f0f0f0; 12 | font-size: 14px; 13 | } 14 | 15 | nav { 16 | height: 60px; 17 | } 18 | 19 | nav a { 20 | display: inline-block; 21 | width: auto; 22 | height: 100%; 23 | text-align: center; 24 | padding: 22.5px 10px; 25 | text-decoration: none; 26 | } 27 | 28 | nav a:hover { 29 | background-color: #fff; 30 | } 31 | 32 | .wrap > div { 33 | width: 95vw; 34 | margin: 0 auto; 35 | background-color: #fefefe; 36 | border: 1px solid #e0e0e0; 37 | padding: 25px; 38 | } 39 | 40 | .login-block { 41 | height: 250px; 42 | text-align: center; 43 | padding-top: 57.5px; 44 | } 45 | 46 | .login-block a { 47 | text-align: center; 48 | display: block; 49 | padding: 10px 15px; 50 | border-radius: 2px; 51 | background-color: #283c5f; 52 | width: 35%; 53 | margin: 0 auto; 54 | color: #fefefe; 55 | text-transform: uppercase; 56 | text-decoration: none; 57 | } 58 | 59 | .login-block a:hover { 60 | cursor: pointer; 61 | background-color: #5885d3 62 | } 63 | 64 | .courses { 65 | background-color: #fefefe; 66 | border: 1px solid #e0e0e0; 67 | } 68 | 69 | .courses ul { 70 | list-style-type: none; 71 | } 72 | 73 | .courses ul li { 74 | padding: 3px; 75 | width: 80%; 76 | } 77 | 78 | .courses ul li:hover { 79 | background-color: #e0e0e0; 80 | cursor: pointer; 81 | } 82 | 83 | .courses ul li a { 84 | display: block; 85 | width: 100%; 86 | text-decoration: none; 87 | } 88 | 89 | .outcome.form-select { 90 | width: 100%; 91 | } 92 | 93 | #sectionReload { 94 | vertical-align: middle; 95 | height: auto; 96 | font-size: 1.5em; 97 | border: none; 98 | background-color: transparent; 99 | } 100 | 101 | #sectionReload:hover { 102 | transition:transform ease-in-out 0.5s; 103 | transform:rotateZ(360deg); 104 | } 105 | 106 | #section-select { width: auto; } 107 | 108 | #load-assignments-btn { display: block } 109 | 110 | select[multiple=multiple] { 111 | height: auto; 112 | border: 1px solid #cbcbcb; 113 | border-radius: 3px; 114 | box-shadow: 0 0 1px rgba(0, 0, 0, 0.1), inset 0 0 10px rgba(0, 0, 0, 0.07); 115 | -moz-transition: box-shadow 0.2s; 116 | -o-transition: box-shadow 0.2s; 117 | -webkit-transition: box-shadow 0.2s; 118 | transition: box-shadow 0.2s; 119 | } 120 | 121 | select[multiple=multiple]:hover { 122 | border-color: #cccccc; 123 | box-shadow: 0 0 15px rgba(0, 0, 0, 0.2); 124 | } 125 | 126 | select option[disabled] { 127 | color: #eeeeee; 128 | text-shadow: none; 129 | border: none; 130 | } 131 | 132 | select:-moz-focusring { 133 | color: transparent; 134 | text-shadow: 0 0 0 #888888; 135 | } 136 | 137 | select::-ms-expand { 138 | display: none; 139 | } 140 | 141 | .table-wrap { 142 | margin-top: 15px; 143 | margin-bottom: 30px; 144 | height: 550px; 145 | overflow: auto; 146 | background-color: #fff; 147 | } 148 | 149 | .scores { 150 | display: block; 151 | position: relative; 152 | padding: 25px 25px 65px 25px !important; 153 | } 154 | 155 | .loader-wrap { 156 | position: absolute; 157 | top: 0; 158 | left: 0; 159 | width: 100%; 160 | height: 100%; 161 | background-color: rgba(255,255,255,0.8); 162 | z-index: 10; 163 | display: none; 164 | } 165 | 166 | .loader-wrap .message { 167 | position: absolute; 168 | top: 50%; 169 | left: 50%; 170 | text-align: center; 171 | font-size: 24px; 172 | text-transform: uppercase; 173 | font-weight: 600; 174 | transform: translate3d(-50%, -50%, 0); 175 | z-index: 100; 176 | } 177 | 178 | #loader { 179 | height: 50%; 180 | width: 50%; 181 | display: block; 182 | margin: 0 auto; 183 | position: absolute; 184 | top: 50%; 185 | left: 25%; 186 | transform: translateY(-50%); 187 | } 188 | 189 | #update-scores::after { 190 | content: "✓"; 191 | color: #efefef; 192 | margin-left: 5px; 193 | font-size: 16px; 194 | } 195 | 196 | #update-scores:hover { 197 | box-shadow: 0 0 0 5px rgba(8, 228, 63, 0.6); 198 | } 199 | 200 | .form-group { 201 | display: inline-block; 202 | width: 35%; 203 | } 204 | 205 | input[type=submit]::after { 206 | content: "✓"; 207 | color: #efefef; 208 | margin-left: 5px; 209 | font-size: 16px; 210 | } 211 | 212 | /* input[type=submit]:hover { 213 | box-shadow: 0 0 0 5px rgba(8, 228, 63, 0.6); 214 | } */ 215 | 216 | *, 217 | *:before, 218 | *:after { 219 | box-sizing: inherit; 220 | } 221 | 222 | 223 | table { 224 | width: 100%; 225 | overflow: auto; 226 | position: relative; 227 | } 228 | 229 | #scores { 230 | position: relative; 231 | } 232 | 233 | th { 234 | text-align: left; 235 | border-bottom: 1px solid black; 236 | position: sticky; 237 | top: 0; 238 | background-color: #d0d0d0; 239 | } 240 | 241 | td, 242 | th { 243 | padding: 10px; 244 | } 245 | 246 | td > select { 247 | width: 100%; 248 | } 249 | 250 | .drop { 251 | background-color: rgba(255,0,0,0.25) 252 | } 253 | 254 | .rise { 255 | background-color: rgba(0, 255, 0, 0.25) 256 | } 257 | 258 | tr:hover { 259 | background-color: rgba(200, 200, 200, 0.25); 260 | cursor: pointer; 261 | } 262 | 263 | .confirm { 264 | opacity: 0; 265 | } 266 | 267 | .lower { 268 | background-color: rgba(255, 0, 0, 0.25); 269 | } 270 | 271 | .higher { 272 | background-color: rgba(0, 255, 0, 0.25); 273 | } 274 | 275 | /* [data-name] { 276 | position: relative; 277 | z-index: 2; 278 | cursor: pointer; 279 | font-weight: 600; 280 | } 281 | 282 | [data-name]:after { 283 | position: absolute; 284 | bottom: 100%; 285 | left: 50%; 286 | margin-left: -5px; 287 | width: 0; 288 | border-top: 5px solid #000; 289 | border-top: 5px solid hsla(0, 0%, 20%, 0.9); 290 | border-right: 5px solid transparent; 291 | border-left: 5px solid transparent; 292 | content: " "; 293 | font-size: 0; 294 | line-height: 0; 295 | } 296 | 297 | [data-name]:before, 298 | [data-name]:after { 299 | visibility: hidden; 300 | -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; 301 | filter: progid: DXImageTransform.Microsoft.Alpha(Opacity=0); 302 | opacity: 0; 303 | pointer-events: none; 304 | } 305 | 306 | [data-name]:hover:before, 307 | [data-name]:hover:after { 308 | visibility: visible; 309 | -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=100)"; 310 | filter: progid: DXImageTransform.Microsoft.Alpha(Opacity=100); 311 | opacity: 1; 312 | } 313 | 314 | [data-name]:before { 315 | position: absolute; 316 | bottom: 100%; 317 | left: 50%; 318 | margin-bottom: 5px; 319 | margin-left: -80px; 320 | padding: 7px; 321 | width: 160px; 322 | -webkit-border-radius: 3px; 323 | -moz-border-radius: 3px; 324 | border-radius: 3px; 325 | background-color: #000; 326 | background-color: hsla(0, 0%, 20%, 0.9); 327 | color: #fff; 328 | content: attr(data-name); 329 | text-align: center; 330 | font-size: 14px; 331 | line-height: 1.2; 332 | } */ 333 | -------------------------------------------------------------------------------- /app/templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} {% block app_content %} 2 |

Sorry

3 | {% endblock %} 4 | -------------------------------------------------------------------------------- /app/templates/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% if sentry_event_id %} 4 | 8 | {% endif %} -------------------------------------------------------------------------------- /app/templates/_formhelpers.html: -------------------------------------------------------------------------------- 1 | {% macro render_field(field) %} 2 |
{{ field(**kwargs)|safe }} 3 | {% if field.errors %} 4 |
    5 | {% for error in field.errors %} 6 |
  • {{ error }}
  • 7 | {% endfor %} 8 |
9 | {% endif %} 10 |
11 | {% endmacro %} -------------------------------------------------------------------------------- /app/templates/about.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %}{% block app_content %} 2 |
3 |

Learning Mastery Helper

4 |
5 |

The Canvas Learning Mastery gradebook is a great way to track student learning outcomes defined in the course. However, it does not affect the traditional gradebook.

6 |

This application allows you to connect Outcomes in a course to Assignments which will be automatically scored. This way, you can update grades all at once rather than one at a time.

7 |
8 |

FAQ

9 |

What data is stored?

10 |

This app stores some information about your Canvas account like your user ID and course ID's. It also stores Outcome and Assignment ID numbers to make the association.

11 |

No student information is saved to the database. Student names and scores are pulled in real time from Canvas. When you leave the site, that data is lost until your next login.

12 |

Do I need an account?

13 |

You need an account in the sense that you define your Canvas address at login. The app makes a request to Canvas on your behalf and you need to allow that connection to take place.

14 |

Who are you?

15 | I'm Brian.

16 |
17 | 18 | {% endblock content %} -------------------------------------------------------------------------------- /app/templates/assignments.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} {% block app_content %} 2 |
3 |
4 |
5 | {% for item in items %} 6 |

{{item.assignment_id}}

7 |

{{item.student_id}}

8 |

{{item.score}}

9 | {% endfor %} 10 |
11 | 12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /app/templates/base.html: -------------------------------------------------------------------------------- 1 | {% extends 'bootstrap/base.html' %} 2 | 3 | {% block html_attribs %} lang="en"{% endblock %} 4 | 5 | {% block title %} 6 | {% if title %}{{ title }}{% else %}Canvas LMG Doctor{% endif %} 7 | {% endblock %} 8 | 9 | {% block styles %} 10 | {{super()}} 11 | 13 | {% endblock %} 14 | 15 | {% block scripts %} 16 | {{super()}} 17 | 18 | {% endblock %} 19 | 20 | {% block navbar %} 21 |
42 | {% endblock %} 43 | 44 | {% block content %} 45 |
46 | {% block app_content %} {% endblock %} 47 |
48 | {% endblock %} 49 | 50 | -------------------------------------------------------------------------------- /app/templates/course.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% import 'bootstrap/wtf.html' as wtf %} 3 | {% from "_formhelpers.html" import render_field %} 4 | {% block app_content %} 5 | 19 | 20 |
21 |
22 |
23 |
24 |

Alignments

25 |
26 |

Which assignment group do you want to align with Outcomes?

27 | {{ form.id }} 28 | {{ wtf.form_field(form.assignment_groups, class="form-control") }} 29 | {{ wtf.form_field(form.submit, class="btn btn-primary") }} 30 |
31 |
32 |
33 |
34 | 35 | 36 | 37 | 38 | 39 | 40 | {% if outcomes is not none %} 41 | {% for outcome in outcomes %} 42 | 43 | 44 | 45 | 58 | 59 | {% endfor %} 60 | {% endif %} 61 | 62 |
outcome idtitlealigned to
{{outcome.outcome_id}}{{outcome.title}} 46 |
47 | 56 |
57 |
63 |
64 |
65 | 66 |
67 |

Scores

68 |
69 |
70 | Loading... 71 |
72 | 81 | 95 | 105 | 106 | 122 | 132 | 133 | 134 |
135 |
136 |
137 |
138 |
139 | 145 |
146 |
147 |
148 | 149 |
150 |
151 |
152 |
153 |
154 |
155 | 156 |

157 | Scores highlighted in red will drop, scores in green will rise when grades are updated 158 |

159 |
160 |
161 |
162 |
163 |
164 |
165 | 166 |
167 |

Rubric Detail

168 |

Select a single assignment to see the raw score and aligned Outcome scores.

169 |
170 |
171 | Loading... 172 |
173 | 182 | 196 | 206 | 207 | 223 | 233 | 234 | 235 |
236 |
237 |
238 |
239 | 240 |
241 |
242 |
243 | 246 |
247 |
248 |
249 |
250 |
251 |
252 |
253 |
254 |
255 |
256 |
257 | 258 |
259 | 260 | 261 | {% endblock %} 262 | -------------------------------------------------------------------------------- /app/templates/dashboard.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} {% block app_content %} 2 |

3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | {% for course in courses %} 12 | 13 | 14 | 15 | 16 | 17 | {% endfor %} 18 | 19 |
CourseYearEssential Standards
{{course.name}}{{course.term}}{{course.outcomes}}
20 |
21 | 22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /app/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} {% block app_content %} 2 |

Canvas Mastery Grades Doctor

3 | {% for message in get_flashed_messages() %} 4 |

{{ message }}

5 | {% endfor %} 6 |

I don't know you!

7 |

Login with Canvas

8 | {%endblock%} 9 | -------------------------------------------------------------------------------- /app/templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} {% block app_content %} 2 |
3 | 14 |
15 | {% endif %} 16 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/temp.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 15 | 16 | -------------------------------------------------------------------------------- /config-example.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | basedir = os.path.abspath(os.path.dirname(__file__)) 4 | 5 | class Config(object): 6 | SECRET_KEY = os.environ.get('SECRET_KEY') or 'some-string' 7 | SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://youruser:yourpw@localhost/yourdb' 8 | SQLALCHEMY_RECORD_QUERIES = False 9 | SQLALCHEMY_TRACK_MODIFICATIONS = False 10 | ADMINS = ['you@email.com'] 11 | DEBUG_TB_PROFILER_ENABLED = True 12 | OAUTH_CREDENTIALS = { 13 | 'canvas': { 14 | 'id': 'your_app_id', 15 | 'secret': 'your_app_secret', 16 | 'base_url': 'https://canvas.instructure.com/api/v1/', 17 | 'token_url': 'https://canvas.instructure.com/login/oauth2/token', 18 | 'authorization_url': 'https://canvas.instructure.com/login/oauth2/auth', 19 | 'redirect_url': 'http://localhost:5000/callback', 20 | } 21 | } 22 | DEBUG_TB_INTERCEPT_REDIRECTS = False 23 | -------------------------------------------------------------------------------- /htmlcov/app___init___py.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Coverage for app/__init__.py: 100% 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 | 17 | 31 |
32 | Hide keyboard shortcuts 33 |

Hot-keys on this page

34 |
35 |

36 | r 37 | m 38 | x 39 | p   toggle line displays 40 |

41 |

42 | j 43 | k   next/prev highlighted chunk 44 |

45 |

46 | 0   (zero) top of page 47 |

48 |

49 | 1   (one) first highlighted chunk 50 |

51 |
52 |
53 |
54 |

1import os 

55 |

2import sentry_sdk 

56 |

3from sentry_sdk.integrations.flask import FlaskIntegration 

57 |

4from flask import Flask, session 

58 |

5from flask_sqlalchemy import SQLAlchemy 

59 |

6from flask_migrate import Migrate 

60 |

7from config import Config 

61 |

8from flask_debugtoolbar import DebugToolbarExtension 

62 |

9from flask_login import LoginManager, UserMixin, login_user, logout_user, current_user 

63 |

10from flask_bootstrap import Bootstrap 

64 |

11from flask_cors import CORS 

65 |

12 

66 |

13os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1' 

67 |

14 

68 |

15 

69 |

16app = Flask(__name__) 

70 |

17app.config.from_object(Config) 

71 |

18app.config['CORS_HEADERS'] = 'Content-Type' 

72 |

19# DebugToolbarExtension(app) 

73 |

20db = SQLAlchemy(app) 

74 |

21login = LoginManager(app) 

75 |

22login.login_view = 'index' 

76 |

23migrate = Migrate(app, db) 

77 |

24bootstrap = Bootstrap(app) 

78 |

25CORS(app, resources={r"/student*": {"origins": "https://elkhart.instructure.com/*"}}) 

79 |

26 

80 |

27from app import app, routes, errors 

81 |

28 

82 |

29# Connect to Sentry 

83 |

30# sentry_sdk.init( 

84 |

31# dsn=app.config['SENTRY_DSN'], 

85 |

32# integrations=[FlaskIntegration()], 

86 |

33# send_default_pii=True, 

87 |

34# release="canvas-mastery@0.1.9" 

88 |

35# ) 

89 |
90 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /htmlcov/app_courses_py.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Coverage for app/courses.py: 93% 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 | 17 | 31 |
32 | Hide keyboard shortcuts 33 |

Hot-keys on this page

34 |
35 |

36 | r 37 | m 38 | x 39 | p   toggle line displays 40 |

41 |

42 | j 43 | k   next/prev highlighted chunk 44 |

45 |

46 | 0   (zero) top of page 47 |

48 |

49 | 1   (one) first highlighted chunk 50 |

51 |
52 |
53 |
54 |

1from datetime import datetime 

55 |

2 

56 |

3from app.models import Assignment 

57 |

4 

58 |

5 

59 |

6class Course(object): 

60 |

7 def __init__(self, course): 

61 |

8 self.course = course 

62 |

9 

63 |

10 def process_course(course): 

64 |

11 """ Find aligned Outcomes for a requested course 

65 |

12 Take in a Canvas Course object and pare it down to a simple dictionary. This 

66 |

13 is used to load only the necessary data on the user dashboard. 

67 |

14 

68 |

15 :param course: canvasapi Course 

69 |

16 :ptype: object 

70 |

17 

71 |

18 :returns: processed dict 

72 |

19 """ 

73 |

20 

74 |

21 query = Assignment.query.filter(Assignment.course_id == course.id).filter( 

75 |

22 Assignment.outcome_id.isnot(None) 

76 |

23 ) 

77 |

24 

78 |

25 processed = {} 

79 |

26 processed["id"] = course.id 

80 |

27 processed["name"] = course.name 

81 |

28 processed["outcomes"] = query.count() 

82 |

29 

83 |

30 if course.start_at is not None: 

84 |

31 processed["term"] = datetime.strptime( 

85 |

32 course.start_at, "%Y-%m-%dT%H:%M:%SZ" 

86 |

33 ).year 

87 |

34 else: 

88 |

35 processed["term"] = datetime.strptime( 

89 |

36 course.created_at, "%Y-%m-%dT%H:%M:%SZ" 

90 |

37 ).year 

91 |

38 

92 |

39 return processed 

93 |
94 | 102 | 103 | 104 | -------------------------------------------------------------------------------- /htmlcov/app_errors_py.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Coverage for app/errors.py: 67% 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 | 17 | 31 |
32 | Hide keyboard shortcuts 33 |

Hot-keys on this page

34 |
35 |

36 | r 37 | m 38 | x 39 | p   toggle line displays 40 |

41 |

42 | j 43 | k   next/prev highlighted chunk 44 |

45 |

46 | 0   (zero) top of page 47 |

48 |

49 | 1   (one) first highlighted chunk 50 |

51 |
52 |
53 |
54 |

1from flask import render_template 

55 |

2from app import app, db 

56 |

3 

57 |

4 

58 |

5class FailedJob(Exception): 

59 |

6 pass 

60 |

7 

61 |

8@app.errorhandler(404) 

62 |

9def not_found(error): 

63 |

10 return render_template('404.html'), 404 

64 |

11 

65 |

12@app.errorhandler(500) 

66 |

13def internal_error(error): 

67 |

14 db.session.rollback() 

68 |

15 return render_template('500.html'), 500 

69 |
70 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /htmlcov/app_forms_py.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Coverage for app/forms.py: 100% 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 | 17 | 31 |
32 | Hide keyboard shortcuts 33 |

Hot-keys on this page

34 |
35 |

36 | r 37 | m 38 | x 39 | p   toggle line displays 40 |

41 |

42 | j 43 | k   next/prev highlighted chunk 44 |

45 |

46 | 0   (zero) top of page 47 |

48 |

49 | 1   (one) first highlighted chunk 50 |

51 |
52 |
53 |
54 |

1from flask_wtf import FlaskForm 

55 |

2from wtforms import StringField, PasswordField, SelectField, BooleanField, SubmitField, HiddenField 

56 |

3from wtforms.validators import DataRequired 

57 |

4from app.models import Outcome 

58 |

5 

59 |

6 

60 |

7class LoginForm(FlaskForm): 

61 |

8 username = StringField('Username', validators=[DataRequired()]) 

62 |

9 password = PasswordField('Password', validators=[DataRequired()]) 

63 |

10 remember_me = BooleanField('Remember me') 

64 |

11 submit = SubmitField('Sign In') 

65 |

12 

66 |

13 

67 |

14class StoreOutcomesForm(FlaskForm): 

68 |

15 id = HiddenField('course_id') 

69 |

16 assignment_groups = SelectField('Assignment Group', coerce=int, choices=[]) 

70 |

17 submit = SubmitField('Import Assignments') 

71 |

18 

72 |

19 

73 |

20class SelectSectionForm(FlaskForm): 

74 |

21 id = HiddenField('course_id') 

75 |

22 sections = SelectField('Course Section', coerce=int, choices=[]) 

76 |
77 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /htmlcov/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Coverage report 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 | 17 | 28 |
29 | Hide keyboard shortcuts 30 |

Hot-keys on this page

31 |
32 |

33 | n 34 | s 35 | m 36 | x 37 | c   change column sorting 38 |

39 |
40 |
41 |
42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 |
Modulestatementsmissingexcludedcoverage
Total427205052%
app/__init__.py2200100%
app/assignments.py8829067%
app/auth.py2512052%
app/courses.py151093%
app/errors.py93067%
app/forms.py1600100%
app/models.py4411075%
app/outcomes.py7948039%
app/routes.py129101022%
127 |

128 | No items found using the specified filter. 129 |

130 |
131 | 139 | 140 | 141 | -------------------------------------------------------------------------------- /htmlcov/jquery.ba-throttle-debounce.min.js: -------------------------------------------------------------------------------- 1 | /* 2 | * jQuery throttle / debounce - v1.1 - 3/7/2010 3 | * http://benalman.com/projects/jquery-throttle-debounce-plugin/ 4 | * 5 | * Copyright (c) 2010 "Cowboy" Ben Alman 6 | * Dual licensed under the MIT and GPL licenses. 7 | * http://benalman.com/about/license/ 8 | */ 9 | (function(b,c){var $=b.jQuery||b.Cowboy||(b.Cowboy={}),a;$.throttle=a=function(e,f,j,i){var h,d=0;if(typeof f!=="boolean"){i=j;j=f;f=c}function g(){var o=this,m=+new Date()-d,n=arguments;function l(){d=+new Date();j.apply(o,n)}function k(){h=c}if(i&&!h){l()}h&&clearTimeout(h);if(i===c&&m>e){l()}else{if(f!==true){h=setTimeout(i?k:l,i===c?e-m:e)}}}if($.guid){g.guid=j.guid=j.guid||$.guid++}return g};$.debounce=function(d,e,f){return f===c?a(d,e,false):a(d,f,e!==false)}})(this); 10 | -------------------------------------------------------------------------------- /htmlcov/jquery.hotkeys.js: -------------------------------------------------------------------------------- 1 | /* 2 | * jQuery Hotkeys Plugin 3 | * Copyright 2010, John Resig 4 | * Dual licensed under the MIT or GPL Version 2 licenses. 5 | * 6 | * Based upon the plugin by Tzury Bar Yochay: 7 | * http://github.com/tzuryby/hotkeys 8 | * 9 | * Original idea by: 10 | * Binny V A, http://www.openjs.com/scripts/events/keyboard_shortcuts/ 11 | */ 12 | 13 | (function(jQuery){ 14 | 15 | jQuery.hotkeys = { 16 | version: "0.8", 17 | 18 | specialKeys: { 19 | 8: "backspace", 9: "tab", 13: "return", 16: "shift", 17: "ctrl", 18: "alt", 19: "pause", 20 | 20: "capslock", 27: "esc", 32: "space", 33: "pageup", 34: "pagedown", 35: "end", 36: "home", 21 | 37: "left", 38: "up", 39: "right", 40: "down", 45: "insert", 46: "del", 22 | 96: "0", 97: "1", 98: "2", 99: "3", 100: "4", 101: "5", 102: "6", 103: "7", 23 | 104: "8", 105: "9", 106: "*", 107: "+", 109: "-", 110: ".", 111 : "/", 24 | 112: "f1", 113: "f2", 114: "f3", 115: "f4", 116: "f5", 117: "f6", 118: "f7", 119: "f8", 25 | 120: "f9", 121: "f10", 122: "f11", 123: "f12", 144: "numlock", 145: "scroll", 191: "/", 224: "meta" 26 | }, 27 | 28 | shiftNums: { 29 | "`": "~", "1": "!", "2": "@", "3": "#", "4": "$", "5": "%", "6": "^", "7": "&", 30 | "8": "*", "9": "(", "0": ")", "-": "_", "=": "+", ";": ": ", "'": "\"", ",": "<", 31 | ".": ">", "/": "?", "\\": "|" 32 | } 33 | }; 34 | 35 | function keyHandler( handleObj ) { 36 | // Only care when a possible input has been specified 37 | if ( typeof handleObj.data !== "string" ) { 38 | return; 39 | } 40 | 41 | var origHandler = handleObj.handler, 42 | keys = handleObj.data.toLowerCase().split(" "); 43 | 44 | handleObj.handler = function( event ) { 45 | // Don't fire in text-accepting inputs that we didn't directly bind to 46 | if ( this !== event.target && (/textarea|select/i.test( event.target.nodeName ) || 47 | event.target.type === "text") ) { 48 | return; 49 | } 50 | 51 | // Keypress represents characters, not special keys 52 | var special = event.type !== "keypress" && jQuery.hotkeys.specialKeys[ event.which ], 53 | character = String.fromCharCode( event.which ).toLowerCase(), 54 | key, modif = "", possible = {}; 55 | 56 | // check combinations (alt|ctrl|shift+anything) 57 | if ( event.altKey && special !== "alt" ) { 58 | modif += "alt+"; 59 | } 60 | 61 | if ( event.ctrlKey && special !== "ctrl" ) { 62 | modif += "ctrl+"; 63 | } 64 | 65 | // TODO: Need to make sure this works consistently across platforms 66 | if ( event.metaKey && !event.ctrlKey && special !== "meta" ) { 67 | modif += "meta+"; 68 | } 69 | 70 | if ( event.shiftKey && special !== "shift" ) { 71 | modif += "shift+"; 72 | } 73 | 74 | if ( special ) { 75 | possible[ modif + special ] = true; 76 | 77 | } else { 78 | possible[ modif + character ] = true; 79 | possible[ modif + jQuery.hotkeys.shiftNums[ character ] ] = true; 80 | 81 | // "$" can be triggered as "Shift+4" or "Shift+$" or just "$" 82 | if ( modif === "shift+" ) { 83 | possible[ jQuery.hotkeys.shiftNums[ character ] ] = true; 84 | } 85 | } 86 | 87 | for ( var i = 0, l = keys.length; i < l; i++ ) { 88 | if ( possible[ keys[i] ] ) { 89 | return origHandler.apply( this, arguments ); 90 | } 91 | } 92 | }; 93 | } 94 | 95 | jQuery.each([ "keydown", "keyup", "keypress" ], function() { 96 | jQuery.event.special[ this ] = { add: keyHandler }; 97 | }); 98 | 99 | })( jQuery ); 100 | -------------------------------------------------------------------------------- /htmlcov/jquery.isonscreen.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2010 2 | * @author Laurence Wheway 3 | * Dual licensed under the MIT (http://www.opensource.org/licenses/mit-license.php) 4 | * and GPL (http://www.opensource.org/licenses/gpl-license.php) licenses. 5 | * 6 | * @version 1.2.0 7 | */ 8 | (function($) { 9 | jQuery.extend({ 10 | isOnScreen: function(box, container) { 11 | //ensure numbers come in as intgers (not strings) and remove 'px' is it's there 12 | for(var i in box){box[i] = parseFloat(box[i])}; 13 | for(var i in container){container[i] = parseFloat(container[i])}; 14 | 15 | if(!container){ 16 | container = { 17 | left: $(window).scrollLeft(), 18 | top: $(window).scrollTop(), 19 | width: $(window).width(), 20 | height: $(window).height() 21 | } 22 | } 23 | 24 | if( box.left+box.width-container.left > 0 && 25 | box.left < container.width+container.left && 26 | box.top+box.height-container.top > 0 && 27 | box.top < container.height+container.top 28 | ) return true; 29 | return false; 30 | } 31 | }) 32 | 33 | 34 | jQuery.fn.isOnScreen = function (container) { 35 | for(var i in container){container[i] = parseFloat(container[i])}; 36 | 37 | if(!container){ 38 | container = { 39 | left: $(window).scrollLeft(), 40 | top: $(window).scrollTop(), 41 | width: $(window).width(), 42 | height: $(window).height() 43 | } 44 | } 45 | 46 | if( $(this).offset().left+$(this).width()-container.left > 0 && 47 | $(this).offset().left < container.width+container.left && 48 | $(this).offset().top+$(this).height()-container.top > 0 && 49 | $(this).offset().top < container.height+container.top 50 | ) return true; 51 | return false; 52 | } 53 | })(jQuery); 54 | -------------------------------------------------------------------------------- /htmlcov/jquery.tablesorter.min.js: -------------------------------------------------------------------------------- 1 | 2 | (function($){$.extend({tablesorter:new function(){var parsers=[],widgets=[];this.defaults={cssHeader:"header",cssAsc:"headerSortUp",cssDesc:"headerSortDown",sortInitialOrder:"asc",sortMultiSortKey:"shiftKey",sortForce:null,sortAppend:null,textExtraction:"simple",parsers:{},widgets:[],widgetZebra:{css:["even","odd"]},headers:{},widthFixed:false,cancelSelection:true,sortList:[],headerList:[],dateFormat:"us",decimal:'.',debug:false};function benchmark(s,d){log(s+","+(new Date().getTime()-d.getTime())+"ms");}this.benchmark=benchmark;function log(s){if(typeof console!="undefined"&&typeof console.debug!="undefined"){console.log(s);}else{alert(s);}}function buildParserCache(table,$headers){if(table.config.debug){var parsersDebug="";}var rows=table.tBodies[0].rows;if(table.tBodies[0].rows[0]){var list=[],cells=rows[0].cells,l=cells.length;for(var i=0;i1){arr=arr.concat(checkCellColSpan(table,headerArr,row++));}else{if(table.tHead.length==1||(cell.rowSpan>1||!r[row+1])){arr.push(cell);}}}return arr;};function checkHeaderMetadata(cell){if(($.metadata)&&($(cell).metadata().sorter===false)){return true;};return false;}function checkHeaderOptions(table,i){if((table.config.headers[i])&&(table.config.headers[i].sorter===false)){return true;};return false;}function applyWidget(table){var c=table.config.widgets;var l=c.length;for(var i=0;i');$("tr:first td",table.tBodies[0]).each(function(){colgroup.append($('').css('width',$(this).width()));});$(table).prepend(colgroup);};}function updateHeaderSortCount(table,sortList){var c=table.config,l=sortList.length;for(var i=0;ib)?1:0));};function sortTextDesc(a,b){return((ba)?1:0));};function sortNumeric(a,b){return a-b;};function sortNumericDesc(a,b){return b-a;};function getCachedSortType(parsers,i){return parsers[i].type;};this.construct=function(settings){return this.each(function(){if(!this.tHead||!this.tBodies)return;var $this,$document,$headers,cache,config,shiftDown=0,sortOrder;this.config={};config=$.extend(this.config,$.tablesorter.defaults,settings);$this=$(this);$headers=buildHeaders(this);this.config.parsers=buildParserCache(this,$headers);cache=buildCache(this);var sortCSS=[config.cssDesc,config.cssAsc];fixColumnWidth(this);$headers.click(function(e){$this.trigger("sortStart");var totalRows=($this[0].tBodies[0]&&$this[0].tBodies[0].rows.length)||0;if(!this.sortDisabled&&totalRows>0){var $cell=$(this);var i=this.column;this.order=this.count++%2;if(!e[config.sortMultiSortKey]){config.sortList=[];if(config.sortForce!=null){var a=config.sortForce;for(var j=0;j0){$this.trigger("sorton",[config.sortList]);}applyWidget(this);});};this.addParser=function(parser){var l=parsers.length,a=true;for(var i=0;i body { font-size: 16px; } 10 | 11 | p { font-size: .75em; line-height: 1.33333333em; } 12 | 13 | table { border-collapse: collapse; } 14 | 15 | td { vertical-align: top; } 16 | 17 | table tr.hidden { display: none !important; } 18 | 19 | p#no_rows { display: none; font-size: 1.2em; } 20 | 21 | a.nav { text-decoration: none; color: inherit; } 22 | a.nav:hover { text-decoration: underline; color: inherit; } 23 | 24 | #header { background: #f8f8f8; width: 100%; border-bottom: 1px solid #eee; } 25 | 26 | .indexfile #footer { margin: 1em 3em; } 27 | 28 | .pyfile #footer { margin: 1em 1em; } 29 | 30 | #footer .content { padding: 0; font-size: 85%; font-family: verdana, sans-serif; color: #666666; font-style: italic; } 31 | 32 | #index { margin: 1em 0 0 3em; } 33 | 34 | #header .content { padding: 1em 3rem; } 35 | 36 | h1 { font-size: 1.25em; display: inline-block; } 37 | 38 | #filter_container { display: inline-block; float: right; margin: 0 2em 0 0; } 39 | #filter_container input { width: 10em; } 40 | 41 | h2.stats { margin-top: .5em; font-size: 1em; } 42 | 43 | .stats span { border: 1px solid; border-radius: .1em; padding: .1em .5em; margin: 0 .1em; cursor: pointer; border-color: #ccc #999 #999 #ccc; } 44 | .stats span.run { background: #eeffee; } 45 | .stats span.run.show_run { border-color: #999 #ccc #ccc #999; background: #ddffdd; } 46 | .stats span.mis { background: #ffeeee; } 47 | .stats span.mis.show_mis { border-color: #999 #ccc #ccc #999; background: #ffdddd; } 48 | .stats span.exc { background: #f7f7f7; } 49 | .stats span.exc.show_exc { border-color: #999 #ccc #ccc #999; background: #eeeeee; } 50 | .stats span.par { background: #ffffd5; } 51 | .stats span.par.show_par { border-color: #999 #ccc #ccc #999; background: #ffffaa; } 52 | 53 | #source p .annotate.long, .help_panel { display: none; position: absolute; z-index: 999; background: #ffffcc; border: 1px solid #888; border-radius: .2em; box-shadow: #cccccc .2em .2em .2em; color: #333; padding: .25em .5em; } 54 | 55 | #source p .annotate.long { white-space: normal; float: right; top: 1.75em; right: 1em; height: auto; } 56 | 57 | #keyboard_icon { float: right; margin: 5px; cursor: pointer; } 58 | 59 | .help_panel { padding: .5em; border: 1px solid #883; } 60 | .help_panel .legend { font-style: italic; margin-bottom: 1em; } 61 | .indexfile .help_panel { width: 20em; height: 4em; } 62 | .pyfile .help_panel { width: 16em; height: 8em; } 63 | 64 | #panel_icon { float: right; cursor: pointer; } 65 | 66 | .keyhelp { margin: .75em; } 67 | .keyhelp .key { border: 1px solid black; border-color: #888 #333 #333 #888; padding: .1em .35em; font-family: monospace; font-weight: bold; background: #eee; } 68 | 69 | #source { padding: 1em 0 1em 3rem; font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace; } 70 | #source p { position: relative; white-space: pre; } 71 | #source p * { box-sizing: border-box; } 72 | #source p .n { float: left; text-align: right; width: 3rem; box-sizing: border-box; margin-left: -3rem; padding-right: 1em; color: #999999; font-family: verdana, sans-serif; } 73 | #source p .n a { text-decoration: none; color: #999999; font-size: .8333em; line-height: 1em; } 74 | #source p .n a:hover { text-decoration: underline; color: #999999; } 75 | #source p.highlight .n { background: #ffdd00; } 76 | #source p .t { display: inline-block; width: 100%; box-sizing: border-box; margin-left: -.5em; padding-left: 0.3em; border-left: 0.2em solid white; } 77 | #source p .t:hover { background: #f2f2f2; } 78 | #source p .t:hover ~ .r .annotate.long { display: block; } 79 | #source p .t .com { color: green; font-style: italic; line-height: 1px; } 80 | #source p .t .key { font-weight: bold; line-height: 1px; } 81 | #source p .t .str { color: #000080; } 82 | #source p.mis .t { border-left: 0.2em solid #ff0000; } 83 | #source p.mis.show_mis .t { background: #ffdddd; } 84 | #source p.mis.show_mis .t:hover { background: #f2d2d2; } 85 | #source p.run .t { border-left: 0.2em solid #00ff00; } 86 | #source p.run.show_run .t { background: #ddffdd; } 87 | #source p.run.show_run .t:hover { background: #d2f2d2; } 88 | #source p.exc .t { border-left: 0.2em solid #808080; } 89 | #source p.exc.show_exc .t { background: #eeeeee; } 90 | #source p.exc.show_exc .t:hover { background: #e2e2e2; } 91 | #source p.par .t { border-left: 0.2em solid #eeee99; } 92 | #source p.par.show_par .t { background: #ffffaa; } 93 | #source p.par.show_par .t:hover { background: #f2f2a2; } 94 | #source p .r { position: absolute; top: 0; right: 2.5em; font-family: verdana, sans-serif; } 95 | #source p .annotate { font-family: georgia; color: #666; padding-right: .5em; } 96 | #source p .annotate.short:hover ~ .long { display: block; } 97 | #source p .annotate.long { width: 30em; right: 2.5em; } 98 | #source p input { display: none; } 99 | #source p input ~ .r label.ctx { cursor: pointer; border-radius: .25em; } 100 | #source p input ~ .r label.ctx::before { content: "▶ "; } 101 | #source p input ~ .r label.ctx:hover { background: #d5f7ff; color: #666; } 102 | #source p input:checked ~ .r label.ctx { background: #aaeeff; color: #666; border-radius: .75em .75em 0 0; padding: 0 .5em; margin: -.25em 0; } 103 | #source p input:checked ~ .r label.ctx::before { content: "▼ "; } 104 | #source p input:checked ~ .ctxs { padding: .25em .5em; overflow-y: scroll; max-height: 10.5em; } 105 | #source p label.ctx { color: #999; display: inline-block; padding: 0 .5em; font-size: .8333em; } 106 | #source p .ctxs { display: block; max-height: 0; overflow-y: hidden; transition: all .2s; padding: 0 .5em; font-family: verdana, sans-serif; white-space: nowrap; background: #aaeeff; border-radius: .25em; margin-right: 1.75em; } 107 | #source p .ctxs span { display: block; text-align: right; } 108 | 109 | #index td, #index th { text-align: right; width: 5em; padding: .25em .5em; border-bottom: 1px solid #eee; } 110 | #index td.left, #index th.left { padding-left: 0; } 111 | #index td.right, #index th.right { padding-right: 0; } 112 | #index td.name, #index th.name { text-align: left; width: auto; } 113 | #index th { font-style: italic; color: #333; border-bottom: 1px solid #ccc; cursor: pointer; } 114 | #index th:hover { background: #eee; border-bottom: 1px solid #999; } 115 | #index th.headerSortDown, #index th.headerSortUp { border-bottom: 1px solid #000; white-space: nowrap; background: #eee; } 116 | #index th.headerSortDown:after { content: " ↓"; } 117 | #index th.headerSortUp:after { content: " ↑"; } 118 | #index td.name a { text-decoration: none; color: #000; } 119 | #index tr.total td, #index tr.total_dynamic td { font-weight: bold; border-top: 1px solid #ccc; border-bottom: none; } 120 | #index tr.file:hover { background: #eeeeee; } 121 | #index tr.file:hover td.name { text-decoration: underline; color: #000; } 122 | 123 | #scroll_marker { position: fixed; right: 0; top: 0; width: 16px; height: 100%; background: white; border-left: 1px solid #eee; will-change: transform; } 124 | #scroll_marker .marker { background: #ddd; position: absolute; min-height: 3px; width: 100%; } 125 | -------------------------------------------------------------------------------- /lmgapp.py: -------------------------------------------------------------------------------- 1 | import json 2 | import requests 3 | from app import app, db 4 | from app.models import Outcome, Assignment, User 5 | from app.assignments import Assignments 6 | from app.outcomes import Outcomes 7 | 8 | 9 | @app.shell_context_processor 10 | def make_shell_context(): 11 | return { 12 | 'db': db, 13 | 'Outcomes': Outcomes, 14 | 'Assignments': Assignments, 15 | 'Outcome': Outcome, 16 | 'Assignment': Assignment, 17 | 'User': User, 18 | 'json': json, 19 | 'requests': requests 20 | } 21 | -------------------------------------------------------------------------------- /migration.py: -------------------------------------------------------------------------------- 1 | from app import db 2 | from app.models import Outcome 3 | from sqlalchemy.orm.session import make_transient 4 | 5 | outcomes = Outcome.query.all() 6 | 7 | for outcome in outcomes: 8 | db.session.expunge(outcome) 9 | make_transient(outcome) 10 | 11 | outcome.outcome_id = outcome.id 12 | outcome.id = None 13 | 14 | db.session.add(outcome) 15 | 16 | db.session.commit() 17 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | 3 | import logging 4 | from logging.config import fileConfig 5 | 6 | from sqlalchemy import engine_from_config 7 | from sqlalchemy import pool 8 | 9 | from alembic import context 10 | 11 | # this is the Alembic Config object, which provides 12 | # access to the values within the .ini file in use. 13 | config = context.config 14 | 15 | # Interpret the config file for Python logging. 16 | # This line sets up loggers basically. 17 | fileConfig(config.config_file_name) 18 | logger = logging.getLogger('alembic.env') 19 | 20 | # add your model's MetaData object here 21 | # for 'autogenerate' support 22 | # from myapp import mymodel 23 | # target_metadata = mymodel.Base.metadata 24 | from flask import current_app 25 | config.set_main_option('sqlalchemy.url', 26 | current_app.config.get('SQLALCHEMY_DATABASE_URI')) 27 | target_metadata = current_app.extensions['migrate'].db.metadata 28 | 29 | # other values from the config, defined by the needs of env.py, 30 | # can be acquired: 31 | # my_important_option = config.get_main_option("my_important_option") 32 | # ... etc. 33 | 34 | 35 | def run_migrations_offline(): 36 | """Run migrations in 'offline' mode. 37 | 38 | This configures the context with just a URL 39 | and not an Engine, though an Engine is acceptable 40 | here as well. By skipping the Engine creation 41 | we don't even need a DBAPI to be available. 42 | 43 | Calls to context.execute() here emit the given string to the 44 | script output. 45 | 46 | """ 47 | url = config.get_main_option("sqlalchemy.url") 48 | context.configure( 49 | url=url, target_metadata=target_metadata, literal_binds=True 50 | ) 51 | 52 | with context.begin_transaction(): 53 | context.run_migrations() 54 | 55 | 56 | def run_migrations_online(): 57 | """Run migrations in 'online' mode. 58 | 59 | In this scenario we need to create an Engine 60 | and associate a connection with the context. 61 | 62 | """ 63 | 64 | # this callback is used to prevent an auto-migration from being generated 65 | # when there are no changes to the schema 66 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html 67 | def process_revision_directives(context, revision, directives): 68 | if getattr(config.cmd_opts, 'autogenerate', False): 69 | script = directives[0] 70 | if script.upgrade_ops.is_empty(): 71 | directives[:] = [] 72 | logger.info('No changes in schema detected.') 73 | 74 | connectable = engine_from_config( 75 | config.get_section(config.config_ini_section), 76 | prefix='sqlalchemy.', 77 | poolclass=pool.NullPool, 78 | ) 79 | 80 | with connectable.connect() as connection: 81 | context.configure( 82 | connection=connection, 83 | target_metadata=target_metadata, 84 | process_revision_directives=process_revision_directives, 85 | **current_app.extensions['migrate'].configure_args 86 | ) 87 | 88 | with context.begin_transaction(): 89 | context.run_migrations() 90 | 91 | 92 | if context.is_offline_mode(): 93 | run_migrations_offline() 94 | else: 95 | run_migrations_online() 96 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /migrations/versions/88510f7d95e7_initial.py: -------------------------------------------------------------------------------- 1 | """initial 2 | 3 | Revision ID: 88510f7d95e7 4 | Revises: 5 | Create Date: 2019-05-28 10:01:02.358894 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '88510f7d95e7' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('Users', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('canvas_id', sa.String(length=64), nullable=False), 24 | sa.Column('name', sa.String(length=64), nullable=False), 25 | sa.Column('token', sa.String(length=255), nullable=False), 26 | sa.Column('expiration', sa.Integer(), nullable=True), 27 | sa.Column('refresh_token', sa.String(length=255), nullable=True), 28 | sa.PrimaryKeyConstraint('id'), 29 | sa.UniqueConstraint('canvas_id') 30 | ) 31 | op.create_table('outcome', 32 | sa.Column('id', sa.Integer(), nullable=False), 33 | sa.Column('title', sa.String(length=64), nullable=True), 34 | sa.Column('course_id', sa.Integer(), nullable=True), 35 | sa.PrimaryKeyConstraint('id') 36 | ) 37 | op.create_table('assignment', 38 | sa.Column('id', sa.Integer(), nullable=False), 39 | sa.Column('title', sa.String(length=128), nullable=True), 40 | sa.Column('course_id', sa.Integer(), nullable=True), 41 | sa.Column('outcome_id', sa.Integer(), nullable=True), 42 | sa.ForeignKeyConstraint(['outcome_id'], ['outcome.id'], ), 43 | sa.PrimaryKeyConstraint('id') 44 | ) 45 | # ### end Alembic commands ### 46 | 47 | 48 | def downgrade(): 49 | # ### commands auto generated by Alembic - please adjust! ### 50 | op.drop_table('assignment') 51 | op.drop_table('outcome') 52 | op.drop_table('Users') 53 | # ### end Alembic commands ### 54 | -------------------------------------------------------------------------------- /migrations/versions/ba0d3f6d8cc9_really_remove_last_section_key.py: -------------------------------------------------------------------------------- 1 | """really remove last_section key 2 | 3 | Revision ID: ba0d3f6d8cc9 4 | Revises: ee57394e52d2 5 | Create Date: 2019-08-08 10:44:07.047393 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import mysql 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'ba0d3f6d8cc9' 14 | down_revision = 'ee57394e52d2' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.drop_column('Users', 'last_section') 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.add_column('Users', sa.Column('last_section', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True)) 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /migrations/versions/bc4519596133_create_new_sequential_id_for_outcome_.py: -------------------------------------------------------------------------------- 1 | """create new sequential ID for outcome primary key 2 | 3 | Revision ID: bc4519596133 4 | Revises: ba0d3f6d8cc9 5 | Create Date: 2019-12-09 09:48:33.123831 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'bc4519596133' 14 | down_revision = 'ba0d3f6d8cc9' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('outcome', sa.Column('outcome_id', sa.Integer(), nullable=True)) 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.drop_column('outcome', 'outcome_id') 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /migrations/versions/ee57394e52d2_remove_last_section_key_for_now.py: -------------------------------------------------------------------------------- 1 | """remove last_section key for now 2 | 3 | Revision ID: ee57394e52d2 4 | Revises: 88510f7d95e7 5 | Create Date: 2019-08-08 10:40:17.744061 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'ee57394e52d2' 14 | down_revision = '88510f7d95e7' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('Users', sa.Column('last_section', sa.Integer(), nullable=True)) 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.drop_column('Users', 'last_section') 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.5.4 2 | alembic==1.0.8 3 | appdirs==1.4.3 4 | asn1crypto==0.24.0 5 | astroid==2.2.5 6 | async-timeout==3.0.1 7 | atomicwrites==1.3.0 8 | attrs==19.1.0 9 | autopep8==1.4.3 10 | black==19.10b0 11 | blinker==1.4 12 | canvasapi==0.13.0 13 | certifi==2019.3.9 14 | cffi==1.12.2 15 | chardet==3.0.4 16 | Click==7.0 17 | colorclass==2.2.0 18 | coverage==5.0.3 19 | cryptography==2.6.1 20 | dill==0.3.1.1 21 | docopt==0.6.2 22 | dominate==2.4.0 23 | entrypoints==0.3 24 | flake8==3.7.8 25 | Flask==1.1.1 26 | Flask-Bootstrap==3.3.7.1 27 | Flask-Caching==1.6.0 28 | Flask-Cors==3.0.8 29 | Flask-DebugToolbar==0.10.1 30 | Flask-Login==0.4.1 31 | Flask-Migrate==2.4.0 32 | Flask-SQLAlchemy==2.4.1 33 | Flask-Testing==0.7.1 34 | Flask-WTF==0.14.2 35 | httplib2==0.12.3 36 | idna==2.8 37 | importlib-metadata==0.17 38 | isort==4.3.15 39 | itsdangerous==1.1.0 40 | Jinja2==2.10.3 41 | jsonify==0.5 42 | lazy-object-proxy==1.3.1 43 | Mako==1.0.7 44 | MarkupSafe==1.1.1 45 | mccabe==0.6.1 46 | more-itertools==7.0.0 47 | multidict==4.5.2 48 | multiprocess==0.70.9 49 | oauth2==1.9.0.post1 50 | oauthlib==3.0.1 51 | packaging==19.0 52 | pathos==0.2.5 53 | pathspec==0.6.0 54 | pip-tools==3.6.0 55 | pip-upgrader==1.4.15 56 | pluggy==0.12.0 57 | pox==0.2.7 58 | ppft==1.6.6.1 59 | py==1.8.0 60 | pycodestyle==2.5.0 61 | pycparser==2.19 62 | pydocstyle==5.0.2 63 | pyflakes==2.1.1 64 | pylint==2.3.1 65 | PyLTI==0.7.0 66 | PyMySQL==0.9.3 67 | pyparsing==2.4.0 68 | python-dateutil==2.8.0 69 | python-dotenv==0.10.1 70 | python-editor==1.0.4 71 | pytz==2018.9 72 | regex==2019.12.19 73 | requests==2.22.0 74 | requests-mock==1.7.0 75 | requests-oauthlib==1.2.0 76 | sentry-sdk==0.11.1 77 | six==1.12.0 78 | snowballstemmer==2.0.0 79 | SQLAlchemy==1.3.0 80 | terminaltables==3.1.0 81 | toml==0.10.0 82 | tqdm==4.32.2 83 | typed-ast==1.4.0 84 | urllib3==1.25.2 85 | visitor==0.1.3 86 | wcwidth==0.1.7 87 | Werkzeug==0.15.5 88 | wrapt==1.11.1 89 | WTForms==2.2.1 90 | yarl==1.3.0 91 | zipp==0.5.1 92 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # Specify a configuration file. 4 | ; rcfile=/Users/bbennett/Desktop/dev/lmgApp/.flake8 5 | 6 | # Python code to execute, usually for sys.path manipulation such as 7 | # pygtk.require(). 8 | #init-hook= 9 | 10 | # Add files or directories to the blacklist. They should be base names, not 11 | # paths. 12 | ignore=third_party 13 | 14 | # Add files or directories matching the regex patterns to the blacklist. The 15 | # regex matches against base names, not paths. 16 | ignore-patterns=object_detection_grpc_client.py,prediction_pb2.py,prediction_pb2_grpc.py,mnist_DDP.py,mnistddpserving.py 17 | 18 | # Pickle collected data for later comparisons. 19 | persistent=no 20 | 21 | # List of plugins (as comma separated values of python modules names) to load, 22 | # usually to register additional checkers. 23 | load-plugins= 24 | 25 | # Use multiple processes to speed up Pylint. 26 | jobs=4 27 | 28 | # Allow loading of arbitrary C extensions. Extensions are imported into the 29 | # active Python interpreter and may run arbitrary code. 30 | unsafe-load-any-extension=no 31 | 32 | # A comma-separated list of package or module names from where C extensions may 33 | # be loaded. Extensions are loading into the active Python interpreter and may 34 | # run arbitrary code 35 | extension-pkg-whitelist= 36 | 37 | 38 | [MESSAGES CONTROL] 39 | 40 | # Only show warnings with the listed confidence levels. Leave empty to show 41 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 42 | confidence= 43 | 44 | # Enable the message, report, category or checker with the given id(s). You can 45 | # either give multiple identifier separated by comma (,) or put this option 46 | # multiple time (only on the command line, not in the configuration file where 47 | # it should appear only once). See also the "--disable" option for examples. 48 | #enable= 49 | 50 | # Disable the message, report, category or checker with the given id(s). You 51 | # can either give multiple identifiers separated by comma (,) or put this 52 | # option multiple times (only on the command line, not in the configuration 53 | # file where it should appear only once).You can also use "--disable=all" to 54 | # disable everything first and then reenable specific checks. For example, if 55 | # you want to run only the similarities checker, you can use "--disable=all 56 | # --enable=similarities". If you want to run only the classes checker, but have 57 | # no Warning level messages displayed, use"--disable=all --enable=classes 58 | # --disable=W" 59 | disable=import-star-module-level,old-octal-literal,oct-method,print-statement,unpacking-in-except,parameter-unpacking,backtick,old-raise-syntax,old-ne-operator,long-suffix,dict-view-method,dict-iter-method,metaclass-assignment,next-method-called,raising-string,indexing-exception,raw_input-builtin,long-builtin,file-builtin,execfile-builtin,coerce-builtin,cmp-builtin,buffer-builtin,basestring-builtin,apply-builtin,filter-builtin-not-iterating,using-cmp-argument,useless-suppression,range-builtin-not-iterating,suppressed-message,missing-docstring,no-absolute-import,old-division,cmp-method,reload-builtin,zip-builtin-not-iterating,intern-builtin,unichr-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,input-builtin,round-builtin,hex-method,nonzero-method,map-builtin-not-iterating,relative-import,invalid-name,bad-continuation,no-member,locally-disabled,fixme,import-error,too-many-locals 60 | 61 | 62 | [REPORTS] 63 | 64 | # Set the output format. Available formats are text, parseable, colorized, msvs 65 | # (visual studio) and html. You can also give a reporter class, eg 66 | # mypackage.mymodule.MyReporterClass. 67 | output-format=text 68 | 69 | # Put messages in a separate file for each module / package specified on the 70 | # command line instead of printing them on stdout. Reports (if any) will be 71 | # written in a file name "pylint_global.[txt|html]". This option is deprecated 72 | # and it will be removed in Pylint 2.0. 73 | files-output=no 74 | 75 | # Tells whether to display a full report or only the messages 76 | reports=no 77 | 78 | # Python expression which should return a note less than 10 (10 is the highest 79 | # note). You have access to the variables errors warning, statement which 80 | # respectively contain the number of errors / warnings messages and the total 81 | # number of statements analyzed. This is used by the global evaluation report 82 | # (RP0004). 83 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 84 | 85 | # Template used to display messages. This is a python new-style format string 86 | # used to format the message information. See doc for all details 87 | #msg-template= 88 | 89 | 90 | [BASIC] 91 | 92 | # Good variable names which should always be accepted, separated by a comma 93 | good-names=i,j,k,ex,Run,_ 94 | 95 | # Bad variable names which should always be refused, separated by a comma 96 | bad-names=foo,bar,baz,toto,tutu,tata 97 | 98 | # Colon-delimited sets of names that determine each other's naming style when 99 | # the name regexes allow several styles. 100 | name-group= 101 | 102 | # Include a hint for the correct naming format with invalid-name 103 | include-naming-hint=no 104 | 105 | # List of decorators that produce properties, such as abc.abstractproperty. Add 106 | # to this list to register other decorators that produce valid properties. 107 | property-classes=abc.abstractproperty 108 | 109 | # Regular expression matching correct function names 110 | function-rgx=[a-z_][a-z0-9_]{2,30}$ 111 | 112 | # Naming hint for function names 113 | function-name-hint=[a-z_][a-z0-9_]{2,30}$ 114 | 115 | # Regular expression matching correct variable names 116 | variable-rgx=[a-z_][a-z0-9_]{2,30}$ 117 | 118 | # Naming hint for variable names 119 | variable-name-hint=[a-z_][a-z0-9_]{2,30}$ 120 | 121 | # Regular expression matching correct constant names 122 | const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 123 | 124 | # Naming hint for constant names 125 | const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 126 | 127 | # Regular expression matching correct attribute names 128 | attr-rgx=[a-z_][a-z0-9_]{2,30}$ 129 | 130 | # Naming hint for attribute names 131 | attr-name-hint=[a-z_][a-z0-9_]{2,30}$ 132 | 133 | # Regular expression matching correct argument names 134 | argument-rgx=[a-z_][a-z0-9_]{2,30}$ 135 | 136 | # Naming hint for argument names 137 | argument-name-hint=[a-z_][a-z0-9_]{2,30}$ 138 | 139 | # Regular expression matching correct class attribute names 140 | class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 141 | 142 | # Naming hint for class attribute names 143 | class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 144 | 145 | # Regular expression matching correct inline iteration names 146 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 147 | 148 | # Naming hint for inline iteration names 149 | inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ 150 | 151 | # Regular expression matching correct class names 152 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 153 | 154 | # Naming hint for class names 155 | class-name-hint=[A-Z_][a-zA-Z0-9]+$ 156 | 157 | # Regular expression matching correct module names 158 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 159 | 160 | # Naming hint for module names 161 | module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 162 | 163 | # Regular expression matching correct method names 164 | method-rgx=[a-z_][a-z0-9_]{2,30}$ 165 | 166 | # Naming hint for method names 167 | method-name-hint=[a-z_][a-z0-9_]{2,30}$ 168 | 169 | # Regular expression which should only match function or class names that do 170 | # not require a docstring. 171 | no-docstring-rgx=^_ 172 | 173 | # Minimum line length for functions/classes that require docstrings, shorter 174 | # ones are exempt. 175 | docstring-min-length=-1 176 | 177 | 178 | [ELIF] 179 | 180 | # Maximum number of nested blocks for function / method body 181 | max-nested-blocks=5 182 | 183 | 184 | [TYPECHECK] 185 | 186 | # Tells whether missing members accessed in mixin class should be ignored. A 187 | # mixin class is detected if its name ends with "mixin" (case insensitive). 188 | ignore-mixin-members=yes 189 | 190 | # List of module names for which member attributes should not be checked 191 | # (useful for modules/projects where namespaces are manipulated during runtime 192 | # and thus existing member attributes cannot be deduced by static analysis. It 193 | # supports qualified module names, as well as Unix pattern matching. 194 | ignored-modules= 195 | 196 | # List of class names for which member attributes should not be checked (useful 197 | # for classes with dynamically set attributes). This supports the use of 198 | # qualified names. 199 | ignored-classes=optparse.Values,thread._local,_thread._local 200 | 201 | # List of members which are set dynamically and missed by pylint inference 202 | # system, and so shouldn't trigger E1101 when accessed. Python regular 203 | # expressions are accepted. 204 | generated-members= 205 | 206 | # List of decorators that produce context managers, such as 207 | # contextlib.contextmanager. Add to this list to register other decorators that 208 | # produce valid context managers. 209 | contextmanager-decorators=contextlib.contextmanager 210 | 211 | 212 | [FORMAT] 213 | [flake8] 214 | # Maximum number of characters on a single line. 215 | max-line-length=88 216 | 217 | # Regexp for a line that is allowed to be longer than the limit. 218 | ignore-long-lines=^\s*(# )??$ 219 | 220 | # Allow the body of an if to be on the same line as the test if there is no 221 | # else. 222 | single-line-if-stmt=no 223 | 224 | # List of optional constructs for which whitespace checking is disabled. `dict- 225 | # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. 226 | # `trailing-comma` allows a space between comma and closing bracket: (a, ). 227 | # `empty-line` allows space-only lines. 228 | no-space-check=trailing-comma,dict-separator 229 | 230 | # Maximum number of lines in a module 231 | max-module-lines=1000 232 | 233 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 234 | # tab). 235 | # Use 2 spaces consistent with TensorFlow style. 236 | indent-string=' ' 237 | 238 | # Number of spaces of indent required inside a hanging or continued line. 239 | indent-after-paren=4 240 | 241 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 242 | expected-line-ending-format= 243 | 244 | 245 | [MISCELLANEOUS] 246 | 247 | # List of note tags to take in consideration, separated by a comma. 248 | notes=FIXME,XXX,TODO 249 | 250 | 251 | [VARIABLES] 252 | 253 | # Tells whether we should check for unused import in __init__ files. 254 | init-import=no 255 | 256 | # A regular expression matching the name of dummy variables (i.e. expectedly 257 | # not used). 258 | dummy-variables-rgx=(_+[a-zA-Z0-9]*?$)|dummy 259 | 260 | # List of additional names supposed to be defined in builtins. Remember that 261 | # you should avoid to define new builtins when possible. 262 | additional-builtins= 263 | 264 | # List of strings which can identify a callback function by name. A callback 265 | # name must start or end with one of those strings. 266 | callbacks=cb_,_cb 267 | 268 | # List of qualified module names which can have objects that can redefine 269 | # builtins. 270 | redefining-builtins-modules=six.moves,future.builtins 271 | 272 | 273 | [LOGGING] 274 | 275 | # Logging modules to check that the string format arguments are in logging 276 | # function parameter format 277 | logging-modules=logging 278 | 279 | 280 | [SIMILARITIES] 281 | 282 | # Minimum lines number of a similarity. 283 | min-similarity-lines=4 284 | 285 | # Ignore comments when computing similarities. 286 | ignore-comments=yes 287 | 288 | # Ignore docstrings when computing similarities. 289 | ignore-docstrings=yes 290 | 291 | # Ignore imports when computing similarities. 292 | ignore-imports=no 293 | 294 | 295 | [SPELLING] 296 | 297 | # Spelling dictionary name. Available dictionaries: none. To make it working 298 | # install python-enchant package. 299 | spelling-dict= 300 | 301 | # List of comma separated words that should not be checked. 302 | spelling-ignore-words= 303 | 304 | # A path to a file that contains private dictionary; one word per line. 305 | spelling-private-dict-file= 306 | 307 | # Tells whether to store unknown words to indicated private dictionary in 308 | # --spelling-private-dict-file option instead of raising a message. 309 | spelling-store-unknown-words=no 310 | 311 | 312 | [IMPORTS] 313 | 314 | # Deprecated modules which should not be used, separated by a comma 315 | deprecated-modules=regsub,TERMIOS,Bastion,rexec 316 | 317 | # Create a graph of every (i.e. internal and external) dependencies in the 318 | # given file (report RP0402 must not be disabled) 319 | import-graph= 320 | 321 | # Create a graph of external dependencies in the given file (report RP0402 must 322 | # not be disabled) 323 | ext-import-graph= 324 | 325 | # Create a graph of internal dependencies in the given file (report RP0402 must 326 | # not be disabled) 327 | int-import-graph= 328 | 329 | # Force import order to recognize a module as part of the standard 330 | # compatibility libraries. 331 | known-standard-library= 332 | 333 | # Force import order to recognize a module as part of a third party library. 334 | known-third-party=enchant 335 | 336 | # Analyse import fallback blocks. This can be used to support both Python 2 and 337 | # 3 compatible code, which means that the block might have code that exists 338 | # only in one or another interpreter, leading to false positives when analysed. 339 | analyse-fallback-blocks=no 340 | 341 | 342 | [DESIGN] 343 | 344 | # Maximum number of arguments for function / method 345 | max-args=7 346 | 347 | # Argument names that match this expression will be ignored. Default to name 348 | # with leading underscore 349 | ignored-argument-names=_.* 350 | 351 | # Maximum number of locals for function / method body 352 | max-locals=15 353 | 354 | # Maximum number of return / yield for function / method body 355 | max-returns=6 356 | 357 | # Maximum number of branch for function / method body 358 | max-branches=12 359 | 360 | # Maximum number of statements in function / method body 361 | max-statements=50 362 | 363 | # Maximum number of parents for a class (see R0901). 364 | max-parents=7 365 | 366 | # Maximum number of attributes for a class (see R0902). 367 | max-attributes=7 368 | 369 | # Minimum number of public methods for a class (see R0903). 370 | min-public-methods=0 371 | 372 | # Maximum number of public methods for a class (see R0904). 373 | max-public-methods=20 374 | 375 | # Maximum number of boolean expressions in a if statement 376 | max-bool-expr=5 377 | 378 | 379 | [CLASSES] 380 | 381 | # List of method names used to declare (i.e. assign) instance attributes. 382 | defining-attr-methods=__init__,__new__,setUp 383 | 384 | # List of valid names for the first argument in a class method. 385 | valid-classmethod-first-arg=cls 386 | 387 | # List of valid names for the first argument in a metaclass class method. 388 | valid-metaclass-classmethod-first-arg=mcs 389 | 390 | # List of member names, which should be excluded from the protected access 391 | # warning. 392 | exclude-protected=_asdict,_fields,_replace,_source,_make 393 | 394 | 395 | [EXCEPTIONS] 396 | 397 | # Exceptions that will emit a warning when being caught. Defaults to 398 | # "Exception" 399 | overgeneral-exceptions=Exception 400 | -------------------------------------------------------------------------------- /tests/.coverage: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bennettscience/canvas-learning-mastery/13a3f3f57e6fb5831e65333d5795f875d3722478/tests/.coverage -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bennettscience/canvas-learning-mastery/13a3f3f57e6fb5831e65333d5795f875d3722478/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_assignments.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from canvasapi import Canvas 3 | 4 | from app import app, db 5 | from app.models import Assignment, Outcome 6 | from app.assignments import Assignments 7 | from tests import settings 8 | 9 | 10 | class TestAssignments(unittest.TestCase): 11 | def setUp(self): 12 | app.config['TESTING'] = True 13 | 14 | app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite://" 15 | db.create_all() 16 | 17 | self.canvas = Canvas(settings.BASE_URL, settings.API_KEY) 18 | self.course_id = 39775 19 | self.assignment_id = 190160 20 | self.assignment_group = 40777 21 | 22 | def tearDown(self): 23 | db.session.remove() 24 | db.drop_all() 25 | 26 | def test_add_assignment(self): 27 | a1 = Assignment(id=1, title="Some assignment 1", outcome_id="") 28 | a2 = Assignment(id=2, title="Some assignment 2", outcome_id="") 29 | db.session.add_all([a1, a2]) 30 | db.session.commit() 31 | 32 | def test_save_assignment_data(self): 33 | Assignments.save_assignment_data( 34 | self.canvas, self.course_id, self.assignment_group 35 | ) 36 | query = Assignment.query.all() 37 | self.assertEqual(len(query), 15) 38 | 39 | def test_alignments(self): 40 | assignments = Assignment.query.all() 41 | for a in assignments: 42 | Outcome.is_aligned(a) 43 | 44 | def test_create_alignment(self): 45 | a1 = Assignment(id=1, title="Some assignment 1", outcome_id="") 46 | o1 = Outcome(id=123, title="Some outcome 1") 47 | db.session.add_all([a1, o1]) 48 | db.session.commit() 49 | o1.align(a1) 50 | 51 | def test_get_assignment_rubric_results(self): 52 | cols_expected = [ 53 | { 54 | "id": "7065_8592", 55 | "name": "G.PL.3", 56 | "outcome_id": 15300 57 | }, 58 | { 59 | "id": "7065_7537", 60 | "name": "G.T.1", 61 | "outcome_id": 15303 62 | } 63 | ] 64 | result = Assignments.build_assignment_rubric_results( 65 | self.canvas, self.course_id, self.assignment_id 66 | ) 67 | self.assertIsInstance(result, dict) 68 | self.assertIsInstance(result['columns'], list) 69 | self.assertIsInstance(result['student_results'], list) 70 | self.assertEqual(result['columns'], cols_expected) 71 | 72 | def test_get_course_assignments(self): 73 | course_id = 37656 74 | result = Assignments.get_course_assignments(self.canvas, course_id) 75 | self.assertIsInstance(result, list) 76 | 77 | def test_build_enrollment_list(self): 78 | course = self.canvas.get_course(self.course_id) 79 | enrollments = Assignments.build_enrollment_list(course) 80 | 81 | self.assertIsInstance(enrollments, list) 82 | self.assertEqual(len(enrollments), 2) 83 | 84 | def test_build_submission_dict(self): 85 | o1 = Outcome(id=1, title='Test Outcome', course_id=39775, outcome_id=123) 86 | a1 = Assignment(id=190128, title='Test Assignment', course_id=39775) 87 | db.session.add_all([o1, a1]) 88 | db.session.commit() 89 | 90 | o1.align(a1) 91 | db.session.commit() 92 | 93 | course = self.canvas.get_course(self.course_id) 94 | enrollments = [31874, 31875] 95 | assignment_list = [190128] 96 | submissions = [] 97 | 98 | all_submissions = course.get_multiple_submissions( 99 | assignment_ids=assignment_list, 100 | student_ids=enrollments, 101 | include=("user", "assignment"), 102 | grouped=True, 103 | ) 104 | 105 | for student in all_submissions: 106 | items = student.submissions 107 | for item in items: 108 | submissions.append(Assignments.process_enrollment_submissions(item)) 109 | 110 | self.assertIsInstance(submissions, list) 111 | -------------------------------------------------------------------------------- /tests/test_auth.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from app.auth import Auth 3 | 4 | 5 | class TestAuth(unittest.TestCase): 6 | 7 | def test_login_tuple(self): 8 | auth_url = Auth.login() 9 | self.assertIsInstance(auth_url, object) 10 | 11 | def test_login_url(self): 12 | auth_url = Auth.login() 13 | self.assertIsNotNone(auth_url) 14 | -------------------------------------------------------------------------------- /tests/test_courses.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from canvasapi import Canvas 4 | from app.courses import Course 5 | from app.models import Assignment 6 | from app import app, db 7 | from tests import settings 8 | 9 | 10 | def setUpModule(): 11 | app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite://' 12 | db.create_all() 13 | 14 | a1 = Assignment(title='Some assignment 1', course_id=1, outcome_id=123) 15 | a2 = Assignment(title='Some assignment 2', course_id=1, outcome_id=456) 16 | db.session.add_all([a1, a2]) 17 | db.session.commit() 18 | 19 | 20 | def tearDownModule(): 21 | db.session.remove() 22 | db.drop_all() 23 | 24 | 25 | class TestCourses(unittest.TestCase): 26 | 27 | def setUp(self): 28 | 29 | self.canvas = Canvas(settings.BASE_URL, settings.API_KEY) 30 | 31 | class CourseDict(dict): 32 | pass 33 | 34 | self.course = CourseDict() 35 | 36 | self.course.id = 1 37 | self.course.name = "Demo" 38 | self.course.sis_course_id: None 39 | self.course.uuid = "WvAHhY5FINzq5IyRIJybGeiXyFkG3SqHUPb7jZY5" 40 | self.course.integration_id = None 41 | self.course.sis_import_id = 34 42 | self.course.start_at = "2018-06-01T00:00:00Z" 43 | self.course.created_at = "2020-05-25T00:00:00Z" 44 | 45 | def test_process_course_return(self): 46 | processed = Course.process_course(self.course) 47 | self.assertIsNotNone(processed) 48 | 49 | def test_process_course_structure(self): 50 | expected = { 51 | "id": 1, 52 | "name": "Demo", 53 | "outcomes": 2, 54 | "term": 2018 55 | } 56 | 57 | processed = Course.process_course(self.course) 58 | self.assertDictEqual(processed, expected) 59 | 60 | def test_course_with_no_start_date(self): 61 | expected = { 62 | "id": 1, 63 | "name": "Demo", 64 | "outcomes": 2, 65 | "term": 2020 66 | } 67 | 68 | self.course.start_at = None 69 | processed = Course.process_course(self.course) 70 | self.assertDictEqual(processed, expected) 71 | -------------------------------------------------------------------------------- /tests/test_outcomes.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from canvasapi import Canvas 3 | 4 | from app import app, db 5 | from app.models import Outcome, Assignment 6 | from app.outcomes import Outcomes 7 | from tests import settings 8 | 9 | 10 | def setUpModule(): 11 | app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite://' 12 | db.create_all() 13 | 14 | o1 = Outcome(title='Some outcome 1', course_id=999, outcome_id=123) 15 | o2 = Outcome(title='Some outcome 1', course_id=888, outcome_id=123) 16 | a1 = Assignment(title="Some assignment", course_id=999, id=111) 17 | db.session.add_all([o1, o2, a1]) 18 | db.session.commit() 19 | 20 | 21 | def tearDownModule(): 22 | db.session.remove() 23 | db.drop_all() 24 | 25 | 26 | class TestAddOutcomes(unittest.TestCase): 27 | 28 | def setUp(self): 29 | app.testing = True 30 | self.client = app.test_client() 31 | self.canvas = Canvas(settings.BASE_URL, settings.API_KEY) 32 | 33 | def tearDown(self): 34 | pass 35 | 36 | def test_add_single_outcome(self): 37 | o1 = Outcome(title='Some outcome 1', course_id=999, outcome_id=123) 38 | db.session.add(o1) 39 | db.session.commit() 40 | 41 | def test_add_duplicate_outcomes(self): 42 | o1 = Outcome(title='Some outcome 1', course_id=999, outcome_id=123) 43 | o2 = Outcome(title='Some outcome 1', course_id=888, outcome_id=123) 44 | db.session.add_all([o1, o2]) 45 | db.session.commit() 46 | 47 | def test_query_outcomes(self): 48 | outcome = Outcome.query.filter_by(outcome_id=123).first() 49 | self.assertIs(outcome.outcome_id, 123) 50 | 51 | def test_align_outcome_to_assignment(self): 52 | a1 = Assignment(id=123456, title='Some assignment', course_id=999) 53 | db.session.add(a1) 54 | db.session.commit() 55 | 56 | o1 = Outcome.query.filter_by(outcome_id=123).first() 57 | o1.align(a1) 58 | 59 | def test_add_outcomes_from_canvas(self): 60 | Outcomes.save_outcome_data(self.canvas, 39830) 61 | outcomes = Outcome.query.filter_by(course_id=39830).all() 62 | self.assertEqual(len(outcomes), 2) 63 | 64 | 65 | class TestAlignOutcomes(unittest.TestCase): 66 | 67 | def setUp(self): 68 | app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite://' 69 | db.create_all() 70 | o2 = Outcome(outcome_id=123, course_id=999, title="Some Outcome") 71 | a2 = Assignment(id=999, course_id=999, title="Some Assignment") 72 | db.session.add_all([o2, a2]) 73 | db.session.commit() 74 | 75 | def tearDown(self): 76 | db.session.remove() 77 | db.drop_all() 78 | 79 | def test_align_assignment_to_outcome(self): 80 | o3 = Outcome(outcome_id=1, course_id=999, title="Test Outcome 1") 81 | a1 = Assignment(title='Some Assignment', course_id=999, id=1) 82 | 83 | db.session.add_all([o3, a1]) 84 | db.session.commit() 85 | 86 | outcome_id = 1 87 | assignment_id = 1 88 | course_id = 999 89 | Outcomes.align_assignment_to_outcome(course_id, outcome_id, assignment_id) 90 | 91 | outcome = Outcome.query.filter_by(outcome_id=1).first() 92 | self.assertIsNotNone(outcome.assignment_id) 93 | 94 | def test_unalign_outcome(self): 95 | o = Outcome.query.filter_by(outcome_id=123).first() 96 | a = Assignment.query.filter_by(id=999).first() 97 | 98 | o.align(a) 99 | db.session.commit() 100 | 101 | o.align(None) 102 | db.session.commit() 103 | 104 | self.assertIsNone(o.assignment_id) 105 | -------------------------------------------------------------------------------- /tests/test_routes.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from app import app 3 | from flask import template_rendered 4 | 5 | from contextlib import contextmanager 6 | 7 | # https://stackoverflow.com/questions/23987564/test-flask-render-template-context 8 | @contextmanager 9 | def captured_templates(app): 10 | recorded = [] 11 | 12 | def record(sender, template, context, **extra): 13 | recorded.append((template, context)) 14 | template_rendered.connect(record, app) 15 | try: 16 | yield recorded 17 | finally: 18 | template_rendered.disconnect(record, app) 19 | 20 | 21 | class TestRoutes(unittest.TestCase): 22 | 23 | def setUp(self): 24 | app.testing = True 25 | self.client = app.test_client() 26 | self.course_id = 39775 27 | 28 | def tearDown(self): 29 | pass 30 | 31 | def test_index_logged_out(self): 32 | with captured_templates(app) as templates: 33 | resp = self.client.get('/') 34 | self.assertEqual(resp.status_code, 200) 35 | self.assertEqual(len(templates), 1) 36 | template, context = templates[0] 37 | self.assertEqual(template.name, 'login.html') 38 | 39 | def test_dashboard_logged_out(self): 40 | # redireect the user to the login screen 41 | resp = self.client.get('/dashboard') 42 | self.assertEqual(resp.status_code, 302) 43 | 44 | def test_logout(self): 45 | # Redirect the user back to the login screen 46 | resp = self.client.get('/logout') 47 | self.assertEqual(resp.status_code, 302) 48 | 49 | def test_course_no_id(self): 50 | resp = self.client.get('/course') 51 | self.assertEqual(resp.status_code, 404) 52 | 53 | def test_course_logged_out(self): 54 | resp = self.client.get(f'course/{self.course_id}') 55 | self.assertEqual(resp.status_code, 302) 56 | 57 | def test_logged_out_course_with_id(self): 58 | with captured_templates(app) as templates: 59 | resp = self.client.get(f'/course/{self.course_id}') 60 | self.assertEqual(resp.status_code, 200) 61 | self.assertEqual(len(templates), 1) 62 | template, context = templates[0] 63 | self.assertEqual(template.name, 'course.html') 64 | -------------------------------------------------------------------------------- /tests/test_users.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from app import app, db 4 | from app.models import User 5 | 6 | 7 | def setUpModule(): 8 | app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite://' 9 | db.create_all() 10 | 11 | 12 | def tearDownModule(): 13 | db.session.remove() 14 | db.drop_all() 15 | 16 | 17 | class TestUsers(unittest.TestCase): 18 | def test_new_user(self): 19 | u1 = User(id=1, canvas_id=123, name='Brian Bennett', token='123456abcd') 20 | db.session.add(u1) 21 | db.session.commit() 22 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | from tests import settings 3 | 4 | import requests_mock 5 | 6 | """ 7 | This is shamelessly lifted from 8 | https://github.com/ucfopen/canvasapi/ 9 | """ 10 | 11 | 12 | def get_institution_url(base_url): 13 | """ 14 | Clean up a given base URL. 15 | :param base_url: The base URL of the API. 16 | :type base_url: str 17 | :rtype: str 18 | """ 19 | base_url = base_url.rstrip("/") 20 | index = base_url.find("/api/v1") 21 | 22 | if index != -1: 23 | return base_url[0:index] 24 | 25 | return base_url 26 | 27 | 28 | def register_uris(requirements, requests_mocker): 29 | """ 30 | Given a list of required fixtures and an requests_mocker object, 31 | register each fixture as a uri with the mocker. 32 | :param base_url: str 33 | :param requirements: dict 34 | :param requests_mocker: requests_mock.mocker.Mocker 35 | """ 36 | for fixture, objects in requirements.items(): 37 | try: 38 | with open("tests/fixtures/{}.json".format(fixture)) as file: 39 | data = json.loads(file.read()) 40 | except IOError: 41 | raise ValueError( 42 | "Fixture {}.json contains invalid JSON.".format(fixture)) 43 | 44 | if not isinstance(objects, list): 45 | raise TypeError("{} is not a list.".format(objects)) 46 | 47 | for obj_name in objects: 48 | obj = data.get(obj_name) 49 | 50 | if obj is None: 51 | raise ValueError( 52 | "{} does not exist in {}.json".format( 53 | obj_name.__repr__(), fixture) 54 | ) 55 | 56 | method = requests_mock.ANY if obj["method"] == "ANY" else obj["method"] 57 | if obj["endpoint"] == "ANY": 58 | url = requests_mock.ANY 59 | else: 60 | url = ( 61 | get_institution_url(settings.BASE_URL) 62 | + "/api/v1/" 63 | + obj["endpoint"] 64 | ) 65 | 66 | try: 67 | requests_mocker.register_uri( 68 | method, 69 | url, 70 | json=obj.get("data"), 71 | status_code=obj.get("status_code", 200), 72 | headers=obj.get("headers", {}), 73 | ) 74 | except Exception as e: 75 | print(e) 76 | --------------------------------------------------------------------------------