Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: CI

on:
push:
branches: [master]
pull_request:
# Allow this workflow to be called from other workflows
workflow_call:

jobs:
run_tests:
name: ${{ matrix.toxenv }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.12"]
toxenv: [lint, py312]

steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- name: Install uv
uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6.3.1
with:
enable-cache: true
python-version: "${{ matrix.python-version }}"

- name: Install CI dependencies
run: uv sync --group ci

- name: Run tox
run: uv run tox -e ${{ matrix.toxenv }}

- name: Upload coverage to Codecov
if: matrix.toxenv == 'py312'
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
with:
token: ${{ secrets.CODECOV_TOKEN }}
flags: unittests
fail_ci_if_error: false
2 changes: 0 additions & 2 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,4 @@ include LICENSE.txt
include README.rst
# graft edx_proctoring
# prune edx_proctoring/static/proctoring/spec
recursive-include requirements *.in *.txt
global-exclude *.pyc
include requirements/constraints.txt
71 changes: 26 additions & 45 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,48 +1,29 @@
.PHONY: help clean upgrade requirements test-python test quality quality-python
.PHONY: help requirements upgrade lint format test clean

.DEFAULT_GOAL := help

help: ## display this help message
@echo "Please use \`make <target>' where <target> is one of:"
@awk -F ':.*?## ' '/^[a-zA-Z]/ && NF==2 {printf "\033[36m %-25s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) | sort

clean: ## remove generated byte code, coverage reports, and build artifacts
find . -name '__pycache__' -exec rm -rf {} +
find . -name '*.pyc' -exec rm -f {} +
find . -name '*.pyo' -exec rm -f {} +
find . -name '*~' -exec rm -f {} +
rm -fr build/
rm -fr dist/
rm -fr *.egg-info

COMMON_CONSTRAINTS_TXT=requirements/common_constraints.txt
.PHONY: $(COMMON_CONSTRAINTS_TXT)
$(COMMON_CONSTRAINTS_TXT):
wget -O "$(@)" https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt || touch "$(@)"

upgrade: export CUSTOM_COMPILE_COMMAND=make upgrade
upgrade: $(COMMON_CONSTRAINTS_TXT)
## update the requirements/*.txt files with the latest packages satisfying requirements/*.in
pip install -qr requirements/pip-tools.txt
pip-compile --upgrade --rebuild --allow-unsafe -o requirements/pip.txt requirements/pip.in
pip-compile --upgrade -o requirements/pip-tools.txt requirements/pip-tools.in
pip install -qr requirements/pip.txt
pip install -qr requirements/pip-tools.txt
pip-compile --upgrade -o requirements/base.txt requirements/base.in
pip-compile --upgrade -o requirements/server.txt requirements/server.in
pip-compile --upgrade -o requirements/testing.txt requirements/testing.in

requirements: ## install development environment requirements
pip install -qr requirements/base.txt
pip install -qr requirements/testing.txt


quality-python: ## Run python linters
pylint --rcfile=pylintrc mockprock setup.py

quality: quality-python ## Run linters

test-python: clean ## run tests using pytest and generate coverage report
pytest

test: test-python ## run tests
help: ## Show this help message
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'

requirements: ## Sync dev dependencies
uv sync --group dev
uv tool install tox --with tox-uv

upgrade: ## Upgrade and regenerate pinned dependencies
uv run --with edx-lint edx_lint write_uv_constraints pyproject.toml
uv lock --upgrade

lint: ## Run linting checks
uv run tox -e lint

format: ## Auto-fix formatting and import order issues
uv run ruff check --fix .
uv run ruff format .

test: ## Run tests
uv run tox -e py312

clean: ## Clean cache, test, and build directories
rm -rf build/ dist/ *.egg-info/ .pytest_cache/ .ruff_cache/ htmlcov/
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find . -type f -name "*.pyc" -delete
4 changes: 4 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
comment: false
coverage:
status:
patch: off
11 changes: 5 additions & 6 deletions mockprock/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,10 @@


class MockProckBackend(BaseRestProctoringProvider):
base_url = 'http://host.docker.internal:11136'
verbose_name = 'Mock Proctoring Service'
tech_support_email = 'support@youjustgotmockprockd.com'
tech_support_phone = '+1 605 475 6968'
base_url = "http://host.docker.internal:11136"
verbose_name = "Mock Proctoring Service"
tech_support_email = "support@youjustgotmockprockd.com"
tech_support_phone = "+1 605 475 6968"
needs_oauth = True
token_expiration_time = 86400
npm_module = '@edx/mockprock'

npm_module = "@edx/mockprock"
23 changes: 14 additions & 9 deletions mockprock/commands.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,34 @@
"""Command line utilities"""

import argparse

from mockprock.backend import MockProckBackend


def get_url():
"""
Prints or opens an instructor dashboard url
"""
parser = argparse.ArgumentParser(description='Return an instructor dashboard url')
parser.add_argument("client_id", type=str, help="mockprock oauth client id", nargs="?", default='client')
parser.add_argument("client_secret", type=str, help="mockprock oauth client secret", nargs="?", default='clientsecret')
parser.add_argument('-o', dest='open_url', help='Open in browser', default=False, action='store_true')
parser.add_argument('-c', dest='course_id', type=str, help='Course ID', required=True)
parser = argparse.ArgumentParser(description="Return an instructor dashboard url")
parser.add_argument("client_id", type=str, help="mockprock oauth client id", nargs="?", default="client")
parser.add_argument(
"client_secret", type=str, help="mockprock oauth client secret", nargs="?", default="clientsecret"
)
parser.add_argument("-o", dest="open_url", help="Open in browser", default=False, action="store_true")
parser.add_argument("-c", dest="course_id", type=str, help="Course ID", required=True)
args = parser.parse_args()
course_id = args.course_id
user = {
'id': 1,
'full_name': 'Course Instructor',
'email': 'instructor@example.com',
"id": 1,
"full_name": "Course Instructor",
"email": "instructor@example.com",
}
backend = MockProckBackend(args.client_id, args.client_secret)
url = backend.get_instructor_url(course_id, user)
if args.open_url:
print('Opening %s' % url)
print(f"Opening {url}")
import webbrowser

webbrowser.open(url)
else:
print(url)
96 changes: 55 additions & 41 deletions mockprock/db.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from collections import namedtuple
from contextlib import closing
import json
import sqlite3
import uuid
from collections import namedtuple
from contextlib import closing


def namedtuple_factory(cursor, row):
Expand All @@ -11,8 +11,9 @@ def namedtuple_factory(cursor, row):
Row = namedtuple("Row", fields)
return Row(*row)


def init_app(app):
app.db = DB('mockprock.sqlite', app.logger)
app.db = DB("mockprock.sqlite", app.logger)
app.db.setup()


Expand All @@ -27,7 +28,7 @@ def connect(self):
return conn

def setup(self):
create_sql = '''
create_sql = """
CREATE TABLE IF NOT EXISTS exams (
id TEXT PRIMARY KEY,
course_id TEXT,
Expand All @@ -46,89 +47,102 @@ def setup(self):
created TIMESTAMP,
modified TIMESTAMP,
lms_host TEXT
);'''.split(';')
);""".split(";")
with self.connect() as conn:
for stmt in create_sql:
conn.execute(stmt)

def get_exam(self, exam_id):
with self.connect() as conn:
with closing(conn.cursor()) as c:
c.execute('select * from exams where id = ?', (exam_id,))
c.execute("select * from exams where id = ?", (exam_id,))
row = c.fetchone()
if row:
return {
'id': row.id,
'name': row.name,
'course_id': row.course_id,
'rules': json.loads(row.rules),
"id": row.id,
"name": row.name,
"course_id": row.course_id,
"rules": json.loads(row.rules),
}
return {}

def save_exam(self, exam, client_id=None):
rules = exam.get('rules', {})
rules = exam.get("rules", {})
rules = json.dumps(rules)
exam_id = exam.get('external_id', None)
exam_id = exam.get("external_id", None)
if not exam_id:
exam_id = exam['external_id'] = uuid.uuid4().hex
exam_id = exam["external_id"] = uuid.uuid4().hex
with self.connect() as conn:
pars = (exam_id, exam['course_id'], exam['exam_name'], exam['is_practice_exam'], rules)
pars = (exam_id, exam["course_id"], exam["exam_name"], exam["is_practice_exam"], rules)
try:
conn.execute("insert into exams (id, course_id, name, is_practice, rules, created) values (?, ?, ?, ?, ?, datetime('now'))", pars)
self.logger.info('Saved exam %s from %s', exam_id, client_id)
conn.execute(
"insert into exams (id, course_id, name, is_practice, rules, created) values (?, ?, ?, ?, ?, datetime('now'))",
pars,
)
self.logger.info("Saved exam %s from %s", exam_id, client_id)
except sqlite3.IntegrityError:
pars = (exam['course_id'], exam['exam_name'], exam['is_practice_exam'], rules, exam_id)
stmt = 'update exams set course_id = ?, name = ?, is_practice = ?, rules = ? where id = ?'
pars = (exam["course_id"], exam["exam_name"], exam["is_practice_exam"], rules, exam_id)
stmt = "update exams set course_id = ?, name = ?, is_practice = ?, rules = ? where id = ?"
conn.execute(stmt, pars)
self.logger.info('Updated exam %s from %s', exam_id, client_id)
self.logger.info("Updated exam %s from %s", exam_id, client_id)
return exam_id

def get_attempt(self, exam_id, attempt_id):
with self.connect() as conn:
with closing(conn.cursor()) as c:
c.execute('select id, status, exam_id, lms_host, user_id, user_email from attempts where id = ? and exam_id = ?', (attempt_id, exam_id))
c.execute(
"select id, status, exam_id, lms_host, user_id, user_email from attempts where id = ? and exam_id = ?",
(attempt_id, exam_id),
)
row = c.fetchone()
if row:
return {
'id': row.id,
'status': row.status,
'exam_id': row.exam_id,
'lms_host': row.lms_host,
'user_id': row.user_id,
'email': row.user_email,
"id": row.id,
"status": row.status,
"exam_id": row.exam_id,
"lms_host": row.lms_host,
"user_id": row.user_id,
"email": row.user_email,
}
return {}

def save_attempt(self, attempt):
attempt_id = attempt.get('id', None)
attempt_id = attempt.get("id", None)
if attempt_id:
stmt = "update attempts set status = ?, modified = datetime('now') where id = ?"
pars = (attempt['status'], attempt_id)
pars = (attempt["status"], attempt_id)
else:
attempt_id = attempt['id'] = uuid.uuid4().hex
stmt = """insert into attempts (id, exam_id, status, user_id, user_name, user_email, lms_host, created, modified)
attempt_id = attempt["id"] = uuid.uuid4().hex
stmt = """insert into attempts (id, exam_id, status, user_id, user_name, user_email, lms_host, created, modified)
values (?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))"""
pars = (attempt_id, attempt['exam_id'], attempt['status'], attempt['user_id'],
attempt['full_name'], attempt['email'], attempt['lms_host'])
pars = (
attempt_id,
attempt["exam_id"],
attempt["status"],
attempt["user_id"],
attempt["full_name"],
attempt["email"],
attempt["lms_host"],
)
with self.connect() as conn:
conn.execute(stmt, pars)
self.logger.info('Created attempt %s from %r', attempt_id, attempt)
self.logger.info("Created attempt %s from %r", attempt_id, attempt)
return attempt

def get_exams(self, course_id=None):
if course_id:
stmt, pars = 'select * from exams where course_id = ?', [course_id]
stmt, pars = "select * from exams where course_id = ?", [course_id]
else:
stmt, pars = 'select * from exams', []
stmt, pars = "select * from exams", []
with self.connect() as conn:
with closing(conn.cursor()) as c:
c.execute(stmt, pars)
for row in c:
yield {
'id': row.id,
'name': row.name,
'course_id': row.course_id,
'is_practice': row.is_practice,
'rules': json.loads(row.rules),
'created': row.created
"id": row.id,
"name": row.name,
"course_id": row.course_id,
"is_practice": row.is_practice,
"rules": json.loads(row.rules),
"created": row.created,
}
Loading
Loading