├── .gitignore ├── README.md ├── requirements.txt ├── static └── style.css └── syllabus ├── __init__.py ├── app.py └── templates ├── create.html ├── edit.html ├── fork.html ├── join.html ├── login.html ├── projects.html ├── register.html └── view.html /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | venv/ 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # App-specific 27 | app.conf 28 | test.py 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This project is now moving to [dat-syllabus](https://github.com/sdockray/dat-syllabus) which is an app for [Beaker Browser](http://beakerbrowser.com). Right now, you can use Beaker Browser to fork the base syllabus and create your own. The question now is whether to make a Hub (like [hashbase.io](http://hashbase.io), for example) and if so, what it should do. 2 | 3 | # syllabus-hub 4 | 5 | This is a website for a syllabus sharing community - a "GitHub for syllabi." You can see it running at [http://syllabus.thepublicschool.org](http://syllabus.thepublicschool.org) 6 | 7 | It imagines a syllabus a bit like software, as a product of collective intellectual labor, as a score to be interpreted or acted out in different contexts. 8 | 9 | Technically, it is a Flask front end that uses Gitlab as a backend via the Gitlab API. Gitlab/Github can be confusing to people who are non-programmers and a lot of the interface is irrelevant to syllabi, so this project strips it down to the essentials in order to build back up. Right now, it features a syllabus list, markdown editing, simplified creation, and syllabus cloning. I think that revision history, branching, adding licenses, issues (as discussions), and possibly merge requests or groups could all be interesting things to implement (all of which are fairly easy through the API). 10 | 11 | This project comes out of conversations with many people at The Public School including especially Chandler McWilliams and Caleb Waldorf. 12 | 13 | ### Installation 14 | 15 | ``` 16 | git clone http://github.com/sdockray/syllabus-hub 17 | cd syllabus-hub 18 | virtualenv venv 19 | source venv/bin/activate 20 | pip install -r requirements.txt 21 | ``` 22 | 23 | then you have to create a config file 24 | ``` 25 | nano app.conf 26 | ``` 27 | and make sure the following are defined: 28 | ``` 29 | SECRET_KEY = 'change me to anything' 30 | GIT_SERVER = 'http://your.gitlab.server' 31 | GIT_PRIVATE_TOKEN = 'rootUserPrivateToken' 32 | PORT = 5001 33 | ``` 34 | If you want to contribute to the development, it is a fairly straightforward Flask project. You will likely want to do some more with the Gitlab API, so have a look at the documentation for pyapi-gitlab: [http://pyapi-gitlab.readthedocs.io/en/latest/#api-doc](http://pyapi-gitlab.readthedocs.io/en/latest/#api-doc) 35 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask 2 | flask-login 3 | flask-markdown 4 | Flask-WTF 5 | pyapi-gitlab 6 | itsdangerous 7 | markdown2 -------------------------------------------------------------------------------- /static/style.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdockray/syllabus-hub/cecf7aa43fe1b14dd3e9d10cf5eae31e03bab20d/static/style.css -------------------------------------------------------------------------------- /syllabus/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sdockray/syllabus-hub/cecf7aa43fe1b14dd3e9d10cf5eae31e03bab20d/syllabus/__init__.py -------------------------------------------------------------------------------- /syllabus/app.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import unicodedata 4 | 5 | from flask import Flask, render_template, url_for, request, redirect, flash, g 6 | from flaskext.markdown import Markdown 7 | from flask.ext.login import login_user, logout_user, current_user, login_required, LoginManager, UserMixin 8 | from flask_wtf import Form 9 | from wtforms import StringField, PasswordField 10 | from wtforms.widgets import TextArea 11 | from wtforms.validators import DataRequired, Email, EqualTo, Length, Regexp 12 | import gitlab 13 | import markdown2 14 | 15 | # set up and config 16 | app = Flask(__name__) 17 | app.config.from_pyfile('../app.conf', silent=True) 18 | app.config.from_envvar('SYLLABUS_APP_SETTINGS', silent=True) 19 | app.secret_key = app.config['SECRET_KEY'] 20 | # initialize extensions 21 | Markdown(app) 22 | login_manager = LoginManager() 23 | login_manager.init_app(app) 24 | login_manager.login_view = 'login' 25 | 26 | # Allows templates access to current user through g.user 27 | @app.before_request 28 | def before_request(): 29 | g.user = current_user 30 | 31 | 32 | # utility for encoding problems 33 | def dcode(s): 34 | return unicodedata.normalize('NFKD', s.decode("utf-8")).encode('ascii', 'ignore') 35 | 36 | # Flashes form errors 37 | def flash_errors(form): 38 | for field, errors in form.errors.items(): 39 | for error in errors: 40 | flash(u"Error in the %s field - %s" % ( 41 | getattr(form, field).label.text, 42 | error 43 | )) 44 | 45 | """ 46 | Forms 47 | """ 48 | class LoginForm(Form): 49 | email = StringField('Email', validators=[DataRequired(), Email()]) 50 | password = PasswordField('Password', validators=[DataRequired()]) 51 | 52 | class RegistrationForm(Form): 53 | name = StringField('Display Name', [Length(min=4, max=25)]) 54 | username = StringField('Unique Username', 55 | [Length(min=4, max=25), 56 | Regexp(r'^[\w]+$') 57 | ]) 58 | email = StringField('Email Address', [Length(min=6, max=50)]) 59 | password = PasswordField('Password', [ 60 | DataRequired(), 61 | EqualTo('confirm', message='Passwords must match'), 62 | Length(min=8) 63 | ]) 64 | confirm = PasswordField('Repeat Password') 65 | 66 | class EditForm(Form): 67 | content = StringField(u'Text', widget=TextArea()) 68 | 69 | class CreateForm(Form): 70 | title = StringField('Title', [Length(min=3)]) 71 | content = StringField(u'Text', widget=TextArea()) 72 | 73 | 74 | """ 75 | Non-project Gitlab API interaction 76 | """ 77 | class Git(object): 78 | def __init__(self, *args, **kwargs): 79 | if 'token' in kwargs and kwargs['token']: 80 | self.gl = gitlab.Gitlab(app.config['GIT_SERVER'], token=kwargs['token']) 81 | elif 'user' in kwargs and kwargs['user'] and kwargs['user'].is_authenticated: 82 | self.gl = gitlab.Gitlab(app.config['GIT_SERVER'], token=kwargs['user'].id) 83 | else: 84 | self.gl = gitlab.Gitlab(app.config['GIT_SERVER'], token=app.config['GIT_PRIVATE_TOKEN']) 85 | 86 | def user_login(self, email, password): 87 | try: 88 | self.gl.login(email, password) 89 | return self.gl.currentuser() 90 | except: 91 | return False 92 | 93 | def user_create(self, name, username, email, password): 94 | print name, username, password, email 95 | try: 96 | return self.gl.createuser(name, username, password, email) 97 | except: 98 | return False 99 | 100 | def current_user(self): 101 | u = self.gl.currentuser() 102 | if not u or not 'private_token' in u: 103 | return False 104 | return u 105 | 106 | def user_projects(self): 107 | u = self.gl.currentuser() 108 | print u 109 | return [p for p in self.gl.getall(self.gl.getprojectsowned)] 110 | 111 | def all_projects(self, ignore_forks=False): 112 | if ignore_forks: 113 | return [p for p in self.gl.getall(self.gl.getprojectsall) if 'forked_from_project' not in p] 114 | else: 115 | return [p for p in self.gl.getall(self.gl.getprojectsall)] 116 | 117 | def create_project_with_content(self, title, content): 118 | import string 119 | new_p = self.gl.createproject( 120 | "".join(l for l in title if l not in string.punctuation), 121 | description=title, 122 | public=True, 123 | visibility_level=20) 124 | if new_p: 125 | self.gl.createfile(new_p['id'], 'README.md', 'master', content, 'creating syllabus') 126 | return new_p 127 | 128 | """ 129 | Interfaces with Gitlab API for a project 130 | """ 131 | class Project(Git): 132 | def __init__(self, project_id, user=False): 133 | if user and user.is_authenticated: 134 | super(Project, self).__init__(token=user.id) 135 | else: 136 | super(Project, self).__init__() 137 | self.project = self.gl.getproject(project_id) 138 | 139 | def get_content(self, file_name='README.md', branch='master'): 140 | return self.gl.getrawfile(self.project['id'], branch, file_name) 141 | 142 | def get_title(self): 143 | return self.project['name_with_namespace'] 144 | 145 | def get_forks_count(self): 146 | return self.project['forks_count'] 147 | 148 | def update(self, content, file_name='README.md', branch='master'): 149 | self.gl.updatefile(self.project['id'], file_name, branch, content, 'saved via web') 150 | 151 | def join(self): 152 | if 'id' in self.project: 153 | # 40 seems to allow saving (not sure if a slightly lower level would also work? 30 does not) 154 | return self.gl.addprojectmember(self.project['id'], self.current_user()['id'], 40) 155 | return False 156 | 157 | def fork(self): 158 | if 'id' in self.project: 159 | # I have to do direct curl because the API call seems buggy 160 | cmd = 'curl -X POST -H "PRIVATE-TOKEN: %s" "%s/api/v3/projects/fork/%s"' % (self.current_user()['private_token'], app.config['GIT_SERVER'], self.project['id']) 161 | os.system(cmd) 162 | return True # just assume 163 | #return self.gl.createfork(self.project['id']) 164 | return False 165 | 166 | def can_edit(self, consider_members=True): 167 | u = self.current_user() 168 | if not u or not 'username' in u or not 'owner' in self.project: 169 | return False 170 | if u['username']==self.project['owner']['username']: 171 | return True 172 | if consider_members: 173 | for m in self.gl.getall(self.gl.getprojectmembers, self.project['id']): 174 | if m['id']==u['id']: 175 | return True 176 | return False 177 | 178 | def can_fork(self): 179 | u = self.current_user() 180 | if not u or not 'username' in u or not 'owner' in self.project: 181 | return False 182 | if self.can_edit(consider_members=False): 183 | return False 184 | import pprint 185 | for p in self.gl.getall(self.gl.getprojectsowned): 186 | if 'forked_from_project' in p and p['forked_from_project']['id']==self.project['id']: 187 | return False 188 | return True 189 | 190 | 191 | """ 192 | User/ login manager stuff 193 | """ 194 | class User(UserMixin): 195 | def __init__(self, *args, **kwargs): 196 | super(User, self).__init__() 197 | for kw in kwargs: 198 | setattr(self, kw, kwargs[kw]) 199 | self.user_id = self.id 200 | self.id = self.private_token 201 | 202 | @login_manager.user_loader 203 | def user_loader(id): 204 | git = Git(token=id) 205 | u = git.current_user() 206 | if not u: 207 | return User() 208 | user = User(**u) 209 | return user 210 | 211 | 212 | """ 213 | ROUTING 214 | """ 215 | 216 | @app.route('/') 217 | def projects_list(): 218 | git = Git() 219 | return render_template('projects.html', 220 | title = 'syll...', 221 | projects = git.all_projects() 222 | ) 223 | 224 | @app.route('/home') 225 | @login_required 226 | def projects_user(): 227 | git = Git(token=current_user.id) 228 | return render_template('projects.html', 229 | title = '%s /' % current_user.name, 230 | projects = git.user_projects() 231 | ) 232 | 233 | 234 | @app.route('//') 235 | def view_project(namespace, project_name): 236 | p = Project("%s/%s" % (namespace, project_name), user=current_user) 237 | return render_template('view.html', 238 | title = p.get_title(), 239 | project = p.project, 240 | content = dcode(p.get_content()), 241 | edit_url = url_for('edit_project', namespace=namespace, project_name=project_name) if p.can_edit() else False, 242 | fork_url = url_for('fork_project', namespace=namespace, project_name=project_name) if p.can_fork() else False 243 | ) 244 | 245 | @app.route('/make', methods=['GET', 'POST']) 246 | @login_required 247 | def create_project(): 248 | if 'content' in request.form: 249 | # create project 250 | git = Git(token=current_user.id) 251 | new_p = git.create_project_with_content(request.form['title'], request.form['content']) 252 | if new_p: 253 | path = new_p['path_with_namespace'].split('/') 254 | return redirect(url_for('view_project', namespace=path[0], project_name=path[1])) 255 | else: 256 | flash('Sorry but something went wrong and the syllabus could not be saved.') 257 | return redirect(url_for('create_project')) 258 | return render_template('create.html', 259 | title = 'New syllabus', 260 | save_url = url_for('create_project'), 261 | form = CreateForm()) 262 | 263 | 264 | @app.route('///edit', methods=['GET', 'POST']) 265 | @login_required 266 | def edit_project(namespace, project_name): 267 | p = Project("%s/%s" % (namespace, project_name), user=current_user) 268 | if not p.can_edit(): 269 | return redirect(url_for('join_project', namespace=namespace, project_name=project_name)) 270 | if 'content' in request.form: 271 | p.update(request.form['content']) 272 | flash('Syllabus saved.') 273 | return redirect(url_for('view_project', namespace=namespace, project_name=project_name)) 274 | return render_template('edit.html', 275 | title = p.get_title(), 276 | save_url = url_for('edit_project', namespace=namespace, project_name=project_name), 277 | form = EditForm(content=dcode(p.get_content()))) 278 | 279 | 280 | @app.route('///join', methods=['GET', 'POST']) 281 | @login_required 282 | def join_project(namespace, project_name): 283 | p = Project("%s/%s" % (namespace, project_name), user=current_user) 284 | if p.can_edit(): 285 | return "You are already collaborating on this syllabus." 286 | if request.method == 'POST': 287 | p.join() 288 | flash('You are now collaborating on this syllabus.') 289 | return redirect(url_for('view_project', namespace=namespace, project_name=project_name)) 290 | return render_template('join.html', 291 | title = p.get_title(), 292 | project_name = project_name, 293 | owner = p.project['owner']['name'], 294 | url = url_for('view_project', namespace=namespace, project_name=project_name), 295 | join_url = url_for('join_project', namespace=namespace, project_name=project_name), 296 | fork_url = url_for('fork_project', namespace=namespace, project_name=project_name)) 297 | 298 | @app.route('///clone', methods=['GET', 'POST']) 299 | @login_required 300 | def fork_project(namespace, project_name): 301 | p = Project("%s/%s" % (namespace, project_name), user=current_user) 302 | if not p.can_fork(): 303 | return "Something went wrong. You can't clone this syllabus." 304 | if request.method == 'POST': 305 | new_p = p.fork() 306 | if new_p: 307 | return redirect(url_for('projects_user')) 308 | return render_template('fork.html', 309 | title = p.get_title(), 310 | project_name = project_name, 311 | url = url_for('view_project', namespace=namespace, project_name=project_name), 312 | fork_url = url_for('fork_project', namespace=namespace, project_name=project_name) 313 | ) 314 | 315 | @app.route('/login', methods=['GET', 'POST']) 316 | def login(): 317 | form = LoginForm() 318 | if form.validate_on_submit(): 319 | git = Git() 320 | u = git.user_login(request.form['email'], request.form['password']) 321 | if u: 322 | user = User(**u) 323 | login_user(user) 324 | return redirect(request.args.get('next') or url_for('projects_user')) 325 | return render_template('login.html', 326 | title = 'Login', 327 | form = form) 328 | 329 | @app.route('/register', methods=['GET', 'POST']) 330 | def register(): 331 | form = RegistrationForm() 332 | if form.validate_on_submit(): 333 | git = Git() 334 | success = git.user_create(request.form['name'], request.form['username'], request.form['email'], request.form['password']) 335 | if success: 336 | u = git.user_login(request.form['email'], request.form['password']) 337 | if u: 338 | user = User(**u) 339 | login_user(user) 340 | return redirect(request.args.get('next') or url_for('projects_user')) 341 | else: 342 | flash("The account couldn't be created. It might be because the email or username are already being used.") 343 | else: 344 | flash_errors(form) 345 | return render_template('register.html', 346 | title = 'Register', 347 | form = form) 348 | 349 | @app.route('/logout') 350 | @login_required 351 | def logout(): 352 | logout_user() 353 | return 'Logged out' 354 | 355 | 356 | 357 | 358 | if __name__ == '__main__': 359 | app.debug = True 360 | app.run(host='0.0.0.0', port=app.config['PORT']) 361 | -------------------------------------------------------------------------------- /syllabus/templates/create.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | edit: {{ title }} 7 | 8 | 9 | 10 | 11 | 20 | 21 | 22 |
23 |
24 |
25 | {{ form.title(placeholder='Title', style="width: 400px;") }} 26 |

27 |
28 |
29 | 41 |
42 |
43 |

FYI: there is no way to delete anything yet.

44 |
45 | 48 | 49 | -------------------------------------------------------------------------------- /syllabus/templates/edit.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | edit: {{ title }} 7 | 8 | 9 | 10 | 11 | 20 | 21 | 22 |
23 |
24 |

25 |
26 | {{ form.content }} 27 |
28 |
29 |
30 | 33 | 34 | -------------------------------------------------------------------------------- /syllabus/templates/fork.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | clone: {{ title }} 7 | 8 | 14 | 15 | 16 |
17 |

You are about to clone the {{ project_name }} syllabus.

18 |

This means you are creating a copy of the syllabus that you can edit.

19 |

The edits that you make to your copy will not affect the syllabus you copied. The original syllabus and your clone will be linked together, however, so that people can see different variations and evolutions of a syllabus across times and places.

20 |

If you want to continue, click the button below.

21 |
22 | 23 |
24 |
25 | 26 | -------------------------------------------------------------------------------- /syllabus/templates/join.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | join: {{ title }} 7 | 8 | 14 | 15 | 16 |
17 |

You are about to join the {{ project_name }} syllabus created by {{ owner }}.

18 |

You should only join if:

19 |
    20 |
  • you are collaborating with the syllabus creator
  • 21 |
  • you want to make the most minor of changes (to spelling or formatting)
  • 22 |
23 |

Otherwise, you should probably clone the syllabus. Then you'll have your own copy that you can change as much as you want.

24 |

If you still want to join this syllabus, click the button below.

25 |
26 | 27 |
28 |
29 | 30 | -------------------------------------------------------------------------------- /syllabus/templates/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ title }} 7 | 8 | 14 | 15 | 16 |
17 |
18 |
19 | login (or register) to create, edit, or clone a syllabus 20 | {{ form.email(placeholder='Email') }} 21 | {{ form.password(placeholder='Password') }} 22 | 23 | {{ form.csrf_token }} 24 |
25 |
26 |
27 | 28 | -------------------------------------------------------------------------------- /syllabus/templates/projects.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ title }} 7 | 8 | 14 | 15 | 16 |
17 |
18 | 26 |
27 |
28 |
29 | {{ title }} 30 | 36 |
37 |
38 | 39 | -------------------------------------------------------------------------------- /syllabus/templates/register.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ title }} 7 | 8 | 14 | 15 | 16 |
17 | {% with messages = get_flashed_messages() %} 18 | {% if messages %} 19 |
    20 | {% for message in messages %} 21 |
  • {{ message }}
  • 22 | {% endfor %} 23 |
24 | {% endif %} 25 | {% endwith %} 26 |
27 |
28 | register (or login) to create or clone a syllabus 29 | {{ form.name(placeholder='Display name') }} 30 | {{ form.username(placeholder='Unique username') }} 31 | {{ form.email(placeholder='Email') }} 32 | {{ form.password(placeholder='Password') }} 33 | {{ form.confirm(placeholder='Password again') }} 34 | 35 | {{ form.csrf_token }} 36 |
37 |
38 |
39 | 40 | -------------------------------------------------------------------------------- /syllabus/templates/view.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ title }} 7 | 8 | 9 | 18 | 19 | 20 | {% if 'forked_from_project' in project: %} 21 | cloned from: {{ project['forked_from_project']['name_with_namespace'] }} 22 | {% elif project['forks_count']: %} 23 | 24 | {% endif %} 25 |
26 | {% with messages = get_flashed_messages() %} 27 | {% if messages %} 28 |
    29 | {% for message in messages %} 30 |
  • {{ message }}
  • 31 | {% endfor %} 32 |
33 | {% endif %} 34 | {% endwith %} 35 |
36 |
    37 |
  • /
  • 38 | {{ title }} 39 | {% if edit_url %} 40 |
  • edit
  • 41 | {% endif %} 42 | {% if fork_url %} 43 |
  • clone
  • 44 | {% endif %} 45 |
46 |
47 |
48 |
49 | {{ content | markdown }} 50 |
51 |
52 | 53 | --------------------------------------------------------------------------------