passerelle/passerelle/apps/family/models.py

504 lines
20 KiB
Python

# -*- coding: utf-8 -*-
#
# 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/>.
from datetime import date, time
import os
import shutil
import sys
import zipfile
from collections import defaultdict
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.utils import six
from django.utils.translation import ugettext_lazy as _
from django.http import Http404, HttpResponse
from django.db import models, transaction
from django.utils.timezone import make_aware, datetime, get_current_timezone, now, is_naive
from passerelle.base.models import BaseResource
from passerelle.compat import json_loads
from passerelle.utils.api import endpoint
SEXES = (
('M', _('Male')),
('F', _('Female')),
)
DATE_FORMAT = '%Y-%m-%d'
DATETIME_FORMAT = DATE_FORMAT + 'T%H:%M:%S'
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)
except ValueError:
date = datetime.strptime(date, DATE_FORMAT)
if is_naive(date):
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,
'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 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 not old_key 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=(
('native', _('Native')),
('concerto_fondettes', _('Concerto extract from Fondettes')),
('concerto_orleans', _(u'Concerto extract from Orléans')),
),
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:
archive = zipfile.ZipFile(self.archive)
except zipfile.BadZipfile:
raise ValidationError(_('Invalid zip file.'))
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)
return super(GenericFamily, self).clean()
@transaction.atomic
def save(self, *args, **kwargs):
super(GenericFamily, self).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)
archive = zipfile.ZipFile(self.archive.path)
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, created = 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 in data.keys():
if not date_attribute.endswith('_date'):
continue
if date_attribute == 'payment_date':
data[date_attribute] = get_datetime(data[date_attribute])
else:
data[date_attribute] = get_date(data[date_attribute])
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 not file_invoice_id 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', 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='^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='^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='^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
@six.python_2_unicode_compatible
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 u'%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=6,
decimal_places=2, default=0)
amount = models.DecimalField(_('Amount'), max_digits=6, 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'))
response = HttpResponse(open(self.pdf_filename(), 'rb').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)