530 lines
18 KiB
Python
530 lines
18 KiB
Python
# barbacompta - invoicing for dummies
|
|
# Copyright (C) 2019-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/>.
|
|
|
|
|
|
import datetime as dt
|
|
import re
|
|
|
|
from adminsortable2.admin import SortableInlineAdminMixin
|
|
from django import http
|
|
from django.conf.urls import url
|
|
from django.contrib import admin
|
|
from django.contrib.admin.options import BaseModelAdmin
|
|
from django.contrib.humanize.templatetags.humanize import ordinal
|
|
from django.db import transaction
|
|
from django.db.models import TextField
|
|
from django.forms import Textarea
|
|
from django.forms.models import BaseInlineFormSet
|
|
from django.shortcuts import render
|
|
from django.urls import path, reverse
|
|
from django.utils.html import format_html
|
|
from django.utils.six import BytesIO
|
|
|
|
import eo_gestion.admin
|
|
|
|
from .. import actions, ods
|
|
from . import forms, models, views
|
|
from .templatetags.eo_facture import amountformat
|
|
|
|
|
|
class LookupAllowed:
|
|
def lookup_allowed(self, *args, **kwargs):
|
|
return True
|
|
|
|
|
|
class SelectRelatedMixin:
|
|
def get_queryset(self, request):
|
|
qs = super().get_queryset(request)
|
|
return qs.select_related()
|
|
|
|
|
|
class CommonPaymentInline(BaseModelAdmin):
|
|
def formfield_for_foreignkey(self, db_field, request, **kwargs):
|
|
if db_field.name == 'facture' and request.path.endswith('/add/'):
|
|
kwargs['queryset'] = models.Facture.objects.avec_solde()
|
|
if db_field.name == 'ligne_banque_pop' and request.path.endswith('/add/'):
|
|
kwargs['queryset'] = models.encaissements_avec_solde_non_affecte()
|
|
return super().formfield_for_foreignkey(db_field, request, **kwargs)
|
|
|
|
|
|
class PrestationInline(SelectRelatedMixin, admin.TabularInline):
|
|
model = models.Prestation
|
|
form = forms.PrestationForm
|
|
|
|
|
|
class FactureInline(SelectRelatedMixin, admin.TabularInline):
|
|
model = models.Facture
|
|
form = forms.FactureForm
|
|
|
|
|
|
class PaymentInlineFormset(BaseInlineFormSet):
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
for form in self.forms:
|
|
if form.instance.id is None:
|
|
field = form.fields["ligne_banque_pop"]
|
|
if not hasattr(field, "parent_instance"):
|
|
field.queryset = models.encaissements_avec_solde_non_affecte()
|
|
field = form.fields["facture"]
|
|
if not hasattr(field, "parent_instance"):
|
|
field.queryset = models.Facture.objects.avec_solde()
|
|
|
|
|
|
class PaymentInline(SelectRelatedMixin, admin.TabularInline, CommonPaymentInline):
|
|
form = forms.PaymentForm
|
|
|
|
model = models.Payment
|
|
list_display = ['facture', 'ligne_banque_pop', 'montant_affecte']
|
|
formset = PaymentInlineFormset
|
|
extra = 0
|
|
|
|
|
|
class LigneInline(SelectRelatedMixin, SortableInlineAdminMixin, admin.TabularInline):
|
|
form = forms.LigneForm
|
|
model = models.Ligne
|
|
original = False
|
|
verbose_name_plural = "Lignes de facture (vous pouvez les réordonner par drag&drop)"
|
|
formfield_overrides = {
|
|
TextField: {
|
|
'widget': Textarea(attrs={'cols': 30, 'rows': 1}),
|
|
}
|
|
}
|
|
|
|
|
|
class ActiveFilter(admin.SimpleListFilter):
|
|
title = 'statut'
|
|
parameter_name = 'active'
|
|
|
|
def value(self):
|
|
value = super().value()
|
|
return 'True' if value in (None, True) else value
|
|
|
|
def choices(self, changelist):
|
|
choices = list(super().choices(changelist))
|
|
return choices[1:] # don't include automatic "All"
|
|
|
|
def lookups(self, request, model_admin):
|
|
return [(True, 'actif'), (False, 'inactif'), ('all', 'tous')]
|
|
|
|
def queryset(self, request, queryset):
|
|
if self.value() == 'all':
|
|
return queryset
|
|
return queryset.filter(active=bool(self.value() == 'True'))
|
|
|
|
|
|
class ClientAdmin(admin.ModelAdmin):
|
|
form = forms.ClientForm
|
|
list_display = ['nom', 'adresse', 'email', 'telephone']
|
|
list_editable = ['email', 'telephone']
|
|
list_filter = [ActiveFilter]
|
|
ordering = ['nom']
|
|
search_fields = ['nom', 'email']
|
|
save_on_top = True
|
|
list_select_related = True
|
|
raw_id_fields = ['chorus_structure']
|
|
|
|
def get_readonly_fields(self, request, obj=None):
|
|
readonly_fields = super().get_readonly_fields(request, obj=obj)
|
|
if obj and obj.chorus_structure:
|
|
readonly_fields += ('siret', 'service_code')
|
|
return readonly_fields
|
|
|
|
def save_model(self, request, obj, form, change):
|
|
if not obj.id:
|
|
obj.creator = request.user
|
|
obj.save()
|
|
|
|
class Media:
|
|
css = {'all': ('css/client.css',)}
|
|
|
|
|
|
def show_client(obj):
|
|
url = reverse('admin:eo_facture_client_change', args=[obj.client.id])
|
|
return format_html('<a href="{0}">{1}</a>', url, obj.client)
|
|
|
|
|
|
show_client.short_description = 'Client'
|
|
show_client.admin_order_field = 'client'
|
|
|
|
|
|
class ContratAdmin(LookupAllowed, admin.ModelAdmin):
|
|
form = forms.ContratForm
|
|
inlines = [
|
|
PrestationInline,
|
|
]
|
|
list_display = [
|
|
"__str__",
|
|
show_client,
|
|
'column_montant',
|
|
'pourcentage_facture',
|
|
'creation',
|
|
'creator',
|
|
'tag_list',
|
|
]
|
|
list_filter = ['tags', 'client']
|
|
list_select_related = True
|
|
save_on_top = True
|
|
search_fields = ['intitule', 'client__nom', 'tags__name']
|
|
readonly_fields = ['creator']
|
|
list_select_related = True
|
|
actions = [actions.export_references_as_fodt]
|
|
autocomplete_fields = ['client']
|
|
|
|
def column_montant(self, obj):
|
|
return amountformat(obj.montant())
|
|
|
|
column_montant.short_description = 'Montant'
|
|
|
|
def get_queryset(self, request):
|
|
qs = super().get_queryset(request)
|
|
return qs.prefetch_related('prestations', 'factures__lignes', 'tags')
|
|
|
|
def tag_list(self, obj):
|
|
return ', '.join(o.name for o in obj.tags.all())
|
|
|
|
def save_model(self, request, obj, form, change):
|
|
if not obj.id:
|
|
obj.creator = request.user
|
|
obj.save()
|
|
|
|
class Media:
|
|
css = {'all': ('css/contrat.css',)}
|
|
|
|
def duplicate(self, request, contract):
|
|
contrat = models.Contrat.objects.get(id=contract)
|
|
if request.method == 'POST':
|
|
form = forms.DuplicateContractForm(request=request, data=request.POST)
|
|
if form.is_valid():
|
|
return http.HttpResponseRedirect(
|
|
reverse('admin:eo_facture_contrat_change', args=[form.cleaned_data['new_contrat'].id])
|
|
)
|
|
else:
|
|
new_intitule = contrat.intitule
|
|
new_intitule += ' dupliqué le %s' % dt.datetime.now()
|
|
form = forms.DuplicateContractForm(initial={'contrat': contrat.id, 'new_intitule': new_intitule})
|
|
context = {
|
|
'form': form,
|
|
'contrat': contrat,
|
|
'app_label': self.model._meta.app_label,
|
|
'has_change_permission': self.has_change_permission(request),
|
|
'opts': self.model._meta,
|
|
}
|
|
|
|
return render(request, "admin/eo_facture/contrat/duplicate.html", context=context)
|
|
|
|
def facturer_echeance(self, request, object_id):
|
|
if request.method != 'POST':
|
|
raise http.Http404
|
|
|
|
contrat = self.get_object(request, object_id)
|
|
|
|
assert contrat.periodicite
|
|
|
|
echeance = int(request.POST['echeance'])
|
|
|
|
numero = None
|
|
for i, debut, _ in contrat.periodicite_echeances():
|
|
if i == echeance:
|
|
numero = i
|
|
break
|
|
|
|
assert numero is not None
|
|
|
|
with transaction.atomic():
|
|
facture, _ = models.Facture.objects.update_or_create(
|
|
client=contrat.client,
|
|
contrat=contrat,
|
|
echeance=debut,
|
|
numero_d_echeance=numero,
|
|
defaults={
|
|
'creator': request.user,
|
|
'numero_d_echeance': numero,
|
|
},
|
|
)
|
|
if facture.proforma:
|
|
facture.intitule = f'{contrat.intitule} {facture.periode}'
|
|
facture.clean()
|
|
facture.save()
|
|
facture.import_ligne()
|
|
|
|
return http.HttpResponseRedirect(reverse('admin:eo_facture_facture_change', args=(facture.id,)))
|
|
|
|
def get_urls(self):
|
|
urls = super().get_urls()
|
|
duplicate_view = self.admin_site.admin_view(self.duplicate)
|
|
facturer_echeance_view = self.admin_site.admin_view(self.facturer_echeance)
|
|
my_urls = [
|
|
url(r'^(.+)/duplicate/$', duplicate_view, name='eo_facture_contrat_duplicate'),
|
|
path(
|
|
'<path:object_id>/facturer-echeance/',
|
|
facturer_echeance_view,
|
|
name='eo_facture_contrat_facturer_echeance',
|
|
),
|
|
]
|
|
return my_urls + urls
|
|
|
|
def get_fields(self, request, obj=None):
|
|
fields = list(super().get_fields(request, obj=obj))
|
|
if obj:
|
|
if obj.periodicite:
|
|
fields = [field for field in fields if field != 'percentage_per_year']
|
|
elif obj.percentage_per_year and len(obj.percentage_per_year) > 1:
|
|
fields = [field for field in fields if field != 'periodicite']
|
|
return fields
|
|
|
|
|
|
def index(facture):
|
|
return format_html('{0}', ordinal(facture.index()))
|
|
|
|
|
|
index.short_description = "Ordre"
|
|
|
|
|
|
class FactureAdmin(LookupAllowed, admin.ModelAdmin):
|
|
form = forms.FactureForm
|
|
inlines = [LigneInline, PaymentInline]
|
|
list_display = [
|
|
'code',
|
|
index,
|
|
'show_contrat',
|
|
'show_client',
|
|
'emission',
|
|
'creator',
|
|
'column_montant',
|
|
'column_montant_ttc',
|
|
'column_solde',
|
|
'paid',
|
|
]
|
|
list_filter = ['paid', 'emission', 'creator', 'client']
|
|
fields = [
|
|
'proforma',
|
|
'ordre',
|
|
'client',
|
|
'contrat',
|
|
'intitule',
|
|
'numero_engagement',
|
|
'notes',
|
|
'private_notes',
|
|
'taux_tva',
|
|
'emission',
|
|
'echeance',
|
|
'paid',
|
|
'solde',
|
|
'montant',
|
|
'montant_ttc',
|
|
'account_on_previous_period',
|
|
]
|
|
readonly_fields = ['creator', 'solde', 'ordre', 'montant', 'montant_ttc', 'periode']
|
|
date_hierarchy = 'emission'
|
|
list_select_related = True
|
|
save_on_top = True
|
|
search_fields = ['contrat__intitule', 'contrat__client__nom']
|
|
list_select_related = True
|
|
actions = [actions.export_invoices_as_zip]
|
|
autocomplete_fields = ['client']
|
|
|
|
def column_montant(self, obj):
|
|
return amountformat(obj.montant)
|
|
|
|
column_montant.short_description = 'Montant'
|
|
|
|
def column_montant_ttc(self, obj):
|
|
return amountformat(obj.montant_ttc)
|
|
|
|
column_montant_ttc.short_description = 'Montant TTC'
|
|
|
|
def column_solde(self, obj):
|
|
return amountformat(obj.solde())
|
|
|
|
column_solde.short_description = 'Solde'
|
|
|
|
def has_delete_permission(self, request, obj=None):
|
|
# ne pas supprimer les factures émises
|
|
if obj and not obj.proforma:
|
|
return False
|
|
return super().has_delete_permission(request, obj=obj)
|
|
|
|
def get_queryset(self, request):
|
|
from django.db import connection
|
|
|
|
qs = super().get_queryset(request)
|
|
qs = qs.prefetch_related("lignes__facture__client")
|
|
qs = qs.prefetch_related("payments__ligne_banque_pop")
|
|
qs = qs.prefetch_related("contrat__factures")
|
|
if connection.vendor == "postgresql":
|
|
qs = qs.extra({"year": "EXTRACT(year FROM emission)"})
|
|
elif connection.vendor == "sqlite":
|
|
qs = qs.extra({"year": "strftime('%Y', emission)"})
|
|
else:
|
|
qs = qs.extra({"year": "YEAR(emission)"})
|
|
qs = qs.order_by("-year", "-proforma", "-ordre")
|
|
return qs
|
|
|
|
def save_model(self, request, obj, form, change):
|
|
if not obj.id:
|
|
obj.creator = request.user
|
|
obj.save()
|
|
|
|
def add_simple(self, request):
|
|
context = {}
|
|
if request.method == "POST":
|
|
form = forms.RapidFactureForm(request=request, data=request.POST)
|
|
if form.is_valid():
|
|
return http.HttpResponseRedirect(
|
|
reverse('admin:eo_facture_facture_change', args=[form.cleaned_data['facture'].id])
|
|
)
|
|
else:
|
|
form = forms.RapidFactureForm(initial=request.GET)
|
|
context = {
|
|
'form': form,
|
|
'app_label': self.model._meta.app_label,
|
|
'has_change_permission': self.has_change_permission(request),
|
|
'opts': self.model._meta,
|
|
}
|
|
|
|
return render(request, "admin/eo_facture/facture/add_simple.html", context=context)
|
|
|
|
def sheet(self, request):
|
|
workbook = ods.Workbook()
|
|
ws = workbook.add_sheet("Factures")
|
|
for i, header_name in enumerate(
|
|
['Référence', 'Nom client', 'Date', 'Montant HT', 'Montant TTC', 'TVA']
|
|
):
|
|
ws.write(0, i, header_name)
|
|
|
|
qs = self.get_queryset(request)
|
|
qs = sorted(qs, key=lambda i: (i.emission.year, i.ordre or -1, i.emission))
|
|
|
|
for j, facture in enumerate(qs):
|
|
ws.write(j + 1, 0, facture.code())
|
|
ws.write(j + 1, 1, facture.client.nom)
|
|
ws.write(j + 1, 2, facture.emission)
|
|
ws.write(j + 1, 3, facture.montant)
|
|
ws.write(j + 1, 4, facture.montant_ttc)
|
|
ws.write(j + 1, 5, facture.tva)
|
|
stream = BytesIO()
|
|
workbook.save(stream)
|
|
response = http.HttpResponse(
|
|
stream.getvalue(), content_type='application/vnd.oasis.opendocument.spreadsheet'
|
|
)
|
|
response['Content-Disposition'] = 'attachment; filename="factures.ods"'
|
|
return response
|
|
|
|
def get_urls(self):
|
|
urls = super().get_urls()
|
|
my_urls = [
|
|
url(r'^view/([^/]*)', views.facture),
|
|
url(
|
|
r'^add_simple/$',
|
|
self.admin_site.admin_view(self.add_simple),
|
|
name='eo_facture_facture_add_simple',
|
|
),
|
|
url(
|
|
r'^(.+)/view_pdf/',
|
|
self.admin_site.admin_view(views.facture_pdf),
|
|
name='eo_facture_facture_print',
|
|
),
|
|
url(r'^(.+)/view/$', self.admin_site.admin_view(views.facture), name='eo_facture_facture_html'),
|
|
url(
|
|
r'^(.+)/send-to-chorus/',
|
|
self.admin_site.admin_view(views.send_to_chorus),
|
|
name='eo_facture_facture_send_to_chorus',
|
|
),
|
|
url(
|
|
r"^sheet/$",
|
|
self.admin_site.admin_view(self.sheet),
|
|
name="eo_facture_facture_sheet",
|
|
),
|
|
url(
|
|
r'^(.+)/cancel/',
|
|
self.admin_site.admin_view(views.cancel),
|
|
name='eo_facture_facture_cancel',
|
|
),
|
|
]
|
|
return my_urls + urls
|
|
|
|
def show_client(self, obj):
|
|
if obj.client:
|
|
url = reverse('admin:eo_facture_client_change', args=[obj.client.id])
|
|
return format_html('<a href="{0}">{1}</a>', url, obj.client)
|
|
|
|
show_client.short_description = 'Client'
|
|
|
|
def show_contrat(self, obj):
|
|
if obj.contrat:
|
|
url = reverse('admin:eo_facture_contrat_change', args=[obj.contrat.id])
|
|
return format_html('<a href="{0}">{1}</a>', url, obj.contrat)
|
|
return None
|
|
|
|
show_contrat.short_description = 'Contrat'
|
|
|
|
# adapt get_object and get_changelist_instance to produce and accept URL
|
|
# with facture's code
|
|
FACTURE_RE = re.compile('^F(?P<year>20[0-9]{2})(?P<ordre>[0-9]{4})$')
|
|
|
|
def get_object(self, request, object_id, from_field=None):
|
|
m = self.FACTURE_RE.match(object_id)
|
|
if m:
|
|
queryset = self.get_queryset(request)
|
|
model = queryset.model
|
|
year = int(m.group('year'))
|
|
ordre = int(m.group('ordre'))
|
|
try:
|
|
return queryset.get(emission__year=year, ordre=ordre)
|
|
except model.DoesNotExist:
|
|
pass
|
|
return super().get_object(request, object_id, from_field=from_field)
|
|
|
|
def get_changelist_instance(self, request):
|
|
changelist = super().get_changelist_instance(request)
|
|
changelist.pk_attname = 'pk_or_code'
|
|
return changelist
|
|
|
|
def get_fields(self, request, obj=None):
|
|
fields = list(super().get_fields(request, obj=obj))
|
|
if not obj or not obj.contrat or obj.contrat.periodicite:
|
|
fields += ['numero_d_echeance', 'periode']
|
|
return fields
|
|
|
|
def get_readonly_fields(self, request, obj=None):
|
|
fields = list(super().get_readonly_fields(request, obj=obj))
|
|
if obj and obj.contrat and obj.contrat.periodicite:
|
|
fields += ['periode']
|
|
return fields
|
|
|
|
|
|
class PaymentAdmin(LookupAllowed, admin.ModelAdmin, CommonPaymentInline):
|
|
form = forms.PaymentForm
|
|
list_display = ['facture', 'ligne_banque_pop', 'montant_affecte']
|
|
list_filter = ['ligne_banque_pop__date_valeur', 'facture', 'facture__contrat', 'facture__contrat__client']
|
|
list_select_related = True
|
|
|
|
|
|
class PrestationAdmin(admin.ModelAdmin):
|
|
form = forms.PrestationForm
|
|
list_select_related = True
|
|
|
|
|
|
admin.site.register(models.Client, ClientAdmin)
|
|
admin.site.register(models.Contrat, ContratAdmin)
|
|
admin.site.register(models.Facture, FactureAdmin)
|
|
admin.site.register(models.Prestation, PrestationAdmin)
|
|
|
|
eo_gestion.admin.site.register(models.Client, ClientAdmin)
|
|
eo_gestion.admin.site.register(models.Contrat, ContratAdmin)
|
|
eo_gestion.admin.site.register(models.Facture, FactureAdmin)
|
|
eo_gestion.admin.site.register(models.Payment, PaymentAdmin)
|