Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Makes the speakers model pluggable #160

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
50 changes: 47 additions & 3 deletions docs/speakers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@ The ``speaker`` app allows speakers to set up their profile, prior to or as
part of the proposal submission phase. The **dashboard** is the means through
which speakers manage their own profiles.

We are planning to make the Speaker model more pluggable so, if you have
particular fields you'd like your speakers to fill out, you'll be able to
customize things more easily.

Additional Speakers
-------------------
Expand All @@ -20,3 +17,50 @@ and ``invite_token`` field for the invitation sent to the additional speaker
to join.

.. todo:: perhaps explain the invitation flow


Pluggable Speaker Models and Forms
----------------------------------

By default, Symposion uses `DefaultSpeakerModel` and `DefaultSpeakerForm` to
manage speaker profiles. Often you will want to include extra fields in your
speaker model. This was previously not possible without forking Symposion.

If you want to extend the speaker model, you will
need to do the following:

- Create a model that extends `SpeakerBase`
- In your site's `settings.py` file, add a setting for
`SYMPOSION_SPEAKER_MODEL`. This should be the fully qualified location of
your new speaker model, e.g. `pinaxcon.customizations.models.CustomSpeaker`

`DefaultSpeakerForm` is a `ModelForm` based on either the model specified as
`SYMPOSION_SPEAKER_MODEL`, or `DefaultSpeaker` if you don't specify your own
model type. Its default behaviour is to display all of the fields on your
model, except for some fields that are used internally to manage the additional
speaker invitation flow. This should be good enough for most applications.

If you want to customize your form beyond this, you will need to do the
following:

- Create a `ModelForm` that saves your custom speaker model
- In your site's `settings.py` file, add a setting for
`SYMPOSION_SPEAKER_FORM`



Migrating your customized Symposion speaker models
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

It is possible to migrate customised Symposion speaker models. Running the
migrations that introduce pluggable speaker profiles moves `twitter_username`
over to the new `DefaultSpeaker` model.

Generally you'll need to consider the following when making a migration:

- Ensuring that `ProposalBase` and `AdditionalSpeaker` links are maintained
- Any Model that previously depended upon `Speaker` has its links updated

However, if you are maintaining a customised Symposion speaker model, it is
likely that you have already forked Symposion. In this case, there'll be
diminishing returns to migrating over to the new model for your existing apps.
2 changes: 1 addition & 1 deletion symposion/proposals/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

from symposion.markdown_parser import parse
from symposion.conference.models import Section
from symposion.speakers.models import Speaker
from symposion.speakers.models import SpeakerBase as Speaker


@python_2_unicode_compatible
Expand Down
17 changes: 7 additions & 10 deletions symposion/proposals/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,16 @@
ProposalBase, ProposalSection, ProposalKind
)
from symposion.proposals.models import SupportingDocument, AdditionalSpeaker
from symposion.speakers.models import Speaker
from symposion.speakers.models import speaker_model
from symposion.utils.loader import import_named_object
from symposion.utils.mail import send_email

from symposion.proposals.forms import (
AddSpeakerForm, SupportingDocumentCreateForm
)


def get_form(name):
dot = name.rindex(".")
mod_name, form_name = name[:dot], name[dot + 1:]
__import__(mod_name)
return getattr(sys.modules[mod_name], form_name)
SpeakerModel = speaker_model()


def proposal_submit(request):
Expand Down Expand Up @@ -79,7 +76,7 @@ def proposal_submit_kind(request, kind_slug):
if not kind.section.proposalsection.is_available():
return redirect("proposal_submit")

form_class = get_form(settings.PROPOSAL_FORMS[kind_slug])
form_class = import_named_object(settings.PROPOSAL_FORMS[kind_slug])

if request.method == "POST":
form = form_class(request.POST)
Expand Down Expand Up @@ -122,13 +119,13 @@ def create_speaker_token(email_address):
# create token and look for an existing speaker to prevent
# duplicate tokens and confusing the pending speaker
try:
pending = Speaker.objects.get(
pending = SpeakerModel.objects.get(
Q(user=None, invite_email=email_address)
)
except Speaker.DoesNotExist:
except SpeakerModel.DoesNotExist:
salt = hashlib.sha1(str(random.random())).hexdigest()[:5]
token = hashlib.sha1(salt + email_address).hexdigest()
pending = Speaker.objects.create(
pending = SpeakerModel.objects.create(
invite_email=email_address,
invite_token=token,
)
Expand Down
2 changes: 1 addition & 1 deletion symposion/schedule/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from symposion.markdown_parser import parse
from symposion.proposals.models import ProposalBase
from symposion.conference.models import Section
from symposion.speakers.models import Speaker
from symposion.speakers.models import SpeakerBase as Speaker


@python_2_unicode_compatible
Expand Down
6 changes: 3 additions & 3 deletions symposion/speakers/admin.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from __future__ import unicode_literals
from django.contrib import admin

from symposion.speakers.models import Speaker
from symposion.speakers.models import speaker_model


admin.site.register(Speaker,
list_display=["name", "email", "created", "twitter_username"],
admin.site.register(speaker_model(),
list_display=["name", "email", "created"],
search_fields=["name"])
31 changes: 17 additions & 14 deletions symposion/speakers/forms.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
from __future__ import unicode_literals
from django import forms

from symposion.speakers.models import Speaker
from symposion.speakers.models import speaker_model
from symposion.utils.loader import object_from_settings

def speaker_form():
default = "symposion.speakers.forms.DefaultSpeakerForm"
return object_from_settings("SYMPOSION_SPEAKER_FORM", default)

class SpeakerForm(forms.ModelForm):
SpeakerModel = speaker_model()


class DefaultSpeakerForm(forms.ModelForm):

class Meta:
model = Speaker
fields = [
"name",
"biography",
"photo",
"twitter_username",
model = SpeakerModel
exclude = [
"user",
"biography_html",
"annotation",
"invite_email",
"invite_token",
"created",
]

def clean_twitter_username(self):
value = self.cleaned_data["twitter_username"]
if value.startswith("@"):
value = value[1:]
return value
32 changes: 32 additions & 0 deletions symposion/speakers/migrations/0003_auto_20170810_1644.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.2 on 2017-08-10 16:44
from __future__ import unicode_literals

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('symposion_speakers', '0002_speaker_twitter_username'),
]

operations = [
migrations.CreateModel(
name='DefaultSpeaker',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('_twitter_username', models.CharField(blank=True, help_text='Your Twitter account', max_length=15)),
],
),
migrations.RenameModel(
old_name='Speaker',
new_name='SpeakerBase',
),
migrations.AddField(
model_name='defaultspeaker',
name='speakerbase_ptr',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='symposion_speakers.SpeakerBase'),
),
]
28 changes: 28 additions & 0 deletions symposion/speakers/migrations/0005_auto_20170810_1646.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.2 on 2017-08-10 16:46
from __future__ import unicode_literals

from django.db import migrations


def move_speakers_to_subclass(apps, schema_editor):
''' Replaces all of the concrete 'Speaker' objects with 'DefaultSpeaker'
objects. '''

SpeakerBase = apps.get_model('symposion_speakers', 'SpeakerBase')
DefaultSpeaker = apps.get_model('symposion_speakers', 'DefaultSpeaker')

for speaker in SpeakerBase.objects.all():
ds = DefaultSpeaker(_twitter_username=speaker.twitter_username)
ds.speakerbase_ptr = speaker
ds.save()

class Migration(migrations.Migration):

dependencies = [
('symposion_speakers', '0003_auto_20170810_1644'),
]

operations = [
migrations.RunPython(move_speakers_to_subclass),
]
25 changes: 25 additions & 0 deletions symposion/speakers/migrations/0006_auto_20170810_1650.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.2 on 2017-08-10 16:50
from __future__ import unicode_literals

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('symposion_speakers', '0005_auto_20170810_1646'),
]

operations = [
migrations.RemoveField(
model_name='defaultspeaker',
name='id',
),
migrations.AlterField(
model_name='defaultspeaker',
name='speakerbase_ptr',
field=models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='symposion_speakers.SpeakerBase'),
),
]
24 changes: 24 additions & 0 deletions symposion/speakers/migrations/0007_auto_20170810_1651.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.2 on 2017-08-10 16:51
from __future__ import unicode_literals

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('symposion_speakers', '0006_auto_20170810_1650'),
]

operations = [
migrations.RenameField(
model_name='defaultspeaker',
old_name='_twitter_username',
new_name='twitter_username',
),
migrations.RemoveField(
model_name='speakerbase',
name='twitter_username',
),
]
72 changes: 61 additions & 11 deletions symposion/speakers/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,51 @@

from django.contrib.auth.models import User

from model_utils.managers import InheritanceManager

from symposion.markdown_parser import parse
from symposion.utils.loader import object_from_settings


def speaker_model():
default = "symposion.speakers.models.DefaultSpeaker"
return object_from_settings("SYMPOSION_SPEAKER_MODEL", default)


@python_2_unicode_compatible
class Speaker(models.Model):
class SpeakerBase(models.Model):
''' Base class for conference speaker profiles. This model is not meant to
be used directly; it merely contains the default fields that every
conference would want. You should instead subclass this model.
DefaultSpeaker is a minimal subclass that may be useful. '''

objects = InheritanceManager()


def subclass(self):
''' Returns the subclassed version of this model '''

SESSION_COUNT_CHOICES = [
(1, "One"),
(2, "Two")
]
try:
# The cached subclass instance so we don't need to query frequently
return self.__subclass_instance__
except AttributeError:
instance = self.__class__.objects.get_subclass(id=self.id)
if type(instance) == type(self):
instance = self
self.__subclass_instance__ = instance
return instance

def __getattr__(self, attr):
''' Overrides getattr to allow us to return subclass properties
from the base class. '''

try:
return super(SpeakerBase, self).__getattr__(attr)
except AttributeError:
if attr == "__subclass_instance__":
raise
subclass = self.subclass()
return getattr(subclass, attr)

user = models.OneToOneField(User, null=True, related_name="speaker_profile", verbose_name=_("User"))
name = models.CharField(verbose_name=_("Name"), max_length=100,
Expand All @@ -30,11 +65,6 @@ class Speaker(models.Model):
"Markdown</a>."), verbose_name=_("Biography"))
biography_html = models.TextField(blank=True)
photo = models.ImageField(upload_to="speaker_photos", blank=True, verbose_name=_("Photo"))
twitter_username = models.CharField(
max_length=15,
blank=True,
help_text=_(u"Your Twitter account")
)
annotation = models.TextField(verbose_name=_("Annotation")) # staff only
invite_email = models.CharField(max_length=200, unique=True, null=True, db_index=True, verbose_name=_("Invite_email"))
invite_token = models.CharField(max_length=40, db_index=True, verbose_name=_("Invite token"))
Expand All @@ -51,7 +81,7 @@ class Meta:

def save(self, *args, **kwargs):
self.biography_html = parse(self.biography)
return super(Speaker, self).save(*args, **kwargs)
return super(SpeakerBase, self).save(*args, **kwargs)

def __str__(self):
if self.user:
Expand All @@ -78,3 +108,23 @@ def all_presentations(self):
for p in self.copresentations.all():
presentations.append(p)
return presentations


#@python_2_unicode_compatible
class DefaultSpeaker(SpeakerBase):

def clean_twitter_username(self):
value = self.twitter_username
if value.startswith("@"):
value = value[1:]
return value

def save(self, *args, **kwargs):
self.twitter_username = self.clean_twitter_username()
return super(DefaultSpeaker, self).save(*args, **kwargs)

twitter_username = models.CharField(
max_length=15,
blank=True,
help_text=_(u"Your Twitter account")
)
Loading