620 lines
22 KiB
Python
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)
|