diff --git a/.gitignore b/.gitignore index 49df31ac..c978acac 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,7 @@ __pycache__/ *.swp # Translations -#*.mo TODO +*.mo *.pot # Django stuff diff --git a/api/serializers.py b/api/serializers.py index f520cf98..8c22ed21 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -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. """ @@ -407,9 +419,8 @@ class GeneralOptionSerializer(NamespacedHMSerializer): fields = ('general_message_fr', 'general_message_en', 'search_display_page', 'pagination_number', 'pagination_large_number', 'req_expire_hrs', - 'site_name', 'email_from', 'GTU_sum_up', - 'GTU') - + 'site_name', 'main_site_url', 'email_from', + 'GTU_sum_up', 'GTU') class HomeServiceSerializer(NamespacedHMSerializer): """Serialize `preferences.models.Service` objects. @@ -633,9 +644,8 @@ class AdherentSerializer(NamespacedHMSerializer): } -class HomeCreationSerializer(NamespacedHMSerializer): - """Serialize 'users.models.User' minimal infos to create home - """ +class BasicUserSerializer(NamespacedHMSerializer): + """Serialize 'users.models.User' minimal infos""" uid = serializers.IntegerField(source='uid_number') gid = serializers.IntegerField(source='gid_number') @@ -813,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 @@ -1001,6 +1012,17 @@ class CNAMERecordSerializer(serializers.ModelSerializer): model = machines.Domain fields = ('alias', 'hostname') +class DNAMERecordSerializer(serializers.ModelSerializer): + """Serialize `machines.models.Domain` objects with the data needed to + generate a DNAME DNS record. + """ + alias = serializers.CharField(read_only=True) + zone = serializers.CharField(read_only=True) + + class Meta: + model = machines.DName + fields = ('alias', 'zone') + class DNSZonesSerializer(serializers.ModelSerializer): """Serialize the data about DNS Zones. @@ -1015,14 +1037,14 @@ class DNSZonesSerializer(serializers.ModelSerializer): a_records = ARecordSerializer(many=True, source='get_associated_a_records') aaaa_records = AAAARecordSerializer(many=True, source='get_associated_aaaa_records') cname_records = CNAMERecordSerializer(many=True, source='get_associated_cname_records') + dname_records = DNAMERecordSerializer(many=True, source='get_associated_dname_records') sshfp_records = SSHFPInterfaceSerializer(many=True, source='get_associated_sshfp_records') class Meta: model = machines.Extension fields = ('name', 'soa', 'ns_records', 'originv4', 'originv6', 'mx_records', 'txt_records', 'srv_records', 'a_records', - 'aaaa_records', 'cname_records', 'sshfp_records') - + 'aaaa_records', 'cname_records', 'dname_records', 'sshfp_records') #REMINDER diff --git a/api/urls.py b/api/urls.py index e361d732..4a34c1de 100644 --- a/api/urls.py +++ b/api/urls.py @@ -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), @@ -88,6 +89,8 @@ router.register(r'topologie/portprofile', views.PortProfileViewSet) # USERS router.register_viewset(r'users/user', views.UserViewSet, base_name='user') router.register_viewset(r'users/homecreation', views.HomeCreationViewSet, base_name='homecreation') +router.register_viewset(r'users/normaluser', views.NormalUserViewSet, base_name='normaluser') +router.register_viewset(r'users/criticaluser', views.CriticalUserViewSet, base_name='criticaluser') router.register_viewset(r'users/club', views.ClubViewSet) router.register_viewset(r'users/adherent', views.AdherentViewSet) router.register_viewset(r'users/serviceuser', views.ServiceUserViewSet) diff --git a/api/views.py b/api/views.py index 3d7f4bc2..8f7b9c1f 100644 --- a/api/views.py +++ b/api/views.py @@ -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. """ @@ -445,7 +456,19 @@ class HomeCreationViewSet(viewsets.ReadOnlyModelViewSet): """Exposes infos of `users.models.Users` objects to create homes. """ queryset = users.User.objects.exclude(Q(state=users.User.STATE_DISABLED) | Q(state=users.User.STATE_NOT_YET_ACTIVE)) - serializer_class = serializers.HomeCreationSerializer + serializer_class = serializers.BasicUserSerializer + + +class NormalUserViewSet(viewsets.ReadOnlyModelViewSet): + """Exposes infos of `users.models.Users`without specific rights objects.""" + queryset = users.User.objects.exclude(groups__listright__critical=True).distinct() + serializer_class = serializers.BasicUserSerializer + + +class CriticalUserViewSet(viewsets.ReadOnlyModelViewSet): + """Exposes infos of `users.models.Users`without specific rights objects.""" + queryset = users.User.objects.filter(groups__listright__critical=True).distinct() + serializer_class = serializers.BasicUserSerializer class ClubViewSet(viewsets.ReadOnlyModelViewSet): @@ -541,8 +564,8 @@ class ServiceRegenViewSet(viewsets.ModelViewSet): # Config des switches class SwitchPortView(generics.ListAPIView): - """Exposes the associations between hostname, mac address and IPv4 in - order to build the DHCP lease files. + """Output each port of a switch, to be serialized with + additionnal informations (profiles etc) """ queryset = topologie.Switch.objects.all().select_related("switchbay").select_related("model__constructor").prefetch_related("ports__custom_profile__vlan_tagged").prefetch_related("ports__custom_profile__vlan_untagged").prefetch_related("ports__machine_interface__domain__extension").prefetch_related("ports__room") @@ -551,16 +574,14 @@ class SwitchPortView(generics.ListAPIView): # Rappel fin adhésion class ReminderView(generics.ListAPIView): - """Exposes the associations between hostname, mac address and IPv4 in - order to build the DHCP lease files. + """Output for users to remind an end of their subscription. """ queryset = preferences.Reminder.objects.all() serializer_class = serializers.ReminderSerializer class RoleView(generics.ListAPIView): - """Exposes the associations between hostname, mac address and IPv4 in - order to build the DHCP lease files. + """Output of roles for each server """ queryset = machines.Role.objects.all().prefetch_related('servers') serializer_class = serializers.RoleSerializer diff --git a/apt_requirements.txt b/apt_requirements.txt index 5a3fc103..0cf83a7c 100644 --- a/apt_requirements.txt +++ b/apt_requirements.txt @@ -17,3 +17,4 @@ libjs-bootstrap fonts-font-awesome graphviz git +gettext diff --git a/cotisations/admin.py b/cotisations/admin.py index afe4621c..4b47ccc8 100644 --- a/cotisations/admin.py +++ b/cotisations/admin.py @@ -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) diff --git a/cotisations/forms.py b/cotisations/forms.py index 01e52756..57bd7355 100644 --- a/cotisations/forms.py +++ b/cotisations/forms.py @@ -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): """ @@ -266,4 +316,3 @@ class RechargeForm(FormRevMixin, Form): } ) return self.cleaned_data - diff --git a/cotisations/locale/fr/LC_MESSAGES/django.mo b/cotisations/locale/fr/LC_MESSAGES/django.mo deleted file mode 100644 index 3a1671bb..00000000 Binary files a/cotisations/locale/fr/LC_MESSAGES/django.mo and /dev/null differ diff --git a/cotisations/migrations/0036_custominvoice_remark.py b/cotisations/migrations/0036_custominvoice_remark.py new file mode 100644 index 00000000..7719b31d --- /dev/null +++ b/cotisations/migrations/0036_custominvoice_remark.py @@ -0,0 +1,20 @@ +# -*- 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'), + ), + ] diff --git a/cotisations/migrations/0037_costestimate.py b/cotisations/migrations/0037_costestimate.py new file mode 100644 index 00000000..3d97f3f3 --- /dev/null +++ b/cotisations/migrations/0037_costestimate.py @@ -0,0 +1,28 @@ +# -*- 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',), + ), + ] diff --git a/cotisations/migrations/0038_auto_20181231_1657.py b/cotisations/migrations/0038_auto_20181231_1657.py new file mode 100644 index 00000000..a9415bf0 --- /dev/null +++ b/cotisations/migrations/0038_auto_20181231_1657.py @@ -0,0 +1,31 @@ +# -*- 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'), + ), + ] diff --git a/cotisations/models.py b/cotisations/models.py index 6dd63b6f..623db068 100644 --- a/cotisations/models.py +++ b/cotisations/models.py @@ -284,8 +284,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 cannot be deleted.") + return True, None # TODO : change Vente to Purchase @@ -624,7 +681,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') ) diff --git a/cotisations/templates/cotisations/aff_cost_estimate.html b/cotisations/templates/cotisations/aff_cost_estimate.html new file mode 100644 index 00000000..d4a3f60d --- /dev/null +++ b/cotisations/templates/cotisations/aff_cost_estimate.html @@ -0,0 +1,101 @@ +{% 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 %} + +
+ {% if cost_estimate_list.paginator %} + {% include 'pagination.html' with list=cost_estimate_list%} + {% endif %} + + + + + + + + + + + + + + + + + {% for estimate in cost_estimate_list %} + + + + + + + + + + + + {% endfor %} +
+ {% trans "Recipient" as tr_recip %} + {% include 'buttons/sort.html' with prefix='invoice' col='user' text=tr_user %} + {% trans "Designation" %}{% trans "Total price" %} + {% trans "Payment method" as tr_payment_method %} + {% include 'buttons/sort.html' with prefix='invoice' col='payement' text=tr_payment_method %} + + {% trans "Date" as tr_date %} + {% include 'buttons/sort.html' with prefix='invoice' col='date' text=tr_date %} + + {% trans "Validity" as tr_validity %} + {% include 'buttons/sort.html' with prefix='invoice' col='validity' text=tr_validity %} + + {% trans "Cost estimate ID" as tr_estimate_id %} + {% include 'buttons/sort.html' with prefix='invoice' col='id' text=tr_estimate_id %} + + {% trans "Invoice created" as tr_invoice_created%} + {% include 'buttons/sort.html' with prefix='invoice' col='paid' text=tr_invoice_created %} +
{{ estimate.recipient }}{{ estimate.name }}{{ estimate.prix_total }}{{ estimate.payment }}{{ estimate.date }}{{ estimate.validity }}{{ estimate.id }} + {% if estimate.final_invoice %} + + {% else %} + ' + {% endif %} + + {% 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 %} + + + + + {% trans "PDF" %} + +
+ + {% if custom_invoice_list.paginator %} + {% include 'pagination.html' with list=custom_invoice_list %} + {% endif %} +
diff --git a/cotisations/templates/cotisations/edit_facture.html b/cotisations/templates/cotisations/edit_facture.html index a00084f6..c7a6975c 100644 --- a/cotisations/templates/cotisations/edit_facture.html +++ b/cotisations/templates/cotisations/edit_facture.html @@ -35,7 +35,11 @@ with this program; if not, write to the Free Software Foundation, Inc.,
{% csrf_token %} + {% if title %} +

{{title}}

+ {% else %}

{% trans "Edit the invoice" %}

+ {% endif %} {% massive_bootstrap_form factureform 'user' %} {{ venteform.management_form }}

{% trans "Articles" %}

diff --git a/cotisations/templates/cotisations/facture.html b/cotisations/templates/cotisations/facture.html index 1f87f579..8a1a6d7a 100644 --- a/cotisations/templates/cotisations/facture.html +++ b/cotisations/templates/cotisations/facture.html @@ -44,6 +44,12 @@ with this program; if not, write to the Free Software Foundation, Inc., {% blocktrans %}Current balance: {{ balance }} €{% endblocktrans %}

{% endif %} +{% if factureform %} +{% bootstrap_form_errors factureform %} +{% endif %} +{% if discount_form %} +{% bootstrap_form_errors discount_form %} +{% endif %} {% csrf_token %} @@ -68,8 +74,12 @@ with this program; if not, write to the Free Software Foundation, Inc., {% endfor %} +

{% trans "Discount" %}

+ {% if discount_form %} + {% bootstrap_form discount_form %} + {% endif %}

- {% blocktrans %}Total price: 0,00 €{% endblocktrans %} + {% blocktrans %}Total price: 0,00 €{% endblocktrans %}

{% endif %} {% bootstrap_button action_name button_type='submit' icon='ok' button_class='btn-success' %} @@ -78,105 +88,117 @@ with this program; if not, write to the Free Software Foundation, Inc., {% if articlesformset or payment_method%} {% endif %} diff --git a/cotisations/templates/cotisations/factures.tex b/cotisations/templates/cotisations/factures.tex index 3f2ebedc..2cfd4f46 100644 --- a/cotisations/templates/cotisations/factures.tex +++ b/cotisations/templates/cotisations/factures.tex @@ -43,7 +43,7 @@ \begin{document} - + %---------------------------------------------------------------------------------------- % HEADING SECTION %---------------------------------------------------------------------------------------- @@ -70,13 +70,17 @@ {\bf Siret :} {{siret|safe}} \vspace{2cm} - + \begin{tabular*}{\textwidth}{@{\extracolsep{\fill}} l r} {\bf Pour :} {{recipient_name|safe}} & {\bf Date :} {{DATE}} \\ {\bf Adresse :} {% if address is None %}Aucune adresse renseignée{% else %}{{address}}{% endif %} & \\ {% if fid is not None %} + {% if is_estimate %} + {\bf Devis n\textsuperscript{o} :} {{ fid }} & \\ + {% else %} {\bf Facture n\textsuperscript{o} :} {{ fid }} & \\ {% endif %} + {% endif %} \end{tabular*} \\ @@ -84,39 +88,57 @@ %---------------------------------------------------------------------------------------- % TABLE OF EXPENSES %---------------------------------------------------------------------------------------- - + \begin{tabularx}{\textwidth}{|X|r|r|r|} \hline \textbf{Désignation} & \textbf{Prix Unit.} \euro & \textbf{Quantité} & \textbf{Prix total} \euro\\ \doublehline - + {% for a in article %} {{a.name}} & {{a.price}} \euro & {{a.quantity}} & {{a.total_price}} \euro\\ \hline {% endfor %} - + \end{tabularx} - + \vspace{1cm} \hfill \begin{tabular}{|l|r|} \hline \textbf{Total} & {{total|floatformat:2}} \euro \\ + {% if not is_estimate %} \textbf{Votre règlement} & {% if paid %}{{total|floatformat:2}}{% else %} 00,00 {% endif %} \euro \\ \doublehline \textbf{À PAYER} & {% if not paid %}{{total|floatformat:2}}{% else %} 00,00 {% endif %} \euro\\ + {% endif %} \hline \end{tabular} - + + \vspace{1cm} + \begin{tabularx}{\textwidth}{r X} + \hline + \textbf{Moyen de paiement} & {{payment_method|default:"Non spécifié"}} \\ + \hline + {% if remark %} + \textbf{Remarque} & {{remark|safe}} \\ + \hline + {% endif %} + {% if end_validity %} + \textbf{Validité} & Jusqu'au {{end_validity}} \\ + \hline + {% endif %} + \end{tabularx} + + \vfill %---------------------------------------------------------------------------------------- % FOOTNOTE %---------------------------------------------------------------------------------------- - + \hrule \smallskip \footnotesize{TVA non applicable, art. 293 B du CGI} diff --git a/cotisations/templates/cotisations/index_cost_estimate.html b/cotisations/templates/cotisations/index_cost_estimate.html new file mode 100644 index 00000000..a0b3a661 --- /dev/null +++ b/cotisations/templates/cotisations/index_cost_estimate.html @@ -0,0 +1,36 @@ +{% extends "cotisations/sidebar.html" %} +{% 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 %} + +{% block title %}{% trans "Cost estimates" %}{% endblock %} + +{% block content %} +

{% trans "Cost estimates list" %}

+{% can_create CostEstimate %} +{% include "buttons/add.html" with href='cotisations:new-cost-estimate'%} +{% acl_end %} +{% include 'cotisations/aff_cost_estimate.html' %} +{% endblock %} diff --git a/cotisations/templates/cotisations/sidebar.html b/cotisations/templates/cotisations/sidebar.html index 4f077fad..c3240a9a 100644 --- a/cotisations/templates/cotisations/sidebar.html +++ b/cotisations/templates/cotisations/sidebar.html @@ -45,6 +45,11 @@ with this program; if not, write to the Free Software Foundation, Inc., {% trans "Custom invoices" %} {% acl_end %} + {% can_view_all CostEstimate %} + + {% trans "Cost estimate" %} + + {% acl_end %} {% can_view_all Article %} {% trans "Available articles" %} diff --git a/cotisations/tex.py b/cotisations/tex.py index 3f404f22..4d3715af 100644 --- a/cotisations/tex.py +++ b/cotisations/tex.py @@ -36,6 +36,7 @@ from django.template import Context from django.http import HttpResponse from django.conf import settings from django.utils.text import slugify +import logging TEMP_PREFIX = getattr(settings, 'TEX_TEMP_PREFIX', 'render_tex-') @@ -48,8 +49,9 @@ def render_invoice(_request, ctx={}): Render an invoice using some available information such as the current date, the user, the articles, the prices, ... """ + is_estimate = ctx.get('is_estimate', False) filename = '_'.join([ - 'invoice', + 'cost_estimate' if is_estimate else 'invoice', slugify(ctx.get('asso_name', "")), slugify(ctx.get('recipient_name', "")), str(ctx.get('DATE', datetime.now()).year), @@ -93,6 +95,20 @@ def create_pdf(template, ctx={}): return pdf +def escape_chars(string): + """Escape the '%' and the '€' signs to avoid messing with LaTeX""" + if not isinstance(string, str): + return string + mapping = ( + ('€', r'\euro'), + ('%', r'\%'), + ) + r = str(string) + for k, v in mapping: + r = r.replace(k, v) + return r + + def render_tex(_request, template, ctx={}): """Creates a PDF from a LaTex templates using pdflatex. diff --git a/cotisations/urls.py b/cotisations/urls.py index edc448fe..45032fe2 100644 --- a/cotisations/urls.py +++ b/cotisations/urls.py @@ -51,11 +51,41 @@ urlpatterns = [ views.facture_pdf, name='facture-pdf' ), + url( + r'^new_cost_estimate/$', + views.new_cost_estimate, + name='new-cost-estimate' + ), + url( + r'^index_cost_estimate/$', + views.index_cost_estimate, + name='index-cost-estimate' + ), + url( + r'^cost_estimate_pdf/(?P[0-9]+)$', + views.cost_estimate_pdf, + name='cost-estimate-pdf', + ), url( r'^index_custom_invoice/$', views.index_custom_invoice, name='index-custom-invoice' ), + url( + r'^edit_cost_estimate/(?P[0-9]+)$', + views.edit_cost_estimate, + name='edit-cost-estimate' + ), + url( + r'^cost_estimate_to_invoice/(?P[0-9]+)$', + views.cost_estimate_to_invoice, + name='cost-estimate-to-invoice' + ), + url( + r'^del_cost_estimate/(?P[0-9]+)$', + views.del_cost_estimate, + name='del-cost-estimate' + ), url( r'^new_custom_invoice/$', views.new_custom_invoice, diff --git a/cotisations/views.py b/cotisations/views.py index 4cd76f93..d4805dc2 100644 --- a/cotisations/views.py +++ b/cotisations/views.py @@ -47,7 +47,10 @@ from users.models import User from re2o.settings import LOGO_PATH from re2o import settings from re2o.views import form -from re2o.utils import SortTable, re2o_paginator +from re2o.base import ( + SortTable, + re2o_paginator, +) from re2o.acl import ( can_create, can_edit, @@ -65,7 +68,8 @@ from .models import ( Paiement, Banque, CustomInvoice, - BaseInvoice + BaseInvoice, + CostEstimate ) from .forms import ( FactureForm, @@ -77,9 +81,11 @@ from .forms import ( DelBanqueForm, SelectArticleForm, RechargeForm, - CustomInvoiceForm + CustomInvoiceForm, + DiscountForm, + CostEstimateForm, ) -from .tex import render_invoice +from .tex import render_invoice, escape_chars from .payment_methods.forms import payment_method_factory from .utils import find_payment_method @@ -175,7 +181,58 @@ def new_facture(request, user, userid): ) -# TODO : change facture to invoice +@login_required +@can_create(CostEstimate) +def new_cost_estimate(request): + """ + View used to generate a custom invoice. It's mainly used to + get invoices that are not taken into account, for the administrative + point of view. + """ + # The template needs the list of articles (for the JS part) + articles = Article.objects.filter( + Q(type_user='All') | Q(type_user=request.user.class_name) + ) + # Building the invocie form and the article formset + cost_estimate_form = CostEstimateForm(request.POST or None) + + articles_formset = formset_factory(SelectArticleForm)( + request.POST or None, + form_kwargs={'user': request.user} + ) + discount_form = DiscountForm(request.POST or None) + + if cost_estimate_form.is_valid() and articles_formset.is_valid() and discount_form.is_valid(): + cost_estimate_instance = cost_estimate_form.save() + for art_item in articles_formset: + if art_item.cleaned_data: + article = art_item.cleaned_data['article'] + quantity = art_item.cleaned_data['quantity'] + Vente.objects.create( + facture=cost_estimate_instance, + name=article.name, + prix=article.prix, + type_cotisation=article.type_cotisation, + duration=article.duration, + number=quantity + ) + discount_form.apply_to_invoice(cost_estimate_instance) + messages.success( + request, + _("The cost estimate was created.") + ) + return redirect(reverse('cotisations:index-cost-estimate')) + + return form({ + 'factureform': cost_estimate_form, + 'action_name': _("Confirm"), + 'articlesformset': articles_formset, + 'articlelist': articles, + 'discount_form': discount_form, + 'title': _("Cost estimate"), + }, 'cotisations/facture.html', request) + + @login_required @can_create(CustomInvoice) def new_custom_invoice(request): @@ -195,8 +252,9 @@ def new_custom_invoice(request): request.POST or None, form_kwargs={'user': request.user} ) + discount_form = DiscountForm(request.POST or None) - if invoice_form.is_valid() and articles_formset.is_valid(): + if invoice_form.is_valid() and articles_formset.is_valid() and discount_form.is_valid(): new_invoice_instance = invoice_form.save() for art_item in articles_formset: if art_item.cleaned_data: @@ -210,6 +268,7 @@ def new_custom_invoice(request): duration=article.duration, number=quantity ) + discount_form.apply_to_invoice(new_invoice_instance) messages.success( request, _("The custom invoice was created.") @@ -220,7 +279,8 @@ def new_custom_invoice(request): 'factureform': invoice_form, 'action_name': _("Confirm"), 'articlesformset': articles_formset, - 'articlelist': articles + 'articlelist': articles, + 'discount_form': discount_form }, 'cotisations/facture.html', request) @@ -263,7 +323,8 @@ def facture_pdf(request, facture, **_kwargs): 'siret': AssoOption.get_cached_value('siret'), 'email': AssoOption.get_cached_value('contact'), 'phone': AssoOption.get_cached_value('telephone'), - 'tpl_path': os.path.join(settings.BASE_DIR, LOGO_PATH) + 'tpl_path': os.path.join(settings.BASE_DIR, LOGO_PATH), + 'payment_method': facture.paiement.moyen, }) @@ -328,6 +389,55 @@ def del_facture(request, facture, **_kwargs): }, 'cotisations/delete.html', request) +@login_required +@can_edit(CostEstimate) +def edit_cost_estimate(request, invoice, **kwargs): + # Building the invocie form and the article formset + invoice_form = CostEstimateForm( + request.POST or None, + instance=invoice + ) + purchases_objects = Vente.objects.filter(facture=invoice) + purchase_form_set = modelformset_factory( + Vente, + fields=('name', 'number'), + extra=0, + max_num=len(purchases_objects) + ) + purchase_form = purchase_form_set( + request.POST or None, + queryset=purchases_objects + ) + if invoice_form.is_valid() and purchase_form.is_valid(): + if invoice_form.changed_data: + invoice_form.save() + purchase_form.save() + messages.success( + request, + _("The cost estimate was edited.") + ) + return redirect(reverse('cotisations:index-cost-estimate')) + + return form({ + 'factureform': invoice_form, + 'venteform': purchase_form, + 'title': "Edit the cost estimate" + }, 'cotisations/edit_facture.html', request) + + +@login_required +@can_edit(CostEstimate) +@can_create(CustomInvoice) +def cost_estimate_to_invoice(request, cost_estimate, **_kwargs): + """Create a custom invoice from a cos estimate""" + cost_estimate.create_invoice() + messages.success( + request, + _("An invoice was successfully created from your cost estimate.") + ) + return redirect(reverse('cotisations:index-custom-invoice')) + + @login_required @can_edit(CustomInvoice) def edit_custom_invoice(request, invoice, **kwargs): @@ -364,10 +474,10 @@ def edit_custom_invoice(request, invoice, **kwargs): @login_required -@can_view(CustomInvoice) -def custom_invoice_pdf(request, invoice, **_kwargs): +@can_view(CostEstimate) +def cost_estimate_pdf(request, invoice, **_kwargs): """ - View used to generate a PDF file from an existing invoice in database + View used to generate a PDF file from an existing cost estimate in database 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. @@ -379,7 +489,7 @@ def custom_invoice_pdf(request, invoice, **_kwargs): purchases_info = [] for purchase in purchases_objects: purchases_info.append({ - 'name': purchase.name, + 'name': escape_chars(purchase.name), 'price': purchase.prix, 'quantity': purchase.number, 'total_price': purchase.prix_total @@ -398,11 +508,74 @@ def custom_invoice_pdf(request, invoice, **_kwargs): 'siret': AssoOption.get_cached_value('siret'), 'email': AssoOption.get_cached_value('contact'), 'phone': AssoOption.get_cached_value('telephone'), - 'tpl_path': os.path.join(settings.BASE_DIR, LOGO_PATH) + 'tpl_path': os.path.join(settings.BASE_DIR, LOGO_PATH), + 'payment_method': invoice.payment, + 'remark': invoice.remark, + 'end_validity': invoice.date + invoice.validity, + 'is_estimate': True, + }) + + +@login_required +@can_delete(CostEstimate) +def del_cost_estimate(request, estimate, **_kwargs): + """ + View used to delete an existing invocie. + """ + if request.method == "POST": + estimate.delete() + messages.success( + request, + _("The cost estimate was deleted.") + ) + return redirect(reverse('cotisations:index-cost-estimate')) + return form({ + 'objet': estimate, + 'objet_name': _("Cost Estimate") + }, 'cotisations/delete.html', request) + + +@login_required +@can_view(CustomInvoice) +def custom_invoice_pdf(request, invoice, **_kwargs): + """ + View used to generate a PDF file from an existing invoice in database + 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. + """ + # 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) + purchases_info = [] + for purchase in purchases_objects: + purchases_info.append({ + 'name': escape_chars(purchase.name), + 'price': purchase.prix, + 'quantity': purchase.number, + 'total_price': purchase.prix_total + }) + return render_invoice(request, { + 'paid': invoice.paid, + 'fid': invoice.id, + 'DATE': invoice.date, + 'recipient_name': invoice.recipient, + 'address': invoice.address, + 'article': purchases_info, + 'total': invoice.prix_total(), + 'asso_name': AssoOption.get_cached_value('name'), + 'line1': AssoOption.get_cached_value('adresse1'), + 'line2': AssoOption.get_cached_value('adresse2'), + 'siret': AssoOption.get_cached_value('siret'), + 'email': AssoOption.get_cached_value('contact'), + 'phone': AssoOption.get_cached_value('telephone'), + 'tpl_path': os.path.join(settings.BASE_DIR, LOGO_PATH), + 'payment_method': invoice.payment, + 'remark': invoice.remark, }) -# TODO : change facture to invoice @login_required @can_delete(CustomInvoice) def del_custom_invoice(request, invoice, **_kwargs): @@ -753,12 +926,35 @@ def index_banque(request): }) +@login_required +@can_view_all(CustomInvoice) +def index_cost_estimate(request): + """View used to display every custom invoice.""" + pagination_number = GeneralOption.get_cached_value('pagination_number') + cost_estimate_list = CostEstimate.objects.prefetch_related('vente_set') + cost_estimate_list = SortTable.sort( + cost_estimate_list, + request.GET.get('col'), + request.GET.get('order'), + SortTable.COTISATIONS_CUSTOM + ) + cost_estimate_list = re2o_paginator( + request, + cost_estimate_list, + pagination_number, + ) + return render(request, 'cotisations/index_cost_estimate.html', { + 'cost_estimate_list': cost_estimate_list + }) + + @login_required @can_view_all(CustomInvoice) def index_custom_invoice(request): """View used to display every custom invoice.""" pagination_number = GeneralOption.get_cached_value('pagination_number') - custom_invoice_list = CustomInvoice.objects.prefetch_related('vente_set') + 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 = SortTable.sort( custom_invoice_list, request.GET.get('col'), diff --git a/freeradius_utils/auth.py b/freeradius_utils/auth.py index ab10d457..e605aea5 100644 --- a/freeradius_utils/auth.py +++ b/freeradius_utils/auth.py @@ -57,14 +57,9 @@ application = get_wsgi_application() from machines.models import Interface, IpList, Nas, Domain from topologie.models import Port, Switch from users.models import User -from preferences.models import OptionalTopologie +from preferences.models import RadiusOption -options, created = OptionalTopologie.objects.get_or_create() -VLAN_NOK = options.vlan_decision_nok.vlan_id -VLAN_OK = options.vlan_decision_ok.vlan_id -RADIUS_POLICY = options.radius_general_policy - #: Serveur radius de test (pas la prod) TEST_SERVER = bool(os.getenv('DBG_FREERADIUS', False)) @@ -287,6 +282,7 @@ def find_nas_from_request(nas_id): .select_related('machine__switch__stack')) return nas.first() + def check_user_machine_and_register(nas_type, username, mac_address): """Verifie le username et la mac renseignee. L'enregistre si elle est inconnue. @@ -327,32 +323,50 @@ def check_user_machine_and_register(nas_type, username, mac_address): def decide_vlan_switch(nas_machine, nas_type, port_number, - mac_address): + mac_address): """Fonction de placement vlan pour un switch en radius filaire auth par mac. Plusieurs modes : - - nas inconnu, port inconnu : on place sur le vlan par defaut VLAN_OK - - pas de radius sur le port : VLAN_OK - - bloq : VLAN_NOK - - force : placement sur le vlan indiqué dans la bdd - - mode strict : - - pas de chambre associée : VLAN_NOK - - pas d'utilisateur dans la chambre : VLAN_NOK - - cotisation non à jour : VLAN_NOK - - sinon passe à common (ci-dessous) - - mode common : - - interface connue (macaddress): - - utilisateur proprio non cotisant ou banni : VLAN_NOK - - user à jour : VLAN_OK - - interface inconnue : - - register mac désactivé : VLAN_NOK - - register mac activé -> redirection vers webauth + - tous les modes: + - nas inconnu: VLAN_OK + - port inconnu: Politique définie dans RadiusOption + - pas de radius sur le port: VLAN_OK + - force: placement sur le vlan indiqué dans la bdd + - mode strict: + - pas de chambre associée: Politique définie + dans RadiusOption + - pas d'utilisateur dans la chambre : Rejet + (redirection web si disponible) + - utilisateur de la chambre banni ou désactivé : Rejet + (redirection web si disponible) + - utilisateur de la chambre non cotisant et non whiteslist: + Politique définie dans RadiusOption + + - sinon passe à common (ci-dessous) + - mode common : + - interface connue (macaddress): + - utilisateur proprio non cotisant / machine désactivée: + Politique définie dans RadiusOption + - utilisateur proprio banni : + Politique définie dans RadiusOption + - user à jour : VLAN_OK (réassignation de l'ipv4 au besoin) + - interface inconnue : + - register mac désactivé : Politique définie + dans RadiusOption + - register mac activé: redirection vers webauth + Returns: + tuple avec : + - Nom du switch (str) + - chambre (str) + - raison de la décision (str) + - vlan_id (int) + - decision (bool) """ # Get port from switch and port number extra_log = "" # Si le NAS est inconnu, on place sur le vlan defaut if not nas_machine: - return ('?', u'Chambre inconnue', u'Nas inconnu', VLAN_OK, True) + return ('?', u'Chambre inconnue', u'Nas inconnu', RadiusOption.get_cached_value('vlan_decision_ok').vlan_id, True) sw_name = str(getattr(nas_machine, 'short_name', str(nas_machine))) @@ -367,7 +381,13 @@ def decide_vlan_switch(nas_machine, nas_type, port_number, # Aucune information particulière ne permet de déterminer quelle # politique à appliquer sur ce port if not port: - return (sw_name, "Chambre inconnue", u'Port inconnu', VLAN_OK, True) + return ( + sw_name, + "Chambre inconnue", + u'Port inconnu', + getattr(RadiusOption.get_cached_value('unknown_port_vlan'), 'vlan_id', None), + RadiusOption.get_cached_value('unknown_port')!= RadiusOption.REJECT + ) # On récupère le profil du port port_profile = port.get_port_profile @@ -378,11 +398,11 @@ def decide_vlan_switch(nas_machine, nas_type, port_number, DECISION_VLAN = int(port_profile.vlan_untagged.vlan_id) extra_log = u"Force sur vlan " + str(DECISION_VLAN) else: - DECISION_VLAN = VLAN_OK + DECISION_VLAN = RadiusOption.get_cached_value('vlan_decision_ok') - # Si le port est désactivé, on rejette sur le vlan de déconnexion + # Si le port est désactivé, on rejette la connexion if not port.state: - return (sw_name, port.room, u'Port desactivé', VLAN_NOK, True) + return (sw_name, port.room, u'Port desactivé', None, False) # Si radius est désactivé, on laisse passer if port_profile.radius_type == 'NO': @@ -392,33 +412,68 @@ def decide_vlan_switch(nas_machine, nas_type, port_number, DECISION_VLAN, True) - # Si le 802.1X est activé sur ce port, cela veut dire que la personne a été accept précédemment + # Si le 802.1X est activé sur ce port, cela veut dire que la personne a + # été accept précédemment # Par conséquent, on laisse passer sur le bon vlan - if nas_type.port_access_mode == '802.1X' and port_profile.radius_type == '802.1X': + if (nas_type.port_access_mode, port_profile.radius_type) == ('802.1X', '802.1X'): room = port.room or "Chambre/local inconnu" - return (sw_name, room, u'Acceptation authentification 802.1X', DECISION_VLAN, True) + return ( + sw_name, + room, + u'Acceptation authentification 802.1X', + DECISION_VLAN, + True + ) # Sinon, cela veut dire qu'on fait de l'auth radius par mac # Si le port est en mode strict, on vérifie que tous les users - # rattachés à ce port sont bien à jour de cotisation. Sinon on rejette (anti squattage) - # Il n'est pas possible de se connecter sur une prise strict sans adhérent à jour de cotis - # dedans + # rattachés à ce port sont bien à jour de cotisation. Sinon on rejette + # (anti squattage) + # Il n'est pas possible de se connecter sur une prise strict sans adhérent + # à jour de cotis dedans if port_profile.radius_mode == 'STRICT': room = port.room if not room: - return (sw_name, "Inconnue", u'Chambre inconnue', VLAN_NOK, True) + return ( + sw_name, + "Inconnue", + u'Chambre inconnue', + getattr(RadiusOption.get_cached_value('unknown_room_vlan'), 'vlan_id', None), + RadiusOption.get_cached_value('unknown_room')!= RadiusOption.REJECT + ) room_user = User.objects.filter( Q(club__room=port.room) | Q(adherent__room=port.room) ) if not room_user: - return (sw_name, room, u'Chambre non cotisante -> Web redirect', None, False) + return ( + sw_name, + room, + u'Chambre non cotisante -> Web redirect', + None, + False + ) for user in room_user: - if not user.has_access(): - return (sw_name, room, u'Chambre resident desactive -> Web redirect', None, False) + if user.is_ban() or user.state != User.STATE_ACTIVE: + return ( + sw_name, + room, + u'Utilisateur banni ou désactivé -> Web redirect', + None, + False + ) + elif not (user.is_connected() or user.is_whitelisted()): + return ( + sw_name, + room, + u'Utilisateur non cotisant', + getattr(RadiusOption.get_cached_value('non_member_vlan'), 'vlan_id', None), + RadiusOption.get_cached_value('non_member')!= RadiusOption.REJECT + ) # else: user OK, on passe à la verif MAC - # Si on fait de l'auth par mac, on cherche l'interface via sa mac dans la bdd + # Si on fait de l'auth par mac, on cherche l'interface + # via sa mac dans la bdd if port_profile.radius_mode == 'COMMON' or port_profile.radius_mode == 'STRICT': # Authentification par mac interface = (Interface.objects @@ -428,38 +483,67 @@ def decide_vlan_switch(nas_machine, nas_type, port_number, .first()) if not interface: room = port.room - # On essaye de register la mac, si l'autocapture a été activée - # Sinon on rejette sur vlan_nok - if not nas_type.autocapture_mac: - return (sw_name, "", u'Machine inconnue', VLAN_NOK, True) - # On rejette pour basculer sur du webauth + # On essaye de register la mac, si l'autocapture a été activée, + # on rejette pour faire une redirection web si possible. + if nas_type.autocapture_mac: + return ( + sw_name, + room, + u'Machine Inconnue -> Web redirect', + None, + False + ) + # Sinon on bascule sur la politique définie dans les options + # radius. else: - return (sw_name, room, u'Machine Inconnue -> Web redirect', None, False) + return ( + sw_name, + "", + u'Machine inconnue', + getattr(RadiusOption.get_cached_value('unknown_machine_vlan'), 'vlan_id', None), + RadiusOption.get_cached_value('unknown_machine')!= RadiusOption.REJECT + ) - # L'interface a été trouvée, on vérifie qu'elle est active, sinon on reject + # L'interface a été trouvée, on vérifie qu'elle est active, + # sinon on reject # Si elle n'a pas d'ipv4, on lui en met une # Enfin on laisse passer sur le vlan pertinent else: room = port.room + if interface.machine.user.is_ban(): + return ( + sw_name, + room, + u'Adherent banni', + getattr(RadiusOption.get_cached_value('banned_vlan'), 'vlan_id', None), + RadiusOption.get_cached_value('banned')!= RadiusOption.REJECT + ) if not interface.is_active: - return (sw_name, - room, - u'Machine non active / adherent non cotisant', - VLAN_NOK, - True) - ## Si on choisi de placer les machines sur le vlan correspondant à leur type : - if RADIUS_POLICY == 'MACHINE': + return ( + sw_name, + room, + u'Machine non active / adherent non cotisant', + getattr(RadiusOption.get_cached_value('non_member_vlan'), 'vlan_id', None), + RadiusOption.get_cached_value('non_member')!= RadiusOption.REJECT + ) + # Si on choisi de placer les machines sur le vlan + # correspondant à leur type : + if RadiusOption.get_cached_value('radius_general_policy') == 'MACHINE': DECISION_VLAN = interface.type.ip_type.vlan.vlan_id if not interface.ipv4: interface.assign_ipv4() - return (sw_name, - room, - u"Ok, Reassignation de l'ipv4" + extra_log, - DECISION_VLAN, - True) + return ( + sw_name, + room, + u"Ok, Reassignation de l'ipv4" + extra_log, + DECISION_VLAN, + True + ) else: - return (sw_name, - room, - u'Machine OK' + extra_log, - DECISION_VLAN, - True) + return ( + sw_name, + room, + u'Machine OK' + extra_log, + DECISION_VLAN, + True + ) diff --git a/install_re2o.sh b/install_re2o.sh index 6168ec08..b6d8b2aa 100755 --- a/install_re2o.sh +++ b/install_re2o.sh @@ -316,6 +316,10 @@ update_django() { echo "Collecting web frontend statics ..." python3 manage.py collectstatic --noinput echo "Collecting web frontend statics: Done" + + echo "Generating locales ..." + python3 manage.py compilemessages + echo "Generating locales: Done" } diff --git a/logs/locale/fr/LC_MESSAGES/django.mo b/logs/locale/fr/LC_MESSAGES/django.mo deleted file mode 100644 index 030b0cac..00000000 Binary files a/logs/locale/fr/LC_MESSAGES/django.mo and /dev/null differ diff --git a/logs/views.py b/logs/views.py index 21e3c470..a54edd56 100644 --- a/logs/views.py +++ b/logs/views.py @@ -102,15 +102,18 @@ from re2o.utils import ( all_baned, all_has_access, all_adherent, + all_active_assigned_interfaces_count, + all_active_interfaces_count, +) +from re2o.base import ( re2o_paginator, + SortTable ) from re2o.acl import ( can_view_all, can_view_app, can_edit_history, ) -from re2o.utils import all_active_assigned_interfaces_count -from re2o.utils import all_active_interfaces_count, SortTable @login_required diff --git a/machines/forms.py b/machines/forms.py index abc96811..94b9293a 100644 --- a/machines/forms.py +++ b/machines/forms.py @@ -273,6 +273,7 @@ class ExtensionForm(FormRevMixin, ModelForm): self.fields['origin'].label = _("A record origin") self.fields['origin_v6'].label = _("AAAA record origin") self.fields['soa'].label = _("SOA record to use") + self.fields['dnssec'].label = _("Sign with DNSSEC") class DelExtensionForm(FormRevMixin, Form): diff --git a/machines/locale/fr/LC_MESSAGES/django.mo b/machines/locale/fr/LC_MESSAGES/django.mo deleted file mode 100644 index c9696d92..00000000 Binary files a/machines/locale/fr/LC_MESSAGES/django.mo and /dev/null differ diff --git a/machines/migrations/0097_extension_dnssec.py b/machines/migrations/0097_extension_dnssec.py new file mode 100644 index 00000000..48e41f77 --- /dev/null +++ b/machines/migrations/0097_extension_dnssec.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2018-12-24 14:00 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('machines', '0096_auto_20181013_1417'), + ] + + operations = [ + migrations.AddField( + model_name='extension', + name='dnssec', + field=models.BooleanField(default=False, help_text='Should the zone be signed with DNSSEC'), + ), + ] diff --git a/machines/migrations/0098_auto_20190102_1745.py b/machines/migrations/0098_auto_20190102_1745.py new file mode 100644 index 00000000..e886e8a1 --- /dev/null +++ b/machines/migrations/0098_auto_20190102_1745.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2019-01-02 23:45 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('machines', '0097_extension_dnssec'), + ] + + operations = [ + migrations.AlterField( + model_name='role', + name='specific_role', + field=models.CharField(blank=True, choices=[('dhcp-server', 'DHCP server'), ('switch-conf-server', 'Switches configuration server'), ('dns-recursif-server', 'Recursive DNS server'), ('dns-recursive-server', 'Recursive DNS server'), ('ntp-server', 'NTP server'), ('radius-server', 'RADIUS server'), ('log-server', 'Log server'), ('ldap-master-server', 'LDAP master server'), ('ldap-backup-server', 'LDAP backup server'), ('smtp-server', 'SMTP server'), ('postgresql-server', 'postgreSQL server'), ('mysql-server', 'mySQL server'), ('sql-client', 'SQL client'), ('gateway', 'Gateway')], max_length=32, null=True), + ), + ] diff --git a/machines/migrations/0099_role_recursive_dns.py b/machines/migrations/0099_role_recursive_dns.py new file mode 100644 index 00000000..c1ce3965 --- /dev/null +++ b/machines/migrations/0099_role_recursive_dns.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2019-01-02 23:45 +from __future__ import unicode_literals + +from django.db import migrations, models + + +def migrate(apps, schema_editor): + Role = apps.get_model('machines', 'Role') + + for role in Role.objects.filter(specific_role='dns-recursif-server'): + role.specific_role = 'dns-recursive-server' + role.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('machines', '0098_auto_20190102_1745'), + ] + + operations = [ + migrations.RunPython(migrate), + ] + + diff --git a/machines/migrations/0100_auto_20190102_1753.py b/machines/migrations/0100_auto_20190102_1753.py new file mode 100644 index 00000000..35f7b78d --- /dev/null +++ b/machines/migrations/0100_auto_20190102_1753.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2019-01-02 23:53 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('machines', '0099_role_recursive_dns'), + ] + + operations = [ + migrations.AlterField( + model_name='role', + name='specific_role', + field=models.CharField(blank=True, choices=[('dhcp-server', 'DHCP server'), ('switch-conf-server', 'Switches configuration server'), ('dns-recursive-server', 'Recursive DNS server'), ('ntp-server', 'NTP server'), ('radius-server', 'RADIUS server'), ('log-server', 'Log server'), ('ldap-master-server', 'LDAP master server'), ('ldap-backup-server', 'LDAP backup server'), ('smtp-server', 'SMTP server'), ('postgresql-server', 'postgreSQL server'), ('mysql-server', 'mySQL server'), ('sql-client', 'SQL client'), ('gateway', 'Gateway')], max_length=32, null=True), + ), + ] diff --git a/machines/models.py b/machines/models.py index 48e50644..a4685676 100644 --- a/machines/models.py +++ b/machines/models.py @@ -696,6 +696,10 @@ class Extension(RevMixin, AclMixin, models.Model): 'SOA', on_delete=models.CASCADE ) + dnssec = models.BooleanField( + default=False, + help_text=_("Should the zone be signed with DNSSEC") + ) class Meta: permissions = ( @@ -741,6 +745,9 @@ class Extension(RevMixin, AclMixin, models.Model): .filter(cname__interface_parent__in=all_active_assigned_interfaces()) .prefetch_related('cname')) + def get_associated_dname_records(self): + return (DName.objects.filter(alias=self)) + @staticmethod def can_use_all(user_request, *_args, **_kwargs): """Superdroit qui permet d'utiliser toutes les extensions sans @@ -1089,7 +1096,7 @@ class Interface(RevMixin, AclMixin, FieldPermissionModelMixin, models.Model): .get_cached_value('ipv6_mode') == 'DHCPV6'): return self.ipv6list.filter(slaac_ip=False) else: - return None + return [] def mac_bare(self): """ Formatage de la mac type mac_bare""" @@ -1373,7 +1380,10 @@ class Ipv6List(RevMixin, AclMixin, FieldPermissionModelMixin, models.Model): .filter(interface=self.interface, slaac_ip=True) .exclude(id=self.id)): raise ValidationError(_("A SLAAC IP address is already registered.")) - prefix_v6 = self.interface.type.ip_type.prefix_v6.encode().decode('utf-8') + try: + prefix_v6 = self.interface.type.ip_type.prefix_v6.encode().decode('utf-8') + except AttributeError: # Prevents from crashing when there is no defined prefix_v6 + prefix_v6 = None if prefix_v6: if (IPv6Address(self.ipv6.encode().decode('utf-8')).exploded[:20] != IPv6Address(prefix_v6).exploded[:20]): @@ -1602,7 +1612,7 @@ class Role(RevMixin, AclMixin, models.Model): ROLE = ( ('dhcp-server', _("DHCP server")), ('switch-conf-server', _("Switches configuration server")), - ('dns-recursif-server', _("Recursive DNS server")), + ('dns-recursive-server', _("Recursive DNS server")), ('ntp-server', _("NTP server")), ('radius-server', _("RADIUS server")), ('log-server', _("Log server")), @@ -1631,18 +1641,6 @@ class Role(RevMixin, AclMixin, models.Model): verbose_name = _("server role") verbose_name_plural = _("server roles") - @classmethod - def get_instance(cls, roleid, *_args, **_kwargs): - """Get the Role instance with roleid. - - Args: - roleid: The id - - Returns: - The role. - """ - return cls.objects.get(pk=roleid) - @classmethod def interface_for_roletype(cls, roletype): """Return interfaces for a roletype""" @@ -1657,14 +1655,6 @@ class Role(RevMixin, AclMixin, models.Model): machine__interface__role=cls.objects.filter(specific_role=roletype) ) - @classmethod - def get_instance(cls, roleid, *_args, **_kwargs): - """Get the Machine instance with machineid. - :param userid: The id - :return: The user - """ - return cls.objects.get(pk=roleid) - @classmethod def interface_for_roletype(cls, roletype): """Return interfaces for a roletype""" diff --git a/machines/templates/machines/aff_extension.html b/machines/templates/machines/aff_extension.html index b8493f5f..fae4e25b 100644 --- a/machines/templates/machines/aff_extension.html +++ b/machines/templates/machines/aff_extension.html @@ -38,6 +38,7 @@ with this program; if not, write to the Free Software Foundation, Inc., {% if ipv6_enabled %} {% trans "AAAA record origin" %} {% endif %} + {% trans "DNSSEC" %} @@ -50,6 +51,7 @@ with this program; if not, write to the Free Software Foundation, Inc., {% if ipv6_enabled %} {{ extension.origin_v6 }} {% endif %} + {{ extension.dnssec|tick }} {% can_edit extension %} {% include 'buttons/edit.html' with href='machines:edit-extension' id=extension.id %} diff --git a/machines/templates/machines/aff_machines.html b/machines/templates/machines/aff_machines.html index 60e1a57a..d5a83ed3 100644 --- a/machines/templates/machines/aff_machines.html +++ b/machines/templates/machines/aff_machines.html @@ -28,7 +28,7 @@ with this program; if not, write to the Free Software Foundation, Inc.,
{% if machines_list.paginator %} - {% include "pagination.html" with list=machines_list %} + {% include "pagination.html" with list=machines_list go_to_id="machines" %} {% endif %} @@ -215,6 +215,6 @@ with this program; if not, write to the Free Software Foundation, Inc., {% if machines_list.paginator %} - {% include "pagination.html" with list=machines_list %} + {% include "pagination.html" with list=machines_list go_to_id="machines" %} {% endif %} diff --git a/machines/templates/machines/machine.html b/machines/templates/machines/machine.html index 1a8ce8b1..1e2e3700 100644 --- a/machines/templates/machines/machine.html +++ b/machines/templates/machines/machine.html @@ -95,9 +95,9 @@ with this program; if not, write to the Free Software Foundation, Inc., {% if interfaceform %}

{% trans "Interface" %}

{% if i_mbf_param %} - {% massive_bootstrap_form interfaceform 'ipv4,machine' mbf_param=i_mbf_param %} + {% massive_bootstrap_form interfaceform 'ipv4,machine,port_lists' mbf_param=i_mbf_param %} {% else %} - {% massive_bootstrap_form interfaceform 'ipv4,machine' %} + {% massive_bootstrap_form interfaceform 'ipv4,machine,port_lists' %} {% endif %} {% endif %} {% if domainform %} @@ -146,7 +146,7 @@ with this program; if not, write to the Free Software Foundation, Inc., {% endif %} {% if aliasform %}

{% trans "Alias" %}

- {% bootstrap_form aliasform %} + {% massive_bootstrap_form aliasform 'extension' %} {% endif %} {% if serviceform %}

{% trans "Service" %}

diff --git a/machines/views.py b/machines/views.py index 8d395749..59d4bd5a 100644 --- a/machines/views.py +++ b/machines/views.py @@ -55,6 +55,8 @@ from re2o.acl import ( from re2o.utils import ( all_active_assigned_interfaces, filter_active_interfaces, +) +from re2o.base import ( SortTable, re2o_paginator, ) diff --git a/preferences/forms.py b/preferences/forms.py index 2f90927f..fd052edd 100644 --- a/preferences/forms.py +++ b/preferences/forms.py @@ -42,6 +42,7 @@ from .models import ( Reminder, RadiusKey, SwitchManagementCred, + RadiusOption, ) from topologie.models import Switch @@ -114,11 +115,6 @@ class EditOptionalTopologieForm(ModelForm): prefix=prefix, **kwargs ) - self.fields['radius_general_policy'].label = _("RADIUS general policy") - self.fields['vlan_decision_ok'].label = _("VLAN for machines accepted" - " by RADIUS") - self.fields['vlan_decision_nok'].label = _("VLAN for machines rejected" - " by RADIUS") self.initial['automatic_provision_switchs'] = Switch.objects.filter(automatic_provision=True).order_by('interface__domain__name') @@ -229,6 +225,13 @@ class EditHomeOptionForm(ModelForm): self.fields['twitter_account_name'].label = _("Twitter account name") +class EditRadiusOptionForm(ModelForm): + """Edition forms for Radius options""" + class Meta: + model = RadiusOption + fields = '__all__' + + class ServiceForm(ModelForm): """Edition, ajout de services sur la page d'accueil""" class Meta: diff --git a/preferences/locale/fr/LC_MESSAGES/django.mo b/preferences/locale/fr/LC_MESSAGES/django.mo deleted file mode 100644 index 1d64de15..00000000 Binary files a/preferences/locale/fr/LC_MESSAGES/django.mo and /dev/null differ diff --git a/preferences/migrations/0055_generaloption_main_site_url.py b/preferences/migrations/0055_generaloption_main_site_url.py new file mode 100644 index 00000000..655c0b07 --- /dev/null +++ b/preferences/migrations/0055_generaloption_main_site_url.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2018-11-14 16:46 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('preferences', '0053_optionaluser_self_change_room'), + ] + + operations = [ + migrations.AddField( + model_name='generaloption', + name='main_site_url', + field=models.URLField(default='http://re2o.example.org', max_length=255), + ), + ] diff --git a/preferences/migrations/0056_1_radiusoption.py b/preferences/migrations/0056_1_radiusoption.py new file mode 100644 index 00000000..8a7cb45c --- /dev/null +++ b/preferences/migrations/0056_1_radiusoption.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2018-10-13 14:29 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import re2o.mixins + + +class Migration(migrations.Migration): + + dependencies = [ + ('machines', '0095_auto_20180919_2225'), + ('preferences', '0055_generaloption_main_site_url'), + ] + + operations = [ + migrations.CreateModel( + name='RadiusOption', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('radius_general_policy', models.CharField(choices=[('MACHINE', "On the IP range's VLAN of the machine"), ('DEFINED', "Preset in 'VLAN for machines accepted by RADIUS'")], default='DEFINED', max_length=32)), + ], + options={ + 'verbose_name': 'radius policies', + }, + bases=(re2o.mixins.AclMixin, models.Model), + ), + migrations.AddField( + model_name='radiusoption', + name='banned_vlan', + field=models.ForeignKey(blank=True, help_text='Vlan for banned if not rejected.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='banned_vlan', to='machines.Vlan', verbose_name='Banned Vlan'), + ), + migrations.AddField( + model_name='radiusoption', + name='non_member_vlan', + field=models.ForeignKey(blank=True, help_text='Vlan for non members if not rejected.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='non_member_vlan', to='machines.Vlan', verbose_name='Non member Vlan'), + ), + migrations.AddField( + model_name='radiusoption', + name='unknown_machine_vlan', + field=models.ForeignKey(blank=True, help_text='Vlan for unknown machines if not rejected.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='unknown_machine_vlan', to='machines.Vlan', verbose_name='Unknown machine Vlan'), + ), + migrations.AddField( + model_name='radiusoption', + name='unknown_port_vlan', + field=models.ForeignKey(blank=True, help_text='Vlan for unknown ports if not rejected.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='unknown_port_vlan', to='machines.Vlan', verbose_name='Unknown port Vlan'), + ), + migrations.AddField( + model_name='radiusoption', + name='unknown_room_vlan', + field=models.ForeignKey(blank=True, help_text='Vlan for unknown room if not rejected.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='unknown_room_vlan', to='machines.Vlan', verbose_name='Unknown room Vlan'), + ), + migrations.AddField( + model_name='radiusoption', + name='banned', + field=models.CharField(choices=[('REJECT', 'Reject the machine'), ('SET_VLAN', 'Place the machine on the VLAN')], default='REJECT', max_length=32, verbose_name='Policy for banned users.'), + ), + migrations.AddField( + model_name='radiusoption', + name='non_member', + field=models.CharField(choices=[('REJECT', 'Reject the machine'), ('SET_VLAN', 'Place the machine on the VLAN')], default='REJECT', max_length=32, verbose_name='Policy non member users.'), + ), + migrations.AddField( + model_name='radiusoption', + name='unknown_machine', + field=models.CharField(choices=[('REJECT', 'Reject the machine'), ('SET_VLAN', 'Place the machine on the VLAN')], default='REJECT', max_length=32, verbose_name='Policy for unknown machines'), + ), + migrations.AddField( + model_name='radiusoption', + name='unknown_port', + field=models.CharField(choices=[('REJECT', 'Reject the machine'), ('SET_VLAN', 'Place the machine on the VLAN')], default='REJECT', max_length=32, verbose_name='Policy for unknown machines'), + ), + migrations.AddField( + model_name='radiusoption', + name='unknown_room', + field=models.CharField(choices=[('REJECT', 'Reject the machine'), ('SET_VLAN', 'Place the machine on the VLAN')], default='REJECT', max_length=32, verbose_name='Policy for machine connecting from unregistered room (relevant on ports with STRICT radius mode)'), + ), + migrations.AddField( + model_name='radiusoption', + name='vlan_decision_ok', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vlan_ok_option', to='machines.Vlan'), + ), + ] diff --git a/preferences/migrations/0056_2_radiusoption.py b/preferences/migrations/0056_2_radiusoption.py new file mode 100644 index 00000000..1a8ecccd --- /dev/null +++ b/preferences/migrations/0056_2_radiusoption.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2018-10-13 14:29 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import re2o.mixins + + +def create_radius_policy(apps, schema_editor): + OptionalTopologie = apps.get_model('preferences', 'OptionalTopologie') + RadiusOption = apps.get_model('preferences', 'RadiusOption') + + option,_ = OptionalTopologie.objects.get_or_create() + + radius_option = RadiusOption() + radius_option.radius_general_policy = option.radius_general_policy + radius_option.vlan_decision_ok = option.vlan_decision_ok + + radius_option.save() + +def revert_radius(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('machines', '0095_auto_20180919_2225'), + ('preferences', '0055_generaloption_main_site_url'), + ('preferences', '0056_1_radiusoption'), + ] + + operations = [ + migrations.RunPython(create_radius_policy, revert_radius), + ] diff --git a/preferences/migrations/0056_3_radiusoption.py b/preferences/migrations/0056_3_radiusoption.py new file mode 100644 index 00000000..f3e5f98c --- /dev/null +++ b/preferences/migrations/0056_3_radiusoption.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2018-10-13 14:29 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import re2o.mixins + + +class Migration(migrations.Migration): + + dependencies = [ + ('machines', '0095_auto_20180919_2225'), + ('preferences', '0055_generaloption_main_site_url'), + ('preferences', '0056_2_radiusoption'), + ] + + operations = [ + migrations.RemoveField( + model_name='optionaltopologie', + name='radius_general_policy', + ), + migrations.RemoveField( + model_name='optionaltopologie', + name='vlan_decision_nok', + ), + migrations.RemoveField( + model_name='optionaltopologie', + name='vlan_decision_ok', + ), + ] diff --git a/preferences/migrations/0056_4_radiusoption.py b/preferences/migrations/0056_4_radiusoption.py new file mode 100644 index 00000000..8d93fff9 --- /dev/null +++ b/preferences/migrations/0056_4_radiusoption.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2018-12-04 13:57 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('preferences', '0056_3_radiusoption'), + ] + + operations = [ + migrations.AlterField( + model_name='radiusoption', + name='unknown_port', + field=models.CharField(choices=[('REJECT', 'Reject the machine'), ('SET_VLAN', 'Place the machine on the VLAN')], default='REJECT', max_length=32, verbose_name='Policy for unknown port'), + ), + ] diff --git a/preferences/migrations/0057_optionaluser_all_users_active.py b/preferences/migrations/0057_optionaluser_all_users_active.py new file mode 100644 index 00000000..3f0cc8c1 --- /dev/null +++ b/preferences/migrations/0057_optionaluser_all_users_active.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2019-01-05 17:15 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('preferences', '0056_4_radiusoption'), + ] + + operations = [ + migrations.AddField( + model_name='optionaluser', + name='all_users_active', + field=models.BooleanField(default=False, help_text='If True, all new created and connected users are active. If False, only when a valid registration has been paid'), + ), + ] diff --git a/preferences/models.py b/preferences/models.py index b21f42fe..228807a6 100644 --- a/preferences/models.py +++ b/preferences/models.py @@ -116,6 +116,11 @@ class OptionalUser(AclMixin, PreferencesModel): default=False, help_text=_("A new user can create their account on Re2o") ) + all_users_active = models.BooleanField( + default=False, + help_text=_("If True, all new created and connected users are active.\ + If False, only when a valid registration has been paid") + ) class Meta: permissions = ( @@ -199,25 +204,6 @@ class OptionalTopologie(AclMixin, PreferencesModel): ('tftp', 'tftp'), ) - radius_general_policy = models.CharField( - max_length=32, - choices=CHOICE_RADIUS, - default='DEFINED' - ) - vlan_decision_ok = models.OneToOneField( - 'machines.Vlan', - on_delete=models.PROTECT, - related_name='decision_ok', - blank=True, - null=True - ) - vlan_decision_nok = models.OneToOneField( - 'machines.Vlan', - on_delete=models.PROTECT, - related_name='decision_nok', - blank=True, - null=True - ) switchs_web_management = models.BooleanField( default=False, help_text="Web management, activé si provision automatique" @@ -297,19 +283,19 @@ class OptionalTopologie(AclMixin, PreferencesModel): log_servers = Role.all_interfaces_for_roletype("log-server").filter(type__ip_type=self.switchs_ip_type) radius_servers = Role.all_interfaces_for_roletype("radius-server").filter(type__ip_type=self.switchs_ip_type) dhcp_servers = Role.all_interfaces_for_roletype("dhcp-server") + dns_recursive_servers = Role.all_interfaces_for_roletype("dns-recursive-server").filter(type__ip_type=self.switchs_ip_type) subnet = None subnet6 = None if self.switchs_ip_type: subnet = self.switchs_ip_type.ip_set_full_info subnet6 = self.switchs_ip_type.ip6_set_full_info - return {'ntp_servers': return_ips_dict(ntp_servers), 'log_servers': return_ips_dict(log_servers), 'radius_servers': return_ips_dict(radius_servers), 'dhcp_servers': return_ips_dict(dhcp_servers), 'subnet': subnet, 'subnet6': subnet6} + return {'ntp_servers': return_ips_dict(ntp_servers), 'log_servers': return_ips_dict(log_servers), 'radius_servers': return_ips_dict(radius_servers), 'dhcp_servers': return_ips_dict(dhcp_servers), 'dns_recursive_servers': return_ips_dict(dns_recursive_servers), 'subnet': subnet, 'subnet6': subnet6} @cached_property def provision_switchs_enabled(self): """Return true if all settings are ok : switchs on automatic provision, ip_type""" return bool(self.provisioned_switchs and self.switchs_ip_type and SwitchManagementCred.objects.filter(default_switch=True).exists() and self.switchs_management_interface_ip and bool(self.switchs_provision != 'sftp' or self.switchs_management_sftp_creds)) - class Meta: permissions = ( ("view_optionaltopologie", _("Can view the topology options")), @@ -431,6 +417,7 @@ class GeneralOption(AclMixin, PreferencesModel): req_expire_hrs = models.IntegerField(default=48) site_name = models.CharField(max_length=32, default="Re2o") email_from = models.EmailField(default="www-data@example.com") + main_site_url = models.URLField(max_length=255, default="http://re2o.example.org") GTU_sum_up = models.TextField( default="", blank=True, @@ -587,3 +574,122 @@ class MailMessageOption(AclMixin, models.Model): ) verbose_name = _("email message options") + +class RadiusOption(AclMixin, PreferencesModel): + class Meta: + verbose_name = _("radius policies") + + MACHINE = 'MACHINE' + DEFINED = 'DEFINED' + CHOICE_RADIUS = ( + (MACHINE, _("On the IP range's VLAN of the machine")), + (DEFINED, _("Preset in 'VLAN for machines accepted by RADIUS'")), + ) + REJECT = 'REJECT' + SET_VLAN = 'SET_VLAN' + CHOICE_POLICY = ( + (REJECT, _('Reject the machine')), + (SET_VLAN, _('Place the machine on the VLAN')) + ) + radius_general_policy = models.CharField( + max_length=32, + choices=CHOICE_RADIUS, + default='DEFINED' + ) + unknown_machine = models.CharField( + max_length=32, + choices=CHOICE_POLICY, + default=REJECT, + verbose_name=_("Policy for unknown machines"), + ) + unknown_machine_vlan = models.ForeignKey( + 'machines.Vlan', + on_delete=models.PROTECT, + related_name='unknown_machine_vlan', + blank=True, + null=True, + verbose_name=_('Unknown machine Vlan'), + help_text=_( + 'Vlan for unknown machines if not rejected.' + ) + ) + unknown_port = models.CharField( + max_length=32, + choices=CHOICE_POLICY, + default=REJECT, + verbose_name=_("Policy for unknown port"), + ) + unknown_port_vlan = models.ForeignKey( + 'machines.Vlan', + on_delete=models.PROTECT, + related_name='unknown_port_vlan', + blank=True, + null=True, + verbose_name=_('Unknown port Vlan'), + help_text=_( + 'Vlan for unknown ports if not rejected.' + ) + ) + unknown_room = models.CharField( + max_length=32, + choices=CHOICE_POLICY, + default=REJECT, + verbose_name=_( + "Policy for machine connecting from " + "unregistered room (relevant on ports with STRICT " + "radius mode)" + ), + ) + unknown_room_vlan = models.ForeignKey( + 'machines.Vlan', + related_name='unknown_room_vlan', + on_delete=models.PROTECT, + blank=True, + null=True, + verbose_name=_('Unknown room Vlan'), + help_text=_( + 'Vlan for unknown room if not rejected.' + ) + ) + non_member = models.CharField( + max_length=32, + choices=CHOICE_POLICY, + default=REJECT, + verbose_name=_("Policy non member users."), + ) + non_member_vlan = models.ForeignKey( + 'machines.Vlan', + related_name='non_member_vlan', + on_delete=models.PROTECT, + blank=True, + null=True, + verbose_name=_('Non member Vlan'), + help_text=_( + 'Vlan for non members if not rejected.' + ) + ) + banned = models.CharField( + max_length=32, + choices=CHOICE_POLICY, + default=REJECT, + verbose_name=_("Policy for banned users."), + ) + banned_vlan = models.ForeignKey( + 'machines.Vlan', + related_name='banned_vlan', + on_delete=models.PROTECT, + blank=True, + null=True, + verbose_name=_('Banned Vlan'), + help_text=_( + 'Vlan for banned if not rejected.' + ) + ) + vlan_decision_ok = models.OneToOneField( + 'machines.Vlan', + on_delete=models.PROTECT, + related_name='vlan_ok_option', + blank=True, + null=True + ) + diff --git a/preferences/templates/preferences/aff_radiusoptions.html b/preferences/templates/preferences/aff_radiusoptions.html new file mode 100644 index 00000000..17e2a869 --- /dev/null +++ b/preferences/templates/preferences/aff_radiusoptions.html @@ -0,0 +1,96 @@ +{% 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 %} + +
+ + + + + + + + + +
{% trans "General policy for VLAN setting" %}{{ radiusoptions.radius_general_policy }}{% trans "This setting defines the VLAN policy after acceptance by RADIUS: either on the IP range's VLAN of the machine, or a VLAN preset in 'VLAN for machines accepted by RADIUS'" %}
{% trans "VLAN for machines accepted by RADIUS" %}Vlan {{ radiusoptions.vlan_decision_ok }}
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Situation" %}{% trans "Behavior" %}
{% trans "Unknown machine" %} + {% if radiusoptions.unknown_machine == 'REJECT' %} + {% trans "Reject" %} + {% else %} + Vlan {{ radiusoptions.unknown_machine_vlan }} + {% endif %} +
{% trans "Unknown port" %} + {% if radiusoptions.unknown_port == 'REJECT' %} + {% trans "Reject" %} + {% else %} + Vlan {{ radiusoptions.unknown_port_vlan }} + {% endif %} +
{% trans "Unknown room" %} + {% if radiusoptions.unknown_room == 'REJECT' %} + {% trans "Reject" %} + {% else %} + Vlan {{ radiusoptions.unknown_room_vlan }} + {% endif %} +
{% trans "Non member" %} + {% if radiusoptions.non_member == 'REJECT' %} + {% trans "Reject" %} + {% else %} + Vlan {{ radiusoptions.non_member_vlan }} + {% endif %} +
{% trans "Banned user" %} + {% if radiusoptions.unknown_port == 'REJECT' %} + {% trans "Reject" %} + {% else %} + Vlan {{ radiusoptions.banned_vlan }} + {% endif %} +
+ diff --git a/preferences/templates/preferences/display_preferences.html b/preferences/templates/preferences/display_preferences.html index 1c29c595..e4321f5b 100644 --- a/preferences/templates/preferences/display_preferences.html +++ b/preferences/templates/preferences/display_preferences.html @@ -31,293 +31,427 @@ with this program; if not, write to the Free Software Foundation, Inc., {% block title %}{% trans "Preferences" %}{% endblock %} {% block content %} -

{% trans "User preferences" %}

-
- - {% trans "Edit" %} - -

-

-
{% trans "General preferences" %}
- - - - - - - - - - - - - -
{% trans "Creation of members by everyone" %}{{ useroptions.all_can_create_adherent|tick }}{% trans "Creation of clubs by everyone" %}{{ useroptions.all_can_create_club|tick }}
{% trans "Self registration" %}{{ useroptions.self_adhesion|tick }}{% trans "Delete not yet active users after" %}{{ useroptions.delete_notyetactive }} days
+
-
{% trans "Users general permissions" %}
- - - - - - - - - - - - - - - - - -
{% trans "Default shell for users" %}{{ useroptions.shell_default }}{% trans "Users can edit their shell" %}{{ useroptions.self_change_shell|tick }}
{% trans "Users can edit their room" %}{{ useroptions.self_change_room|tick }}{% trans "Telephone number required" %}{{ useroptions.is_tel_mandatory|tick }}
{% trans "GPG fingerprint field" %}{{ useroptions.gpg_fingerprint|tick }}
+
+ + +
+ + + {% trans "Edit" %} + +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Website name" %}{{ generaloptions.site_name }}{% trans "Email address for automatic emailing" %}{{ generaloptions.email_from }}
{% trans "Number of results displayed when searching" %}{{ generaloptions.search_display_page }}{% trans "Number of items per page (standard size)" %}{{ generaloptions.pagination_number }}
{% trans "Number of items per page (large size)" %}{{ generaloptions.pagination_large_number }}{% trans "Time before expiration of the reset password link (in hours)" %}{{ generaloptions.req_expire_hrs }}
{% trans "General message displayed on the website" %}{{ generaloptions.general_message }}{% trans "Main site url" %}{{ generaloptions.main_site_url }}
{% trans "Summary of the General Terms of Use" %}{{ generaloptions.GTU_sum_up }}{% trans "General Terms of Use" %}{{ generaloptions.GTU }} +
+ + + + + + + + + + + +
{% trans "Local email accounts enabled" %}{{ useroptions.local_email_accounts_enabled|tick }}{% trans "Local email domain" %}{{ useroptions.local_email_domain }}
{% trans "Maximum number of email aliases allowed" %}{{ useroptions.max_email_address }}
+
+
+ +
+ +
+ +

+ + + {% trans "Edit" %} + +

+ + + + + + + + + + + + + + + + + + +
{% trans "Creation of members by everyone" %}{{ useroptions.all_can_create_adherent|tick }}{% trans "Creation of clubs by everyone" %}{{ useroptions.all_can_create_club|tick }}
{% trans "Self registration" %}{{ useroptions.self_adhesion|tick }}{% trans "Delete not yet active users after" %}{{ useroptions.delete_notyetactive }} days
{% trans "All users are active by default" %}{{ useroptions.all_users_active|tick }}
+ +

{% trans "Users general permissions" %}

+ + + + + + + + + + + + + + + + + +
{% trans "Default shell for users" %}{{ useroptions.shell_default }}{% trans "Users can edit their shell" %}{{ useroptions.self_change_shell|tick }}
{% trans "Users can edit their room" %}{{ useroptions.self_change_room|tick }}{% trans "Telephone number required" %}{{ useroptions.is_tel_mandatory|tick }}
{% trans "GPG fingerprint field" %}{{ useroptions.gpg_fingerprint|tick }}
+
+
+
+ +
+ + +
+ + + + {% trans "Edit" %} + +

+ + + + + + + + + + + + + + + + + +
{% trans "Password per machine" %}{{ machineoptions.password_machine|tick }}{% trans "Maximum number of interfaces allowed for a standard user" %}{{ machineoptions.max_lambdauser_interfaces }}
{% trans "Maximum number of DNS aliases allowed for a standard user" %}{{ machineoptions.max_lambdauser_aliases }}{% trans "IPv6 support" %}{{ machineoptions.ipv6_mode }}
{% trans "Creation of machines" %}{{ machineoptions.create_machine|tick }}
+
+
+ +
+ +
-
{% trans "Email accounts preferences" %}
- - - - - - - - - - - -
{% trans "Local email accounts enabled" %}{{ useroptions.local_email_accounts_enabled|tick }}{% trans "Local email domain" %}{{ useroptions.local_email_domain }}
{% trans "Maximum number of email aliases allowed" %}{{ useroptions.max_email_address }}
-

{% trans "Machines preferences" %}

- - - {% trans "Edit" %} - -

-

- - - - - - - - - - - - - - - - - -
{% trans "Password per machine" %}{{ machineoptions.password_machine|tick }}{% trans "Maximum number of interfaces allowed for a standard user" %}{{ machineoptions.max_lambdauser_interfaces }}
{% trans "Maximum number of DNS aliases allowed for a standard user" %}{{ machineoptions.max_lambdauser_aliases }}{% trans "IPv6 support" %}{{ machineoptions.ipv6_mode }}
{% trans "Creation of machines" %}{{ machineoptions.create_machine|tick }}
-

{% trans "Topology preferences" %}

{% trans "Edit" %} -

-

- - - - - - - - - - - - - - - - - -
{% trans "General policy for VLAN setting" %}{{ topologieoptions.radius_general_policy }}{% trans "This setting defines the VLAN policy after acceptance by RADIUS: either on the IP range's VLAN of the machine, or a VLAN preset in 'VLAN for machines accepted by RADIUS'" %}
{% trans "VLAN for machines accepted by RADIUS" %}{{ topologieoptions.vlan_decision_ok }}{% trans "VLAN for machines rejected by RADIUS" %}{{ topologieoptions.vlan_decision_nok }}
Placement sur ce vlan par default en cas de rejet{{ topologieoptions.vlan_decision_nok }}
+

+ + + + + + + + + + + + + + + + + +
{% trans "General policy for VLAN setting" %}{{ topologieoptions.radius_general_policy }}{% trans "This setting defines the VLAN policy after acceptance by RADIUS: either on the IP range's VLAN of the machine, or a VLAN preset in 'VLAN for machines accepted by RADIUS'" %}
{% trans "VLAN for machines accepted by RADIUS" %}{{ topologieoptions.vlan_decision_ok }}{% trans "VLAN for machines rejected by RADIUS" %}{{ topologieoptions.vlan_decision_nok }}
{% trans "VLAN for non members machines" %}{{ topologieoptions.vlan_non_member }}
-
Clef radius
- {% can_create RadiusKey%} - Ajouter une clef radius - {% acl_end %} - {% include "preferences/aff_radiuskey.html" with radiuskey_list=radiuskey_list %} +

Clef radius

+ {% can_create RadiusKey%} + Ajouter une clef radius + {% acl_end %} + {% include "preferences/aff_radiuskey.html" with radiuskey_list=radiuskey_list %} -

Configuration des switches

- - - - - - - -
Web management, activé si provision automatique{{ topologieoptions.switchs_web_management }}Rest management, activé si provision auto{{ topologieoptions.switchs_rest_management }}
+
+
+
+ +
- -
{% if topologieoptions.provision_switchs_enabled %}Provision de la config des switchs{% else %}Provision de la config des switchs{% endif%}
- - - - - - - - - - - - - - - - - - - - - - - - - -
Switchs configurés automatiquement{{ topologieoptions.provisioned_switchs|join:", " }} {% if topologieoptions.provisioned_switchs %} OK{% else %}Manquant{% endif %}
Plage d'ip de management des switchs{{ topologieoptions.switchs_ip_type }} {% if topologieoptions.switchs_ip_type %} OK{% else %}Manquant{% endif %}
Serveur des config des switchs{{ topologieoptions.switchs_management_interface }} {% if topologieoptions.switchs_management_interface %} - {{ topologieoptions.switchs_management_interface_ip }} OK{% else %}Manquant{% endif %}
Mode de provision des switchs{{ topologieoptions.switchs_provision }}
Mode TFTP OK
Mode SFTP{% if topologieoptions.switchs_management_sftp_creds %} OK{% else %}Creds manquants{% endif %}
- -
Creds de management des switchs
- {% can_create SwitchManagementCred%} - Ajouter un id/mdp de management switch - {% acl_end %} -

-

- {% if switchmanagementcred_list %} OK{% else %}Manquant{% endif %} - {% include "preferences/aff_switchmanagementcred.html" with switchmanagementcred_list=switchmanagementcred_list %} - - - -

{% trans "General preferences" %}

- + {% trans "Edit" %} -

-

- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{% trans "Website name" %}{{ generaloptions.site_name }}{% trans "Email address for automatic emailing" %}{{ generaloptions.email_from }}
{% trans "Number of results displayed when searching" %}{{ generaloptions.search_display_page }}{% trans "Number of items per page (standard size)" %}{{ generaloptions.pagination_number }}
{% trans "Number of items per page (large size)" %}{{ generaloptions.pagination_large_number }}{% trans "Time before expiration of the reset password link (in hours)" %}{{ generaloptions.req_expire_hrs }}
{% trans "General message displayed on the website" %}{{ generaloptions.general_message }}{% trans "Summary of the General Terms of Use" %}{{ generaloptions.GTU_sum_up }}
{% trans "General Terms of Use" %}{{ generaloptions.GTU }} -
-

{% trans "Information about the organisation" %}

+

+ + + + + + + + +
Web management, activé si provision automatique{{ topologieoptions.switchs_web_management }}Rest management, activé si provision auto{{ topologieoptions.switchs_rest_management }}
+ + + +
{% if topologieoptions.provision_switchs_enabled %}Provision de la config des switchs{% else %}Provision de la config des switchs{% endif%}
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Switchs configurés automatiquement{{ topologieoptions.provisioned_switchs|join:", " }} {% if topologieoptions.provisioned_switchs %} OK{% else %}Manquant{% endif %}
Plage d'ip de management des switchs{{ topologieoptions.switchs_ip_type }} {% if topologieoptions.switchs_ip_type %} OK{% else %}Manquant{% endif %}
Serveur des config des switchs{{ topologieoptions.switchs_management_interface }} {% if topologieoptions.switchs_management_interface %} - {{ topologieoptions.switchs_management_interface_ip }} OK{% else %}Manquant{% endif %}
Mode de provision des switchs{{ topologieoptions.switchs_provision }}
Mode TFTP OK
Mode SFTP{% if topologieoptions.switchs_management_sftp_creds %} OK{% else %}Creds manquants{% endif %}
+ +
Creds de management des switchs
+ {% can_create SwitchManagementCred%} + Ajouter un id/mdp de management switch + {% acl_end %} +

+

+ {% if switchmanagementcred_list %} OK{% else %}Manquant{% endif %} + {% include "preferences/aff_switchmanagementcred.html" with switchmanagementcred_list=switchmanagementcred_list %} +
+
+ +
+ +
+ + + {% trans "Edit" %} + + {% include "preferences/aff_radiusoptions.html" %} +
+
+ +
+ +
{% trans "Edit" %} -

-

- - - - - - - - - - - - - - - - - - - - - - - - - -
{% trans "Name" %}{{ assooptions.name }}{% trans "SIRET number" %}{{ assooptions.siret }}
{% trans "Address" %}{{ assooptions.adresse1 }}
- {{ assooptions.adresse2 }} -
{% trans "Contact email address" %}{{ assooptions.contact }}
{% trans "Telephone number" %}{{ assooptions.telephone }}{% trans "Usual name" %}{{ assooptions.pseudo }}
{% trans "User object of the organisation" %}{{ assooptions.utilisateur_asso }}{% trans "Description of the organisation" %}{{ assooptions.description|safe }}
-

{% trans "Custom email message" %}

+

+ + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Name" %}{{ assooptions.name }}{% trans "SIRET number" %}{{ assooptions.siret }}
{% trans "Address" %}{{ assooptions.adresse1 }}
+ {{ assooptions.adresse2 }} +
{% trans "Contact email address" %}{{ assooptions.contact }}
{% trans "Telephone number" %}{{ assooptions.telephone }}{% trans "Usual name" %}{{ assooptions.pseudo }}
{% trans "User object of the organisation" %}{{ assooptions.utilisateur_asso }}{% trans "Description of the organisation" %}{{ assooptions.description|safe }}
+
+
+ +
+ +
+ {% trans "Edit" %} -

-

- - - - - - - - - -
{% trans "Welcome email (in French)" %}{{ mailmessageoptions.welcome_mail_fr|safe }}
{% trans "Welcome email (in English)" %}{{ mailmessageoptions.welcome_mail_en|safe }}
-

Options pour le mail de fin d'adhésion

+

+ + + + + + + + + + +
{% trans "Welcome email (in French)" %}{{ mailmessageoptions.welcome_mail_fr|safe }}
{% trans "Welcome email (in English)" %}{{ mailmessageoptions.welcome_mail_en|safe }}
+
+
+ +
+ +
{% can_create preferences.Reminder%} + Ajouter un rappel +

{% acl_end %} {% include "preferences/aff_reminder.html" with reminder_list=reminder_list %} +
+
-

{% trans "List of services and homepage preferences" %}

+ +
+ +
{% can_create preferences.Service%} + {% trans " Add a service" %} +

{% acl_end %} {% include "preferences/aff_service.html" with service_list=service_list %} - - - {% trans "Edit" %} - -

{% trans "List of contact email addresses" %}

+ +
+
+ +
+ +
+ {% can_create preferences.MailContact %} {% trans "Add an address" %} {% acl_end %} {% trans "Delete one or several addresses" %} +

{% include "preferences/aff_mailcontact.html" with mailcontact_list=mailcontact_list %} -

-

- - - - - - - - - - - -
{% trans "Twitter account URL" %}{{ homeoptions.twitter_url }}{% trans "Twitter account name" %}{{ homeoptions.twitter_account_name }}
{% trans "Facebook account URL" %}{{ homeoptions.facebook_url }}
+
+
+ +
+ +
+ + + + {% trans "Edit" %} + +

+ + + + + + + + + + + +
{% trans "Twitter account URL" %}{{ homeoptions.twitter_url }}{% trans "Twitter account name" %}{{ homeoptions.twitter_account_name }}
{% trans "Facebook account URL" %}{{ homeoptions.facebook_url }}
+
+
+ {% endblock %} diff --git a/preferences/templates/preferences/edit_preferences.html b/preferences/templates/preferences/edit_preferences.html index a1540f33..c3dd4652 100644 --- a/preferences/templates/preferences/edit_preferences.html +++ b/preferences/templates/preferences/edit_preferences.html @@ -37,6 +37,12 @@ with this program; if not, write to the Free Software Foundation, Inc., {% csrf_token %} {% massive_bootstrap_form options 'utilisateur_asso,automatic_provision_switchs' %} + {% if formset %} + {{ formset.management_form }} + {% for f in formset %} + {% bootstrap_form f %} + {% endfor %} + {% endif %} {% trans "Edit" as tr_edit %} {% bootstrap_button tr_edit button_type="submit" icon='ok' button_class='btn-success' %} diff --git a/preferences/templates/preferences/sidebar.html b/preferences/templates/preferences/sidebar.html index 4f69298b..98b597ea 100644 --- a/preferences/templates/preferences/sidebar.html +++ b/preferences/templates/preferences/sidebar.html @@ -22,6 +22,8 @@ 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 %} {% block sidebar %} diff --git a/preferences/urls.py b/preferences/urls.py index 63ca6a39..30163868 100644 --- a/preferences/urls.py +++ b/preferences/urls.py @@ -66,6 +66,11 @@ urlpatterns = [ views.edit_options, name='edit-options' ), + url( + r'^edit_options/(?P
RadiusOption)$', + views.edit_options, + name='edit-options' + ), url(r'^add_service/$', views.add_service, name='add-service'), url( r'^edit_service/(?P[0-9]+)$', diff --git a/preferences/views.py b/preferences/views.py index ee81ae89..586be60f 100644 --- a/preferences/views.py +++ b/preferences/views.py @@ -62,7 +62,8 @@ from .models import ( HomeOption, Reminder, RadiusKey, - SwitchManagementCred + SwitchManagementCred, + RadiusOption, ) from . import models from . import forms @@ -86,6 +87,7 @@ def display_options(request): reminder_list = Reminder.objects.all() radiuskey_list = RadiusKey.objects.all() switchmanagementcred_list = SwitchManagementCred.objects.all() + radiusoptions, _ = RadiusOption.objects.get_or_create() return form({ 'useroptions': useroptions, 'machineoptions': machineoptions, @@ -98,7 +100,8 @@ def display_options(request): 'mailcontact_list': mailcontact_list, 'reminder_list': reminder_list, 'radiuskey_list' : radiuskey_list, - 'switchmanagementcred_list': switchmanagementcred_list, + 'switchmanagementcred_list': switchmanagementcred_list, + 'radiusoptions' : radiusoptions, }, 'preferences/display_preferences.html', request) @@ -134,7 +137,9 @@ def edit_options(request, section): messages.success(request, _("The preferences were edited.")) return redirect(reverse('preferences:display-options')) return form( - {'options': options}, + { + 'options': options, + }, 'preferences/edit_preferences.html', request ) diff --git a/re2o/aes_field.py b/re2o/aes_field.py index 2720f5af..5f50ddd2 100644 --- a/re2o/aes_field.py +++ b/re2o/aes_field.py @@ -37,8 +37,8 @@ from django.db import models from django import forms from django.conf import settings -EOD = '`%EofD%`' # This should be something that will not occur in strings - +EOD_asbyte = b'`%EofD%`' # This should be something that will not occur in strings +EOD = EOD_asbyte.decode('utf-8') def genstring(length=16, chars=string.printable): """ Generate a random string of length `length` and composed of @@ -46,23 +46,23 @@ def genstring(length=16, chars=string.printable): return ''.join([choice(chars) for i in range(length)]) -def encrypt(key, s): - """ AES Encrypt a secret `s` with the key `key` """ +def encrypt(key, secret): + """ AES Encrypt a secret with the key `key` """ obj = AES.new(key) - datalength = len(s) + len(EOD) + datalength = len(secret) + len(EOD) if datalength < 16: saltlength = 16 - datalength else: saltlength = 16 - datalength % 16 - ss = ''.join([s, EOD, genstring(saltlength)]) - return obj.encrypt(ss) + encrypted_secret = ''.join([secret, EOD, genstring(saltlength)]) + return obj.encrypt(encrypted_secret) -def decrypt(key, s): - """ AES Decrypt a secret `s` with the key `key` """ +def decrypt(key, secret): + """ AES Decrypt a secret with the key `key` """ obj = AES.new(key) - ss = obj.decrypt(s) - return ss.split(bytes(EOD, 'utf-8'))[0] + uncrypted_secret = obj.decrypt(secret) + return uncrypted_secret.split(EOD_asbyte)[0] class AESEncryptedFormField(forms.CharField): @@ -81,8 +81,7 @@ class AESEncryptedField(models.CharField): if value is None: return None try: - return decrypt(settings.AES_KEY, - binascii.a2b_base64(value)).decode('utf-8') + return decrypt(settings.AES_KEY, binascii.a2b_base64(value)).decode('utf-8') except Exception as e: raise ValueError(value) @@ -90,18 +89,14 @@ class AESEncryptedField(models.CharField): if value is None: return value try: - return decrypt(settings.AES_KEY, - binascii.a2b_base64(value)).decode('utf-8') + return decrypt(settings.AES_KEY, binascii.a2b_base64(value)).decode('utf-8') except Exception as e: raise ValueError(value) def get_prep_value(self, value): if value is None: return value - return binascii.b2a_base64(encrypt( - settings.AES_KEY, - value - )).decode('utf-8') + return binascii.b2a_base64(encrypt(settings.AES_KEY, value)).decode('utf-8') def formfield(self, **kwargs): defaults = {'form_class': AESEncryptedFormField} diff --git a/re2o/base.py b/re2o/base.py new file mode 100644 index 00000000..023a16ff --- /dev/null +++ b/re2o/base.py @@ -0,0 +1,267 @@ +# -*- mode: python; coding: utf-8 -*- +# 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 Gabriel Détraz +# +# 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. + +# -*- coding: utf-8 -*- +""" +Regroupe les fonctions transversales utiles + +Et non corrélées/dépendantes des autres applications +""" + +import smtplib + +from django.utils.translation import ugettext_lazy as _ +from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger + +from re2o.settings import EMAIL_HOST + + +# Mapping of srtftime format for better understanding +# https://docs.python.org/3.6/library/datetime.html#strftime-strptime-behavior +datetime_mapping={ + '%a': '%a', + '%A': '%A', + '%w': '%w', + '%d': 'dd', + '%b': '%b', + '%B': '%B', + '%m': 'mm', + '%y': 'yy', + '%Y': 'yyyy', + '%H': 'HH', + '%I': 'HH(12h)', + '%p': 'AMPM', + '%M': 'MM', + '%S': 'SS', + '%f': 'µµ', + '%z': 'UTC(+/-HHMM)', + '%Z': 'UTC(TZ)', + '%j': '%j', + '%U': 'ww', + '%W': 'ww', + '%c': '%c', + '%x': '%x', + '%X': '%X', + '%%': '%%', +} + + +def smtp_check(local_part): + """Return True if the local_part is already taken + False if available""" + try: + srv = smtplib.SMTP(EMAIL_HOST) + srv.putcmd("vrfy", local_part) + reply_code = srv.getreply()[0] + srv.close() + if reply_code in [250, 252]: + return True, _("This domain is already taken") + except: + return True, _("Smtp unreachable") + return False, None + + +def convert_datetime_format(format): + i=0 + new_format = "" + while i < len(format): + if format[i] == '%': + char = format[i:i+2] + new_format += datetime_mapping.get(char, char) + i += 2 + else: + new_format += format[i] + i += 1 + return new_format + + +def get_input_formats_help_text(input_formats): + """Returns a help text about the possible input formats""" + if len(input_formats) > 1: + help_text_template="Format: {main} {more}" + else: + help_text_template="Format: {main}" + more_text_template="" + help_text = help_text_template.format( + main=convert_datetime_format(input_formats[0]), + more=more_text_template.format( + '\n'.join(map(convert_datetime_format, input_formats)) + ) + ) + return help_text + + +class SortTable: + """ Class gathering uselful stuff to sort the colums of a table, according + to the column and order requested. It's used with a dict of possible + values and associated model_fields """ + + # All the possible possible values + # The naming convention is based on the URL or the views function + # The syntax to describe the sort to apply is a dict where the keys are + # the url value and the values are a list of model field name to use to + # order the request. They are applied in the order they are given. + # A 'default' might be provided to specify what to do if the requested col + # doesn't match any keys. + + USERS_INDEX = { + 'user_name': ['name'], + 'user_surname': ['surname'], + 'user_pseudo': ['pseudo'], + 'user_room': ['room'], + 'default': ['state', 'pseudo'] + } + USERS_INDEX_BAN = { + 'ban_user': ['user__pseudo'], + 'ban_start': ['date_start'], + 'ban_end': ['date_end'], + 'default': ['-date_end'] + } + USERS_INDEX_WHITE = { + 'white_user': ['user__pseudo'], + 'white_start': ['date_start'], + 'white_end': ['date_end'], + 'default': ['-date_end'] + } + USERS_INDEX_SCHOOL = { + 'school_name': ['name'], + 'default': ['name'] + } + MACHINES_INDEX = { + 'machine_name': ['name'], + 'default': ['pk'] + } + COTISATIONS_INDEX = { + 'cotis_user': ['user__pseudo'], + 'cotis_paiement': ['paiement__moyen'], + 'cotis_date': ['date'], + 'cotis_id': ['id'], + 'default': ['-date'] + } + COTISATIONS_CUSTOM = { + 'invoice_date': ['date'], + 'invoice_id': ['id'], + 'invoice_recipient': ['recipient'], + 'invoice_address': ['address'], + 'invoice_payment': ['payment'], + 'default': ['-date'] + } + COTISATIONS_CONTROL = { + 'control_name': ['user__adherent__name'], + 'control_surname': ['user__surname'], + 'control_paiement': ['paiement'], + 'control_date': ['date'], + 'control_valid': ['valid'], + 'control_control': ['control'], + 'control_id': ['id'], + 'control_user-id': ['user__id'], + 'default': ['-date'] + } + TOPOLOGIE_INDEX = { + 'switch_dns': ['interface__domain__name'], + 'switch_ip': ['interface__ipv4__ipv4'], + 'switch_loc': ['switchbay__name'], + 'switch_ports': ['number'], + 'switch_stack': ['stack__name'], + 'default': ['switchbay', 'stack', 'stack_member_id'] + } + TOPOLOGIE_INDEX_PORT = { + 'port_port': ['port'], + 'port_room': ['room__name'], + 'port_interface': ['machine_interface__domain__name'], + 'port_related': ['related__switch__name'], + 'port_radius': ['radius'], + 'port_vlan': ['vlan_force__name'], + 'default': ['port'] + } + TOPOLOGIE_INDEX_ROOM = { + 'room_name': ['name'], + 'default': ['name'] + } + TOPOLOGIE_INDEX_BUILDING = { + 'building_name': ['name'], + 'default': ['name'] + } + TOPOLOGIE_INDEX_BORNE = { + 'ap_name': ['interface__domain__name'], + 'ap_ip': ['interface__ipv4__ipv4'], + 'ap_mac': ['interface__mac_address'], + 'default': ['interface__domain__name'] + } + TOPOLOGIE_INDEX_STACK = { + 'stack_name': ['name'], + 'stack_id': ['stack_id'], + 'default': ['stack_id'], + } + TOPOLOGIE_INDEX_MODEL_SWITCH = { + 'model-switch_name': ['reference'], + 'model-switch_contructor': ['constructor__name'], + 'default': ['reference'], + } + TOPOLOGIE_INDEX_SWITCH_BAY = { + 'switch-bay_name': ['name'], + 'switch-bay_building': ['building__name'], + 'default': ['name'], + } + TOPOLOGIE_INDEX_CONSTRUCTOR_SWITCH = { + 'constructor-switch_name': ['name'], + 'default': ['name'], + } + LOGS_INDEX = { + 'sum_date': ['revision__date_created'], + 'default': ['-revision__date_created'], + } + LOGS_STATS_LOGS = { + 'logs_author': ['user__name'], + 'logs_date': ['date_created'], + 'default': ['-date_created'] + } + + @staticmethod + def sort(request, col, order, values): + """ Check if the given values are possible and add .order_by() and + a .reverse() as specified according to those values """ + fields = values.get(col, None) + if not fields: + fields = values.get('default', []) + request = request.order_by(*fields) + if values.get(col, None) and order == 'desc': + return request.reverse() + else: + return request + + +def re2o_paginator(request, query_set, pagination_number): + """Paginator script for list display in re2o. + :request: + :query_set: Query_set to paginate + :pagination_number: Number of entries to display""" + paginator = Paginator(query_set, pagination_number) + page = request.GET.get('page') + try: + results = paginator.page(page) + except PageNotAnInteger: + # If page is not an integer, deliver first page. + results = paginator.page(1) + except EmptyPage: + # If page is out of range (e.g. 9999), deliver last page of results. + results = paginator.page(paginator.num_pages) + return results diff --git a/re2o/locale/fr/LC_MESSAGES/django.mo b/re2o/locale/fr/LC_MESSAGES/django.mo deleted file mode 100644 index 83348f41..00000000 Binary files a/re2o/locale/fr/LC_MESSAGES/django.mo and /dev/null differ diff --git a/re2o/login.py b/re2o/login.py index 471c2e02..0b552239 100644 --- a/re2o/login.py +++ b/re2o/login.py @@ -114,9 +114,9 @@ class CryptPasswordHasher(hashers.BasePasswordHasher): Check password against encoded using CRYPT algorithm """ assert encoded.startswith(self.algorithm) - salt = hash_password_salt(challenge_password) - return constant_time_compare(crypt.crypt(password.encode(), salt), - challenge.encode()) + salt = hash_password_salt(encoded) + return constant_time_compare(crypt.crypt(password, salt), + encoded) def safe_summary(self, encoded): """ diff --git a/re2o/utils.py b/re2o/utils.py index 6f7870f0..20218a81 100644 --- a/re2o/utils.py +++ b/re2o/utils.py @@ -38,55 +38,11 @@ from __future__ import unicode_literals from django.utils import timezone from django.db.models import Q -from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger from cotisations.models import Cotisation, Facture, Vente from machines.models import Interface, Machine from users.models import Adherent, User, Ban, Whitelist - -# Mapping of srtftime format for better understanding -# https://docs.python.org/3.6/library/datetime.html#strftime-strptime-behavior -datetime_mapping={ - '%a': '%a', - '%A': '%A', - '%w': '%w', - '%d': 'dd', - '%b': '%b', - '%B': '%B', - '%m': 'mm', - '%y': 'yy', - '%Y': 'yyyy', - '%H': 'HH', - '%I': 'HH(12h)', - '%p': 'AMPM', - '%M': 'MM', - '%S': 'SS', - '%f': 'µµ', - '%z': 'UTC(+/-HHMM)', - '%Z': 'UTC(TZ)', - '%j': '%j', - '%U': 'ww', - '%W': 'ww', - '%c': '%c', - '%x': '%x', - '%X': '%X', - '%%': '%%', -} - - -def convert_datetime_format(format): - i=0 - new_format = "" - while i < len(format): - if format[i] == '%': - char = format[i:i+2] - new_format += datetime_mapping.get(char, char) - i += 2 - else: - new_format += format[i] - i += 1 - return new_format - +from preferences.models import AssoOption def all_adherent(search_time=None): """ Fonction renvoyant tous les users adherents. Optimisee pour n'est @@ -103,7 +59,7 @@ def all_adherent(search_time=None): vente__in=Vente.objects.filter( facture__in=Facture.objects.all().exclude(valid=False) ) - ).filter(date_end__gt=search_time) + ).filter(Q(date_start__lt=search_time) & Q(date_end__gt=search_time)) ) ) ).distinct() @@ -115,7 +71,7 @@ def all_baned(search_time=None): search_time = timezone.now() return User.objects.filter( ban__in=Ban.objects.filter( - date_end__gt=search_time + Q(date_start__lt=search_time) & Q(date_end__gt=search_time) ) ).distinct() @@ -126,20 +82,23 @@ def all_whitelisted(search_time=None): search_time = timezone.now() return User.objects.filter( whitelist__in=Whitelist.objects.filter( - date_end__gt=search_time + Q(date_start__lt=search_time) & Q(date_end__gt=search_time) ) ).distinct() def all_has_access(search_time=None): - """ Renvoie tous les users beneficiant d'une connexion - : user adherent ou whiteliste et non banni """ + """ Return all connected users : active users and whitelisted + + asso_user defined in AssoOption pannel + ---- + Renvoie tous les users beneficiant d'une connexion + : user adherent et whiteliste non banni plus l'utilisateur asso""" if search_time is None: search_time = timezone.now() - return User.objects.filter( + filter_user = ( Q(state=User.STATE_ACTIVE) & - ~Q(ban__in=Ban.objects.filter(date_end__gt=search_time)) & - (Q(whitelist__in=Whitelist.objects.filter(date_end__gt=search_time)) | + ~Q(ban__in=Ban.objects.filter(Q(date_start__lt=search_time) & Q(date_end__gt=search_time))) & + (Q(whitelist__in=Whitelist.objects.filter(Q(date_start__lt=search_time) & Q(date_end__gt=search_time))) | Q(facture__in=Facture.objects.filter( vente__in=Vente.objects.filter( cotisation__in=Cotisation.objects.filter( @@ -148,10 +107,14 @@ def all_has_access(search_time=None): facture__in=Facture.objects.all() .exclude(valid=False) ) - ).filter(date_end__gt=search_time) + ).filter(Q(date_start__lt=search_time) & Q(date_end__gt=search_time)) ) ))) - ).distinct() + ) + asso_user = AssoOption.get_cached_value('utilisateur_asso') + if asso_user: + filter_user |= Q(id=asso_user.id) + return User.objects.filter(filter_user).distinct() def filter_active_interfaces(interface_set): @@ -203,164 +166,6 @@ def all_active_assigned_interfaces_count(): return all_active_interfaces_count().filter(ipv4__isnull=False) -class SortTable: - """ Class gathering uselful stuff to sort the colums of a table, according - to the column and order requested. It's used with a dict of possible - values and associated model_fields """ - - # All the possible possible values - # The naming convention is based on the URL or the views function - # The syntax to describe the sort to apply is a dict where the keys are - # the url value and the values are a list of model field name to use to - # order the request. They are applied in the order they are given. - # A 'default' might be provided to specify what to do if the requested col - # doesn't match any keys. - - USERS_INDEX = { - 'user_name': ['name'], - 'user_surname': ['surname'], - 'user_pseudo': ['pseudo'], - 'user_room': ['room'], - 'default': ['state', 'pseudo'] - } - USERS_INDEX_BAN = { - 'ban_user': ['user__pseudo'], - 'ban_start': ['date_start'], - 'ban_end': ['date_end'], - 'default': ['-date_end'] - } - USERS_INDEX_WHITE = { - 'white_user': ['user__pseudo'], - 'white_start': ['date_start'], - 'white_end': ['date_end'], - 'default': ['-date_end'] - } - USERS_INDEX_SCHOOL = { - 'school_name': ['name'], - 'default': ['name'] - } - MACHINES_INDEX = { - 'machine_name': ['name'], - 'default': ['pk'] - } - COTISATIONS_INDEX = { - 'cotis_user': ['user__pseudo'], - 'cotis_paiement': ['paiement__moyen'], - 'cotis_date': ['date'], - 'cotis_id': ['id'], - 'default': ['-date'] - } - COTISATIONS_CUSTOM = { - 'invoice_date': ['date'], - 'invoice_id': ['id'], - 'invoice_recipient': ['recipient'], - 'invoice_address': ['address'], - 'invoice_payment': ['payment'], - 'default': ['-date'] - } - COTISATIONS_CONTROL = { - 'control_name': ['user__adherent__name'], - 'control_surname': ['user__surname'], - 'control_paiement': ['paiement'], - 'control_date': ['date'], - 'control_valid': ['valid'], - 'control_control': ['control'], - 'control_id': ['id'], - 'control_user-id': ['user__id'], - 'default': ['-date'] - } - TOPOLOGIE_INDEX = { - 'switch_dns': ['interface__domain__name'], - 'switch_ip': ['interface__ipv4__ipv4'], - 'switch_loc': ['switchbay__name'], - 'switch_ports': ['number'], - 'switch_stack': ['stack__name'], - 'default': ['switchbay', 'stack', 'stack_member_id'] - } - TOPOLOGIE_INDEX_PORT = { - 'port_port': ['port'], - 'port_room': ['room__name'], - 'port_interface': ['machine_interface__domain__name'], - 'port_related': ['related__switch__name'], - 'port_radius': ['radius'], - 'port_vlan': ['vlan_force__name'], - 'default': ['port'] - } - TOPOLOGIE_INDEX_ROOM = { - 'room_name': ['name'], - 'default': ['name'] - } - TOPOLOGIE_INDEX_BUILDING = { - 'building_name': ['name'], - 'default': ['name'] - } - TOPOLOGIE_INDEX_BORNE = { - 'ap_name': ['interface__domain__name'], - 'ap_ip': ['interface__ipv4__ipv4'], - 'ap_mac': ['interface__mac_address'], - 'default': ['interface__domain__name'] - } - TOPOLOGIE_INDEX_STACK = { - 'stack_name': ['name'], - 'stack_id': ['stack_id'], - 'default': ['stack_id'], - } - TOPOLOGIE_INDEX_MODEL_SWITCH = { - 'model-switch_name': ['reference'], - 'model-switch_contructor': ['constructor__name'], - 'default': ['reference'], - } - TOPOLOGIE_INDEX_SWITCH_BAY = { - 'switch-bay_name': ['name'], - 'switch-bay_building': ['building__name'], - 'default': ['name'], - } - TOPOLOGIE_INDEX_CONSTRUCTOR_SWITCH = { - 'constructor-switch_name': ['name'], - 'default': ['name'], - } - LOGS_INDEX = { - 'sum_date': ['revision__date_created'], - 'default': ['-revision__date_created'], - } - LOGS_STATS_LOGS = { - 'logs_author': ['user__name'], - 'logs_date': ['date_created'], - 'default': ['-date_created'] - } - - @staticmethod - def sort(request, col, order, values): - """ Check if the given values are possible and add .order_by() and - a .reverse() as specified according to those values """ - fields = values.get(col, None) - if not fields: - fields = values.get('default', []) - request = request.order_by(*fields) - if values.get(col, None) and order == 'desc': - return request.reverse() - else: - return request - - -def re2o_paginator(request, query_set, pagination_number): - """Paginator script for list display in re2o. - :request: - :query_set: Query_set to paginate - :pagination_number: Number of entries to display""" - paginator = Paginator(query_set, pagination_number) - page = request.GET.get('page') - try: - results = paginator.page(page) - except PageNotAnInteger: - # If page is not an integer, deliver first page. - results = paginator.page(1) - except EmptyPage: - # If page is out of range (e.g. 9999), deliver last page of results. - results = paginator.page(paginator.num_pages) - return results - - def remove_user_room(room): """ Déménage de force l'ancien locataire de la chambre """ try: @@ -370,18 +175,3 @@ def remove_user_room(room): user.room = None user.save() - -def get_input_formats_help_text(input_formats): - """Returns a help text about the possible input formats""" - if len(input_formats) > 1: - help_text_template="Format: {main} {more}" - else: - help_text_template="Format: {main}" - more_text_template="" - help_text = help_text_template.format( - main=convert_datetime_format(input_formats[0]), - more=more_text_template.format( - '\n'.join(map(convert_datetime_format, input_formats)) - ) - ) - return help_text diff --git a/search/forms.py b/search/forms.py index 5c98415f..5fa5fca8 100644 --- a/search/forms.py +++ b/search/forms.py @@ -27,7 +27,7 @@ from __future__ import unicode_literals from django import forms from django.forms import Form from django.utils.translation import ugettext_lazy as _ -from re2o.utils import get_input_formats_help_text +from re2o.base import get_input_formats_help_text CHOICES_USER = ( ('0', _("Active")), diff --git a/search/locale/fr/LC_MESSAGES/django.mo b/search/locale/fr/LC_MESSAGES/django.mo deleted file mode 100644 index 94a44104..00000000 Binary files a/search/locale/fr/LC_MESSAGES/django.mo and /dev/null differ diff --git a/search/views.py b/search/views.py index a92b0105..eb0027ec 100644 --- a/search/views.py +++ b/search/views.py @@ -46,7 +46,7 @@ from search.forms import ( CHOICES_AFF, initial_choices ) -from re2o.utils import SortTable +from re2o.base import SortTable from re2o.acl import can_view_all diff --git a/static/css/base.css b/static/css/base.css index 736935b3..a13c596f 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -79,19 +79,6 @@ a > i.fa { vertical-align: middle; } -/* Pull sidebars to the bottom */ -@media (min-width: 767px) { - .row { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - } - .row > [class*='col-'] { - flex-direction: column; - } -} - /* On small screens, set height to 'auto' for sidenav and grid */ @media screen and (max-width: 767px) { .sidenav { @@ -102,7 +89,7 @@ a > i.fa { } .table-responsive { - overflow-y: visible; + overflow: visible; } /* Make modal wider on wide screens */ @@ -145,3 +132,14 @@ th.long_text{ .dashboard{ text-align: center; } + +/* Detailed information on profile page */ +dl.profile-info { + margin-top: -16px; + margin-bottom: 0; +} + +dl.profile-info > div { + padding: 8px; + border-top: 1px solid #ddd; +} diff --git a/static/js/collapse-from-url.js b/static/js/collapse-from-url.js new file mode 100644 index 00000000..6c85762b --- /dev/null +++ b/static/js/collapse-from-url.js @@ -0,0 +1,33 @@ +// 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 Alexandre Iooss +// +// 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. + +// This script makes URL hash controls Bootstrap collapse +// e.g. if there is #information in the URL +// then the collapse with id "information" will be open. + +$(document).ready(function () { + if(location.hash != null && location.hash !== ""){ + // Open the collapse corresponding to URL hash + $(location.hash + '.collapse').collapse('show'); + } else { + // Open default collapse + $('.collapse-default.collapse').collapse('show'); + } +}); diff --git a/templates/base.html b/templates/base.html index 76ba975a..867be422 100644 --- a/templates/base.html +++ b/templates/base.html @@ -45,6 +45,7 @@ with this program; if not, write to the Free Software Foundation, Inc., {% bootstrap_javascript %} + {# Load CSS #} {% bootstrap_css %} diff --git a/templates/locale/fr/LC_MESSAGES/django.mo b/templates/locale/fr/LC_MESSAGES/django.mo deleted file mode 100644 index 6f33dd5a..00000000 Binary files a/templates/locale/fr/LC_MESSAGES/django.mo and /dev/null differ diff --git a/templates/pagination.html b/templates/pagination.html index cf488c5d..5ecced6d 100644 --- a/templates/pagination.html +++ b/templates/pagination.html @@ -23,23 +23,52 @@ with this program; if not, write to the Free Software Foundation, Inc., {% endcomment %} {% load url_insert_param %} +{% load i18n %} {% if list.paginator.num_pages > 1 %} - {% endif %} - diff --git a/topologie/forms.py b/topologie/forms.py index fa089507..ed6fa5b9 100644 --- a/topologie/forms.py +++ b/topologie/forms.py @@ -55,6 +55,8 @@ from .models import ( SwitchBay, Building, PortProfile, + ModuleSwitch, + ModuleOnSwitch, ) @@ -269,3 +271,23 @@ class EditPortProfileForm(FormRevMixin, ModelForm): prefix=prefix, **kwargs) +class EditModuleForm(FormRevMixin, ModelForm): + """Add and edit module instance""" + class Meta: + model = ModuleSwitch + fields = '__all__' + + def __init__(self, *args, **kwargs): + prefix = kwargs.pop('prefix', self.Meta.model.__name__) + super(EditModuleForm, self).__init__(*args, prefix=prefix, **kwargs) + + +class EditSwitchModuleForm(FormRevMixin, ModelForm): + """Add/edit a switch to a module""" + class Meta: + model = ModuleOnSwitch + fields = '__all__' + + def __init__(self, *args, **kwargs): + prefix = kwargs.pop('prefix', self.Meta.model.__name__) + super(EditSwitchModuleForm, self).__init__(*args, prefix=prefix, **kwargs) diff --git a/topologie/locale/fr/LC_MESSAGES/django.mo b/topologie/locale/fr/LC_MESSAGES/django.mo deleted file mode 100644 index 61d2bf80..00000000 Binary files a/topologie/locale/fr/LC_MESSAGES/django.mo and /dev/null differ diff --git a/topologie/migrations/0067_auto_20181230_1819.py b/topologie/migrations/0067_auto_20181230_1819.py new file mode 100644 index 00000000..57f268ea --- /dev/null +++ b/topologie/migrations/0067_auto_20181230_1819.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2018-12-30 17:19 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import re2o.mixins + + +class Migration(migrations.Migration): + + dependencies = [ + ('topologie', '0066_modelswitch_commercial_name'), + ] + + operations = [ + migrations.CreateModel( + name='ModuleOnSwitch', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('slot', models.CharField(help_text='Slot on switch', max_length=15, verbose_name='Slot')), + ], + options={ + 'verbose_name': 'link between switchs and modules', + 'permissions': (('view_moduleonswitch', 'Can view a moduleonswitch object'),), + }, + bases=(re2o.mixins.AclMixin, re2o.mixins.RevMixin, models.Model), + ), + migrations.CreateModel( + name='ModuleSwitch', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('reference', models.CharField(help_text='Reference of a module', max_length=255, verbose_name='Module reference')), + ('comment', models.CharField(blank=True, help_text='Comment', max_length=255, null=True, verbose_name='Comment')), + ], + options={ + 'verbose_name': 'Module of a switch', + 'permissions': (('view_moduleswitch', 'Can view a module object'),), + }, + bases=(re2o.mixins.AclMixin, re2o.mixins.RevMixin, models.Model), + ), + migrations.AddField( + model_name='modelswitch', + name='is_itself_module', + field=models.BooleanField(default=False, help_text='Does the switch, itself, considered as a module'), + ), + migrations.AddField( + model_name='modelswitch', + name='is_modular', + field=models.BooleanField(default=False, help_text='Is this switch model modular'), + ), + migrations.AddField( + model_name='moduleonswitch', + name='module', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='topologie.ModuleSwitch'), + ), + migrations.AddField( + model_name='moduleonswitch', + name='switch', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='topologie.Switch'), + ), + migrations.AlterUniqueTogether( + name='moduleonswitch', + unique_together=set([('slot', 'switch')]), + ), + ] diff --git a/topologie/migrations/0068_auto_20190102_1758.py b/topologie/migrations/0068_auto_20190102_1758.py new file mode 100644 index 00000000..b03e7ae5 --- /dev/null +++ b/topologie/migrations/0068_auto_20190102_1758.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2019-01-02 23:58 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('topologie', '0067_auto_20181230_1819'), + ] + + operations = [ + migrations.AlterField( + model_name='modelswitch', + name='is_itself_module', + field=models.BooleanField(default=False, help_text='Is the switch, itself, considered as a module'), + ), + ] diff --git a/topologie/models.py b/topologie/models.py index cd191d7e..e05fa50e 100644 --- a/topologie/models.py +++ b/topologie/models.py @@ -252,6 +252,7 @@ class Switch(AclMixin, Machine): help_text='Provision automatique de ce switch', ) + class Meta: unique_together = ('stack', 'stack_member_id') permissions = ( @@ -281,31 +282,18 @@ class Switch(AclMixin, Machine): def create_ports(self, begin, end): """ Crée les ports de begin à end si les valeurs données sont cohérentes. """ - - s_begin = s_end = 0 - nb_ports = self.ports.count() - if nb_ports > 0: - ports = self.ports.order_by('port').values('port') - s_begin = ports.first().get('port') - s_end = ports.last().get('port') - if end < begin: raise ValidationError(_("The end port is less than the start" " port.")) - if end - begin > self.number: + ports_to_create = range(begin, end + 1) + existing_ports = Port.objects.filter(switch=self.switch).values_list('port', flat=True) + non_existing_ports = list(set(ports_to_create) - set(existing_ports)) + + if len(non_existing_ports) + existing_ports.count() > self.number: raise ValidationError(_("This switch can't have that many ports.")) - begin_range = range(begin, s_begin) - end_range = range(s_end+1, end+1) - for i in itertools.chain(begin_range, end_range): - port = Port() - port.switch = self - port.port = i - try: - with transaction.atomic(), reversion.create_revision(): - port.save() - reversion.set_comment(_("Creation")) - except IntegrityError: - ValidationError(_("Creation of an existing port.")) + with transaction.atomic(), reversion.create_revision(): + reversion.set_comment(_("Creation")) + Port.objects.bulk_create([Port(switch=self.switch, port=port_id) for port_id in non_existing_ports]) def main_interface(self): """ Returns the 'main' interface of the switch @@ -317,7 +305,7 @@ class Switch(AclMixin, Machine): @cached_property def get_name(self): - return self.name or self.main_interface().domain.name + return self.name or getattr(self.main_interface(), 'domain', 'Unknown') @cached_property def get_radius_key(self): @@ -380,6 +368,17 @@ class Switch(AclMixin, Machine): """Return dict ip6:subnet for all ipv6 of the switch""" return dict((str(interface.ipv6().first()), interface.type.ip_type.ip6_set_full_info) for interface in self.interface_set.all()) + @cached_property + def list_modules(self): + """Return modules of that switch, list of dict (rank, reference)""" + modules = [] + if getattr(self.model, 'is_modular', None): + if self.model.is_itself_module: + modules.append((1, self.model.reference)) + for module_of_self in self.moduleonswitch_set.all(): + modules.append((module_of_self.slot, module_of_self.module.reference)) + return modules + def __str__(self): return str(self.get_name) @@ -402,6 +401,14 @@ class ModelSwitch(AclMixin, RevMixin, models.Model): null=True, blank=True ) + is_modular = models.BooleanField( + default=False, + help_text=_("Is this switch model modular"), + ) + is_itself_module = models.BooleanField( + default=False, + help_text=_("Is the switch, itself, considered as a module"), + ) class Meta: permissions = ( @@ -417,6 +424,53 @@ class ModelSwitch(AclMixin, RevMixin, models.Model): return str(self.constructor) + ' ' + self.reference +class ModuleSwitch(AclMixin, RevMixin, models.Model): + """A module of a switch""" + reference = models.CharField( + max_length=255, + help_text=_("Reference of a module"), + verbose_name=_("Module reference") + ) + comment = models.CharField( + max_length=255, + null=True, + blank=True, + help_text=_("Comment"), + verbose_name=_("Comment") + ) + + class Meta: + permissions = ( + ("view_moduleswitch", _("Can view a module object")), + ) + verbose_name = _("Module of a switch") + + + def __str__(self): + return str(self.reference) + + +class ModuleOnSwitch(AclMixin, RevMixin, models.Model): + """Link beetween module and switch""" + module = models.ForeignKey('ModuleSwitch', on_delete=models.CASCADE) + switch = models.ForeignKey('Switch', on_delete=models.CASCADE) + slot = models.CharField( + max_length=15, + help_text=_("Slot on switch"), + verbose_name=_("Slot") + ) + + class Meta: + permissions = ( + ("view_moduleonswitch", _("Can view a moduleonswitch object")), + ) + verbose_name = _("link between switchs and modules") + unique_together = ['slot', 'switch'] + + def __str__(self): + return 'On slot ' + str(self.slot) + ' of ' + str(self.switch) + + class ConstructorSwitch(AclMixin, RevMixin, models.Model): """Un constructeur de switch""" diff --git a/topologie/templates/topologie/aff_modules.html b/topologie/templates/topologie/aff_modules.html new file mode 100644 index 00000000..0c7a3207 --- /dev/null +++ b/topologie/templates/topologie/aff_modules.html @@ -0,0 +1,110 @@ +{% 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 logs_extra %} +{% load i18n %} + +{% if module_list.paginator %} +{% include "pagination.html" with list=module_list %} +{% endif %} + + + + + + + + + + + {% for module in module_list %} + + + + + + + {% endfor %} +
{% trans "Reference" %}{% trans "Comment" %}{% trans "Switchs" %}
{{ module.reference }}{{ module.comment }} + {% for module_switch in module.moduleonswitch_set.all %} + Slot {{ module_switch.slot }} of {{ module_switch.switch }} + {% can_edit module_switch %} + + + + {% acl_end %} + {% can_delete module_switch %} + + + + {% acl_end %} +
+ {% endfor %} +
+ {% can_edit module %} + + + + + + + {% acl_end %} + {% history_button module %} + {% can_delete module %} + + + + {% acl_end %} +
+ +{% if module_list.paginator %} +{% include "pagination.html" with list=module_list %} +{% endif %} + +

{% trans "All modular switchs" %}

+ + + + + + + + {% for switch in modular_switchs %} + {% if switch.list_modules %} + + + + {% for module in switch.list_modules %} + + + + + + {% endfor %} +{% endif %} +{% endfor %} +
{% trans "Switch" %}{% trans "Reference" %}{% trans "Slot" %}
+ {{ switch }} +
{{ module.1 }}{{ module.0 }}
diff --git a/topologie/templates/topologie/index_module.html b/topologie/templates/topologie/index_module.html new file mode 100644 index 00000000..d9cc2925 --- /dev/null +++ b/topologie/templates/topologie/index_module.html @@ -0,0 +1,43 @@ +{% extends "topologie/sidebar.html" %} +{% 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 bootstrap3 %} +{% load acl %} +{% load i18n %} + +{% block title %}{% trans "Topology" %}{% endblock %} + +{% block content %} +

{% trans "Modules of switchs" %}

+{% can_create ModuleSwitch %} +{% trans " Add a module" %} +
+{% acl_end %} + {% include "topologie/aff_modules.html" with module_list=module_list modular_switchs=modular_switchs %} +
+
+
+{% endblock %} + diff --git a/topologie/templates/topologie/sidebar.html b/topologie/templates/topologie/sidebar.html index a35721f9..80317a16 100644 --- a/topologie/templates/topologie/sidebar.html +++ b/topologie/templates/topologie/sidebar.html @@ -33,6 +33,10 @@ with this program; if not, write to the Free Software Foundation, Inc., {% trans "Switches" %} + + + + {% trans "Switches modules" %} diff --git a/topologie/templates/topologie/topo.html b/topologie/templates/topologie/topo.html index bf9f760b..a7824020 100644 --- a/topologie/templates/topologie/topo.html +++ b/topologie/templates/topologie/topo.html @@ -37,7 +37,7 @@ with this program; if not, write to the Free Software Foundation, Inc., {% endif %}
{% csrf_token %} - {% massive_bootstrap_form topoform 'room,related,machine_interface,members,vlan_tagged' %} + {% massive_bootstrap_form topoform 'room,related,machine_interface,members,vlan_tagged,switch' %} {% bootstrap_button action_name icon='ok' button_class='btn-success' %}

diff --git a/topologie/urls.py b/topologie/urls.py index 77d68d50..70eae8e4 100644 --- a/topologie/urls.py +++ b/topologie/urls.py @@ -123,4 +123,15 @@ urlpatterns = [ url(r'^edit_vlanoptions/(?P[0-9]+)$', views.edit_vlanoptions, name='edit-vlanoptions'), - ] + url(r'^add_module/$', views.add_module, name='add-module'), + url(r'^edit_module/(?P[0-9]+)$', + views.edit_module, + name='edit-module'), + url(r'^del_module/(?P[0-9]+)$', views.del_module, name='del-module'), + url(r'^index_module/$', views.index_module, name='index-module'), + url(r'^add_module_on/$', views.add_module_on, name='add-module-on'), + url(r'^edit_module_on/(?P[0-9]+)$', + views.edit_module_on, + name='edit-module-on'), + url(r'^del_module_on/(?P[0-9]+)$', views.del_module_on, name='del-module-on'), +] diff --git a/topologie/views.py b/topologie/views.py index 0bd0f6c2..55f0a060 100644 --- a/topologie/views.py +++ b/topologie/views.py @@ -48,7 +48,10 @@ from django.utils.translation import ugettext as _ import tempfile from users.views import form -from re2o.utils import re2o_paginator, SortTable +from re2o.base import ( + re2o_paginator, + SortTable, +) from re2o.acl import ( can_create, can_edit, @@ -83,6 +86,8 @@ from .models import ( Building, Server, PortProfile, + ModuleSwitch, + ModuleOnSwitch, ) from .forms import ( EditPortForm, @@ -99,6 +104,8 @@ from .forms import ( EditSwitchBayForm, EditBuildingForm, EditPortProfileForm, + EditModuleForm, + EditSwitchModuleForm, ) from subprocess import ( @@ -313,6 +320,22 @@ def index_model_switch(request): ) +@login_required +@can_view_all(ModuleSwitch) +def index_module(request): + """Display all modules of switchs""" + module_list = ModuleSwitch.objects.all() + modular_switchs = Switch.objects.filter(model__is_modular=True) + pagination_number = GeneralOption.get_cached_value('pagination_number') + module_list = re2o_paginator(request, module_list, pagination_number) + return render( + request, + 'topologie/index_module.html', + {'module_list': module_list, + 'modular_switchs': modular_switchs} + ) + + @login_required @can_edit(Vlan) def edit_vlanoptions(request, vlan_instance, **_kwargs): @@ -530,19 +553,13 @@ def create_ports(request, switchid): except Switch.DoesNotExist: messages.error(request, _("Nonexistent switch")) return redirect(reverse('topologie:index')) - - s_begin = s_end = 0 - nb_ports = switch.ports.count() - if nb_ports > 0: - ports = switch.ports.order_by('port').values('port') - s_begin = ports.first().get('port') - s_end = ports.last().get('port') - + + first_port = getattr(switch.ports.order_by('port').first(), 'port', 1) + last_port = switch.number + first_port - 1 port_form = CreatePortsForm( request.POST or None, - initial={'begin': s_begin, 'end': s_end} + initial={'begin': first_port, 'end': last_port} ) - if port_form.is_valid(): begin = port_form.cleaned_data['begin'] end = port_form.cleaned_data['end'] @@ -1051,6 +1068,115 @@ def del_port_profile(request, port_profile, **_kwargs): ) +@login_required +@can_create(ModuleSwitch) +def add_module(request): + """ View used to add a Module object """ + module = EditModuleForm(request.POST or None) + if module.is_valid(): + module.save() + messages.success(request, _("The module was created.")) + return redirect(reverse('topologie:index-module')) + return form( + {'topoform': module, 'action_name': _("Create a module")}, + 'topologie/topo.html', + request + ) + + +@login_required +@can_edit(ModuleSwitch) +def edit_module(request, module_instance, **_kwargs): + """ View used to edit a Module object """ + module = EditModuleForm(request.POST or None, instance=module_instance) + if module.is_valid(): + if module.changed_data: + module.save() + messages.success(request, _("The module was edited.")) + return redirect(reverse('topologie:index-module')) + return form( + {'topoform': module, 'action_name': _("Edit")}, + 'topologie/topo.html', + request + ) + + +@login_required +@can_delete(ModuleSwitch) +def del_module(request, module, **_kwargs): + """Compleete delete a module""" + if request.method == "POST": + try: + module.delete() + messages.success(request, _("The module was deleted.")) + except ProtectedError: + messages.error( + request, + (_("The module %s is used by another object, impossible to" + " deleted it.") % module) + ) + return redirect(reverse('topologie:index-module')) + return form( + {'objet': module, 'objet_name': _("Module")}, + 'topologie/delete.html', + request + ) + +@login_required +@can_create(ModuleOnSwitch) +def add_module_on(request): + """Add a module to a switch""" + module_switch = EditSwitchModuleForm(request.POST or None) + if module_switch.is_valid(): + module_switch.save() + messages.success(request, _("The module added to that switch")) + return redirect(reverse('topologie:index-module')) + return form( + {'topoform': module_switch, 'action_name': _("Create")}, + 'topologie/topo.html', + request + ) + + +@login_required +@can_edit(ModuleOnSwitch) +def edit_module_on(request, module_instance, **_kwargs): + """ View used to edit a Module object """ + module = EditSwitchModuleForm(request.POST or None, instance=module_instance) + if module.is_valid(): + if module.changed_data: + module.save() + messages.success(request, _("The module was edited.")) + return redirect(reverse('topologie:index-module')) + return form( + {'topoform': module, 'action_name': _("Edit")}, + 'topologie/topo.html', + request + ) + + +@login_required +@can_delete(ModuleOnSwitch) +def del_module_on(request, module, **_kwargs): + """Compleete delete a module""" + if request.method == "POST": + try: + module.delete() + messages.success(request, _("The module was deleted.")) + except ProtectedError: + messages.error( + request, + (_("The module %s is used by another object, impossible to" + " deleted it.") % module) + ) + return redirect(reverse('topologie:index-module')) + return form( + {'objet': module, 'objet_name': _("Module")}, + 'topologie/delete.html', + request + ) + + def make_machine_graph(): """ Create the graph of switchs, machines and access points. diff --git a/users/forms.py b/users/forms.py index e2f09dca..d4110dcd 100644 --- a/users/forms.py +++ b/users/forms.py @@ -45,7 +45,8 @@ from django.utils.safestring import mark_safe from machines.models import Interface, Machine, Nas from topologie.models import Port from preferences.models import OptionalUser -from re2o.utils import remove_user_room, get_input_formats_help_text +from re2o.utils import remove_user_room +from re2o.base import get_input_formats_help_text from re2o.mixins import FormRevMixin from re2o.field_permissions import FieldPermissionFormMixin @@ -116,6 +117,7 @@ class PassForm(FormRevMixin, FieldPermissionFormMixin, forms.ModelForm): """Changement du mot de passe""" user = super(PassForm, self).save(commit=False) user.set_password(self.cleaned_data.get("passwd1")) + user.set_active() user.save() @@ -323,14 +325,6 @@ class AdherentForm(FormRevMixin, FieldPermissionFormMixin, ModelForm): self.fields['room'].empty_label = _("No room") self.fields['school'].empty_label = _("Select a school") - def clean_email(self): - if not OptionalUser.objects.first().local_email_domain in self.cleaned_data.get('email'): - return self.cleaned_data.get('email').lower() - else: - raise forms.ValidationError( - _("You can't use a {} address.").format( - OptionalUser.objects.first().local_email_domain)) - class Meta: model = Adherent fields = [ @@ -344,6 +338,19 @@ class AdherentForm(FormRevMixin, FieldPermissionFormMixin, ModelForm): 'room', ] + force = forms.BooleanField( + label=_("Force the move?"), + initial=False, + required=False + ) + + def clean_email(self): + if not OptionalUser.objects.first().local_email_domain in self.cleaned_data.get('email'): + return self.cleaned_data.get('email').lower() + else: + raise forms.ValidationError( + _("You can't use a {} address.").format( + OptionalUser.objects.first().local_email_domain)) def clean_telephone(self): """Verifie que le tel est présent si 'option est validée @@ -355,12 +362,6 @@ class AdherentForm(FormRevMixin, FieldPermissionFormMixin, ModelForm): ) return telephone - force = forms.BooleanField( - label=_("Force the move?"), - initial=False, - required=False - ) - def clean_force(self): """On supprime l'ancien user de la chambre si et seulement si la case est cochée""" @@ -368,6 +369,7 @@ class AdherentForm(FormRevMixin, FieldPermissionFormMixin, ModelForm): remove_user_room(self.cleaned_data.get('room')) return + class AdherentCreationForm(AdherentForm): """Formulaire de création d'un user. AdherentForm auquel on ajoute une checkbox afin d'éviter les @@ -383,8 +385,22 @@ class AdherentCreationForm(AdherentForm): # Checkbox for GTU gtu_check = forms.BooleanField(required=True) - gtu_check.label = mark_safe("{}
{}{}".format( - _("I commit to accept the"), GeneralOption.get_cached_value('GTU'), _("General Terms of Use"), _("."))) + #gtu_check.label = mark_safe("{} {}{}".format( + # _("I commit to accept the"), GeneralOption.get_cached_value('GTU'), _("General Terms of Use"), _("."))) + + class Meta: + model = Adherent + fields = [ + 'name', + 'surname', + 'pseudo', + 'email', + 'school', + 'comment', + 'telephone', + 'room', + 'state', + ] def __init__(self, *args, **kwargs): super(AdherentCreationForm, self).__init__(*args, **kwargs) @@ -398,12 +414,6 @@ class AdherentEditForm(AdherentForm): if 'shell' in self.fields: self.fields['shell'].empty_label = _("Default shell") - def clean_gpg_fingerprint(self): - """Format the GPG fingerprint""" - gpg_fingerprint = self.cleaned_data.get('gpg_fingerprint', None) - if gpg_fingerprint: - return gpg_fingerprint.replace(' ', '').upper() - class Meta: model = Adherent fields = [ @@ -429,6 +439,7 @@ class ClubForm(FormRevMixin, FieldPermissionFormMixin, ModelForm): self.fields['surname'].label = _("Name") self.fields['school'].label = _("School") self.fields['comment'].label = _("Comment") + self.fields['email'].label = _("Email Address") if 'room' in self.fields: self.fields['room'].label = _("Room") self.fields['room'].empty_label = _("No room") @@ -443,7 +454,9 @@ class ClubForm(FormRevMixin, FieldPermissionFormMixin, ModelForm): 'school', 'comment', 'room', + 'email', 'telephone', + 'email', 'shell', 'mailing' ] @@ -488,13 +501,14 @@ class PasswordForm(FormRevMixin, ModelForm): class ServiceUserForm(FormRevMixin, ModelForm): - """ Modification d'un service user""" + """Service user creation + force initial password set""" password = forms.CharField( label=_("New password"), max_length=255, validators=[MinLengthValidator(8)], widget=forms.PasswordInput, - required=False + required=True ) class Meta: @@ -506,7 +520,7 @@ class ServiceUserForm(FormRevMixin, ModelForm): super(ServiceUserForm, self).__init__(*args, prefix=prefix, **kwargs) def save(self, commit=True): - """Changement du mot de passe""" + """Password change""" user = super(ServiceUserForm, self).save(commit=False) if self.cleaned_data['password']: user.set_password(self.cleaned_data.get("password")) @@ -516,6 +530,14 @@ class ServiceUserForm(FormRevMixin, ModelForm): class EditServiceUserForm(ServiceUserForm): """Formulaire d'edition de base d'un service user. Ne permet d'editer que son group d'acl et son commentaire""" + password = forms.CharField( + label=_("New password"), + max_length=255, + validators=[MinLengthValidator(8)], + widget=forms.PasswordInput, + required=False + ) + class Meta(ServiceUserForm.Meta): fields = ['access_group', 'comment'] diff --git a/users/locale/fr/LC_MESSAGES/django.mo b/users/locale/fr/LC_MESSAGES/django.mo deleted file mode 100644 index 4547ff8e..00000000 Binary files a/users/locale/fr/LC_MESSAGES/django.mo and /dev/null differ diff --git a/users/migrations/0079_auto_20181228_2039.py b/users/migrations/0079_auto_20181228_2039.py new file mode 100644 index 00000000..79ab56d9 --- /dev/null +++ b/users/migrations/0079_auto_20181228_2039.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2018-12-28 19:39 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0078_auto_20181011_1405'), + ] + + operations = [ + migrations.AlterField( + model_name='adherent', + name='gpg_fingerprint', + field=models.CharField(blank=True, max_length=49, null=True), + ), + ] diff --git a/users/models.py b/users/models.py index 63d0a875..a2798207 100755 --- a/users/models.py +++ b/users/models.py @@ -81,6 +81,7 @@ from re2o.settings import LDAP, GID_RANGES, UID_RANGES from re2o.login import hashNT from re2o.field_permissions import FieldPermissionModelMixin from re2o.mixins import AclMixin, RevMixin +from re2o.base import smtp_check from cotisations.models import Cotisation, Facture, Paiement, Vente from machines.models import Domain, Interface, Machine, regen @@ -93,7 +94,7 @@ from preferences.models import OptionalMachine, MailMessageOption def linux_user_check(login): """ Validation du pseudo pour respecter les contraintes unix""" - UNIX_LOGIN_PATTERN = re.compile("^[a-zA-Z][a-zA-Z0-9-]*[$]?$") + UNIX_LOGIN_PATTERN = re.compile("^[a-z][a-z0-9-]*[$]?$") return UNIX_LOGIN_PATTERN.match(login) @@ -336,7 +337,7 @@ class User(RevMixin, FieldPermissionModelMixin, AbstractBaseUser, def set_active(self): """Enable this user if he subscribed successfully one time before""" if self.state == self.STATE_NOT_YET_ACTIVE: - if self.facture_set.filter(valid=True).filter(Q(vente__type_cotisation='All') | Q(vente__type_cotisation='Adhesion')).exists(): + if self.facture_set.filter(valid=True).filter(Q(vente__type_cotisation='All') | Q(vente__type_cotisation='Adhesion')).exists() or OptionalUser.get_cached_value('all_users_active'): self.state = self.STATE_ACTIVE self.save() @@ -474,7 +475,8 @@ class User(RevMixin, FieldPermissionModelMixin, AbstractBaseUser, """ Renvoie si un utilisateur a accès à internet """ return (self.state == User.STATE_ACTIVE and not self.is_ban() and - (self.is_connected() or self.is_whitelisted())) + (self.is_connected() or self.is_whitelisted())) \ + or self == AssoOption.get_cached_value('utilisateur_asso') def end_access(self): """ Renvoie la date de fin normale d'accès (adhésion ou whiteliste)""" @@ -576,7 +578,8 @@ class User(RevMixin, FieldPermissionModelMixin, AbstractBaseUser, mac_refresh : synchronise les machines de l'user group_refresh : synchronise les group de l'user Si l'instance n'existe pas, on crée le ldapuser correspondant""" - if sys.version_info[0] >= 3: + if sys.version_info[0] >= 3 and self.state != self.STATE_ARCHIVE and\ + self.state != self.STATE_DISABLED: self.refresh_from_db() try: user_ldap = LdapUser.objects.get(uidNumber=self.uid_number) @@ -693,10 +696,8 @@ class User(RevMixin, FieldPermissionModelMixin, AbstractBaseUser, def autoregister_machine(self, mac_address, nas_type): """ Fonction appellée par freeradius. Enregistre la mac pour une machine inconnue sur le compte de l'user""" - all_interfaces = self.user_interfaces(active=False) - if all_interfaces.count() > OptionalMachine.get_cached_value( - 'max_lambdauser_interfaces' - ): + allowed, _message = Machine.can_create(self, self.id) + if not allowed: return False, _("Maximum number of registered machines reached.") if not nas_type: return False, _("Re2o doesn't know wich machine type to assign.") @@ -1025,17 +1026,12 @@ class User(RevMixin, FieldPermissionModelMixin, AbstractBaseUser, ): raise ValidationError("This pseudo is already in use.") if not self.local_email_enabled and not self.email and not (self.state == self.STATE_ARCHIVE): - raise ValidationError( - {'email': ( - _("There is neither a local email address nor an external" + raise ValidationError(_("There is neither a local email address nor an external" " email address for this user.") - ), } ) if self.local_email_redirect and not self.email: - raise ValidationError( - {'local_email_redirect': ( - _("You can't redirect your local emails if no external email" - " address has been set.")), } + raise ValidationError(_("You can't redirect your local emails if no external email" + " address has been set.") ) def __str__(self): @@ -1054,20 +1050,27 @@ class Adherent(User): null=True ) gpg_fingerprint = models.CharField( - max_length=40, + max_length=49, blank=True, null=True, - validators=[RegexValidator( - '^[0-9A-F]{40}$', - message=_("A GPG fingerprint must contain 40 hexadecimal" - " characters.") - )] ) class Meta(User.Meta): verbose_name = _("member") verbose_name_plural = _("members") + def format_gpgfp(self): + """Format gpg finger print as AAAA BBBB... from a string AAAABBBB....""" + self.gpg_fingerprint = ' '.join([self.gpg_fingerprint[i:i + 4] for i in range(0, len(self.gpg_fingerprint), 4)]) + + def validate_gpgfp(self): + """Validate from raw entry if is it a valid gpg fp""" + if self.gpg_fingerprint: + gpg_fingerprint = self.gpg_fingerprint.replace(' ', '').upper() + if not re.match("^[0-9A-F]{40}$", gpg_fingerprint): + raise ValidationError(_("A gpg fingerprint must contain 40 hexadecimal carracters")) + self.gpg_fingerprint = gpg_fingerprint + @classmethod def get_instance(cls, adherentid, *_args, **_kwargs): """Try to find an instance of `Adherent` with the given id. @@ -1098,6 +1101,13 @@ class Adherent(User): _("You don't have the right to create a user.") ) + def clean(self, *args, **kwargs): + """Format the GPG fingerprint""" + super(Adherent, self).clean(*args, **kwargs) + if self.gpg_fingerprint: + self.validate_gpgfp() + self.format_gpgfp() + class Club(User): """ A class representing a club (it is considered as a user @@ -1889,6 +1899,9 @@ class EMailAddress(RevMixin, AclMixin, models.Model): def clean(self, *args, **kwargs): self.local_part = self.local_part.lower() - if "@" in self.local_part: - raise ValidationError(_("The local part must not contain @.")) + if "@" in self.local_part or "+" in self.local_part: + raise ValidationError(_("The local part must not contain @ or +.")) + result, reason = smtp_check(self.local_part) + if result: + raise ValidationError(reason) super(EMailAddress, self).clean(*args, **kwargs) diff --git a/users/templates/users/profil.html b/users/templates/users/profil.html index cdc9a9d1..b4fa7875 100644 --- a/users/templates/users/profil.html +++ b/users/templates/users/profil.html @@ -23,7 +23,6 @@ with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. {% endcomment %} -{% load bootstrap3 %} {% load acl %} {% load logs_extra %} {% load design %} @@ -78,8 +77,9 @@ with this program; if not, write to the Free Software Foundation, Inc., {% if solde_activated %}
-
- {{ users.solde }} +
+ {{ users.solde }}
@@ -92,8 +92,9 @@ with this program; if not, write to the Free Software Foundation, Inc.,
{% if nb_machines %}
-
- {% trans " Machines" %} {{ nb_machines }} +
+ {% trans " Machines" %} {{ nb_machines }}
{% else %}
-
{% trans "No machine" %}
+
+ {% trans "No machine" %} +
{% trans " Add a machine" %} @@ -117,158 +121,200 @@ with this program; if not, write to the Free Software Foundation, Inc.,
-
-

- {% trans " Detailed information" %} +
+

+ {% trans " Detailed information" %}

-
-
- - - {% trans "Edit" %} - - - - {% trans "Change the password" %} - - {% can_change User state %} - - - {% trans "Change the state" %} - - {% acl_end %} - {% can_change User groups %} - - - {% trans "Edit the groups" %} - - {% acl_end %} - {% history_button users text=True %} +
+
+ + + {% trans "Edit" %} + + + + {% trans "Change the password" %} + + {% can_change User state %} + + + {% trans "Change the state" %} + + {% acl_end %} + {% can_change User groups %} + + + {% trans "Edit the groups" %} + + {% acl_end %} + {% history_button users text=True %}
-
- - +
+
{% if users.is_class_club %} -
- {% if users.club.mailing %} - - {% else %} - - {% endif %} - {% else %} - - - {% endif %} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {% if users.end_adhesion != None %} - +
{% trans "Mailing" %}
+ {% if users.club.mailing %} +
{{ users.pseudo }}(-admin)
+ {% else %} +
{% trans "Mailing disabled" %}
+ {% endif %} {% else %} - +
{% trans "Firt name" %}
+
{{ users.name }}
{% endif %} - - {% if users.end_whitelist != None %} - - {% else %} - - {% endif %} - - - {% if users.end_ban != None %} - - {% else %} - - {% endif %} - - {% if users.state == 0 %} - - {% elif users.state == 1 %} - - {% elif users.state == 2 %} - - {% elif users.state == 3 %} - + +
+
{% trans "Surname" %}
+
{{ users.surname }}
+
+ +
+
{% trans "Username" %}
+
{{ users.pseudo }}
+
+ +
+
{% trans "Email address" %}
+
{{ users.email }}
+
+ +
+
{% trans "Room" %}
+
+ {{ users.room }} {% can_view_all Port %}{% if users.room.port_set.all %} / + {{ users.room.port_set.all|join:", " }} {% endif %}{% acl_end %} +
+
+ +
+
{% trans "Telephone number" %}
+
{{ users.telephone }}
+
+ +
+
{% trans "School" %}
+
{{ users.school }}
+
+ +
+
{% trans "Comment" %}
+
{{ users.comment }}
+
+ +
+
{% trans "Registration date" %}
+
{{ users.registered }}
+
+ +
+
{% trans "Last login" %}
+
{{ users.last_login }}
+
+ +
+
{% trans "End of membership" %}
+ {% if users.end_adhesion != None %} +
{{ users.end_adhesion }}
+ {% else %} +
{% trans "Not a member" %}
{% endif %} -
- - + + +
+
{% trans "Whitelist" %}
+ {% if users.end_whitelist != None %} +
{{ users.end_whitelist }}
+ {% else %} +
{% trans "None" %}
+ {% endif %} +
+ +
+
{% trans "Ban" %}
+ {% if users.end_ban != None %} +
{{ users.end_ban }}
+ {% else %} +
{% trans "Not banned" %}
+ {% endif %} +
+ +
+
{% trans "State" %}
+ {% if users.state == 0 %} +
{% trans "Active" %}
+ {% elif users.state == 1 %} +
{% trans "Disabled" %}
+ {% elif users.state == 2 %} +
{% trans "Archived" %}
+ {% elif users.state == 3 %} +
{% trans "Not yet Member" %}
+ {% endif %} +
+ +
+
{% trans "Internet access" %}
{% if users.has_access == True %} -
+
+ {% blocktrans with end_access=users.end_access %}Active + (until {{ end_access }}){% endblocktrans %}
{% else %} - +
{% trans "Disabled" %}
{% endif %} - + + +
+
{% trans "Groups of rights" %}
{% if users.groups.all %} -
+
{{ users.groups.all|join:", " }}
{% else %} - +
{% trans "None" %}
{% endif %} - - - - - {% if users.adherent.gpg_fingerprint %} - - - {% endif %} - - - {% if users.shell %} - - - {% endif %} - -
{% trans "Mailing" %}{{ users.pseudo }}(-admin){% trans "Mailing disabled" %}{% trans "Firt name" %}{{ users.name }}{% trans "Surname" %}{{ users.surname }}
{% trans "Username" %}{{ users.pseudo }}{% trans "Email address" %}{{users.email}}
{% trans "Room" %}{{ users.room }} {% can_view_all Port %}{% if users.room.port_set.all %} / {{ users.room.port_set.all|join:", " }} {% endif %}{% acl_end %}{% trans "Telephone number" %}{{ users.telephone }}
{% trans "School" %}{{ users.school }}{% trans "Comment" %}{{ users.comment }}
{% trans "Registration date" %}{{ users.registered }}{% trans "Last login" %}{{ users.last_login }}
{% trans "End of membership" %}{{ users.end_adhesion }}{% trans "Not a member" %}{% trans "Whitelist" %}{{ users.end_whitelist }}{% trans "None" %}
{% trans "Ban" %}{{ users.end_ban }}{% trans "Not banned" %}{% trans "State" %}{% trans "Active" %}{% trans "Disabled" %}{% trans "Archived" %}{% trans "Not yet Member" %}
{% trans "Internet access" %}{% blocktrans with end_access=users.end_access %}Active (until {{ end_access }}){% endblocktrans %}{% trans "Disabled" %}{% trans "Groups of rights" %}{{ users.groups.all|join:", "}}{% trans "None" %}
{% trans "Balance" %}{{ users.solde }} € - {% if user_solde %} - - - {% trans "Refill" %} - - {% endif %} - {% trans "GPG fingerprint" %}{{ users.adherent.gpg_fingerprint }}
{% trans "Shell" %}{{ users.shell }}
-
+
+ +
+
{% trans "Balance" %}
+
+ {{ users.solde }} € + {% if user_solde %} + + + {% trans "Refill" %} + + {% endif %} +
+
+ + {% if users.adherent.gpg_fingerprint %} +
+
{% trans "GPG fingerprint" %}
+
{{ users.adherent.gpg_fingerprint }}
+
+ {% endif %} + + {% if users.shell %} +
+
{% trans "Shell" %}
+
{{ users.shell }}
+
+ {% endif %} +
{% if users.is_class_club %}