526 lines
19 KiB
Python
526 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,
|
|
perm="can_access",
|
|
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,
|
|
perm="can_access",
|
|
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,
|
|
perm="can_access",
|
|
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,
|
|
perm="can_access",
|
|
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,
|
|
perm="can_access",
|
|
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,
|
|
perm="can_access",
|
|
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}
|