Skip to content

Commit 0e38fb9

Browse files
authored
#158 - BE - Submit with git (#323)
1 parent 7bac49c commit 0e38fb9

File tree

10 files changed

+444
-12
lines changed

10 files changed

+444
-12
lines changed

Diff for: devops/lms.yml

+3
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ services:
9797
volumes:
9898
- ../lms:/app_dir/lms
9999
- ../../notebooks-tests:/app_dir/notebooks-tests
100+
- repositories-data-volume:/repositories
100101
environment:
101102
- DB_NAME=${DB_NAME:-db}
102103
- DB_USER=${DB_USERNAME:-postgres}
@@ -109,6 +110,7 @@ services:
109110
- CELERY_CHECKS_PUBLIC_VHOST=lmstests-public
110111
- CELERY_RABBITMQ_HOST=rabbitmq
111112
- CELERY_RABBITMQ_PORT=5672
113+
- REPOSITORY_FOLDER=/repositories
112114
links:
113115
- db
114116
- rabbitmq
@@ -126,6 +128,7 @@ volumes:
126128
docker-engine-lib:
127129
db-data-volume:
128130
rabbit-data-volume:
131+
repositories-data-volume:
129132

130133

131134
networks:

Diff for: lms/lmsweb/__init__.py

+19
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import pathlib
22
import shutil
3+
import typing
34

45
from flask import Flask
56
from flask_babel import Babel # type: ignore
7+
from flask_httpauth import HTTPBasicAuth
68
from flask_limiter import Limiter # type: ignore
79
from flask_limiter.util import get_remote_address # type: ignore
810
from flask_mail import Mail # type: ignore
@@ -28,6 +30,8 @@
2830
static_folder=str(static_dir),
2931
)
3032

33+
http_basic_auth = HTTPBasicAuth()
34+
3135
limiter = Limiter(webapp, key_func=get_remote_address)
3236

3337

@@ -52,3 +56,18 @@
5256

5357
# gunicorn search for application
5458
application = webapp
59+
60+
61+
@http_basic_auth.get_password
62+
def get_password(username: str) -> typing.Optional[str]:
63+
user = models.User.get_or_none(models.User.username == username)
64+
return user.password if user else None
65+
66+
67+
@http_basic_auth.verify_password
68+
def verify_password(username: str, client_password: str):
69+
username_username = models.User.username == username
70+
login_user = models.User.get_or_none(username_username)
71+
if login_user is None or not login_user.is_password_valid(client_password):
72+
return False
73+
return login_user

Diff for: lms/lmsweb/config.py.example

+2
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,5 @@ LIMITS_PER_HOUR = 50
6767

6868
# Change password settings
6969
MAX_INVALID_PASSWORD_TRIES = 5
70+
71+
REPOSITORY_FOLDER = os.getenv("REPOSITORY_FOLDER", os.path.abspath(os.path.join(os.curdir, "repositories")))

Diff for: lms/lmsweb/git_service.py

+229
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import os
2+
import shutil
3+
import subprocess # noqa: S404
4+
import tempfile
5+
import typing
6+
import pathlib
7+
8+
import flask
9+
10+
from lms.lmsdb import models
11+
from lms.models import upload
12+
from lms.utils import hashing
13+
from lms.utils.log import log
14+
15+
16+
class _GitOperation(typing.NamedTuple):
17+
response_content_type: str
18+
service_command: typing.List[str]
19+
supported: bool
20+
format_response: typing.Optional[typing.Callable]
21+
contain_new_commits: bool
22+
23+
24+
class GitService:
25+
_GIT_PROCESS_TIMEOUT = 20
26+
_GIT_VALID_EXIT_CODE = 0
27+
_STATELESS_RPC = '--stateless-rpc'
28+
_ADVERTISE_REFS = '--advertise-refs'
29+
_UPLOAD_COMMAND = 'git-upload-pack'
30+
_RECEIVE_COMMAND = 'git-receive-pack'
31+
_REFS_COMMAND = '/info/refs'
32+
33+
def __init__(
34+
self,
35+
user: models.User,
36+
exercise_number: int,
37+
course_id: int,
38+
request: flask.Request,
39+
base_repository_folder: str,
40+
):
41+
self._base_repository_folder = base_repository_folder
42+
self._user = user
43+
self._exercise_number = exercise_number
44+
self._course_id = course_id
45+
self._request = request
46+
47+
@property
48+
def project_name(self) -> str:
49+
return f'{self._course_id}-{self._exercise_number}-{self._user.id}'
50+
51+
@property
52+
def repository_folder(self) -> pathlib.Path:
53+
return pathlib.Path(self._base_repository_folder) / self.project_name
54+
55+
def handle_operation(self) -> flask.Response:
56+
git_operation = self._extract_git_operation()
57+
repository_folder = self.repository_folder / 'config'
58+
59+
new_repository = not repository_folder.exists()
60+
if new_repository:
61+
self._initialize_bare_repository()
62+
63+
if not git_operation.supported:
64+
raise OSError
65+
66+
data_out = self._execute_git_operation(git_operation)
67+
68+
if git_operation.format_response:
69+
data_out = git_operation.format_response(data_out)
70+
71+
if git_operation.contain_new_commits:
72+
files = self._load_files_from_repository()
73+
solution_hash = hashing.by_content(str(files))
74+
upload.upload_solution(
75+
course_id=self._course_id,
76+
exercise_number=self._exercise_number,
77+
files=files,
78+
solution_hash=solution_hash,
79+
user_id=self._user.id,
80+
)
81+
82+
return self.build_response(data_out, git_operation)
83+
84+
def _execute_command(
85+
self,
86+
args: typing.List[str],
87+
cwd: typing.Union[str, pathlib.Path],
88+
proc_input: typing.Optional[bytes] = None,
89+
):
90+
proc = subprocess.Popen( # noqa: S603
91+
args=args,
92+
stdin=subprocess.PIPE,
93+
stdout=subprocess.PIPE,
94+
stderr=subprocess.PIPE,
95+
cwd=cwd,
96+
)
97+
data_out, _ = proc.communicate(proc_input, self._GIT_PROCESS_TIMEOUT)
98+
operation_failed = proc.wait() != self._GIT_VALID_EXIT_CODE
99+
if operation_failed:
100+
log.error(
101+
'Failed to execute command %s. stdout=%s\nstderr=%s',
102+
args, proc.stdout.read(), proc.stderr.read(),
103+
)
104+
raise OSError
105+
return data_out
106+
107+
def _execute_git_operation(self, git_operation: _GitOperation) -> bytes:
108+
return self._execute_command(
109+
args=git_operation.service_command,
110+
cwd=self._base_repository_folder,
111+
proc_input=self._request.data,
112+
)
113+
114+
def _initialize_bare_repository(self) -> None:
115+
os.makedirs(self.repository_folder, exist_ok=True)
116+
self._execute_command(
117+
args=['git', 'init', '--bare'],
118+
cwd=self.repository_folder,
119+
)
120+
121+
@staticmethod
122+
def build_response(
123+
data_out: bytes,
124+
git_operation: _GitOperation,
125+
) -> flask.Response:
126+
res = flask.make_response(data_out)
127+
res.headers['Pragma'] = 'no-cache'
128+
res.headers['Cache-Control'] = 'no-cache, max-age=0, must-revalidate'
129+
res.headers['Content-Type'] = git_operation.response_content_type
130+
return res
131+
132+
def _extract_git_operation(self) -> _GitOperation:
133+
if self._request.path.endswith(self._UPLOAD_COMMAND):
134+
return self._build_upload_operation()
135+
136+
elif self._request.path.endswith(self._RECEIVE_COMMAND):
137+
return self._build_receive_operation()
138+
139+
elif self._request.path.endswith(self._REFS_COMMAND):
140+
return self._build_refs_operation()
141+
142+
else:
143+
log.error(
144+
'Failed to find the git command for route %s',
145+
self._request.path,
146+
)
147+
raise NotImplementedError
148+
149+
def _build_refs_operation(self) -> _GitOperation:
150+
allowed_commands = [self._UPLOAD_COMMAND, self._RECEIVE_COMMAND]
151+
service_name = self._request.args.get('service')
152+
content_type = f'application/x-{service_name}-advertisement'
153+
supported = service_name in allowed_commands
154+
155+
def format_response_callback(response_bytes: bytes) -> bytes:
156+
packet = f'# service={service_name}\n'
157+
length = len(packet) + 4
158+
prefix = '{:04x}'.format(length & 0xFFFF)
159+
160+
data = (prefix + packet + '0000').encode()
161+
data += response_bytes
162+
return data
163+
164+
return _GitOperation(
165+
response_content_type=content_type,
166+
service_command=[
167+
service_name,
168+
self._STATELESS_RPC,
169+
self._ADVERTISE_REFS,
170+
self.project_name,
171+
],
172+
supported=supported,
173+
format_response=format_response_callback,
174+
contain_new_commits=False,
175+
)
176+
177+
def _build_receive_operation(self) -> _GitOperation:
178+
return _GitOperation(
179+
response_content_type='application/x-git-receive-pack-result',
180+
service_command=[
181+
self._RECEIVE_COMMAND,
182+
self._STATELESS_RPC,
183+
self.project_name,
184+
],
185+
supported=True,
186+
format_response=None,
187+
contain_new_commits=True,
188+
)
189+
190+
def _build_upload_operation(self) -> _GitOperation:
191+
return _GitOperation(
192+
response_content_type='application/x-git-upload-pack-result',
193+
service_command=[
194+
self._UPLOAD_COMMAND,
195+
self._STATELESS_RPC,
196+
self.project_name,
197+
],
198+
supported=True,
199+
format_response=None,
200+
contain_new_commits=False,
201+
)
202+
203+
def _load_files_from_repository(self) -> typing.List[upload.File]:
204+
"""
205+
Since the remote server is a git bare repository
206+
we need to 'clone' the bare repository to resolve the files.
207+
We are not changing the remote at any end - that is why we
208+
don't care about git files here.
209+
"""
210+
with tempfile.TemporaryDirectory() as tempdir:
211+
self._execute_command(
212+
args=['git', 'clone', self.repository_folder, '.'],
213+
cwd=tempdir,
214+
)
215+
to_return = []
216+
# remove git internal files
217+
shutil.rmtree(pathlib.Path(tempdir) / '.git')
218+
for root, _, files in os.walk(tempdir):
219+
for file in files:
220+
upload_file = self._load_file(file, root, tempdir)
221+
to_return.append(upload_file)
222+
return to_return
223+
224+
@staticmethod
225+
def _load_file(file_name: str, root: str, tempdir: str) -> upload.File:
226+
file_path = str(pathlib.Path(root).relative_to(tempdir) / file_name)
227+
with open(pathlib.Path(root) / file_name) as f:
228+
upload_file = upload.File(path=file_path, code=f.read())
229+
return upload_file

Diff for: lms/lmsweb/routes.py

+1
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
STATUS = '/status'
33
DOWNLOADS = '/download'
44
SHARED = '/shared'
5+
GIT = '/git/<int:course_id>/<int:exercise_number>.git'

Diff for: lms/lmsweb/views.py

+19-3
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import arrow # type: ignore
44
from flask import (
5-
jsonify, make_response, render_template, request,
5+
Response, jsonify, make_response, render_template, request,
66
send_from_directory, session, url_for,
77
)
88
from flask_babel import gettext as _ # type: ignore
@@ -18,17 +18,18 @@
1818
ALL_MODELS, Comment, Course, Note, Role, RoleOptions, SharedSolution,
1919
Solution, SolutionFile, User, UserCourse, database,
2020
)
21-
from lms.lmsweb import babel, limiter, routes, webapp
21+
from lms.lmsweb import babel, http_basic_auth, limiter, routes, webapp
2222
from lms.lmsweb.admin import (
2323
AdminModelView, SPECIAL_MAPPING, admin, managers_only,
2424
)
2525
from lms.lmsweb.config import (
2626
CONFIRMATION_TIME, LANGUAGES, LIMITS_PER_HOUR,
27-
LIMITS_PER_MINUTE, LOCALE, MAX_UPLOAD_SIZE,
27+
LIMITS_PER_MINUTE, LOCALE, MAX_UPLOAD_SIZE, REPOSITORY_FOLDER,
2828
)
2929
from lms.lmsweb.forms.change_password import ChangePasswordForm
3030
from lms.lmsweb.forms.register import RegisterForm
3131
from lms.lmsweb.forms.reset_password import RecoverPassForm, ResetPassForm
32+
from lms.lmsweb.git_service import GitService
3233
from lms.lmsweb.manifest import MANIFEST
3334
from lms.lmsweb.redirections import (
3435
PERMISSIVE_CORS, get_next_url, login_manager,
@@ -570,6 +571,21 @@ def download(download_id: str):
570571
return response
571572

572573

574+
@webapp.route(f'{routes.GIT}/info/refs')
575+
@webapp.route(f'{routes.GIT}/git-receive-pack', methods=['POST'])
576+
@webapp.route(f'{routes.GIT}/git-upload-pack', methods=['POST'])
577+
@http_basic_auth.login_required
578+
def git_handler(course_id: int, exercise_number: int) -> Response:
579+
git_service = GitService(
580+
user=http_basic_auth.current_user(),
581+
exercise_number=exercise_number,
582+
course_id=course_id,
583+
request=request,
584+
base_repository_folder=REPOSITORY_FOLDER,
585+
)
586+
return git_service.handle_operation()
587+
588+
573589
@webapp.route(f'{routes.SOLUTIONS}/<int:solution_id>')
574590
@webapp.route(f'{routes.SOLUTIONS}/<int:solution_id>/<int:file_id>')
575591
@login_required

Diff for: lms/models/upload.py

+23-3
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,13 @@ def new(
7272
errors: List[Union[UploadError, AlreadyExists]] = []
7373
for exercise_number, files, solution_hash in Extractor(file):
7474
try:
75-
solution = _upload_to_db(
76-
exercise_number, course_id, user_id, files, solution_hash,
75+
upload_solution(
76+
course_id=course_id,
77+
exercise_number=exercise_number,
78+
files=files,
79+
solution_hash=solution_hash,
80+
user_id=user_id,
7781
)
78-
_run_auto_checks(solution)
7982
except (UploadError, AlreadyExists) as e:
8083
log.debug(e)
8184
errors.append(e)
@@ -87,3 +90,20 @@ def new(
8790
raise UploadError(errors)
8891

8992
return matches, misses
93+
94+
95+
def upload_solution(
96+
course_id: int,
97+
exercise_number: int,
98+
files: List[File],
99+
solution_hash: str,
100+
user_id: int,
101+
):
102+
solution = _upload_to_db(
103+
exercise_number=exercise_number,
104+
course_id=course_id,
105+
user_id=user_id,
106+
files=files,
107+
solution_hash=solution_hash,
108+
)
109+
_run_auto_checks(solution)

0 commit comments

Comments
 (0)