diff --git a/.eslintrc.js b/.eslintrc.js index 60d95f36..8c3481d5 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,19 +1,21 @@ module.exports = { - env: { - browser: true, - es2021: true, + "env": { + "browser": true, + "node": true }, - globals: { - bootstrap: true, - Dropzone: true, - workbox: true, - }, - extends: [ - 'airbnb-base', + "ignorePatterns": [ + "/lms/static/prism.js", + "/lms/static/markdown.js", ], - parserOptions: { - ecmaVersion: 12, - sourceType: 'module', + "globals": { + "bootstrap": true, + "Dropzone": true, + "workbox": true, + }, + "extends": "eslint:recommended", + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" }, rules: { 'no-param-reassign': [2, { props: false }], diff --git a/.github/workflows/cr-checks.yml b/.github/workflows/cr-checks.yml index e0a8117c..df97a8f2 100644 --- a/.github/workflows/cr-checks.yml +++ b/.github/workflows/cr-checks.yml @@ -9,14 +9,14 @@ on: jobs: build: - runs-on: ubuntu-18.04 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - - name: Set up Python 3.8 + - name: Set up Python 3.12 uses: actions/setup-python@v1 with: - python-version: 3.8 + python-version: 3.12 - name: Install dependencies run: | python -m pip install --upgrade pip @@ -27,7 +27,7 @@ jobs: chmod +x /opt/vnu/vnu-runtime-image/bin/vnu - name: Lint run: | - flake8 lms --count --show-source --statistics + flake8 lms --ignore Q000,I202,W503,S101,I100,I101,E800 --import-order-style=google --count --show-source --statistics - name: Test run: | export PYTHONPATH=`pwd` diff --git a/.gitignore b/.gitignore index c5b6edef..4fb7459b 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,4 @@ lms/lmsweb/config.py db.sqlite vim.session devops/rabbitmq.cookie +our.db diff --git a/Dockerfile b/Dockerfile index 9079dc9e..1850687f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,26 +1,34 @@ -FROM python:3-buster +FROM python:3.12 -RUN apt update \ - && apt install -y --no-install-recommends docker.io vim unixodbc-dev \ - && apt clean \ - && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y \ + ca-certificates \ + curl \ + wget \ + unzip \ + && install -m 0755 -d /etc/apt/keyrings \ + && curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \ + && chmod a+r /etc/apt/keyrings/docker.asc \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null \ + && apt-get update \ + && apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* COPY requirements.txt /tmp/requirements.txt -RUN pip3 install -r /tmp/requirements.txt +RUN pip3 install --no-cache-dir -r /tmp/requirements.txt -# Install vnu (html/css validator) -RUN wget https://github.com/validator/validator/releases/download/20.6.30/vnu.linux.zip && \ - unzip vnu.linux.zip -d /opt/vnu/ && \ - chmod +x /opt/vnu/vnu-runtime-image/bin/vnu -ENV PATH=/opt/vnu/vnu-runtime-image/bin:$PATH +RUN wget https://github.com/validator/validator/releases/download/20.6.30/vnu.linux.zip -O /tmp/vnu.linux.zip \ + && unzip /tmp/vnu.linux.zip -d /opt/vnu/ \ + && chmod +x /opt/vnu/vnu-runtime-image/bin/vnu \ + && rm /tmp/vnu.linux.zip -RUN adduser --disabled-password --gecos '' app-user +ENV PATH=/opt/vnu/vnu-runtime-image/bin:$PATH -RUN mkdir -p /app_dir/lms -RUN chown -R app-user:app-user /app_dir +RUN adduser --disabled-password --gecos '' app-user \ + && mkdir -p /app_dir/lms \ + && chown -R app-user:app-user /app_dir -# Note: we don't copy the code to container because we mount the code in different ways -# on each setup WORKDIR /app_dir/lms -ENV LOGURU_LEVEL INFO -ENV PYTHONPATH /app_dir/:$PYTHONPATH +ENV LOGURU_LEVEL=INFO +ENV PYTHONPATH=/app_dir/:$PYTHONPATH +# Note: Code is mounted at runtime, hence not copied. diff --git a/README.md b/README.md index fdff3b06..b033cbca 100644 --- a/README.md +++ b/README.md @@ -3,26 +3,30 @@
- -
๐ Welcome to Python course learning management system. ๐ -The system objectives - +The system objectives - + 1. Allow teachers and mentors to input exercises list and provide feedback/comments to students exercises solutions. 2. Allow students to load their exercises solutions and get feedback to their work. ## Creating development environment + ### Prerequisites + 1. Linux based system - either [WSL on windows](https://docs.microsoft.com/en-us/windows/wsl/install-win10) or full blown linux. -2. [Python](https://www.python.org/downloads/release/python-385/) +2. [Python](https://www.python.org/downloads/release/python-385/) 3. [Docker](https://docs.docker.com/docker-for-windows/install/) and docker-compose. + ### Minimal setup -This setup is for debug purposes and will use sqlite database and frontend only. + +This setup is for debug purposes and will use SQLite database and frontend only. Steps to do: + 1. Clone this repository. 2. Set environment variables. 3. Run the application. @@ -48,7 +52,9 @@ After logging in, use [localhost admin](https://127.0.0.1:5000/admin) to modify ### Full setup + This setup will create the following items: + * Application - LMS code. * Middleware (messaging queue) - RabbitMQ. * Persistence database - PostgreSQL. @@ -70,7 +76,8 @@ cd devops ``` In case you want to add the stub data to PostgreSQL DB, run: -``` + +```bash docker exec -it lms_http_1 bash python lmsdb/bootstrap.py ``` @@ -86,17 +93,23 @@ In case you want to enable the mail system: ## Code modification check list -### Run flake8 -``` + +## Run flake8 + +```bash # on lms root directory flake8 lms ``` + ### Run tests -``` + +```bash export PYTHONPATH=`pwd` pip install -r requirements.txt pip install -r dev_requirements.txt py.test -vvv ``` + ### Contributing + View [contributing guidelines](https://github.com/PythonFreeCourse/lms/blob/master/CONTRIBUTING.md). diff --git a/dev_requirements.txt b/dev_requirements.txt index 4f73822f..dad4bd27 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -1,4 +1,4 @@ -debugpy==1.4.3 -ipdb==0.13.9 -pytest-cov==2.12.1 -pytest-env==0.6.2 +debugpy==1.8.1 +ipdb==0.13.13 +pytest-cov==4.1.0 +pytest-env==1.1.3 diff --git a/devops/build.sh b/devops/build.sh index 97943fb4..13455d66 100755 --- a/devops/build.sh +++ b/devops/build.sh @@ -6,7 +6,7 @@ MAIN_FOLDER="${SCRIPT_FOLDER}/.." echo "Using sudo to remove the old erlang cookie" ERLANG_COOKIE_FILE="${SCRIPT_FOLDER}/rabbitmq.cookie" -sudo rm -f "$ERLANG_COOKIE_FILE" +sudo rm -rf "$ERLANG_COOKIE_FILE" echo "Running build on folder ${MAIN_FOLDER}" ( cd "${MAIN_FOLDER}" && docker build -t lms . ) diff --git a/devops/lms.yml b/devops/lms.yml index 1026e773..16b73c9d 100644 --- a/devops/lms.yml +++ b/devops/lms.yml @@ -21,7 +21,7 @@ services: - lms rabbitmq: - image: rabbitmq:3.9-management-alpine + image: rabbitmq:3.8-management-alpine hostname: celery-mq volumes: - rabbit-data-volume:/var/lib/rabbitmq @@ -130,8 +130,6 @@ volumes: rabbit-data-volume: repositories-data-volume: - networks: lms: - external: - name: lms + external: true diff --git a/devops/upgrade.sh b/devops/upgrade.sh index 4b21413f..8fbbdbff 100755 --- a/devops/upgrade.sh +++ b/devops/upgrade.sh @@ -21,6 +21,7 @@ upgrade () { docker exec -t lms_db_1 pg_dump -c -U lmsweb lms > /home/$USER/dump_`date +%d-%m-%Y"_"%H_%M_%S`.sql cd devops || { echo "cd failed"; exit 127; } source ./build.sh + source ./start.sh sudo systemctl restart lms sudo systemctl start nginx source ./i18n.sh diff --git a/lms/lmsdb/bootstrap.py b/lms/lmsdb/bootstrap.py index e6dff390..09f08fe6 100644 --- a/lms/lmsdb/bootstrap.py +++ b/lms/lmsdb/bootstrap.py @@ -322,6 +322,22 @@ def _assessment_migration() -> bool: return True +def _linter_email_migration(): + old_mail_address = 'lms-checks@python.guru' + new_mail_address = 'lms-checks@pythonic.guru' + + find_user = models.User.select().where + mail_field = models.User.mail_address + + if find_user(mail_field == old_mail_address).exists(): + user = find_user(mail_field == old_mail_address).get() + user.mail_address = new_mail_address + user.save() + log.info(f'Changed {old_mail_address} to {new_mail_address} in User') + else: + log.info(f'{new_mail_address} already exists in User') + + def is_tables_exists(tables: Union[Model, Iterable[Model]]) -> bool: if not isinstance(tables, (tuple, list)): tables = (tables,) @@ -351,6 +367,7 @@ def main(): _uuid_migration() _add_user_course_constaint() + _linter_email_migration() models.create_basic_roles() if models.User.select().count() == 0: diff --git a/lms/lmsdb/models.py b/lms/lmsdb/models.py index 4a038c67..7dd4a92c 100644 --- a/lms/lmsdb/models.py +++ b/lms/lmsdb/models.py @@ -268,8 +268,8 @@ def has_course(self, course_id: int) -> bool: @classmethod def get_system_user(cls) -> 'User': instance, _ = cls.get_or_create(**{ - cls.mail_address.name: 'linter-checks@python.guru', - User.username.name: 'linter-checks@python.guru', + cls.mail_address.name: 'linter-checks@pythonic.guru', + User.username.name: 'linter-checks@pythonic.guru', }, defaults={ User.fullname.name: 'Checker guru', User.role.name: Role.get_staff_role(), @@ -322,14 +322,15 @@ def __str__(self): @pre_save(sender=User) def on_save_handler(model_class, instance, created): """Hash password on creation/save.""" + supported_hashing = ('pbkdf2:sha256', 'scrypt:') # If password changed then it won't start with hash's method prefix - is_password_changed = not instance.password.startswith('pbkdf2:sha256') + is_password_changed = not instance.password.startswith(supported_hashing) if created or is_password_changed: instance.password = generate_password_hash(instance.password) instance.uuid = uuid4() - is_api_key_changed = not instance.api_key.startswith('pbkdf2:sha256') + is_api_key_changed = not instance.api_key.startswith(supported_hashing) if created or is_api_key_changed: if not instance.api_key: instance.api_key = model_class.random_password() @@ -638,9 +639,7 @@ class Solution(BaseModel): ) @property - def solution_files( - self, - ) -> Union[Iterable['SolutionFile'], 'SolutionFile']: + def solution_files(self) -> Iterable["SolutionFile"]: return SolutionFile.filter(SolutionFile.solution == self) @property @@ -782,8 +781,8 @@ def create_solution( raise AlreadyExists('This solution already exists.') instance = cls.create(**{ - cls.exercise.name: exercise, - cls.solver.name: solver, + cls.exercise.name: exercise.id, + cls.solver.name: solver.id, cls.submission_timestamp.name: datetime.now(), cls.hashed.name: hash_, }) @@ -1172,9 +1171,13 @@ def create_comment( @classmethod def _by_file(cls, file_id: int): fields = ( - cls.id, cls.line_number, cls.is_auto, - CommentText.id.alias('comment_id'), CommentText.text, + cls.id, + cls.line_number, + cls.is_auto, + cls.timestamp, + CommentText.text, SolutionFile.id.alias('file_id'), + User.id.alias('author_id'), User.fullname.alias('author_name'), User.role.alias('author_role'), ) @@ -1210,7 +1213,7 @@ def generate_string( def create_demo_users() -> None: - print('First run! Here are some users to get start with:') # noqa: T001 + print('First run! Here are some users to get start with:') # noqa: T201 fields = ['username', 'fullname', 'mail_address', 'role'] student_role = Role.by_name('Student') admin_role = Role.by_name('Administrator') @@ -1224,7 +1227,7 @@ def create_demo_users() -> None: password = User.random_password() api_key = User.random_password(stronger=True) User.create(**user, password=password, api_key=api_key) - print(f"User: {user['username']}, Password: {password}") # noqa: T001 + print(f"User: {user['username']}, Password: {password}") # noqa: T201 def create_basic_roles() -> None: diff --git a/lms/lmstests/public/unittests/image/Dockerfile b/lms/lmstests/public/unittests/image/Dockerfile index 953dc829..f7d768f5 100644 --- a/lms/lmstests/public/unittests/image/Dockerfile +++ b/lms/lmstests/public/unittests/image/Dockerfile @@ -1,7 +1,8 @@ -FROM python:3.8.0-slim-buster +FROM python:3.12-slim COPY requirements.txt /tmp/requirements.txt -RUN pip3 install -r /tmp/requirements.txt +RUN pip config --user set global.progress_bar off && \ + pip3 install -r /tmp/requirements.txt RUN adduser --disabled-password --gecos '' app-user diff --git a/lms/lmstests/public/unittests/image/requirements.txt b/lms/lmstests/public/unittests/image/requirements.txt index f8f855a4..2bd71b64 100644 --- a/lms/lmstests/public/unittests/image/requirements.txt +++ b/lms/lmstests/public/unittests/image/requirements.txt @@ -1,12 +1,11 @@ -atomicwrites==1.4.0 -attrs==19.3.0 -importlib-metadata==1.6.0 -more-itertools==8.2.0 -packaging==20.3 -pluggy==0.13.1 -py==1.10.0 -pyparsing==2.4.7 -pytest==5.0.1 -six==1.14.0 -wcwidth==0.1.9 -zipp==3.1.0 +atomicwrites==1.4.1 +attrs==23.2.0 +importlib-metadata==7.0.1 +more-itertools==10.2.0 +packaging==23.2 +pluggy==1.4.0 +pyparsing==3.1.1 +pytest==8.0.1 +six==1.16.0 +wcwidth==0.2.13 +zipp==3.17.0 diff --git a/lms/lmstests/public/unittests/import_tests.py b/lms/lmstests/public/unittests/import_tests.py index 7cc4ff13..38a65d35 100644 --- a/lms/lmstests/public/unittests/import_tests.py +++ b/lms/lmstests/public/unittests/import_tests.py @@ -13,28 +13,31 @@ def register_test_class(file_path: str, test_class: typing.ClassVar): subject = test_class.__doc__ - exercise = models.Exercise.get_or_none(models.Exercise.subject == subject) - if not exercise: - log.info(f'Failed to find exercise subject {subject}') + exercises = tuple( + models.Exercise.filter(models.Exercise.subject == subject), + ) + if not exercises: + log.info(f'Failed to find exercises for subject {subject}') raise SystemError with open(file_path, 'r') as file_reader: code = file_reader.read() - exercise_test = models.ExerciseTest.get_or_create_exercise_test( - exercise=exercise, - code=code, - ) + for exercise in exercises: + exercise_test = models.ExerciseTest.get_or_create_exercise_test( + exercise=exercise, + code=code, + ) - for test_func_name in inspect.getmembers(test_class): - test_func_name = test_func_name[0] - if test_func_name.startswith('test_'): - test_func = getattr(test_class, test_func_name) - models.ExerciseTestName.create_exercise_test_name( - exercise_test=exercise_test, - test_name=test_func_name, - pretty_test_name=test_func.__doc__, - ) + for test_func_name in inspect.getmembers(test_class): + test_func_name = test_func_name[0] + if test_func_name.startswith('test_'): + test_func = getattr(test_class, test_func_name) + models.ExerciseTestName.create_exercise_test_name( + exercise_test=exercise_test, + test_name=test_func_name, + pretty_test_name=test_func.__doc__, + ) def load_tests_from_path(file_path: str): @@ -60,5 +63,5 @@ def load_test_from_module(file_path: str): if __name__ == '__main__': if len(sys.argv) != 2: - print('python load_tests.py test-module-path') # noqa: T001 + print('python load_tests.py test-module-path') # noqa: T201 load_tests_from_path(file_path=sys.argv[1]) diff --git a/lms/lmstests/public/unittests/services.py b/lms/lmstests/public/unittests/services.py index e62a6eb3..4dad0724 100644 --- a/lms/lmstests/public/unittests/services.py +++ b/lms/lmstests/public/unittests/services.py @@ -1,6 +1,6 @@ import logging import subprocess # noqa: S404 -from typing import Iterable, List, Optional, Tuple +from typing import Iterable, List, Optional from flask_babel import gettext as _ # type: ignore import junitparser @@ -51,7 +51,6 @@ def _run_tests_on_solution(self): python_file = 'test_checks.py' test_output_path = 'output.xml' - junit_results = None try: with executers.get_executor(self._executor_name) as executor: executor.write_file(python_file, python_code) @@ -78,12 +77,17 @@ def _generate_python_code(self) -> str: test_code = self._exercise_auto_test.code return f'{test_code}\n\n{user_code}' - def _get_parsed_suites( + def _get_test_cases( self, raw_results: bytes, - ) -> Optional[Iterable[junitparser.TestSuite]]: + ) -> Optional[Iterable[junitparser.TestCase]]: try: parsed_string = junitparser.TestSuite.fromstring(raw_results) - return parsed_string.testsuites() + test_suites = tuple(parsed_string.testsuites()) + single_test_suite = not test_suites + if single_test_suite: + yield from tuple(parsed_string) + for test_suite in test_suites: + yield from tuple(test_suite) except SyntaxError: # importing xml make the lint go arrrr self._logger.exception('Failed to parse junit result') return None @@ -98,17 +102,15 @@ def _populate_junit_results(self, raw_results: bytes) -> None: if not raw_results: return None - suites = self._get_parsed_suites(raw_results) - if not suites: + test_cases = self._get_test_cases(raw_results) + if not test_cases: return None tests_ran = False number_of_failures = 0 - for test_suite in suites: - failures, ran = self._handle_test_suite(test_suite) - number_of_failures += failures - if ran and not tests_ran: - tests_ran = ran + for test_case in test_cases: + number_of_failures += int(self._handle_test_case(test_case)) + tests_ran = True if not tests_ran: self._handle_failed_to_execute_tests(raw_results) @@ -181,13 +183,3 @@ def _handle_test_case(self, case: TestCase) -> NumberOfErrors: self._handle_result(case.name, result) number_of_failures += 1 return number_of_failures - - def _handle_test_suite( - self, test_suite: junitparser.TestSuite, - ) -> Tuple[int, bool]: - number_of_failures = 0 - tests_ran = False - for case in test_suite: - tests_ran = True - number_of_failures += int(self._handle_test_case(case)) - return number_of_failures, tests_ran diff --git a/lms/lmstests/sandbox/linters/defines.py b/lms/lmstests/sandbox/linters/defines.py index 06774b5c..8d62c309 100644 --- a/lms/lmstests/sandbox/linters/defines.py +++ b/lms/lmstests/sandbox/linters/defines.py @@ -15,7 +15,7 @@ 'C404': 'ืืื ืกืืื ืืืฉืชืืฉ ืคื ืึพlist comprehension โ ืืฉืชืืฉื ืึพdictionary comprehension ืืืงืื.', 'C406': 'ืืื ืกืืื ืืืฉืชืืฉ ืืคืื ืงืฆืืlist
ืื tuple
. ืืคืฉืจ ืืืงืื ืืืฉืชืืฉ ืคืฉืื ืืกืืืจืืื ืืืชืืืืื ืฉืืืืฆืืื ืืช ืืื ื ืื ืชืื ืื.',
'C407': 'ืื ืืืืืื ืืืฉืชืืฉ ืืื ืึพlist/dict comprehension, ืืคืื ืงืฆืื ืืืืขืช ืืงืื ืื ืจืืืจ.',
- 'C408': 'ืืื ืกืืื ืืงืจืื ืคื ืืคืื ืงืฆืื โ ืขืืืฃ ืืฆืืื ืืื ื ื ืชืื ืื ืจืืง. ืืืงืื dict()
, ืืืืืื, ืจืฉืื \{\}
.', # NOQA: W605
+ 'C408': 'ืืื ืกืืื ืืงืจืื ืคื ืืคืื ืงืฆืื โ ืขืืืฃ ืืฆืืื ืืื ื ื ืชืื ืื ืจืืง. ืืืงืื dict()
, ืืืืืื, ืจืฉืื {}
.', # NOQA: W605
'C409': 'ืืขืืจืช ืึพtuple()
ืจืฉืืื, ืื ืืื ืืื ืฆืืจื. ืขืืืฃ ืืืฉืชืืฉ ืืกืืืจืืื ืขืืืืื ืืืงืื.',
'C410': 'ืืื ืฆืืจื ืืืืืจ ืืช ืืจืฉืืื ืืื ืึพlist. ืืืฆืืื ืืืชื ืืืงืจืืื ืืคืื ืงืฆืื.',
'C413': 'ืืื ืฆืืจื ืืืืืจ ืืจืฉืืื, sorted ืืืจ ืืืืืจื ืจืฉืืื ืืขืฆืื.',
@@ -135,6 +135,7 @@
'S322', # input is a dangerous method of Python 2 yada yada
'T000', # todo note found
'T001', # print found
+ 'T201', # print found (newer version of T001)
'T002', # Python 2.x reserved word print used
'W291', # whitespaces @ end of line
'W292', # no new line in the end of the code
diff --git a/lms/lmstests/sandbox/linters/python.py b/lms/lmstests/sandbox/linters/python.py
index 3d272cc3..b8103091 100644
--- a/lms/lmstests/sandbox/linters/python.py
+++ b/lms/lmstests/sandbox/linters/python.py
@@ -1,5 +1,6 @@
+from collections.abc import Iterable
import tempfile
-import typing
+from typing import cast
from flake8.main import application
@@ -10,7 +11,7 @@
class PythonLinter(BaseLinter):
def initialize(self):
self._app = application.Application()
- self._app.initialize(argv=['--import-order-style', 'google'])
+ self._argv = ['--import-order-style', 'google']
@property
def app(self) -> application.Application:
@@ -24,19 +25,22 @@ def get_error_text(self, error: LinterError) -> str:
def match_to_file_suffix(file_suffix: str) -> bool:
return file_suffix.lower() == 'py'
- def _get_errors_from_solution(self) -> typing.Iterable[LinterError]:
- index_of_check = 0
+ def _get_errors_from_solution(self) -> Iterable[LinterError]:
with tempfile.NamedTemporaryFile('w') as temp_file:
temp_file.write(self._code)
temp_file.flush()
-
- self.app.run_checks([temp_file.name])
- checkers = self.app.file_checker_manager.checkers
- results = checkers[index_of_check].results
+ self.app.initialize(argv=[temp_file.name, *self._argv])
+ self.app.run_checks()
+ assert self.app.file_checker_manager is not None
+ artifacts = self.app.file_checker_manager.results
+ results = [r for _, results_, _ in artifacts for r in results_]
for result in results:
+ assert isinstance(result, tuple)
+ result = cast(tuple, result)
response = LinterError(
- *result, solution_file_id=self._solution_file_id)
+ *result, solution_file_id=self._solution_file_id,
+ )
if response.error_code in defines.FLAKE_SKIP_ERRORS:
self._logger.info(
'Skipping error %s on line %s to solution file %s',
diff --git a/lms/lmstests/sandbox/linters/sql.py b/lms/lmstests/sandbox/linters/sql.py
index ca93089b..a2d95029 100644
--- a/lms/lmstests/sandbox/linters/sql.py
+++ b/lms/lmstests/sandbox/linters/sql.py
@@ -12,7 +12,8 @@
class SQLLinter(BaseLinter):
def initialize(self):
- self._app = Linter(config=FluffConfig.from_root())
+ config = {'dialect': 'ansi'}
+ self._app = Linter(config=FluffConfig(overrides=config))
@property
def app(self) -> Linter:
diff --git a/lms/lmsweb/__init__.py b/lms/lmsweb/__init__.py
index fe328733..953e35a4 100644
--- a/lms/lmsweb/__init__.py
+++ b/lms/lmsweb/__init__.py
@@ -32,7 +32,12 @@
http_basic_auth = HTTPBasicAuth()
-limiter = Limiter(webapp, key_func=get_remote_address)
+limiter = Limiter(
+ app=webapp,
+ key_func=get_remote_address,
+ default_limits=["60 per minute"],
+ storage_uri='memory://',
+)
if not config_file.exists():
diff --git a/lms/lmsweb/git_service.py b/lms/lmsweb/git_service.py
index a20d4254..e28689a8 100644
--- a/lms/lmsweb/git_service.py
+++ b/lms/lmsweb/git_service.py
@@ -200,7 +200,7 @@ def _build_upload_operation(self) -> _GitOperation:
contain_new_commits=False,
)
- def _load_files_from_repository(self) -> typing.List[upload.File]:
+ def _load_files_from_repository(self) -> list[upload.File]:
"""
Since the remote server is a git bare repository
we need to 'clone' the bare repository to resolve the files.
@@ -209,7 +209,7 @@ def _load_files_from_repository(self) -> typing.List[upload.File]:
"""
with tempfile.TemporaryDirectory() as tempdir:
self._execute_command(
- args=['git', 'clone', self.repository_folder, '.'],
+ args=['git', 'clone', str(self.repository_folder), '.'],
cwd=tempdir,
)
to_return = []
diff --git a/lms/lmsweb/tools/validators.py b/lms/lmsweb/tools/validators.py
index 0b73d3ce..f1ec408c 100644
--- a/lms/lmsweb/tools/validators.py
+++ b/lms/lmsweb/tools/validators.py
@@ -1,29 +1,29 @@
+from typing import TYPE_CHECKING
+
from flask_babel import gettext as _ # type: ignore
-from wtforms.fields.core import StringField
+from wtforms import StringField
from wtforms.validators import ValidationError
from lms.lmsdb.models import User
+if TYPE_CHECKING:
+ from lms.lmsweb.forms.register import RegisterForm
+ from lms.lmsweb.forms.reset_password import ResetPassForm
+
-def UniqueUsernameRequired(
- _form: 'RegisterForm', field: StringField, # type: ignore # NOQA: F821
-) -> None:
+def UniqueUsernameRequired(__: 'RegisterForm', field: StringField) -> None:
username_exists = User.get_or_none(User.username == field.data)
if username_exists:
raise ValidationError(_('The username is already in use'))
-def UniqueEmailRequired(
- _form: 'RegisterForm', field: StringField, # type: ignore # NOQA: F821
-) -> None:
+def UniqueEmailRequired(__: 'RegisterForm', field: StringField) -> None:
email_exists = User.get_or_none(User.mail_address == field.data)
if email_exists:
raise ValidationError(_('The email is already in use'))
-def EmailNotExists(
- _form: 'ResetPassForm', field: StringField, # type: ignore # NOQA: F821
-) -> None:
+def EmailNotExists(__: 'ResetPassForm', field: StringField) -> None:
email_exists = User.get_or_none(User.mail_address == field.data)
if not email_exists:
raise ValidationError(_('Invalid email'))
diff --git a/lms/lmsweb/views.py b/lms/lmsweb/views.py
index 109e8e3c..ef2a1c02 100644
--- a/lms/lmsweb/views.py
+++ b/lms/lmsweb/views.py
@@ -1,66 +1,115 @@
+from functools import partial
from typing import Any, Callable, Optional
import arrow # type: ignore
from flask import (
- Response, jsonify, make_response, render_template, request,
- send_from_directory, session, url_for,
+ # Response,
+ jsonify,
+ make_response,
+ render_template,
+ request,
+ send_from_directory,
+ session,
+ url_for,
)
from flask_babel import gettext as _ # type: ignore
from flask_limiter.util import get_remote_address # type: ignore
from flask_login import ( # type: ignore
- current_user, login_required, login_user, logout_user,
+ current_user,
+ login_required,
+ login_user,
+ logout_user,
)
from itsdangerous import BadSignature, SignatureExpired
from werkzeug.datastructures import FileStorage
from werkzeug.utils import redirect
from lms.lmsdb.models import (
- ALL_MODELS, Comment, Course, Note, Role, RoleOptions, SharedSolution,
- Solution, SolutionFile, User, UserCourse, database,
+ ALL_MODELS,
+ Comment,
+ Course,
+ Note,
+ Role,
+ RoleOptions,
+ SharedSolution,
+ Solution,
+ SolutionFile,
+ User,
+ UserCourse,
+ database,
+)
+from lms.lmsweb import (
+ babel,
+ # http_basic_auth,
+ limiter,
+ routes,
+ webapp,
)
-from lms.lmsweb import babel, http_basic_auth, limiter, routes, webapp
from lms.lmsweb.admin import (
- AdminModelView, SPECIAL_MAPPING, admin, managers_only,
+ AdminModelView,
+ SPECIAL_MAPPING,
+ admin,
+ managers_only,
)
from lms.lmsweb.config import (
- CONFIRMATION_TIME, LANGUAGES, LIMITS_PER_HOUR,
- LIMITS_PER_MINUTE, LOCALE, MAX_UPLOAD_SIZE, REPOSITORY_FOLDER,
+ CONFIRMATION_TIME,
+ LANGUAGES,
+ LIMITS_PER_HOUR,
+ LIMITS_PER_MINUTE,
+ LOCALE,
+ MAX_UPLOAD_SIZE,
+ # REPOSITORY_FOLDER,
)
from lms.lmsweb.forms.change_password import ChangePasswordForm
from lms.lmsweb.forms.register import RegisterForm
from lms.lmsweb.forms.reset_password import RecoverPassForm, ResetPassForm
-from lms.lmsweb.git_service import GitService
+# from lms.lmsweb.git_service import GitService
from lms.lmsweb.manifest import MANIFEST
from lms.lmsweb.redirections import (
- PERMISSIVE_CORS, get_next_url, login_manager,
+ PERMISSIVE_CORS,
+ get_next_url,
+ login_manager,
)
from lms.models import (
- comments, courses, exercises, notes, notifications,
- share_link, solutions, upload, users,
+ comments,
+ courses,
+ exercises,
+ notes,
+ notifications,
+ share_link,
+ solutions,
+ upload,
+ users,
)
from lms.models.errors import (
- AlreadyExists, FileSizeError, ForbiddenPermission, LmsError,
- UnauthorizedError, UploadError, fail,
+ AlreadyExists,
+ FileSizeError,
+ ForbiddenPermission,
+ LmsError,
+ UnauthorizedError,
+ UploadError,
+ fail,
)
from lms.models.users import SERIALIZER, auth, retrieve_salt
from lms.utils.consts import RTL_LANGUAGES
from lms.utils.files import (
- get_language_name_by_extension, get_mime_type_by_extention,
+ get_language_name_by_extension,
+ get_mime_type_by_extention,
)
from lms.utils.log import log
from lms.utils.mail import (
- send_change_password_mail, send_confirmation_mail,
+ send_change_password_mail,
+ send_confirmation_mail,
send_reset_password_mail,
)
HIGH_ROLES = {str(RoleOptions.STAFF), str(RoleOptions.ADMINISTRATOR)}
-@babel.localeselector
def get_locale():
if LOCALE in LANGUAGES:
return LOCALE
- return 'en'
+ return "en"
@webapp.before_request
@@ -88,167 +137,192 @@ def load_user(uuid):
@webapp.errorhandler(429)
def ratelimit_handler(e):
- log.info(f'IP Address: {get_remote_address()}: {e}')
+ log.info(f"IP Address: {get_remote_address()}: {e}")
return make_response(
- jsonify(error='ratelimit exceeded %s' % e.description), 429,
+ jsonify(error="ratelimit exceeded %s" % e.description),
+ 429,
)
-@webapp.route('/login', methods=['GET', 'POST'])
+@webapp.route("/login", methods=["GET", "POST"])
@limiter.limit(
- f'{LIMITS_PER_MINUTE}/minute;{LIMITS_PER_HOUR}/hour',
+ f"{LIMITS_PER_MINUTE}/minute;{LIMITS_PER_HOUR}/hour",
deduct_when=lambda response: response.status_code != 200,
)
def login(login_message: Optional[str] = None):
if current_user.is_authenticated:
- return get_next_url(request.args.get('next'))
+ return get_next_url(request.args.get("next"))
- username = request.form.get('username')
- password = request.form.get('password')
- next_page = request.form.get('next')
- login_message = request.args.get('login_message')
+ username = request.form.get("username")
+ password = request.form.get("password")
+ next_page = request.form.get("next")
+ login_message = request.args.get("login_message")
- if request.method == 'POST':
+ if request.method == "POST":
try:
user = auth(username, password)
except (ForbiddenPermission, UnauthorizedError) as e:
error_message, _ = e.args
- error_details = {'next': next_page, 'login_message': error_message}
- return redirect(url_for('login', **error_details))
+ error_details = {"next": next_page, "login_message": error_message}
+ return redirect(url_for("login", **error_details))
else:
login_user(user)
- session['_invalid_password_tries'] = 0
+ session["_invalid_password_tries"] = 0
return get_next_url(next_page)
- return render_template('login.html', login_message=login_message)
+ return render_template("login.html", login_message=login_message)
-@webapp.route('/signup', methods=['GET', 'POST'])
-@limiter.limit(f'{LIMITS_PER_MINUTE}/minute;{LIMITS_PER_HOUR}/hour')
+@webapp.route("/signup", methods=["GET", "POST"])
+@limiter.limit(f"{LIMITS_PER_MINUTE}/minute;{LIMITS_PER_HOUR}/hour")
def signup():
- if not webapp.config.get('REGISTRATION_OPEN', False):
- return redirect(url_for(
- 'login', login_message=_('Can not register now'),
- ))
+ if not webapp.config.get("REGISTRATION_OPEN", False):
+ return redirect(
+ url_for(
+ "login",
+ login_message=_("Can not register now"),
+ ),
+ )
form = RegisterForm()
if not form.validate_on_submit():
- return render_template('signup.html', form=form)
-
- user = User.create(**{
- User.mail_address.name: form.email.data,
- User.username.name: form.username.data,
- User.fullname.name: form.fullname.data,
- User.role.name: Role.get_unverified_role(),
- User.password.name: form.password.data,
- User.api_key.name: User.random_password(),
- })
+ return render_template("signup.html", form=form)
+
+ user = User.create(
+ **{
+ User.mail_address.name: form.email.data,
+ User.username.name: form.username.data,
+ User.fullname.name: form.fullname.data,
+ User.role.name: Role.get_unverified_role(),
+ User.password.name: form.password.data,
+ User.api_key.name: User.random_password(),
+ },
+ )
send_confirmation_mail(user)
- return redirect(url_for(
- 'login', login_message=_('Registration successfully'),
- ))
+ return redirect(
+ url_for(
+ "login",
+ login_message=_("Registration successfully"),
+ ),
+ )
-@webapp.route('/confirm-email/${code}
`;
+ }
+ },
+ });
+}
+
+window.markLine = markLine;
+window.removeMark = removeMark;
window.hoverLine = hoverLine;
window.addCommentToLine = addCommentToLine;
-window.getLineColorByRole = getLineColorByRole;
window.addEventListener('load', () => {
const codeElementData = document.getElementById('code-view').dataset;
window.solutionId = codeElementData.id;
@@ -151,7 +291,10 @@ window.addEventListener('load', () => {
window.exerciseId = codeElementData.exercise;
sessionStorage.setItem('role', codeElementData.role);
sessionStorage.setItem('solver', codeElementData.solver);
+ sessionStorage.setItem('solverId', codeElementData.solverId);
sessionStorage.setItem('allowedComment', codeElementData.allowedComment);
+ customElements.define('comment-line', LineComment);
+ configureMarkdownParser();
addLineSpansToPre(document.getElementsByTagName('code'));
pullComments(window.fileId, treatComments);
});
diff --git a/lms/static/grader.js b/lms/static/grader.js
index 1ab53792..710357fa 100644
--- a/lms/static/grader.js
+++ b/lms/static/grader.js
@@ -26,29 +26,14 @@ function sendComment(kind, fileId, line, commentData) {
}
function visuallyRemoveComment(commentId) {
- const commentElement = document.querySelector(`.grader-delete[data-commentid="${commentId}"]`).closest('.comment');
- const lineElement = document.querySelector(`.line[data-line="${commentElement.dataset.line}"]`);
- const existingPopover = bootstrap.Popover.getInstance(lineElement);
- const hr = commentElement.nextElementSibling || commentElement.previousElementSibling;
- if (hr === null) {
- lineElement.dataset.marked = false;
- window.markLine(lineElement, 'none');
- const popover = bootstrap.Popover.getInstance(lineElement);
- if (popover !== null) {
- popover.dispose();
- }
- } else {
- let removeContent = `'+(n?e:c(e,!0))+"
\n":""+(n?e:c(e,!0))+"
\n"}blockquote(e){return`\n${e}\n`}html(e,t){return e}heading(e,t,n){return`
${e}
\n`}table(e,t){return t&&(t=`${t}`),"${e}
`}br(){return"An error occurred:
"+c(n.message+"",!0)+"";return t?Promise.resolve(e):e}if(t)return Promise.reject(n);throw n}}}const ae=new oe;function ce(e,t){return ae.parse(e,t)}ce.options=ce.setOptions=function(e){return ae.setOptions(e),ce.defaults=ae.defaults,n(ce.defaults),ce},ce.getDefaults=t,ce.defaults=e.defaults,ce.use=function(...e){return ae.use(...e),ce.defaults=ae.defaults,n(ce.defaults),ce},ce.walkTokens=function(e,t){return ae.walkTokens(e,t)},ce.parseInline=ae.parseInline,ce.Parser=ie,ce.parser=ie.parse,ce.Renderer=se,ce.TextRenderer=re,ce.Lexer=ne,ce.lexer=ne.lex,ce.Tokenizer=w,ce.Hooks=le,ce.parse=ce;const he=ce.options,pe=ce.setOptions,ue=ce.use,ke=ce.walkTokens,ge=ce.parseInline,fe=ce,de=ie.parse,xe=ne.lex;e.Hooks=le,e.Lexer=ne,e.Marked=oe,e.Parser=ie,e.Renderer=se,e.TextRenderer=re,e.Tokenizer=w,e.getDefaults=t,e.lexer=xe,e.marked=ce,e.options=he,e.parse=fe,e.parseInline=ge,e.parser=de,e.setOptions=pe,e.use=ue,e.walkTokens=ke})); diff --git a/lms/static/my.css b/lms/static/my.css index 553a0d49..3e447636 100644 --- a/lms/static/my.css +++ b/lms/static/my.css @@ -470,6 +470,7 @@ button#share-action:hover { flex-flow: column wrap; flex-basis: 70vw; flex-grow: 4; + border: 1px solid #e1e4e8; } #code-view { @@ -478,6 +479,43 @@ button#share-action:hover { overflow-x: hidden; } +.line-number { + box-sizing: content-box; + display: flex; + flex-shrink: 0; + justify-content: flex-end; + align-items: center; + padding-right: 0.75em; + text-align: right; + border-right: 1px solid #e1e4e8; + color: rgba(0, 0, 0, 0.5); +} + +.line { + display: inline-block; + width: 100%; + padding-inline-start: 0.5em; +} + +.comments-container { + display: flex; + width: 100%; + padding: 0.4em 0; + flex-direction: column; +} + +.comments-container code { + border: 1px solid #e1e4e8; + border-radius: 5px; + padding: 0.25rem; +} + +.line-comment { + display: flex; + background: white; + border-block: 1px solid #e1e4e8; +} + #code-toolbar { display: flex; background-color: #f5f2f0; @@ -669,17 +707,18 @@ ol.comments > li:active { cursor: grabbing; } -.comment-author { - color: #50514f; +.comment-text { + display: inline-flex; + flex-direction: column; + gap: 0.625rem; } -span.line { - cursor: pointer; - direction: ltr; - display: inline-block; - text-align: left; - width: calc(100% - 1.4em); - margin-left: 0.3em; +.comment-text p { + margin: 0; +} + +.comment-author { + color: #50514f; } #grader-buttons { @@ -695,7 +734,7 @@ span.line { margin-top: 1rem; } -.line[data-vimbackground="true"] { +.line-container[data-vimbackground="true"] { background: lightgreen; } @@ -715,14 +754,35 @@ code > code:last-of-type{ display: inherit; } */ +pre[class*="language-"] { + /* Override prism.js */ + padding: 0 !important; + margin: 0 !important; +} + +code#user-code { + display: flex; + flex-direction: column; +} -.line[data-marked="true"] { +.line-container[data-marked="true"] { background: #fac4c3; } +.line-container { + display: flex; + align-items: center; + flex-direction: row; + justify-content: start; + cursor: pointer; + width: 100%; + flex-grow: 1; +} + .grader-add { background-attachment: fixed; - display: inline-flex; + display: flex; + margin-inline-start: -0.5rem; width: 1rem; height: 1em; line-height: 1em; diff --git a/lms/static/my.js b/lms/static/my.js index 9e798eb9..fc6389b9 100644 --- a/lms/static/my.js +++ b/lms/static/my.js @@ -84,7 +84,7 @@ function trackDisableShareButton(solutionId, button) { } function updateNotificationsBadge() { - const dropdown = document.getElementById('navbarNavDropdown'); + const dropdown = document.getElementById('navbarSupportedContent'); const container = document.getElementById('notifications-list'); if (dropdown === null || container === null) { return; diff --git a/lms/static/purify.js b/lms/static/purify.js new file mode 100644 index 00000000..56bf4d96 --- /dev/null +++ b/lms/static/purify.js @@ -0,0 +1,4 @@ +// https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js +/*! @license DOMPurify 3.0.9 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.0.9/LICENSE */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).DOMPurify=t()}(this,(function(){"use strict";const{entries:e,setPrototypeOf:t,isFrozen:n,getPrototypeOf:o,getOwnPropertyDescriptor:r}=Object;let{freeze:i,seal:a,create:l}=Object,{apply:c,construct:s}="undefined"!=typeof Reflect&&Reflect;i||(i=function(e){return e}),a||(a=function(e){return e}),c||(c=function(e,t,n){return e.apply(t,n)}),s||(s=function(e,t){return new e(...t)});const u=N(Array.prototype.forEach),m=N(Array.prototype.pop),f=N(Array.prototype.push),p=N(String.prototype.toLowerCase),d=N(String.prototype.toString),h=N(String.prototype.match),g=N(String.prototype.replace),T=N(String.prototype.indexOf),y=N(String.prototype.trim),E=N(Object.prototype.hasOwnProperty),A=N(RegExp.prototype.test),_=(b=TypeError,function(){for(var e=arguments.length,t=new Array(e),n=0;n
{% if current_file | language_name in image_extensions %}{% else %}{{- current_file.code | trim(chars=' ') | e -}}{% endif %}
+ {% if current_file | language_name in image_extensions %}{% else %}{{- current_file.code | trim(chars=' ') | e -}}{% endif %}