diff --git a/.env-api-prod b/.env-api-prod index e28920a..ed1ab02 100644 --- a/.env-api-prod +++ b/.env-api-prod @@ -5,6 +5,8 @@ SQLITE_DB_PATH="/app/data/moh.sqlite" THE_OG_UBIT="____" THE_OG_PN=____ +THE_OG_NAME="____" +THE_OG_SURNAME="____" AUTOLAB_CLIENT_ID=____ AUTOLAB_SECRET=____ diff --git a/.env-testing b/.env-testing new file mode 100644 index 0000000..15d3cba --- /dev/null +++ b/.env-testing @@ -0,0 +1,13 @@ +API_MODE=prod +API_URL_PREFIX="/" +DB=relational +SQLITE_DB_PATH="/app/data/moh.sqlite" + +THE_OG_UBIT="____" +THE_OG_PN=____ +THE_OG_NAME="____" +THE_OG_SURNAME="____" + +AUTOLAB_CLIENT_ID=____ +AUTOLAB_SECRET=____ +AUTOLAB_CALLBACK=____ \ No newline at end of file diff --git a/.github/workflows/formatter.yml b/.github/workflows/formatter.yml index 47c6e83..c52740e 100644 --- a/.github/workflows/formatter.yml +++ b/.github/workflows/formatter.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [ "3.10", "3.11", "3.12" ] + python-version: [ "3.14" ] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index 8dd7ede..015a87d 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -6,7 +6,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [ "3.10", "3.11", "3.12" ] + python-version: [ "3.14" ] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} @@ -20,4 +20,4 @@ jobs: pip install -r ./api/utils.txt - name: Analysing the code with pylint run: | - pylint $(git ls-files '*.py') \ No newline at end of file + pylint ./api \ No newline at end of file diff --git a/.pylintrc b/.pylintrc index 76a60fe..3df3ba1 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,3 +1,6 @@ +[MASTER] +init-hook='import sys; sys.path.append(".")' + [MAIN] # Analyse import fallback blocks. This can be used to support both Python 2 and @@ -104,10 +107,6 @@ recursive=no # source root. source-roots= -# When enabled, pylint would attempt to guess common misconfiguration and emit -# user-friendly hints instead of false-positive error messages. -suggestion-mode=yes - # Allow loading of arbitrary C extensions. Extensions are imported into the # active Python interpreter and may run arbitrary code. unsafe-load-any-extension=no @@ -290,7 +289,7 @@ exclude-too-few-public-methods= ignored-parents= # Maximum number of arguments for function / method. -max-args=5 +max-args=8 # Maximum number of attributes for a class (see R0902). max-attributes=7 @@ -308,7 +307,7 @@ max-locals=15 max-parents=7 # Maximum number of positional arguments for function / method. -max-positional-arguments=5 +max-positional-arguments=8 # Maximum number of public methods for a class (see R0904). max-public-methods=20 @@ -522,7 +521,7 @@ ignore-imports=yes ignore-signatures=yes # Minimum lines number of a similarity. -min-similarity-lines=4 +min-similarity-lines=8 [SPELLING] @@ -570,7 +569,7 @@ contextmanager-decorators=contextlib.contextmanager # List of members which are set dynamically and missed by pylint inference # system, and so shouldn't trigger E1101 when accessed. Python regular # expressions are accepted. -generated-members= +generated-members=cursor # Tells whether to warn about missing members when the owner of the attribute # is inferred to be None. diff --git a/README.md b/README.md index 68efaae..b3054e6 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,59 @@ # MakeOfficeHours -[![linting: pylint](https://img.shields.io/badge/linting-pylint-yellowgreen)](https://github.com/pylint-dev/pylint) +## Getting Started -# api/ -A Flask API server that handles enqueuing and dequeuing students from the office hours queue. +See the relevant documentation for each component for development. -# Quick Started -1. You will first need to install all the development packages mainly use for, it will allow you to run tools locally for the project. +We have two Docker configurations that simulate a production sort of environment. The production setup will expect TLS certificates in the nginx directory. There's a shell script named `generate_testing_keys.sh` that can generate self-signed certificates. + +The production setup will run the application on port 443. + +To run the project in production: ```bash -pip install -r utils.txt +docker compose up --build ``` -# Project structure -[//]: # (TODO: Going to do a markdown of a file structure here so that you can see the project structure) +The testing setup will run the application on port 3000. -# Development -For this project please use the following python version: -`"3.10", "3.11", "3.12"` - -Running the development server: +To run the project in testing: ```bash -docker compose up api-development --build +docker compose -f compose-testing.yaml up --build ``` -# Pylint -Project uses pylint to keep the code style organized. +These will use the respective environment files (`.env-api-prod` and `.env-testing`) for Autolab credentials and for creating the initial administrator account. Currently in production mode, accessing any user account (including the administrator account) require authentication via Autolab. Thus, you should probably only develop using either the testing configuration or by running everything locally. Testing exposes some additional endpoints (which are accessible from the frontend at `/dev-login`) with simple password authentication. This is not safe to use in production; anyone can claim any unclaimed user in the roster without proof. -You can run the Pylint on the api folder by doing the following +The `THE_OG_UBIT` and `THE_OG_PN` fields will add this account to the roster but will not create an account. You can create an account by logging into an Autolab account with a matching UBIT, or in testing hit the `/signup` endpoint (accessible from the frontend's `/dev-login` page). -```bash -pylint $(git ls-files '*.py') -``` +Autolab integration is currently only used for authentication purposes. Since there isn't much need for further development on this front and since we have alternate authentication for testing purposes, testing API keys will generally not be distributed. Don't let this discourage you if you have ideas for further Autolab integration though, please talk to us on our Discord if you're interested! -# Formatter -Using the Black formatter https://github.com/psf/black -```bash -black $(git ls-files '*.py') -``` +## Project Structure + +### `/api` +A Flask API server and some utilities to run it locally and such. + +### `/client` +A Vue frontend for the site. + +### `/hardware` +Currently, a Python script for reading a specific card format to identify users and an example of a card payload. We may separate the card swipe hardware from the web application in the future. In that case, the relevant project will live here. + +### `/nginx` +Some utilities for nginx, mainly the aforementioned shell script to generate testing keys for the Docker setup. + +### `/tests` +Tests for the API server, which are not up to date. + +## License + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. -# Resource -Good resources to look at: -- https://flask.palletsprojects.com/en/stable/blueprints/ -- https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-xv-a-better-application-structure diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..5ca7192 --- /dev/null +++ b/api/README.md @@ -0,0 +1,114 @@ +# MakeOfficeHours API + +[![linting: pylint](https://img.shields.io/badge/linting-pylint-yellowgreen)](https://github.com/pylint-dev/pylint) + +A Flask API server that handles enqueuing and dequeuing students from the office hours queue. + +# Project Structure + +### [/auth](./auth): + +Routes and functions related to authentication. Authentication is independent of the roster; a user can be in the roster +but not have a sign-in. This just handles authenticating with Autolab, or password authentication in testing. + +### [/config](./config): + +Configuration for the Flask server. + +### [/database](./database): + +Home of the database interface and implementation. [`db.py`](./database/db.py) selects the implementation based on the +`DB` environment variable. This should probably always be set to "relational", as this is the only complete +implementation. If completed "mock" and "testing" may be useful for testing without a database, but the application is +fleshed out enough where this may not be necessary. + +Anything outside this folder should import `db` from `db.py` and use the interface to interact with the database. If the +current functionality is not sufficient, it should be added to the database interface. + +### [/queue](./queue): + +Routes and functions related to the queue. This includes all endpoints relevant to enqueuing/dequeuing students. + +Visits are created whenever a student is dequeued. Until one of the endpoints are hit that end a visit, TAs are +prevented from dequeuing and students are prevented from enqueuing. + +### [/roster](./roster): + +Routes and functions related to course management, mainly enrollments. + +TAs can manage the roster but to ensure sensitive information is kept secret, they can only add students who only have +access to the queue endpoints. This is to ensure course instructors don't need to be concerned with the day-to-day +operations of the site (e.g. a student adds the course and comes to TA office hours but can't use the site, the TA +can just add them without having to go through the professor.) + +The [controller](./roster/controller.py) defines two decorators that are used pretty extensively through the project +for permission checking: `@exact_level` and `@min_level`. It is **very** important that these decorators are below +`@blueprint.route`, as they are otherwise ignored. + +### [/visits](./visits): + +Routes and functions related to visits. + +Visit records are sensitive information and should not be freely accessible to TAs. However, TAs can currently see +visits that they were +personally involved with for their own review. + +### [/student_data_lookups](./student_data_lookups): + +Currently, nothing. As this app grows to interact with other services, these interactions should live here. + +### [/utils](./utils) + +Mainly debug utilities. The `@debug_access_only` decorator is defined here. As with the roster decorators, these **must** +go below `@blueprint.route`. If used properly, it effectively disables this route in production. This is used for the +password authentication routes to ensure that in production only Autolab can be used to verify UBITs. + + +# Development + +If you want to run the project locally, make sure you install the dependencies. + +```bash +pip install -r requirements.txt +``` + +For this project please use python 3.14. + +To run the API server locally, run +```bash +python -m api.run_local +``` +from the **root directory** of the project. This will start the server in debug mode on port 5050. + +# Utilities + +You can install these utilities locally with: + +```bash +pip install -r utils.txt +``` + +## Pylint + +Project uses pylint to keep the code style organized. + +You can run the Pylint on the api folder by doing the following + +```bash +pylint $(git ls-files '*.py') +``` + +## Formatter + +Using the Black formatter https://github.com/psf/black + +```bash +black $(git ls-files '*.py') +``` + +# Resources + +Good resources to look at: + +- https://flask.palletsprojects.com/en/stable/blueprints/ +- https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-xv-a-better-application-structure diff --git a/api/admin/routes.py b/api/admin/routes.py new file mode 100644 index 0000000..b55fb9b --- /dev/null +++ b/api/admin/routes.py @@ -0,0 +1,85 @@ +"""Routes for use by administrator page""" + +from flask import Blueprint, request, g + +from api.roster.controller import site_admin_only +from api.database.db import db + +blueprint = Blueprint("admin", __name__) + + +@blueprint.route("/course", methods=["POST"]) +@site_admin_only +def create_course(): + """Admin only route to create a new course. + + Params: + - name + - semester + - url + """ + + json = request.get_json() + + try: + json["url"].encode("ascii") + except UnicodeEncodeError: + return {"message": "Invalid course URL."}, 400 + + course = db.create_course(json["name"], json["semester"], json["url"]) + + if course == -1: + return {"message": "Failed to create course, uniqueness failed"}, 400 + + return db.get_course(course) + + +@blueprint.route("/courses", methods=["GET"]) +@site_admin_only +def get_courses(): + """Retrieves all courses""" + return {"courses": db.get_courses()} + + +@blueprint.route("/user", methods=["POST"]) +@site_admin_only +def create_user(): + """Creates a normal user. + + Params: + "ubit": + "pn": + "first_name": + "last_name": + """ + json = request.get_json() + + if not all(key in json for key in ["ubit", "pn", "first_name", "last_name"]): + return {"message": "Malformed request"}, 400 + + user = db.create_account(json["ubit"], json["pn"]) + db.set_initial_name(user, json["first_name"], json["last_name"]) + + return {"user": user} + + +@blueprint.route("/course//appoint", methods=["POST"]) +@site_admin_only +def create_instructor(): + """Appoints a user to be the instructor of a course. + This user must already exist. + + Params: + course_id: ID of the course + ubit: UBIT of the instructor to promote + :return: + """ + json = request.get_json() + + user = db.lookup_identifier(json["ubit"]) + + if not user: + return {"message": "User not found"}, 404 + + db.add_to_roster(user["user_id"], "instructor", g.course_id) + return {"message": "Enrolled user as an instructor."} diff --git a/api/auth/autolab_oauth.py b/api/auth/autolab_oauth.py index 46a5ea0..8529958 100644 --- a/api/auth/autolab_oauth.py +++ b/api/auth/autolab_oauth.py @@ -1,8 +1,7 @@ +"""Functions to handle authentication via Autolab""" + import os -import base64 import json -import random -import secrets from urllib.parse import urlencode import requests @@ -13,122 +12,118 @@ AUTOLAB_ID = os.getenv("AUTOLAB_CLIENT_ID", "client_id") REDIRECT_URI = os.getenv("AUTOLAB_CALLBACK", "callback") + def get_authorization_url(): + """Generates the appropriate Autolab authorization URL based on the environment variables""" autolab_url = "https://autolab.cse.buffalo.edu/oauth/authorize?" - params={ - "redirect_uri":REDIRECT_URI, - "client_id":AUTOLAB_ID, - "response_type":"code", - "state":"abc", - "scopes":"user_info" - } - for (name, value) in params.items(): + params = { + "redirect_uri": REDIRECT_URI, + "client_id": AUTOLAB_ID, + "response_type": "code", + "state": "abc", + "scopes": "user_info", + } + for name, value in params.items(): autolab_url += name + "=" + value + "&" autolab_url = autolab_url[:-1] - print(autolab_url) return autolab_url -def start_session(): - token = secrets.token_urlsafe(20) - state = secrets.token_urlsafe(20) - users_collection.insert_one({"token": token, "state": state}) - return [token, state] +def handle_code_after_redirect(code): + """Cashes in the code for a token and signs the user in""" + token = cash_in_code_for_token(code) + if token is None: + return None -def handle_code_after_redirect(code, state, session): - token = cash_in_code_for_token(code) + result = user_info(token) - [users_email, users_name] = user_info(token) + if not result: + return None - ubit = users_email.split("@")[0] - user_profile = db.lookup_identifier(ubit) + [users_email, _] = result - if not user_profile: - return None + ubit = users_email.split("@")[0] + user_profile = db.lookup_identifier(ubit) - auth_token = db.sign_in_with_autolab(user_profile["user_id"]) - - return auth_token + if not user_profile: + return None + auth_token = db.sign_in_with_autolab(user_profile["user_id"]) + return auth_token def cash_in_code_for_token(code): + """Obtains the user's token given a code""" token_url = "https://autolab.cse.buffalo.edu/oauth/token" headers = { "Content-Type": "application/x-www-form-urlencoded", } - data = urlencode({ - "grant_type": "authorization_code", - "code": code, - "client_id": AUTOLAB_ID, - "client_secret": AUTOLAB_SECRET, - "redirect_uri": REDIRECT_URI - }) - - response = requests.post(token_url, headers=headers, data=data) - print(response) + data = urlencode( + { + "grant_type": "authorization_code", + "code": code, + "client_id": AUTOLAB_ID, + "client_secret": AUTOLAB_SECRET, + "redirect_uri": REDIRECT_URI, + } + ) + try: + response = requests.post(token_url, headers=headers, data=data, timeout=5.0) + except requests.Timeout: + return None the_good_stuff = json.loads(response.content.decode()) access_token = the_good_stuff.get("access_token") - refresh_token = the_good_stuff.get("refresh_token") - scope = the_good_stuff.get("scope") - expires_in = the_good_stuff.get("expires_in") - created_at = the_good_stuff.get("created_at") - - # users_collection.update_one({"token": token}, {"$set": the_good_stuff}) + # refresh_token = the_good_stuff.get("refresh_token") + # scope = the_good_stuff.get("scope") + # expires_in = the_good_stuff.get("expires_in") + # created_at = the_good_stuff.get("created_at") return access_token -def cash_in_refresh_token_for_token(refresh_token, token): - token_url = "https://autolab.cse.buffalo.edu/oauth/token" - headers = { - "Content-Type": "application/x-www-form-urlencoded", - } - data = urlencode({ - "grant_type": "refresh_token", - "refresh_token": refresh_token - }) - - response = requests.post(token_url, headers=headers, data=data) - print(response) - the_good_stuff = json.loads(response.content.decode()) - access_token = the_good_stuff.get("access_token") - refresh_token = the_good_stuff.get("refresh_token") - scope = the_good_stuff.get("scope") - expires_in = the_good_stuff.get("expires_in") - created_at = the_good_stuff.get("created_at") - - users_collection.update_one({"token": token}, {"$set": the_good_stuff}) - - return access_token +# def cash_in_refresh_token_for_token(refresh_token, token): +# token_url = "https://autolab.cse.buffalo.edu/oauth/token" +# headers = { +# "Content-Type": "application/x-www-form-urlencoded", +# } +# data = urlencode({"grant_type": "refresh_token", "refresh_token": refresh_token}) +# +# response = requests.post(token_url, headers=headers, data=data) +# the_good_stuff = json.loads(response.content.decode()) +# access_token = the_good_stuff.get("access_token") +# # refresh_token = the_good_stuff.get("refresh_token") +# # scope = the_good_stuff.get("scope") +# # expires_in = the_good_stuff.get("expires_in") +# # created_at = the_good_stuff.get("created_at") +# +# return access_token def user_info(access_token): + """Hits the API's user endpoint and extracts the user's information""" user_url = "https://autolab.cse.buffalo.edu/api/v1/user" - headers = { - "Authorization": "Bearer " + access_token - } - response = requests.get(user_url, headers=headers) - print(response) + headers = {"Authorization": "Bearer " + access_token} + try: + response = requests.get(user_url, headers=headers, timeout=5.0) + except requests.Timeout: + return None user_data = json.loads(response.content.decode()) email = user_data.get("email") first_name = user_data.get("first_name") last_name = user_data.get("last_name") - # users_collection.update_one({"token": token}, {"$set": user_data}) return [email, first_name + " " + last_name] -def check_token(token): - user_record = users_collection.find_one({"token": token}) - if user_record and "email" in user_record: - return [user_record.get("email"), - user_record.get("first_name") + " " + user_record.get("last_name")] - else: - return [None, None] +# def check_token(token): +# user_record = users_collection.find_one({"token": token}) +# if user_record and "email" in user_record: +# return [ +# user_record.get("email"), +# user_record.get("first_name") + " " + user_record.get("last_name"), +# ] +# else: +# return [None, None] # TODO: Use refresh tokens. For now, once the token expires you'll lose access and things will break - -if __name__ == '__main__': - pass diff --git a/api/auth/controller.py b/api/auth/controller.py index 5cb6487..6ffabe8 100644 --- a/api/auth/controller.py +++ b/api/auth/controller.py @@ -1,16 +1,32 @@ +"""Functions to handle authentication operations""" + from api.database.db import db -import os def create_account(username, numeric_identifier, auth_level="student"): + """Creates an account with the specified information. Does + not create a sign-in. Also adds this account to the roster. + + :param username: the username of the account to add + :param numeric_identifier: a numeric identifier for this account. used by the card swipe + :param auth_level: the authentication level. + :return: the id of the newly created account. + """ + account_id = db.create_account(username, numeric_identifier) db.add_to_roster(account_id, auth_level) return account_id + def get_user(cookies): + """Gets the user account associated with these cookies + retrieved from a request. + + :param cookies: dict[str, str] of the request's cookies + :return: A dict containing the user's information if such a user exists, + none otherwise. + """ if "auth_token" not in cookies: - return None + return None return db.get_authenticated_user(cookies["auth_token"]) - - diff --git a/api/auth/routes.py b/api/auth/routes.py index 173f808..cf3467e 100644 --- a/api/auth/routes.py +++ b/api/auth/routes.py @@ -1,24 +1,23 @@ """Authentication Blueprint for MOH""" import json -import os -import urllib.parse -import requests - -from flask import Blueprint, request, make_response, redirect +from flask import Blueprint, request, make_response, redirect, g from api.database.db import db from api.auth.autolab_oauth import get_authorization_url, handle_code_after_redirect +from api.roster.controller import min_level from api.utils.debug import debug_access_only blueprint = Blueprint("auth", __name__) #### Autolab Paths + @blueprint.route("/authorize", methods=["GET"]) def login_with_autolab(): """ - Called when the user clicks login with Autolab. Starts the process on talking to Autolab to get an Oauth access token + Called when the user clicks login with Autolab. Starts the process + on talking to Autolab to get an Oauth access token """ return redirect(get_authorization_url(), code=302) @@ -26,18 +25,22 @@ def login_with_autolab(): @blueprint.route("/callback", methods=["GET"]) def getting_code_from_autolab(): """ - Next step in the OAuth proccess. We're getting an auth code from Autolab and need to cash it in for an access token and refresh token + Next step in the OAuth proccess. We're getting an auth code from Autolab + and need to cash it in for an access token and refresh token """ print(request.args) code = request.args.get("code") - state = request.args.get("state") + # state = request.args.get("state") # TODO: check cookie to match state to session - session = "not_implemented" + # session = "not_implemented" - auth_token = handle_code_after_redirect(code, state, session) + auth_token = handle_code_after_redirect(code) if not auth_token: - res = make_response("You are not enrolled in this class. If you should be, email Paul. It's his fault", 401) + res = make_response( + "You are not enrolled in this class. If you should be, email Paul. It's his fault", + 401, + ) return res res = make_response(redirect("/queue")) @@ -49,6 +52,7 @@ def getting_code_from_autolab(): ### Universal Paths (Used regardless of auth provider) + @blueprint.route("/signout", methods=["POST"]) def signout(): """Signs out the currently logged-in user, invalidating their auth token @@ -76,6 +80,7 @@ def signout(): ### Password auth paths + @blueprint.route("/login", methods=["POST"]) @debug_access_only def login(): @@ -110,7 +115,6 @@ def login(): def signup(): """Creates an account using the given credentials, fails if ubit already registered for an account - or if ubit is not in the roster Args: ubit: forum data field of ubit password: forum data field of password @@ -138,7 +142,25 @@ def signup(): return res +@blueprint.route("/update-name", methods=["PATCH"]) +def update_preferred_name(): + """ + Update the user's preferred name. + + :return: 200, on success + 400, if malformed + 401, if user isn't authenticated + """ + user = g.user + + if user is None: + return {"message": "You are not authenticated!"}, 401 + + body = request.get_json() + + if (name := body.get("name")) is None: + return {"message": "Malformed request."}, 400 -# TODO: update preferred name + db.set_preferred_name(user["ubit"], name) -# TODO: account has UBIT (For AL lookups) and pn (For card swipes) + return {"message": "Updated preferred name."} diff --git a/api/config/config.py b/api/config/config.py index 05aff57..dbc1e2a 100644 --- a/api/config/config.py +++ b/api/config/config.py @@ -8,4 +8,4 @@ class Config: def __init__(self): self.API_MODE = os.getenv("API_MODE", "Can not find mode") - self.MAX_CONTENT_LENGTH = 16 * 1000 * 1000 \ No newline at end of file + self.MAX_CONTENT_LENGTH = 16 * 1000 * 1000 diff --git a/api/database/db.py b/api/database/db.py index 0120e52..6cc23f4 100644 --- a/api/database/db.py +++ b/api/database/db.py @@ -1,19 +1,20 @@ +"""Defines the database based on the DB environment variable. +Outside the database folder, this is how the database should be accessed.""" + import os from api.database.relational_db.relational_db import RelationalDB from api.database.testing_db.testing_db import TestingDB -from api.database.mock_db.mock_db import MockDB def create_db(): + """Create the database based on the DB environment variable""" db_type = os.getenv("DB") match db_type: case "relational": return RelationalDB() case "testing": return TestingDB() - case "mock": - return MockDB() case None: raise EnvironmentError('environment variable "DB" not set') case _: diff --git a/api/database/db_interface.py b/api/database/db_interface.py index f28b493..01e7008 100644 --- a/api/database/db_interface.py +++ b/api/database/db_interface.py @@ -1,19 +1,19 @@ +"""The complete database interface. Split into multiple classes for readability.""" + +# pylint: disable=duplicate-code from abc import ABC, abstractmethod +from api.database.idb_courses import ICourses from api.database.idb_queue import IQueue -from api.database.idb_ratings import IRatings from api.database.idb_accounts import IAccounts from api.database.idb_roster import IRoster from api.database.idb_sessions import ISessions -class DBInterface(IQueue, IRatings, IAccounts, IRoster, ISessions, ABC): - - # All database implements must extend this class - - def __init__(self): - super().__init__() +class DBInterface(IQueue, IAccounts, IRoster, ISessions, ICourses, ABC): + """The combined database interface. + All database implements must extend this class""" @abstractmethod def connect(self): - pass + """Connect to the database. May not do anything based on implementation.""" diff --git a/api/database/idb_accounts.py b/api/database/idb_accounts.py index 1f60681..e354b82 100644 --- a/api/database/idb_accounts.py +++ b/api/database/idb_accounts.py @@ -1,61 +1,115 @@ +"""The account component of the database interface.""" + +# pylint: disable=duplicate-code from abc import ABC, abstractmethod class IAccounts(ABC): - - def __init__(self): - super().__init__() + """Definitions for the accounts component of the database interface.""" @abstractmethod - def create_account(self, ubit, pn): - # Creates an account with the provided ubit and pn. Generates, and returns, a unique id for the new account + def create_account(self, ubit, pn, role="user"): + """Creates an account with the provided ubit and pn. Generates, and returns, a unique id for the new account""" raise NotImplementedError() @abstractmethod - def lookup_person_number(self, person_number) -> dict[str, str]: - # Returns the database entry for the user with the specified person number. + def lookup_person_number(self, person_number, course=None) -> dict[str, str]: + """Returns the database entry for the user with the specified person number.""" raise NotImplementedError() @abstractmethod - def lookup_identifier(self, identifier) -> dict[str, str]: - # Returns the database entry for the user with the specified identifier. - # resolves UBIT -> person number -> unique id + def lookup_identifier(self, identifier, course=None) -> dict[str, str]: + """Returns the database entry for the user with the specified identifier. + resolves UBIT -> person number -> unique id + """ raise NotImplementedError() @abstractmethod - def get_authenticated_user(self, auth_token) -> dict[str, str]: - # Returns the database entry for the user with the specified auth token. + def get_authenticated_user(self, auth_token, course=None) -> dict[str, str]: + """Returns the database entry for the user with the specified auth token.""" raise NotImplementedError() @abstractmethod def sign_up(self, username, pw) -> str | None: - # creates a sign in for the requested user - # returns None if the user's ubit isn't in the system - # returns an auth token for the user + """creates a sign in for the requested user + + :param username: the user's username + :param pw: the desired password + :return: None if the user's ubit isn't in the system + an auth token for the user, otherwise + """ + raise NotImplementedError() @abstractmethod def sign_in(self, username, pw) -> str | None: - # generates and returns a valid auth token for the user if the username and password match - # returns None on error + """generates and returns a valid auth token for the user if the username and password match + + :param username: The username to check + :param pw: The password to check + :return: The generated auth token, on success + returns None on error + """ + raise NotImplementedError() @abstractmethod def sign_in_with_autolab(self, ubit) -> str | None: - # generates and returns a valid auth token for the user. Assumes Autolab already authenticated the user - # returns None on error + """generates and returns a valid auth token for the user. + Assumes Autolab already authenticated the user + + :param ubit: The ubit to generate an auth token for. + :return: The generated auth token + None on error + """ raise NotImplementedError() @abstractmethod def sign_out(self, auth_token): - # invalidates the specified auth token + """invalidates the specified auth token""" raise NotImplementedError() @abstractmethod def set_preferred_name(self, identifier, name): - # set the user's preferred name based on identifier + """set the user's preferred name based on identifier + + :param identifier: the identifier of the user + :param name: the desired new name + :return The user ID on success + None if the user doesn't exist + """ + raise NotImplementedError() @abstractmethod - def set_name(self, identifier, first_name, last_name): - raise NotImplementedError() \ No newline at end of file + def set_initial_name(self, user_id, first_name, last_name): + """Sets the user's full name based on identifier + + :param user_id: the user's id + :param first_name: the user's desired first name + :param last_name: the user's desired last name + :return: The user ID on success + None if the user doesn't exist + """ + + raise NotImplementedError() + + @abstractmethod + def delete_user(self, user_id): + """Deletes the specified user. They must not be returned + in other functions after this, except in historical visit + records. These records must have some indication that + the visit is "archived." + + :param user_id: The user id of the user to delete + """ + raise NotImplementedError() + + @abstractmethod + def get_enrollments(self, user_id): + """Return all the courses the specified user is a member + of, alongside the role. + + :param user_id: the user id to query + :return: + """ diff --git a/api/database/idb_courses.py b/api/database/idb_courses.py new file mode 100644 index 0000000..57b7836 --- /dev/null +++ b/api/database/idb_courses.py @@ -0,0 +1,37 @@ +"""Database functions relating to courses""" + +from abc import ABC, abstractmethod + + +class ICourses(ABC): + """Class containing database methods for course management""" + + @abstractmethod + def create_course(self, name, semester, url) -> int: + """Create a new course with the specified information + + :param name: name of the course, e.g. "CSE 116: Intro to Computer Science II" + :param semester: identifier of this specified offering, e.g. "f26" + :param url: unique url for this course, e.g. "cse116-f26" + :return: the generated course_id for this course + """ + raise NotImplementedError() + + @abstractmethod + def get_course(self, identifier) -> dict[str, str]: + """ + Retrieve database info for the specified course. + + :param identifier: the course id or url to look up + :return: database info for this course + """ + raise NotImplementedError() + + @abstractmethod + def get_courses(self) -> list[dict[str, str]]: + """ + Retrieve database info for all courses + + :return: All courses in the form of dicts + """ + raise NotImplementedError() diff --git a/api/database/idb_queue.py b/api/database/idb_queue.py index cf6c0c3..36ecaa1 100644 --- a/api/database/idb_queue.py +++ b/api/database/idb_queue.py @@ -1,47 +1,121 @@ +"""The queue component of the database interface""" + +# pylint: disable=duplicate-code from abc import ABC, abstractmethod class IQueue(ABC): + """Definitions for the database's queue interface""" + + @abstractmethod + def enqueue_student(self, student, course): + """Add the specified student to the end of the queue. - def __init__(self): - super().__init__() + :param student: Identifier for the student to add + """ + raise NotImplementedError() @abstractmethod - def enqueue_student(self, student): + def enqueue_student_front(self, student, course): + """Add the specified student to the front of the queue. + + :param student: Identifier for the student to add + """ raise NotImplementedError() @abstractmethod - def enqueue_student_front(self, student): + def dequeue_student(self, course): + """Remove the student at the front of the queue + from the queue. + + + :return: The student's information, the visit reason, + and visit time in a dict. + { + "user_id": , + "preferred_name": , + "ubit": , + "person_num": , + "enqueue_time": , + "enqueue_reason": , + } + """ + raise NotImplementedError() @abstractmethod - def dequeue_student(self): + def get_queue(self, course): + """Retrieve the queue. + + :return: The queue as a list of dicts matching + { + "id": , + "preferred_name": , + "ubit": , + "pn": , + } + """ raise NotImplementedError() @abstractmethod - def get_queue(self): + def remove_student(self, student, course): + """Remove the specified student from the queue + + :param student: The student's user id + :return: The student's ID and join time in a dict: + { + "user_id": , + "joined": + } + None on failure + """ raise NotImplementedError() @abstractmethod - def remove_student(self, student): + def dequeue_specified_student(self, student_id, course): + """Remove the specified student from the queue + + :param student_id: The user ID of the student to dequeue + :return: The same information as dequeue_student + """ raise NotImplementedError() @abstractmethod - def clear_queue(self): + def clear_queue(self, course): + """Removes all students from the queue.""" raise NotImplementedError() @abstractmethod - def set_reason(self, student, reason): + def set_reason(self, student, reason, course): + """Marks the student's reason for joining the queue + + :param student: the student whose reason to change + :param reason: the user specified reason for joining + """ raise NotImplementedError() @abstractmethod - def move_to_end(self, student): + def move_to_end(self, student, course): + """Move the specified student to the end of the queue + + :param student: ID of the student to move + :return: True on success, False on failure + """ raise NotImplementedError() @abstractmethod - def get_hw_authorization(self): + def get_hw_authorization(self, course): + """Retrieves the authorization code for the card swipe + + :return: the authorization code + """ raise NotImplementedError() @abstractmethod - def reset_hw_authorization(self): - raise NotImplementedError() \ No newline at end of file + def reset_hw_authorization(self, course): + """Resets the hardware authorization code + and generates a new, random one, + + :return: The newly generated code. + """ + raise NotImplementedError() diff --git a/api/database/idb_ratings.py b/api/database/idb_ratings.py deleted file mode 100644 index 49f3a0b..0000000 --- a/api/database/idb_ratings.py +++ /dev/null @@ -1,11 +0,0 @@ -from abc import ABC, abstractmethod - - -class IRatings(ABC): - - def __init__(self): - super().__init__() - - @abstractmethod - def rate_student(self, student, rating, feedback): - raise NotImplementedError() diff --git a/api/database/idb_roster.py b/api/database/idb_roster.py index 91cec71..c8d00ab 100644 --- a/api/database/idb_roster.py +++ b/api/database/idb_roster.py @@ -1,15 +1,64 @@ +"""The roster component of the database interface""" + +# pylint: disable=duplicate-code from abc import ABC, abstractmethod class IRoster(ABC): + """Definitions for the roster component of the database interface""" + + @abstractmethod + def add_to_roster(self, user_id, role, course): + """Set the user with id user_id to have the role + specified by role. + + :param user_id: The user_id of the user + :param role: The desired role. + :param course: the course to add to user to + :return: + """ + raise NotImplementedError() - def __init__(self): - super().__init__() + @abstractmethod + def get_roster(self, course): + """Retrieve the entire roster and their + relevant information from the database. + + :return: The roster as a list of dicts like: + { + "user_id", + "preferred_name", + "last_name", + "ubit", + "person_num", + "course_role", + } + """ + raise NotImplementedError() @abstractmethod - def add_to_roster(self, user_id, role): + def remove_from_roster(self, user_id, course): + """Remove the specified user from the course + + :param user_id: the user to remove + :param course: the course to remove from + :return: + """ raise NotImplementedError() @abstractmethod - def get_roster(self): - raise NotImplementedError() \ No newline at end of file + def clear_students(self, course): + """Marks all students as deleted.""" + raise NotImplementedError + + # @abstractmethod + # def get_matched_student(self, query) -> list: + # """ + # Find students matching query. + # + # + # :param query: Substring of either the student's + # preferred name, last name, or username. + # :return: A list of student information as a dict + # """ + # raise NotImplementedError() diff --git a/api/database/idb_sessions.py b/api/database/idb_sessions.py index f45a044..1a67afa 100644 --- a/api/database/idb_sessions.py +++ b/api/database/idb_sessions.py @@ -1,31 +1,50 @@ +"""The user sessions component of the database interface""" + +# pylint: disable=duplicate-code from abc import ABC, abstractmethod class ISessions(ABC): + """Definitions for the user sessions component of the database interface.""" @abstractmethod - def update_swipe_time(self, user): - """ Update time user was enqueued to current time """ + def update_swipe_time(self, user, course): + """Update time user was last enqueued to current time. + + :param user: the user id of the user to refresh. + """ raise NotImplementedError() @abstractmethod - def reset_swipe_time(self, user): - """ Reset time user was enqueued """ + def reset_swipe_time(self, user, course): + """Reset time user was enqueued. + + :param course: the course the user is a part of + :param user: the user id of the user + """ raise NotImplementedError() @abstractmethod - def get_swipe_time(self, user): - """ Return time user was enqueued """ + def get_swipe_time(self, user, course): + """Return time user was enqueued. + + :param course: the course the user is a part of + :param user: the user id of the user + :return: the timestamp of when the user last swiped formatted YYYY-MM-DD HH:MM:SS + None if the user has never swiped, or if it was reset + """ raise NotImplementedError() @abstractmethod - def get_on_site(self): - """ Return list of students who have swiped in <= 2 hours - who are not currently in the queue + def get_on_site(self, course): + """Return list of students who have swiped in <= 2 hours + who are not currently in the queue + + :return: list of active students. """ raise NotImplementedError() @abstractmethod - def clear_on_site(self): - """ Reset everyone's last enqueue time """ + def clear_on_site(self, course): + """Reset everyone's last enqueue time""" raise NotImplementedError() diff --git a/api/database/idb_visits.py b/api/database/idb_visits.py index 4dd2430..d49d301 100644 --- a/api/database/idb_visits.py +++ b/api/database/idb_visits.py @@ -1,18 +1,21 @@ +"""The visits component of the database interface""" + +# pylint: disable=duplicate-code from abc import ABC, abstractmethod -class IVisits(ABC): - def __init__(self): - super().__init__() +class IVisits(ABC): + """Definitions for the visits component of the database interface""" @abstractmethod - def create_visit(self, student, ta, enqueue_time, visit_reason) -> int: + def create_visit(self, student, ta, enqueue_time, visit_reason, course) -> int: """Create a database entry for the ongoing visit between the specified student and TA. enqueue_time should be in format "YYYY-MM-DD HH:MM:SS" Should indicate the time the visit was created + :param course: the course to create a visit in :param student: The user ID of the student :param ta: The user ID of the TA :param enqueue_time: Timestamp when the student joined the queue, @@ -34,22 +37,37 @@ def end_visit(self, visit_id, reason): @abstractmethod def cancel_visit(self, visit_id): - """ Destroy this visit from the database if it's still in progress. + """Destroy this visit from the database if it's still in progress. Un-dequeue this student from the queue if they have been dequeued. :param visit_id: :return: """ - raise NotImplementedError() @abstractmethod - def get_in_progress_visits(self): - """ Return all database entries for visits that have - not ended. + def get_in_progress_visits(self, course): + """Return all database entries for visits that have + not ended for this course. + + :param course: the course to get visits from :return: """ - raise NotImplementedError() \ No newline at end of file + raise NotImplementedError() + + @abstractmethod + def get_visits(self, course, user_id=None): + """Return all database entries for visits. + + If user_id is set, get all visits that contain + this user, either as a student or as a TA. + + :param course: the course to get visits from + :param user_id: Optional user_id + :return: All visits from the database + """ + + raise NotImplementedError() diff --git a/api/database/mock_db/mock_db.py b/api/database/mock_db/mock_db.py deleted file mode 100644 index 2aba52a..0000000 --- a/api/database/mock_db/mock_db.py +++ /dev/null @@ -1,22 +0,0 @@ -from api.database.db_interface import DBInterface - - -class MockDB(DBInterface): - - def connect(self): - pass - - def enqueue_student(self, student): - pass - - def dequeue_student(self): - pass - - def rate_student(self, student, rating, feedback): - pass - - def create_account(self, ubit, pn): - pass - - def add_to_roster(self, user_id, role): - pass diff --git a/api/database/relational_db/migrations/1_roster_management.sql b/api/database/relational_db/migrations/1_roster_management.sql new file mode 100644 index 0000000..f664c4e --- /dev/null +++ b/api/database/relational_db/migrations/1_roster_management.sql @@ -0,0 +1,23 @@ +PRAGMA user_version = 1; + +-- Making ubit and person_num unique means that a user cannot be re-enrolled if their original +-- account is deleted under normal circumstances. Unfortunately it is not possible to remove +-- this constraint. Thus, we're recreating the entire table here and taking the opportunity to +-- add the deleted column while we're at it. +CREATE TABLE users_new +( + user_id INTEGER PRIMARY KEY, + preferred_name VARCHAR(255), + last_name VARCHAR(255), + ubit VARCHAR(16), + person_num INTEGER, + course_role VARCHAR(16), + last_swipe TEXT +); + +INSERT INTO users_new (user_id, preferred_name, last_name, ubit, person_num, course_role, last_swipe) +SELECT user_id, preferred_name, last_name, ubit, person_num, course_role, last_swipe FROM users; + +ALTER TABLE users_new ADD COLUMN deleted DEFAULT false; +DROP TABLE users; +ALTER TABLE users_new RENAME TO users; \ No newline at end of file diff --git a/api/database/relational_db/relational_db.py b/api/database/relational_db/relational_db.py index 016af8e..55560bf 100644 --- a/api/database/relational_db/relational_db.py +++ b/api/database/relational_db/relational_db.py @@ -1,3 +1,5 @@ +"""An implementation of the database interface using SQLite.""" + import os from api.database.db_interface import DBInterface @@ -5,19 +7,27 @@ from api.database.relational_db.relational_db_queue import RelationalDBQueue from api.database.relational_db.relational_db_accounts import RelationalDBAccounts -from api.database.relational_db.relational_db_ratings import RelationalDBRatings from api.database.relational_db.relational_db_sessions import RelationalDBSessions from api.database.relational_db.relational_db_visits import RelationalDBVisits -class RelationalDB(DBInterface, RelationalDBAccounts, RelationalDBQueue, RelationalDBRatings, RelationalDBVisits, RelationalDBSessions): +class RelationalDB( + DBInterface, + RelationalDBAccounts, + RelationalDBQueue, + RelationalDBVisits, + RelationalDBSessions, +): # pylint: disable=too-many-ancestors + """Implementation for the SQLite version of the database interface.""" def __init__(self): super().__init__() + self.db_version = 0 self.filename = os.getenv("SQLITE_DB_PATH", "./moh.sqlite") - self.initialize() + self._initialize() + self._migrate() - def initialize(self): + def _initialize(self): with self.cursor() as c: c.execute( """ @@ -92,8 +102,21 @@ def initialize(self): ) def cursor(self): + """Creates new cursor. Use with statements to ensure connections are cleaned up.""" return RelationalDBCursor(self) def connect(self): pass + def _migrate(self): + with self.cursor() as c: + self.db_version = c.execute("PRAGMA user_version").fetchone()[0] + for script in sorted(os.listdir("./api/database/relational_db/migrations")): + if int(script.split("_")[0]) > self.db_version: + with open( + f"./api/database/relational_db/migrations/{script}", + "r", + encoding="utf-8", + ) as sc_file: + c.executescript(sc_file.read()) + self.db_version = c.execute("PRAGMA user_version").fetchone()[0] diff --git a/api/database/relational_db/relational_db_accounts.py b/api/database/relational_db/relational_db_accounts.py index 076da45..a1e8c43 100644 --- a/api/database/relational_db/relational_db_accounts.py +++ b/api/database/relational_db/relational_db_accounts.py @@ -1,23 +1,83 @@ +"""Accounts and roster methods for SQLite implementation""" + import datetime -import hashlib import secrets +import hashlib -from api.database.idb_accounts import IAccounts import bcrypt -import hashlib +from api.database.idb_accounts import IAccounts +from api.database.idb_courses import ICourses from api.database.idb_roster import IRoster -class RelationalDBAccounts(IAccounts, IRoster): +class RelationalDBAccounts(IAccounts, IRoster, ICourses): + """Implementations for the accounts and roster components.""" + + def get_courses(self) -> list[dict[str, str]]: + with self.cursor() as cursor: + courses = cursor.execute( + """ + SELECT course_id, course_name, course_sem, course_url FROM courses + """ + ).fetchall() + + courses_l = [] + + for course in courses: + courses_l.append(dict(course)) + + return courses_l - def create_account(self, ubit, pn): + def get_enrollments(self, user_id): + with self.cursor() as cursor: + courses = cursor.execute( + """ + SELECT c.course_id, course_name, course_sem, course_url, course_role FROM courses as c + INNER JOIN enrollments as e + ON c.course_id = e.course_id + WHERE e.user_id = ? + """, + (user_id,), + ).fetchall() + + courses_l = [] + for course in courses: + courses_l.append(dict(course)) + + return courses_l + + def create_course(self, name, semester, url) -> int: + with self.cursor() as cursor: + prev = cursor.execute( + "SELECT * FROM courses WHERE course_url = ? OR course_id = ?", + (url, url), + ).fetchone() + if prev is not None: + return -1 + + course = cursor.execute( + "INSERT INTO courses (course_name, course_sem, course_url) VALUES (?, ?, ?) RETURNING *", + (name, semester, url), + ).fetchone() + + return course["course_id"] + + def get_course(self, identifier) -> dict[str, str]: + with self.cursor() as cursor: + result = cursor.execute( + "SELECT * FROM courses WHERE course_id = ? OR course_url = ?", + (identifier, identifier), + ).fetchone() + return dict(result) if result is not None else None + + def create_account(self, ubit, pn, role="user"): with self.cursor() as cursor: user_id = cursor.execute( """ - SELECT user_id FROM users WHERE ubit=? or person_num=? + SELECT user_id FROM users WHERE (ubit=? or person_num=?) """, (ubit, pn), ).fetchone() @@ -25,72 +85,97 @@ def create_account(self, ubit, pn): if user_id is None: user_id = cursor.execute( """ - INSERT into users (ubit, person_num, course_role) VALUES ( - ?, ?, "student" + INSERT into users (ubit, person_num, site_role) VALUES ( + ?, ?, ? ) RETURNING user_id; """, - (ubit, pn), + (ubit, pn, role), ).fetchone()[0] else: user_id = user_id[0] return user_id - def lookup_person_number(self, person_number): + def _get_course_role(self, user_id, course): + + if course is None: + return None + + course = self.get_course(course) + + with self.cursor() as cursor: + role = cursor.execute( + "SELECT course_role FROM enrollments WHERE user_id = ? AND course_id = ?", + ( + user_id, + course["course_id"], + ), + ).fetchone() + if role is not None: + return role["course_role"] + return None + + def lookup_person_number(self, person_number, course=None): with self.cursor() as cursor: user = cursor.execute( """ - SELECT preferred_name, last_name, ubit, person_num, course_role, user_id from users + SELECT preferred_name, last_name, ubit, person_num, site_role, user_id from users + WHERE person_num = ? """, (person_number,), ).fetchone() + if user is None: + return None - if user is None: - return None + course_role = self._get_course_role(user["user_id"], course) return { "preferred_name": user[0], "last_name": user[1], "ubit": user[2], "person_num": user[3], - "course_role": user[4], + "site_role": user[4], "user_id": user[5], + "course_role": course_role, } - def lookup_identifier(self, identifier): + def lookup_identifier(self, identifier, course=None): with self.cursor() as cursor: user = cursor.execute( """ - SELECT preferred_name, last_name, ubit, person_num, course_role, user_id from users - WHERE ubit = ? OR person_num = ? OR user_id = ? + SELECT preferred_name, last_name, ubit, person_num, site_role, user_id from users + WHERE (ubit = ? OR person_num = ? OR user_id = ?) """, (identifier, identifier, identifier), ).fetchone() - if user is None: - return None + if not user: + return None - return { - "preferred_name": user[0], - "last_name": user[1], - "ubit": user[2], - "person_num": user[3], - "course_role": user[4], - "user_id": user[5], - } + course_role = self._get_course_role(user["user_id"], course) - def get_authenticated_user(self, auth_token): + return { + "preferred_name": user["preferred_name"], + "last_name": user["last_name"], + "ubit": user["ubit"], + "person_num": user["person_num"], + "site_role": user["site_role"], + "user_id": user["user_id"], + "course_role": course_role, + } + + def get_authenticated_user(self, auth_token, course=None): hashed_token = hashlib.sha256(auth_token.encode()).digest() with self.cursor() as cursor: user = cursor.execute( """ - SELECT preferred_name, last_name, ubit, person_num, course_role, users.user_id, last_swipe + SELECT preferred_name, last_name, ubit, person_num, site_role, users.user_id FROM users INNER JOIN auth ON users.user_id = auth.user_id - WHERE auth_token = ? + WHERE auth_token = ? AND expires_at > CURRENT_TIMESTAMP """, (hashed_token,), @@ -99,7 +184,8 @@ def get_authenticated_user(self, auth_token): if not user: return None - enqueue_time = user[6] + # TODO: fix this + enqueue_time = None if enqueue_time is None: on_site = False @@ -118,9 +204,10 @@ def get_authenticated_user(self, auth_token): "last_name": user[1], "ubit": user[2], "person_num": user[3], - "course_role": user[4], + "site_role": user[4], "user_id": user[5], - "on_site": on_site + "on_site": on_site, + "course_role": self._get_course_role(user["user_id"], course), } def sign_up(self, username, pw) -> str | None: @@ -163,7 +250,6 @@ def _generate_auth_token(self, user_id): return auth_token - def sign_in(self, username, pw) -> str | None: with self.cursor() as cursor: hashed = cursor.execute( @@ -186,8 +272,7 @@ def sign_in(self, username, pw) -> str | None: auth_token = self._generate_auth_token(user_id) return auth_token - - def sign_in_with_autolab(self, user_id) -> str | None: + def sign_in_with_autolab(self, ubit) -> str | None: with self.cursor() as cursor: cursor.execute( """ @@ -195,13 +280,12 @@ def sign_in_with_autolab(self, user_id) -> str | None: INTO auth (user_id) VALUES (?) """, - (user_id,) + (ubit,), ) - auth_token = self._generate_auth_token(user_id) + auth_token = self._generate_auth_token(ubit) return auth_token - def sign_out(self, auth_token): hashed_auth = hashlib.sha256(auth_token.encode()).digest() with self.cursor() as cursor: @@ -214,38 +298,54 @@ def sign_out(self, auth_token): (hashed_auth,), ) - def add_to_roster(self, user_id, role): + def add_to_roster(self, user_id, role, course): with self.cursor() as cursor: - cursor.execute( + res = cursor.execute( """ - UPDATE users - SET course_role = ? - WHERE user_id = ? - """, - (role, user_id), - ) + SELECT user_id FROM enrollments WHERE user_id = ? AND course_id = ? + """, (user_id, course) + ).fetchone() + + if res is not None: + cursor.execute( + "UPDATE enrollments SET course_role = ? WHERE user_id = ? AND course_id = ?", + (role, user_id, course)) + else: + cursor.execute( + """ + INSERT INTO enrollments (course_id, user_id, course_role) VALUES (?, ?, ?) + """, + (course, user_id, role), + ) - def get_roster(self): + def get_roster(self, course): with self.cursor() as cursor: - users = cursor.execute(""" - SELECT user_id, preferred_name, last_name, ubit, person_num, course_role FROM users + users = cursor.execute( + """ + SELECT users.user_id, preferred_name, last_name, ubit, person_num, site_role, e.course_role FROM users + INNER JOIN enrollments as e ON e.user_id = users.user_id + WHERE course_id = ? ORDER BY ubit - """).fetchall() + """, + (course,), + ).fetchall() result = [] for user in users: - result.append({ - "user_id": user[0], - "preferred_name": user[1], - "last_name": user[2], - "ubit": user[3], - "person_num": user[4], - "course_role": user[5] - }) + result.append( + { + "user_id": user[0], + "preferred_name": user[1], + "last_name": user[2], + "ubit": user[3], + "person_num": user[4], + "site_role": user[5], + "course_role": user[6], + } + ) return result - def set_preferred_name(self, identifier, name): with self.cursor() as cursor: @@ -254,25 +354,69 @@ def set_preferred_name(self, identifier, name): UPDATE users SET preferred_name = ? WHERE ubit = ? OR person_num = ? OR user_id = ? RETURNING user_id - """, (name, identifier, identifier, identifier)).fetchone() + """, + (name, identifier, identifier, identifier), + ).fetchone() if user is None: return None return user[0] - def set_name(self, user_id, first_name, last_name): + def set_initial_name(self, user_id, first_name, last_name): with self.cursor() as cursor: user = cursor.execute( """ UPDATE users SET preferred_name = ?, last_name = ? + WHERE user_id = ? AND preferred_name IS NULL + RETURNING user_id + """, + (first_name, last_name, user_id), + ).fetchone() + + if user is None: + return None + + return user[0] + + def remove_from_roster(self, user_id, course): + with self.cursor() as cursor: + user = cursor.execute( + """ + DELETE FROM enrollments + WHERE user_id = ? AND course_id = ? + RETURNING user_id + """, + (user_id, course), + ).fetchone() + + if user is None: + return None + + return user["user_id"] + + def delete_user(self, user_id): + with self.cursor() as cursor: + user = cursor.execute( + """ + UPDATE users SET deleted = true WHERE user_id = ? RETURNING user_id - """, (first_name, last_name, user_id) + """, + (user_id,), ).fetchone() if user is None: return None - return user[0] \ No newline at end of file + return user[0] + + def clear_students(self, course): + with self.cursor() as cursor: + cursor.execute( + """ + DELETE FROM enrollments WHERE course_role = 'student' AND course_id = ? + """, + (course,), + ) diff --git a/api/database/relational_db/relational_db_cursor.py b/api/database/relational_db/relational_db_cursor.py index f8bb4b1..a4ab4f2 100644 --- a/api/database/relational_db/relational_db_cursor.py +++ b/api/database/relational_db/relational_db_cursor.py @@ -1,13 +1,20 @@ +"""Helper to create and cleanup cursors to access the database""" + import sqlite3 class RelationalDBCursor: + """Class defining a cursor. Stores a connection and handles cleanup + after use (committing changes and closing the connection) + """ + def __init__(self, db): self.db = db self.connection = None def __enter__(self): self.connection = sqlite3.connect(self.db.filename) + self.connection.row_factory = sqlite3.Row return self.connection.cursor() def __exit__(self, exc_type, exc_value, traceback): diff --git a/api/database/relational_db/relational_db_queue.py b/api/database/relational_db/relational_db_queue.py index e1f6444..ee09c3f 100644 --- a/api/database/relational_db/relational_db_queue.py +++ b/api/database/relational_db/relational_db_queue.py @@ -1,22 +1,28 @@ +"""Queue component for the relational DB""" + import datetime +import secrets from api.database.idb_queue import IQueue -import secrets + class RelationalDBQueue(IQueue): + """Implementations of the database queue methods.""" - def enqueue_student(self, student): + def enqueue_student(self, student, course): with self.cursor() as cursor: cursor.execute( """ - INSERT OR IGNORE INTO queue (user_id, priority) VALUES (?, 0) + INSERT OR IGNORE INTO queue (user_id, priority, course_id) VALUES (?, 0, ?) """, - (student,), + (student, course), ) - def enqueue_student_front(self, student): + def enqueue_student_front(self, student, course): with self.cursor() as cursor: - priority = cursor.execute("SELECT MAX(priority) FROM queue").fetchone()[0] + priority = cursor.execute( + "SELECT MAX(priority) FROM queue WHERE course_id = ?", (course,) + ).fetchone()[0] if priority is None: priority = 0 else: @@ -24,13 +30,13 @@ def enqueue_student_front(self, student): cursor.execute( """ - INSERT OR IGNORE INTO queue (user_id, priority) - VALUES (?, ?) + INSERT OR IGNORE INTO queue (user_id, priority, course_id) + VALUES (?, ?, ?) """, - (student, priority), + (student, priority, course), ) - def dequeue_student(self): + def dequeue_student(self, course): with self.cursor() as cursor: rows = cursor.execute("SELECT COUNT(*) from queue").fetchone()[0] if rows == 0: @@ -39,36 +45,43 @@ def dequeue_student(self): user = cursor.execute( """ SELECT users.user_id, preferred_name, ubit, person_num, joined - FROM queue + FROM queue INNER JOIN users ON queue.user_id = users.user_id + WHERE course_id = ? ORDER BY priority DESC, joined - """ + """, + (course,), ).fetchone() - cursor.execute("UPDATE queue SET dequeued = true WHERE user_id = ?", (user[0],)) + cursor.execute( + "UPDATE queue SET dequeued = true WHERE user_id = ?", (user[0],) + ) return { "user_id": user[0], "preferred_name": user[1], "ubit": user[2], "person_num": str(user[3]), - "enqueue_time": user[4] + "enqueue_time": user[4], } - def dequeue_specified_student(self, student_id): + def dequeue_specified_student(self, student_id, course): with self.cursor() as cursor: user = cursor.execute( """ SELECT users.user_id, preferred_name, ubit, person_num, joined, enqueue_reason FROM queue INNER JOIN users ON queue.user_id = users.user_id - WHERE users.user_id = ? - """, (student_id,) + WHERE users.user_id = ? AND course_id = ? + """, + (student_id, course), ).fetchone() if user is None: return None - cursor.execute("UPDATE queue SET dequeued = true WHERE user_id = ?", (user[0],)) + cursor.execute( + "UPDATE queue SET dequeued = true WHERE user_id = ?", (user[0],) + ) return { "user_id": user[0], @@ -76,21 +89,22 @@ def dequeue_specified_student(self, student_id): "ubit": user[2], "person_num": str(user[3]), "enqueue_time": user[4], - "enqueue_reason": user[5] + "enqueue_reason": user[5], } - def get_queue(self): + def get_queue(self, course): with self.cursor() as cursor: users = cursor.execute( """ SELECT users.user_id, preferred_name, ubit, person_num FROM queue INNER JOIN users ON queue.user_id = users.user_id - WHERE dequeued = false + WHERE dequeued = false AND course_id = ? ORDER BY priority DESC, joined - """ + """, + (course,), ) - users_l = list() + users_l = [] for user in users: users_l.append( @@ -104,46 +118,54 @@ def get_queue(self): return users_l - def clear_queue(self): + def clear_queue(self, course): with self.cursor() as cursor: cursor.execute( - "DELETE FROM queue WHERE dequeued = false" + "DELETE FROM queue WHERE dequeued = false AND course_id = ?", course ) - def remove_student(self, student): + def remove_student(self, student, course): with self.cursor() as cursor: - queue_info = cursor.execute("SELECT * FROM queue WHERE user_id = ?", (student, )).fetchone() + queue_info = cursor.execute( + "SELECT * FROM queue WHERE user_id = ? AND course_id = ?", + (student, course), + ).fetchone() if queue_info is None: return None with self.cursor() as cursor: - - cursor.execute("DELETE FROM queue WHERE user_id = ?", (student, )) + cursor.execute( + "DELETE FROM queue WHERE user_id = ? AND course_id = ?", + (student, course), + ) return {"user_id": queue_info[0], "joined": queue_info[1]} - def set_reason(self, student, reason): + def set_reason(self, student, reason, course): with self.cursor() as cursor: cursor.execute( - "UPDATE queue SET enqueue_reason = ? WHERE user_id = ?", (reason, student) + "UPDATE queue SET enqueue_reason = ? WHERE user_id = ? AND course_id = ?", + (reason, student, course), ) - def move_to_end(self, student): - now = str(datetime.datetime.now().isoformat(' ', timespec="seconds")) + def move_to_end(self, student, course): + now = str(datetime.datetime.now().isoformat(" ", timespec="seconds")) with self.cursor() as cursor: res = cursor.execute( - "UPDATE queue SET joined = ?, priority = 0 WHERE user_id = ? RETURNING user_id", (now, student) + "UPDATE queue SET joined = ?, priority = 0 WHERE user_id = ? AND course_id = ? RETURNING user_id", + (now, student, course), ).fetchone() if res is None: return False return True - def get_hw_authorization(self): + def get_hw_authorization(self, course): with self.cursor() as cursor: res = cursor.execute( - "SELECT authorization FROM hardware WHERE expires_at > CURRENT_TIMESTAMP" + "SELECT authorization FROM hardware WHERE expires_at > CURRENT_TIMESTAMP AND course_id = ?", + (course,), ).fetchone() if res is None: @@ -151,16 +173,15 @@ def get_hw_authorization(self): return res[0] - def reset_hw_authorization(self): + def reset_hw_authorization(self, course): with self.cursor() as cursor: - cursor.execute( - "DELETE FROM hardware" - ) + cursor.execute("DELETE FROM hardware") auth_code = secrets.token_urlsafe(16) cursor.execute( - "INSERT INTO hardware (authorization) VALUES (?)", (auth_code,) + "INSERT INTO hardware (authorization, course_id) VALUES (?, ?)", + (auth_code, course), ) return auth_code diff --git a/api/database/relational_db/relational_db_ratings.py b/api/database/relational_db/relational_db_ratings.py deleted file mode 100644 index ef41529..0000000 --- a/api/database/relational_db/relational_db_ratings.py +++ /dev/null @@ -1,8 +0,0 @@ -from api.database.idb_ratings import IRatings - - -class RelationalDBRatings(IRatings): - - def rate_student(self, student, rating, feedback): - pass - # do database stuff diff --git a/api/database/relational_db/relational_db_sessions.py b/api/database/relational_db/relational_db_sessions.py index 6470085..c76e770 100644 --- a/api/database/relational_db/relational_db_sessions.py +++ b/api/database/relational_db/relational_db_sessions.py @@ -1,48 +1,63 @@ +"""Definition of the class containing session implementations""" + from api.database.idb_sessions import ISessions class RelationalDBSessions(ISessions): + """Implementations for the sessions component for the relational DB""" - def update_swipe_time(self, user): + def update_swipe_time(self, user, course): with self.cursor() as cursor: - cursor.execute(""" - UPDATE users + cursor.execute( + """ + UPDATE enrollments SET last_swipe = datetime('now', 'localtime') - WHERE user_id = ? - """, (user,)) + WHERE user_id = ? AND course_id = ? + """, + (user, course), + ) - def reset_swipe_time(self, user): + def reset_swipe_time(self, user, course): with self.cursor() as cursor: - cursor.execute(""" - UPDATE users + cursor.execute( + """ + UPDATE enrollments SET last_swipe = NULL - WHERE user_id = ? - """, (user, )) + WHERE user_id = ? AND course_id = ? + """, + (user, course), + ) - def get_swipe_time(self, user): + def get_swipe_time(self, user, course): with self.cursor() as cursor: - time = cursor.execute(""" - SELECT last_swipe FROM users - WHERE last_swipe > datetime('now', 'localtime', '-2 hours') AND user_id = ? - """, (user,)).fetchone() + time = cursor.execute( + """ + SELECT last_swipe FROM enrollments + WHERE last_swipe > datetime('now', 'localtime', '-2 hours') + AND user_id = ? AND course_id = ? + """, + (user, course), + ).fetchone() if time is None: return None return time[0] - def get_on_site(self): + def get_on_site(self, course): with self.cursor() as cursor: users = cursor.execute( """ - SELECT users.user_id, preferred_name, ubit, person_num - FROM users - LEFT JOIN queue ON users.user_id = queue.user_id + SELECT u.user_id, preferred_name, ubit, person_num + FROM enrollments as e + LEFT JOIN queue as q ON e.user_id = q.user_id + INNER JOIN users as u on u.user_id = e.user_id WHERE last_swipe > datetime('now', 'localtime', '-2 hours') - AND queue.user_id IS NULL - """ + AND q.user_id IS NULL AND e.course_id = ? + """, + (course,), ) - users_l = list() + users_l = [] for user in users: users_l.append( { @@ -54,8 +69,11 @@ def get_on_site(self): ) return users_l - def clear_on_site(self): - with self.cursor as cursor: - cursor.execute(""" - UPDATE users SET last_swipe = NULL - """) \ No newline at end of file + def clear_on_site(self, course): + with self.cursor() as cursor: + cursor.execute( + """ + UPDATE enrollments SET last_swipe = NULL WHERE course_id = ? + """, + (course,), + ) diff --git a/api/database/relational_db/relational_db_visits.py b/api/database/relational_db/relational_db_visits.py index 65ac7f9..5c2151c 100644 --- a/api/database/relational_db/relational_db_visits.py +++ b/api/database/relational_db/relational_db_visits.py @@ -1,50 +1,58 @@ +"""Visits component of the relational DB""" + import datetime from api.database.idb_visits import IVisits class RelationalDBVisits(IVisits): + """Implementations for the visits component""" - def __init__(self): - super().__init__() - - def create_visit(self, student, ta, enqueue_time, visit_reason) -> int: + def create_visit(self, student, ta, enqueue_time, visit_reason, course) -> int: with self.cursor() as cursor: - visit_id = cursor.execute(""" - INSERT INTO visits (student_id, ta_id, enqueue_time, student_visit_reason) VALUES ( - ?, ?, ?, ?) + visit_id = cursor.execute( + """ + INSERT INTO visits (student_id, ta_id, enqueue_time, student_visit_reason, course_id) VALUES ( + ?, ?, ?, ?, ?) RETURNING visit_id - """, (student, ta, enqueue_time, visit_reason)).fetchone()[0] + """, + (student, ta, enqueue_time, visit_reason, course), + ).fetchone()[0] return visit_id - - def end_visit(self, visit_id, reason): # YYYY-MM-DD HH:MM:SS - now = str(datetime.datetime.now().isoformat(' ', timespec="seconds")) + now = str(datetime.datetime.now().isoformat(" ", timespec="seconds")) with self.cursor() as cursor: - student = cursor.execute("SELECT (student_id) from visits WHERE visit_id = ?", (visit_id, )).fetchone() + student = cursor.execute( + "SELECT (student_id) from visits WHERE visit_id = ?", (visit_id,) + ).fetchone() if student is None: return student = student[0] - cursor.execute(""" + cursor.execute( + """ UPDATE visits SET session_end = ?, session_end_reason = ? WHERE visit_id = ? AND session_end is null - """, (now, reason, visit_id)) + """, + (now, reason, visit_id), + ) - self.remove_student(student) + cursor.execute("DELETE FROM queue WHERE user_id = ?", (student,)) def cancel_visit(self, visit_id): with self.cursor() as cursor: - student = cursor.execute("SELECT (student_id) from visits WHERE visit_id = ?", (visit_id,)).fetchone() + student = cursor.execute( + "SELECT (student_id) from visits WHERE visit_id = ?", (visit_id,) + ).fetchone() if student is None: return @@ -53,27 +61,81 @@ def cancel_visit(self, visit_id): cursor.execute("DELETE from visits WHERE visit_id = ?", (visit_id,)) - cursor.execute("UPDATE queue SET dequeued = false WHERE user_id = ?", (student, )) + cursor.execute( + "UPDATE queue SET dequeued = false WHERE user_id = ?", (student,) + ) - - def get_in_progress_visits(self): + def get_in_progress_visits(self, course): with self.cursor() as cursor: - result = cursor.execute(""" + result = cursor.execute( + """ SELECT visit_id, student_id, student_visit_reason, ta_id, enqueue_time, session_start FROM visits - WHERE session_end IS NULL - """).fetchall() + WHERE session_end IS NULL AND course_id = ? + """, + (course,), + ).fetchall() visits = [] for row in result: - visits.append({ - "visit_id": row[0], - "student_id": row[1], - "student_visit_reason": row[2], - "ta_id": row[3], - "enqueue_time": row[4], - "session_start": row[5] - }) + visits.append( + { + "visit_id": row[0], + "student_id": row[1], + "student_visit_reason": row[2], + "ta_id": row[3], + "enqueue_time": row[4], + "session_start": row[5], + } + ) return visits + def get_visits(self, course, user_id=None): + with self.cursor() as cursor: + if user_id is None: + result = cursor.execute( + """ + SELECT visits.*, + students.preferred_name as student_name, + students.last_name as student_surname, + students.ubit as student_ubit, + tas.preferred_name as ta_name, + tas.last_name as ta_surname, + tas.ubit as ta_ubit, + ((tas.user_id is not null and (tas.deleted or students.deleted)) + or (tas.user_id is null and students.deleted)) as archived + FROM visits + LEFT JOIN users as students ON students.user_id = visits.student_id + LEFT JOIN users as tas ON tas.user_id = visits.ta_id + WHERE course_id = ? + """, + (course,), + ).fetchall() + else: + result = cursor.execute( + """ + SELECT visits.*, + students.preferred_name as student_name, + students.last_name as student_surname, + students.ubit as student_ubit, + tas.preferred_name as ta_name, + tas.last_name as ta_surname, + tas.ubit as ta_ubit, + ((tas.user_id is not null and (tas.deleted or students.deleted)) + or (tas.user_id is null and students.deleted)) as archived + FROM visits + + LEFT JOIN users as students ON students.user_id = visits.student_id + LEFT JOIN users as tas ON tas.user_id = visits.ta_id + WHERE (student_id = ? OR ta_id = ?) AND course_id = ? + """, + (user_id, user_id, course), + ).fetchall() + + visits = [] + + for res in result: + res = dict(res) + res["archived"] = bool(res["archived"]) + visits.append(res) - + return visits diff --git a/api/database/testing_db/testing_db.py b/api/database/testing_db/testing_db.py index 841704d..27fd9cc 100644 --- a/api/database/testing_db/testing_db.py +++ b/api/database/testing_db/testing_db.py @@ -1,17 +1,16 @@ +"""test implementation of the database.""" + from api.database.db_interface import DBInterface from api.database.testing_db.testing_db_queue import TestingDBQueue -from api.database.testing_db.testing_db_ratings import TestingDBRatings from api.database.testing_db.testing_db_accounts import TestingDBAccounts +from api.database.testing_db.testing_db_visits import TestingDBVisits -class TestingDB(DBInterface, TestingDBQueue, TestingDBRatings, TestingDBAccounts): - - def __init__(self): - super().__init__() +class TestingDB( + DBInterface, TestingDBQueue, TestingDBAccounts, TestingDBVisits +): # pylint: disable=too-many-ancestors + """Definition of the full TestingDB""" def connect(self): pass - - def add_to_roster(self, user_id, role): - pass diff --git a/api/database/testing_db/testing_db_accounts.py b/api/database/testing_db/testing_db_accounts.py index fcede8c..4e21326 100644 --- a/api/database/testing_db/testing_db_accounts.py +++ b/api/database/testing_db/testing_db_accounts.py @@ -1,14 +1,87 @@ +"""Accounts/roster component of the testing DB""" + +import secrets + from api.database.idb_accounts import IAccounts +from api.database.idb_roster import IRoster +from api.database.testing_db.testing_db_utils import users + +import api.database.testing_db.testing_db_utils as utils + + +class TestingDBAccounts(IAccounts, IRoster): + """Implementations for the accounts and roster methods""" + + def get_enrollments(self, user_id): + pass + + def remove_from_roster(self, user_id, course): + pass + + def clear_students(self, course): + pass + + def create_account(self, ubit, pn, role="user"): + users.append({"ubit": ubit, "person_num": pn}) + + def lookup_person_number(self, person_number, course=None) -> dict[str, str]: + return utils.lookup_person_number(person_number) + + def lookup_identifier(self, identifier, course=None) -> dict[str, str]: + return utils.lookup_identifier(identifier) + + def get_authenticated_user(self, auth_token, course=None) -> dict[str, str]: + for user in users: + if user["auth"] == auth_token: + return user + return {} + + def sign_up(self, username, pw) -> str | None: + for user in users: + if user["ubit"] == username: + user["pw"] = pw + user["auth"] = secrets.token_urlsafe() + return user["auth"] + return None + + def sign_in(self, username, pw) -> str | None: + for user in users: + if user["ubit"] == username and user["pw"] == pw: + user["auth"] = secrets.token_urlsafe() + return user["auth"] + return None + + def sign_in_with_autolab(self, ubit) -> str | None: + for user in users: + if user["ubit"] == ubit: + user["auth"] = secrets.token_urlsafe() + return user["auth"] + return None + + def sign_out(self, auth_token): + for user in users: + if user["auth"] == auth_token: + user["auth"] = "nah" + return + + def set_preferred_name(self, identifier, name): + user = self.lookup_identifier(identifier) + if user is not None: + user["fn"] = name + def set_initial_name(self, user_id, first_name, last_name): + user = self.lookup_identifier(user_id) + if user is not None: + user["fn"] = first_name + user["sn"] = last_name -class TestingDBAccounts(IAccounts): + def add_to_roster(self, user_id, role, course): + user = self.lookup_identifier(user_id) + if user is not None and role in {"student", "ta", "instructor", "admin"}: + user["course_role"] = role - def __init__(self): - super().__init__() - self.queue = [] - self.next_id = 0 + def get_roster(self, course): + return users - def create_account(self, ubit, pn): - account_id = self.next_id - self.next_id += 1 - return account_id + def delete_user(self, user_id): + pass diff --git a/api/database/testing_db/testing_db_queue.py b/api/database/testing_db/testing_db_queue.py index 52ebb48..615ee69 100644 --- a/api/database/testing_db/testing_db_queue.py +++ b/api/database/testing_db/testing_db_queue.py @@ -1,14 +1,51 @@ +"""Queue component of the testing DB""" + from api.database.idb_queue import IQueue +import api.database.testing_db.testing_db_utils as utils + class TestingDBQueue(IQueue): + """Queue implemention for testing DB""" def __init__(self): super().__init__() self.queue = [] - def enqueue_student(self, student): + def enqueue_student_front(self, student, course): + if (user := utils.lookup_identifier(student)) != {}: + self.queue.insert(0, user) + + def get_queue(self, course): + return self.queue + + def remove_student(self, student, course): + student = utils.lookup_identifier(student) + if student == {}: + return None + + return self.queue.remove(utils.lookup_identifier(student)) + + def dequeue_specified_student(self, student_id, course): + pass + + def clear_queue(self, course): + pass + + def set_reason(self, student, reason, course): + pass + + def move_to_end(self, student, course): + pass + + def get_hw_authorization(self, course): + pass + + def reset_hw_authorization(self, course): + pass + + def enqueue_student(self, student, course): self.queue.append(student) - def dequeue_student(self): + def dequeue_student(self, course): return self.queue.pop(0) diff --git a/api/database/testing_db/testing_db_ratings.py b/api/database/testing_db/testing_db_ratings.py deleted file mode 100644 index 73f1a58..0000000 --- a/api/database/testing_db/testing_db_ratings.py +++ /dev/null @@ -1,11 +0,0 @@ -from api.database.idb_ratings import IRatings - - -class TestingDBRatings(IRatings): - - def __init__(self): - super().__init__() - - def rate_student(self, student, rating, feedback): - pass - # do database stuff diff --git a/api/database/testing_db/testing_db_utils.py b/api/database/testing_db/testing_db_utils.py new file mode 100644 index 0000000..11da712 --- /dev/null +++ b/api/database/testing_db/testing_db_utils.py @@ -0,0 +1,26 @@ +"""Utility functions for the testing db implementation""" + +import datetime + +users: list[dict[str, str]] = [] + + +def lookup_person_number(person_number) -> dict[str, str]: + """get a user based on person number""" + for user in users: + if user["person_num"] == person_number: + return user + return {} + + +def lookup_identifier(identifier) -> dict[str, str]: + """get a user based on user id, person number, or ubit""" + for i, user in enumerate(users): + if identifier in {user["person_num"], user["ubit"], i}: + return user + return {} + + +def timestamp(): + """generate a timestamp of the current time based on the expected format""" + return str(datetime.datetime.now().isoformat(" ", timespec="seconds")) diff --git a/api/database/testing_db/testing_db_visits.py b/api/database/testing_db/testing_db_visits.py new file mode 100644 index 0000000..0ad35b0 --- /dev/null +++ b/api/database/testing_db/testing_db_visits.py @@ -0,0 +1,48 @@ +"""Visits and Sessions part of the testing DB""" + +from api.database.idb_courses import ICourses +from api.database.idb_sessions import ISessions +from api.database.idb_visits import IVisits + + +class TestingDBVisits(IVisits, ISessions, ICourses): + """Visit and session implementation for testing DB""" + + def create_course(self, name, semester, url) -> int: + pass + + def get_course(self, identifier) -> dict[str, str]: + pass + + def get_courses(self) -> list[dict[str, str]]: + pass + + def create_visit(self, student, ta, enqueue_time, visit_reason, course) -> int: + pass + + def end_visit(self, visit_id, reason): + pass + + def cancel_visit(self, visit_id): + pass + + def get_in_progress_visits(self, course): + pass + + def get_visits(self, course, user_id=None): + pass + + def update_swipe_time(self, user, course): + pass + + def reset_swipe_time(self, user, course): + pass + + def get_swipe_time(self, user, course): + pass + + def get_on_site(self, course): + pass + + def clear_on_site(self, course): + pass diff --git a/api/queue/controller.py b/api/queue/controller.py index ced688c..268b01a 100644 --- a/api/queue/controller.py +++ b/api/queue/controller.py @@ -1,67 +1,133 @@ +"""Queue management functions""" + import datetime from api.database.db import db +from api.database.testing_db.testing_db_utils import lookup_identifier def decode_pn(raw): + """Decode a person number from raw card swipe data, based on UB's card format. + + :param raw: the raw data from the card swipe + + :return: The parsed data on success, empty string on failure + """ try: return raw.split("/^")[1][14:22] - except Exception: + except IndexError: return "" -def add_to_queue_by_card_swipe(swipe_data): +def add_to_queue_by_card_swipe(swipe_data, course): + """Parse the raw swipe data and enqueue user matching the person number. + Should also reset the user's swipe time to the current timestamp. + + :param swipe_data: the raw swipe data + :param course: the course to enqueue into + + :return True on success, False on failure + """ pn = decode_pn(swipe_data) - student = db.lookup_person_number(pn) + student = db.lookup_person_number(pn, course) if student is not None: - add_to_queue(student) - db.update_swipe_time(student["user_id"]) + add_to_queue(student, course) + db.update_swipe_time(student["user_id"], course) return True return False -def add_to_queue_by_ta_override(identifier, front=False): - student = db.lookup_identifier(identifier) +def add_to_queue_by_ta_override(identifier, course, front=False): + """Add the specified user to the queue. + Should also reset the specified user's swipe time to the current timestamp. + + :param identifier: the user's identifier + :param course: the course to enqueue into + :param front: (optional) whether to add the user to the front or back of the queue + :return: True on success, False on failure + """ + student = db.lookup_identifier(identifier, course) if student is not None: if front: - add_to_front_of_queue(student) + add_to_front_of_queue(student, course) else: - add_to_queue(student) - db.update_swipe_time(student["user_id"]) + add_to_queue(student, course) + db.update_swipe_time(student["user_id"], course) return True return False -def add_to_queue(user_account): +def add_to_queue(user_account, course): + """Adds the specified user to the back of the queue. + + :param user_account: a dict containing the user's id in a field named "user_id" + :param course: the course to enqueue into + """ user_id = user_account["user_id"] - db.enqueue_student(user_id) + if user_account.get("course_role") is not None: + db.enqueue_student(user_id, course) + -def add_to_front_of_queue(user_account): +def add_to_front_of_queue(user_account, course): + """Adds the specified user to the front of the queue. + + :param user_account: a dict containing the user's id in a field named "user_id" + :param course: the course to enqueue into + """ user_id = user_account["user_id"] - db.enqueue_student_front(user_id) + if user_account.get("course_role") is not None: + db.enqueue_student_front(user_id, course) + -def remove_from_queue_without_visit(student, reason): - queue_info = db.remove_student(student) +def remove_from_queue_without_visit(student, reason, course): + """Removes the specified student from the queue and immediately + closes the visit for the specified reason. + + :param student: the user id of the student to remove + :param reason: the reason for removal + :param course: the course to enqueue into + :return: True on success, False on failure + """ + queue_info = db.remove_student(student, course) if queue_info is None: return False - visit = db.create_visit(student, None, queue_info["joined"], "") + visit = db.create_visit(student, None, queue_info["joined"], "", course) db.end_visit(visit, reason) return True -def self_add_to_queue(student): - if is_active(student): - db.enqueue_student(student) + +def self_add_to_queue(student, course): + """Attempt to add the specified student to the queue. + Should fail if the user hasn't refreshed their swipe + within the past two hours. + + :param student: The user id of the student to add + :param course: the course to enqueue into + :return: True on success, False on failure + """ + student = db.lookup_identifier(student, course) + if is_active(student["user_id"], course) and student.get("course_role") is not None: + db.enqueue_student(student["user_id"], course) return True return False -def is_active(student): + +def is_active(student, course): + """Checks if the student has refreshed their swipe + within the past two hours. + + :param student: The user id of the student to check + :param course: the course to check + :return: True if the user has refreshed within the past two hours, + False otherwise + """ # YYYY-MM-DD HH:MM:SS time_format = "%Y-%m-%d %H:%M:%S" now = datetime.datetime.now() - enqueue_time = db.get_swipe_time(student) + enqueue_time = db.get_swipe_time(student, course) if enqueue_time is None: return False @@ -74,42 +140,3 @@ def is_active(student): return False return True - -def get_students_visit(student): - in_progress = db.get_in_progress_visits() - in_progress = list(filter(lambda v: v["student_id"] == student, in_progress)) - - if len(in_progress) == 0: - return None - - - visit = in_progress[0] - ta = db.lookup_identifier(visit["ta_id"]) - - return { - "ta_name": ta["preferred_name"] - } - - -def get_tas_visit(ta): - - in_progress = db.get_in_progress_visits() - in_progress = list(filter(lambda v: v["ta_id"] == ta, in_progress)) - - if len(in_progress) == 0: - return None - - visit = in_progress[0] - student = db.lookup_identifier(visit["student_id"]) - - return { - "id": visit["student_id"], - "username": student["ubit"], - "pn": student["person_num"], - "preferred_name": student["preferred_name"], - "visitID": visit["visit_id"], - "visit_reason": visit["student_visit_reason"] - } - - - diff --git a/api/queue/routes.py b/api/queue/routes.py index ffb33f2..d87b4ff 100644 --- a/api/queue/routes.py +++ b/api/queue/routes.py @@ -1,14 +1,19 @@ """Queue Blueprint for MOH""" -from flask import Blueprint, request - -import api.queue.controller as controller -from api.auth.controller import get_user -from api.queue.controller import remove_from_queue_without_visit, get_tas_visit, self_add_to_queue, get_students_visit +from flask import Blueprint, request, g + +from api.queue.controller import ( + remove_from_queue_without_visit, + self_add_to_queue, + decode_pn, + add_to_queue_by_card_swipe, + add_to_queue_by_ta_override, + is_active, +) from api.roster.controller import min_level from api.database.db import db -blueprint = Blueprint("queue", __name__) +blueprint = Blueprint("queue", __name__, url_prefix="/course/") @blueprint.route("/enqueue-card-swipe", methods=["POST"]) @@ -32,9 +37,6 @@ def enqueue_card_swipe(): 400 Bad Request - Bad read 401 Forbidden - Bad code 404 Not Found - No student matching the card swipe was found - { - "message": - } """ body = request.get_json() @@ -44,10 +46,10 @@ def enqueue_card_swipe(): if code != db.get_hw_authorization(): return {"message": "Invalid code"}, 403 - if controller.decode_pn(swipe_data) == "": + if decode_pn(swipe_data) == "": return {"message": "Bad read"}, 400 - if controller.add_to_queue_by_card_swipe(swipe_data): + if add_to_queue_by_card_swipe(swipe_data, g.course_id): return {"message": "Student was added to the queue"} return {"message": "No student matching the card swipe was found"}, 404 @@ -61,11 +63,11 @@ def enqueue_ta_override(): Force enqueue a student. - Resolving the id will be done in the order: UBIT -> pn -> id (Although, these _should_ all be unique so the order - shouldn't matter) + Resolving the id will be done in the order: UBIT -> pn -> id Args: - body.identifier: A unique identifier for the student This can either be their UBIT, pn, or the id of their account + body.identifier: A unique identifier for the student This can either be their UBIT, pn, + or the id of their account Body: { @@ -76,147 +78,16 @@ def enqueue_ta_override(): 200 OK - Student was added to the queue 403 Unauthorized - Requester does not have TA permissions 404 Not Found - No student matching provided identifier - { - "message": , - } - - Use case: A student didn't bring their card to OH so they can't swipe in. The TA can force add them to the queue """ body = request.get_json() identifier = body["identifier"] - if controller.add_to_queue_by_ta_override(identifier): + if add_to_queue_by_ta_override(identifier, g.course_id): return {"message": "Student was added to the queue"} return {"message": "No student matching provided identifier"}, 404 -@blueprint.route("/restore-visit", methods=["GET"]) -@min_level("student") -def restore_visit(): - """ - Returns a visit in the database involving the user that hasn't - ended yet, if such a visit exists. - - i.e. if a TA refreshes the queue before ending the visit - - - Returns: - 200 OK - visit found (TA) - { - "id": , - "username": , - "pn": , - "preferred_name: , - "visitID": , - "visit_reason": - } - - 200 OK - visit found (Student) - { - "ta_name": - } - - 404 Not Found - no such visit exists - """ - user = get_user(request.cookies) - - if user is None: - return {"message": "You are not authenticated!"}, 403 - - user_id = user["user_id"] - - if user["course_role"] == "student": - visit = get_students_visit(user_id) - else: - visit = get_tas_visit(user_id) - - if visit is None: - return {"message": "You do not have an in-progress visit."}, 404 - - return visit - -@blueprint.route("/cancel-visit", methods=["POST"]) -@min_level('ta') -def cancel_visit(): - body = request.get_json() - - visit_id = body.get("visit_id") - - if visit_id is None: - return {"message": "Malformed request"}, 400 - - db.cancel_visit(visit_id) - - return {"message": "Canceled visit"}, 200 - - -@blueprint.route("/active-visits", methods=["GET"]) -@min_level('ta') -def get_active_visits(): - in_progress = db.get_in_progress_visits() - - visits = [] - - for visit in in_progress: - student = db.lookup_identifier(visit["student_id"]) - - if visit["ta_id"] is not None: - ta = db.lookup_identifier(visit["ta_id"]) - ta_name = ta["preferred_name"] - else: - ta_name = None - - visits.append({ - "student_id": visit["student_id"], - "student_username": student["ubit"], - "student_name": student["preferred_name"], - "visitID": visit["visit_id"], - "visit_reason": visit["student_visit_reason"], - "ta_id": visit["ta_id"], - "ta_name": ta_name - }) - return visits - -@blueprint.route("/visits/", methods=["GET"]) -@min_level('instructor') -def get_visit(visit_id): - """ - Retrieve all information about the specified visit. - - """ - - pass - -@blueprint.route("/steal-visit/", methods=["PATCH"]) -@min_level('ta') -def steal_visit(visit_id): - """ - Replace the TA associated with the visit with the TA who sent - the request. Returns all information about the visit being stolen. - - Does not work on visits that are not in progress, or if the TA - has an active visit. - - """ - - pass - -@blueprint.route("/abandon-visit", methods=["PATCH"]) -@min_level('ta') -def abandon_visit(): - """ - Abandon the visit associated with the TA who sent the request. - Does not end the visit. - - Returns an error if the TA isn't in an active visit. Future retrievals - of this visit should return "None" as the TA's ID and name. - - - """ - pass - - @blueprint.route("/help-a-student", methods=["POST"]) @min_level("ta") @@ -224,17 +95,13 @@ def dequeue(): """ role: TA - Remove the specified student from the queue and create a Visit in the DB - - Not allowed if TA is already in a visit + Remove the specified student from the queue and create a Visit in the DB. Not allowed if TA is already in a visit Args: body.id: The ID of the account to dequeue Body: - { - "id": - } + "id": Returns: 200 OK - Student was dequeued @@ -249,31 +116,34 @@ def dequeue(): 400 Bad Request - The queue is empty or user is not in the queue 403 Unauthorized - Requester does not have TA permissions - { - "message": - } """ body = request.get_json() - if not (auth_token := request.cookies.get("auth_token")): + if not request.cookies.get("auth_token"): return {"message": "You are not logged in!"}, 403 - user = db.get_authenticated_user(auth_token) + user = g.user user_id = user["user_id"] - in_progress = db.get_in_progress_visits() + in_progress = db.get_in_progress_visits(g.course_id) in_progress = list(filter(lambda v: v["ta_id"] == user_id, in_progress)) if len(in_progress) != 0: return {"message": "You have a visit in progress."}, 400 - student = db.dequeue_specified_student(body["id"]) + student = db.dequeue_specified_student(body["id"], g.course_id) if student is None: return {"message": "The queue is empty"}, 400 - visit = db.create_visit(body["id"], user_id, student["enqueue_time"], student["enqueue_reason"]) + visit = db.create_visit( + body["id"], + user_id, + student["enqueue_time"], + student["enqueue_reason"], + g.course_id, + ) return { "id": int(student["user_id"]), @@ -281,7 +151,7 @@ def dequeue(): "pn": str(student["person_num"]), "preferred_name": student["preferred_name"], "visitID": visit, - "visit_reason": student["enqueue_reason"] + "visit_reason": student["enqueue_reason"], } @@ -304,14 +174,10 @@ def get_queue(): }, ... ] - - 403 Unauthorized - Requester does not have TA permissions - { - "message": - } + 403 Forbidden - Requester does not have TA permissions """ + return db.get_queue(g.course_id) - return db.get_queue() @blueprint.route("/get-queue-size", methods=["GET"]) def get_queue_size(): @@ -325,7 +191,7 @@ def get_queue_size(): } """ - queue = db.get_queue() + queue = db.get_queue(g.course_id) return {"size": len(queue)} @@ -346,7 +212,6 @@ def get_anon_queue(): "position": , "length": } - 400 Bad Request - You are not in the queue { "message": , @@ -364,15 +229,19 @@ def get_anon_queue(): user_id = user["user_id"] - queue = db.get_queue() + queue = db.get_queue(g.course_id) for i, entry in enumerate(queue, 1): if entry["id"] == user_id: return {"position": i, "length": len(queue)} - active = controller.is_active(user_id) + active = is_active(user_id, g.course_id) - return {"message": "You are not in the queue!", "length": len(queue), "active": active}, 400 + return { + "message": "You are not in the queue!", + "length": len(queue), + "active": active, + }, 400 @blueprint.route("/remove-self-from-queue", methods=["POST"]) @@ -387,16 +256,12 @@ def remove_self(): body.reason: a text reason for removing the user from the queue Body: - { - "reason": - } + "reason": + Returns: 200 OK - You were removed from the queue and a visit was created 400 Bad Request - You were not in the queue - { - "message": - } """ if not (auth_token := request.cookies.get("auth_token")): @@ -410,14 +275,16 @@ def remove_self(): user_id = user["user_id"] body = request.get_json() - if remove_from_queue_without_visit(user_id, f"[SELF-REMOVE]: {body["reason"]}"): - return {"message":"Removed self from queue."} - else: - return {"message": "You are not in the queue!"}, 400 + if remove_from_queue_without_visit( + user_id, f"[SELF-REMOVE]: {body["reason"]}", g.course_id + ): + return {"message": "Removed self from queue."} + + return {"message": "You are not in the queue!"}, 400 @blueprint.route("/remove-from-queue", methods=["POST"]) -@min_level('ta') +@min_level("ta") def remove(): """ role: TA @@ -429,18 +296,13 @@ def remove(): body.user_id: user ID of the student being removed Body: - { - "reason": , - "user_id": - } + "reason": , + "user_id": Returns: 200 OK - Student was removed from the queue and a visit was created 400 Bad Request - Student with user_id was not in the queue 403 Unauthorized - Requester does not have TA permissions - { - "message": - } """ body = request.get_json() @@ -448,72 +310,70 @@ def remove(): if body.get("user_id") is None or body.get("reason") is None: return {"message": "Malformed request"}, 400 - user_id = body.get("user_id") reason = body.get("reason") - if remove_from_queue_without_visit(user_id, f"[REMOVED BY TA]: {reason}"): + if remove_from_queue_without_visit( + user_id, f"[REMOVED BY TA]: {reason}", g.course_id + ): return {"message": "Removed student from queue"} return {"message": "Student is not in queue"}, 400 @blueprint.route("/clear-queue", methods=["DELETE"]) -@min_level('ta') +@min_level("ta") def clear_queue(): + """Removes all students from the queue + + Must be TA or higher + + :return: 200 with success message on success + """ db.clear_queue() return {"message": "Successfully cleared the queue."} @blueprint.route("/enqueue-override-front", methods=["POST"]) -@min_level('ta') +@min_level("ta") def enqueue_override_front(): - """ Exact same behavior as /enqueue-ta-override, except it sends the student to the front. + """Exact same behavior as /enqueue-ta-override, except it sends the student to the front. Args: - body.identifier: A unique identifier for the student This can either be their UBIT, pn, or the id of their account + body.identifier: A unique identifier for the student This can either be their UBIT, pn, + or the id of their account Body: - { - "identifier": - } + "identifier": Returns: 200 OK - Student was added to the queue 403 Unauthorized - Requester does not have TA permissions 404 Not Found - No student matching provided identifier - { - "message": , - } """ body = request.get_json() identifier = body["identifier"] - if controller.add_to_queue_by_ta_override(identifier, True): + if add_to_queue_by_ta_override(identifier, g.course_id, True): return {"message": "Student was added to the front of the queue"} return {"message": "No student matching provided identifier"}, 404 -@blueprint.route("/end-visit", methods=["POST"]) -@min_level('ta') -def end_visit(): - body = request.get_json() - - visit = body.get("id") - reason = body.get("reason") - - if visit is None or reason is None: - return {"message": "Malformed request"}, 400 - - db.end_visit(visit, reason) - - return {"message": "Ended the visit"} @blueprint.route("/update-reason", methods=["PATCH"]) -@min_level('student') +@min_level("student") def update_reason(): + """Update the student's reason for visiting office hours. + + Params: + "reason": the student specified reason + + :return: 200 on success, + 401 if not authenticated, + 400 if visit is missing + """ body = request.get_json() - user = get_user(request.cookies) + user = g.user if user is None: return {"message": "You are not authenticated"}, 401 @@ -523,67 +383,119 @@ def update_reason(): if not reason: return {"message": "Malformed request"}, 400 - db.set_reason(user["user_id"], reason) + db.set_reason(user["user_id"], reason, g.course_id) return {"message": "Reason updated"} + @blueprint.route("/move-to-end", methods=["PATCH"]) -@min_level('ta') +@min_level("ta") def move_to_end(): + """Move the specified user to the end of the queue. + + Params: + "user_id": the id of the student to move + + :return: 200 on success, + 400 if the user doesn't exist or isn't in the queue. + """ body = request.get_json() if (user_id := body.get("user_id")) is None: return {"message": "Malformed request"}, 400 - if db.move_to_end(user_id): + if db.move_to_end(user_id, g.course_id): return {"message": "Moved student to end of the queue"} return {"message": "Specified user is not in queue"}, 400 @blueprint.route("/swipe-authorization", methods=["GET"]) -@min_level('instructor') +@min_level("ta") def get_swipe_auth_code(): - code = db.get_hw_authorization() + """Get the swipe authorization code. + + Must be TA or higher. + + :return: 200 with the authorization code as: {"code": } + """ + code = db.get_hw_authorization(g.course_id) if code is None: return {"message": "Auth code not set"}, 404 return {"code": code} + @blueprint.route("/reset-swipe-auth", methods=["DELETE"]) -@min_level('instructor') +@min_level("instructor") def reset_swipe_auth_code(): - db.reset_hw_authorization() + """Reset the current authorization code. + + :return: 200 with the newly generated code as: {"code": } + """ + db.reset_hw_authorization(g.course_id) return {"message": "Reset auth code"} + @blueprint.route("/enqueue", methods=["POST"]) -@min_level('student') +@min_level("student") def self_enqueue(): + """Attempt to self-enqueue the student who hit this endpoint. + Fail if the student hasn't enqueued within the past two hours. - if not (auth_token := request.cookies.get("auth_token")): + :return: 200 on success, + 401 if the user isn't authenticated, + 403 if the user hasn't swiped within the past two hours + """ + + if not request.cookies.get("auth_token"): return {"message": "You are not logged in!"}, 401 - user = db.get_authenticated_user(auth_token) + user = g.user if not user: return {"message": "You are not logged in!"}, 401 - if not self_add_to_queue(user["user_id"]): + if not self_add_to_queue(user["user_id"], g.course_id): return {"message": "You have not swiped in the past two hours!"}, 403 return {"message": "Added yourself to the queue."}, 200 + @blueprint.route("/on-site", methods=["GET"]) -@min_level('ta') +@min_level("ta") def get_on_site(): - return db.get_on_site() + """Return a list of students who are on-site. + + :return: 200 on success: + [ + { + "id": , + "username": , + "pn": , + "preferred_name: + }, + ... + ] + """ + return db.get_on_site(g.course_id) + @blueprint.route("/deactivate", methods=["PATCH"]) -@min_level('ta') +@min_level("ta") def deactivate(): + """Deactivate the specified student. Regardless of when they + last refreshed, they should not be able to re-add themselves to + the queue. + + Params: + "user_id": the id of the student to deactivate. + + :return: 200 on success + """ body = request.get_json() - db.reset_swipe_time(body["user_id"]) + db.reset_swipe_time(body["user_id"], g.course_id) return {"message": "Deactivated the student."} diff --git a/api/ratings/controller.py b/api/ratings/controller.py deleted file mode 100644 index 2ae2839..0000000 --- a/api/ratings/controller.py +++ /dev/null @@ -1 +0,0 @@ -pass diff --git a/api/ratings/routes.py b/api/ratings/routes.py deleted file mode 100644 index bd86aec..0000000 --- a/api/ratings/routes.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Ratings Blueprint for MOH""" - -from flask import Blueprint - -blueprint = Blueprint("ratings", __name__) - -# TODO: All the end points for students and TAs. Rating contains rater/ratee/rating/feedback/visitID - -# TODO: create_rating - -# TODO: update rating (must be author) - -# TODO: get ratings (instructor/admin only) diff --git a/api/roster/controller.py b/api/roster/controller.py index 99144b3..6979b3f 100644 --- a/api/roster/controller.py +++ b/api/roster/controller.py @@ -1,35 +1,47 @@ -from api.database.db import db +"""Roster related functions and permission-checking decorators for the API""" + from functools import wraps -from flask import request, current_app +from flask import request, current_app, g + +from api.database.db import db + def get_power_level(role): + """Returns the numerical power level of each role""" match role: case "student": return 0 case "ta": return 1 case "instructor": - return 5 + return 5 case "admin": return 10 return -1 + def exact_level(role): + """Ensures that only the role specified has access to a feature. It is very important that these + decorators are below @blueprint.route, as they are otherwise ignored.""" + def decorator(f): @wraps(f) def check_permission(*args, **kwargs): if current_app.config.get("API_MODE", "") == "testing": return f(*args, **kwargs) + if g.user and g.user["site_role"] == "admin": + return f(*args, **kwargs) + if not (auth_token := request.cookies.get("auth_token")): return {"message": "You are not authenticated"}, 403 - user = db.get_authenticated_user(auth_token) + user = db.get_authenticated_user(auth_token, g.course_id) if not user: return {"message": "You are not authenticated"}, 403 - if role == user["role"]: + if role == user["course_role"]: return f(*args, **kwargs) return { @@ -42,22 +54,28 @@ def check_permission(*args, **kwargs): def min_level(min_role): + """Allows for specified level and above to access features. It is very important that these decorators are + below @blueprint.route, as they are otherwise ignored.""" + def decorator(f): @wraps(f) def check_permission(*args, **kwargs): if current_app.config.get("API_MODE", "") == "testing": return f(*args, **kwargs) - if not (auth_token := request.cookies.get("auth_token")): + if not request.cookies.get("auth_token"): return {"message": "You are not authenticated"}, 403 required_level = get_power_level(min_role) - user = db.get_authenticated_user(auth_token) + user = g.user if not user: return {"message": "You are not authenticated"}, 403 + if user["site_role"] == "admin": + return f(*args, **kwargs) + power_level = get_power_level(user["course_role"]) if power_level >= required_level: @@ -71,7 +89,37 @@ def check_permission(*args, **kwargs): return decorator -def add_to_roster(ubit, pn, first_name, last_name, role): + +def authenticated(): + """Require some authentication to access this route.""" + + def decorator(f): + @wraps(authenticated) + def check_auth(*args, **kwargs): + if g.user: + return f(*args, **kwargs) + return {"message": "You are not authenticated."}, 401 + + return check_auth + + return decorator + + +def site_admin_only(f): + """Require site-wide admin to access this route""" + + @wraps(f) + def check_auth(*args, **kwargs): + if g.user and g.user["site_role"] == "admin": + return f(*args, **kwargs) + return {"message": "You are not permitted to access this resource."}, 403 + + return check_auth + + +def add_to_roster(ubit, pn, first_name, last_name, role, course): + """Adds a student to the roster with the following information: UBIT, + person number, first name, last name, role""" user_id = db.create_account(ubit, pn) - db.add_to_roster(user_id, role) - db.set_name(user_id, first_name, last_name) \ No newline at end of file + db.add_to_roster(user_id, role, course) + db.set_initial_name(user_id, first_name, last_name) diff --git a/api/roster/routes.py b/api/roster/routes.py index 698bd10..6236594 100644 --- a/api/roster/routes.py +++ b/api/roster/routes.py @@ -1,9 +1,9 @@ """Roster Blueprint for MOH""" -from flask import Blueprint, request +from flask import Blueprint, request, g from api.auth.controller import get_user -from api.roster.controller import min_level, add_to_roster +from api.roster.controller import min_level, add_to_roster, get_power_level from api.database.db import db blueprint = Blueprint("roster", __name__) @@ -11,32 +11,34 @@ # IMPORTANT: @blueprint.route must always be outermost decorator, # any other decorators such as, auth decorators (min_level, exact_level) must go below it + @blueprint.route("/upload-roster", methods=["POST"]) -@min_level('instructor') +@min_level("ta") def upload_roster(): """ - Role: instructor or admin + Role: TA or higher - Populate the database with the uploaded roster. - Doesn't create log-ins for the users. + Populate the database with the uploaded roster. + Doesn't create log-ins for the users. - CSV formatted ubit,pn,first_name,last_name,role + CSV formatted ubit,pn,first_name,last_name,role - Params: - - "roster": the uploaded CSV file + Params: + - "roster": the uploaded CSV file - Returns: - - 200 if successful - - 401 if unauthorized - - 400 if roster is missing or invalid format + Returns: + - 200 if successful + - 401 if unauthorized + - 400 if roster is missing or invalid format """ - print(request.files) + user = g.user + if not request.files or request.files.get("roster") is None: return {"message": "Invalid roster upload (missing file)"}, 400 file = request.files.get("roster") - if file.filename == '' or not file.filename.endswith(".csv"): + if file.filename == "" or not file.filename.endswith(".csv"): return {"message": "Invalid roster upload (invalid file)"}, 400 buffer = file.read() @@ -45,7 +47,7 @@ def upload_roster(): lines = buffer.split("\n") users = [] for line in lines: - if line == '': + if line == "": break info = line.strip().split(",") @@ -55,79 +57,72 @@ def upload_roster(): if not info[1].isnumeric(): return {"message": "Invalid roster upload (non-numeric PN)"}, 400 pn = int(info[1]) - # role has to be valid and not above instructor's authority - if info[4] not in {"student", "ta", "instructor"}: + # role has to be valid and not above user's authority + if info[4] not in {"student", "ta", "instructor"} or get_power_level( + info[4] + ) >= get_power_level(user["course_role"]): return {"message": "Invalid roster upload (bad role)"}, 400 - users.append({ - "ubit": info[0], - "pn": pn, - "first_name": info[2], - "last_name": info[3], - "role": info[4] - }) + users.append( + { + "ubit": info[0], + "pn": pn, + "first_name": info[2], + "last_name": info[3], + "role": info[4], + } + ) for user in users: - add_to_roster(user["ubit"], user["pn"], user["first_name"], user["last_name"], user["role"]) + add_to_roster( + user["ubit"], + user["pn"], + user["first_name"], + user["last_name"], + user["role"], + g.course_id, + ) return {"message": "Successfully uploaded roster"}, 200 # TODO: get roster + @blueprint.route("/get-roster", methods=["GET"]) -@min_level('instructor') +@min_level("ta") def get_roster(): """ - Role: instructor or admin - - Returns: - 401 if unauthorized - 200 if successful: - { - roster: [ - { - "user_id": - "ubit": , - "pn": , - "preferred_name": , - "last_name": - "role": - } - ] - } + Role: ta, instructor, or admin + + Returns: + 401 if unauthorized + 200 if successful: + { + roster: [ + { + "user_id": + "ubit": , + "pn": , + "preferred_name": , + "last_name": + "role": + } + ] + } """ - roster = db.get_roster() + roster = db.get_roster(g.course_id) return {"roster": roster} - -@blueprint.route("/update-name", methods=["PATCH"]) -@min_level('student') -def update_preferred_name(): - user = get_user(request.cookies) - - if user is None: - return {"message": "You are not authenticated!"}, 401 - - body = request.get_json() - - if (name := body.get("name")) is None: - return {"message": "Malformed request."}, 400 - - db.set_preferred_name(user["ubit"], name) - - return {"message": "Updated preferred name."} - - @blueprint.route("/enroll", methods=["POST"]) -@min_level('instructor') +@min_level("ta") def enroll_user(): """ - Enroll a single user. Won't enroll admins. + Enroll a single user. Won't enroll admins. TAs can only enroll students. Body: @@ -146,9 +141,9 @@ def enroll_user(): """ data = request.get_json() - required_fields = ["ubit", "pn", - "preferred_name", "last_name", - "role"] + user = g.user + + required_fields = ["ubit", "pn", "preferred_name", "last_name", "role"] legal_roles = {"student", "ta", "instructor"} @@ -159,54 +154,94 @@ def enroll_user(): if data["role"] not in legal_roles: return {"message": "Malformed request"}, 400 + if get_power_level(data["role"]) >= get_power_level(user["course_role"]): + return {"message": "You cannot enroll a user at this level."}, 403 user_id = db.create_account(data["ubit"], data["pn"]) - db.add_to_roster(user_id, data["role"]) - db.set_name(user_id, data["preferred_name"], data["last_name"]) + db.add_to_roster(user_id, data["role"], g.course_id) + db.set_initial_name(user_id, data["preferred_name"], data["last_name"]) + + return {"message": "Successfully enrolled user", "id": user_id} + - return {"message": "Successfully enrolled user", - "id": user_id} +@blueprint.route("/user/", methods=["DELETE"]) +def delete_user(user_id): + """Unenrolls the specified user. -@blueprint.route("/visits/", methods=["GET"]) -@blueprint.route("/visits", methods=["GET"], defaults={"user_id": None}) -@min_level('instructor') -def get_visits(user_id): + :return: 200 on success + 401 if removing this user isn't permitted + 404 if user doesn't exist """ - Get a list of visits. If a user_id is specified, only include - visits where the specified user is involved (either as the student - or TA). + caller = g.user + user = db.lookup_identifier(user_id, g.course_id) + if get_power_level(caller["course_role"]) <= get_power_level(user["course_role"]): + return {"message": "You cannot remove this user."}, 401 - Params: - - user_id: + for visit in filter( + lambda v: int(v["student_id"]) == int(user_id) + or int(v["ta_id"]) == int(user_id), + db.get_in_progress_visits(g.course_id), + ): + db.end_visit( + visit["visit_id"], + "[Visit ended due to a participant's account being unenrolled.]", + ) - Returns: - 200 on success: - { - "visits": [ - { - "visit_id": , - "ta_id": , - "ta_name": - "student_id": , - "student_name": - "start_time": - "end_time": - } - ] - } + db.remove_student(user_id, g.course_id) + db.reset_swipe_time(user_id, g.course_id) + + if db.remove_from_roster(user_id, g.course_id) is None: + return {"message": "User not found."}, 404 + return {"message": "Successfully removed user from roster"} - :return: + +@blueprint.route("/user//role", methods=["PATCH"]) +@min_level("ta") +def update_role(user_id): + """Update the specified user's role. Can only promote people to your level. + + Body: { + "role": + } + + :return: 200 on success """ - pass + user = db.lookup_identifier(user_id, g.course_id) + caller = g.user + role = request.json["role"] + + if user is None: + return {"message": "User not found."}, 401 + if role not in {"student", "ta", "instructor"}: + return {"message": "Invalid role."}, 400 -# TODO: add to roster - to add an individual to the roster + if get_power_level(caller["course_role"]) < get_power_level(user["course_role"]): + return {"message": "You are not permitted to change this user's role."}, 401 -# TODO: Remove from roster + if get_power_level(caller["course_role"]) < get_power_level(role): + return {"message": "You are not permitted to set this user to this role."}, 401 -# TODO: Whenever someone is added to the roster, check if they have an account and create one for them if not + db.add_to_roster(user_id, role, g.course_id) + + return {"message": "Updated role."} + + +@blueprint.route("/clear-enrollments", methods=["DELETE"]) +@min_level("instructor") +def clear_enrollments(): + """Clear all student enrollments. This soft-deletes their accounts, + clears the queue, etc. + + :return: 200 on success + """ + for visit in db.get_in_progress_visits(): + db.end_visit(visit["visit_id"], "[Visit ended due to course reset.]") -# TODO: handle roles here (Will make it easier to move to multiple courses in the future) + db.clear_queue() + db.clear_on_site() + db.clear_students() + return {"message": "Removed all students from the course."} diff --git a/api/run_local.py b/api/run_local.py index 3b8c26a..e4437ad 100644 --- a/api/run_local.py +++ b/api/run_local.py @@ -1,11 +1,11 @@ -# Runs the app locally without Gunicorn. To be used for dev and testing +"""Runs the app locally without Gunicorn. To be used for dev and testing""" -from dotenv import load_dotenv +from dotenv import load_dotenv # pylint: disable=wrong-import-position load_dotenv() -from api.server import create_app +from api.server import create_app # pylint: disable=wrong-import-position app = create_app() app.debug = True -app.run() +app.run(port=5050) diff --git a/api/server.py b/api/server.py index d67c095..c676b56 100644 --- a/api/server.py +++ b/api/server.py @@ -4,28 +4,29 @@ """ import datetime -import io + import os -import requests -from flask import Flask, render_template, request, redirect -from flask import send_file + +from flask import Flask, request, g, abort from api.config import config from api.database.db import db -from api.roster.controller import min_level, get_power_level -from api.utils.debug import debug_access_only +from api.utils import debug_routes import api.auth.routes as auth_routes import api.queue.routes as queue_routes -import api.ratings.routes as ratings_routes import api.roster.routes as roster_routes -import api.utils.debug_routes as debug_routes +import api.visits.routes as visits_routes +import api.admin.routes as admin_routes URL_PREFIX = os.getenv("API_URL_PREFIX", "/") THE_OG_UBIT = os.getenv("THE_OG_UBIT", None) THE_OG_PN = os.getenv("THE_OG_PN", None) +THE_OG_NAME = os.getenv("THE_OG_NAME", "") +THE_OG_SURNAME = os.getenv("THE_OG_SURNAME") og = db.lookup_person_number(THE_OG_PN) + def create_app(): """Create and return Flask API server @@ -33,38 +34,93 @@ def create_app(): """ if THE_OG_UBIT and THE_OG_PN: - og = db.lookup_person_number(THE_OG_PN) - if not og: + if not db.lookup_person_number(THE_OG_PN): # create the OG account - og_id = db.create_account(THE_OG_UBIT, THE_OG_PN) - db.add_to_roster(og_id, "admin") + admin = db.create_account(THE_OG_UBIT, THE_OG_PN, "admin") + db.set_initial_name(admin, THE_OG_NAME, THE_OG_SURNAME) - - app = Flask(__name__, template_folder="../client/templates", static_folder="../client/static") + app = Flask(__name__) app.config.from_object(config.Config()) app.logger.debug(app.config) app.register_blueprint(auth_routes.blueprint, url_prefix=URL_PREFIX) - app.register_blueprint(queue_routes.blueprint, url_prefix=URL_PREFIX) - app.register_blueprint(ratings_routes.blueprint, url_prefix=URL_PREFIX) - app.register_blueprint(roster_routes.blueprint, url_prefix=URL_PREFIX) - app.register_blueprint(debug_routes.blueprint, url_prefix=URL_PREFIX) + app.register_blueprint( + queue_routes.blueprint, url_prefix=URL_PREFIX + "/course/" + ) + app.register_blueprint( + roster_routes.blueprint, url_prefix=URL_PREFIX + "/course/" + ) + app.register_blueprint( + debug_routes.blueprint, url_prefix=URL_PREFIX + "/course/" + ) + app.register_blueprint( + visits_routes.blueprint, url_prefix=URL_PREFIX + "/course/" + ) + app.register_blueprint(admin_routes.blueprint, url_prefix=URL_PREFIX) + + @app.url_value_preprocessor + def pull_info(_, values): + """Grab the course and user info associated with this request""" + g.course_url = values.pop("course_id", None) if values is not None else None + g.course_context = ( + db.get_course(g.course_url) if g.course_url is not None else None + ) + + if g.course_context is not None: + g.course_id = g.course_context["course_id"] + else: + g.course_id = None + + if auth_token := request.cookies.get("auth_token"): + g.user = db.get_authenticated_user(auth_token, g.course_id) + else: + g.user = None + + if g.user and g.course_context: + g.course_context["course_role"] = g.user["course_role"] + + @app.before_request + def check_route(): + """Checks that, if this route is expecting a course, that this course exists.""" + if g.course_url is not None and not g.course_context: + return {"message": "Course not found"}, 404 + return None @app.route(URL_PREFIX + "/user/", methods=["GET"]) - @min_level('ta') def get_user_info(user_id): + if not g.user: + abort(403) + user = db.lookup_identifier(user_id) - return user + + if user is None: + return {"message": "User not found"}, 404 + + return { + "preferred_name": user["preferred_name"], + "last_name": user["last_name"], + "ubit": user["ubit"], + } + + @app.route("/course/") + def get_course_context(): + """Retrieve info about the specified course.""" + if g.user: + return g.course_context + abort(403) @app.route(URL_PREFIX + "/me", methods=["GET"]) def get_my_info(): - if not (auth_token := request.cookies.get("auth_token")): - return {"message": "You are not authenticated.."}, 401 + if not request.cookies.get("auth_token"): + return {"message": "You are not logged in."}, 401 + + if not g.user: + return {"message": "Invalid authentication."}, 401 - if not (user := db.get_authenticated_user(auth_token)): - return {"message": "You are not authenticated."}, 401 + user = g.user | {"enrollments": db.get_enrollments(g.user["user_id"])} + del user["course_role"] return user diff --git a/api/utils/debug.py b/api/utils/debug.py index 62abdcd..283c96e 100644 --- a/api/utils/debug.py +++ b/api/utils/debug.py @@ -1,12 +1,15 @@ """Util functions that will be used for debugging only within flask debug mode""" from functools import wraps -from flask import current_app, abort, request, Blueprint +from flask import current_app, abort # Referenced: https://stackoverflow.com/a/55729767 def debug_access_only(func): - """Limit route access to debug mode only, return 404 if access outside of debug mode""" + """Limit route access to debug mode only, return 404 if access outside of debug mode. + Must cognizant of the order decorators are declared. Decorators that limit route + access must be declared after @blueprint.route. + """ @wraps(func) def wrapped(**kwargs): diff --git a/api/utils/debug_routes.py b/api/utils/debug_routes.py index ed38d85..e7d10f5 100644 --- a/api/utils/debug_routes.py +++ b/api/utils/debug_routes.py @@ -1,4 +1,5 @@ -from api.database.db import db +"""Routes for debugging. These should only be accessible from debug mode using the @debug_access_only decorator.""" + from flask import request, Blueprint from api.auth.controller import create_account from api.utils.debug import debug_access_only @@ -9,6 +10,21 @@ @blueprint.route("/force-enroll", methods=["POST"]) @debug_access_only def force_enroll(): + """ + Forcefully enroll a user. Debug only. + + Params (as form data): + - ubit: the desired ubit + - pn: the desired person number + - role: the desired role, from {'student', 'ta', 'instructor', 'admin'} + + :return: 400, if malformed + 200, if successful: + { + "message": , + "id": + } + """ body = request.form ubit = body.get("ubit") pn = body.get("pn") diff --git a/api/visits/controller.py b/api/visits/controller.py new file mode 100644 index 0000000..1792d83 --- /dev/null +++ b/api/visits/controller.py @@ -0,0 +1,48 @@ +"""Visit management functions""" + +from api.database.db import db + + +def get_students_visit(student, course): + """Retrieves the in-progress visit involving the specified student, if it exists. + + :param student: the student's user id + :param course: the course to retrieve visit from + :return: the in-progress visit if it exists, None otherwise. + """ + in_progress = db.get_in_progress_visits(course) + in_progress = list(filter(lambda v: v["student_id"] == student, in_progress)) + + if len(in_progress) == 0: + return None + + visit = in_progress[0] + ta = db.lookup_identifier(visit["ta_id"]) + + return {"ta_name": ta["preferred_name"]} + + +def get_tas_visit(ta, course): + """Retrieves the in-progress visit involving the specified TA, if it exists. + + :param ta: the TA's user id + :param course: the course to retrieve visit from + :return: the in-progress visit if it exists, None otherwise. + """ + in_progress = db.get_in_progress_visits(course) + in_progress = list(filter(lambda v: v["ta_id"] == ta, in_progress)) + + if len(in_progress) == 0: + return None + + visit = in_progress[0] + student = db.lookup_identifier(visit["student_id"]) + + return { + "id": visit["student_id"], + "username": student["ubit"], + "pn": student["person_num"], + "preferred_name": student["preferred_name"], + "visitID": visit["visit_id"], + "visit_reason": visit["student_visit_reason"], + } diff --git a/api/visits/routes.py b/api/visits/routes.py new file mode 100644 index 0000000..80627a8 --- /dev/null +++ b/api/visits/routes.py @@ -0,0 +1,203 @@ +"""Visits Blueprint for MOH""" + +from flask import Blueprint, request, g + +from api.auth.controller import get_user +from api.database.db import db +from api.roster.controller import min_level, get_power_level +from api.visits.controller import get_students_visit, get_tas_visit + +blueprint = Blueprint("visits", __name__) + + +@blueprint.route("/restore-visit", methods=["GET"]) +@min_level("student") +def restore_visit(): + """ + Returns a visit in the database involving the user that hasn't + ended yet, if such a visit exists. + + i.e. if a TA refreshes the queue before ending the visit + + Returns: + 200 OK - visit found (TA) + { + "id": , + "username": , + "pn": , + "preferred_name: , + "visitID": , + "visit_reason": + } + 200 OK - visit found (Student) + { + "ta_name": + } + 404 Not Found - no such visit exists + """ + user = g.user + + if user is None: + return {"message": "You are not authenticated!"}, 403 + + user_id = user["user_id"] + + if user["course_role"] == "student": + visit = get_students_visit(user_id, g.course_id) + else: + visit = get_tas_visit(user_id, g.course_id) + + if visit is None: + return {"message": "You do not have an in-progress visit."}, 404 + + return visit + + +@blueprint.route("/cancel-visit", methods=["POST"]) +@min_level("ta") +def cancel_visit(): + """Cancel the specified visit and return the student to the queue. + + Must be TA or higher. + + Params: + { + "visit_id": the id of the visit to cancel + } + + :return: 200 with success message on success + 400 if malformed + """ + body = request.get_json() + + visit_id = body.get("visit_id") + + if visit_id is None: + return {"message": "Malformed request"}, 400 + + db.cancel_visit(visit_id) + + return {"message": "Canceled visit"}, 200 + + +@blueprint.route("/active-visits", methods=["GET"]) +@min_level("ta") +def get_active_visits(): + """Return all incomplete visits. + + Must be TA or higher. + + :return: 200 with a list of visits formatted: + [ + { + "student_id": ..., + "student_username": ..., + "student_name": ..., + "visitID": ..., + "visit_reason": ..., + "ta_id": ..., + "ta_name": ..., + },... + ] + """ + in_progress = db.get_in_progress_visits(g.course_id) + + visits = [] + + for visit in in_progress: + student = db.lookup_identifier(visit["student_id"]) + + if visit["ta_id"] is not None: + ta = db.lookup_identifier(visit["ta_id"]) + ta_name = ta["preferred_name"] + else: + ta_name = None + + visits.append( + { + "student_id": visit["student_id"], + "student_username": student["ubit"], + "student_name": student["preferred_name"], + "visitID": visit["visit_id"], + "visit_reason": visit["student_visit_reason"], + "ta_id": visit["ta_id"], + "ta_name": ta_name, + } + ) + return visits + + +@blueprint.route("/end-visit", methods=["POST"]) +@min_level("ta") +def end_visit(): + """End the specified visit. + + Must be TA or higher. + + Params: + "id": the id of the visit + "reason": the reason the visit ended + + :return: 200 with success message on success + 400 if malformed (visit doesn't exist, or reason is missing) + """ + body = request.get_json() + + visit = body.get("id") + reason = body.get("reason") + + if visit is None or reason is None: + return {"message": "Malformed request"}, 400 + + db.end_visit(visit, reason) + + return {"message": "Ended the visit"} + + +@blueprint.route("/visits/", methods=["GET"]) +@blueprint.route("/visits", methods=["GET"], defaults={"user_id": None}) +@min_level("ta") +def get_visits(user_id): + """ + Get a list of visits. If a user_id is specified, only include + visits where the specified user is involved (either as the student + or TA). + + Params: + - user_id: + + Returns: + 200 on success: + { + "visits": [ + { + "visit_id": , + "ta_id": , + "ta_name": + "student_id": , + "student_name": + "start_time": + "end_time": + "archived": + } + ] + } + + + :return: + """ + + user = g.user + + if ( + user["site_role"] == "admin" + or get_power_level(user["course_role"]) > 1 + or ( + user_id is not None + and get_power_level(user["course_role"]) > 0 + and int(user_id) == int(user["user_id"]) + ) + ): + return {"visits": db.get_visits(g.course_id, user_id)} + + return {"message": "You are not permitted to view this resource"}, 403 diff --git a/client/Dockerfile b/client/Dockerfile index 0e27c17..93162b6 100644 --- a/client/Dockerfile +++ b/client/Dockerfile @@ -5,5 +5,3 @@ WORKDIR /app COPY . . RUN npm install - -CMD npm run build diff --git a/client/README.md b/client/README.md index 1679f1b..d103969 100644 --- a/client/README.md +++ b/client/README.md @@ -14,7 +14,7 @@ npm run dev This will start the development server on port 3000. -The frontend server expects the backend to be running on port 5000, and requests for paths starting with `/api/` to be proxied to the backend. Vite is configured to do this; however, in deployment this will have to be done independently. +The frontend server expects the backend to be running on port 5050, and requests for paths starting with `/api/` to be proxied to the backend. Vite is configured to do this; however, in deployment this will have to be done independently. ### Some Notes diff --git a/client/package-lock.json b/client/package-lock.json index 083f27f..b44841f 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -57,7 +57,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -988,9 +987,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", - "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.0.tgz", + "integrity": "sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==", "cpu": [ "arm" ], @@ -1002,9 +1001,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", - "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.0.tgz", + "integrity": "sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg==", "cpu": [ "arm64" ], @@ -1016,9 +1015,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", - "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.0.tgz", + "integrity": "sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w==", "cpu": [ "arm64" ], @@ -1030,9 +1029,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", - "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.0.tgz", + "integrity": "sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg==", "cpu": [ "x64" ], @@ -1044,9 +1043,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", - "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.0.tgz", + "integrity": "sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw==", "cpu": [ "arm64" ], @@ -1058,9 +1057,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", - "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.0.tgz", + "integrity": "sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q==", "cpu": [ "x64" ], @@ -1072,9 +1071,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", - "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.0.tgz", + "integrity": "sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==", "cpu": [ "arm" ], @@ -1086,9 +1085,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", - "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.0.tgz", + "integrity": "sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==", "cpu": [ "arm" ], @@ -1100,9 +1099,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", - "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.0.tgz", + "integrity": "sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==", "cpu": [ "arm64" ], @@ -1114,9 +1113,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", - "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.0.tgz", + "integrity": "sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==", "cpu": [ "arm64" ], @@ -1128,9 +1127,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", - "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.0.tgz", + "integrity": "sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==", "cpu": [ "loong64" ], @@ -1142,9 +1141,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", - "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.0.tgz", + "integrity": "sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==", "cpu": [ "loong64" ], @@ -1156,9 +1155,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", - "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.0.tgz", + "integrity": "sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==", "cpu": [ "ppc64" ], @@ -1170,9 +1169,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", - "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.0.tgz", + "integrity": "sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==", "cpu": [ "ppc64" ], @@ -1184,9 +1183,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", - "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.0.tgz", + "integrity": "sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==", "cpu": [ "riscv64" ], @@ -1198,9 +1197,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", - "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.0.tgz", + "integrity": "sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==", "cpu": [ "riscv64" ], @@ -1212,9 +1211,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", - "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.0.tgz", + "integrity": "sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==", "cpu": [ "s390x" ], @@ -1226,9 +1225,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", - "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.0.tgz", + "integrity": "sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==", "cpu": [ "x64" ], @@ -1240,9 +1239,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", - "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.0.tgz", + "integrity": "sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==", "cpu": [ "x64" ], @@ -1254,9 +1253,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", - "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.0.tgz", + "integrity": "sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==", "cpu": [ "x64" ], @@ -1268,9 +1267,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", - "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.0.tgz", + "integrity": "sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w==", "cpu": [ "arm64" ], @@ -1282,9 +1281,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", - "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.0.tgz", + "integrity": "sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg==", "cpu": [ "arm64" ], @@ -1296,9 +1295,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", - "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.0.tgz", + "integrity": "sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg==", "cpu": [ "ia32" ], @@ -1310,9 +1309,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", - "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.0.tgz", + "integrity": "sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ==", "cpu": [ "x64" ], @@ -1324,9 +1323,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", - "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.0.tgz", + "integrity": "sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw==", "cpu": [ "x64" ], @@ -1345,9 +1344,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -1357,7 +1356,6 @@ "integrity": "sha512-r0bBaXu5Swb05doFYO2kTWHMovJnNVbCsII0fhesM8bNRlLhXIuckley4a2DaD+vOdmm5G+zGkQZAPZsF80+YQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1735,7 +1733,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2236,9 +2233,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "funding": [ { "type": "github", @@ -2361,9 +2358,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -2387,9 +2384,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "funding": [ { "type": "opencollective", @@ -2406,7 +2403,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2436,13 +2433,13 @@ "license": "MIT" }, "node_modules/rollup": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", - "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.0.tgz", + "integrity": "sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" @@ -2452,31 +2449,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.55.1", - "@rollup/rollup-android-arm64": "4.55.1", - "@rollup/rollup-darwin-arm64": "4.55.1", - "@rollup/rollup-darwin-x64": "4.55.1", - "@rollup/rollup-freebsd-arm64": "4.55.1", - "@rollup/rollup-freebsd-x64": "4.55.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", - "@rollup/rollup-linux-arm-musleabihf": "4.55.1", - "@rollup/rollup-linux-arm64-gnu": "4.55.1", - "@rollup/rollup-linux-arm64-musl": "4.55.1", - "@rollup/rollup-linux-loong64-gnu": "4.55.1", - "@rollup/rollup-linux-loong64-musl": "4.55.1", - "@rollup/rollup-linux-ppc64-gnu": "4.55.1", - "@rollup/rollup-linux-ppc64-musl": "4.55.1", - "@rollup/rollup-linux-riscv64-gnu": "4.55.1", - "@rollup/rollup-linux-riscv64-musl": "4.55.1", - "@rollup/rollup-linux-s390x-gnu": "4.55.1", - "@rollup/rollup-linux-x64-gnu": "4.55.1", - "@rollup/rollup-linux-x64-musl": "4.55.1", - "@rollup/rollup-openbsd-x64": "4.55.1", - "@rollup/rollup-openharmony-arm64": "4.55.1", - "@rollup/rollup-win32-arm64-msvc": "4.55.1", - "@rollup/rollup-win32-ia32-msvc": "4.55.1", - "@rollup/rollup-win32-x64-gnu": "4.55.1", - "@rollup/rollup-win32-x64-msvc": "4.55.1", + "@rollup/rollup-android-arm-eabi": "4.61.0", + "@rollup/rollup-android-arm64": "4.61.0", + "@rollup/rollup-darwin-arm64": "4.61.0", + "@rollup/rollup-darwin-x64": "4.61.0", + "@rollup/rollup-freebsd-arm64": "4.61.0", + "@rollup/rollup-freebsd-x64": "4.61.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.0", + "@rollup/rollup-linux-arm-musleabihf": "4.61.0", + "@rollup/rollup-linux-arm64-gnu": "4.61.0", + "@rollup/rollup-linux-arm64-musl": "4.61.0", + "@rollup/rollup-linux-loong64-gnu": "4.61.0", + "@rollup/rollup-linux-loong64-musl": "4.61.0", + "@rollup/rollup-linux-ppc64-gnu": "4.61.0", + "@rollup/rollup-linux-ppc64-musl": "4.61.0", + "@rollup/rollup-linux-riscv64-gnu": "4.61.0", + "@rollup/rollup-linux-riscv64-musl": "4.61.0", + "@rollup/rollup-linux-s390x-gnu": "4.61.0", + "@rollup/rollup-linux-x64-gnu": "4.61.0", + "@rollup/rollup-linux-x64-musl": "4.61.0", + "@rollup/rollup-openbsd-x64": "4.61.0", + "@rollup/rollup-openharmony-arm64": "4.61.0", + "@rollup/rollup-win32-arm64-msvc": "4.61.0", + "@rollup/rollup-win32-ia32-msvc": "4.61.0", + "@rollup/rollup-win32-x64-gnu": "4.61.0", + "@rollup/rollup-win32-x64-msvc": "4.61.0", "fsevents": "~2.3.2" } }, @@ -2619,7 +2616,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2684,12 +2680,11 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2875,7 +2870,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.26", "@vue/compiler-sfc": "3.5.26", diff --git a/client/package.json b/client/package.json index bf375da..c82e13e 100644 --- a/client/package.json +++ b/client/package.json @@ -9,7 +9,8 @@ "scripts": { "dev": "vite --port 3000 --host 0.0.0.0", "build": "run-p type-check \"build-only {@}\" --", - "preview": "vite preview", + "testing": "vite --config vite.config.testing.ts --host 0.0.0.0 --port 3000", + "preview": "vite preview --config vite.config.testing.ts --host 0.0.0.0", "build-only": "vite build", "type-check": "vue-tsc --build" }, diff --git a/client/src/App.vue b/client/src/App.vue index e44f7fd..765c97a 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -1,6 +1,17 @@ @@ -14,5 +25,4 @@ @import "assets/css/base.css"; - diff --git a/client/src/assets/css/base.css b/client/src/assets/css/base.css index 92fe167..923f1b0 100644 --- a/client/src/assets/css/base.css +++ b/client/src/assets/css/base.css @@ -1,10 +1,3 @@ -:root { - --accent-color: #0461cf; - --dark-accent-color: #03499e; - --bg-color: #FFFFFF; - --danger-color: #EC221F; -} - #queue-banner.bad { background-color: #FDD3D0 } diff --git a/client/src/assets/css/student-queue.css b/client/src/assets/css/student-queue.css index 579d8b5..75a3629 100644 --- a/client/src/assets/css/student-queue.css +++ b/client/src/assets/css/student-queue.css @@ -2,6 +2,7 @@ display: flex; justify-content: center; flex-direction: column; + margin: 32px 8%; } .queue-section { diff --git a/client/src/assets/nowhere.png b/client/src/assets/nowhere.png new file mode 100644 index 0000000..0ffff47 Binary files /dev/null and b/client/src/assets/nowhere.png differ diff --git a/client/src/components/ManageTable.vue b/client/src/components/ManageTable.vue deleted file mode 100644 index ebb9c72..0000000 --- a/client/src/components/ManageTable.vue +++ /dev/null @@ -1,56 +0,0 @@ - - - - - \ No newline at end of file diff --git a/client/src/components/Table.vue b/client/src/components/Table.vue new file mode 100644 index 0000000..e2a126d --- /dev/null +++ b/client/src/components/Table.vue @@ -0,0 +1,43 @@ + + + + + \ No newline at end of file diff --git a/client/src/components/common/CourseDropdown.vue b/client/src/components/common/CourseDropdown.vue new file mode 100644 index 0000000..2aff004 --- /dev/null +++ b/client/src/components/common/CourseDropdown.vue @@ -0,0 +1,95 @@ + + + + + \ No newline at end of file diff --git a/client/src/components/common/Header.vue b/client/src/components/common/Header.vue index f7ff10a..cc75547 100644 --- a/client/src/components/common/Header.vue +++ b/client/src/components/common/Header.vue @@ -1,13 +1,74 @@ \ No newline at end of file diff --git a/client/src/components/instructor/Visit.vue b/client/src/components/instructor/Visit.vue index 3b38b73..5588c08 100644 --- a/client/src/components/instructor/Visit.vue +++ b/client/src/components/instructor/Visit.vue @@ -1,8 +1,12 @@ @@ -92,13 +96,12 @@ const cancelVisit = () => {

{{ visit_info["preferred_name"] }}

{{ visit_info["username"] }}@buffalo.edu

-
-
+
@@ -106,6 +109,11 @@ const cancelVisit = () => {
+
+ + + +
diff --git a/client/src/components/instructor/VisitTable.vue b/client/src/components/instructor/VisitTable.vue new file mode 100644 index 0000000..12a4f30 --- /dev/null +++ b/client/src/components/instructor/VisitTable.vue @@ -0,0 +1,229 @@ + + + + + \ No newline at end of file diff --git a/client/src/layouts/AppLayout.vue b/client/src/layouts/AppLayout.vue index b003ed9..e073796 100644 --- a/client/src/layouts/AppLayout.vue +++ b/client/src/layouts/AppLayout.vue @@ -1,7 +1,21 @@ @@ -15,10 +29,14 @@ import Header from "@/components/common/Header.vue";
@@ -26,8 +44,7 @@ import Header from "@/components/common/Header.vue"; \ No newline at end of file diff --git a/client/src/pages/DevLogin.vue b/client/src/pages/DevLogin.vue index fbb05f5..5029491 100644 --- a/client/src/pages/DevLogin.vue +++ b/client/src/pages/DevLogin.vue @@ -43,27 +43,6 @@ const submitLogin = () => { diff --git a/client/src/pages/Home.vue b/client/src/pages/Home.vue index 14ed41e..d059c2a 100644 --- a/client/src/pages/Home.vue +++ b/client/src/pages/Home.vue @@ -9,9 +9,32 @@ const router = useRouter() const autolabLink = ref(); +const myCourses = ref([]); + +function findDestination() { + let destination = "" + const stored = localStorage.getItem("last-course") + + if (myCourses.value.length === 0) { + router.push("/nowhere") + } + + if (stored != null) { + fetch(`/api/course/${stored}`).then(res => { + if (res.ok) { + res.json().then(json => { if (json["course_role"] != null) router.push(`/${stored}/queue`) }) + } + }) + } + + destination = myCourses.value[0]["course_url"] + localStorage.setItem("last-course", destination) + router.push(`/${destination}/queue`) +} + fetch("/api/me").then(res => { if (res.ok) { - router.push("/queue") + res.json().then(json => myCourses.value = json["enrollments"]).then(() => findDestination()) } }) diff --git a/client/src/pages/InstructorQueue.vue b/client/src/pages/InstructorQueue.vue index 4b8d723..3abc160 100644 --- a/client/src/pages/InstructorQueue.vue +++ b/client/src/pages/InstructorQueue.vue @@ -5,23 +5,27 @@ import {nextTick, ref} from "vue"; import ConfirmationDialog from "@/components/common/ConfirmationDialog.vue"; import Visit from "@/components/instructor/Visit.vue"; import EditInfo from "@/components/common/EditInfo.vue"; -import {useRouter} from "vue-router"; +import {useRoute, useRouter} from "vue-router"; import Alert from "@/components/common/Alert.vue"; import OnSiteEntry from "@/components/instructor/OnSiteEntry.vue"; import ActiveEntry from "@/components/instructor/ActiveEntry.vue"; +import CourseDropdown from "@/components/common/CourseDropdown.vue"; const students = ref([]) const onSite = ref([]) const inVisit = ref([]) const router = useRouter() +const route = useRoute() -const taName = ref(""); +const course = route.params.course -const courseManager = ref(false); +const taName = ref(""); const error = ref(); +const myCourses = ref([]); + fetch("/api/me").then(res => { if (!res.ok) { router.push("/") @@ -29,23 +33,23 @@ fetch("/api/me").then(res => { return res.json() }).then(data => { taName.value = data["preferred_name"] - courseManager.value = data["course_role"] == 'instructor' || data["course_role"] == 'admin' + myCourses.value = data["enrollments"] }) function getQueue() { - fetch("/api/get-queue").then(res => { + fetch(`/api/course/${route.params.course}/get-queue`).then(res => { return res.json() }).then(data => { students.value = data }) - fetch("/api/on-site").then(res => { + fetch(`/api/course/${route.params.course}/on-site`).then(res => { return res.json() }).then(data => { onSite.value = data }) - fetch("/api/active-visits").then(res => { + fetch(`/api/course/${route.params.course}/active-visits`).then(res => { return res.json(); }).then(data => { inVisit.value = data; @@ -70,7 +74,7 @@ const forceEnqueueErrorMessage = ref(''); function submitForceEnqueue() { - fetch("/api/enqueue-ta-override", { + fetch(`/api/course/${route.params.course}/enqueue-ta-override`, { method: "POST", body: JSON.stringify({"identifier": forceEnqueueEntry.value}), headers: {"Content-Type": "application/json"} @@ -93,7 +97,7 @@ function submitForceEnqueue() { function enqueueStudent(student: number) { console.log("Student: ", student) - fetch("/api/enqueue-ta-override", { + fetch(`/api/course/${route.params.course}/enqueue-ta-override`, { method: "POST", body: JSON.stringify({"identifier": student}), headers: {"Content-Type": "application/json"} @@ -121,7 +125,7 @@ const visitDialog = ref(); function callStudent(id: number) { - fetch("/api/help-a-student", { + fetch(`/api/course/${route.params.course}/help-a-student`, { method: "POST", body: JSON.stringify({"id": id}), headers: {"Content-Type": "application/json"} @@ -143,7 +147,7 @@ function callStudent(id: number) { const clearQueueDialog = ref(); function clearQueue() { - fetch("/api/clear-queue", { + fetch(`/api/course/${course}/clear-queue`, { method: "DELETE" }).then(res => { if (!res.ok) { @@ -171,7 +175,7 @@ function showRemoveStudentDialog(id: number) { } function removeStudent() { - fetch("/api/remove-from-queue", { + fetch(`/api/course/${course}/remove-from-queue`, { method: "POST", body: JSON.stringify({"reason": removeStudentReason.value, "user_id": removeStudentId}), headers: {"Content-Type": "application/json"} @@ -198,7 +202,7 @@ function showDeactivateStudentDialog(id: number) { } function deactivateStudent() { - fetch("/api/deactivate", { + fetch(`/api/course/${course}/deactivate`, { method: "PATCH", body: JSON.stringify({"user_id": deactivateStudentId}), headers: {"Content-Type": "application/json"} @@ -218,7 +222,7 @@ function deactivateStudent() { const editInfo = ref(); function getInProgressVisit() { - fetch("/api/restore-visit").then(res => { + fetch(`/api/course/${route.params.course}/restore-visit`).then(res => { if (!res.ok) { throw new Error("No in-progress visit found.") } @@ -240,7 +244,7 @@ function signOut() { } function moveToEnd(id: number) { - fetch("/api/move-to-end", { + fetch(`/api/course/${course}/move-to-end`, { method: "PATCH", body: JSON.stringify({"user_id": id}), headers: {"Content-Type": "application/json"} @@ -263,9 +267,9 @@ let endVisitID = 0; const endVisitTAName = ref(""); function endOtherTAsVisit(id: number) { - fetch("/api/end-visit", { + fetch(`/api/course/${course}/end-visit`, { method: "POST", - body: JSON.stringify({"id": id, "reason": `[Visit canceled by ${taName}]`}), + body: JSON.stringify({"id": id, "reason": `[Visit canceled by ${taName.value}]`}), headers: {"Content-Type": "application/json"} }).then(res => { if (res.ok) { @@ -276,6 +280,22 @@ function endOtherTAsVisit(id: number) { } }) } +const currentCourse = ref(); + + +function fetchCourse() { + fetch(`/api/course/${course}`).then(res => { + if (res.ok) { + return res.json(); + } else { + router.push("/") + } + }).then(json => { + currentCourse.value = json + }) +} + +fetchCourse(); @@ -346,18 +366,19 @@ function endOtherTAsVisit(id: number) {
+ +

Hello, {{ taName }}!

{{ students?.length }} student{{ students?.length !== 1 ? "s" : "" }} in the queue.

- +
-
@@ -404,4 +425,5 @@ function endOtherTAsVisit(id: number) { \ No newline at end of file diff --git a/client/src/pages/ManageCourse.vue b/client/src/pages/ManageCourse.vue index 33bb8db..d50068a 100644 --- a/client/src/pages/ManageCourse.vue +++ b/client/src/pages/ManageCourse.vue @@ -1,38 +1,64 @@