39 Commits b8b2be6798 ... bbb9e1a517

Author SHA1 Message Date
  Jocelyn Delalande bbb9e1a517 Partly reverts f18aa84 7 years ago
  jocelyn aa8229d38e Merge branch 'change_info' of ARN/coin into master 7 years ago
  jocelyn b2c47517a6 Merge branch 'feat-list-loan-in-MemberAdmin-view' of daimrod/coin into master 7 years ago
  Jocelyn Delalande 878aa4fbd6 Order loans in member admin to display running loans first 7 years ago
  Jocelyn Delalande 6664144b31 Display wether a loan is running or not in member admin 7 years ago
  Jocelyn Delalande 40f4120b16 Add a Loan.is_running property 7 years ago
  daimrod 369f9b8eed Merge branch 'feat-option-to-disable-fee-reminders' of daimrod/coin into master 7 years ago
  Grégoire Jadi 8ebe239ee9 Check if we should to send the membership fees email from the model 7 years ago
  Grégoire Jadi 2e54d53692 Add checkbox to enable/disable membership fees reminder 7 years ago
  daimrod 3fa14674b9 Merge branch 'feat-cmd-list-members' of daimrod/coin into master 7 years ago
  Grégoire Jadi 3a60f81e11 Add a small note about members_email new filters in README 7 years ago
  Grégoire Jadi 47bc4151a8 BREAKING CHANGE! Remove subscribers_email superseeded by members_email --subscribers 7 years ago
  jocelyn 58f3a3310d Merge branch 'save_as_admin_item' of Sim/coin into master 7 years ago
  daimrod df7da50c33 Merge branch 'fix-manage_py-warnings' of daimrod/coin into master 7 years ago
  Grégoire Jadi 9277b03b21 Set upper bound to django-autocomplete-light's version instead of fixing it 7 years ago
  Grégoire Jadi bafefc478e Fix Django19Warning : The django.contrib.admin.util module has been renamed 7 years ago
  Grégoire Jadi 172e5c45bf Bump to django-autocomplete-light to fix taggit warnings in manage.py 7 years ago
  daimrod e3d38a5d16 Merge branch 'spelling' of Sim/coin into master 7 years ago
  Grégoire Jadi 5e5ca51d29 Improve members email listing 7 years ago
  SimonBoulier f18aa84f69 Fix #14 (and a bit more) : Correct several spelling/translation errors in the admin interface 7 years ago
  daimrod dba388f984 Merge branch 'mac_serial_new_loan' of Sim/coin into master 7 years ago
  SimonBoulier a7d7eb977a Fix #69 Display mac or ref when creating a new loan in the admin interface 7 years ago
  daimrod 78c7ebc114 Merge branch 'deloyed_status_for_hardware' of Sim/coin into master 7 years ago
  SimonBoulier 570303b770 Ajout du status 'deployed' pour un objet. Fix #72 Inventaire : pouvoir indiquer un matos déployé mais pas chez un adhérent 7 years ago
  SimonBoulier 04216fd7a1 Partial solution to #79 : For an item of the hardware provisioning, replace 'Save and add another' by 'Save as new' which duplicate the item 7 years ago
  Grégoire Jadi 35837b9396 Fix#119 : hardware_provisioning: List loan in member view 7 years ago
  daimrod 697bb03c67 Merge branch 'mac_serial_loan' of Sim/coin into master 7 years ago
  SimonBoulier 61d54fed30 Fix #118: display mac address and serial number in loan listing 7 years ago
  ljf 8280ce6bfb [fix] Conflict 7 years ago
  ljf 51b6f94b68 [enh] Use render instead of render_response 7 years ago
  ljf 09e334c1ab [fix] Separate change form to fix admin error 7 years ago
  ljf 8e76d97c7a [enh] Add documentation about profile edition 7 years ago
  ljf 89467fc2cb [fix] Rename profil as profile 7 years ago
  ljf 974963e878 [enh] Member can update their info 8 years ago
  ljf 91ab649ba9 [enh] Use render instead of render_response 7 years ago
  ljf 95fc516c88 [fix] Separate change form to fix admin error 7 years ago
  ljf c8888fd335 [enh] Add documentation about profile edition 7 years ago
  ljf f6d1ad3bab [fix] Rename profil as profile 7 years ago
  ljf 294f93f48e [enh] Member can update their info 8 years ago

+ 3 - 1
README.md

@@ -187,7 +187,8 @@ Some useful administration commands are available via `manage.py`.
 per line.  This may be useful to automatically feed a mailing list software.
 Note that membership is based on the `status` field of users, not on
 membership fees.  That is, even if a member has forgot to renew his or her
-membership fee, his or her address will still show up in this list.
+membership fee, his or her address will still show up in this list. More
+filters are available, see the command's help for more details.
 
 `python manage.py charge_subscriptions`: generate invoices (including a
 PDF version) for each subscriber.  You probably want to run this command
@@ -336,6 +337,7 @@ List of available settings in your `settings_local.py` file.
 - `MEMBER_MEMBERSHIP_INFO_URL`: Link to a page with information on how to become a member or pay the membership fee
 - `SUBSCRIPTION_REFERENCE`: Pattern used to display a unique reference for any subscription. Helpful for bank wire transfer identification
 - `PAYMENT_DELAY`: Payment delay in days for issued invoices ( default is 30 days which is the default in french law)
+- `MEMBER_CAN_EDIT_PROFILE`: Allow members to edit their profiles
 
 More information
 ================

+ 1 - 1
coin/billing/admin.py

@@ -5,7 +5,7 @@ from django.contrib import admin
 from django.contrib import messages
 from django.http import HttpResponseRedirect
 from django.conf.urls import url
-from django.contrib.admin.util import flatten_fieldsets
+from django.contrib.admin.utils import flatten_fieldsets
 
 from coin.filtering_queryset import LimitedAdminInlineMixin
 from coin.billing.models import Invoice, InvoiceDetail, Payment

+ 19 - 0
coin/billing/migrations/0008_auto_20170802_2021.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('billing', '0007_auto_20170801_1530'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='invoice',
+            name='status',
+            field=models.CharField(default='open', max_length=50, verbose_name='statut', choices=[('open', '\xc0 payer'), ('closed', 'R\xe9gl\xe9e'), ('trouble', 'Litige')]),
+        ),
+    ]

+ 2 - 2
coin/billing/models.py

@@ -109,8 +109,8 @@ class InvoiceQuerySet(models.QuerySet):
 class Invoice(models.Model):
 
     INVOICES_STATUS_CHOICES = (
-        ('open', 'A payer'),
-        ('closed', 'Reglée'),
+        ('open', 'À payer'),
+        ('closed', 'Réglée'),
         ('trouble', 'Litige')
     )
 

+ 151 - 0
coin/isp_database/migrations/0014_auto_20170802_2021.py

@@ -0,0 +1,151 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import multiselectfield.db.fields
+import django.core.validators
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('isp_database', '0013_merge'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='chatroom',
+            options={'verbose_name': 'Salon de discussions', 'verbose_name_plural': 'Salons de discussions'},
+        ),
+        migrations.AlterModelOptions(
+            name='coveredarea',
+            options={'verbose_name': 'Zone couverte', 'verbose_name_plural': 'Zones couvertes'},
+        ),
+        migrations.AlterModelOptions(
+            name='ispinfo',
+            options={'verbose_name': 'Information du FAI', 'verbose_name_plural': 'Informations du FAI'},
+        ),
+        migrations.AlterModelOptions(
+            name='otherwebsite',
+            options={'verbose_name': 'Autre site Internet', 'verbose_name_plural': 'Autres sites Internet'},
+        ),
+        migrations.AlterModelOptions(
+            name='registeredoffice',
+            options={'verbose_name': 'Si\xe8ge social', 'verbose_name_plural': 'Si\xe8ges sociaux'},
+        ),
+        migrations.AlterField(
+            model_name='coveredarea',
+            name='name',
+            field=models.CharField(max_length=512, verbose_name='Nom'),
+        ),
+        migrations.AlterField(
+            model_name='coveredarea',
+            name='technologies',
+            field=multiselectfield.db.fields.MultiSelectField(max_length=42, verbose_name='Technologie', choices=[('ftth', 'FTTH'), ('dsl', '*DSL'), ('wifi', 'WiFi'), ('vpn', 'VPN'), ('cube', 'Brique Internet')]),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='creationDate',
+            field=models.DateField(help_text='Date de cr\xe9ation de la structure l\xe9gale', null=True, verbose_name='Date de cr\xe9ation', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='description',
+            field=models.TextField(help_text='Description courte du projet', verbose_name='Description', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='email',
+            field=models.EmailField(help_text='Adresse courriel de contact', max_length=254, verbose_name='Courriel'),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='ffdnMemberSince',
+            field=models.DateField(help_text='Date \xe0 laquelle le FAI a rejoint la F\xe9d\xe9ration FDN', null=True, verbose_name='Membre de FFDN depuis', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='latitude',
+            field=models.FloatField(help_text='Coordonn\xe9es latitudinales du si\xe8ge', null=True, verbose_name='Latitude', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='logoURL',
+            field=models.URLField(help_text='Adresse HTTP(S) du logo du FAI', verbose_name='URL du logo', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='longitude',
+            field=models.FloatField(help_text='Coordonn\xe9es longitudinales du si\xe8ge', null=True, verbose_name='Longitude', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='mainMailingList',
+            field=models.EmailField(help_text='Principale liste de discussion publique', max_length=254, verbose_name='Liste de discussion principale', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='name',
+            field=models.CharField(help_text='Nom du FAI', max_length=512, verbose_name='Nom'),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='phone_number',
+            field=models.CharField(help_text='Num\xe9ro de t\xe9l\xe9phone de contact principal', max_length=25, verbose_name='Num\xe9ro de t\xe9l\xe9phone', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='progressStatus',
+            field=models.PositiveSmallIntegerField(blank=True, help_text="\xc9tat d'avancement du FAI", null=True, verbose_name="\xc9tat d'avancement", validators=[django.core.validators.MaxValueValidator(7)]),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='shortname',
+            field=models.CharField(help_text='Nom plus court', max_length=15, verbose_name='Abr\xe9viation', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='website',
+            field=models.URLField(help_text='Adresse URL du site Internet', verbose_name='URL du site Internet', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='otherwebsite',
+            name='name',
+            field=models.CharField(max_length=512, verbose_name='Nom'),
+        ),
+        migrations.AlterField(
+            model_name='registeredoffice',
+            name='country_name',
+            field=models.CharField(max_length=512, verbose_name='Pays'),
+        ),
+        migrations.AlterField(
+            model_name='registeredoffice',
+            name='extended_address',
+            field=models.CharField(max_length=512, verbose_name='Adresse compl\xe9mentaire', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='registeredoffice',
+            name='locality',
+            field=models.CharField(max_length=512, verbose_name='Ville'),
+        ),
+        migrations.AlterField(
+            model_name='registeredoffice',
+            name='post_office_box',
+            field=models.CharField(max_length=512, verbose_name='Bo\xeete postale', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='registeredoffice',
+            name='postal_code',
+            field=models.CharField(max_length=512, verbose_name='Code postal', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='registeredoffice',
+            name='region',
+            field=models.CharField(max_length=512, verbose_name='R\xe9gion'),
+        ),
+        migrations.AlterField(
+            model_name='registeredoffice',
+            name='street_address',
+            field=models.CharField(max_length=512, verbose_name='Adresse', blank=True),
+        ),
+    ]

+ 59 - 32
coin/isp_database/models.py

@@ -53,48 +53,55 @@ class ISPInfo(SingleInstanceMixin, models.Model):
         return count_active_subscriptions()
 
     name = models.CharField(max_length=512,
-                            help_text="The ISP's name")
+                            verbose_name="Nom",
+                            help_text="Nom du FAI")
     # Length required by the spec
     shortname = models.CharField(max_length=15, blank=True,
-                                 help_text="Shorter name")
+                                 verbose_name="Abréviation",
+                                 help_text="Nom plus court")
     description = models.TextField(blank=True,
-                                   help_text="Short text describing the project")
+                                   verbose_name="Description",
+                                   help_text="Description courte du projet")
     logoURL = models.URLField(blank=True,
-                              verbose_name="logo URL",
-                              help_text="HTTP(S) URL of the ISP's logo")
+                              verbose_name="URL du logo",
+                              help_text="Adresse HTTP(S) du logo du FAI")
     website = models.URLField(blank=True,
-                              help_text='URL to the official website')
-    email = models.EmailField(help_text="Contact email address")
+                              verbose_name="URL du site Internet",
+                              help_text='Adresse URL du site Internet')
+    email = models.EmailField(verbose_name="Courriel",
+                              help_text="Adresse courriel de contact")
     mainMailingList = models.EmailField(blank=True,
-                                        verbose_name="main mailing list",
-                                        help_text="Main public mailing-list")
+                                        verbose_name="Liste de discussion principale",
+                                        help_text="Principale liste de discussion publique")
     phone_number = models.CharField(max_length=25, blank=True,
-                                    verbose_name="phone number",
-                                    help_text='Main contact phone number')
+                                    verbose_name="Numéro de téléphone",
+                                    help_text='Numéro de téléphone de contact principal')
     creationDate = models.DateField(blank=True, null=True,
-                                    verbose_name="creation date",
-                                     help_text="Date of creation for legal structure")
+                                    verbose_name="Date de création",
+                                    help_text="Date de création de la structure légale")
     ffdnMemberSince = models.DateField(blank=True, null=True,
-                                       verbose_name="FFDN member since",
-                                       help_text="Date at wich the ISP joined the Federation")
+                                       verbose_name="Membre de FFDN depuis",
+                                       help_text="Date à laquelle le FAI a rejoint la Fédération FDN")
     # TODO: choice field
     progressStatus = models.PositiveSmallIntegerField(
         validators=[MaxValueValidator(7)],
-        blank=True, null=True, verbose_name='progress status',
-        help_text="Progression status of the ISP")
+        blank=True, null=True, verbose_name="État d'avancement",
+        help_text="État d'avancement du FAI")
     # TODO: better model for coordinates
     latitude = models.FloatField(blank=True, null=True,
-        help_text="Coordinates of the registered office (latitude)")
+                                 verbose_name="Latitude",
+                                 help_text="Coordonnées latitudinales du siège")
     longitude = models.FloatField(blank=True, null=True,
-        help_text="Coordinates of the registered office (longitude)")
+                                  verbose_name="Longitude",
+                                  help_text="Coordonnées longitudinales du siège")
 
     # Uncomment this (and handle the necessary migrations) if you want to
     # manage one of the counters by hand.  Otherwise, they are computed
     # automatically, which is probably what you want.
-    #memberCount = models.PositiveIntegerField(help_text="Number of members",
+    #memberCount = models.PositiveIntegerField(help_text="Nombre de membres",
     #                                          default=0)
     #subscriberCount = models.PositiveIntegerField(
-    #    help_text="Number of subscribers to an internet access",
+    #    help_text="Nombre d'abonnés à un accès Internet",
     #    default=0)
 
     # field outside of db-ffdn format:
@@ -110,9 +117,13 @@ class ISPInfo(SingleInstanceMixin, models.Model):
         verbose_name="serveur de listes", blank=True,
         help_text="URL du serveur de listes de discussions/diffusion")
 
+    class Meta:
+        verbose_name = "Information du FAI"
+        verbose_name_plural = "Informations du FAI"
+
     @property
     def version(self):
-        """Version of the API"""
+        """Version de l'API"""
         return API_VERSION
 
     @property
@@ -168,25 +179,33 @@ class ISPInfo(SingleInstanceMixin, models.Model):
 
 
 class OtherWebsite(models.Model):
-    name = models.CharField(max_length=512)
+    name = models.CharField(max_length=512, verbose_name="Nom")
     url = models.URLField(verbose_name="URL")
     isp = models.ForeignKey(ISPInfo)
 
+    class Meta:
+        verbose_name = "Autre site Internet"
+        verbose_name_plural = "Autres sites Internet"
+
 
 class RegisteredOffice(models.Model):
     """ http://json-schema.org/address """
-    post_office_box = models.CharField(max_length=512, blank=True)
-    extended_address = models.CharField(max_length=512, blank=True)
-    street_address = models.CharField(max_length=512, blank=True)
-    locality = models.CharField(max_length=512)
-    region = models.CharField(max_length=512)
-    postal_code = models.CharField(max_length=512, blank=True)
-    country_name = models.CharField(max_length=512)
+    post_office_box = models.CharField(max_length=512, blank=True, verbose_name="Boîte postale")
+    extended_address = models.CharField(max_length=512, blank=True, verbose_name="Adresse complémentaire")
+    street_address = models.CharField(max_length=512, blank=True, verbose_name="Adresse")
+    locality = models.CharField(max_length=512, verbose_name="Ville")
+    region = models.CharField(max_length=512, verbose_name="Région")
+    postal_code = models.CharField(max_length=512, blank=True, verbose_name="Code postal")
+    country_name = models.CharField(max_length=512, verbose_name="Pays")
     isp = models.OneToOneField(ISPInfo)
 
     # not in db.ffdn.org spec
     siret = FRSIRETField('SIRET')
 
+    class Meta:
+        verbose_name = "Siège social"
+        verbose_name_plural = "Sièges sociaux"
+
     def to_dict(self):
         d = dict()
         for field in ('post_office_box', 'extended_address', 'street_address',
@@ -202,11 +221,15 @@ class ChatRoom(models.Model):
         verbose_name="URL", max_length=256, validators=[chatroom_url_validator])
     isp = models.ForeignKey(ISPInfo)
 
+    class Meta:
+        verbose_name = "Salon de discussions"
+        verbose_name_plural = "Salons de discussions"
+
 
 class CoveredArea(models.Model):
-    name = models.CharField(max_length=512)
+    name = models.CharField(max_length=512, verbose_name="Nom")
 
-    technologies = MultiSelectField(choices=TECHNOLOGIES, max_length=42)
+    technologies = MultiSelectField(choices=TECHNOLOGIES, max_length=42, verbose_name="Technologie")
     # TODO: find a geojson library
     #area =
     isp = models.ForeignKey(ISPInfo)
@@ -215,6 +238,10 @@ class CoveredArea(models.Model):
         return {"name": self.name,
                 "technologies": self.technologies}
 
+    class Meta:
+        verbose_name = "Zone couverte"
+        verbose_name_plural = "Zones couvertes"
+
 
 class BankInfo(models.Model):
     """Information about bank account and the bank itself

+ 3 - 3
coin/members/admin.py

@@ -15,7 +15,7 @@ from django.utils.html import format_html
 from coin.members.models import (
     Member, CryptoKey, LdapUser, MembershipFee, OfferSubscription)
 from coin.members.membershipfee_filter import MembershipFeeFilter
-from coin.members.forms import MemberChangeForm, MemberCreationForm
+from coin.members.forms import AdminMemberChangeForm, MemberCreationForm
 from coin.utils import delete_selected
 import autocomplete_light
 
@@ -59,7 +59,7 @@ class MemberAdmin(UserAdmin):
     actions = [delete_selected, 'set_as_member', 'set_as_non_member',
                'bulk_send_welcome_email', 'bulk_send_call_for_membership_fee_email']
 
-    form = MemberChangeForm
+    form = AdminMemberChangeForm
     add_form = MemberCreationForm
 
     fieldsets = (
@@ -70,7 +70,7 @@ class MemberAdmin(UserAdmin):
             'organization_name',
             'comments')}),
         ('Coordonnées', {'fields': (
-            'email',
+            ('email', 'send_membership_fees_email'),
             ('home_phone_number', 'mobile_phone_number'),
             'address',
             ('postal_code', 'city', 'country'))}),

+ 50 - 4
coin/members/forms.py

@@ -3,6 +3,7 @@ from __future__ import unicode_literals
 
 from django import forms
 from django.contrib.auth.forms import PasswordResetForm, ReadOnlyPasswordHashField
+from django.forms.utils import ErrorList
 
 from coin.members.models import Member
 
@@ -37,20 +38,18 @@ class MemberCreationForm(forms.ModelForm):
         return member
 
 
-class MemberChangeForm(forms.ModelForm):
-
+class AbstractMemberChangeForm(forms.ModelForm):
     """
     This form was inspired from django.contrib.auth.forms.UserChangeForm
     and adapted to coin specificities
     """
-    password = ReadOnlyPasswordHashField()
 
     class Meta:
         model = Member
         fields = '__all__'
 
     def __init__(self, *args, **kwargs):
-        super(MemberChangeForm, self).__init__(*args, **kwargs)
+        super(AbstractMemberChangeForm, self).__init__(*args, **kwargs)
         f = self.fields.get('user_permissions', None)
         if f is not None:
             f.queryset = f.queryset.select_related('content_type')
@@ -66,5 +65,52 @@ class MemberChangeForm(forms.ModelForm):
         return self.initial["username"]
 
 
+class AdminMemberChangeForm(AbstractMemberChangeForm):
+    password = ReadOnlyPasswordHashField()
+
+
+class SpanError(ErrorList):
+    def __unicode__(self):
+        return self.as_spans()
+    def __str__(self):
+        return self.as_spans()
+    def as_spans(self):
+        if not self: return ''
+        return ''.join(['<span class="error">%s</span>' % e for e in self])
+
+class PersonMemberChangeForm(AbstractMemberChangeForm):
+    """
+    Form use to allow natural person to change their info
+    """
+    class Meta:
+        model = Member
+        fields = ['first_name', 'last_name', 'email', 'nickname',
+                  'home_phone_number', 'mobile_phone_number',
+                  'address', 'postal_code', 'city', 'country']
+
+    def __init__(self, *args, **kwargs):
+        super(PersonMemberChangeForm, self).__init__(*args, **kwargs)
+        self.error_class = SpanError
+        for fieldname in self.fields:
+            self.fields[fieldname].help_text = None
+
+
+class OrganizationMemberChangeForm(AbstractMemberChangeForm):
+    """
+    Form use to allow organization to change their info
+    """
+    class Meta:
+        model = Member
+        fields = ['organization_name', 'email', 'nickname',
+                  'home_phone_number', 'mobile_phone_number',
+                  'address', 'postal_code', 'city', 'country']
+
+    def __init__(self, *args, **kwargs):
+        super(OrganizationChangeForm, self).__init__(*args, **kwargs)
+        self.error_class = SpanError
+        for fieldname in self.fields:
+            self.fields[fieldname].help_text = None
+
 class MemberPasswordResetForm(PasswordResetForm):
     pass
+

+ 2 - 1
coin/members/management/commands/call_for_membership_fees.py

@@ -43,7 +43,8 @@ class Command(BaseCommand):
 
         members = Member.objects.filter(status='member')\
                                 .annotate(end=Max('membership_fees__end_date'))\
-                                .filter(end__in=end_dates)
+                                .filter(end__in=end_dates)\
+                                .filter(send_membership_fees_email=True)
         if verbosity >= 2:
             self.stdout.write(
                 "Got {number} members.".format(number=members.count()))

+ 46 - 3
coin/members/management/commands/members_email.py

@@ -1,15 +1,58 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
+import datetime
+
 from django.core.management.base import BaseCommand, CommandError
+from django.db.models import Q
 
 from coin.members.models import Member
-
+from coin.offers.models import Offer
+from coin.offers.models import OfferSubscription
 
 class Command(BaseCommand):
-    help = 'Returns the email addresses of all members, in a format suitable for bulk importing in Sympa'
+    help = """Returns email addresses of members in a format suitable for bulk importing in Sympa."""
+
+    def add_arguments(self, parser):
+        parser.add_argument('--subscribers', action='store_true',
+                            help='Return only the email addresses of subscribers to any offers.')
+        parser.add_argument('--offer', metavar='OFFER-ID or OFFER-REF',
+                            help='Return only the email addresses of subscribers to the specified offer')
 
     def handle(self, *args, **options):
-        emails = [m.email for m in Member.objects.filter(status='member')]
+        if options['subscribers']:
+            today = datetime.date.today()
+                        
+            offer_subscriptions = OfferSubscription.objects.filter(
+                Q(resign_date__gt=today)
+                | Q(resign_date__isnull=True)
+            )
+            members = [s.member for s in offer_subscriptions]
+        elif options['offer']:
+            try:
+                # Try to find the offer by its reference
+                offer = Offer.objects.get(reference=options['offer'])
+            except Offer.DoesNotExist:
+                try:
+                    # No reference found, maybe it's an offer_id
+                    offer_id = int(options['offer'])
+                    offer = Offer.objects.get(pk=offer_id)
+                except Offer.DoesNotExist:
+                    raise CommandError('Offer "%s" does not exist' % options['offer'])
+                except (IndexError, ValueError):
+                    raise CommandError('Please enter a valid offer reference or id')
+            today = datetime.date.today()
+
+            offer_subscriptions = OfferSubscription.objects.filter(
+                 # Fetch all OfferSubscription to the given Offer
+                Q(offer=offer)
+                # Check if OfferSubscription isn't resigned
+                & (Q(resign_date__isnull=True) | Q(resign_date__gt=today))
+            ).selec_related('member')
+            members = [s.member for s in offer_subscriptions]
+        else:
+            members = Member.objects.filter(status='member')
+
+        emails = list(set([m.email for m in members if m.status == 'member']))
         for email in emails:
             self.stdout.write(email)

+ 19 - 0
coin/members/migrations/0014_member_send_membership_fees_email.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('members', '0013_auto_20161015_1837'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='member',
+            name='send_membership_fees_email',
+            field=models.BooleanField(default=True, help_text="Certains membres n'ont pas \xe0 recevoir de relance (pr\xe9l\xe8vement automatique, membres d'honneurs, etc.)", verbose_name='relance de cotisation'),
+        ),
+    ]

+ 19 - 0
coin/members/migrations/0015_auto_20170824_2308.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('members', '0014_member_send_membership_fees_email'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='member',
+            name='send_membership_fees_email',
+            field=models.BooleanField(default=True, help_text="Pr\xe9cise si l'utilisateur doit recevoir des mails de relance pour la cotisation. Certains membres n'ont pas \xe0 recevoir de relance (pr\xe9l\xe8vement automatique, membres d'honneurs, etc.)", verbose_name='relance de cotisation'),
+        ),
+    ]

+ 7 - 0
coin/members/models.py

@@ -76,6 +76,10 @@ class Member(CoinLdapSyncMixin, AbstractUser):
                         blank=True,
                         verbose_name="Date du dernier email de relance de cotisation envoyé")
 
+    send_membership_fees_email = models.BooleanField(
+        default=True, verbose_name='relance de cotisation',
+        help_text='Précise si l\'utilisateur doit recevoir des mails de relance pour la cotisation. Certains membres n\'ont pas à recevoir de relance (prélèvement automatique, membres d\'honneurs, etc.)')
+
     # Following fields are managed by the parent class AbstractUser :
     # username, first_name, last_name, email
     # However we hack the model to force theses fields to be required. (see
@@ -276,6 +280,9 @@ class Member(CoinLdapSyncMixin, AbstractUser):
 
         :param auto: is it an auto email? (changes slightly template content)
         """
+        if auto and not self.send_membership_fees_email:
+            return False
+
         from dateutil.relativedelta import relativedelta
         from coin.isp_database.models import ISPInfo
 

+ 18 - 8
coin/members/templates/members/detail.html

@@ -98,14 +98,24 @@
 </div>
 <div class="row">
     <div class="large-12 columns">
-        <p>
-            Pour modifier vos informations personnelles et vos coordonnées, veuillez en faire la demande
-            {% if branding.administrative_email %}
-             par email à <a href="mailto:{{ branding.administrative_email }}">{{ branding.administrative_email }}</a>.
-            {% else %}
-             à l'association.
-            {% endif%}
-        </p>
+        {% if form %}
+            <form method="post" action="">
+                {% csrf_token %}
+                <fieldset class="module aligned wide">
+                {{ form.as_p }}
+                </fieldset>
+                <input type="submit" class="button radius" value="Modifier"/>
+            </form>
+        {% else %}
+            <p>
+                Pour modifier vos informations personnelles et vos coordonnées, veuillez en faire la demande
+                {% if branding.administrative_email %}
+                par email à <a href="mailto:{{ branding.administrative_email }}">{{ branding.administrative_email }}</a>.
+                {% else %}
+                à l'association.
+                {% endif%}
+            </p>
+        {% endif %}
     </div>
 </div>
 

+ 23 - 5
coin/members/views.py

@@ -2,11 +2,11 @@
 from __future__ import unicode_literals
 
 from django.template import RequestContext
-from django.shortcuts import render_to_response
+from django.shortcuts import render_to_response, render
 from django.contrib.auth.decorators import login_required
 from django.http import Http404
 from django.conf import settings
-
+from forms import PersonMemberChangeForm, OrganizationMemberChangeForm
 
 @login_required
 def index(request):
@@ -18,10 +18,28 @@ def index(request):
 
 @login_required
 def detail(request):
+
     membership_info_url = settings.MEMBER_MEMBERSHIP_INFO_URL
-    return render_to_response('members/detail.html',
-                              {'membership_info_url': membership_info_url},
-                              context_instance=RequestContext(request))
+    context={
+        'membership_info_url': membership_info_url,
+    }
+
+    if settings.MEMBER_CAN_EDIT_PROFILE:
+        if request.user.type == "natural_person":
+            form_cls = PersonMemberChangeForm
+        else:
+            form_cls = OrganizationMemberChangeForm
+
+        if request.method == "POST":
+            form = form_cls(data = request.POST, instance = request.user)
+            if form.is_valid():
+                form.save()
+        else:
+            form = form_cls(instance = request.user)
+
+        context['form'] = form
+
+    return render(request, 'members/detail.html', context)
 
 
 @login_required

+ 0 - 19
coin/offers/management/commands/subscribers_email.py

@@ -1,19 +0,0 @@
-# -*- coding: utf-8 -*-
-from __future__ import unicode_literals
-
-import datetime
-
-from django.core.management.base import BaseCommand, CommandError
-from django.db.models import Q
-
-from coin.offers.models import OfferSubscription
-
-
-class Command(BaseCommand):
-    help = 'Returns the email addresses of all subscribers, in a format suitable for bulk importing in Sympa'
-
-    def handle(self, *args, **options):
-        emails = [s.member.email for s in OfferSubscription.objects.filter(Q(resign_date__gt=datetime.date.today) | Q(resign_date__isnull=True))]
-        # Use a set to ensure uniqueness
-        for email in set(emails):
-            self.stdout.write(email)

+ 3 - 0
coin/settings_base.py

@@ -263,3 +263,6 @@ FEEDS = (
     ('ffdn', 'http://www.ffdn.org/fr/rss.xml', 3),
 #    ('isp', 'http://isp.example.com/feed/', 3),
 )
+
+# Member can edit their own data
+MEMBER_CAN_EDIT_PROFILE = False

+ 42 - 2
hardware_provisioning/admin.py

@@ -5,9 +5,11 @@ from __future__ import unicode_literals
 
 from django.contrib import admin
 from django.contrib.auth import get_user_model
+from django.forms import ModelChoiceField
 from django.utils import timezone
 
 from .models import ItemType, Item, Loan, Storage
+from coin.members.admin import MemberAdmin
 
 
 User = get_user_model()
@@ -55,7 +57,7 @@ class AvailabilityFilter(admin.SimpleListFilter):
 class ItemAdmin(admin.ModelAdmin):
     list_display = (
         'designation', 'type', 'mac_address', 'serial', 'owner',
-        'buy_date', 'is_available')
+        'buy_date', 'deployed', 'is_available')
     list_filter = (
         AvailabilityFilter, 'type__name', 'storage',
         'buy_date', OwnerFilter)
@@ -63,6 +65,7 @@ class ItemAdmin(admin.ModelAdmin):
         'designation', 'mac_address', 'serial',
         'owner__email', 'owner__nickname',
         'owner__first_name', 'owner__last_name')
+    save_as = True
     actions = ['give_back']
 
     def give_back(self, request, queryset):
@@ -119,9 +122,15 @@ class BorrowerFilter(admin.SimpleListFilter):
             return queryset
 
 
+class ItemChoiceField(ModelChoiceField):
+    # On surcharge cette méthode pour afficher mac et n° de série dans le menu
+    # déroulant de sélection d'un objet dans la création d'un prêt.
+    def label_from_instance(self, obj):
+        return obj.designation + ' ' + obj.get_mac_and_serial()
+
 @admin.register(Loan)
 class LoanAdmin(admin.ModelAdmin):
-    list_display = ('item', 'user', 'loan_date', 'loan_date_end')
+    list_display = ('item', 'get_mac_and_serial', 'user', 'loan_date', 'loan_date_end')
     list_filter = (StatusFilter, BorrowerFilter, 'item__designation')
     search_fields = (
         'item__designation',
@@ -135,6 +144,14 @@ class LoanAdmin(admin.ModelAdmin):
     end_loan.short_description = 'Mettre fin au prêt'
 
 
+    def formfield_for_foreignkey(self, db_field, request, **kwargs):
+        if db_field.name == 'item':
+            kwargs['queryset'] = Item.objects.all()
+            return ItemChoiceField(**kwargs)
+        else:
+            return super(LoanAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs)
+
+
 @admin.register(Storage)
 class StorageAdmin(admin.ModelAdmin):
     list_display = ('name', 'truncated_notes', 'items_count')
@@ -145,3 +162,26 @@ class StorageAdmin(admin.ModelAdmin):
         else:
             return obj.notes
     truncated_notes.short_description = 'notes'
+
+class LoanInline(admin.TabularInline):
+    model = Loan
+    extra = 0
+    exclude = ('notes',)
+    readonly_fields = ('item', 'get_mac_or_serial', 'loan_date', 'loan_date_end', 'is_running')
+
+    show_change_link = True
+
+    def get_queryset(self, request):
+        qs = super(LoanInline, self).get_queryset(request)
+        return qs.order_by('-loan_date_end')
+
+    def has_add_permission(self, request, obj=None):
+        return False
+
+    def has_delete_permission(self, request, obj=None):
+        return False
+
+# Avoid to add LoanInline twice in case the file is loaded more than
+# once.
+if LoanInline not in MemberAdmin.inlines:
+    MemberAdmin.inlines.append(LoanInline)

+ 1 - 1
hardware_provisioning/app.py

@@ -7,5 +7,5 @@ import coin.apps
 
 class HardwareProvisioningConfig(AppConfig, coin.apps.AppURLs):
     name = 'hardware_provisioning'
-    verbose_name = 'prêt de matériel'
+    verbose_name = 'Prêt de matériel'
     exported_urlpatterns = [('hardware_provisioning', 'hardware_provisioning.urls')]

+ 31 - 0
hardware_provisioning/migrations/0015_auto_20170802_1701.py

@@ -0,0 +1,31 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+from django.conf import settings
+import hardware_provisioning.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('hardware_provisioning', '0014_auto_20170422_1847'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='item',
+            name='deployed',
+            field=models.BooleanField(default=False, help_text='Cocher si le mat\xe9riel est en production', verbose_name='d\xe9ploy\xe9'),
+        ),
+        migrations.AlterField(
+            model_name='item',
+            name='mac_address',
+            field=hardware_provisioning.fields.MACAddressField(null=True, max_length=17, blank=True, help_text='Pr\xe9f\xe9rable au n\xb0 de s\xe9rie si possible', unique=True, verbose_name='addresse MAC'),
+        ),
+        migrations.AlterField(
+            model_name='item',
+            name='owner',
+            field=models.ForeignKey(related_name='items', blank=True, to=settings.AUTH_USER_MODEL, help_text="Dans le cas de mat\xe9riel n'appartenant pas \xe0 l'association", null=True, verbose_name='Propri\xe9taire'),
+        ),
+    ]

+ 35 - 0
hardware_provisioning/migrations/0016_auto_20170802_2021.py

@@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+from django.conf import settings
+import hardware_provisioning.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('hardware_provisioning', '0015_auto_20170802_1701'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='item',
+            name='deployed',
+        ),
+        migrations.AlterField(
+            model_name='item',
+            name='mac_address',
+            field=hardware_provisioning.fields.MACAddressField(null=True, max_length=17, blank=True, help_text='pr\xe9f\xe9rable au n\xb0 de s\xe9rie si possible', unique=True, verbose_name='adresse MAC'),
+        ),
+        migrations.AlterField(
+            model_name='item',
+            name='owner',
+            field=models.ForeignKey(related_name='items', blank=True, to=settings.AUTH_USER_MODEL, help_text="dans le cas de mat\xe9riel n'appartenant pas \xe0 l'association", null=True, verbose_name='Propri\xe9taire'),
+        ),
+        migrations.AlterField(
+            model_name='item',
+            name='serial',
+            field=models.CharField(null=True, max_length=250, blank=True, help_text='ou toute autre r\xe9f\xe9rence unique', unique=True, verbose_name='N\xb0 de s\xe9rie'),
+        ),
+    ]

+ 19 - 0
hardware_provisioning/migrations/0017_item_deployed.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('hardware_provisioning', '0016_auto_20170802_2021'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='item',
+            name='deployed',
+            field=models.BooleanField(default=False, help_text='Cocher si le mat\xe9riel est en production', verbose_name='d\xe9ploy\xe9'),
+        ),
+    ]

+ 25 - 7
hardware_provisioning/models.py

@@ -40,13 +40,13 @@ class Item(models.Model):
         null=True, blank=True,
         help_text='Laisser vide si inconnu')
     mac_address = MACAddressField(
-        verbose_name='addresse MAC',
+        verbose_name='adresse MAC',
         blank=True, null=True, unique=True,
         help_text="préférable au n° de série si possible")
     serial = models.CharField(
         verbose_name='N° de série',
         max_length=250, blank=True, null=True, unique=True,
-        help_text='ou toute autre référence unique)')
+        help_text='ou toute autre référence unique')
     buy_date = models.DateField(verbose_name='date d’achat' , blank=True , null=True)
     owner = models.ForeignKey(
         settings.AUTH_USER_MODEL,
@@ -54,6 +54,8 @@ class Item(models.Model):
         related_name='items',
         null=True, blank=True,
         help_text="dans le cas de matériel n'appartenant pas à l'association")
+    deployed = models.BooleanField(verbose_name='déployé', default=False,
+                                   help_text='Cocher si le matériel est en production')
     comment = models.TextField(verbose_name='commentaire', blank=True,
                                null=True)
 
@@ -81,15 +83,21 @@ class Item(models.Model):
 
     def is_available(self):
         """
-        Returns the status of the Item. If a Loan without an end date exists,
-        returns False (else True).
+        Returns the status of the Item. If a running loan exists,
+        or if the item is deployed, returns False (else True).
         """
-        if self.loans.running().exists():
-            return False
-        return True
+        return (not self.deployed) and (not self.loans.running().exists())
     is_available.boolean = True
     is_available.short_description = 'disponible'
 
+    def get_mac_and_serial(self):
+        mac = self.mac_address
+        serial = self.serial
+        if mac and serial:
+            return "{} / {}".format(mac, serial)
+        else:
+            return mac or serial or ''
+
     class Meta:
         verbose_name = 'objet'
 
@@ -128,9 +136,19 @@ class Loan(models.Model):
         return 'prêt de {item} à {user}'.format(
             item=self.item, user=self.user)
 
+    def get_mac_and_serial(self):
+        return self.item.get_mac_and_serial()
+
+    get_mac_and_serial.short_description = "Adresse MAC / n° de série"
+
     def user_can_close(self, user):
         return (not self.item.is_available()) and (self.user == user)
 
+    def is_running(self):
+        return not self.loan_date_end or self.loan_date_end > timezone.now()
+    is_running.boolean = True
+    is_running.short_description = 'En cours ?'
+
     class Meta:
         verbose_name = 'prêt d’objet'
         verbose_name_plural = 'prêts d’objets'

+ 1 - 1
requirements.txt

@@ -3,7 +3,7 @@ psycopg2==2.5.2
 python-ldap==2.4.15
 wsgiref==0.1.2
 python-dateutil==2.2
-django-autocomplete-light==2.1.1
+django-autocomplete-light>=2.2.10,<2.3
 django-activelink==0.4
 html2text
 django-polymorphic==0.7.2

+ 5 - 5
vpn/admin.py

@@ -54,11 +54,11 @@ class VPNConfigurationAdmin(ConfigurationAdminFormMixin, PolymorphicChildModelAd
 
     def activate(self, request, queryset):
         self.set_activation(request, queryset, True)
-    activate.short_description = "Activate selected VPNs"
+    activate.short_description = "Activer les VPN sélectionnés"
 
     def deactivate(self, request, queryset):
         self.set_activation(request, queryset, False)
-    deactivate.short_description = "Deactivate selected VPNs"
+    deactivate.short_description = "Désactiver les VPN sélectionnés"
 
     def generate_endpoints_generic(self, request, queryset, v4=True, v6=True):
         count = 0
@@ -72,14 +72,14 @@ class VPNConfigurationAdmin(ConfigurationAdminFormMixin, PolymorphicChildModelAd
 
     def generate_endpoints(self, request, queryset):
         self.generate_endpoints_generic(request, queryset)
-    generate_endpoints.short_description = "Generate IPv4 and IPv6 endpoints"
+    generate_endpoints.short_description = "Attribuer des adresses IPv4 et IPv6"
 
     def generate_endpoints_v4(self, request, queryset):
         self.generate_endpoints_generic(request, queryset, v6=False)
-    generate_endpoints_v4.short_description = "Generate IPv4 endpoints"
+    generate_endpoints_v4.short_description = "Attribuer des adresses IPv4"
 
     def generate_endpoints_v6(self, request, queryset):
         self.generate_endpoints_generic(request, queryset, v4=False)
-    generate_endpoints_v6.short_description = "Generate IPv6 endpoints"
+    generate_endpoints_v6.short_description = "Attribuer des adresses IPv6"
 
 admin.site.register(VPNConfiguration, VPNConfigurationAdmin)

+ 23 - 0
vpn/migrations/0002_auto_20170802_2021.py

@@ -0,0 +1,23 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('vpn', '0001_squashed_0002_remove_vpnconfiguration_comment'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='vpnconfiguration',
+            options={'verbose_name': 'VPN', 'verbose_name_plural': 'VPN'},
+        ),
+        migrations.AlterField(
+            model_name='vpnconfiguration',
+            name='login',
+            field=models.CharField(help_text='Laisser vide pour une g\xe9n\xe9ration automatique', unique=True, max_length=50, verbose_name='identifiant', blank=True),
+        ),
+    ]

+ 2 - 1
vpn/models.py

@@ -27,7 +27,7 @@ class VPNConfiguration(CoinLdapSyncMixin, Configuration):
     activated = models.BooleanField(default=False, verbose_name='activé')
     login = models.CharField(max_length=50, unique=True, blank=True,
                              verbose_name="identifiant",
-                             help_text="leave empty for automatic generation")
+                             help_text="Laisser vide pour une génération automatique")
     password = models.CharField(max_length=256, verbose_name="mot de passe",
                                 blank=True, null=True)
     ipv4_endpoint = InetAddressField(validators=[validation.validate_v4],
@@ -152,6 +152,7 @@ class VPNConfiguration(CoinLdapSyncMixin, Configuration):
 
     class Meta:
         verbose_name = 'VPN'
+        verbose_name_plural = 'VPN'
 
 
 class LdapVPNConfig(ldapdb.models.Model):