520 lines
19 KiB
Python
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}
|