Browse Source

Merge branch 'master' into news_feed

Conflicts:
	coin/static/css/illyse.css
Fabs 10 years ago
parent
commit
2ee980193d

+ 36 - 0
coin/isp_database/README.md

@@ -0,0 +1,36 @@
+# ISP database application
+
+The goal of this application is to generate a JSON file describing an ISP,
+as defined by http://db.ffdn.org/format
+
+Most information about the ISP can be entered through the admin.  The number
+of members and subscribers is computed automatically: you may want to change
+the way they are computed depending on the way you manage members and
+subscribers.  Additionally, it is possible to change the model so that you
+manage the number of members and subscribers by hand in the admin, though
+this is not recommended.
+
+The JSON file is then accessible at /isp.json
+
+## How to use
+
+First, add `coin.isp_database` to your `INSTALLED_APPS` in settings.py.
+
+Then, add the following in your main `urls.py`:
+
+    from coin.isp_database.views import isp_json
+
+and add
+
+    url(r'^isp.json$', isp_json),
+
+in your `urlpatterns` variable.
+
+## Known issues
+
+Grep for "TODO" in the code.
+
+- the "progress status" field is not user-friendly
+- the model for GPS coordinates is not good (no validation)
+- currently, only one technology is allowed for each covered area
+- describing the geographical shape (polygone) of a covered area is not supported

+ 0 - 0
coin/isp_database/__init__.py


+ 61 - 0
coin/isp_database/admin.py

@@ -0,0 +1,61 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.contrib import admin
+
+from coin.isp_database.models import ISPInfo, RegisteredOffice, OtherWebsite, ChatRoom, CoveredArea
+
+
+class SingleInstanceAdminMixin(object):
+    """Hides the "Add" button when there is already an instance"""
+    def has_add_permission(self, request):
+        num_objects = self.model.objects.count()
+        if num_objects >= 1:
+            return False
+        return super(SingleInstanceAdminMixin, self).has_add_permission(request)
+
+
+class RegisteredOfficeInline(admin.StackedInline):
+    model = RegisteredOffice
+    extra = 0
+    fields = (('street_address', 'extended_address', 'post_office_box'),
+              ('postal_code', 'locality'),
+              ('region', 'country_name'))
+
+
+class OtherWebsiteInline(admin.StackedInline):
+    model = OtherWebsite
+    extra = 0
+
+
+class ChatRoomInline(admin.StackedInline):
+    model = ChatRoom
+    extra = 0
+
+
+class CoveredAreaInline(admin.StackedInline):
+    model = CoveredArea
+    extra = 0
+
+
+class ISPInfoAdmin(SingleInstanceAdminMixin, admin.ModelAdmin):
+    model = ISPInfo
+    fieldsets = (
+        ('General', {'fields': (
+            ('name', 'shortname'),
+            'description',
+            'logoURL',
+            ('creationDate', 'ffdnMemberSince'),
+            'progressStatus',
+            ('latitude', 'longitude'))}),
+        ('Contact', {'fields': (
+            ('email', 'mainMailingList'),
+            'website')}),
+    )
+
+    inlines = (RegisteredOfficeInline, OtherWebsiteInline, ChatRoomInline,
+               CoveredAreaInline)
+    save_on_top = True
+
+
+admin.site.register(ISPInfo, ISPInfoAdmin)

+ 98 - 0
coin/isp_database/migrations/0001_initial.py

@@ -0,0 +1,98 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+import django.core.validators
+import coin.isp_database.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='ChatRoom',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+                ('url', models.CharField(max_length=256, verbose_name='URL')),
+            ],
+            options={
+            },
+            bases=(models.Model,),
+        ),
+        migrations.CreateModel(
+            name='CoveredArea',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+                ('name', models.CharField(max_length=512)),
+                ('technologies', models.CharField(max_length=16, choices=[('ftth', 'FTTH'), ('dsl', '*DSL'), ('wifi', 'WiFi')])),
+            ],
+            options={
+            },
+            bases=(models.Model,),
+        ),
+        migrations.CreateModel(
+            name='ISPInfo',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+                ('name', models.CharField(help_text="The ISP's name", max_length=512)),
+                ('shortname', models.CharField(help_text='Shorter name', max_length=15, blank=True)),
+                ('description', models.TextField(help_text='Short text describing the project', blank=True)),
+                ('logoURL', models.URLField(help_text="HTTP(S) URL of the ISP's logo", verbose_name='logo URL', blank=True)),
+                ('website', models.URLField(help_text='URL to the official website', blank=True)),
+                ('email', models.EmailField(help_text='Contact email address', max_length=254)),
+                ('mainMailingList', models.EmailField(help_text='Main public mailing-list', max_length=254, verbose_name='main mailing list', blank=True)),
+                ('creationDate', models.DateField(help_text='Date of creation for legal structure', null=True, verbose_name='creation date', blank=True)),
+                ('ffdnMemberSince', models.DateField(help_text='Date at wich the ISP joined the Federation', null=True, verbose_name='FFDN member since', blank=True)),
+                ('progressStatus', models.PositiveSmallIntegerField(blank=True, help_text='Progression status of the ISP', null=True, verbose_name='progress status', validators=[django.core.validators.MaxValueValidator(7)])),
+                ('latitude', models.FloatField(help_text='Coordinates of the registered office (latitude)', null=True, blank=True)),
+                ('longitude', models.FloatField(help_text='Coordinates of the registered office (longitude)', null=True, blank=True)),
+            ],
+            options={
+            },
+            bases=(coin.isp_database.models.SingleInstanceMixin, models.Model),
+        ),
+        migrations.CreateModel(
+            name='OtherWebsite',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+                ('name', models.CharField(max_length=512)),
+                ('url', models.URLField(verbose_name='URL')),
+                ('isp', models.ForeignKey(to='isp_database.ISPInfo')),
+            ],
+            options={
+            },
+            bases=(models.Model,),
+        ),
+        migrations.CreateModel(
+            name='RegisteredOffice',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+                ('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)),
+                ('isp', models.OneToOneField(to='isp_database.ISPInfo')),
+            ],
+            options={
+            },
+            bases=(models.Model,),
+        ),
+        migrations.AddField(
+            model_name='coveredarea',
+            name='isp',
+            field=models.ForeignKey(to='isp_database.ISPInfo'),
+            preserve_default=True,
+        ),
+        migrations.AddField(
+            model_name='chatroom',
+            name='isp',
+            field=models.ForeignKey(to='isp_database.ISPInfo'),
+            preserve_default=True,
+        ),
+    ]

+ 0 - 0
coin/isp_database/migrations/__init__.py


+ 173 - 0
coin/isp_database/models.py

@@ -0,0 +1,173 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models
+from django.core.validators import MaxValueValidator
+from django.core.exceptions import ValidationError
+
+from coin.members.models import count_active_members
+from coin.offers.models import count_active_subscriptions
+
+# API version, see http://db.ffdn.org/format
+API_VERSION = "0.1"
+
+TECHNOLOGIES = (('ftth', 'FTTH'),
+                ('dsl', '*DSL'),
+                ('wifi', 'WiFi'))
+
+
+class SingleInstanceMixin(object):
+    """Makes sure that no more than one instance of a given model is created."""
+
+    def clean(self):
+        model = self.__class__
+        if (model.objects.count() > 0 and self.id != model.objects.get().id):
+            raise ValidationError("Can only create 1 instance of %s" % model.__name__)
+        super(SingleInstanceMixin, self).clean()
+
+
+class ISPInfo(SingleInstanceMixin, models.Model):
+    """http://db.ffdn.org/format
+
+    The naming convention is different from Python/django so that it
+    matches exactly the format (which uses CamelCase...)
+    """
+    name = models.CharField(max_length=512,
+                            help_text="The ISP's name")
+    # Length required by the spec
+    shortname = models.CharField(max_length=15, blank=True,
+                                 help_text="Shorter name")
+    description = models.TextField(blank=True,
+                                   help_text="Short text describing the project")
+    logoURL = models.URLField(blank=True,
+                              verbose_name="logo URL",
+                              help_text="HTTP(S) URL of the ISP's logo")
+    website = models.URLField(blank=True,
+                              help_text='URL to the official website')
+    email = models.EmailField(max_length=254,
+                              help_text="Contact email address")
+    mainMailingList = models.EmailField(max_length=254, blank=True,
+                                        verbose_name="main mailing list",
+                                        help_text="Main public mailing-list")
+    creationDate = models.DateField(blank=True, null=True,
+                                    verbose_name="creation date",
+                                     help_text="Date of creation for legal structure")
+    ffdnMemberSince = models.DateField(blank=True, null=True,
+                                       verbose_name="FFDN member since",
+                                       help_text="Date at wich the ISP joined the Federation")
+    # TODO: choice field
+    progressStatus = models.PositiveSmallIntegerField(
+        validators=[MaxValueValidator(7)],
+        blank=True, null=True, verbose_name='progress status',
+        help_text="Progression status of the ISP")
+    # TODO: better model for coordinates
+    latitude = models.FloatField(blank=True, null=True,
+        help_text="Coordinates of the registered office (latitude)")
+    longitude = models.FloatField(blank=True, null=True,
+        help_text="Coordinates of the registered office (longitude)")
+
+    # Uncomment this if you want to manage these counters by hand.
+    #member_count = models.PositiveIntegerField(help_text="Number of members")
+    #subscriber_count = models.PositiveIntegerField(
+    #    help_text="Number of subscribers to an internet access")
+
+    @property
+    def memberCount(self):
+        """Number of members"""
+        return count_active_members()
+
+    @property
+    def subscriberCount(self):
+        """Number of subscribers to an internet access"""
+        return count_active_subscriptions()
+
+    @property
+    def version(self):
+        """Version of the API"""
+        return API_VERSION
+
+    def get_absolute_url(self):
+        return '/isp.json'
+
+    def to_dict(self):
+        data = dict()
+        # These are required
+        for f in ('version', 'name', 'email', 'memberCount', 'subscriberCount'):
+            data[f] = getattr(self, f)
+
+        # These are optional
+        for f in ('shortname', 'description', 'logoURL', 'website',
+                  'mainMailingList', 'progressStatus'):
+            if getattr(self, f):
+                data[f] = getattr(self, f)
+
+        # Dates
+        for d in ('creationDate', 'ffdnMemberSince'):
+            if getattr(self, d):
+                data[d] = getattr(self, d).isoformat()
+
+        # Hackish for now
+        if self.latitude or self.longitude:
+            data['coordinates'] = { "latitude": self.latitude,
+                                    "longitude": self.longitude }
+
+        # Related objects
+        data['coveredAreas'] = [c.to_dict() for c in self.coveredarea_set.all()]
+        otherwebsites = self.otherwebsite_set.all()
+        if otherwebsites:
+            data['otherWebsites'] = { site.name: site.url for site in otherwebsites }
+        chatrooms = self.chatroom_set.all()
+        if chatrooms:
+            data['chatrooms'] = [chatroom.url for chatroom in chatrooms]
+        if hasattr(self, 'registeredoffice'):
+            data['registeredOffice'] = self.registeredoffice.to_dict()
+
+        return data
+
+    def __unicode__(self):
+        return self.name
+
+
+class OtherWebsite(models.Model):
+    name = models.CharField(max_length=512)
+    url = models.URLField(verbose_name="URL")
+    isp = models.ForeignKey(ISPInfo)
+
+
+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)
+    isp = models.OneToOneField(ISPInfo)
+
+    def to_dict(self):
+        d = dict()
+        for field in ('post_office_box', 'extended_address', 'street_address',
+                      'locality', 'region', 'postal_code', 'country_name'):
+            if getattr(self, field):
+                key = field.replace('_', '-')
+                d[key] = getattr(self, field)
+        return d
+
+
+class ChatRoom(models.Model):
+    url = models.CharField(verbose_name="URL", max_length=256)
+    isp = models.ForeignKey(ISPInfo)
+
+
+class CoveredArea(models.Model):
+    name = models.CharField(max_length=512)
+    # TODO: we must allow multiple values
+    technologies = models.CharField(choices=TECHNOLOGIES, max_length=16)
+    # TODO: find a geojson library
+    #area =
+    isp = models.ForeignKey(ISPInfo)
+
+    def to_dict(self):
+        return {"name": self.name,
+                "technologies": [self.technologies]}

+ 3 - 0
coin/isp_database/tests.py

@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.

+ 20 - 0
coin/isp_database/views.py

@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+import json
+
+from django.shortcuts import render
+from django.http import HttpResponse, Http404
+from django.views.decorators.cache import cache_control
+
+from coin.isp_database.models import ISPInfo
+
+
+@cache_control(max_age=7200)
+def isp_json(request):
+    try:
+        isp = ISPInfo.objects.get()
+    except ISPInfo.DoesNotExist:
+        raise Http404
+    data = isp.to_dict()
+    return HttpResponse(json.dumps(data), content_type="application/json")

+ 44 - 25
coin/members/models.py

@@ -131,30 +131,6 @@ class Member(CoinLdapSyncMixin, AbstractUser):
             Q(subscription_date__gt=date) |
             Q(resign_date__lt=date))
 
-    def get_automatic_username(self):
-        """
-        Calcul le username / ldap cn automatiquement en fonction
-        du nom et du prénom
-        """
-
-        # Première lettre de chaque partie du prénom
-        first_name_letters = ''.join(
-            [c[0] for c in self.first_name.split('-')]
-        )
-        # Concaténer avec nom de famille
-        username = ('%s%s' % (first_name_letters, self.last_name))
-        # Remplacer ou enlever les caractères non ascii
-        username = unicodedata.normalize('NFD', username)\
-            .encode('ascii', 'ignore')
-        # Enlever ponctuation et espace
-        punctuation = (string.punctuation + ' ').encode('ascii')
-        username = username.translate(None, punctuation)
-        # En minuscule
-        username = username.lower()
-        # Maximum de 30 char
-        username = username[:30]
-
-        return username
 
     def sync_to_ldap(self, creation, update_fields, *args, **kwargs):
         """
@@ -244,6 +220,48 @@ Member._meta.get_field('last_name').blank = False
 Member._meta.get_field('last_name').null = False
 
 
+def count_active_members():
+    return Member.objects.filter(status='member').count()
+
+def get_automatic_username(first_name, last_name):
+    """
+    Calcul le username automatiquement en fonction
+    du nom et du prénom
+    """
+
+    # Première lettre de chaque partie du prénom
+    first_name_letters = ''.join(
+        [c[0] for c in first_name.split('-')]
+    )
+    # Concaténer avec nom de famille
+    username = ('%s%s' % (first_name_letters, last_name))
+    # Remplacer ou enlever les caractères non ascii
+    username = unicodedata.normalize('NFD', username)\
+        .encode('ascii', 'ignore')
+    # Enlever ponctuation et espace
+    punctuation = (string.punctuation + ' ').encode('ascii')
+    username = username.translate(None, punctuation)
+    # En minuscule
+    username = username.lower()
+    # Maximum de 30 char
+    username = username[:30]
+
+    # Recherche dans les membres existants un username identique
+    member = Member.objects.filter(username=username)
+    base_username = username
+    incr = 2
+    # Tant qu'un membre est trouvé, incrément un entier à la fin
+    while member:
+        if len(base_username) >= 30:
+            username = base_username[30-len(str(incr)):]
+        else:
+            username = base_username
+        username = username + str(incr)
+        member = Member.objects.filter(username=username)
+        incr += 1
+
+    return username
+
 class CryptoKey(models.Model):
 
     KEY_TYPE_CHOICES = (('RSA', 'RSA'), ('GPG', 'GPG'))
@@ -353,7 +371,8 @@ def define_username(sender, instance, **kwargs):
     le calcul automatiquement en fonction du nom et du prénom
     """
     if not instance.username and not instance.pk:
-        instance.username = instance.get_automatic_username()
+        instance.username = get_automatic_username(instance.first_name, 
+                                                   instance.last_name)
 
 
 @receiver(pre_save, sender=LdapUser)

+ 6 - 1
coin/members/templates/members/contact.html

@@ -4,7 +4,12 @@
 <div class="row">
     <div class="large-12 columns">
         <h2>Contact / Support</h2>
-        <div class="panel"><a href="mailto:contact@illyse.org">contact@illyse.org</a></div>
+        <div class="panel">
+            <h3>Courriel</h3>
+            <p><a href="mailto:contact@illyse.org">contact@illyse.org</a></p>
+            <h3>IRC</h3>
+            <p><a href="irc://irc.geeknode.net/illyse">#illyse sur irc.geeknode.net</a></p>
+        </div>
     </div>
 </div>
 {% endblock %}

+ 23 - 0
coin/members/tests.py

@@ -179,6 +179,29 @@ class MemberTests(TestCase):
 
         member.delete()
 
+    def test_when_creating_member_with_username_already_exists_username_is_incr(self):
+        """
+        Lors de la création d'un membre, test si le username existe déjà,
+        renvoi avec un incrément à la fin
+        """
+        random = os.urandom(4).encode('hex')
+
+        member1 = Member(first_name='Hervé', last_name='DUPOND' + random, email='hdupond@coin.org')
+        member1.save()
+        self.assertEqual(member1.username, 'hdupond' + random)
+        
+        member2 = Member(first_name='Henri', last_name='DUPOND' + random, email='hdupond2@coin.org')
+        member2.save()
+        self.assertEqual(member2.username, 'hdupond' + random + '2')
+        
+        member3 = Member(first_name='Hector', last_name='DUPOND' + random, email='hdupond3@coin.org')
+        member3.save()
+        self.assertEqual(member3.username, 'hdupond' + random + '3')
+
+        member1.delete()
+        member2.delete()
+        member3.delete()
+
     def test_when_saving_member_and_ldap_fail_dont_save(self):
         """
         Test que lors de la sauvegarde d'un membre et que la sauvegarde en LDAP

+ 6 - 0
coin/offers/models.py

@@ -4,6 +4,7 @@ from __future__ import unicode_literals
 import datetime
 
 from django.db import models
+from django.db.models import Q
 
 
 class Offer(models.Model):
@@ -89,3 +90,8 @@ class OfferSubscription(models.Model):
     class Meta:
         verbose_name = 'abonnement'
 
+
+def count_active_subscriptions():
+    today = datetime.date.today()
+    query = Q(subscription_date__lte=today) & (Q(resign_date__isnull=True) | Q(resign_date__gte=today))
+    return OfferSubscription.objects.filter(query).count()

+ 8 - 8
coin/resources/models.py

@@ -13,9 +13,9 @@ def validate_subnet(cidr):
     """Checks that a CIDR object is indeed a subnet, i.e. the host bits are
     all set to zero."""
     if not isinstance(cidr, IPNetwork):
-        raise ValidationError("Internal error, expected IPNetwork object")
+        raise ValidationError("Erreur, objet IPNetwork attendu.")
     if cidr.ip != cidr.network:
-        raise ValidationError("{} is not a proper subnet, you probably mean {}".format(cidr, cidr.cidr))
+        raise ValidationError("{} n'est pas un sous-réseau valide, voulez-vous dire {} ?".format(cidr, cidr.cidr))
 
 
 class IPPool(models.Model):
@@ -36,14 +36,14 @@ class IPPool(models.Model):
         if self.inet:
             max_subnetsize = 64 if self.inet.version == 6 else 32
             if not self.inet.prefixlen <= self.default_subnetsize <= max_subnetsize:
-                raise ValidationError('Invalid default subnet size')
+                raise ValidationError('Taille de sous-réseau invalide')
             # Check that related subnet are in the pool (useful when
             # modifying an existing pool that already has subnets
             # allocated in it)
             incorrect = [str(subnet) for subnet in self.ipsubnet_set.all()
                          if not subnet.inet in self.inet]
             if incorrect:
-                err = 'Some subnets allocated in this pool are outside the pool: {}'.format(incorrect)
+                err = "Des sous-réseaux se retrouveraient en-dehors du bloc d'IP: {}".format(incorrect)
                 raise ValidationError(err)
 
     def __unicode__(self):
@@ -90,24 +90,24 @@ class IPSubnet(models.Model):
         try:
             first_free = available.next()
         except StopIteration:
-            raise ValidationError('Unable to allocate an IP subnet in the specified pool: not enough space left.')
+            raise ValidationError("Impossible d'allouer un sous-réseau : bloc d'IP rempli.")
         self.inet = first_free.subnet(self.ip_pool.default_subnetsize, 1).next()
 
     def validate_inclusion(self):
         """Check that we are included in the IP pool"""
         if not self.inet in self.ip_pool.inet:
-            raise ValidationError('Subnet must be included in the IP pool.')
+            raise ValidationError("Le sous-réseau doit être inclus dans le bloc d'IP.")
         # Check that we don't conflict with existing subnets.
         conflicting = self.ip_pool.ipsubnet_set.filter(Q(inet__net_contained_or_equal=self.inet) |
                                                        Q(inet__net_contains_or_equals=self.inet)).exclude(id=self.id)
         if conflicting:
-            raise ValidationError('Subnet must not intersect with existing subnets.\nIntersected subnets: {}.'.format(conflicting))
+            raise ValidationError("Le sous-réseau est en conflit avec des sous-réseaux existants: {}.".format(conflicting))
 
     def validate_reverse_dns(self):
         """Check that reverse DNS entries, if any, are included in the subnet"""
         incorrect = [str(rev.ip) for rev in self.reversednsentry_set.all() if not rev.ip in self.inet]
         if incorrect:
-            raise ValidationError('Some reverse DNS entries are not in the subnet: {}.'.format(incorrect))
+            raise ValidationError("Des entrées DNS inverse ne sont pas dans le sous-réseau: {}.".format(incorrect))
 
     def clean(self):
         if not self.inet:

+ 2 - 1
coin/settings.py

@@ -177,7 +177,8 @@ INSTALLED_APPS = (
     'coin.resources',
     'coin.reverse_dns',
     'coin.configuration',
-    'coin.vpn'
+    'coin.vpn',
+    'coin.isp_database'
 )
 
 # A sample logging configuration. The only tangible logging

+ 25 - 6
coin/static/css/illyse.css

@@ -238,10 +238,11 @@ tr.inactive {
 	color: #F0F0F0;
 	font-size: 0.9em;
 	padding: 0.2em 0.5em;
+	white-space: nowrap;
 }
-.flatfield td+td:before {
-	content: "✎";
-	float: left;
+.flatfield label:before {
+	content: "✎ ";
+	color: #E9E9E9;
 }
 
 .flatfield input {
@@ -249,6 +250,7 @@ tr.inactive {
 	border: 1px solid #E9E9E9;
 	background-color: transparent;
 	text-overflow: ellipsis;
+	padding-left: 1em;
 	box-shadow: none;
 	font-size: 1.1em;
 	color: #222222;
@@ -272,13 +274,30 @@ tr.inactive {
 .feed {
     font-size:80%;
 }
-
 .feed .entry {
     margin-bottom:1em;
-
 }
-
 .feed .entry .date {
     color:gray;
     font-size:80%;
 }
+
+.legend .button, .formcontrol .button {
+	padding: 0.25em 0.5em;
+	border-radius:5px;
+	font-size: 0.9em;
+}
+
+.nogap ul {
+	margin-bottom: 0;
+}
+.nogap ul li {
+	list-style-type: none;
+}
+.errored input {
+	border-color: #FF0000;
+	box-shadow: #FF7777 0 0 5px;
+}
+.formcontrol {
+	text-align: right;
+}

+ 1 - 0
coin/static/js/illyse.js

@@ -1,6 +1,7 @@
 window.onload = function() {
 	var field = document.getElementById("passgen");
 	if (field != undefined) field.onclick = function() {
+		if (!confirm("Ceci va effacer votre ancien mot de passe et en générer un nouveau. Continuer ?")) return false;
 		var cell = field.parentNode;
 		cell.removeChild(field);
 		cell.appendChild(document.createElement("img"));

+ 1 - 1
coin/templates/menu_items.html

@@ -3,7 +3,7 @@
 <li class="{% ifactive 'members:detail' %}active{% endifactive %}"><a href="{% url 'members:detail' %}"><i class="fa fa-user fa-fw"></i> Mes informations</a></li>
 <li class="{% ifactive 'members:subscriptions' %}active{% endifactive %}"><a href="{% url 'members:subscriptions' %}"><i class="fa fa-cog fa-fw"></i> Mes abonnements</a></li>
 <li class="{% ifactive 'members:invoices' %}active{% endifactive %}"><a href="{% url 'members:invoices' %}"><i class="fa fa-eur fa-fw"></i> Mes factures</a></li>
-<li class="{% ifactive '' %}active{% endifactive %}"><a href="/members/contact/"><i class="fa fa-life-ring fa-fw"></i> Contact / Support</a></li>
+<li class="{% ifactive 'members:contact' %}active{% endifactive %}"><a href="{% url 'members:contact' %}"><i class="fa fa-life-ring fa-fw"></i> Contact / Support</a></li>
 <li class="divider"></li>
 {% if user.is_staff %}<li><a href="{% url 'admin:index' %}"><i class="fa fa-cogs fa-fw"></i> Administration</a></li>{% endif %}
 <li class="{% ifactive 'members:password_change' %}active{% endifactive %}"><a href="{% url 'members:password_change' %}"><i class="fa fa-key fa-fw"></i> Modifier mon mot de passe</a></li>

+ 3 - 0
coin/urls.py

@@ -15,10 +15,13 @@ autocomplete_light.autodiscover()
 from django.contrib import admin
 admin.autodiscover()
 
+from coin.isp_database.views import isp_json
+
 urlpatterns = patterns(
     '',
     url(r'^$', 'coin.members.views.index', name='home'),
 
+    url(r'^isp.json$', isp_json),
     url(r'^members/', include('coin.members.urls', namespace='members')),
     url(r'^billing/', include('coin.billing.urls', namespace='billing')),
     url(r'^subscription/', include('coin.offers.urls', namespace='subscription')),

+ 4 - 3
coin/vpn/models.py

@@ -112,18 +112,19 @@ class VPNConfiguration(CoinLdapSyncMixin, Configuration):
         If [delete] is True, then simply delete the faulty endpoints
         instead of raising an exception.
         """
+        error = "L'IP {} n'est pas dans un réseau attribué."
         subnets = self.ip_subnet.all()
         is_faulty = lambda endpoint : endpoint and not any([endpoint in subnet.inet for subnet in subnets])
         if is_faulty(self.ipv4_endpoint):
             if delete:
                 self.ipv4_endpoint = None
             else:
-                raise ValidationError("Endpoint {} is not in an attributed range".format(self.ipv4_endpoint))
+                raise ValidationError(error.format(self.ipv4_endpoint))
         if is_faulty(self.ipv6_endpoint):
             if delete:
                 self.ipv6_endpoint = None
             else:
-                raise ValidationError("Endpoint {} is not in an attributed range".format(self.ipv6_endpoint))
+                raise ValidationError(error.format(self.ipv6_endpoint))
 
     def clean(self):
         # Generate VPN login, of the form "login-vpnX".  The resulting
@@ -141,7 +142,7 @@ class VPNConfiguration(CoinLdapSyncMixin, Configuration):
                     break
             # We may have failed.
             if not self.login:
-                ValidationError("Unable to allocate a VPN login.")
+                ValidationError("Impossible de générer un login VPN")
         # Hash password if needed
         self.password = utils.ldap_hash(self.password)
         # If saving for the first time and IP endpoints are not specified,

+ 10 - 4
coin/vpn/templates/vpn/vpn.html

@@ -6,6 +6,13 @@
     <form class="flatform" action="{{ object.get_absolute_url }}" method="post">{% csrf_token %}
     <p class="legend">Quand vous aurez terminé vos modifications, cliquez sur <input class="button" type="submit" value="Valider" /></p>
     
+    {% if form.non_field_errors or form.ipv4_endpoint.errors or form.ipv6_endpoint.errors %}
+    <div class="alert-box alert nogap">
+      {{ form.non_field_errors }}
+      {{ form.ipv4_endpoint.errors }}
+      {{ form.ipv6_endpoint.errors }}
+    </div>{% endif %}
+    
     <div class="large-6 columns">
         <div class="panel">
             <h3>Statut</h3>
@@ -38,14 +45,13 @@
         <div class="panel">
             <h3>Adresses IP</h3>
             <table class="full-width">
-              {{ form.non_field_errors }}
                 <tr class="flatfield">
                     <td class="center">{{ form.ipv4_endpoint.label_tag }}</td>
-                    <td>{{ form.ipv4_endpoint }} {{ form.ipv4_endpoint.errors }}</td>
+                    <td{% if form.non_field_errors or form.ipv4_endpoint.errors %} class="errored"{% endif %}>{{ form.ipv4_endpoint }}</td>
                 </tr>
                 <tr class="flatfield">
                     <td class="center">{{ form.ipv6_endpoint.label_tag }}</td>
-                    <td>{{ form.ipv6_endpoint }} {{ form.ipv6_endpoint.errors }}</td>
+                    <td{% if form.non_field_errors or form.ipv6_endpoint.errors %} class="errored"{% endif %}>{{ form.ipv6_endpoint }}</td>
                 </tr>
                 <tr>
                     <td class="center"><span class="label">Sous-réseaux</span></td>
@@ -56,7 +62,7 @@
             </table>
         </div>
     </div>
-    <p class="legend">Quand vous aurez terminé vos modifications, cliquez sur <input class="button" type="submit" value="Valider" /></p>
+    <p class="formcontrol"><input class="button" type="submit" value="Valider" /></p>
     </form>
 </div>
 

+ 3 - 1
coin/vpn/views.py

@@ -9,6 +9,7 @@ from django.shortcuts import render_to_response, get_object_or_404
 from django.views.generic.detail import DetailView
 from django.views.generic.edit import UpdateView
 from django.conf import settings
+from django.contrib.messages.views import SuccessMessageMixin
 from django.contrib.auth.decorators import login_required
 from django.utils.decorators import method_decorator
 
@@ -16,9 +17,10 @@ from coin.members.models import Member
 from coin.vpn.models import VPNConfiguration
 
 
-class VPNView(UpdateView):
+class VPNView(SuccessMessageMixin, UpdateView):
     model = VPNConfiguration
     fields = ['ipv4_endpoint', 'ipv6_endpoint', 'comment']
+    success_message = "Configuration enregistrée avec succès !"
 
     @method_decorator(login_required)
     def dispatch(self, *args, **kwargs):