Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
A learning resource for mbpy: ManageBac with PYthon.
These pages provide a higher-level overview of use of mbpy.
The mbpy tool and these resources are provided "as is," and is maintained by the Schools Technology Department at Faria Education Group.
For support of this project, we prefer to use the Discord PLC to post questions, etc. By learning in a community context, we can inspire others to utilize it as well.
All individual information presented in the tutorials are machine-generated data and do not represent actual individuals.
Installing mbpy
Download Docker and ensure it's running on your machine.
We need to get the mbpy command in our terminal. First, this command only needs to run once:
If you are new to docker, that command will build the image and make the container accessible through a default name, which in this case is mbpy_mbpycli. It is using the docker files in the codebase to determine all the settings.
If successful, the terminal will output the version number.
Once the docker image has been built you have some choices on how to actually execute mbpy. The sure-fire way is to connect to the docker container, and run commands directly from within the container. This is a good getting-started method to explore.
You can do that with this command:
There, you can simply use the mbpy command line in the container itself.
For example, mbpy import students and then mbpy extract from-db --table students chain info.To exit, type exit. Note that with this method, the import data will not persist the next time you run docker with this method. (See on how to persist across executions).
Execute the application automatically from the host with the following command, you can execute the same as above, but without the --entrypoint bash part, and then tack on to the end the commands. For example, to output the version number:
Note however this method does not persist across executions. To learn how to enable the ability to persist, see .
cd mbpy
docker compose up --builddocker run -it --entrypoint bash --rm mbpy_mbpyclidocker run -it --rm mbpy_mbpycli version
# or:
docker run -it --rm mbpy_mbpycli import studentsWhat is required to run this?
The following are necessary to install:
Linux, Windows, or Mac computer / server able to run Python 3.10 or higher.
Python 3.10 or higher installed (3.11 is recommended)
200 MB of RAM
Git
Your GitHub usersname. At the moment, the repo is private; thus please contact Adam Morris, Schools Technology & Integrations Director at Faria, to be added to the repo. Once added, you need to accept the invitations.
Please choose from the below pages on which is most appropriate for your use case.
There are some requirements for local installation:
Have access to the repos (mbpy endpoints and mbpy) and accepted the invites
Authenticated with github
Install utility. This will authenticate your device with github.
On Mac, you can use to install it: brew install gh
Once the gh command is installed, use it to login: gh auth login
Have pipx
On Mac, brew install pipx and then pipx ensurepath
On Linux, python3 -m pip install --user pipx and then
Remember you have to be logged in to github for this to work gh auth login as above:
You may need to specify exactly which python version to use, which you can by pass an explicit path to the python version, as demonstrated here:
pipx install git+https://github.com/classroomtechtools/mbpy.git --python $(which python3.11)
Every time you want to add a school to mbpy, it needs to be configured. Learn how here:
To develop mbpy or expand its functionality, use poetry:
After that, you can use poetry shell from that directory, add breakpoints etc and hack away. Use mpby commands like usual.
Please see the below information which essentially bootstraps the Getting Started setup for further functionality.
The instructions in Getting Started and Updating with Docker are intended to provide an environment with the shortest path to being able to get started with the application. Installing with that method has a drawback, in that once the container is exited, any data written to disk is lost.
In order to set up an environment where such operations are available, please see the below.
Using the docker concept of volumes, any data imported into mbpy or written to disk during an operation will persist across executions.
Use HTTPS method, and either choose to login via browser or use a token
Alternatively, you could use a Personal Acccess Token.
python3 -m pipx ensurepathUse the volume option:
This adds a -v ("volume") option, telling docker to take the host's current directory (".") and bind it (":) to the container directory (/docker). Please note, however, that the use of "current directory" assumes that you are in the same directory that you cloned into.
A more general-purpose command, then, which you can execute no matter the current working directory, would be to replace the "." with the full path to the mbpy folder. If you followed the instructions for getting started, it's just this:
Great! So now we can add whatever commands onto the end. However, that is a lot of typing, is there a way to just type mbpy from our host terminal? Yes, yes there is, which is the topic of the next section:
The docker container itself already has an mbpy alias set up so that typing it when connected to the shell in the container executes the application.
docker run -it --rm mbpy_mbpyclidocker run -it -v .:/docker --rm mbpy_mbpyclipipx install git+https://github.com/classroomtechtools/mbpy.gitpipx upgrade mbpy
mbpy db migrategit clone --recurse-submodules https://github.com/classroomtechtools/mbpy.git
cd mbpy
poetry installdocker run -it -v ~/src/mbpy:/docker --rm mbpy_mbpycliThis section details some ready-made pipelines that have been created with the mbpy tool.
This plugin is intended to be used for eventual processing by the third party Follette Destiny. All it does is use mbpy's pipelining feature to select the data points required by Destiny, and convert it into XML.
Destiny knows how to process this data accordingly.
When domain names change, all of the users need to be updated. This pipeline asks for user input, demonstrates what actions it will take, updates the user table accordingly, and then outputs a report.
Using --help to discover what inputs this command requires:
The only required parameter is --users:
mbpy pipelines change-email-domain --users studentsThe script will then ask you to input the values needed
It will then download from the API, and present the selected users that will potentially be changed:
As we can see, there are lots of active students with this email address. The next step will display the first 10 and last 10 records that will be changed (not real student information):
That gives us the confidence that it is doing example what we are expecting it to do.
The operation to make the necessary API calls (PATCH on /students/{id} endpoint) commenses, with a progress bar:
Once the operation is complete, it provides a simple table indicating status code:
Finally, it provides a report of all actions that were taken. In a support context, this report can be sent back to schools for confirmation:
Using
Pipelines are like saved queries. They produce a data set that would otherwise be a very long command.
Pipelines come with some built-in, and can be installed via plugins. To see what is currently available:
For example:
Which outputs the following:
Pipelines can also be used with the EXTRACT keyword, which will let allow you to pass it to a loader. Let's load it with our pretty printer PPRINT:
Querying the data using SQL
The mbpy import command extracts the data from APIs, and puts it into an SQLite database.
This database is located inside the mbpy/data folder. Thus, you can use any database client to navigate the table structures, or execute select statements, joins, etc.
This is an example of using a database client to look for students with a particular domain name:
pandas is a tool that mbpy uses to assist with the creation of pipelines.
Using Python means we also access one of the premier data management tools in the industry: . There are major advantages:
Commands that can convert large data sets into pivot tables
Import and export to CSV and Excel
Much simpler and easier to access than SQL commands
The mbpy tool does not require you to learn pandas; instead mbpy provides a suite of sub commands that utilizes dataframes.

mbpy supports being able to write Python code separate from the mbpy codebase.
Since mbpy is a powerful tool that extracts API information, programmers can utilize it for their own custom solutions.
Without impacting the underlying codebase, a simple plugin architecture is available. This section will demonstrate how to create such a plugin. It will show the output of an example plugin that outputs attendance records.
Make a new directory anywhere outside of the mbpy codebase. As long as the contents of the directory follow a consistent format, anything build into this folder will be able to add functionality to the existingmbpy command.
mbpy plugins --help





mbpy pipelines --helpmbpy pipelines enrollment-breakdown count
class_grade_number class_grade gender
2 PS 1 Female 8
Male 2
4 PS 3 Female 7
Male 3
PYP 3 Female 5
Male 3
5 PS 4 Female 7
Male 13
6 PS 5 Female 6
Male 14
7 MS 6 Female 4The value to change (after the @): example.com
The value to change it to: subdomain.example.commbpy \
EXTRACT \
from-pipeline \
--name enrollment-breakdown \
PPRINT class_grade_number │ class_grade │ gender │ count
╶─────────────────────────────┼───────────────────┼────────────┼─────────╴
2 │ PS 1 │ Female │ 8
╶─────────────────────────────┼───────────────────┼────────────┼─────────╴
2 │ PS 1 │ Male │ 2
╶─────────────────────────────┼───────────────────┼────────────┼─────────╴
4 │ PS 3 │ Female │ 7
╶─────────────────────────────┼───────────────────┼────────────┼─────────╴
4 │ PS 3 │ Male │ 3
╶─────────────────────────────┼───────────────────┼────────────┼─────────╴
4 │ PYP 3 │ Female │ 5
╶─────────────────────────────┼───────────────────┼────────────┼─────────╴
4 │ PYP 3 │ Male │ 3
╶─────────────────────────────┼───────────────────┼────────────┼─────────╴
5 │ PS 4 │ Female │ 7
╶─────────────────────────────┼───────────────────┼────────────┼─────────╴
5 │ PS 4 │ Male │ 13
╶─────────────────────────────┼───────────────────┼────────────┼─────────╴
6 │ PS 5 │ Female │ 6
╶─────────────────────────────┼───────────────────┼────────────┼─────────╴
...Use configuration to set up API keys for a school (or multiple schools)
After installation, the next step is to enable mbpy to use a school's API key. In ManageBac, create a token with at least read permissions for all endpoints, and copy the token value. Then:
You will then be promopted to enter the subdomain, and the token.
When complete, enter the following:
The above command reports all configuration information:
Configuration complete! Learn how to pull data from the API by heading over to "Importing":
Whenever mbpy starts up, it uses the indicated configuration file, whose contents include the API token given for mbpy config new.
If you set up more than one school, and need to switch schools, you can use the following command:
That will tell mbpy to use the configuration file associated with the subdomain name you previously input.
To see a list of all subdomains available:
The above demonstrates how to use "implicit" configuration, where mbpy config use selects the subdomain, and then every subsequent mbpy command uses that configuration (until mbpy config use is invoked).
If you would prefer to explicitly indicate which subdoman, you can set the MBPY_SUBDOMAIN variable:
That will instruct mbpy to use the configuration file, the same as if you first did mbpy config use abc.
Once you've imported the data, the most straight-forward thing to do is to run the pre-prepared scripts
mbpy scripts list
You can run each individual script by appending the line output above after mbpy scripts run, like this:
mbpy scripts run students_id --group health_checks
Just use the group name by itself:
mbpy scripts run health_checks
Example output:
These scripts are simply text files in the scripts folder. They can be grouped into directories. They have to end with .mbpy in order to be executed.
The idea is simple:
Write a query with the command line. See "" for how to do that.
Copy the query into a text file that ends with .mbpy
Place it into the appropriate directory
A community solution.
An mbpy plugin, paired with a PowerSchool plugin, that allows a school to synchronize ManageBac with PowerSchool as the source of truth. These plugins have a key complication to solve, which is mapping PowerSchool courses with ManageBac classes.
Directions and source code are available, however please note that some adjustments will probably be required.
mbpy config newmbpy scripts run your_script
This plugin reshapes both homeroom and class attendance data into a format useful for submission to governing authority. Instead of reporting on total tallies, it breaks down all of the attendance information by day.
cd into the folder you cloned into. If you followed the Getting Started with Docker tutorial, be sure to just cd ~/src first.Those two git commands will bring the codebase up to the latest.
If you didn't follow the Further Instructions with Docker, there's one more step needed. Rebuild the image from the new codebase with this:
However, if you are using volumes to execute docker run, this last step above is unnecessary.
git pull origin main
git submodule updatedocker compose up --buildClone the repo (currently not available) into the plugins folder. Then, from the parent folder, with virtual environment enabled:
Check that it works:
The new plugin should be listed.
This requires
pip install --editable src/plugins/mbpy_powerschool_pluginmbpy plugins --helpmbpy config show mbpy configuration
╷
name │ value
╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━┿━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸
subdomain │ demo
╶────────────────────────────┼──────────────────────────────────────────────────╴
token (last 4 digits) │ daa0
╶────────────────────────────┼──────────────────────────────────────────────────╴
Root Directory │ ~/some/path/here
╶────────────────────────────┼──────────────────────────────────────────────────╴
Scripts Directory │ ~/some/path/here/mbpy/scripts
╶────────────────────────────┼──────────────────────────────────────────────────╴
Path to config file │ ~/some/path/here/mbpy/conf/demo.py
╶────────────────────────────┼──────────────────────────────────────────────────╴
Path to database │ ~/some/path/here/mbpy/data/demo.db
╶────────────────────────────┼──────────────────────────────────────────────────╴mbpy config use subdomainmbpy config listmbpy --subdomain abc …Pulling a local copy, and keeping it up-to-date.
The mbpy tool has an import command that will use the APIs to extract the data, putting them into an SQLite database.
The following command will get pull all "core" data from the API:
mbpy import all That will import school, programs, grades, students, teachers, classes, and memberships:
Imported school (Faria International School)
Imported 16 year groups
Imported 344 subjects and 83 groups
Imported 56 total grades across 15 programs
Imported 87 academic years and 174 academic terms
...To pull more even more of the data, you need to know the command for it. You can view all of the available import commands with the --help feature:
mbpy import --helpThis help feature can be used to learn what subcommand can go after "import", as well as any options that are available for the "import" command. The subcommands are organized into groups, such as "Convenience" and "Core Entity Importers":
This help screen informs us that we can import behaviour notes, with the following:
mbpy import behaviour-notesIt also informs us that we can completely import the classes:
And we can find out more information about each command and sub command using --help.
Displays the following help:
Description of plugin architecture
When the directory structure is complete, go to the mbpy folder, and import it with the following command. Remember you should be in your virtual environment:
Check it was successful by inputting the command mbpy plugins --help and seeing the name displayed. It can then be executed by typing mbpy plugins name_of_plugin.
The bare-bones essentials for getting started building a plugin for mbpy, with explanations.
Place the following files into the same directory:
setup.py:The
nameproperty can be anything, rename it to whatever makes sense. Thepy_modulesvalue needs to match the name of the module, which in this example is "example". Theentry_pointsparameter is where the magic happens. It simply maps out that we will be injecting which module, into where. In this case, it's indicating to inject as anmbpy_plugin, so that it'll be available as a subcommand when the user usesmbpy_plugins --helpto discover how to use it. The path is specified asexample=example.cli:cliwhich means "The example module, which has a file calledcli, which has a function defined ascli, and that is the code that will be injected.
example/cli.py:This is the subcommand that will be added to
mbpy pluginscommand. You can specify parameters using the api interface. There are many example of how to do this in the codebase.
example/__init__.pyThis is actually a blank file, but required to exist (as this is how a module is created in Python.)
The above code is truly bare-bones; there is very little that can be actually accomplished without additional imports and boilerplate.
To continue building, please use the following example idioms:
With the code installed the user can execute mbpy plugins your-name --grade 10.
Displays gender enrollment information from the database
This command has no input parameters, although does require student information to first be imported:
mbpy import studentsOnce complete, this command:
mbpy pipelines enrollment-breakdownThis the kind of table displayed:
The actual pipeline code that builds the above table is the following:
The above code has a great deal of boilerplate that may make it difficult to read at first, however, the key lines are 7 and 8. If we format only those two lines, we see some patterns:
Which basically says "extract students whose archived status is False, then execute a pivot table displaying count, broken down by grade and gender.
The above Python code can be represented directly through mbpy commands as well.
Thus, we can extract the students like this:
It outputs just the first item, with columns down the left side:
Let's check out the help for the pivot command:
So we can see how to build the same command on the command line, formatted with \:
A brief demonstration of using mbpy as a data connector for Big Query
There are convenient scripts available to load Big Query with the tables that have been imported into the SQLite database. They live in the scripts folder and can be viewed with
mbpy scripts listThey are displayed in the group load_bq. Executing them without setup will fail, as there is a process associated with ensuring that script itself has access.
First, it should be noted that the target used in this script is TO-BQ and takes some input parameters:
mbpy stream to-bq --helpIn the help file, you will see that the only required parameter is table ID. BigQuery needs the dataset, and . and then the table ID, the part after the dot, in order to load the data. You only need to give it the table ID (the part after the dot) in the script, so where do you supply the dataset?
Environment variables are another way to send paramters, and this is what is used for the TO-BQ command (as well as other targets).
Environment variables are secure ways to send sensitive data in a scripting environment, which allows us to not have to hard code passwords and other sensitive information. In order to execute the script, we need to load these environment variables.
To do that, we create a text file, call it "bq_env_vars.txt", as an example:
Each of those variables (in uppercase) have values (after the = sign) that the script will use. How do we use this file?
That will load the variables into the current environment, and then executing the script will use them.
Usage: mbpy import [OPTIONS] COMMAND [ARGS]...
Subcommands commands available to import all endpoints
╭─ Additional Options ──────────────────────────────────────────────────────────╮
│ ╷ ╷ │
│ --incrementally/--fully │ │ Use --incrementally to use polling to │
│ │ │ consistently keep the database │
│ │ │ updated with latest. Passing --fully │
│ │ │ will clear saved modified_since_dates │
│ │ │ │
│ --include-archived/--active-only │ │ Whether to include archived entities. │
│ │ │ │
│ --help │ │ Show this message and exit. │
│ ╵ ╵ │
╰───────────────────────────────────────────────────────────────────────────────╯
╭─ Convenience ─────────────────────────────────────────────────────────────────╮
│ ╷ │
│ all │ Pull all of the core entities from the API and store into local │
│ │ database. By default, uses incremental approach to get latest │
│ │ changes since previous import. │
│ ╵ │
╰───────────────────────────────────────────────────────────────────────────────╯
╭─ Core Entity Importers ───────────────────────────────────────────────────────╮
│ ╷ │
│ school │ School │
│ school-subjects │ Subject Groups and Subjects │
│ grades │ Import grades (and programs) │
│ year-groups │ Year Groups │
│ academic-sets │ Academic Years and Terms │
│ memberships │ Memberships │
│ students │ Students │
│ teachers │ Teachers │
│ parents │ Parents │
│ classes │ Classes │
│ ╵ │
╰───────────────────────────────────────────────────────────────────────────────╯
╭─ Non-core Entity Importers ───────────────────────────────────────────────────╮
│ ╷ │
│ behaviour-notes │ Behaviour Notes (depends: None) │
│ relationships │ Parent/Child relationships (depends: Student, Parent) │
│ term-grades │ Term Garades (depends: Classes, AcademicSets) │
│ class-timetables │ Attendance settings in class, when the class occurs │
│ │ during rotation │
│ tasks │ Tasks inputted by teachers in each class (depends: │
│ │ Class) │
│ ╵ │
╰───────────────────────────────────────────────────────────────────────────────╯
╭─ Bulk Importers ──────────────────────────────────────────────────────────────╮
│ ╷ │
│ class-attendance-bydates │ Class attendance per dates │
│ class-attendance-byterm │ Class Attendance │
│ homeroom-attendance-bydates │ Homeroom attendance per dates (depends: │
│ │ Class, Academic Year, Year Group) │
│ homeroom-attendance-byterms │ Homeroom Attendance (depends: Class, Academic │
│ │ Year, Year Group) │
│ tasks-and-results │ Tasks and task results (depends: Classes) │
│ daily-attendance │ Not yet implemented │
│ ╵ │
╰───────────────────────────────────────────────────────────────────────────────╯pip install --editable /path/to/mbpy_plugin_example@click.command('enrollment-breakdown')
@pass_pipeline_context
def pipeline_enrollment_breakdown(ctx):
"""
Table of active students by gender and grade
"""
with ctx.invoker('extract_from_db', table_name='students', where=['archived', False]) as cmd:
cmd('pivot', values=('id'), index=(['class_grade_number', 'class_grade', 'gender']), aggfunc='count') cmd('rename', columns=paired_to_tuple(['id', 'count']))
cmd('print', title='Breakdown by gender', caption='Actual values will vary')with ctx.invoker('extract_from_db',
table_name='students',
where=['archived', False]) as cmd:
cmd(
'pivot',
values=('id'),
index=(['class_grade_number', 'class_grade', 'gender']),
aggfunc='count'
)mbpy import --fully classesmbpy import class-attendance-bydates --help Usage: mbpy import class-attendance-bydates [OPTIONS]
Class attendance per dates
╭─ Additional Options ──────────────────────────────────────────────────────────╮
│ ╷ ╷ ╷ │
│ * │ --start-date │ -s │ (%Y-%m-%d) [required] │
│ │ │ │ │
│ │ --end-date │ -e │ (%Y-%m-%d) │
│ │ │ │ │
│ │ --weekend │ -w │ (INTEGER) │
│ │ │ │ │
│ │ --help │ │ Show this message and exit. │
│ ╵ ╵ ╵ │
╰───────────────────────────────────────────────────────────────────────────────╯export BQ_DATASET_ID=dataset_id
export BQ_CREDENTIALS_FILE=~/path/to/credentials.json
export BQ_PROJECT_ID=project_id source /path/to/bq_env_vars.txtfrom setuptools import setup
setup(
name='example',
version='0.1',
py_modules=['example'],
install_requires=[
'click',
],
entry_points='''
[mbpy_plugins]
example=example.cli:cli
''',
)import click
@click.command('example')
def cli():
"""
Example Plugin!
"""
passimport click
from mbpy.db.schema import Student # to query Student table
@click.command('your-name')
@click.option('-g', '--grade', 'grade_variable', type=int)
@click.pass_context
def cli(ctx, grade_variable):
""" Show students in the passed grade """
with ctx.obj.Session() as session:
students = session.query(Student) \
.where(Student.class_grade_number == grade_number) \
.all()
print(students)mbpy extract from-db --help
mbpy extract from-db --table-name students --where archived False shapembpy extract from-db --table-name students --where archived False shape pivot --helpmbpy extract from-db \
--table-name students \
--where archived False \
shape \
pivot \
--values id \
--index class_grade_number \
--index class_grade \
--index gender \
print


Creating convenient scripts
The prevoius section showed how to use the command line to query the database and get some output on the terminal and save it as a CSV:
Querying the DatabaseHowever, re-using the query later would be quite a hassle to have to type it again. In this section, we will use the mbpy script group of commands save our query and easily re-execute it.
mbpy scripts --help
╭─ Commands ────────────────────────────────────────────────────────────────────────╮
│ list Show all saved queries │
│ run Execute a saved query │
╰───────────────────────────────────────────────────────────────────────────────────╯By default, it comes with a few useful scripts, which we can view:
The last one outputs this:
mbpy scripts run school
│ timezone │ subdomain │ name
╶──────┼────────────────────────────────────┼────────────────────┼──────────────────╴
0 │ Asia/Kuala_Lumpur │ f1 │ FariaOneThe search-students-by-name looks interesting, let's try it:
These scripts we are running are actually simple text files whose contents have queries built just like in the previous section. For example, the search-students-by-name script is this:
executes this:
The above also shows an additional instrumentation that is available for these saved queries, which is that you can use the {0} and {1} to refer to additional arguments passed to the script. If you pass don't pass any, or there is some other problem, mbpy will show you with a useful error message:
You can also associate multiple scripts with one command, using the --group feature. For example, there is a "health check" group that performs three queries, and outputs the results. You can execute all of them this way:
It output enough information to let me see that every student has a student_id assigned, but there are two students in Grade 1, that aren't archived, that do not have any parents associated. Also, there are two other students that don't have a homeroom teacher, nor a year group associated. Better fix that.
In order to add a script to our command, we simply navigate to the src/scripts directory in the codebase, and add the text file there. If you want to group more than one command under one group, simply create a directory.
This is the current structure:
Each script needs to end with .mbpy in order to be detected by the application.
After importing, the data can be queried through the command line. For example:
mbpy EXTRACT from-users PRINTThat will produce screens that display the first 5, and last 5 student records, and every column in the student table for those records.
id role oa_id sb_id account_uid
0 10752543 Admin None None None \
1 10752544 Admin None None None
2 10752545 Advisor None None None
3 10752546 Admin None None None
4 10752547 Advisor None None None Scroll down to see more columns for these records. (Notice the \ indicates there is wraparound.)
These commands can be used to just get data displayed, but it becomes interesting when paired with available commands to perform manipulations. For example, to find the distribution of users' domains, we could do the following:
This school is supposed to have only eduvo.com accounts. Looks like we have a few users with wrong email addresses! Let's find them:
Let's see what we got:
Let's limit to only those that are active (not archived), and let's give ourselves a link we can click:
Command-click to load the url in the browser!
The structure of these commands are the following:
To learn more about how to query with extractors, continue to Querying Commands:
mbpy scripts list
╭─ Scripts ─────────────────────────────────────────────────────────────────────────╮
│ students_id --group health-checks │
│ students_parents --group health-checks │
│ students_grade_year_group --group health-checks │
│ reflections --group export-helpers │
│ search-students-by-name │
│ school │
╰───────────────────────────────────────────────────────────────────────────────────╯mbpy scripts run search-students-by-name Happy
Column info for dataframe
╷ ╷ ╷
│ type │ count non-null │ row 0
╶───────────────────────┼─────────┼────────────────┼────────────────────────────────╴
role │ object │ 1 │ Student
timezone │ object │ 1 │ Asia/Kuala_Lumpur
graduated_on │ object │ 0 │ None
withdrawn_on │ object │ 0 │ None
full_name │ object │ 1 │ Happy Student
student_id │ object │ 1 │ HappyStudent
state │ object │ 0 │ None
display_name │ object │ 1 │ Student, Happy
year_group_id │ float64 │ 1 │ 12014897.0
first_name │ object │ 1 │ Happy
graduating_year │ int64 │ 1 │ 2023
updated_at │ object │ 1 │ 2022-11-16T16:21:45.000+08:00mbpy scripts run search-students-by-name Happymbpy \
EXTRACT from-db --table students \
PRINT \
query --where 'display_name.str.contains("{0}")'mbpy scripts run search-students-by-name
mbpy \
EXTRACT from-db --table students \
PRINT \
query --where 'display_name.str.contains("{0}")'
╭─ Index Error ─────────────────────────────────────────────────────────────────────╮
│ The script is expecting arguments. Please review the code above. │
╰───────────────────────────────────────────────────────────────────────────────────╯print or pprint or csv
mbpy EXTRACT from-students --help to see full list of Loaders.
<chain>
Peform manipulations after extraction.
Optional.
<MODE>
Either EXTRACT or STREAM
In extraction mode, it loads all the information into memory. In stream mode, it loads by chunks.
<extractor>
from-students, from-classes, etc.
mbpy EXTRACT --help to find full list
<streamer>
See "Streaming" section
mbpy STREAM --help to find full list
<LOADER>
mbpy scripts run health-checks
Students without student_id
╶───────────────────────────────────────────────────────────────────────────────────╴
Empty table
0 records with 5 columns
Active students with no parents assigned
╷ ╷ ╷ ╷ ╷
│ mb_id │ grade_number │ archived │ student │ has_parent
╶──────┼──────────┼──────────────┼──────────┼──────────────────────────┼────────────╴
173 │ 14351728 │ 2 │ False │ Rogers, Norville │ False
1914 │ 14553487 │ 2 │ False │ Student1, Test (Wally) │ False
│ │ │ │ Walter │
╵ ╵ ╵ ╵ ╵
2 records with 5 columns
Students with either no grade, no year group, or no hr teacher
╷ ╷ ╷ ╷ ╷ ╷
│ mb_id │ archived │ student │ has_hr_teac… │ has_grade │ has_year_g…
╶─────┼──────────┼──────────┼──────────────┼──────────────┼───────────┼─────────────╴
1 │ 14235240 │ True │ Student, │ False │ True │ False
│ │ │ Happier │ │ │
958 │ 14553487 │ False │ Student1, │ False │ True │ False
│ │ │ Test (Wally) │ │ │
│ │ │ Walter │ │ │
╵ ╵ ╵ ╵ ╵ ╵mbpy/
├─ scripts/
│ ├─ school.mbpy
│ ├─ search-students-by-name.mbpy
│ ├─ health-checks/
│ │ ├─ students_grade_year_group.mbpy
│ │ ├─ students_id.mbpy
│ │ ├─ students_parents.mbpymbpy \
EXTRACT \
from-users \
--fields email \
INFO \
--value-count domain \
extract-on \
--column email \
--pattern '^(?P<handle>.+)@(?P<domain>.+)$' domain count percent
0 eduvo.com 332 97.076023
1 endvo.com 3 0.877193
2 gmail.com 2 0.584795
3 icio.us 1 0.292398
4 mediafire.com 1 0.292398mbpy \
EXTRACT \
from-users \
--fields id,email,role,archived \
PRINT \
query \
--where 'not email.str.endswith("@eduvo.com")' id email role archived
0 10752543 [email protected] Admin True
34 10752652 [email protected] Student False
50 10752674 [email protected] Admin True
51 10752675 [email protected] Student False
145 10947491 [email protected] Parent True
166 10947512 [email protected] Parent False
167 10947513 [email protected] Parent False
174 10947520 [email protected] Parent True
175 10947521 [email protected] Parent True
530 10947916 [email protected] Student True
531 10947917 [email protected] Student True
...mbpy \
EXTRACT \
from-users \
--fields email,profile \
--where archived b= false \
PRINT \
query \
--where 'not email.str.endswith("@eduvo.com")' email profile
17 [email protected] https://demo.managebac.com/teacher/users/10752652
26 [email protected] https://demo.managebac.com/teacher/users/10752675
58 [email protected] https://demo.managebac.com/teacher/users/10947512
59 [email protected] https://demo.managebac.com/teacher/users/10947513
149 [email protected] https://demo.managebac.com/teacher/users/10947922
150 [email protected] https://demo.managebac.com/teacher/users/10947923
151 [email protected] https://demo.managebac.com/teacher/users/10947924
152 [email protected] https://demo.managebac.com/teacher/users/10947925
153 [email protected] https://demo.managebac.com/teacher/users/10947926
199 [email protected] https://demo.managebac.com/teacher/users/10947975
523 [email protected] https://demo.managebac.com/teacher/users/10948377
524 [email protected] https://demo.managebac.com/teacher/users/10948386
525 [email protected] https://demo.managebac.com/teacher/users/10948389
530 [email protected] https://demo.managebac.com/teacher/users/10948406mbpy <MODE> <extractor/streamer> <LOADER> [<chain> <chain> ...]Please refer to the below API methods, which contain responses, for examples of the JSON returned by the plugin.
GET {baseUrl}/ws/schema/query/mk.ManageBac_Stu
GET {baseUrl}/ws/schema/query/mk.ManageBac_Tea
GET {baseUrl}/ws/schema/query/mk.ManageBac_Par
GET {baseUrl}/ws/schema/query/mk.ManageBac_Stu_Class
pagesize
Integer
page
Integer
pagesize
Integer
page
Integer
pagesize
Integer
page
Integer
pagesize
Integer
page
Integer
{
"records": [
{
"tables": {
"u_student_additionals": {
"nickname": "string | null",
"enrollmentdate": "YYYY-MM-DD"
},
"students": {
"dateofbirth": "YYYY-MM-DD",
"middle_name": "string",
"gender": "F", // one character or null
"first_name": "string",
"email": "string",
"yeargroup": "string",
"grade_program": "string",
"student_number": "string", // "123456"
"last_name": "string",
"grade": "string"
},
"u_country_codes": {
"nat": "AB"
}
}
}
],
"extensions": ""
}{
"records": [
{
"tables": {
"teachers": {
"middle_name": "string or null",
"id": "string",
"first_name": "string",
"last_name": "string"
}
}
}
],
"extensions": ""
}{
"records": [
{
"tables": {
"u_student_additionals": {
"father_private_email": "string",
"mother_school_email": "string",
"father_school_email": "string",
"mother_private_email": "string"
},
"students": {
"student_web_id": "string",
"first_name": "string",
"mother_first_name": "string",
"mother_last_name": "string",
"grade_level": "string", // -2, 1, 0, 1, 2, 3
"student_number": "12345",
"father_first_name": "string",
"father_last_name": "string",
"last_name": "string"
}
}
}
],
"extensions": ""
}{
"record": [
{
"tables": {
"students": {
"student_number": "123456"
},
"sections": {
"class_id": "string, including exp",
"section_number": "1"
}
}
}
]
}Automatically email a breakdown of statuses every day
With the following input:
The date for which to report on, ex 2023-02-15
A subject for the email, ex "Daily attendance report"
List of comma-seperated emails to send to
An HTML template
Credentials to use smtp connection
Host, username, password, port
For a Google account:
Create an
Can email the following sample output:
The script to run the daily attendance report is the following:
Where:
{0} is the date for which to report on
{1} is the Subject of the email
{2} is the /path/to/template.html file
For example:
You will notice the above command does not include any SMTP server information. The way to provide these inputs is via shell environment variables. The steps are:
Create a file with the below contents
Replace those values with your real values
Save to a path location
Load those environment variables into the shell, with source /path/to/file
Example file:
If you are using Google and would like to login as a user, go to Manage Account —> Security —> App passwords. This is the password to use above, not your user login. Help article on .
One of the inputs is a path to a template file. You can either use the simple one for testing, or you can customize it extensively. In addition, you can find the template used to generate the sample above below "Default template".
The template is written in , which is a cross between html and Python. It is a convenient way to include both presentation information, such as colors, and logical parameters, such as for loops, into the same file.
This will render a simple html table under the Testing header.
The following uses the business logic possible with python-like syntax, such as for loops, along with style tags.
Please see customization below to tweak or bulid your own.
The above describes how to execute it from the command line directly. To run it every day, a cron job can be installed. Instructions for this task can depend on which system you are using, but briefly:
Use crontab -e to edit the cron table. Every line in the file is one task
A cron job executes in a secure environment, different from the command line directly. That means you need to write the full path.
Here is an example of the daily attendance report being sent every weekday at 4:05 pm:
The template can be coded in , which just means you can use both html, css, and a simple python-like syntax to build the presentation and the business logic needed for the intended output.
Please note that styling information in an email with html is not the same as doing so in the browser. Here is one example of that explains some of the specific techniques to use when styling email with hand-written html.
The variable data is available in the template file, that has the following keys:
The mbpy run scripts daily_homeroom_attendance is actually short-hand script for a longer, harder-to-type sequence. Users can re-write for their own use cases.
{3} list of emails
The raw pandas dataframe.
data.subject
The subject passed via the script as a string
{{ data.subject }}
data.today
Python datetime.today instance of today's date according to the host server
data.table
The data converted into html via pandas.
{{ data.table }}

data.df
mbpy \
SCRIPTS \
RUN daily_attendance_report \
{0} \
{1} \
{2} \
{3}mbpy \
SCRIPTS \
RUN daily_attendance_report \
2023-02-15 \
'The Subject' \
/path/to/template/file.html \
[email protected],[email protected]export SMTP_HOST=smtp.example.com
export SMTP_USER=username
export SMTP_PASSWORD=secret
export SMTP_PORT=465
export SMTP_TLS=yes<h1>Testing</h1>
{{ data.table }}<html>
<body style="font-family: Arial, Helvetica, sans-serif;">
<h2>{{ data.subject }}</h2>
<h3>Generated on {{ data.today.strftime("%A %e %B, %Y") }} (for {{ data.attrs.days }} day(s), from {{ data.attrs.start_date.date() }} to {{ data.attrs.end_date.date() }})</h3>
<p>Here is this week's attendance report for students. Please find the table below with the attendance status for each grade:</p>
<table style="border: 1px solid black; border-collapse: collapse;">
<thead>
<tr style="border-top: 1px solid black;">
<th style="font-weight: 900; padding: 10px; color: #eee; background-color: #999;">{{ data.df.index.name }}</th>
{% for column_name in data.df.columns -%}
<td style="font-weight: 900; padding: 10px; color: #eee; background-color: #999;">{{ column_name }}</td>
{% endfor -%}
</tr>
</thead>
<tbody>
{% set values = data.df.values -%}
{% for grade in data.df.index -%}
<tr style="border-top: 1px solid black">
{% if grade == 'Total' -%}
<th style="font-size:smaller; padding: 10px;background-color: #FF9300;">{{ grade }}</th>
{% else -%}
<th style="font-size:smaller; padding: 10px;background-color: #FFC600;">{{ grade }}</th>
{% endif -%}
{% set row_index = loop.index0 -%}
{% set row = values[row_index] -%}
{% for value in row -%}
{% if grade == 'Total' -%}
<td style="text-align: center; padding: 10px; font-weight: 900; background-color: #FF9300;">{{ value }}</td>
{% else -%}
<td style="text-align: center;padding: 10px;{{ 'font-style: italic;background-color: #eee;' if loop.index == 1 else ''}}">{{ value }}</td>
{% endif -%}
{% endfor -%}
</tr>
{% endfor -%}
</tbody>
</table>
<p>Thank you.</p>
</body>
</html>05 16 * * 1,2,3,4,5 . /path/to/smtp.txt && /path/to/venv/mbpy scripts run daily_homeroom_attendance $(date -I) 'Daily Attendance Report' /path/to/template/file.html '[email protected],[email protected]'{{ data.today.strftime("%A %e %B, %Y") }}mbpy \
EXTRACT \
FROM-HR-ATTENDANCE --start-date {0} \
--end-date {0} \
SMTP --subject '{1}' \
--template '{2}' \
--to '{3}' \
GROUP-BY-PIVOT --margins-name Total \
--value 'Student Id' \
--column Status \
--row Grade \
--row 'Grade #' \
ZERO-NAS \
SPREADSHEET --sort-row 'Grade #' \
--sort-column Total \
--margins-name Total \
--rows-ascending
--columns-descending
--astype int64We'll use the from-db command to demonstrate how it works, but as you can see there are some other powerful features.
There is extensive help documentation included with mbpy, available here:
Let's find out how to use this command:
We just have to pass it a --table option that includes the name of the entity we are seeking, in this case classes. Since we are starting to build complicated commands, let's use the \ so we can format using line breaks and spaces, making it easier to read:
The above output is the default when plain target INFO is used. It selected all the classes that have been imported and converted it into a pandas dataframe. Since we haven't told it to do anything other than that, the default action of displaying info about the dataframe, such as the column names, types, and count of non-null, is shown. The values for the first selected item is shown in "row 0" column.
There's simply too much information, so let's limiting the columns we are displaying, and filtering for just diploma classes:
Okay, what about querying to find all the high-level classes? The subject column has the key hl, but how do we access to query it?
This is where the power of tranformations comes in. Just like the query command after the chain command, mbpy has a slew of operations that can be performed on the query. There is a spread command that will make a new column for every key, and each column will be called subject.hl and subject.levels etc.
So now we can filter for hl classes, and let's arrange the columns in the order we wish to be output, and limit them to just the ones we're interested in:
Not only did we get this output in the terminal, but it saved to CSV as well.
To learn more about what kinds of commands are available, use the --help feature. The following shows the help files for every command issued on this page:
The next section will demonstrate how to save this query for easy execution.
If you are interested in learning more about the extract … chain … formula, skip to this section:
{% for column_name in data.df.index -%}
<tr>
<th>{{ column_name }}</th>
</tr>
{% endfor %}mbpy extract --help
Usage: mbpy extract [OPTIONS] COMMAND [ARGS]...
Start ELT sequence. See mbpy extract --help for extaction commands
╭─ Commands ────────────────────────────────────────────────────────────────────────╮
│ from-api Pass a name like students, classes, academic_info_grades │
│ from-csv Define the path to csv file │
│ from-db Select entities (and associated entities), fields │
│ from-db-connect use raw sql │
│ from-excel Define the path to csv file │ │
│ from-external-api Call out to a list endpoint. Default values work for OA V3 │
│ students list endpoint. For OA, use --bearer and pass in the │
│ bearer via environment variable token after /oauth/token call. │
│ FIXME: Add this here │
│ from-pipeline Use pipelines as an extraction tool for more sophisticated │
│ data management. │
╰───────────────────────────────────────────────────────────────────────────────────╯mbpy extract from-db --help
╭─ Additional Options ──────────────────────────────────────────────────────────────╮
│ * --table -t (academic_terms|academic_years|classes|class_attendance_byterm|c │
│ lass_attendance_bydate|class_timetable|hr_attendance_byterm|hr_a │
│ ttendance_bydate|memberships|modified_since_dates|parents|parent │
│ child_relationships|school|school_subjects|students|subject_grou │
│ p|tasks|task_results|teachers|term_grades|year_groups) │
│ [required] │
│ --fields -f Fields of the entity to include │
│ (TEXT) │
│ --limit -l Define small number for faster dev │
│ (INTEGER) │
│ --help Show this message and exit. │
╰───────────────────────────────────────────────────────────────────────────────────╯mbpy \
EXTRACT from-db \
--table classes \
INFO
Column info for dataframe
╷ ╷ ╷
│ type │ count non-null │ row 0
╶───────────────────────┼─────────┼────────────────┼────────────────────────────────╴
id │ int64 │ 366 │ 11933215
subject_id │ float64 │ 365 │ 4509.0
end_term_id │ int64 │ 366 │ 180452
start_term_id │ int64 │ 366 │ 180451
program_code │ object │ 366 │ diploma
grade_number │ int64 │ 366 │ 13
subject_group_id │ float64 │ 365 │ 1709.0
...
subject │ object │ 335 │ {'name': 'English', 'sl':
│ │ │ True, 'self_taught': False,
│ │ │ 'group_id': 1709, 'group':
│ │ │ 'Language Acquisition', 'hl':
│ │ │ True, 'program_code':
│ │ │ 'diploma', 'levels': ['ab
│ │ │ initio', 'B'], 'id': 4509}
reporting_name │ object │ 366 │ Happy English Class
mbpy \
EXTRACT from-db \
--table classes \
--field-mode select \
--fields uniq_id,program_code,grade_number,subject \
--limit 3 \
PRINT \
query \
--where 'program_code == "diploma"' \
╷ ╷ ╷ ╷
│ uniq_id │ subject │ grade_number │ program_code
╶───┼─────────────────────┼───────────────────────────┼──────────────┼──────────────╴
0 │ happy_english_class │ {'hl': True, 'name': │ 13 │ diploma
│ │ 'English', 'group_id': │ │
│ │ 1709, 'levels': ['ab │ │
│ │ initio', 'B'], │ │
│ │ 'program_code': │ │
│ │ 'diploma', 'sl': True, │ │
│ │ 'id': 4509, │ │
│ │ 'self_taught': False, │ │
│ │ 'group': 'Language │ │
│ │ Acquisition'} │ │
╵ ╵ ╵ ╵mbpy \
EXTRACT from-db \
--table classes \
--fields uniq_id,program_code,grade_number,subject \
--limit 3 \
INFO \
spread
Column info for dataframe
╷ ╷ ╷
│ type │ count non-null │ row 0
╶──────────────────────────┼──────────┼───────────────────┼─────────────────────────╴
subject.id │ int64 │ 3 │ 4509
subject.self_taught │ object │ 1 │ False
subject.hl │ bool │ 3 │ True
subject.group_id │ int64 │ 3 │ 1709
subject.group │ object │ 3 │ Language Acquisition
subject.levels │ object │ 1 │ ['ab initio', 'B']
subject.program_code │ object │ 3 │ diploma
subject.sl │ bool │ 3 │ True
subject.name │ object │ 3 │ English
uniq_id │ object │ 3 │ happy_english_class
program_code │ object │ 3 │ diploma
grade_number │ int64 │ 3 │ 13mbpy \ git:main*
EXTRACT from-db \
--table classes \
--fields uniq_id,program_code,grade_number,subject \
--limit 3 \
PRINT \
spread \
query --where '`subject.hl` == True' \
keep -c uniq_id -c subject.hl -c program_code -c grade_number \
to-csv --path /tmp/output.csv
╷ ╷ ╷ ╷
│ uniq_id │ subject.hl │ program_code │ grade_number
╶────┼──────────────────────────┼───────────────┼─────────────────┼─────────────────╴
0 │ happy_english_class │ True │ diploma │ 13
╵ ╵ ╵ ╵mbpy extract from-db --help
mbpy extract - chain --help
mbpy extract - chain query --help
mbpy extract - chain print --help
mbpy extract - chain spread --help
mbpy extract - chain keep --help
mbpy extract - to-csv keep --helpTo query data after importing, there is the following pattern:
The extractor's job is to load the initial set of data. In most cases, the extractor used is from-db which queries the SQLite database that contains imported datasets, however there are many others, as can be seen by running mbpy extract --help:
Each extractor has different paramters, which can be discovered through --help:
The <LOADER> part of the sequence is the exporting functionality. It can output to screen, save a CSV, or even send an email.
Use the above help command to see a list of targets available.
The tail of the sequence can be "chained" together. Each of these commands accepts as input the data set from the previous command, performs some manipulation, and then outputs the resulting changed dataset.
Help for all available chain commands can be viewed using the following with the --help option.
In order to learn more about each chain command, you can use, for example with the explode chain command:
In this way, it is possible to build up a small command-line data flow.
mbpy <MODE> <extractor> <LOADER> [<chain1> <chain2> <chainN> …]╭─ Extractors ───────────────────────────────────────────────────────────────────╮
│ from-csv Define the path to csv file │
│ from-excel Define the path to csv file │
│ from-excel-dl Extract dataframes from excel downloads available in │
│ ManageBac. Algorithm auto-detects columns and adds title │
│ column │
│ from-db-connect use raw sql │
│ from-db Select entities (and associated entities), fields │
│ from-pipeline Use pipelines as an extraction tool for more sophisticated │
│ data management. │
│ from-api Pass a name like students, classes, academic_info_grades │
│ from-external-api Call out to a list endpoint. Default values work for OA V3 │
│ students list endpoint. For OA, use --bearer and pass in │
│ the bearer via environment variable token after │
│ /oauth/token call. FIXME: Add this here │
╰────────────────────────────────────────────────────────────────────────────────╯mbpy extract from-db --help
╭─ Additional Options ───────────────────────────────────────────────────────────╮
│ * --table -t (academic_terms|academic_years|classes|class_attendance_byter │
│ m|class_attendance_bydate|class_timetable|hr_attendance_byter │
│ m|hr_attendance_bydate|memberships|modified_since_dates|paren │
│ ts|parentchild_relationships|school|school_subjects|students| │
│ subject_group|tasks|task_results|teachers|term_grades|year_gr │
│ oups) │
│ [required] │
│ --fields -f Fields of the entity to include │
│ (TEXT) │
│ --limit -l Define small number for faster dev │
│ (INTEGER) │
│ --help Show this message and exit. │
╰────────────────────────────────────────────────────────────────────────────────╯mbpy extract - --helpmbpy extract - <target> --help
mbpy extract - print --help
mbpy extract - overview --helpmbpy extract - print explode --help╭─ Grouping, aggregation, and transformations ───────────────────────────────────╮
│ cast Make the specified column the same values, but as │
│ strings │
│ collapse-to-json-column Collapses each row to one column with key/values. │
│ columns Specify the order and rename columns in one │
│ command │
│ convert-values Map an entire column values, those not specified │
│ will become null. │
│ dottie Expand the dataframe through a dot-specified path │
│ of columns. │
│ explode Create additional identical rows for every item │
│ indicated in the column provided. │
│ explode-all For any column that have at least one list item, │
│ expand │
│ implode Opposite of explode. Takes the values in column │
│ that will roll up to lists. The other columns by │
│ default take the first value. │
│ keep Delineate the columns to keep. Easy way to │
│ remove unnecessary columns. │
│ normalize Convert a string column using a string mapper, │
│ such as str.lower() │
│ remove Deletes columns. │
│ rename Rename columns │
│ rename-prefix Add a prefix to all of the specified columns │
│ replace Replace all values to a specific value │
│ replace-nulls-with-booleans Replace any nulls with a boolean, for example │
│ False │
│ replace-with-nulls Pass in values that should be converted to None. │
│ Effects the entire input dataframe. │
│ spread Adds additional columns for every top-level │
│ property found as json values. This works by │
│ converting the dataframe to records, then │
│ flatdict which concats the properties in │
│ dot-seperated naming convention. │
│ spread-attendance Same as spread, but with class attendance │
│ responses, re-shapes to avoid sparse dictionaries │
│ to-csv Exports the current dataframe to disk. │
│ to-excel Output dataframe as an excel file. The path has │
│ to end with .xlsx (due to implementation). │
│ transpose Transpose the df. │
│ trim-whitespace Remove whitespace on either left or right side │
╰────────────────────────────────────────────────────────────────────────────────╯╭─ Merge, join, and concatenation ───────────────────────────────────────────────╮
│ add-country Add a new column with the full country name, according to the │
│ column specified. │
│ add-row Manually specify a row to add │
│ api-call Use dataframe as input to build API calls. │
│ api-merge Merge data from entity in an api call into the dataframe by │
│ indicating left column(s) and right column(s) as indexes. │
│ count Count of how many non-null values in each column │
│ csv-merge Read in a csv from a path, merge it with the dataframe. │
│ db-merge Attach data to current dataframe by adding in a table from the │
│ db. │
│ split-on Change the column to a list, splitting on delimiter provided │
│ (comma by default) │
╰────────────────────────────────────────────────────────────────────────────────╯╭─ Selection and Filtering ──────────────────────────────────────────────────────╮
│ drop-columns-nulls Drop all columns that are full of nulls │
│ drop-duplicates Remove duplicates as determined by columns │
│ drop-nulls Filter out any rows from the input dataframe that contain │
│ null in the specified column. │
│ duplicates Remove duplicated items from the df, either throughout or │
│ only those found in certain columns. │
│ extract-duplicates Remove all rows except duplicated items, either throughout │
│ or only those found in certain columns. │
│ filter Select the rows which columns have the value indicated, │
│ using operation (by default, equals). │
│ filters_by Specify multiple filters. See query for a more pythonic │
│ interface. │
│ head Transform to only the first n number of rows │
│ is-false Select rows where passed column are False │
│ is-null Select rows where passed columns are None │
│ is-true Select rows where passed columns are True │
│ not-null Select rows where passed columns are not None │
│ query Query the df with pythonic syntax. Column names are │
│ variables, use backticks for columns with spaces. The @ │
│ symbol has special meaning (evaluate a local variable) so │
│ if you are filtering by email address, use FILTER --column │
│ email --value [email protected] instead. │
│ reindex Change the indexes of the df │
│ reset-index Removes all indexes. │
│ sort Change the order of the df according to the values in │
│ specified columns. │
│ tail Transform to only the first n number of rows │
╰────────────────────────────────────────────────────────────────────────────────╯╭─ Reshaping and pivoting ───────────────────────────────────────────────────────╮
│ assign Make a new column with values built from values from the row │
│ itself. Useful for defining a "full name" column, for │
│ example. │
│ extract-on Use regular expression named group. │
│ frequencies Count of each values provided by columns. │
│ group-by-pivot Group by rows and pivot on columns with values. │
│ inc Create a new column that is calculated from simple math on │
│ column of values. │
│ list-to-columns Given the column whose values are a list, create new columns │
│ for each item inside. Specify the name of these new columns. │
│ pivot Reshape to a pivot table using aggregator, including summary │
│ row │
│ product Create a dataframe that includes sequence of dates │
│ set Create a new column with values. │
│ set-index Sets the dataframes indexes to those passed │
╰────────────────────────────────────────────────────────────────────────────────╯╭─ Output ───────────────────────────────────────────────────────────────────────╮
│ print Prints the current dataframe. │
│ send-email Send email with the dataframe as an attachment. Connects to smtp │
│ server, and thus requires credentials. │
│ summary Print the datatable in a small-screen format. Columns across │
│ become rows (transpose), and only the first three records are │
│ shown. Can be used to display steps. Returns the original df; no │
│ transform is passed to next processor. │
│ to-csv Exports the current dataframe to disk. │
│ to-excel Output dataframe as an excel file. The path has to end with .xlsx │
│ (due to implementation). │
╰────────────────────────────────────────────────────────────────────────────────╯╭─ Debugging and Inspection ─────────────────────────────────────────────────────╮
│ confirm Pauses and waits for the user to confim │
│ info Display columns and number of null values, and by default the values │
│ of the first row. Particularly useful to get sense of the │
│ information. │
│ interact Fire up python and interface with df directly. Useful for │
│ troubleshooting. │
╰────────────────────────────────────────────────────────────────────────────────╯╭─ Miscellaneous ────────────────────────────────────────────────────────────────╮
│ add-rows-empty-slots Creates a new dataframe that has a has many rows for │
│ every value in slots for each value in the column │
│ specified by key. Rows that exist in original are then │
│ placed into it. │
│ New rows will be populated with values whenever there │
│ are unique. │
│ pure-python Define a function in code that performs the desired │
│ transformation. Can only be used in pipeline context. │
│ subprocess Using values from the input dataframe, execute a │
│ subprocess for each row. │
│ new-df Create fresh new dataframe. Use it to build report at │
│ end. │
│ to-dict │
│ store Save the current dataframe for later. │
│ restore Select the previously stored dataframe as the current │
│ one. │
│ load-csv Load a csv, dropping the input dataframe │
╰────────────────────────────────────────────────────────────────────────────────╯This plugin was written by Michal Klobukowski and kindly made available for download.
This plugin is provided for reference, and is likely to require additional modifications, depending on your PowerSchool setup.
There is also some accompanying documentation from PowerSchool that helps to understand the approach.
In order to understand what the plugin produces, please see the following API reference documnetation:
"""
Provided for learning purposes. This actually needs
to be wrapped into a click command line application (and other tweaks) for it
to be executed in the intended context
"""
BASE_URL = "https://domain.example.com/path"
CLIENT_ID = "secret"
CLIENT_SECRET = "secret"
# These globals should be passed via environment variables (not demonstrated here)
import pdb
import click
from uplink import Consumer, RequestsClient, Body, Path, Query, post, returns, headers
from types import SimpleNamespace
import re
import datetime
from collections import defaultdict
from mbpy_endpoints.endpoints import Endpoint
from json.decoder import JSONDecodeError
import pandas as pd
import flatdict
import os
def dot(data):
if type(data) is list:
return list(map(dot, data))
elif type(data) is dict:
sns = SimpleNamespace()
for key, value in data.items():
setattr(sns, key, dot(value))
return sns
else:
return data
def get_dotted_path(data: dict, path: str, default=None):
pathList = re.split(r"\.", path, flags=re.IGNORECASE)
result = data
for key in pathList:
try:
result = result[key]
except:
result = default
break
return result
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import smtplib
import io, ssl
def export_csv(df):
with io.StringIO() as buffer:
df.to_csv(buffer, index=False)
return buffer.getvalue()
def send_email(from_, send_to, subject, body, password, *dataframes):
multipart = MIMEMultipart()
multipart["From"] = from_
multipart["To"] = ",".join(send_to)
multipart["Subject"] = subject
for filename, df in dataframes:
attachment = MIMEApplication(export_csv(df), Name=filename)
attachment["Content-Disposition"] = f'attachment; filename="{filename}"'
multipart.attach(attachment)
multipart.add_header("Content-Type", "text/plain")
multipart.attach(MIMEText(body, "plain"))
context = ssl.create_default_context()
data = multipart.as_bytes()
with smtplib.SMTP_SSL("smtp.gmail.com", 465, context=context) as email:
email.login(from_, password)
email.sendmail(from_, send_to, data)
@headers({"Content-Type": "application/x-www-form-urlencoded"})
class GetToken(Consumer):
def __init__(self, client_id, client_secret, client=RequestsClient):
base_url = BASE_URL
super(GetToken, self).__init__(base_url=base_url, client=client)
bearer_token = base64.b64encode(
bytes(client_id + ":" + client_secret, "ISO-8859-1")
).decode("ascii")
self.session.headers["Authorization"] = f"Basic {bearer_token}"
@post("oauth/access_token")
@returns.json(key="access_token")
def get_access_token(self, grant_type: Query = "client_credentials"):
pass
import base64
@headers({"Content-Type": "application/json"})
class PsWeb(Consumer):
def __init__(self, client_id, client_secret, client=RequestsClient):
auth = GetToken(client_id, client_secret)
response = auth.get_access_token().json()
access_token = response.get("access_token")
if access_token is None:
raise Exception("No access token returned!")
base_url = "https://psweb.asw.waw.pl/ws/schema/query/"
super(PsWeb, self).__init__(base_url=base_url, client=client)
self.session.headers["Authorization"] = f"Bearer {access_token}"
@post("mk.ManageBac_Stu")
def get_students(self, pagesize: Query = 2000, page: Query = 1, **body: Body):
pass
@post("mk.ManageBac_Par")
def get_parents(self, pagesize: Query = 2000, page: Query = 1, **body: Body):
pass
@post("mk.ManageBac_Tea")
def get_teachers(self, pagesize: Query = 2000, page: Query = 1, **body: Body):
pass
@post("mk.ManageBac_Stu_Class")
def get_enrollments(self, pagesize: Query = 10000, page: Query = 1, **body: Body):
pass
def load_entity(api, entity, path):
method = getattr(api, f"get_{entity}")
response = method()
if not response.ok:
raise Exception(
f"{response.request.url} => {response.status_code}\n{response.text}"
)
try:
json = response.json()
except JSONDecodeError:
# something wrong with the endpoint, should probably fail out in production
json = {}
has_records = json.get("record", False)
if not has_records:
message = f"No {entity} records found? Must be an issue with the powerschool source. Exiting with no actions taken"
raise Exception(message)
objects = {}
records = json.get("record", [])
flattened_records = [
dict(flatdict.FlatDict(item, delimiter=".")) for item in records
]
df = pd.DataFrame.from_records(flattened_records)
df.to_csv(f"/tmp/output_{entity}.csv", index=False)
for item in records:
value = get_dotted_path(item, path)
objects[value] = dot(item)
return (df, objects)
def load_enrollments(api):
""" """
page = 1
objects = defaultdict(lambda: defaultdict(dict))
classes = []
records = []
mapped_classes = []
while True:
response = api.get_enrollments(page=page)
json = response.json()
these_records = json.get("record", [])
records.extend(these_records)
for item in these_records:
dotted = dot(item)
section_number = dotted.tables.sections.section_number
class_id = dotted.tables.sections.class_id
if not section_number.isdigit():
class_id = f"{class_id}{section_number}"
mapped_classes.append(class_id)
classes.append(class_id)
objects[dotted.tables.students.student_number][class_id] = dotted
page += 1
if json.get("record") is None:
break
df = pd.DataFrame.from_records(records)
df.to_csv(f"/tmp/output_schedule.csv", index=False)
# df = pd.DataFrame.from_records([{'uniq_id': clss} for clss in set(mapped_classes)])
# df.to_csv(f'/tmp/output_mapped_classes.csv', index=False)
return (df, objects, set(classes))
def get_entity_by_key(mb: Endpoint, entity: str, key: str, query: str):
""" """
method = getattr(mb.endpoints, f"get_{entity}")
results = [
item for item in method(q=query).get(entity) if item.get(key) == query.strip()
]
if len(results) == 0:
return None
assert len(results) == 1, "Issue with multiple students with same `student_id`"
return results.pop()
def execute(mb: Endpoint, records, description, *args, **kwargs):
"""
Interact with the endpoint, add changed record. If any error happens,
log that too
"""
response = None
try:
response = mb(*args, **kwargs)
record = {
"description": description,
"action": mb.__name__,
"change": True,
"error": bool(response.get("error")),
"response": response,
"body": kwargs.get("body"),
}
except Exception as err:
record = {
"description": description,
"action": mb.__name__,
"change": False,
"error": True,
"response": str(err),
"body": "Unexpected Error",
}
record.update(kwargs)
records.append(record)
return response
@click.command("sync-asw")
@click.option(
"-d",
"--date",
"date",
type=click.DateTime(formats=["%Y-%m-%d"]),
default=str(datetime.date.today()),
help="The date on which to execute",
)
@click.option("-x", "--postfix", "postfix", default="")
@click.option(
"-a", "--associate/--skip-associations", "associations", is_flag=True, default=False
)
@click.option(
"-p", "--skip-profile/--update-profile", "profiles", is_flag=True, default=False
)
@click.option("-u", "--user", "user", default=os.environ.get("USER"))
@click.option("-w", "--password", "password", default=os.environ.get("PASSWORD"))
@click.option("-t", "--to", "to_whom", default=[], multiple=True)
@click.pass_obj
def sync(obj, date, postfix, associations, profiles, user, password, to_whom):
"""
Specialist software written for PowerSchool Sync
"""
if len(to_whom) > 0 and not password:
raise Exception("Please provide password to send email")
from mbpy_endpoints import Generator
date_string = date.strftime("%Y-%m-%d")
# if not dry_run:
# #raise Exception('You must remove me before going into production, sir!')
# mb = Generator(auth_token=obj.token, tld=obj.tld, subdomain=obj.baseurl_subdomain, verbosity=1)
# else:
# mb = Generator(auth_token=obj.token, tld=obj.tld, subdomain=obj.baseurl_subdomain, verbosity=1, Endpoint_class=MockEndpoint)
# fake_mb = Generator(auth_token=obj.token, tld=obj.tld, subdomain=obj.baseurl_subdomain, verbosity=1, Endpoint_class=MockEndpoint)
mb = obj.Generator
api = PsWeb(
client_id=CLIENT_ID,
client_secret=CLIENT_SECRET,
)
psdf_enrollments, ps_student_enrollments, _ = load_enrollments(api)
mb_student_enrollments = defaultdict(list)
psdf_students, ps_students = load_entity(
api, "students", "tables.students.student_number"
)
psdf_teachers, teachers = load_entity(api, "teachers", "tables.teachers.id")
psdf_parents, parents = load_entity(
api, "parents", "tables.students.student_number"
)
to_be_removed = defaultdict(lambda: defaultdict(dict))
fields_to_be_updated = defaultdict(lambda: defaultdict(dict))
records = []
missing_classes = []
try:
mb_year_groups = {}
for year_group in mb.generate_year_groups():
mb_year_groups[year_group.get("grade")] = year_group
print("Creating accounts")
mb_students = {}
for student in mb.generate_students():
student_id = student.get("student_id")
mb_students[student_id] = student
today: datetime.date = datetime.datetime.today().date()
date_query_param = today.strftime("%Y-%m-%d")
for stu_id, ps_student in ps_students.items():
mb_student = mb_students.get(stu_id)
if not mb_student is None and mb_student.get("archived"):
execute(
mb.endpoints.unarchive_a_student,
records,
stu_id,
id=mb_student.get("id")
)
if mb_student is None:
day, month, year = ps_student.tables.students.dateofbirth.split("-")
class_grade_number = (
int(ps_student.tables.students.grade.split(" ")[1]) + 1
)
body = {
"student": {
"student_id": stu_id,
"birthday": f"{year}-{month}-{day}",
"middle_name": ps_student.tables.students.middle_name,
"last_name": ps_student.tables.students.last_name,
"first_name": ps_student.tables.students.first_name,
"email": ps_student.tables.students.email,
"nickname": ps_student.tables.u_student_additionals.nickname,
"gender": {"F": "Female", "M": "Male"}.get(
ps_student.tables.students.gender
),
"nationalities": ps_student.tables.u_country_codes.nat,
"class_grade_number": class_grade_number,
}
}
new_student = execute(
mb.endpoints.create_student, records, stu_id, body=body
)
if new_student is None:
records.append(
{
"description": "Error occurred when trying to create student",
"action": "Create student",
"error": True,
"change": False,
"response": None,
"body": "",
}
)
else:
record = new_student.get(
"student", new_student
) # convoluted for dev
# FIXME: add them to the right year group
target_year_group = mb_year_groups.get(
ps_student.tables.students.grade
)
assert target_year_group is not None, "Grade is wrong?"
if not record.get("id") in target_year_group.get("student_ids"):
execute(
mb.endpoints.add_to_year_group,
records,
f'{record.get("student_id")} > {ps_student.tables.students.grade}',
id=target_year_group.get("id"),
body={"student_ids": [record.get("id")]},
)
if not "id" in record:
print(f"Not adding student {stu_id}")
continue # dev
mb_students[stu_id] = record
mb_teachers = {}
for teacher in mb.generate_teachers():
email = teacher.get("email").lower()
mb_teachers[email] = teacher
for email, ps_teacher in teachers.items():
mb_teach = mb_teachers.get(email)
if mb_teach is None:
body = {
"teacher": {
"email": ps_teacher.tables.teachers.id,
"first_name": ps_teacher.tables.teachers.first_name,
"last_name": ps_teacher.tables.teachers.last_name,
"middle_name": ps_teacher.tables.teachers.middle_name,
}
}
new_teacher = execute(
mb.endpoints.create_teacher, records, email, body=body
)
if not "id" in new_teacher:
if error := new_teacher.get("errors"):
print(error)
print(f"Not adding teacher {email}")
continue # dev
mb_teachers[email] = new_teacher
mb_parents = {}
for parent in mb.generate_parents():
email = parent.get("email").lower()
mb_parents[email] = parent
for stu_id, ps_parent in parents.items():
mb_stu = mb_students.get(
stu_id
) # get_entity_by_key(mb, 'students', 'student_id', stu_id)
if mb_stu is None:
continue # can occur in dev environment
# link parents to students
base = ps_parent.tables.u_student_additionals
parent_list = []
for par in ["mother", "father"]:
kind = par.title()
email = getattr(base, f"{par}_school_email")
if email is None:
records.append(
{
"description": "",
"action": "missing_email",
"error": False,
"change": False,
"response": f"{stu_id} has no parent email for {kind}",
"body": "",
}
)
continue
split = email.split("@")[0].split("_")
first_name = f"{par}_first_name"
first_name = (
getattr(base, first_name) if hasattr(base, first_name) else split[1]
)
last_name = f"{par}_last_name"
last_name = (
getattr(base, last_name) if hasattr(base, last_name) else split[0]
)
parent_list.append(
(
kind,
{
"email": email,
"first_name": first_name.title(),
"last_name": last_name.title(),
"gender": {"mother": "Female", "father": "Male"}.get(par),
},
)
)
for role, parent in parent_list:
email = parent.get("email")
mb_parent = mb_parents.get(
email
) # get_entity_by_key(mb, 'parents', 'email', email)
if mb_parent is None:
new_parent = execute(
mb.endpoints.create_parent,
records,
email,
body={"parent": parent},
)
if not "id" in new_parent:
continue # dev, will not be able to associate
# associate it here immediately, so we don't have to rely on running --associations
execute(
mb.endpoints.add_child_association,
records,
f"associate",
parent_id=mb_parent.get("id"),
body={"child": {"id": mb_stu.get("id"), "relationship": role}},
)
mb_parents[email] = new_parent
if mb_parent.get("archived"):
# shouldn't really get to this point, though, since we are unarchiving students, we'll get this for free
# although if it happens just above, we won't have latest info
# anyway, at least we'll have a record of it happening this way
execute(
mb.endpoints.unarchive_a_parent,
records,
email,
id=mb_parent.get("id"),
)
if associations:
# off by default as it takes a very long time to execute, and rarely will change
relationships = list(
mb.generate_parentchild_relationships(mb_parent.get("id"))
)
this_relationship = [
rel.get("relationship")
for rel in relationships
if rel.get("id") == mb_stu.get("id")
]
if len(this_relationship) == 0:
execute(
mb.endpoints.add_child_association,
records,
f'{mb_stu.get("student_id")} -> {email}',
parent_id=mb_parent.get("id"),
body={
"child": {"id": mb_stu.get("id"), "relationship": role}
},
)
elif this_relationship.pop() != role:
execute(
mb.endpoints.update_child,
records,
email,
parent_id=mb_parent.get("id"),
child_id=mb_stu.get("id"),
body={"child": {"relationship": role}},
)
# uses generate memberships endpoint
mb_classes = {}
for clss in mb.generate_classes():
uniq_id = clss.get("uniq_id")
clss["archived"] = False
mb_classes[uniq_id] = clss
for clss in mb.generate_classes(archived=True):
uniq_id = clss.get("uniq_id")
assert uniq_id not in clss, "Class uniq IDs are not unique"
clss["archived"] = True
mb_classes[uniq_id] = clss
for memb in mb.generate_memberships(
class_happens_on=date_string, classes="active", users="active", per_page=200
):
membership = dot(memb)
clss = mb_classes.get(
membership.uniq_class_id
) # get_entity_by_key(mb, 'classes', 'uniq_id', membership.class_id)
if membership.role == "Student":
uniq_student_id = membership.uniq_student_id.strip()
uniq_class_id = membership.uniq_class_id.strip()
# store mb enrollments for later comparison
mb_student_enrollments[uniq_student_id].append(uniq_class_id)
if uniq_student_id != membership.uniq_student_id:
print(f'Whitespace "{uniq_student_id}"')
if uniq_class_id != membership.uniq_class_id:
print(f'Whitespace "{uniq_class_id}"')
mb_student = mb_students.get(
membership.uniq_student_id
) # session.get(Student, membership.user_id)
clss = mb_classes.get(membership.uniq_class_id)
if enrolled := ps_student_enrollments[uniq_student_id][uniq_class_id]:
pass # print(enrolled)
else:
to_be_removed[uniq_student_id][uniq_class_id] = SimpleNamespace(
student=mb_student, clss=clss
)
if ps_stu := ps_students.get(uniq_student_id):
# ensure enrolled into correct year_group
target_year_group = mb_year_groups.get(ps_stu.tables.students.grade)
assert target_year_group is not None, "Grade is wrong?"
if not mb_student.get("id") in target_year_group.get("student_ids"):
execute(
mb.endpoints.add_to_year_group,
records,
f'{mb_student.get("student_id")} > {ps_stu.tables.students.grade}',
id=target_year_group.get("id"),
body={"student_ids": [mb_student.get("id")]},
)
if not profiles:
# stop here unless we need to update profiles
continue
if ps_stu.tables.students.grade != mb_student.get("class_grade"):
# FIXME: This doesn't seem to be working
execute(
mb.endpoints.update_a_student,
records,
mb_student.get("student_id"),
id=mb_student.get("id"),
body={
"student": {
"class_grade_number": int(
ps_stu.tables.students.grade.split(" ")[1]
)
+ 1
}
},
)
# ps_birthday = datetime.datetime.strptime(
# ps_stu.tables.u_student_additionals.enrollmentdate, '%d-%m-%Y').date()
dates_checks = (
# ('attendance_start_date', mb_student.attendance_start_date, ps_stu.tables.u_student_additionals.enrollmentdate),
(
"birthday",
mb_student.get("birthday"),
ps_stu.tables.students.dateofbirth,
),
)
for property, mb_, ps_ in dates_checks:
if not ps_ is None:
ps_date = datetime.datetime.strptime(ps_, "%d-%m-%Y").date()
if not mb_ is None:
mb_date = datetime.datetime.strptime(
mb_, "%Y-%m-%d"
).date()
if ps_date != mb_date:
# incorrect
fields_to_be_updated[uniq_student_id][
property
] = ps_date.isoformat()
else:
# blank
fields_to_be_updated[uniq_student_id][
property
] = ps_date.isoformat()
else:
if not mb_ is None:
fields_to_be_updated[uniq_student_id][property] = None
field_checks = (
(
"email",
mb_student.get("email"),
ps_stu.tables.students.email,
),
(
"last_name",
mb_student.get("last_name"),
ps_stu.tables.students.last_name,
),
(
"first_name",
mb_student.get("first_name"),
ps_stu.tables.students.first_name,
),
(
"middle_name",
mb_student.get("middle_name"),
ps_stu.tables.students.middle_name,
),
(
"nickname",
mb_student.get("nickname"),
ps_stu.tables.u_student_additionals.nickname,
),
(
"class_grade",
mb_student.get("class_grade"),
ps_stu.tables.students.grade,
),
# ('nationalities', (mb_student.get('nationalities') or [None]).pop(),
# ps_stu.tables.u_country_codes.nat),
(
"gender",
mb_student.get("gender"),
{"M": "Male", "F": "Female"}.get(
ps_stu.tables.students.gender
),
),
)
for property, mb_value, ps_value in field_checks:
if not ps_value is None:
if not mb_value is None:
if ps_value != mb_value:
fields_to_be_updated[uniq_student_id][
property
] = ps_value
else:
fields_to_be_updated[uniq_student_id][
property
] = ps_value
else:
if not mb_value is None:
fields_to_be_updated[uniq_student_id][property] = None
for key in [
k
for k in mb_year_groups.keys()
if not k == ps_stu.tables.students.grade
]:
year_group = mb_year_groups[key]
if mb_student.get("id") in year_group.get("student_ids"):
execute(
mb.endpoints.remove_from_year_group,
records,
f'{ps_stu.tables.students.grade} < {mb_student.get("student_id")}',
id=year_group.get("id"),
body={"student_ids": [mb_student.get("id")]},
)
# ensure removed from other year_groups
else:
# no such ps_student, archive the student
execute(
mb.endpoints.archive_a_student,
records,
mb_student.get("student_id"),
id=mb_student.get("id"),
withdrawn_on=date_query_param,
)
print("SSs who need CLASSES to be REMOVED")
for stu_id in to_be_removed:
for class_id in to_be_removed[stu_id]:
item = to_be_removed[stu_id][class_id]
mb_student = item.student
mb_class = item.clss
execute(
mb.endpoints.remove_students_from_class,
records,
f"{class_id} < {stu_id}",
class_id=mb_class.get("id"),
body={"student_ids": [mb_student.get("id")]},
)
print("SS to be UPDATED")
for stu_id in fields_to_be_updated:
for property in fields_to_be_updated[stu_id]:
mb_student = mb_students.get(
stu_id
) # session.query(Student).where(Student.student_id==stu_id).one()
value = fields_to_be_updated[stu_id][property]
if property == "nationalities":
value = [value]
body = {"student": {}}
body["student"][property] = value
execute(
mb.endpoints.update_a_student,
records,
f"{stu_id}.{property} = {value}",
id=mb_student.get("id"),
body=body,
)
print("SS to be ADDED to CLASS")
# classes that student is supposed to be enrolled in according to PS, but not in MB yet
academic_years = mb.endpoints.get_academic_years()
for stud_id in ps_student_enrollments:
ps_stu = ps_students.get(stud_id)
ps_enrol = list(ps_student_enrollments[stud_id].keys())
mb_enrol = mb_student_enrollments[stud_id]
mb_student = mb_students.get(stud_id)
if mb_student is None:
continue # dev, new students won't be there yet
for add in set(ps_enrol) - set(mb_enrol):
clss = mb_classes.get(add)
if clss is None:
missing_classes.append(
{"description": add, "error": True, "body": stu_id}
)
else:
# FIXME: Check that the class has begun, it's possible to be in the source but not intended to be enrolled in MB yet
# as it wouldn't be able to remove them, either
years = academic_years.get(clss.get("program_code"))
if not years:
continue
years = years.get("academic_years")
start_date = None
for terms in years:
for term in terms.get("academic_terms"):
if term.get("id") == clss.get("start_term_id"):
# use datetime as click's date param will need to be compared to it
start_date = datetime.datetime.fromisoformat(
term.get("starts_on")
)
assert start_date is not None, "start_date cannot be None"
if start_date <= date:
execute(
mb.endpoints.add_student_to_class,
records,
f'{stud_id} > {clss.get("uniq_id")}',
class_id=clss.get("id"),
body={"student_ids": [mb_student.get("id")]},
)
else:
records.append(
{
"description": f"{mb_student.get('student_id')} > {clss.get('uniq_id')}",
"error": False,
"change": False,
"body": "Not enrolling as class has not begun",
"action": "Enrol into class not yet started",
}
)
finally:
timestamp = f"{date_string}{postfix}"
df = pd.DataFrame.from_records(records)
print(df)
df = df.sort_values(by="change", ascending=False)
df.to_csv(f"/tmp/executions_{timestamp}.csv", index=False)
df2 = pd.DataFrame.from_records(missing_classes)
# df2 = df2.sort_values(by='change')
df2.to_csv(f"/tmp/missing_classes_{timestamp}.csv", index=False)
subject_description = ""
change_description = ""
error_description = ""
num_errors = len(df.loc[df["error"]])
num_changes = len(df.loc[df["change"]])
if num_errors > 0:
subject_description = f"{num_errors} errors"
error_description = (
df.loc[df["error"]]["action"]
.value_counts()
.sort_values(ascending=False)
.to_string()
)
if num_changes > 0:
subject_description += f" {num_changes} changes"
change_description = (
df.loc[df["change"]]["action"]
.value_counts()
.sort_values(ascending=False)
.to_string()
)
body = ""
if num_errors == 0 and num_changes == 0:
body += (
"Executed successfully. No changes needed, nor any errors encountered."
)
if num_errors > 0:
body += f"Executed, but some errors happened:\n{error_description}\n\n"
if num_changes > 0:
body += f"Summary of changes made:\n{change_description}"
if len(to_whom) > 0:
send_email(
user,
to_whom,
f'Sync Output {"(" if subject_description else ""}{subject_description.strip()}{")" if subject_description else ""}',
body,
password,
("sync_output.csv", df),
("missing_classes.csv", df2),
*[
(f"powerschool_{name}.csv", d)
for name, d in [
("students", psdf_students),
("parents", psdf_parents),
("enrollments", psdf_enrollments),
("teachers", psdf_teachers),
]
],
)
else:
print(body)
return records