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',