Skip to content

Commit

Permalink
Merge branch 'staging' into Feature-Load-Item-Headers-Only
Browse files Browse the repository at this point in the history
  • Loading branch information
campb303 committed Feb 22, 2021
2 parents 6a1b660 + 0460c0c commit f52daa5
Show file tree
Hide file tree
Showing 44 changed files with 1,766 additions and 252 deletions.
10 changes: 7 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,17 @@
# Testing
/coverage

# Productiom
# React Build Files
/build

# Misc
.DS_Store
# React local environment files
.env.local
.env.development.local
.env.test.local
.env.production.local

# Misc
.DS_Store
.vscode/

# Node Package Management
Expand All @@ -28,3 +30,5 @@ yarn-error.log*
/api/venv
__pycache__/
venv-manager.log
/api/.env
*.egg*
3 changes: 2 additions & 1 deletion Dev Environment Setup Guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,4 +252,5 @@ All of the tools in this project are accessible as an npm task so you can intera
| `kill:api` | Kills the runaway API process(es). |
| `venv:create` | This will create a virtual environment in `/api/venv` and install requirements from `/api/requirements.txt`. |
| `venv:delete` | This will delete the folder `/api/venv`. |
| `venv:reset` | This will run `venv:delete` then `venv:create`. |
| `venv:reset` | This will run `venv:delete` then `venv:create`. |
| `venv:freeze` | Regenerates the API requirements.txt file and mitigates [this pip bug](https://github.com/pypa/pip/issues/4022). |
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# webqueue2
A re-write of Purdue ECN's webqueue

![UI Snapshot](./docs/UI%20Snapshots/UI-Snapshot%202020-09-22%20at%201.48.58%20PM.png)
![UI Snapshot](./docs/UI%20Snapshots/UI-Snapshot%202020-12-03%20at%208.10.32%20PM.png)

## Stay Up To Date
See what's being worked on with [the webqueue2 Project](https://github.itap.purdue.edu/ECN/webqueue2/projects/).
Expand Down
48 changes: 44 additions & 4 deletions api/ECNQueue.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ def __parseHeaders(self) -> list:
# Example:
# [ce] QTime-Updated-By: campb303 becomes
# QTime-Updated-By: campb303
queuePrefixPattern = re.compile("\[.*\] {1}")
queuePrefixPattern = re.compile(r"\[.*?\] {1}")
for lineNumber in range(self.__getHeaderBoundary()):
line = self.__rawItem[lineNumber]
lineHasQueuePrefix = queuePrefixPattern.match(line)
Expand All @@ -219,8 +219,22 @@ def __parseHeaders(self) -> list:
message = email.message_from_string(headerString)

headers = []
dateHeaders=[
"QStatus-Updated-Time",
"Status-Updated-Time",
"Edited-Time",
"QTime-Updated-Time",
"Merged-Time",
"Time-Updated-Time",
"Replied-Time",
"Assigned-To-Updated-Time",
"QAssigned-To-Updated-Time",
"Date",
"Sent"
]

for key in message.keys():
headers.append({"type": key, "content": message[key]})
headers.append({"type": key, "content": self.__getFormattedDate(message[key]) if key in dateHeaders else message[key]})

return headers

Expand All @@ -240,6 +254,12 @@ def __parseSections(self) -> list:
for assignment in assignementLsit:
sections.append(assignment)

# Checks for empty content within an item and returns and
if contentEnd <= contentStart:
blankInitialMessage = self.__initialMessageParsing([""])
sections.append(blankInitialMessage)
return sections

# Checks for Directory Identifiers
if self.__rawItem[contentStart] == "\n" and self.__rawItem[contentStart + 1].startswith("\t"):

Expand Down Expand Up @@ -892,6 +912,10 @@ def __userReplyParsing(self, replyContent: list, lineNumber: int) -> dict:
if line == "\n":
newLineCounter = newLineCounter + 1

if newLineCounter == 2 and "datetime" not in replyFromInfo.keys():
errorMessage = "Expected \"Date: [datetime]\" in the header info"
return self.__errorParsing(line, lineNumber + lineNum + 1, errorMessage)

elif line == "===============================================\n":
endingDelimiterCount = endingDelimiterCount + 1

Expand Down Expand Up @@ -1167,7 +1191,19 @@ def __getUserAlias(self) -> str:
Returns:
str: User's Career Account alias if present or empty string
"""
emailUser, emailDomain = self.userEmail.split("@")


try:
emailUser, emailDomain = self.userEmail.split("@")

# Returns an error parse if the self.useremail doesn't contain exactally one "@" symbol
except ValueError:
# Parses through the self.headers list to find the "From" header and its line number
for lineNum, header in enumerate(self.headers):
if header["type"] == "From":
headerString = header["type"] + ": " + header["content"]
return self.__errorParsing(headerString, lineNum + 1, "Expected valid email Address")

return emailUser if emailDomain.endswith("purdue.edu") else ""

def __getFormattedDate(self, date: str) -> str:
Expand Down Expand Up @@ -1322,7 +1358,11 @@ def getQueueCounts() -> list:
possibleItems = os.listdir(queueDirectory + "/" + queue)
validItems = [isValidItemName for file in possibleItems]
queueInfo.append( {"name": queue, "number_of_items": len(validItems)} )
return queueInfo

# Sorts list of queue info alphabetically
sortedQueueInfo = sorted(queueInfo, key = lambda queueInfoList: queueInfoList['name'])

return sortedQueueInfo

def loadAllQueues(headersOnly: bool = True) -> list:
"""Return a list of Queues for each queue.
Expand Down
189 changes: 171 additions & 18 deletions api/api.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,166 @@
from flask import Flask, request
from flask import Flask, request, after_this_request
from flask_restful import Api, Resource
from flask_jwt_extended import (
JWTManager, create_access_token, create_refresh_token,
jwt_required, get_jwt_identity, jwt_refresh_token_required,
set_refresh_cookies, unset_refresh_cookies
)
import os, dotenv
from easyad import EasyAD
from ldap.filter import escape_filter_chars
# pylint says this is an error but it works so ¯\_(ツ)_/¯
from ldap import INVALID_CREDENTIALS as LDAP_INVALID_CREDENTIALS
import ECNQueue

# Load envrionment variables for ./.env
dotenv.load_dotenv()

# Create Flask App
app = Flask(__name__)

# Create API Interface
api = Api(app)


################################################################################
# Configure Flask-JWT-Extended
################################################################################

# Set JWT secret key and create JWT manager
app.config["JWT_SECRET_KEY"] = os.environ.get("JWT_SECRET_KEY")
# Set identity claim field key to sub for JWT RFC complience
# Flask-JWT-Extended uses 'identity' by default for compatibility reasons
app.config["JWT_IDENTITY_CLAIM"] = "sub"
# Set the key for error messages generated by Flask-JWT-Extended
app.config["JWT_ERROR_MESSAGE_KEY"] = "message"

# Look for JWTs in headers (for access) then cookies (for refresh)
app.config["JWT_TOKEN_LOCATION"] = ["headers", "cookies"]
# Restrict cookies to HTTPS in prod, allow HTTP in dev
app.config["JWT_COOKIE_SECURE"] = False if os.environ.get("ENVIRONMENT") == "dev" else True
# Restrict cookies using SameSite=strict flag
app.config["JWT_COOKIE_SAMESITE"] = "strict"
# Restrict refresh tokens to /token/refresh endpoint
app.config["JWT_REFRESH_COOKIE_PATH"] = '/tokens/refresh'
# Set the cookie key for CRSF validation string
# This is the default value. Adding it for easy reference
app.config["JWT_REFRESH_CSRF_HEADER_NAME"] = "X-CSRF-TOKEN"

tokenManager = JWTManager(app)



def user_is_valid(username: str, password: str) -> bool:
"""Checks if user is valid and in webqueue2 login group.
Args:
username (str): Career account username.
password (str): Career account passphrase.
Returns:
bool: True if user is valid, otherwise False.
"""

# Check for empty arguments
if (username == "" or password == ""):
return False

# Initialize EasyAD
config = {
"AD_SERVER": "boilerad.purdue.edu",
"AD_DOMAIN": "boilerad.purdue.edu"
}
ad = EasyAD(config)

# Prepare search critiera for Active Directory
credentials = {
"username": escape_filter_chars(username),
"password": password
}
attributes = [ 'cn', "memberOf" ]
filter_string = f'(&(objectClass=user)(|(sAMAccountName={username})))'

# Do user search
try:
user = ad.search(credentials=credentials, attributes=attributes, filter_string=filter_string)[0]
except LDAP_INVALID_CREDENTIALS:
return False

# Isolate group names
# Example:
# 'CN=00000227-ECNStuds,OU=BoilerADGroups,DC=BoilerAD,DC=Purdue,DC=edu' becomes
# `00000227-ECNStuds`
user_groups = [ group.split(',')[0].split('=')[1] for group in user["memberOf"] ]

# Check group membership
webqueue_login_group = "00000227-ECN-webqueue"
if webqueue_login_group not in user_groups:
return False

return True



class Login(Resource):
def post(self) -> tuple:
"""Validates username/password and returns both access and refresh tokens.
Return Codes:
200 (OK): On success.
401 (Unauthroized): When username or password are incorrect.
422 (Unprocessable Entitiy): When the username or password can't be parsed.
Example:
curl -X POST
-H "Content-Type: application/json"
-d '{"username": "bob", "password": "super_secret"}'
{ "access_token": fjr09hfp09h932jp9ruj3.3r8ihf8h0w8hr08ifhj804h8i.8h48ith08ity409hip0t4 }
Returns:
tuple: Response containing tokens and HTTP response code.
"""
if not request.is_json:
return ({ "message": "JSON missing from request body"}, 422)

data = request.json

fields_to_check = ["username", "password"]
for field in fields_to_check:
if field not in data.keys():
return ({ "message": f"{field} missing from request body"}, 422)

if not user_is_valid(data["username"], data["password"]):
return ({ "message": "Username or password is invalid"}, 401)

access_token = create_access_token(data["username"])
refresh_token = create_refresh_token(data["username"])

# This decorator is needed because Flask-RESTful's 'resourceful routing`
# doesn't allow for direct modification to the Flask response object.
# See: https://flask-restful.readthedocs.io/en/latest/quickstart.html#resourceful-routing
@after_this_request
def _does_this_work(response):
set_refresh_cookies(response, refresh_token)
return response

return ({ "access_token": access_token }, 200)

class RefreshAccessToken(Resource):
@jwt_refresh_token_required
def post(self):
username = get_jwt_identity()
access_token = create_access_token(username)
return ({"access_token": access_token}, 200)

class Item(Resource):
def get(self, queue: str, number: int) -> str:
@jwt_required
def get(self, queue: str, number: int) -> tuple:
"""Returns the JSON representation of the item requested.
Return Codes:
200 (OK): On success.
Example:
{
"lastUpdated": "07-23-20 10:11 PM",
Expand All @@ -37,40 +184,48 @@ def get(self, queue: str, number: int) -> str:
item (int): The number of the item requested.
Returns:
str: JSON representation of the item requested.
tuple: Item as JSON and HTTP response code.
"""

headersOnly = True if request.args.get("headersOnly") == "True" else False
return ECNQueue.Item(queue, number, headersOnly=headersOnly).toJson()

class Queue(Resource):
def get(self, queue: str) -> str:
@jwt_required
def get(self, queues: str) -> tuple:
"""Returns the JSON representation of the queue requested.
Example:
{
"name": ce,
"items": [...]
}
Return Codes:
200 (OK): On success.
Args:
queue (str): The queue requested.
queues (str): Plus (+) deliminited list of queues.
Returns:
str: JSON representation of the queue requested.
tuple: Queues as JSON and HTTP response code.
"""
headersOnly = False if request.args.get("headersOnly") == "False" else True
queues_requested = queue.split("+")

queues = []
queue_list = []
for queue in queues_requested:
queues.append(ECNQueue.Queue(queue, headersOnly=headersOnly).toJson())
return queues
queue_list.append(ECNQueue.Queue(queue, headersOnly=headersOnly).toJson())
return (queue_list, 200)

class QueueList(Resource):
def get(self) -> list:
@jwt_required
def get(self) -> tuple:
"""Returns a list of dictionaries with the number of items in each queue.
Return Codes:
200 (OK): On success.
Example:
[
{
Expand All @@ -84,17 +239,15 @@ def get(self) -> list:
]
Returns:
list: Dictionaries with the number of items in each queue.
tuple: Queues and item counts as JSON and HTTP response code.
"""
return ECNQueue.getQueueCounts()
return (ECNQueue.getQueueCounts(), 200)



api.add_resource(QueueList, "/api/get_queues")
api.add_resource(Login, "/login")
api.add_resource(RefreshAccessToken, "/tokens/refresh")
api.add_resource(Item, "/api/<string:queue>/<int:number>")
api.add_resource(Queue, "/api/<string:queue>")


api.add_resource(Queue, "/api/<string:queues>")
api.add_resource(QueueList, "/api/get_queues")

if __name__ == "__main__":
app.run()
Loading

0 comments on commit f52daa5

Please sign in to comment.