├── 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 |
23 | Drop fork and pull, use clone and push with a course-specific account
24 | Need to describe in detail how to operate multiple acounts as the lecturer and TA has to do that
25 | Do not use Python wrappers - use the REST APIs directly from
26 | home-made Python code, because the APIs are much better documented
27 | than the Python wrappers.
28 | Use Requests
29 | library in Python
30 |
31 |
32 |
33 |
34 |
35 | Further work:
36 |
37 |
38 | Instructor must be able to
39 |
40 |
41 | give one assessment group access to another group's exercise
42 | give two students access to two individual exercises, or maybe it is
43 | sufficient to have one student with access to one exercises and then
44 | they sit together and look at the same screen
45 | automate this process of pairing students or groups and giving
46 | them read/write access (for 3331 and 5620) - the latter thing is easy if
47 | the instructor has admin access and can use REST API to set and remove
48 | new accesses
49 |
50 |
51 | First, check out organization accounts and how github thinks about classroom use:
52 |
53 |
63 |
64 |
65 |
66 | GitHub
67 |
68 |
74 |
75 | What we want to do
76 |
77 |
78 | Fork a repo http://developer.github.com/v3/repos/forks/#create-a-fork
79 | Give TAs full rights to the forked repo http://developer.github.com/v3/repos/collaborators/#add-collaborator
80 | Create a pull request https://developer.github.com/v3/pulls/#create-a-pull-request , see this script
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 |
133 | Access
134 | Overview
135 | Fork (googling indicates this is not straightforward)
136 |
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 |
124 | All students have one personal, private repo with all their
125 | files in a course.
126 | All communication of electronic documents and other files
127 | happens via the repo.
128 | Teachers can assign an evaluation team to evaluate a student's
129 | work.
130 | The evaluation team
131 |
132 |
133 | gets temporary access to the student's repo,
134 | clones the repo,
135 | studies relevant files,
136 | writes a feedback file (according to conventions),
137 | pushes the feedback file (and maybe annotated files) to the repo.
138 |
139 |
140 | The evaluation team can the lecturer, teaching assistants, or
141 | a team of (e.g.) three other students.
142 |
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 |
156 | Students show up for a 2h project lab.
157 | The teacher has a list of all students and marks those who are present.
158 | A script assigns teams of three students to evaluate three other projects.
159 | The script gives the team members access to the repos to be evaluated and
160 | sends an email to the team members with repo details (clone command).
161 | The teams study the files under supervision of and with help from teachers.
162 | The teams write a feedback file for each repo and push it back.
163 | A script will some time after the project lab close down access
164 | to private student repos.
165 |
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 |
178 | + students will have many more exercises and projects evaluated
179 | and in more detail
180 | + students learn a lot from first having done an exercise and
181 | then evaluating three others
182 | - the quality of a thorough evaluation performed by a teacher
183 | will be higher
184 |
185 |
186 |
Functionality to be developed
187 |
188 |
189 | File format with list of all students. Convention for marking who
190 | will enter an evaluation. Content: full name, username, email.
191 | Function for creating a new private repo for the student in
192 | the GitHub classroom .
193 | Send email to the student when
194 | the account as created with info on how to obtain ssh access.
195 | Function for creating teams (based on the students present).
196 | Function for giving a list of users access to a given repo
197 | and for sending email to those who are granted access.
198 | Function for removing all access to a given repo, except for the owner.
199 |
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 |
--------------------------------------------------------------------------------