Skip to content

Commit 3241867

Browse files
aiswaciumawanjohi
authored andcommitted
Add script to generate new Trello boards
1 parent 9f2a70a commit 3241867

File tree

1 file changed

+397
-0
lines changed

1 file changed

+397
-0
lines changed

export.py

Lines changed: 397 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,397 @@
1+
#!/usr/bin/env python3
2+
"""Create and/or populate a Trello board from the ossu/computer-science curriculum JSON.
3+
4+
This script requires requests>=2.23.0, as well as a valid Trello API key and token (https://trello.com/app-key).
5+
Trello API credentials are provided using the following environment variables:
6+
7+
TRELLO_API_KEY
8+
TRELLO_API_TOKEN
9+
10+
Examples:
11+
To download the curriculum and create a new board, simply run::
12+
13+
$ python export.py
14+
15+
To use your own curriculum.json, provide the filename using the -f flag::
16+
17+
$ python export.py -f curriculum.json
18+
19+
To populate an empty board, provide the ID using the -b flag (e.g., trello.com/b/Wvt0LK6d/ossu-compsci)::
20+
21+
$ python export.py -b Wvt0LK6d
22+
23+
Attributes:
24+
CURRICULUM_PATH (str): The location of the curriculum JSON in the ossu/computer-science repository.
25+
JSON_OPTIONS (dict): A keyword dictionary provided to ``json.dump`` / ``json.dumps`` when writing JSON to disk.
26+
27+
"""
28+
29+
import json
30+
import os
31+
import os.path as path
32+
import re
33+
import sys
34+
from argparse import ArgumentParser
35+
from base64 import b64decode
36+
from datetime import datetime
37+
from enum import Enum
38+
39+
from requests import (
40+
Response,
41+
request
42+
)
43+
44+
CURRICULUM_PATH = 'curriculum.json?ref=JSON'
45+
JSON_OPTIONS = { 'indent': 2, 'ensure_ascii': False }
46+
47+
48+
class GitHubContent(object):
49+
50+
@property
51+
def json(self) -> dict:
52+
return self._data
53+
54+
@property
55+
def content(self) -> str:
56+
return self._data['content']
57+
58+
def __init__(self, data:dict):
59+
self._content = None
60+
self._data = data or None
61+
62+
63+
class CurriculumContent(GitHubContent):
64+
65+
@classmethod
66+
def from_cache_model(cls, cache_model:dict):
67+
curriculum_content = cls(dict())
68+
curriculum_content._content = cache_model
69+
return curriculum_content
70+
71+
@classmethod
72+
def from_github_content(cls, github_content:GitHubContent):
73+
return cls(github_content.json)
74+
75+
@property
76+
def content(self) -> dict:
77+
if self._content:
78+
return self._content
79+
content = b64decode(super().content)
80+
self._content = json.loads(content)
81+
return self.content
82+
83+
84+
class OssuCurriculum(object):
85+
86+
class SubsectionType(Enum):
87+
88+
unknown = (0, 'Unknown')
89+
intro = (1, 'Introduction to Programming')
90+
programming = (2, 'Programming')
91+
maths = math = (3, 'Maths')
92+
systems = (4, 'Systems')
93+
theory = (5, 'Theory')
94+
applications = (6, 'Applications')
95+
96+
@classmethod
97+
def from_subsection_name(cls, s:str) -> Enum:
98+
match = re.search(r'(intro|programming|electives|maths?|systems|theory|applications)', s, flags=re.I)
99+
return cls.__members__.get(match.group(1).lower() if match else None, cls.unknown)
100+
101+
def __init__(self, value:int, label:str):
102+
self._value = value
103+
self.label = label
104+
105+
class CurriculumNode(object):
106+
107+
__slots__ = ('name',)
108+
109+
def __init__(self, name:str):
110+
self.name = name
111+
112+
def __str__(self):
113+
return self.name
114+
115+
class Course(CurriculumNode):
116+
117+
_RE_NONCONFORMATIVE:re.Pattern = re.compile(r'(.+?)\)?\|\s(.+)\s\|\s(.+)')
118+
119+
__slots__ = ('url', 'url_alt', 'duration', 'effort', 'extra', 'prerequisites', '__repr__')
120+
121+
def __init__(self, course:dict):
122+
123+
duration, effort = course.get('Duration'), course.get('Effort')
124+
125+
url = course.get('URL')
126+
if not (effort or duration):
127+
# Core Applications contains Markdown artefacts from the course table in the URL field:
128+
# Format: '{url}| {duration} | {effort}'
129+
match:re.Match = self._RE_NONCONFORMATIVE.search(url)
130+
if (match is not None and len(match.groups()) == 3):
131+
url = match.group(1)
132+
duration = match.group(2)
133+
effort = match.group(3)
134+
else:
135+
raise ValueError('no effort, duration or supported URL defined')
136+
137+
super().__init__(name=course['Name'])
138+
self.url:str = url
139+
self.url_alt:str = course.get('Alternative', str())
140+
self.duration:str = duration
141+
self.effort:str = effort
142+
self.extra:str = course.get("Additional Text / Assignments", str())
143+
self.prerequisites:str = course['Prerequisites']
144+
self.__repr__ = lambda: f"{type(self).__module__}.{type(self).__qualname__}({course})"
145+
146+
class Discipline(CurriculumNode):
147+
148+
__slots__ = ('courses', '__repr__')
149+
150+
def __init__(self, discipline):
151+
super().__init__(name=discipline['Name'])
152+
self.courses:list = [OssuCurriculum.Course(i) for i in discipline['Courses']]
153+
self.__repr__ = lambda: f"{type(self).__module__}.{type(self).__qualname__}({discipline})"
154+
155+
class Subsection(CurriculumNode):
156+
157+
__slots__ = ('explanation', 'topics', 'courses', 'disciplines', '__repr__')
158+
159+
def __init__(self, subsection):
160+
super().__init__(name=subsection['Name'])
161+
self.explanation:str = subsection.get('Explanation', str())
162+
self.topics:list = [i['Name'] for i in subsection["Topics Covered"]]
163+
self.courses:list = [OssuCurriculum.Course(i) for i in subsection.get('Courses', list())]
164+
# Only applies to core maths electives:
165+
self.disciplines:list = [OssuCurriculum.Discipline(i) for i in subsection.get('Disciplines', list())]
166+
self.__repr__ = lambda: f"{type(self).__module__}.{type(self).__qualname__}({subsection})"
167+
168+
class Section(CurriculumNode):
169+
170+
__slots__ = ('explanation', 'subsections', '__repr__')
171+
172+
def __init__(self, section):
173+
super().__init__(name=section['Section Name'])
174+
self.explanation:str = section['Explanation']
175+
self.subsections:list = [OssuCurriculum.Subsection(i) for i in section['Subsections']]
176+
self.__repr__ = lambda: f"{type(self).__module__}.{type(self).__qualname__}({section})"
177+
178+
__slots__ = ('sections', '__repr__')
179+
180+
def __init__(self, curriculum:list):
181+
self.sections = [OssuCurriculum.Section(i) for i in curriculum]
182+
self.__repr__ = lambda: f"{type(self).__module__}.{type(self).__qualname__}({curriculum})"
183+
184+
185+
class TrelloClient(object):
186+
187+
TRELLO_ENDPOINT = 'https://api.trello.com/1'
188+
BOARD_SETTINGS = {
189+
'name': "OSSU - CompSci",
190+
'label_colors': {
191+
'curriculum': 'black',
192+
'extra': 'orange',
193+
# Colour of divider labels in the 'Done' list
194+
'subject': 'purple',
195+
# An ordered set of reserved label colours for each successive level.
196+
'sections': ('sky', 'purple', 'yellow')
197+
},
198+
}
199+
200+
@property
201+
def params(self) -> dict:
202+
return { 'key': self.api_key, 'token': self.api_token }
203+
204+
def create_board(self) -> str:
205+
response = self.request_method('POST', f'{self.TRELLO_ENDPOINT}/boards',
206+
params = self.params,
207+
data = {
208+
'name': "OSSU - CompSci",
209+
'defaultLabels': 'false',
210+
'defaultLists': 'false'
211+
}
212+
)
213+
data = json.loads(response.content)
214+
return data['id']
215+
216+
def create_list(self, board_id:str, name:str):
217+
response = self.request_method('POST', f'{self.TRELLO_ENDPOINT}/boards/{board_id}/lists',
218+
params = self.params,
219+
data = {
220+
'name': name,
221+
'pos': 'bottom'
222+
}
223+
)
224+
data = json.loads(response.content)
225+
return data['id']
226+
227+
def create_label(self, board_id:str, name:str, color:str) -> str:
228+
response = self.request_method('POST', f'{self.TRELLO_ENDPOINT}/boards/{board_id}/labels',
229+
params = self.params,
230+
data = {
231+
'name': name,
232+
'color': color
233+
}
234+
)
235+
data = json.loads(response.content)
236+
return data['id']
237+
238+
def create_card(self, list_id:str, label_ids:list, name:str, desc:str) -> str:
239+
response = self.request_method('POST', f'{self.TRELLO_ENDPOINT}/cards',
240+
params = self.params,
241+
data = {
242+
'idList': list_id,
243+
'idLabels': label_ids,
244+
'name': name,
245+
'desc': desc,
246+
'pos': 'bottom'
247+
}
248+
)
249+
data = json.loads(response.content)
250+
return data['id']
251+
252+
def __init__(self, api_key:str, api_token:str, request_method):
253+
self.api_key = api_key
254+
self.api_token = api_token
255+
self.request_method = request_method
256+
257+
258+
def _parse_args(argv:list):
259+
260+
parser = ArgumentParser('ossu-export')
261+
parser.add_argument('-b', '--board_id', type=str, metavar='S',
262+
help="the ID of a Trello board to populate"
263+
)
264+
parser.add_argument('-f', '--from_file', type=str, metavar='S',
265+
help="a JSON file to load decoded content from"
266+
)
267+
parser.add_argument('-t', '--to_file', type=str, metavar='S',
268+
help="a JSON file to save raw content to"
269+
)
270+
271+
args = parser.parse_args(argv).__dict__
272+
return dict([(k,v) for k,v in args.items() if v])
273+
274+
275+
def _request(method:str, url:str, **kwargs):
276+
response = request(method, url, **kwargs)
277+
response.raise_for_status()
278+
return response
279+
280+
281+
def get_github_content() -> GitHubContent:
282+
response:Response = _request(
283+
'GET', f'https://api.github.com/repos/ossu/computer-science/contents/{CURRICULUM_PATH}',
284+
headers = { 'Accept': "application/vnd.github.v3+json" }
285+
)
286+
data = json.loads(response.content)
287+
return GitHubContent(data)
288+
289+
290+
def get_curriculum_content(from_file:str) -> CurriculumContent:
291+
with open(from_file, 'r') as fp:
292+
content = fp.read()
293+
data = json.loads(content)
294+
return CurriculumContent.from_cache_model(data)
295+
296+
297+
def main(board_id:str = None, from_file:str = None, to_file:str = None) -> None:
298+
299+
# --------------- Curriculum retrieval --------------- #
300+
301+
cache_path = path.abspath(f"{datetime.utcnow().strftime('%Y%m%d')}_curriculum.json")
302+
if (not from_file and path.exists(cache_path)):
303+
print(f"Using cached result from {cache_path}")
304+
from_file = cache_path
305+
306+
curriculum_content:CurriculumContent
307+
if from_file:
308+
curriculum_content = get_curriculum_content(from_file)
309+
else:
310+
github_content = get_github_content()
311+
curriculum_content = CurriculumContent.from_github_content(github_content)
312+
313+
out = to_file.strip() if to_file else None
314+
if out:
315+
out = path.abspath(out)
316+
print(f"Writing response to file... (-> {out})")
317+
with open(out, 'w') as fp:
318+
fp.write(json.dumps(github_content.json, **JSON_OPTIONS))
319+
320+
if not from_file:
321+
print(f"Writing decoded response to file (-> {cache_path})")
322+
with open(cache_path, 'w') as fp:
323+
json.dump(curriculum_content.content, fp, **JSON_OPTIONS)
324+
325+
curriculum = OssuCurriculum(curriculum_content.content['Curriculum'])
326+
327+
# --------------- Board creation --------------- #
328+
329+
trello_api_key = os.environ.get('TRELLO_API_KEY')
330+
trello_api_token = os.environ.get('TRELLO_API_TOKEN')
331+
332+
if not (trello_api_key and trello_api_token):
333+
print("Trello API key not found")
334+
return
335+
336+
trello_client = TrelloClient(trello_api_key, trello_api_token, _request)
337+
338+
if board_id is None:
339+
board_id = trello_client.create_board()
340+
341+
print("Creating lists... ", end='')
342+
lists = {
343+
'curriculum': trello_client.create_list(board_id, 'Curriculum'),
344+
'doing': trello_client.create_list(board_id, 'Doing'),
345+
'done': trello_client.create_list(board_id, 'Done')
346+
}
347+
print(lists)
348+
349+
print("Creating labels... ", end='')
350+
labels = {
351+
'curriculum': trello_client.create_label(board_id, "Main Curriculum", 'black'),
352+
'extra': trello_client.create_label(board_id, 'Extra Resources', 'orange'),
353+
'subsection': trello_client.create_label(board_id, 'Subsection', 'purple')
354+
}
355+
print(labels)
356+
357+
section_colors = ('sky', 'purple', 'yellow')
358+
359+
course_number = 1
360+
section:OssuCurriculum.Section
361+
for (idx,section) in enumerate(curriculum.sections):
362+
label = trello_client.create_label(board_id, section.name, section_colors[idx])
363+
364+
subsection:OssuCurriculum.Subsection
365+
for subsection in section.subsections:
366+
subsection_type = OssuCurriculum.SubsectionType.from_subsection_name(subsection.name)
367+
courses = subsection.courses or [c for d in subsection.disciplines for c in d.courses]
368+
369+
print(f"Creating {section.name} / {subsection.name}")
370+
371+
course:OssuCurriculum.Course
372+
for course in courses:
373+
name = f"{course_number:03d} - {course.name}"
374+
375+
_alt = f" ([alt]({course.url_alt})" if course.url_alt else ''
376+
377+
desc = f"**Subsection**: {section.name} - {subsection_type.label}\n"
378+
desc += f"**Course**: [{course.name}]({course.url}){_alt}\n"
379+
desc += f"**Final Project**: _link to your GitHub repository_"
380+
381+
print(f"Creating {section.name} / {subsection.name} / {course.name}")
382+
383+
trello_client.create_card(lists['curriculum'], [label, labels['curriculum']], name, desc)
384+
course_number += 1
385+
386+
for subsection_type in list(OssuCurriculum.SubsectionType)[1:]:
387+
print(f"Creating divider '{subsection_type.label}'")
388+
trello_client.create_card(lists['done'], [labels['subsection'], ], subsection_type.label, desc=str())
389+
390+
print("🎉")
391+
392+
393+
if __name__ == '__main__':
394+
try:
395+
main(**_parse_args(sys.argv[1:]))
396+
except KeyboardInterrupt:
397+
pass

0 commit comments

Comments
 (0)