passerelle/passerelle/contrib/isere_ens/models.py

520 lines
19 KiB
Python

# passerelle - uniform access to multiple data sources and services
# Copyright (C) 2021 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 datetime
from collections import OrderedDict
from urllib import parse as urlparse
from django.core.cache import cache
from django.db import models
from django.utils.formats import date_format
from django.utils.translation import gettext_lazy as _
from passerelle.base.models import BaseResource, HTTPResource
from passerelle.utils.api import endpoint
from passerelle.utils.conversion import simplify
from passerelle.utils.jsonresponse import APIError
API_VERSION = [
('1.0.0', '1.0.0'),
('2.1.0', '2.1.0'),
('2.1.1', '2.1.1'),
]
API_VERSION_DEFAULT = '1.0.0'
SITE_BOOKING_SCHOOL_SCHEMA = {
'$schema': 'http://json-schema.org/draft-04/schema#',
'title': 'ENS site/booking/school',
'description': '',
'type': 'object',
'required': [
'site',
'date',
'pmr',
'morning',
'lunch',
'afternoon',
'participants',
'grade_levels',
'beneficiary_first_name',
'beneficiary_last_name',
'beneficiary_email',
'beneficiary_phone',
],
'properties': OrderedDict(
{
'external_id': {
'description': 'external id',
'type': 'string',
},
'site': {
'description': 'site id (code)',
'type': 'string',
},
'project': {
'description': 'project code',
'type': 'string',
},
'date': {
'description': 'booking date (format: YYYY-MM-DD)',
'type': 'string',
},
'pmr': {
'description': 'PMR',
'type': 'boolean',
},
'morning': {
'description': 'morning booking',
'type': 'boolean',
},
'lunch': {
'description': 'lunch booking',
'type': 'boolean',
},
'afternoon': {
'description': 'afternoon booking',
'type': 'boolean',
},
'participants': {
'description': 'number of participants',
'type': 'string',
'pattern': '^[0-9]+$',
},
'animator': {
'description': 'animator id',
'type': 'string',
'pattern': '^[0-9]*$',
},
'group': {
'description': 'school group id (API v2.1.0/v2.1.1, use applicant if empty)',
'type': 'string',
'pattern': '^[0-9]*$',
},
'applicant': {
'description': 'applicant',
'type': 'string',
},
'grade_levels': {
'description': 'grade levels',
'type': 'array',
'items': {
'type': 'string',
'description': 'level',
},
},
'beneficiary_first_name': {
'description': 'beneficiary first name',
'type': 'string',
},
'beneficiary_last_name': {
'description': 'beneficiary last name',
'type': 'string',
},
'beneficiary_email': {
'description': 'beneficiary email',
'type': 'string',
},
'beneficiary_phone': {
'description': 'beneficiary phone number',
'type': 'string',
},
'beneficiary_cellphone': {
'description': 'beneficiary cell phone number',
'type': 'string',
},
# v1.0.0 only
'code': {
'description': 'booking code (API v1.0.0)',
'type': 'string',
},
'status': {
'description': 'booking status (API v1.0.0)',
'type': 'string',
},
'beneficiary_id': {
'description': 'beneficiary id (API v1.0.0)',
'type': 'string',
},
'public': {
'description': 'public (API v1.0.0)',
'type': 'string',
},
'entity_id': {
'description': 'entity/school id (UAI/RNE) (API v1.0.0)',
'type': 'string',
},
'entity_name': {
'description': 'entity/school name (API v1.0.0)',
'type': 'string',
},
'entity_type': {
'description': 'entity/school type (API v1.0.0)',
'type': 'string',
},
}
),
}
class IsereENS(BaseResource, HTTPResource):
category = _('Business Process Connectors')
base_url = models.URLField(
verbose_name=_('Webservice Base URL'),
help_text=_('Base API URL (before /api/...)'),
)
token = models.CharField(verbose_name=_('Access token'), max_length=128)
api_version = models.CharField(
verbose_name=_('API version'), max_length=10, choices=API_VERSION, default=API_VERSION_DEFAULT
)
class Meta:
verbose_name = _("Espaces naturels sensibles de l'Isère")
def request(self, endpoint, params=None, json=None, method='get'):
url = urlparse.urljoin(self.base_url, endpoint)
headers = {'token': self.token}
if method == 'post' or json is not None:
response = self.requests.post(url, params=params, json=json, headers=headers)
else:
response = self.requests.get(url, params=params, headers=headers)
if response.status_code // 100 != 2:
try:
json_content = response.json()
except ValueError:
json_content = None
raise APIError(
'error status:%s %r, content:%r'
% (response.status_code, response.reason, response.content[:1024]),
data={
'status_code': response.status_code,
'json_content': json_content,
},
)
if response.status_code == 204: # 204 No Content
raise APIError('abnormal empty response')
try:
return response.json()
except ValueError:
raise APIError('invalid JSON in response: %r' % response.content[:1024])
@endpoint(
name='sites',
description=_('Sites'),
display_order=1,
parameters={
'q': {'description': _('Search text in name field')},
'id': {
'description': _('Returns site with code=id'),
},
'kind': {
'description': _('Returns only sites of this kind (school_group or social)'),
},
},
)
def sites(self, request, q=None, id=None, kind=None):
if id is not None:
site = self.request('api/%s/site/%s' % (self.api_version, id))
site['id'] = site['code']
site['text'] = '%(name)s (%(city)s)' % site
sites = [site]
else:
cache_key = 'isere-ens-sites-%d' % self.id
sites = cache.get(cache_key)
if not sites:
sites = self.request('api/%s/site' % self.api_version)
for site in sites:
site['id'] = site['code']
site['text'] = '%(name)s (%(city)s)' % site
cache.set(cache_key, sites, 300)
if kind is not None:
sites = [site for site in sites if site.get(kind)]
if q is not None:
q = simplify(q)
sites = [site for site in sites if q in simplify(site['text'])]
return {'data': sites}
@endpoint(
name='animators',
description=_('Animators'),
display_order=2,
parameters={
'q': {'description': _('Search text in name field')},
'id': {
'description': _('Returns animator number id'),
},
},
)
def animators(self, request, q=None, id=None):
cache_key = 'isere-ens-animators-%d' % self.id
animators = cache.get(cache_key)
if not animators:
animators = self.request('api/%s/schoolAnimator' % self.api_version)
for animator in animators:
animator['id'] = str(animator['id'])
animator['text'] = '%(first_name)s %(last_name)s <%(email)s> (%(entity)s)' % animator
cache.set(cache_key, animators, 300)
if id is not None:
animators = [animator for animator in animators if animator['id'] == id]
if q is not None:
q = simplify(q)
animators = [animator for animator in animators if q in simplify(animator['text'])]
return {'data': animators}
@endpoint(
name='site-calendar',
description=_('Available bookings for a site'),
display_order=3,
parameters={
'site': {'description': _('Site code (aka id)')},
'participants': {
'description': _('Number of participants'),
},
'start_date': {
'description': _('First date of the calendar (format: YYYY-MM-DD, default: today)'),
},
'end_date': {
'description': _(
'Last date of the calendar (format: YYYY-MM-DD, default: start_date + 92 days)'
),
},
},
)
def site_calendar(self, request, site, participants='1', start_date=None, end_date=None):
if start_date:
try:
start_date = datetime.datetime.strptime(start_date, '%Y-%m-%d').date()
except ValueError:
raise APIError(
'bad start_date format (%s), should be YYYY-MM-DD' % start_date,
http_status=400,
)
else:
start_date = datetime.date.today()
if end_date:
try:
end_date = datetime.datetime.strptime(end_date, '%Y-%m-%d').date()
except ValueError:
raise APIError(
'bad end_date format (%s), should be YYYY-MM-DD' % end_date,
http_status=400,
)
else:
end_date = start_date + datetime.timedelta(days=92)
params = {
'participants': participants,
'start_date': start_date.strftime('%Y-%m-%d'),
'end_date': end_date.strftime('%Y-%m-%d'),
}
dates = self.request('api/%s/site/%s/calendar' % (self.api_version, site), params=params)
def status_name(status):
return {
'AVAILABLE': _('available'),
'COMPLETE': _('complete'),
'OVERBOOKING': _('overbooking'),
'OPEN': _('open'),
'CLOSE': _('closed'),
}.get(status) or _('unknown')
for date in dates:
date['id'] = site + ':' + date['date']
date['site'] = site
date_ = datetime.datetime.strptime(date['date'], '%Y-%m-%d').date()
date['date_format'] = date_format(date_, format='DATE_FORMAT')
date['date_number'] = date_format(date_, format='d')
date['date_weekday'] = date_format(date_, format='l')
date['date_weekdayindex'] = date_format(date_, format='w')
date['date_weeknumber'] = date_format(date_, format='W')
date['date_month'] = date_format(date_, format='F Y')
date['disabled'] = False
date['status'] = 'open'
for period in ('morning', 'lunch', 'afternoon'):
date['%s_status' % period] = status_name(date[period])
for period in ('morning', 'afternoon'):
if date[period] in ('COMPLETE', 'CLOSE'):
if date['status'] == 'partially-open':
date['disabled'] = True
date['status'] = 'closed'
else:
date['status'] = 'partially-open'
date['details'] = (
_('Morning (%(morning_status)s), Lunch (%(lunch_status)s), Afternoon (%(afternoon_status)s)')
% date
)
date['text'] = '%(date_format)s - %(details)s' % date
return {'data': dates}
def site_booking_v1(self, request, post_data):
for key in (
'code',
'status',
'beneficiary_id',
'entity_id',
'entity_name',
'entity_type',
'project',
'applicant',
'public',
):
if key not in post_data:
raise APIError('%s is mandatory (API v1.0.0)' % key, err_code='bad-request', http_status=400)
payload = {
'code': post_data['code'],
'status': post_data['status'],
'beneficiary': {
'id': post_data['beneficiary_id'],
'firstName': post_data['beneficiary_first_name'],
'lastName': post_data['beneficiary_last_name'],
'email': post_data['beneficiary_email'],
'phone': post_data['beneficiary_phone'],
'cellphone': post_data.get('beneficiary_cellphone', ''),
},
'entity': {
'id': post_data['entity_id'],
'name': post_data['entity_name'],
'type': post_data['entity_type'],
},
'booking': {
'projectCode': post_data.get('project'),
'siteCode': post_data['site'],
'applicant': post_data['applicant'],
'public': post_data['public'],
'bookingDate': post_data['date'],
'participants': int(post_data['participants']),
'morning': post_data['morning'],
'lunch': post_data['lunch'],
'afternoon': post_data['afternoon'],
'pmr': post_data['pmr'],
'gradeLevels': post_data['grade_levels'],
},
}
if post_data.get('animator'):
payload['booking']['schoolAnimator'] = int(post_data['animator'])
booking = self.request('api/1.0.0/booking', json=payload)
if not isinstance(booking, dict):
raise APIError('response is not a dict', data=booking)
if 'status' not in booking:
raise APIError('no status in response', data=booking)
if booking['status'] not in ('BOOKING', 'OVERBOOKING'):
raise APIError('booking status is %s' % booking['status'], data=booking)
return {'data': booking}
@endpoint(
name='site-booking',
description=_('Book a site for a school'),
display_order=4,
methods=['post'],
post={
'request_body': {
'schema': {
'application/json': SITE_BOOKING_SCHOOL_SCHEMA,
}
}
},
)
def site_booking(self, request, post_data):
if self.api_version == '1.0.0':
return self.site_booking_v1(request, post_data)
payload = {
'siteCode': post_data['site'],
'bookingDate': post_data['date'],
'pmr': post_data['pmr'],
'morning': post_data['morning'],
'lunch': post_data['lunch'],
'afternoon': post_data['afternoon'],
'participants': int(post_data['participants']),
'gradeLevels': post_data['grade_levels'],
'beneficiary': {
'firstName': post_data['beneficiary_first_name'],
'lastName': post_data['beneficiary_last_name'],
'email': post_data['beneficiary_email'],
'phone': post_data['beneficiary_phone'],
'cellphone': post_data.get('beneficiary_cellphone', ''),
},
}
if post_data.get('group'):
payload['schoolGroup'] = int(post_data['group'])
elif post_data.get('applicant'):
payload['schoolGroup'] = None
payload['applicant'] = post_data['applicant']
else:
raise APIError(
'group or applicant are mandatory (API v2.1.0/v2.1.1)',
err_code='bad-request',
http_status=400,
)
if post_data.get('animator'):
payload['schoolAnimator'] = int(post_data['animator'])
if 'external_id' in post_data:
payload['idExternal'] = post_data['external_id']
if 'project' in post_data:
payload['projectCode'] = post_data['project']
booking = self.request('api/' + self.api_version + '/site/booking/school', json=payload)
if not isinstance(booking, dict):
raise APIError('response is not a dict', data=booking)
if 'status' not in booking:
raise APIError('no status in response', data=booking)
if booking['status'] not in ('BOOKING', 'OVERBOOKING'):
raise APIError('booking status is %s' % booking['status'], data=booking)
return {'data': booking}
@endpoint(
name='get-site-booking',
description=_('Booking status'),
display_order=5,
parameters={
'code': {'description': _('Booking Code (API v1.0.0) or External ID (API v2.1.0/v2.1.1)')},
},
)
def get_site_booking(self, request, code):
if self.api_version == '1.0.0':
status = self.request('api/1.0.0/booking/' + code + '/status')
else:
status = self.request('api/' + self.api_version + '/site/booking/school/' + code + '/status/')
if not isinstance(status, dict):
raise APIError('response is not a dict', data=status)
if 'status' not in status:
raise APIError('no status in response', data=status)
return {'data': status}
@endpoint(
name='cancel-site-booking',
description=_('Cancel a booking'),
methods=['post'],
display_order=6,
parameters={
'code': {'description': _('External ID')},
},
)
def cancel_booking(self, request, code):
if self.api_version == '1.0.0':
raise APIError('not available on API v1.0.0', data=None)
status = self.request(
'api/' + self.api_version + '/site/booking/school/cancel/' + code, method='post'
)
if not isinstance(status, dict):
raise APIError('response is not a dict', data=status)
if 'status' not in status:
raise APIError('no status in response', data=status)
return {'data': status}