diff --git a/opentech/public/people/models.py b/opentech/public/people/models.py
index 04ca3f7e421fe7380951e0e72634b90ed310f7b5..98a7560b18439de2dadc6a3e1a1d3184c8d945f8 100644
--- a/opentech/public/people/models.py
+++ b/opentech/public/people/models.py
@@ -13,13 +13,12 @@ from wagtail.wagtailadmin.edit_handlers import (
     FieldRowPanel,
     InlinePanel,
     MultiFieldPanel,
-    PageChooserPanel,
     StreamFieldPanel
 )
 from wagtail.wagtailimages.edit_handlers import ImageChooserPanel
 
 from opentech.public.utils.blocks import StoryBlock
-from opentech.public.utils.models import BasePage
+from opentech.public.utils.models import BasePage, BaseFunding, FundingMixin
 
 
 class SocialMediaProfile(models.Model):
@@ -72,25 +71,8 @@ class PersonPagePersonType(models.Model):
         return self.person_type.title
 
 
-class Funding(Orderable):
+class Funding(BaseFunding):
     page = ParentalKey('PersonPage', related_name='funding')
-    value = models.PositiveIntegerField()
-    year = models.PositiveIntegerField()
-    duration = models.PositiveIntegerField(help_text='In months')
-    source = models.ForeignKey(
-        'wagtailcore.Page',
-        on_delete=models.PROTECT,
-    )
-
-    panels = [
-        FieldRowPanel([
-            FieldPanel('year'),
-            FieldPanel('value'),
-            FieldPanel('duration'),
-        ]),
-        # This is stubbed as we need to be able to select from multiple
-        PageChooserPanel('source'),
-    ]
 
 
 class PersonContactInfomation(Orderable):
@@ -132,7 +114,7 @@ class PersonContactInfomation(Orderable):
             })
 
 
-class PersonPage(BasePage):
+class PersonPage(FundingMixin, BasePage):
     subpage_types = []
     parent_page_types = ['PersonIndexPage']
 
@@ -167,12 +149,7 @@ class PersonPage(BasePage):
         InlinePanel('person_types', label='Person types'),
         FieldPanel('introduction'),
         StreamFieldPanel('biography'),
-        InlinePanel('funding', label='Funding'),
-    ]
-
-    @property
-    def total_funding(self):
-        return sum(funding.value for funding in self.funding.all())
+    ] + FundingMixin.content_panels
 
 
 class PersonIndexPage(BasePage):
diff --git a/opentech/public/people/templates/people/person_page.html b/opentech/public/people/templates/people/person_page.html
index 342ddc2c740fc513267e09c000db9509b17c6699..93755fa587890b773100b18d4767a639ca502d69 100644
--- a/opentech/public/people/templates/people/person_page.html
+++ b/opentech/public/people/templates/people/person_page.html
@@ -52,18 +52,8 @@
                 <h3>{{ item.get_service_display }}</h3>
                 <p>{{ item.profile_url }}</p>
             {% endfor %}
-            <h2>Funding to date</h2>
-            {% for funding in page.funding.all %}
-            <table>
-                <tr>
-                    <td>{{ funding.year }}</td>
-                    <td>${{ funding.value }}</td>
-                    <td>{{ funding.duration }} months</td>
-                    <td><a href="{% pageurl funding.source %}">{{ funding.source }}</a></td>
-                </tr>
-            </table>
-            {% endfor %}
-            <p>Total Funding: {{ page.total_funding }}</p>
+
+            {% include "utils/includes/funding.html" %}
         </div>
     </section>
 
diff --git a/opentech/public/projects/__init__.py b/opentech/public/projects/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/opentech/public/projects/apps.py b/opentech/public/projects/apps.py
new file mode 100644
index 0000000000000000000000000000000000000000..3ef44deeef6f3a7ceb0e0aa64bb05047404d9708
--- /dev/null
+++ b/opentech/public/projects/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class ProjectsConfig(AppConfig):
+    name = 'projects'
diff --git a/opentech/public/projects/migrations/0001_initial.py b/opentech/public/projects/migrations/0001_initial.py
new file mode 100644
index 0000000000000000000000000000000000000000..eaa4ee3ef1b2bce9f64778a51137016a787d0983
--- /dev/null
+++ b/opentech/public/projects/migrations/0001_initial.py
@@ -0,0 +1,88 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.8 on 2018-01-15 16:50
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+import modelcluster.fields
+import wagtail.wagtailcore.blocks
+import wagtail.wagtailcore.fields
+import wagtail.wagtaildocs.blocks
+import wagtail.wagtailembeds.blocks
+import wagtail.wagtailimages.blocks
+import wagtail.wagtailsnippets.blocks
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('images', '0001_initial'),
+        ('wagtailcore', '0040_page_draft_title'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='ProjectContactDetails',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('service', models.CharField(choices=[('website', 'Main Website URL'), ('twitter', 'Twitter Handle'), ('github', 'Github Organisation or Project')], max_length=200)),
+                ('value', models.CharField(max_length=255)),
+            ],
+        ),
+        migrations.CreateModel(
+            name='ProjectIndexPage',
+            fields=[
+                ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')),
+                ('social_text', models.CharField(blank=True, max_length=255)),
+                ('listing_title', models.CharField(blank=True, help_text='Override the page title used when this page appears in listings', max_length=255)),
+                ('listing_summary', models.CharField(blank=True, help_text="The text summary used when this page appears in listings. It's also used as the description for search engines if the 'Search description' field above is not defined.", max_length=255)),
+                ('introduction', models.TextField(blank=True)),
+                ('header_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='images.CustomImage')),
+                ('listing_image', models.ForeignKey(blank=True, help_text='Choose the image you wish to be displayed when this page appears in listings', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='images.CustomImage')),
+                ('social_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='images.CustomImage')),
+            ],
+            options={
+                'abstract': False,
+            },
+            bases=('wagtailcore.page', models.Model),
+        ),
+        migrations.CreateModel(
+            name='ProjectPage',
+            fields=[
+                ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')),
+                ('social_text', models.CharField(blank=True, max_length=255)),
+                ('listing_title', models.CharField(blank=True, help_text='Override the page title used when this page appears in listings', max_length=255)),
+                ('listing_summary', models.CharField(blank=True, help_text="The text summary used when this page appears in listings. It's also used as the description for search engines if the 'Search description' field above is not defined.", max_length=255)),
+                ('introduction', models.TextField(blank=True)),
+                ('body', wagtail.wagtailcore.fields.StreamField((('heading', wagtail.wagtailcore.blocks.CharBlock(classname='full title', icon='title')), ('paragraph', wagtail.wagtailcore.blocks.RichTextBlock()), ('image', wagtail.wagtailcore.blocks.StructBlock((('image', wagtail.wagtailimages.blocks.ImageChooserBlock()), ('caption', wagtail.wagtailcore.blocks.CharBlock(required=False))))), ('quote', wagtail.wagtailcore.blocks.StructBlock((('quote', wagtail.wagtailcore.blocks.CharBlock(classname='title')), ('attribution', wagtail.wagtailcore.blocks.CharBlock(required=False)), ('job_title', wagtail.wagtailcore.blocks.CharBlock(required=False))))), ('embed', wagtail.wagtailembeds.blocks.EmbedBlock()), ('call_to_action', wagtail.wagtailsnippets.blocks.SnippetChooserBlock('utils.CallToActionSnippet', template='blocks/call_to_action_block.html')), ('document', wagtail.wagtailcore.blocks.StructBlock((('document', wagtail.wagtaildocs.blocks.DocumentChooserBlock()), ('title', wagtail.wagtailcore.blocks.CharBlock(required=False)))))))),
+                ('header_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='images.CustomImage')),
+                ('icon', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='images.CustomImage')),
+                ('listing_image', models.ForeignKey(blank=True, help_text='Choose the image you wish to be displayed when this page appears in listings', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='images.CustomImage')),
+                ('social_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='images.CustomImage')),
+            ],
+            options={
+                'abstract': False,
+            },
+            bases=('wagtailcore.page', models.Model),
+        ),
+        migrations.CreateModel(
+            name='ProjectPageRelatedPage',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('sort_order', models.IntegerField(blank=True, editable=False, null=True)),
+                ('page', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailcore.Page')),
+                ('source_page', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='related_pages', to='projects.ProjectPage')),
+            ],
+            options={
+                'ordering': ['sort_order'],
+                'abstract': False,
+            },
+        ),
+        migrations.AddField(
+            model_name='projectcontactdetails',
+            name='project_page',
+            field=modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='contact_details', to='projects.ProjectPage'),
+        ),
+    ]
diff --git a/opentech/public/projects/migrations/0002_projectfunding.py b/opentech/public/projects/migrations/0002_projectfunding.py
new file mode 100644
index 0000000000000000000000000000000000000000..56cd4bf2ad24097246620096a8668e089ae69700
--- /dev/null
+++ b/opentech/public/projects/migrations/0002_projectfunding.py
@@ -0,0 +1,34 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.8 on 2018-01-15 17:12
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+import modelcluster.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('wagtailcore', '0040_page_draft_title'),
+        ('projects', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='ProjectFunding',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('sort_order', models.IntegerField(blank=True, editable=False, null=True)),
+                ('value', models.PositiveIntegerField()),
+                ('year', models.PositiveIntegerField()),
+                ('duration', models.PositiveIntegerField(help_text='In months')),
+                ('page', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='funding', to='projects.ProjectPage')),
+                ('source', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='wagtailcore.Page')),
+            ],
+            options={
+                'ordering': ['sort_order'],
+                'abstract': False,
+            },
+        ),
+    ]
diff --git a/opentech/public/projects/migrations/0003_projectpage_status.py b/opentech/public/projects/migrations/0003_projectpage_status.py
new file mode 100644
index 0000000000000000000000000000000000000000..c1c72f4aff08e022d6d5c2baf6511660ff76ac4d
--- /dev/null
+++ b/opentech/public/projects/migrations/0003_projectpage_status.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.8 on 2018-01-17 12:13
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('projects', '0002_projectfunding'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='projectpage',
+            name='status',
+            field=models.CharField(choices=[('idea', 'Just an Idea. (Pre-alpha)'), ('exists', 'It Exists! (Alpha/Beta)'), ('release', "It's basically done. (Release)"), ('production', 'People Use it. (Production)')], default='idea', max_length=25),
+        ),
+    ]
diff --git a/opentech/public/projects/migrations/0004_projectpage_categories.py b/opentech/public/projects/migrations/0004_projectpage_categories.py
new file mode 100644
index 0000000000000000000000000000000000000000..ad7728e58f3e19da40e4cf19421d5047e044cedf
--- /dev/null
+++ b/opentech/public/projects/migrations/0004_projectpage_categories.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.8 on 2018-01-18 10:03
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('projects', '0003_projectpage_status'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='projectpage',
+            name='categories',
+            field=models.TextField(default='{}', blank=True),
+        ),
+    ]
diff --git a/opentech/public/projects/migrations/__init__.py b/opentech/public/projects/migrations/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/opentech/public/projects/models.py b/opentech/public/projects/models.py
new file mode 100644
index 0000000000000000000000000000000000000000..e2a2b83074a88ad42b4523fb08e00e467eaa8cd9
--- /dev/null
+++ b/opentech/public/projects/models.py
@@ -0,0 +1,171 @@
+import json
+
+from django.db import models
+from django.conf import settings
+from django.core.exceptions import ValidationError
+from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
+from django.core.validators import URLValidator
+
+from modelcluster.fields import ParentalKey
+from wagtail.wagtailadmin.edit_handlers import (
+    FieldPanel,
+    InlinePanel,
+    MultiFieldPanel,
+    PageChooserPanel,
+    StreamFieldPanel,
+)
+
+from wagtail.wagtailcore.fields import StreamField
+from wagtail.wagtailimages.edit_handlers import ImageChooserPanel
+from wagtail.wagtailsearch import index
+
+from opentech.apply.categories.models import Option
+from opentech.public.utils.blocks import StoryBlock
+from opentech.public.utils.models import (
+    BaseFunding,
+    BasePage,
+    FundingMixin,
+    RelatedPage,
+)
+
+from .widgets import CategoriesWidget
+
+
+class ProjectContactDetails(models.Model):
+    project_page = ParentalKey(
+        'ProjectPage',
+        related_name='contact_details'
+    )
+    site_titles = (
+        ('website', "Main Website URL"),
+        ('twitter', "Twitter Handle"),
+        ('github', "Github Organisation or Project"),
+    )
+    site_urls = (
+        ('website', ''),
+        ('twitter', 'https://twitter.com/'),
+        ('github', 'https://github.com/'),
+    )
+    service = models.CharField(
+        max_length=200,
+        choices=site_titles,
+    )
+    value = models.CharField(max_length=255)
+
+    @property
+    def url(self):
+        return dict(self.site_urls)[self.service] + self.value
+
+    def service_name(self):
+        site_display = {
+            'twitter': '@' + self.value,
+            'github': 'Github',
+            'website': 'Main Website',
+        }
+        return site_display[self.service]
+
+    def clean(self):
+        if self.service == 'twitter' and self.value.startswith('@'):
+            self.username = self.username[1:]
+
+        if self.service == 'website':
+            validate = URLValidator()
+            try:
+                validate(self.value)
+            except ValidationError as e:
+                raise ValidationError({'value': e})
+
+
+class ProjectPageRelatedPage(RelatedPage):
+    source_page = ParentalKey('ProjectPage', related_name='related_pages')
+
+    panels = [
+        PageChooserPanel('page', 'projects.ProjectPage'),
+    ]
+
+
+class ProjectFunding(BaseFunding):
+    page = ParentalKey('ProjectPage', related_name='funding')
+
+
+class ProjectPage(FundingMixin, BasePage):
+    STATUSES = (
+        ('idea', "Just an Idea. (Pre-alpha)"),
+        ('exists', "It Exists! (Alpha/Beta)"),
+        ('release', "It's basically done. (Release)"),
+        ('production', "People Use it. (Production)"),
+    )
+
+    subpage_types = []
+    parent_page_types = ['ProjectIndexPage']
+
+    introduction = models.TextField(blank=True)
+    icon = models.ForeignKey(
+        'images.CustomImage',
+        null=True,
+        blank=True,
+        related_name='+',
+        on_delete=models.SET_NULL
+    )
+    status = models.CharField(choices=STATUSES, max_length=25, default=STATUSES[0][0])
+    body = StreamField(StoryBlock())
+
+    categories = models.TextField(default='{}', blank=True)
+
+    search_fields = BasePage.search_fields + [
+        index.SearchField('introduction'),
+        index.SearchField('body'),
+    ]
+
+    content_panels = BasePage.content_panels + [
+        ImageChooserPanel('icon'),
+        FieldPanel('introduction'),
+        FieldPanel('status'),
+        StreamFieldPanel('body'),
+        InlinePanel('contact_details', label="Contact Details"),
+        InlinePanel('related_pages', label="Related Projects"),
+    ] + FundingMixin.content_panels + [
+        MultiFieldPanel(
+            [FieldPanel('categories', widget=CategoriesWidget)],
+            heading="Categories",
+            classname="collapsible collapsed",
+        ),
+    ]
+
+    def category_options(self):
+        categories = json.loads(self.categories)
+        options = [int(id) for options in categories.values() for id in options]
+        return Option.objects.select_related().filter(id__in=options).order_by('category_id', 'sort_order')
+
+
+class ProjectIndexPage(BasePage):
+    subpage_types = ['ProjectPage']
+    parent_page_types = ['home.Homepage']
+
+    introduction = models.TextField(blank=True)
+
+    content_panels = BasePage.content_panels + [
+        FieldPanel('introduction'),
+    ]
+
+    search_fields = BasePage.search_fields + [
+        index.SearchField('introduction'),
+    ]
+
+    def get_context(self, request, *args, **kwargs):
+        context = super().get_context(request, *args, **kwargs)
+        subpages = self.get_children().live()
+        per_page = settings.DEFAULT_PER_PAGE
+        page_number = request.GET.get('page')
+        paginator = Paginator(subpages, per_page)
+
+        try:
+            subpages = paginator.page(page_number)
+        except PageNotAnInteger:
+            subpages = paginator.page(1)
+        except EmptyPage:
+            subpages = paginator.page(paginator.num_pages)
+
+        context['subpages'] = subpages
+
+        return context
diff --git a/opentech/public/projects/templates/projects/project_page.html b/opentech/public/projects/templates/projects/project_page.html
new file mode 100644
index 0000000000000000000000000000000000000000..823a17436634fa9e8d34cbf1026a6e2f26242a6e
--- /dev/null
+++ b/opentech/public/projects/templates/projects/project_page.html
@@ -0,0 +1,47 @@
+{% extends "base.html" %}
+
+{% load wagtailcore_tags wagtailimages_tags %}
+
+
+{% block content %}
+    <div class="intro">
+        <div class="container">
+            {% if page.icon %}
+                {% image page.icon original %}
+            {% endif %}
+            <h1>{{ page.title }}</h1>
+            {% if page.introduction %}
+                <p>{{ page.introduction }}</p>
+            {% endif %}
+
+            {{ page.body }}
+
+            {% with contact_details=page.contact_details.all %}
+                {% if contact_details %}
+                    {% for contact in contact_details %}
+                <p><a href="{{ contact.url }}">
+                    <span class="icon {{ contact.service }}">{{ contact.service_name }}
+                        </a></p>
+                    {% endfor %}
+
+                {% endif %}
+            {% endwith %}
+
+        </div>
+    </div>
+    {% include "utils/includes/funding.html" %}
+
+    {% regroup page.category_options by category as categories %}
+    {% for category, options in categories %}
+    <ul>
+        <li><h3>{{ category.name }}</h3>
+            <ul>
+                {% for option in options %}
+                <li>{{ option.value }}</li>
+                {% endfor %}
+            </ul>
+        </li>
+    </ul>
+    {% endfor %}
+
+{% endblock content %}
diff --git a/opentech/public/projects/templates/projects/widgets/categories_widget.html b/opentech/public/projects/templates/projects/widgets/categories_widget.html
new file mode 100644
index 0000000000000000000000000000000000000000..95344239bf4fa9ded866f3cba93e69d3c6ccdf33
--- /dev/null
+++ b/opentech/public/projects/templates/projects/widgets/categories_widget.html
@@ -0,0 +1 @@
+{% spaceless %}<ul class="multiple">{% for widget in widget.subwidgets %}{% include widget.template_name %}{% endfor %}</ul>{% endspaceless %}
diff --git a/opentech/public/projects/templates/projects/widgets/options_option.html b/opentech/public/projects/templates/projects/widgets/options_option.html
new file mode 100644
index 0000000000000000000000000000000000000000..a1e97f516f91d1ec14a9406f56c35279fe1452d2
--- /dev/null
+++ b/opentech/public/projects/templates/projects/widgets/options_option.html
@@ -0,0 +1 @@
+<label{% if widget.attrs.id %} for="{{ widget.attrs.id }}"{% endif %} style="width:50%;">{{ widget.label }}</label><div class="field-content" style="width:50%;"><div class="input">{% include "django/forms/widgets/input.html" %}</div></div>
diff --git a/opentech/public/projects/templates/projects/widgets/options_widget.html b/opentech/public/projects/templates/projects/widgets/options_widget.html
new file mode 100644
index 0000000000000000000000000000000000000000..7c87506fb6b153514e3fed5e561b760624457b83
--- /dev/null
+++ b/opentech/public/projects/templates/projects/widgets/options_widget.html
@@ -0,0 +1,15 @@
+<li>
+<h1>{{ widget.attrs.label_tag }}</h1>
+<fieldset>
+{% with id=widget.attrs.id %}
+<ul{% if id %} id="{{ id }}"{% endif %}class="fields {% if widget.attrs.class %}{{ widget.attrs.class }}{% endif %}">{% for group, options, index in widget.optgroups %}
+        {% for option in options %}
+        <li {% if id %} id="{{ id }}_{{ index }}"{% endif %}>
+            <div class="field checkbox_input boolean_field">
+            {% include option.template_name with widget=option %}{% endfor %}
+            </div>
+        </li>
+    {% endfor %}
+</ul>{% endwith %}
+</fieldset>
+</li>
diff --git a/opentech/public/projects/tests.py b/opentech/public/projects/tests.py
new file mode 100644
index 0000000000000000000000000000000000000000..240a8138ac394dad233e9f57cbad37f129b63287
--- /dev/null
+++ b/opentech/public/projects/tests.py
@@ -0,0 +1,82 @@
+import json
+
+from django.test import TestCase
+
+from opentech.apply.categories.models import Option
+from opentech.apply.categories.tests.factories import CategoryFactory, OptionFactory
+
+from .widgets import CategoriesWidget
+
+
+class TestCategoriesWidget(TestCase):
+    def setUp(self):
+        self.category = CategoryFactory()
+        self.options = OptionFactory.create_batch(3, category=self.category)
+
+    def test_init_has_no_queries(self):
+        with self.assertNumQueries(0):
+            CategoriesWidget()
+
+    def test_can_access_categories_and_options(self):
+        widget = CategoriesWidget()
+        widgets = list(widget.widgets)
+        self.assertEqual(len(widgets), 1)
+        choices = list(widgets[0].choices)
+        self.assertEqual(len(choices), len(self.options))
+        self.assertCountEqual(list(choices), list(Option.objects.values_list('id', 'value')))
+
+    def test_can_get_multiple_categories(self):
+        CategoryFactory()
+        widget = CategoriesWidget()
+        widgets = list(widget.widgets)
+        self.assertEqual(len(widgets), 2)
+
+    def test_can_decompress_data(self):
+        widget = CategoriesWidget()
+        value = json.dumps({
+            self.category.id: [self.options[0].id]
+        })
+        self.assertEqual(widget.decompress(value), [[self.options[0].id]])
+
+    def test_can_decompress_multiple_data(self):
+        new_category = CategoryFactory()
+        widget = CategoriesWidget()
+        value = json.dumps({
+            self.category.id: [self.options[0].id],
+            new_category.id: [],
+        })
+        self.assertEqual(widget.decompress(value), [[self.options[0].id], []])
+
+    def test_can_get_data_from_form(self):
+        name = 'categories'
+        widget = CategoriesWidget()
+        submitted_data = {
+            name + '_0': [self.options[1].id],
+        }
+
+        value = widget.value_from_datadict(submitted_data, [], name)
+
+        self.assertEqual(value, json.dumps({self.category.id: [self.options[1].id]}))
+
+    def test_can_get_multiple_data_from_form(self):
+        new_category = CategoryFactory()
+        new_options = OptionFactory.create_batch(3, category=new_category)
+
+        name = 'categories'
+        widget = CategoriesWidget()
+        answer_1 = [self.options[1].id]
+        answer_2 = [new_options[1].id, new_options[2].id]
+        submitted_data = {
+            name + '_0': answer_1,
+            name + '_1': answer_2,
+        }
+
+        value = widget.value_from_datadict(submitted_data, [], name)
+
+        self.assertEqual(
+            value,
+            json.dumps({
+                self.category.id: answer_1,
+                new_category.id: answer_2,
+            })
+        )
diff --git a/opentech/public/projects/views.py b/opentech/public/projects/views.py
new file mode 100644
index 0000000000000000000000000000000000000000..fd0e0449559b2e00e226cc9f96df7caed44172aa
--- /dev/null
+++ b/opentech/public/projects/views.py
@@ -0,0 +1,3 @@
+# from django.shortcuts import render
+
+# Create your views here.
diff --git a/opentech/public/projects/widgets.py b/opentech/public/projects/widgets.py
new file mode 100644
index 0000000000000000000000000000000000000000..8b0b2c2b17fb83cbce281da77f8530bcae68f39e
--- /dev/null
+++ b/opentech/public/projects/widgets.py
@@ -0,0 +1,66 @@
+import json
+
+from django import forms
+
+from opentech.apply.categories.models import Category
+
+
+class LazyChoices:
+    def __init__(self, queryset, display):
+        self.queryset = queryset
+        self.display = display
+
+    def __iter__(self):
+        for choice in self.queryset.values_list(*self.display):
+            yield choice
+
+
+class LazyWidgets:
+    def __init__(self, widget, model):
+        self.model = model
+        self.widget = widget
+
+    def __iter__(self):
+        for obj in self.model.objects.order_by('id'):
+            yield self.widget(
+                attrs={'id': obj.id, 'label_tag': obj.name},
+                choices=LazyChoices(obj.options, ['id', 'value']),
+            )
+
+
+class OptionsWidget(forms.CheckboxSelectMultiple):
+    template_name = 'projects/widgets/options_widget.html'
+    option_template_name = 'projects/widgets/options_option.html'
+
+    def __init__(self, *args, **kwargs):
+        choices = kwargs['choices']
+        super().__init__(*args, **kwargs)
+        self.choices = choices
+
+
+class CategoriesWidget(forms.MultiWidget):
+    template_name = 'projects/widgets/categories_widget.html'
+
+    def __init__(self, *args, **kwargs):
+        kwargs['widgets'] = list()
+        super().__init__(*args, **kwargs)
+        self.widgets = LazyWidgets(OptionsWidget, Category)
+
+    def decompress(self, value):
+        data = json.loads(value)
+        return [
+            data.get(str(widget.attrs['id']), list()) for widget in self.widgets
+        ]
+
+    def value_from_datadict(self, data, files, name):
+        data = {
+            widget.attrs['id']: widget.value_from_datadict(data, files, name + '_%s' % i)
+            for i, widget in enumerate(self.widgets)
+        }
+        return json.dumps(data)
+
+    def get_context(self, *args, **kwargs):
+        context = super().get_context(*args, **kwargs)
+        # Mutliwidget kills the wrap_label option when it is building the context
+        context['wrap_label'] = True
+        return context
diff --git a/opentech/public/utils/models.py b/opentech/public/utils/models.py
index 08d72f3bbfe463094b0e4eb5ad085cfcf498578b..b4505bea983cb511936ec9103b069bb4cab3dc53 100644
--- a/opentech/public/utils/models.py
+++ b/opentech/public/utils/models.py
@@ -3,6 +3,8 @@ from django.db import models
 
 from wagtail.wagtailadmin.edit_handlers import (
     FieldPanel,
+    FieldRowPanel,
+    InlinePanel,
     MultiFieldPanel,
     PageChooserPanel,
     StreamFieldPanel,
@@ -259,3 +261,40 @@ class BasePage(SocialFields, ListingFields, Page):
         SocialFields.promote_panels +
         ListingFields.promote_panels
     )
+
+
+class BaseFunding(Orderable):
+    value = models.PositiveIntegerField()
+    year = models.PositiveIntegerField()
+    duration = models.PositiveIntegerField(help_text='In months')
+    source = models.ForeignKey(
+        'wagtailcore.Page',
+        on_delete=models.PROTECT,
+    )
+
+    panels = [
+        FieldRowPanel([
+            FieldPanel('year'),
+            FieldPanel('value'),
+            FieldPanel('duration'),
+        ]),
+        PageChooserPanel('source', ['public_funds.FundPage', 'public_funds.LabPage']),
+    ]
+
+    class Meta(Orderable.Meta):
+        abstract = True
+
+
+class FundingMixin(models.Model):
+    '''Implements the funding total calculation
+
+    You still need to include the content panel in the child class
+    '''
+    content_panels = [InlinePanel('funding', label="Funding")]
+
+    class Meta:
+        abstract = True
+
+    @property
+    def total_funding(self):
+        return sum(funding.value for funding in self.funding.all())
diff --git a/opentech/public/utils/templates/utils/includes/funding.html b/opentech/public/utils/templates/utils/includes/funding.html
new file mode 100644
index 0000000000000000000000000000000000000000..d858ce5dcc11d072858f56f0674dc61fdb9e4719
--- /dev/null
+++ b/opentech/public/utils/templates/utils/includes/funding.html
@@ -0,0 +1,13 @@
+{% load wagtailcore_tags %}
+<h2>Funding to date</h2>
+{% for funding in page.funding.all %}
+<table>
+    <tr>
+        <td>{{ funding.year }}</td>
+        <td>${{ funding.value }}</td>
+        <td>{{ funding.duration }} months</td>
+        <td><a href="{% pageurl funding.source %}">{{ funding.source }}</a></td>
+    </tr>
+</table>
+{% endfor %}
+<p>Total Funding: {{ page.total_funding }}</p>
diff --git a/opentech/settings/base.py b/opentech/settings/base.py
index 9d805608db649bdf1fe68bbbf3fb1c351c3a5aea..38c958902a2f43a3babbbe31749555e0dba3f657 100644
--- a/opentech/settings/base.py
+++ b/opentech/settings/base.py
@@ -27,6 +27,7 @@ INSTALLED_APPS = [
     'opentech.public.navigation',
     'opentech.public.news',
     'opentech.public.people',
+    'opentech.public.projects',
     'opentech.public.search',
     'opentech.public.standardpages',
     'opentech.public.utils',