534 lines
20 KiB
Python
534 lines
20 KiB
Python
#
|
|
# passerelle - uniform access to multiple data sources and services
|
|
# Copyright (C) 2016 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 json
|
|
import os
|
|
import sys
|
|
import zipfile
|
|
from collections import defaultdict
|
|
from datetime import date
|
|
|
|
import pytz
|
|
from django.core.exceptions import ValidationError
|
|
from django.core.files.storage import default_storage
|
|
from django.db import models, transaction
|
|
from django.http import Http404, HttpResponse
|
|
from django.utils.timezone import datetime, make_aware, now
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
from passerelle.base.models import BaseResource
|
|
from passerelle.utils.api import endpoint
|
|
|
|
SEXES = (
|
|
('M', _('Male')),
|
|
('F', _('Female')),
|
|
)
|
|
|
|
DATE_FORMAT = '%Y-%m-%d'
|
|
DATETIME_FORMAT = DATE_FORMAT + 'T%H:%M:%S'
|
|
|
|
FILE_FORMATS = (
|
|
('native', _('Native')),
|
|
('concerto_fondettes', _('Concerto extract from Fondettes (legacy)')),
|
|
('opus_fondettes', _('Opus extract from Fondettes')),
|
|
('concerto_orleans', _('Concerto extract from Orléans')),
|
|
('egee_thonon', _('Egee Invoices from Thonon Agglomération')),
|
|
)
|
|
|
|
|
|
def get_date(dt):
|
|
if isinstance(dt, date):
|
|
return dt
|
|
if not dt:
|
|
return None
|
|
return datetime.strptime(dt, DATE_FORMAT).date()
|
|
|
|
|
|
def get_datetime(date):
|
|
if not date:
|
|
return None
|
|
if not isinstance(date, datetime):
|
|
try:
|
|
date = datetime.strptime(date, DATETIME_FORMAT)
|
|
# full datetime are supposed in UTC timezone
|
|
date = pytz.utc.localize(date)
|
|
except ValueError:
|
|
date = datetime.strptime(date, DATE_FORMAT)
|
|
# simple date are supposed in local timezone
|
|
date = make_aware(date)
|
|
return date
|
|
|
|
|
|
def format_address(data):
|
|
address = '%(street_number)s, %(street_name)s' % data
|
|
if data['address_complement']:
|
|
address += ', %(address_complement)s' % data
|
|
address += ', %(zipcode)s %(city)s' % data
|
|
return address
|
|
|
|
|
|
def format_family(family):
|
|
data = {'quotient': family.family_quotient}
|
|
for attr in ('street_name', 'street_number', 'address_complement', 'zipcode', 'city', 'country'):
|
|
data[attr] = getattr(family, attr, None) or ''
|
|
data['address'] = format_address(data)
|
|
return data
|
|
|
|
|
|
def format_person(p):
|
|
data = {
|
|
'id': str(p.id),
|
|
'text': p.fullname,
|
|
'first_name': p.first_name,
|
|
'last_name': p.last_name,
|
|
'birthdate': p.birthdate,
|
|
'sex': p.sex,
|
|
}
|
|
for attr in (
|
|
'phone',
|
|
'cellphone',
|
|
'street_name',
|
|
'street_number',
|
|
'address_complement',
|
|
'zipcode',
|
|
'city',
|
|
'country',
|
|
'email',
|
|
):
|
|
data[attr] = getattr(p, attr, None) or ''
|
|
data['address'] = format_address(data)
|
|
return data
|
|
|
|
|
|
def format_invoice(i):
|
|
invoice = {
|
|
'id': i.external_id,
|
|
'reference_id': i.external_id,
|
|
'label': i.label,
|
|
'amount': i.amount,
|
|
'total_amount': i.total_amount,
|
|
'created': i.issue_date,
|
|
'pay_limit_date': i.pay_limit_date,
|
|
'payment_date': i.payment_date,
|
|
'paid': i.paid,
|
|
'online_payment': i.online_payment,
|
|
'no_online_payment_reason': i.no_online_payment_reason,
|
|
'has_pdf': i.has_pdf,
|
|
}
|
|
if i.pay_limit_date and now().date() > i.pay_limit_date:
|
|
invoice['online_payment'] = False
|
|
invoice['no_online_payment_reason'] = 'past-due-date'
|
|
return invoice
|
|
|
|
|
|
class FileNotFoundError(Exception):
|
|
http_status = 200
|
|
log_error = False
|
|
|
|
|
|
def dict_cherry_pick(d1, attributes):
|
|
d2 = {}
|
|
for attribute in attributes:
|
|
old_key = new_key = attribute
|
|
if isinstance(attribute, tuple):
|
|
old_key, new_key = attribute
|
|
if old_key not in d1:
|
|
continue
|
|
d2[new_key] = d1[old_key]
|
|
return d2
|
|
|
|
|
|
class GenericFamily(BaseResource):
|
|
category = _('Business Process Connectors')
|
|
archive = models.FileField(_('Data Archive'), upload_to='archives', max_length=256)
|
|
file_format = models.CharField(
|
|
_('File Format'),
|
|
max_length=40,
|
|
choices=FILE_FORMATS,
|
|
default='native',
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = _('Generic Family Connector')
|
|
|
|
@classmethod
|
|
def get_verbose_name(cls):
|
|
return cls._meta.verbose_name
|
|
|
|
def clean(self):
|
|
if self.archive:
|
|
try:
|
|
with zipfile.ZipFile(self.archive) as archive:
|
|
if self.file_format != 'native':
|
|
modname = 'passerelle.apps.family.loaders.%s' % self.file_format
|
|
__import__(modname)
|
|
module = sys.modules[modname]
|
|
module.Loader(self).clean(archive)
|
|
except zipfile.BadZipfile:
|
|
raise ValidationError(_('Invalid zip file.'))
|
|
|
|
return super().clean()
|
|
|
|
@transaction.atomic
|
|
def save(self, *args, **kwargs):
|
|
super().save(*args, **kwargs)
|
|
if not zipfile.is_zipfile(self.archive.path):
|
|
return
|
|
|
|
invoices_dir = default_storage.path('family-%s/invoices' % self.id)
|
|
if not os.path.exists(invoices_dir):
|
|
os.makedirs(invoices_dir)
|
|
|
|
with zipfile.ZipFile(self.archive.path) as archive:
|
|
if self.file_format != 'native':
|
|
modname = 'passerelle.apps.family.loaders.%s' % self.file_format
|
|
__import__(modname)
|
|
module = sys.modules[modname]
|
|
module.Loader(self).load(archive)
|
|
return
|
|
|
|
archive_files = archive.namelist()
|
|
|
|
family_files = [d for d in archive_files if d.endswith('.json')]
|
|
families = []
|
|
invoices = []
|
|
children = []
|
|
adults = []
|
|
|
|
for f in family_files:
|
|
family_data = json.loads(archive.read(f))
|
|
families.append(family_data['id'])
|
|
address = family_data.get('address') or {}
|
|
family_data.update(address)
|
|
data = dict_cherry_pick(
|
|
family_data,
|
|
(
|
|
'login',
|
|
'password',
|
|
'family_quotient',
|
|
('number', 'street_number'),
|
|
('postal_code', 'zipcode'),
|
|
('street', 'street_name'),
|
|
('complement', 'address_complement'),
|
|
),
|
|
)
|
|
family, dummy = Family.objects.update_or_create(
|
|
external_id=family_data['id'], resource=self, defaults=data
|
|
)
|
|
for adult in family_data.get('adults') or []:
|
|
adults.append(adult['id'])
|
|
adult_address = adult.get('address') or {}
|
|
adult.update(adult_address)
|
|
data = dict_cherry_pick(
|
|
adult,
|
|
(
|
|
'first_name',
|
|
'last_name',
|
|
'phone',
|
|
('mobile', 'cellphone'),
|
|
'sex',
|
|
('number', 'street_number'),
|
|
('postal_code', 'zipcode'),
|
|
('street', 'street_name'),
|
|
('complement', 'address_complement'),
|
|
'country',
|
|
),
|
|
)
|
|
Adult.objects.update_or_create(family=family, external_id=adult['id'], defaults=data)
|
|
# cleanup adults
|
|
Adult.objects.exclude(external_id__in=adults).delete()
|
|
|
|
for child in family_data.get('children') or []:
|
|
children.append(child['id'])
|
|
data = dict_cherry_pick(child, ('first_name', 'last_name', 'sex', 'birthdate'))
|
|
Child.objects.get_or_create(family=family, external_id=child['id'], defaults=data)
|
|
# cleanup children
|
|
Child.objects.exclude(external_id__in=children).delete()
|
|
|
|
for invoice in family_data['invoices']:
|
|
invoices.append(invoice['id'])
|
|
data = dict_cherry_pick(
|
|
invoice,
|
|
(
|
|
'label',
|
|
('created', 'issue_date'),
|
|
'pay_limit_date',
|
|
'litigation_date',
|
|
'total_amount',
|
|
'payment_date',
|
|
'amount',
|
|
'autobilling',
|
|
),
|
|
)
|
|
for date_attribute, date_value in data.items():
|
|
if not date_attribute.endswith('_date'):
|
|
continue
|
|
if date_attribute == 'payment_date':
|
|
data[date_attribute] = get_datetime(date_value)
|
|
else:
|
|
data[date_attribute] = get_date(date_value)
|
|
data['paid'] = bool(data.get('payment_date'))
|
|
Invoice.objects.update_or_create(
|
|
resource=self, family=family, external_id=invoice['id'], defaults=data
|
|
)
|
|
if 'invoices/%s.pdf' % invoice['id'] in archive_files:
|
|
with open(os.path.join(invoices_dir, '%s.pdf' % invoice['id']), 'wb') as fp:
|
|
fp.write(archive.read('invoices/%s.pdf' % invoice['id']))
|
|
|
|
# cleanup invoices
|
|
Invoice.objects.exclude(external_id__in=invoices).delete()
|
|
for filename in os.listdir(invoices_dir):
|
|
file_invoice_id = os.path.splitext(filename)[0]
|
|
if file_invoice_id not in invoices:
|
|
os.unlink(os.path.join(invoices_dir, filename))
|
|
|
|
# cleanup families
|
|
Family.objects.exclude(external_id__in=families).delete()
|
|
|
|
def get_family_by_nameid(self, NameID):
|
|
try:
|
|
link = FamilyLink.objects.get(resource=self, name_id=NameID)
|
|
return link.family
|
|
except FamilyLink.DoesNotExist:
|
|
return None
|
|
|
|
@endpoint(name='family', perm='can_access', pattern='^link/$')
|
|
def family_link(self, request, NameID=None, login=None, password=None, **kwargs):
|
|
"""
|
|
Links a NameID to a person
|
|
"""
|
|
try:
|
|
f = Family.objects.get(login=login, password=password, resource=self)
|
|
except Family.DoesNotExist:
|
|
return {'data': False}
|
|
FamilyLink.objects.get_or_create(resource=self, name_id=NameID, family=f)
|
|
return {'data': True}
|
|
|
|
@endpoint(name='family', perm='can_access', pattern='^unlink/$')
|
|
def family_unlink(self, request, NameID=None, **kwargs):
|
|
"""
|
|
Unlinks a NameID from a person
|
|
"""
|
|
try:
|
|
FamilyLink.objects.get(resource=self, name_id=NameID).delete()
|
|
return {'data': True}
|
|
except FamilyLink.DoesNotExist:
|
|
return {'data': False}
|
|
|
|
@endpoint(perm='can_access', name='family')
|
|
def family_infos(self, request, NameID, **kwargs):
|
|
"""
|
|
Displays the information of person's family
|
|
"""
|
|
family = self.get_family_by_nameid(NameID)
|
|
if not family:
|
|
return {'data': None}
|
|
|
|
data = {'id': family.get_display_id(), 'adults': [], 'children': []}
|
|
for adult in family.adult_set.all():
|
|
data['adults'].append(format_person(adult))
|
|
for child in family.child_set.all():
|
|
data['children'].append(format_person(child))
|
|
data.update(format_family(family))
|
|
return {'data': data}
|
|
|
|
@endpoint(name='family', perm='can_access', pattern='^adults/$')
|
|
def adults_infos(self, request, NameID):
|
|
family_infos = self.family_infos(request, NameID)['data']
|
|
if not family_infos:
|
|
return {'data': []}
|
|
return {'data': family_infos['adults']}
|
|
|
|
@endpoint(name='family', perm='can_access', pattern='^children/$')
|
|
def children_infos(self, request, NameID, **kwargs):
|
|
family_infos = self.family_infos(request, NameID)['data']
|
|
if not family_infos:
|
|
return {'data': []}
|
|
return {'data': family_infos['children']}
|
|
|
|
def get_invoices(self, NameID, paid=False):
|
|
family = self.get_family_by_nameid(NameID)
|
|
if not family:
|
|
return []
|
|
invoices = []
|
|
for i in family.invoice_set.exclude(payment_date__isnull=paid):
|
|
invoices.append(format_invoice(i))
|
|
return invoices
|
|
|
|
@endpoint(name='regie', perm='OPEN', pattern='^invoices/$')
|
|
def active_invoices(self, request, NameID):
|
|
return {'data': self.get_invoices(NameID)}
|
|
|
|
@endpoint(name='regie', perm='can_access', pattern='^invoices/history/$')
|
|
def invoices_history(self, request, NameID, **kwargs):
|
|
return {'data': self.get_invoices(NameID, paid=True)}
|
|
|
|
def get_invoice(self, invoice_id):
|
|
try:
|
|
return self.invoice_set.get(external_id=invoice_id)
|
|
except Invoice.DoesNotExist:
|
|
return None
|
|
|
|
@endpoint(name='regie', perm='can_access', pattern=r'^invoice/(?P<invoice_id>\w+)/$')
|
|
def get_invoice_details(self, request, invoice_id, NameID=None, email=None, **kwargs):
|
|
invoice = self.get_invoice(invoice_id)
|
|
if not invoice:
|
|
return {'data': None}
|
|
return {'data': format_invoice(invoice)}
|
|
|
|
@endpoint(name='regie', perm='can_access', pattern=r'^invoice/(?P<invoice_id>\w+)/pdf/$')
|
|
def get_invoice_pdf(self, request, invoice_id, **kwargs):
|
|
invoice = self.get_invoice(invoice_id)
|
|
if not invoice:
|
|
raise FileNotFoundError
|
|
return invoice.get_pdf()
|
|
|
|
@endpoint(
|
|
name='regie', methods=['post'], perm='can_access', pattern=r'^invoice/(?P<invoice_id>\w+)/pay/$'
|
|
)
|
|
def pay_invoice(self, request, invoice_id, **kwargs):
|
|
data = json.loads(request.body)
|
|
invoice = self.get_invoice(invoice_id)
|
|
if not invoice:
|
|
return {'data': False}
|
|
transaction_date = get_datetime(data['transaction_date'])
|
|
invoice.paid = True
|
|
invoice.payment_date = transaction_date
|
|
invoice.amount = 0
|
|
invoice.payment_transaction_id = data['transaction_id']
|
|
invoice.save()
|
|
return {'data': True}
|
|
|
|
@endpoint(name='regie', perm='can_access', pattern='^users/with-pending-invoices/$')
|
|
def get_pending_invoices_by_nameid(self, request):
|
|
data = defaultdict(lambda: {'invoices': []})
|
|
for i in (
|
|
Invoice.objects.filter(
|
|
payment_date__isnull=True, family__resource=self, family__familylink__isnull=False
|
|
)
|
|
.select_related('family')
|
|
.prefetch_related('family__familylink_set')
|
|
):
|
|
name_id = i.family.familylink_set.all()[0].name_id
|
|
data[name_id]['invoices'].append(format_invoice(i))
|
|
return {'data': data}
|
|
|
|
|
|
class FamilyLink(models.Model):
|
|
resource = models.ForeignKey('GenericFamily', on_delete=models.CASCADE)
|
|
name_id = models.CharField(max_length=256)
|
|
family = models.ForeignKey('Family', on_delete=models.CASCADE)
|
|
|
|
|
|
class Family(models.Model):
|
|
resource = models.ForeignKey('GenericFamily', on_delete=models.CASCADE)
|
|
external_id = models.CharField(_('External id'), max_length=16, db_index=True)
|
|
login = models.CharField(_('Login'), max_length=64, null=True)
|
|
password = models.CharField(_('Password'), max_length=64, null=True)
|
|
street_number = models.CharField(_('Street number'), max_length=32, null=True)
|
|
street_name = models.CharField(_('Street name'), max_length=128, null=True)
|
|
address_complement = models.CharField(_('Address complement'), max_length=64, null=True)
|
|
zipcode = models.CharField(_('Zipcode'), max_length=16, null=True)
|
|
city = models.CharField(_('City'), max_length=64, null=True)
|
|
family_quotient = models.DecimalField(_('Family quotient'), max_digits=10, decimal_places=2, default=0)
|
|
creation_timestamp = models.DateTimeField(auto_now_add=True)
|
|
update_timestamp = models.DateTimeField(auto_now=True)
|
|
|
|
def get_display_id(self):
|
|
return self.external_id
|
|
|
|
|
|
class Person(models.Model):
|
|
family = models.ForeignKey('Family', on_delete=models.CASCADE)
|
|
external_id = models.CharField(_('Person\'s external id'), max_length=32, db_index=True)
|
|
first_name = models.CharField(_('First name'), max_length=64)
|
|
last_name = models.CharField(_('Last name'), max_length=64)
|
|
sex = models.CharField(_('Sex'), max_length=1, choices=SEXES)
|
|
birthdate = models.DateField(_('Birthdate'), null=True, blank=True)
|
|
creation_timestamp = models.DateTimeField(auto_now_add=True)
|
|
update_timestamp = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
@property
|
|
def fullname(self):
|
|
return '%s %s' % (self.first_name, self.last_name)
|
|
|
|
def __str__(self):
|
|
return self.fullname
|
|
|
|
|
|
class Adult(Person):
|
|
phone = models.CharField(_('Phone'), max_length=32, null=True)
|
|
cellphone = models.CharField(_('Cellphone'), max_length=32, null=True)
|
|
street_number = models.CharField(_('Street number'), max_length=32, null=True)
|
|
street_name = models.CharField(_('Street name'), max_length=128, null=True)
|
|
address_complement = models.CharField(_('Address complement'), max_length=64, null=True)
|
|
zipcode = models.CharField(_('Zipcode'), max_length=16, null=True)
|
|
city = models.CharField(_('City'), max_length=64, null=True)
|
|
country = models.CharField(_('Country'), max_length=128, null=True)
|
|
email = models.EmailField(_('Email'), null=True)
|
|
|
|
|
|
class Child(Person):
|
|
pass
|
|
|
|
|
|
class Invoice(models.Model):
|
|
resource = models.ForeignKey('GenericFamily', on_delete=models.CASCADE)
|
|
family = models.ForeignKey('Family', null=True, on_delete=models.CASCADE)
|
|
external_id = models.CharField(_('External id'), max_length=128, db_index=True)
|
|
label = models.CharField(_('Label'), max_length=128, null=True)
|
|
issue_date = models.DateField(_('Issue date'), null=True)
|
|
pay_limit_date = models.DateField(_('Due date'), null=True)
|
|
litigation_date = models.DateField(_('Litigation date'), null=True)
|
|
total_amount = models.DecimalField(_('Total amount'), max_digits=8, decimal_places=2, default=0)
|
|
amount = models.DecimalField(_('Amount'), max_digits=8, decimal_places=2, default=0)
|
|
payment_date = models.DateTimeField(_('Payment date'), null=True)
|
|
paid = models.BooleanField(_('Paid'), default=False)
|
|
autobilling = models.BooleanField(_('Autobilling'), default=False)
|
|
online_payment = models.BooleanField(_('Online payment'), default=True)
|
|
no_online_payment_reason = models.CharField(_('No online payment reason'), max_length=100, null=True)
|
|
payment_transaction_id = models.CharField(_('Payment transaction id'), max_length=128, null=True)
|
|
creation_timestamp = models.DateTimeField(auto_now_add=True)
|
|
update_timestamp = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
ordering = ['issue_date']
|
|
|
|
def pdf_filename(self):
|
|
invoices_dir = default_storage.path('family-%s/invoices' % self.resource.id)
|
|
return os.path.join(invoices_dir, '%s.pdf' % self.external_id)
|
|
|
|
@property
|
|
def has_pdf(self):
|
|
return os.path.exists(self.pdf_filename())
|
|
|
|
def get_pdf(self):
|
|
if not self.has_pdf:
|
|
raise Http404(_('PDF file not found'))
|
|
|
|
with open(self.pdf_filename(), 'rb') as fd:
|
|
response = HttpResponse(fd.read(), content_type='application/pdf')
|
|
response['Content-Disposition'] = 'attachment; filename=%s.pdf' % self.external_id
|
|
return response
|
|
|
|
def write_pdf(self, contents):
|
|
with open(self.pdf_filename(), 'wb') as fp:
|
|
fp.write(contents)
|