8
0
Fork 0
mirror of https://gitlab.federez.net/re2o/re2o synced 2024-07-02 04:04:06 +00:00

Merge branch 'feature_document_templates' into 'dev'

Feature document templates

See merge request federez/re2o!391
This commit is contained in:
chirac 2019-01-21 21:03:38 +01:00
commit 411467a520
22 changed files with 741 additions and 49 deletions

View file

@ -164,3 +164,17 @@ Collec new statics
```bash
python3 manage.py collectstatic
```
## MR 391: Document templates and subscription vouchers
Re2o can now use templates for generated invoices. To load default templates run
```bash
./install update
```
Be carefull, you need the proper rights to edit a DocumentTemplate.
Re2o now sends subscription voucher when an invoice is controlled. It uses one
of the templates. You also need to set the name of the president of your association
to be set in your settings.

View file

@ -611,9 +611,11 @@ class HostMacIpView(generics.ListAPIView):
"""Exposes the associations between hostname, mac address and IPv4 in
order to build the DHCP lease files.
"""
queryset = all_active_interfaces()
serializer_class = serializers.HostMacIpSerializer
def get_queryset(self):
return all_active_interfaces()
# Firewall
@ -646,7 +648,7 @@ class DNSZonesView(generics.ListAPIView):
class DNSReverseZonesView(generics.ListAPIView):
"""Exposes the detailed information about each extension (hostnames,
"""Exposes the detailed information about each extension (hostnames,
IPs, DNS records, etc.) in order to build the DNS zone files.
"""
queryset = (machines.IpType.objects.all())

View file

@ -48,7 +48,7 @@ from re2o.field_permissions import FieldPermissionFormMixin
from re2o.mixins import FormRevMixin
from .models import (
Article, Paiement, Facture, Banque,
CustomInvoice, Vente, CostEstimate
CustomInvoice, Vente, CostEstimate,
)
from .payment_methods import balance

View file

@ -46,11 +46,14 @@ from django.urls import reverse
from django.shortcuts import redirect
from django.contrib import messages
from preferences.models import CotisationsOption
from machines.models import regen
from re2o.field_permissions import FieldPermissionModelMixin
from re2o.mixins import AclMixin, RevMixin
from cotisations.utils import find_payment_method, send_mail_invoice
from cotisations.utils import (
find_payment_method, send_mail_invoice, send_mail_voucher
)
from cotisations.validators import check_no_balance
@ -236,15 +239,35 @@ class Facture(BaseInvoice):
'control': self.can_change_control,
}
self.__original_valid = self.valid
self.__original_control = self.control
def get_subscription(self):
"""Returns every subscription associated with this invoice."""
return Cotisation.objects.filter(
vente__in=self.vente_set.filter(
Q(type_cotisation='All') |
Q(type_cotisation='Adhesion')
)
)
def is_subscription(self):
"""Returns True if this invoice contains at least one subscribtion."""
return bool(self.get_subscription())
def save(self, *args, **kwargs):
super(Facture, self).save(*args, **kwargs)
if not self.__original_valid and self.valid:
send_mail_invoice(self)
if self.is_subscription() \
and not self.__original_control \
and self.control \
and CotisationsOption.get_cached_value('send_voucher_mail'):
send_mail_voucher(self)
def __str__(self):
return str(self.user) + ' ' + str(self.date)
@receiver(post_save, sender=Facture)
def facture_post_save(**kwargs):
"""
@ -775,7 +798,7 @@ class Paiement(RevMixin, AclMixin, models.Model):
if payment_method is not None and use_payment_method:
return payment_method.end_payment(invoice, request)
## So make this invoice valid, trigger send mail
# So make this invoice valid, trigger send mail
invoice.valid = True
invoice.save()

View file

@ -75,7 +75,7 @@ with this program; if not, write to the Free Software Foundation, Inc.,
{% if estimate.final_invoice %}
<a href="{% url 'cotisations:edit-custom-invoice' estimate.final_invoice.pk %}"><i style="color: #1ECA18;" class="fa fa-check"></i></a>
{% else %}
<i style="color: #D10115;" class="fa fa-times"></i>'
<i style="color: #D10115;" class="fa fa-times"></i>
{% endif %}
</td>
<td>

View file

@ -48,7 +48,7 @@ with this program; if not, write to the Free Software Foundation, Inc.,
{% trans "Date" as tr_date %}
{% include 'buttons/sort.html' with prefix='cotis' col='date' text=tr_date %}
</th>
<th>
<th>
{% trans "Invoice ID" as tr_invoice_id %}
{% include 'buttons/sort.html' with prefix='cotis' col='id' text=tr_invoice_id %}
</th>
@ -63,17 +63,17 @@ with this program; if not, write to the Free Software Foundation, Inc.,
<td>{{ facture.prix_total }}</td>
<td>{{ facture.paiement }}</td>
<td>{{ facture.date }}</td>
<td>{{ facture.id }}</td>
<td>{{ facture.id }}</td>
<td>
{% can_edit facture %}
{% include 'buttons/edit.html' with href='cotisations:edit-facture' id=facture.id %}
{% include 'buttons/edit.html' with href='cotisations:edit-facture' id=facture.id %}
{% acl_else %}
{% trans "Controlled invoice" %}
{% acl_end %}
{% can_delete facture %}
{% include 'buttons/suppr.html' with href='cotisations:del-facture' id=facture.id %}
{% include 'buttons/suppr.html' with href='cotisations:del-facture' id=facture.id %}
{% acl_end %}
{% history_button facture %}
{% history_button facture %}
</td>
<td>
{% if facture.valid %}
@ -83,13 +83,18 @@ with this program; if not, write to the Free Software Foundation, Inc.,
{% else %}
<i class="text-danger">{% trans "Invalidated invoice" %}</i>
{% endif %}
{% if facture.control and facture.is_subscription %}
<a class="btn btn-primary btn-sm" role="button" href="{% url 'cotisations:voucher-pdf' facture.id %}">
<i class="fa fa-file-pdf-o"></i> {% trans "Voucher" %}
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% if facture_list.paginator %}
{% if facture_list.paginator %}
{% include 'pagination.html' with list=facture_list %}
{% endif %}
{% endif %}
</div>

View file

@ -0,0 +1,22 @@
Bonjour {{name}} !
Nous vous informons que votre cotisation auprès de {{asso_name}} a été acceptée. Vous voilà donc membre de l'association.
Vous trouverez en pièce jointe un reçu.
Pour nous faire part de toute remarque, suggestion ou problème vous pouvez nous envoyer un mail à {{asso_email}}.
À bientôt,
L'équipe de {{asso_name}}.
---
Your subscription to {{asso_name}} has just been accepted. You are now a full member of {{asso_name}}.
You will find with this email a subscription voucher.
For any information, suggestion or problem, you can contact us via email at
{{asso_email}}.
Regards,
The {{asso_name}} team.

View file

@ -0,0 +1,87 @@
{% load i18n %}
{% language 'fr' %}
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
% Invoice Template
% LaTeX Template
% Version 1.0 (3/11/12)
%% This template has been downloaded from:
% http://www.LaTeXTemplates.com
%
% Original author:
% Trey Hunner (http://www.treyhunner.com/)
%
% License:
% CC BY-NC-SA 3.0 (http://creativecommons.org/licenses/by-nc-sa/3.0/)
%
% Important note:
% This template requires the invoice.cls file to be in the same directory as
% the .tex file. The invoice.cls file provides the style used for structuring the
% document.
%
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%----------------------------------------------------------------------------------------
% DOCUMENT CONFIGURATION
%----------------------------------------------------------------------------------------
\documentclass[12pt]{article} % Use the custom invoice class (invoice.cls)
\usepackage[utf8]{inputenc}
\usepackage[letterpaper,hmargin=0.79in,vmargin=0.79in]{geometry}
\usepackage{longtable}
\usepackage{graphicx}
\usepackage{tabularx}
\usepackage{eurosym}
\usepackage{multicol}
\pagestyle{empty} % No page numbers
\linespread{1.5}
\newcommand{\doublehline}{\noalign{\hrule height 1pt}}
\setlength{\parindent}{0cm}
\begin{document}
%----------------------------------------------------------------------------------------
% HEADING SECTION
%----------------------------------------------------------------------------------------
\begin{center}
{\Huge\bf Reçu d'adhésion \\ {{asso_name|safe}} } % Company providing the invoice
\end{center}
\bigskip
\hrule
\bigskip
\vfill
Je sousigné, {{pres_name|safe}}, déclare par la présente avoir reçu le bulletin d'adhésion de:
\begin{center}
\setlength{\tabcolsep}{10pt} % Make table columns tighter, usefull for postionning
\begin{tabular}{r l r l}
{\bf Prénom :}~ & {{firstname|safe}} & {% if phone %}{\bf Téléphone :}~ & {{phone}}{% else %} & {% endif %} \\
{\bf Nom :}~ & {{lastname|safe}} & {\bf Mail :}~ & {{email|safe}} \\
\end{tabular}
\end{center}
\bigskip
ainsi que sa cotisation.
Le postulant, déclare reconnaître l'objet de l'association, et en a accepté les statuts ainsi que le règlement intérieur qui sont mis à sa disposition dans les locaux de l'association. L'adhésion du membre sus-nommé est ainsi validée. Ce reçu confirme la qualité de membre du postulant, et ouvre droit à la participation à l'assemblée générale de l'association jusqu'au {{date_end|date:"d F Y"}}.
\bigskip
Validé électroniquement par {{pres_name|safe}}, le {{date_begin|date:"d/m/Y"}}.
\vfill
\hrule
\smallskip
\footnotesize
Les informations recueillies sont nécessaires pour votre adhésion. Conformément à la loi "Informatique et Libertés" du 6 janvier 1978, vous disposez d'un droit d'accès et de rectification aux données personnelles vous concernant. Pour l'exercer, adressez-vous au secrétariat de l'association.
\end{document}
{% endlanguage %}

View file

@ -31,12 +31,16 @@ from subprocess import Popen, PIPE
import os
from datetime import datetime
from django.db import models
from django.template.loader import get_template
from django.template import Context
from django.http import HttpResponse
from django.conf import settings
from django.utils.text import slugify
import logging
from django.utils.translation import ugettext_lazy as _
from re2o.mixins import AclMixin, RevMixin
from preferences.models import CotisationsOption
TEMP_PREFIX = getattr(settings, 'TEX_TEMP_PREFIX', 'render_tex-')
@ -49,6 +53,7 @@ def render_invoice(_request, ctx={}):
Render an invoice using some available information such as the current
date, the user, the articles, the prices, ...
"""
options, _ = CotisationsOption.objects.get_or_create()
is_estimate = ctx.get('is_estimate', False)
filename = '_'.join([
'cost_estimate' if is_estimate else 'invoice',
@ -58,7 +63,30 @@ def render_invoice(_request, ctx={}):
str(ctx.get('DATE', datetime.now()).month),
str(ctx.get('DATE', datetime.now()).day),
])
r = render_tex(_request, 'cotisations/factures.tex', ctx)
templatename = options.invoice_template.template.name.split('/')[-1]
r = render_tex(_request, templatename, ctx)
r['Content-Disposition'] = 'attachment; filename="{name}.pdf"'.format(
name=filename
)
return r
def render_voucher(_request, ctx={}):
"""
Render a subscribtion voucher.
"""
options, _ = CotisationsOption.objects.get_or_create()
filename = '_'.join([
'voucher',
slugify(ctx.get('asso_name', "")),
slugify(ctx.get('firstname', "")),
slugify(ctx.get('lastname', "")),
str(ctx.get('date_begin', datetime.now()).year),
str(ctx.get('date_begin', datetime.now()).month),
str(ctx.get('date_begin', datetime.now()).day),
])
templatename = options.voucher_template.template.name.split('/')[-1]
r = render_tex(_request, templatename, ctx)
r['Content-Disposition'] = 'attachment; filename="{name}.pdf"'.format(
name=filename
)
@ -83,12 +111,13 @@ def create_pdf(template, ctx={}):
with tempfile.TemporaryDirectory() as tempdir:
for _ in range(2):
process = Popen(
['pdflatex', '-output-directory', tempdir],
stdin=PIPE,
stdout=PIPE,
)
process.communicate(rendered_tpl)
with open("/var/www/re2o/out.log", "w") as f:
process = Popen(
['pdflatex', '-output-directory', tempdir],
stdin=PIPE,
stdout=f,#PIPE,
)
process.communicate(rendered_tpl)
with open(os.path.join(tempdir, 'texput.pdf'), 'rb') as f:
pdf = f.read()

View file

@ -51,6 +51,11 @@ urlpatterns = [
views.facture_pdf,
name='facture-pdf'
),
url(
r'^voucher_pdf/(?P<factureid>[0-9]+)$',
views.voucher_pdf,
name='voucher-pdf'
),
url(
r'^new_cost_estimate/$',
views.new_cost_estimate,
@ -176,5 +181,5 @@ urlpatterns = [
views.control,
name='control'
),
url(r'^$', views.index, name='index'),
url(r'^$', views.index, name='index'),
] + payment_methods.urls.urlpatterns

View file

@ -25,7 +25,7 @@ from django.template.loader import get_template
from django.core.mail import EmailMessage
from .tex import create_pdf
from preferences.models import AssoOption, GeneralOption
from preferences.models import AssoOption, GeneralOption, CotisationsOption
from re2o.settings import LOGO_PATH
from re2o import settings
@ -93,3 +93,38 @@ def send_mail_invoice(invoice):
attachments=[('invoice.pdf', pdf, 'application/pdf')]
)
mail.send()
def send_mail_voucher(invoice):
"""Creates a voucher from an invoice and sends it by email to the client"""
ctx = {
'asso_name': AssoOption.get_cached_value('name'),
'pres_name': AssoOption.get_cached_value('pres_name'),
'firstname': invoice.user.name,
'lastname': invoice.user.surname,
'email': invoice.user.email,
'phone': invoice.user.telephone,
'date_end': invoice.get_subscription().latest('date_end').date_end,
'date_begin': invoice.get_subscription().earliest('date_start').date_start
}
templatename = CotisationsOption.get_cached_value('voucher_template').template.name.split('/')[-1]
pdf = create_pdf(templatename, ctx)
template = get_template('cotisations/email_subscription_accepted')
ctx = {
'name': "{} {}".format(
invoice.user.name,
invoice.user.surname
),
'asso_email': AssoOption.get_cached_value('contact'),
'asso_name': AssoOption.get_cached_value('name')
}
mail = EmailMessage(
'Votre reçu / Your voucher',
template.render(ctx),
GeneralOption.get_cached_value('email_from'),
[invoice.user.get_mail],
attachments=[('voucher.pdf', pdf, 'application/pdf')]
)
mail.send()

View file

@ -69,7 +69,7 @@ from .models import (
Banque,
CustomInvoice,
BaseInvoice,
CostEstimate
CostEstimate,
)
from .forms import (
FactureForm,
@ -85,7 +85,7 @@ from .forms import (
DiscountForm,
CostEstimateForm,
)
from .tex import render_invoice, escape_chars
from .tex import render_invoice, render_voucher, escape_chars
from .payment_methods.forms import payment_method_factory
from .utils import find_payment_method
@ -217,6 +217,7 @@ def new_cost_estimate(request):
number=quantity
)
discount_form.apply_to_invoice(cost_estimate_instance)
messages.success(
request,
_("The cost estimate was created.")
@ -482,7 +483,6 @@ def cost_estimate_pdf(request, invoice, **_kwargs):
invoice with the total price, the payment method, the address and the
legal information for the user.
"""
# TODO : change vente to purchase
purchases_objects = Vente.objects.all().filter(facture=invoice)
# Get the article list and build an list out of it
# contiaining (article_name, article_price, quantity, total_price)
@ -720,7 +720,7 @@ def edit_paiement(request, paiement_instance, **_kwargs):
if payment_method is not None:
payment_method.save()
messages.success(
request,_("The payment method was edited.")
request, _("The payment method was edited.")
)
return redirect(reverse('cotisations:index-paiement'))
return form({
@ -954,7 +954,8 @@ def index_custom_invoice(request):
"""View used to display every custom invoice."""
pagination_number = GeneralOption.get_cached_value('pagination_number')
cost_estimate_ids = [i for i, in CostEstimate.objects.values_list('id')]
custom_invoice_list = CustomInvoice.objects.prefetch_related('vente_set').exclude(id__in=cost_estimate_ids)
custom_invoice_list = CustomInvoice.objects.prefetch_related(
'vente_set').exclude(id__in=cost_estimate_ids)
custom_invoice_list = SortTable.sort(
custom_invoice_list,
request.GET.get('col'),
@ -1020,7 +1021,8 @@ def credit_solde(request, user, **_kwargs):
kwargs={'userid': user.id}
))
refill_form = RechargeForm(request.POST or None, user=user, user_source=request.user)
refill_form = RechargeForm(
request.POST or None, user=user, user_source=request.user)
if refill_form.is_valid():
price = refill_form.cleaned_data['value']
invoice = Facture(user=user)
@ -1050,3 +1052,29 @@ def credit_solde(request, user, **_kwargs):
'max_balance': find_payment_method(p).maximum_balance,
}, 'cotisations/facture.html', request)
@login_required
@can_view(Facture)
def voucher_pdf(request, invoice, **_kwargs):
"""
View used to generate a PDF file from a controlled invoice
Creates a line for each Purchase (thus article sold) and generate the
invoice with the total price, the payment method, the address and the
legal information for the user.
"""
if not invoice.control:
messages.error(
request,
_("Could not find a voucher for that invoice.")
)
return redirect(reverse('cotisations:index'))
return render_voucher(request, {
'asso_name': AssoOption.get_cached_value('name'),
'pres_name': AssoOption.get_cached_value('pres_name'),
'firstname': invoice.user.name,
'lastname': invoice.user.surname,
'email': invoice.user.email,
'phone': invoice.user.telephone,
'date_end': invoice.get_subscription().latest('date_end').date_end,
'date_begin': invoice.date
})

View file

@ -59,7 +59,7 @@ _ask_value() {
install_requirements() {
### Usage: install_requirements
### Usage: install_requirements
#
# This function will install the required packages from APT repository
# and Pypi repository. Those packages are all required for Re2o to work
@ -273,7 +273,7 @@ write_settings_file() {
django_secret_key="$(python -c "import random; print(''.join([random.SystemRandom().choice('abcdefghijklmnopqrstuvwxyz0123456789%=+') for i in range(50)]))")"
aes_key="$(python -c "import random; print(''.join([random.SystemRandom().choice('abcdefghijklmnopqrstuvwxyz0123456789%=+') for i in range(32)]))")"
if [ "$db_engine_type" == 1 ]; then
sed -i 's/db_engine/django.db.backends.mysql/g' "$SETTINGS_LOCAL_FILE"
else
@ -324,6 +324,21 @@ update_django() {
copy_templates_files() {
### Usage: copy_templates_files
#
# This will copy LaTeX templates in the media root.
echo "Copying LaTeX templates ..."
mkdir -p media/templates/
cp cotisations/templates/cotisations/factures.tex media/templates/default_invoice.tex
cp cotisations/templates/cotisations/voucher.tex media/templates/default_voucher.tex
chown -R www-data:www-data media/templates/
echo "Copying LaTeX templates: Done"
}
create_superuser() {
### Usage: create_superuser
#
@ -476,7 +491,7 @@ interactive_guide() {
sql_host="$(dialog --clear --backtitle "$BACKTITLE" \
--title "$TITLE" --inputbox "$INPUTBOX" \
$HEIGHT $WIDTH 2>&1 >/dev/tty)"
# Prompt to enter the remote database name
TITLE="SQL database name"
INPUTBOX="The name of the remote SQL database"
@ -523,14 +538,14 @@ interactive_guide() {
ldap_is_local="$(dialog --clear --backtitle "$BACKTITLE" \
--title "$TITLE" --menu "$MENU" \
$HEIGHT $WIDTH $CHOICE_HEIGHT "${OPTIONS[@]}" 2>&1 >/dev/tty)"
# Prompt to enter the LDAP domain extension
TITLE="Domain extension"
INPUTBOX="The local domain extension to use (e.g. 'example.net'). This is used in the LDAP configuration."
extension_locale="$(dialog --clear --backtitle "$BACKTITLE" \
--title "$TITLE" --inputbox "$INPUTBOX" \
$HEIGHT $WIDTH 2>&1 >/dev/tty)"
# Building the DN of the LDAP from the extension
IFS='.' read -a extension_locale_array <<< $extension_locale
for i in "${extension_locale_array[@]}"
@ -546,7 +561,7 @@ interactive_guide() {
ldap_host="$(dialog --clear --backtitle "$BACKTITLE" \
--title "$TITLE" --inputbox "$INPUTBOX" \
$HEIGHT $WIDTH 2>&1 >/dev/tty)"
# Prompt to choose if TLS should be activated or not for the LDAP
TITLE="TLS on LDAP"
MENU="Would you like to activate TLS for communicating with the remote LDAP ?"
@ -583,7 +598,7 @@ interactive_guide() {
#########################
BACKTITLE="Re2o setup - configuration of the mail server"
# Prompt to enter the hostname of the mail server
TITLE="Mail server hostname"
INPUTBOX="The hostname of the mail server to use"
@ -591,7 +606,7 @@ interactive_guide() {
--title "$TITLE" --inputbox "$TITLE" \
$HEIGHT $WIDTH 2>&1 >/dev/tty)"
# Prompt to choose the port of the mail server
# Prompt to choose the port of the mail server
TITLE="Mail server port"
MENU="Which port (thus which protocol) to use to contact the mail server"
OPTIONS=(25 "SMTP"
@ -608,7 +623,7 @@ interactive_guide() {
########################
BACKTITLE="Re2o setup - configuration of the web server"
# Prompt to choose the web server
TITLE="Web server to use"
MENU="Which web server to install for accessing Re2o web frontend (automatic setup of nginx is not supported) ?"
@ -617,14 +632,14 @@ interactive_guide() {
web_serveur="$(dialog --clear --backtitle "$BACKTITLE" \
--title "$TITLE" --menu "$MENU" \
$HEIGHT $WIDTH $CHOICE_HEIGHT "${OPTIONS[@]}" 2>&1 >/dev/tty)"
# Prompt to enter the requested URL for the web frontend
TITLE="Web URL"
INPUTBOX="URL for accessing the web server (e.g. re2o.example.net). Be sure that this URL is accessible and correspond to a DNS entry (if applicable)."
url_server="$(dialog --clear --backtitle "$BACKTITLE" \
--title "$TITLE" --inputbox "$INPUTBOX" \
$HEIGHT $WIDTH 2>&1 >/dev/tty)"
# Prompt to choose if the TLS should be setup or not for the web server
TITLE="TLS on web server"
MENU="Would you like to activate the TLS (with Let'Encrypt) on the web server ?"
@ -679,7 +694,7 @@ interactive_guide() {
update_django
create_superuser
install_webserver "$web_serveur" "$is_tls" "$url_server"
@ -748,9 +763,10 @@ main_function() {
echo " * {help} ---------- Display this quick usage documentation"
echo " * {setup} --------- Launch the full interactive guide to setup entirely"
echo " re2o from scratch"
echo " * {update} -------- Collect frontend statics, install the missing APT"
echo " * {update} -------- Collect frontend statics, install the missing APT and copy LaTeX templates files"
echo " and pip packages and apply the migrations to the DB"
echo " * {update-django} - Apply Django migration and collect frontend statics"
echo " * {copy-template-files} - Copy LaTeX templates files to media/templates"
echo " * {update-packages} Install the missing APT and pip packages"
echo " * {update-settings} Interactively rewrite the settings file"
echo " * {reset-db} ------ Erase the previous local database, setup a new empty"
@ -782,9 +798,14 @@ main_function() {
update )
install_requirements
copy_templates_files
update_django
;;
copy-templates-files )
copy_templates_files
;;
update-django )
update_django
;;
@ -800,7 +821,7 @@ main_function() {
reset-db )
if [ ! -z "$2" ]; then
db_password="$2"
case "$3" in
case "$3" in
mysql )
db_engine_type=1;;
postresql )

View file

@ -40,7 +40,8 @@ from .models import (
HomeOption,
RadiusKey,
SwitchManagementCred,
Reminder
Reminder,
DocumentTemplate
)
@ -101,6 +102,12 @@ class ReminderAdmin(VersionAdmin):
"""Class reminder for switch"""
pass
class DocumentTemplateAdmin(VersionAdmin):
"""Admin class for DocumentTemplate"""
pass
admin.site.register(OptionalUser, OptionalUserAdmin)
admin.site.register(OptionalMachine, OptionalMachineAdmin)
admin.site.register(OptionalTopologie, OptionalTopologieAdmin)
@ -113,3 +120,4 @@ admin.site.register(RadiusKey, RadiusKeyAdmin)
admin.site.register(SwitchManagementCred, SwitchManagementCredAdmin)
admin.site.register(AssoOption, AssoOptionAdmin)
admin.site.register(MailMessageOption, MailMessageOptionAdmin)
admin.site.register(DocumentTemplate, DocumentTemplateAdmin)

View file

@ -43,9 +43,12 @@ from .models import (
RadiusKey,
SwitchManagementCred,
RadiusOption,
CotisationsOption,
DocumentTemplate
)
from topologie.models import Switch
class EditOptionalUserForm(ModelForm):
"""Formulaire d'édition des options de l'user. (solde, telephone..)"""
class Meta:
@ -182,9 +185,6 @@ class EditAssoOptionForm(ModelForm):
self.fields['pseudo'].label = _("Usual name")
self.fields['utilisateur_asso'].label = _("Account used for editing"
" from /admin")
self.fields['payment'].label = _("Payment")
self.fields['payment_id'].label = _("Payment ID")
self.fields['payment_pass'].label = _("Payment password")
self.fields['description'].label = _("Description")
@ -253,6 +253,13 @@ class EditRadiusOptionForm(ModelForm):
return cleaned_data
class EditCotisationsOptionForm(ModelForm):
"""Edition forms for Cotisations options"""
class Meta:
model = CotisationsOption
fields = '__all__'
class ServiceForm(ModelForm):
"""Edition, ajout de services sur la page d'accueil"""
class Meta:
@ -371,3 +378,36 @@ class DelMailContactForm(Form):
else:
self.fields['mailcontacts'].queryset = MailContact.objects.all()
class DocumentTemplateForm(FormRevMixin, ModelForm):
"""
Form used to create a document template.
"""
class Meta:
model = DocumentTemplate
fields = '__all__'
def __init__(self, *args, **kwargs):
prefix = kwargs.pop('prefix', self.Meta.model.__name__)
super(DocumentTemplateForm, self).__init__(
*args, prefix=prefix, **kwargs)
class DelDocumentTemplateForm(FormRevMixin, Form):
"""
Form used to delete one or more document templatess.
The use must choose the one to delete by checking the boxes.
"""
document_templates = forms.ModelMultipleChoiceField(
queryset=DocumentTemplate.objects.none(),
label=_("Available document templates"),
widget=forms.CheckboxSelectMultiple
)
def __init__(self, *args, **kwargs):
instances = kwargs.pop('instances', None)
super(DelDocumentTemplateForm, self).__init__(*args, **kwargs)
if instances:
self.fields['document_templates'].queryset = instances
else:
self.fields['document_templates'].queryset = Banque.objects.all()

View file

@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2019-01-20 23:39
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
import preferences.models
import re2o.mixins
def create_defaults(apps, schema_editor):
CotisationsOption = apps.get_model('preferences', 'CotisationsOption')
CotisationsOption.objects.get_or_create()
class Migration(migrations.Migration):
dependencies = [
('preferences', '0058_auto_20190108_1650'),
]
operations = [
migrations.CreateModel(
name='CotisationsOption',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('send_voucher_mail', models.BooleanField(default=False, verbose_name='Send voucher by email when the invoice is controlled.')),
],
options={
'verbose_name': 'cotisations options',
},
bases=(re2o.mixins.AclMixin, models.Model),
),
migrations.CreateModel(
name='DocumentTemplate',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('template', models.FileField(upload_to='templates/', verbose_name='template')),
('name', models.CharField(max_length=125, unique=True, verbose_name='name')),
],
options={
'verbose_name': 'document template',
'verbose_name_plural': 'document templates',
},
bases=(re2o.mixins.RevMixin, re2o.mixins.AclMixin, models.Model),
),
migrations.AddField(
model_name='assooption',
name='pres_name',
field=models.CharField(default='', help_text='Displayed on subscription vouchers', max_length=255, verbose_name='President of the association'),
),
migrations.AddField(
model_name='cotisationsoption',
name='invoice_template',
field=models.OneToOneField(default=preferences.models.default_invoice, on_delete=django.db.models.deletion.PROTECT, related_name='invoice_template', to='preferences.DocumentTemplate', verbose_name='Template for invoices'),
),
migrations.AddField(
model_name='cotisationsoption',
name='voucher_template',
field=models.OneToOneField(default=preferences.models.default_voucher, on_delete=django.db.models.deletion.PROTECT, related_name='voucher_template', to='preferences.DocumentTemplate', verbose_name='Template for subscription voucher'),
),
migrations.RunPython(create_defaults),
]

View file

@ -24,6 +24,7 @@
Reglages généraux, machines, utilisateurs, mail, general pour l'application.
"""
from __future__ import unicode_literals
import os
from django.utils.functional import cached_property
from django.utils import timezone
@ -36,7 +37,7 @@ from django.utils.translation import ugettext_lazy as _
import machines.models
from re2o.mixins import AclMixin
from re2o.mixins import AclMixin, RevMixin
from re2o.aes_field import AESEncryptedField
from datetime import timedelta
@ -521,6 +522,12 @@ class AssoOption(AclMixin, PreferencesModel):
null=True,
blank=True,
)
pres_name = models.CharField(
max_length=255,
default="",
verbose_name=_("President of the association"),
help_text=_("Displayed on subscription vouchers")
)
class Meta:
permissions = (
@ -687,3 +694,95 @@ class RadiusOption(AclMixin, PreferencesModel):
null=True
)
def default_invoice():
tpl, _ = DocumentTemplate.objects.get_or_create(
name="Re2o default invoice",
template="templates/default_invoice.tex"
)
return tpl.id
def default_voucher():
tpl, _ = DocumentTemplate.objects.get_or_create(
name="Re2o default voucher",
template="templates/default_voucher.tex"
)
return tpl.id
class CotisationsOption(AclMixin, PreferencesModel):
class Meta:
verbose_name = _("cotisations options")
invoice_template = models.OneToOneField(
'preferences.DocumentTemplate',
verbose_name=_("Template for invoices"),
related_name="invoice_template",
on_delete=models.PROTECT,
default=default_invoice,
)
voucher_template = models.OneToOneField(
'preferences.DocumentTemplate',
verbose_name=_("Template for subscription voucher"),
related_name="voucher_template",
on_delete=models.PROTECT,
default=default_voucher,
)
send_voucher_mail = models.BooleanField(
verbose_name=_("Send voucher by email when the invoice is controlled."),
default=False,
)
class DocumentTemplate(RevMixin, AclMixin, models.Model):
"""Represent a template in order to create documents such as invoice or
subscription voucher.
"""
template = models.FileField(
upload_to='templates/',
verbose_name=_('template')
)
name = models.CharField(
max_length=125,
verbose_name=_('name'),
unique=True
)
class Meta:
verbose_name = _("document template")
verbose_name_plural = _("document templates")
def __str__(self):
return str(self.name)
@receiver(models.signals.post_delete, sender=DocumentTemplate)
def auto_delete_file_on_delete(sender, instance, **kwargs):
"""
Deletes file from filesystem
when corresponding `DocumentTemplate` object is deleted.
"""
if instance.template:
if os.path.isfile(instance.template.path):
os.remove(instance.template.path)
@receiver(models.signals.pre_save, sender=DocumentTemplate)
def auto_delete_file_on_change(sender, instance, **kwargs):
"""
Deletes old file from filesystem
when corresponding `DocumentTemplate` object is updated
with new file.
"""
if not instance.pk:
return False
try:
old_file = DocumentTemplate.objects.get(pk=instance.pk).template
except DocumentTemplate.DoesNotExist:
return False
new_file = instance.template
if not old_file == new_file:
if os.path.isfile(old_file.path):
os.remove(old_file.path)

View file

@ -0,0 +1,50 @@
{% comment %}
Re2o est un logiciel d'administration développé initiallement au rezometz. Il
se veut agnostique au réseau considéré, de manière à être installable en
quelques clics.
Copyright © 2017 Gabriel Détraz
Copyright © 2017 Goulven Kermarec
Copyright © 2017 Augustin Lemesle
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 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 General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
{% endcomment %}
{% load acl %}
{% load i18n %}
{% load logs_extra %}
<table class="table table-striped">
<thead>
<tr>
<th>{% trans "Document template" %}</th>
<th>{% trans "File" %}</th>
<th></th>
</tr>
</thead>
{% for template in document_template_list %}
<tr>
<td>{{ template.name }}</td>
<td><a href="{{template.template.url}}">{{template.template}}</a></td>
<td class="text-right">
{% can_edit template %}
{% include 'buttons/edit.html' with href='preferences:edit-document-template' id=template.id %}
{% acl_end %}
{% history_button template %}
</td>
</tr>
{% endfor %}
</table>

View file

@ -343,9 +343,59 @@ with this program; if not, write to the Free Software Foundation, Inc.,
<th>{% trans "Description of the organisation" %}</th>
<td>{{ assooptions.description|safe }}</td>
</tr>
<tr>
<th>{% trans "President of the association"%}</th>
<td>{{ assooptions.pres_name }}</td>
</tr>
</table>
</div>
</div>
<div class="panel panel-default" id="templates">
<div class="panel-heading" data-toggle="collapse" href="#collapse_templates">
<h4 class="panel-title">
<a><i class="fa fa-edit"></i> {% trans "Document templates" %}</a>
</h4>
</div>
<div id="collapse_templates" class="panel-collapse panel-body collapse">
{% can_create DocumentTemplate %}
<a class="btn btn-primary btn-sm" role="button" href="{% url 'preferences:add-document-template' %}">
<i class="fa fa-cart-plus"></i> {% trans "Add a document template" %}
</a>
{% acl_end %}
<a class="btn btn-danger btn-sm" role="button" href="{% url 'preferences:del-document-template' %}">
<i class="fa fa-trash"></i> {% trans "Delete one or several document templates" %}
</a>
{% include 'preferences/aff_document_template.html' %}
</div>
</div>
<div class="panel panel-default" id="cotisation">
<div class="panel-heading" data-toggle="collapse" href="#collapse_cotisation">
<h4 class="panel-title">
<a><i class="fa fa-eur"></i> {% trans "Cotisation's options" %}</a>
</h4>
</div>
<div id="collapse_cotisation" class="panel-collapse panel-body collapse">
<a class="btn btn-primary btn-sm" role="button" href="{% url 'preferences:edit-options' 'CotisationsOption' %}">
<i class="fa fa-edit"></i>
</a>
<table class="table table-striped">
<tr>
<th>{% trans "Send voucher by email" %}</th>
<td>{{ cotisationsoptions.send_voucher_mail | tick }}</th>
</tr>
<tr>
<th>{% trans "Invoices' template" %}</th>
<td>{{ cotisationsoptions.invoice_template }}</td>
</tr>
<tr>
<th>{% trans "Vouchers' template" %}</th>
<td>{{ cotisationsoptions.voucher_template }}</td>
</tr>
</table>
</div>
</div>
<div class="panel panel-default" id="mail">
<div class="panel-heading" data-toggle="collapse" href="#collapse_mail">

View file

@ -71,6 +71,11 @@ urlpatterns = [
views.edit_options,
name='edit-options'
),
url(
r'^edit_options/(?P<section>CotisationsOption)$',
views.edit_options,
name='edit-options'
),
url(r'^add_service/$', views.add_service, name='add-service'),
url(
r'^edit_service/(?P<serviceid>[0-9]+)$',
@ -106,5 +111,20 @@ urlpatterns = [
name='edit-switchmanagementcred'
),
url(r'^del_switchmanagementcred/(?P<switchmanagementcredid>[0-9]+)$', views.del_switchmanagementcred, name='del-switchmanagementcred'),
url(
r'^add_document_template/$',
views.add_document_template,
name='add-document-template'
),
url(
r'^edit_document_template/(?P<documenttemplateid>[0-9]+)$',
views.edit_document_template,
name='edit-document-template'
),
url(
r'^del_document_template/$',
views.del_document_template,
name='del-document-template'
),
url(r'^$', views.display_options, name='display-options'),
]

View file

@ -48,7 +48,9 @@ from .forms import (
ServiceForm,
ReminderForm,
RadiusKeyForm,
SwitchManagementCredForm
SwitchManagementCredForm,
DocumentTemplateForm,
DelDocumentTemplateForm
)
from .models import (
Service,
@ -64,6 +66,8 @@ from .models import (
RadiusKey,
SwitchManagementCred,
RadiusOption,
CotisationsOption,
DocumentTemplate
)
from . import models
from . import forms
@ -88,6 +92,8 @@ def display_options(request):
radiuskey_list = RadiusKey.objects.all()
switchmanagementcred_list = SwitchManagementCred.objects.all()
radiusoptions, _ = RadiusOption.objects.get_or_create()
cotisationsoptions, _created = CotisationsOption.objects.get_or_create()
document_template_list = DocumentTemplate.objects.order_by('name')
return form({
'useroptions': useroptions,
'machineoptions': machineoptions,
@ -102,6 +108,8 @@ def display_options(request):
'radiuskey_list' : radiuskey_list,
'switchmanagementcred_list': switchmanagementcred_list,
'radiusoptions' : radiusoptions,
'cotisationsoptions': cotisationsoptions,
'document_template_list': document_template_list,
}, 'preferences/display_preferences.html', request)
@ -405,3 +413,86 @@ def del_mailcontact(request, instances):
request
)
@login_required
@can_create(DocumentTemplate)
def add_document_template(request):
"""
View used to add a document template.
"""
document_template = DocumentTemplateForm(
request.POST or None,
request.FILES or None,
)
if document_template.is_valid():
document_template.save()
messages.success(
request,
_("The document template was created.")
)
return redirect(reverse('preferences:display-options'))
return form({
'preferenceform': document_template,
'action_name': _("Add"),
'title': _("New document template")
}, 'preferences/preferences.html', request)
@login_required
@can_edit(DocumentTemplate)
def edit_document_template(request, document_template_instance, **_kwargs):
"""
View used to edit a document_template.
"""
document_template = DocumentTemplateForm(
request.POST or None,
request.FILES or None,
instance=document_template_instance)
if document_template.is_valid():
if document_template.changed_data:
document_template.save()
messages.success(
request,
_("The document template was edited.")
)
return redirect(reverse('preferences:display-options'))
return form({
'preferenceform': document_template,
'action_name': _("Edit"),
'title': _("Edit document template")
}, 'preferences/preferences.html', request)
@login_required
@can_delete_set(DocumentTemplate)
def del_document_template(request, instances):
"""
View used to delete a set of document template.
"""
document_template = DelDocumentTemplateForm(
request.POST or None, instances=instances)
if document_template.is_valid():
document_template_del = document_template.cleaned_data['document_templates']
for document_template in document_template_del:
try:
document_template.delete()
messages.success(
request,
_("The document template %(document_template)s was deleted.") % {
'document_template': document_template
}
)
except ProtectedError:
messages.error(
request,
_("The document template %(document_template)s can't be deleted \
because it is currently being used.") % {
'document_template': document_template
}
)
return redirect(reverse('preferences:display-options'))
return form({
'preferenceform': document_template,
'action_name': _("Delete"),
'title': _("Delete document template")
}, 'preferences/preferences.html', request)

View file

@ -120,6 +120,7 @@ TEMPLATES = [
'DIRS': [
# Use only absolute paths with '/' delimiters even on Windows
os.path.join(BASE_DIR, 'templates').replace('\\', '/'),
os.path.join(BASE_DIR, 'media', 'templates').replace('\\', '/'),
],
'APP_DIRS': True,
'OPTIONS': {