Skip to content

Commit 2872d0e

Browse files
committed
frontend: Integrate Git-based FSP with course and task administration
1 parent f5bf65c commit 2872d0e

10 files changed

Lines changed: 141 additions & 48 deletions

File tree

inginious/common/filesystems/__init__.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,28 @@ def _checkpath(self, path):
5252
if path.startswith("/") or ".." in path or path.strip() != path:
5353
raise FileNotFoundError()
5454

55+
@abstractmethod
56+
def try_stage(self, filepath: str) -> None:
57+
"""
58+
For versioned filesystems, try staging `filepath` if it points to
59+
modified content. Otherwise, do nothing.
60+
61+
:param filepath: The path towards items to stage if modified.
62+
"""
63+
64+
@abstractmethod
65+
def try_commit(self, filepath: str, msg: str=None, user: tuple[str, str]=None):
66+
"""
67+
For versioned filesystems, add `filepath` content to the history with
68+
`msg` message from `user` author. Otherwise, do nothing.
69+
If `user` is not provided, `filepath` content is only staged, if needed,
70+
and not committed.
71+
72+
:param filepath: Path towards item(s) to commit.
73+
:param msg: An optional commit message.
74+
:param user: Optional authorship information for the commit.
75+
"""
76+
5577
@abstractmethod
5678
def from_subfolder(self, subfolder: str) -> FileSystemProvider:
5779
"""

inginious/common/filesystems/git.py

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# coding=utf-8
22
from __future__ import annotations
3+
import os
34

4-
from git import Repo, exc, Actor
5+
from git import Repo, exc, Actor, Remote, Submodule
56

67
from inginious.common.filesystems.local import LocalFSProvider
78

@@ -21,34 +22,65 @@ def from_subfolder(self, subfolder) -> GitFSProvider:
2122
self._checkpath(subfolder)
2223
return GitFSProvider(self.prefix + subfolder)
2324

25+
def _is_prefix_allowed(self, prefix: str) -> bool:
26+
# See https://github.com/gitpython-developers/GitPython/issues/832
27+
banned = ['$', '%']
28+
allowed = True
29+
[allowed := allowed and c not in prefix for c in banned]
30+
return allowed
31+
2432
def __init__(self, prefix: str):
2533
super().__init__(prefix)
26-
# TODO: filter directories beginning with '$'.
2734
try:
28-
self.repo = Repo(prefix)
35+
self.repo = Repo(prefix) if self._is_prefix_allowed(prefix) else None
2936
except exc.NoSuchPathError:
3037
self.repo = None
3138
except exc.InvalidGitRepositoryError:
3239
# tasks/ directory won't be initialized as a git repository.
3340
self.repo = None
3441

3542
def ensure_exists(self) -> None:
36-
self.repo = Repo.init(self.prefix, b="main")
37-
with self.repo.config_writer() as cfg:
38-
# TODO: Set user SSH key ?
39-
pass
43+
if self._is_prefix_allowed(self.prefix):
44+
self.repo = Repo.init(self.prefix, b="main")
45+
if len([remote for remote in self.repo.remotes if remote.name == 'origin']) == 0:
46+
Remote.add(self.repo, 'origin', f'ingitolite:{self.prefix}')
47+
48+
def _should_stage(self, filepath: str) -> bool:
49+
# We should stage if the filepath is tracked but has a diff, or if the
50+
# filepath is untracked.
51+
return False if self.repo is None else len(self.repo.index.diff(None, paths=[filepath])) == 1 or filepath in self.repo.untracked_files
52+
53+
def _is_task(self, filepath: str) -> bool:
54+
descriptors = [f'task.{ext}' for ext in ['yaml', 'json']]
55+
is_task = False
56+
if os.path.isdir(filepath):
57+
[is_task := is_task or descr in os.listdir(filepath) for descr in descriptors]
58+
return is_task
59+
60+
def _is_submodule(self, filepath: str) -> bool:
61+
return len([sm for sm in self.repo.submodules if sm.name == filepath]) == 1
62+
63+
def _try_stage(self, filepath: str, should_stage: bool) -> None:
64+
if should_stage:
65+
if self._is_task(filepath) and not self._is_submodule(filepath):
66+
# Add newly created tasks as course submodule.
67+
Submodule.add(self.repo, name=filepath, path=filepath)
68+
else:
69+
self.repo.git.add([filepath])
70+
71+
def try_stage(self, filepath: str) -> None:
72+
self._try_stage(filepath, self._should_stage(filepath))
4073

41-
def _try_commit(self, filepath: str, msg: str=None, user: tuple[str, str]=None):
74+
def try_commit(self, filepath: str, msg: str=None, user: tuple[str, str]=None) -> None:
4275
# Should we stage the descriptor?
43-
to_stage = len(self.repo.index.diff(None, paths=[filepath])) == 1 or filepath in self.repo.untracked_files
76+
should_stage = self._should_stage(filepath)
4477
# Is the descriptor already staged? We should have a valid HEAD to test
4578
# that case.
46-
# FIXME: This may commit other staged files. Is that an issue?
47-
descr_changed = to_stage or (self.repo.head.is_valid() and len(self.repo.index.diff("HEAD", paths=[filepath])) == 1)
79+
# This may commit other staged files, e.g., when adding a newly created
80+
# task.
81+
descr_changed = should_stage or (self.repo.head.is_valid() and len(self.repo.index.diff("HEAD", paths=[filepath])) == 1)
4882
if descr_changed:
49-
if to_stage:
50-
self.repo.index.add([filepath])
51-
self.repo.index.write()
83+
self._try_stage(filepath, should_stage)
5284
if user is not None:
5385
actor=Actor(name=user[0], email=user[1])
5486
# This function is only called for writing course / task
@@ -65,20 +97,21 @@ def put(self, filepath: str, content, msg: str=None, user: tuple[str, str]=None)
6597
# This function is called each time the button 'save changes' is pressed
6698
# on the web GUI, even if the content is unchanged...
6799
# We check that content has indeed changed before trying to commit.
68-
self._try_commit(filepath, msg, user)
100+
self.try_commit(filepath, msg, user)
69101

70102
def delete(self, filepath: str=None) -> None:
71103
super().delete(filepath)
72-
if filepath is not None:
104+
if self.repo is not None and filepath is not None:
73105
self.repo.index.add([filepath])
74106

75107
def move(self, src, dest):
76108
super().move(src, dest)
77-
self.repo.index.add([src, dst])
109+
if self.repo is not None:
110+
self.repo.index.add([src, dst])
78111

79112
def copy_to(self, src_disk, dest=None):
80113
super().copy_to(src_disk, dest)
81-
if dest is None:
114+
if dest is None and self.repo is not None:
82115
self.repo.index.add([self.prefix])
83116

84117
def distribute(self, filepath, allow_folders=True):

inginious/common/filesystems/local.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ def from_subfolder(self, subfolder: str) -> LocalFSProvider:
3737
self._checkpath(subfolder)
3838
return LocalFSProvider(self.prefix + subfolder)
3939

40+
def try_stage(self, filepath: str):
41+
pass
42+
43+
def try_commit(self, filepath: str, msg: str=None, user: tuple[str, str]=None):
44+
pass
45+
4046
def exists(self, path: str=None) -> bool:
4147
if path is None:
4248
path = self.prefix

inginious/frontend/course_factory.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
""" Factory for loading courses from disk """
77
import logging
8+
import os
89

910
from inginious.common.filesystems import FileSystemProvider
1011
from inginious.common.log import get_course_logger
@@ -75,20 +76,22 @@ def get_course_descriptor_content(self, courseid):
7576
:raise: InvalidNameException, CourseNotFoundException, CourseUnreadableException
7677
:return: the content of the dict that describes the course
7778
"""
78-
path = self._get_course_descriptor_path(courseid)
79+
(_course_fs, path) = self._get_course_descriptor_path(courseid)
7980
return loads_json_or_yaml(path, self._filesystem.get(path).decode("utf-8"))
8081

81-
def update_course_descriptor_content(self, courseid, content):
82+
def update_course_descriptor_content(self, courseid, content, msg: str=None, user: tuple[str,str]=None):
8283
"""
8384
Updates the content of the dict that describes the course
8485
:param courseid: the course id of the course
8586
:param content: the new dict that replaces the old content
8687
:raise InvalidNameException, CourseNotFoundException
8788
"""
88-
path = self._get_course_descriptor_path(courseid)
89-
self._filesystem.put(path, get_json_or_yaml(path, content))
89+
(course_fs, path) = self._get_course_descriptor_path(courseid)
90+
content = get_json_or_yaml(path, content)
91+
(_head, course_descriptor) = os.path.split(path)
92+
course_fs.put(course_descriptor, content, msg=msg, user=user)
9093

91-
def update_course_descriptor_element(self, courseid, key, value):
94+
def update_course_descriptor_element(self, courseid, key, value, msg: str=None, user: tuple[str, str]=None):
9295
"""
9396
Updates the value for the key in the dict that describes the course
9497
:param courseid: the course id of the course
@@ -98,7 +101,7 @@ def update_course_descriptor_element(self, courseid, key, value):
98101
"""
99102
course_structure = self.get_course_descriptor_content(courseid)
100103
course_structure[key] = value
101-
self.update_course_descriptor_content(courseid, course_structure)
104+
self.update_course_descriptor_content(courseid, course_structure, msg=msg, user=user)
102105

103106
def get_fs(self):
104107
"""
@@ -138,12 +141,12 @@ def _get_course_descriptor_path(self, courseid):
138141
raise InvalidNameException("Course with invalid name: " + courseid)
139142
course_fs = self.get_course_fs(courseid)
140143
if course_fs.exists("course.yaml"):
141-
return courseid+"/course.yaml"
144+
return (course_fs, courseid+"/course.yaml")
142145
if course_fs.exists("course.json"):
143-
return courseid+"/course.json"
146+
return (course_fs, courseid+"/course.json")
144147
raise CourseNotFoundException()
145148

146-
def create_course(self, courseid, init_content):
149+
def create_course(self, courseid, init_content, user: tuple[str, str]):
147150
"""
148151
Create a new course folder and set initial descriptor content, folder can already exist
149152
:param courseid: the course id of the course
@@ -159,7 +162,7 @@ def create_course(self, courseid, init_content):
159162
if course_fs.exists("course.yaml") or course_fs.exists("course.json"):
160163
raise CourseAlreadyExistsException("Course with id " + courseid + " already exists.")
161164
else:
162-
course_fs.put("course.yaml", get_json_or_yaml("course.yaml", init_content))
165+
course_fs.put("course.yaml", get_json_or_yaml("course.yaml", init_content), msg="Course created.", user=user)
163166

164167
get_course_logger(courseid).info("Course %s created in the factory.", courseid)
165168

@@ -191,7 +194,7 @@ def _cache_update_needed(self, courseid):
191194
return True
192195

193196
try:
194-
descriptor_name = self._get_course_descriptor_path(courseid)
197+
(_course_fs, descriptor_name) = self._get_course_descriptor_path(courseid)
195198
last_update = {descriptor_name: self._filesystem.get_last_modification_time(descriptor_name)}
196199
translations_fs = self._filesystem.from_subfolder("$i18n")
197200
if translations_fs.exists():
@@ -216,7 +219,7 @@ def _update_cache(self, courseid):
216219
:raise InvalidNameException, CourseNotFoundException, CourseUnreadableException
217220
"""
218221
self._logger.info("Caching course {}".format(courseid))
219-
path_to_descriptor = self._get_course_descriptor_path(courseid)
222+
(_course_fs, path_to_descriptor) = self._get_course_descriptor_path(courseid)
220223
try:
221224
course_descriptor = loads_json_or_yaml(path_to_descriptor, self._filesystem.get(path_to_descriptor).decode("utf8"))
222225
except Exception as e:

inginious/frontend/pages/course_admin/settings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ def POST_AUTH(self, courseid): # pylint: disable=arguments-differ
137137

138138

139139
if len(errors) == 0:
140-
self.course_factory.update_course_descriptor_content(courseid, course_content)
140+
self.course_factory.update_course_descriptor_content(courseid, course_content, user=self.user_manager.session_git())
141141
errors = None
142142
course, __ = self.get_course_and_check_rights(courseid, allow_all_staff=False) # don't forget to reload the modified course
143143

inginious/frontend/pages/course_admin/task_edit.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,6 @@ def POST_AUTH(self, courseid, taskid): # pylint: disable=arguments-differ
192192
task_fs.copy_to(tmpdirname)
193193

194194
self.task_factory.delete_all_possible_task_files(courseid, taskid)
195-
self.task_factory.update_task_descriptor_content(courseid, taskid, data, force_extension=file_ext)
195+
self.task_factory.update_task_descriptor_content(courseid, taskid, data, force_extension=file_ext, user=self.user_manager.session_git())
196196

197197
return json.dumps({"status": "ok"})

inginious/frontend/pages/course_admin/task_list.py

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,40 +43,54 @@ def POST_AUTH(self, courseid): # pylint: disable=arguments-differ
4343
except Exception as e:
4444
errors.append(_("Something wrong happened: ") + str(e))
4545
else:
46-
try:
47-
self.update_dispenser(course, json.loads(user_input["course_structure"]))
48-
except Exception as e:
49-
errors.append(_("Something wrong happened: ") + str(e))
46+
47+
user = self.user_manager.session_git()
48+
msg = "Task(s) update.\n\nTask(s) created:\n"
5049

5150
for taskid in json.loads(user_input.get("new_tasks", "[]")):
5251
try:
5352
self.task_factory.create_task(course, taskid, {
54-
"name": taskid, "problems": {}, "environment_type": "mcq"})
53+
"name": taskid, "problems": {}, "environment_type": "mcq"}, user)
54+
msg += f"- {taskid}\n"
5555
except Exception as ex:
5656
errors.append(_("Couldn't create task {} : ").format(taskid) + str(ex))
57+
msg += "Task(s) deleted:\n"
5758
for taskid in json.loads(user_input.get("deleted_tasks", "[]")):
5859
try:
5960
self.task_factory.delete_task(courseid, taskid)
61+
msg += f"- {taskid}\n"
6062
except Exception as ex:
6163
errors.append(_("Couldn't delete task {} : ").format(taskid) + str(ex))
64+
msg += "Task(s) wiped:\n"
6265
for taskid in json.loads(user_input.get("wiped_tasks", "[]")):
6366
try:
6467
self.wipe_task(courseid, taskid)
68+
msg += f"- {taskid}\n"
6569
except Exception as ex:
6670
errors.append(_("Couldn't wipe task {} : ").format(taskid) + str(ex))
6771

72+
# Update task dispenser after tasks modifications.
73+
# This enables committing the task repository update.
74+
try:
75+
self.update_dispenser(
76+
course, json.loads(user_input["course_structure"]), cmsg=msg
77+
)
78+
except Exception as e:
79+
errors.append(_("Something wrong happened: ") + str(e))
80+
6881
# don't forget to reload the modified course
6982
course, __ = self.get_course_and_check_rights(courseid, allow_all_staff=False)
7083
return self.page(course, errors, not errors)
7184

72-
def update_dispenser(self, course, dispenser_data):
85+
def update_dispenser(self, course, dispenser_data, cmsg: str=None):
7386
""" Update the task dispenser based on dispenser_data """
7487
task_dispenser = course.get_task_dispenser()
7588
data, msg = task_dispenser.check_dispenser_data(dispenser_data)
7689
if data:
77-
self.course_factory.update_course_descriptor_element(course.get_id(), 'task_dispenser',
78-
task_dispenser.get_id())
79-
self.course_factory.update_course_descriptor_element(course.get_id(), 'dispenser_data', data)
90+
self.course_factory.update_course_descriptor_element(
91+
course.get_id(), 'task_dispenser', task_dispenser.get_id()
92+
)
93+
self.course_factory.update_course_descriptor_element(course.get_id(), 'dispenser_data', data, msg=cmsg, user=self.user_manager.session_git())
8094
else:
8195
raise Exception(_("Invalid course structure: ") + msg)
8296

@@ -87,7 +101,7 @@ def clean_task_files(self, course):
87101
descriptor = self.task_factory.get_task_descriptor_content(course.get_id(), taskid)
88102
for field in legacy_fields:
89103
descriptor.pop(field, None)
90-
self.task_factory.update_task_descriptor_content(course.get_id(), taskid, descriptor)
104+
self.task_factory.update_task_descriptor_content(course.get_id(), taskid, descriptor, user=self.user_manager.session_git())
91105

92106
def submission_url_generator(self, taskid):
93107
""" Generates a submission url """

inginious/frontend/pages/mycourses.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ def POST_AUTH(self): # pylint: disable=arguments-differ
2626
if "new_courseid" in user_input and self.user_manager.user_is_superadmin():
2727
try:
2828
courseid = user_input["new_courseid"]
29-
self.course_factory.create_course(courseid, {"name": courseid, "accessible": False})
29+
user = (self.user_manager.session_realname(), self.user_manager.session_email())
30+
self.course_factory.create_course(courseid, {"name": courseid, "accessible": False}, user=user)
3031
success = True
3132
except:
3233
success = False

0 commit comments

Comments
 (0)