FAQ | This is a LIVE service | Changelog

GitLab has been upgraded to 13.10.3. See what's new in the Changelog.

Commit 7eb209ba authored by Monty Dawson's avatar Monty Dawson

Merge branch '19-update-events' into 'master'

Add creation/update ops for events

Closes #19

See merge request !21
parents 9056684b 146700ca
Pipeline #63252 passed with stages
in 6 minutes and 53 seconds
......@@ -3,13 +3,15 @@ Education Space Scheduling and Modelling synchronisation tool
Usage:
essmsync (-h | --help)
essmsync [--configuration=FILE]... [--quiet] [--debug] [--really-do-this] [<operation>]...
essmsync [--configuration=FILE]... [--quiet] [--debug] [--http-debug] [--really-do-this]
[<operation>]...
Options:
-h, --help Show a brief usage summary.
--quiet Reduce logging verbosity.
--debug Log debugging information
--http-debug Log debugging information from http calls
-c, --configuration=FILE Specify configuration file to load. Multiple
files will be merged (latter taking precedence).
......@@ -41,7 +43,7 @@ def main():
)
# Debug logging (don't use in production - secrets are displayed)
if opts['--debug']:
if opts['--http-debug']:
# Also enable requests debug logging
http.client.HTTPConnection.debuglevel = 1
req_log = logging.getLogger('requests.packages.urllib3')
......
......@@ -8,6 +8,7 @@ from . import state
from . import utils
from .exceptions import BookerAPIError, ConfigurationInvalid
from . import termtime
from . import comparison
LOG = logging.getLogger(__name__)
......@@ -21,18 +22,31 @@ def booker_get(resource, **kwargs):
)
def booker_post(resource, form=False, **kwargs):
def booker_post(resource, with_auth=True, is_update=True, **kwargs):
if is_update and state.get('read_only', True):
return None
return utils.api_post(
state.get('booker.api.url') + resource,
exception=BookerAPIError,
headers=booker_headers(form),
headers=booker_headers(with_auth),
**kwargs
)
def booker_headers(form=False):
def booker_put(resource, **kwargs):
if state.get('read_only', True):
return None
return utils.api_put(
state.get('booker.api.url') + resource,
exception=BookerAPIError,
headers=booker_headers(),
**kwargs
)
def booker_headers(with_auth=True):
h = {'user-agent': state.USER_AGENT}
if not form:
if with_auth:
h['Authorization'] = 'Bearer ' + state.get('booker.api.access_token')
return h
......@@ -44,8 +58,10 @@ def auth():
if not state.get('booker.api.access_token'):
(token_resp, _) = booker_post(
'Token',
# Don't pass auth token as that's what we're trying to get
with_auth=False,
is_update=False,
# Token endpoint only takes form-data not json!
form=True,
data={
'grant_type': 'password',
'username': state.get('booker.api.username'),
......@@ -348,6 +364,44 @@ def op_get_events(start=None, end=None):
state.set('booker.events', bookings)
def op_update_events():
"""
Use results of comparison operation to send POST and PUT requests to
Booker API to create and update events
"""
# compare all room bookings if haven't already
if state.get('comparison.events') is None:
comparison.op_compare_events()
comp = state.get('comparison.events')
for booking in comp.get('create'):
code = booking['code']
room_id = booking['Room']['Id']
params = {k: v for k, v in booking.items() if k not in ['code', 'Room']}
LOG.info(f"Creating event '{code}':")
LOG.info(f" Start Time : '{params['StartTime']}'")
LOG.info(f" End Time : '{params['EndTime']}'")
LOG.info(f" Room Id/OI : '{room_id}'/'{params['RoomId']}'")
LOG.info(f" Building OI: '{params['BuildingId']}'")
LOG.info(f" Title : '{params['Title']}'")
booker_post('api/booker/booking', json=params)
for booking in comp.get('update'):
code = booking['code']
room_id = booking['Room']['Id']
params = {k: v for k, v in booking.items() if k not in ['code', 'Room']}
LOG.info(f"Updating event '{code}':")
LOG.info(f" Start Time : '{params['StartTime']}'")
LOG.info(f" End Time : '{params['EndTime']}'")
LOG.info(f" Room Id/OI : '{room_id}'/'{params['RoomId']}'")
LOG.info(f" Building OI: '{params['BuildingId']}'")
LOG.info(f" Title : '{params['Title']}'")
booker_put(f"api/booker/booking/{booking['OptimeIndex']}", json=params)
# Currently not deleting (aka cancelling) bookings in Booker
def _get_bookings(start=None, end=None, filter=None):
"""
Get all bookings from Booker between start and end dates (defaulting to
......@@ -372,6 +426,7 @@ def _get_bookings(start=None, end=None, filter=None):
room_ois = [r['OptimeIndex'] for r in state.get('booker.rooms')]
(bookings, _) = booker_post(
'api/booker/calendars/events',
is_update=False,
json={
'start': start,
'end': end,
......
......@@ -590,12 +590,11 @@ def op_compare_events():
new_event = {
**new_event_template,
**expected,
'code': code, # For logging when creating
'RoomId': room_oi, # Booker room OptimeIndex not actually Id
'BuildingId': building_oi_by_room_oi.get(room_oi),
'Description': expected_desc,
}
# Remove unnecessary 'Room' key (handled by 'RoomId')
del new_event['Room']
results['create'].append(new_event)
elif len(matched) > 1:
# Couldn't find a single match - not good
......@@ -623,11 +622,10 @@ def op_compare_events():
updated_event = {
**match,
**expected,
'code': code, # For logging when updating
'RoomId': room_oi, # Booker room OptimeIndex not actually Id
'BuildingId': building_oi_by_room_oi.get(room_oi),
}
# Remove unnecessary 'Room' key (handled by 'RoomId')
del updated_event['Room']
results['update'].append(updated_event)
# Find those in Booker but not TermTime
......
......@@ -7,6 +7,7 @@ import re
from . import state
from . import utils
from . import booker
from datetime import datetime, timezone
from secrets import token_hex
from .exceptions import TermTimeAPIError
......@@ -223,19 +224,27 @@ def op_get_events():
elif not isinstance(room_activities, list):
LOG.error('TermTime events result is not a list')
raise TermTimeAPIError()
# Only interested in activities in rooms we're syncing (i.e. those already
# filtered in booker.rooms) so get a list of ids to match against
if state.get('booker.rooms') is None:
booker.op_get_rooms()
room_ids = {r['Id'] for r in state.get('booker.rooms') if r.get('Id')}
events = []
# compile events from each room's activities
for r in room_activities:
# room data added to each event (only roomcode actually needed)
room_data = {k: r[k] for k in ['roomcode', 'roomname', 'buildingname']}
# Remove events with "Booking" activity type (these are roombookings)
# and not in past
acts = [
{**a, **room_data} for a in r['activities']
if a.get('type') != 'Booking'
and start_dt <= utils.termtime_parse_datetime(a.get('start'))
]
events += acts
if r.get('roomcode') in room_ids:
# room data added to each event (only roomcode actually needed)
room_data = {k: r[k] for k in ['roomcode', 'roomname', 'buildingname']}
# Remove events with "Booking" activity type (these are roombookings)
# and not in past
acts = [
{**a, **room_data} for a in r['activities']
if a.get('type') != 'Booking'
and start_dt <= utils.termtime_parse_datetime(a.get('start'))
]
events += acts
else:
LOG.warning(f"Ignoring activities in TermTime room '{r.get('roomcode')}'")
LOG.info(f'TermTime events: {len(events)}')
state.set('termtime.events', events)
......
......@@ -56,5 +56,21 @@
"size": 10
}
]
},
{
"roomcode": "B999-99-9999",
"roomname": "Ignored Room",
"buildingname": "Building Two",
"activities": [
{
"id": "Activity-90000",
"name": "Ignored Booking",
"start": "2021-04-01 12:00:00",
"end": "2021-04-01 13:00:00",
"type": "Lecture",
"instance": 1,
"size": 10
}
]
}
]
# Test Booker operations
import unittest
import urllib
from faker import Faker
from . import TestCase
from .. import state
from .. import booker
from .. import termtime
from .. import exceptions
......@@ -835,6 +837,207 @@ class BookerGetBookingsTests(TestCase):
self.assertNotEqual(b.get('Room', {}).get('Id', ''), IGNORE_ROOM)
class BookerUpdateTests(TestCase):
def setUp(self):
super().setUp()
# Catch request when POSTing and PUTting to termtime API
self.requests = {
'POST': [],
'PUT': [],
}
def booker_callback(request, context):
split_path = [urllib.parse.unquote(s) for s in request.path.split('/')]
request_info = {
'data': request.json(),
'path': split_path,
# generally the resource's 'OptimeIndex' is the last part of url path
'oi': split_path[-1],
}
self.requests[request.method].append(request_info)
# dummy 'successful' response
return '{}'
self.set_request_mock_func('POST', 'booker/api', 'booker/booking', booker_callback)
self.set_request_mock_func('PUT', 'booker/api', 'booker/booking/.+', booker_callback, True)
# Not in read only mode but default
state.set('read_only', False)
def assertDictIncludes(self, data, expected):
"""
Asserts that dict `data` contains all the key/value pairs of dict
`expected`, though it may contain more.
"""
self.assertIsInstance(data, dict)
self.assertIsInstance(expected, dict)
for k, v in expected.items():
self.assertIn(k, data)
self.assertEqual(data[k], v)
def test_update_events_all_matched(self):
"""
update_events operation with default fixtures will have nothing to
create or update as all bookings will be matched
"""
self.assertIsNone(state.get('comparison.events'))
booker.op_update_events()
self.assertEqual(len(self.requests['POST']), 0)
self.assertEqual(len(self.requests['PUT']), 0)
def test_update_events_create(self):
"""
update_events operation will send request to create an event if a new
one is added
"""
self.assertIsNone(state.get('termtime.events'))
self.assertIsNone(state.get('comparison.events'))
# Preload the TermTime events
termtime.op_get_events()
# Add a new event
NEW_EVENT = {
'roomcode': 'B001-01-0001',
'roomname': 'Teaching Room 1',
'buildingname': 'Building One',
'id': 'Activity-99999',
'name': 'New Test Booking',
'start': '2021-02-22 10:00:00',
'end': '2021-02-22 12:00:00',
'type': 'Lecture',
'instance': 1,
'size': 99,
}
state.get('termtime.events').append(NEW_EVENT)
booker.op_update_events()
# Addition was needed
self.assertEqual(len(state.get('comparison.events.create')), 1)
# Single POST request made
self.assertEqual(len(self.requests['POST']), 1)
self.assertEqual(len(self.requests['PUT']), 0)
EXPECTED_TO_INCLUDE = {
'Title': 'New Test Booking',
'RoomId': 1, # OptimeIndex of room B001-01-0001
'BuildingId': 1, # OptimeIndex of "Building One"
'StartTime': '2021-02-22T10:00:00Z',
'EndTime': '2021-02-22T12:00:00Z',
'ExpectedAttendees': 99,
'Type': 'Teaching',
'Status': 'Approved',
'BatchId': -1,
}
# The single POST matches new booking.
self.assertDictIncludes(self.requests['POST'][0]['data'], EXPECTED_TO_INCLUDE)
def test_update_events_update(self):
"""
update_events operation will send request to update an event if one is
modified
"""
self.assertIsNone(state.get('termtime.events'))
self.assertIsNone(state.get('comparison.events'))
# Preload the TermTime events
termtime.op_get_events()
# Update Lent event's name, room and time
ACTIVITY_ID = 'Activity-10000'
INSTANCE = 2
UPDATES = {
'name': 'Rescheduled event',
'roomcode': 'B002-00-0001',
'start': '2021-02-01 15:00:00',
'end': '2021-02-01 16:00:00',
}
updated = 0
for d in state.get('termtime.events'):
# Find the event to update
if d.get('id') == ACTIVITY_ID and d.get('instance') == INSTANCE:
updated += 1
for k, v in UPDATES.items():
# Check the update changes the current value and change it
self.assertNotEqual(d.get(k), v)
d[k] = v
self.assertEqual(updated, 1)
booker.op_update_events()
# Single PUT request made
self.assertEqual(len(self.requests['POST']), 0)
self.assertEqual(len(self.requests['PUT']), 1)
# Last part of url contains Lent event's OptimeIndex
put = self.requests['PUT'][0]
self.assertEqual(put['oi'], '1012')
# and data contains expected changes
EXPECTED_TO_INCLUDE = {
'Title': 'Rescheduled event',
'RoomId': 3, # OptimeIndex of room 'B002-00-0001'
'StartTime': '2021-02-01T15:00:00Z',
'EndTime': '2021-02-01T16:00:00Z',
}
self.assertDictIncludes(put['data'], EXPECTED_TO_INCLUDE)
def test_update_events_read_only(self):
"""
update_events operation will *not* send requests to create or update
events if in read only mode
"""
state.set('read_only', True)
self.assertIsNone(state.get('termtime.events'))
self.assertIsNone(state.get('comparison.events'))
# Preload the TermTime events
termtime.op_get_events()
# Add a new event
s = state.get('termtime.events')
s.append({
'roomcode': 'B001-01-0001',
'roomname': 'Teaching Room 1',
'buildingname': 'Building One',
'id': 'Activity-99999',
'name': 'New Test Booking',
'start': '2021-02-22 10:00:00',
'end': '2021-02-22 12:00:00',
'type': 'Lecture',
'instance': 1,
'size': 99,
})
# Update one
for d in s:
if d.get('id') == 'Activity-10000' and d.get('instance') == 1:
self.assertEqual(d['name'], 'Booking made by essm-sync (Michaelmas)')
d['name'] = 'Updated Event Title'
booker.op_update_events()
# Addition and update was needed
self.assertEqual(len(state.get('comparison.events.create')), 1)
self.assertEqual(len(state.get('comparison.events.update')), 1)
# No requests made
self.assertEqual(len(self.requests['POST']), 0)
self.assertEqual(len(self.requests['PUT']), 0)
class ShouldIgnoreBookingsFactoryTest(unittest.TestCase):
""" Tests for `booker.should_ignore_bookings_factory` """
......
......@@ -1202,8 +1202,6 @@ class CompareEventsTests(ComparisonTests):
self.assertEqual(s[0]['Description'], f'Booked by TermTime [{ID}:{INSTANCE}]')
self.assertEqual(s[0]['RoomId'], NEW_ROOM_OI)
self.assertEqual(s[0]['BuildingId'], NEW_BUILDING_OI)
# Room is not needed as RoomId is set
self.assertIsNone(s[0].get('Room'))
def test_compare_events_create_needed(self):
"""
......@@ -1237,12 +1235,16 @@ class CompareEventsTests(ComparisonTests):
'Services_Layout': -1,
'Title': 'Newly Created Event',
'RoomId': 4, # OptimeIndex for Seminar Room B
'Room': {
'Id': 'B201-03-0011',
},
'BuildingId': 3, # OptimeIndex for Secondary Building
'StartTime': '2021-04-05T09:00:00Z', # Hour shift for DST to UTC
'EndTime': '2021-04-05T10:30:00Z',
'Type': 'Teaching',
'ExpectedAttendees': 10,
'Description': 'Booked by TermTime [Activity-99999:1]',
'code': 'Activity-99999:1',
}
# Add an event
state.get('termtime.events').append(NEW_TERMTIME_EVENT)
......
......@@ -250,7 +250,8 @@ class TermTimeGetTests(TestCase):
"""
events = self._assert_non_empty_list('events', termtime.op_get_events)
# Fixture (for All Year) has 3 events (1 room booking ignored)
# Fixture (for All Year) has 3 events
# (1 whole room ignored and 1 room booking in valid room ignored)
self.assertEqual(len(events), 3)
self.assertEqual(0, len([e for e in events if e.get('type') == 'Booking']))
......
......@@ -45,7 +45,7 @@ def api_via_method(method_func, url, exception=APIError, retries=2, sleep=5, **k
try:
try:
r = method_func(url, **kwargs)
LOG.debug(f'Response [{r.status_code}]: {r.text}')
LOG.debug(f'Response [{r.status_code}]: {r.text[:500]}')
r.raise_for_status()
return (r.json(), r)
# Exceptions that can be retried
......
......@@ -129,6 +129,12 @@ Events starting before the current time (or state `now`) and those matching any
of the filters in `booker.ignore_bookings` (see `configuration-example.yaml`) are
also excluded.
#### update_events
Use the results of the appropriate comparison operation (see below) to send
requests to create and update events.
> Requests only sent if using `--really-do-this` command line argument
## TermTime
#### get_buildings
......
Markdown is supported
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