Design a representation of a mage character from the World of Darkness RPG, as well their associated spells.

Here is a visual representation of the schema. You can see it more closely on LucidChart if you like.

Lucid schema

You can also see my draft here and on YUML

I have already made an attempt at laying out the models, but my main concern is that I'm either:

a) over complicating the above design;
b) implementing what I want incorrectly;
c) both.

The contents of my models.py, and mages models.py are below:


from django.db import models
from nwod_characters.util import IntegerRangeField
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType

# Create your models here.
class NWODCharacter(models.Model):
    class Meta:
        abstract = True

    name = models.CharField(max_length=200)
    player = models.ForeignKey('auth.User')
    created_date = models.DateTimeField(auto_now_add=True, auto_now=False)
    updated_date = models.DateTimeField(auto_now_add=False, auto_now=True)
    published_date = models.DateTimeField(blank=True, null=True)
    sub_race = models.CharField(choices=SUB_RACE_CHOICES, max_length=50)
    faction = models.CharField(choices=FACTION_CHOICES, max_length=50, null=True)

class Characteristics(models.Model):
    class Meta:
        abstract = True
    VIRTUE_CHOICES = (('prudence', 'Prudence'), ('justice', 'Justice'),
     ('temperance', 'Temperance'), ('fortitude', 'Fortitude'), ('faith', 'Faith'), 
     ('hope', 'Hope'), ('charity', 'Charity'))
    VICE_CHOICES = (('lust', 'Lust'), ('gluttony', 'Gluttony'), ('greed', 'Greed'),
     ('sloth', 'Sloth'), ('wrath', 'Wrath'), ('envy', 'Envy'), ('pride', 'Pride'))

    power_level = IntegerRangeField(min_value=1, max_value=10)
    energy_trait = IntegerRangeField(min_value=1, max_value=10)
    virtue = models.CharField(choices=VIRTUE_CHOICES, max_length=50)
    vice = models.CharField(choices=VICE_CHOICES, max_length=50)
    morality = IntegerRangeField(min_value=0, max_value=10)
    size = IntegerRangeField(min_value=1, max_value=10, default=5)

def resistance_attributes():
    res = [ATTRIBUTE_CHOICES[i][-1][-1] for i in range(len(ATTRIBUTE_CHOICES))]
    return res

class Trait(models.Model):
    MIN = 0
    MAX = 5
    current_value = IntegerRangeField(min_value=MIN, max_value=MAX)
    maximum_value = IntegerRangeField(min_value=MIN, max_value=MAX)
    class Meta:
        abstract = True

class BookReference(models.Model):
    things_in_books = models.Q(app_label='mage', model='spell') | models.Q(app_label='characters', model='merit')
    content_type = models.ForeignKey(ContentType, limit_choices_to=things_in_books,
        null=True, blank=True)
    object_id = models.PositiveIntegerField(null=True)
    content_object = GenericForeignKey('content_type', 'object_id')    
    book_name = models.CharField(max_length=50)
    book_page = models.PositiveSmallIntegerField(default=0)

class Merit(Trait, models.Model):
    name = models.CharField(max_length=50)
    book_ref = models.ForeignKey('BookReference')

class Skill(models.Model):
    name = models.CharField(max_length=50, choices=SKILL_CHOICES)

class Attribute(models.Model):
    name = models.CharField(max_length=50, choices=ATTRIBUTE_CHOICES)

class CrossCharacterMixin(models.Model):
    cross_character_types = models.Q(app_label='mage', model='mage')
    content_type = models.ForeignKey(ContentType, limit_choices_to=cross_character_types,
        null=True, blank=True)
    object_id = models.PositiveIntegerField(null=True)
    content_object = GenericForeignKey('content_type', 'object_id')
    class Meta:
        abstract = True

class SkillLink(models.Model):
    skill = models.ForeignKey('Skill', choices=SKILL_CHOICES)
    class Meta:
        abstract = True

class AttributeLink(models.Model):
    attribute = models.ForeignKey('Attribute', choices=ATTRIBUTE_CHOICES)
    class Meta:
        abstract = True  

class CharacterSkillLink(SkillLink, Trait, CrossCharacterMixin):
        (1, 'Primary'), (2, 'Secondary'), (3, 'Tertiary')
    priority = models.PositiveSmallIntegerField(choices=PRIORITY_CHOICES, default=None)
    speciality = models.CharField(max_length=200)

class CharacterAttributeLink(AttributeLink, Trait, CrossCharacterMixin):
    MIN = 1
        (1, 'Primary'), (2, 'Secondary'), (3, 'Tertiary')
    priority = models.PositiveSmallIntegerField(choices=PRIORITY_CHOICES, default=None)

from django.db import models
from characters.models import NWODCharacter, Characteristics, Trait, AttributeLink, SkillLink
from characters.choices import ATTRIBUTE_CHOICES, SKILL_CHOICES
from nwod_characters.util import modify_verbose, IntegerRangeField
from django.contrib.contenttypes.fields import GenericRelation

@modify_verbose({'power_level': 'Gnosis',
                'energy_trait': 'Mana',
                'faction': 'Order',
                'sub_race': 'Path',
                'morality': 'Wisdom',
class Mage(NWODCharacter, Characteristics):
        ('AC', 'Acanthus'),
        ('Ma', 'Mastigos'),
        ('Mo', 'Moros'),
        ('Ob', 'Obrimos'),
        ('Th', 'Thyrsus'),
        ('AA', 'The Adamantine Arrow'),
        ('GotV', 'Guardians of the Veil'),
        ('Myst', 'The Mysterium'),
        ('SL', 'The Silver Ladder'),
        ('FC', 'The Free Council')

    def __str__(self):
        return self.name

        (None, '----'), ('Fate', 'Fate'), ('Mind', 'Mind'), ('Spirit', 'Spirit'), ('Death', 'Death'),
         ('Forces', 'Forces'), ('Time', 'Time'), ('Space', 'Space'), ('Life', 'Life'), ('Matter', 'Matter'),
         ('Prime', 'Prime')

class Spell(models.Model):
    name = models.CharField(max_length=50)
    vulgar = models.BooleanField(default=False)
    # All spells have a primary arcana, and 0-Many secondary arcana.
    # Each spell's arcanum have different rating. E.g. Fate 1, Prime 1
    #non-optional arcana in addition to the main arcana
    # arcana that are not needed to cast the spell
    arcana = models.ManyToManyField('Arcana', choices=ARCANUM_CHOICES, through='SpellArcanumLink', 
    def primary_arcana(self):
        return self.arcana.filter(type="primary")
    def secondary_arcana(self):
        return self.arcana.filter(type="secondary")
    def optional_arcana(self):
        return self.arcana.filter(type="optional")

    # All spells have a 'Attribute+Skill+Primary arcana' pool for casting
    skill = models.ManyToManyField('Skill', choices=SKILL_CHOICES, related_name='spell_by_skill',
    attribute = models.ManyToManyField('Attribute', choices=ATTRIBUTE_CHOICES, 
        related_name='spell_by_attribute', through='SpellAttributeLink')
    def rote_skill(self):
        return self.skill.filter(type="rote") if self.contested else None
    def rote_attribute(self):
        return self.attribute.filter(type="rote") if self.contested else None
    # Mages can own spells
    mage = models.ManyToManyField('Mage', related_name='spell_by_mage', through='SpellMageLink')
    # Optional contested skill check, e.g. 'Attribute+Skill+Primary arcana vs Attribute+Skill'
    contested = models.BooleanField(default=False)
    def contested_attribute(self):
        return self.attribute.filter(type="contested") if self.contested else None
    def contested_skill(self):
        return self.skill.filter(type="contested") if self.contested else None
    # Optional attribute to subtract from casting dicepool, 
    # e.g. 'Attribute+Skill+Primary arcana-Resist Attribute'
    resisted = models.BooleanField(default=False)
    def resisted_attribute(self):
        return self.attribute.filter(type="resisted") if self.resisted else None
    # Spells come from books
    book_ref = GenericRelation('BookReference', null=True, blank=True)

class ArcanaLink(models.Model):
    arcana = models.ForeignKey('Arcana', choices=ARCANUM_CHOICES)
    class Meta:
        abstract = True

class Arcana(models.Model):
    name = models.CharField(max_length=50, choices=ARCANUM_CHOICES)

class CharacterArcanumLink(ArcanaLink, Trait):
        (1, 'Ruling'), (2, 'Common'), (3, 'Inferior')
    priority = models.PositiveSmallIntegerField(choices=PRIORITY_CHOICES, default=None)
    mage = models.ForeignKey('Mage')

class SpellLink(models.Model):
    spell = models.ForeignKey('Spell')
    class Meta:
        abstract = True

class SpellArcanumLink(ArcanaLink, SpellLink):
    type = models.CharField(max_length=32,
        choices=(('primary', 'primary'), ('secondary', 'secondary'), ('optional', 'optional'))
    value = IntegerRangeField(min_value=1, max_value=10)

class SpellAttributeLink(SpellLink, AttributeLink):
    type = models.CharField(max_length=32, 
        choices=(('resisted', 'resisted'), ('contested', 'contested'), ('rote', 'rote'))

class SpellSkillLink(SpellLink, SkillLink):
    type = models.CharField(max_length=32,
        choices=(('contested', 'contested'), ('rote', 'rote')), default='Rote'

class SpellMageLink(SpellLink):
    mage = models.ForeignKey('Mage') 

Below are the ancillary files (utils, and choices):

    ('Mental', (
            ('Academics', 'Academics'),
            ('Computer', 'Computer'),
            ('Crafts', 'Crafts'),
            ('Investigation', 'Investigation'),
            ('Medicine', 'Medicine'),
            ('Occult', 'Occult'),
            ('Politics', 'Politics'),
            ('Science', 'Science'),
    ('Physical', (
            ('Athletics', 'Athletics'),
            ('Brawl', 'Brawl'),
            ('Drive', 'Drive'),
            ('Firearms', 'Firearms'),
            ('Larceny', 'Larceny'),
            ('Stealth', 'Stealth'),
            ('Survival', 'Survival'),
            ('Weaponry', 'Weaponry'),
    ('Social', (
            ('Animal Ken', 'Animal Ken'),
            ('Empathy', 'Empathy'),
            ('Expression', 'Expression'),
            ('Intimidation', 'Intimidation'),
            ('Persuasion', 'Persuasion'),
            ('Socialize', 'Socialize'),
            ('Streetwise', 'Streetwise'),
            ('Subterfuge', 'Subterfuge'),

    ('Mental', (
            ('Intelligence', 'Intelligence'),
            ('Wits', 'Wits'),
            ('Resolve', 'Resolve'),
    ('Physical', (
            ('Strength', 'Strength'),
            ('Dexterity', 'Dexterity'),
            ('Stamina', 'Stamina'),
    ('Social', (
            ('Presence', 'Presence'),
            ('Manipulation', 'Manipulation'),
            ('Composure', 'Composure'),


class IntegerRangeField(models.IntegerField):
    def __init__(self, verbose_name=None, name=None, min_value=None, max_value=None, **kwargs):
        self.min_value, self.max_value = min_value, max_value
        models.IntegerField.__init__(self, verbose_name, name, **kwargs)
    def formfield(self, **kwargs):
        defaults = {'min_value': self.min_value, 'max_value':self.max_value}
        return super(IntegerRangeField, self).formfield(**defaults)

def modify_verbose(name_dict):
    def wrap(cls):
        for field, val in list(name_dict.items()):
            setattr(cls._meta.get_field(field), 'verbose_name', val)
        return cls
    return wrap

And, as I'm intended to use django-rest-framework I'm just using the admin page for inputting data, so here's my admin.py:

from django.contrib import admin
from django.contrib.contenttypes import admin as genericAdmin
from .models import Skill, Attribute, CharacterAttributeLink, CharacterSkillLink, BookReference
from .mage.models import Mage, Spell, Arcana, CharacterArcanumLink, SpellArcanumLink, SpellAttributeLink, SpellSkillLink

class AttributeInline(genericAdmin.GenericTabularInline):
    model = CharacterAttributeLink
    extra = 9
    max_num = 9
    can_delete = False

class SkillInline(genericAdmin.GenericTabularInline):
    model = CharacterSkillLink
    extra = 24
    max_num = 24
    can_delete = False

class ExtraAttributeInline(admin.StackedInline):
    model = SpellAttributeLink
    extra = 1
    can_delete = False

class ExtraSkillInline(admin.StackedInline):
    model = SpellSkillLink
    extra = 1
    can_delete = False

class ArcanaInline(admin.TabularInline):
    model = CharacterArcanumLink

class ExtraSpellArcanaInline(admin.TabularInline):
    model = SpellArcanumLink
    extra = 1

class BookReferenceInline(genericAdmin.GenericStackedInline):
    model = BookReference
    max_num = 1

class SpellInline(admin.TabularInline):
    model = Spell.mage.through  

class MageAdmin(admin.ModelAdmin):
    inlines = [AttributeInline, SkillInline, ArcanaInline, SpellInline]

class SpellAdmin(admin.ModelAdmin):
    inlines = [ExtraAttributeInline, ExtraSkillInline, ExtraSpellArcanaInline, BookReferenceInline]

# Register your models here.
admin.site.register(Mage, MageAdmin)
admin.site.register(Spell, SpellAdmin)

Key points I want to check:

  • Based on my comments in my models.py and the diagram, have I created the right links between the models?
  • Is this a good design for what I'm trying to achieve? I want to have many characters (some mages), mages have spells, spells have various Dice Pools made of combinations of traits (Attributes, Skills, Arcana, etcé) on them.
  • Am I writing Python correctly and idiomatically?

Please let me know if anything doesn't make sense. I'm more than happy to clarify if you can tell me what doesn't make sense.

Here is the source-code on github, if it helps at all.

My main concern is the general design pattern I've employed with the linking tables. The code has moved on since then, but the shape of my tables is the same.

  • \$\begingroup\$ If the code here doesn't work, then the question will have to be closed. \$\endgroup\$
    – Jamal
    Feb 5, 2015 at 17:18

1 Answer 1


You migrated the SKILL_CHOICES and ATTRIBUTE_CHOICES out to a separate file. Why not do the same for VIRTUE_CHOICES, VICE_CHOICES, SUB_RACE_CHOICES, FACTION_CHOICES, ARCANUM_CHOICES, PRIORITY_CHOICES? How are the other choices special?

I don't really see how I can review the rest of the code, though. What you have here is largely a data model... and without seeing code that will use these objects or the domain knowledge what this data model represents, I can't tell you whether your data model is good.

... I wonder, though... do you really need to turn all your links into classes? Would a table structure really be so bad? You can have a class that manages that specific table... I imagine that you'd use some of the collections classes that your language provides.

  • \$\begingroup\$ You're right, they aren't. That would be good code clean up. \$\endgroup\$ Feb 12, 2015 at 16:03

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.