Browse Source

Implement generic backend handling for offers and subscriptions

With this, administrative subscriptions and configuration backends are
clearly separated.  We use LDAP in the configuration backends, but they
can be implemented with anything else.  For some usages, no configuration
backend is even needed.

Genericity is hard to achieve with SQL.  The implementation here is
somewhat unsatisfying and error-prone: it uses implicit constraints which
are not enforced at the database level.  Fortunately, Django provides the
tools to enforce such constraints in most cases anyway.

This is still pretty raw, and subject to changes.  A first backend is
implemented directly (VPN), but in the future, a mixin class will handle
the details of the relation between configuration backend and
administrative subscription, since it works the same for every backend.
Baptiste Jonglez 11 years ago
parent
commit
ba20e9c92f

+ 6 - 0
coin/offers/admin.py

@@ -18,6 +18,12 @@ class OfferAdmin(admin.ModelAdmin):
     list_display_links = ('name',)
     search_fields = ['name']
 
+    def get_readonly_fields(self, request, obj=None):
+        if obj:
+            return ['backend',]
+        else:
+            return []
+
 
 class OfferSubscriptionAdmin(admin.ModelAdmin):
     list_display = ('member', 'offer', 'subscription_date', 'commitment',

+ 100 - 0
coin/offers/migrations/0006_auto__add_field_offer_backend.py

@@ -0,0 +1,100 @@
+# -*- coding: utf-8 -*-
+from south.utils import datetime_utils as datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+
+class Migration(SchemaMigration):
+
+    def forwards(self, orm):
+        # Adding field 'Offer.backend'
+        db.add_column(u'offers_offer', 'backend',
+                      self.gf('django.db.models.fields.CharField')(default='none', max_length=50),
+                      keep_default=False)
+
+
+    def backwards(self, orm):
+        # Deleting field 'Offer.backend'
+        db.delete_column(u'offers_offer', 'backend')
+
+
+    models = {
+        u'auth.group': {
+            'Meta': {'object_name': 'Group'},
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+            'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+        },
+        u'auth.permission': {
+            'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'},
+            'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+        },
+        u'auth.user': {
+            'Meta': {'object_name': 'User'},
+            'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+            'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Group']"}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+            'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Permission']"}),
+            'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+        },
+        u'contenttypes.contenttype': {
+            'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+            'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+        },
+        u'members.member': {
+            'Meta': {'object_name': 'Member'},
+            'address': ('django.db.models.fields.TextField', [], {}),
+            'city': ('django.db.models.fields.CharField', [], {'max_length': '200'}),
+            'country': ('django.db.models.fields.CharField', [], {'default': "'France'", 'max_length': '200'}),
+            'email': ('django.db.models.fields.EmailField', [], {'max_length': '254'}),
+            'entry_date': ('django.db.models.fields.DateField', [], {'default': 'datetime.date.today'}),
+            'first_name': ('django.db.models.fields.CharField', [], {'max_length': '200'}),
+            'home_phone_number': ('django.db.models.fields.CharField', [], {'max_length': '25', 'blank': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'last_name': ('django.db.models.fields.CharField', [], {'max_length': '200'}),
+            'ldap_cn': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'}),
+            'mobile_phone_number': ('django.db.models.fields.CharField', [], {'max_length': '25', 'blank': 'True'}),
+            'organization_name': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'}),
+            'postal_code': ('django.db.models.fields.CharField', [], {'max_length': '15'}),
+            'resign_date': ('django.db.models.fields.DateField', [], {'null': 'True', 'blank': 'True'}),
+            'status': ('django.db.models.fields.CharField', [], {'default': "'non_adherent'", 'max_length': '50'}),
+            'type': ('django.db.models.fields.CharField', [], {'default': "'personne_physique'", 'max_length': '20'}),
+            'user': ('django.db.models.fields.related.OneToOneField', [], {'default': 'None', 'to': u"orm['auth.User']", 'unique': 'True', 'null': 'True', 'on_delete': 'models.SET_NULL'})
+        },
+        u'offers.offer': {
+            'Meta': {'object_name': 'Offer'},
+            'backend': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
+            'billing_period': ('django.db.models.fields.IntegerField', [], {'default': '1'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'initial_fees': ('django.db.models.fields.DecimalField', [], {'max_digits': '5', 'decimal_places': '2'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'period_fees': ('django.db.models.fields.DecimalField', [], {'max_digits': '5', 'decimal_places': '2'}),
+            'type': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+        },
+        u'offers.offersubscription': {
+            'Meta': {'object_name': 'OfferSubscription'},
+            'commitment': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'member': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['members.Member']"}),
+            'offer': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['offers.Offer']"}),
+            'resign_date': ('django.db.models.fields.DateField', [], {'null': 'True', 'blank': 'True'}),
+            'subscription_date': ('django.db.models.fields.DateField', [], {'default': 'datetime.date.today'})
+        }
+    }
+
+    complete_apps = ['offers']

+ 50 - 9
coin/offers/models.py

@@ -4,13 +4,31 @@ from django.db import models
 
 
 class Offer(models.Model):
-    OFFER_TYPE_CHOICES = (
-        ('vpn', 'VPN'),
-        ('dsl', 'DSL'),
-        ('other', 'Other'),
+    """Description of an offer available to subscribers.
+
+    Implementation notes: achieving genericity is difficult, especially
+    because different technologies may have very different configuration
+    parameters.
+
+    Technology-specific configuration (e.g. for VPN) is implemented as a
+    model having a OneToOne relation to OfferSubscription.  In order to
+    reach the technology-specific configuration model from an
+    OfferSubscription, the OneToOne relation MUST have a related_name
+    equal to one of the backends in OFFER_BACKEND_CHOICES (for instance
+    "openvpn_ldap").
+
+    """
+    OFFER_BACKEND_CHOICES = (
+        ('openvpn_ldap', 'OpenVPN (LDAP)'),
+        # Use this if you don't actually want to implement a backend, for
+        # instance if you resell somebody else's offers and don't manage
+        # technical information yourself.
+        ('none', 'None'),
     )
     name = models.CharField(max_length=255, blank=False, null=False,
                             verbose_name='Nom de l\'offre')
+    type = models.CharField(max_length=50,
+                            verbose_name="Type of offer, for instance technology used (informative only)")
     billing_period = models.IntegerField(blank=False, null=False, default=1,
                                          verbose_name='Période de facturation',
                                          help_text='en mois')
@@ -23,8 +41,10 @@ class Offer(models.Model):
                                       blank=False, null=False,
                                       verbose_name='Frais de mise en service',
                                       help_text='en €')
-    type = models.CharField(max_length=50, choices=OFFER_TYPE_CHOICES)
-    
+    # TODO: really ensure that this field does not change (as it would
+    # seriously break subscriptions)
+    backend = models.CharField(max_length=50, choices=OFFER_BACKEND_CHOICES)
+
     def __unicode__(self):
         return u'%s - %d€ / %im [%s]' % (self.name, self.period_fees,
                                           self.billing_period, self.type)
@@ -35,7 +55,16 @@ class Offer(models.Model):
 
 class OfferSubscription(models.Model):
     """Only contains administrative details about a subscription, not
-    technical.  Nothing here should end up into the LDAP backend."""
+    technical.  Nothing here should end up into the LDAP backend.
+
+    Implementation notes: the model actually implementing the backend
+    (technical configuration for the technology) MUST relate to this class
+    with a OneToOneField whose related name is a member of
+    OFFER_BACKEND_CHOICES, for instance:
+
+      models.OneToOneField('offers.OfferSubscription', related_name="openvpn_ldap")
+
+    """
     subscription_date = models.DateField(
         null=False,
         blank=False,
@@ -54,9 +83,21 @@ class OfferSubscription(models.Model):
     member = models.ForeignKey('members.Member', verbose_name='Membre')
     offer = models.ForeignKey('Offer', verbose_name='Offre')
 
+    @property
+    def configuration(self):
+        """Returns the configuration object associated to this subscription,
+        according to the backend type specified in the offer.  Yes, this
+        is hand-made genericity.  If you can think of a better way, feel
+        free to propose something.
+        """
+        if self.offer.backend == 'none' or not hasattr(self, self.offer.backend):
+            return
+        return getattr(self, self.offer.backend)
+
     def __unicode__(self):
-        return u'%s - %s - %s' % (self.member, self.offer.name,
-                                   self.subscription_date)
+        return u'%s - %s - %s - %s' % (self.member, self.offer.name,
+                                       self.subscription_date,
+                                       self.configuration)
 
     class Meta:
         verbose_name = 'abonnement'

+ 27 - 1
coin/vpn/models.py

@@ -6,6 +6,7 @@ import ldapdb.models
 from ldapdb.models.fields import CharField, ListField
 
 from coin.models import CoinLdapSyncModel
+from coin.offers.models import OfferSubscription
 from coin import utils
 
 
@@ -23,8 +24,33 @@ def str_or_none(obj):
     return str(obj) if obj else None
 
 
+def validate_backend_type(subscription):
+    """Ensures that the subscription we link to has the right backend
+    type. This is a bit ugly, as validators are not meant for
+    database-level sanity checks, but this is the cost of doing genericity
+    the way we do.
+
+    Note that this validator should not be needed for most cases, as the
+    "limit_choices_to" parameter of the "administrative_subscription"
+    OneToOneField automatically limits the available choices on forms.
+    But it does not protect us if we fiddle manually with the database:
+    better safe than sorry.
+
+    """
+    if OfferSubscription.objects.get(pk=subscription).offer.backend != 'openvpn_ldap':
+        raise ValidationError('Administrative subscription must have a "openvpn_ldap" backend.')
+
+
 class VPNSubscription(CoinLdapSyncModel):
-    administrative_subscription = models.OneToOneField('offers.OfferSubscription')
+    administrative_subscription = models.OneToOneField(
+        'offers.OfferSubscription',
+        related_name='openvpn_ldap',
+        limit_choices_to={
+            # Only consider VPN subscription...
+            'offer__backend': 'openvpn_ldap',
+            # ...with no VPNSubscription object associated yet
+            'openvpn_ldap': None},
+        validators=[validate_backend_type])
     # TODO: do some access control to prevent the user from changing this field
     activated = models.BooleanField(default=False)
     login = models.CharField(max_length=50, unique=True)