Commit 8e2e8d7f authored by adietmue's avatar adietmue
Browse files

Resources: Assistants are now a list in lectures, added selections and payments

parent e2c1f2f2
......@@ -27,7 +27,7 @@ import json
import requests
from eve.auth import TokenAuth
from eve.io.mongo import Validator
from flask import request, g, current_app
from flask import request, g, current_app, abort
# Requests to AMIVAPI
......@@ -112,16 +112,26 @@ class APIAuth(TokenAuth):
Furthermore, grant admin rights if the user is member of the
admin group.
By default, Eve only returns 401, we refine this a little:
- 401 if token missing (eve default already) or not found in AMIVAPI
- 403 if not permitted
"""
g.token = token # Safe in g such that other methods can use it
# Valid Login always required
# Return 401 if token not recognized by AMIVAPI
if get_user() is None:
return False
# Read always allowed
# Write any resource but 'signups': you need to be an admin
return method == 'GET' or (resource == 'signups') or is_admin()
# 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()):
return True
else:
abort(403)
# Dynamic Filter
......
......@@ -2,6 +2,9 @@
Check out [the Eve docs for configuration](http://python-eve.org/config.html)
if you are unsure about some of the settings.
Several validation rules are still missing, they are marked with TODO in the
schema directly.
"""
from os import environ
......@@ -28,7 +31,7 @@ DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
# A schema for required start/end time tuple
TIMESPAN = {
TIMESPAN_SCHEMA = {
'type': 'dict',
'schema': {
'start': {
......@@ -45,6 +48,10 @@ TIMESPAN = {
}
# Same as Eve, but include 403
STANDARD_ERRORS = [400, 401, 403, 404, 405, 406, 409, 410, 412, 422, 428]
# Resources
DOMAIN = {
'lectures': {
......@@ -69,25 +76,21 @@ DOMAIN = {
'max': 3,
'required': True
},
'assistants': {
# List of nethz of assistants
'type': 'list',
'schema': {
'type': 'string',
'maxlength': 10,
'empty': False,
'nullable': False,
}
# TODO: Not the same nethz twice
# TODO: nethz is enough?
}
},
},
'assistants': {
'schema': {
'nethz': {
'type': 'string',
'maxlength': 10,
'unique': True,
'empty': False,
'nullable': False,
'required': True,
},
'name': {
'type': 'string',
'readonly': True,
},
},
},
'courses': {
'schema': {
'lecture': {
......@@ -100,19 +103,16 @@ DOMAIN = {
'not_patchable': True, # Course is tied to lecture
},
'assistant': {
'type': 'objectid',
'data_relation': {
'resource': 'assistants',
'field': '_id',
'embeddable': True
},
'type': 'string'
# TODO: Assistant needs to exist for lecture
},
'signup': TIMESPAN,
'signup': TIMESPAN_SCHEMA,
'datetimes': {
'type': 'list',
'schema': TIMESPAN,
'schema': TIMESPAN_SCHEMA,
# TODO: Timeslots must not overlap
},
'room': {
'type': 'string',
......@@ -121,6 +121,7 @@ DOMAIN = {
'required': True,
'nullable': False,
'empty': False,
# TODO: Room must be empty for time slot
},
'spots': {
'type': 'integer',
......@@ -132,6 +133,8 @@ DOMAIN = {
},
'signups': {
# Signup for a user to a course
'schema': {
'nethz': {
'type': 'string',
......@@ -150,12 +153,65 @@ DOMAIN = {
'embeddable': True
},
'unique_combination': ['nethz'],
# TODO: No overlapping courses
},
'status': {
'type': 'string',
'allowed': ['waiting', 'accepted', 'accepted+paid'],
'allowed': ['waiting', 'reserved', 'accepted'],
'readonly': True,
},
},
},
'selections': {
# Easy way for users to safe their selections before signup is open
# List of selected courses per user
'schema': {
'nethz': {
'type': 'string',
'maxlength': 10,
'empty': False,
'nullable': False,
'required': True,
'only_own_nethz': True,
'unique': True,
},
'courses': {
'type': 'list',
'schema': {
'type': 'objectid',
'data_relation': {
'resource': 'courses',
'field': '_id',
'embeddable': True
},
# TODO: No duplicate entries
# TODO: No entries that are already reserved
},
},
},
},
'payments': {
# Dummy endpoint for payments.
# TODO: Implement as soon as PSP is known.
'schema': {
'signups': {
'type': 'list',
'schema': {
'type': 'objectid',
'data_relation': {
'resource': 'signups',
'field': '_id',
'embeddable': True
},
# TODO: No duplicate entries
},
'required': True,
'nullable': False,
}
}
}
}
......@@ -10,9 +10,11 @@ to allow easier assertion of status_codes and make json handling easier.
"""
import json
from contextlib import contextmanager
import pytest
from flask import g
from flask.testing import FlaskClient
from app import create_app
......@@ -37,6 +39,15 @@ class TestClient(FlaskClient):
# Set header
kwargs['content_type'] = "application/json"
# Add Fake Header if specified in context
try:
auth = g.fake_token
kwargs.setdefault('headers', {})['Authorization'] = auth
except (RuntimeError, AttributeError):
# RuntimeError: No g, AttributeError: key in g is missing
pass # No fake token to set
# Send the actual request
response = super(TestClient, self).open(*args, **kwargs)
if assert_status is not None:
......@@ -54,6 +65,30 @@ def drop_database(application):
database.drop_collection(collection)
@contextmanager
def user(self, **kwargs):
"""Additional context to fake a user."""
with self.test_request_context():
g.user = 'Not None :)'
g.admin = False
# The test requests will use this header
g.fake_token = 'Token Trolololo'
# Allow to overwrite settings
for key, value in kwargs.items():
setattr(g, key, value)
yield
@contextmanager
def admin(self, **kwargs):
"""Additional context to fake a user."""
with self.user(**kwargs):
g.admin = True
yield
@pytest.fixture
def app():
......@@ -61,5 +96,10 @@ def app():
application = create_app(settings=TEST_SETTINGS)
application.test_client_class = TestClient
application.client = application.test_client()
# Using __get__ binds the function to the application instance
application.user = user.__get__(application)
application.admin = admin.__get__(application)
yield application
drop_database(application)
"""Tests for basic requests to all resources as admin."""
from flask import g
def test_create(app):
"""Test creating everything as an admin user."""
with app.test_request_context():
# Fake a admin user
g.user = 'Not None :)'
g.admin = True
faketoken = {'Authorization': 'Token Trolololo'}
with app.admin():
lecture = {
'title': "Awesome Lecture",
'department': "itet",
'year': 3,
'assistants': ['pablo', 'pablone'],
}
lecture_response = app.client.post('lectures',
data=lecture,
headers=faketoken,
assert_status=201)
assistant = {'nethz': "Pablo"}
assistant_response = app.client.post('assistants',
data=assistant,
headers=faketoken,
assert_status=201)
course = {
'lecture': lecture_response['_id'],
'assistant': assistant_response['_id'],
'assistant': 'pablo',
'room': 'ETZ E 6',
'spots': 30,
'signup': {
......@@ -46,26 +33,29 @@ def test_create(app):
}
course_response = app.client.post('courses',
data=course,
headers=faketoken,
assert_status=201)
selection = {
'nethz': 'Pablito',
'courses': [course_response['_id']]
}
app.client.post('selections', data=selection, assert_status=201)
signup = {
'nethz': "Pablito",
'course': course_response['_id']
}
app.client.post('signups',
data=signup,
headers=faketoken,
assert_status=201)
signup_response = app.client.post('signups',
data=signup,
assert_status=201)
payment = {'signups': [signup_response['_id']]}
app.client.post('payments', data=payment, assert_status=201)
def test_no_double_signup(app):
"""Users can signup for several courses, but not for any course twice."""
with app.test_request_context():
# Fake a admin user
g.user = 'Not None :)'
g.admin = True
faketoken = {'Authorization': 'Token Trolololo'}
with app.admin():
# Create two fake courses to sign up to
first = str(app.data.driver.db['courses'].insert({}))
second = str(app.data.driver.db['courses'].insert({}))
......@@ -77,7 +67,6 @@ def test_no_double_signup(app):
}
app.client.post('signups',
data=signup,
headers=faketoken,
assert_status=assert_status)
# No Double signup to same course
......@@ -87,18 +76,14 @@ def test_no_double_signup(app):
_signup(second, 201)
def test_no_patch(app):
"""Test that certain fields cannot be changed.
These are: Course->lecture and signup->nethz
"""
no_patch_error = "this field can not be changed with PATCH"
with app.test_request_context():
# Fake a admin user
g.user = 'Not None :)'
g.admin = True
headers = {'Authorization': 'Token Trolololo', 'If-Match': 'tag'}
with app.admin():
headers = {'If-Match': 'tag'}
# Create fake resources, make sure to set _etag so we can patch
course = str(app.data.driver.db['courses'].insert({'_etag': 'tag'}))
......
......@@ -5,16 +5,18 @@ import pytest
from flask import g
@pytest.mark.parametrize('resource',
['lectures', 'assistants', 'courses', 'signups'])
ALL_RESOURCES = ['lectures', 'courses', 'signups', 'selections', 'payments']
ADMIN_RESOURCES = ['lectures', 'courses'] # only admin can write
@pytest.mark.parametrize('resource', ALL_RESOURCES)
@pytest.mark.parametrize('method', ['get', 'post'])
def test_auth_required_for_resource(app, resource, method):
"""Without auth header, we get get 401 for all methods."""
getattr(app.client, method)('/' + resource, data={}, assert_status=401)
@pytest.mark.parametrize('resource',
['lectures', 'assistants', 'courses', 'signups'])
@pytest.mark.parametrize('resource', ALL_RESOURCES)
@pytest.mark.parametrize('method', ['get', 'patch', 'delete'])
def test_auth_required_for_item(app, resource, method):
"""Without auth provided, we can access any item either."""
......@@ -27,7 +29,7 @@ def test_auth_required_for_item(app, resource, method):
assert_status=401)
@pytest.mark.parametrize('resource', ['lectures', 'assistants', 'courses'])
@pytest.mark.parametrize('resource', ADMIN_RESOURCES)
def test_user_can_read(app, resource):
"""Users should be able to to GET requests on resource and item.
......@@ -49,43 +51,30 @@ def test_user_can_read(app, resource):
assert_status=200)
@pytest.mark.parametrize('resource', ['lectures', 'assistants', 'courses'])
@pytest.mark.parametrize('resource', ADMIN_RESOURCES)
def test_user_cannot_write(app, resource):
"""Users cannot create, modify or delete."""
with app.test_request_context():
# Fake a user, specify non-admin
g.user = 'Not None :)'
g.admin = False
faketoken = {'Authorization': 'Token Trolololo'}
with app.user():
data = {}
# Post something
# Try to post something
app.client.post('/' + resource,
data=data,
headers=faketoken,
assert_status=401)
assert_status=403)
# Create fake item, try to patch/delete it
_id = app.data.driver.db[resource].insert({})
app.client.patch('/%s/%s' % (resource, _id),
data=data,
headers=faketoken,
assert_status=401)
assert_status=403)
app.client.delete('/%s/%s' % (resource, _id),
headers=faketoken,
assert_status=401)
assert_status=403)
def test_signup_with_own_nethz_only(app):
"""Users can only post singups with their own nethz."""
with app.test_request_context():
nethz = 'Something'
# Fake a user, specify non-admin
g.user = 'Not None :)'
g.nethz = nethz
g.admin = False
faketoken = {'Authorization': 'Token Trolololo'}
nethz = 'Something'
with app.user(nethz=nethz):
# Create fake course to sign up to
course = str(app.data.driver.db['courses'].insert({}))
......@@ -96,7 +85,6 @@ def test_signup_with_own_nethz_only(app):
}
app.client.post('/signups',
data=bad_signup,
headers=faketoken,
assert_status=422)
# Try with own nethz
......@@ -106,69 +94,87 @@ def test_signup_with_own_nethz_only(app):
}
app.client.post('/signups',
data=good_signup,
headers=faketoken,
assert_status=201)
def test_selection_for_own_nethz_only(app):
"""Users can only select courses for themselves."""
nethz = 'Something'
with app.user(nethz=nethz):
# Create fake course to select
course = str(app.data.driver.db['courses'].insert({}))
# Try with other nethz
bad_selection = {
'nethz': 'Notthenethz',
'courses': [course],
}
app.client.post('/selections',
data=bad_selection,
assert_status=422)
# Try with own nethz
good_selection = {
'nethz': nethz,
'courses': [course],
}
app.client.post('/selections',
data=good_selection,
assert_status=201)
def test_user_signup_visibility(app):
"""Test that we a user cannot see others' signups."""
with app.test_request_context():
# Fake a user, specify non-admin
g.user = 'Not None :)'
g.nethz = 'Something'
g.admin = False
token = {'Authorization': 'Token FakeeTrolololo', 'If-Match': 'Wrong'}
nethz = 'Something'
with app.user(nethz=nethz):
# Create fake signup with different nethz
own = str(app.data.driver.db['signups'].insert({'nethz': g.nethz}))
own = str(app.data.driver.db['signups'].insert({'nethz': nethz}))
other = str(app.data.driver.db['signups'].insert({'nethz': 'trolo'}))
# Resource: Can only see own, not both signups
response = app.client.get('/signups', headers=token, assert_status=200)
response = app.client.get('/signups', assert_status=200)
assert len(response['_items']) == 1
assert response['_items'][0]['nethz'] == g.nethz
assert response['_items'][0]['nethz'] == nethz
# Items
own_url = '/signups/' + own
other_url = '/signups/' + other
# Get
app.client.get(own_url, headers=token, assert_status=200)
app.client.get(other_url, headers=token, assert_status=404)
app.client.get(own_url, assert_status=200)
app.client.get(other_url, assert_status=404)
# Patch (if we can see item, we get 412 since etag is wrong)
app.client.patch(own_url, headers=token, data={}, assert_status=412)
app.client.patch(other_url, headers=token, data={}, assert_status=404)
# Patch (if we can see item, we get 428 since etag is missing)
app.client.patch(own_url, data={}, assert_status=428)
app.client.patch(other_url, data={}, assert_status=404)
# Delete (etag missing again)
app.client.delete(own_url, headers=token, assert_status=412)
app.client.delete(other_url, headers=token, assert_status=404)
app.client.delete(own_url, assert_status=428)
app.client.delete(other_url, assert_status=404)
def test_admin_signup_visibility(app):
"""Test that we an admin can see others' signups."""
with app.test_request_context():
# Fake a user, specify admin
g.user = 'Not None :)'
g.nethz = 'Nothing special really'
g.admin = True
token = {'Authorization': 'Token FakeeTrolololo', 'If-Match': 'Wrong'}
with app.admin():
headers = {'If-Match': 'Wrong'}
# Create fake signup with different nethz
other = str(app.data.driver.db['signups'].insert({'nethz': 'trolo'}))
# Resource: Can see signups
response = app.client.get('/signups', headers=token, assert_status=200)
response = app.client.get('/signups',
headers=headers,
assert_status=200)
assert len(response['_items']) == 1
# Items
url = '/signups/' + other
# Get
app.client.get(url, headers=token, assert_status=200)
app.client.get(url, headers=headers, assert_status=200)
# Patch (if we can see item, we get 412 since etag is wrong)
app.client.patch(url, headers=token, data={}, assert_status=412)
app.client.patch(url, headers=headers, data={}, assert_status=412)
# Delete (etag missing again)
app.client.delete(url, headers=token, assert_status=412)
app.client.delete(url, headers=headers, assert_status=412)
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