Commit beae8ee8 authored by klafyvel's avatar klafyvel

Merge branch 'dev' into '2.7'

Dev

See merge request federez/re2o!398
parents 201b87f3 4f9e0f8a
Pipeline #1782 passed with stage
in 5 minutes and 28 seconds
...@@ -164,3 +164,17 @@ Collec new statics ...@@ -164,3 +164,17 @@ Collec new statics
```bash ```bash
python3 manage.py collectstatic 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. ...@@ -28,7 +28,7 @@ done.
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType 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(): def _create_api_permission():
...@@ -71,4 +71,5 @@ def can_view(user): ...@@ -71,4 +71,5 @@ def can_view(user):
'codename': settings.API_PERMISSION_CODENAME 'codename': settings.API_PERMISSION_CODENAME
} }
can = user.has_perm('%(app_label)s.%(codename)s' % kwargs) 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): ...@@ -46,6 +46,6 @@ class ExpiringTokenAuthentication(TokenAuthentication):
) )
utc_now = datetime.datetime.now(datetime.timezone.utc) utc_now = datetime.datetime.now(datetime.timezone.utc)
if token.created < utc_now - token_duration: if token.created < utc_now - token_duration:
raise exceptions.AuthenticationFailed(_('Token has expired')) raise exceptions.AuthenticationFailed(_("The token has expired."))
return token.user, token 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): ...@@ -391,13 +391,25 @@ class OptionalTopologieSerializer(NamespacedHMSerializer):
class Meta: class Meta:
model = preferences.OptionalTopologie model = preferences.OptionalTopologie
fields = ('radius_general_policy', 'vlan_decision_ok', fields = ('switchs_ip_type', 'switchs_web_management',
'vlan_decision_nok', 'switchs_ip_type', 'switchs_web_management',
'switchs_web_management_ssl', 'switchs_rest_management', 'switchs_web_management_ssl', 'switchs_rest_management',
'switchs_management_utils', 'switchs_management_interface_ip', 'switchs_management_utils', 'switchs_management_interface_ip',
'provision_switchs_enabled', 'switchs_provision', 'switchs_management_sftp_creds') '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): class GeneralOptionSerializer(NamespacedHMSerializer):
"""Serialize `preferences.models.GeneralOption` objects. """Serialize `preferences.models.GeneralOption` objects.
""" """
...@@ -811,7 +823,8 @@ class SwitchPortSerializer(serializers.ModelSerializer): ...@@ -811,7 +823,8 @@ class SwitchPortSerializer(serializers.ModelSerializer):
model = topologie.Switch model = topologie.Switch
fields = ('short_name', 'model', 'switchbay', 'ports', 'ipv4', 'ipv6', fields = ('short_name', 'model', 'switchbay', 'ports', 'ipv4', 'ipv6',
'interfaces_subnet', 'interfaces6_subnet', 'automatic_provision', 'rest_enabled', '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 # LOCAL EMAILS
......
...@@ -67,6 +67,7 @@ router.register_viewset(r'machines/role', views.RoleViewSet) ...@@ -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/optionaluser', views.OptionalUserView),
router.register_view(r'preferences/optionalmachine', views.OptionalMachineView), router.register_view(r'preferences/optionalmachine', views.OptionalMachineView),
router.register_view(r'preferences/optionaltopologie', views.OptionalTopologieView), 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_view(r'preferences/generaloption', views.GeneralOptionView),
router.register_viewset(r'preferences/service', views.HomeServiceViewSet, base_name='homeservice'), router.register_viewset(r'preferences/service', views.HomeServiceViewSet, base_name='homeservice'),
router.register_view(r'preferences/assooption', views.AssoOptionView), router.register_view(r'preferences/assooption', views.AssoOptionView),
......
...@@ -292,6 +292,17 @@ class OptionalTopologieView(generics.RetrieveAPIView): ...@@ -292,6 +292,17 @@ class OptionalTopologieView(generics.RetrieveAPIView):
return preferences.OptionalTopologie.objects.first() 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): class GeneralOptionView(generics.RetrieveAPIView):
"""Exposes details of `preferences.models.GeneralOption` settings. """Exposes details of `preferences.models.GeneralOption` settings.
""" """
...@@ -600,9 +611,11 @@ class HostMacIpView(generics.ListAPIView): ...@@ -600,9 +611,11 @@ class HostMacIpView(generics.ListAPIView):
"""Exposes the associations between hostname, mac address and IPv4 in """Exposes the associations between hostname, mac address and IPv4 in
order to build the DHCP lease files. order to build the DHCP lease files.
""" """
queryset = all_active_interfaces()
serializer_class = serializers.HostMacIpSerializer serializer_class = serializers.HostMacIpSerializer
def get_queryset(self):
return all_active_interfaces()
# Firewall # Firewall
...@@ -635,7 +648,7 @@ class DNSZonesView(generics.ListAPIView): ...@@ -635,7 +648,7 @@ class DNSZonesView(generics.ListAPIView):
class DNSReverseZonesView(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. IPs, DNS records, etc.) in order to build the DNS zone files.
""" """
queryset = (machines.IpType.objects.all()) queryset = (machines.IpType.objects.all())
......
...@@ -30,7 +30,7 @@ from django.contrib import admin ...@@ -30,7 +30,7 @@ from django.contrib import admin
from reversion.admin import VersionAdmin from reversion.admin import VersionAdmin
from .models import Facture, Article, Banque, Paiement, Cotisation, Vente from .models import Facture, Article, Banque, Paiement, Cotisation, Vente
from .models import CustomInvoice from .models import CustomInvoice, CostEstimate
class FactureAdmin(VersionAdmin): class FactureAdmin(VersionAdmin):
...@@ -38,6 +38,11 @@ class FactureAdmin(VersionAdmin): ...@@ -38,6 +38,11 @@ class FactureAdmin(VersionAdmin):
pass pass
class CostEstimateAdmin(VersionAdmin):
"""Admin class for cost estimates."""
pass
class CustomInvoiceAdmin(VersionAdmin): class CustomInvoiceAdmin(VersionAdmin):
"""Admin class for custom invoices.""" """Admin class for custom invoices."""
pass pass
...@@ -76,3 +81,4 @@ admin.site.register(Paiement, PaiementAdmin) ...@@ -76,3 +81,4 @@ admin.site.register(Paiement, PaiementAdmin)
admin.site.register(Vente, VenteAdmin) admin.site.register(Vente, VenteAdmin)
admin.site.register(Cotisation, CotisationAdmin) admin.site.register(Cotisation, CotisationAdmin)
admin.site.register(CustomInvoice, CustomInvoiceAdmin) admin.site.register(CustomInvoice, CustomInvoiceAdmin)
admin.site.register(CostEstimate, CostEstimateAdmin)
...@@ -46,7 +46,10 @@ from django.shortcuts import get_object_or_404 ...@@ -46,7 +46,10 @@ from django.shortcuts import get_object_or_404
from re2o.field_permissions import FieldPermissionFormMixin from re2o.field_permissions import FieldPermissionFormMixin
from re2o.mixins import FormRevMixin 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 from .payment_methods import balance
...@@ -104,7 +107,44 @@ class SelectArticleForm(FormRevMixin, Form): ...@@ -104,7 +107,44 @@ class SelectArticleForm(FormRevMixin, Form):
user = kwargs.pop('user') user = kwargs.pop('user')
target_user = kwargs.pop('target_user', None) target_user = kwargs.pop('target_user', None)
super(SelectArticleForm, self).__init__(*args, **kwargs) 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): class CustomInvoiceForm(FormRevMixin, ModelForm):
...@@ -116,6 +156,15 @@ class CustomInvoiceForm(FormRevMixin, ModelForm): ...@@ -116,6 +156,15 @@ class CustomInvoiceForm(FormRevMixin, ModelForm):
fields = '__all__' 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): class ArticleForm(FormRevMixin, ModelForm):
""" """
Form used to create an article. Form used to create an article.
...@@ -248,7 +297,8 @@ class RechargeForm(FormRevMixin, Form): ...@@ -248,7 +297,8 @@ class RechargeForm(FormRevMixin, Form):
super(RechargeForm, self).__init__(*args, **kwargs) super(RechargeForm, self).__init__(*args, **kwargs)
self.fields['payment'].empty_label = \ self.fields['payment'].empty_label = \
_("Select a payment method") _("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): def clean(self):
""" """
...@@ -260,10 +310,9 @@ class RechargeForm(FormRevMixin, Form): ...@@ -260,10 +310,9 @@ class RechargeForm(FormRevMixin, Form):
if balance_method.maximum_balance is not None and \ if balance_method.maximum_balance is not None and \
value + self.user.solde > balance_method.maximum_balance: value + self.user.solde > balance_method.maximum_balance:
raise forms.ValidationError( raise forms.ValidationError(
_("Requested amount is too high. Your balance can't exceed \ _("Requested amount is too high. Your balance can't exceed"
%(max_online_balance)s €.") % { " %(max_online_balance)s €.") % {
'max_online_balance': balance_method.maximum_balance 'max_online_balance': balance_method.maximum_balance
} }
) )
return self.cleaned_data 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 ...@@ -46,11 +46,14 @@ from django.urls import reverse
from django.shortcuts import redirect from django.shortcuts import redirect
from django.contrib import messages from django.contrib import messages
from preferences.models import CotisationsOption
from machines.models import regen from machines.models import regen
from re2o.field_permissions import FieldPermissionModelMixin from re2o.field_permissions import FieldPermissionModelMixin
from re2o.mixins import AclMixin, RevMixin 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 from cotisations.validators import check_no_balance
...@@ -236,15 +239,35 @@ class Facture(BaseInvoice): ...@@ -236,15 +239,35 @@ class Facture(BaseInvoice):
'control': self.can_change_control, 'control': self.can_change_control,
} }
self.__original_valid = self.valid 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): def save(self, *args, **kwargs):
super(Facture, self).save(*args, **kwargs) super(Facture, self).save(*args, **kwargs)
if not self.__original_valid and self.valid: if not self.__original_valid and self.valid:
send_mail_invoice(self) 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): def __str__(self):
return str(self.user) + ' ' + str(self.date) return str(self.user) + ' ' + str(self.date)
@receiver(post_save, sender=Facture) @receiver(post_save, sender=Facture)
def facture_post_save(**kwargs): def facture_post_save(**kwargs):
""" """
...@@ -284,8 +307,65 @@ class CustomInvoice(BaseInvoice): ...@@ -284,8 +307,65 @@ class CustomInvoice(BaseInvoice):
verbose_name=_("Address") verbose_name=_("Address")
) )
paid = models.BooleanField( 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 # TODO : change Vente to Purchase
...@@ -624,7 +704,7 @@ class Article(RevMixin, AclMixin, models.Model): ...@@ -624,7 +704,7 @@ class Article(RevMixin, AclMixin, models.Model):
objects_pool = cls.objects.filter( objects_pool = cls.objects.filter(
Q(type_user='All') | Q(type_user='Adherent') 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( objects_pool = objects_pool.filter(
Q(type_cotisation='All') | Q(type_cotisation='Adhesion') Q(type_cotisation='All') | Q(type_cotisation='Adhesion')
) )
...@@ -718,7 +798,7 @@ class Paiement(RevMixin, AclMixin, models.Model): ...@@ -718,7 +798,7 @@ class Paiement(RevMixin, AclMixin, models.Model):
if payment_method is not None and use_payment_method: if payment_method is not None and use_payment_method:
return payment_method.end_payment(invoice, request) 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.valid = True
invoice.save() invoice.save()
......
...@@ -57,7 +57,7 @@ def note_payment(request, facture, factureid): ...@@ -57,7 +57,7 @@ def note_payment(request, facture, factureid):
user = facture.user user = facture.user
payment_method = find_payment_method(facture.paiement) payment_method = find_payment_method(facture.paiement)
if not payment_method or not isinstance(payment_method, NotePayment): if not payment_method or not isinstance(payment_method, NotePayment):
messages.error(request, "Erreur inconnue") messages.error(request, _("Unknown error."))
return redirect(reverse( return redirect(reverse(
'users:profil', 'users:profil',
kwargs={'userid': user.id}