passerelle/passerelle/contrib/planitech/models.py

810 lines
29 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/>.
import collections
import hashlib
import json
import re
import uuid
from datetime import date, datetime, time, timedelta
from urllib import parse as urlparse
from django.core.cache import cache
from django.db import models, transaction
from django.db.models import JSONField
from django.utils import dateformat, dateparse
from django.utils.encoding import force_bytes
from django.utils.translation import gettext_lazy as _
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
DEFAULT_MIN_CAPACITY = 0
DEFAULT_MAX_CAPACITY = 100000
CREATE_RESERVATION_SCHEMA = {
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "Planitech createreservation",
"description": "",
"type": "object",
"required": [
"date",
"start_time",
"end_time",
"place_id",
"price",
"name_id",
"first_name",
"last_name",
"email",
"activity_id",
"object",
"type_id",
"vat_rate",
],
"properties": {
"date": {
"description": "Date",
"type": "string",
},
"start_time": {
"description": "Start time",
"type": "string",
},
"end_time": {
"description": "End time",
"type": "string",
},
"place_id": {
"description": "Place identifier",
"type": "number",
},
"price": {
"description": "Price",
"type": "number",
},
"name_id": {
"description": "Publik user nameID",
"type": "string",
},
"first_name": {
"description": "First name",
"type": "string",
},
"last_name": {
"description": "Last name",
"type": "string",
},
"email": {
"description": "Email",
"type": "string",
},
"activity_id": {
"description": "Activity identifier",
"type": "number",
},
"object": {
"description": "Object",
"type": "string",
},
"type_id": {
"description": "Rerservation type identifier",
"type": "number",
},
"vat_rate": {
"description": "VAT rate",
"type": "number",
},
"price_code": {
"description": "User price code",
"type": "string",
},
},
}
GET_RESERVATION_PRICE_SCHEMA = {
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "Planitech getreservationprice",
"description": "",
"type": "object",
"required": [
"date",
"start_time",
"end_time",
"place_id",
"name_id",
"first_name",
"last_name",
"email",
"activity_id",
"type_id",
],
"properties": {
"date": {
"description": "Date",
"type": "string",
},
"start_time": {
"description": "Start time",
"type": "string",
},
"end_time": {
"description": "End time",
"type": "string",
},
"place_id": {
"description": "Place identifier",
"type": "number",
},
"name_id": {
"description": "Publik user nameID",
"type": "string",
},
"first_name": {
"description": "First name",
"type": "string",
},
"last_name": {
"description": "Last name",
"type": "string",
},
"email": {
"description": "Email",
"type": "string",
},
"activity_id": {
"description": "Activity identifier",
"type": "number",
},
"type_id": {
"description": "Rerservation type identifier",
"type": "number",
},
"price_code": {
"description": "User price code",
"type": "string",
},
},
}
RESERVATION_STATUS = {"confirmed": 3, "invalid": 0, " pre-reservation": 1, "standard": 2}
UPDATE_RESERVATION_SCHEMA = {
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "Planitech updatereservation",
"description": "",
"type": "object",
"required": ["reservation_id", "status"],
"properties": {
"reservation_id": {
"description": "Reservation Identifier",
"type": "number",
},
"status": {
"description": "Status of the reservation",
"type": "string",
"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', force_bytes(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)
def get_extensions(post_data):
extensions = collections.defaultdict(dict)
for field_name, field_value in post_data.items():
if field_name.startswith('extension_'):
try:
extension_id, extension_property = field_name[10:].split('_', 1)
except ValueError:
raise APIError("Wrong extension format for : '%s'" % field_name)
extensions[extension_id][extension_property] = field_value
res = []
for extension in extensions.values():
for key in ('name', 'value'):
if key not in extension:
raise APIError("Missing '%s' in extension" % key)
res.append(
{'name': extension['name'], 'value': extension['value'], 'type': extension.get('type', 'string')}
)
return res
class PlanitechConnector(BaseResource):
url = models.URLField(
max_length=400,
verbose_name=_('Planitec API endpoint'),
help_text=_('URL of the Planitec 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)
price_code = models.CharField(max_length=128, verbose_name=_('Price code'), blank=True)
category = _('Business Process Connectors')
class Meta:
verbose_name = _('Planitec')
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_fields(self):
if self.custom_fields:
return self.custom_fields.get('places', [])
return []
def _raw_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._get_places_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],
"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 _get_places_referential(
self, min_capacity=DEFAULT_MIN_CAPACITY, max_capacity=DEFAULT_MAX_CAPACITY, **kwargs
):
ref = self._raw_get_places_referential()
res = {}
try:
min_capacity, max_capacity = int(min_capacity), int(max_capacity)
except (ValueError, TypeError):
raise APIError("min_capacity and max_capacity must be integers")
for place_id, place_data in ref.items():
# Filter on capacity
if min_capacity != DEFAULT_MIN_CAPACITY or max_capacity != DEFAULT_MAX_CAPACITY:
if place_data.get('capacity') is None:
continue
if place_data['capacity'] < min_capacity or place_data['capacity'] > max_capacity:
continue
# Filter on custom fields
skip = False
for filter_name, filter_value in kwargs.items():
for field in self._get_places_fields():
if filter_name == field['name']:
if field['type'] == 'int':
filter_value = int(filter_value)
if place_data.get(filter_name) != filter_value:
skip = True
break
if skip:
continue
res[place_id] = place_data
return res
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.text)
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 Planitec failed: %s" % str(e))
def update_or_create_user(self, post_data):
dyn_price_code = post_data.get('price_code')
price_code = dyn_price_code or self.price_code
with transaction.atomic():
pairing, created = Pairing.objects.get_or_create(
resource=self,
name_id=post_data['name_id'],
defaults={'external_id': uuid.uuid4().hex, 'price_code': price_code},
)
if created:
# Create planitec user
params = {
"externalUserIdentifier": pairing.external_id,
"name": post_data['last_name'],
"firstName": post_data['first_name'],
"mail": post_data['email'],
"pricingCode": price_code,
}
data = self._call_planitech(self.requests.post, 'createPerson', params)
if data.get('creationStatus') != 'OK':
raise APIError("Person creation failed: %s" % data.get('creationStatus'))
elif dyn_price_code and pairing.price_code != dyn_price_code:
# Update planitec user
pairing.price_code = dyn_price_code
pairing.save()
params = {'externalUserIdentifier': pairing.external_id, 'pricingCode': dyn_price_code}
data = self._call_planitech(self.requests.post, 'updatePerson', params)
if data.get('modificationStatus') != 'OK':
raise APIError("Person update failed: %s" % data.get('modificationStatus'))
return pairing
@endpoint(
perm='can_access',
post={
'description': _('Get reservation price'),
'request_body': {'schema': {'application/json': GET_RESERVATION_PRICE_SCHEMA}},
},
)
def getreservationprice(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'])
pairing = self.update_or_create_user(post_data)
params = {
"activityID": mste.Uint32(post_data['activity_id']),
"contractorExternalIdentifier": pairing.external_id,
"end": end_datetime,
"isWeekly": False,
"places": [float(post_data['place_id'])],
"start": start_datetime,
"typeID": mste.Uint32(post_data['type_id']),
}
data = self._call_planitech(self.requests.post, 'getFutureReservationPrice', params)
if data.get('calculationStatus') != 'OK':
raise APIError("Get reservation price failed: %s" % data.get('calculationStatus'))
price = data.get('calculatedPrice', False)
if price is False:
raise APIError("Get reservation price failed: no price")
return {'data': {'price': int(price), 'raw_data': data}}
@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()
pairing = self.update_or_create_user(post_data)
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']),
}
extensions = get_extensions(post_data)
if extensions:
params['extensions'] = extensions
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._raw_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._raw_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_id):
places_ref = self._raw_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_id in places_id:
place_data = {'id': place_id, 'text': places_ref[place_id]['label'], 'dates': []}
place_dates = []
for place in raw_data.get('availablePlaces', []):
if place_id == int(place['placeIdentifier']):
for freegap in place.get('freeGaps', []):
place_dates.append(freegap[0].date().isoformat())
break
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'),
'type': 'date',
'example_value': '2018-10-10',
},
'start_days': {
'description': _('Start days'),
'example_value': '2',
},
'end_date': {
'description': _('End date'),
'type': '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, comma separated list of integers beetween 0 (sunday) and 6 (saturday)'
),
'example_value': 'true',
'type': 'string',
},
'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=DEFAULT_MIN_CAPACITY,
start_date=None,
start_days=None,
end_date=None,
end_days=None,
max_capacity=DEFAULT_MAX_CAPACITY,
weekdays=None,
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)]
else:
places_id = self._get_places_referential(
min_capacity=min_capacity, max_capacity=max_capacity, **kwargs
).keys()
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 weekdays is not None:
reservation_days = []
for day in [d.strip() for d in weekdays.split(',') if d.strip()]:
try:
day = mste.Uint32(day)
if not 0 <= day <= 6:
raise ValueError()
reservation_days.append(mste.Uint32(day))
except (ValueError, TypeError):
raise APIError('weekdays must be a comma separated list of integers beetween 0 and 6')
if reservation_days:
params['reservationDays'] = reservation_days
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, places_id)}
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 place'), methods=['get'], perm='can_access')
def getplace(self, request, id):
try:
id_ = int(id)
except ValueError:
raise APIError('ID must be an integer')
ref = self._raw_get_places_referential()
if id_ not in ref:
raise APIError('No place with ID %s' % id_)
return {'data': ref[int(id_)]}
@endpoint(description_get=_('Get places referential'), methods=['get'], perm='can_access')
def getplacesreferential(self, request, **kwargs):
return {'data': self._get_places_referential(**kwargs)}
@endpoint(description_get=_('Get reservation infos'), methods=['get'], perm='can_access')
def getreservationsinfo(self, request, reservation_id):
params = {'reservationIdentifiers': [float(reservation_id)]}
data = self._call_planitech(self.requests.post, 'getReservationsInfo', params)
data = data.get('requestedReservations', [])
res = {}
# Delete circular references that are not json serializable
if data:
res = data[0]
if 'activity' in res and 'reservations' in res['activity']:
del res['activity']['reservations']
if 'contractor' in res and 'reservations' in res['contractor']:
del res['contractor']['reservations']
return {'data': res}
@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']]),
}
extensions = get_extensions(post_data)
if extensions:
params['extensions'] = extensions
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, on_delete=models.CASCADE)
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)
price_code = models.CharField(max_length=128, verbose_name=_('Price code'), blank=True)