Skip to content

Commit

Permalink
Merge branch 'Feature-Load-Item-Headers-Only' into enhancement-user-i…
Browse files Browse the repository at this point in the history
…mage-service
  • Loading branch information
wrigh393 committed Mar 22, 2021
2 parents 66470c8 + 322a075 commit c8e5499
Show file tree
Hide file tree
Showing 25 changed files with 861 additions and 318 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,4 @@ yarn-error.log*
__pycache__/
venv-manager.log
/api/.env
/api/webqueueapi.egg-info
*.egg*
97 changes: 55 additions & 42 deletions api/ECNQueue.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,6 @@
Attributes:
queueDirectory: The directory to load queues from.
queuesToIgnore: Queues that will not be loaded when running getQueues()
Raises:
# TODO: Add description(s) of when a ValueError is raised.
ValueError: [description]
"""

#------------------------------------------------------------------------------#
Expand Down Expand Up @@ -62,7 +58,10 @@
#------------------------------------------------------------------------------#

def isValidItemName(name: str) -> bool:
"""Returns true if file name is a valid item name
"""Returns true if file name is a valid item name.
A file name is true if it contains between 1 and 3 integer numbers allowing for
any integer between 0 and 999.
Example:
isValidItemName("21") -> true
Expand All @@ -83,16 +82,23 @@ def isValidItemName(name: str) -> bool:
# Classes
#------------------------------------------------------------------------------#
class Item:
"""A single issue.
"""A chronological representation of an interaction with a user.
Example:
# Create an Item (ce100)
>>> item = Item("ce", 100)
>>> item = Item("ce", 100, headersOnly=false)
# Create an Item without parsing its contents (ce100)
>>> item = Item("ce", 100, headersOnly=True)
Args:
queue (str): The name of the Item's queue.
number (int): The number of the Item.
headersOnly (bool, optional): Whether or not to parse headers only. Defaults to True.
Attributes:
lastUpdated: An ISO 8601 formatted time string showing the last time the file was updated according to the filesystem.
headers: A list of dictionaries containing header keys and values.
content: A list of section dictionaries.
content: A list of section dictionaries (only included if headersOnly is False).
isLocked: A boolean showing whether or not a lockfile for the item is present.
userEmail: The email address of the person who this item is from.
userName: The real name of the person who this item is from.
Expand All @@ -104,21 +110,22 @@ class Item:
department: The most recent department for this item.
dateReceived: The date this item was created.
jsonData: A JSON serializable representation of the Item.
Raises:
ValueError: When the number passed to the constructor cannot be parsed.
"""

def __init__(self, queue: str, number: int) -> None:
def __init__(self, queue: str, number: int, headersOnly: bool = False) -> None:
self.queue = queue
try:
self.number = int(number)
except ValueError:
raise ValueError(" Could not convert \"" +
number + "\" to an integer")

raise ValueError(f'Could not convert "{number}" to an integer')
self.__path = "/".join([queueDirectory, self.queue, str(self.number)])
self.lastUpdated = self.__getLastUpdated()
self.__rawItem = self.__getRawItem()
self.headers = self.__parseHeaders()
self.content = self.__parseSections()
if not headersOnly: self.content = self.__parseSections()
self.isLocked = self.__isLocked()
self.userEmail = self.__parseFromData(data="userEmail")
self.userName = self.__parseFromData(data="userName")
Expand All @@ -129,28 +136,12 @@ def __init__(self, queue: str, number: int) -> None:
self.priority = self.__getMostRecentHeaderByType("Priority")
self.department = self.__getMostRecentHeaderByType("Department")
self.building = self.__getMostRecentHeaderByType("Building")
self.dateReceived = self.__getFormattedDate(
self.__getMostRecentHeaderByType("Date"))
self.dateReceived = self.__getFormattedDate(self.__getMostRecentHeaderByType("Date"))
self.jsonData = {}

# TODO: Autopopulate jsonData w/ __dir__() command. Exclude `^_` and `jsonData`.
self.jsonData = {
"queue": self.queue,
"number": self.number,
"lastUpdated": self.lastUpdated,
"headers": self.headers,
"content": self.content,
"isLocked": self.isLocked,
"userEmail": self.userEmail,
"userName": self.userName,
"userAlias": self.userAlias,
"assignedTo": self.assignedTo,
"subject": self.subject,
"status": self.status,
"priority": self.priority,
"department": self.department,
"building": self.building,
"dateReceived": self.dateReceived
}
for attribute in self.__dir__():
if "_" not in attribute and attribute != "toJson" and attribute != "jsonData":
self.jsonData[attribute] = self.__getattribute__(attribute)

def __getLastUpdated(self) -> str:
"""Returns last modified time of item reported by the filesystem in mm-dd-yy hh:mm am/pm format.
Expand Down Expand Up @@ -1245,24 +1236,31 @@ def toJson(self) -> dict:
def __repr__(self) -> str:
return self.queue + str(self.number)

# TODO: Make Queue iterable using __iter__. See: https://thispointer.com/python-how-to-make-a-class-iterable-create-iterator-class-for-it/
class Queue:
"""A collection of items.
"""A collection of Items.
Example:
# Create a queue (ce)
>>> queue = Queue("ce")
# Create a queue without parsing item contents (ce)
>>> queue = Queue("ce", headersOnly=False)
Args:
queue (str): The name of the queue.
headersOnly (bool, optional): Whether or not to parse headers only. Defaults to True.
Attributes:
name: The name of the queue.
items: A list of Items in the queue.
jsonData: A JSON serializable representation of the Queue.
"""

def __init__(self, name: str) -> None:
def __init__(self, name: str, headersOnly: bool = True) -> None:
self.name = name
self.headersOnly = headersOnly
self.__directory = queueDirectory + "/" + self.name + "/"
self.items = self.__getItems()
self._index = 0

self.jsonData = {
"name": self.name,
Expand All @@ -1283,7 +1281,7 @@ def __getItems(self) -> list:
isFile = True if os.path.isfile(itemPath) else False

if isFile and isValidItemName(item):
items.append(Item(self.name, item))
items.append(Item(self.name, item, headersOnly=self.headersOnly))

return items

Expand All @@ -1309,6 +1307,13 @@ def __len__(self) -> int:
def __repr__(self) -> str:
return f'{self.name}_queue'

# Implements the interable interface requirements by passing direct references
# to the item list's interable values.
def __iter__(self) -> list:
return iter(self.items)
def __next__(self) -> int:
return iter(self.items).__next__()

def getValidQueues() -> list:
"""Returns a list of queues on the filesystem excluding ignored queues.
Expand Down Expand Up @@ -1359,16 +1364,24 @@ def getQueueCounts() -> list:

return sortedQueueInfo


def loadQueues() -> list:
def loadAllQueues(headersOnly: bool = True) -> list:
"""Return a list of Queues for each queue.
Example:
# Load all Queues without parsing Item content
>>> loadAllQueues();
Load all Queues and parsing Item content
>>> loadAllQueues(headersOnly=False)
Args:
headersOnly (bool, optional): Whether or not to parse headers only. Defaults to True.
Returns:
list: list of Queues for each queue.
list: List of Queues for each queue.
"""
queues = []

for queue in getValidQueues():
queues.append(Queue(queue))
queues.append(Queue(queue, headersOnly=headersOnly))

return queues
81 changes: 69 additions & 12 deletions api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@
jwt_required, get_jwt_identity, jwt_refresh_token_required,
set_refresh_cookies, unset_refresh_cookies
)
from werkzeug.security import check_password_hash
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
Expand Down Expand Up @@ -47,6 +50,57 @@



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.
Expand Down Expand Up @@ -76,10 +130,8 @@ def post(self) -> tuple:
if field not in data.keys():
return ({ "message": f"{field} missing from request body"}, 422)

if data["username"] != os.environ.get("SHARED_USERNAME"):
return ({ "message": "Username invalid"}, 401)
if not check_password_hash(os.environ.get("SHARED_PASSWORD_HASH"), data["password"]):
return ({ "message": "Password invalid"}, 401)
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"])
Expand Down Expand Up @@ -110,7 +162,6 @@ def get(self, queue: str, number: int) -> tuple:
200 (OK): On success.
Example:
/api/ce/100 returns:
{
"lastUpdated": "07-23-20 10:11 PM",
"headers": [...],
Expand All @@ -135,13 +186,21 @@ def get(self, queue: str, number: int) -> tuple:
Returns:
tuple: Item as JSON and HTTP response code.
"""
return (ECNQueue.Item(queue, number).toJson(), 200)

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

class Queue(Resource):
@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.
Expand All @@ -151,12 +210,12 @@ def get(self, queues: str) -> tuple:
Returns:
tuple: Queues as JSON and HTTP response code.
"""
headersOnly = False if request.args.get("headersOnly") == "False" else True
queues_requested = queues.split("+")

queue_list = []
for queue in queues_requested:
queue_list.append(ECNQueue.Queue(queue).toJson())

queue_list.append(ECNQueue.Queue(queue, headersOnly=headersOnly).toJson())
return (queue_list, 200)

class QueueList(Resource):
Expand All @@ -183,9 +242,7 @@ def get(self) -> tuple:
tuple: Queues and item counts as JSON and HTTP response code.
"""
return (ECNQueue.getQueueCounts(), 200)




api.add_resource(Login, "/login")
api.add_resource(RefreshAccessToken, "/tokens/refresh")
api.add_resource(Item, "/api/<string:queue>/<int:number>")
Expand Down
10 changes: 8 additions & 2 deletions api/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# The Python virtual environment should be managed via the venv-manager utility, not directly by pip.
# See: https://pip.pypa.io/en/stable/reference/pip_install/#example-requirements-file

# General Utilities
Expand All @@ -10,10 +11,15 @@ python-dotenv
Flask-RESTful
python-dateutil
Flask-JWT-Extended
# Prevent upgrade to 2.x until Flask-JWT-Extended is updated to support it
# Flask-JWT-Extended doesn't support PyJWT 2.x as of 3.25.0
# Prevent upgrade to PyJWT 2.x until Flask-JWT-Extended is updated to support it.
# Check: https://github.com/vimalloc/flask-jwt-extended/tags
PyJWT == 1.*
# Specify pyldap version for custom build. This is not installed by pip.
pyldap == 3.3.1
easyad

# API Documentation
mkdocs
mkdocs
mkdocs-material
mkautodoc
Loading

0 comments on commit c8e5499

Please sign in to comment.