passerelle/passerelle/contrib/planitech/models.py

620 lines
22 KiB
Python

# passerelle - uniform access to multiple data sources and services
# Copyright (C) 2018 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from datetime import date, datetime, time, timedelta
import hashlib
import json
import re
import urlparse
import uuid
from django.core.cache import cache
from django.db import models, transaction
from django.utils import dateformat
from django.utils import dateparse
from django.utils.translation import ugettext_lazy as _
from jsonfield import JSONField
from requests.exceptions import RequestException
from passerelle.base.models import BaseResource
from passerelle.contrib.planitech import mste
from passerelle.utils.api import endpoint
from passerelle.utils.jsonresponse import APIError
CREATE_RESERVATION_SCHEMA = {
"$schema": "http://json-schema.org/draft-03/schema#",
"title": "Planitech createreservation",
"description": "",
"type": "object",
"properties": {
"date": {
"description": "Date",
"type": "string",
"required": True
},
"start_time": {
"description": "Start time",
"type": "string",
"required": True
},
"end_time": {
"description": "End time",
"type": "string",
"required": True
},
"place_id": {
"description": "Place identifier",
"type": "number",
"required": True
},
"price": {
"description": "Price",
"type": "number",
"required": True
},
"name_id": {
"description": "Publik user nameID",
"type": "string",
"required": True
},
"first_name": {
"description": "First name",
"type": "string",
"required": True
},
"last_name": {
"description": "Last name",
"type": "string",
"required": True
},
"email": {
"description": "Email",
"type": "string",
"required": True
},
"activity_id": {
"description": "Activity identifier",
"type": "number",
"required": True
},
"object": {
"description": "Object",
"type": "string",
"required": True
},
"type_id": {
"description": "Rerservation type identifier",
"type": "number",
"required": True
},
"vat_rate": {
"description": "VAT rate",
"type": "number",
"required": True
}
}
}
RESERVATION_STATUS = {
"confirmed": 3, "invalid": 0, " pre-reservation": 1, "standard": 2
}
UPDATE_RESERVATION_SCHEMA = {
"$schema": "http://json-schema.org/draft-03/schema#",
"title": "Planitech updatereservation",
"description": "",
"type": "object",
"properties": {
"reservation_id": {
"description": "Reservation Identifier",
"type": "number",
"required": True
},
"status": {
"description": "Status of the reservation",
"type": "string",
"required": True,
"enum": list(RESERVATION_STATUS.keys())
}
}
}
def parse_date(date_str):
date_obj = dateparse.parse_date(date_str)
if date_obj is None:
raise APIError("Invalid date format: %s" % date_str)
return date_obj
def parse_time(time_str):
timeobj = dateparse.parse_time(time_str)
if timeobj is None:
raise APIError("Invalid time format: %s" % time_str)
return timeobj
def compute_hash(content, hardness, salt):
sha = hashlib.new('sha512', salt + content)
for idx in range(hardness):
sha = hashlib.new('sha512', sha.digest())
return sha.hexdigest().upper()
def date_to_datetime(date_str):
date_obj = parse_date(date_str)
if date_obj is None:
raise APIError("Invalid date string: %s" % date_str)
return datetime.combine(date_obj, time(hour=12))
def get_duration(start_time_str, end_time_str):
start_time = parse_time(start_time_str)
end_time = parse_time(end_time_str)
return time_to_minutes(end_time) - time_to_minutes(start_time)
def get_salt(salt):
return re.match(r'<(.*?)>', salt).groups()[0]
def combine_date_time(date_p, time_str):
if not isinstance(date_p, date):
date_p = parse_date(date_p)
time_obj = parse_time(time_str)
return datetime.combine(date_p, time_obj)
def time_to_minutes(timeobj):
return float(timeobj.hour * 60 + timeobj.minute)
class PlanitechConnector(BaseResource):
url = models.URLField(
max_length=400, verbose_name=_('Planitech API endpoint'),
help_text=_('URL of the Planitech API endpoint'))
username = models.CharField(max_length=128, verbose_name=_('Service username'))
password = models.CharField(
max_length=128, verbose_name=_('Service password'), null=True, blank=True)
verify_cert = models.BooleanField(
default=True, verbose_name=_('Check HTTPS Certificate validity'))
custom_fields = JSONField(_('Custom places fields'), blank=True, null=True)
category = _('Business Process Connectors')
class Meta:
verbose_name = _('Planitech')
def _call_planitech(self, session_meth, endpoint, params=None):
if not getattr(self, '_planitech_session', False):
self._login()
self._planitech_session = True
kwargs = {}
if params is not None:
kwargs['data'] = json.dumps(mste.encode(params))
response = session_meth(urlparse.urljoin(self.url, endpoint), **kwargs)
if response.status_code != 200:
error_msg = "Planitech error %s" % response.status_code
try:
data = mste.decode(response.json())
if hasattr(data, 'get'):
error = data.get('errors')
if error:
error_msg += " - %s" % error
except TypeError:
pass
raise APIError(error_msg)
return mste.decode(response.json())
def _get_places_by_capacity(self, min_capacity, max_capacity):
places = self._get_places_referential()
min_capacity = int(min_capacity)
max_capacity = int(max_capacity)
return [place['identifier'] for place in places.values()
if (min_capacity <= place['capacity'] <= max_capacity)
and place['capacity']]
def _get_places_referential(self, refresh_cache=False):
cache_key = 'planitech-%s-places' % self.id
ref = cache.get(cache_key)
if ref is None or refresh_cache:
data = self._call_planitech(self.requests.get, 'getPlacesList')
ref = {}
for place in data['placesList']:
place_id = int(place['identifier'])
ref[place_id] = {
'identifier': place_id, 'label': place['label']
}
extensionAttributes = {
'capacity': {
'name': 'TOTCAP',
'type': 'int'
}
}
for custom_field in self.custom_fields:
field_name = custom_field['name']
extensionAttributes[field_name] = {
'name': field_name,
'type': custom_field['type']
}
data = self._call_planitech(
self.requests.post, 'getPlacesInfo',
{
"placeIdentifiers": [float(key) for key in ref.keys()],
"extensionAttributes": extensionAttributes
}
)
for place in data['requestedPlaces']:
place_id = int(place['identifier'])
ref[place_id]['street_number'] = place.get('streetNumber')
ref[place_id]['address'] = place.get('address1')
ref[place_id]['city'] = place.get('city')
ref[place_id]['zipcode'] = place.get('zipCode')
# Custom attributes
for attr_name, attr_def in extensionAttributes.items():
attr_value = place.get(attr_name)
if attr_value is not None and attr_def['type'] == 'int':
attr_value = int(attr_value)
ref[place_id][attr_name] = attr_value
cache.set(cache_key, ref)
return ref
def _filter_places_referential(self, places_ref, ref_filters):
# Strip filter name from their prefix
filters = {}
for p_name, p_value in ref_filters.items():
if p_name.startswith('referential_'):
p_name = p_name.replace('referential_', '')
filters[p_name] = p_value
# Filter on custom attributes
if filters:
res = {}
for place_id, place in places_ref.items():
for filter_name, filter_value in filters.items():
for field in self.custom_fields:
if filter_name == field['name']:
if field['type'] == 'int':
filter_value = int(filter_value)
if place.get(filter_name) == filter_value:
res[place_id] = place
places_ref = res
return places_ref
def _login(self):
try:
auth_url = urlparse.urljoin(self.url, 'auth')
response = self.requests.get(auth_url, headers={'MH-LOGIN': self.username})
response.raise_for_status()
part1, salt1, part2, salt2, _ = re.split(r'(\<.*?\>)', response.content)
hardness1 = int(part1.split(':')[1])
hardness2 = int(part2.split(':')[1])
salt1 = get_salt(salt1)
salt2 = get_salt(salt2)
tmp_hash = compute_hash(self.password, hardness1, salt1)
hash_pass = compute_hash(tmp_hash, hardness2, salt2)
response = self.requests.get(auth_url, headers={'MH-PASSWORD': hash_pass})
response.raise_for_status()
# the last response should have set a cookie which will be used for authentication
except RequestException as e:
raise APIError("Authentication to Planitech failed: %s" % str(e))
@endpoint(
perm='can_access',
post={
'description': _('Create reservation'),
'request_body': {
'schema': {
'application/json': CREATE_RESERVATION_SCHEMA
}
}
}
)
def createreservation(self, request, post_data):
start_datetime = combine_date_time(post_data['date'], post_data['start_time'])
end_datetime = combine_date_time(post_data['date'], post_data['end_time'])
request_date = datetime.now()
with transaction.atomic():
pairing, created = Pairing.objects.get_or_create(
resource=self, name_id=post_data['name_id'],
defaults={'external_id': uuid.uuid4().get_hex()})
if created:
params = {
"externalUserIdentifier": pairing.external_id,
"name": post_data['last_name'],
"firstName": post_data['first_name'],
"mail": post_data['email']
}
data = self._call_planitech(self.requests.post, 'createPerson', params)
if data.get('creationStatus') != 'OK':
raise APIError("Person creation failed: %s" % data.get('creationStatus'))
params = {
"activityID": mste.Uint32(post_data['activity_id']),
"contractorExternalIdentifier": pairing.external_id,
"end": end_datetime,
"isWeekly": False,
"object": post_data['object'],
"places": [float(post_data['place_id'])],
"price": mste.Uint32(post_data['price']),
"requestDate": request_date,
"start": start_datetime,
"typeID": mste.Uint32(post_data['type_id']),
"vatRate": mste.Uint32(post_data['vat_rate'])
}
data = self._call_planitech(self.requests.post, 'createReservation', params)
if data.get('creationStatus') != 'OK':
raise APIError("Reservation creation failed: %s" % data.get('creationStatus'))
reservation_id = data.get('reservationIdentifier')
if not reservation_id:
raise APIError("Reservation creation failed: no reservation ID")
return {
'data': {
'reservation_id': int(reservation_id),
'raw_data': data
}
}
def hourly(self):
self._get_places_referential(refresh_cache=True)
def _date_display(self, raw_data):
available_dates = set()
for place in raw_data.get('availablePlaces', []):
for freegap in place.get('freeGaps', []):
available_dates.add(freegap[0].date())
res = []
available_dates = list(available_dates)
available_dates.sort()
for date_obj in available_dates:
date_text = dateformat.format(date_obj, 'l d F Y')
short_text = dateformat.format(date_obj, 'd/m/Y')
res.append({
"id": date_obj.isoformat(), "text": date_text,
"short_text": short_text})
return res
def _place_display(self, raw_data):
available_places = []
for place in raw_data.get('availablePlaces', []):
available_places.append(int(place['placeIdentifier']))
places_ref = self._get_places_referential()
res = []
for place in available_places:
res.append({"id": place, "text": places_ref[place]['label']})
return res
def _full_display(self, raw_data):
places_ref = self._get_places_referential()
res = {
'date': self._date_display(raw_data),
'place': self._place_display(raw_data)
}
all_dates = [d['id'] for d in res['date']]
full = []
for place in raw_data.get('availablePlaces', []):
place_id = int(place['placeIdentifier'])
place_data = {
'id': place_id,
'text': places_ref[place_id]['label'],
'dates': []
}
place_dates = []
for freegap in place.get('freeGaps', []):
place_dates.append(freegap[0].date().isoformat())
for d in all_dates:
available = d in place_dates
place_data['dates'].append({'id': d, 'available': available})
assert len(place_data['dates']) == len(all_dates)
full.append(place_data)
res['full'] = full
return res
@endpoint(
description_get=_('Get days available for reservation'),
methods=['get'], perm='can_access',
parameters={
'min_capacity': {
'description': _('Minimum capacity'),
'example_value': '1',
},
'max_capacity': {
'description': _('Maximum capacity'),
'example_value': '10',
},
'start_date': {
'description': _('Start date'),
'example_value': '2018-10-10',
},
'start_days': {
'description': _('Start days'),
'example_value': '2',
},
'end_date': {
'description': _('End date'),
'example_value': '2018-12-10',
},
'start_time': {
'description': _('Start time'),
'example_value': '10:00',
},
'end_time': {
'description': _('End time'),
'example_value': '18:00',
},
'end_days': {
'description': _('End days'),
'example_value': '10',
},
'weekdays': {
'description': _('Week days'),
'example_value': 'true',
'type': 'bool',
},
'place_id': {
'description': _('Place identifier'),
'example_value': '2',
'type': 'int',
},
'display': {
'description': _('Display'),
'example_value': 'date',
},
})
def getfreegaps(
self, request, display, start_time, end_time, min_capacity=0, start_date=None,
start_days=None, end_date=None, end_days=None, max_capacity=100000, weekdays=False,
place_id=None, **kwargs):
# Additional parameters check
valid_displays = ['date', 'place', 'full']
if display not in valid_displays:
raise APIError("Valid display are: %s" % ", ".join(valid_displays))
if start_date is None and start_days is None:
raise APIError("start_date or start_days is required")
# Starting date computation
if start_date is not None:
utc_start_datetime = combine_date_time(start_date, start_time)
else:
date_obj = (datetime.now() + timedelta(int(start_days))).date()
utc_start_datetime = combine_date_time(date_obj, start_time)
# Ending date computation
if end_date is None and end_days is None:
utc_end_datetime = (utc_start_datetime + timedelta(days=1)).replace(hour=0, minute=0)
elif end_date is not None:
utc_end_datetime = combine_date_time(end_date, '00:00')
elif end_days is not None:
date_obj = (datetime.now() + timedelta(int(end_days))).date()
utc_end_datetime = combine_date_time(date_obj, '00:00')
duration = get_duration(start_time, end_time)
# Places restriction
if place_id is not None:
places_id = [int(place_id)]
elif kwargs:
places_id = self._filter_places_referential(
self._get_places_referential(), kwargs).keys()
else:
places_id = self._get_places_by_capacity(int(min_capacity), int(max_capacity))
params = {
"placeIdentifiers": [float(p_id) for p_id in places_id],
"startingDate": utc_start_datetime,
"endingDate": utc_end_datetime,
"requestedStartingTime": float(0),
"requestedEndingTime": duration
}
if not weekdays:
params['reservationDays'] = [mste.Uint32(0), mste.Uint32(6)]
raw_data = self._call_planitech(self.requests.post, 'getFreeGaps', params)
if display == 'date':
return {'data': self._date_display(raw_data)}
if display == 'place':
return {'data': self._place_display(raw_data)}
if display == 'full':
return {'data': self._full_display(raw_data)}
def generic_call(self, endpoint, data_key):
raw_data = self._call_planitech(self.requests.post, endpoint)
data = raw_data[data_key]
for item in data:
if 'identifier' in item:
item['identifier'] = int(item['identifier'])
return data
@endpoint(description_get=_('Get activities'), methods=['get'], perm='can_access')
def getactivities(self, request):
return {'data': self.generic_call('getActivities', 'activities')}
@endpoint(description_get=_('Get activity types'), methods=['get'], perm='can_access')
def getactivitytypes(self, request):
return {'data': self.generic_call('getActivityTypes', 'types')}
@endpoint(description_get=_('Get places referential'), methods=['get'], perm='can_access')
def getplacesreferential(self, request, **kwargs):
return {
'data': self._filter_places_referential(
self._get_places_referential(), kwargs)
}
@endpoint(description_get=_('Get reservation types'), methods=['get'], perm='can_access')
def getreservationtypes(self, request):
return {'data': self.generic_call('getReservationTypes', 'types')}
@endpoint(description_get=_('Get users'), methods=['get'], perm='can_access')
def getusers(self, request):
return {'data': self.generic_call('getUsersList', 'usersList')}
@endpoint(
methods=['post'], perm='can_access',
post={
'description': _('Update reservation'),
'request_body': {
'schema': {
'application/json': UPDATE_RESERVATION_SCHEMA
}
}
}
)
def updatereservation(self, request, post_data):
params = {
"reservationIdentifier": mste.Uint32(post_data['reservation_id']),
"situation": mste.Uint32(RESERVATION_STATUS[post_data['status']])
}
data = self._call_planitech(self.requests.post, 'updateReservation', params)
if data.get('modificationStatus') != 'OK':
raise APIError("Update reservation failed: %s" % data.get('modificationStatus'))
return {
'data': {
'raw_data': data
}
}
def check_status(self):
auth_url = urlparse.urljoin(self.url, 'auth')
response = self.requests.get(auth_url, headers={'MH-LOGIN': self.username})
response.raise_for_status()
class Pairing(models.Model):
class Meta:
unique_together = (('resource', 'name_id'), ('resource', 'external_id'),)
resource = models.ForeignKey(PlanitechConnector)
name_id = models.CharField(blank=False, max_length=256)
external_id = models.CharField(blank=False, max_length=256)
created = models.DateTimeField(auto_now_add=True)