Commit e0b14f25 authored by adietmue's avatar adietmue
Browse files

Signups: add additional checks and payments

parent 0950a6ce
......@@ -11,6 +11,10 @@ Next, you should check out the following files:
- `security.py`:
Authentication is defined here, in particular the interaction with AMIVAPI.
- `signups.py`:
Processing of signups: waiting list, payments, etc.
"""
from os import getcwd
......@@ -19,6 +23,14 @@ from flask import Config
from security import APIAuth, only_own_nethz
from validation import APIValidator
from signups import (
new_signups,
deleted_signup,
patched_signup,
patched_course,
block_course_deletion,
mark_as_paid,
)
def create_app(settings=None):
......@@ -43,6 +55,16 @@ def create_app(settings=None):
'on_pre_%s_%s' % (method, resource))
event += only_own_nethz
# Also use hooks to add pre- and postprocessing to resources
application.on_post_POST_signups += new_signups
application.on_deleted_item_signups += deleted_signup
application.on_updated_signups += patched_signup
application.on_updated_courses += patched_course
application.on_delete_item_courses += block_course_deletion
application.on_inserted_payments += mark_as_paid
return application
......
......@@ -47,7 +47,7 @@ def request_cache(key):
try:
# If the value is already in g, don't call function
return getattr(g, key)
except KeyError:
except AttributeError:
setattr(g, key, function(*args, **kwargs))
return getattr(g, key)
return _wrapper
......@@ -118,11 +118,12 @@ class APIAuth(TokenAuth):
if get_user() is None:
return False
# Check Permissions, return 403 if not permitted
# Users: Read always allowed, write only on specific resources
# Admins: Can do all
user_writable = ['signups', 'selections', 'payments']
if (method == 'GET' or (resource in user_writable) or is_admin()):
# Check permitted methods for users, return 403 if not permitted
# Admins can do everything
domain = current_app.config['DOMAIN']
allowed_methods = domain[resource].get('user_methods', [])
if (method in allowed_methods or is_admin()):
return True
else:
abort(403)
......
......@@ -23,6 +23,9 @@ MONGO_USERNAME = 'pvkuser'
MONGO_PASSWORD = 'pvkpass'
MONGO_DBNAME = 'pvk'
# Only JSON, simplifies hooks
XML = False
RESOURCE_METHODS = ['GET', 'POST']
ITEM_METHODS = ['GET', 'PATCH', 'DELETE']
......@@ -32,6 +35,10 @@ ITEM_METHODS = ['GET', 'PATCH', 'DELETE']
DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
# More Feedback when creating something: Return all fields
BANDWIDTH_SAVER = False
# A schema for required start/end time tuple
TIMESPAN_SCHEMA = {
'type': 'dict',
......@@ -57,6 +64,9 @@ STANDARD_ERRORS = [400, 401, 403, 404, 405, 406, 409, 410, 412, 422, 428]
# Resources
DOMAIN = {
'lectures': {
'user_methods': ['GET'],
'schema': {
'title': {
'type': 'string',
......@@ -94,6 +104,9 @@ DOMAIN = {
},
'courses': {
'user_methods': ['GET'],
'schema': {
'lecture': {
'type': 'objectid',
......@@ -137,6 +150,8 @@ DOMAIN = {
'signups': {
# Signup for a user to a course
'user_methods': ['GET', 'POST', 'PATCH', 'DELETE'],
'schema': {
'nethz': {
'type': 'string',
......@@ -155,12 +170,14 @@ DOMAIN = {
'embeddable': True
},
'unique_combination': ['nethz'],
'required': True,
# TODO: No overlapping courses
},
'status': {
'type': 'string',
'allowed': ['waiting', 'reserved', 'accepted'],
'readonly': True,
'default': 'waiting',
},
},
},
......@@ -169,6 +186,8 @@ DOMAIN = {
# Easy way for users to safe their selections before signup is open
# List of selected courses per user
'user_methods': ['GET', 'POST', 'PATCH', 'DELETE'],
'schema': {
'nethz': {
'type': 'string',
......@@ -199,6 +218,9 @@ DOMAIN = {
# Dummy endpoint for payments.
# TODO: Implement as soon as PSP is known.
# Only admins can delete payments
'user_methods': ['GET', 'POST', 'PATCH'],
'schema': {
'signups': {
'type': 'list',
......@@ -210,6 +232,7 @@ DOMAIN = {
'embeddable': True
},
# TODO: No duplicate entries
# TODO: No courses on waiting list
},
'required': True,
'nullable': False,
......
"""Signup processing (Waiting list and payments).
So far only dummy functionality, i.e. if a payment is posted, all courses
are set to accepted.
As soon as we have payment service provider, the definite functionality needs
to be implemented.
TODO: Send notification mails
"""
import json
from functools import wraps
from itertools import chain
from flask import current_app, abort
from eve.methods.get import getitem_internal
from eve.methods.patch import patch_internal
def wrap_response(f):
"""Wrapper to modify payload for successful requests (status 2xx)."""
@wraps(f)
def wrapped(request, response):
if response.status_code // 100 == 2:
payload = json.loads(response.get_data(as_text=True))
if '_items' in payload:
f(payload['_items'])
else:
f([payload])
response.set_data(json.dumps(payload))
return wrapped
@wrap_response
def new_signups(signups):
"""Update the status for all signups to a course."""
# Remove duplicates by using a set
courses = set(item['course'] for item in signups)
# Re-format signups into a dict so we can update them easier later
signups_by_id = {str(item['_id']): item for item in signups}
modified = chain.from_iterable(update_signups(course)
for course in courses)
for _id in modified:
# Update response payload if needed
try:
signups_by_id[_id]['status'] = 'reserved'
except AttributeError:
pass # Not in response, nothing to do
def deleted_signup(signup):
"""Update status of course the signup belonged to."""
update_signups(signup['course'])
def patched_signup(update, original):
"""Update status of new course."""
if 'course' in update:
update_signups(str(update['course']))
def patched_course(update, original):
"""If the number of spots changed, update signups of course."""
if 'spots' in update:
update_signups(str(original['_id']))
def block_course_deletion(course):
"""If a course has signups, it can't be deleted."""
count = current_app.data.driver.db['signups'].count({
'course': str(course['_id'])
})
if count > 0:
abort(409, "Course cannot be deleted as long as it has signups.")
def update_signups(course):
"""Update waiting list for all provided courses.
Return list of ids of all signups with modified status.
"""
# If the course is embedded, we already have the data we need
course_data = getitem_internal('courses', _id=str(course))[0]
course_id = course_data['_id']
total_spots = course_data.get('spots', 0)
# Next, count current signups not on waiting list
collection = current_app.data.driver.db['signups']
taken_spots = collection.count({'status': {'$ne': 'waiting'}})
available_spots = total_spots - taken_spots
if available_spots <= 0:
return []
# Finally, get as many signups on the waiting list as spots available
# sort by _updated, use nethz as tie breaker
signups = collection.find({'course': course_id, 'status': 'waiting'},
projection=['_id', 'status'],
sort=[('_updated', 1), ('nethz', 1)],
limit=available_spots)
signups_to_update = [str(item['_id']) for item in signups
if item['status'] == 'waiting']
for signup_id in signups_to_update:
patch_internal('signups',
_id=signup_id,
payload={'status': 'reserved'},
concurrency_check=False,
skip_validation=True)
return signups_to_update
def mark_as_paid(payments):
"""After successful payment, set status to `accepted`."""
for payment in payments:
for signup in payment['signups']:
data = {'status': 'accepted'}
patch_internal('signups',
_id=str(signup),
payload=data,
concurrency_check=False,
skip_validation=True)
......@@ -54,7 +54,7 @@ class TestClient(FlaskClient):
assert response.status_code == assert_status, \
response.get_data(as_text=True)
return json.loads(response.get_data(as_text=True))
return json.loads(response.get_data(as_text=True) or '{}')
def drop_database(application):
......@@ -70,6 +70,7 @@ def user(self, **kwargs):
"""Additional context to fake a user."""
with self.test_request_context():
g.user = 'Not None :)'
g.nethz = 'Something'
g.admin = False
# The test requests will use this header
......
"""Test for signup processing, in particular waiting list."""
from unittest.mock import patch, call
import pytest
from datetime import datetime as dt
from signups import update_signups
def test_success(app):
"""If there are enough spots, the status will be 'reserved'."""
with app.admin(): # Admin so we don't need to care about nethz
# Create fake courses to sign up to
course_id = str(app.data.driver.db['courses'].insert({'spots': 10}))
signup = {
'nethz': 'Something',
'course': course_id,
}
signup_response = app.client.post('/signups',
data=signup,
assert_status=201)
assert signup_response['status'] == 'reserved'
# Now pay
payment = {
'signups': [signup_response['_id']]
}
app.client.post('/payments',
data=payment,
assert_status=201)
# Check signup
updated_signup = app.client.get('/signups/' + signup_response['_id'],
assert_status=200)
assert updated_signup['status'] == 'accepted'
def test_zero_spots(app):
"""Settings spots to zero will just put everyone on the waiting list."""
with app.admin():
# Create fake courses to sign up to
course_id = str(app.data.driver.db['courses'].insert({'spots': 0}))
signup = {
'nethz': 'Something',
'course': course_id,
}
signup_response = app.client.post('/signups',
data=signup,
assert_status=201)
assert signup_response['status'] == 'waiting'
def test_not_enough_spots(app):
"""If there are not enough spots, signups go to waiting list."""
with app.admin():
# Create fake courses to sign up to
course_id = str(app.data.driver.db['courses'].insert({'spots': 1}))
first = {
'nethz': 'Something',
'course': course_id,
}
first_response = app.client.post('/signups',
data=first,
assert_status=201)
print(first_response['_updated'])
assert first_response['status'] == 'reserved'
second = {
'nethz': 'Otherthing',
'course': course_id,
}
second_response = app.client.post('/signups',
data=second,
assert_status=201)
print(second_response['_updated'])
assert second_response['status'] == 'waiting'
def test_update_spots(app):
"""Test the main update function.
As a key for sorting the _updated timestamp will be used, with
nethz as a tie breaker
"""
with app.admin():
# Create a course with two spots
course = app.data.driver.db['courses'].insert({'spots': 2})
# Create four signups on waiting list
# 1. Oldest _created timestamp, but recently modified (recent _updated)
first_data = {
'course': course,
'status': 'waiting',
'_created': dt(2020, 10, 10),
'_updated': dt(2020, 10, 20),
}
first_id = str(app.data.driver.db['signups'].insert(first_data))
# 3. oldest _updated
second_data = {
'course': course,
'status': 'waiting',
'_created': dt(2020, 10, 11),
'_updated': dt(2020, 10, 11),
}
second_id = str(app.data.driver.db['signups'].insert(second_data))
# 3. earlier _created, second oldest _updated
third_data = {
'course': course,
'status': 'waiting',
'_created': dt(2020, 10, 12),
'_updated': dt(2020, 10, 15),
'nethz': 'abc'
}
third_id = str(app.data.driver.db['signups'].insert(third_data))
# 4. Same updated time as 3, but different id that will loose tie
fourth_data = {
'course': course,
'status': 'waiting',
'_created': dt(2020, 10, 13),
'_updated': dt(2020, 10, 15),
'nethz': 'bcd'
}
fourth_id = str(app.data.driver.db['signups'].insert(fourth_data))
# Do the update!
# We except 2 and 3 to get spots
update_signups(str(course))
def status(_id):
return app.client.get('/signups/' + _id,
assert_status=200)['status']
assert status(first_id) == 'waiting'
assert status(second_id) == 'reserved'
assert status(third_id) == 'reserved'
assert status(fourth_id) == 'waiting'
@pytest.fixture
def course(app):
"""Create a fake course without any data for a test."""
with app.admin():
# Create a few courses to sign up to
db = app.data.driver.db['courses']
yield str(db.insert({'_etag': 'tag'}))
@pytest.fixture
def mock_update():
"""Mock the actual updating of spots for a test."""
with patch('signups.update_signups', return_value=[]) as update:
yield update
def test_post_signups_triggers_update(app, course, mock_update):
"""Test the the update of spots gets triggered correctly."""
data = {
'course': course,
'nethz': 'bli'
}
app.client.post('signups', data=data, assert_status=201)
mock_update.assert_called_with(course)
def test_batch_post_signups_triggers_update(app, course, mock_update):
"""Test the the update of spots gets triggered correctly."""
# We need a second course to test everything
other_course = str(app.data.driver.db['courses'].insert({}))
batch = [{
'course': course,
'nethz': 'bla'
}, {
'course': course,
'nethz': 'bli'
}, {
'course': other_course,
'nethz': 'bli'
}]
app.client.post('/signups', data=batch, assert_status=201)
# Same course doesn't get updated twice per request
mock_update.assert_has_calls([call(course), call(other_course)],
any_order=True)
def test_patch_signup_triggers_update(app, course, mock_update):
"""Test the the update of spots gets triggered correctly."""
fake = str(app.data.driver.db['signups'].insert({
'_etag': 'tag',
'nethz': 'lala',
}))
app.client.patch('/signups/' + fake,
data={'course': course},
headers={'If-Match': 'tag'},
assert_status=200)
mock_update.assert_called_with(course)
def test_delete_signup_triggers_update(app, course, mock_update):
"""Test the the update of spots gets triggered correctly."""
fake = str(app.data.driver.db['signups'].insert({
'course': course,
'_etag': 'tag'
}))
app.client.delete('/signups/' + fake,
headers={'If-Match': 'tag'},
assert_status=204)
mock_update.assert_called_with(course)
def test_patch_course_without_update(app, course, mock_update):
"""Update of spots gets only triggered if number of spots changes."""
app.client.patch('/courses/' + course,
data={'room': 'Something'},
headers={'If-Match': 'tag'},
assert_status=200)
mock_update.assert_not_called()
def test_patch_course_with_update(app, course, mock_update):
"""Update of spots gets only triggered if number of spots changes."""
app.client.patch('/courses/' + course,
data={'spots': '10'},
headers={'If-Match': 'tag'},
assert_status=200)
mock_update.assert_called_with(course)
def test_block_delete_ok(app, course):
"""If a course has no signups, it can be deleted."""
app.client.delete('/courses/' + course,
headers={'If-Match': 'tag'},
assert_status=204)
def test_block_delete_blocked(app, course):
"""If a course has signups, it cannot be deleted."""
str(app.data.driver.db['signups'].insert({'course': course}))
app.client.delete('/courses/' + course,
headers={'If-Match': 'tag'},
assert_status=409)
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment