Parcourir la source

[enh] A module to display VPS info to user

ljf il y a 7 ans
Parent
commit
3ca1d4d109

+ 1 - 0
vps/__init__.py

@@ -0,0 +1 @@
+default_app_config = 'vps.apps.VPSConfig'

+ 94 - 0
vps/admin.py

@@ -0,0 +1,94 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.contrib import admin
+from polymorphic.admin import PolymorphicChildModelAdmin
+
+from coin.configuration.admin import ConfigurationAdminFormMixin
+from coin.utils import delete_selected
+
+from .models import VPSConfiguration, FingerPrint, Console
+
+
+class ConsoleInline(admin.TabularInline):
+    model = Console
+    extra = 0
+
+
+class FingerPrintInline(admin.TabularInline):
+    model = FingerPrint
+    extra = 0
+
+
+class VPSConfigurationInline(admin.StackedInline):
+    model = VPSConfiguration
+    # fk_name = 'offersubscription'
+    readonly_fields = ['configuration_ptr']
+
+
+class VPSConfigurationAdmin(ConfigurationAdminFormMixin, PolymorphicChildModelAdmin):
+    base_model = VPSConfiguration
+    list_display = ('offersubscription', 'activated',
+                    'ipv4_endpoint', 'ipv6_endpoint', 'comment')
+    list_filter = ('activated',)
+    search_fields = ('comment',
+                     # TODO: searching on member directly doesn't work
+                     'offersubscription__member__first_name',
+                     'offersubscription__member__last_name',
+                     'offersubscription__member__email')
+    actions = (delete_selected, "generate_endpoints", "generate_endpoints_v4",
+               "generate_endpoints_v6", "activate", "deactivate")
+    inline = VPSConfigurationInline
+
+    def get_readonly_fields(self, request, obj=None):
+        if obj:
+            return []
+        else:
+            return []
+
+    def set_activation(self, request, queryset, value):
+        count = 0
+        # We must update each object individually, because we want to run
+        # the save() method to update the backend.
+        for vps in queryset:
+            if vps.activated != value:
+                vps.activated = value
+                vps.full_clean()
+                vps.save()
+                count += 1
+        action = "activated" if value else "deactivated"
+        msg = "{} VPS subscription(s) {}.".format(count, action)
+        self.message_user(request, msg)
+
+    def activate(self, request, queryset):
+        self.set_activation(request, queryset, True)
+    activate.short_description = "Activate selected VPSs"
+
+    def deactivate(self, request, queryset):
+        self.set_activation(request, queryset, False)
+    deactivate.short_description = "Deactivate selected VPSs"
+
+    def generate_endpoints_generic(self, request, queryset, v4=True, v6=True):
+        count = 0
+        for vps in queryset:
+            if vps.generate_endpoints(v4, v6):
+                vps.full_clean()
+                vps.save()
+                count += 1
+        msg = "{} VPS subscription(s) updated.".format(count)
+        self.message_user(request, msg)
+
+    def generate_endpoints(self, request, queryset):
+        self.generate_endpoints_generic(request, queryset)
+    generate_endpoints.short_description = "Generate IPv4 and IPv6 endpoints"
+
+    def generate_endpoints_v4(self, request, queryset):
+        self.generate_endpoints_generic(request, queryset, v6=False)
+    generate_endpoints_v4.short_description = "Generate IPv4 endpoints"
+
+    def generate_endpoints_v6(self, request, queryset):
+        self.generate_endpoints_generic(request, queryset, v4=False)
+    generate_endpoints_v6.short_description = "Generate IPv6 endpoints"
+
+VPSConfigurationAdmin.inlines = VPSConfigurationAdmin.inlines + (FingerPrintInline, ConsoleInline )
+admin.site.register(VPSConfiguration, VPSConfigurationAdmin)

+ 14 - 0
vps/apps.py

@@ -0,0 +1,14 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.apps import AppConfig
+import coin.apps
+
+from . import urls
+
+
+class VPSConfig(AppConfig, coin.apps.AppURLs):
+    name = 'vps'
+    verbose_name = "Gestion d'accès VPS"
+
+    exported_urlpatterns = [('vps', urls.urlpatterns)]

+ 62 - 0
vps/migrations/0001_initial.py

@@ -0,0 +1,62 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import netfields.fields
+import coin.validation
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('configuration', '0004_auto_20161015_1837'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Console',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+                ('protocol', models.CharField(max_length=256, verbose_name='protocole', choices=[('VNC', 'VNC')])),
+                ('domain', models.CharField(max_length=256, null=True, verbose_name='nom de domaine', blank=True)),
+                ('port', models.IntegerField(null=True, verbose_name='port')),
+                ('password_link', models.URLField(help_text='Lien \xe0 usage unique (d\xe9truit apr\xe8s ouverture)', null=True, verbose_name='Mot de passe', blank=True)),
+            ],
+            options={
+                'verbose_name': 'Console',
+            },
+        ),
+        migrations.CreateModel(
+            name='FingerPrint',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+                ('algo', models.CharField(max_length=256, verbose_name='algo', choices=[('ED25519', 'ED25519'), ('RSA', 'RSA'), ('ECDSA', 'ECDSA')])),
+                ('fingerprint', models.CharField(max_length=256, verbose_name='empreinte')),
+                ('length', models.IntegerField(null=True, verbose_name='longueur de la cl\xe9')),
+                ('polymorphic_ctype', models.ForeignKey(related_name='polymorphic_vps.fingerprint_set+', editable=False, to='contenttypes.ContentType', null=True)),
+            ],
+            options={
+                'verbose_name': 'Empreinte',
+            },
+        ),
+        migrations.CreateModel(
+            name='VPSConfiguration',
+            fields=[
+                ('configuration_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='configuration.Configuration')),
+                ('activated', models.BooleanField(default=False, verbose_name='activ\xe9')),
+                ('ipv4_endpoint', netfields.fields.InetAddressField(validators=[coin.validation.validate_v4], max_length=39, blank=True, help_text='Adresse IPv4 utilis\xe9e par d\xe9faut sur le VPS', null=True, verbose_name='IPv4')),
+                ('ipv6_endpoint', netfields.fields.InetAddressField(validators=[coin.validation.validate_v6], max_length=39, blank=True, help_text='Adresse IPv6 utilis\xe9e par d\xe9faut sur le VPS', null=True, verbose_name='IPv6')),
+                ('console', models.ForeignKey(verbose_name='console', blank=True, to='vps.Console', null=True)),
+            ],
+            options={
+                'verbose_name': 'VPS',
+            },
+            bases=('configuration.configuration',),
+        ),
+        migrations.AddField(
+            model_name='fingerprint',
+            name='vps',
+            field=models.ForeignKey(verbose_name='vps', to='vps.VPSConfiguration'),
+        ),
+    ]

+ 19 - 0
vps/migrations/0002_auto_20170803_0350.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('vps', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='vpsconfiguration',
+            name='console',
+            field=models.OneToOneField(null=True, blank=True, to='vps.Console', verbose_name='console'),
+        ),
+    ]

+ 24 - 0
vps/migrations/0003_auto_20170803_0411.py

@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('vps', '0002_auto_20170803_0350'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='vpsconfiguration',
+            name='console',
+        ),
+        migrations.AddField(
+            model_name='console',
+            name='vps',
+            field=models.OneToOneField(default=1, verbose_name='vps', to='vps.VPSConfiguration'),
+            preserve_default=False,
+        ),
+    ]

+ 0 - 0
vps/migrations/__init__.py


+ 137 - 0
vps/models.py

@@ -0,0 +1,137 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models
+from polymorphic import PolymorphicModel
+from django.core.exceptions import ValidationError
+from django.core.urlresolvers import reverse
+from netfields import InetAddressField, NetManager
+
+from coin.configuration.models import Configuration
+# from coin.offers.backends import ValidateBackendType
+from coin import validation
+
+FINGERPRINT_TYPES = (
+    ('ED25519', 'ED25519'),
+    ('RSA', 'RSA'),
+    ('ECDSA', 'ECDSA')
+)
+
+PROTOCOLE_TYPES = (
+    ('VNC', 'VNC'),
+)
+
+
+class VPSConfiguration(Configuration):
+    url_namespace = "vps"
+    activated = models.BooleanField(default=False, verbose_name='activé')
+    ipv4_endpoint = InetAddressField(validators=[validation.validate_v4],
+                                     verbose_name="IPv4", blank=True, null=True,
+                                     help_text="Adresse IPv4 utilisée par "
+                                     "défaut sur le VPS")
+    ipv6_endpoint = InetAddressField(validators=[validation.validate_v6],
+                                     verbose_name="IPv6", blank=True, null=True,
+                                     help_text="Adresse IPv6 utilisée par "
+                                     "défaut sur le VPS")
+    objects = NetManager()
+
+    def get_absolute_url(self):
+        return reverse('vps:details', args=[str(self.pk)])
+
+    # This method is part of the general configuration interface.
+    def subnet_event(self):
+        self.check_endpoints(delete=True)
+        # We potentially changed the endpoints, so we need to save.  Also,
+        # saving will update the subnets in the LDAP backend.
+        self.full_clean()
+        self.save()
+
+    def get_subnets(self, version):
+        subnets = self.ip_subnet.all()
+        return [subnet for subnet in subnets if subnet.inet.version == version]
+
+    def generate_endpoints(self, v4=True, v6=True):
+        """Generate IP endpoints in one of the attributed IP subnets.  If there is
+        no available subnet for a given address family, then no endpoint
+        is generated for this address family.  If there already is an
+        endpoint, do nothing.
+
+        Returns True if an endpoint was generated.
+
+        TODO: this should be factored for other technologies (DSL, etc)
+
+        """
+        subnets = self.ip_subnet.all()
+        updated = False
+        if v4 and self.ipv4_endpoint is None:
+            subnets_v4 = [s for s in subnets if s.inet.version == 4]
+            if len(subnets_v4) > 0:
+                self.ipv4_endpoint = subnets_v4[0].inet.ip
+                updated = True
+        if v6 and self.ipv6_endpoint is None:
+            subnets_v6 = [s for s in subnets if s.inet.version == 6]
+            if len(subnets_v6) > 0:
+                # With v6, we choose the second host of the subnet (cafe::1)
+                gen = subnets_v6[0].inet.iter_hosts()
+                gen.next()
+                self.ipv6_endpoint = gen.next()
+                updated = True
+        return updated
+
+    def check_endpoints(self, delete=False):
+        """Check that the IP endpoints are included in one of the attributed IP
+        subnets.
+
+        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(error.format(self.ipv4_endpoint))
+        if is_faulty(self.ipv6_endpoint):
+            if delete:
+                self.ipv6_endpoint = None
+            else:
+                raise ValidationError(error.format(self.ipv6_endpoint))
+
+    def clean(self):
+        # If saving for the first time and IP endpoints are not specified,
+        # generate them automatically.
+        if self.pk is None:
+            self.generate_endpoints()
+        self.check_endpoints()
+
+    def __unicode__(self):
+        return 'VPS ' #+ self.login
+
+    class Meta:
+        verbose_name = 'VPS'
+
+class FingerPrint(PolymorphicModel):
+    vps = models.ForeignKey(VPSConfiguration, verbose_name="vps")
+    algo = models.CharField(max_length=256, verbose_name="algo",
+                            choices=FINGERPRINT_TYPES)
+    fingerprint = models.CharField(max_length=256, verbose_name="empreinte")
+    length = models.IntegerField(verbose_name="longueur de la clé", null=True)
+
+    class Meta:
+        verbose_name = 'Empreinte'
+
+
+class Console(models.Model):
+    vps = models.OneToOneField(VPSConfiguration, verbose_name="vps")
+    protocol = models.CharField(max_length=256, verbose_name="protocole",
+                            choices=PROTOCOLE_TYPES)
+    domain = models.CharField(max_length=256, verbose_name="nom de domaine",
+                                blank=True, null=True)
+    port = models.IntegerField(verbose_name="port", null=True)
+    password_link = models.URLField(verbose_name="Mot de passe", blank=True,
+                           null=True, help_text="Lien à usage unique (détruit après ouverture)")
+    class Meta:
+        verbose_name = 'Console'
+

+ 8 - 0
vps/templates/vps/fragments/password.html

@@ -0,0 +1,8 @@
+<tr id="password">
+    <td class="center"><span class="label">Mot de passe</span></td>
+    <td><span class="pass">{{ password }}</span></td>
+</tr>
+<tr>
+    <td class="warning" colspan="2">Ce mot de passe ne sera affiché qu'une seule fois. Si vous le perdez, il faudra en générer un nouveau.</td>
+</tr>
+

+ 147 - 0
vps/templates/vps/vps.html

@@ -0,0 +1,147 @@
+{% extends "base.html" %}
+
+{% load subnets %}
+
+{% block content %}
+<div class="row">
+    <h2>Configuration du VPS</h2>
+    {% if form %}
+        <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>
+    {% endif %}
+    {% for message in messages %}
+        <div class="message eat-up{% if message.tags %} {{ message.tags }}{% endif %}">
+            {{ message }}
+        </div>
+    {% endfor %}
+
+    {% if form %}
+        {% 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 %}
+    {% endif %}
+    <div class="large-12 columns">
+        <div class="panel">
+            <h3>Authentification</h3>
+            <table class="full-width">
+                {% if object.password %}
+                <tr>
+                    <td class="center" colspan="2">
+                        <a class="button tiny radius" id="passgen" href="{% url 'vps:generate_password' object.pk %}"><i class="fa fa-refresh"></i>
+ Générer un nouveau mot de passe</a>
+                    </td>
+                </tr>
+                {% endif %}
+                {% if object.crypto_link %}
+                <tr>
+                    <td class="center"><span class="label">Matériel cryptographique</span></td>
+                    <td><a href="{{object.crypto_link}}">Télecharger (lien supprimé après ouverture)</a></td>
+                </tr>
+                {% endif %}
+                {% if object.fingerprint_set.all %}
+                <tr>
+                    <td class="center"><span class="label">Empreinte(s)</span></td>
+                    <td>{% for fingerprint in object.fingerprint_set.all %}
+                        {{ fingerprint.length }} {{ fingerprint.fingerprint }} ({{ fingerprint.algo }})
+                        {% endfor %}</td>
+                </tr>
+                {% endif %}
+                {% if object.console %}
+                <tr>
+                    <td class="center"><span class="label">Console {{object.console.protocol }}</span></td>
+                    <td>
+                        {{ object.console.domain }}:{{ object.console.port }}<br />
+
+                    <a href="{{object.console.password_link}}">Voir le mot de passe (lien supprimé après ouverture)</a>
+                    </td>
+                </tr>
+                {% endif %}
+                <tr class="flatfield">
+                    {% if form %}
+                        <td class="center">{{ form.comment.label_tag }}</td>
+                        <td>{{ form.comment }}</td>
+                    {% else %}
+                    <td class="center"><span class="label">Commentaire</span></td>
+                        <td>{{ object.comment }}</td>
+                        
+                    {% endif %}
+                </tr>
+                <tr>
+                    <td class="center boolviewer" colspan="2">
+                    {% if form %}
+                        <input type="checkbox" disabled="disabled"{% if object.activated %} checked="checked"{% endif %} />
+                    {% endif %}
+                        <span>Ce VPS est {{ object.activated|yesno:"activé,désactivé" }}</span>
+                    </td>
+                </tr>
+            </table>
+        </div>
+    </div>
+
+    <div class="large-12 columns">
+        <div class="panel">
+            <h3>Adresses IP</h3>
+            <table class="full-width">
+                {% if form %}
+                    <tr class="flatfield">
+                        <td class="center">{{ form.ipv4_endpoint.label_tag }}</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{% if form.non_field_errors or form.ipv6_endpoint.errors %} class="errored"{% endif %}>{{ form.ipv6_endpoint }}</td>
+                    </tr>
+                {% else %}
+                    <tr class="flatfield">
+                        <td class="center"><span class="label">IPv4</span></td>
+                        <td>{{ object.ipv4_endpoint }}</td>
+                    </tr>
+                    <tr class="flatfield">
+                        <td class="center"><span class="label">IPv6</span></td>
+                        <td>{{ object.ipv6_endpoint }}</td>
+                    </tr>
+                {% endif %}
+                <tr>
+                    <td class="center"><span class="label">Sous-réseaux</span></td>
+                    <td>
+                        {% for subnet in object.ip_subnet.all %}{{ subnet|prettify }}<br/>{% endfor %}
+                    </td>
+                </tr>
+            </table>
+        </div>
+    </div>
+    {% if form %}
+    <p class="formcontrol"><input class="button" type="submit" value="Valider" /></p>
+    </form>
+    {% endif %}
+</div>
+
+{% endblock %}
+
+{% block extra_js %}
+    <script>
+        // Bouton génération du mot de passe
+        $('#passgen').click(function(){
+            if (!confirm("Ceci va effacer votre ancien mot de passe et en générer un nouveau. Continuer ?")) return false;
+
+            parent_cell = $(this).parent();
+            parent_cell.html('<span class="pending_request"><i class="fa fa-refresh fa-spin"></i> Génération en cours</span>');
+
+            $.ajax({
+                'url': $(this).attr('href')
+            }).done(function(html) {
+                //Remplace le tr parent par le contenu renvoyé (qui est deux tr successifs)
+                parent_cell.parent().replaceWith(html);
+            }).fail(function( jqXHR, textStatus ) {
+                parent_cell.html('<span class="error">Échec de la requête : ' + textStatus + '</span>');
+            });
+
+            return false;
+        });
+
+    </script>
+{% endblock extra_js %}

+ 116 - 0
vps/tests.py

@@ -0,0 +1,116 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from unittest import skipUnless
+
+from django.test import TestCase
+from django.conf import settings
+
+from coin.offers.models import Offer, OfferSubscription
+from coin.resources.models import IPPool, IPSubnet
+from coin.members.models import Member
+from coin.members.tests import MemberTestsUtils
+
+from .models import VPSConfiguration
+
+USING_POSTGRES = (settings.DATABASES['default']['ENGINE']
+                  ==
+                  'django.db.backends.postgresql_psycopg2')
+
+class VPSTestCase(TestCase):
+    fixtures = ['example_pools.json', 'offers.json']
+
+    def setUp(self):
+        self.v6_pool = IPPool.objects.get(default_subnetsize=56)
+        self.v4_pool = IPPool.objects.get(default_subnetsize=32)
+        self.offer = Offer.objects.filter(configuration_type="VPSConfiguration")[0]
+
+        # Create a member.
+        cn = MemberTestsUtils.get_random_username()
+        self.member = Member.objects.create(first_name=u"Toto",
+                                            last_name=u"L'artichaut",
+                                            username=cn)
+
+        # Create a new VPS with subnets.
+        # We need Django to call clean() so that magic happens.
+        abo = OfferSubscription(offer=self.offer, member=self.member)
+        abo.full_clean()
+        abo.save()
+        vps = VPSConfiguration(offersubscription=abo)
+        vps.full_clean()
+        vps.save()
+        v6 = IPSubnet(ip_pool=self.v6_pool, configuration=vps)
+        v6.full_clean()
+        v6.save()
+        v4 = IPSubnet(ip_pool=self.v4_pool, configuration=vps)
+        v4.full_clean()
+        v4.save()
+
+        # Create additional VPS, they should automatically be attributed a
+        # new login.
+        for i in range(5):
+            abo = OfferSubscription(offer=self.offer, member=self.member)
+            abo.full_clean()
+            abo.save()
+            vps = VPSConfiguration(offersubscription=abo)
+            vps.full_clean()
+            vps.save()
+
+    def tearDown(self):
+        """Properly clean up objects, so that they don't stay in LDAP"""
+        for vps in VPSConfiguration.objects.all():
+            vps.delete()
+        Member.objects.get().delete()
+
+    @skipUnless(USING_POSTGRES, "Using a postgresql-only field")
+    def test_has_ipv4_endpoint(self):
+        vps = VPSConfiguration.objects.all()[0]
+        self.assertIsNotNone(vps.ipv4_endpoint)
+
+    @skipUnless(USING_POSTGRES, "Using a postgresql-only field")
+    def test_has_correct_ipv4_endpoint(self):
+        """If there is not endpoint, we consider it to be correct."""
+        vps = VPSConfiguration.objects.all()[0]
+        if vps.ipv4_endpoint is not None:
+            subnet = vps.ip_subnet.get(ip_pool=self.v4_pool)
+            self.assertIn(vps.ipv4_endpoint, subnet.inet)
+
+    @skipUnless(USING_POSTGRES, "Using a postgresql-only field")
+    def test_has_ipv6_endpoint(self):
+        vps = VPSConfiguration.objects.all()[0]
+        self.assertIsNotNone(vps.ipv6_endpoint)
+
+    @skipUnless(USING_POSTGRES, "Using a postgresql-only field")
+    def test_has_correct_ipv6_endpoint(self):
+        """If there is not endpoint, we consider it to be correct."""
+        vps = VPSConfiguration.objects.all()[0]
+        if vps.ipv6_endpoint is not None:
+            subnet = vps.ip_subnet.get(ip_pool=self.v6_pool)
+            self.assertIn(vps.ipv6_endpoint, subnet.inet)
+
+    @skipUnless(USING_POSTGRES, "Using a postgresql-only field")
+    def test_change_v4subnet_is_vps_endpoint_correct(self):
+        vps = VPSConfiguration.objects.all()[0]
+        subnet = vps.ip_subnet.get(ip_pool=self.v4_pool)
+        subnet.inet = "192.168.42.42/31"
+        subnet.full_clean()
+        subnet.save()
+        self.test_has_correct_ipv4_endpoint()
+
+    @skipUnless(USING_POSTGRES, "Using a postgresql-only field")
+    def test_change_v6subnet_is_vps_endpoint_correct(self):
+        vps = VPSConfiguration.objects.all()[0]
+        subnet = vps.ip_subnet.get(ip_pool=self.v6_pool)
+        subnet.inet = "2001:db8:4242:4200::/56"
+        subnet.full_clean()
+        subnet.save()
+        self.test_has_correct_ipv6_endpoint()
+
+    def test_automatic_login(self):
+        vps = VPSConfiguration.objects.all()[0]
+        expected_login = vps.offersubscription.member.username + "-vps1"
+        self.assertEqual(vps.login, expected_login)
+
+    def test_has_multiple_vps(self):
+        vpss = VPSConfiguration.objects.all()
+        self.assertEqual(len(vpss), 6)

+ 13 - 0
vps/urls.py

@@ -0,0 +1,13 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.conf.urls import patterns, url
+
+from .views import VPSView
+
+urlpatterns = patterns(
+    '',
+    # This is part of the generic configuration interface (the "name" is
+    # the same as the "backend_name" of the model).
+    url(r'^(?P<pk>\d+)$', VPSView.as_view(template_name="vps/vps.html"), name="details"),
+)

+ 36 - 0
vps/views.py

@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.shortcuts import get_object_or_404
+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
+
+from coin.members.models import Member
+
+from .models import VPSConfiguration
+
+
+class VPSView(SuccessMessageMixin, UpdateView):
+    model = VPSConfiguration
+    fields = ['ipv4_endpoint', 'ipv6_endpoint', 'comment']
+    success_message = "Configuration enregistrée avec succès !"
+
+    @method_decorator(login_required)
+    def dispatch(self, *args, **kwargs):
+        return super(VPSView, self).dispatch(*args, **kwargs)
+
+    def get_form(self, form_class=None):
+        if settings.MEMBER_CAN_EDIT_VPS_CONF:
+            return super(VPSView, self).get_form(form_class)
+        return None
+
+    def get_object(self):
+        if self.request.user.is_superuser:
+            return get_object_or_404(VPSConfiguration, pk=self.kwargs.get("pk"))
+        # For normal users, ensure the VPS belongs to them.
+        return get_object_or_404(VPSConfiguration, pk=self.kwargs.get("pk"),
+                                 offersubscription__member=self.request.user)
+