├── README.md ├── doc └── src │ ├── make.sh │ ├── old │ ├── bitgit-api.do.txt │ └── bitgit-api.html │ ├── workflow.do.txt │ └── workflow.html ├── examples └── workflow │ ├── Attendance │ └── students_base.txt │ ├── README.md │ ├── copy_attendance_file.py │ ├── default_parameters.txt │ ├── download_spreadsheet.py │ ├── end_semester.py │ ├── manage_group.py │ ├── mark_active.py │ ├── message_collaboration.rst │ └── message_new_student.rst ├── requirements.txt ├── setup.py └── virtual_classroom ├── .gitignore ├── __init__.py ├── api.py ├── classroom.py ├── collaboration.py ├── default_parameters.txt ├── get_all_feedbacks.py ├── get_all_repos.py ├── group.py ├── parameters.py ├── send_email.py ├── student.py ├── students_file.py └── utils.py /README.md: -------------------------------------------------------------------------------- 1 | virtual-classroom 2 | ================= 3 | 4 | Scripts for automating GitHub classrooms: 5 | 6 | * Creating private repositories for every student in a course. 7 | * Dividing students into groups for exercise assessement. 8 | * Opening up private repos temporarily for assessment groups. 9 | 10 | 11 | Documentation 12 | ------------- 13 | 14 | See the [wiki](https://github.com/hplgit/virtual-classroom/wiki). 15 | 16 | 17 | 18 | Installation 19 | ------------ 20 | 21 | Just clone this repository 22 | and run the main program by 23 | 24 | python start_group.py 25 | 26 | To learn about the options and how it works, see the [wiki](https://github.com/hplgit/virtual-classroom/wiki). 27 | 28 | 29 | Contact 30 | ------- 31 | 32 | The latest version of this software can be obtained from 33 | 34 | https://github.com/hplgit/virtual-classroom 35 | 36 | Please report bugs and other issues through the issue tracker at: 37 | 38 | https://github.com/hplgit/virtual-classroom/issues 39 | 40 | 41 | Authors 42 | ------- 43 | 44 | virtual-classrom is developed by 45 | 46 | * Aslak Bergersen 47 | * Sebstian Mitusch 48 | 49 | under the supervision of 50 | 51 | * Hans Petter Langtangen 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /doc/src/make.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | name=workflow 3 | 4 | doconce format html $name --html_style=bootswatch_journal 5 | -------------------------------------------------------------------------------- /doc/src/old/bitgit-api.do.txt: -------------------------------------------------------------------------------- 1 | ======= Automatic operation of GitHub and Bitbucket ======= 2 | 3 | 4 | 5 | !bsummary Conclusions 6 | * Drop fork and pull, use clone and push with a course-specific account 7 | * Need to describe in detail "how to operate multiple acounts": "http://dbushell.com/2013/01/27/multiple-accounts-and-ssh-keys/" as the lecturer and TA has to do that 8 | * Do not use Python wrappers - use the REST APIs directly from 9 | home-made Python code, because the APIs are much better documented 10 | than the Python wrappers. 11 | * Use "Requests": "http://docs.python-requests.org/en/latest/index.html" 12 | library in Python 13 | !esummary 14 | 15 | Further work: 16 | 17 | * Instructor must be able to 18 | * give one assessment group access to another group's exercise 19 | * give two students access to two individual exercises, or maybe it is 20 | sufficient to have one student with access to one exercises and then 21 | they sit together and look at the same screen 22 | * automate this process of pairing students or groups and giving 23 | them read/write access (for 3331 and 5620) - the latter thing is easy if 24 | the instructor has admin access and can use REST API to set and remove 25 | new accesses 26 | * First, check out organization accounts and how github thinks about classroom use: 27 | * "GitHub goes to school": "https://github.com/blog/1775-github-goes-to-school", see also URL: "https://education.github.com/" 28 | * "GitHub doc for using Git in the classroom": "https://education.github.com/guide" (GitHub has thought about this so understand their model first) 29 | * "Access permissions to GitHub accounts": "https://help.github.com/articles/what-are-the-different-access-permissions" 30 | * "Employing Git in the classroom": "http://www.academia.edu/5968989/Employing_Git_in_the_Classroom" (Very similar set-up to INF5620) 31 | * "Interesting new method for INF5620": "http://db.grinnell.edu/sigcse/sigcse2013/Program/viewAcceptedProposal.pdf?sessionType=paper&sessionNumber=257" 32 | * "Application to exams": "http://lfborjas.com/2010/10/30/git-classroom-exams.html" 33 | * "OpenShift used in teaching": "https://www.openshift.com/blogs/openshift-goes-to-school-how-a-little-automation-goes-a-long-way-in-the-classroom" 34 | 35 | ===== GitHub ===== 36 | 37 | * Intro: URL: "http://developer.github.com/guides/getting-started/" 38 | * "Python example using reskit": "http://agrimmsreality.blogspot.no/2012/05/sampling-github-api-v3-in-python.html" 39 | * "All about OAuth authorization from Python": "http://goodcode.io/wp-content/uploads/2012/06/OAuth-edited.pdf" 40 | 41 | === What we want to do === 42 | 43 | o Fork a repo URL: "http://developer.github.com/v3/repos/forks/#create-a-fork" 44 | o Give TAs full rights to the forked repo URL: "http://developer.github.com/v3/repos/collaborators/#add-collaborator" 45 | o Create a pull request URL: "https://developer.github.com/v3/pulls/#create-a-pull-request", see "this script": "http://pastebin.com/F9n3nPuu" 46 | 47 | The rest is plain git commands from the OS. 48 | 49 | !bbox 50 | A simpler scheme is to just clone and push, but the students must then 51 | give access to the private repo to both the lecturer and the teaching 52 | assistant. 53 | !ebox 54 | 55 | Or can the lecturer clone and then copy the repo to share it with the TA? 56 | What about a course specific bitbucket account? Must then learn how to 57 | operate multiple accounts: 58 | URL: "http://dbushell.com/2013/01/27/multiple-accounts-and-ssh-keys/". 59 | With just clone and push rather than fork and pull, it all becomes easier 60 | to automate! 61 | 62 | === Search === 63 | 64 | !bc 65 | https://api.github.com/search/repositories?q=5620 66 | !ec 67 | 68 | ===== Bitbucket ===== 69 | 70 | Read the "intro": "https://confluence.atlassian.com/display/BITBUCKET/Use+the+Bitbucket+REST+APIs" first. 71 | 72 | * "Command-line tool from Atlassian to operate Bitbucket": "https://bitbucket.org/atlassian/stash-command-line-tools" (can do pull request) 73 | * "Another, Python-based command-line tool": "https://bitbucket.org/zhemao/bitbucket-cli" (look at the code, but it's easier to do this directly with the API) 74 | * "Python access to API": "https://github.com/Sheeprider/BitBucket-api" 75 | but cannot do fork, or add collaborator (or maybe *service* module can add hook for that?) 76 | * Bitbucket REST API: 77 | * "Set access permissions": "https://confluence.atlassian.com/display/BITBUCKET/privileges+Endpoint#privilegesEndpoint-PUTanewprivilege" 78 | * "Invite users to a repo": "https://confluence.atlassian.com/display/BITBUCKET/invitations+Endpoint" 79 | * "Fork or clone a repo": "https://confluence.atlassian.com/display/BITBUCKET/repository+Resource+1.0#repositoryResource1.0-POSTanewfork", see also "this": "https://confluence.atlassian.com/display/BITBUCKET/repository+Resource" and "this": "http://stackoverflow.com/questions/11640035/using-bitbuckets-api-to-fork-a-repository" 80 | 81 | === What we want to do === 82 | 83 | * "Access": "https://bitbucket-api.readthedocs.org/en/latest/usage.html" 84 | * "Overview": "https://bitbucket-api.readthedocs.org/en/latest/bitbucket.html" 85 | * "Fork": "https://confluence.atlassian.com/display/BITBUCKET/repository+Resource+1.0#repositoryResource1.0-POSTanewfork" (googling indicates this is not straightforward) 86 | 87 | Seems that github is best for what I want. 88 | -------------------------------------------------------------------------------- /doc/src/old/bitgit-api.html: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | 17 | 18 |

Automatic operation of GitHub and Bitbucket

19 | 20 |
Conclusions. 21 | 22 | 31 |
32 | 33 | 34 |

35 | Further work: 36 | 37 |

65 | 66 |

GitHub

67 | 68 | 74 | 75 |

What we want to do

76 | 77 |
    78 |
  1. Fork a repo http://developer.github.com/v3/repos/forks/#create-a-fork
  2. 79 |
  3. Give TAs full rights to the forked repo http://developer.github.com/v3/repos/collaborators/#add-collaborator
  4. 80 |
  5. Create a pull request https://developer.github.com/v3/pulls/#create-a-pull-request, see this script
  6. 81 |
82 | 83 | The rest is plain git commands from the OS. 84 | 85 |

86 | 87 | 88 |

89 | A simpler scheme is to just clone and push, but the students must then 90 | give access to the private repo to both the lecturer and the teaching 91 | assistant. 92 |
93 | 94 | 95 | 96 |

97 | Or can the lecturer clone and then copy the repo to share it with the TA? 98 | What about a course specific bitbucket account? Must then learn how to 99 | operate multiple accounts: 100 | http://dbushell.com/2013/01/27/multiple-accounts-and-ssh-keys/. 101 | With just clone and push rather than fork and pull, it all becomes easier 102 | to automate! 103 | 104 |

Search

105 | 106 | 107 | 108 |
https://api.github.com/search/repositories?q=5620
109 | 
110 | 111 |

Bitbucket

112 | 113 | Read the intro first. 114 | 115 | 129 | 130 |

What we want to do

131 | 132 | 137 | 138 | Seems that github is best for what I want. 139 | 140 | 141 | 142 | -------------------------------------------------------------------------------- /doc/src/workflow.do.txt: -------------------------------------------------------------------------------- 1 | TITLE: Virtual classroom for INF5620 2 | AUTHOR: Hans Petter 3 | AUTHOR: Aslak 4 | AUTHOR: Solveig 5 | DATE: today 6 | 7 | ======= The workflow in a virtual GitHub classroom ======= 8 | 9 | * All students have *one* personal, private repo with all their 10 | files in a course. 11 | * All communication of electronic documents and other files 12 | happens via the repo. 13 | * Teachers can assign an evaluation team to evaluate a student's 14 | work. 15 | * The evaluation team 16 | * gets temporary access to the student's repo, 17 | * clones the repo, 18 | * studies relevant files, 19 | * writes a feedback file (according to conventions), 20 | * pushes the feedback file (and maybe annotated files) to the repo. 21 | * The evaluation team can the lecturer, teaching assistants, or 22 | a team of (e.g.) three other students. 23 | 24 | This classroom model is advantageous when exercises and projects may 25 | consist of numerous files. Systems where each file has to be attached 26 | (as in email) are tedious to use both for the students and the evaluators. 27 | The model above also calls for automation: if one teacher is supposed 28 | to evaluate the work of all students, all repos can be cloned or 29 | pulled, and a script can visit the relevant files in each repo, 30 | pop up an editor for writing feedback, add this file, commit, and 31 | push everything back. 32 | 33 | ===== Use in INF5620 ===== 34 | 35 | * Students show up for a 2h project lab. 36 | * The teacher has a list of all students and marks those who are present. 37 | * A script assigns teams of three students to evaluate three other projects. 38 | The script gives the team members access to the repos to be evaluated and 39 | sends an email to the team members with repo details (clone command). 40 | * The teams study the files under supervision of and with help from teachers. 41 | * The teams write a feedback file for each repo and push it back. 42 | * A script will some time after the project lab close down access 43 | to private student repos. 44 | 45 | Teams can be assigned at random, one can use fixed teams, or they can 46 | form themselves on the fly. 47 | 48 | Only the students who show up in the project lab will have their work 49 | evaluated. 50 | 51 | Pros and cons: 52 | 53 | * + students will have many more exercises and projects evaluated 54 | and in more detail 55 | * + students learn a lot from first having done an exercise and 56 | then evaluating three others 57 | * - the quality of a thorough evaluation performed by a teacher 58 | will be higher 59 | 60 | ======= Functionality to be developed ======= 61 | 62 | * File format with list of all students. Convention for marking who 63 | will enter an evaluation. Content: full name, username, email. 64 | * Function for creating a new private repo for the student in 65 | the "GitHub classroom": "https://github.com/UiO-INF5620". 66 | Send email to the student when 67 | the account as created with info on how to obtain ssh access. 68 | * Function for creating teams (based on the students present). 69 | * Function for giving a list of users access to a given repo 70 | and for sending email to those who are granted access. 71 | * Function for removing all access to a given repo, except for the owner. 72 | 73 | The virtual classroom is hosted at GitHub: 74 | URL: "https://github.com/UiO-INF5620". 75 | 76 | !bsummary Programming tools 77 | Based on some research, it seems best to use the "`requests`": 78 | "http://docs.python-requests.org/en/latest/index.html" module in 79 | Python and call the API directly instead of using Python wrappers for 80 | the API (e.g., "githubpy": "http://michaelliao.github.io/githubpy/"). 81 | !esummary 82 | 83 | === Resources === 84 | 85 | * Intro: URL: "http://developer.github.com/guides/getting-started/" 86 | * "Python example using reskit": "http://agrimmsreality.blogspot.no/2012/05/sampling-github-api-v3-in-python.html" 87 | * "All about OAuth authorization from Python": "http://goodcode.io/wp-content/uploads/2012/06/OAuth-edited.pdf" 88 | 89 | === GitHub and education === 90 | 91 | * "GitHub goes to school": "https://github.com/blog/1775-github-goes-to-school", see also URL: "https://education.github.com/" 92 | * "GitHub doc for using Git in the classroom": "https://education.github.com/guide" (GitHub has thought about this so understand their model first) 93 | * "Access permissions to GitHub accounts": "https://help.github.com/articles/what-are-the-different-access-permissions" 94 | * "Employing Git in the classroom": "http://www.academia.edu/5968989/Employing_Git_in_the_Classroom" (Very similar set-up to INF5620) 95 | * "Interesting new method for INF5620": "http://db.grinnell.edu/sigcse/sigcse2013/Program/viewAcceptedProposal.pdf?sessionType=paper&sessionNumber=257" 96 | * "Application to exams": "http://lfborjas.com/2010/10/30/git-classroom-exams.html" 97 | * "OpenShift used in teaching": "https://www.openshift.com/blogs/openshift-goes-to-school-how-a-little-automation-goes-a-long-way-in-the-classroom" 98 | 99 | 100 | -------------------------------------------------------------------------------- /doc/src/workflow.html: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Virtual classroom for INF5620 12 | 13 | 14 | 15 | 18 | 19 | 37 | 38 | 39 | 40 | 41 | 52 | 53 | 54 | 55 | 56 | 57 | 83 | 84 | 85 |
86 | 87 |

 

 

 

88 | 89 | 90 | 91 | 92 | 93 |
94 |

Virtual classroom for INF5620

95 | 96 |

97 | 98 | 99 |

100 | Hans Petter 101 |
102 | 103 |
104 | Aslak 105 |
106 | 107 |
108 | Solveig 109 |
110 | 111 |

112 | 113 | 114 |
115 |

116 |

Jul 14, 2016

117 |
118 |

119 |

120 | 121 |

The workflow in a virtual GitHub classroom

122 | 123 | 143 | 144 | This classroom model is advantageous when exercises and projects may 145 | consist of numerous files. Systems where each file has to be attached 146 | (as in email) are tedious to use both for the students and the evaluators. 147 | The model above also calls for automation: if one teacher is supposed 148 | to evaluate the work of all students, all repos can be cloned or 149 | pulled, and a script can visit the relevant files in each repo, 150 | pop up an editor for writing feedback, add this file, commit, and 151 | push everything back. 152 | 153 |

Use in INF5620

154 | 155 | 166 | 167 | Teams can be assigned at random, one can use fixed teams, or they can 168 | form themselves on the fly. 169 | 170 |

171 | Only the students who show up in the project lab will have their work 172 | evaluated. 173 | 174 |

175 | Pros and cons: 176 | 177 |

185 | 186 |

Functionality to be developed

187 | 188 | 200 | 201 | The virtual classroom is hosted at GitHub: 202 | https://github.com/UiO-INF5620. 203 | 204 |

205 |

Programming tools. 206 | Based on some research, it seems best to use the requests module in 207 | Python and call the API directly instead of using Python wrappers for 208 | the API (e.g., githubpy). 209 |
210 | 211 | 212 |

Resources

213 | 214 | 219 | 220 |

GitHub and education

221 | 222 | 231 | 232 | 233 | 234 | 235 |
236 | 237 | 238 | 239 | 240 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | -------------------------------------------------------------------------------- /examples/workflow/Attendance/students_base.txt: -------------------------------------------------------------------------------- 1 | Attendance // Name // Github username // Email // Course 2 | -------------------------------------------------------------------------------- /examples/workflow/README.md: -------------------------------------------------------------------------------- 1 | # Workflow similar to earlier version 2 | 3 | **Please use Python3.** 4 | 5 | ## Start of semester 6 | 7 | 1. Edit `defaults_parameters.txt`. 8 | 2. Ask students to fill out the Google Form https://docs.google.com/a/simula.no/forms/d/1JGSMC-9sRcuCanLF7MavHlGWVxuOjX-bwcIoyoM4yPE/edit 9 | 3. Make sure the response Google Spreadsheet has the ordering: *Timestamp, Full name, UiO-Username, Username on Github, Email Address, Course* 10 | 4. Download the student list with `python download_spreadsheet.py` 11 | 5. Mark all participating students with an `x` in `Attendance/students_base.txt` 12 | 6. Create a github repository and team for each student with 13 | 14 | `python manage_group.py --i True --no-email --f Attendance/students_base.txt` 15 | 16 | (If you choose `--email`, make sure to update the file `message_new_student.rst` beforehand). 17 | 18 | *Note*: This command will *not* overwrite existing repositories. This means that you can execute this command again if new students join the course. 19 | 20 | Assignments 21 | ----------- 22 | 1. Each student has a repository with the name `{course}-*XYZ*` where `{course}` is the course parameter supplied in 23 | `default_parameters.txt` and `*XYZ*` is some substring of the student name. The solutions should be pushed into this repository. 24 | 2. If the assignment is a peer-reviewed assignment, see next section. Otherwise, proceed with the next step. 25 | 3. At the assignment deadline, all repositories can be downloaded with: 26 | 27 | `python manage_group.py --get_repos True --get_repos_filepath assignment2_solutions --f Attendance/students_base.txt` 28 | 29 | 30 | Performing a peer-review 31 | ------------------------ 32 | 1. Run `python copy_attendance_file.py` and mark the students that take part of the group with `x` (this is useful if some of the students hand in late). 33 | 2. Alternatively you may run `python mark_active.py` to mark students active since a given date. 34 | 2. Run `python manage_group.py --email_tmp_file emails_assignment4.txt --email_delay 1.0 --f Attendance/students_base-2015-12-01.txt`. This creates github teams of size 3 with access to 3 other student's repositories. Each group should review the student's solutions and push the reports to their repositories. The delay unit is seconds. 35 | 36 | To continue if sending out emails was interrupted simply run 37 | `python manage_group.py --email_review_groups True --email_tmp_file emails_assignment4.txt --email_delay 1.0 --f Attendance/students_base-2015-12-01.txt` 38 | 39 | After review, delete all teams (and with it the access to their peers repositories) with: 40 | 41 | 3. `python manage_group.py --e True` 42 | 43 | End of semester 44 | --------------- 45 | 46 | 1. Backup all repositories. For example: 47 | 48 | ```bash 49 | python manage_group.py --get_repos=True --get_repos_filepath=repos_2015 50 | ``` 51 | 52 | 2. Delete all course repositories, teams and members (not owners) with: 53 | 54 | ```bash 55 | python end_semester.py 56 | ``` 57 | 58 | -------------------------------------------------------------------------------- /examples/workflow/copy_attendance_file.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import shutil 3 | 4 | src_file = "Attendance/students_base.txt" 5 | 6 | date = datetime.now() 7 | month = str(date.month) if date.month > 9 else "0" + str(date.month) 8 | day = str(date.day) if date.day > 9 else "0" + str(date.day) 9 | dst_file = "Attendance/students_base-%s-%s-%s.txt" % (date.year, month, day) 10 | 11 | shutil.copy(src_file, dst_file) 12 | -------------------------------------------------------------------------------- /examples/workflow/default_parameters.txt: -------------------------------------------------------------------------------- 1 | university:Virtual-Classroom 2 | course:Test 3 | max_students:3 4 | filepath:Attendance/students_base-%s-%s-%s.txt 5 | smtp:google 6 | rank:False 7 | students_file:{course}-students.txt 8 | -------------------------------------------------------------------------------- /examples/workflow/download_spreadsheet.py: -------------------------------------------------------------------------------- 1 | import virtual_classroom.utils as utils 2 | 3 | # Name of spreadsheet on Google sheets (You should change this to correct value) 4 | spreadsheet_name = "VirtualClassroomTest (Responses)" 5 | 6 | # Download parse, return contents and save it in default place 7 | csv_str = utils.download_google_spreadsheet(spreadsheet_name) # Should prompt for login info if default not set 8 | 9 | utils.create_students_file_from_csv(csv_str=csv_str, output_filename="Attendance/students_base.txt") 10 | 11 | -------------------------------------------------------------------------------- /examples/workflow/end_semester.py: -------------------------------------------------------------------------------- 1 | from virtual_classroom.classroom import Classroom 2 | 3 | classroom = Classroom("Attendance/students_base.txt") 4 | classroom.end_semester() 5 | -------------------------------------------------------------------------------- /examples/workflow/manage_group.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Future import 3 | from __future__ import print_function, unicode_literals 4 | 5 | # Python import 6 | from sys import exit 7 | from os import path 8 | from argparse import ArgumentParser 9 | from datetime import datetime 10 | 11 | # Virtual classroom 12 | from virtual_classroom.parameters import get_parameters 13 | from virtual_classroom.classroom import Classroom 14 | 15 | 16 | def read_command_line(): 17 | """Read arguments from commandline""" 18 | parser = ArgumentParser() 19 | 20 | parameters = get_parameters() 21 | 22 | # File with attendance 23 | date = datetime.now() 24 | month = str(date.month) if date.month > 9 else "0" + str(date.month) 25 | day = str(date.day) if date.day > 9 else "0" + str(date.day) 26 | parameters['filepath'] = path.join(path.dirname(__file__), 27 | parameters['filepath'] % (date.year, month, day)) 28 | 29 | parser.add_argument('--f', '--file', type=str, 30 | default=parameters['filepath'], 31 | help=""" A file including all students, in this course. Format: 32 | Attendence(X/-) // Name // Username // email""", metavar="students_file") 33 | parser.add_argument('--c', '--course', type=str, 34 | default=parameters['course'], help="Name of the course", metavar="course") 35 | parser.add_argument('--u', '--university', type=str, 36 | default=parameters['university'], 37 | help="Name of the university, the viritual-classroom should \ 38 | be called -", metavar="university") 39 | parser.add_argument('--m', '--max_students', type=int, default=parameters['max_students'], 40 | help="Maximum number of students in each group.", metavar="max group size") 41 | parser.add_argument('--e', '--end_group', type=bool, 42 | default=False, metavar="end group (bool)", 43 | help='Delete the current teams on the form Team-') 44 | parser.add_argument('--i', '--start_semester', type=bool, 45 | default=False, metavar="initialize group (bool)", 46 | help='Create repositories and teams for the students.') 47 | parser.add_argument('--g', '--get_repos', type=bool, 48 | default=False, help="Clone all student repos into the" + \ 49 | "filepath ./_all_repos", 50 | metavar="Get all repos (bool)") 51 | parser.add_argument('--get_repos_filepath', type=str, default=".", 52 | help="This argument is only used when --get_repos is used. \ 53 | It states the location of where the folder \ 54 | _all_repos should be located \ 55 | this is expected to be a relative path from where \ 56 | you are when you execute this program", 57 | metavar="Get all repos (bool)") 58 | parser.add_argument('--F', '--get_feedback', type=bool, 59 | default=False, help="Store all the feedback files into the" + \ 60 | "filepath ./_all_repos. To change" \ 61 | " the location use '--get_feedback_filepath'", 62 | metavar="Get all feedbacks (bool)") 63 | parser.add_argument('--get_feedback_filepath', type=str, default="", 64 | help="This argument is only used when --get_feedback is used. \ 65 | It states the location of where the folder \ 66 | _all_feedbacks should be located \ 67 | this is expected to be a relative path from where \ 68 | you are when you execute this program", 69 | metavar="Get all feedbacks (bool)") 70 | parser.add_argument('--smtp', type=str, choices=['uio','google'], 71 | default=parameters['smtp'], 72 | help='Choose which smtp server emails are to be sent from.') 73 | parser.add_argument('--rank', type=bool, default=False, 74 | help="How to divide in to groups, with or without a \ 75 | classification of the students from 1 to 3, where 1 is \ 76 | a top student.", metavar="rank") 77 | parser.add_argument('--email', dest='email', action='store_true', help="Send email") 78 | parser.add_argument('--no-email', dest='email', action='store_false', help="Send no email") 79 | parser.add_argument("--email_tmp_file", dest="email_tmp_file", type=str, default="email_tmp_%s.txt", 80 | help="This argument is used to determine the name of the file to store information \ 81 | emails sent.") 82 | parser.add_argument("--email_delay", dest="email_delay", type=float, default=1.0, 83 | help="This argument is used to determine the delay between each email sent.") 84 | parser.add_argument("--email_review_groups", dest="email_review_groups", type=bool, default=False, 85 | help="This flag tells the script to only send emails to review groups.\ 86 | Useful if sending out the emails was interrupted.") 87 | parser.set_defaults(email=True) 88 | 89 | args = parser.parse_args() 90 | 91 | # Check if file exists 92 | if not path.isfile(args.f) and not args.e and not args.F and not args.g: 93 | msg = "The file: %s does not exist. \nPlease provide a different file path, or" + \ 94 | "create the file first. Use the script 'copy-attendance-file.py'" 95 | msg = msg % args.f 96 | print(msg) 97 | exit(1) 98 | 99 | return args.f, args.c, args.u, args.m, args.e, args.i, args.g, args.get_repos_filepath, \ 100 | args.F, args.get_feedback_filepath, args.smtp, args.rank, \ 101 | args.email, args.email_tmp_file, args.email_delay, args.email_review_groups 102 | 103 | 104 | def main(): 105 | students_file, course, university, max_students, \ 106 | end, start_semester, get_repos, get_repos_filepath, get_feedback, \ 107 | get_feedback_filepath, smtp, rank, email, email_tmp_file, email_delay, \ 108 | email_review_groups = read_command_line() 109 | 110 | classroom = Classroom(students_file) 111 | 112 | if end: 113 | classroom.end_peer_review() 114 | 115 | elif get_repos: 116 | classroom.download_repositories(get_repos_filepath) 117 | 118 | elif get_feedback: 119 | from virtual_classroom.get_all_feedbacks import Feedbacks 120 | # TODO: Not yet supported. 121 | feedbacks = Feedbacks(university, course, get_feedback_filepath) 122 | feedbacks() 123 | 124 | else: 125 | if not email_review_groups: 126 | if not start_semester: 127 | classroom.start_peer_review(max_group_size=max_students, rank=rank) 128 | elif email: 129 | classroom.email_students("message_new_student.rst", 130 | "New repository", 131 | smtp=smtp) 132 | 133 | if (not start_semester and email) or email_review_groups: 134 | classroom.email_review_groups("message_collaboration.rst", 135 | "New group", 136 | smtp=smtp, 137 | tmp_file=email_tmp_file, 138 | delay=email_delay) 139 | 140 | if __name__ == '__main__': 141 | main() 142 | -------------------------------------------------------------------------------- /examples/workflow/mark_active.py: -------------------------------------------------------------------------------- 1 | from virtual_classroom.classroom import Classroom 2 | 3 | try: input = raw_input 4 | except: pass 5 | 6 | src_file = "Attendance/students_base.txt" 7 | src_file_input = input("Students file to read from (default: %s): " % src_file) 8 | src_file = src_file if src_file_input.strip() == "" else src_file_input 9 | 10 | active_since = input("Mark students with commits after (date): ") 11 | 12 | dst_file = "Attendance/students-active-since-%s" % active_since 13 | dst_file_input = input("Students file to write to (default: %s): " % dst_file) 14 | dst_file = dst_file if dst_file_input.strip() == "" else dst_file_input 15 | 16 | ignore_present = input("Also check unmarked students in src file? (y/n): ") 17 | ignore_present = (ignore_present.strip() == "y") 18 | 19 | c = Classroom(src_file, ignore_present=ignore_present) 20 | c.mark_active_repositories(active_since, dst_file) 21 | 22 | -------------------------------------------------------------------------------- /examples/workflow/message_collaboration.rst: -------------------------------------------------------------------------------- 1 | Dear {{ student.name }}! 2 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 3 | 4 | {%- set filtered_group = group.students|rejectattr("name", "equalto", student.name)|list %} 5 | {%- set filtered_group_names = filtered_group|map(attribute="name")|list %} 6 | {%- if filtered_group_names|length >= 2 %} 7 | {%- set part_group_names = filtered_group_names[:-1]|join(", ") %} 8 | {%- set group_names = part_group_names + " and " + filtered_group_names[-1] %} 9 | {%- else %} 10 | {%- set group_names = filtered_group_names|join("") %} 11 | {%- endif %} 12 | 13 | You are receiving this e-mail because you are taking the INF3331/INF4331 14 | course and have submitted peer-reviewed assignement. You are now asked to join 15 | a collaboration with {{ filtered_group|length }} of your fellow students. 16 | Together you are asked to performed peer-review on {{ group.review_repos|length }} other fellow 17 | solutions. 18 | 19 | You have been assigned to work with {{ group_names }} as part of 20 | {{ group.team_name }}. Start by contacting your collaborators to organize 21 | yourself. The email addresses of your collaborators are: 22 | 23 | | {{ filtered_group|map(attribute="email")|join("\n| ") }} 24 | 25 | You now have access to push and pull to three (or two) other students repositories. 26 | Please review the solutions in all of these repositories. 27 | 28 | The repositories to be reviewed are listed here: https://github.com/orgs/{{ classroom.org }}/teams/{{ group.team_name }}/repositories. 29 | 30 | You can clone these repositories with: 31 | 32 | .. code-block:: bash 33 | {# #} 34 | {%- for repo_name in group.review_repos %} 35 | git clone git@github.com:{{ classroom.org }}/{{ repo_name }}.git 36 | {%- endfor %} 37 | 38 | If you get a "permission denied" error, try changing the URL in the command above to https://github.com/{{ classroom.org }}/NAME.git (replace NAME with the actual repo name). 39 | 40 | Guidelines 41 | ~~~~~~~~~~ 42 | 43 | * The guidelines and a Latex template for the feedback file is available here: https://www.overleaf.com/read/zzrxxbxbmqws and should be used (you may alternatively use a Markdown version with the same layout). You can write the review with Overleaf (its free to sign up): open link above and click on "Create a new project to start writing!" to get started. 44 | * A review is completed by pushing the review Latex and PDF files to each of the reviewed repositories. The name of the files should be: feedback.tex and feedback.pdf. 45 | -------------------------------------------------------------------------------- /examples/workflow/message_new_student.rst: -------------------------------------------------------------------------------- 1 | Hi {{ student.name }}, and welcome to {{ student.course }} 2 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 3 | 4 | In this course we will use GitHub as a tool for version control and 5 | collaboration. You have now access to a repository which is called 6 | {{ student.repo_name }}. It is very important that every file you make in this 7 | course that is supposed to be evaluated is added to this 8 | repo (short for repository). We recommend that you put all your course 9 | work in the repo, because this serves as a backup and as an archive of 10 | previous versions of the files. You work with the repo through the Git 11 | version control system. The workflow may seem a bit cumbersome at 12 | first sight, but this is how professionals work with software and 13 | technical documents all around the world. 14 | 15 | **Important**: Open your primary email on GitHub and confirm that you 16 | want to join {{ classroom.org }}. You have to do this to get 17 | access to your new repo. 18 | 19 | There is a lot of information about git on the web, but below we give 20 | the quick need-to-know steps for the course. First you need to install 21 | git. On Ubuntu, this can be done by the command: 22 | 23 | .. code-block:: 24 | 25 | sudo apt-get install git 26 | 27 | On MacOSX follow the instructions on https://git-scm.com/downloads. 28 | 29 | The next step is to clone the repository you have been given access 30 | to. You simply write 31 | 32 | .. code-block:: 33 | 34 | git clone git@github.com:{{ classroom.org }}/{{ student.repo_name }}.git 35 | 36 | which gives you a directory {{ student.repo_name }}. When you enter the folder, 37 | you are in a Git-control directory tree and must use certain Git 38 | commands to register files and update the repo. 39 | 40 | So let's say you create a new file 'test.py' that you want Git to track 41 | the history of. Then you must write 42 | 43 | .. code-block:: 44 | 45 | git add test.py 46 | 47 | After you have worked with files and they seem to be in an acceptable 48 | state, or you stop working for the day, you should do 49 | 50 | .. code-block:: 51 | 52 | git commit test.py -am 'Short description of the changes you made to files since last git commit command...' 53 | 54 | The file changes are now on your computer only. To send them to the 55 | cloud and back them up, do 56 | 57 | .. code-block:: 58 | 59 | git push origin master 60 | 61 | If you work on multiple computers, or if you collaborate with others 62 | in the repo, it is very important that you do 63 | 64 | .. code-block:: 65 | 66 | git pull origin master 67 | 68 | to download the latest version of the files before you start editing 69 | any of them. 70 | 71 | 72 | More information: 73 | 74 | * Complete the interactive `git demo `_ 75 | * See the `Quick intro to Git and GitHub `_ 76 | * A more extensive introduction to Git is provided by the three first chapters in `this book `_ 77 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | gspread 2 | oauth2client 3 | docutils 4 | jinja2 5 | dateutil 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | 3 | setup(name='virtual-classroom', 4 | version='0.0.1', 5 | description='Library for automating Github classroom', 6 | packages=['virtual_classroom'] 7 | ) -------------------------------------------------------------------------------- /virtual_classroom/.gitignore: -------------------------------------------------------------------------------- 1 | Attendance/* 2 | build/* 3 | __pycache__/* 4 | 5 | *.pyc 6 | *.html 7 | -------------------------------------------------------------------------------- /virtual_classroom/__init__.py: -------------------------------------------------------------------------------- 1 | # Nothing here yet -------------------------------------------------------------------------------- /virtual_classroom/api.py: -------------------------------------------------------------------------------- 1 | # Python imports 2 | from requests import get, post, put, delete 3 | from getpass import getpass 4 | from json import dumps 5 | from re import findall 6 | 7 | # Python3 and 2 compatible 8 | try: input = raw_input 9 | except NameError: pass 10 | 11 | 12 | # Mimic enum like behaviour 13 | # Maybe I went too far and made it unnecessarily complicated 14 | class Endpoint(object): 15 | class EndpointItem(object): 16 | def __init__(self, *args): 17 | self.values = [] 18 | for arg in args: 19 | if isinstance(arg, self.__class__): 20 | self.values += [i for i in arg.values] 21 | else: 22 | self.values.append(arg) 23 | 24 | def url(self, *args): 25 | url = "".join(self.values) 26 | return url.format(*args) 27 | 28 | def __str__(self): 29 | return self.url() 30 | 31 | API_URL = EndpointItem("https://api.github.com") 32 | 33 | USERS_API = EndpointItem(API_URL, "/users/{}") 34 | 35 | REPO_API = EndpointItem(API_URL, "/repos/{}/{}") 36 | 37 | REPOSITORIES_API = EndpointItem(API_URL, "/repositories") 38 | REPOSITORY = EndpointItem(REPOSITORIES_API, "/{}") 39 | 40 | ORGS_API = EndpointItem(API_URL, "/orgs/{}") 41 | TEAMS = EndpointItem(ORGS_API, "/teams") 42 | REPOS = EndpointItem(ORGS_API, "/repos") 43 | MEMBERS = EndpointItem(ORGS_API, "/members") 44 | ORG_MEMBER = EndpointItem(MEMBERS, "/{}") 45 | 46 | TEAM_API = EndpointItem(API_URL, "/teams/{}") 47 | TEAM_REPOS = EndpointItem(TEAM_API, "/repos") 48 | TEAM_REPO = EndpointItem(TEAM_REPOS, "/{}/{}") 49 | TEAM_MEMBERSHIPS = EndpointItem(TEAM_API, "/memberships") 50 | TEAM_MEMBERSHIP = EndpointItem(TEAM_MEMBERSHIPS, "/{}") 51 | TEAM_MEMBERS = EndpointItem(TEAM_API, "/members") 52 | 53 | 54 | class APIManager(object): 55 | """Connects and communicates with the Github API. 56 | 57 | """ 58 | auth = None 59 | 60 | def __init__(self): 61 | if self.auth is None: 62 | self.auth = self.create_auth() 63 | 64 | @classmethod 65 | def create_auth(cls): 66 | """Get password and username from the user""" 67 | # Get username and password for admin to classroom 68 | admin = input('For GitHub\nUsername: ') 69 | p = getpass('Password: ') 70 | cls.auth = (admin, p) 71 | 72 | # Check if username and password is correct 73 | r = get(Endpoint.API_URL, auth=(admin, p)) 74 | if r.status_code != 200: 75 | print('Username or password is wrong (GitHub), please try again!') 76 | exit(1) 77 | 78 | return cls.auth 79 | 80 | def delete_repo(self, owner, name): 81 | return delete(Endpoint.REPO_API.url(owner, name), auth=self.auth) 82 | 83 | def delete_team(self, org, team): 84 | return delete(Endpoint.TEAM_API.url(team), auth=self.auth) 85 | 86 | def delete_org_member(self, org, member): 87 | return delete(Endpoint.ORG_MEMBER.url(org, member), auth=self.auth) 88 | 89 | def delete_team(self, team): 90 | return delete(Endpoint.TEAM_API.url(team), auth=self.auth) 91 | 92 | def delete_team_membership(self, team, member): 93 | return delete(Endpoint.TEAM_MEMBERSHIP.url(team, member), auth=self.auth) 94 | 95 | def add_team_repo(self, team_id, org, repo): 96 | return put(Endpoint.TEAM_REPO.url(team_id, org, repo), headers={'Content-Length': "0"}, auth=self.auth) 97 | 98 | def add_team_membership(self, team_id, member): 99 | return put(Endpoint.TEAM_MEMBERSHIP.url(team_id, member), headers={'Content-Length': "0"}, auth=self.auth) 100 | 101 | def create_repo(self, org, key_repo): 102 | return post(Endpoint.REPOS.url(org), data=dumps(key_repo), auth=self.auth) 103 | 104 | def create_team(self, org, key_team): 105 | return post(Endpoint.TEAMS.url(org), data=dumps(key_team), auth=self.auth) 106 | 107 | def get_repo(self, org, repo_name): 108 | return get(Endpoint.REPO_API.url(org, repo_name), auth=self.auth) 109 | 110 | def get_repository(self, repo_id): 111 | return get(Endpoint.REPOSITORY.url(repo_id), auth=self.auth) 112 | 113 | def get_user(self, username): 114 | return get(Endpoint.USERS_API.url(username), auth=self.auth) 115 | 116 | def get_team_members(self, team_id): 117 | return self._get(Endpoint.TEAM_MEMBERS.url(team_id)) 118 | 119 | def get_team_repos(self, team_id): 120 | return self._get(Endpoint.TEAM_REPOS.url(team_id)) 121 | 122 | def get_teams(self, org): 123 | return self._get(Endpoint.TEAMS.url(org)) 124 | 125 | def get_repos(self, org): 126 | return self._get(Endpoint.REPOS.url(org)) 127 | 128 | def get_members(self, org, role='all'): 129 | return self._get(Endpoint.MEMBERS.url(org), params={'role': role}) 130 | 131 | def _get(self, url, params=None): 132 | 133 | p = {'per_page':100, 'page':1} 134 | if params is not None: 135 | p.update(params) 136 | 137 | # Find number of pages 138 | r = get(url, auth=self.auth, params=p) 139 | 140 | if 'Link' not in r.headers.keys(): 141 | return r.json() 142 | 143 | else: 144 | header = r.headers['Link'].split(',') 145 | for link in header: 146 | if 'rel="last"' in link: 147 | link = link.split(";")[0] 148 | pages = findall("\?page\=(\d+)", link) 149 | if len(pages) <= 0: 150 | pages = findall("\&page\=(\d+)", link) 151 | pages = int(pages[0]) 152 | 153 | # Get each page 154 | teams = r.json() 155 | for page in range(pages-1): 156 | p['page'] = page+2 157 | r = get(url, auth=self.auth, params=p) 158 | teams += r.json() 159 | 160 | return teams -------------------------------------------------------------------------------- /virtual_classroom/classroom.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import print_function, unicode_literals 4 | from datetime import datetime 5 | from time import sleep 6 | 7 | from .student import Student 8 | from .send_email import Email, EmailBody, SMTPGoogle, SMTPUiO, connect_to_email_server 9 | from .parameters import get_parameters 10 | from .collaboration import start_peer_review 11 | from .get_all_repos import download_repositories 12 | from .api import APIManager 13 | from .students_file import parse_students_file, save_students_file 14 | from .group import ReviewGroup 15 | 16 | try: 17 | from dateutil.parser import parse 18 | except ImportError: 19 | print("This program depends on the module dateutil, install to run" + \ 20 | " program!\n\n sudo pip install python-dateutil") 21 | exit(1) 22 | 23 | 24 | class Classroom(object): 25 | """Contains help functions to get an overveiw of the virtual classroom""" 26 | 27 | def __init__(self, filename=None, ignore_present=False): 28 | self.students = {} 29 | self.review_groups = None 30 | 31 | # Load parameters 32 | parameters = get_parameters() 33 | self.university = parameters["university"] 34 | self.course = parameters["course"] 35 | self.org = "%s-%s" % (self.university, self.course) 36 | 37 | try: 38 | raw_students = parse_students_file(filename) 39 | except Exception as e: 40 | print("Error when parsing students file: %s" % e) 41 | print("Continuing without built students. Some methods will not work.") 42 | return 43 | 44 | # Create a dict with students 45 | for student in raw_students: 46 | if (student["present"].lower() == 'x' or ignore_present) and student["username"] != "": 47 | rank = 1 # Rank is not functional at the moment. 48 | print("Initialize student {0}".format(student["name"])) 49 | self.students[student["username"].lower()] = Student(student["name"], 50 | student["uio_username"], 51 | student["username"], 52 | self.university, 53 | self.course, 54 | student["email"], 55 | student["present"], 56 | rank) 57 | 58 | def mark_active_repositories(self, active_since, filename=None, dayfirst=True, **kwargs): 59 | """Create a students file where students with active repositories are marked 60 | 61 | Active here means that the repository has been pushed changes since the given `active_since` date. 62 | 63 | Parameters 64 | ---------- 65 | active_since : str, datetime 66 | A string or datetime object representing a date from which to count a repostiory as active. 67 | filename : str, optional 68 | A string with the file name of the students file to write to. 69 | Default is "students-active-since-dd-mm-yyyy.txt" 70 | If an empty string is given the default (global) students_file will be overwritten. 71 | dayfirst : bool, optional 72 | Used for parsing active_since if it is a string. Then if dayfirst is True ambigious dates will interpret 73 | the day before the month. 74 | Default is True. 75 | kwargs 76 | Optional keyword arguments that is sent to the parsing of a string `active_since` value. 77 | 78 | """ 79 | if not isinstance(active_since, datetime): 80 | active_since = parse(active_since, dayfirst=dayfirst, **kwargs) 81 | 82 | if filename is None: 83 | filename = "students-active-since-%s.%s.%s.txt" % (active_since.day, active_since.month, active_since.year) 84 | elif filename == "": 85 | filename = None 86 | 87 | for student in self.students.values(): 88 | student.present = (student.last_active > active_since) 89 | save_students_file(self.students.values(), filename=filename) 90 | 91 | def start_peer_review(self, max_group_size=None, rank=None, shuffle=False): 92 | parameters = get_parameters() 93 | # TODO: Consider renaming max_students to max_group_size 94 | max_group_size = parameters["max_students"] if max_group_size is None else max_group_size 95 | rank = parameters["rank"] if rank is None else rank 96 | 97 | self.review_groups = start_peer_review(self.students, max_group_size, rank, shuffle=shuffle) 98 | 99 | def fetch_peer_review(self): 100 | # TODO: Limitation is that it fetches all collaborations, not just most recent 101 | # (if multiple are active, or one forgot to delete the old) 102 | api = APIManager() 103 | teams = api.get_teams(self.org) 104 | self.review_groups = [] 105 | for team in teams: 106 | if "Team-" in team["name"]: 107 | # This is an ongoing review group. 108 | group_students = [] 109 | members = api.get_team_members(team["id"]) 110 | for member in members: 111 | username = member["login"].lower() 112 | # TODO: This might crash. A student could drop out mid-peer-review. 113 | # What to do if student doesn't exist? 114 | group_students.append(self.students[username]) 115 | 116 | review_repos = [] 117 | repos = api.get_team_repos(team["id"]) 118 | for repo in repos: 119 | review_repos.append(repo["name"]) 120 | 121 | self.review_groups.append(ReviewGroup(team["name"], 122 | group_students, 123 | review_repos)) 124 | print("Found %d review groups." % (len(self.review_groups))) 125 | 126 | def end_peer_review(self): 127 | api = APIManager() 128 | teams = api.get_teams(self.org) 129 | 130 | number_deleted = 0 131 | number_not_deleted = 0 132 | not_deleted = '' 133 | for team in teams: 134 | if 'Team-' in team['name']: 135 | r = api.delete_team(team['id']) 136 | if r.status_code != 204: 137 | number_not_deleted += 1 138 | not_deleted += '\n' + team['name'] 139 | else: 140 | number_deleted += 1 141 | 142 | if number_not_deleted == 0: 143 | print('Deleted all teams related to the group session (%d teams deleted)' % \ 144 | number_deleted) 145 | else: 146 | print('Deleted %s teams, but there were %s teams that where not deleted:%s' % \ 147 | (number_deleted, number_not_deleted, not_deleted)) 148 | 149 | def end_semester(self): 150 | # TODO: Also delete teams. Might benefit from iterating through self.students. 151 | # TODO: Consider if using self.students is better than fetching all members of org. 152 | # Downside is some students might not be marked anymore in the students file. 153 | # But there would be realistic workarounds for this, and would keep it cleaner here. 154 | api = APIManager() 155 | list_repos = api.get_repos(self.org) 156 | list_members = api.get_members(self.org, "member") 157 | 158 | for member in list_members: 159 | # if member['login'].encode('utf-8') in members_to_delete 160 | print("Deleting %s" % member["login"]) 161 | r = api.delete_org_member(self.org, member["login"]) 162 | print(r.status_code) 163 | 164 | # Delete repos 165 | for repo in list_repos: 166 | if self.course in repo['name']: 167 | print("Deleting repository ", self.org + repo['name']) 168 | r = api.delete_repo(self.org, repo["name"]) 169 | print(r.status_code) 170 | 171 | @staticmethod 172 | def download_repositories(directory): 173 | """Downloads all repositories in the classroom 174 | 175 | """ 176 | download_repositories(directory) 177 | 178 | def preview_email(self, filename, extra_params={}, student=None, group=None): 179 | """Preview the formatted email of the file. 180 | 181 | Useful for making sure the email templates are rendered correctly. 182 | 183 | Using the webbrowser package the function will try to open a HTML page, 184 | for realistic preview of the formatted email. 185 | 186 | Parameters 187 | ---------- 188 | filename : str 189 | Filename of the template file for the email. 190 | extra_params : dict, optional 191 | Extra params to format the email body text. 192 | student : Student, optional 193 | If supplied use the values of this student to format the email. 194 | If not supplied the first student in the student list is chosen. 195 | group : ReviewGroup, optional 196 | If supplied use the values of this group to format the email. 197 | If not supplied the first review group is chosen if it exists in this instance. 198 | 199 | Returns 200 | ------- 201 | str 202 | The formatted template in text (not HTML) format. 203 | 204 | """ 205 | email_body = EmailBody(filename) 206 | 207 | student = self.students[list(self.students.keys())[0]] if student is None else student 208 | if group is None: 209 | group = None if self.review_groups is None else self.review_groups[0] 210 | params = {"group": group, "student": student, "classroom": self} 211 | params.update(extra_params) 212 | email_body.params = params 213 | render = email_body.render() 214 | 215 | try: 216 | import webbrowser 217 | import os 218 | path = os.path.abspath('temp.html') 219 | url = 'file://' + path 220 | html = '' \ 221 | + email_body.text_to_html(render) \ 222 | + '' 223 | 224 | with open(path, 'wb') as f: 225 | f.write(html.encode("utf-8")) 226 | webbrowser.open(url) 227 | except: 228 | pass 229 | return render 230 | 231 | def email_students(self, filename, subject="", extra_params={}, smtp=None): 232 | """Sends an email to all students in the classroom. 233 | 234 | Will try to format the email body text with student attributes and `extra_params`. 235 | 236 | Parameters 237 | ---------- 238 | filename : str 239 | Path to the file containing the email body text 240 | subject : str, optional 241 | Subject of the email 242 | extra_params : dict, optional 243 | Dictionary of extra parameters to format the email body text 244 | smtp : str, optional 245 | The SMTP server to use. Can either be 'google' or 'uio'. 246 | 247 | """ 248 | server = connect_to_email_server(smtp) 249 | email_body = EmailBody(filename) 250 | email = Email(server, email_body, subject=subject) 251 | 252 | for name in self.students: 253 | student = self.students[name] 254 | params = {"student": student, "classroom": self} 255 | params.update(extra_params) 256 | email_body.params = params 257 | email.send(student.email) 258 | 259 | def email_review_groups(self, 260 | filename, 261 | subject="", 262 | extra_params={}, 263 | smtp=None, 264 | tmp_file="review_groups_{}.txt", 265 | delay=1.0): 266 | """Sends an email to all review groups in the classroom. 267 | 268 | Will try to format the email body text with group attributes, 269 | student attributes and `extra_params`. 270 | 271 | Parameters 272 | ---------- 273 | filename : str 274 | Path to the file containing the email body text 275 | subject : str, optional 276 | Subject of the email 277 | extra_params : dict, optional 278 | Dictionary of extra parameters to format the email body text 279 | smtp : str, optional 280 | The SMTP server to use. Can either be 'google' or 'uio'. 281 | tmp_file : str, optional 282 | A temporary file to write information about which emails have been sent. 283 | The filename may contain one {} which will be filled with a timestamp. 284 | You can use this file to continue from a previous send email operation that failed midway, 285 | just remember to specify the correct timestamp if you used the default timestamp. 286 | Default is "review_groups_%s.txt". 287 | delay : float, optional 288 | Delay between each email procedure. The delay is given in seconds and can be a decimal value. 289 | Default is 1.0. 290 | 291 | """ 292 | if self.review_groups is None: 293 | self.fetch_peer_review() 294 | 295 | if tmp_file: 296 | import time 297 | now = time.time() 298 | tmp_file = tmp_file.format(int(now)) 299 | 300 | done_mails = [] 301 | try: 302 | with open(tmp_file, "r") as f: 303 | contents = f.read() 304 | done_mails = contents.split(",") 305 | except IOError: 306 | pass 307 | 308 | server = connect_to_email_server(smtp) 309 | email_body = EmailBody(filename) 310 | email = Email(server, email_body, subject=subject) 311 | 312 | with open(tmp_file, "a") as f: 313 | for group in self.review_groups: 314 | params = {"group": group, "classroom": self} 315 | for student in group.students: 316 | if student.email in done_mails: 317 | continue 318 | params["student"] = student 319 | params.update(extra_params) 320 | email_body.params = params 321 | res = email.send(student.email) 322 | if res: 323 | f.write("{},".format(student.email)) 324 | sleep(delay) 325 | 326 | 327 | 328 | 329 | 330 | 331 | -------------------------------------------------------------------------------- /virtual_classroom/collaboration.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import print_function, unicode_literals 4 | from requests import get, put, post, delete 5 | from sys import exit 6 | from json import dumps 7 | import random 8 | 9 | # Local imports 10 | from .api import APIManager 11 | from .group import ReviewGroup 12 | 13 | # Python3 and 2 compatible 14 | try: input = raw_input 15 | except NameError: pass 16 | 17 | 18 | def start_peer_review(students, max_group_size, rank, shuffle=False): 19 | """Divide the students in to groups and give them access to another groups 20 | repositories.""" 21 | 22 | review_groups = [] 23 | 24 | max_group_size = int(max_group_size) 25 | if max_group_size < 1: 26 | print("Max group size should not be lower than 1.") 27 | exit(1) 28 | 29 | if len(students.values()) < 2*max_group_size: 30 | print("The group is too small for a peer review. Consider reducing the max group size (with --m X).") 31 | exit(1) 32 | 33 | # Set up groups with max number of students 34 | number_of_students = len(students.values()) 35 | rest = number_of_students % max_group_size 36 | integer_div = number_of_students // max_group_size 37 | number_of_groups = integer_div if rest == 0 else integer_div + 1 38 | min_size = number_of_students // number_of_groups 39 | num_big_groups = number_of_students % number_of_groups 40 | students_values = list(students.values()) 41 | if shuffle: 42 | random.shuffle(students_values) 43 | 44 | if not rank: 45 | groups = [] 46 | to_be_reviewed_groups = [] 47 | shifted_students = students_values[max_group_size:] + students_values[:max_group_size] 48 | 49 | offset = 0 50 | for i in range(number_of_groups): 51 | size = min_size + 1 if i < num_big_groups else min_size 52 | groups.append(students_values[offset:offset + size]) 53 | to_be_reviewed_groups.append(list(shifted_students)[offset:offset + size]) 54 | offset += size 55 | 56 | else: 57 | rank_1 = [] 58 | rank_2 = [] 59 | rank_3 = [] 60 | 61 | # get a list of students seperated on rank 62 | for s in students.values(): 63 | if s.rank == 1: 64 | rank_1.append(s) 65 | elif s.rank == 2: 66 | rank_2.append(s) 67 | elif s.rank == 3: 68 | rank_3.append(s) 69 | 70 | # Container for groups 71 | groups = [[] for i in range(number_of_groups)] 72 | 73 | # One from each category 74 | stopped1 = 0 75 | stopped2 = 0 76 | stopped3 = 0 77 | nloops = 0 78 | while stopped1 + stopped2 + stopped3 < 3: 79 | nloops += 1 80 | for i in range(number_of_groups * (nloops - 1), 81 | number_of_groups * nloops): 82 | j = number_of_groups * (nloops - 1) 83 | if i < len(rank_1): 84 | groups[i - j].append(rank_1[i]) 85 | elif stopped1 == 0: 86 | stopped1 = 1 87 | 88 | if i < len(rank_2): 89 | groups[i - j].append(rank_2[i]) 90 | elif stopped2 == 0: 91 | stopped2 = 1 92 | 93 | if i < len(rank_3): 94 | groups[i - j].append(rank_3[i]) 95 | elif stopped3 == 0: 96 | stopped3 = 1 97 | 98 | test_student = groups[0][0] 99 | 100 | # Get some parameters from the student instance 101 | # TODO: This could even be taken from the global parameters? 102 | org = test_student.org 103 | 104 | api = APIManager() 105 | 106 | teams = api.get_teams(org) # get all teams in the organisation 107 | max_team_number = 0 108 | for team in teams: 109 | if 'Team-' in team['name']: 110 | if max_team_number == 0: 111 | print('Warning: There are already teams with collaboration. Delete these by runing' \ 112 | + ' "python start_group.py --e True"') 113 | 114 | max_team_number = max(max_team_number, 115 | int(team['name'].split('-')[1])) 116 | 117 | # Check that all students have repository names. Otherwise 118 | # print warning and abort 119 | for students in groups: 120 | for student in students: 121 | try: 122 | student.repo_name 123 | except: 124 | print("Uups. student {} has no repository. You need to fix that.".format(student.email)) 125 | import sys; 126 | sys.exit(1) 127 | 128 | for n, group in enumerate(groups): 129 | 130 | # Get evualation group (next group in the list) 131 | # eval_group = self.groups[(n+1)%len(self.groups)] 132 | eval_group = to_be_reviewed_groups[n] 133 | 134 | # Create a team with access to an another team's repos 135 | team_name = "Team-%s" % (n + max_team_number + 1) 136 | team_key = { 137 | "name": team_name, 138 | "permission": "push" # or pull? 139 | } 140 | r_team = api.create_team(org, team_key) 141 | team_id = r_team.json()['id'] 142 | 143 | # When creating a team the user is added, fix this by removing 144 | # auth[0] from the team before the students are added 145 | try: 146 | if r_team.json()['members_count'] != 0 and r_team.status_code == 201: 147 | r_remove_auth = api.delete_team_membership(team_id, api.auth[0]) 148 | if r_remove_auth.status_code != 204: 149 | print("Could not delete user:%s from team:%s, this should" 150 | "be done manually or by a separate script" % (api.auth[0], team_name)) 151 | except: 152 | from IPython import embed; 153 | embed() 154 | 155 | review_repos = [] 156 | # Add repos to the team 157 | for s in eval_group: 158 | r_add_repo = api.add_team_repo(team_id, s.org, s.repo_name) 159 | review_repos.append(s.repo_name) 160 | if r_add_repo.status_code != 204: 161 | print("Error: %d - Can't add repo:%s to Team-%d" \ 162 | % (r_add_repo.status_code, s.repo_name, n)) 163 | 164 | # Add students to the team 165 | for s in group: 166 | r_add_member = api.add_team_membership(team_id, s.username) 167 | if r_add_member.status_code != 200: 168 | print("Error: %d - Can't give user:%s access to Team-%d" \ 169 | % (r_add_member.status_code, s.username, n)) 170 | 171 | # Add solution repo 172 | # r_add_fasit = put(s.url_teams + "/%s/repos/%s/Solutions" % 173 | # (r_team.json()['id'], s.org), auth=s.auth) 174 | # if r_add_fasit.status_code != 204: 175 | # print("Error: %d - Can't add solutions repo to teams" % 176 | # r_add_fasit.status_code) 177 | 178 | # TODO: Create google form here 179 | 180 | review_groups.append(ReviewGroup(team_name, group, review_repos)) 181 | 182 | return review_groups 183 | 184 | 185 | class Collaboration(): 186 | """Holds all the information about the groups during a group session""" 187 | 188 | def __init__(self, students, max_group_size, send_email, rank): 189 | """Divide the students in to groups and give them access to another groups 190 | repositories.""" 191 | 192 | self.review_groups = [] 193 | 194 | if len(students.values()) < 2: 195 | print("There are one or less students, no need for collaboration") 196 | exit(1) 197 | 198 | self.send_email = send_email 199 | 200 | if len(students.values()) < 2*max_group_size: 201 | print("The group is too small for a peer review. Consider reducing the max group size (with --m X).") 202 | exit(1) 203 | 204 | if max_group_size > len(students.values()): 205 | #TODO: This case fails 206 | self.groups = list(students.values()) 207 | test_student = self.groups[0] 208 | 209 | else: 210 | # Set up groups with max number of students 211 | number_of_students = len(students.values()) 212 | rest = number_of_students%max_group_size 213 | integer_div = number_of_students//max_group_size 214 | number_of_groups = integer_div if rest == 0 else integer_div + 1 215 | 216 | if not rank: 217 | self.groups = [] 218 | to_be_reviewed_groups = [] 219 | shifted_students = students.values()[max_group_size:] + students.values()[:max_group_size] 220 | 221 | for i in range(number_of_groups): 222 | self.groups.append(list(students.values())[i::number_of_groups]) 223 | to_be_reviewed_groups.append(list(shifted_students)[i::number_of_groups]) 224 | 225 | else: 226 | rank_1 = [] 227 | rank_2 = [] 228 | rank_3 = [] 229 | 230 | # get a list of students seperated on rank 231 | for s in students.itervalues(): 232 | if s.rank == 1: 233 | rank_1.append(s) 234 | elif s.rank == 2: 235 | rank_2.append(s) 236 | elif s.rank == 3: 237 | rank_3.append(s) 238 | 239 | # Container for groups 240 | self.groups = [[] for i in range(number_of_groups)] 241 | 242 | # One from each category 243 | stopped1 = 0 244 | stopped2 = 0 245 | stopped3 = 0 246 | nloops = 0 247 | while stopped1 + stopped2 + stopped3 < 3: 248 | nloops += 1 249 | for i in range(number_of_groups*(nloops-1), 250 | number_of_groups*nloops): 251 | j = number_of_groups*(nloops-1) 252 | if i < len(rank_1): 253 | self.groups[i-j].append(rank_1[i]) 254 | elif stopped1 == 0: 255 | stopped1 = 1 256 | 257 | if i < len(rank_2): 258 | self.groups[i-j].append(rank_2[i]) 259 | elif stopped2 == 0: 260 | stopped2 = 1 261 | 262 | if i < len(rank_3): 263 | self.groups[i-j].append(rank_3[i]) 264 | elif stopped3 == 0: 265 | stopped3 = 1 266 | 267 | test_student = self.groups[0][0] 268 | 269 | # Get some parameters from the student instance 270 | self.auth = test_student.auth 271 | self.url_orgs = test_student.url_orgs 272 | self.org = test_student.org 273 | self.url_teams = test_student.url_teams 274 | 275 | teams = test_student.get_teams() # get all teams in the UiO organisation 276 | max_team_number = 0 277 | for team in teams: 278 | if 'Team-' in team['name']: 279 | if max_team_number == 0: 280 | print('Warning: There are already teams with collaboration. Delete these by runing'\ 281 | +' "python start_group.py --e True"') 282 | 283 | max_team_number = max(max_team_number, 284 | int(team['name'].split('-')[1])) 285 | 286 | # Check that all students have repository names. Otherwise 287 | # print warning and abort 288 | for students in self.groups: 289 | for student in students: 290 | try: 291 | student.repo_name 292 | except: 293 | print("Uups. student {} has no repository. You need to fix that.".format(student.email)) 294 | import sys; sys.exit(1) 295 | 296 | 297 | for n, group in enumerate(self.groups): 298 | 299 | # Get evualation group (next group in the list) 300 | #eval_group = self.groups[(n+1)%len(self.groups)] 301 | eval_group = to_be_reviewed_groups[n] 302 | 303 | # Create a team with access to an another team's repos 304 | repo_names = self.get_repo_names(eval_group) 305 | team_name = "Team-%s" % (n + max_team_number + 1) 306 | team_key = { 307 | "name": team_name, 308 | "permission": "push", # or pull? 309 | "repo_names": repo_names # is this necessary? 310 | } 311 | r_team = post( 312 | self.url_orgs+"/teams", 313 | data=dumps(team_key), 314 | auth=self.auth 315 | ) 316 | 317 | # When creating a team the user is added, fix this by removing 318 | # auth[0] from the team before the students are added 319 | try: 320 | if r_team.json()['members_count'] != 0 and r_team.status_code == 201: 321 | url_rm_auth = self.url_teams + '/' + str(r_team.json()['id']) + \ 322 | '/members/' + self.auth[0] 323 | r_remove_auth = delete(url_rm_auth, auth=self.auth) 324 | if r_remove_auth.status_code != 204: 325 | print("Could not delete user:%s from team:%s, this should" 326 | "be done manualy or by a seperate script" % (self.auth[0], team_name)) 327 | except: 328 | from IPython import embed; embed() 329 | 330 | review_repos = [] 331 | # Add repos to the team 332 | for s in eval_group: 333 | url_add_repo = s.url_teams + "/%s/repos/%s/%s" \ 334 | % (r_team.json()['id'], s.org, s.repo_name) 335 | r_add_repo = put(url_add_repo, auth=s.auth) 336 | reivew_repos.append(s.repo_name) 337 | if r_add_repo.status_code != 204: 338 | print("Error: %d - Can't add repo:%s to Team-%d" \ 339 | % (str(r_add_repo.status_code), s.repo_name, n)) 340 | 341 | # Add students to the team 342 | for s in group: 343 | url_add_member = s.url_teams + "/%s/members/%s" \ 344 | % (r_team.json()['id'], s.username) 345 | r_add_member = put(url_add_member, auth=s.auth) 346 | if r_add_member.status_code != 204: 347 | print("Error: %d - Can't give user:%s access to Team-%d" \ 348 | % (r_add_member.status_code, s.username, n)) 349 | 350 | # Add solution repo 351 | #r_add_fasit = put(s.url_teams + "/%s/repos/%s/Solutions" % 352 | # (r_team.json()['id'], s.org), auth=s.auth) 353 | #if r_add_fasit.status_code != 204: 354 | # print("Error: %d - Can't add solutions repo to teams" % 355 | # r_add_fasit.status_code) 356 | 357 | # TODO: Create google form here 358 | 359 | self.review_groups.append(ReviewGroup(team_name, group, review_repos)) 360 | 361 | # Send email 362 | if send_email is not None: 363 | self.send_email.new_group(team_name, group, eval_group)#, self.project) 364 | 365 | def get_repo_names(self, team): 366 | repo_names = [] 367 | for s in team: 368 | #print(s.org) 369 | #print(s.name) 370 | repo_names.append("github/%s/%s-%s" % (s.org, s.course, s.repo_name)) 371 | return repo_names 372 | -------------------------------------------------------------------------------- /virtual_classroom/default_parameters.txt: -------------------------------------------------------------------------------- 1 | university:UiO 2 | course:INF3331 3 | max_students:3 4 | filepath:Attendance/INF3331-%s-%s-%s.txt 5 | smtp:google 6 | rank:True 7 | -------------------------------------------------------------------------------- /virtual_classroom/get_all_feedbacks.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function, unicode_literals 2 | import re 3 | import os 4 | import sys 5 | import base64 6 | import codecs 7 | from requests import get 8 | 9 | from .api import APIManager 10 | 11 | # Python3 and 2 compatible 12 | try: input = raw_input 13 | except NameError: pass 14 | 15 | 16 | class Feedbacks(object): 17 | def __init__(self, university, course, output_path): 18 | self.university = university 19 | self.course = course 20 | #self.output_path = output_path 21 | 22 | self.org = "%s-%s" % (university, course) 23 | 24 | # To look up information about students 25 | attendance_path = os.path.join(os.path.dirname(__file__), \ 26 | 'Attendance', '%s-students_base.txt' % course) 27 | if os.path.isfile(attendance_path): 28 | self.students_base_dict = self.get_students(codecs.open(attendance_path, 'r', 29 | encoding='utf-8').readlines()) 30 | else: 31 | attendance_path = input('There is no file %s, pleace provide the' % attendance_path \ 32 | + 'filepath to where the student base file is located:') 33 | self.students_base_dict = self.get_students(open(attendance_path, 'r').readlines()) 34 | 35 | # Header for each file 36 | self.header = "/"*50 + "\n// Name: %(name)s \n" + "// Email: %(email)s \n" + \ 37 | "// Username: %(username)s \n" + "// Repo: %(repo)s \n" + \ 38 | "// Editors: %(editors)s \n" + "/"*50 + "\n\n" 39 | self.header = self.header.encode('utf-8') 40 | 41 | # TODO: these should be accessible through default_parameters 42 | # User defined variables 43 | assignment_name = input('What is this assignment called: ') 44 | feedback_name_base = input('\nWhat are the base filename of your feedback ' \ 45 | + 'files called, e.g.\nif you answer "PASSED" the ' \ 46 | + 'program will look for "PASSED_YES \nand "PASSED_NO" ' \ 47 | + '(case insensetive) : ').lower() 48 | 49 | # The files to look for 50 | self.file_feedback = [feedback_name_base + '_yes', feedback_name_base + '_no'] 51 | 52 | # Create path 53 | original_dir = os.getcwd() 54 | if output_path == "": 55 | feedback_filepath = os.path.join(original_dir, "%s_all_feedbacks" % course) 56 | else: 57 | feedback_filepath = os.path.join(original_dir, output_path, \ 58 | "%s_all_feedbacks" % course) 59 | 60 | # Path for feedback 61 | self.passed_path = os.path.join(feedback_filepath, assignment_name, 'PASSED') 62 | self.not_passed_path = os.path.join(feedback_filepath, assignment_name, 'NOT_PASSED') 63 | self.not_done_path = os.path.join(feedback_filepath, assignment_name) 64 | 65 | # Create folder structure 66 | try: 67 | os.makedirs(self.passed_path) 68 | os.makedirs(self.not_passed_path) 69 | except Exception as e: 70 | if os.listdir(self.passed_path) == [] and os.listdir(self.not_passed_path) == []: 71 | pass 72 | else: 73 | print("There are already collected feedbacks for %s." % assignment_name \ 74 | + " Remove these or copy them to another directory.") 75 | sys.exit(1) 76 | 77 | def __call__(self): 78 | print("\nLooking for feedbacks, this may take some time ...\n") 79 | api = APIManager() 80 | repos = api.get_repos(self.org) 81 | not_done = [] 82 | 83 | for repo in repos: 84 | # Assumes that no repo has the same naming convension 85 | if self.course + "-" in repo['name']: 86 | 87 | # Get sha from the last commit 88 | url_commits = repo['commits_url'][:-6] 89 | sha = get(url_commits, auth=api.auth).json()[0]['sha'] 90 | 91 | # Get tree 92 | self.url_trees = repo['trees_url'][:-6] 93 | r = get(self.url_trees + '/' + sha, auth=api.auth) 94 | 95 | # Get feedback file 96 | success, contents, status, path, extension = self.find_file(r.json()['tree']) 97 | 98 | # Get infomation about user and the file 99 | # Need some extra tests since multiple teams can have 100 | # access to the same repo. 101 | r = get(repo['teams_url'], auth=api.auth) 102 | for i in range(len(r.json())): 103 | try: 104 | personal_info = self.students_base_dict[r.json()[i]['name'].encode('utf-8')] 105 | personal_info['repo'] = repo['name'].encode('utf-8') 106 | break 107 | except Exception as e: 108 | if i == len(r.json()) - 1: 109 | print("There are no owners (team) of this repo " \ 110 | + "matching student base. " + 111 | r.json()[i]['name']) 112 | personal_info['name'] = repo['name'] 113 | 114 | if success: 115 | # Check if there is reason to belive that the user have cheated 116 | personal_info['editors'] = ", ".join(self.get_correctors(path, repo)) 117 | 118 | #TODO: Store this feedback in a list of potential cheaters 119 | 120 | # Write feedback with header to file 121 | # Work around for files with different 122 | # formats, could be fixed with google forms. 123 | try: 124 | text = self.header % personal_info 125 | text += contents.decode('utf-8') 126 | except UnicodeDecodeError as e: 127 | if 'ascii' in e: 128 | for key, value in personal_info.iteritems(): 129 | print(value) 130 | personal_info[key] = value.decode('utf-8') 131 | text = self.header % personal_info 132 | elif 'utf-8' in e: 133 | text += contents.decode('latin1') 134 | except Exception as e: 135 | print("Could not get the contents of %(name)s feedback file. Error:" \ 136 | % personal_info) 137 | print(e) 138 | 139 | filename = personal_info['name'].replace(' ', '_') + '.' + extension 140 | folder = self.passed_path if status == 'yes' else self.not_passed_path 141 | folder = os.path.join(folder, filename) 142 | feedback = codecs.open(folder, 'w', encoding='utf-8') 143 | feedback.write(text) 144 | feedback.close() 145 | 146 | # No feedback 147 | else: 148 | not_done.append(personal_info) 149 | 150 | # TODO: Only write those how where pressent at group and didn't get feedback 151 | # now it just writes everyone how has not gotten any feedback. 152 | text = "Students that didn't get any feedbacks\n" 153 | text += "Name // username // email\n" 154 | for student in not_done: 155 | text += "%(name)s // %(username)s // %(email)s\n" % student 156 | not_done_file = codecs.open(os.path.join(self.not_done_path, 'No_feedback.txt'), 'w', 157 | encoding='utf-8') 158 | not_done_file.write(text) 159 | not_done_file.close() 160 | 161 | number_of_feedbacks = len(repos) - len(not_done) 162 | print("\nFetched feedback from %s students and %s have not gotten any feedback" % \ 163 | (number_of_feedbacks, len(not_done))) 164 | 165 | def get_students(self, text): 166 | student_dict = {} 167 | for line in text[1:]: 168 | pressent, name, username, email, rank = re.split(r"\s*\/\/\s*", line.replace('\n', '')) 169 | student_dict[name.encode('utf-8')] = {'name': name, 170 | 'username': username, 'email': email} 171 | return student_dict 172 | 173 | def find_file(self, tree): 174 | api = APIManager() 175 | for file in tree: 176 | # Explore the subdirectories recursively 177 | if file['type'].encode('utf-8') == 'tree': 178 | r = get(file['url'], auth=api.auth) 179 | success, contents, status, path, extension = self.find_file(r.json()['tree']) 180 | if success: 181 | return success, contents, status, path, extension 182 | 183 | # Check if the files in the folder match file_feedback 184 | if file['path'].split(os.path.sep)[-1].split('.')[0].lower() in self.file_feedback: 185 | # Get filename and extension 186 | if '.' in file['path'].split(os.path.sep)[-1]: 187 | file_name, extension = file['path'].split(os.path.sep)[-1].lower().split('.') 188 | else: 189 | file_name = file['path'].split(os.path.sep)[-1].lower() 190 | extension = 'txt' 191 | 192 | r = get(file['url'], auth=api.auth) 193 | return True, base64.b64decode(r.json()['content']), \ 194 | file_name.split('_')[-1], file['path'], extension 195 | 196 | # If file not found 197 | return False, "", "", "", "" 198 | 199 | def get_correctors(self, path, repo): 200 | api = APIManager() 201 | url_commit = 'https://api.github.com/repos/%s/%s/commits' % (self.org, repo['name']) 202 | r = get(url_commit, auth=api.auth, params={'path': path}) 203 | # TODO: Change commiter with author? 204 | editors = [commit['commit']['author']['name'] for commit in r.json()] 205 | return editors 206 | -------------------------------------------------------------------------------- /virtual_classroom/get_all_repos.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function, unicode_literals 2 | import sys 3 | import os 4 | 5 | # Local imports 6 | from .api import APIManager 7 | from .parameters import get_parameters 8 | 9 | 10 | def download_repositories(directory): 11 | call_dir = os.getcwd() 12 | repos_filepath = os.path.join(call_dir, directory) 13 | if not os.path.isdir(repos_filepath): 14 | os.makedirs(repos_filepath) 15 | else: 16 | if os.listdir(repos_filepath) != []: 17 | print("There are already repos in this folder, please \ 18 | remove them before cloning new into this folder") 19 | sys.exit(0) 20 | 21 | parameters = get_parameters() 22 | university = parameters["university"] 23 | course = parameters["course"] 24 | org = "%s-%s" % (university, course) 25 | 26 | api = APIManager() 27 | 28 | print("Getting list of repositories...") 29 | repos = api.get_repos(org) 30 | print("Found {} repositories.".format(len(repos))) 31 | 32 | # Create the SSH links 33 | SSH_links = [] 34 | for i, repo in enumerate(repos): 35 | print("Getting repository links: {}%".format((100*i)/len(repos))) 36 | if course in repo['name']: 37 | r = api.get_repository(repo['id']) 38 | SSH_links.append(r.json()['ssh_url']) 39 | 40 | # Change to destination folder 41 | os.chdir(repos_filepath) 42 | 43 | # Clone into the repos 44 | for i, SSH_link in enumerate(SSH_links): 45 | print("Cloning repositories {}%".format((100*i)/len(SSH_links))) 46 | result = os.system('git clone ' + SSH_link) 47 | print("done.") 48 | 49 | # Change back to call dir 50 | os.chdir(call_dir) 51 | -------------------------------------------------------------------------------- /virtual_classroom/group.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | class ReviewGroup(object): 4 | 5 | def __init__(self, team_name, students, review_repos): 6 | self.team_name = team_name 7 | self.students = students 8 | self.review_repos = review_repos 9 | 10 | -------------------------------------------------------------------------------- /virtual_classroom/parameters.py: -------------------------------------------------------------------------------- 1 | import pkg_resources 2 | import os 3 | import shutil 4 | 5 | _parameters = None 6 | 7 | 8 | def get_parameters(): 9 | if _parameters is None: 10 | parse_config_file() 11 | return _parameters 12 | 13 | 14 | def parse_config_file(): 15 | global _parameters 16 | try: 17 | contents = open("default_parameters.txt", "rb") 18 | except: 19 | print("Could not open default_parameters.txt. Using global config file instead.") 20 | contents = pkg_resources.resource_stream(__name__, "default_parameters.txt") 21 | _parameters = {} if _parameters is None else _parameters 22 | for line in contents.readlines(): 23 | key, value = line.decode("utf-8").split(":") 24 | process_value = value[:-1].lower() 25 | if process_value in ("false", "no", "nei"): 26 | _parameters[key] = False 27 | else: 28 | _parameters[key] = value[:-1] 29 | contents.close() 30 | 31 | 32 | def create_local_config_file(): 33 | # TODO: It could be nice to have an interactively created config file. 34 | # However it is relatively small and easy to format so really not that useful. 35 | try: 36 | open("default_parameters.txt", "rb") 37 | print("You already have a local default_parameters.txt. Delete this before copying.") 38 | return 39 | except: 40 | pass 41 | 42 | filename = os.path.join(os.path.dirname(__file__), "default_parameters.txt") 43 | shutil.copy(filename, "default_parameters.txt") 44 | print("Local configuration file created: default_parameters.txt") 45 | -------------------------------------------------------------------------------- /virtual_classroom/send_email.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | from getpass import getpass 3 | from smtplib import SMTP, SMTP_SSL 4 | from datetime import datetime 5 | from email.encoders import encode_base64 6 | from email.mime.multipart import MIMEMultipart 7 | from email.mime.text import MIMEText 8 | from email.mime.base import MIMEBase 9 | from sys import exit 10 | from os import path 11 | 12 | # Local imports 13 | from .parameters import get_parameters 14 | 15 | try: 16 | from docutils import core 17 | except ImportError: 18 | print('docutils is required, exiting.\n\n sudo pip install docutils') 19 | exit(1) 20 | 21 | try: 22 | import jinja2 23 | except ImportError: 24 | print("jinja2 is required, exiting.\n\n sudo pip install jinja2") 25 | exit(1) 26 | 27 | # Python3 and 2 compatible 28 | try: input = raw_input 29 | except NameError: pass 30 | 31 | 32 | def connect_to_email_server(smtp=None): 33 | """Connects to an email server. Prompting for login information. 34 | 35 | Parameters 36 | ---------- 37 | smtp : str, optional 38 | Name of the smtp server (uio or google). 39 | If not specified it will use "smtp" in default parameters. 40 | 41 | Returns 42 | ------- 43 | EmailServer 44 | An instance that holds the email server connection. 45 | 46 | """ 47 | parameters = get_parameters() 48 | smtp = parameters["smtp"] if smtp is None else smtp 49 | # Set up e-mail server 50 | if smtp == 'google': 51 | server = SMTPGoogle() 52 | elif smtp == 'uio': 53 | server = SMTPUiO() 54 | return server 55 | 56 | 57 | class EmailServer(object): 58 | """ 59 | Class holds a connection to an email server to avoid closing and opening 60 | connections for each mail. 61 | """ 62 | def __init__(self, smtp_server, port): 63 | self.smtp_server, self.port = smtp_server, port 64 | 65 | tries = 0 66 | while tries < 3: 67 | # Get username and password from user 68 | self.username = input("\nFor %s\nUsername: " % smtp_server) 69 | self.password = getpass("Password:") 70 | if self.login(): 71 | return 72 | tries += 1 73 | print("Too many tries. Exiting...") 74 | exit(1) 75 | 76 | def login(self): 77 | raise NotImplementedError("Only use subclasses of EmailServer.") 78 | 79 | def logout(self): 80 | """ 81 | Closes connection to the smtp server. 82 | """ 83 | self.server.quit() 84 | 85 | 86 | class SMTPGoogle(EmailServer): 87 | """ 88 | Contains a google smtp server connection. 89 | """ 90 | def __init__(self): 91 | super(SMTPGoogle, self).__init__('smtp.gmail.com', 587) 92 | self.email = self.username 93 | 94 | def login(self): 95 | try: 96 | self.server = SMTP(self.smtp_server, self.port) 97 | self.server.starttls() 98 | self.server.login(self.username, self.password) 99 | return True 100 | except Exception as e: 101 | print('Username or password is wrong for %s, please try again!'\ 102 | % self.username) 103 | print(e) 104 | print("") 105 | print("Maybe you need activate less secure apps on gmail? See https://www.google.com/settings/u/1/security/lesssecureapps") 106 | return False 107 | 108 | 109 | class SMTPUiO(EmailServer): 110 | """ 111 | Class holds a connection to a UiO SMTP server. 112 | """ 113 | def __init__(self): 114 | smtp_address = 'smtp.uio.no' 115 | super(SMTPUiO, self).__init__(smtp_address, 465) 116 | self.email = input('Email address (%s): ' % smtp_address) 117 | 118 | def login(self): 119 | try: 120 | self.server = SMTP_SSL(self.smtp_server, self.port) 121 | self.server.login(self.username, self.password) 122 | return True 123 | except: 124 | print('Username or password is wrong for %s, please try again!'\ 125 | % self.smtp_server) 126 | return False 127 | 128 | 129 | class Email(object): 130 | def __init__(self, server_connection, email_body, subject=""): 131 | self.server_connection = server_connection 132 | self.email_body = email_body 133 | self.subject = subject 134 | 135 | def send(self, recipients, subject=None, msg=None): 136 | """Send email 137 | 138 | This method may raise the following exceptions: 139 | 140 | SMTPHeloError The server didn't reply properly to 141 | the helo greeting. 142 | SMTPRecipientsRefused The server rejected ALL recipients 143 | (no mail was sent). 144 | SMTPSenderRefused The server didn't accept the from_addr. 145 | SMTPDataError The server replied with an unexpected 146 | error code (other than a refusal of 147 | a recipient). 148 | 149 | """ 150 | subject = self.subject if subject is None else subject 151 | msg = self.format_mail(recipients, subject) if msg is None else msg 152 | 153 | failed_deliveries = \ 154 | self.server_connection.server.sendmail(self.server_connection.email, 155 | recipients, msg.as_string()) 156 | if failed_deliveries: 157 | print('Could not reach these addresses:', failed_deliveries) 158 | return False 159 | else: 160 | print('Email successfully sent to %s' % recipients) 161 | return True 162 | 163 | def format_mail(self, recipients, subject): 164 | if isinstance(recipients, (list, dict, tuple)): 165 | recipients = ", ".join(recipients) 166 | 167 | # Compose email 168 | msg = MIMEMultipart() 169 | msg['Subject'] = subject 170 | msg['To'] = recipients 171 | msg['From'] = self.server_connection.email 172 | msg.attach(self.email_body.format()) 173 | return msg 174 | 175 | 176 | class EmailBody(object): 177 | def __init__(self, filename, params=None): 178 | self.filename = filename 179 | self.params = params 180 | self.cache = True # Cache pre-formatted content 181 | self.cached_content = None 182 | 183 | def read(self): 184 | with open(self.filename, "rb") as f: 185 | contents = f.read().decode("utf-8") 186 | return contents 187 | 188 | @staticmethod 189 | def text_to_html(text): 190 | """Convert the text to html code""" 191 | parts = core.publish_parts(source=text, writer_name='html') 192 | return parts['body_pre_docinfo']+parts['fragment'] 193 | 194 | def render(self): 195 | content = self.cached_content 196 | if content is None: 197 | content = jinja2.Template(self.read()) 198 | 199 | if self.cache: 200 | self.cached_content = content 201 | return content.render(**self.params) 202 | 203 | def format(self): 204 | body_text = self.render() 205 | body_text = self.text_to_html(body_text).encode('utf-8') # ae, o, aa support 206 | body_text = MIMEText(body_text, 'html', 'utf-8') 207 | return body_text 208 | 209 | 210 | -------------------------------------------------------------------------------- /virtual_classroom/student.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # For support for python 2 and 3 4 | from __future__ import print_function, unicode_literals 5 | 6 | from dateutil.parser import parse 7 | from datetime import datetime 8 | 9 | from .api import APIManager 10 | 11 | 12 | class Student(object): 13 | """Holds all the information about the student.""" 14 | 15 | def __init__(self, name, uio_username, username, university, course, email, present, rank): 16 | """When initialized it testes if the information is correct and if the 17 | student has been initialized before. If not it calles create_repository() 18 | """ 19 | self.name = name 20 | self._present = "-" 21 | try: 22 | self.rank = int(rank) 23 | if rank > 3 or rank < 1: 24 | print("%s has a rank out of bound(%s) is has to be," % self.name, self.rank + \ 25 | "from 1 to 3. It is now set to 2.") 26 | self.rank = 2 27 | except: 28 | print("%s has wrong format on his/her rank," % self.name + \ 29 | " it has to be an integer. It is now set to 2.") 30 | self.rank = 2 31 | 32 | self.uio_username = uio_username 33 | self.email = email 34 | self.username = username 35 | self.course = course 36 | self.university = university 37 | 38 | # Create useful strings 39 | self.org = "%s-%s" % (university, course) 40 | 41 | # TODO: There probably is a good way to populate this when finding student repo below. 42 | self.last_active = None 43 | self.present = present 44 | 45 | self.api = APIManager() 46 | 47 | self.repo_name = "%s-%s" % (self.course, self.uio_username) 48 | # Check that there is an user with the given username 49 | if self.is_user(): 50 | repo = self.api.get_repo(self.org, self.repo_name) 51 | if repo.status_code == 404: 52 | # Create repo if it doesn't exist 53 | self.create_repository() 54 | self.last_active = parse(datetime.now().isoformat(), ignoretz=True) 55 | else: 56 | # Populate last active 57 | self.last_active = parse(repo.json()["pushed_at"], ignoretz=True) 58 | 59 | def is_user(self): 60 | """ 61 | Check if the given username is a user on GitHub. 62 | If it is not a user the program will skip this student 63 | and give a warning. 64 | """ 65 | ref = self.api.get_user(self.username) 66 | msg = "User: %s does not exist on GitHub and a repository will not be created." \ 67 | % self.username 68 | if ref.status_code != 200: 69 | print(msg) 70 | return False 71 | return True 72 | 73 | def create_repository(self): 74 | """Creates a repository '-' and a team ''.""" 75 | 76 | # Arguments to new team and repo 77 | key_repo = { 78 | "name": self.repo_name, 79 | "auto_init": True, 80 | "private": True 81 | } 82 | key_team = { 83 | "name": self.uio_username, 84 | "repo_names": ["github/%s" % self.repo_name], # correct? 85 | "permission": "admin" 86 | } 87 | 88 | # Add team and repo 89 | r_repo = self.api.create_repo(self.org, key_repo) 90 | r_team = self.api.create_team(self.org, key_team) 91 | 92 | # When creating a team the user is added, fix this by removing 93 | # auth[0] from the team before the student is added 94 | if r_team.json()['members_count'] != 0 and r_team.status_code == 201: 95 | team_id = str(r_team.json()['id']) 96 | r_remove_auth = self.api.delete_team_member(self.org, team_id, self.api.auth[0]) 97 | if r_remove_auth.status_code != 204: 98 | print("Could not delete user:%s from team:%s, this should" % (self.api.auth[0], self.name) + \ 99 | "be done manually or by a separate script") 100 | 101 | # Check success 102 | success = True 103 | if r_repo.status_code != 201: 104 | print("Error: %d - did not manage to add a repository for %s" % \ 105 | (r_repo.status_code, self.username)) 106 | success = False 107 | elif r_team.status_code != 201: 108 | print("Error: %d - did not manage to add a team for %s" % \ 109 | (r_team.status_code, self.username)) 110 | success = False 111 | 112 | # Add repository to team and invite user to team 113 | if success: 114 | team_id = str(r_team.json()['id']) 115 | # TODO: Is there a reason why you go for Content-Length: 0 for just one of the two PUT requests? 116 | r_add_repo = self.api.add_team_repo(team_id, self.org, self.repo_name) 117 | r_add_member = self.api.add_team_membership(team_id, self.username) 118 | 119 | # Check if everthing succeeded 120 | if r_add_repo.status_code != 204: 121 | print("Error: %d - did not manage to add repo to team:%s" % \ 122 | (r_add_repo.status_code, self.name)) 123 | elif r_add_member.status_code != 200: 124 | print("Error: %d - did not manage to add usr:%s to team:%s" \ 125 | % (r_add_member.status_code, self.username, self.name)) 126 | 127 | def repo_exist(self, repo_name): 128 | """Check if there exixts a repo with the given name""" 129 | repos = self.api.get_repos(self.org) 130 | for repo in repos: 131 | if repo_name == repo['name']: 132 | return True 133 | 134 | return False 135 | 136 | def has_team(self): 137 | """Check if there exist a team """ 138 | teams = self.api.get_teams(self.org) 139 | for team in teams: 140 | if self.uio_username == team['name']: 141 | return True 142 | 143 | return False 144 | 145 | def get_stats(self): 146 | """Not implemented""" 147 | pass 148 | 149 | @property 150 | def present(self): 151 | return self._present 152 | 153 | @present.setter 154 | def present(self, value): 155 | if value is True or str(value).lower() == "x": 156 | self._present = "x" 157 | else: 158 | self._present = "-" 159 | -------------------------------------------------------------------------------- /virtual_classroom/students_file.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function, unicode_literals 2 | from re import split 3 | import os 4 | 5 | from .parameters import get_parameters 6 | 7 | 8 | students_file_columns = ["present", 9 | "name", 10 | "uio_username", 11 | "username", 12 | "email", 13 | "course"] 14 | 15 | 16 | def get_students_file_path(filename=None): 17 | if filename is None: 18 | parameters = get_parameters() 19 | students_file = parameters["students_file"] 20 | filename = students_file.format(**parameters) 21 | filename = os.path.join(os.path.dirname(__file__), filename) 22 | return filename 23 | 24 | 25 | def parse_students_file(filename=None): 26 | rs = open(get_students_file_path(filename), "rb") 27 | 28 | students_values = [] 29 | i = 0 30 | for line in rs.readlines(): 31 | i += 1 32 | entries = split(r"\s*\/\/\s*", line.decode("utf-8").replace('\n', '')) 33 | student_values = _extract_entries(entries) 34 | if student_values is not None: 35 | students_values.append(student_values) 36 | elif i > 1: 37 | print("Found no student on line %d. Possibly wrong formatting." % i) 38 | 39 | return students_values 40 | 41 | 42 | def _extract_entries(entries): 43 | if len(entries) < len(students_file_columns): 44 | return None 45 | 46 | values = {} 47 | for i in range(len(students_file_columns)): 48 | values[students_file_columns[i]] = entries[i] 49 | 50 | if values[students_file_columns[0]].lower() not in ("x", "-"): 51 | return None 52 | 53 | return values 54 | 55 | 56 | def save_students_file(students, filename=None): 57 | string = "Attendance // Name // UiO Username // Github username // Email // Course" + "\n" 58 | for student in students: 59 | string += " // ".join([getattr(student, i) for i in students_file_columns]) 60 | string += "\n" 61 | 62 | with open(get_students_file_path(filename), "wb") as f: 63 | f.write(string.encode("utf-8")) 64 | -------------------------------------------------------------------------------- /virtual_classroom/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function, unicode_literals 2 | import os 3 | import json 4 | 5 | from .parameters import get_parameters 6 | 7 | # Python3 and 2 compatible 8 | try: input = raw_input 9 | except NameError: pass 10 | 11 | 12 | class CSVObject(object): 13 | def __init__(self, filename=None, content=None): 14 | if filename is None: 15 | self.raw_content = content 16 | else: 17 | self.raw_content = open(filename, "rb").read().decode("utf-8") 18 | self.values = [] 19 | self._parse() 20 | 21 | def _parse(self): 22 | try: 23 | self._csv_read("utf-8") 24 | except: 25 | self._csv_read(None) 26 | 27 | def _csv_read(self, encoding): 28 | import csv 29 | if encoding is None: 30 | gen = self.raw_content.splitlines() 31 | else: 32 | gen = self.raw_content.encode(encoding).splitlines() 33 | reader = csv.reader(gen) 34 | # from IPython import embed; embed(); 35 | for row in reader: 36 | if encoding is not None: 37 | row = [i.decode(encoding) for i in row] 38 | self.values.append(row) 39 | 40 | def __getitem__(self, item): 41 | return self.values[item] 42 | 43 | 44 | def download_google_spreadsheet(name, filename=None): 45 | import gspread 46 | from oauth2client.service_account import ServiceAccountCredentials 47 | 48 | # Get password and username 49 | json_file = input("Path to Google credentials JSON file (see" \ 50 | " http://gspread.readthedocs.org/en/latest/oauth2.html): ") 51 | 52 | # Log on to disk 53 | scope = ['https://spreadsheets.google.com/feeds'] 54 | credentials = ServiceAccountCredentials.from_json_keyfile_name(json_file, scope) 55 | 56 | gc = gspread.authorize(credentials) 57 | try: 58 | wks = gc.open(name).sheet1.export() 59 | except gspread.SpreadsheetNotFound: 60 | json_key = json.load(open(json_file)) 61 | print("The spreadsheet document '{}' not found. Maybe it does not exist?".format(name)) 62 | print("Otherwise, make sure that you shared the spreadsheet with {} and try again.".format( 63 | json_key['client_email'])) 64 | return None 65 | 66 | if filename is not None: 67 | with open(filename, "wb") as f: 68 | f.write(wks.encode("utf-8")) 69 | 70 | return wks.decode("utf-8") 71 | 72 | 73 | def create_students_file_from_csv(csv_str=None, csv_filename=None, output_filename="students_base.txt"): 74 | csv = CSVObject(filename=csv_filename, content=csv_str) 75 | 76 | if os.path.isfile(output_filename): 77 | answ = input("The %s file exists, are you " % (output_filename) + \ 78 | "sure you want to overwrite this?! (yes/no): ") 79 | if "yes" != answ.lower(): 80 | exit(1) 81 | 82 | string = 'Attendance // ' + ' // '.join(csv[0][1:]) + '\n' 83 | for row in csv[1:]: 84 | string += '- // ' + ' // '.join(row[1:]) + '\n' # Remove timestamp from each row 85 | 86 | with open(output_filename, 'wb') as f: 87 | f.write(string.encode("utf-8")) 88 | 89 | print('Output written on %s.' % output_filename) 90 | return output_filename 91 | 92 | 93 | 94 | 95 | --------------------------------------------------------------------------------