Skip to content
Snippets Groups Projects
Unverified Commit 060768d4 authored by Dan Braghis's avatar Dan Braghis Committed by GitHub
Browse files

Merge pull request #47 from OpenTechFund/feature/106-create-project-page

Feature/106 create project page
parents 01fab416 63a53a94
No related branches found
No related tags found
No related merge requests found
Showing
with 612 additions and 39 deletions
...@@ -13,13 +13,12 @@ from wagtail.wagtailadmin.edit_handlers import ( ...@@ -13,13 +13,12 @@ from wagtail.wagtailadmin.edit_handlers import (
FieldRowPanel, FieldRowPanel,
InlinePanel, InlinePanel,
MultiFieldPanel, MultiFieldPanel,
PageChooserPanel,
StreamFieldPanel StreamFieldPanel
) )
from wagtail.wagtailimages.edit_handlers import ImageChooserPanel from wagtail.wagtailimages.edit_handlers import ImageChooserPanel
from opentech.public.utils.blocks import StoryBlock 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): class SocialMediaProfile(models.Model):
...@@ -72,25 +71,8 @@ class PersonPagePersonType(models.Model): ...@@ -72,25 +71,8 @@ class PersonPagePersonType(models.Model):
return self.person_type.title return self.person_type.title
class Funding(Orderable): class Funding(BaseFunding):
page = ParentalKey('PersonPage', related_name='funding') 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): class PersonContactInfomation(Orderable):
...@@ -132,7 +114,7 @@ class PersonContactInfomation(Orderable): ...@@ -132,7 +114,7 @@ class PersonContactInfomation(Orderable):
}) })
class PersonPage(BasePage): class PersonPage(FundingMixin, BasePage):
subpage_types = [] subpage_types = []
parent_page_types = ['PersonIndexPage'] parent_page_types = ['PersonIndexPage']
...@@ -167,12 +149,7 @@ class PersonPage(BasePage): ...@@ -167,12 +149,7 @@ class PersonPage(BasePage):
InlinePanel('person_types', label='Person types'), InlinePanel('person_types', label='Person types'),
FieldPanel('introduction'), FieldPanel('introduction'),
StreamFieldPanel('biography'), StreamFieldPanel('biography'),
InlinePanel('funding', label='Funding'), ] + FundingMixin.content_panels
]
@property
def total_funding(self):
return sum(funding.value for funding in self.funding.all())
class PersonIndexPage(BasePage): class PersonIndexPage(BasePage):
......
...@@ -52,18 +52,8 @@ ...@@ -52,18 +52,8 @@
<h3>{{ item.get_service_display }}</h3> <h3>{{ item.get_service_display }}</h3>
<p>{{ item.profile_url }}</p> <p>{{ item.profile_url }}</p>
{% endfor %} {% endfor %}
<h2>Funding to date</h2>
{% for funding in page.funding.all %} {% include "utils/includes/funding.html" %}
<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>
</div> </div>
</section> </section>
......
from django.apps import AppConfig
class ProjectsConfig(AppConfig):
name = 'projects'
# -*- 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'),
),
]
# -*- 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,
},
),
]
# -*- 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),
),
]
# -*- 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),
),
]
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
{% 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 %}
{% spaceless %}<ul class="multiple">{% for widget in widget.subwidgets %}{% include widget.template_name %}{% endfor %}</ul>{% endspaceless %}
<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>
<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>
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,
})
)
# from django.shortcuts import render
# Create your views here.
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
...@@ -3,6 +3,8 @@ from django.db import models ...@@ -3,6 +3,8 @@ from django.db import models
from wagtail.wagtailadmin.edit_handlers import ( from wagtail.wagtailadmin.edit_handlers import (
FieldPanel, FieldPanel,
FieldRowPanel,
InlinePanel,
MultiFieldPanel, MultiFieldPanel,
PageChooserPanel, PageChooserPanel,
StreamFieldPanel, StreamFieldPanel,
...@@ -259,3 +261,40 @@ class BasePage(SocialFields, ListingFields, Page): ...@@ -259,3 +261,40 @@ class BasePage(SocialFields, ListingFields, Page):
SocialFields.promote_panels + SocialFields.promote_panels +
ListingFields.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())
{% 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>
...@@ -27,6 +27,7 @@ INSTALLED_APPS = [ ...@@ -27,6 +27,7 @@ INSTALLED_APPS = [
'opentech.public.navigation', 'opentech.public.navigation',
'opentech.public.news', 'opentech.public.news',
'opentech.public.people', 'opentech.public.people',
'opentech.public.projects',
'opentech.public.search', 'opentech.public.search',
'opentech.public.standardpages', 'opentech.public.standardpages',
'opentech.public.utils', 'opentech.public.utils',
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment