Commit beae8ee8 authored by klafyvel's avatar klafyvel

Merge branch 'dev' into '2.7'

Dev

See merge request !398
parents 201b87f3 4f9e0f8a
Pipeline #1782 passed with stage
in 5 minutes and 28 seconds
......@@ -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.
......@@ -28,7 +28,7 @@ done.
from django.conf import settings
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import ugettext as _
def _create_api_permission():
......@@ -71,4 +71,5 @@ def can_view(user):
'codename': settings.API_PERMISSION_CODENAME
}
can = user.has_perm('%(app_label)s.%(codename)s' % kwargs)
return can, None if can else _("You cannot see this application.")
return can, None if can else _("You don't have the right to see this"
" application.")
......@@ -46,6 +46,6 @@ class ExpiringTokenAuthentication(TokenAuthentication):
)
utc_now = datetime.datetime.now(datetime.timezone.utc)
if token.created < utc_now - token_duration:
raise exceptions.AuthenticationFailed(_('Token has expired'))
raise exceptions.AuthenticationFailed(_("The token has expired."))
return token.user, token
# 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 © 2018 Maël Kervella
#
# 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.
msgid ""
msgstr ""
"Project-Id-Version: 2.5\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2019-01-08 23:06+0100\n"
"PO-Revision-Date: 2019-01-07 01:37+0100\n"
"Last-Translator: Laouen Fernet <laouen.fernet@supelec.fr>\n"
"Language-Team: \n"
"Language: fr_FR\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: acl.py:74
msgid "You don't have the right to see this application."
msgstr "Vous n'avez pas le droit de voir cette application."
#: authentication.py:49
msgid "The token has expired."
msgstr "Le jeton a expiré."
......@@ -391,13 +391,25 @@ class OptionalTopologieSerializer(NamespacedHMSerializer):
class Meta:
model = preferences.OptionalTopologie
fields = ('radius_general_policy', 'vlan_decision_ok',
'vlan_decision_nok', 'switchs_ip_type', 'switchs_web_management',
fields = ('switchs_ip_type', 'switchs_web_management',
'switchs_web_management_ssl', 'switchs_rest_management',
'switchs_management_utils', 'switchs_management_interface_ip',
'provision_switchs_enabled', 'switchs_provision', 'switchs_management_sftp_creds')
class RadiusOptionSerializer(NamespacedHMSerializer):
"""Serialize `preferences.models.RadiusOption` objects
"""
class Meta:
model = preferences.RadiusOption
fields = ('radius_general_policy', 'unknown_machine',
'unknown_machine_vlan', 'unknown_port',
'unknown_port_vlan', 'unknown_room', 'unknown_room_vlan',
'non_member', 'non_member_vlan', 'banned', 'banned_vlan',
'vlan_decision_ok')
class GeneralOptionSerializer(NamespacedHMSerializer):
"""Serialize `preferences.models.GeneralOption` objects.
"""
......@@ -811,7 +823,8 @@ class SwitchPortSerializer(serializers.ModelSerializer):
model = topologie.Switch
fields = ('short_name', 'model', 'switchbay', 'ports', 'ipv4', 'ipv6',
'interfaces_subnet', 'interfaces6_subnet', 'automatic_provision', 'rest_enabled',
'web_management_enabled', 'get_radius_key_value', 'get_management_cred_value')
'web_management_enabled', 'get_radius_key_value', 'get_management_cred_value',
'list_modules')
# LOCAL EMAILS
......
......@@ -67,6 +67,7 @@ router.register_viewset(r'machines/role', views.RoleViewSet)
router.register_view(r'preferences/optionaluser', views.OptionalUserView),
router.register_view(r'preferences/optionalmachine', views.OptionalMachineView),
router.register_view(r'preferences/optionaltopologie', views.OptionalTopologieView),
router.register_view(r'preferences/radiusoption', views.RadiusOptionView),
router.register_view(r'preferences/generaloption', views.GeneralOptionView),
router.register_viewset(r'preferences/service', views.HomeServiceViewSet, base_name='homeservice'),
router.register_view(r'preferences/assooption', views.AssoOptionView),
......
......@@ -292,6 +292,17 @@ class OptionalTopologieView(generics.RetrieveAPIView):
return preferences.OptionalTopologie.objects.first()
class RadiusOptionView(generics.RetrieveAPIView):
"""Exposes details of `preferences.models.OptionalTopologie` settings.
"""
permission_classes = (ACLPermission,)
perms_map = {'GET': [preferences.RadiusOption.can_view_all]}
serializer_class = serializers.RadiusOptionSerializer
def get_object(self):
return preferences.RadiusOption.objects.first()
class GeneralOptionView(generics.RetrieveAPIView):
"""Exposes details of `preferences.models.GeneralOption` settings.
"""
......@@ -600,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
......@@ -635,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())
......
......@@ -30,7 +30,7 @@ from django.contrib import admin
from reversion.admin import VersionAdmin
from .models import Facture, Article, Banque, Paiement, Cotisation, Vente
from .models import CustomInvoice
from .models import CustomInvoice, CostEstimate
class FactureAdmin(VersionAdmin):
......@@ -38,6 +38,11 @@ class FactureAdmin(VersionAdmin):
pass
class CostEstimateAdmin(VersionAdmin):
"""Admin class for cost estimates."""
pass
class CustomInvoiceAdmin(VersionAdmin):
"""Admin class for custom invoices."""
pass
......@@ -76,3 +81,4 @@ admin.site.register(Paiement, PaiementAdmin)
admin.site.register(Vente, VenteAdmin)
admin.site.register(Cotisation, CotisationAdmin)
admin.site.register(CustomInvoice, CustomInvoiceAdmin)
admin.site.register(CostEstimate, CostEstimateAdmin)
......@@ -46,7 +46,10 @@ from django.shortcuts import get_object_or_404
from re2o.field_permissions import FieldPermissionFormMixin
from re2o.mixins import FormRevMixin
from .models import Article, Paiement, Facture, Banque, CustomInvoice
from .models import (
Article, Paiement, Facture, Banque,
CustomInvoice, Vente, CostEstimate,
)
from .payment_methods import balance
......@@ -104,7 +107,44 @@ class SelectArticleForm(FormRevMixin, Form):
user = kwargs.pop('user')
target_user = kwargs.pop('target_user', None)
super(SelectArticleForm, self).__init__(*args, **kwargs)
self.fields['article'].queryset = Article.find_allowed_articles(user, target_user)
self.fields['article'].queryset = Article.find_allowed_articles(
user, target_user)
class DiscountForm(Form):
"""
Form used in oder to create a discount on an invoice.
"""
is_relative = forms.BooleanField(
label=_("Discount is on percentage."),
required=False,
)
discount = forms.DecimalField(
label=_("Discount"),
max_value=100,
min_value=0,
max_digits=5,
decimal_places=2,
required=False,
)
def apply_to_invoice(self, invoice):
invoice_price = invoice.prix_total()
discount = self.cleaned_data['discount']
is_relative = self.cleaned_data['is_relative']
if is_relative:
amount = discount/100 * invoice_price
else:
amount = discount
if amount:
name = _("{}% discount") if is_relative else _("{}€ discount")
name = name.format(discount)
Vente.objects.create(
facture=invoice,
name=name,
prix=-amount,
number=1
)
class CustomInvoiceForm(FormRevMixin, ModelForm):
......@@ -116,6 +156,15 @@ class CustomInvoiceForm(FormRevMixin, ModelForm):
fields = '__all__'
class CostEstimateForm(FormRevMixin, ModelForm):
"""
Form used to create a cost estimate.
"""
class Meta:
model = CostEstimate
exclude = ['paid', 'final_invoice']
class ArticleForm(FormRevMixin, ModelForm):
"""
Form used to create an article.
......@@ -248,7 +297,8 @@ class RechargeForm(FormRevMixin, Form):
super(RechargeForm, self).__init__(*args, **kwargs)
self.fields['payment'].empty_label = \
_("Select a payment method")
self.fields['payment'].queryset = Paiement.find_allowed_payments(user_source).exclude(is_balance=True)
self.fields['payment'].queryset = Paiement.find_allowed_payments(
user_source).exclude(is_balance=True)
def clean(self):
"""
......@@ -260,10 +310,9 @@ class RechargeForm(FormRevMixin, Form):
if balance_method.maximum_balance is not None and \
value + self.user.solde > balance_method.maximum_balance:
raise forms.ValidationError(
_("Requested amount is too high. Your balance can't exceed \
%(max_online_balance)s €.") % {
_("Requested amount is too high. Your balance can't exceed"
" %(max_online_balance)s €.") % {
'max_online_balance': balance_method.maximum_balance
}
)
return self.cleaned_data
This diff is collapsed.
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2018-12-29 14:22
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cotisations', '0035_notepayment'),
]
operations = [
migrations.AddField(
model_name='custominvoice',
name='remark',
field=models.TextField(blank=True, null=True, verbose_name='Remark'),
),
]
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2018-12-29 21:03
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('cotisations', '0036_custominvoice_remark'),
]
operations = [
migrations.CreateModel(
name='CostEstimate',
fields=[
('custominvoice_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='cotisations.CustomInvoice')),
('validity', models.DurationField(verbose_name='Period of validity')),
('final_invoice', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='origin_cost_estimate', to='cotisations.CustomInvoice')),
],
options={
'permissions': (('view_costestimate', 'Can view a cost estimate object'),),
},
bases=('cotisations.custominvoice',),
),
]
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2018-12-31 22:57
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('cotisations', '0037_costestimate'),
]
operations = [
migrations.AlterField(
model_name='costestimate',
name='final_invoice',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='origin_cost_estimate', to='cotisations.CustomInvoice'),
),
migrations.AlterField(
model_name='costestimate',
name='validity',
field=models.DurationField(help_text='DD HH:MM:SS', verbose_name='Period of validity'),
),
migrations.AlterField(
model_name='custominvoice',
name='paid',
field=models.BooleanField(default=False, verbose_name='Paid'),
),
]
......@@ -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):
"""
......@@ -284,8 +307,65 @@ class CustomInvoice(BaseInvoice):
verbose_name=_("Address")
)
paid = models.BooleanField(
verbose_name=_("Paid")
verbose_name=_("Paid"),
default=False
)
remark = models.TextField(
verbose_name=_("Remark"),
blank=True,
null=True
)
class CostEstimate(CustomInvoice):
class Meta:
permissions = (
('view_costestimate', _("Can view a cost estimate object")),
)
validity = models.DurationField(
verbose_name=_("Period of validity"),
help_text="DD HH:MM:SS"
)
final_invoice = models.ForeignKey(
CustomInvoice,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="origin_cost_estimate",
primary_key=False
)
def create_invoice(self):
"""Create a CustomInvoice from the CostEstimate."""
if self.final_invoice is not None:
return self.final_invoice
invoice = CustomInvoice()
invoice.recipient = self.recipient
invoice.payment = self.payment
invoice.address = self.address
invoice.paid = False
invoice.remark = self.remark
invoice.date = timezone.now()
invoice.save()
self.final_invoice = invoice
self.save()
for sale in self.vente_set.all():
Vente.objects.create(
facture=invoice,
name=sale.name,
prix=sale.prix,
number=sale.number,
)
return invoice
def can_delete(self, user_request, *args, **kwargs):
if not user_request.has_perm('cotisations.delete_costestimate'):
return False, _("You don't have the right "
"to delete a cost estimate.")
if self.final_invoice is not None:
return False, _("The cost estimate has an "
"invoice and can't be deleted.")
return True, None
# TODO : change Vente to Purchase
......@@ -624,7 +704,7 @@ class Article(RevMixin, AclMixin, models.Model):
objects_pool = cls.objects.filter(
Q(type_user='All') | Q(type_user='Adherent')
)
if not target_user.is_adherent():
if target_user is not None and not target_user.is_adherent():
objects_pool = objects_pool.filter(
Q(type_cotisation='All') | Q(type_cotisation='Adhesion')
)
......@@ -718,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()
......
......@@ -57,7 +57,7 @@ def note_payment(request, facture, factureid):
user = facture.user
payment_method = find_payment_method(facture.paiement)
if not payment_method or not isinstance(payment_method, NotePayment):
messages.error(request, "Erreur inconnue")
messages.error(request, _("Unknown error."))
return redirect(reverse(
'users:profil',
kwargs={'userid': user.id}
......@@ -85,7 +85,7 @@ def note_payment(request, facture, factureid):
)
facture.valid = True
facture.save()
messages.success(request, "Le paiement par note a bien été effectué")
messages.success(request, _("The payment with note was done."))
return redirect(reverse(
'users:profil',
kwargs={'userid': user.id}
......
......@@ -49,9 +49,7 @@ with this program; if not, write to the Free Software Foundation, Inc.,
<td>{{ article.available_for_everyone | tick }}</td>
<td class="text-right">
{% can_edit article %}
<a class="btn btn-primary btn-sm" role="button" title="{% trans "Edit" %}" href="{% url 'cotisations:edit-article' article.id %}">
<i class="fa fa-edit"></i>
</a>
{% include 'buttons/edit.html' with href='cotisations:edit-article' id=article.id %}
{% acl_end %}
{% history_button article %}
</td>
......
{% 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 © 2018 Hugo Levy-Falk
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 i18n %}
{% load acl %}
{% load logs_extra %}
{% load design %}
<div class="table-responsive">
{% if cost_estimate_list.paginator %}
{% include 'pagination.html' with list=cost_estimate_list%}
{% endif %}
<table class="table table-striped">
<thead>
<tr>
<th>
{% trans "Recipient" as tr_recip %}
{% include 'buttons/sort.html' with prefix='invoice' col='user' text=tr_recip %}
</th>
<th>{% trans "Designation" %}</th>
<th>{% trans "Total price" %}</th>
<th>
{% trans "Payment method" as tr_payment_method %}
{% include 'buttons/sort.html' with prefix='invoice' col='payement' text=tr_payment_method %}
</th>
<th>
{% trans "Date" as tr_date %}
{% include 'buttons/sort.html' with prefix='invoice' col='date' text=tr_date %}
</th>
<th>
{% trans "Validity" as tr_validity %}
{% include 'buttons/sort.html' with prefix='invoice' col='validity' text=tr_validity %}
</th>
<th>
{% trans "Cost estimate ID" as tr_estimate_id %}
{% include 'buttons/sort.html' with prefix='invoice' col='id' text=tr_estimate_id %}
</th>
<th>
{% trans "Invoice created" as tr_invoice_created%}
{% include 'buttons/sort.html' with prefix='invoice' col='paid' text=tr_invoice_created %}
</th>
<th></th>
<th></th>
</tr>
</thead>
{% for estimate in cost_estimate_list %}
<tr>
<td>{{ estimate.recipient }}</td>
<td>{{ estimate.name }}</td>
<td>{{ estimate.prix_total }}</td>
<td>{{ estimate.payment }}</td>
<td>{{ estimate.date }}</td>
<td>{{ estimate.validity }}</td>
<td>{{ estimate.id }}</td>
<td>
{% 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>
{% endif %}
</td>
<td>
{% can_edit estimate %}
{% include 'buttons/edit.html' with href='cotisations:edit-cost-estimate' id=estimate.id %}
{% acl_end %}
{% history_button estimate %}
{% include 'buttons/suppr.html' with href='cotisations:del-cost-estimate' id=estimate.id %}
<a class="btn btn-primary btn-sm" role="button" href="{% url 'cotisations:cost-estimate-to-invoice' estimate.id %}">
<i class="fa fa-file"></i>
</a>
<a class="btn btn-primary btn-sm" role="button" href="{% url 'cotisations:cost-estimate-pdf' estimate.id %}">
<i class="fa fa-file-pdf-o"></i> {% trans "PDF" %}
</a>
</td>
</tr>
{% endfor %}
</table>