diff --git a/.env-testing b/.env-testing new file mode 100644 index 0000000..4016d8b --- /dev/null +++ b/.env-testing @@ -0,0 +1,11 @@ +API_MODE=prod +API_URL_PREFIX="/" +DB=relational +SQLITE_DB_PATH="/app/data/moh.sqlite" + +THE_OG_UBIT="____" +THE_OG_PN=____ + +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..efe1201 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 @@ -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/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..6702ed8 100644 --- a/api/auth/routes.py +++ b/api/auth/routes.py @@ -1,10 +1,6 @@ """Authentication Blueprint for MOH""" import json -import os -import urllib.parse - -import requests from flask import Blueprint, request, make_response, redirect from api.database.db import db @@ -15,10 +11,12 @@ #### 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 +24,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 +51,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 +79,7 @@ def signout(): ### Password auth paths + @blueprint.route("/login", methods=["POST"]) @debug_access_only def login(): @@ -136,9 +140,3 @@ def signup(): "auth_token", auth_token, max_age=int(2.592e6), httponly=True, secure=True ) return res - - - -# TODO: update preferred name - -# TODO: account has UBIT (For AL lookups) and pn (For card swipes) 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..9d7b29e 100644 --- a/api/database/db_interface.py +++ b/api/database/db_interface.py @@ -1,19 +1,18 @@ +"""The complete database interface. Split into multiple classes for readability.""" + +# pylint: disable=duplicate-code from abc import ABC, abstractmethod 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, 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..2145423 100644 --- a/api/database/idb_accounts.py +++ b/api/database/idb_accounts.py @@ -1,61 +1,107 @@ +"""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 + """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. + """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 + # + """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. + """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, 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 set_name(self, identifier, first_name, last_name): - raise NotImplementedError() \ No newline at end of file + 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() diff --git a/api/database/idb_queue.py b/api/database/idb_queue.py index cf6c0c3..b2d7e67 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): - - def __init__(self): - super().__init__() + """Definitions for the database's queue interface""" @abstractmethod def enqueue_student(self, student): + """Add the specified student to the end of the queue. + + :param student: Identifier for the student to add + """ raise NotImplementedError() @abstractmethod def enqueue_student_front(self, student): + """Add the specified student to the front of the queue. + + :param student: Identifier for the student to add + """ raise NotImplementedError() @abstractmethod def dequeue_student(self): + """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 get_queue(self): + """Retrieve the queue. + + :return: The queue as a list of dicts matching + { + "id": , + "preferred_name": , + "ubit": , + "pn": , + } + """ raise NotImplementedError() @abstractmethod def remove_student(self, student): + """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 dequeue_specified_student(self, student_id): + """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): + """Removes all students from the queue.""" raise NotImplementedError() @abstractmethod def set_reason(self, student, reason): + """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): + """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): + """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 + """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..5bd6b81 100644 --- a/api/database/idb_roster.py +++ b/api/database/idb_roster.py @@ -1,15 +1,53 @@ +"""The roster component of the database interface""" + +# pylint: disable=duplicate-code from abc import ABC, abstractmethod class IRoster(ABC): - - def __init__(self): - super().__init__() + """Definitions for the roster component of the database interface""" @abstractmethod def add_to_roster(self, user_id, role): + """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. + :return: + """ raise NotImplementedError() @abstractmethod def get_roster(self): - raise NotImplementedError() \ No newline at end of file + """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 clear_students(self): + """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..f30dcd4 100644 --- a/api/database/idb_sessions.py +++ b/api/database/idb_sessions.py @@ -1,31 +1,48 @@ +"""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 """ + """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 """ + """Reset time user was enqueued. + + :param user: the user id of the user + """ raise NotImplementedError() @abstractmethod def get_swipe_time(self, user): - """ Return time user was enqueued """ + """Return time user was enqueued. + + :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 + """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 """ + """Reset everyone's last enqueue time""" raise NotImplementedError() diff --git a/api/database/idb_visits.py b/api/database/idb_visits.py index 4dd2430..a953cf9 100644 --- a/api/database/idb_visits.py +++ b/api/database/idb_visits.py @@ -1,9 +1,11 @@ +"""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: @@ -34,22 +36,34 @@ 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 + """Return all database entries for visits that have not ended. :return: """ - raise NotImplementedError() \ No newline at end of file + raise NotImplementedError() + + @abstractmethod + def get_visits(self, 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 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..efd143c 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 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..25ecf8f 100644 --- a/api/database/relational_db/relational_db_accounts.py +++ b/api/database/relational_db/relational_db_accounts.py @@ -1,15 +1,17 @@ +"""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_roster import IRoster class RelationalDBAccounts(IAccounts, IRoster): + """Implementations for the accounts and roster components.""" def create_account(self, ubit, pn): @@ -17,7 +19,7 @@ def create_account(self, ubit, pn): user_id = cursor.execute( """ - SELECT user_id FROM users WHERE ubit=? or person_num=? + SELECT user_id FROM users WHERE (ubit=? or person_num=?) AND deleted = false """, (ubit, pn), ).fetchone() @@ -43,7 +45,7 @@ def lookup_person_number(self, person_number): user = cursor.execute( """ SELECT preferred_name, last_name, ubit, person_num, course_role, user_id from users - WHERE person_num = ? + WHERE person_num = ? AND deleted = false """, (person_number,), ).fetchone() @@ -65,7 +67,7 @@ def lookup_identifier(self, identifier): 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 = ? + WHERE (ubit = ? OR person_num = ? OR user_id = ?) AND deleted = false """, (identifier, identifier, identifier), ).fetchone() @@ -90,7 +92,7 @@ def get_authenticated_user(self, auth_token): SELECT preferred_name, last_name, ubit, person_num, course_role, users.user_id, last_swipe FROM users INNER JOIN auth ON users.user_id = auth.user_id - WHERE auth_token = ? + WHERE auth_token = ? AND deleted = false AND expires_at > CURRENT_TIMESTAMP """, (hashed_token,), @@ -120,7 +122,7 @@ def get_authenticated_user(self, auth_token): "person_num": user[3], "course_role": user[4], "user_id": user[5], - "on_site": on_site + "on_site": on_site, } def sign_up(self, username, pw) -> str | None: @@ -163,7 +165,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 +187,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 +195,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: @@ -228,24 +227,28 @@ def add_to_roster(self, user_id, role): def get_roster(self): with self.cursor() as cursor: - users = cursor.execute(""" + users = cursor.execute( + """ SELECT user_id, preferred_name, last_name, ubit, person_num, course_role FROM users + WHERE deleted = false ORDER BY ubit - """).fetchall() + """ + ).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], + "course_role": user[5], + } + ) return result - def set_preferred_name(self, identifier, name): with self.cursor() as cursor: @@ -254,7 +257,9 @@ 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 @@ -269,10 +274,35 @@ def set_name(self, user_id, first_name, last_name): preferred_name = ?, last_name = ? WHERE user_id = ? RETURNING user_id - """, (first_name, last_name, user_id) + """, + (first_name, last_name, user_id), ).fetchone() if user is None: return None - return user[0] \ No newline at end of file + return user[0] + + 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 + """, + (user_id,), + ).fetchone() + + if user is None: + return None + + return user[0] + + def clear_students(self): + with self.cursor() as cursor: + cursor.execute( + """ + UPDATE users SET deleted = true WHERE course_role = 'student' + """ + ) 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..df74cfb 100644 --- a/api/database/relational_db/relational_db_queue.py +++ b/api/database/relational_db/relational_db_queue.py @@ -1,9 +1,13 @@ +"""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): with self.cursor() as cursor: @@ -44,14 +48,16 @@ def dequeue_student(self): ORDER BY priority DESC, joined """ ).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): @@ -62,13 +68,16 @@ def dequeue_specified_student(self, student_id): FROM queue INNER JOIN users ON queue.user_id = users.user_id WHERE users.user_id = ? - """, (student_id,) + """, + (student_id,), ).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,7 +85,7 @@ 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): @@ -90,7 +99,7 @@ def get_queue(self): """ ) - users_l = list() + users_l = [] for user in users: users_l.append( @@ -106,35 +115,36 @@ def get_queue(self): def clear_queue(self): with self.cursor() as cursor: - cursor.execute( - "DELETE FROM queue WHERE dequeued = false" - ) + cursor.execute("DELETE FROM queue WHERE dequeued = false") def remove_student(self, student): 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 = ?", (student,) + ).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 = ?", (student,)) return {"user_id": queue_info[0], "joined": queue_info[1]} def set_reason(self, student, reason): 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 = ?", + (reason, student), ) def move_to_end(self, student): - now = str(datetime.datetime.now().isoformat(' ', timespec="seconds")) + 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 = ? RETURNING user_id", + (now, student), ).fetchone() if res is None: return False @@ -153,9 +163,7 @@ def get_hw_authorization(self): def reset_hw_authorization(self): with self.cursor() as cursor: - cursor.execute( - "DELETE FROM hardware" - ) + cursor.execute("DELETE FROM hardware") auth_code = secrets.token_urlsafe(16) 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..69ca0b1 100644 --- a/api/database/relational_db/relational_db_sessions.py +++ b/api/database/relational_db/relational_db_sessions.py @@ -1,30 +1,42 @@ +"""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): with self.cursor() as cursor: - cursor.execute(""" + cursor.execute( + """ UPDATE users SET last_swipe = datetime('now', 'localtime') WHERE user_id = ? - """, (user,)) + """, + (user,), + ) def reset_swipe_time(self, user): with self.cursor() as cursor: - cursor.execute(""" + cursor.execute( + """ UPDATE users SET last_swipe = NULL WHERE user_id = ? - """, (user, )) + """, + (user,), + ) def get_swipe_time(self, user): with self.cursor() as cursor: - time = cursor.execute(""" + time = cursor.execute( + """ SELECT last_swipe FROM users WHERE last_swipe > datetime('now', 'localtime', '-2 hours') AND user_id = ? - """, (user,)).fetchone() + """, + (user,), + ).fetchone() if time is None: return None @@ -42,7 +54,7 @@ def get_on_site(self): AND queue.user_id IS NULL """ ) - users_l = list() + users_l = [] for user in users: users_l.append( { @@ -55,7 +67,9 @@ def get_on_site(self): return users_l def clear_on_site(self): - with self.cursor as cursor: - cursor.execute(""" + with self.cursor() as cursor: + cursor.execute( + """ UPDATE users SET last_swipe = NULL - """) \ No newline at end of file + """ + ) diff --git a/api/database/relational_db/relational_db_visits.py b/api/database/relational_db/relational_db_visits.py index 65ac7f9..7e83196 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): - - def __init__(self): - super().__init__() + """Implementations for the visits component""" def create_visit(self, student, ta, enqueue_time, visit_reason) -> int: with self.cursor() as cursor: - visit_id = cursor.execute(""" + visit_id = cursor.execute( + """ INSERT INTO visits (student_id, ta_id, enqueue_time, student_visit_reason) VALUES ( ?, ?, ?, ?) RETURNING visit_id - """, (student, ta, enqueue_time, visit_reason)).fetchone()[0] + """, + (student, ta, enqueue_time, visit_reason), + ).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,78 @@ 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): 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() + """ + ).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, 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 + """ + ).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 = ? + """, + (user_id, user_id), + ).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..e91bf6e 100644 --- a/api/database/testing_db/testing_db_accounts.py +++ b/api/database/testing_db/testing_db_accounts.py @@ -1,14 +1,81 @@ +"""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): - def __init__(self): - super().__init__() - self.queue = [] - self.next_id = 0 +class TestingDBAccounts(IAccounts, IRoster): + """Implementations for the accounts and roster methods""" + + def clear_students(self): + pass def create_account(self, ubit, pn): - account_id = self.next_id - self.next_id += 1 - return account_id + users.append({"ubit": ubit, "person_num": pn}) + + def lookup_person_number(self, person_number) -> dict[str, str]: + return utils.lookup_person_number(person_number) + + def lookup_identifier(self, identifier) -> dict[str, str]: + return utils.lookup_identifier(identifier) + + def get_authenticated_user(self, auth_token) -> 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_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 + + def add_to_roster(self, user_id, role): + user = self.lookup_identifier(user_id) + if user is not None and role in {"student", "ta", "instructor", "admin"}: + user["course_role"] = role + + def get_roster(self): + return users + + 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..9e8b510 100644 --- a/api/database/testing_db/testing_db_queue.py +++ b/api/database/testing_db/testing_db_queue.py @@ -1,12 +1,49 @@ +"""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_front(self, student): + if (user := utils.lookup_identifier(student)) != {}: + self.queue.insert(0, user) + + def get_queue(self): + return self.queue + + def remove_student(self, student): + student = utils.lookup_identifier(student) + if student == {}: + return None + + return self.queue.remove(utils.lookup_identifier(student)) + + def dequeue_specified_student(self, student_id): + pass + + def clear_queue(self): + pass + + def set_reason(self, student, reason): + pass + + def move_to_end(self, student): + pass + + def get_hw_authorization(self): + pass + + def reset_hw_authorization(self): + pass + def enqueue_student(self, student): self.queue.append(student) 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..0123553 --- /dev/null +++ b/api/database/testing_db/testing_db_visits.py @@ -0,0 +1,38 @@ +"""Visits and Sessions part of the testing DB""" + +from api.database.idb_sessions import ISessions +from api.database.idb_visits import IVisits + + +class TestingDBVisits(IVisits, ISessions): + """Visit and session implementation for testing DB""" + + def create_visit(self, student, ta, enqueue_time, visit_reason) -> int: + pass + + def end_visit(self, visit_id, reason): + pass + + def cancel_visit(self, visit_id): + pass + + def get_in_progress_visits(self): + pass + + def get_visits(self, user_id=None): + pass + + def update_swipe_time(self, user): + pass + + def reset_swipe_time(self, user): + pass + + def get_swipe_time(self, user): + pass + + def get_on_site(self): + pass + + def clear_on_site(self): + pass diff --git a/api/queue/controller.py b/api/queue/controller.py index ced688c..3c0192a 100644 --- a/api/queue/controller.py +++ b/api/queue/controller.py @@ -1,16 +1,31 @@ +"""Queue management functions""" + import datetime from api.database.db import db 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): + """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 + + :return True on success, False on failure + """ pn = decode_pn(swipe_data) student = db.lookup_person_number(pn) if student is not None: @@ -21,6 +36,13 @@ def add_to_queue_by_card_swipe(swipe_data): def add_to_queue_by_ta_override(identifier, 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 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) if student is not None: if front: @@ -33,14 +55,31 @@ def add_to_queue_by_ta_override(identifier, front=False): def add_to_queue(user_account): + """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" + """ user_id = user_account["user_id"] db.enqueue_student(user_id) + def add_to_front_of_queue(user_account): + """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" + """ user_id = user_account["user_id"] db.enqueue_student_front(user_id) + def remove_from_queue_without_visit(student, reason): + """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 + :return: True on success, False on failure + """ queue_info = db.remove_student(student) if queue_info is None: @@ -50,13 +89,29 @@ def remove_from_queue_without_visit(student, reason): db.end_visit(visit, reason) return True + def self_add_to_queue(student): + """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 + :return: True on success, False on failure + """ if is_active(student): db.enqueue_student(student) return True return False + def is_active(student): + """Checks if the student has refreshed their swipe + within the past two hours. + + :param student: The user id of the student 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" @@ -74,42 +129,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..190d6ea 100644 --- a/api/queue/routes.py +++ b/api/queue/routes.py @@ -2,9 +2,15 @@ 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 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 @@ -32,9 +38,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 +47,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): return {"message": "Student was added to the queue"} return {"message": "No student matching the card swipe was found"}, 404 @@ -61,11 +64,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 +79,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): 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 +96,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,9 +117,6 @@ 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() @@ -273,7 +138,9 @@ def dequeue(): 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"] + ) return { "id": int(student["user_id"]), @@ -281,7 +148,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,15 +171,12 @@ def get_queue(): }, ... ] - - 403 Unauthorized - Requester does not have TA permissions - { - "message": - } + 403 Forbidden - Requester does not have TA permissions """ return db.get_queue() + @blueprint.route("/get-queue-size", methods=["GET"]) def get_queue_size(): """ @@ -346,7 +210,6 @@ def get_anon_queue(): "position": , "length": } - 400 Bad Request - You are not in the queue { "message": , @@ -370,9 +233,13 @@ def get_anon_queue(): if entry["id"] == user_id: return {"position": i, "length": len(queue)} - active = controller.is_active(user_id) + active = is_active(user_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 +254,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")): @@ -411,13 +274,13 @@ def remove_self(): 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 + 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 +292,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,7 +306,6 @@ 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") @@ -458,59 +315,56 @@ def remove(): @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, 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) @@ -527,9 +381,18 @@ def update_reason(): 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: @@ -542,8 +405,14 @@ def move_to_end(): @blueprint.route("/swipe-authorization", methods=["GET"]) -@min_level('instructor') +@min_level("ta") def get_swipe_auth_code(): + """Get the swipe authorization code. + + Must be TA or higher. + + :return: 200 with the authorization code as: {"code": } + """ code = db.get_hw_authorization() if code is None: @@ -551,16 +420,29 @@ def get_swipe_auth_code(): return {"code": code} + @blueprint.route("/reset-swipe-auth", methods=["DELETE"]) -@min_level('instructor') +@min_level("instructor") def reset_swipe_auth_code(): + """Reset the current authorization code. + + :return: 200 with the newly generated code as: {"code": } + """ db.reset_hw_authorization() 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. + + :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 (auth_token := request.cookies.get("auth_token")): return {"message": "You are not logged in!"}, 401 @@ -575,14 +457,38 @@ def self_enqueue(): 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 a list of students who are on-site. + + :return: 200 on success: + [ + { + "id": , + "username": , + "pn": , + "preferred_name: + }, + ... + ] + """ return db.get_on_site() + @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"]) 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..d019923 100644 --- a/api/roster/controller.py +++ b/api/roster/controller.py @@ -1,20 +1,29 @@ -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 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): @@ -42,6 +51,9 @@ 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): @@ -71,7 +83,10 @@ def check_permission(*args, **kwargs): return decorator + def add_to_roster(ubit, pn, first_name, last_name, role): + """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.set_name(user_id, first_name, last_name) diff --git a/api/roster/routes.py b/api/roster/routes.py index 698bd10..7d50fa6 100644 --- a/api/roster/routes.py +++ b/api/roster/routes.py @@ -3,7 +3,7 @@ from flask import Blueprint, request 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 = get_user(request.cookies) + 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,47 +57,58 @@ 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"], + ) 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": + } + ] + } """ @@ -104,10 +117,16 @@ def get_roster(): return {"roster": roster} - @blueprint.route("/update-name", methods=["PATCH"]) -@min_level('student') +@min_level("student") def update_preferred_name(): + """ + Update the user's preferred name. + + :return: 200, on success + 400, if malformed + 401, if user isn't authenticated + """ user = get_user(request.cookies) if user is None: @@ -124,10 +143,10 @@ def update_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 +165,9 @@ def enroll_user(): """ data = request.get_json() - required_fields = ["ubit", "pn", - "preferred_name", "last_name", - "role"] + user = get_user(request.cookies) + + required_fields = ["ubit", "pn", "preferred_name", "last_name", "role"] legal_roles = {"student", "ta", "instructor"} @@ -159,54 +178,93 @@ 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"]) - return {"message": "Successfully enrolled user", - "id": user_id} + return {"message": "Successfully enrolled user", "id": user_id} -@blueprint.route("/visits/", methods=["GET"]) -@blueprint.route("/visits", methods=["GET"], defaults={"user_id": None}) -@min_level('instructor') -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: +@blueprint.route("/user/", methods=["DELETE"]) +def delete_user(user_id): + """Deletes the specified user. - Returns: - 200 on success: - { - "visits": [ - { - "visit_id": , - "ta_id": , - "ta_name": - "student_id": , - "student_name": - "start_time": - "end_time": - } - ] + :return: 200 on success + 401 if removing this user isn't permitted + 404 if user doesn't exist + """ + caller = get_user(request.cookies) + user = db.lookup_identifier(user_id) + if get_power_level(caller["course_role"]) <= get_power_level(user["course_role"]): + return {"message": "You cannot remove this user."}, 401 + + 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(), + ): + db.end_visit( + visit["visit_id"], + "[Visit ended due to a participant's account being deleted.]", + ) + + db.remove_student(user_id) + db.reset_swipe_time(user_id) + + if db.delete_user(user_id) is None: + return {"message": "User not found."}, 404 + + return {"message": "Successfully removed user"} + +@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: + :return: 200 on success """ - pass + user = db.lookup_identifier(user_id) + caller = get_user(request.cookies) + 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 + 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: add to roster - to add an individual to the 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: Remove from roster + db.add_to_roster(user_id, role) -# TODO: Whenever someone is added to the roster, check if they have an account and create one for them if not + 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..ec1d621 100644 --- a/api/server.py +++ b/api/server.py @@ -4,21 +4,20 @@ """ 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 + 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 +from api.roster.controller import min_level 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 URL_PREFIX = os.getenv("API_URL_PREFIX", "/") THE_OG_UBIT = os.getenv("THE_OG_UBIT", None) @@ -26,6 +25,7 @@ og = db.lookup_person_number(THE_OG_PN) + def create_app(): """Create and return Flask API server @@ -33,14 +33,12 @@ 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") - - app = Flask(__name__, template_folder="../client/templates", static_folder="../client/static") + app = Flask(__name__) app.config.from_object(config.Config()) @@ -48,14 +46,18 @@ def create_app(): 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(visits_routes.blueprint, url_prefix=URL_PREFIX) @app.route(URL_PREFIX + "/user/", methods=["GET"]) - @min_level('ta') + @min_level("ta") def get_user_info(user_id): user = db.lookup_identifier(user_id) + + if user is None: + return {"message": "User not found"}, 404 + return user @app.route(URL_PREFIX + "/me", methods=["GET"]) 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..ccedf65 --- /dev/null +++ b/api/visits/controller.py @@ -0,0 +1,46 @@ +"""Visit management functions""" + +from api.database.db import db + + +def get_students_visit(student): + """Retrieves the in-progress visit involving the specified student, if it exists. + + :param student: the student's user id + :return: the in-progress visit if it exists, None otherwise. + """ + 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): + """Retrieves the in-progress visit involving the specified TA, if it exists. + + :param ta: the TA's user id + :return: the in-progress visit if it exists, None otherwise. + """ + 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/visits/routes.py b/api/visits/routes.py new file mode 100644 index 0000000..e00d876 --- /dev/null +++ b/api/visits/routes.py @@ -0,0 +1,199 @@ +"""Visits Blueprint for MOH""" + +from flask import Blueprint, request + +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 = 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(): + """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() + + 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 = get_user(request.cookies) + + if 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(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/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/instructor/Visit.vue b/client/src/components/instructor/Visit.vue index 3b38b73..12843aa 100644 --- a/client/src/components/instructor/Visit.vue +++ b/client/src/components/instructor/Visit.vue @@ -2,7 +2,7 @@ import {nextTick, ref} from "vue"; -const props = defineProps(["visit_info"]) +const props = defineProps(["visit_info", "read_only"]) const emit = defineEmits(["open", "close"]) const dialogRef = ref(); @@ -82,7 +82,7 @@ const cancelVisit = () => { }) } - +const closeBtn = ref(); @@ -92,13 +92,12 @@ const cancelVisit = () => {

{{ visit_info["preferred_name"] }}

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

-
-
+
@@ -106,6 +105,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..c7e0091 --- /dev/null +++ b/client/src/components/instructor/VisitTable.vue @@ -0,0 +1,224 @@ + + + + + \ No newline at end of file diff --git a/client/src/pages/InstructorQueue.vue b/client/src/pages/InstructorQueue.vue index 4b8d723..ceb8bf7 100644 --- a/client/src/pages/InstructorQueue.vue +++ b/client/src/pages/InstructorQueue.vue @@ -265,7 +265,7 @@ const endVisitTAName = ref(""); function endOtherTAsVisit(id: number) { fetch("/api/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) { @@ -352,7 +352,7 @@ function endOtherTAsVisit(id: number) {
- +
diff --git a/client/src/pages/ManageCourse.vue b/client/src/pages/ManageCourse.vue index 33bb8db..79bd2e1 100644 --- a/client/src/pages/ManageCourse.vue +++ b/client/src/pages/ManageCourse.vue @@ -5,11 +5,14 @@ import {ref} from "vue"; import TableEntry from "@/components/TableEntry.vue"; import ConfirmationDialog from "@/components/common/ConfirmationDialog.vue"; import Alert from "@/components/common/Alert.vue"; -import ManageTable from "@/components/ManageTable.vue"; +import Table from "@/components/Table.vue"; +import Visit from "@/components/instructor/Visit.vue"; +import VisitTable from "@/components/instructor/VisitTable.vue"; const router = useRouter() -const me = ref(); +const me = ref({}); +const manager = ref(false); fetch("/api/me").then(res => { if (!res.ok) { @@ -17,14 +20,17 @@ fetch("/api/me").then(res => { } return res.json(); }).then(data => { - if (data["course_role"] !== "instructor" && data["course_role"] !== "admin") { + if (data["course_role"] === "student") { router.push("/queue") } + if (data["course_role"] !== "ta") { + manager.value = true; + } me.value = data; getCode(); }) -const users = ref(); +const users = ref>>([]); const getRoster = () => fetch("/api/get-roster").then(res => { if (!res.ok) { @@ -32,7 +38,14 @@ const getRoster = () => fetch("/api/get-roster").then(res => { } return res.json(); }).then(json => { - users.value = json["roster"] + users.value = [] + const roster: Array = json["roster"] + + roster.sort((a, b) => { return a["ubit"].localeCompare(b["ubit"]) }) + + roster.forEach((user) => { + users.value.push([user["user_id"], user["ubit"], user["preferred_name"], user["last_name"], user["person_num"], user["course_role"]]) + }) }); getRoster() @@ -125,10 +138,85 @@ const resetAuth = () => { } +const visitTable = ref(); +const visitRef = ref(); + +const visitInfo = ref({}); + +function showOldVisit(visit: Array) { + visitInfo.value = { + "preferred_name": `${visit[4]} ${visit[5]}`, + "username": visit[3], + "visit_reason": visit[9], + "visit_result": visit[10] + } + visitRef.value?.show() +} + +function deleteUser(user: string) { + fetch(`/api/user/${user}`, { method: "DELETE"}).then(res => { + if (!res.ok) { + alertBox.value?.setError("Failed to remove user") + } else { + getRoster() + } + } + ) +} + +const clearDialog = ref(); + +function clearStudents() { + fetch("/api/clear-enrollments", { method: "DELETE"} ).then(res => { + if (!res.ok) { + alertBox.value?.setError("Failed to clear roster.") + } else { + getRoster(); + } + }) +} + +function updateRole(user: string, role: string) { + fetch(`/api/user/${user}/role`, { + method: "PATCH", + body: JSON.stringify({"role": role}), + headers: {"Content-Type": "application/json"} + }).then(res => { + if (!res.ok) { + res.json().then((json) => { + alertBox.value?.setError(`Failed to change role: ${json["message"]}`) + getRoster() + }) + + } + }) +} + +function rolePrettyName(role: string) { + switch (role) { + case "student": return "Student" + case "ta": return "TA" + case "instructor": return "Instructor" + case "admin": return "Admin" + default: return "IDK" + } +} +