passerelle/passerelle/apps/maelis/models.py

679 lines
26 KiB
Python

# Copyright (C) 2020 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/>.
from collections import defaultdict
from urllib.parse import urljoin
import zeep
from django.db import models
from django.utils import timezone
from django.utils.dateparse import parse_date
from django.utils.translation import gettext_lazy as _
from zeep.helpers import serialize_object
from zeep.wsse.username import UsernameToken
from passerelle.base.models import BaseResource
from passerelle.utils.api import endpoint
from passerelle.utils.jsonresponse import APIError
from . import utils
LINK_SCHEMA = {
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "Maelis",
"description": "",
"type": "object",
"required": ["family_id", "password"],
"properties": {
"family_id": {
"description": "family_id",
"type": "string",
},
"password": {
"description": "family password",
"type": "string",
},
"school_year": {
"description": "school year",
"type": "string",
},
},
}
COORDINATES_SCHEMA = {
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "Maelis",
"description": "Person Coordinates",
"type": "object",
"properties": {
"num": {"description": "number", "type": "string", "pattern": "^[0-9]*$"},
"street": {
"description": "street",
"type": "string",
},
"zipcode": {
"description": "zipcode",
"type": "string",
},
"town": {
"description": "town",
"type": "string",
},
"phone": {
"description": "phone",
"type": "string",
},
"mobile": {
"description": "mobile",
"type": "string",
},
"mail": {
"description": "mail",
"type": "string",
},
},
}
class Maelis(BaseResource):
base_url = models.URLField(_('Base API URL'), default='http://www3.sigec.fr/entrouvertws/services/')
login = models.CharField(_('API Login'), max_length=256)
password = models.CharField(_('API Password'), max_length=256)
category = _('Business Process Connectors')
class Meta:
verbose_name = 'Maélis'
@classmethod
def get_verbose_name(cls):
return cls._meta.verbose_name
def check_status(self):
response = self.requests.get(self.base_url)
response.raise_for_status()
def get_client(self, wsdl_name):
wsse = UsernameToken(self.login, self.password)
wsdl_url = urljoin(self.base_url, wsdl_name)
return self.soap_client(wsdl_url=wsdl_url, wsse=wsse)
def call(self, wsdl_name, service, **kwargs):
client = self.get_client(wsdl_name)
method = getattr(client.service, service)
try:
return method(**kwargs)
except zeep.exceptions.Fault as e:
raise APIError(e)
def get_link(self, name_id):
try:
return self.link_set.get(name_id=name_id)
except Link.DoesNotExist:
raise APIError('User not linked to family', err_code='not-found')
def get_family_data(self, family_id, school_year=None):
if not school_year:
# fallback to current year if not provided
school_year = utils.get_school_year()
family_data = serialize_object(
self.call('FamilyService?wsdl', 'readFamily', dossierNumber=family_id, schoolYear=school_year)
)
for child in family_data['childInfoList']:
utils.normalize_person(child)
return family_data
def get_child_info(self, NameID, childID):
link = self.get_link(NameID)
family_data = self.get_family_data(link.family_id)
for child in family_data.get('childInfoList', []):
if child['num'] == childID:
return child
raise APIError('Child not found', err_code='not-found')
def get_invoices(self, regie_id, name_id):
family_id = self.get_link(name_id).family_id
return [
utils.normalize_invoice(i)
for i in self.call(
'InvoiceService?wsdl', 'readInvoices', numDossier=family_id, codeRegie=regie_id
)
]
@endpoint(
display_category=_('Family'),
display_order=1,
description=_('Create link between user and family'),
perm='can_access',
parameters={
'NameID': {'description': _('Publik ID')},
},
post={'request_body': {'schema': {'application/json': LINK_SCHEMA}}},
)
def link(self, request, NameID, post_data):
if 'school_year' not in post_data:
# fallback to default year if not provided
post_data['school_year'] = utils.get_school_year()
r = self.call(
'FamilyService?wsdl',
'readFamilyByPassword',
dossierNumber=post_data['family_id'],
password=post_data['password'],
schoolYear=post_data['school_year'],
)
if not r.number:
raise APIError('Family not found', err_code='not-found')
Link.objects.update_or_create(resource=self, name_id=NameID, defaults={'family_id': r.number})
return {'data': serialize_object(r)}
@endpoint(
display_category=_('Family'),
display_order=2,
description=_('Delete link between user and family'),
methods=['post'],
perm='can_access',
parameters={
'NameID': {'description': _('Publik ID')},
},
)
def unlink(self, request, NameID):
link = self.get_link(NameID)
link_id = link.pk
link.delete()
return {'link': link_id, 'deleted': True, 'family_id': link.family_id}
@endpoint(
display_category=_('Family'),
display_order=4,
description=_("Get information about user's family"),
name='family-info',
perm='can_access',
parameters={
'NameID': {'description': _('Publik ID')},
},
)
def family_info(self, request, NameID):
link = self.get_link(NameID)
family_data = self.get_family_data(link.family_id)
return {'data': family_data}
@endpoint(
display_category=_('Family'),
display_order=6,
description=_("Get information about children"),
perm='can_access',
name='children-info',
parameters={
'NameID': {'description': _('Publik ID')},
},
)
def children_info(self, request, NameID):
link = self.get_link(NameID)
family_data = self.get_family_data(link.family_id)
return {'data': family_data['childInfoList']}
@endpoint(
display_category=_('Family'),
display_order=7,
description=_("Get information about adults"),
perm='can_access',
name='adults-info',
parameters={
'NameID': {'description': _('Publik ID')},
},
)
def adults_info(self, request, NameID):
link = self.get_link(NameID)
family_data = self.get_family_data(link.family_id)
adults = []
if family_data.get('rl1InfoBean'):
adults.append(utils.normalize_person(family_data['rl1InfoBean']))
if family_data.get('rl2InfoBean'):
adults.append(utils.normalize_person(family_data['rl2InfoBean']))
return {'data': adults}
@endpoint(
display_category=_('Family'),
display_order=7,
description=_("Get information about a child"),
perm='can_access',
name='child-info',
parameters={
'NameID': {'description': _('Publik ID')},
'childID': {'description': _('Child ID')},
},
)
def child_info(self, request, NameID, childID):
return {'data': self.get_child_info(NameID, childID)}
@endpoint(
display_category=_('Family'),
display_order=7,
description=_('Update coordinates'),
perm='can_access',
name='update-coordinates',
parameters={
'NameID': {'description': _('Publik ID')},
'personID': {'description': _('Person ID')},
},
post={'request_body': {'schema': {'application/json': COORDINATES_SCHEMA}}},
)
def update_coordinates(self, request, NameID, personID, post_data):
link = self.get_link(NameID)
params = defaultdict(dict)
for address_param in ('num', 'zipcode', 'town'):
if address_param in post_data:
params['adresse'][address_param] = post_data[address_param]
if 'street' in post_data:
params['adresse']['street1'] = post_data['street']
for contact_param in ('phone', 'mobile', 'mail'):
if contact_param in post_data:
params['contact'][contact_param] = post_data[contact_param]
r = self.call(
'FamilyService?wsdl', 'updateCoordinate', numDossier=link.family_id, numPerson=personID, **params
)
return serialize_object(r)
@endpoint(
display_category=_('Invoices'),
display_order=1,
name='regie',
perm='can_access',
pattern=r'^(?P<regie_id>[\w-]+)/invoices/?$',
example_pattern='{regie_id}/invoices',
description=_("Get invoices to pay"),
parameters={
'NameID': {'description': _('Publik ID')},
'regie_id': {'description': _('Regie identifier'), 'example_value': '42-42'},
},
)
def invoices(self, request, regie_id, NameID):
invoices = [i for i in self.get_invoices(regie_id=regie_id, name_id=NameID) if not i['paid']]
return {'data': invoices}
@endpoint(
display_category=_('Invoices'),
display_order=2,
name='regie',
perm='can_access',
pattern=r'^(?P<regie_id>[\w-]+)/invoices/history/?$',
example_pattern='{regie_id}/invoices/history',
description=_("Get invoices already paid"),
parameters={
'NameID': {'description': _('Publik ID')},
'regie_id': {'description': _('Regie identifier'), 'example_value': '42-42'},
},
)
def invoices_history(self, request, regie_id, NameID):
invoices = [i for i in self.get_invoices(regie_id=regie_id, name_id=NameID) if i['paid']]
return {'data': invoices}
@endpoint(
display_category=_('Invoices'),
display_order=3,
name='regie',
perm='can_access',
pattern=r'^(?P<regie_id>[\w-]+)/invoice/(?P<invoice_id>(historical-)?\w+-\d+)/?$',
example_pattern='{regie_id}/invoice/{invoice_id}',
description=_('Get invoice details'),
parameters={
'NameID': {'description': _('Publik ID')},
'regie_id': {'description': _('Regie identifier'), 'example_value': '1'},
'invoice_id': {'description': _('Invoice identifier'), 'example_value': '42-42'},
},
)
def invoice(self, request, regie_id, invoice_id, NameID):
for invoice in self.get_invoices(regie_id=regie_id, name_id=NameID):
if invoice['id'] == invoice_id:
return {'data': invoice}
@endpoint(
display_category=_('Invoices'),
display_order=4,
name='regie',
perm='can_access',
pattern=r'^(?P<regie_id>[\w-]+)/invoice/(?P<invoice_id>(historical-)?\w+-\d+)/pdf/?$',
example_pattern='{regie_id}/invoice/{invoice_id}/pdf',
description=_('Get invoice as a PDF file'),
parameters={
'NameID': {'description': _('Publik ID')},
'regie_id': {'description': _('Regie identifier'), 'example_value': '1'},
'invoice_id': {'description': _('Invoice identifier'), 'example_value': '42-42'},
},
)
def invoice_pdf(self, request, regie_id, invoice_id, **kwargs):
# TODO to implement
pass
@endpoint(
perm='can_access',
description=_('Get activity list'),
name='activity-list',
parameters={
'NameID': {'description': _('Publik ID')},
'personID': {'description': _('Person ID')},
'school_year': {'description': _('School year')},
},
)
def activity_list(
self, request, NameID, personID, school_year=None, start_datetime=None, end_datetime=None
):
link = self.get_link(NameID)
family_data = self.get_family_data(link.family_id)
if personID not in [c['id'] for c in family_data['childInfoList']]:
raise APIError('Child not found', err_code='not-found')
if not school_year:
school_year = utils.get_school_year()
if not start_datetime:
start_datetime = timezone.now()
if not end_datetime:
end_datetime = start_datetime + timezone.timedelta(days=62)
r = self.call(
'ActivityService?wsdl',
'readActivityList',
schoolyear=school_year,
numPerson=personID,
dateStartCalend=start_datetime,
dateEndCalend=end_datetime,
)
activities = serialize_object(r)
return {'data': [utils.normalize_activity(a) for a in activities]}
def get_activities_dates(self, query_date):
if query_date:
try:
start_date = parse_date(query_date)
except ValueError as exc:
raise APIError('input is well formatted but not a valid date: %s' % exc)
if not start_date:
raise APIError("input isn't well formatted, YYYY-MM-DD expected: %s" % query_date)
else:
start_date = timezone.now().date()
if start_date.strftime('%m-%d') >= '05-01':
# start displaying next year activities on may
school_year = start_date.year
else:
school_year = start_date.year - 1
end_date = utils.get_datetime('%s-07-31' % (school_year + 1)).date()
return school_year, start_date, end_date
def get_child_activities(self, childID, school_year, start_date, end_date):
r = self.call(
'ActivityService?wsdl',
'readActivityList',
schoolyear=school_year,
numPerson=childID,
dateStartCalend=start_date,
dateEndCalend=end_date,
)
return serialize_object(r)
@endpoint(
display_category=_('Activities'),
perm='can_access',
display_order=2,
description=_('Get child activities'),
name='child-activities',
parameters={
'NameID': {'description': _('Publik ID')},
'childID': {'description': _('Child ID')},
'subscribePublication': {'description': _('string including E, N or L (default to "E")')},
'subscribingStatus': {'description': _('subscribed, not-subscribed or None')},
'queryDate': {'description': _('Optional querying date (YYYY-MM-DD)')},
},
)
def child_activities(
self, request, NameID, childID, subscribePublication='E', subscribingStatus=None, queryDate=None
):
if subscribingStatus and subscribingStatus not in ('subscribed', 'not-subscribed'):
raise APIError('wrong value for subscribingStatus: %s' % subscribingStatus)
school_year, start_date, end_date = self.get_activities_dates(queryDate)
child_info = self.get_child_info(NameID, childID)
activities = self.get_child_activities(childID, school_year, start_date, end_date)
flatted_activities = utils.flatten_activities(activities, start_date, end_date)
utils.mark_subscribed_flatted_activities(flatted_activities, child_info)
data = utils.flatted_activities_as_list(
flatted_activities, subscribePublication, subscribingStatus, start_date
)
return {'data': data}
@endpoint(
display_category=_('Activities'),
perm='can_access',
display_order=3,
description=_('Get bus lines (units)'),
name='bus-lines',
parameters={
'NameID': {'description': _('Publik ID')},
'childID': {'description': _('Child ID')},
'activityID': {'description': _('Activity ID')},
'unitID': {'description': _('Unit ID')},
'queryDate': {'description': _('Optional querying date (YYYY-MM-DD)')},
'direction': {'description': _('aller, retour or None')},
},
)
def bus_lines(self, request, NameID, childID, activityID, unitID, queryDate=None, direction=None):
if direction and direction.lower() not in ('aller', 'retour'):
raise APIError('wrong value for direction: %s' % direction)
school_year, start_date, end_date = self.get_activities_dates(queryDate)
self.get_child_info(NameID, childID)
activities = self.get_child_activities(childID, school_year, start_date, end_date)
flatted_activities = utils.flatten_activities(activities, start_date, end_date)
legacy_activity_info = flatted_activities[activityID]['info']
legacy_unit_info = flatted_activities[activityID]['units'][unitID]['info']
bus_lines = []
bus_activity_id = legacy_activity_info['bus_activity_id']
for bus_unit_id in legacy_activity_info['bus_unit_ids']:
bus_unit_info = flatted_activities[bus_activity_id]['units'][bus_unit_id]['info']
if direction and direction.lower() not in bus_unit_info['unit_text']:
continue
unit_calendar_letter = bus_unit_info['unit_calendar_letter']
unit_weekly_planning = ""
for letter in legacy_activity_info['activity_weekly_planning_mask']:
if letter == '0':
unit_weekly_planning += unit_calendar_letter
else:
unit_weekly_planning += '1'
bus_lines.append(
{
'id': bus_unit_info['unit_id'],
'text': bus_unit_info['unit_text'],
'unit_id': bus_unit_info['unit_id'],
'activity_id': bus_activity_id,
'unit_calendar_letter': unit_calendar_letter,
'unit_weekly_planning': unit_weekly_planning,
'subscribe_start_date': legacy_unit_info['unit_start_date'],
'subscribe_end_date': legacy_unit_info['unit_end_date'],
}
)
return {'data': bus_lines}
@endpoint(
display_category=_('Activities'),
perm='can_access',
display_order=4,
description=_('Get bus stops (places)'),
name='bus-stops',
parameters={
'NameID': {'description': _('Publik ID')},
'childID': {'description': _('Child ID')},
'busActivityID': {'description': _('Activity ID')},
'busUnitID': {'description': _('Bus Unit ID')},
'queryDate': {'description': _('Optional querying date (YYYY-MM-DD)')},
},
)
def bus_stops(self, request, NameID, childID, busActivityID, busUnitID, queryDate=None):
school_year, start_date, end_date = self.get_activities_dates(queryDate)
self.get_child_info(NameID, childID)
activities = self.get_child_activities(childID, school_year, start_date, end_date)
for activity in activities:
if activity['activityPortail']['idAct'] != busActivityID:
continue
break
else:
raise APIError('Bus activity not found: %s' % busActivityID, err_code='not-found')
for unit in activity['unitPortailList']:
if unit['idUnit'] != busUnitID:
continue
break
else:
raise APIError('Bus unit not found: %s' % busUnitID, err_code='not-found')
bus_stops = []
for place in unit['placeList']:
bus_stops.append(
{
'id': place['id'],
'text': ' '.join([w.capitalize() for w in place['lib'].split(' ')]),
}
)
if bus_stops:
bus_stops[0]['disabled'] = True # hide terminus
return {'data': bus_stops}
@endpoint(
display_category=_('Activities'),
perm='can_access',
display_order=3,
description=_('Read child planning'),
name='child-planning',
parameters={
'NameID': {'description': _('Publik ID')},
'childID': {'description': _('Child ID')},
'start_date': {'description': _('Start date (YYYY-MM-DD format)')},
'end_date': {'description': _('End date (YYYY-MM-DD format)')},
'legacy': {'description': _('Decompose events related to parts of the day if set')},
},
)
def child_planning(self, request, NameID, childID, start_date=None, end_date=None, legacy=None):
"""Return an events list sorted by id"""
link = self.get_link(NameID)
family_data = self.get_family_data(link.family_id)
if childID not in [c['id'] for c in family_data['childInfoList']]:
raise APIError('Child not found', err_code='not-found')
if start_date and end_date:
start = utils.get_datetime(start_date)
end = utils.get_datetime(end_date)
else:
start, end = utils.week_boundaries_datetimes(start_date)
school_year = utils.get_school_year(start.date())
r = self.call(
'ActivityService?wsdl',
'readActivityList',
schoolyear=school_year,
numPerson=childID,
dateStartCalend=start,
dateEndCalend=end,
)
activities = serialize_object(r)
events = {key: value for a in activities for (key, value) in utils.get_events(a, start, end)}
for date in utils.month_range(start, end):
r = self.call(
'ActivityService?wsdl',
'readChildMonthPlanning',
year=date.year,
numMonth=date.month,
numPerson=childID,
)
planning = serialize_object(r['calendList'])
for schedule in planning:
utils.book_event(events, schedule, start, end)
if not legacy:
events = {
x['id']: x # dictionary is used de remove dupplicated events
for e in events.values()
for x in utils.decompose_event(e)
}
return {'data': [s[1] for s in sorted(events.items())]}
@endpoint(
display_category=_('Family'),
perm='can_access',
display_order=10,
description=_('Subscribe'),
parameters={
'NameID': {'description': _('Publik ID')},
'childID': {'description': _('Child ID')},
'activityID': {'description': _('Activity ID')},
'unitID': {'description': _('Unit ID')},
'placeID': {'description': _('Place ID')},
'weeklyPlanning': {'description': _('Week planning (7 chars)')},
'start_date': {'description': _('Start date of the unit (YYYY-MM-DD)')},
'end_date': {'description': _('End date of the unit (YYYY-MM-DD)')},
},
)
def subscribe(
self, request, NameID, childID, activityID, unitID, placeID, weeklyPlanning, start_date, end_date
):
self.get_child_info(NameID, childID)
client = self.get_client('FamilyService?wsdl')
trigram_type = client.get_type('ns1:activityUnitPlaceBean')
trigram = trigram_type(idActivity=activityID, idUnit=unitID, idPlace=placeID)
subscription_type = client.get_type('ns1:subscribeActivityRequestBean')
subpscription = subscription_type(
personNumber=childID,
activityUnitPlace=trigram,
weeklyPlanning=weeklyPlanning,
dateStart=start_date,
dateEnd=end_date,
)
r = self.call('FamilyService?wsdl', 'subscribeActivity', subscribeActivityRequestBean=subpscription)
return {'data': serialize_object(r)}
@endpoint(
display_category=_('Family'),
perm='can_access',
display_order=11,
description=_('Unsubscribe'),
parameters={
'NameID': {'description': _('Publik ID')},
'childID': {'description': _('Child ID')},
'activityID': {'description': _('Activity ID')},
'start_date': {'description': _('Start date of the unit (YYYY-MM-DD)')},
},
)
def unsubscribe(self, request, NameID, childID, activityID, start_date):
self.get_child_info(NameID, childID)
r = self.call(
'FamilyService?wsdl',
'deletesubscribe',
numPerson=childID,
idActivite=activityID,
dateRefDelete=start_date,
)
return {'data': serialize_object(r)}
class Link(models.Model):
resource = models.ForeignKey(Maelis, on_delete=models.CASCADE)
name_id = models.CharField(blank=False, max_length=256)
family_id = models.CharField(blank=False, max_length=128)
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ('resource', 'name_id')