From d0c284ef5185e49f6427a40767248b9d155e1ba3 Mon Sep 17 00:00:00 2001 From: John McCann Cunniff Jr <36013983+wabscale@users.noreply.github.com> Date: Mon, 3 May 2021 12:51:31 -0400 Subject: [PATCH] v3.0.0 Anubis LMS (#84) * CHG course aware admin utilities * ADD professor and TA management components * ADD basic permission and crash checks * CHG rename pytest.sh * CHG lint * ADD TODO on all places in api that need docs * CHG document a huge amount of the api * FIX debug message target * CHG move nav items into Components subfolder * CHG set the DH_HOST for testing data seed * CHG significantly reduce course context checks * FIX spacing issue in design doc and add management ide auth notes * CHG more graceful course context checks in the frontend * FIX theia admin network policy * CHG simplify theia proxy LRU cache * CHG more clear error message on course action permission * FIX remove is_admin check from public submission * FIX remove is_admin check from auth user create * CHG increment version on about page --- .gitmodules | 3 - Makefile | 33 +- api/Makefile | 7 +- api/anubis/app.py | 2 +- api/anubis/config.py | 58 +--- api/anubis/models/__init__.py | 114 +++++-- api/anubis/rpc/batch.py | 2 +- api/anubis/rpc/seed.py | 153 ++++----- api/anubis/rpc/theia.py | 9 +- api/anubis/utils/{users => }/auth.py | 112 ++++++- api/anubis/utils/data.py | 50 ++- api/anubis/utils/{ => http}/decorators.py | 177 +++++++--- api/anubis/utils/http/files.py | 19 ++ api/anubis/utils/http/https.py | 35 +- api/{ => anubis/utils/lms}/__init__.py | 0 .../utils/{assignment => lms}/assignments.py | 116 ++++--- .../utils/{assignment => lms}/autograde.py | 8 +- api/anubis/utils/lms/course.py | 282 ++++++++++++++++ .../utils/{assignment => lms}/questions.py | 19 +- api/anubis/utils/{users => lms}/students.py | 10 +- .../utils/{assignment => lms}/submissions.py | 0 .../utils/{assignment => lms}/webhook.py | 9 +- api/anubis/utils/seed.py | 65 ++++ api/anubis/utils/services/cache.py | 7 + api/anubis/utils/services/elastic.py | 52 ++- api/anubis/utils/services/logger.py | 33 +- api/anubis/utils/services/rpc.py | 4 +- api/anubis/utils/services/theia.py | 4 +- api/anubis/utils/users/__init__.py | 0 api/anubis/utils/visuals/assignments.py | 56 +++- api/anubis/utils/visuals/usage.py | 43 ++- api/anubis/views/admin/__init__.py | 6 +- api/anubis/views/admin/assignments.py | 105 +++++- api/anubis/views/admin/auth.py | 6 +- api/anubis/views/admin/autograde.py | 127 ++++--- api/anubis/views/admin/config.py | 16 +- api/anubis/views/admin/courses.py | 309 ++++++++++++++++-- api/anubis/views/admin/dangling.py | 40 ++- api/anubis/views/admin/ide.py | 103 +++--- api/anubis/views/admin/questions.py | 121 ++++++- api/anubis/views/admin/regrade.py | 98 ++++-- api/anubis/views/admin/seed.py | 22 +- api/anubis/views/admin/static.py | 70 ++-- api/anubis/views/admin/students.py | 248 ++++++++++++++ api/anubis/views/admin/users.py | 181 ---------- api/anubis/views/admin/visuals.py | 39 ++- api/anubis/views/pipeline/pipeline.py | 73 +++-- api/anubis/views/public/__init__.py | 2 +- api/anubis/views/public/assignments.py | 56 +--- api/anubis/views/public/auth.py | 33 +- api/anubis/views/public/courses.py | 9 +- api/anubis/views/public/ide.py | 29 +- api/anubis/views/public/memes.py | 11 + api/anubis/views/public/messages.py | 11 - api/anubis/views/public/profile.py | 67 ++-- api/anubis/views/public/questions.py | 56 +++- api/anubis/views/public/repos.py | 7 +- api/anubis/views/public/static.py | 3 +- api/anubis/views/public/submissions.py | 18 +- api/anubis/views/public/visuals.py | 28 +- api/anubis/views/public/webhook.py | 46 ++- api/jobs/reaper.py | 42 +-- api/jobs/visuals.py | 11 +- ...0f2e0f_add_sequential_response_tracking.py | 7 +- ...be9_add_ta_for_and_professor_for_tables.py | 120 +++++++ ...2a_add_theia_options_to_assignment_and_.py | 42 +++ ...568_add_autograde_enabled_to_assignment.py | 33 ++ ...f35fc53e_add_course_awareness_to_static.py | 67 ++++ ...0_chg_text_type_for_all_user_generated_.py | 83 +++++ ...114e003a_add_hidden_to_assignment_tests.py | 3 +- api/requirements-dev.txt | 3 - api/tests/seed.py | 3 + api/tests/test.sh | 9 +- api/tests/test_assignment_admin.py | 40 +++ api/tests/test_assignments_public.py | 11 + .../__init__.py => tests/test_auth_admin.py} | 0 api/tests/test_autograde_admin.py | 11 + api/tests/test_config_admin.py | 12 + api/tests/test_courses_admin.py | 45 +++ api/tests/test_courses_public.py | 16 + api/tests/test_dangling_admin.py | 7 + api/tests/test_ide_admin.py | 16 + api/tests/test_ide_public.py | 35 ++ api/tests/test_profile_public.py | 16 + api/tests/test_questions_admin.py | 45 +++ api/tests/test_questions_public.py | 26 ++ api/tests/test_regrade_admin.py | 24 ++ api/tests/test_repos_public.py | 16 + api/tests/test_static.py | 25 -- api/tests/test_static_admin.py | 32 ++ api/tests/test_static_public.py | 26 ++ api/tests/test_students_admin.py | 15 + api/tests/test_visuals_admin.py | 10 + api/tests/test_visuals_public.py | 9 + ...est_webhooks.py => test_webhook_public.py} | 29 +- api/tests/utils.py | 189 ++++++----- api/usage.py | 11 - docs/design.md | 22 +- docs/design.pdf | Bin 1229473 -> 1229944 bytes docs/img/cluster.mmd.svg | 2 +- docs/img/rpc-queue-1.mmd.svg | 2 +- docs/img/rpc-queue-2.mmd.svg | 2 +- docs/img/rpc-queue-3.mmd.svg | 2 +- docs/img/submission-flow.mmd.svg | 2 +- docs/img/theia-pod.mmd.svg | 2 +- k8s/chart/templates/network-policy.yml | 10 +- k8s/debug/restart.sh | 22 +- k8s/debug/upgrade.sh | 28 ++ queries/first_sub.sql | 9 - queries/pass_time.sql | 39 --- queries/passed_distro.sql | 10 - queries/questions.sql | 10 - theia/ide/admin/cli/docs/conf.py | 2 +- theia/proxy/index.js | 11 +- web/package.json | 1 + web/src/App.jsx | 5 +- .../Admin/Assignment/AssignmentCard.jsx | 28 ++ .../Components/Admin/Course/CourseCard.jsx | 135 ++++++++ .../Admin/Course/CourseTasProfessors.jsx | 141 ++++++++ web/src/Components/AuthWrapper.jsx | 22 ++ web/src/Components/Header.jsx | 61 +++- web/src/{ => Components}/Navigation/Nav.jsx | 2 +- web/src/Components/Navigation/NavList.jsx | 2 +- .../Components/Public/About/Description.jsx | 2 +- .../Components/Public/Questions/Questions.jsx | 2 +- .../Components/Public/Repos/ReposTable.jsx | 4 +- web/src/Main.jsx | 2 +- .../Pages/Admin/Assignment/Assignments.jsx | 8 +- web/src/Pages/Admin/Config.jsx | 2 +- web/src/Pages/Admin/Courses.jsx | 224 +++++-------- web/src/Pages/Admin/Static.jsx | 12 +- web/src/Pages/Admin/Theia.jsx | 2 +- web/src/Pages/Admin/Users.jsx | 71 ++-- web/src/{Navigation => }/navconfig.jsx | 45 +-- web/yarn.lock | 18 + 135 files changed, 4023 insertions(+), 1452 deletions(-) delete mode 100644 .gitmodules rename api/anubis/utils/{users => }/auth.py (64%) rename api/anubis/utils/{ => http}/decorators.py (52%) rename api/{ => anubis/utils/lms}/__init__.py (100%) rename api/anubis/utils/{assignment => lms}/assignments.py (66%) rename api/anubis/utils/{assignment => lms}/autograde.py (96%) create mode 100644 api/anubis/utils/lms/course.py rename api/anubis/utils/{assignment => lms}/questions.py (94%) rename api/anubis/utils/{users => lms}/students.py (88%) rename api/anubis/utils/{assignment => lms}/submissions.py (100%) rename api/anubis/utils/{assignment => lms}/webhook.py (96%) create mode 100644 api/anubis/utils/seed.py delete mode 100644 api/anubis/utils/users/__init__.py create mode 100644 api/anubis/views/admin/students.py delete mode 100644 api/anubis/views/admin/users.py delete mode 100644 api/anubis/views/public/messages.py create mode 100644 api/migrations/versions/3d972cfa5be9_add_ta_for_and_professor_for_tables.py create mode 100644 api/migrations/versions/4331be83342a_add_theia_options_to_assignment_and_.py create mode 100644 api/migrations/versions/452a7485f568_add_autograde_enabled_to_assignment.py create mode 100644 api/migrations/versions/51a4f35fc53e_add_course_awareness_to_static.py create mode 100644 api/migrations/versions/b99d63327de0_chg_text_type_for_all_user_generated_.py delete mode 100644 api/requirements-dev.txt create mode 100644 api/tests/seed.py create mode 100644 api/tests/test_assignment_admin.py create mode 100644 api/tests/test_assignments_public.py rename api/{anubis/utils/assignment/__init__.py => tests/test_auth_admin.py} (100%) create mode 100644 api/tests/test_autograde_admin.py create mode 100644 api/tests/test_config_admin.py create mode 100644 api/tests/test_courses_admin.py create mode 100644 api/tests/test_courses_public.py create mode 100644 api/tests/test_dangling_admin.py create mode 100644 api/tests/test_ide_admin.py create mode 100644 api/tests/test_ide_public.py create mode 100644 api/tests/test_profile_public.py create mode 100644 api/tests/test_questions_admin.py create mode 100644 api/tests/test_questions_public.py create mode 100644 api/tests/test_regrade_admin.py create mode 100644 api/tests/test_repos_public.py delete mode 100644 api/tests/test_static.py create mode 100644 api/tests/test_static_admin.py create mode 100644 api/tests/test_static_public.py create mode 100644 api/tests/test_students_admin.py create mode 100644 api/tests/test_visuals_admin.py create mode 100644 api/tests/test_visuals_public.py rename api/tests/{test_webhooks.py => test_webhook_public.py} (86%) delete mode 100644 api/usage.py create mode 100755 k8s/debug/upgrade.sh delete mode 100644 queries/first_sub.sql delete mode 100644 queries/pass_time.sql delete mode 100644 queries/passed_distro.sql delete mode 100644 queries/questions.sql create mode 100644 web/src/Components/Admin/Course/CourseCard.jsx create mode 100644 web/src/Components/Admin/Course/CourseTasProfessors.jsx rename web/src/{ => Components}/Navigation/Nav.jsx (92%) rename web/src/{Navigation => }/navconfig.jsx (73%) diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index a0a39cc37..000000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "docs/m.css"] - path = docs/m.css - url = git@github.com:mosra/m.css.git diff --git a/Makefile b/Makefile index 32ab007d1..2fa663ceb 100644 --- a/Makefile +++ b/Makefile @@ -17,11 +17,6 @@ BUILT_IMAGES := $(shell \ 2> /dev/null \ ) RUNNING_CONTAINERS := $(shell docker-compose ps -q) -API_IP := $(shell docker network inspect anubis_default | \ - jq '.[0].Containers | .[] | select(.Name == "anubis_api_1") | .IPv4Address' -r | \ - awk '{print substr($$0, 1, index($$0, "/")-1)}' \ - 2> /dev/null \ -) VOLUMES := $(shell docker volume ls | awk '{if (match($$2, /^anubis_.*$$/)) {print $$2}}') @@ -45,6 +40,14 @@ push: docker-compose build --parallel --pull $(PUSH_SERVICES) docker-compose push $(PUSH_SERVICES) +startup-links: + @echo '' + @echo 'seed: http://localhost/api/admin/seed/' + @echo 'auth: http://localhost/api/admin/auth/token/superuser' + @echo 'auth: http://localhost/api/admin/auth/token/ta' + @echo 'auth: http://localhost/api/admin/auth/token/professor' + @echo 'site: http://localhost/' + .PHONY: debug # Start the cluster in debug mode debug: docker-compose up -d $(PERSISTENT_SERVICES) @@ -55,10 +58,7 @@ debug: sleep 3 @echo 'running migrations' make -C api migrations - @echo '' - @echo 'seed: http://localhost/api/admin/seed/' - @echo 'auth: http://localhost/api/admin/auth/token/jmc1283' - @echo 'site: http://localhost/' + make startup-links .PHONY: mindebug # Start the minimal cluster in debug mode mindebug: @@ -70,26 +70,17 @@ mindebug: sleep 3 @echo 'running migrations' make -C api migrations - @echo '' - @echo 'seed: http://localhost/api/admin/seed/' - @echo 'auth: http://localhost/api/admin/auth/token/jmc1283' - @echo 'site: http://localhost/' + make startup-links .PHONY: mkdebug # Start minikube debug mkdebug: ./k8s/debug/provision.sh - @echo '' - @echo 'seed: http://localhost/api/admin/seed/' - @echo 'auth: http://localhost/api/admin/auth/token/jmc1283' - @echo 'site: http://localhost/' + make startup-links .PHONY: mkrestart # Restart minikube debug mkrestart: ./k8s/debug/restart.sh - @echo '' - @echo 'seed: http://localhost/api/admin/seed/' - @echo 'auth: http://localhost/api/admin/auth/token/jmc1283' - @echo 'site: http://localhost/' + make startup-links yeetdb: docker-compose kill db diff --git a/api/Makefile b/api/Makefile index a43ba1a90..6c6fb4dd0 100644 --- a/api/Makefile +++ b/api/Makefile @@ -10,7 +10,8 @@ all: venv venv: virtualenv -p `which python3` venv - ./venv/bin/pip install -r ./requirements.txt -r ./requirements-dev.txt + ./venv/bin/pip install -r ./requirements.txt + ./venv/bin/pip install -U black pytest debug: make -C .. debug @@ -25,10 +26,8 @@ cleanyeetdb: migrations: venv ./venv/bin/alembic upgrade head -.PHONY: lint # Run autopep8 then black on lint directories +.PHONY: lint # Run black on lint directories lint: venv - @echo 'autopep to check and fix un-python things' - ./venv/bin/autopep8 --in-place --aggressive --aggressive $(LINT_FILES) @echo 'black to stylize' ./venv/bin/black $(LINT_FILES) diff --git a/api/anubis/app.py b/api/anubis/app.py index 56e6ce8c0..794d62a05 100644 --- a/api/anubis/app.py +++ b/api/anubis/app.py @@ -79,7 +79,7 @@ def create_pipeline_app(): # Initialize app with all the extra services init_services(app) - # register blueprints + # register views register_pipeline_views(app) return app diff --git a/api/anubis/config.py b/api/anubis/config.py index db00214d3..501c4e85f 100644 --- a/api/anubis/config.py +++ b/api/anubis/config.py @@ -4,71 +4,47 @@ class Config: - SECRET_KEY = None - STATS_REAP_DURATION = timedelta(days=60) - - # sqlalchemy - SQLALCHEMY_DATABASE_URI = None - SQLALCHEMY_POOL_PRE_PING = True - SQLALCHEMY_POOL_SIZE = 100 - SQLALCHEMY_POOL_RECYCLE = 280 - SQLALCHEMY_TRACK_MODIFICATIONS = False - - # OAuth - OAUTH_CONSUMER_KEY = "" - OAUTH_CONSUMER_SECRET = "" - - # Cache config - CACHE_REDIS_HOST = "redis-master" - CACHE_REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", default="anubis") - - # Logger config - LOGGER_NAME = os.environ.get("LOGGER_NAME", default="anubis-api") - - # Theia config - THEIA_DOMAIN = "" - THEIA_TIMEOUT = timedelta(hours=6) def __init__(self): + # General flask config self.DEBUG = os.environ.get("DEBUG", default="0") == "1" - self.SECRET_KEY = os.environ.get("SECRET_KEY", default="DEBUG") + # sqlalchemy + self.SQLALCHEMY_DATABASE_URI = None + self.SQLALCHEMY_POOL_PRE_PING = True + self.SQLALCHEMY_POOL_SIZE = 100 + self.SQLALCHEMY_POOL_RECYCLE = 280 + self.SQLALCHEMY_TRACK_MODIFICATIONS = False self.SQLALCHEMY_DATABASE_URI = os.environ.get( "DATABASE_URI", default="mysql+pymysql://anubis:anubis@{}/anubis".format( os.environ.get("DB_HOST", "db") ), ) + + # Elasticsearch self.DISABLE_ELK = os.environ.get("DISABLE_ELK", default="0") == "1" # OAuth self.OAUTH_CONSUMER_KEY = os.environ.get("OAUTH_CONSUMER_KEY", default="DEBUG") - self.OAUTH_CONSUMER_SECRET = os.environ.get( - "OAUTH_CONSUMER_SECRET", default="DEBUG" - ) + self.OAUTH_CONSUMER_SECRET = os.environ.get("OAUTH_CONSUMER_SECRET", default="DEBUG") # Redis - self.CACHE_REDIS_HOST = os.environ.get( - "CACHE_REDIS_HOST", default="redis-master" - ) - - self.CACHE_REDIS_PASSWORD = os.environ.get( - "REDIS_PASS", default="anubis" - ) + self.CACHE_REDIS_HOST = os.environ.get("CACHE_REDIS_HOST", default="redis-master") + self.CACHE_REDIS_PASSWORD = os.environ.get("REDIS_PASS", default="anubis") # Logger self.LOGGER_NAME = os.environ.get("LOGGER_NAME", default="anubis-api") # Theia - self.THEIA_DOMAIN = os.environ.get( - "THEIA_DOMAIN", default="ide.anubis.osiris.services" - ) + self.THEIA_DOMAIN = os.environ.get("THEIA_DOMAIN", default="ide.anubis.osiris.services") self.THEIA_TIMEOUT = timedelta(hours=6) - logging.info( - "Starting with DATABASE_URI: {}".format(self.SQLALCHEMY_DATABASE_URI) - ) + # autograding specific config + self.STATS_REAP_DURATION = timedelta(days=60) + + logging.info("Starting with DATABASE_URI: {}".format(self.SQLALCHEMY_DATABASE_URI)) logging.info("Starting with SECRET_KEY: {}".format(self.SECRET_KEY)) logging.info("Starting with DEBUG: {}".format(self.DEBUG)) diff --git a/api/anubis/models/__init__.py b/api/anubis/models/__init__.py index f447e05ef..28e6e75b9 100644 --- a/api/anubis/models/__init__.py +++ b/api/anubis/models/__init__.py @@ -41,24 +41,36 @@ class User(db.Model): # Fields netid = db.Column(db.String(128), primary_key=True, unique=True, index=True) - github_username = db.Column(db.String(128), index=True) - name = db.Column(db.String(128)) - is_admin = db.Column(db.Boolean, nullable=False, default=False) + github_username = db.Column(db.TEXT, index=True) + name = db.Column(db.TEXT) is_superuser = db.Column(db.Boolean, nullable=False, default=False) # Timestamps created = db.Column(db.DateTime, default=datetime.now) last_updated = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now) + ta_for = db.relationship('TAForCourse', cascade='all,delete') + professor_for = db.relationship('ProfessorForCourse', cascade='all,delete') + @property def data(self): + professor_for = [pf.data for pf in self.professor_for] + ta_for = [taf.data for taf in self.ta_for] + extra_for = [] + if self.is_superuser: + courses = Course.query.all() + for course in courses: + extra_for.append({'id': course.id, 'name': course.name}) return { "id": self.id, "netid": self.netid, "github_username": self.github_username, "name": self.name, - "is_admin": self.is_admin, "is_superuser": self.is_superuser, + "is_admin": len(professor_for) > 0 or len(ta_for) > 0 or self.is_superuser, + "professor_for": professor_for, + "ta_for": ta_for, + "admin_for": professor_for + ta_for + extra_for, } def __repr__(self): @@ -75,11 +87,13 @@ class Course(db.Model): id = default_id() # Fields - name = db.Column(db.String(256), nullable=False) - course_code = db.Column(db.String(256), nullable=False) - semester = db.Column(db.String(256), nullable=True) - section = db.Column(db.String(256), nullable=True) - professor = db.Column(db.String(256), nullable=False) + name = db.Column(db.TEXT, nullable=False) + course_code = db.Column(db.TEXT, nullable=False) + semester = db.Column(db.TEXT, nullable=True) + section = db.Column(db.TEXT, nullable=True) + professor = db.Column(db.TEXT, nullable=False) + theia_default_image = db.Column(db.TEXT, nullable=False, default='registry.osiris.services/anubis/xv6') + theia_default_options = db.Column(MutableJson, default=lambda: {"limits": {"cpu": "4", "memory": "4Gi"}}) @property def total_assignments(self): @@ -108,6 +122,42 @@ def data(self): } +class TAForCourse(db.Model): + __tablename__ = "ta_for_course" + + # Foreign Keys + owner_id = db.Column(db.String(128), db.ForeignKey(User.id), primary_key=True) + course_id = db.Column(db.String(128), db.ForeignKey(Course.id), primary_key=True) + + owner = db.relationship(User) + course = db.relationship(Course) + + @property + def data(self): + return { + 'id': self.course.id, + 'name': self.course.name, + } + + +class ProfessorForCourse(db.Model): + __tablename__ = "professor_for_course" + + # Foreign Keys + owner_id = db.Column(db.String(128), db.ForeignKey(User.id), primary_key=True) + course_id = db.Column(db.String(128), db.ForeignKey(Course.id), primary_key=True) + + owner = db.relationship(User) + course = db.relationship(Course) + + @property + def data(self): + return { + 'id': self.course.id, + 'name': self.course.name, + } + + class InCourse(db.Model): __tablename__ = "in_course" @@ -129,20 +179,22 @@ class Assignment(db.Model): course_id = db.Column(db.String(128), db.ForeignKey(Course.id), index=True) # Fields - name = db.Column(db.String(256), nullable=False, unique=True) + name = db.Column(db.TEXT, nullable=False, unique=True) hidden = db.Column(db.Boolean, default=False) - description = db.Column(db.Text, nullable=True) - github_classroom_url = db.Column(db.String(256), nullable=True, default=None) - pipeline_image = db.Column(db.String(256), unique=True, nullable=True) + description = db.Column(db.TEXT, nullable=True) + github_classroom_url = db.Column(db.TEXT, nullable=True, default=None) + pipeline_image = db.Column(db.TEXT, unique=True, nullable=True) unique_code = db.Column( db.String(8), unique=True, default=lambda: base64.b16encode(os.urandom(4)).decode(), ) ide_enabled = db.Column(db.Boolean, default=True) + autograde_enabled = db.Column(db.Boolean, default=True) theia_image = db.Column( - db.String(128), default="registry.osiris.services/anubis/theia-xv6" + db.TEXT, default="registry.osiris.services/anubis/theia-xv6" ) + theia_options = db.Column(MutableJson, default=lambda: {}) # Dates release_date = db.Column(db.DateTime, nullable=False) @@ -163,6 +215,7 @@ def data(self): "course": self.course.data, "description": self.description, "ide_enabled": self.ide_enabled, + "autograde_enabled": self.autograde_enabled, "ide_active": self.due_date + timedelta(days=3 * 7) > datetime.now(), "github_classroom_link": self.github_classroom_url, "tests": [t.data for t in self.tests if t.hidden is False], @@ -205,8 +258,8 @@ class AssignmentRepo(db.Model): ) # Fields - github_username = db.Column(db.String(256), nullable=False) - repo_url = db.Column(db.String(128), nullable=False) + github_username = db.Column(db.TEXT, nullable=False) + repo_url = db.Column(db.TEXT, nullable=False) # Relationships owner = db.relationship(User) @@ -233,7 +286,7 @@ class AssignmentTest(db.Model): assignment_id = db.Column(db.String(128), db.ForeignKey(Assignment.id)) # Fields - name = db.Column(db.String(128), index=True) + name = db.Column(db.TEXT, index=True) hidden = db.Column(db.Boolean, default=False) # Relationships @@ -262,7 +315,7 @@ class AssignmentQuestion(db.Model): solution = db.Column(db.Text, nullable=True) sequence = db.Column(db.Integer, index=True, nullable=False) code_question = db.Column(db.Boolean, default=False) - code_language = db.Column(db.String(128), nullable=True, default='') + code_language = db.Column(db.TEXT, nullable=True, default='') placeholder = db.Column(db.Text, nullable=True, default="") # Timestamps @@ -319,7 +372,7 @@ class AssignedStudentQuestion(db.Model): owner = db.relationship(User) assignment = db.relationship(Assignment) question = db.relationship(AssignmentQuestion) - responses = db.relationship('AssignedQuestionResponse', cascade='all,delete') + responses = db.relationship('AssignedQuestionResponse', cascade='all,delete', backref='question') @property def data(self): @@ -402,7 +455,7 @@ class Submission(db.Model): # Fields commit = db.Column(db.String(128), unique=True, index=True, nullable=False) processed = db.Column(db.Boolean, default=False) - state = db.Column(db.String(128), default="") + state = db.Column(db.TEXT, default="") errors = db.Column(MutableJson, default=None, nullable=True) token = db.Column( db.String(64), default=lambda: base64.b16encode(os.urandom(32)).decode() @@ -439,9 +492,9 @@ def init_submission_models(self): logger.debug("found tests: {}".format(list(map(lambda x: x.data, tests)))) for test in tests: - tr = SubmissionTestResult(submission=self, assignment_test=test) + tr = SubmissionTestResult(submission_id=self.id, assignment_test_id=test.id) db.session.add(tr) - sb = SubmissionBuild(submission=self) + sb = SubmissionBuild(submission_id=self.id) db.session.add(sb) self.processed = False @@ -628,6 +681,7 @@ class TheiaSession(db.Model): # id id = default_id(32) + course_id = db.Column(db.String(128), db.ForeignKey(Course.id), nullable=False, index=True) # Foreign keys owner_id = db.Column(db.String(128), db.ForeignKey(User.id), nullable=False) @@ -638,10 +692,10 @@ class TheiaSession(db.Model): # Fields active = db.Column(db.Boolean, default=True) - state = db.Column(db.String(128)) - cluster_address = db.Column(db.String(256), nullable=True, default=None) + state = db.Column(db.TEXT) + cluster_address = db.Column(db.TEXT, nullable=True, default=None) image = db.Column( - db.String(128), default="registry.osiris.services/anubis/theia-xv6" + db.TEXT, default="registry.osiris.services/anubis/theia-xv6" ) options = db.Column(MutableJson, nullable=False, default=lambda: dict()) network_locked = db.Column(db.Boolean, default=True) @@ -656,6 +710,7 @@ class TheiaSession(db.Model): owner = db.relationship(User) assignment = db.relationship(Assignment) + course = db.relationship(Course) @property def data(self): @@ -698,11 +753,12 @@ class StaticFile(db.Model): __tablename__ = "static_file" id = default_id() + course_id = db.Column(db.String(128), db.ForeignKey(Course.id), nullable=False, index=True) # Fields - filename = db.Column(db.String(256)) - path = db.Column(db.String(256)) - content_type = db.Column(db.String(128)) + filename = db.Column(db.TEXT) + path = db.Column(db.TEXT) + content_type = db.Column(db.TEXT) blob = db.Column(db.LargeBinary(length=(2 ** 32) - 1)) hidden = db.Column(db.Boolean) @@ -710,6 +766,8 @@ class StaticFile(db.Model): created = db.Column(db.DateTime, default=datetime.now) last_updated = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now) + course = db.relationship(Course) + @property def data(self): return { diff --git a/api/anubis/rpc/batch.py b/api/anubis/rpc/batch.py index 6c31ebcb9..f23a73ddc 100644 --- a/api/anubis/rpc/batch.py +++ b/api/anubis/rpc/batch.py @@ -3,7 +3,7 @@ def rpc_bulk_regrade(submissions): from anubis.app import create_app - from anubis.utils.assignment.submissions import bulk_regrade_submission + from anubis.utils.lms.submissions import bulk_regrade_submission app = create_app() diff --git a/api/anubis/rpc/seed.py b/api/anubis/rpc/seed.py index 6ea546b8b..c15f196d8 100644 --- a/api/anubis/rpc/seed.py +++ b/api/anubis/rpc/seed.py @@ -1,5 +1,4 @@ import random -import string from datetime import datetime, timedelta from anubis.models import ( @@ -17,50 +16,42 @@ AssignmentQuestion, AssignedStudentQuestion, AssignedQuestionResponse, + TAForCourse, + ProfessorForCourse, + StaticFile, ) -from anubis.utils.data import rand -from anubis.utils.assignment.questions import assign_questions - - -def rand_str(n=10): - return rand()[:n] - - -def rand_netid(): - return "".join(random.choice(string.ascii_lowercase) for _ in range(3)) + "".join( - random.choice(string.digits) for _ in range(random.randint(2, 4)) - ) +from anubis.utils.data import rand, with_context +from anubis.utils.lms.questions import assign_questions +from anubis.utils.seed import create_name, create_netid, rand_commit def create_assignment(course, users): # Assignment 1 uniq assignment = Assignment( - name="uniq", - unique_code='575ba490', - pipeline_image="registry.osiris.services/anubis/assignment/575ba490", - hidden=False, + id=rand(), name=f"assignment {course.name}", unique_code=rand(8), hidden=False, + pipeline_image=f"registry.osiris.services/anubis/assignment/{rand(8)}", github_classroom_url='http://localhost', release_date=datetime.now() - timedelta(hours=2), - due_date=datetime.now() + timedelta(hours=2), - grace_date=datetime.now() + timedelta(hours=3), - course=course, - ide_enabled=True, + due_date=datetime.now() + timedelta(hours=12), + grace_date=datetime.now() + timedelta(hours=13), + course_id=course.id, ide_enabled=True, autograde_enabled=False, ) for i in range(random.randint(2, 4)): b, c = random.randint(1, 5), random.randint(1, 5) assignment_question = AssignmentQuestion( + id=rand(), question=f"What is {c} + {b}?", solution=f"{c + b}", sequence=i, code_question=False, - assignment=assignment, + assignment_id=assignment.id, ) db.session.add(assignment_question) tests = [] for i in range(random.randint(3, 5)): - tests.append(AssignmentTest(name=f"test {i}", assignment=assignment)) + tests.append(AssignmentTest(id=rand(), name=f"test {i}", assignment_id=assignment.id)) submissions = [] repos = [] @@ -68,9 +59,8 @@ def create_assignment(course, users): for user in users: repos.append( AssignmentRepo( - owner=user, - assignment=assignment, - repo_url="https://github.com/wabscale/xv6-public.git", + id=rand(), owner=user, assignment_id=assignment.id, + repo_url="https://github.com/wabscale/xv6-public", github_username=user.github_username, ) ) @@ -102,10 +92,11 @@ def create_assignment(course, users): for i in range(random.randint(1, 10)): submissions.append( Submission( - commit=rand_str(), + id=rand(), + commit=rand_commit(), state="Waiting for resources...", owner=user, - assignment=assignment, + assignment_id=assignment.id, repo=repos[-1], ) ) @@ -122,12 +113,13 @@ def create_assignment(course, users): def create_students(n=10): students = [] for i in range(random.randint(n // 2, n)): + name = create_name() + netid = create_netid(name) students.append( User( - netid=rand_netid(), - github_username=rand_str(), - name=f"first last {i}", - is_admin=False, + name=name, + netid=netid, + github_username=rand(8), is_superuser=False, ) ) @@ -135,10 +127,8 @@ def create_students(n=10): return students -def create_course(users): - course = Course( - name="Intro to OS", course_code="CS-UY 3224", section="A", professor="Gustavo" - ) +def create_course(users, **kwargs): + course = Course(id=rand(), **kwargs) db.session.add(course) for user in users: @@ -147,39 +137,7 @@ def create_course(users): return course -def seed_main(): - # Yeet - TheiaSession.query.delete() - AssignedQuestionResponse.query.delete() - AssignedStudentQuestion.query.delete() - AssignmentQuestion.query.delete() - SubmissionTestResult.query.delete() - SubmissionBuild.query.delete() - Submission.query.delete() - AssignmentRepo.query.delete() - AssignmentTest.query.delete() - InCourse.query.delete() - Assignment.query.delete() - Course.query.delete() - User.query.delete() - db.session.commit() - - # Create - me = User( - netid="jmc1283", - github_username="wabscale", - name="John Cunniff", - is_admin=True, - is_superuser=True, - ) - db.session.add(me) - - students = create_students(50) + [me] - course = create_course(students) - assignment, _, submissions, _ = create_assignment(course, students) - - db.session.commit() - +def init_submissions(submissions): # Init models for submission in submissions: submission.init_submission_models() @@ -197,13 +155,56 @@ def seed_main(): test_result.message = 'Test passed' if test_passed else 'Test failed' test_result.stdout = 'blah blah blah test output' - db.session.commit() - assign_questions(assignment) +@with_context +def seed(): + # Yeet + TheiaSession.query.delete() + AssignedQuestionResponse.query.delete() + AssignedStudentQuestion.query.delete() + AssignmentQuestion.query.delete() + SubmissionTestResult.query.delete() + SubmissionBuild.query.delete() + Submission.query.delete() + AssignmentRepo.query.delete() + AssignmentTest.query.delete() + InCourse.query.delete() + Assignment.query.delete() + TAForCourse.query.delete() + ProfessorForCourse.query.delete() + StaticFile.query.delete() + Course.query.delete() + User.query.delete() + db.session.commit() + # Create + superuser = User(netid="superuser", github_username="superuser", name="super", is_superuser=True) + ta_user = User(netid="ta", github_username="ta", name="T A") + professor_user = User(netid="professor", github_username="professor", name="professor") + student_user = User(netid="student", github_username="student", name="student") + db.session.add_all([superuser, professor_user, ta_user, student_user]) + + # OS test course + intro_to_os_students = create_students(50) + [superuser, professor_user, ta_user, student_user] + intro_to_os_course = create_course( + intro_to_os_students, + name="Intro to OS", course_code="CS-UY 3224", section="A", professor="Gustavo", + ) + os_assignment, _, os_submissions, _ = create_assignment(intro_to_os_course, intro_to_os_students) + init_submissions(os_submissions) + assign_questions(os_assignment) + ta = TAForCourse(owner=ta_user, course=intro_to_os_course) + professor = ProfessorForCourse(owner=professor_user, course=intro_to_os_course) + db.session.add_all([professor, ta]) + + # MMDS test course + mmds_students = create_students(50) + mmds_course = create_course( + mmds_students, + name="Mining Massive Datasets", course_code="CS-UY 3843", section="A", professor="Gustavo", + ) + mmds_assignment, _, mmds_submissions, _ = create_assignment(mmds_course, mmds_students) + init_submissions(mmds_submissions) + assign_questions(mmds_assignment) -def seed_debug(*_): - from anubis.app import create_app - app = create_app() - with app.app_context(): - seed_main() + db.session.commit() diff --git a/api/anubis/rpc/theia.py b/api/anubis/rpc/theia.py index 7462be8ed..37ff22eed 100644 --- a/api/anubis/rpc/theia.py +++ b/api/anubis/rpc/theia.py @@ -1,3 +1,4 @@ +import base64 import os import time from datetime import datetime @@ -5,10 +6,9 @@ from kubernetes import config, client from anubis.models import db, Config, TheiaSession +from anubis.utils.auth import create_token from anubis.utils.services.elastic import esindex from anubis.utils.services.logger import logger -from anubis.utils.users.auth import create_token -import base64 def get_theia_pod_name(theia_session: TheiaSession) -> str: @@ -409,7 +409,7 @@ def reap_theia_session(theia_session_id: str): db.session.commit() -def reap_all_theia_sessions(*_): +def reap_all_theia_sessions(course_id: str): from anubis.app import create_app app = create_app() @@ -419,7 +419,8 @@ def reap_all_theia_sessions(*_): with app.app_context(): theia_sessions = TheiaSession.query.filter( - TheiaSession.active, + TheiaSession.active == True, + TheiaSession.course_id == course_id, ).all() for n, theia_session in enumerate(theia_sessions): diff --git a/api/anubis/utils/users/auth.py b/api/anubis/utils/auth.py similarity index 64% rename from api/anubis/utils/users/auth.py rename to api/anubis/utils/auth.py index d051bb6db..d8f0f40b9 100644 --- a/api/anubis/utils/users/auth.py +++ b/api/anubis/utils/auth.py @@ -1,3 +1,4 @@ +import functools import traceback from datetime import datetime, timedelta from functools import wraps @@ -8,12 +9,37 @@ from flask import request from anubis.config import config -from anubis.models import User +from anubis.models import User, TAForCourse, ProfessorForCourse from anubis.utils.data import is_debug from anubis.utils.http.https import error_response, get_request_ip from anubis.utils.services.logger import logger +class AuthenticationError(Exception): + """ + This exception should be raised if a request + lacks the proper authentication fow whatever + action they are requesting. + + If the view function is wrapped in any of the + require auth decorators, then this exception + will be caught and return a 401. + """ + + +class LackCourseContext(Exception): + """ + Most of the admin actions require there to + be a course context to be set. This exception + should be raised if there is not a course + context set. + + If there is some other permission issue involving + the course context, then a AuthenticationError + may be more appropriate. + """ + + def get_user(netid: Union[str, None]) -> Union[User, None]: """ Load a user by username @@ -93,14 +119,54 @@ def create_token(netid: str, **extras) -> Union[str, None]: return None # Create new token - return jwt.encode( - { - "netid": user.netid, - "exp": datetime.utcnow() + timedelta(hours=6), - **extras, - }, - config.SECRET_KEY, - ) + return jwt.encode({ + "netid": user.netid, + "exp": datetime.utcnow() + timedelta(hours=6), + **extras, + }, config.SECRET_KEY) + + +def _course_context_wrapper(function): + """ + Wrap a view function or view decorator with a + LackCourseContext handler. This should be applied + to the admin require decorators. + + :param function: + :return: + """ + + @functools.wraps(function) + def wrapper(*args, **kwargs): + try: + return function(*args, **kwargs) + except LackCourseContext as e: + logger.error(traceback.format_exc()) + return error_response(str(e) or 'Please set your course context') + + return wrapper + + +def _auth_context_wrapper(function): + """ + Wrap a view function or decorator with an + AuthenticationError handler. It will handle + the exception with a 401. This should be + applied to all the require decorators. + + :param function: + :return: + """ + + @functools.wraps(function) + def wrapper(*args, **kwargs): + try: + return function(*args, **kwargs) + except AuthenticationError as e: + logger.error(traceback.format_exc()) + return error_response(str(e) or 'Unauthenticated'), 401 + + return wrapper def require_user(unless_debug=False): @@ -115,6 +181,8 @@ def require_user(unless_debug=False): def decorator(func): @wraps(func) + @_auth_context_wrapper + @_course_context_wrapper def wrapper(*args, **kwargs): # Get the user in the current # request context. @@ -153,6 +221,8 @@ def require_admin(unless_debug=False, unless_vpn=False): def decorator(func): @wraps(func) + @_auth_context_wrapper + @_course_context_wrapper def wrapper(*args, **kwargs): # Get the user in the current # request context. @@ -170,8 +240,19 @@ def wrapper(*args, **kwargs): # Check that there is a user specified # in the current request context, and # that use is an admin. - if user is None or user.is_admin is False: - return error_response("Unauthenticated"), 401 + if user is None: + raise AuthenticationError('Request is anonymous') + + if user.is_superuser: + return func(*args, **kwargs) + + ta = TAForCourse.query.filter( + TAForCourse.owner_id == user.id).first() + prof = ProfessorForCourse.query.filter( + ProfessorForCourse.owner_id == user.id).first() + + if ta is None and prof is None: + raise AuthenticationError('User is not ta or professor') # Pass the parameters to the # decorated function. @@ -195,6 +276,8 @@ def require_superuser(unless_debug=False, unless_vpn=False): def decorator(func): @wraps(func) + @_auth_context_wrapper + @_course_context_wrapper def wrapper(*args, **kwargs): # Get the user in the current # request context. @@ -212,9 +295,14 @@ def wrapper(*args, **kwargs): # Check that there is a user specified # in the current request context, and # that use is a superuser. - if user is None or user.is_superuser is False: + if user is None: return error_response("Unauthenticated"), 401 + # If the user is not a superuser, then return a 400 error + # so it will be displayed in a snackbar. + if user.is_superuser is False: + return error_response("Requires superuser") + # Pass the parameters to the # decorated function. return func(*args, **kwargs) diff --git a/api/anubis/utils/data.py b/api/anubis/utils/data.py index 9670a5764..bac445e21 100644 --- a/api/anubis/utils/data.py +++ b/api/anubis/utils/data.py @@ -1,6 +1,7 @@ +import functools from datetime import datetime from email.mime.text import MIMEText -from hashlib import sha256 +from hashlib import sha512 from json import dumps from os import environ, urandom from smtplib import SMTP @@ -226,7 +227,7 @@ def rand(max_len: int = None): :param max_len: :return: """ - rand_hash = sha256(urandom(32)).hexdigest() + rand_hash = sha512(urandom(32)).hexdigest() if max_len is not None: return rand_hash[:max_len] return rand_hash @@ -259,19 +260,60 @@ def row2dict(row) -> dict: values. This function looks at internal sqlalchemy fields to create a raw dictionary from the columns in the table. + * Something to note is that datetime object fields will be + converted to strings in the response * + :param row: :return: """ raw = {} + # Read through the sqlalchemy internal + # column values. for column in row.__table__.columns: + + # Get the value corresponding to the + # name of the column. value = getattr(row, column.name) + # If the value is a datetime object, then + # we need to convert it to a string to + # maintain that the response is a simple + # dictionary. if isinstance(value, datetime): - raw[column.name] = str(value) - continue + value = str(value) + # Write the column value into the response + # dictionary. raw[column.name] = value return raw + + +def with_context(function): + """ + This decorator is meant to save time and repetitive initialization + when using flask-sqlalchemy outside of an app_context. + + :param function: + :return: + """ + + @functools.wraps(function) + def wrapper(*args, **kwargs): + + # Do the import here to avoid circular + # import issues. + from anubis.app import create_app + + # Create a fresh app + app = create_app() + + # Push an app context + with app.app_context(): + + # Call the function within an app context + return function(*args, **kwargs) + + return wrapper diff --git a/api/anubis/utils/decorators.py b/api/anubis/utils/http/decorators.py similarity index 52% rename from api/anubis/utils/decorators.py rename to api/anubis/utils/http/decorators.py index 18da6b257..702a26d7c 100644 --- a/api/anubis/utils/decorators.py +++ b/api/anubis/utils/http/decorators.py @@ -5,19 +5,48 @@ from flask import request from anubis.models import Submission -from anubis.utils.users.auth import current_user +from anubis.utils.auth import current_user, AuthenticationError from anubis.utils.data import jsonify, _verify_data_shape from anubis.utils.http.https import error_response -def load_from_id(model, verify_owner=True): +def load_from_id(model, verify_owner=False): + """ + This flask decorator loads the id kwarg passed in by flask + and uses it to pull the sqlalchemy object corresponding to that id + + >>> @app.route('/assignment/') + >>> @require_user + >>> @load_from_id(Assignment) + >>> def view_function(assignment: Assignment): + >>> pass + + If the verify_owner is true, then the sqlachemy object's owner + relationship (assuming it has one) will be checked against the + current logged in user. + + :param model: + :param verify_owner: + :return: + """ + def wrapper(func): @wraps(func) def decorator(id, *args, **kwargs): + # Use the id from the view functions params to query for + # the object. r = model.query.filter_by(id=id).first() - if r is None or (verify_owner and current_user().id != r.owner.id): - logging.info("Unauthenticated GET {}".format(request.path)) + + # If the sqlalchemy object was not found, then return a 400 + if r is None: return error_response("Unable to find"), 400 + + # If the verify_owner option is on, then + # check the object's owner against the currently + # logged in user. + if verify_owner and current_user().id != r.owner.id: + raise AuthenticationError() + return func(r, *args, **kwargs) return decorator @@ -54,59 +83,100 @@ def json_endpoint( only_required: bool = False, ): """ - Wrap a route so that it always converts data - response to proper json. - - @app.route('/') - @json - def test(): - return { - 'success': True - } + Wrap a route so that it always converts data response to proper + json. This decorator will save a whole lot of time verifying + json body data. + + The required fields should be a list of either strings or tuples. + + If the required fields is a list of strings, then each of the + strings will be verified in the json body, and passed to the + view function as a kwarg. + + >>> @app.route('/') + >>> @json_endpoint(['name')]) + >>> def test(name, **_): + >>> return { + >>> 'success': True + >>> } + + If the required fields are a list of tuples, then the first item + should be the string name of the field, then its type. When you + specify the type in a tuple, then that fields type will also + be verified in the json body. + + >>> @app.route('/') + >>> @json_endpoint([('name', str)]) + >>> def test(name: str, **_): + >>> return { + >>> 'success': True + >>> } """ def wrapper(func): @wraps(func) def json_wrap(*args, **kwargs): - if not request.headers.get("Content-Type", default=None).startswith( - "application/json" - ): - return { - "success": False, - "error": "Content-Type header is not application/json", - "data": None, - }, 406 # Not Acceptable - json_data: dict = request.json + # Get the content type header + content_type = request.headers.get("Content-Type", default="") + + # Verify that the content type header was application json. + # If the content type header is not application/json, then + # flask will not parse the body of the request. + if not content_type.startswith("application/json"): + + # If the content-type was not set properly, then we + # should hand back a 406 not acceptable error code. + return error_response("Content-Type header is not application/json"), 406 + + # After verifying that the content type header was set, + # then we can access the request json body + json_body: dict = request.json + + # Build a list of the required field string values + _required_fields: List[str] = [] + + # If the required fields was set, then we + # need to verify that they exist in the json + # body, along with type checks if they were + # specified. if required_fields is not None: # Check required fields for index, field in enumerate(required_fields): - # If field was a tuple, extract field name and required - # type + # If field was a tuple, extract field name and required type. required_type = None if isinstance(field, tuple): + + # If the tuple was more than two items, then + # we dont know how to handle. + if len(field) != 2: + pass + + # Pull the field apart into the field and required type field, required_type = field - required_fields[index] = field - # Make sure - if field not in json_data: + # At this point, the tuple will have been parsed if it had one, + # so the field will always be a string. Add it to the running + # (fresh) list of required field string objects. + _required_fields.append(field) + + # Make sure that the field is in the json body. + # If this condition is not met, then we will return + # a 406 not acceptable. + if field not in json_body: + # field missing, return error - return ( - error_response( - "Malformed requests. Missing field {}.".format(field) - ), - 406, - ) # Not Acceptable + # Not Acceptable + return error_response(f"Malformed requests. Missing field {field}."), 406 # If a type was specified, verify it if required_type is not None: - if not isinstance(json_data[field], required_type): - return ( - error_response( - "Malformed requests. Invalid field type." - ), - 406, - ) # Not Acceptable + + # Do a type check on the json body field + if not isinstance(json_body[field], required_type): + + # Not Acceptable + return error_response("Malformed requests. Invalid field type."), 406 # Give the positional args first, # then the json data (in the order of @@ -121,18 +191,21 @@ def json_wrap(*args, **kwargs): # as it will overwrite any keys already in the # kwargs with the values in the json. if not only_required: - for key, value in json_data.items(): - if key not in required_fields: + for key, value in json_body.items(): + if key not in _required_fields: kwargs[key] = value # Call the function while trying to maintain a # logical order to the arguments return func( *args, - **{field: json_data[field] for field in required_fields}, + **{field: json_body[field] for field in _required_fields}, **kwargs, ) - return func(json_data, *args, **kwargs) + + # If there was no required fields specified, then we can just call the + # view function with the first argument being the json body. + return func(json_body, *args, **kwargs) return json_wrap @@ -156,11 +229,15 @@ def check_submission_token(func): @wraps(func) def wrapper(submission_id: str): + # Try to get the submission submission = Submission.query.filter(Submission.id == submission_id).first() + + # Try to get a token from the request query token = request.args.get("token", default=None) # Verify submission exists if submission is None: + # Log that there was an issue with finding the submission logging.error( "Invalid submission from submission pipeline", extra={ @@ -170,10 +247,13 @@ def wrapper(submission_id: str): "ip": request.remote_addr, }, ) + + # Give back a 406 rejected error return error_response("Invalid"), 406 # Verify we got a token if token is None: + # Log that there was an issue with finding a token logging.error( "Attempted report post with no token", extra={ @@ -183,10 +263,13 @@ def wrapper(submission_id: str): "ip": request.remote_addr, }, ) + + # Give back a 406 rejected error return error_response("Invalid"), 406 # Verify token matches if token != submission.token: + # Log that there was an issue verifying tokens logging.error( "Invalid token reported from pipeline", extra={ @@ -196,9 +279,15 @@ def wrapper(submission_id: str): "ip": request.remote_addr, }, ) + + # Give back a 406 rejected error return error_response("Invalid"), 406 + # Log that the request was validated logging.info("Pipeline request validated {}".format(request.path)) + + # Call the view function, and pass the + # submission sqlalchemy object. return func(submission) return wrapper diff --git a/api/anubis/utils/http/files.py b/api/anubis/utils/http/files.py index 5bd2f6be8..672656a9e 100644 --- a/api/anubis/utils/http/files.py +++ b/api/anubis/utils/http/files.py @@ -5,11 +5,30 @@ def get_mime_type(blob: bytes) -> str: + """ + Get an approximate content type for a given blob. This + is very useful for getting the content-type of an + uploaded file. + + * This function uses libmagic, which requires some + extra .so files outside of the python install * + + :param blob: + :return: + """ m = magic.Magic(mime=True) + return m.from_buffer(blob) def make_blob_response(file: StaticFile) -> Response: + """ + Take a static file object, and form a flask response, + having the correct bytes content and content-type header. + + :param file: + :return: + """ # Make a flask response from file data blob response = make_response(file.blob) diff --git a/api/anubis/utils/http/https.py b/api/anubis/utils/http/https.py index fabd63fe4..b9fc268c5 100644 --- a/api/anubis/utils/http/https.py +++ b/api/anubis/utils/http/https.py @@ -64,11 +64,40 @@ def success_response(data: Union[dict, str, None]) -> dict: } -def get_number_arg(arg_name: str = "number", default_value: int = 10) -> int: - n = request.args.get(arg_name, default=default_value) +def get_number_arg(arg_name: str = "number", default_value: int = 10, reject_negative: bool = True) -> int: + """ + Quick function for getting a number http query argument. If the + argument is not found, or cannot be converted to an int + then use a fallback default value. + + Optionally if reject_negative is True, then it will fallback + to the default value if the user specified is negative. + + :param arg_name: + :param default_value: + :param reject_negative: + :return: + """ + + # Get the query argument in string form + n_str: str = request.args.get(arg_name, default=default_value) + try: - return int(n) + # Attempt to convert to a python int + n: int = int(n_str) + + # If reject_negative, then check if + # the parsed value is negative. + if reject_negative and n < 0: + # If it was negative, then fallback to default + return default_value + + # Return integer value + return n except ValueError: + # ValueError is raised if the string to int + # conversion was unsuccessful. In that case, + # then we fallback to default return default_value diff --git a/api/__init__.py b/api/anubis/utils/lms/__init__.py similarity index 100% rename from api/__init__.py rename to api/anubis/utils/lms/__init__.py diff --git a/api/anubis/utils/assignment/assignments.py b/api/anubis/utils/lms/assignments.py similarity index 66% rename from api/anubis/utils/assignment/assignments.py rename to api/anubis/utils/lms/assignments.py index 76e29393f..0a50c98fe 100644 --- a/api/anubis/utils/assignment/assignments.py +++ b/api/anubis/utils/lms/assignments.py @@ -16,11 +16,13 @@ AssignmentRepo, SubmissionTestResult, ) -from anubis.utils.users.auth import get_user -from anubis.utils.services.cache import cache +from anubis.utils.auth import get_user from anubis.utils.data import is_debug +from anubis.utils.lms.course import assert_course_admin +from anubis.utils.lms.course import is_course_admin +from anubis.utils.lms.questions import ingest_questions +from anubis.utils.services.cache import cache from anubis.utils.services.logger import logger -from anubis.utils.assignment.questions import ingest_questions @cache.memoize(timeout=5, unless=is_debug) @@ -38,7 +40,7 @@ def get_courses(netid: str): return [c.data for c in classes] -@cache.memoize(timeout=5, unless=is_debug) +@cache.memoize(timeout=10, unless=is_debug) def get_assignments(netid: str, course_id=None) -> Union[List[Dict[str, str]], None]: """ Get all the current assignments for a netid. Optionally specify a class_name @@ -55,42 +57,69 @@ def get_assignments(netid: str, course_id=None) -> Union[List[Dict[str, str]], N if user is None: return None - filters = [] - if course_id is not None: - filters.append(Course.id == course_id) - - # Only hide assignments if user is not admin - if not (user.is_admin or user.is_superuser): - filters.append(Assignment.release_date <= datetime.now()) - filters.append(Assignment.hidden == False) - - assignments = Assignment.query \ - .join(Course).join(InCourse).join(User).filter( - User.netid == netid, - *filters - ).order_by(Assignment.due_date.desc()).all() - - a = [a.data for a in assignments] - for assignment_data in a: + # Get all the courses the user is in + in_courses = InCourse.query.join(Course).filter( + InCourse.owner_id == user.id, + ).all() + + # Build a list of course ids. If the user + # specified a specific course, make a list + # of only that course id. + course_ids = [course_id] \ + if course_id is not None \ + else [in_course.course.id for in_course in in_courses] + + # Build a list of all the assignments visible + # to this user for each of the specified courses. + assignments: List[Assignment] = [] + for course in course_ids: + # Query filters + filters = [] + + # If the current user is not a course admin or a superuser, then + # we should filter out assignments that have not been released, + # and those marked as hidden. + if not (user.is_superuser or is_course_admin(course_id)): + filters.append(Assignment.release_date <= datetime.now()) + filters.append(Assignment.hidden == False) + + # Get the assignment objects that should be visible to this user. + course_assignments = Assignment.query.join(Course).filter( + Course.id == course, + *filters, + ).all() + + # Add all the assignment objects to the running list + assignments.extend(course_assignments) + + # Take all the sqlalchemy assignment objects, + # and break them into data dictionaries. + # Sort them by due_date. + response = [_a.data for _a in sorted( + assignments, + reverse=True, + key=lambda assignment: assignment.due_date, + )] + + # Add submission and repo information to the assignments + for assignment_data in response: + # If the current user has a submission for this assignment, then mark it assignment_data["has_submission"] = ( - Submission.query.join(User) - .join(Assignment) - .filter( - Assignment.id == assignment_data["id"], - User.netid == netid, - ) - .first() - is not None + Submission.query.join(User).join(Assignment).filter( + Assignment.id == assignment_data["id"], + User.netid == netid, + ).first() is not None ) + + # If the current user has a repo for this assignment, then mark it assignment_data["has_repo"] = ( - AssignmentRepo.query.filter( - AssignmentRepo.owner_id == user.id, - AssignmentRepo.assignment_id == assignment_data['id'], - ).first() - is not None + AssignmentRepo.query.filter( + AssignmentRepo.owner_id == user.id, + AssignmentRepo.assignment_id == assignment_data['id'], + ).first() is not None ) - return a + return response @cache.memoize(timeout=3, unless=is_debug) @@ -151,18 +180,26 @@ def assignment_sync(assignment_data: dict) -> Tuple[Union[dict, str], bool]: ).first() # Attempt to find the class - c = Course.query.filter( + course_name = assignment_data.get('class', None) or assignment_data.get('course', None) + c: Course = Course.query.filter( or_( - Course.name == assignment_data["class"], - Course.course_code == assignment_data["class"], + Course.name == course_name, + Course.course_code == course_name, ) ).first() if c is None: return "Unable to find class", False + assert_course_admin(c.id) + # Check if it exists if assignment is None: - assignment = Assignment(unique_code=assignment_data["unique_code"]) + assignment = Assignment( + theia_image=c.theia_default_image, + theia_options=c.theia_default_options, + unique_code=assignment_data["unique_code"], + course=c, + ) # Update fields assignment.name = assignment_data["name"] @@ -170,7 +207,6 @@ def assignment_sync(assignment_data: dict) -> Tuple[Union[dict, str], bool]: assignment.description = assignment_data["description"] assignment.pipeline_image = assignment_data["pipeline_image"] assignment.github_classroom_url = assignment_data["github_classroom_url"] - assignment.course = c try: assignment.release_date = date_parse(assignment_data["date"]["release"]) assignment.due_date = date_parse(assignment_data["date"]["due"]) diff --git a/api/anubis/utils/assignment/autograde.py b/api/anubis/utils/lms/autograde.py similarity index 96% rename from api/anubis/utils/assignment/autograde.py rename to api/anubis/utils/lms/autograde.py index 8f10bb320..526d99e64 100644 --- a/api/anubis/utils/assignment/autograde.py +++ b/api/anubis/utils/lms/autograde.py @@ -1,10 +1,10 @@ from parse import parse from anubis.models import Submission, Assignment -from anubis.utils.services.cache import cache from anubis.utils.data import is_debug from anubis.utils.http.https import error_response -from anubis.utils.users.students import get_students_in_class +from anubis.utils.lms.students import get_students_in_class +from anubis.utils.services.cache import cache @cache.memoize(timeout=5 * 60, unless=is_debug) @@ -57,7 +57,7 @@ def autograde(student_id, assignment_id): return best.id if best is not None else None -def stats_wrapper(assignment: Assignment, user_id: str, netid: str, name: str, submission_id: str) -> dict: +def autograde_submission_result_wrapper(assignment: Assignment, user_id: str, netid: str, name: str, submission_id: str) -> dict: """ The autograde results require quite of bit more information than just the id of the best submission. This function takes some high level @@ -158,7 +158,7 @@ def bulk_autograde(assignment_id, netids=None, offset=0, limit=20): bests.append( # Use the stats_wrapper function to add all the necessary # metadata for the submission. - stats_wrapper( + autograde_submission_result_wrapper( assignment, student["id"], student["netid"], diff --git a/api/anubis/utils/lms/course.py b/api/anubis/utils/lms/course.py new file mode 100644 index 000000000..e8566159a --- /dev/null +++ b/api/anubis/utils/lms/course.py @@ -0,0 +1,282 @@ +import base64 +import json +import traceback +import urllib.parse +from typing import Union, Tuple, Any + +from flask import request + +from anubis.utils.auth import LackCourseContext, current_user, AuthenticationError +from anubis.utils.services.logger import logger +from anubis.models import ( + Course, + TAForCourse, + ProfessorForCourse, + Assignment, + AssignmentRepo, + AssignedStudentQuestion, + AssignmentQuestion, + AssignmentTest, + Submission, + TheiaSession, + StaticFile, + User, + InCourse, +) + + +def get_course_context(full_stop: bool = True) -> Union[None, Course]: + """ + Get the course context for the current admin user. On the anubis website + when a user is an admin, they have a context autocomplete at the top + with all the courses they are admins for. When they select a course, + it sets a course context cookie. + + This function pulls that information out of the cookie, verifies + that the user is truly an admin for that course, then returns the + corresponding Course object. + + The full_stop option is there to save time. When it is set, + any discrepancy found (like say if the current user is not + an admin for the course they have set) then a LackCourseContext + exception will be raised. When this happens, there is a high + level wrapper that handles returns a 400 to the user saying + they lack the proper course context / privileges. + + :param full_stop: + :return: + """ + + def _get_course_context(): + """ + Putting this in a separate function made handling the + full_stop option slightly easier. + + :return: + """ + + # Get the raw course cookie string + course_raw = request.cookies.get('course', default=None) + + # If there is no cookie set, then return None + if course_raw is None: + return None + + # Attempt to urllib unquote, base64 decode, then json loads + # the raw cookie. There is a lot that can go wrong here, so + # just handle any exceptions. + try: + course_data = json.loads(base64.urlsafe_b64decode( + urllib.parse.unquote(course_raw).encode() + )) + except Exception as e: + # Print the exception traceback + logger.error(traceback.format_exc()) + logger.error(str(e)) + return None + + # Get the course id from the loaded course data + course_id = course_data.get('id', None) + + # If there was no id in the course data, then return None + if course_id is None: + return None + + # Verify that the current user is actually an admin for + # this course. + if not is_course_admin(course_id): + raise AuthenticationError() + + # Get the course object + course = Course.query.filter( + Course.id == course_id, + ).first() + + # And return it + return course + + # Get the current course context + context = _get_course_context() + + # If full_stop is on, then verify we got a valid context, + # or raise a LackCourseContext. + if context is None and full_stop: + raise LackCourseContext() + + # Return the course context + return context + + +def is_course_superuser(course_id: str) -> bool: + """ + Use this function to verify if the current user is a superuser for + the specified course_id. A user is a superuser for a course if they are + a professor, or if they are a superuser. + + :param course_id: + :return: + """ + # Get the current user + user = current_user() + + # If they are a superuser, then we can just return True + if user.is_superuser: + return True + + # Check to see if they are a professor for the current course + prof = ProfessorForCourse.query.filter( + ProfessorForCourse.owner_id == user.id, + TAForCourse.course_id == course_id, + ).first() + + # Return True if they are a professor for the course + return prof is not None + + +def is_course_admin(course_id: str) -> bool: + """ + Use this function to verify if the current user is an admin for + the specified course_id. A user is an admin for a course if they are + a ta, professor, or if they are a superuser. + + :param course_id: + :return: + """ + # Get the current user + user = current_user() + + # If they are a superuser, then just return True + if user.is_superuser: + return True + + # Check to see if they are a TA for the course + ta = TAForCourse.query.filter( + TAForCourse.owner_id == user.id, + TAForCourse.course_id == course_id, + ).first() + + # Check to see if they are a professor for the course + prof = ProfessorForCourse.query.filter( + ProfessorForCourse.owner_id == user.id, + TAForCourse.course_id == course_id, + ).first() + + # Return True if they are either a ta or professor + return ta is not None or prof is not None + + +def assert_course_admin(course_id: str = None): + """ + Use this function to assert that the current user is + an admin for the specified course. If they are not, then + an authentication error will be raised. + + The AuthenticationError will be handled in a high level + flask view wrapper that will return whatever string we + put as the message for the authentication error. + + :param course_id: + :return: + """ + if not is_course_admin(course_id): + raise LackCourseContext('Requires course TA permissions') + + +def assert_course_superuser(course_id: str = None): + """ + Use this function to assert that the current user is + a superuser for the specified course. If they are not, then + an authentication error will be raised. + + The AuthenticationError will be handled in a high level + flask view wrapper that will return whatever string we + put as the message for the authentication error. + + :param course_id: + :return: + """ + if not is_course_superuser(course_id): + raise LackCourseContext('Requires course Professor permissions') + + +def assert_course_context(*models: Tuple[Any]): + """ + This function checks that all the sqlalchemy objects that are + passed to this function are within the current course context. + If they are not, then a LackCourseContext will be raised. + + :param models: + :return: + """ + + # Get the current course context + context = get_course_context() + + # Create a stack of objects to check + object_stack = list(models) + + # This next bit of code definitely deserves an explanation. Most + # all the sqlalchemy models have some relationship or backref + # that leads to a course object. What I am doing here is using a + # stack to continuously access the next backref or relationship + # until we get to a course object. + # + # Since the majority of models have either an .assignment or + # .course backref, we can simplify things a bit by iterating + # over all the model types that have the same name for the next + # relationship or backref to access. + + # Iterate until the stack is empty + while len(object_stack) != 0: + model = object_stack.pop() + + # If the model is a course, then we can actually do the context check + if isinstance(model, Course): + + # Check that the current user is an admin for the course object + if not is_course_admin(model.id): + raise LackCourseContext('You cannot edit resources in this course context') + + # Verify that the course object is the same as the set context + if model.id != context.id: + raise LackCourseContext('Cannot view or edit resource outside course context') + + # Group together all the models that have an + # assignment backref or relationship + for model_type in [ + Submission, + AssignmentTest, + AssignmentRepo, + AssignedStudentQuestion, + AssignmentQuestion, + ]: + if isinstance(model, model_type): + object_stack.append(model.assignment) + continue + + # Group together all the models that have a course + # backref or relationship + for model_type in [ + Assignment, + StaticFile, + TheiaSession, + ]: + if isinstance(model, model_type): + object_stack.append(model.course) + continue + + # --------------------------------- + # If the model is a user then we need to handle it a + # little differently. We'll just need to verify that + # the student is in the course. + if isinstance(model, User): + + # Get course student is in + in_course = InCourse.query.filter( + InCourse.owner_id == model.id, + InCourse.course_id == context.id, + ).first() + + # Verify that they are in the course + if in_course is None: + raise LackCourseContext('Student is not within this course context') diff --git a/api/anubis/utils/assignment/questions.py b/api/anubis/utils/lms/questions.py similarity index 94% rename from api/anubis/utils/assignment/questions.py rename to api/anubis/utils/lms/questions.py index 51d4b9a8d..b0e36d404 100644 --- a/api/anubis/utils/assignment/questions.py +++ b/api/anubis/utils/lms/questions.py @@ -9,8 +9,8 @@ User, InCourse, ) -from anubis.utils.services.cache import cache from anubis.utils.data import _verify_data_shape, is_debug +from anubis.utils.services.cache import cache def get_question_sequence_mapping( @@ -222,8 +222,21 @@ def get_all_questions(assignment: Assignment) -> Dict[int, List[Dict[str, str]]] } -@cache.memoize(timeout=1, unless=is_debug) -def get_assigned_questions(assignment_id: str, user_id: str, full=False): +@cache.memoize(timeout=5, unless=is_debug) +def get_assigned_questions(assignment_id: str, user_id: str, full: bool = False): + """ + Get the assigned question objects for a user_id and assignment. + + If the full option is on, then a full view (including solutions) will be returned + + * The results are lightly cached * + + :param assignment_id: + :param user_id: + :param full: + :return: + """ + # Get assigned questions assigned_questions = AssignedStudentQuestion.query.filter( AssignedStudentQuestion.assignment_id == assignment_id, diff --git a/api/anubis/utils/users/students.py b/api/anubis/utils/lms/students.py similarity index 88% rename from api/anubis/utils/users/students.py rename to api/anubis/utils/lms/students.py index 51b7027bd..64f875ec6 100644 --- a/api/anubis/utils/users/students.py +++ b/api/anubis/utils/lms/students.py @@ -1,12 +1,12 @@ from typing import List, Dict from anubis.models import User, InCourse, Course -from anubis.utils.services.cache import cache from anubis.utils.data import is_debug +from anubis.utils.services.cache import cache @cache.memoize(timeout=60, unless=is_debug) -def get_students(course_code: str = None) -> List[Dict[str, dict]]: +def get_students(course_id: str = None, course_code: str = None) -> List[Dict[str, dict]]: """ Get students by course code. If no course code is specified, then all courses will be considered. @@ -14,6 +14,7 @@ def get_students(course_code: str = None) -> List[Dict[str, dict]]: * This response is cached for up to 60 seconds * :param course_code: + :param course_id: :return: """ @@ -22,7 +23,10 @@ def get_students(course_code: str = None) -> List[Dict[str, dict]]: # If a course code is specified, then add it to the filter if course_code is not None: - filters = [Course.course_code == course_code] + filters.append(Course.course_code == course_code) + + if course_id is not None: + filters.append(Course.id == course_id) # Get all users, and break them into their data props return [ diff --git a/api/anubis/utils/assignment/submissions.py b/api/anubis/utils/lms/submissions.py similarity index 100% rename from api/anubis/utils/assignment/submissions.py rename to api/anubis/utils/lms/submissions.py diff --git a/api/anubis/utils/assignment/webhook.py b/api/anubis/utils/lms/webhook.py similarity index 96% rename from api/anubis/utils/assignment/webhook.py rename to api/anubis/utils/lms/webhook.py index 8c1d5ffa3..a603a8687 100644 --- a/api/anubis/utils/assignment/webhook.py +++ b/api/anubis/utils/lms/webhook.py @@ -2,6 +2,13 @@ def parse_webhook(webhook): + """ + Parse out some of the important basics of any webhook. + + :param webhook: + :return: + """ + # Load the basics from the webhook repo_url = webhook["repository"]["url"] repo_name = webhook["repository"]["name"] @@ -73,7 +80,7 @@ def check_repo(assignment, repo_url, github_username, user=None) -> AssignmentRe AssignmentRepo.assignment_id == assignment.id, AssignmentRepo.owner == user, ).first() - + # If the user is None, then the submission is # dangling. else: diff --git a/api/anubis/utils/seed.py b/api/anubis/utils/seed.py new file mode 100644 index 000000000..6f7b63e77 --- /dev/null +++ b/api/anubis/utils/seed.py @@ -0,0 +1,65 @@ +import random +import string + +names = ["Joette", "Anabelle", "Fred", "Woodrow", "Neoma", "Dorian", "Treasure", "Tami", "Berdie", "Jordi", "Frances", + "Gerhardt", "Kristina", "Carmelita", "Sim", "Hideo", "Arland", "Wirt", "Robt", "Narcissus", "Steve", "Monique", + "Kellen", "Jessenia", "Nathalia", "Lissie", "Loriann", "Theresa", "Pranav", "Eppie", "Angelic", "Louvenia", + "Mathews", "Natalie", "Susan", "Cyril", "Vester", "Rakeem", "Duff", "Garret", "Agnes", "Carol", "Pairlee", + "Viridiana", "Keith", "Elinore", "Rico", "Demonte", "Imelda", "Jackeline", "Kenneth", "Adalynn", "Blair", + "Stetson", "Adamaris", "Zaniyah", "Heyward", "Austin", "Elden", "Gregory", "Lemuel", "Aaliyah", "Abby", + "Hassie", "Sanjuanita", "Takisha", "Orlo", "Geary", "Bettye", "Luciano", "Gretchen", "Chimere", "Melanie", + "Angele", "Michial", "Emmons", "Edmund", "Renae", "Letha", "Curtiss", "Boris", "Winter", "Nealy", "Renard", + "Taliyah", "Jaren", "Nilda", "Tiny", "Manila", "Mariann", "Dennis", "Autumn", "Aron", "Drew", "Shea", "Britt", + "Luvenia", "Doloris", "Bret", "Sammy", "Elmer", "Florencio", "Selah", "Simona", "Tatyana", "Beau", "Alvin", + "Leslie", "Kimberely", "Sydni", "Mitchel", "Belle", "Brain", "Marlin", "Vallie", "Colon", "Hoyt", "Destinee", + "Shamar", "Ezzard", "Sheilah", "Leisa", "Tennille", "Brandyn", "Yasmin", "Malaya", "Larry", "Mina", "Myrle", + "Blaine", "Gusta", "Beryl", "Abdul", "Cleda", "Lailah", "Alexandrea", "Unknown", "Gertrude", "Davon", "Minda", + "Gabe", "Myles", "Vonda", "Zandra", "Salome", "Minnie", "Merl", "Biddie", "Catina", "Cassidy", "Norman", + "Emilia", "Fanny", "Nancie", "Domingo", "Christa", "Severt", "Danita", "Jennie", "Anaya", "Michelle", + "Brittnie", "Althea", "Kimberlee", "Ursula", "Ballard", "Silvester", "Ilda", "Rock", "Tyler", "Hildegarde", + "Aurelio", "Lovell", "Neha", "Jeramiah", "Kristin", "Kelis", "Adolf", "Elwood", "Almus", "Geo", "Machelle", + "Arnulfo", "Love", "Lollie", "Bobbye", "Columbus", "Susie", "Reta", "Krysten", "Sunny", "Alzina", "Carolyne", + "Laurine", "Jayla", "Halbert", "Grayce", "Alvie", "Haylee", "Hosea", "Alvira", "Pallie", "Marylin", "Elise", + "Lidie", "Vita", "Jakob", "Elmira", "Oliver", "Arra", "Debbra", "Migdalia", "Lucas", "Verle", "Dellar", + "Madaline", "Iverson", "Lorin", "Easter", "Britta", "Kody", "Colie", "Chaz", "Glover", "Nickolas", "Francisca", + "Donavan", "Merlene", "Belia", "Laila", "Nikhil", "Burdette", "Mildred", "Malissa", "Del", "Reagan", "Loney", + "Lambert", "Ellen", "Sydell", "Juanita", "Alphonsus", "Gianna", "William", "Oneal", "Anya", "Luis", "Shad", + "Armin", "Marvin"] + + +def create_name() -> str: + """ + Get a randomly generated first and last + name from the list of names. + + :return: + """ + return f"{random.choice(names)} {random.choice(names)}" + + +def create_netid(name: str) -> str: + """ + Generate a netid from a provided name. This will + pull the initials from the name and append some + numbers. + + :param name: + :return: + """ + initials = "".join(word[0].lower() for word in name.split()) + numbers = "".join(random.choice(string.digits) for _ in range(3)) + + return f"{initials}{numbers}" + + +def rand_commit(n=40) -> str: + """ + Generate a random commit hash. The commit length will + be 40 characters. + + :param n: + :return: + """ + from anubis.utils.data import rand + + return rand(n) diff --git a/api/anubis/utils/services/cache.py b/api/anubis/utils/services/cache.py index 1f32a359e..ffecdf2d3 100644 --- a/api/anubis/utils/services/cache.py +++ b/api/anubis/utils/services/cache.py @@ -5,4 +5,11 @@ @cache.memoize(timeout=1) def cache_health(): + """ + This simple function is used to do health checks + on the cache (redis specifically). If this function + fails, then redis is probably failing. + + :return: + """ return None diff --git a/api/anubis/utils/services/elastic.py b/api/anubis/utils/services/elastic.py index 0d25ab2e7..3a582eed5 100644 --- a/api/anubis/utils/services/elastic.py +++ b/api/anubis/utils/services/elastic.py @@ -24,8 +24,9 @@ def log_endpoint(log_type, message_func=None): def some_function(arg1, arg2): .... - :log_type str: log type to noted in event - :message_func callable: function to return message to be logged + :param log_type: log type to noted in event + :param message_func: callable function to return message to be logged + :return: """ def decorator(function): @@ -66,11 +67,12 @@ def wrapper(*args, **kwargs): return decorator -def esindex(index="error", **kwargs): +def esindex(index: str = "error", **kwargs): """ Index anything with elasticsearch - :kwargs dict: + :param index: + :param kwargs: dict """ if config.DISABLE_ELK: return @@ -78,23 +80,39 @@ def esindex(index="error", **kwargs): def add_global_error_handler(app): + """ + This function adds a global error handler to the flask + app. There are a few reasons why this may not always be the + best idea. Some exceptions raised should be handled by flask (for + example: 404 not found exceptions). + + :param app: + :return: + """ + @app.errorhandler(Exception) def global_err_handler(error): - tb = traceback.format_exc() # get traceback string - logger.error( - tb, - extra={ - "from": "global-error-handler", - "traceback": tb, - "ip": get_request_ip(), - "method": request.method, - "path": request.path, - "query": json.dumps(dict(list(request.args.items()))), - "headers": json.dumps(dict(list(request.headers.items()))), - }, - ) + # get traceback string + tb = traceback.format_exc() + + # Log the traceback string through logstash, with extra information + logger.error(tb, extra={ + "from": "global-error-handler", + "traceback": tb, + "ip": get_request_ip(), + "method": request.method, + "path": request.path, + "query": json.dumps(dict(list(request.args.items()))), + "headers": json.dumps(dict(list(request.headers.items()))), + }) + + # Handle the error if it is a 404 if isinstance(error, exceptions.NotFound): return "", 404 + + # If it is a method not allowed, return a 405 if isinstance(error, exceptions.MethodNotAllowed): return "MethodNotAllowed", 405 + + # Else return a vague 500 return "err", 500 diff --git a/api/anubis/utils/services/logger.py b/api/anubis/utils/services/logger.py index 1c31d13d5..5d23635db 100644 --- a/api/anubis/utils/services/logger.py +++ b/api/anubis/utils/services/logger.py @@ -5,14 +5,33 @@ from anubis.config import config -def get_logger(logger_name): - logger = logging.getLogger(logger_name) - logger.setLevel(logging.DEBUG) - +def _get_logger(logger_name): + """ + For any of the anubis services that use the API, the + logger name should be specified so that all the logs + that are thrown at logstash, then elasticsearch + can be tracked back to a specific service. + + :param logger_name: + :return: + """ + from anubis.utils.data import is_debug + + # Initialize a logger for this app with the logger + # name specified + _logger = logging.getLogger(logger_name) + + # Set the log level to debug if in debug mode + if is_debug(): + _logger.setLevel(logging.DEBUG) + + # If the disable ELK option is not enabled, then + # add the logstash udp logging layer. if not config.DISABLE_ELK: - logger.addHandler(logstash.LogstashHandler("logstash", 5000)) + _logger.addHandler(logstash.LogstashHandler("logstash", 5000)) - return logger + return _logger -logger = get_logger(config.LOGGER_NAME) +# Create a single logger object for the current app instance +logger = _get_logger(config.LOGGER_NAME) diff --git a/api/anubis/utils/services/rpc.py b/api/anubis/utils/services/rpc.py index 3e17ca768..fa17ae6e8 100644 --- a/api/anubis/utils/services/rpc.py +++ b/api/anubis/utils/services/rpc.py @@ -3,7 +3,7 @@ from anubis.config import config from anubis.rpc.pipeline import create_submission_pipeline -from anubis.rpc.seed import seed_debug +from anubis.rpc.seed import seed from anubis.rpc.theia import ( initialize_theia_session, reap_theia_session, @@ -51,7 +51,7 @@ def enqueue_ide_reap_stale(*args): def enqueue_seed(): """Enqueue debug seed data""" - rpc_enqueue(seed_debug, queue='default') + rpc_enqueue(seed, queue='default') def enqueue_create_visuals(*_): diff --git a/api/anubis/utils/services/theia.py b/api/anubis/utils/services/theia.py index 2eea2e609..3b1fa67ba 100644 --- a/api/anubis/utils/services/theia.py +++ b/api/anubis/utils/services/theia.py @@ -4,9 +4,9 @@ from anubis.config import config from anubis.models import TheiaSession, User, Config -from anubis.utils.users.auth import create_token -from anubis.utils.services.cache import cache +from anubis.utils.auth import create_token from anubis.utils.data import is_debug +from anubis.utils.services.cache import cache @cache.memoize(timeout=5, unless=is_debug) diff --git a/api/anubis/utils/users/__init__.py b/api/anubis/utils/users/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/api/anubis/utils/visuals/assignments.py b/api/anubis/utils/visuals/assignments.py index f0b7aeb14..b90109a50 100644 --- a/api/anubis/utils/visuals/assignments.py +++ b/api/anubis/utils/visuals/assignments.py @@ -1,7 +1,9 @@ import numpy as np import pandas as pd +from typing import List, Any, Union, Dict from anubis.models import db, AssignmentTest +from anubis.utils.data import is_debug from anubis.utils.services.cache import cache from anubis.utils.visuals.queries import ( time_to_pass_test_sql, @@ -9,15 +11,24 @@ assignment_test_fail_count_sql, assignment_test_pass_count_sql, ) -from anubis.utils.data import is_debug @cache.memoize(timeout=60, unless=is_debug) -def get_assignment_visual_data(assignment_id: str): +def get_admin_assignment_visual_data(assignment_id: str) -> List[Dict[str, Any]]: + """ + Get the admin visual data for an assignment. Visual data is generated + for each assignment test that is part of the assignment. + + :param assignment_id: + :return: + """ + + # Get all the assignment tests for the specified assignment assignment_tests = AssignmentTest.query.filter( AssignmentTest.assignment_id == assignment_id ).all() + # Build a list of visual data for each assignment test response = [] for assignment_test in assignment_tests: response.append({ @@ -30,25 +41,55 @@ def get_assignment_visual_data(assignment_id: str): def get_assignment_tests_pass_times(assignment_test: AssignmentTest): + """ + Calculate the amount of time it took each student in the class + to get their test to pass. This is measured as the time between + their first submission for the assignment and the first submission + that passed the specific test. + + :param assignment_test: + :return: + """ + + # Run the very long query to generate the list of netids and timedetlas result = db.session.execute(time_to_pass_test_sql, { 'assignment_id': assignment_test.assignment_id, 'assignment_test_id': assignment_test.id, }) - data = pd.DataFrame( + + # Build a dataframe of the durations, converted to hours + df = pd.DataFrame( data=[x[2].total_seconds() // 3600 for x in result.fetchall()], columns=['duration'], ) - # data = data[data['duration'] != 0] - data = data[np.abs(data.duration - data.duration.mean()) <= (3 * data.duration.std())] \ + + # Drop outlier values (> 3 sigma) + df = df[np.abs(df.duration - df.duration.mean()) <= (3 * df.duration.std())] \ .value_counts().to_dict() + # Return the x and y plot data for the scatter visual return [ {'x': np.abs(x[0]), 'y': y, 'size': 3} - for x, y in data.items() + for x, y in df.items() ] def get_assignment_tests_pass_counts(assignment_test: AssignmentTest): + """ + Get the number of students that had: + - no submission + - passed the test + - failed the test + for the specified test. + + The data from this function is turned into the pass counts + radial donut on the autograde page. + + :param assignment_test: + :return: + """ + + # Run a query to find the number of students with no submission result = db.session.execute(assignment_test_fail_nosub_sql, { 'assignment_id': assignment_test.assignment_id, 'assignment_test_id': assignment_test.id, @@ -56,6 +97,7 @@ def get_assignment_tests_pass_counts(assignment_test: AssignmentTest): n = result.fetchone()[0] nosub_count = int(n if n is not None else 0) + # Run a query to find the number of students that failed the test result = db.session.execute(assignment_test_fail_count_sql, { 'assignment_id': assignment_test.assignment_id, 'assignment_test_id': assignment_test.id, @@ -63,6 +105,7 @@ def get_assignment_tests_pass_counts(assignment_test: AssignmentTest): n = result.fetchone()[0] fail_count = int(n if n is not None else 0) + # Run a query to find the number of students that passed the test result = db.session.execute(assignment_test_pass_count_sql, { 'assignment_id': assignment_test.assignment_id, 'assignment_test_id': assignment_test.id, @@ -70,6 +113,7 @@ def get_assignment_tests_pass_counts(assignment_test: AssignmentTest): n = result.fetchone()[0] pass_count = int(n if n is not None else 0) + # Format the response to fit what the frontend is expecting return [ {'label': 'no submission', 'theta': nosub_count, 'color': 'grey'}, {'label': 'test failed', 'theta': fail_count, 'color': 'red'}, diff --git a/api/anubis/utils/visuals/usage.py b/api/anubis/utils/visuals/usage.py index e9ad98c83..2b9a5b494 100644 --- a/api/anubis/utils/visuals/usage.py +++ b/api/anubis/utils/visuals/usage.py @@ -1,17 +1,28 @@ -import numpy as np -import pandas as pd from datetime import datetime from io import BytesIO from typing import List, Dict, Any +import numpy as np +import pandas as pd + from anubis.models import Assignment, Submission, TheiaSession -from anubis.utils.services.cache import cache from anubis.utils.data import is_debug +from anubis.utils.services.cache import cache def get_submissions() -> pd.DataFrame: + """ + Get all submissions from visible assignments, and put them in a dataframe + + :return: + """ + # Get the submission sqlalchemy objects raw_submissions = Submission.query.join(Assignment).filter(Assignment.hidden == False).all() + + # Specify which columns we want columns = ['id', 'owner_id', 'assignment_id', 'processed', 'created'] + + # Build a dataframe of from the columns we pull out of each submission object submissions = pd.DataFrame( data=list(map(lambda x: ({ column: getattr(x, column) @@ -19,13 +30,27 @@ def get_submissions() -> pd.DataFrame: }), raw_submissions)), columns=columns ) + + # Round the submission timestamps to the nearest hour submissions['created'] = submissions['created'].apply(lambda date: pd.to_datetime(date).round('H')) + return submissions def get_theia_sessions() -> pd.DataFrame: + """ + Get all theia session objects, and throw them into a dataframe + + :return: + """ + + # Get all the theia session sqlalchemy objects raw_theia_sessions = TheiaSession.query.all() + + # Specify which columns we want columns = ['id', 'owner_id', 'assignment_id', 'created', 'ended'] + + # Build a dataframe of from the columns we pull out of each theia session object theia_sessions = pd.DataFrame( data=list(map(lambda x: ({ column: getattr(x, column) @@ -33,16 +58,26 @@ def get_theia_sessions() -> pd.DataFrame: }), raw_theia_sessions)), columns=columns ) + + # Round the timestamps to the nearest hour theia_sessions['created'] = theia_sessions['created'].apply(lambda date: pd.to_datetime(date).round('H')) theia_sessions['ended'] = theia_sessions['ended'].apply(lambda date: pd.to_datetime(date).round('H')) + + # Add a duration column if len(theia_sessions) > 0: + # Get the duration from subtracting the end from the start time, and converting to minutes theia_sessions['duration'] = theia_sessions[['ended', 'created']].apply( lambda row: (row[0] - row[1]).seconds / 60, axis=1) + + # The apply breaks if there are no rows, so make it empty in that case else: theia_sessions['duration'] = [] + + # Drop outliers based on duration theia_sessions = theia_sessions[ np.abs(theia_sessions.duration - theia_sessions.duration.mean()) <= (3 * theia_sessions.duration.std()) - ] # Drop outliers based on duration + ] + return theia_sessions diff --git a/api/anubis/views/admin/__init__.py b/api/anubis/views/admin/__init__.py index 69f106e8b..af2dbbf96 100644 --- a/api/anubis/views/admin/__init__.py +++ b/api/anubis/views/admin/__init__.py @@ -7,10 +7,11 @@ def register_admin_views(app): from anubis.views.admin.regrade import regrade from anubis.views.admin.autograde import autograde_ from anubis.views.admin.static import static - from anubis.views.admin.users import students + from anubis.views.admin.students import students_ from anubis.views.admin.courses import courses_ from anubis.views.admin.config import config_ from anubis.views.admin.visuals import visuals_ + from anubis.views.admin.dangling import dangling views = [ ide, @@ -21,10 +22,11 @@ def register_admin_views(app): regrade, autograde_, static, - students, + students_, courses_, config_, visuals_, + dangling, ] for view in views: diff --git a/api/anubis/views/admin/assignments.py b/api/anubis/views/admin/assignments.py index b9a9c2529..54d9b9a93 100644 --- a/api/anubis/views/admin/assignments.py +++ b/api/anubis/views/admin/assignments.py @@ -5,15 +5,16 @@ from sqlalchemy.exc import DataError, IntegrityError from anubis.models import db, Assignment, User, AssignmentTest, SubmissionTestResult -from anubis.utils.assignment.assignments import assignment_sync -from anubis.utils.users.auth import require_admin +from anubis.utils.auth import require_admin from anubis.utils.data import rand from anubis.utils.data import row2dict -from anubis.utils.decorators import load_from_id, json_response, json_endpoint -from anubis.utils.services.elastic import log_endpoint +from anubis.utils.http.decorators import load_from_id, json_response, json_endpoint from anubis.utils.http.https import error_response, success_response +from anubis.utils.lms.assignments import assignment_sync +from anubis.utils.lms.course import assert_course_admin, get_course_context, assert_course_context +from anubis.utils.lms.questions import get_assigned_questions +from anubis.utils.services.elastic import log_endpoint from anubis.utils.services.logger import logger -from anubis.utils.assignment.questions import get_assigned_questions assignments = Blueprint("admin-assignments", __name__, url_prefix="/admin/assignments") @@ -35,6 +36,8 @@ def private_assignment_id_questions_get_netid(assignment: Assignment, netid: str if user is None: return error_response("user not found") + assert_course_context(assignment) + return success_response( { "netid": user.netid, @@ -45,13 +48,22 @@ def private_assignment_id_questions_get_netid(assignment: Assignment, netid: str @assignments.route("/get/") @require_admin() +@load_from_id(Assignment, verify_owner=False) @json_response -def admin_assignments_get_id(id): - assignment = Assignment.query.filter(Assignment.id == id).first() +def admin_assignments_get_id(assignment: Assignment): + """ + Get the full data for an assignment id. The course context + must be set, and will be checked. - if assignment is None: - return error_response("Assignment does not exist") + :param assignment: + :return: + """ + # Confirm that the assignment they are asking for is part + # of this course + assert_course_context(assignment) + + # Pass back the full data return success_response({"assignment": assignment.full_data}) @@ -59,24 +71,57 @@ def admin_assignments_get_id(id): @require_admin() @json_response def admin_assignments_list(): - all_assignments = Assignment.query.order_by(Assignment.due_date.desc()).all() + """ + List all assignments within the course context. - return success_response( - {"assignments": [row2dict(assignment) for assignment in all_assignments]} - ) + * The response will be the row2dict of the assignment, not a data prop * + + :return: + """ + + # Get the course context + course = get_course_context() + + # Get all the assignment objects within the course context, + # sorted by the due date. + all_assignments = Assignment.query.filter( + Assignment.course_id == course.id + ).order_by(Assignment.due_date.desc()).all() + + # Pass back the row2dict of each assignment object + return success_response({ + "assignments": [row2dict(assignment) for assignment in all_assignments] + }) @assignments.route('/tests/toggle-hide/') @require_admin() @json_response def admin_assignment_tests_toggle_hide_assignment_test_id(assignment_test_id: str): - assignment_test = AssignmentTest.query.filter( + """ + Toggle an assignment test being hidden. + + :param assignment_test_id: + :return: + """ + + # Pull the assignment test + assignment_test: AssignmentTest = AssignmentTest.query.filter( AssignmentTest.id == assignment_test_id, ).first() + + # Make sure the assignment test exists if assignment_test is None: - return error_response('test not found'), 406 + return error_response('test not found') + + # Verify that course the assignment test is apart of and + # the course context match + assert_course_context(assignment_test) + # Toggle the hidden field assignment_test.hidden = not assignment_test.hidden + + # Commit the change db.session.commit() return success_response({ @@ -89,23 +134,44 @@ def admin_assignment_tests_toggle_hide_assignment_test_id(assignment_test_id: st @require_admin() @json_response def admin_assignment_tests_delete_assignment_test_id(assignment_test_id: str): + """ + Delete an assignment test. + + :param assignment_test_id: + :return: + """ + + # Pull the assignment test assignment_test = AssignmentTest.query.filter( AssignmentTest.id == assignment_test_id, ).first() + + # Make sure the assignment test exists if assignment_test is None: - return error_response('test not found'), 406 + return error_response('test not found') + # Verify that course the assignment test is apart of and + # the course context match + assert_course_context(assignment_test) + + # Save the test name so we can use it in the response test_name = assignment_test.name + # Delete all the submission test results that are pointing to + # this test SubmissionTestResult.query.filter( SubmissionTestResult.assignment_test_id == assignment_test.id, ).delete() + # Delete the test itself AssignmentTest.query.filter( AssignmentTest.id == assignment_test_id, ).delete() + + # Commit the changes db.session.commit() + # Pass back the status return success_response({ 'status': f'{test_name} deleted', 'variant': 'warning', @@ -122,7 +188,6 @@ def private_assignment_save(assignment: dict): :param assignment: :return: """ - logger.info(json.dumps(assignment, indent=2)) # Get assignment @@ -136,6 +201,8 @@ def private_assignment_save(assignment: dict): assignment["id"] = rand() db.session.add(db_assignment) + assert_course_context(db_assignment) + # Update all it's fields for key, value in assignment.items(): if 'date' in key: @@ -168,7 +235,7 @@ def private_assignment_sync(assignment: dict): body = { "assignment": { "name": "{name}", - "class": "CS-UY 3224", + "course": "CS-UY 3224", "hidden": true, "github_classroom_url": "", "unique_code": "{code}", @@ -221,6 +288,8 @@ def private_assignment_sync(assignment: dict): :return: """ + # The course context assertion happens in the sync function + # Create or update assignment message, success = assignment_sync(assignment) diff --git a/api/anubis/views/admin/auth.py b/api/anubis/views/admin/auth.py index 9587583a8..a9ec95dcb 100644 --- a/api/anubis/views/admin/auth.py +++ b/api/anubis/views/admin/auth.py @@ -3,7 +3,7 @@ from flask import Blueprint, Response from anubis.models import User -from anubis.utils.users.auth import create_token, require_admin +from anubis.utils.auth import create_token, require_superuser from anubis.utils.data import is_debug from anubis.utils.http.https import error_response, success_response @@ -11,7 +11,7 @@ @auth.route("/token/") -@require_admin(unless_debug=True) +@require_superuser(unless_debug=True) def private_token_netid(netid): """ For debugging, you can use this to sign in as the given user. @@ -24,7 +24,7 @@ def private_token_netid(netid): return error_response("User does not exist") if not is_debug() and other.is_superuser: - return error_response("You can not log in as a superuser.") + return error_response("You cannot log in as a superuser.") token = create_token(other.netid) res = Response( diff --git a/api/anubis/views/admin/autograde.py b/api/anubis/views/admin/autograde.py index 183e38e90..93c8accbb 100644 --- a/api/anubis/views/admin/autograde.py +++ b/api/anubis/views/admin/autograde.py @@ -1,12 +1,13 @@ from flask import Blueprint -from anubis.models import Submission, Assignment, User -from anubis.utils.users.auth import require_admin -from anubis.utils.decorators import json_response -from anubis.utils.services.elastic import log_endpoint +from anubis.models import Submission, Assignment, User, InCourse +from anubis.utils.auth import require_admin +from anubis.utils.http.decorators import json_response from anubis.utils.http.https import success_response, error_response, get_number_arg -from anubis.utils.assignment.questions import get_assigned_questions -from anubis.utils.assignment.autograde import bulk_autograde, autograde, stats_wrapper +from anubis.utils.lms.autograde import bulk_autograde, autograde, autograde_submission_result_wrapper +from anubis.utils.lms.course import assert_course_admin, assert_course_context, get_course_context +from anubis.utils.lms.questions import get_assigned_questions +from anubis.utils.services.elastic import log_endpoint autograde_ = Blueprint("admin-autograde", __name__, url_prefix="/admin/autograde") @@ -14,7 +15,7 @@ @autograde_.route("/assignment/") @require_admin() @json_response -def private_stats_assignment(assignment_id, netid=None): +def admin_autograde_assignment_assignment_id(assignment_id, netid=None): """ Calculate result statistics for an assignment. This endpoint is potentially very IO and computationally expensive. We basically @@ -31,30 +32,70 @@ def private_stats_assignment(assignment_id, netid=None): :param netid: :return: """ + + # Get options for autograde calculations limit = get_number_arg("limit", 10) offset = get_number_arg("offset", 0) + # Pull the assignment object + assignment = Assignment.query.filter( + Assignment.id == assignment_id + ).first() + + # Verify that we got an assignment + if assignment is None: + return error_response('assignment does not exist') + + # Verify that the current course context, and the assignment course match + assert_course_context(assignment) + + # Get the (possibly cached) autograde calculations bests = bulk_autograde(assignment_id, limit=limit, offset=offset) + + # Pass back the results return success_response({"stats": bests}) @autograde_.route("/for//") @require_admin() @json_response -def private_stats_for(assignment_id, user_id): +def admin_autograde_for_assignment_id_user_id(assignment_id, user_id): + """ + Get the autograde results for a specific assignment and user. + + :param assignment_id: + :param user_id: + :return: + """ + + # Get the course context + course = get_course_context() + + # Pull the assignment object assignment = Assignment.query.filter( - Assignment.id == assignment_id, + Assignment.id == assignment_id ).first() - user = User.query.filter(User.id == user_id).first() + + # Verify that we got an assignment + if assignment is None: + return error_response('assignment does not exist') + + # Pull the student user object + student = User.query.filter(User.id == user_id).first() + + # Verify that the current course context, and the assignment course match + assert_course_context(assignment, student) + + # Calculate the best submission for this student and assignment submission_id = autograde(user_id, assignment_id) - return success_response( - { - "stats": stats_wrapper( - assignment, user.id, user.netid, user.name, submission_id - ) - } - ) + # Pass back the + return success_response({ + "stats": autograde_submission_result_wrapper( + assignment, student.id, student.netid, + student.name, submission_id + ) + }) @autograde_.route("/submission//") @@ -72,35 +113,47 @@ def private_submission_stats_id(assignment_id: str, netid: str): :return: """ - user = User.query.filter( - User.netid == netid - ).first() - if user is None: + # Get the user matching the specified netid + student = User.query.filter(User.netid == netid).first() + + # Make sure the user exists + if student is None: return error_response('User does not exist') - assignment = Assignment.query.filter( - Assignment.id == assignment_id - ).first() + # Pull the assignment object + assignment = Assignment.query.filter(Assignment.id == assignment_id).first() + + # Verify that we got an assignment if assignment is None: - return error_response('Assignment does not exist') + return error_response('assignment does not exist') + + # Verify that the current course context, and the assignment course match + assert_course_context(assignment, student) - submission_id = autograde(user.id, assignment.id) + # Calculate the best submission + submission_id = autograde(student.id, assignment.id) + # Set the default for the full_data of the submission submission_full_data = None + + # Get the submission full_data if there was a best submission if submission_id is not None: + # Pull the submission submission = Submission.query.filter( Submission.id == submission_id ).first() + + # Get the full picture of the submission submission_full_data = submission.admin_data - return success_response( - { - "student": user.data, - "submission": submission_full_data, - "assignment": assignment.full_data, - "questions": get_assigned_questions( - assignment.id, user.id, True - ), - "course": assignment.course.data, - } - ) + # Pass back the full, unobstructed view of the student, + # assignment, question assignments, and submission data + return success_response({ + "student": student.data, + "course": assignment.course.data, + "assignment": assignment.full_data, + "submission": submission_full_data, + "questions": get_assigned_questions( + assignment.id, student.id, True + ), + }) diff --git a/api/anubis/views/admin/config.py b/api/anubis/views/admin/config.py index c53168dbf..a936be73f 100644 --- a/api/anubis/views/admin/config.py +++ b/api/anubis/views/admin/config.py @@ -1,26 +1,34 @@ from flask import Blueprint from anubis.models import db, Config -from anubis.utils.users.auth import require_admin -from anubis.utils.decorators import json_response, json_endpoint +from anubis.utils.auth import require_admin, require_superuser +from anubis.utils.http.decorators import json_response, json_endpoint from anubis.utils.http.https import success_response config_ = Blueprint('config', __name__, url_prefix='/admin/config') @config_.route('/list') -@require_admin(unless_debug=True, unless_vpn=True) +@require_admin(unless_vpn=True) @json_response def config_list(): + """ + List all config items. + + :return: + """ + + # Pull all the config items items = Config.query.all() + # Return the broken down objects return success_response({ 'config': [item.data for item in items] }) @config_.route('/save', methods=['POST']) -@require_admin(unless_debug=True) +@require_superuser() @json_endpoint(required_fields=[('config', list)]) def config_add(config, **_): """ diff --git a/api/anubis/views/admin/courses.py b/api/anubis/views/admin/courses.py index 8bf55ff85..d9f20d2dc 100644 --- a/api/anubis/views/admin/courses.py +++ b/api/anubis/views/admin/courses.py @@ -1,68 +1,337 @@ from flask import Blueprint from sqlalchemy.exc import IntegrityError, DataError -from anubis.models import db, Course -from anubis.utils.users.auth import require_admin +from anubis.models import db, Course, TAForCourse, ProfessorForCourse, User +from anubis.utils.auth import require_admin, require_superuser, current_user from anubis.utils.data import row2dict -from anubis.utils.decorators import json_response, json_endpoint +from anubis.utils.http.decorators import json_response, json_endpoint from anubis.utils.http.https import success_response, error_response +from anubis.utils.lms.course import assert_course_superuser, get_course_context courses_ = Blueprint("admin-courses", __name__, url_prefix="/admin/courses") +@courses_.route("/") @courses_.route("/list") @require_admin() @json_response def admin_courses_list(): - courses = Course.query.all() + """ + List the data for the current course context. - return success_response( - { - "courses": [ - {"join_code": course.id[:6], **row2dict(course)} for course in courses - ], - } - ) + :return: + """ + + # Get the course context + course = get_course_context() + + # Return the course context broken down + return success_response({ + "course": {"join_code": course.id[:6], **row2dict(course)}, + }) @courses_.route("/new") -@require_admin() +@require_superuser() @json_response def admin_courses_new(): + """ + Create a new course will placeholder + in all the fields. + + * Requires superuser * + + :return: + """ + + # Create a new course with placeholder + # in all the fields. course = Course( name="placeholder", course_code="placeholder", section="a", professor="placeholder", ) + + # Add it to the session db.session.add(course) + + # Commit the new Course db.session.commit() - return success_response( - { - "course": course.data, - "status": "Created new course", - } - ) + # Return the status + return success_response({ + "course": course.data, + "status": "Created new course", + }) @courses_.route("/save", methods=["POST"]) @require_admin() @json_endpoint(required_fields=[("course", dict)]) def admin_courses_save_id(course: dict): + """ + Update information about the course. + + :param course: + :return: + """ + + # Get the course id from the posted data course_id = course.get("id", None) + + # Try to get the database object corresponding to that course db_course = Course.query.filter(Course.id == course_id).first() + # Make sure we got a course if db_course is None: return error_response("Course not found.") - for key, value in course.items(): - setattr(db_course, key, value) + # Assert that the current user is a professor or a superuser + assert_course_superuser(course_id) + + # Update all the items in the course with the posted data + for column in Course.__table__.columns: + if column.name in course: + setattr(db_course, column.name, course[column.name]) + # Commit the changes try: db.session.commit() except (IntegrityError, DataError) as e: db.session.rollback() return error_response("Unable to save " + str(e)) + # Return the status return success_response({"course": db_course.data, "status": "Changes saved."}) + + +@courses_.route('/list/tas') +@require_admin() +@json_response +def admin_course_list_tas(): + """ + List all TAs for the current course context. + + :return: + """ + + # Get the current course context + course = get_course_context() + + # Get all the TAs in the current course context + tas = User.query.join(TAForCourse).filter( + TAForCourse.course_id == course.id, + ).all() + + # Return the list of basic user information about the tas + return success_response({'users': [ + { + 'id': user.id, 'netid': user.netid, + 'name': user.name, 'github_username': user.github_username + } + for user in tas + ]}) + + +@courses_.route('/list/professors') +@require_admin() +@json_response +def admin_course_list_professors(): + """ + Get all the professors for the current course context. + + :return: + """ + + # Get the current course context + course = get_course_context() + + # Get all the professors within the current course context + professors = User.query.join(ProfessorForCourse).filter( + ProfessorForCourse.course_id == course.id, + ).all() + + # Return the list of basic user information about the professors + return success_response({'users': [ + { + 'id': user.id, 'netid': user.netid, + 'name': user.name, 'github_username': user.github_username, + } + for user in professors + ]}) + + +@courses_.route('/make/ta/') +@require_admin() +@json_response +def admin_course_make_ta_id(user_id: str): + """ + Make a user a ta for the current course. + + :param user_id: + :return: + """ + + # Get the current course context + course = get_course_context() + + # Get the user that will be the TA + other = User.query.filter(User.id == user_id).first() + + # Check that the user exists + if other is None: + return error_response('User id does not exist') + + # Check to see if the user is already a ta + ta = TAForCourse.query.filter( + TAForCourse.owner_id == user_id, + ).first() + if ta is not None: + return error_response('They are already a TA') + + # Make the user a TA if they are not already + ta = TAForCourse( + owner_id=user_id, + course_id=course.id, + ) + + # Add and commit the change + db.session.add(ta) + db.session.commit() + + # Return the status + return success_response({ + 'status': 'TA added to course' + }) + + +@courses_.route('/remove/ta/') +@require_admin() +@json_response +def admin_course_remove_ta_id(user_id: str): + """ + Remove a TA from the current course context + + :param user_id: + :return: + """ + + # Get the current user + user = current_user() + + # Get the course context + course = get_course_context() + + # Assert that the current user is a professor or superuser + assert_course_superuser(course.id) + + # Get the user object for the specified user + other = User.query.filter(User.id == user_id).first() + + # Make sure that the other user exists + if other is None: + return error_response('User id does not exist') + + # If the other user is the current user, then stop + if not user.is_superuser and other.id == user.id: + return error_response('cannot remove yourself') + + # Delete the TA + TAForCourse.query.filter( + TAForCourse.owner_id == user_id, + TAForCourse.course_id == course.id, + ).delete() + + # Commit the delete + db.session.commit() + + # Return the status + return success_response({ + 'status': 'TA removed from course', + 'variant': 'warning', + }) + + +@courses_.route('/make/professor/') +@require_superuser() +@json_response +def admin_course_make_professor_id(user_id: str): + """ + Make a user a professor for a course + + :param user_id: + :return: + """ + + # Get the current course context + course = get_course_context() + + # Get the other user + other = User.query.filter(User.id == user_id).first() + + # Make sure they exist + if other is None: + return error_response('User id does not exist') + + # Check to see if the other user is already a professor + # for this course + prof = ProfessorForCourse.query.filter( + ProfessorForCourse.owner_id == user_id, + ).first() + + # If they are already a professor, then stop + if prof is not None: + return error_response('They are already a professor') + + # Create a new professor + prof = ProfessorForCourse( + owner_id=user_id, + course_id=course.id, + ) + + # Add and commit the change + db.session.add(prof) + db.session.commit() + + # Return the status + return success_response({ + 'status': 'Professor added to course' + }) + + +@courses_.route('/remove/professor/') +@require_superuser() +@json_response +def admin_course_remove_professor_id(user_id: str): + """ + Remove a professor from a course. + + :param user_id: + :return: + """ + + # Get the current user + course = get_course_context() + + # Get the other user + other = User.query.filter(User.id == user_id).first() + + # Make sure the other user exists + if other is None: + return error_response('User id does not exist') + + # Delete the professor + ProfessorForCourse.query.filter( + ProfessorForCourse.owner_id == user_id, + ProfessorForCourse.course_id == course.id, + ).delete() + + # Commit the delete + db.session.commit() + + # Return the status + return success_response({ + 'status': 'Professor removed from course', + 'variant': 'warning', + }) diff --git a/api/anubis/views/admin/dangling.py b/api/anubis/views/admin/dangling.py index 9ead48e4f..51a3f5d47 100644 --- a/api/anubis/views/admin/dangling.py +++ b/api/anubis/views/admin/dangling.py @@ -1,17 +1,17 @@ from flask import Blueprint from anubis.models import Submission -from anubis.utils.users.auth import require_admin -from anubis.utils.decorators import json_response -from anubis.utils.services.elastic import log_endpoint +from anubis.utils.auth import require_superuser +from anubis.utils.http.decorators import json_response from anubis.utils.http.https import success_response -from anubis.utils.assignment.submissions import fix_dangling +from anubis.utils.lms.submissions import fix_dangling +from anubis.utils.services.elastic import log_endpoint dangling = Blueprint("admin-dangling", __name__, url_prefix="/admin/dangling") -@dangling.route("/") -@require_admin() +@dangling.route("/list") +@require_superuser() @log_endpoint("cli", lambda: "dangling") @json_response def private_dangling(): @@ -19,30 +19,50 @@ def private_dangling(): This route should hand back a json list of all submissions that are dangling. Dangling being that we have no netid to match to the github username that submitted the assignment. + + :return: """ + # Pull all the dandling submissions dangling_ = Submission.query.filter( Submission.owner_id == None, ).all() + + # Get the data response for the dangling submissions dangling_ = [a.data for a in dangling_] - return success_response({"dangling": dangling_, "count": len(dangling_)}) + # Pass back all the dangling submissions + return success_response({"count": len(dangling_), "dangling": dangling_}) @dangling.route("/reset") -@require_admin() +@require_superuser() @log_endpoint("reset-dangling", lambda: "reset-dangling") @json_response def private_reset_dangling(): + """ + Reset all the submission that are dangling + + :return: + """ + + # Build a list of all the reset submissions resets = [] + + # Iterate over all the dangling submissions for s in Submission.query.filter_by(owner_id=None).all(): + # Reset the submission models s.init_submission_models() + + # Append the new dangling submission data for the response resets.append(s.data) + + # Return all the reset submissions return success_response({"reset": resets}) @dangling.route("/fix") -@require_admin() +@require_superuser() @log_endpoint("cli", lambda: "fix-dangling") @json_response def private_fix_dangling(): @@ -61,4 +81,4 @@ def private_fix_dangling(): :return: """ - return fix_dangling() + return success_response({'fixed': fix_dangling()}) diff --git a/api/anubis/views/admin/ide.py b/api/anubis/views/admin/ide.py index 0f8b8c257..65cb58249 100644 --- a/api/anubis/views/admin/ide.py +++ b/api/anubis/views/admin/ide.py @@ -5,98 +5,66 @@ from anubis.models import db, TheiaSession from anubis.rpc.theia import reap_all_theia_sessions -from anubis.utils.users.auth import require_admin, current_user -from anubis.utils.decorators import json_response, json_endpoint -from anubis.utils.services.elastic import log_endpoint +from anubis.utils.auth import require_admin, current_user +from anubis.utils.http.decorators import json_response, json_endpoint from anubis.utils.http.https import success_response, error_response +from anubis.utils.lms.course import get_course_context +from anubis.utils.services.elastic import log_endpoint from anubis.utils.services.rpc import enqueue_ide_initialize from anubis.utils.services.rpc import rpc_enqueue, enqueue_ide_stop ide = Blueprint("admin-ide", __name__, url_prefix="/admin/ide") -@ide.route("/initialize") -@require_admin() -@log_endpoint("admin-ide-initialize") -@json_response -def admin_ide_initialize(): - user = current_user() - - session = TheiaSession.query.filter( - TheiaSession.active, - TheiaSession.owner_id == user.id, - TheiaSession.assignment_id == None, - ).first() - - if session is not None: - return success_response({ - "session": session.data, - "settings": session.settings, - }) - - # Create a new session - session = TheiaSession( - owner_id=user.id, - assignment_id=None, - network_locked=False, - privileged=True, - image="registry.osiris.services/anubis/theia-admin", - repo_url="https://github.com/os3224/anubis-assignment-tests.git", - options={'limits': {'cpu': '4', 'memory': '4Gi'}}, - active=True, - state="Initializing", - ) - db.session.add(session) - db.session.commit() - - # Send kube resource initialization rpc job - enqueue_ide_initialize(session.id) - - return success_response({ - "session": session.data, - "settings": session.settings, - "status": "Admin IDE Initialized." - }) - - +@ide.route("/initialize", methods=["POST"]) @ide.route("/initialize-custom", methods=["POST"]) @require_admin() @log_endpoint("admin-ide-initialize") @json_endpoint([('settings', dict)]) def admin_ide_initialize_custom(settings: dict, **_): """ - - + Initialize a new management ide with options. :param settings: :param _: :return: """ + + # Get the current user user = current_user() + # Get the current course context + course = get_course_context() + + # Check to see if there is already a management session + # allocated for the current user session = TheiaSession.query.filter( TheiaSession.active, TheiaSession.owner_id == user.id, + TheiaSession.course_id == course.id, TheiaSession.assignment_id == None, ).first() + # If there is already a session, then stop if session is not None: return success_response({"session": session.data}) + # Read the options out of the posted data network_locked = settings.get('network_locked', False) privileged = settings.get('privileged', True) image = settings.get('image', 'registry.osiris.services/anubis/theia-admin') repo_url = settings.get('repo_url', 'https://github.com/os3224/anubis-assignment-tests') options_str = settings.get('options', '{"limits": {"cpu": "4", "memory": "4Gi"}}') + # Attempt to load the options_str into a dict object try: options = json.loads(options_str) except json.JSONDecodeError: - return error_response('Can not parse JSON options'), 400 + return error_response('Can not parse JSON options') # Create a new session session = TheiaSession( - owner_id=user.id, assignment_id=None, + owner_id=user.id, assignment_id=None, course_id=course.id, network_locked=network_locked, privileged=privileged, image=image, repo_url=repo_url, options=options, active=True, state="Initializing", @@ -119,17 +87,32 @@ def admin_ide_initialize_custom(settings: dict, **_): @log_endpoint("admin-ide-active") @json_response def admin_ide_active(): + """ + Get the list of all active Theia ides within + the current course context. + + :return: + """ + + # Get the current user user = current_user() + # Get the course context + course = get_course_context() + + # Query for an active theia session within this course context session = TheiaSession.query.filter( TheiaSession.active, TheiaSession.owner_id == user.id, + TheiaSession.course_id == course.id, TheiaSession.assignment_id == None, ).first() + # If there was no session, then stop if session is None: return success_response({"session": None}) + # Return the active session informatino return success_response({ "session": session.data, "settings": session.settings, @@ -147,8 +130,13 @@ def admin_ide_list(): :return: """ + course = get_course_context() + # Get all active sessions - sessions = TheiaSession.query.filter(TheiaSession.active).all() + sessions = TheiaSession.query.filter( + TheiaSession.active == True, + TheiaSession.course_id == course.id, + ).all() # Hand back response return success_response({"sessions": [session.data for session in sessions]}) @@ -165,7 +153,12 @@ def admin_ide_stop_id(id: str): :return: """ - session = TheiaSession.query.filter(TheiaSession.id == id).first() + course = get_course_context() + + session = TheiaSession.query.filter( + TheiaSession.id == id, + TheiaSession.course_id == course.id, + ).first() if session is None: return error_response("Session does not exist.") @@ -194,8 +187,10 @@ def private_ide_reap_all(): :return: """ + course = get_course_context() + # Send reap job to rpc cluster - rpc_enqueue(reap_all_theia_sessions, 'theia', args=tuple()) + rpc_enqueue(reap_all_theia_sessions, 'theia', args=(course.id,)) # Hand back status return success_response( diff --git a/api/anubis/views/admin/questions.py b/api/anubis/views/admin/questions.py index 74008f42e..f7515c4bc 100644 --- a/api/anubis/views/admin/questions.py +++ b/api/anubis/views/admin/questions.py @@ -2,11 +2,17 @@ from flask import Blueprint from anubis.models import db, Assignment, AssignmentQuestion, AssignedStudentQuestion -from anubis.utils.users.auth import require_admin -from anubis.utils.decorators import json_response, json_endpoint -from anubis.utils.services.elastic import log_endpoint +from anubis.utils.auth import require_admin +from anubis.utils.http.decorators import json_response, json_endpoint from anubis.utils.http.https import error_response, success_response -from anubis.utils.assignment.questions import ( +from anubis.utils.services.elastic import log_endpoint +from anubis.utils.lms.course import ( + assert_course_admin, + assert_course_superuser, + assert_course_context, + get_course_context, +) +from anubis.utils.lms.questions import ( hard_reset_questions, get_all_questions, assign_questions, @@ -19,14 +25,27 @@ @require_admin() @log_endpoint("admin", lambda: "add new question") @json_response -def private_questions_add_unique_code(unique_code: str): +def admin_questions_add_unique_code(unique_code: str): + """ + Add a new blank question to the assignment. + + :param unique_code: + :return: + """ + # Try to find assignment assignment: Assignment = Assignment.query.filter( Assignment.unique_code == unique_code ).first() + + # If the assignment does not exist, then stop if assignment is None: - return error_response("Unable to find assignment"), 400 + return error_response("Unable to find assignment") + + # Assert that the set course context matches the course of the assignment + assert_course_context(assignment) + # Create a new, blank question aq = AssignmentQuestion( assignment_id=assignment.id, sequence=0, @@ -36,9 +55,12 @@ def private_questions_add_unique_code(unique_code: str): code_language='', placeholder='', ) + + # Add and commit the question db.session.add(aq) db.session.commit() + # Return the status return success_response({ "status": "Question added", }) @@ -48,21 +70,43 @@ def private_questions_add_unique_code(unique_code: str): @require_admin() @log_endpoint("admin", lambda: "delete question") @json_response -def private_questions_del(assignment_question_id: str): - aq = AssignmentQuestion.query.filter( +def admin_questions_delete_question_id(assignment_question_id: str): + """ + Delete an assignment question + + :param assignment_question_id: + :return: + """ + + # Get the assignment question + assignment_question: AssignmentQuestion = AssignmentQuestion.query.filter( AssignmentQuestion.id == assignment_question_id, ).first() - if aq is None: - return error_response('Could not find question'), 400 + + # Verify that the question exists + if assignment_question is None: + return error_response('Could not find question') + + # Assert that the set course context matches the course of the assignment + assert_course_context(assignment_question) try: + # Try to delete the question AssignmentQuestion.query.filter( AssignmentQuestion.id == assignment_question_id, ).delete() + + # Try to commit the delete db.session.commit() + except sqlalchemy.exc.IntegrityError: + # Rollback the commit + db.session.rollback() + + # If this commit fails, then it is assigned to students return error_response('Question is already assigned to students. Reset assignments to delete.') + # Return the status return success_response({ "status": "Question deleted", 'variant': 'warning', @@ -93,16 +137,26 @@ def private_questions_hard_reset_unique_code(unique_code: str): :param unique_code: :return: """ + # Try to find assignment assignment: Assignment = Assignment.query.filter( Assignment.unique_code == unique_code ).first() + + # If the assignment does not exist, then stop if assignment is None: return error_response("Unable to find assignment") + # Assert that the current user is a professor or superuser + assert_course_superuser(assignment.course_id) + + # Assert that the set course context matches the course of the assignment + assert_course_context(assignment) + # Hard reset questions hard_reset_questions(assignment) + # Pass back the status return success_response({ "status": "Questions hard reset", 'variant': 'warning', @@ -133,18 +187,31 @@ def private_questions_reset_assignments_unique_code(unique_code: str): :param unique_code: :return: """ + # Try to find assignment assignment: Assignment = Assignment.query.filter( Assignment.unique_code == unique_code ).first() + + # Verify that the assignment exists if assignment is None: return error_response("Unable to find assignment") + # Assert that the current user is a professor or superuser + assert_course_superuser(assignment.course_id) + + # Assert that the set course context matches the course of the assignment + assert_course_context(assignment) + + # Delete all the question assignments AssignedStudentQuestion.query.filter( AssignedStudentQuestion.assignment_id == assignment.id ).delete() + + # Commit the delete db.session.commit() + # Pass back the status return success_response({ "status": "Questions assignments reset", 'variant': 'warning', @@ -164,32 +231,39 @@ def admin_questions_update(assignment_question_id: str, question: dict): :return: """ - db_assignment_question = AssignmentQuestion.query.filter( + # Get the assignment question + db_assignment_question: AssignmentQuestion = AssignmentQuestion.query.filter( AssignmentQuestion.id == assignment_question_id ).first() - db_assignment_question: AssignmentQuestion + # Verify that the assignment question exists if db_assignment_question is None: return error_response('question not found') + # Assert that the set course context matches the course of the assignment + assert_course_context(db_assignment_question) + + # Update the fields of the question db_assignment_question.question = question['question'] db_assignment_question.solution = question['solution'] db_assignment_question.code_language = question['code_language'] db_assignment_question.code_question = question['code_question'] db_assignment_question.sequence = question['sequence'] + # Commit any changes db.session.commit() + # Pass back status return success_response({ 'status': 'Question updated' }) -@questions.route("/get/") +@questions.route("/get-assignments/") @require_admin() @log_endpoint("admin", lambda: "questions get") @json_response -def private_questions_get_unique_code(unique_code: str): +def private_questions_get_assignments_unique_code(unique_code: str): """ Get all questions for the given assignment. @@ -201,9 +275,15 @@ def private_questions_get_unique_code(unique_code: str): assignment: Assignment = Assignment.query.filter( Assignment.unique_code == unique_code ).first() + + # If the assignment does not exist, then stop if assignment is None: return error_response("Unable to find assignment") + # Assert that the assignment is within the course context + assert_course_context(assignment) + + # Get all the question assignments assignment_questions = AssignmentQuestion.query.filter( AssignmentQuestion.assignment_id == assignment.id, ).order_by(AssignmentQuestion.sequence, AssignmentQuestion.created.desc()).all() @@ -216,11 +296,11 @@ def private_questions_get_unique_code(unique_code: str): }) -@questions.route("/get-assignments/") +@questions.route("/get/") @require_admin() @log_endpoint("admin", lambda: "get question assignments") @json_response -def private_questions_get_assignments_unique_code(unique_code: str): +def private_questions_get_unique_code(unique_code: str): """ Get all questions for the given assignment. @@ -232,9 +312,14 @@ def private_questions_get_assignments_unique_code(unique_code: str): assignment: Assignment = Assignment.query.filter( Assignment.unique_code == unique_code ).first() + + # Verify that the assignment exists if assignment is None: return error_response("Unable to find assignment") + # Assert that the assignment is within the course context + assert_course_context(assignment) + return success_response({ 'questions': get_all_questions(assignment) }) @@ -259,9 +344,13 @@ def private_questions_assign_unique_code(unique_code: str): assignment: Assignment = Assignment.query.filter( Assignment.unique_code == unique_code ).first() + + # Verify that we got an assignment if assignment is None: return error_response("Unable to find assignment") + assert_course_context(assignment) + # Assign the questions assigned_questions = assign_questions(assignment) diff --git a/api/anubis/views/admin/regrade.py b/api/anubis/views/admin/regrade.py index 3ba2b4eae..8d3a2b50b 100644 --- a/api/anubis/views/admin/regrade.py +++ b/api/anubis/views/admin/regrade.py @@ -1,38 +1,55 @@ -from datetime import datetime, timedelta import math +from datetime import datetime, timedelta from flask import Blueprint from sqlalchemy import or_ from anubis.models import Submission, Assignment from anubis.rpc.batch import rpc_bulk_regrade -from anubis.utils.users.auth import require_admin +from anubis.utils.auth import require_admin from anubis.utils.data import split_chunks -from anubis.utils.decorators import json_response -from anubis.utils.services.elastic import log_endpoint +from anubis.utils.http.decorators import json_response +from anubis.utils.http.decorators import load_from_id from anubis.utils.http.https import error_response, success_response, get_number_arg +from anubis.utils.lms.course import assert_course_admin, get_course_context, assert_course_context +from anubis.utils.services.elastic import log_endpoint from anubis.utils.services.rpc import enqueue_autograde_pipeline, rpc_enqueue regrade = Blueprint("admin-regrade", __name__, url_prefix="/admin/regrade") -@regrade.route("/status/") +@regrade.route("/status/") @require_admin() +@load_from_id(Assignment, verify_owner=False) @json_response -def admin_regrade_status(assignment_id: str): +def admin_regrade_status(assignment: Assignment): + """ + Get the autograde status for an assignment. The status + is some high level stats the proportion of submissions + within the assignment that have been processed + + :param assignment: + :return: + """ + + # Assert that the assignment is within the current course context + assert_course_context(assignment) + + # Get the number of submissions that are being processed processing = Submission.query.filter( - Submission.assignment_id == assignment_id, + Submission.assignment_id == assignment.id, Submission.processed == False, ).count() + # Get the total number of submissions total = Submission.query.filter( - Submission.assignment_id == assignment_id, + Submission.assignment_id == assignment.id, ).count() - percent = 0 - if total > 0: - percent = math.ceil(((total - processing) / total) * 100) + # Calculate the percent of submissions that have been processed + percent = math.ceil(((total - processing) / total) * 100) if total > 0 else 0 + # Return the status return success_response({ 'percent': f'{percent}% of submissions processed', 'processing': processing, @@ -45,7 +62,7 @@ def admin_regrade_status(assignment_id: str): @require_admin() @log_endpoint("cli", lambda: "regrade-commit") @json_response -def private_regrade_submission(commit): +def admin_regrade_submission_commit(commit): """ Regrade a specific submission via the unique commit hash. @@ -54,21 +71,26 @@ def private_regrade_submission(commit): """ # Find the submission - s = Submission.query.filter( + submission: Submission = Submission.query.filter( Submission.commit == commit, Submission.owner_id is not None, ).first() - if s is None: - return error_response("not found") + + # Make sure the submission exists + if submission is None: + return error_response("Submission does not exist") + + # Assert that the submission is within the current course context + assert_course_context(submission) # Reset submission in database - s.init_submission_models() + submission.init_submission_models() # Enqueue the submission pipeline - enqueue_autograde_pipeline(s.id) + enqueue_autograde_pipeline(submission.id) # Return status - return success_response({"submission": s.data, "user": s.owner.data}) + return success_response({"submission": submission.data, "user": submission.owner.data}) @regrade.route("/assignment/") @@ -96,43 +118,51 @@ def private_regrade_assignment(assignment_id): :return: """ - extra = [] + # Get the options for the regrade hours = get_number_arg('hours', default_value=-1) not_processed = get_number_arg('not_processed', default_value=-1) processed = get_number_arg('processed', default_value=-1) reaped = get_number_arg('reaped', default_value=-1) - # Add hours to filter query + # Build a list of filters based on the options + filters = [] + + # Number of hours back to regrade if hours > 0: - extra.append( - Submission.created > datetime.now() - timedelta(hours=hours) - ) + filters.append(Submission.created > datetime.now() - timedelta(hours=hours)) + + # Only regrade submissions that have been processed if processed == 1: - extra.append( - Submission.processed == True, - ) + filters.append(Submission.processed == True) + + # Only regrade submissions that have not been processed if not_processed == 1: - extra.append( - Submission.processed == False, - ) + filters.append(Submission.processed == False) + + # Only regrade submissions that have been reaped if reaped == 1: - extra.append( - Submission.state == 'Reaped after timeout', - ) + filters.append(Submission.state == 'Reaped after timeout') # Find the assignment assignment = Assignment.query.filter( or_(Assignment.id == assignment_id, Assignment.name == assignment_id) ).first() + + # Verify that the assignment exists if assignment is None: return error_response("cant find assignment") - # Get all submissions that have an owner (not dangling) + # Assert that the assignment is within the current course context + assert_course_context(assignment) + + # Get all submissions matching the filters submissions = Submission.query.filter( Submission.assignment_id == assignment.id, Submission.owner_id is not None, - *extra + *filters ).all() + + # Get a count of submissions for the response submission_count = len(submissions) # Split the submissions into bite sized chunks diff --git a/api/anubis/views/admin/seed.py b/api/anubis/views/admin/seed.py index be5162ed5..afd85a7a6 100644 --- a/api/anubis/views/admin/seed.py +++ b/api/anubis/views/admin/seed.py @@ -1,22 +1,32 @@ from flask import Blueprint -from anubis.utils.users.auth import require_admin +from anubis.utils.auth import require_superuser from anubis.utils.data import is_debug -from anubis.utils.decorators import json_response +from anubis.utils.http.decorators import json_response from anubis.utils.http.https import success_response, error_response -from anubis.utils.services.rpc import enqueue_seed as seed_ +from anubis.utils.services.rpc import enqueue_seed seed = Blueprint("admin-seed", __name__, url_prefix="/admin/seed") @seed.route("/") -@require_admin(unless_debug=True) +@require_superuser(unless_debug=True) @json_response -def private_seed(): +def admin_seed(): + """ + Seed debug data. + + :return: + """ + + # Only allow seed to run if in debug mode if not is_debug(): return error_response('Seed only enabled in debug mode') - seed_() + # Enqueue a seed job + enqueue_seed() + + # Return the status return success_response({ 'status': 'enqueued seed' }) diff --git a/api/anubis/views/admin/static.py b/api/anubis/views/admin/static.py index 2c2bc784f..a66e3ae13 100644 --- a/api/anubis/views/admin/static.py +++ b/api/anubis/views/admin/static.py @@ -1,10 +1,11 @@ from flask import Blueprint, request from anubis.models import db, StaticFile -from anubis.utils.users.auth import require_admin +from anubis.utils.auth import require_admin from anubis.utils.data import rand -from anubis.utils.decorators import json_response +from anubis.utils.http.decorators import json_response from anubis.utils.http.files import get_mime_type +from anubis.utils.lms.course import get_course_context, assert_course_admin, assert_course_context from anubis.utils.http.https import ( get_number_arg, get_request_file_stream, @@ -18,12 +19,29 @@ @static.route('/delete/') @require_admin() @json_response -def static_delete_static_id(static_id: str): - StaticFile.query.filter( +def admin_static_delete_static_id(static_id: str): + """ + Delete a static file item. + + :param static_id: + :return: + """ + + # Get the static file object + static_file = StaticFile.query.filter( StaticFile.id == static_id - ).delete() + ).first() + + # Assert that the static file is within the current course context + assert_course_context(static_file) + + # Delete the object + db.session.delete(static_file) + + # Commit the delete db.session.commit() + # Pass back the status return success_response({ 'status': 'File deleted', 'variant': 'warning', @@ -33,7 +51,7 @@ def static_delete_static_id(static_id: str): @static.route("/list") @require_admin() @json_response -def static_public_list(): +def admin_static_list(): """ List all public blob files. Optionally specify a limit and an offset. @@ -43,24 +61,31 @@ def static_public_list(): :return: """ + # Get the current course context + course = get_course_context() + + # Get options for the query limit = get_number_arg("limit", default_value=20) offset = get_number_arg("offset", default_value=0) + # Get all public static files within this course public_files = StaticFile.query \ + .filter(StaticFile.course_id == course.id) \ .order_by(StaticFile.created.desc()) \ .limit(limit) \ .offset(offset) \ .all() - return success_response( - {"files": [public_file.data for public_file in public_files]} - ) + # Pass back the list of files + return success_response({ + "files": [public_file.data for public_file in public_files] + }) @static.route("/upload", methods=["POST"]) @require_admin(unless_debug=True) @json_response -def static_public_upload(): +def admin_static_upload(): """ Upload a new public static file. The file will immediately be publicly available. @@ -71,18 +96,18 @@ def static_public_upload(): :return: """ - path = request.args.get("path", default=None) + # Get the current course context + course = get_course_context() - # If the path was not specified, then create some hash for it - if path is None: - path = "/" + rand(16) + # Create a path hash + path = "/" + rand(16) # Pull file from request stream, filename = get_request_file_stream(with_filename=True) # Make sure we got a file if stream is None: - return error_response("No file uploaded"), 406 + return error_response("No file uploaded") # Figure out content type mime_type = get_mime_type(stream) @@ -95,9 +120,7 @@ def static_public_upload(): # If the blob doesn't already exist, create one if blob is None: - blob = StaticFile( - path=path, - ) + blob = StaticFile(path=path, course_id=course.id) # Update the fields blob.filename = filename @@ -108,9 +131,8 @@ def static_public_upload(): db.session.add(blob) db.session.commit() - return success_response( - { - "status": f"{filename} uploaded", - "blob": blob.data, - } - ) + # Pass back the status + return success_response({ + "status": f"{filename} uploaded", + "blob": blob.data, + }) diff --git a/api/anubis/views/admin/students.py b/api/anubis/views/admin/students.py new file mode 100644 index 000000000..5a5cad0b9 --- /dev/null +++ b/api/anubis/views/admin/students.py @@ -0,0 +1,248 @@ +from flask import Blueprint + +from anubis.models import db, User, Course, InCourse, Submission, Assignment +from anubis.utils.auth import require_admin, current_user, require_superuser +from anubis.utils.http.decorators import json_response, json_endpoint +from anubis.utils.http.https import success_response, error_response, get_number_arg +from anubis.utils.lms.course import assert_course_superuser, get_course_context, assert_course_context +from anubis.utils.lms.students import get_students + +students_ = Blueprint("admin-students", __name__, url_prefix="/admin/students") + + +@students_.route('/list/basic') +@require_admin() +@json_response +def admin_student_list_basic(): + """ + List the most basic information about all the students + within the Anubis system. + + :return: + """ + + # Get all users + students = get_students() + + # Return their id and netid + return success_response({ + 'users': [ + {'id': user['id'], 'netid': user['netid']} + for user in students + ] + }) + + +@students_.route("/list") +@require_admin() +@json_response +def admin_students_list(): + """ + List all users within the current course context + + :return: + """ + + # Get the current course context + course = get_course_context() + + # Get all students within the current course context + students = get_students(course_id=course.id) + + # Pass back the students + return success_response({ + "students": students + }) + + +@students_.route("/info/") +@require_admin() +@json_response +def admin_students_info_id(id: str): + """ + Get basic information about a specific student by id. + + :param id: + :return: + """ + + # Get the student object + student = User.query.filter( + User.id == id, + ).first() + + # Check if student exists + if student is None: + return error_response("Student does not exist") + + assert_course_context(student) + + # Get courses student is in + in_courses = InCourse.query.filter( + InCourse.owner_id == student.id, + ).all() + + # Get a list of all the course ids + course_ids = list(map(lambda x: x.course_id, in_courses)) + + # Get the course objects + courses = Course.query.filter( + Course.id.in_(course_ids) + ).all() + + # Pass back the student and course information + return success_response({ + "student": student.data, + "courses": [course.data for course in courses], + }) + + +@students_.route("/submissions/") +@require_admin() +@json_response +def admin_students_submissions_id(id: str): + """ + Get some number of submissions for a specific user within + the course context. + + use limit and offset parameters to view submission window + + :param id: + :return: + """ + + # Get an optional limit and offset for query + limit = get_number_arg("limit", 50) + offset = get_number_arg("offset", 0) + + # Get the current course context + course = get_course_context() + + # Get the user object + student = User.query.filter(User.id == id).first() + + # Check if student exists + if student is None: + return error_response("Student does not exist") + + # Get n most recent submissions from the user + submissions = ( + Submission.query.join(Assignment).filter( + Submission.owner_id == student.id, + Assignment.course_id == course.id, + ).order_by(Submission.created.desc()).limit(limit).offset(offset).all() + ) + + # Pass back the student and submission information + return success_response({ + "student": student.data, + "submissions": [submission.data for submission in submissions], + }) + + +@students_.route("/update/", methods=["POST"]) +@require_admin() +@json_endpoint([("name", str), ("github_username", str)], only_required=True) +def admin_students_update_id(id: str, name: str = None, github_username: str = None): + """ + Update either the name of github username of a student. + + The student must be within the current course context, and the + user making the change must be a professor or superuser. + + :param id: + :param name: + :param github_username: + :return: + """ + + # Get the course context + course = get_course_context() + + # Assert that the current user is a professor + # or superuser in the course context + assert_course_superuser(course.id) + + # Get the current user + user = current_user() + + # Get the student object + student = User.query.filter(User.id == id).first() + + # Check if student exists + if student is None: + return error_response("Student does not exist") + + # If the student is a superuser, then stop + if student.is_superuser and not user.is_superuser: + return error_response('You cannot edit a superuser') + + # Make sure that the student is within the course context + in_course = InCourse.query.filter( + InCourse.owner_id == student.id, + InCourse.course_id == course.id, + ).first() + + # Verify that the student is in the context + if in_course is None: + return error_response('You cannot edit someone not in your course') + + # Update fields + student.name = name + student.github_username = github_username + + # Commit changes + db.session.add(student) + db.session.commit() + + # Pass back the status + return success_response({"status": "saved"}) + + +@students_.route("/toggle-superuser/") +@require_superuser() +@json_response +def admin_students_toggle_superuser(id: str): + """ + Toggle the superuser status for a user. Requires user to be superuser + to be able to make this change. + + :param id: + :return: + """ + + # Get the current user + user = current_user() + + # Get the other user + other = User.query.filter(User.id == id).first() + + # Double check that the current user is a superuser + if not user.is_superuser: + return error_response("Only superusers can create other superusers.") + + # If the other user was not found, then stop + if other is None: + return error_response("User could not be found") + + # Make sure that the other user is not also the current user + if user.id == other.id: + return error_response("You can not toggle your own permission.") + + # Toggle the superuser field + other.is_superuser = not other.is_superuser + + # Commit the change + db.session.commit() + + # Pass back the status based on if the other is now a superuser + if other.is_superuser: + return success_response({ + "status": f"{other.name} is now a superuser", "variant": "warning" + }) + + # Pass back the status based on if the other user is now no longer a superuser + else: + return success_response({ + "status": f"{other.name} is no longer a superuser", "variant": "success" + }) diff --git a/api/anubis/views/admin/users.py b/api/anubis/views/admin/users.py deleted file mode 100644 index f8a524f67..000000000 --- a/api/anubis/views/admin/users.py +++ /dev/null @@ -1,181 +0,0 @@ -from flask import Blueprint - -from anubis.models import db, User, Course, InCourse, Submission -from anubis.utils.users.auth import require_admin, current_user -from anubis.utils.services.cache import cache -from anubis.utils.data import is_debug -from anubis.utils.decorators import json_response, json_endpoint -from anubis.utils.http.https import success_response, error_response, get_number_arg -from anubis.utils.users.students import get_students - -students = Blueprint("admin-students", __name__, url_prefix="/admin/students") - - -@students.route("/list") -@require_admin() -@json_response -@cache.cached(timeout=5, unless=is_debug) -def admin_students_list(): - return success_response({"students": get_students()}) - - -@students.route("/info/") -@require_admin() -@json_response -def admin_students_info_id(id: str): - student = User.query.filter( - User.id == id, - ).first() - - # Check if student exists - if student is None: - return error_response("Student does not exist"), 400 - - # Get courses student is in - courses = ( - Course.query.join(InCourse) - .filter( - InCourse.owner_id == student.id, - ) - .all() - ) - - return success_response( - { - "student": student.data, - "courses": [course.data for course in courses], - } - ) - - -@students.route("/submissions/") -@require_admin() -@json_response -def admin_students_submissions_id(id: str): - student = User.query.filter( - User.id == id, - ).first() - - # Check if student exists - if student is None: - return error_response("Student does not exist"), 400 - - # Get an optional limit from the request query - limit = get_number_arg("limit", 50) - - # Get n most recent submissions from the user - submissions = ( - Submission.query.filter( - Submission.owner_id == student.id, - ) - .orderby(Submission.created.desc()) - .limit(limit) - .all() - ) - - return success_response( - { - "student": student.data, - "submissions": [submission.data for submission in submissions], - } - ) - - -@students.route("/update/", methods=["POST"]) -@require_admin() -@json_endpoint( - required_fields=[("name", str), ("github_username", str)], only_required=True -) -def admin_students_update_id(id: str, name: str = None, github_username: str = None): - student = User.query.filter( - User.id == id, - ).first() - - # Check if student exists - if student is None: - return error_response("Student does not exist"), 400 - - # Update fields - student.name = name - student.github_username = github_username - - # Commit changes - db.session.add(student) - db.session.commit() - - return success_response( - { - "status": "saved", - } - ) - - -@students.route("/toggle-admin/") -@require_admin() -@json_response -def admin_students_toggle_admin(id: str): - """ - Toggle the admin status for a user. Requires user to - be admin to be able to make this change. - - :param id: - :return: - """ - user: User = current_user() - other = User.query.filter(User.id == id).first() - - if other is None: - return error_response("User could not be found") - - if user.id == other.id: - return error_response("You can not toggle your own permission.") - - other.is_admin = not other.is_admin - db.session.commit() - - if other.is_admin: - return success_response( - {"status": f"{other.name} is now an admin", "variant": "warning"} - ) - - else: - return success_response( - {"status": f"{other.name} is no longer an admin", "variant": "success"} - ) - - -@students.route("/toggle-superuser/") -@require_admin() -@json_response -def admin_students_toggle_superuser(id: str): - """ - Toggle the superuser status for a user. Requires user to be superuser - to be able to make this change. - - :param id: - :return: - """ - user: User = current_user() - other = User.query.filter(User.id == id).first() - - if not user.is_superuser: - return error_response("Only superusers can create other superusers.") - - if other is None: - return error_response("User could not be found") - - if user.id == other.id: - return error_response("You can not toggle your own permission.") - - other.is_superuser = not other.is_superuser - db.session.commit() - - if other.is_superuser: - return success_response( - {"status": f"{other.name} is now a superuser", "variant": "warning"} - ) - - else: - return success_response( - {"status": f"{other.name} is no longer a superuser", "variant": "success"} - ) diff --git a/api/anubis/views/admin/visuals.py b/api/anubis/views/admin/visuals.py index 556aad267..0730188f7 100644 --- a/api/anubis/views/admin/visuals.py +++ b/api/anubis/views/admin/visuals.py @@ -1,21 +1,44 @@ from flask import Blueprint - -from anubis.utils.decorators import json_response -from anubis.utils.http.https import success_response -from anubis.utils.visuals.assignments import get_assignment_visual_data -from anubis.utils.users.auth import require_admin - +from anubis.models import Assignment +from anubis.utils.auth import require_admin +from anubis.utils.http.decorators import json_response +from anubis.utils.http.https import success_response, error_response +from anubis.utils.visuals.assignments import get_admin_assignment_visual_data +from anubis.utils.lms.course import get_course_context, assert_course_context, assert_course_admin visuals_ = Blueprint('admin-visuals', __name__, url_prefix='/admin/visuals') @visuals_.route('/assignment/') -@require_admin(unless_debug=True) +@require_admin() @json_response def public_visuals_assignment_id(assignment_id: str): + """ + Get the admin visual data for a specific assignment. + + Currently the data passed back feeds into the radial + and passed time scatter graphs. + + :param assignment_id: + :return: + """ + + # Get the assignment object + assignment = Assignment.query.filter( + Assignment.id == assignment_id + ).first() + + # If the assignment does not exist, then stop + if assignment is None: + return error_response('Assignment does not exist') + + # Assert that the assignment is within the course context + assert_course_context(assignment) + + # Generate and pass back the visual data return success_response({ - 'assignment_data': get_assignment_visual_data( + 'assignment_data': get_admin_assignment_visual_data( assignment_id ) }) diff --git a/api/anubis/views/pipeline/pipeline.py b/api/anubis/views/pipeline/pipeline.py index e70a99377..491623c77 100644 --- a/api/anubis/views/pipeline/pipeline.py +++ b/api/anubis/views/pipeline/pipeline.py @@ -1,13 +1,14 @@ import json +from typing import Union from flask import request, Blueprint +from parse import parse from anubis.models import Submission, SubmissionTestResult, AssignmentTest from anubis.models import db -from anubis.utils.decorators import json_response, check_submission_token, json_endpoint +from anubis.utils.http.decorators import json_response, check_submission_token, json_endpoint from anubis.utils.http.https import success_response from anubis.utils.services.logger import logger -from parse import parse pipeline = Blueprint("pipeline", __name__, url_prefix="/pipeline") @@ -17,6 +18,11 @@ @json_response def pipeline_report_panic(submission: Submission): """ + Pipeline workers will hit this endpoint if there was + a panic that needs to be reported. This view function + should mark the submission as processed, and update + its state. + POSTed json should be of the shape: { @@ -29,6 +35,7 @@ def pipeline_report_panic(submission: Submission): :return: """ + # log th panic logger.error( "submission panic reported", extra={ @@ -40,30 +47,24 @@ def pipeline_report_panic(submission: Submission): }, ) + # Set the submission state submission.processed = True submission.state = ( "Whoops! There was an error on our end. The error has been logged." ) submission.errors = {"panic": request.json} + # commit the changes to the session db.session.add(submission) db.session.commit() - # for user in User.query.filter(User.is_admin == True).all(): - # notify(user, panic_msg.format( - # submission=json.dumps(submission.data, indent=2), - # assignment=json.dumps(submission.assignment.data, indent=2), - # user=json.dumps(submission.owner.data, indent=2), - # panic=json.dumps(request.json, indent=2), - # ), 'Anubis pipeline panic submission_id={}'.format(submission.id)) - return success_response("Panic successfully reported") @pipeline.route("/report/build/", methods=["POST"]) @check_submission_token -@json_endpoint(required_fields=[("stdout", str), ("passed", bool)]) -def pipeline_report_build(submission: Submission, stdout: str, passed: bool, **kwargs): +@json_endpoint([("stdout", str), ("passed", bool)]) +def pipeline_report_build(submission: Submission, stdout: str, passed: bool, **_): """ POSTed json should be of the shape: @@ -78,6 +79,7 @@ def pipeline_report_build(submission: Submission, stdout: str, passed: bool, **k :return: """ + # Log the build being reported logger.info( "submission build reported", extra={ @@ -111,23 +113,19 @@ def pipeline_report_build(submission: Submission, stdout: str, passed: bool, **k @pipeline.route("/report/test/", methods=["POST"]) @check_submission_token -@json_endpoint( - required_fields=[ - ("test_name", str), - ("passed", bool), - ("message", str), - ("stdout", str), - ] -) +@json_endpoint([("test_name", str), ("passed", bool), ("message", str), ("stdout", str)]) def pipeline_report_test( submission: Submission, test_name: str, passed: bool, message: str, stdout: str, - **kwargs + **_ ): """ + Submission pipelines will hit this endpoint when there + is a test result to report. + POSTed json should be of the shape: { @@ -145,6 +143,7 @@ def pipeline_report_test( :return: """ + # Log the build logger.info( "submission test reported", extra={ @@ -159,7 +158,7 @@ def pipeline_report_test( }, ) - submission_test_result: SubmissionTestResult = None + submission_test_result: Union[SubmissionTestResult, None] = None # Look for corresponding submission_test_result based on given name for result in submission.test_results: @@ -194,6 +193,11 @@ def pipeline_report_test( @json_endpoint(required_fields=[("state", str)]) def pipeline_report_state(submission: Submission, state: str, **kwargs): """ + When a submission pipeline wants to report a state, it + hits this endpoint. If there is a ?processed=1 in the + http query, then the submission will also be marked as + processed. + POSTed json should be of the shape: { @@ -206,6 +210,7 @@ def pipeline_report_state(submission: Submission, state: str, **kwargs): :return: """ + # Log the state update logger.info( "submission state update", extra={ @@ -217,20 +222,42 @@ def pipeline_report_state(submission: Submission, state: str, **kwargs): }, ) + # Get the processed option if it was specified processed = request.args.get("processed", default="0") + + # Set the processed field if it was specified submission.processed = processed != "0" + # Figure out if the test is hidden + # We do this by checking the state that was given, + # to read the name of the test. If the assignment + # test that was found is marked as hidden, then + # we should not update the state of the submission + # model. + # + # If we were to update the state of the submission + # when a hidden test is reported, then it would be + # visible to the students in the frontend. hidden_test = False + + # Do a basic match on the expected test match = parse('Running test: {}', state) + + # If we got a match if match: + # Get the parsed assignment test name test_name = match[0] + + # Try to get the assignment test assignment_test = AssignmentTest.query.filter( AssignmentTest.assignment_id == submission.assignment_id, AssignmentTest.name == test_name ).first() + + # Set hidden_test to True if the test exists, and if it is marked as hidden hidden_test = assignment_test is not None and assignment_test.hidden - # Update state field + # Update state field if the state report is not for a hidden test if not hidden_test: submission.state = state diff --git a/api/anubis/views/public/__init__.py b/api/anubis/views/public/__init__.py index f3b9c710d..53648262d 100644 --- a/api/anubis/views/public/__init__.py +++ b/api/anubis/views/public/__init__.py @@ -25,7 +25,7 @@ def register_public_views(app): static, courses, questions, - memes,visuals, + memes, visuals, ] for view in views: diff --git a/api/anubis/views/public/assignments.py b/api/anubis/views/public/assignments.py index c8f4bc7ed..6bba4e2e5 100644 --- a/api/anubis/views/public/assignments.py +++ b/api/anubis/views/public/assignments.py @@ -1,12 +1,11 @@ from flask import Blueprint, request -from anubis.models import User, Assignment, AssignedStudentQuestion, db -from anubis.utils.assignment.assignments import get_assignments -from anubis.utils.users.auth import current_user, require_user -from anubis.utils.decorators import json_response, load_from_id, json_endpoint -from anubis.utils.services.elastic import log_endpoint +from anubis.models import User +from anubis.utils.auth import current_user, require_user +from anubis.utils.http.decorators import json_response from anubis.utils.http.https import success_response -from anubis.utils.assignment.questions import get_assigned_questions +from anubis.utils.lms.assignments import get_assignments +from anubis.utils.services.elastic import log_endpoint assignments = Blueprint( "public-assignments", __name__, url_prefix="/public/assignments" @@ -14,6 +13,7 @@ @assignments.route("/") +@assignments.route("/list") @require_user() @log_endpoint("public-assignments") @json_response @@ -38,47 +38,3 @@ def public_assignments(): # Iterate over assignments, getting their data return success_response({"assignments": assignment_data}) - - -@assignments.route("/questions/get/") -@require_user() -@log_endpoint("public-questions-get", lambda: "get questions") -@load_from_id(Assignment, verify_owner=False) -@json_response -def public_assignment_questions_id(assignment: Assignment): - """ - Get assigned questions for the current user for a given assignment. - - :param assignment: - :return: - """ - # Load current user - user: User = current_user() - - return success_response( - {"questions": get_assigned_questions(assignment.id, user.id)} - ) - - -@assignments.route("/questions/save/") -@require_user() -@log_endpoint("public-questions-save", lambda: "save questions") -@load_from_id(AssignedStudentQuestion, verify_owner=True) -@json_endpoint(required_fields=[("response", str)]) -def public_assignment_questions_save_id( - assigned_question: AssignedStudentQuestion, response: str, **kwargs -): - """ - Save response for a given assignment question - - :param assigned_question: - :param response: - :param kwargs: - :return: - """ - assigned_question.response = response - - db.session.add(assigned_question) - db.session.commit() - - return success_response("Success") diff --git a/api/anubis/views/public/auth.py b/api/anubis/views/public/auth.py index 70de22366..47b00af88 100644 --- a/api/anubis/views/public/auth.py +++ b/api/anubis/views/public/auth.py @@ -1,14 +1,14 @@ from flask import Blueprint, make_response, redirect, request from anubis.models import User, db -from anubis.utils.assignment.assignments import get_courses, get_assignments -from anubis.utils.users.auth import create_token, current_user, require_user +from anubis.utils.auth import create_token, current_user, require_user from anubis.utils.data import is_debug -from anubis.utils.decorators import json_endpoint -from anubis.utils.services.elastic import log_endpoint +from anubis.utils.http.decorators import json_endpoint from anubis.utils.http.https import success_response, error_response +from anubis.utils.lms.course import get_course_context +from anubis.utils.lms.submissions import fix_dangling +from anubis.utils.services.elastic import log_endpoint from anubis.utils.services.oauth import OAUTH_REMOTE_APP as provider -from anubis.utils.assignment.submissions import fix_dangling auth = Blueprint("public-auth", __name__, url_prefix="/public/auth") oauth = Blueprint("public-oauth", __name__, url_prefix="/public") @@ -69,7 +69,7 @@ def public_oauth(): # Create the user if they do not already exist if u is None: - u = User(netid=netid, name=name, is_admin=False) + u = User(netid=netid, name=name) db.session.add(u) db.session.commit() @@ -105,15 +105,20 @@ def public_whoami(): if u.github_username is None: status = "Please set your github username in your profile so we can identify your repos!" - return success_response( - { - "user": u.data, - "classes": get_courses(u.netid), - "assignments": get_assignments(u.netid), - "status": status, - "variant": "warning", + course_context = None + context = get_course_context(False) + if context is not None: + course_context = { + "id": context.id, + "name": context.name, } - ) + + return success_response({ + "user": u.data, + "context": course_context, + "status": status, + "variant": "warning", + }) @auth.route("/set-github-username", methods=["POST"]) diff --git a/api/anubis/views/public/courses.py b/api/anubis/views/public/courses.py index 811e6d125..3c849151b 100644 --- a/api/anubis/views/public/courses.py +++ b/api/anubis/views/public/courses.py @@ -3,11 +3,11 @@ from flask import Blueprint from anubis.models import db, User, InCourse, Course -from anubis.utils.assignment.assignments import get_courses -from anubis.utils.users.auth import require_user, current_user -from anubis.utils.decorators import json_response -from anubis.utils.services.elastic import log_endpoint +from anubis.utils.auth import require_user, current_user +from anubis.utils.http.decorators import json_response from anubis.utils.http.https import success_response, error_response +from anubis.utils.lms.assignments import get_courses +from anubis.utils.services.elastic import log_endpoint courses = Blueprint("public-courses", __name__, url_prefix="/public/courses") @@ -29,6 +29,7 @@ def valid_join_code(join_code: str) -> bool: @courses.route("/") +@courses.route("/list") @require_user() @log_endpoint("public-classes") @json_response diff --git a/api/anubis/views/public/ide.py b/api/anubis/views/public/ide.py index 71aeceb99..a6568c944 100644 --- a/api/anubis/views/public/ide.py +++ b/api/anubis/views/public/ide.py @@ -1,13 +1,15 @@ +import copy from datetime import datetime, timedelta from typing import Dict from flask import Blueprint, request from anubis.models import User, TheiaSession, db, Assignment, AssignmentRepo -from anubis.utils.users.auth import current_user, require_user -from anubis.utils.decorators import json_response, load_from_id -from anubis.utils.services.elastic import log_endpoint +from anubis.utils.auth import current_user, require_user +from anubis.utils.http.decorators import json_response, load_from_id from anubis.utils.http.https import error_response, success_response +from anubis.utils.lms.course import is_course_admin +from anubis.utils.services.elastic import log_endpoint from anubis.utils.services.logger import logger from anubis.utils.services.rpc import enqueue_ide_stop, enqueue_ide_initialize from anubis.utils.services.theia import ( @@ -58,6 +60,7 @@ def public_ide_active(assignment_id): return success_response({"active": None}) return success_response({ + "active": True, "session": session.data, }) @@ -173,7 +176,7 @@ def public_ide_initialize(assignment: Assignment): {"active": active_session.active, "session": active_session.data} ) - if not (user.is_admin or user.is_superuser): + if user.is_superuser or is_course_admin(assignment.course_id): if datetime.now() <= assignment.release_date: return error_response("Assignment has not been released.") @@ -193,16 +196,20 @@ def public_ide_initialize(assignment: Assignment): autosave = request.args.get('autosave', 'true') == 'true' logger.info(f'autosave {autosave}') + options = copy.deepcopy(assignment.theia_options) + options['autosave'] = autosave + # Create a new session session = TheiaSession( owner_id=user.id, assignment_id=assignment.id, + course_id=assignment.course.id, repo_url=repo.repo_url, network_locked=True, privileged=False, active=True, state="Initializing", - options={'autosave': autosave} + options=options, ) db.session.add(session) db.session.commit() @@ -211,10 +218,8 @@ def public_ide_initialize(assignment: Assignment): enqueue_ide_initialize(session.id) # Redirect to proxy - return success_response( - { - "active": session.active, - "session": session.data, - "status": "Session created", - } - ) + return success_response({ + "active": session.active, + "session": session.data, + "status": "Session created", + }) diff --git a/api/anubis/views/public/memes.py b/api/anubis/views/public/memes.py index 32aa910d4..082acbd4b 100644 --- a/api/anubis/views/public/memes.py +++ b/api/anubis/views/public/memes.py @@ -9,5 +9,16 @@ @memes.route("/") @log_endpoint("rick-roll", lambda: "rick-roll") def public_memes(): + """ + There are a couple of places on the front end that have + hidden rick rolls. They link to this endpoint where they will + be redirected to the youtube rick roll video. + + :return: + """ + + # Log the rick roll logger.info("rick-roll") + + # Redirect them to the rick roll video return redirect("https://www.youtube.com/watch?v=dQw4w9WgXcQ&autoplay=1") diff --git a/api/anubis/views/public/messages.py b/api/anubis/views/public/messages.py deleted file mode 100644 index 46e737aee..000000000 --- a/api/anubis/views/public/messages.py +++ /dev/null @@ -1,11 +0,0 @@ -panic_msg = """ -A verified panic as been reported from a submission pipeline. - -submission: {submission} - -assignment: {assignment} - -user: {user} - -panic report: {panic} -""" diff --git a/api/anubis/views/public/profile.py b/api/anubis/views/public/profile.py index cb787f084..6e018decb 100644 --- a/api/anubis/views/public/profile.py +++ b/api/anubis/views/public/profile.py @@ -4,10 +4,10 @@ from flask import Blueprint, request from anubis.models import User, db -from anubis.utils.users.auth import current_user, require_user -from anubis.utils.decorators import json_response -from anubis.utils.services.elastic import log_endpoint +from anubis.utils.auth import current_user, require_user +from anubis.utils.http.decorators import json_response from anubis.utils.http.https import error_response, success_response +from anubis.utils.services.elastic import log_endpoint from anubis.utils.services.logger import logger profile = Blueprint("public-profile", __name__, url_prefix="/public/profile") @@ -18,41 +18,70 @@ @log_endpoint("public-set-github-username", lambda: "github username set") @json_response def public_set_github_username(): - u: User = current_user() + """ + Let the user set their github username with + this endpoint. Some things to consider is + making sure that the github username they + give us has not already been taken. + + :return: + """ + # Get the current user + user = current_user() + + # Read the github username from the http query github_username = request.args.get("github_username", default=None) + + # Verify that a github username was given to us if github_username is None: return error_response("missing field") + + # Take of any whitespace that may be in the github username github_username = github_username.strip() + # Check to see if there is any whitespace in the username if any(i in string.whitespace for i in github_username): - return error_response("Your username can not have spaces") + return error_response("Your github username cannot have spaces") + # Do some very basic checks on the github username they + # gave us. We check to see that all the characters are + # either ascii letters, digits, underscores or hyphen. + # + # We also check that their username did not start with + # hyphens. This check is very simple, and may not cover + # all the allowed rules that github puts on their + # username. if not ( - all(i in (string.ascii_letters + string.digits + "-") for i in github_username) + all(i in (string.ascii_letters + string.digits + "-_") for i in github_username) and not github_username.startswith("-") and not github_username.endswith("-") ): + + # Give them back an error saying they have illegal characters return error_response( "Github usernames may only contain alphanumeric characters " "or single hyphens, and cannot begin or end with a hyphen." ) - logger.info(str(u.last_updated)) - logger.info(str(u.last_updated + timedelta(hours=1)) + " - " + str(datetime.now())) + # Check to see if the github username they gave us belongs to + # someone else in the system. + other = User.query.filter( + User.id != user.id, + User.github_username == github_username + ).first() - if ( - u.github_username is not None - and u.last_updated + timedelta(hours=1) < datetime.now() - ): - return error_response( - "Github usernames can only be " - "changed one hour after first setting. " - "Email the TAs to reset your github username." - ) # reject their github username change + # If there is someone else in anubis that has that username, + # then we should give back an error + if other: + return error_response('That github username is already taken!') + + # If all the tests and checks pass, then we can update their github username + user.github_username = github_username - u.github_username = github_username - db.session.add(u) + # Then commit the change + db.session.add(user) db.session.commit() + # And give back the new github username as the response return success_response(github_username) diff --git a/api/anubis/views/public/questions.py b/api/anubis/views/public/questions.py index 077892c72..87eb98f86 100644 --- a/api/anubis/views/public/questions.py +++ b/api/anubis/views/public/questions.py @@ -1,19 +1,44 @@ from flask import Blueprint from sqlalchemy.exc import IntegrityError, DataError -from anubis.models import db, AssignedStudentQuestion, AssignedQuestionResponse, User -from anubis.utils.users.auth import require_user, current_user -from anubis.utils.decorators import json_endpoint +from anubis.models import db, Assignment, AssignedStudentQuestion, AssignedQuestionResponse, User +from anubis.utils.auth import require_user, current_user +from anubis.utils.http.decorators import json_endpoint, load_from_id, json_response from anubis.utils.http.https import success_response, error_response +from anubis.utils.lms.course import is_course_admin +from anubis.utils.lms.questions import get_assigned_questions +from anubis.utils.services.elastic import log_endpoint questions = Blueprint("public-questions", __name__, url_prefix="/public/questions") +@questions.route("/get/") +@require_user() +@log_endpoint("public-questions-get", lambda: "get questions") +@load_from_id(Assignment, verify_owner=False) +@json_response +def public_assignment_questions_id(assignment: Assignment): + """ + Get assigned questions for the current user for a given assignment. + + :param assignment: + :return: + """ + # Load current user + user: User = current_user() + + return success_response({ + "questions": get_assigned_questions(assignment.id, user.id) + }) + + @questions.route("/save/", methods=["POST"]) @require_user() @json_endpoint(required_fields=[("response", str)]) def public_questions_save(id: str, response: str): """ + Save the response for an assigned question. + body = { response: str } @@ -21,30 +46,47 @@ def public_questions_save(id: str, response: str): :param id: :return: """ + + # Get the current user user: User = current_user() + # Try to find the assigned question assigned_question = AssignedStudentQuestion.query.filter( AssignedStudentQuestion.id == id, ).first() + # Verify that the assigned question they are attempting to update + # actually exists if assigned_question is None: return error_response("Assigned question does not exist") - if ( - not (user.is_admin or user.is_superuser) - and assigned_question.owner_id != user.id - ): + # Check that the person that the assigned question belongs to the + # user. If the current user is a course admin (TA, Professor or superuser) + # then we can skip this check. + if not is_course_admin(user.id) and assigned_question.owner_id != user.id: return error_response("Assigned question does not exist") + # Verify that the response is a string object + if not isinstance(response, str): + return error_response('response must be a string') + + # Create a new response res = AssignedQuestionResponse( assigned_question_id=assigned_question.id, response=response ) + + # Add the response to the session db.session.add(res) try: + # Try to commit the response db.session.commit() except (IntegrityError, DataError): + # If the response they gave was too long then a DataError will + # be raised. The max length for the mariadb TEXT type is something + # like 2^16 characters. If they hit this limit, then they are doing + # something wrong. return error_response("Server was unable to save your response.") return success_response({ diff --git a/api/anubis/views/public/repos.py b/api/anubis/views/public/repos.py index 660b9481d..d1e768cfc 100644 --- a/api/anubis/views/public/repos.py +++ b/api/anubis/views/public/repos.py @@ -3,15 +3,16 @@ from flask import Blueprint from anubis.models import User, AssignmentRepo, Assignment -from anubis.utils.users.auth import current_user, require_user -from anubis.utils.decorators import json_response -from anubis.utils.services.elastic import log_endpoint +from anubis.utils.auth import current_user, require_user +from anubis.utils.http.decorators import json_response from anubis.utils.http.https import success_response +from anubis.utils.services.elastic import log_endpoint repos = Blueprint("public-repos", __name__, url_prefix="/public/repos") @repos.route("/") +@repos.route("/list") @require_user() @log_endpoint("repos", lambda: "repos") @json_response diff --git a/api/anubis/views/public/static.py b/api/anubis/views/public/static.py index 64ecbc945..a7442a86b 100644 --- a/api/anubis/views/public/static.py +++ b/api/anubis/views/public/static.py @@ -2,8 +2,8 @@ from sqlalchemy import or_ from anubis.models import StaticFile -from anubis.utils.services.cache import cache from anubis.utils.http.files import make_blob_response +from anubis.utils.services.cache import cache static = Blueprint("public-static", __name__, url_prefix="/public/static") @@ -17,6 +17,7 @@ def public_static(path: str, filename: str = None): * response is possibly cached * + :param filename: :param path: :return: """ diff --git a/api/anubis/views/public/submissions.py b/api/anubis/views/public/submissions.py index eeac6939f..4dd97da72 100644 --- a/api/anubis/views/public/submissions.py +++ b/api/anubis/views/public/submissions.py @@ -1,13 +1,13 @@ from flask import Blueprint, request from anubis.models import User, Submission -from anubis.utils.assignment.assignments import get_submissions -from anubis.utils.users.auth import current_user, require_user -from anubis.utils.decorators import json_response -from anubis.utils.services.elastic import log_endpoint +from anubis.utils.auth import current_user, require_user +from anubis.utils.http.decorators import json_response from anubis.utils.http.https import error_response, success_response +from anubis.utils.lms.assignments import get_submissions +from anubis.utils.lms.submissions import regrade_submission +from anubis.utils.services.elastic import log_endpoint from anubis.utils.services.logger import logger -from anubis.utils.assignment.submissions import regrade_submission submissions = Blueprint( "public-submissions", __name__, url_prefix="/public/submissions" @@ -39,7 +39,7 @@ def public_submissions(): # Load current user user: User = current_user() - if perspective_of_id is not None and not (user.is_admin or user.is_superuser): + if perspective_of_id is not None and not (user.is_superuser): return error_response("Bad Request"), 400 logger.debug("id: " + str(perspective_of_id)) @@ -74,7 +74,7 @@ def public_submission(commit: str): # Get current user user: User = current_user() - if not (user.is_admin or user.is_superuser): + if not user.is_superuser: # Try to find commit (verifying ownership) s = Submission.query.filter( Submission.owner_id == user.id, @@ -122,5 +122,9 @@ def public_regrade_commit(commit=None): if submission is None: return error_response("invalid commit hash or netid"), 406 + # Check that autograde is enabled for the assignment + if not submission.assignment.autograde_enabled: + return error_response('Autograde is disabled for this assignment'), 400 + # Regrade return regrade_submission(submission) diff --git a/api/anubis/views/public/visuals.py b/api/anubis/views/public/visuals.py index 734ec7b7f..23f6d042e 100644 --- a/api/anubis/views/public/visuals.py +++ b/api/anubis/views/public/visuals.py @@ -1,11 +1,9 @@ from flask import Blueprint, make_response -from anubis.models import Assignment -from anubis.utils.visuals.usage import get_usage_plot, get_submissions, get_raw_submissions -from anubis.utils.services.cache import cache from anubis.utils.data import is_debug from anubis.utils.http.https import success_response - +from anubis.utils.services.cache import cache +from anubis.utils.visuals.usage import get_usage_plot, get_raw_submissions visuals = Blueprint('public-visuals', __name__, url_prefix='/public/visuals') @@ -13,18 +11,40 @@ @visuals.route('/usage') @cache.cached(timeout=360, unless=is_debug) def public_visuals_usage(): + """ + Get the usage png graph. This endpoint is heavily + cached. + + :return: + """ + + # Get the png blob of the usage graph. + # The get_usage_plot is itself a cached function. blob = get_usage_plot() + # Take the png bytes, and make a flask response response = make_response(blob) + + # Set the response content type response.headers['Content-Type'] = 'image/png' + # Pass back the image response return response @visuals.route('/raw-usage') def public_visuals_raw_usage(): + """ + Get the raw usage data for generating a react-vis + graph in the frontend of the usage stats. + + :return: + """ + + # Get the raw usage stats usage = get_raw_submissions() + # Pass back the visual data return success_response({ 'usage': usage }) diff --git a/api/anubis/views/public/webhook.py b/api/anubis/views/public/webhook.py index 5433031da..bf0432e7e 100644 --- a/api/anubis/views/public/webhook.py +++ b/api/anubis/views/public/webhook.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta +from typing import Union from flask import Blueprint, request @@ -12,22 +13,34 @@ Submission, ) from anubis.utils.data import is_debug -from anubis.utils.decorators import json_response -from anubis.utils.services.elastic import log_endpoint, esindex +from anubis.utils.http.decorators import json_response from anubis.utils.http.https import error_response, success_response +from anubis.utils.lms.webhook import parse_webhook, guess_github_username, check_repo +from anubis.utils.services.elastic import log_endpoint, esindex from anubis.utils.services.logger import logger from anubis.utils.services.rpc import enqueue_autograde_pipeline -from anubis.utils.assignment.webhook import parse_webhook, guess_github_username, check_repo webhook = Blueprint("public-webhook", __name__, url_prefix="/public/webhook") -def webhook_log_msg(): - if ( - request.headers.get("Content-Type", None) == "application/json" - and request.headers.get("X-GitHub-Event", None) == "push" - ): - return request.json["pusher"]["name"] +def webhook_log_msg() -> Union[str, None]: + """ + Log message for the webhook. We log the + + :return: + """ + + # Get the content type from the headers + content_type = request.headers.get("Content-Type", None) + + # Get the github event header + x_github_event = request.headers.get("X-GitHub-Event", None) + + # If the content type is json, and the github event was a push, + # then log the repository name + if content_type == "application/json" and x_github_event == "push": + return request.json["repository"]["name"] + return None @@ -39,6 +52,8 @@ def public_webhook(): """ This route should be hit by the github when a push happens. We should take the the github repo url and enqueue it as a job. + + :return: """ content_type = request.headers.get("Content-Type", None) @@ -93,10 +108,8 @@ def public_webhook(): return success_response("initial commit") repo = ( - AssignmentRepo.query.join(Assignment) - .join(Course) - .join(InCourse) - .join(User) + AssignmentRepo.query + .join(Assignment).join(Course).join(InCourse).join(User) .filter( User.github_username == github_username_guess, Assignment.unique_code == assignment.unique_code, @@ -192,6 +205,11 @@ def public_webhook(): ) # if the github username is not found, create a dangling submission - enqueue_autograde_pipeline(submission.id) + if assignment.autograde_enabled: + enqueue_autograde_pipeline(submission.id) + else: + submission.processed = 1 + submission.state = 'autograde disabled for this assignment' + db.session.commit() return success_response("submission accepted") diff --git a/api/jobs/reaper.py b/api/jobs/reaper.py index 53670531f..e2dafa2d4 100644 --- a/api/jobs/reaper.py +++ b/api/jobs/reaper.py @@ -8,9 +8,10 @@ from anubis.app import create_app from anubis.models import db, Submission, Assignment, AssignmentRepo, TheiaSession +from anubis.utils.lms.autograde import bulk_autograde +from anubis.utils.lms.webhook import check_repo, guess_github_username from anubis.utils.services.rpc import enqueue_ide_stop, enqueue_ide_reap_stale, enqueue_autograde_pipeline -from anubis.utils.assignment.autograde import bulk_autograde -from anubis.utils.assignment.webhook import check_repo, guess_github_username +from anubis.utils.data import with_context def reap_stale_submissions(): @@ -40,24 +41,14 @@ def reap_stale_submissions(): db.session.commit() -def reap_theia_sessions(): - # Get theia sessions that are older than n hours - theia_sessions = TheiaSession.query.filter( - TheiaSession.active == True, - TheiaSession.last_proxy <= datetime.now() - timedelta(hours=3), - ).all() - - for theia_session in theia_sessions: - enqueue_ide_stop(theia_session.id) - - -def reap_stats(): - from anubis.config import config +def reap_broken_submissions(): """ Calculate stats for recent submissions :return: """ + from anubis.config import config + recent_assignments = Assignment.query.group_by( Assignment.course_id ).having( @@ -84,7 +75,7 @@ def reap_stats(): bulk_autograde(assignment.id) -def reap_repos(): +def reap_broken_repos(): """ For reasons not clear to me yet, the webhooks are sometimes missing on the first commit. The result is that repos will be created on @@ -223,18 +214,19 @@ def reap_repos(): print(f'checked repo: {repo_name} {github_username} {user} {repo.id}') +@with_context def reap(): - app = create_app() + # Enqueue a job to reap stale ide k8s resources + enqueue_ide_reap_stale() + + # Reap the stale submissions + reap_stale_submissions() - with app.app_context(): - # Reap the stale submissions - reap_stale_submissions() - enqueue_ide_reap_stale() - reap_repos() + # Reap broken repos + reap_broken_repos() - with app.test_request_context(): - # Calculate bulk stats (pre-process stats calls) - reap_stats() + # Reap broken submissions in recent assignments + reap_broken_submissions() if __name__ == "__main__": diff --git a/api/jobs/visuals.py b/api/jobs/visuals.py index b20a6532f..12c8863d8 100644 --- a/api/jobs/visuals.py +++ b/api/jobs/visuals.py @@ -1,17 +1,12 @@ from datetime import datetime -from anubis.app import create_app +from anubis.utils.data import with_context from anubis.utils.visuals.usage import get_usage_plot +@with_context def main(): - app = create_app() - - with app.app_context(): - with app.test_request_context(): - - # Create and cache the usage plot - get_usage_plot() + get_usage_plot() if __name__ == "__main__": diff --git a/api/migrations/versions/3327ed0f2e0f_add_sequential_response_tracking.py b/api/migrations/versions/3327ed0f2e0f_add_sequential_response_tracking.py index 8b3aea4e2..2a88d4a75 100644 --- a/api/migrations/versions/3327ed0f2e0f_add_sequential_response_tracking.py +++ b/api/migrations/versions/3327ed0f2e0f_add_sequential_response_tracking.py @@ -5,11 +5,12 @@ Create Date: 2021-04-01 00:30:31.115702 """ -from alembic import op +from hashlib import sha256 +from os import urandom + import sqlalchemy as sa +from alembic import op from sqlalchemy.dialects import mysql -from os import urandom -from hashlib import sha256 # revision identifiers, used by Alembic. revision = "3327ed0f2e0f" diff --git a/api/migrations/versions/3d972cfa5be9_add_ta_for_and_professor_for_tables.py b/api/migrations/versions/3d972cfa5be9_add_ta_for_and_professor_for_tables.py new file mode 100644 index 000000000..52103825c --- /dev/null +++ b/api/migrations/versions/3d972cfa5be9_add_ta_for_and_professor_for_tables.py @@ -0,0 +1,120 @@ +"""ADD ta_for and professor_for tables + +Revision ID: 3d972cfa5be9 +Revises: b99d63327de0 +Create Date: 2021-04-27 14:47:30.881951 + +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = "3d972cfa5be9" +down_revision = "b99d63327de0" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "professor_for_course", + sa.Column("owner_id", sa.String(length=128), nullable=False), + sa.Column("course_id", sa.String(length=128), nullable=False), + sa.ForeignKeyConstraint( + ["course_id"], + ["course.id"], + ), + sa.ForeignKeyConstraint( + ["owner_id"], + ["user.id"], + ), + sa.PrimaryKeyConstraint("owner_id", "course_id"), + ) + op.create_table( + "ta_for_course", + sa.Column("owner_id", sa.String(length=128), nullable=False), + sa.Column("course_id", sa.String(length=128), nullable=False), + sa.ForeignKeyConstraint( + ["course_id"], + ["course.id"], + ), + sa.ForeignKeyConstraint( + ["owner_id"], + ["user.id"], + ), + sa.PrimaryKeyConstraint("owner_id", "course_id"), + ) + op.alter_column( + "assignment", "name", existing_type=mysql.MEDIUMTEXT(), nullable=False + ) + op.alter_column( + "assignment_repo", + "github_username", + existing_type=mysql.MEDIUMTEXT(), + nullable=False, + ) + op.alter_column( + "assignment_repo", + "repo_url", + existing_type=mysql.MEDIUMTEXT(), + nullable=False, + ) + op.alter_column( + "course", + "course_code", + existing_type=mysql.MEDIUMTEXT(), + nullable=False, + ) + op.alter_column( + "course", "name", existing_type=mysql.MEDIUMTEXT(), nullable=False + ) + op.alter_column( + "course", "professor", existing_type=mysql.MEDIUMTEXT(), nullable=False + ) + op.drop_column("user", "is_admin") + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "user", + sa.Column( + "is_admin", + mysql.TINYINT(display_width=1), + autoincrement=False, + nullable=False, + ), + ) + op.alter_column( + "course", "professor", existing_type=mysql.MEDIUMTEXT(), nullable=True + ) + op.alter_column( + "course", "name", existing_type=mysql.MEDIUMTEXT(), nullable=True + ) + op.alter_column( + "course", + "course_code", + existing_type=mysql.MEDIUMTEXT(), + nullable=True, + ) + op.alter_column( + "assignment_repo", + "repo_url", + existing_type=mysql.MEDIUMTEXT(), + nullable=True, + ) + op.alter_column( + "assignment_repo", + "github_username", + existing_type=mysql.MEDIUMTEXT(), + nullable=True, + ) + op.alter_column( + "assignment", "name", existing_type=mysql.MEDIUMTEXT(), nullable=True + ) + op.drop_table("ta_for_course") + op.drop_table("professor_for_course") + # ### end Alembic commands ### diff --git a/api/migrations/versions/4331be83342a_add_theia_options_to_assignment_and_.py b/api/migrations/versions/4331be83342a_add_theia_options_to_assignment_and_.py new file mode 100644 index 000000000..db893abbe --- /dev/null +++ b/api/migrations/versions/4331be83342a_add_theia_options_to_assignment_and_.py @@ -0,0 +1,42 @@ +"""ADD theia options to assignment and course + +Revision ID: 4331be83342a +Revises: d8b8114e003a +Create Date: 2021-04-27 14:19:24.005972 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "4331be83342a" +down_revision = "d8b8114e003a" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "assignment", sa.Column("theia_options", sa.JSON(), nullable=True) + ) + op.add_column( + "course", sa.Column("theia_default_image", sa.TEXT(), nullable=False) + ) + op.add_column( + "course", sa.Column("theia_default_options", sa.JSON(), nullable=True) + ) + conn = op.get_bind() + with conn.begin(): + conn.execute("update assignment set theia_options = '{}';") + conn.execute("update course set theia_default_image = '{}';") + conn.execute("update course set theia_default_options = '{}';") + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("course", "theia_default_options") + op.drop_column("course", "theia_default_image") + op.drop_column("assignment", "theia_options") + # ### end Alembic commands ### diff --git a/api/migrations/versions/452a7485f568_add_autograde_enabled_to_assignment.py b/api/migrations/versions/452a7485f568_add_autograde_enabled_to_assignment.py new file mode 100644 index 000000000..3c030c98a --- /dev/null +++ b/api/migrations/versions/452a7485f568_add_autograde_enabled_to_assignment.py @@ -0,0 +1,33 @@ +"""ADD autograde_enabled to assignment + +Revision ID: 452a7485f568 +Revises: 3d972cfa5be9 +Create Date: 2021-04-27 20:45:32.938022 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "452a7485f568" +down_revision = "3d972cfa5be9" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "assignment", + sa.Column("autograde_enabled", sa.Boolean(), nullable=True, default=True), + ) + conn = op.get_bind() + with conn.begin(): + conn.execute('update assignment set autograde_enabled = 1;') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('assignment', 'autograde_enabled') + # ### end Alembic commands ### diff --git a/api/migrations/versions/51a4f35fc53e_add_course_awareness_to_static.py b/api/migrations/versions/51a4f35fc53e_add_course_awareness_to_static.py new file mode 100644 index 000000000..7d42bb01a --- /dev/null +++ b/api/migrations/versions/51a4f35fc53e_add_course_awareness_to_static.py @@ -0,0 +1,67 @@ +"""ADD course awareness to static + +Revision ID: 51a4f35fc53e +Revises: 452a7485f568 +Create Date: 2021-04-27 21:37:27.563873 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "51a4f35fc53e" +down_revision = "452a7485f568" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + conn = op.get_bind() + with conn.begin(): + op.add_column( + "static_file", + sa.Column("course_id", sa.String(length=128)), + ) + op.create_index( + op.f("ix_static_file_course_id"), + "static_file", + ["course_id"], + unique=False, + ) + op.add_column( + "theia_session", + sa.Column("course_id", sa.String(length=128)), + ) + op.create_index( + op.f("ix_theia_session_course_id"), + "theia_session", + ["course_id"], + unique=False, + ) + op.create_foreign_key(None, "static_file", "course", ["course_id"], ["id"]) + op.create_foreign_key(None, "theia_session", "course", ["course_id"], ["id"]) + + try: + course_id, = conn.execute("select id from course where course_code = 'CS-UY 3224';").fetchone() + conn.execute("update static_file set course_id = %s;", (course_id,)) + conn.execute("update theia_session set course_id = %s;", (course_id,)) + + op.alter_column('static_file', 'course_id', existing_type=sa.String(128), nullable=False) + op.alter_column('theia_session', 'course_id', existing_type=sa.String(128), nullable=False) + except TypeError: + pass + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, "static_file", type_="foreignkey") + op.drop_index(op.f("ix_static_file_course_id"), table_name="static_file") + + op.drop_constraint(None, "theia_session", type_="foreignkey") + op.drop_index(op.f("ix_theia_session_course_id"), table_name="theia_session") + + op.drop_column("static_file", "course_id") + op.drop_column("theia_session", "course_id") + # ### end Alembic commands ### diff --git a/api/migrations/versions/b99d63327de0_chg_text_type_for_all_user_generated_.py b/api/migrations/versions/b99d63327de0_chg_text_type_for_all_user_generated_.py new file mode 100644 index 000000000..3a51ec3f8 --- /dev/null +++ b/api/migrations/versions/b99d63327de0_chg_text_type_for_all_user_generated_.py @@ -0,0 +1,83 @@ +"""CHG text type for all user generated strings + +Revision ID: b99d63327de0 +Revises: 4331be83342a +Create Date: 2021-04-27 14:20:44.854766 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = 'b99d63327de0' +down_revision = '4331be83342a' +branch_labels = None +depends_on = None + + +def upgrade(): + # User + op.alter_column('user', 'github_username', type_=sa.TEXT(length=2 ** 16)) + op.alter_column('user', 'name', type_=sa.TEXT(length=2 ** 16)) + + # Course + op.alter_column('course', 'name', type_=sa.TEXT(length=2 ** 16)) + op.alter_column('course', 'course_code', type_=sa.TEXT(length=2 ** 16)) + op.alter_column('course', 'semester', type_=sa.TEXT(length=2 ** 16)) + op.alter_column('course', 'section', type_=sa.TEXT(length=2 ** 16)) + op.alter_column('course', 'professor', type_=sa.TEXT(length=2 ** 16)) + + # Assignment + op.alter_column('assignment', 'name', type_=sa.TEXT(length=2 ** 16)) + op.alter_column('assignment', 'description', type_=sa.TEXT(length=2 ** 16)) + op.alter_column('assignment', 'github_classroom_url', type_=sa.TEXT(length=2 ** 16)) + op.alter_column('assignment', 'pipeline_image', type_=sa.TEXT(length=2 ** 16)) + + # Assignment repo + op.alter_column('assignment_repo', 'github_username', type_=sa.TEXT(length=2 ** 16)) + op.alter_column('assignment_repo', 'repo_url', type_=sa.TEXT(length=2 ** 16)) + + # Assignment test + op.alter_column('assignment_test', 'name', type_=sa.TEXT(length=2 ** 16)) + op.alter_column('assignment_question', 'code_language', type_=sa.TEXT(length=2 ** 16)) + + # Submission + op.alter_column('submission', 'state', type_=sa.TEXT(length=2 ** 16)) + + # Theia session + op.alter_column('theia_session', 'state', type_=sa.TEXT(length=2 ** 16)) + op.alter_column('theia_session', 'cluster_address', type_=sa.TEXT(length=2 ** 16)) + + +def downgrade(): + # User + op.alter_column('user', 'github_username', type_=sa.String(256)) + op.alter_column('user', 'name', type_=sa.String(256)) + + # Course + op.alter_column('course', 'name', type_=sa.String(256)) + op.alter_column('course', 'course_code', type_=sa.String(256)) + op.alter_column('course', 'semester', type_=sa.String(256)) + op.alter_column('course', 'section', type_=sa.String(256)) + op.alter_column('course', 'professor', type_=sa.String(256)) + + # Assignment + op.alter_column('assignment', 'name', type_=sa.String(256)) + op.alter_column('assignment', 'description', type_=sa.String(256)) + op.alter_column('assignment', 'github_classroom_url', type_=sa.String(256)) + op.alter_column('assignment', 'pipeline_image', type_=sa.String(256)) + + # Assignment repo + op.alter_column('assignment_repo', 'github_username', type_=sa.String(256)) + op.alter_column('assignment_repo', 'repo_url', type_=sa.String(256)) + + # Assignment test + op.alter_column('assignment_test', 'name', type_=sa.String(256)) + op.alter_column('assignment_question', 'code_language', type_=sa.String(256)) + + # Submission + op.alter_column('submission', 'state', type_=sa.String(256)) + + # Theia session + op.alter_column('theia_session', 'state', type_=sa.String(256)) + op.alter_column('theia_session', 'cluster_address', type_=sa.String(256)) diff --git a/api/migrations/versions/d8b8114e003a_add_hidden_to_assignment_tests.py b/api/migrations/versions/d8b8114e003a_add_hidden_to_assignment_tests.py index 4c3a32f70..f2cc155ed 100644 --- a/api/migrations/versions/d8b8114e003a_add_hidden_to_assignment_tests.py +++ b/api/migrations/versions/d8b8114e003a_add_hidden_to_assignment_tests.py @@ -5,9 +5,8 @@ Create Date: 2021-04-13 11:44:17.221824 """ -from alembic import op import sqlalchemy as sa - +from alembic import op # revision identifiers, used by Alembic. revision = "d8b8114e003a" diff --git a/api/requirements-dev.txt b/api/requirements-dev.txt deleted file mode 100644 index 1b9e36737..000000000 --- a/api/requirements-dev.txt +++ /dev/null @@ -1,3 +0,0 @@ -black -autopep8 -pytest diff --git a/api/tests/seed.py b/api/tests/seed.py new file mode 100644 index 000000000..787b1ac18 --- /dev/null +++ b/api/tests/seed.py @@ -0,0 +1,3 @@ +from anubis.rpc.seed import seed + +seed() diff --git a/api/tests/test.sh b/api/tests/test.sh index 94c12049e..e63730ccd 100755 --- a/api/tests/test.sh +++ b/api/tests/test.sh @@ -13,5 +13,12 @@ source venv/bin/activate popd -export PYTHONPATH="${TEST_ROOT}:${API_ROOT}" + +export PYTHONPATH="${TEST_ROOT}:${API_ROOT}" DISABLE_ELK=1 DB_HOST=127.0.0.1 +if (( $# == 0 )); then + echo 'seeding data...' + python seed.py &>/dev/null +fi + +echo 'Running tests...' exec pytest -p no:warnings $@ diff --git a/api/tests/test_assignment_admin.py b/api/tests/test_assignment_admin.py new file mode 100644 index 000000000..edb24d324 --- /dev/null +++ b/api/tests/test_assignment_admin.py @@ -0,0 +1,40 @@ +from datetime import datetime, timedelta + +from utils import Session, permission_test + +sample_sync = { + "name": "name", + "course": "CS-UY 3224", + "hidden": True, + "github_classroom_url": "", + "unique_code": "aa11bb22", + "pipeline_image": "registry.osiris.services/anubis/assignment/aa11bb2233", + "date": { + "release": str(datetime.now() - timedelta(hours=2)), + "due": str(datetime.now() + timedelta(hours=12)), + "grace": str(datetime.now() + timedelta(hours=13)), + }, + "description": "This is a very long description that encompasses the entire assignment\n", + "questions": [ + {"sequence": 1, "questions": [{"q": "What is 3*4?", "a": "12"}, {"q": "What is 3*2", "a": "6"}]}, + {"sequence": 2, "questions": [{"q": "What is sqrt(144)?", "a": "12"}]} + ], + "tests": ["abc123"], +} + + +def test_assignment_admin(): + superuser = Session('superuser') + + permission_test('/admin/assignments/list') + + assignment = superuser.get('/admin/assignments/list')['assignments'][0] + assignment_id = assignment['id'] + _assignment = superuser.get(f'/admin/assignments/get/{assignment_id}')['assignment'] + assignment_test_id = _assignment['tests'][0]['id'] + + permission_test(f'/admin/assignments/get/{assignment_id}') + permission_test(f'/admin/assignments/assignment/{assignment_id}/questions/get/student') + permission_test(f'/admin/assignments/tests/toggle-hide/{assignment_test_id}') + permission_test(f'/admin/assignments/save', method='post', json={'assignment': assignment}) + permission_test(f'/admin/assignments/sync', method='post', json={'assignment': sample_sync}) diff --git a/api/tests/test_assignments_public.py b/api/tests/test_assignments_public.py new file mode 100644 index 000000000..d8026b974 --- /dev/null +++ b/api/tests/test_assignments_public.py @@ -0,0 +1,11 @@ +from utils import Session + + +def test_assignment_public(): + s = Session('student') + s.get('/public/assignments/') + s.get('/public/assignments/list') + + s = Session('superuser') + s.get('/public/assignments/') + s.get('/public/assignments/list') diff --git a/api/anubis/utils/assignment/__init__.py b/api/tests/test_auth_admin.py similarity index 100% rename from api/anubis/utils/assignment/__init__.py rename to api/tests/test_auth_admin.py diff --git a/api/tests/test_autograde_admin.py b/api/tests/test_autograde_admin.py new file mode 100644 index 000000000..2ec6af894 --- /dev/null +++ b/api/tests/test_autograde_admin.py @@ -0,0 +1,11 @@ +from utils import Session, permission_test, get_student_id + + +def test_autograde_admin(): + superuser = Session('superuser') + assignment_id = superuser.get('/admin/assignments/list')['assignments'][0]['id'] + student_id = get_student_id() + + permission_test(f'/admin/autograde/assignment/{assignment_id}') + permission_test(f'/admin/autograde/for/{assignment_id}/{student_id}') + permission_test(f'/admin/autograde/submission/{assignment_id}/student') diff --git a/api/tests/test_config_admin.py b/api/tests/test_config_admin.py new file mode 100644 index 000000000..8e78cf223 --- /dev/null +++ b/api/tests/test_config_admin.py @@ -0,0 +1,12 @@ +from utils import permission_test + +sample_config = [ + {'key': 'MAX_IDES', 'value': '75'} +] + + +def test_config_admin(): + permission_test('/admin/config/list') + permission_test('/admin/config/save', method='post', json={'config': sample_config}, fail_for=[ + 'student', 'professor', 'ta', + ]) diff --git a/api/tests/test_courses_admin.py b/api/tests/test_courses_admin.py new file mode 100644 index 000000000..8c7df0c68 --- /dev/null +++ b/api/tests/test_courses_admin.py @@ -0,0 +1,45 @@ +from utils import Session, permission_test + + +def test_courses_admin(): + superuser = Session('superuser') + student = Session('student', new=True) + student_id = student.get('/public/auth/whoami')['user']['id'] + + permission_test('/admin/courses/') + permission_test('/admin/courses/list') + + course = superuser.get('/admin/courses/list')['course'] + + permission_test('/admin/courses/new', fail_for=['student', 'ta', 'professor']) + permission_test('/admin/courses/list/tas') + permission_test('/admin/courses/list/professors') + + permission_test('/admin/courses/save', method='post', json={'course': course}, fail_for=[ + 'student', 'ta', + ]) + + permission_test( + f'/admin/courses/make/ta/{student_id}', + after=lambda: superuser.get(f'/admin/courses/remove/ta/{student_id}', skip_verify=True, return_request=True), + ) + superuser.get(f'/admin/courses/make/ta/{student_id}', skip_verify=True, return_request=True) + permission_test( + f'/admin/courses/remove/ta/{student_id}', + after=lambda: superuser.get(f'/admin/courses/make/ta/{student_id}', skip_verify=True, return_request=True), + fail_for=['student', 'ta'] + ) + + permission_test( + f'/admin/courses/make/professor/{student_id}', + after=lambda: superuser.get(f'/admin/courses/remove/professor/{student_id}', skip_verify=True, + return_request=True), + fail_for=['student', 'ta', 'professor'] + ) + superuser.get(f'/admin/courses/make/professor/{student_id}', skip_verify=True, return_request=True) + permission_test( + f'/admin/courses/remove/professor/{student_id}', + after=lambda: superuser.get(f'/admin/courses/make/professor/{student_id}', skip_verify=True, + return_request=True), + fail_for=['student', 'ta', 'professor'] + ) diff --git a/api/tests/test_courses_public.py b/api/tests/test_courses_public.py new file mode 100644 index 000000000..3fe70eb3b --- /dev/null +++ b/api/tests/test_courses_public.py @@ -0,0 +1,16 @@ +from utils import Session + + +def test_courses_public(): + s = Session('student') + s.get('/public/courses/') + courses = s.get('/public/courses/list')['courses'] + join_code = courses[0]['join_code'] + + sn = Session('student', new=True, add_to_os=False) + courses = sn.get('/public/courses/list')['courses'] + assert len(courses) == 0 + sn.get(f'/public/courses/join/aaa', should_fail=True) + sn.get(f'/public/courses/join/{join_code}') + courses = sn.get('/public/courses/list')['courses'] + assert len(courses) == 1 diff --git a/api/tests/test_dangling_admin.py b/api/tests/test_dangling_admin.py new file mode 100644 index 000000000..5dbb49ce1 --- /dev/null +++ b/api/tests/test_dangling_admin.py @@ -0,0 +1,7 @@ +from utils import permission_test + + +def test_dangling_admin(): + permission_test('/admin/dangling/list', fail_for=['student', 'ta', 'professor']) + permission_test('/admin/dangling/reset', fail_for=['student', 'ta', 'professor']) + permission_test('/admin/dangling/fix', fail_for=['student', 'ta', 'professor']) diff --git a/api/tests/test_ide_admin.py b/api/tests/test_ide_admin.py new file mode 100644 index 000000000..2f0fcb57f --- /dev/null +++ b/api/tests/test_ide_admin.py @@ -0,0 +1,16 @@ +from utils import permission_test + +settings_sample = { + 'network_locked': False, + 'privileged': True, + 'image': 'registry.osiris.services/anubis/theia-admin', + 'repo_url': 'https://github.com/os3224/anubis-assignment-tests', + 'options': '{"limits": {"cpu": "4", "memory": "4Gi"}}', +} + + +def test_ide_admin(): + permission_test('/admin/ide/initialize', method='post', json={'settings': settings_sample}) + permission_test('/admin/ide/active') + permission_test('/admin/ide/list') + permission_test('/admin/ide/reap-all') diff --git a/api/tests/test_ide_public.py b/api/tests/test_ide_public.py new file mode 100644 index 000000000..d7fabff85 --- /dev/null +++ b/api/tests/test_ide_public.py @@ -0,0 +1,35 @@ +from utils import Session, create_repo + + +def test_ide_public(): + s = Session('student', new=True) + s.get('/public/ide/available') + assignments = s.get('/public/assignments/list')['assignments'] + assignment_id = assignments[0]['id'] + + s.get(f'/public/ide/active/{assignment_id}') + active = s.get(f'/public/ide/active/{assignment_id}')['active'] + assert active is None + + s.get(f'/public/ide/initialize/{assignment_id}', should_fail=True) + + create_repo(s, assignment_id) + + resp = s.get(f'/public/ide/initialize/{assignment_id}') + assert resp['session'] is not None + assert resp['active'] + session_id = resp['session']['id'] + + active = s.get(f'/public/ide/active/{assignment_id}')['active'] + assert active is not None + + s.get(f'/public/ide/poll/{session_id}') + s.get(f'/public/ide/poll/{session_id}') + s.get(f'/public/ide/poll/{session_id}') + s.get(f'/public/ide/redirect-url/{session_id}') + + s.get(f'/public/ide/stop/{session_id}') + + resp = s.get(f'/public/ide/active/{assignment_id}') + assert not resp['active'] + assert 'session' not in resp diff --git a/api/tests/test_profile_public.py b/api/tests/test_profile_public.py new file mode 100644 index 000000000..070b974b3 --- /dev/null +++ b/api/tests/test_profile_public.py @@ -0,0 +1,16 @@ +from anubis.utils.data import rand +from utils import Session + + +def test_profile_public(): + s = Session('student') + + s.get( + '/public/profile/set-github-username', + params={'github_username': 'professor'}, should_fail=True + ) + + s.get( + '/public/profile/set-github-username', + params={'github_username': rand(8)}, + ) diff --git a/api/tests/test_questions_admin.py b/api/tests/test_questions_admin.py new file mode 100644 index 000000000..e4add29c1 --- /dev/null +++ b/api/tests/test_questions_admin.py @@ -0,0 +1,45 @@ +from utils import Session, permission_test + +sample_question = { + 'question': 'question', + 'solution': 'solution', + 'code_language': 'markdown', + 'code_question': True, + 'sequence': 0, +} + + +def test_questions_admin(): + superuser = Session('superuser') + professor = Session('professor') + student = Session('student') + ta = Session('ta') + + unique_code = superuser.get('/admin/assignments/list')['assignments'][0]['unique_code'] + superuser.get(f'/admin/questions/reset-assignments/{unique_code}') + + permission_test(f'/admin/questions/get/{unique_code}') + permission_test(f'/admin/questions/get-assignments/{unique_code}') + + permission_test(f'/admin/questions/add/{unique_code}') + permission_test(f'/admin/questions/add/{unique_code}') + questions = superuser.get(f'/admin/questions/get/{unique_code}')['questions'] + print(questions) + question_id = questions['0'][0]['id'] + permission_test(f'/admin/questions/update/{question_id}', method='post', json={'question': sample_question}) + + superuser.get(f'/admin/questions/delete/{questions["0"][0]["id"]}') + professor.get(f'/admin/questions/delete/{questions["0"][1]["id"]}') + ta.get(f'/admin/questions/delete/{questions["0"][2]["id"]}') + student.get(f'/admin/questions/delete/{questions["0"][3]["id"]}', should_fail=True) + + permission_test(f'/admin/questions/add/{unique_code}') + superuser.get(f'/admin/questions/reset-assignments/{unique_code}') + permission_test( + f'/admin/questions/assign/{unique_code}', + after=lambda: superuser.get(f'/admin/questions/reset-assignments/{unique_code}') + ) + + superuser.get(f'/admin/questions/reset-assignments/{unique_code}') + permission_test(f'/admin/questions/reset-assignments/{unique_code}', fail_for=['student', 'ta']) + permission_test(f'/admin/questions/hard-reset/{unique_code}', fail_for=['student', 'ta']) diff --git a/api/tests/test_questions_public.py b/api/tests/test_questions_public.py new file mode 100644 index 000000000..72e32728c --- /dev/null +++ b/api/tests/test_questions_public.py @@ -0,0 +1,26 @@ +from utils import Session + + +def test_questions_public(): + s = Session('student') + assignment_id = s.get('/public/assignments/list')['assignments'][0]['id'] + + questions = s.get(f'/public/questions/get/{assignment_id}')['questions'] + s.get(f'/public/questions/get/notanid', should_fail=True) + + for index, question in enumerate(questions): + question_id = question['id'] + s.post_json( + f'/public/questions/save/{question_id}', + json={'response': 'test123'} + ) + s.post_json( + f'/public/questions/save/{question_id}', + json={'response': 1}, should_fail=True, + ) + s.post_json( + f'/public/questions/save/{question_id}', + json={'response': None}, should_fail=True, + ) + _questions = s.get(f'/public/questions/get/{assignment_id}')['questions'] + assert _questions[index]['response'] == 'test123' diff --git a/api/tests/test_regrade_admin.py b/api/tests/test_regrade_admin.py new file mode 100644 index 000000000..be5986fcf --- /dev/null +++ b/api/tests/test_regrade_admin.py @@ -0,0 +1,24 @@ +from anubis.models import Submission +from utils import Session, permission_test, with_context + + +@with_context +def get_student_submission_commit(assignment_ids): + for assignment_id in assignment_ids: + submission = Submission.query.filter( + Submission.assignment_id == assignment_id, + Submission.owner_id != None, + ).first() + if submission is not None: + return submission.commit, assignment_id + + +def test_regrade_admin(): + superuser = Session('superuser') + assignments = superuser.get('/admin/assignments/list')['assignments'] + assignment_ids = list(map(lambda x: x['id'], assignments)) + commit, assignment_id = get_student_submission_commit(assignment_ids) + + permission_test(f'/admin/regrade/status/{assignment_id}') + permission_test(f'/admin/regrade/submission/{commit}') + permission_test(f'/admin/regrade/assignment/{assignment_id}') diff --git a/api/tests/test_repos_public.py b/api/tests/test_repos_public.py new file mode 100644 index 000000000..5e98761e7 --- /dev/null +++ b/api/tests/test_repos_public.py @@ -0,0 +1,16 @@ +from utils import Session, create_repo + + +def test_repos_public(): + s = Session('student', new=True) + + s.get('/public/repos/') + repos = s.get('/public/repos/list')['repos'] + assert len(repos) == 0 + + create_repo(s) + + repos = s.get('/public/repos/list')['repos'] + assert len(repos) == 1 + repos = s.get('/public/repos/')['repos'] + assert len(repos) == 1 diff --git a/api/tests/test_static.py b/api/tests/test_static.py deleted file mode 100644 index ae5470434..000000000 --- a/api/tests/test_static.py +++ /dev/null @@ -1,25 +0,0 @@ -import io - -import requests - - -def test_upload(): - logo = requests.get('https://linux.org/images/logo.png').content - filename = 'logo.png' - - for _ in range(5): - logo_file = io.BytesIO(logo) - r = requests.post( - 'http://localhost/api/admin/static/upload', - files={ - filename: logo_file, - }, - ) - - print(r.text) - - assert r.status_code == 200 - - -if __name__ == '__main__': - test_upload() diff --git a/api/tests/test_static_admin.py b/api/tests/test_static_admin.py new file mode 100644 index 000000000..be16c0a36 --- /dev/null +++ b/api/tests/test_static_admin.py @@ -0,0 +1,32 @@ +import io + +import requests + +from utils import Session + + +def test_static_admin(): + logo = requests.get('https://linux.org/images/logo.png').content + filename = 'logo.png' + + student = Session('student') + logo_file = io.BytesIO(logo) + student.post('/admin/static/upload', files={filename: logo_file}, should_fail=True) + + prof = Session('professor') + for _ in range(5): + logo_file = io.BytesIO(logo) + prof.post('/admin/static/upload', files={filename: logo_file}) + prof.get('/admin/static/list') + + ta = Session('ta') + for _ in range(5): + logo_file = io.BytesIO(logo) + ta.post('/admin/static/upload', files={filename: logo_file}) + ta.get('/admin/static/list') + + su = Session('superuser') + for _ in range(5): + logo_file = io.BytesIO(logo) + su.post('/admin/static/upload', files={filename: logo_file}) + su.get('/admin/static/list') diff --git a/api/tests/test_static_public.py b/api/tests/test_static_public.py new file mode 100644 index 000000000..bf8bd5a13 --- /dev/null +++ b/api/tests/test_static_public.py @@ -0,0 +1,26 @@ +import io + +import requests + +from utils import Session + + +def test_static_public(): + logo = requests.get('https://linux.org/images/logo.png').content + filename = 'logo.png' + prof = Session('professor') + logo_file = io.BytesIO(logo) + blob_id = prof.post('/admin/static/upload', files={filename: logo_file})['blob']['path'].lstrip('/') + + student = Session('student') + r = student.get(f'/public/static/{blob_id}', return_request=True, skip_verify=True) + assert r.status_code == 200 + assert r.headers.get('content-type') == 'image/png' + + r = student.get(f'/public/static/{blob_id}/logo.png', return_request=True, skip_verify=True) + assert r.status_code == 200 + assert r.headers.get('content-type') == 'image/png' + + r = student.get(f'/public/static/{blob_id}/logo.pn', return_request=True, skip_verify=True) + assert r.status_code == 404 + assert r.text.startswith('404 Not Found :(') diff --git a/api/tests/test_students_admin.py b/api/tests/test_students_admin.py new file mode 100644 index 000000000..6d080a8a9 --- /dev/null +++ b/api/tests/test_students_admin.py @@ -0,0 +1,15 @@ +from utils import Session, permission_test + + +def test_students_admin(): + student = Session('student', new=True) + student_id = student.get('/public/auth/whoami')['user']['id'] + + permission_test('/admin/students/list') + permission_test('/admin/students/list/basic') + permission_test(f'/admin/students/info/{student_id}') + permission_test(f'/admin/students/submissions/{student_id}') + permission_test(f'/admin/students/update/{student_id}', method='post', json={ + 'name': 'student', 'github_username': 'student', + }, fail_for=['student', 'ta']) + permission_test(f'/admin/students/toggle-superuser/{student_id}', fail_for=['student', 'ta', 'professor']) diff --git a/api/tests/test_visuals_admin.py b/api/tests/test_visuals_admin.py new file mode 100644 index 000000000..89e340c8c --- /dev/null +++ b/api/tests/test_visuals_admin.py @@ -0,0 +1,10 @@ +from utils import Session, permission_test + + +def test_visuals_admin(): + superuser = Session('superuser') + assignment_id = superuser.get('/admin/assignments/list')['assignments'][0]['id'] + + student = Session('student') + student.get(f'/admin/visuals/assignment/{assignment_id}', should_fail=True) + permission_test(f'/admin/visuals/assignment/{assignment_id}') diff --git a/api/tests/test_visuals_public.py b/api/tests/test_visuals_public.py new file mode 100644 index 000000000..e717cc4ad --- /dev/null +++ b/api/tests/test_visuals_public.py @@ -0,0 +1,9 @@ +from utils import Session + + +def test_visuals_public(): + student = Session('student') + usage = student.get('/public/visuals/usage', return_request=True, skip_verify=True) + assert usage.status_code == 200 + + student.get('/public/visuals/raw-usage') diff --git a/api/tests/test_webhooks.py b/api/tests/test_webhook_public.py similarity index 86% rename from api/tests/test_webhooks.py rename to api/tests/test_webhook_public.py index aa6113e29..eb068f06a 100644 --- a/api/tests/test_webhooks.py +++ b/api/tests/test_webhook_public.py @@ -5,7 +5,7 @@ import requests from anubis.models import db, User, Assignment, InCourse, Course -from utils import app_context, do_seed +from anubis.utils.data import with_context def pp(data: dict): @@ -36,8 +36,7 @@ def gen_webhook(name, code, username, after=None, before=None, ref="refs/heads/m def post_webhook(webhook): return requests.post( - 'http://localhost:5000/public/webhook/', - json=webhook, + 'http://localhost:5000/public/webhook/', json=webhook, headers={'Content-Type': 'application/json', 'X-GitHub-Event': 'push'}, ) @@ -46,28 +45,30 @@ def gen_rand(n: int = 40): return hashlib.sha256(os.urandom(12)).hexdigest()[:n] -@app_context +@with_context def create_user(github_username: str): u = User(netid=gen_rand(6), name=gen_rand(6), github_username=github_username) - c = Course.query.first() + c = Course.query.filter(Course.name == 'Intro to OS').first() ic = InCourse(course=c, owner=u) db.session.add_all([u, ic]) db.session.commit() return u.github_username -@app_context +@with_context def do_webhook_tests_user(github_username): user = User.query.filter_by(github_username=github_username).first() import pymysql - connection = pymysql.connect(host='localhost', - user='anubis', - password='anubis', - database='anubis', - charset='utf8mb4') + connection = pymysql.connect( + host='localhost', + user='anubis', + password='anubis', + database='anubis', + charset='utf8mb4' + ) - assignment = Assignment.query.filter_by(name='uniq').first() + assignment = Assignment.query.join(Course).filter(Course.name == 'Intro to OS').first() r = post_webhook( gen_webhook(assignment.name, assignment.unique_code, user.github_username, "0" * 40, "0" * 40)).json() assert r['data'] == 'initial commit' @@ -116,12 +117,10 @@ def do_webhook_tests_user(github_username): def test_webhooks(): - do_seed() - username1 = create_user(f'abc123') username2 = create_user(f'{gen_rand(3)}-{gen_rand(3)}') - do_webhook_tests_user('wabscale') + do_webhook_tests_user('superuser') do_webhook_tests_user(username1) do_webhook_tests_user(username2) diff --git a/api/tests/utils.py b/api/tests/utils.py index dcb4a1b58..2ec5fdd3b 100644 --- a/api/tests/utils.py +++ b/api/tests/utils.py @@ -1,16 +1,15 @@ -import functools +import base64 import json import os -import random -import string import sys import traceback import requests from anubis.app import create_app -from anubis.models import db, User -from anubis.utils.users.auth import create_token +from anubis.models import db, User, Course, InCourse, TAForCourse, ProfessorForCourse, AssignmentRepo +from anubis.utils.data import with_context +from anubis.utils.seed import create_name, create_netid os.environ["DEBUG"] = "1" os.environ["DISABLE_ELK"] = "1" @@ -34,26 +33,6 @@ def initialize_env(): sys.path.append(api_root) -def app_context(func): - @functools.wraps(func) - def wrapper(*args, **kwargs): - initialize_env() - - from anubis.app import create_app - app = create_app() - - with app.app_context(): - return func(*args, **kwargs) - - return wrapper - - -@app_context -def do_seed(): - from anubis.rpc.seed import seed_main - seed_main() - - def format_exception(e: Exception): exception_list = traceback.format_stack() exception_list = exception_list[:-2] @@ -90,30 +69,58 @@ def print_full_error(e, r): exit(1) -def _create_user_session(url: str, name: str, netid: str, admin: bool, superuser: bool): - """ - Create a new user on the backend +@with_context +def create_user(permission: str = 'superuser', add_to_os: bool = True): + assert permission in ['superuser', 'professor', 'ta', 'student'] - :return: requests session - """ + name = create_name() + netid = create_netid(name) # Create user in db user = User( netid=netid, name=name, github_username=netid, - is_admin=admin, - is_superuser=superuser, + is_superuser=permission == 'superuser', ) db.session.add(user) + + course = Course.query.filter(Course.name == 'Intro to OS').first() + + if add_to_os: + db.session.add(InCourse( + owner=user, + course=course, + )) + if permission == 'ta': + db.session.add(TAForCourse( + course=course, + owner=user, + )) + if permission == 'professor': + db.session.add(ProfessorForCourse( + course=course, + owner=user, + )) db.session.commit() - # Get user token - token = create_token(netid) + return netid + + +def _create_user_session(url: str, netid: str = 'superuser', new: bool = False, add_to_os: bool = True): + """ + Create a new user on the backend + + :return: requests session + """ # Create requests session session = requests.session() - session.cookies["token"] = token + + if new: + netid = create_user(netid, add_to_os) + + session.get(url + f'/admin/auth/token/{netid}') r = session.get(url + "/public/auth/whoami") try: @@ -122,29 +129,16 @@ def _create_user_session(url: str, name: str, netid: str, admin: bool, superuser assert data["success"] is True assert data["data"] is not None assert data["error"] is None + admin_for = data['data']['user']['admin_for'] + for i in admin_for: + if i['name'] == 'Intro to OS': + session.cookies['course'] = base64.urlsafe_b64encode(json.dumps(i).encode()).decode() except AssertionError as e: print_full_error(e, r) - return session + return session, netid -def create_name() -> str: - name_file_path = os.path.join( - os.path.dirname(os.path.realpath(__file__)), "names.json" - ) - name_file = open(name_file_path) - names = json.load(name_file) - - return f"{random.choice(names)} {random.choice(names)}" - - -def create_netid(name: str) -> str: - initials = "".join(word[0].lower() for word in name.split()) - numbers = "".join(random.choice(string.digits) for _ in range(3)) - - return f"{initials}{numbers}" - - -class TestSession(object): +class Session(object): """ This object will provide you with a functioning test session with a ransom user set up with it. You can call the get and post functions @@ -156,14 +150,13 @@ class TestSession(object): """ def __init__( - self, domain: str = "localhost", port: int = 80, admin=False, superuser=False + self, permission: str = 'superuser', new: bool = False, add_to_os: bool = True, + domain: str = "localhost", port: int = 80, ): self.url = f"http://{domain}:{port}/api" self.timings = [] - self.name = create_name() - self.netid = create_netid(self.name) - self._session = _create_user_session( - self.url, self.name, self.netid, admin, superuser + self._session, self.netid = _create_user_session( + self.url, permission, new=new, add_to_os=add_to_os ) @staticmethod @@ -246,33 +239,17 @@ def get( ) def post( - self, - path, - return_request=False, - should_succeed=True, - should_fail=False, - skip_verify=False, - **kwargs, + self, path, return_request=False, should_succeed=True, + should_fail=False, skip_verify=False, **kwargs, ): return self._make_request( - path, - self._session.post, - return_request, - should_succeed, - should_fail, - skip_verify, - **kwargs, + path, self._session.post, return_request, should_succeed, + should_fail, skip_verify, **kwargs, ) def post_json( - self, - path, - json, - return_request=False, - should_succeed=True, - should_fail=False, - skip_verify=False, - **kwargs, + self, path, json, return_request=False, should_succeed=True, + should_fail=False, skip_verify=False, **kwargs, ): kwargs["json"] = json if "headers" not in kwargs: @@ -300,9 +277,57 @@ def run_main(func): return func() +@with_context +def create_repo(s: Session, assignment_id: str = None): + if assignment_id is None: + assignments = s.get('/public/assignments/list')['assignments'] + assignment_id = assignments[0]['id'] + user = User.query.filter(User.netid == s.netid).first() + db.session.add(AssignmentRepo( + owner=user, + assignment_id=assignment_id, + github_username=s.netid, + repo_url='https://github.com/wabscale/xv6-public', + )) + db.session.commit() + + +@with_context +def get_student_id(): + student = User.query.filter(User.netid == 'student').first() + return student.id + + +def permission_test(path, fail_for: list = None, method='get', after: callable = None, **kwargs): + def _test_permission(_path, _fail_for, _method, _after, **_kwargs): + sessions = { + 'student': Session('student'), + 'ta': Session('ta'), + 'professor': Session('professor'), + 'superuser': Session('superuser'), + } + + for permission, session in sessions.items(): + if _method == 'get': + request_func = session.get + elif _method == 'post': + request_func = session.post_json + else: + request_func = session.get + request_func(path, should_fail=permission in _fail_for, **_kwargs) + + if _after is not None: + _after() + + if fail_for is None: + fail_for = ['student'] + + return _test_permission(path, fail_for, method, after, **kwargs) + + if __name__ == "__main__": def test_this_file(): - ts = TestSession() + ts = Session() ts.get("/public/auth/whoami") diff --git a/api/usage.py b/api/usage.py deleted file mode 100644 index c3835e1a3..000000000 --- a/api/usage.py +++ /dev/null @@ -1,11 +0,0 @@ -import matplotlib.pyplot as plt -import pandas as pd -import numpy as np -import pymysql - -from datetime import datetime -import os - -from anubis.app import create_app - - diff --git a/docs/design.md b/docs/design.md index 214b77240..6167b7aec 100644 --- a/docs/design.md +++ b/docs/design.md @@ -325,6 +325,11 @@ repo is cloned instead of a student assignment repo. It has less networking rest and even has docker running right in the pod. With this, TA's and professors can create, modify, and deploy assignments. +Using some very clever authentication mechanics, the management IDEs will be initialized with a personal token +that will be used by the CLI within the container. The result of this is that you will be able to just use the +anubis CLI. It will be routed to the in cluster API instances. All the requests you make to the API will be +authenticated using that personal token that gets dropped into the pod. + ![Management IDE](./img/theia4.png) ### 2.6 Datastores @@ -600,7 +605,8 @@ Creating assignment directory... Initializing the assignment with sample data... You now have an Anubis assignment initialized at new-assignment -cd into that directory and run the sync command to upload it to Anubis. +cd into that directory and run the sync command to upload it to +Anubis. cd new-assignment anubis assignment build --push @@ -638,8 +644,10 @@ functions. Here is a minimal example of an assignment.py that will build and run ```python from utils import register_test, register_build, exec_as_student -from utils import TestResult, BuildResult, Panic, DEBUG, xv6_run, did_xv6_crash, verify_expected - +from utils import ( + TestResult, BuildResult, Panic, DEBUG, + xv6_run, did_xv6_crash, verify_expected +) @register_build def build(build_result: BuildResult): @@ -681,6 +689,10 @@ Now you have your tests. That's great. The next thing you need to do is push the upload the assignment data to anubis. This is as simple as running two commands: ```shell -anubis assignment sync # sends assignment metadata to anubis -anubis assignment build --push # builds then pushes the assignment pipeline image to the registry +# sends assignment metadata to anubis +anubis assignment sync + +# builds then pushes the assignment +# pipeline image to the registry +anubis assignment build --push ``` diff --git a/docs/design.pdf b/docs/design.pdf index 0e0e2e7cd770b344e7e444d61f1d99e497861a5a..c5f854c03a6e3de71b7c211920b74cdfd3611cb8 100644 GIT binary patch delta 15604 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 byb(+rVSY&W96}+2VQ1!sAtx7Clz{m^Ui$=) delta 15073 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 ZQ*OqTH2Fc)U$Bg
Anubis
users
Cluster
Traefik
Anubis Cloud IDE
API
Static
logging
State Stores
rpc_cluster
submission pipeline jobs
students
Admins
Ingress
Ingress
Ingress
Ingress
Ingress
Ingress
forward
forward
forward
create
create
create
log
log
log
cache or push rpc
cache or push rpc
cache or push rpc
log stream
push state updates
push state updates
push state updates
create
create
create
rpc poll
rpc poll
rpc poll
rpc poll
store
submission job 1337
submission job 1338
submission job 1339
pipeline API
RPC Worker
RPC Worker
RPC Worker
RPC Worker
Database
elasticsearch
redis cache
logstash
web static
web static
web static
API 1
API 1
API 1
theia proxy
theia proxy
theia session 1337
theia session 1338
theia session 1339
Traefik
TA
Professor
student
student
student
student
student
student
student
student
\ No newline at end of file +
Anubis
users
Cluster
Traefik
Anubis Cloud IDE
API
Static
logging
State Stores
rpc_cluster
submission pipeline jobs
students
Admins
Ingress
Ingress
Ingress
Ingress
Ingress
Ingress
forward
forward
forward
create
create
create
log
log
log
cache or push rpc
cache or push rpc
cache or push rpc
log stream
push state updates
push state updates
push state updates
create
create
create
rpc poll
rpc poll
rpc poll
rpc poll
store
submission job 1337
submission job 1338
submission job 1339
pipeline API
RPC Worker
RPC Worker
RPC Worker
RPC Worker
Database
elasticsearch
redis cache
logstash
web static
web static
web static
API 1
API 1
API 1
theia proxy
theia proxy
theia session 1337
theia session 1338
theia session 1339
Traefik
TA
Professor
student
student
student
student
student
student
student
student
\ No newline at end of file diff --git a/docs/img/rpc-queue-1.mmd.svg b/docs/img/rpc-queue-1.mmd.svg index e3e0f7e38..73c47e720 100644 --- a/docs/img/rpc-queue-1.mmd.svg +++ b/docs/img/rpc-queue-1.mmd.svg @@ -1 +1 @@ -
Anubis
RPC Queue
RPC worker pool
enqueue
dequeue
API
worker
job1
...
job3
\ No newline at end of file +
Anubis
RPC Queue
RPC worker pool
enqueue
dequeue
API
worker
job1
...
job3
\ No newline at end of file diff --git a/docs/img/rpc-queue-2.mmd.svg b/docs/img/rpc-queue-2.mmd.svg index 0c5f21196..b65501760 100644 --- a/docs/img/rpc-queue-2.mmd.svg +++ b/docs/img/rpc-queue-2.mmd.svg @@ -1 +1 @@ -
Anubis
RPC Queue
RPC worker pool
enqueue
dequeue
API
worker
New Theia Start Job
job1
...
job3
\ No newline at end of file +
Anubis
RPC Queue
RPC worker pool
enqueue
dequeue
API
worker
New Theia Start Job
job1
...
job3
\ No newline at end of file diff --git a/docs/img/rpc-queue-3.mmd.svg b/docs/img/rpc-queue-3.mmd.svg index 64462f74c..174ce7449 100644 --- a/docs/img/rpc-queue-3.mmd.svg +++ b/docs/img/rpc-queue-3.mmd.svg @@ -1 +1 @@ -
Anubis
RPC default Queue
RPC theia Queue
RPC default worker pool
RPC theia worker pool
enqueue theia start
enqueue regrade
dequeue
dequeue
API
worker
worker
New Theia Start Job
job3
...
job1
\ No newline at end of file +
Anubis
RPC default Queue
RPC theia Queue
RPC default worker pool
RPC theia worker pool
enqueue theia start
enqueue regrade
dequeue
dequeue
API
worker
worker
New Theia Start Job
job3
...
job1
\ No newline at end of file diff --git a/docs/img/submission-flow.mmd.svg b/docs/img/submission-flow.mmd.svg index 53d9b0097..dc1da667a 100644 --- a/docs/img/submission-flow.mmd.svg +++ b/docs/img/submission-flow.mmd.svg @@ -1 +1 @@ -
submission flow
github
anubis
rpc cluster
submision pipeline
processing
webhook
push job
kube job create
anubis api
database
pipeline api
report
test
build
clone
rpc worker
github
\ No newline at end of file +
submission flow
github
anubis
rpc cluster
submision pipeline
processing
webhook
push job
kube job create
anubis api
database
pipeline api
report
test
build
clone
rpc worker
github
\ No newline at end of file diff --git a/docs/img/theia-pod.mmd.svg b/docs/img/theia-pod.mmd.svg index 0c466b3a0..5399e557f 100644 --- a/docs/img/theia-pod.mmd.svg +++ b/docs/img/theia-pod.mmd.svg @@ -1 +1 @@ -
pod
student
proxy
proxy instance
proxy instance
proxy instance
theia
highly replicated proxy deployment
autosave
pushes to github every 5 minutes
theia server container
volume
shared PV between containers with student repo
\ No newline at end of file +
pod
student
proxy
proxy instance
proxy instance
proxy instance
theia
highly replicated proxy deployment
autosave
pushes to github every 5 minutes
theia server container
volume
shared PV between containers with student repo
\ No newline at end of file diff --git a/k8s/chart/templates/network-policy.yml b/k8s/chart/templates/network-policy.yml index e7385cf6c..934892c5e 100644 --- a/k8s/chart/templates/network-policy.yml +++ b/k8s/chart/templates/network-policy.yml @@ -142,8 +142,8 @@ spec: - from: - podSelector: matchLabels: - app: theia - role: proxy + app.kubernetes.io/name: theia + component: proxy ports: - protocol: TCP port: 5000 @@ -160,8 +160,8 @@ spec: - to: - podSelector: matchLabels: - app: api - role: api + app.kubernetes.io/name: anubis + component: api ports: - port: 5000 - to: @@ -195,7 +195,7 @@ spec: # - from: # - podSelector: # matchLabels: -# app: theia +# app.kubernetes.io/name: theia # network-policy: admin # role: theia-session # ports: diff --git a/k8s/debug/restart.sh b/k8s/debug/restart.sh index 964a82df4..77bdc7a53 100755 --- a/k8s/debug/restart.sh +++ b/k8s/debug/restart.sh @@ -41,27 +41,7 @@ pushd .. docker-compose build --parallel --pull api web logstash theia-proxy theia-init theia-sidecar popd -# Upgrade or install minimal anubis cluster in debug mode -helm upgrade \ - --install anubis ./chart \ - --namespace anubis \ - --set "imagePullPolicy=IfNotPresent" \ - --set "elasticsearch.storageClassName=standard" \ - --set "debug=true" \ - --set "api.replicas=1" \ - --set "web.replicas=1" \ - --set "pipeline_api.replicas=1" \ - --set "rpc.default.replicas=1" \ - --set "rpc.theia.replicas=1" \ - --set "rpc.regrade.replicas=1" \ - --set "theia.proxy.replicas=1" \ - --set "api.datacenter=false" \ - --set "reaper.suspend=true" \ - --set "visuals.suspend=true" \ - --set "theia.proxy.domain=ide.localhost" \ - --set "rollingUpdates=false" \ - --set "domain=localhost" \ - $@ +./debug/upgrade.sh # Restart the most common deployments kubectl rollout restart deployments.apps/api -n anubis diff --git a/k8s/debug/upgrade.sh b/k8s/debug/upgrade.sh new file mode 100755 index 000000000..466855201 --- /dev/null +++ b/k8s/debug/upgrade.sh @@ -0,0 +1,28 @@ +#!/bin/sh + +cd $(dirname $(realpath $0)) +cd .. + +kubectl config use-context minikube + +# Upgrade or install minimal anubis cluster in debug mode +helm upgrade \ + --install anubis ./chart \ + --namespace anubis \ + --set "imagePullPolicy=IfNotPresent" \ + --set "elasticsearch.storageClassName=standard" \ + --set "debug=true" \ + --set "api.replicas=1" \ + --set "web.replicas=1" \ + --set "pipeline_api.replicas=1" \ + --set "rpc.default.replicas=1" \ + --set "rpc.theia.replicas=1" \ + --set "rpc.regrade.replicas=1" \ + --set "theia.proxy.replicas=1" \ + --set "api.datacenter=false" \ + --set "reaper.suspend=true" \ + --set "visuals.suspend=true" \ + --set "theia.proxy.domain=ide.localhost" \ + --set "rollingUpdates=false" \ + --set "domain=localhost" \ + $@ diff --git a/queries/first_sub.sql b/queries/first_sub.sql deleted file mode 100644 index c622b875b..000000000 --- a/queries/first_sub.sql +++ /dev/null @@ -1,9 +0,0 @@ -select u.netid as uid, at.name as atid, s.created as created - from assignment_test at - join assignment a on a.id = at.assignment_id - join submission_test_result str on at.id = str.assignment_test_id - join submission s on str.submission_id = s.id - join user u on u.id = s.owner_id - where a.name = 'midterm' and str.passed = 1 - group by at.name, u.id - having min(s.created); diff --git a/queries/pass_time.sql b/queries/pass_time.sql deleted file mode 100644 index 5c068f58d..000000000 --- a/queries/pass_time.sql +++ /dev/null @@ -1,39 +0,0 @@ -/* -List the amount of time between each students - -*/ - -select at.name, u.netid as netid, TIMEDIFF(first_pass.created, first_sub.created) as time_to_pass -from submission_test_result str - join assignment_test at on str.assignment_test_id = at.id - join assignment a on at.assignment_id = a.id - join submission s on s.id = str.submission_id - join user u on s.owner_id = u.id - join ( - /* first submissions where it passed */ - select s.id as sid, u.id as uid, at.id as atid, s.created as created - from assignment_test at - join assignment a on a.id = at.assignment_id - join submission_test_result str on at.id = str.assignment_test_id - join submission s on str.submission_id = s.id - join user u on u.id = s.owner_id - where a.name = 'midterm' - and str.passed = 1 - group by at.name, u.id - having min(s.created) -) first_pass on first_pass.uid = u.id and first_pass.atid = at.id - join ( - /* first submissions */ - select s.id as sid, u.id as uid, at.id as atid, s.created as created - from submission s - join assignment a on a.id = s.assignment_id - join user u on u.id = s.owner_id - join assignment_test at on at.assignment_id = a.id - join submission_test_result str on str.submission_id = s.id - where a.name = 'midterm' - group by at.name, u.id - having min(s.created) -) first_sub on first_sub.uid = u.id and first_sub.atid = at.id -where a.name = 'midterm' -group by at.name, u.id -order by time_to_pass; diff --git a/queries/passed_distro.sql b/queries/passed_distro.sql deleted file mode 100644 index a462a7104..000000000 --- a/queries/passed_distro.sql +++ /dev/null @@ -1,10 +0,0 @@ -select a.name, s.state, count(s.id), st.passed - from submission s - join assignment a on a.id = s.assignment_id - join ( - select str.submission_id as submission_id, sum(str.passed) as passed - from submission_test_result str - group by str.submission_id - ) st on st.submission_id = s.id - where a.name = 'midterm' - group by s.state, st.passed; diff --git a/queries/questions.sql b/queries/questions.sql deleted file mode 100644 index b9ed2c8bb..000000000 --- a/queries/questions.sql +++ /dev/null @@ -1,10 +0,0 @@ -select - aq.sequence as 'question number', - aq.last_updated as last_updated, - asq.response as response - from assigned_student_question asq - join assignment a on a.id = asq.assignment_id - join user u on asq.owner_id = u.id - join assignment_question aq on aq.id = asq.question_id - where u.netid = 'ai1138' and a.name = 'midterm' - order by aq.sequence; diff --git a/theia/ide/admin/cli/docs/conf.py b/theia/ide/admin/cli/docs/conf.py index 16defc43f..a4277bde4 100755 --- a/theia/ide/admin/cli/docs/conf.py +++ b/theia/ide/admin/cli/docs/conf.py @@ -74,7 +74,7 @@ # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' -# If true, `todo` and `todoList` produce output, else they produce nothing. +# If true, `` and `todoList` produce output, else they produce nothing. todo_include_todos = False diff --git a/theia/proxy/index.js b/theia/proxy/index.js index c3c99876c..d5489e041 100644 --- a/theia/proxy/index.js +++ b/theia/proxy/index.js @@ -8,16 +8,7 @@ const LRU = require("lru-cache"); const SECRET_KEY = process.env.SECRET_KEY ?? 'DEBUG'; -const cache = new LRU({ - max: 100, - length: function (n, key) { - return n * 2 + key.length - }, - dispose: function (key, n) { - n.close() - }, - maxAge: 1000 * 60 * 60, -}) +const cache = new LRU(100); const knex = Knex({ client: "mysql", diff --git a/web/package.json b/web/package.json index 4d78df499..faf63481a 100644 --- a/web/package.json +++ b/web/package.json @@ -29,6 +29,7 @@ "react-syntax-highlighter": "^15.3.0", "react-vis": "^1.11.7", "remark-gfm": "^1.0.0", + "universal-cookie": "^4.0.4", "web-vitals": "^1.1.0" }, "scripts": { diff --git a/web/src/App.jsx b/web/src/App.jsx index 3ff275e30..72e795c89 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -7,7 +7,7 @@ import clsx from 'clsx'; import {makeStyles, ThemeProvider} from '@material-ui/core/styles'; import CssBaseline from '@material-ui/core/CssBaseline'; -import {drawerWidth} from './Navigation/navconfig'; +import {drawerWidth} from './navconfig'; import theme from './Theme/Dark'; import AuthContext from './Contexts/AuthContext'; @@ -15,7 +15,7 @@ import AuthContext from './Contexts/AuthContext'; import AuthWrapper from './Components/AuthWrapper'; import Main from './Main'; import useQuery from './hooks/useQuery'; -import Nav from './Navigation/Nav'; +import Nav from './Components/Navigation/Nav'; import Error from './Components/Error'; import Footer from './Components/Footer'; import Header from './Components/Header'; @@ -113,7 +113,6 @@ export default function App() { setOpen(false); }; - return (
diff --git a/web/src/Components/Admin/Assignment/AssignmentCard.jsx b/web/src/Components/Admin/Assignment/AssignmentCard.jsx index 6e86e0ba2..779ae0cf9 100644 --- a/web/src/Components/Admin/Assignment/AssignmentCard.jsx +++ b/web/src/Components/Admin/Assignment/AssignmentCard.jsx @@ -67,6 +67,11 @@ export default function AssignmentCard({assignment, editableFields, updateField, const [progress, setProgress] = useState(''); const [reset, setReset] = useState(0); const [warningOpen, setWarningOpen] = useState(false); + const [jsonValues, setJsonValues] = useState( + ...editableFields.filter(({type}) => type === 'json').map(({field}) => ({ + [field]: JSON.stringify(assignment[field]), + })), + ); React.useEffect(() => { axios.get(`/api/admin/regrade/status/${assignment.id}`).then((response) => { @@ -103,6 +108,29 @@ export default function AssignmentCard({assignment, editableFields, updateField, /> ); + case 'json': + return ( + + { + setJsonValues((prev) => { + prev[field] = e.target.value; + return {...prev}; + }); + try { + const value = JSON.parse(e.target.value); + updateField(assignment.id, field, false, false, true)(value); + } catch (e) { + } + }} + /> + + ); case 'boolean': return ( diff --git a/web/src/Components/Admin/Course/CourseCard.jsx b/web/src/Components/Admin/Course/CourseCard.jsx new file mode 100644 index 000000000..961b7ea6c --- /dev/null +++ b/web/src/Components/Admin/Course/CourseCard.jsx @@ -0,0 +1,135 @@ +import React, {useState} from 'react'; +import makeStyles from '@material-ui/core/styles/makeStyles'; +import Grid from '@material-ui/core/Grid'; +import TextField from '@material-ui/core/TextField'; +import DateFnsUtils from '@date-io/date-fns'; +import {KeyboardDatePicker, KeyboardTimePicker, MuiPickersUtilsProvider} from '@material-ui/pickers'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Switch from '@material-ui/core/Switch'; +import CardActionArea from '@material-ui/core/CardActionArea'; +import Button from '@material-ui/core/Button'; +import Card from '@material-ui/core/Card'; +import CardContent from '@material-ui/core/CardContent'; +import EditIcon from '@material-ui/icons/Edit'; +import yellow from '@material-ui/core/colors/yellow'; +import AuthContext from '../../../Contexts/AuthContext'; +import {Link} from 'react-router-dom'; + + +const useStyles = makeStyles((theme) => ({ + button: { + margin: theme.spacing(1), + }, +})); + +export default function CourseCard({course, _disabled, editableFields, updateField, saveCourse}) { + const classes = useStyles(); + + return ( + + + + {editableFields.map(({field, label, disabled = false, type = 'string'}) => { + switch (type) { + case 'string': + return ( + + + + ); + case 'boolean': + return ( + + + } + label={label} + labelPlacement="end" + /> + + ); + case 'datetime': + const date = new Date(course[field]); + return ( + + + + + + + ); + } + })} + + + {!_disabled ? ( + + {(user) => ( + + + + {user.is_superuser && ( + + )} + + )} + + ) : null} + + ); +} diff --git a/web/src/Components/Admin/Course/CourseTasProfessors.jsx b/web/src/Components/Admin/Course/CourseTasProfessors.jsx new file mode 100644 index 000000000..cd6744599 --- /dev/null +++ b/web/src/Components/Admin/Course/CourseTasProfessors.jsx @@ -0,0 +1,141 @@ +import React, {useState} from 'react'; +import {useSnackbar} from 'notistack'; +import axios from 'axios'; + +import makeStyles from '@material-ui/core/styles/makeStyles'; +import Grid from '@material-ui/core/Grid'; +import {DataGrid} from '@material-ui/data-grid'; +import Paper from '@material-ui/core/Paper'; +import Autocomplete from '@material-ui/lab/Autocomplete'; + +import standardStatusHandler from '../../../Utils/standardStatusHandler'; +import standardErrorHandler from '../../../Utils/standardErrorHandler'; +import {TextField, Tooltip} from '@material-ui/core'; +import AddIcon from '@material-ui/icons/Add'; +import Fab from '@material-ui/core/Fab'; +import Button from '@material-ui/core/Button'; +import DeleteForeverIcon from '@material-ui/icons/DeleteForever'; + + +const useStyles = makeStyles((theme) => ({ + fab: { + margin: theme.spacing(1, 1), + }, + autocomplete: { + width: 150, + }, + paper: { + display: 'flex', + padding: theme.spacing(1), + }, + dataGridPaper: { + width: '100%', + height: 700, + }, + inline: { + display: 'inline', + }, +})); + + +export default function CourseTasProfessors({base}) { + const classes = useStyles(); + const {enqueueSnackbar} = useSnackbar(); + const [users, setUsers] = useState([]); + const [selected, setSelected] = useState(null); + const [tas, setTas] = useState([]); + const [reset, setReset] = useState(0); + + React.useEffect(() => { + axios.get(`/api/admin/courses/list/${base}s`).then((response) => { + const data = standardStatusHandler(response, enqueueSnackbar); + if (data?.users) { + setTas(data.users); + } + }).catch(standardErrorHandler(enqueueSnackbar)); + }, [reset]); + + React.useEffect(() => { + axios.get(`/api/admin/students/list/basic`).then((response) => { + const data = standardStatusHandler(response, enqueueSnackbar); + if (data?.users) { + setUsers(data.users); + } + }).catch(standardErrorHandler(enqueueSnackbar)); + }, [reset]); + + + const makeTA = () => { + if (!selected) { + enqueueSnackbar('select netid', {variant: 'error'}); + return; + } + axios.get(`/api/admin/courses/make/${base}/${selected.id}`).then((response) => { + const data = standardStatusHandler(response, enqueueSnackbar); + if (data) { + setReset((prev) => ++prev); + } + }).catch(standardErrorHandler(enqueueSnackbar)); + }; + + const removeTA = (id) => () => { + axios.get(`/api/admin/courses/remove/${base}/${id}`).then((response) => { + const data = standardStatusHandler(response, enqueueSnackbar); + if (data) { + setReset((prev) => ++prev); + } + }).catch(standardErrorHandler(enqueueSnackbar)); + }; + + const columns = [ + {field: 'id', headerName: 'ID', hide: true}, + {field: 'netid', headerName: 'Netid', width: 200}, + {field: 'name', headerName: 'Name', width: 200}, + { + field: 'delete', headerName: 'Delete', width: 150, renderCell: (params) => ( + + ), + }, + ]; + + return ( + + + +
+ + + + + +
+ } + value={selected} + getOptionLabel={(item) => item.netid} + onChange={(_, e) => setSelected(e)} + options={users} + /> +
+
+ + + + + +
+ ); +} diff --git a/web/src/Components/AuthWrapper.jsx b/web/src/Components/AuthWrapper.jsx index 86ac30df5..445b9fc7a 100644 --- a/web/src/Components/AuthWrapper.jsx +++ b/web/src/Components/AuthWrapper.jsx @@ -3,6 +3,27 @@ import AuthContext from '../Contexts/AuthContext'; import axios from 'axios'; import standardStatusHandler from '../Utils/standardStatusHandler'; import {useSnackbar} from 'notistack'; +import Cookies from 'universal-cookie'; + + +const checkCourseContextReset = (user) => { + if (!user) { + return; + } + + const cookie = new Cookies(); + const course_context = cookie.get('course'); + if (course_context) { + const course = JSON.parse(atob(course_context)); + const course_id = course?.id; + + if (user.admin_for.find(({id}) => id === course_id) === undefined) { + cookie.remove('course', {path: '/'}); + window.location.reload(true); + } + } +}; + export default function AuthWrapper({children}) { const {enqueueSnackbar} = useSnackbar(); @@ -12,6 +33,7 @@ export default function AuthWrapper({children}) { React.useEffect(() => { axios.get('/api/public/auth/whoami').then((response) => { const data = standardStatusHandler(response, enqueueSnackbar); + checkCourseContextReset(data?.user); setUser({ setReset, ...(data?.user ?? {}), diff --git a/web/src/Components/Header.jsx b/web/src/Components/Header.jsx index df858fa5b..7f9e374df 100644 --- a/web/src/Components/Header.jsx +++ b/web/src/Components/Header.jsx @@ -1,19 +1,30 @@ -import React from 'react'; +import React, {useState} from 'react'; +import Cookies from 'universal-cookie'; +import {useSnackbar} from 'notistack'; import clsx from 'clsx'; -import AppBar from '@material-ui/core/AppBar'; import Grid from '@material-ui/core/Grid'; import IconButton from '@material-ui/core/IconButton'; import MenuIcon from '@material-ui/icons/Menu'; import Toolbar from '@material-ui/core/Toolbar'; import Chip from '@material-ui/core/Chip'; -import ChevronLeftIcon from '@material-ui/icons/ChevronLeft'; -import ChevronRightIcon from '@material-ui/icons/ChevronRight'; -import {useTheme} from '@material-ui/core/styles'; +import Button from '@material-ui/core/Button'; +import AppBar from '@material-ui/core/AppBar'; +import Autocomplete from '@material-ui/lab/Autocomplete'; +import TextField from '@material-ui/core/TextField'; +import RefreshIcon from '@material-ui/icons/Refresh'; export default function Header({classes, open, onDrawerToggle, user}) { - const theme = useTheme(); + const cookie = new Cookies(); + const {enqueueSnackbar} = useSnackbar(); + const [course, setCourse] = useState((() => { + try { + return JSON.parse(atob(cookie.get('course'))); + } catch (_) { + } + return null; + })()); return ( @@ -39,8 +50,42 @@ export default function Header({classes, open, onDrawerToggle, user}) {
-
- {user?.netid && } +
+ {user?.is_admin ? ( + option.name} + value={course} + style={{width: 200}} + onChange={(_, e) => { + cookie.remove('course', {path: '/'}); + if (!!e) { + cookie.set('course', btoa(JSON.stringify(e)), {path: '/'}); + enqueueSnackbar('You may need to reload the page', { + variant: 'warning', + action: ( + + ), + }); + } + setCourse(e); + }} + renderInput={(params) => ( + + )} + /> + ) : null} +
+ {user?.netid && } +
diff --git a/web/src/Navigation/Nav.jsx b/web/src/Components/Navigation/Nav.jsx similarity index 92% rename from web/src/Navigation/Nav.jsx rename to web/src/Components/Navigation/Nav.jsx index 52a32c566..c7862b454 100644 --- a/web/src/Navigation/Nav.jsx +++ b/web/src/Components/Navigation/Nav.jsx @@ -2,7 +2,7 @@ import React from 'react'; import Drawer from '@material-ui/core/Drawer'; import Typography from '@material-ui/core/Typography'; -import NavList from '../Components/Navigation/NavList'; +import NavList from './NavList'; export default function Nav({classes, open, handleDrawerClose}) { return ( diff --git a/web/src/Components/Navigation/NavList.jsx b/web/src/Components/Navigation/NavList.jsx index a41cebd42..14e1eef80 100644 --- a/web/src/Components/Navigation/NavList.jsx +++ b/web/src/Components/Navigation/NavList.jsx @@ -12,7 +12,7 @@ import LaunchOutlinedIcon from '@material-ui/icons/LaunchOutlined'; import {isWidthDown} from '@material-ui/core/withWidth'; -import {admin_nav, footer_nav, public_nav} from '../../Navigation/navconfig'; +import {admin_nav, footer_nav, public_nav} from '../../navconfig'; import NavItem from './NavItem'; import AuthContext from '../../Contexts/AuthContext'; diff --git a/web/src/Components/Public/About/Description.jsx b/web/src/Components/Public/About/Description.jsx index 4f684e501..eea40e800 100644 --- a/web/src/Components/Public/About/Description.jsx +++ b/web/src/Components/Public/About/Description.jsx @@ -19,7 +19,7 @@ export default function Description(props) { /> - Anubis v2.2.8 + Anubis v3.0.0 Anubis is a custom built, distributed autograder created by John Cunniff, and Somto Ejinkonye. diff --git a/web/src/Components/Public/Questions/Questions.jsx b/web/src/Components/Public/Questions/Questions.jsx index 38e8505fb..97e2168f6 100644 --- a/web/src/Components/Public/Questions/Questions.jsx +++ b/web/src/Components/Public/Questions/Questions.jsx @@ -9,7 +9,7 @@ import QuestionGrid from './QuestionGrid'; import useGet from '../../../hooks/useGet'; export default function Questions({assignment_id}) { - const [{loading, error, data}] = useGet(`/api/public/assignments/questions/get/${assignment_id}`); + const [{loading, error, data}] = useGet(`/api/public/questions/get/${assignment_id}`); if (assignment_id === null) return ; if (loading) return ; diff --git a/web/src/Components/Public/Repos/ReposTable.jsx b/web/src/Components/Public/Repos/ReposTable.jsx index 925698e45..35730a49f 100644 --- a/web/src/Components/Public/Repos/ReposTable.jsx +++ b/web/src/Components/Public/Repos/ReposTable.jsx @@ -41,7 +41,7 @@ export default function ReposTable({rows}) { - Class + Course Assignment Github Username Repo URL @@ -51,7 +51,7 @@ export default function ReposTable({rows}) { {rows.map((row) => ( - {row.class_name} + {row.course_code} {row.assignment_name} {row.github_username} diff --git a/web/src/Main.jsx b/web/src/Main.jsx index 40b9605a8..7138bc969 100644 --- a/web/src/Main.jsx +++ b/web/src/Main.jsx @@ -1,6 +1,6 @@ import React from 'react'; import {Redirect, Route, Switch} from 'react-router-dom'; -import {admin_nav, footer_nav, not_shown_nav, public_nav} from './Navigation/navconfig'; +import {admin_nav, footer_nav, not_shown_nav, public_nav} from './navconfig'; export default function Main({user}) { return ( diff --git a/web/src/Pages/Admin/Assignment/Assignments.jsx b/web/src/Pages/Admin/Assignment/Assignments.jsx index 58ac03682..5f1209e54 100644 --- a/web/src/Pages/Admin/Assignment/Assignments.jsx +++ b/web/src/Pages/Admin/Assignment/Assignments.jsx @@ -41,10 +41,12 @@ const editableFields = [ {field: 'name', label: 'Assignment Name'}, {field: 'github_classroom_url', label: 'Github Classroom URL'}, {field: 'theia_image', label: 'Theia Image'}, + {field: 'theia_options', label: 'Theia Options', type: 'json'}, {field: 'pipeline_image', label: 'Pipeline Image', disabled: true}, {field: 'unique_code', label: 'Unique Code', disabled: true}, {field: 'hidden', label: 'Hidden', type: 'boolean'}, {field: 'ide_enabled', label: 'Theia Enabled', type: 'boolean'}, + {field: 'autograde_enabled', label: 'Autograde Enabled', type: 'boolean'}, {field: 'release_date', label: 'Release Date', type: 'datetime'}, {field: 'due_date', label: 'Due Date', type: 'datetime'}, {field: 'grace_date', label: 'Grace Date', type: 'datetime'}, @@ -75,7 +77,7 @@ export default function Assignments() { }).catch((error) => enqueueSnackbar(error.toString(), {variant: 'error'})); }, [reset]); - const updateField = (id, field, toggle = false, datetime = false) => (e) => { + const updateField = (id, field, toggle = false, datetime = false, json = false) => (e) => { if (!e) { return; } @@ -92,6 +94,10 @@ export default function Assignments() { break; } + if (json) { + assignment[field] = e; + } + assignment[field] = e.target.value.toString(); break; } diff --git a/web/src/Pages/Admin/Config.jsx b/web/src/Pages/Admin/Config.jsx index 72ab8ca01..e8b44911a 100644 --- a/web/src/Pages/Admin/Config.jsx +++ b/web/src/Pages/Admin/Config.jsx @@ -23,7 +23,7 @@ const useStyles = makeStyles((theme) => ({ }, paper: { padding: theme.spacing(1), - minHeight: 400, + height: 400, }, })); diff --git a/web/src/Pages/Admin/Courses.jsx b/web/src/Pages/Admin/Courses.jsx index 1cb2da679..9e3b07b5a 100644 --- a/web/src/Pages/Admin/Courses.jsx +++ b/web/src/Pages/Admin/Courses.jsx @@ -1,32 +1,26 @@ import React, {useState} from 'react'; +import axios from 'axios'; +import {format} from 'date-fns'; +import {useSnackbar} from 'notistack'; +import {Route, Switch} from 'react-router-dom'; import Grid from '@material-ui/core/Grid'; -import {useSnackbar} from 'notistack'; -import axios from 'axios'; -import standardStatusHandler from '../../Utils/standardStatusHandler'; -import Card from '@material-ui/core/Card'; -import CardContent from '@material-ui/core/CardContent'; -import makeStyles from '@material-ui/core/styles/makeStyles'; -import TextField from '@material-ui/core/TextField'; -import DateFnsUtils from '@date-io/date-fns'; -import {KeyboardDatePicker, KeyboardTimePicker, MuiPickersUtilsProvider} from '@material-ui/pickers'; -import FormControlLabel from '@material-ui/core/FormControlLabel'; -import Switch from '@material-ui/core/Switch'; -import CardActionArea from '@material-ui/core/CardActionArea'; + import Button from '@material-ui/core/Button'; -import {format} from 'date-fns'; +import makeStyles from '@material-ui/core/styles/makeStyles'; import Typography from '@material-ui/core/Typography'; +import CourseCard from '../../Components/Admin/Course/CourseCard'; +import AuthContext from '../../Contexts/AuthContext'; +import standardStatusHandler from '../../Utils/standardStatusHandler'; +import standardErrorHandler from '../../Utils/standardErrorHandler'; +import CourseTasProfessors from '../../Components/Admin/Course/CourseTasProfessors'; + const useStyles = makeStyles((theme) => ({ root: { minWidth: 275, }, - bullet: { - display: 'inline-block', - margin: '0 2px', - transform: 'scale(0.8)', - }, title: { fontSize: 14, }, @@ -46,31 +40,20 @@ const editableFields = [ {field: 'join_code', label: 'Join Code', disabled: true}, ]; -// const createCourse = (state, enqueueSnackbar) => () => { -// const {setReset} = state; -// axios.get('/api/admin/courses/new').then((response) => { -// const data = standardStatusHandler(response, enqueueSnackbar); -// if (data) { -// setReset((prev) => ++prev); -// } -// }).catch(standardErrorHandler(enqueueSnackbar)); -// }; - - export default function Courses() { const classes = useStyles(); const {enqueueSnackbar} = useSnackbar(); - const [courses, setCourses] = useState([]); + const [course, setCourse] = useState([]); const [edits, setEdits] = useState(0); const [reset, setReset] = useState(0); React.useEffect(() => { axios.get('/api/admin/courses/list').then((response) => { const data = standardStatusHandler(response, enqueueSnackbar); - if (data) { - setCourses(data.courses); + if (data?.course) { + setCourse(data.course); } - }).catch((error) => enqueueSnackbar(error.toString(), {variant: 'error'})); + }).catch(standardErrorHandler(enqueueSnackbar)); }, [reset]); const updateField = (id, field, toggle = false, datetime = false) => (e) => { @@ -78,44 +61,34 @@ export default function Courses() { return; } - for (const course of courses) { - if (course.id === id) { - if (toggle) { - course[field] = !course[field]; - break; - } - - if (datetime) { - course[field] = format(e, 'yyyy-MM-dd HH:mm:ss'); - break; - } - + if (course.id === id) { + if (toggle) { + course[field] = !course[field]; + } else if (datetime) { + course[field] = format(e, 'yyyy-MM-dd HH:mm:ss'); + } else { course[field] = e.target.value.toString(); - break; } } - setCourses(courses); + setCourse(course); setEdits((state) => ++state); }; - const saveCourse = (id) => () => { - for (const course of courses) { - if (course.id === id) { - axios.post(`/api/admin/courses/save`, {course}).then((response) => { - standardStatusHandler(response, enqueueSnackbar); - }).catch((error) => enqueueSnackbar(error.toString(), {variant: 'error'})); - return; - } - } + const saveCourse = () => () => { + axios.post(`/api/admin/courses/save`, {course}).then((response) => { + standardStatusHandler(response, enqueueSnackbar); + }).catch(standardErrorHandler(enqueueSnackbar)); + }; - enqueueSnackbar('An error occurred', {variant: 'error'}); + const createCourse = () => { + axios.get('/api/admin/courses/new').then((response) => { + const data = standardStatusHandler(response, enqueueSnackbar); + if (data) { + setReset((prev) => ++prev); + } + }).catch(standardErrorHandler(enqueueSnackbar)); }; - // const state = { - // courses, setCourses, - // edits, setEdits, - // reset, setReset, - // }; return ( @@ -127,94 +100,51 @@ export default function Courses() { Course Management - {/* */} - {/* */} - {/* Create Course*/} - {/* */} - {/* */} + + + + {(user) => ( + <> + {!!user ? ( + + + + ) : null} + + )} + + + - {courses.map((course) => ( - - - - - {editableFields.map(({field, label, disabled = false, type = 'string'}) => { - switch (type) { - case 'string': - return ( - - - - ); - case 'boolean': - return ( - - - } - label={label} - labelPlacement="end" - /> - - ); - case 'datetime': - const date = new Date(course[field]); - return ( - - - - - - - ); - } - })} - - - - - - - - ))} + + + + + + + + + + + + + + + diff --git a/web/src/Pages/Admin/Static.jsx b/web/src/Pages/Admin/Static.jsx index 6df700e76..3a79668b4 100644 --- a/web/src/Pages/Admin/Static.jsx +++ b/web/src/Pages/Admin/Static.jsx @@ -1,16 +1,18 @@ import React, {useState} from 'react'; +import {useSnackbar} from 'notistack'; +import axios from 'axios'; + +import {DataGrid} from '@material-ui/data-grid'; import makeStyles from '@material-ui/core/styles/makeStyles'; import Grid from '@material-ui/core/Grid'; -import {useSnackbar} from 'notistack'; import Typography from '@material-ui/core/Typography'; -import {DataGrid} from '@material-ui/data-grid'; import Paper from '@material-ui/core/Paper'; import Button from '@material-ui/core/Button'; import DeleteForeverIcon from '@material-ui/icons/DeleteForever'; -import axios from 'axios'; + +import FileUploadDialog from '../../Components/Admin/Static/FileUploadDialog'; import standardStatusHandler from '../../Utils/standardStatusHandler'; import standardErrorHandler from '../../Utils/standardErrorHandler'; -import FileUploadDialog from '../../Components/Admin/Static/FileUploadDialog'; const useStyles = makeStyles((theme) => ({ paper: { @@ -39,7 +41,7 @@ const useColumns = (state, enqueueSnackbar) => ([ {field: 'id', headerName: 'ID'}, {field: 'content_type', headerName: 'Content Type', width: 150}, { - field: 'path', headerName: 'URL', width: 200, renderCell: ({row}) => ( + field: 'path', headerName: 'URL', width: 300, renderCell: ({row}) => (
([ {field: 'netid', headerName: 'Netid'}, {field: 'state', headerName: 'State'}, {field: 'course_code', headerName: 'Course', width: 120}, - {field: 'assignment_name', headerName: 'Assignment', width: 110}, + {field: 'assignment_name', headerName: 'Assignment', width: 200}, {field: 'created', headerName: 'Created', type: 'dateTime', width: 170}, {field: 'autosave', headerName: 'Autosave', width: 100, renderCell: ({row}) => ( diff --git a/web/src/Pages/Admin/Users.jsx b/web/src/Pages/Admin/Users.jsx index 0e70c14c4..5fcd58064 100644 --- a/web/src/Pages/Admin/Users.jsx +++ b/web/src/Pages/Admin/Users.jsx @@ -1,15 +1,15 @@ import React, {useState} from 'react'; import clsx from 'clsx'; +import axios from 'axios'; +import {useSnackbar} from 'notistack'; +import {Link} from 'react-router-dom'; -import makeStyles from '@material-ui/core/styles/makeStyles'; import {DataGrid} from '@material-ui/data-grid/'; +import makeStyles from '@material-ui/core/styles/makeStyles'; import Paper from '@material-ui/core/Paper'; import Grid from '@material-ui/core/Grid'; import Switch from '@material-ui/core/Switch'; import TextField from '@material-ui/core/TextField'; -import axios from 'axios'; -import {useSnackbar} from 'notistack'; -import {Link} from 'react-router-dom'; import PersonIcon from '@material-ui/icons/Person'; import Fab from '@material-ui/core/Fab'; import Tooltip from '@material-ui/core/Tooltip'; @@ -19,6 +19,7 @@ import Typography from '@material-ui/core/Typography'; import standardStatusHandler from '../../Utils/standardStatusHandler'; import standardErrorHandler from '../../Utils/standardErrorHandler'; +import AuthContext from '../../Contexts/AuthContext'; const useStyles = makeStyles((theme) => ({ paper: { @@ -40,23 +41,6 @@ const useStyles = makeStyles((theme) => ({ })); -const toggleAdmin = (id, {setStudents, setEdits}, enqueueSnackbar) => () => { - axios.get(`/api/admin/students/toggle-admin/${id}`).then((response) => { - if (standardStatusHandler(response, enqueueSnackbar)) { - setStudents((students) => { - for (const student of students) { - if (student.id === id) { - student.is_admin = !student.is_admin; - } - } - return students; - }); - setEdits((state) => ++state); - } - }).catch(standardErrorHandler(enqueueSnackbar)); -}; - - const toggleSuperuser = (id, {setStudents, setEdits}, enqueueSnackbar) => () => { axios.get(`/api/admin/students/toggle-superuser/${id}`).then((response) => { if (standardStatusHandler(response, enqueueSnackbar)) { @@ -73,7 +57,7 @@ const toggleSuperuser = (id, {setStudents, setEdits}, enqueueSnackbar) => () => }).catch(standardErrorHandler(enqueueSnackbar)); }; -const useColumns = (pageState, enqueueSnackbar) => ([ +const useColumns = (pageState, enqueueSnackbar) => (user) => ([ { field: 'id', headerName: 'ID', @@ -116,20 +100,6 @@ const useColumns = (pageState, enqueueSnackbar) => ([ {field: 'netid', headerName: 'netid'}, {field: 'name', headerName: 'Name', width: 150}, {field: 'github_username', headerName: 'Github Username', width: 200}, - { - field: 'is_admin', - headerName: 'Admin', - renderCell: (params) => ( - - - - ), - width: 150, - }, { field: 'is_superuser', headerName: 'Superuser', @@ -143,6 +113,7 @@ const useColumns = (pageState, enqueueSnackbar) => ([ ), width: 150, + hide: !user.is_superuser, }, ]); @@ -235,18 +206,22 @@ export default function Users() {
- + + {(user) => ( + + )} +
diff --git a/web/src/Navigation/navconfig.jsx b/web/src/navconfig.jsx similarity index 73% rename from web/src/Navigation/navconfig.jsx rename to web/src/navconfig.jsx index 7067f7bb2..f7a57ec35 100644 --- a/web/src/Navigation/navconfig.jsx +++ b/web/src/navconfig.jsx @@ -14,28 +14,28 @@ import AttachFileIcon from '@material-ui/icons/AttachFile'; import BookIcon from '@material-ui/icons/Book'; import TimelineIcon from '@material-ui/icons/Timeline'; -import About from '../Pages/Public/About'; -import Courses from '../Pages/Public/Courses'; -import Assignments from '../Pages/Public/Assignments'; -import Profile from '../Pages/Public/Profile'; -import Repos from '../Pages/Public/Repos'; -import Submissions from '../Pages/Public/Submissions'; -import Submission from '../Pages/Public/Submission'; -import Blog from '../Pages/Public/Blog'; -import Visuals from '../Pages/Public/Visuals'; +import About from './Pages/Public/About'; +import Courses from './Pages/Public/Courses'; +import Assignments from './Pages/Public/Assignments'; +import Profile from './Pages/Public/Profile'; +import Repos from './Pages/Public/Repos'; +import Submissions from './Pages/Public/Submissions'; +import Submission from './Pages/Public/Submission'; +import Blog from './Pages/Public/Blog'; +import Visuals from './Pages/Public/Visuals'; -import AdminUsers from '../Pages/Admin/Users'; -import AdminUser from '../Pages/Admin/User'; -import AdminAssignments from '../Pages/Admin/Assignment/Assignments'; -import AdminStats from '../Pages/Admin/Autograde/AutogradeResults'; -import AdminAssignmentStats from '../Pages/Admin/Autograde/AutogradeAssignments'; -import AdminSubmissionStats from '../Pages/Admin/Autograde/AutogradeSubmission'; -import AdminCourses from '../Pages/Admin/Courses'; -import AdminTheia from '../Pages/Admin/Theia'; -import AdminStatic from '../Pages/Admin/Static'; -import AdminConfig from '../Pages/Admin/Config'; -import AdminAssignmentQuestions from '../Pages/Admin/Assignment/AssignmentQuestions'; -import AdminAssignmentTests from '../Pages/Admin/Assignment/AssignmentTests'; +import AdminUsers from './Pages/Admin/Users'; +import AdminUser from './Pages/Admin/User'; +import AdminAssignments from './Pages/Admin/Assignment/Assignments'; +import AdminStats from './Pages/Admin/Autograde/AutogradeResults'; +import AdminAssignmentStats from './Pages/Admin/Autograde/AutogradeAssignments'; +import AdminSubmissionStats from './Pages/Admin/Autograde/AutogradeSubmission'; +import AdminCourses from './Pages/Admin/Courses'; +import AdminTheia from './Pages/Admin/Theia'; +import AdminStatic from './Pages/Admin/Static'; +import AdminConfig from './Pages/Admin/Config'; +import AdminAssignmentQuestions from './Pages/Admin/Assignment/AssignmentQuestions'; +import AdminAssignmentTests from './Pages/Admin/Assignment/AssignmentTests'; export const footer_nav = [ { @@ -108,10 +108,11 @@ export const admin_nav = [ Page: AdminUsers, }, { - id: 'Courses', + id: 'Course', icon: , path: '/admin/courses', Page: AdminCourses, + exact: false, }, { id: 'Assignments', diff --git a/web/yarn.lock b/web/yarn.lock index ae98e45f2..fe30a8206 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -1795,6 +1795,11 @@ dependencies: "@babel/types" "^7.3.0" +"@types/cookie@^0.3.3": + version "0.3.3" + resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.3.3.tgz#85bc74ba782fb7aa3a514d11767832b0e3bc6803" + integrity sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow== + "@types/eslint@^7.2.4": version "7.2.6" resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-7.2.6.tgz#5e9aff555a975596c03a98b59ecd103decc70c3c" @@ -3673,6 +3678,11 @@ cookie@0.4.0: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba" integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg== +cookie@^0.4.0: + version "0.4.1" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1" + integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA== + copy-concurrently@^1.0.0: version "1.0.5" resolved "https://registry.yarnpkg.com/copy-concurrently/-/copy-concurrently-1.0.5.tgz#92297398cae34937fcafd6ec8139c18051f0b5e0" @@ -11871,6 +11881,14 @@ unist-util-visit@^2.0.0: unist-util-is "^4.0.0" unist-util-visit-parents "^3.0.0" +universal-cookie@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/universal-cookie/-/universal-cookie-4.0.4.tgz#06e8b3625bf9af049569ef97109b4bb226ad798d" + integrity sha512-lbRVHoOMtItjWbM7TwDLdl8wug7izB0tq3/YVKhT/ahB4VDvWMyvnADfnJI8y6fSvsjh51Ix7lTGC6Tn4rMPhw== + dependencies: + "@types/cookie" "^0.3.3" + cookie "^0.4.0" + universalify@^0.1.0: version "0.1.2" resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"