passerelle/passerelle/contrib/isere_ens/models.py

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}