diff --git a/bauble/model/lock.py b/bauble/model/lock.py new file mode 100644 index 0000000..0da4c57 --- /dev/null +++ b/bauble/model/lock.py @@ -0,0 +1,46 @@ +from datetime import datetime, timedelta + +from sqlalchemy import * +from sqlalchemy.orm import * +from sqlalchemy.orm.session import object_session +from sqlalchemy.exc import DBAPIError +from sqlalchemy.ext.associationproxy import association_proxy + +import bauble +import bauble.db as db +from bauble.model.user import User +import bauble.types as types +import bauble.search as search + +def default_expiration(): + return datetime.utcnow() + timedelta(days=90) + + +def get_lock(resource, session): + lock = session.query(Lock).filter_by(resource).first() + return lock if lock else None + + +class Lock(db.Base): + __tablename__ = 'lock' + + resource = Column(String(32), nullable=False, index=True) + + date_created = Column(types.DateTime, nullable=False, default=func.now()) + + # a future datetime when this lock automatically expires + date_expires = Column(types.DateTime, nullable=False, default=default_expiration) + + # a past datetime when this lock was released, either expired or delete + date_released = Column(types.DateTime) + + # this should be a + user_id = Column(Integer, ForeignKey(User.id, schema="public"), nullable=False) + user = relationship(User, backref="locks") + + def json(self, depth=1): + d = dict(resource=self.resource) + if(depth>0): + d['date_created'] = str(self.date_created) + d['data_released'] = str(self.date_released) if self.date_released else "" + d['user'] = self.user.json(depth=depth - 1) diff --git a/bauble/server/resource.py b/bauble/server/resource.py index 5d7b344..0a35cc8 100644 --- a/bauble/server/resource.py +++ b/bauble/server/resource.py @@ -24,6 +24,7 @@ from bauble.model.organization import Organization from bauble.model.user import User from bauble.model.reportdef import ReportDef +from bauble.model.lock import Lock, get_lock from bauble.server import app, API_ROOT, parse_accept_header, JSON_MIMETYPE, \ TEXT_MIMETYPE, parse_auth_header, accept import bauble.types as types @@ -65,6 +66,12 @@ def __init__(self): "DELETE": self.delete }) + self.add_route(API_ROOT + self.resource + "//lock", + {"OPTIONS": self.options_response, + "POST": self.lock, + "DELETE": self.delete_lock, + }) + self.add_route(API_ROOT + self.resource + '/count', {"GET": self.count, "OPTIONS": self.options_response, @@ -80,16 +87,19 @@ def __init__(self): {"OPTIONS": self.options_response, "GET": self.get_schema }) + self.add_route(API_ROOT + self.resource + "//schema", {"OPTIONS": self.options_response, 'GET': self.get_schema }) + self.add_route(API_ROOT + self.resource + "//", {"OPTIONS": self.options_response, "GET": self.get_relation }) + session_events = [] @@ -431,6 +441,14 @@ def save_or_update(self, resource_id=None, depth=1): # if this is a PUT to a specific ID then get the existing family # else we'll create a new one if request.method == 'PUT' and resource_id is not None: + # first check if the resource is locked + resource = request.path[len(API_ROOT):] + lock = get_lock(resource) + if lock: + response.status_code = 423 # locked + return lock.json(depth=1) + + # create the new instance instance = session.query(self.mapped_class).get(resource_id) for key in data.keys(): setattr(instance, key, data[key]) @@ -457,6 +475,58 @@ def save_or_update(self, resource_id=None, depth=1): session.close() + def lock(self, resource_id): + """ + """ + session = None + try: + session = self.connect() + username, password = parse_auth_header() + user = session.query(User).filter_by(username=username).one() + + # check if a lock already exists for this resource + resource = request.path[len(API_ROOT):-len("/lock")] + is_locked = session.query(Lock).\ + filter(Lock.resource==resource, Lock.user_id==user.id, + Lock.date_released is not None).count() > 0 + if is_locked: + bottle.abort(423, 'Resource is already locked') + + # lock the resource + lock = Lock(resource=resource, user_id=user.id) + session.add(lock) + session.commit() + response.status = 201 + return lock.json(depth=1) + finally: + if session: + session.close() + + + def delete_lock(self, resource_id): + """ + """ + session = None + try: + session = self.connect() + username, password = parse_auth_header() + user = session.query(User).filter_by(username=username).one() + + # check if this user has a lock on the resource + resource = request.path[len(API_ROOT):-len("/lock")] + locks = session.query(Lock).filter_by(user_id=user.id, resource=resource) + if locks.count() < 1: + bottle.abort(410, "Resource not locked by user") + + # delete all the locks the user has on this resource + map(session.delete, locks.all()) + session.commit() + finally: + if session: + session.close() + + + @staticmethod def get_ref_id(ref): # assume that if ref is not a str then it is a resource JSON object diff --git a/test/api/test_lock.py b/test/api/test_lock.py new file mode 100644 index 0000000..ef30c94 --- /dev/null +++ b/test/api/test_lock.py @@ -0,0 +1,25 @@ + +import json + +import requests + +import test.api as test +import bauble.db as db +from bauble.model.family import Family, FamilySynonym, FamilyNote + +def test_lock(): + # create a family family + family = test.create_resource('/family', {'family': test.get_random_name()}) + url = test.api_root + family['ref'] + "/lock" + response = requests.post(url, auth=(test.default_user, test.default_password)) + assert response.status_code == 201 + + # # get the lock description + # response = requests.get(url, auth=(test.default_user, test.default_password)) + # assert response.status_code == 200 + + # delete the lock + response = requests.delete(url, auth=(test.default_user, test.default_password)) + assert response.status_code == 200 + + # TODO: test that other users can't delete lock