diff --git a/SciPost_v1/settings/base.py b/SciPost_v1/settings/base.py index b56426d508f6fc38af093e3d5601e78dd247052a..87f579dc23d225cbb052ad35ef175bf691d330d5 100644 --- a/SciPost_v1/settings/base.py +++ b/SciPost_v1/settings/base.py @@ -94,6 +94,7 @@ INSTALLED_APPS = ( 'django_celery_results', 'django_celery_beat', 'finances', + 'forums', 'guides', 'invitations', 'journals', diff --git a/SciPost_v1/urls.py b/SciPost_v1/urls.py index f77a4e7437771b6ed4477f64b44322eae1c1ffc1..ff3d8f0be9e8609ef65d924bed3ed4c447c55853 100644 --- a/SciPost_v1/urls.py +++ b/SciPost_v1/urls.py @@ -46,6 +46,7 @@ urlpatterns = [ url(r'^commentaries/', include('commentaries.urls', namespace="commentaries")), url(r'^commentary/', include('commentaries.urls', namespace="_commentaries")), url(r'^comments/', include('comments.urls', namespace="comments")), + url(r'^forums/', include('forums.urls', namespace="forums")), url(r'^funders/', include('funders.urls', namespace="funders")), url(r'^finances/', include('finances.urls', namespace="finances")), url(r'^guides/', include('guides.urls', namespace="guides")), diff --git a/forums/__init__.py b/forums/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/forums/admin.py b/forums/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..eb93544bf80a3350f869917de301142b8a7899cf --- /dev/null +++ b/forums/admin.py @@ -0,0 +1,28 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django.contrib import admin + +from guardian.admin import GuardedModelAdmin + +from .models import Forum, Post, Motion + + +class ForumAdmin(GuardedModelAdmin): + prepopulated_fields = {'slug': ('name',)} + search_fields = ['name',] + +admin.site.register(Forum, ForumAdmin) + + +class PostAdmin(admin.ModelAdmin): + search_fields = ['posted_by', 'subject', 'text'] + +admin.site.register(Post, PostAdmin) + + +class MotionAdmin(admin.ModelAdmin): + search_fields = ['posted_by', 'subject', 'text'] + +admin.site.register(Motion, MotionAdmin) diff --git a/forums/apps.py b/forums/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..54a0867c3793ca6b1838c382fdcf594a236bc406 --- /dev/null +++ b/forums/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ForumsConfig(AppConfig): + name = 'forums' diff --git a/forums/forms.py b/forums/forms.py new file mode 100644 index 0000000000000000000000000000000000000000..f9cbb48e951acaa0f93e2627f24105f41ca97812 --- /dev/null +++ b/forums/forms.py @@ -0,0 +1,87 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django import forms + +from ajax_select.fields import AutoCompleteSelectField + +from .models import Forum, Meeting, Post, Motion + + +class ForumForm(forms.ModelForm): + class Meta: + model = Forum + fields = ['name', 'slug', 'description', + 'publicly_visible', 'moderators', + 'parent_content_type', 'parent_object_id'] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['parent_content_type'].widget = forms.HiddenInput() + self.fields['parent_object_id'].widget = forms.HiddenInput() + + +class MeetingForm(ForumForm): + class Meta: + model = Meeting + fields = ['name', 'slug', 'description', + 'publicly_visible', 'moderators', + 'parent_content_type', 'parent_object_id', + 'date_from', 'date_until', 'preamble'] + + +class ForumGroupPermissionsForm(forms.ModelForm): + """ + Used for granting a specific Group access to a given Forum. + """ + group = AutoCompleteSelectField('group_lookup') + can_view = forms.BooleanField(required=False) + can_post = forms.BooleanField(required=False) + + class Meta: + model = Forum + fields = [] + + +class ForumOrganizationPermissionsForm(forms.Form): + organization = AutoCompleteSelectField('organization_lookup') + can_view = forms.BooleanField() + can_post = forms.BooleanField() + + +class PostForm(forms.ModelForm): + """ + Create a new Post. The parent must be defined, the model class and + instance being defined by url parameters. + """ + class Meta: + model = Post + fields = ['posted_by', 'posted_on', 'needs_vetting', + 'parent_content_type', 'parent_object_id', + 'subject', 'text'] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['posted_by'].widget = forms.HiddenInput() + self.fields['posted_on'].widget = forms.HiddenInput() + self.fields['needs_vetting'].widget = forms.HiddenInput() + self.fields['parent_content_type'].widget = forms.HiddenInput() + self.fields['parent_object_id'].widget = forms.HiddenInput() + + +class MotionForm(PostForm): + """ + Form for creating a Motion to be voted on in a Forum or during a Meeting. + """ + class Meta: + model = Motion + fields = ['posted_by', 'posted_on', 'needs_vetting', + 'parent_content_type', 'parent_object_id', + 'subject', 'text', + 'eligible_for_voting', 'voting_deadline'] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['eligible_for_voting'].widget = forms.HiddenInput() + self.fields['eligible_for_voting'].disabled = True diff --git a/forums/managers.py b/forums/managers.py new file mode 100644 index 0000000000000000000000000000000000000000..e7799a8ef68021305906561e12634540c3d075dd --- /dev/null +++ b/forums/managers.py @@ -0,0 +1,19 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django.db import models + + +class ForumQuerySet(models.QuerySet): + + def anchors(self): + """Return only the Forums which do not have a parent.""" + return self.filter(parent_object_id__isnull=True) + + +class PostQuerySet(models.QuerySet): + + def motions_excluded(self): + """Filter all Motions out of the Post queryset.""" + return self.filter(motion__isnull=True) diff --git a/forums/migrations/0001_initial.py b/forums/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..974b33dbafd111b77029018a7f18a87e0f0fd111 --- /dev/null +++ b/forums/migrations/0001_initial.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2019-03-03 09:36 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Forum', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=256)), + ('slug', models.SlugField(allow_unicode=True)), + ('publicly_visible', models.BooleanField(default=False)), + ('parent_object_id', models.PositiveIntegerField(blank=True, null=True)), + ('moderators', models.ManyToManyField(related_name='moderated_forums', to=settings.AUTH_USER_MODEL)), + ('parent_content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Post', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('posted_on', models.DateTimeField(default=django.utils.timezone.now)), + ('needs_vetting', models.BooleanField(default=True)), + ('parent_object_id', models.PositiveIntegerField()), + ('subject', models.CharField(max_length=256)), + ('text', models.TextField()), + ('parent_content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ('posted_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('vetted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vetted_posts', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['posted_on'], + }, + ), + ] diff --git a/forums/migrations/0002_auto_20190306_0807.py b/forums/migrations/0002_auto_20190306_0807.py new file mode 100644 index 0000000000000000000000000000000000000000..6e5c4f9b1af14c1a1abb3b0ab36266a1a7f35833 --- /dev/null +++ b/forums/migrations/0002_auto_20190306_0807.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2019-03-06 07:07 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('forums', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='forum', + options={'ordering': ['name'], 'permissions': [('can_view_forum', 'Can view Forum')]}, + ), + ] diff --git a/forums/migrations/0003_auto_20190307_0908.py b/forums/migrations/0003_auto_20190307_0908.py new file mode 100644 index 0000000000000000000000000000000000000000..955e9d1d2151c6289e5e6966fee6eac99d34f55d --- /dev/null +++ b/forums/migrations/0003_auto_20190307_0908.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2019-03-07 08:08 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('forums', '0002_auto_20190306_0807'), + ] + + operations = [ + migrations.AlterModelOptions( + name='forum', + options={'ordering': ['name'], 'permissions': [('can_view_forum', 'Can view Forum'), ('can_post_to_forum', 'Can add Post to Forum')]}, + ), + ] diff --git a/forums/migrations/0004_auto_20190308_1055.py b/forums/migrations/0004_auto_20190308_1055.py new file mode 100644 index 0000000000000000000000000000000000000000..442372dc6830832e2a2f3cf1c54408a0c5bac95e --- /dev/null +++ b/forums/migrations/0004_auto_20190308_1055.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2019-03-08 09:55 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('forums', '0003_auto_20190307_0908'), + ] + + operations = [ + migrations.AlterField( + model_name='post', + name='text', + field=models.TextField(help_text='You can use ReStructuredText, see a <a href="https://devguide.python.org/documenting/#restructuredtext-primer" target="_blank">primer on python.org</a>'), + ), + ] diff --git a/forums/migrations/0005_forum_description.py b/forums/migrations/0005_forum_description.py new file mode 100644 index 0000000000000000000000000000000000000000..609fd9d50e9c35893595cb686385bbdf13a4b35b --- /dev/null +++ b/forums/migrations/0005_forum_description.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2019-03-09 05:18 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('forums', '0004_auto_20190308_1055'), + ] + + operations = [ + migrations.AddField( + model_name='forum', + name='description', + field=models.TextField(blank=True, help_text='You can use ReStructuredText, see a <a href="https://devguide.python.org/documenting/#restructuredtext-primer" target="_blank">primer on python.org</a>', null=True), + ), + ] diff --git a/forums/migrations/0006_meeting.py b/forums/migrations/0006_meeting.py new file mode 100644 index 0000000000000000000000000000000000000000..02cf93d9f53e582bd87c52e03995d939f1ef2b9c --- /dev/null +++ b/forums/migrations/0006_meeting.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2019-03-09 08:56 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('forums', '0005_forum_description'), + ] + + operations = [ + migrations.CreateModel( + name='Meeting', + fields=[ + ('forum', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='forums.Forum')), + ('date_from', models.DateField()), + ('date_until', models.DateField()), + ('preamble', models.TextField(help_text='Explanatory notes for the meeting.\nYou can use ReStructuredText, see a <a href="https://devguide.python.org/documenting/#restructuredtext-primer" target="_blank">primer on python.org</a>')), + ('minutes', models.TextField(blank=True, help_text='To be filled in after completion of the meeting.\nYou can use ReStructuredText, see a <a href="https://devguide.python.org/documenting/#restructuredtext-primer" target="_blank">primer on python.org</a>', null=True)), + ], + bases=('forums.forum',), + ), + ] diff --git a/forums/migrations/0007_motion.py b/forums/migrations/0007_motion.py new file mode 100644 index 0000000000000000000000000000000000000000..764efd14aa5fcd9943723cd6374b4263554b9f6e --- /dev/null +++ b/forums/migrations/0007_motion.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2019-03-09 17:29 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('forums', '0006_meeting'), + ] + + operations = [ + migrations.CreateModel( + name='Motion', + fields=[ + ('post_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='forums.Post')), + ('voting_deadline', models.DateField()), + ('accepted', models.NullBooleanField()), + ('eligible_for_voting', models.ManyToManyField(blank=True, related_name='eligible_to_vote_on_motion', to=settings.AUTH_USER_MODEL)), + ('in_abstain', models.ManyToManyField(blank=True, related_name='abstain_with_motion', to=settings.AUTH_USER_MODEL)), + ('in_agreement', models.ManyToManyField(blank=True, related_name='agree_on_motion', to=settings.AUTH_USER_MODEL)), + ('in_disagreement', models.ManyToManyField(blank=True, related_name='disagree_with_motion', to=settings.AUTH_USER_MODEL)), + ('in_doubt', models.ManyToManyField(blank=True, related_name='doubt_on_motion', to=settings.AUTH_USER_MODEL)), + ], + bases=('forums.post',), + ), + ] diff --git a/forums/migrations/0008_auto_20190310_0713.py b/forums/migrations/0008_auto_20190310_0713.py new file mode 100644 index 0000000000000000000000000000000000000000..f03b76d94c575b03271279eca737b9aebff2e757 --- /dev/null +++ b/forums/migrations/0008_auto_20190310_0713.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2019-03-10 06:13 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('forums', '0007_motion'), + ] + + operations = [ + migrations.AlterField( + model_name='motion', + name='post_ptr', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='forums.Post'), + ), + ] diff --git a/forums/migrations/0009_auto_20190310_0715.py b/forums/migrations/0009_auto_20190310_0715.py new file mode 100644 index 0000000000000000000000000000000000000000..6291e88dd54cb5bd775a4161e5ff51eb0fb34448 --- /dev/null +++ b/forums/migrations/0009_auto_20190310_0715.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2019-03-10 06:15 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('forums', '0008_auto_20190310_0713'), + ] + + operations = [ + migrations.RenameField( + model_name='motion', + old_name='post_ptr', + new_name='post', + ), + ] diff --git a/forums/migrations/__init__.py b/forums/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/forums/models.py b/forums/models.py new file mode 100644 index 0000000000000000000000000000000000000000..47b56585aee6dec7b173de21acf4f2c1d8efb798 --- /dev/null +++ b/forums/models.py @@ -0,0 +1,259 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +import datetime + +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.models import ContentType +from django.core.urlresolvers import reverse +from django.db import models +from django.utils import timezone + +from .managers import ForumQuerySet, PostQuerySet + + +class Forum(models.Model): + """ + A Forum is a discussion location for a defined set of Users. + + A Forum instance can be publicly visible. For publicly invisible forums, + as well as for thread creation and posting rights, + access is specified flexibly on a per-Group and/or per-User basis + via object-level permissions (through the django-guardian required app). + + Forums can be related to parent/children via parent [GenericForeignKey] + and child_forums [GenericRelation] fields. + + Similarly, Posts in a Forum are listed in the posts [GenericRelation] field. + """ + name = models.CharField(max_length=256) + slug = models.SlugField(allow_unicode=True) + description = models.TextField( + blank=True, null=True, + help_text=( + 'You can use ReStructuredText, see a ' + '<a href="https://devguide.python.org/documenting/#restructuredtext-primer" ' + 'target="_blank">primer on python.org</a>') + ) + publicly_visible = models.BooleanField(default=False) + moderators = models.ManyToManyField('auth.User', related_name='moderated_forums') + + parent_content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, + blank=True, null=True) + parent_object_id = models.PositiveIntegerField(blank=True, null=True) + parent = GenericForeignKey('parent_content_type', 'parent_object_id') + + child_forums = GenericRelation('forums.Forum', + content_type_field='parent_content_type', + object_id_field='parent_object_id', + related_query_name='parent_forums') + posts = GenericRelation('forums.Post', + content_type_field='parent_content_type', + object_id_field='parent_object_id', + related_query_name='parent_forums') + motions = GenericRelation('forums.Motion', + content_type_field='parent_content_type', + object_id_field='parent_object_id', + related_query_name='parent_forums') + + objects = ForumQuerySet.as_manager() + + class Meta: + ordering = ['name',] + permissions = [ + ('can_view_forum', 'Can view Forum'), + ('can_post_to_forum', 'Can add Post to Forum'), + ] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('forums:forum_detail', kwargs={'slug': self.slug}) + + @property + def nr_posts(self): + """Recursively counts the number of posts in this Forum.""" + nr = 0 + for post in self.posts.all(): + nr += post.nr_followups + if self.posts.all(): + nr += self.posts.all().count() + return nr + + def posts_hierarchy_id_list(self): + id_list = [] + for post in self.posts.all(): + id_list += post.posts_hierarchy_id_list() + return id_list + + @property + def latest_post(self): + id_list = self.posts_hierarchy_id_list() + print ('forum post id_list: %s' % id_list) + try: + return Post.objects.filter(id__in=id_list).order_by('-posted_on').first() + except: + return None + + +class Meeting(Forum): + """ + A Meeting is a Forum but with fixed start and end dates, + and with additional descriptor fields (preamble, minutes). + + By definition, adding new Posts is allowed up to and including + the date specified in ``date_until``. The Meeting can however + be viewed in perpetuity by users who have viewing rights. + """ + forum = models.OneToOneField('forums.Forum', on_delete=models.CASCADE, + parent_link=True) + date_from = models.DateField() + date_until = models.DateField() + preamble = models.TextField( + help_text=( + 'Explanatory notes for the meeting.\n' + 'You can use ReStructuredText, see a ' + '<a href="https://devguide.python.org/documenting/#restructuredtext-primer" ' + 'target="_blank">primer on python.org</a>') + ) + minutes = models.TextField( + blank=True, null=True, + help_text=( + 'To be filled in after completion of the meeting.\n' + 'You can use ReStructuredText, see a ' + '<a href="https://devguide.python.org/documenting/#restructuredtext-primer" ' + 'target="_blank">primer on python.org</a>') + ) + + def __str__(self): + return '%s, [%s to %s]' % (self.forum, + self.date_from.strftime('%Y-%m-%d'), + self.date_until.strftime('%Y-%m-%d')) + + @property + def future(self): + return datetime.date.today() < self.date_from + + @property + def ongoing(self): + today = datetime.date.today() + return today >= self.date_from and today <= self.date_until + + @property + def past(self): + return datetime.date.today() > self.date_until + + @property + def context_colors(self): + """If meeting is future: primary; ongoing: success; voting: warning; finished: info.""" + today = datetime.date.today() + if today < self.date_from: + return {'bg': 'primary', 'text': 'white', 'message': 'Meeting is coming up'} + elif today <= self.date_until: + return {'bg': 'success', 'text': 'light', 'message': 'Meeting is ongoing'} + elif today < self.date_until + datetime.timedelta(days=8): + return {'bg': 'warning', 'text': 'dark', 'message': 'Meeting is finished, voting open'} + else: + return {'bg': 'info', 'text': 'dark', 'message': 'Meeting is finished'} + + +class Post(models.Model): + """ + A comment, feedback, question or similar, with a specified parent object. + + If the Post is submitted by Admin, Advisory Board members or Fellows, + it is marked as not needing vetting before becoming visible. + Similarly, for Posts created by organizations.Contacts, no vetting is required. + Otherwise, e.g. for Contributors-submitted Posts to a publicly-visible + Forum, vetting by Admin is required. + + A Post must have a parent object (represented here as a GenericForeignKey). + If the parent is a Forum, the Post is interpreted as the head of + a new discussion thread. If the parent is a Post, then it is interpreted as + part of an ongoing thread. + + The text field can contain ReStructuredText markup, formatted in templates + through the ``scipost`` app's ``restructuredtext`` template filter, which + relies on the ``docutils`` required app. + """ + posted_by = models.ForeignKey('auth.User', on_delete=models.CASCADE) + posted_on = models.DateTimeField(default=timezone.now) + needs_vetting = models.BooleanField(default=True) + vetted_by = models.ForeignKey('auth.User', related_name='vetted_posts', + blank=True, null=True, on_delete=models.PROTECT) + parent_content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + parent_object_id = models.PositiveIntegerField() + parent = GenericForeignKey('parent_content_type', 'parent_object_id') + followup_posts = GenericRelation('forums.Post', + content_type_field='parent_content_type', + object_id_field='parent_object_id', + related_query_name='parent_posts') + subject = models.CharField(max_length=256) + text = models.TextField( + help_text=( + 'You can use ReStructuredText, see a ' + '<a href="https://devguide.python.org/documenting/#restructuredtext-primer" ' + 'target="_blank">primer on python.org</a>') + ) + + objects = PostQuerySet.as_manager() + + class Meta: + ordering = ['posted_on',] + + def __str__(self): + return '%s %s: %s' % (self.posted_by.first_name, + self.posted_by.last_name, self.subject[:32]) + + def get_absolute_url(self): + return '%s#post%s' % (self.get_forum().get_absolute_url(), self.id) + + @property + def nr_followups(self): + nr = 0 + for followup in self.followup_posts.all(): + nr += followup.nr_followups + if self.followup_posts: + nr += self.followup_posts.all().count() + return nr + + def posts_hierarchy_id_list(self): + id_list = [self.id] + for post in self.followup_posts.all(): + id_list += post.posts_hierarchy_id_list() + print ('post %s id_list: %s' % (self.id, id_list)) + return id_list + + def get_forum(self): + """ + Climb back the hierarchy up to the original Forum. + If no Forum is found, return None. + """ + type_forum = ContentType.objects.get_by_natural_key('forums', 'forum') + if self.parent_content_type == type_forum: + return self.parent + else: + return self.parent.get_forum() + + +class Motion(Post): + """ + A Motion is a posting to a Forum or Meeting, on which Forum participants + can vote. + """ + post = models.OneToOneField('forums.Post', on_delete=models.CASCADE, + parent_link=True) + eligible_for_voting = models.ManyToManyField('auth.User', blank=True, + related_name='eligible_to_vote_on_motion') + in_agreement = models.ManyToManyField('auth.User', blank=True, + related_name='agree_on_motion') + in_doubt = models.ManyToManyField('auth.User', blank=True, + related_name='doubt_on_motion') + in_disagreement = models.ManyToManyField('auth.User', blank=True, + related_name='disagree_with_motion') + in_abstain = models.ManyToManyField('auth.User', blank=True, + related_name='abstain_with_motion') + voting_deadline = models.DateField() + accepted = models.NullBooleanField() diff --git a/forums/templates/forums/base.html b/forums/templates/forums/base.html new file mode 100644 index 0000000000000000000000000000000000000000..83076f2ac5c43e84223489d441e1ca50836c9e5b --- /dev/null +++ b/forums/templates/forums/base.html @@ -0,0 +1,13 @@ +{% extends 'scipost/base.html' %} + +{% block breadcrumb %} + <div class="container-outside header"> + <div class="container"> + <nav class="breadcrumb hidden-sm-down"> + {% block breadcrumb_items %} + <a href="{% url 'forums:forums' %}" class="breadcrumb-item">Forums</a> + {% endblock %} + </nav> + </div> + </div> +{% endblock %} diff --git a/forums/templates/forums/forum_as_li.html b/forums/templates/forums/forum_as_li.html new file mode 100644 index 0000000000000000000000000000000000000000..b4afb1dd469eea6c1cdb78accc13a0b4977e1124 --- /dev/null +++ b/forums/templates/forums/forum_as_li.html @@ -0,0 +1,11 @@ +<li class="d-flex flex-wrap justify-content-between"> + <a href="{{ forum.get_absolute_url }}">{{ forum }}</a> + <span class="badge badge-secondary badge-pill">{% with nr_posts=forum.nr_posts %}{{ nr_posts }} post{{ nr_posts|pluralize }}{% endwith %}</span> + {% if forum.child_forums.all|length > 0 %} + <ul class="list-unstyled forumList"> + {% for child in forum.child_forums.all %} + {% include 'forums/forum_as_li.html' with forum=child %} + {% endfor %} + </ul> + {% endif %} +</li> diff --git a/forums/templates/forums/forum_confirm_delete.html b/forums/templates/forums/forum_confirm_delete.html new file mode 100644 index 0000000000000000000000000000000000000000..e1b83558d653e628e3a1a2e7c9c3b05447eb5904 --- /dev/null +++ b/forums/templates/forums/forum_confirm_delete.html @@ -0,0 +1,35 @@ +{% extends 'forums/base.html' %} + +{% load bootstrap %} +{% load restructuredtext %} + +{% block pagetitle %}: Delete Forum{% endblock pagetitle %} + +{% block content %} +<div class="row"> + <div class="col-12"> + <h1 class="highlight">Delete Forum</h1> + + <h3 class="highlight">Description</h3> + {{ object.description|restructuredtext }} + + <h3 class="highlight">Posts</h3> + {% for post in object.posts.all %} + {% include 'forums/post_card.html' with forum=object post=post %} + {% endfor %} + + </div> +</div> + +<div class="row"> + <div class="col-12"> + + <form method="post"> + {% csrf_token %} + <h3 class="mb-2">Are you sure you want to delete this Forum (and all associated Posts)?</h3> + <input type="submit" class="btn btn-danger" value="Yes, delete it" /> + </form> + </div> +</div> + +{% endblock content %} diff --git a/forums/templates/forums/forum_detail.html b/forums/templates/forums/forum_detail.html new file mode 100644 index 0000000000000000000000000000000000000000..17ef9d9607f9def4404f1742de6de7770aa6c0ff --- /dev/null +++ b/forums/templates/forums/forum_detail.html @@ -0,0 +1,167 @@ +{% extends 'forums/base.html' %} + +{% load bootstrap %} +{% load guardian_tags %} +{% load restructuredtext %} + +{% block breadcrumb_items %} + {{ block.super }} +<span class="breadcrumb-item">{% if forum.meeting %}Meeting{% else %}Forum{% endif %} Details</span> +{% endblock %} + +{% load scipost_extras %} + +{% block pagetitle %}: {% if forum.meeting %}Meeting{% else %}Forum{% endif %} details{% endblock pagetitle %} + +{% get_obj_perms request.user for forum as "user_perms" %} + +{% block content %} + +<div class="row"> + <div class="col-12"> + <h3 class="highlight"> + {% if forum.meeting %} + {% with context_colors=forum.meeting.context_colors %} + <span class="badge badge-{{ context_colors.bg }} mx-0 mb-2 p-2 text-{{ context_colors.text }}"> + {{ context_colors.message }} + <span class="small text-muted"> [{{ forum.meeting.date_from|date:"Y-m-d" }} to {{ forum.meeting.date_until|date:"Y-m-d" }}]</span> + </span> + {% endwith %} + <br/> + {% endif %} + + <span class="d-flex flex-wrap justify-content-between"> + <a href="{{ forum.get_absolute_url }}">{{ forum }}</a> + <span class="badge badge-primary badge-pill">{% with nr_posts=forum.nr_posts %}{{ nr_posts }} post{{ nr_posts|pluralize }}{% endwith %}</span> + </span> + </h3> + + {% if forum.parent %} + <p>Parent: <a href="{{ forum.parent.get_absolute_url }}">{{ forum.parent }}</a></p> + {% endif %} + {% if forum.child_forums.all|length > 0 %} + <p>Descendants: {% for child in forum.child_forums.all %}<a href="{{ child.get_absolute_url }}">{{ child }}</a>{% if not forloop.last %}, {% endif %}{% endfor %}</p> + {% endif %} + + {% if perms.forums.add_forum or "can_change_forum" in user_perms %} + <div class="container border border-danger"> + <h4>Admin actions:</h4> + <ul> + <li><a href="{% url 'forums:forum_update' slug=forum.slug %}" class="text-warning">Update this {% if forum.meeting %}Meeting{% else %}Forum{% endif %}</a></li> + <li> + {% if not forum.child_forums.all|length > 0 %} + <a href="{% url 'forums:forum_delete' slug=forum.slug %}" class="text-danger">Delete this {% if forum.meeting %}Meeting{% else %}Forum{% endif %} (and all Posts {% if forum.meeting %}and Motions {% endif %}it contains)</a> + {% else %} + <span class="text-danger" style="text-decoration: line-through;">Delete this Forum</span> Please delete descendant Forums first. + {% endif %} + </li> + {% if not forum.meeting %} + <li><a href="{% url 'forums:forum_create' parent_model='forum' parent_id=forum.id %}">Create a (sub)Forum within this one</a></li> + <li><a href="{% url 'forums:meeting_create' parent_model='forum' parent_id=forum.id %}">Create a Meeting within this Forum</a></li> + {% endif %} + </ul> + + <div class="card"> + <div class="card-header"> + Permissions on this {% if forum.meeting %}Meeting{% else %}Forum{% endif %} instance + <button class="btn btn-link small" data-toggle="collapse" data-target="#permissionsCard"> + View/manage</button> + </div> + <div class="card-body collapse" id="permissionsCard"> + <p><a href="{% url 'forums:forum_permissions' slug=forum.slug %}">Grant permissions to a new group</a></p> + <p>Groups with permissions [click on the Group's name to manage permissions]:</p> + <ul> + {% for group in groups_with_perms %} + {% get_obj_perms group for forum as "group_perms" %} + <li><a href="{% url 'forums:forum_permissions' slug=forum.slug group_id=group.id %}">{{ group.name }}</a>: {{ group_perms }}</li> + {% empty %} + <li>No group has permissions on this Forum</li> + {% endfor %} + </ul> + + <p>Users with permissions:</p> + <ul> + {% for u in users_with_perms %} + {% get_obj_perms u for forum as "u_perms" %} + <li>{{ u.first_name }} {{ u.last_name }}: {{ u_perms }}</li> + {% endfor %} + </ul> + </div> + </div> + </div> + {% endif %} + + + <h3>Table of Contents</h3> + <ul> + <li><a href="#Description">Description</a></li> + {% if forum.meeting %} + <li><a href="#Preamble">Preamble</a></li> + <li><a href="#Motions">Motions</a></li> + {% endif %} + <li><a href="#Posts">Posts</a></li> + {% if forum.meeting %} + <li><a href="#Minutes">Minutes</a></li> + {% endif %} + </ul> + </div> +</div> + +<div class="row"> + <div class="col-12"> + <h3 class="highlight" id="Description">Description</h3> + {{ forum.description|restructuredtext }} + </div> +</div> + +{% if forum.meeting %} +<div class="row"> + <div class="col-12"> + <h3 class="highlight" id="Preamble">Preamble</h3> + {{ forum.preamble|restructuredtext }} + </div> +</div> + +<div class="row"> + <div class="col-12"> + <h3 class="highlight" id="Motions">Motions</h3> + <ul> + {% if forum.meeting.future %} + <li>Adding Motions will be activated once the meeting starts</li> + {% elif forum.meeting.past %} + <li><span class="text-danger">Adding Motions is deactivated</span> (Meeting is over)</li> + {% else %} + <li><a href="{% url 'forums:motion_create' slug=forum.slug parent_model='forum' parent_id=forum.id %}">Add a new Motion</a></li> + {% endif %} + </ul> + {% for motion in forum.motions.all %} + {% include 'forums/post_card.html' with forum=forum post=motion.post %} + {% endfor %} + </div> +</div> +{% endif %} + +<div class="row"> + <div class="col-12"> + <h3 class="highlight" id="Posts">Posts</h3> + <ul> + <li><a href="{% url 'forums:post_create' slug=forum.slug parent_model='forum' parent_id=forum.id %}">Add a new Post</a></li> + </ul> + + {% for post in forum.posts.motions_excluded %} + {% include 'forums/post_card.html' with forum=forum post=post %} + {% endfor %} + + </div> +</div> + +{% if forum.meeting %} +<div class="row"> + <div class="col-12"> + <h3 class="highlight" id="Minutes">Minutes</h3> + {{ forum.minutes|restructuredtext }} + </div> +</div> +{% endif %} + +{% endblock content %} diff --git a/forums/templates/forums/forum_form.html b/forums/templates/forums/forum_form.html new file mode 100644 index 0000000000000000000000000000000000000000..75e06ae88d97432ee6ec375c71dd435c91d55473 --- /dev/null +++ b/forums/templates/forums/forum_form.html @@ -0,0 +1,37 @@ +{% extends 'forums/base.html' %} + +{% load bootstrap %} + +{% block headsup %} +<script> +$(document).ready(function() { + +$("#id_name").keyup(function() { + slug_value = this.value.split(" ").join("_"); + $("#id_slug").val(slug_value); +}); + +}); +</script> +{% endblock headsup %} + +{% block breadcrumb_items %} +{{ block.super }} +<span class="breadcrumb-item">{% if form.instance.id %}Update {{ form.instance }}{% else %}Add new Forum{% endif %}</span> +{% endblock %} + +{% block pagetitle %}: Forums{% endblock pagetitle %} + + +{% block content %} + +<div class="row"> + <div class="col-12"> + <form action="" method="post"> + {% csrf_token %} + {{ form|bootstrap }} + <input type="submit" value="Submit" class="btn btn-primary"> + </div> +</div> + +{% endblock content %} diff --git a/forums/templates/forums/forum_list.html b/forums/templates/forums/forum_list.html new file mode 100644 index 0000000000000000000000000000000000000000..81e76c3a1f5fd688d31d1d538eb6c77749582fba --- /dev/null +++ b/forums/templates/forums/forum_list.html @@ -0,0 +1,93 @@ +{% extends 'forums/base.html' %} + +{% load bootstrap %} +{% load restructuredtext %} + + +{% block breadcrumb_items %} +{{ block.super }} +<span class="breadcrumb-item">Forums</span> +{% endblock %} + + +{% block pagetitle %}: Forums{% endblock pagetitle %} + +{% block content %} + +<div class="row"> + <div class="col-12"> + + <h3 class="highlight">Forums</h3> + {% if perms.forums.can_add_forum %} + <ul> + <li><a href="{% url 'forums:forum_create' %}">Create a new Forum</a></li> + <li><a href="{% url 'forums:meeting_create' %}">Create a new Meeting</a></li> + </ul> + {% endif %} + + <div class="card-columns"> + {% for forum in object_list %} + <div class="card"> + <div class="card-header"> + {% if forum.meeting %} + {% with context_colors=forum.meeting.context_colors %} + <span class="badge badge-{{ context_colors.bg }} mx-0 mb-2 p-2 text-{{ context_colors.text }}">{{ context_colors.message }}</span> + {% endwith %} + <br/> + {% endif %} + <span class="d-flex flex-wrap justify-content-between"> + <a href="{{ forum.get_absolute_url }}">{{ forum|truncatechars:30 }}</a> + <span class="badge badge-primary badge-pill">{% with nr_posts=forum.nr_posts %}{{ nr_posts }} post{{ nr_posts|pluralize }}{% endwith %}</span> + </span> + </div> + <div class="card-body"> + {{ forum.description|restructuredtext }} + {% if forum.child_forums.all|length > 0 %} + <hr/> + <p>Descendants:</p> + <ul class="list-unstyled forumList"> + {% for child in forum.child_forums.all %} + {% include 'forums/forum_as_li.html' with forum=child %} + {% endfor %} + </ul> + {% endif %} + </div> + </div> + {% empty %} + <p>No visible Forums found.</p> + {% endfor %} + </div> + </div> +</div> + +<div class="row"> + <div class="col-12"> + <h3 class="highlight">Latest postings</h3> + <table class="table"> + <thead class="thead-default"> + <tr> + <th>Forum</th> + <th>Latest post</th> + <th>Posted on</th> + <th>Nr posts</th> + </tr> + </thead> + <tbody> + {% for forum in object_list %} + <tr> + <td><a href="{{ forum.get_absolute_url }}">{{ forum }}</a></td> + <td>{{ forum.latest_post }}</td> + <td>{{ forum.latest_post.posted_on|date:"Y-m-d" }}</td> + <td><span class="badge badge-primary badge-pill">{{ forum.nr_posts }}</span></td> + </tr> + {% empty %} + <tr> + <td>No visible Posts found.</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> +</div> + +{% endblock content %} diff --git a/forums/templates/forums/forum_permissions.html b/forums/templates/forums/forum_permissions.html new file mode 100644 index 0000000000000000000000000000000000000000..55f0c5580b2655f0aced06f55af1bc7ac0bbe531 --- /dev/null +++ b/forums/templates/forums/forum_permissions.html @@ -0,0 +1,37 @@ +{% extends 'forums/base.html' %} + +{% load bootstrap %} +{% load guardian_tags %} + +{% block breadcrumb_items %} + {{ block.super }} +<span class="breadcrumb-item">Forum permissions</span> +{% endblock %} + +{% load scipost_extras %} + +{% block pagetitle %}: Forum permissions{% endblock pagetitle %} + + +{% block content %} + +<div class="row"> + <div class="col-12"> + + <h3 class="highlight">{{ forum.name }}: permissions{% if group %} for Group {{ group.name }}{% endif %}</h3> + + <form action="{% url 'forums:forum_permissions' slug=forum.slug %}" method="post"> + {% csrf_token %} + {{ form|bootstrap }} + <input type="submit" value="Assign permissions" class="btn btn-primary"> + </form> + + </div> +</div> + +{% endblock content %} + +{% block footer_script %} +{{ block.super }} +{{ form.media }} +{% endblock footer_script %} diff --git a/forums/templates/forums/post_card.html b/forums/templates/forums/post_card.html new file mode 100644 index 0000000000000000000000000000000000000000..78da1449f3b3de3c22d50cd9f098583781fc83f5 --- /dev/null +++ b/forums/templates/forums/post_card.html @@ -0,0 +1,80 @@ +{% load restructuredtext %} + +<div class="card {% if post.motion %}text-white bg-dark{% else %}text-body{% endif %}" id="post{{ post.id }}"> + <div class="card-header"> + {{ post.subject }} + <div class="postInfo"> + {{ post.posted_by.first_name }} {{ post.posted_by.last_name }} on {{ post.posted_on|date:"Y-m-d" }} + - <a href="{{ post.get_absolute_url }}"><i class="fa fa-link" title="permalink to this Post"></i></a> + {% if post.parent and not post.motion %} + - regarding <a href="{{ post.parent.get_absolute_url }}">{{ post.parent }}</a> + {% endif %} + </div> + </div> + <div class="card-body"> + {{ post.text|restructuredtext }} + </div> + <div class="card-footer"> + <div class="d-flex flex-wrap justify-content-end align-items-center"> + <div class="flex-grow-1"> + {% if post.followup_posts.all|length > 0 %} + <span class="badge badge-primary badge-pill">{% with nr_followups=post.nr_followups %}{{ nr_followups }} repl{{ nr_followups|pluralize:"y,ies" }}{% endwith %}</span> + {% endif %} + {% if post.post_ptr %} + <a href="{% url 'forums:post_create' slug=forum.slug parent_model='post' parent_id=post.post_ptr.id %}">{% if post.motion %}Post a Comment on this Motion{% else %}Reply to the above post{% endif %}</a> + {% else %} + <a href="{% url 'forums:post_create' slug=forum.slug parent_model='post' parent_id=post.id %}">{% if post.motion %}Post a Comment on this Motion{% else %}Reply to the above post{% endif %}</a> + {% endif %} + </div> + + {% if post.motion %} + <div class="align-self-center px-2"> + Voting results + <span class="text-white-50"> + {% if request.user in post.motion.in_agreement.all %} + <br/>You have voted <span class="text-success">Agree</span> + {% elif request.user in post.motion.in_doubt.all %} + <br/>You have voted <span class="text-warning">Doubt</span> + {% elif request.user in post.motion.in_disagreement.all %} + <br/>You have voted <span class="text-danger">Disagree</span> + {% elif request.user in post.motion.in_abstain.all %} + <br/>You have <span class="text-white-50">Abstained</span> + {% elif request.user in post.motion.eligible_for_voting.all %} + <br/>[click to vote] + {% endif %} + </span> + </div> + <div> + <form action="{% url 'forums:motion_vote' slug=forum.slug motion_id=post.motion.id vote='Y' %}" method="post">{% csrf_token %} + <input type="submit" class="btn btn-success" data-toggle="tooltip" data-placement="top" title="Agree" value="{{ post.motion.in_agreement.all|length }}"> + </form> + </div> + <div> + <form action="{% url 'forums:motion_vote' slug=forum.slug motion_id=post.motion.id vote='M' %}" method="post">{% csrf_token %} + <input type="submit" class="btn btn-warning" data-toggle="tooltip" data-placement="top" title="Doubt" value="{{ post.motion.in_doubt.all|length }}"> + </form> + </div> + <div> + <form action="{% url 'forums:motion_vote' slug=forum.slug motion_id=post.motion.id vote='N' %}" method="post">{% csrf_token %} + <input type="submit" class="btn btn-danger" data-toggle="tooltip" data-placement="top" title="Disagree" value="{{ post.motion.in_disagreement.all|length }}"> + </form> + </div> + <div> + <form action="{% url 'forums:motion_vote' slug=forum.slug motion_id=post.motion.id vote='A' %}" method="post">{% csrf_token %} + <input type="submit" class="btn btn-secondary" data-toggle="tooltip" data-placement="top" title="Abstain" value="{{ post.motion.in_abstain.all|length }}"> + </form> + </div> + <div class="align-self-center px-2"> + Voting deadline:<br/>{{ post.motion.voting_deadline|date:'Y-m-d' }} + </div> + {% endif %} + </div> + </div> + {% if post.followup_posts.all|length > 0 %} + <div class="followupPosts{% if post.motion %}AfterMotion{% endif %}"> + {% for followup in post.followup_posts.all %} + {% include 'forums/post_card.html' with post=followup %} + {% endfor %} + </div> + {% endif %} +</div> diff --git a/forums/templates/forums/post_confirm_create.html b/forums/templates/forums/post_confirm_create.html new file mode 100644 index 0000000000000000000000000000000000000000..145af977bfdfa1f595174601cc5f2a67085013b0 --- /dev/null +++ b/forums/templates/forums/post_confirm_create.html @@ -0,0 +1,58 @@ +{% extends 'forums/base.html' %} + +{% load bootstrap %} +{% load restructuredtext %} + + +{% block breadcrumb_items %} +{{ block.super }} +<span class="breadcrumb-item">Confirm Post creation</span> +{% endblock %} + +{% block pagetitle %}: Post: confirm creation{% endblock pagetitle %} + +{% block content %} + +<div class="row"> + <div class="col-12"> + <h3 class="highlight">Preview</h3> + + <div class="card"> + <div class="card-header"> + {{ form.initial.subject }} + </div> + <div class="card-body"> + {% if form.initial.text %} + {{ form.initial.text|restructuredtext }} + {% else %} + <span class="text-danger">No text given</span> + {% endif %} + </div> + </div> + + {% if form.errors %} + {% for field in form %} + {% for error in field.errors %} + <div class="alert alert-danger"> + <strong>{{ field.name }} - {{ error|escape }}</strong> + </div> + {% endfor %} + {% endfor %} + {% for error in form.non_field_errors %} + <div class="alert alert-danger"> + <strong>{{ error|escape }}</strong> + </div> + {% endfor %} + {% endif %} + + <form action="" method="post"> + {% csrf_token %} + {{ form|bootstrap }} + <input type="submit" value="Confirm Post creation" class="btn btn-primary"> + <span class="text-danger"> <strong>Not satisfied?</strong> Hit your browser's back button and redraft your Post</span> + </form> + + </div> +</div> + +{% endblock content %} diff --git a/forums/templates/forums/post_form.html b/forums/templates/forums/post_form.html new file mode 100644 index 0000000000000000000000000000000000000000..867b3e610d6ce4bc9dce735f54e7bd3a657de793 --- /dev/null +++ b/forums/templates/forums/post_form.html @@ -0,0 +1,27 @@ +{% extends 'forums/base.html' %} + +{% load bootstrap %} +{% load restructuredtext %} + + +{% block breadcrumb_items %} +{{ block.super }} +<span class="breadcrumb-item">{% if form.instance.id %}Update {{ form.instance }}{% else %}New Post{% endif %}</span> +{% endblock %} + +{% block pagetitle %}: Post{% endblock pagetitle %} + +{% block content %} + +<div class="row"> + <div class="col-12"> + <h3 class="highlight">Compose a new Post</h3> + <form action="" method="post"> + {% csrf_token %} + {{ form|bootstrap }} + <input type="submit" value="Run preview" class="btn btn-primary"> + </form> + </div> +</div> + +{% endblock content %} diff --git a/forums/tests.py b/forums/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..7ce503c2dd97ba78597f6ff6e4393132753573f6 --- /dev/null +++ b/forums/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/forums/urls.py b/forums/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..9d6e766cd90a953f3c2e26520d517bef9d048a40 --- /dev/null +++ b/forums/urls.py @@ -0,0 +1,85 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django.conf.urls import url + +from . import views + +urlpatterns = [ + url( + r'^forum/(?P<parent_model>[a-z]+)/(?P<parent_id>[0-9]+)/add/$', + views.ForumCreateView.as_view(), + name='forum_create' + ), + url( + r'^add/$', + views.ForumCreateView.as_view(), + name='forum_create' + ), + url( + r'^meeting/(?P<parent_model>[a-z]+)/(?P<parent_id>[0-9]+)/add/$', + views.MeetingCreateView.as_view(), + name='meeting_create' + ), + url( + r'^meeting/add/$', + views.MeetingCreateView.as_view(), + name='meeting_create' + ), + url( + r'^(?P<slug>[\w-]+)/$', + views.ForumDetailView.as_view(), + name='forum_detail' + ), + url( + r'^(?P<slug>[\w-]+)/update/$', + views.ForumUpdateView.as_view(), + name='forum_update' + ), + url( + r'^(?P<slug>[\w-]+)/delete/$', + views.ForumDeleteView.as_view(), + name='forum_delete' + ), + url( + r'^(?P<slug>[\w-]+)/permissions/(?P<group_id>[0-9]+)/$', + views.ForumPermissionsView.as_view(), + name='forum_permissions' + ), + url( + r'^(?P<slug>[\w-]+)/permissions/$', + views.ForumPermissionsView.as_view(), + name='forum_permissions' + ), + url( + r'^$', + views.ForumListView.as_view(), + name='forums' + ), + url( + r'^(?P<slug>[\w-]+)/post/(?P<parent_model>[a-z]+)/(?P<parent_id>[0-9]+)/add/$', + views.PostCreateView.as_view(), + name='post_create' + ), + url( + r'^(?P<slug>[\w-]+)/motion/(?P<parent_model>[a-z]+)/(?P<parent_id>[0-9]+)/add/$', + views.MotionCreateView.as_view(), + name='motion_create' + ), + url( + r'^(?P<slug>[\w-]+)/post/(?P<parent_model>[a-z]+)/(?P<parent_id>[0-9]+)/add/confirm/$', + views.PostConfirmCreateView.as_view(), + name='post_confirm_create' + ), + url( + r'^(?P<slug>[\w-]+)/motion/(?P<parent_model>[a-z]+)/(?P<parent_id>[0-9]+)/add/confirm/$', + views.MotionConfirmCreateView.as_view(), + name='motion_confirm_create' + ), + url( + r'^(?P<slug>[\w-]+)/motion/(?P<motion_id>[0-9]+)/(?P<vote>[YMNA])/$', + views.motion_vote, + name='motion_vote' + ), +] diff --git a/forums/views.py b/forums/views.py new file mode 100644 index 0000000000000000000000000000000000000000..dc9eb86728d7b1d623d1779db96f6280ec8aa782 --- /dev/null +++ b/forums/views.py @@ -0,0 +1,358 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +import datetime + +from django import forms +from django.contrib import messages +from django.contrib.auth.models import User, Group +from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import PermissionDenied +from django.core import serializers +from django.http import Http404 +from django.shortcuts import get_object_or_404, redirect +from django.urls import reverse, reverse_lazy +from django.utils import timezone +from django.views.generic.detail import DetailView +from django.views.generic.edit import CreateView, UpdateView, DeleteView +from django.views.generic.list import ListView + +from guardian.decorators import permission_required_or_403 +from guardian.mixins import PermissionRequiredMixin +from guardian.shortcuts import (assign_perm, remove_perm, + get_objects_for_user, get_perms, get_users_with_perms, get_groups_with_perms) + +from .models import Forum, Meeting, Post, Motion +from .forms import (ForumForm, ForumGroupPermissionsForm, ForumOrganizationPermissionsForm, + MeetingForm, PostForm, MotionForm) + +from scipost.mixins import PermissionsMixin + + +class ForumCreateView(PermissionsMixin, CreateView): + permission_required = 'forums.add_forum' + model = Forum + form_class = ForumForm + template_name = 'forums/forum_form.html' + success_url = reverse_lazy('forums:forums') + + def get_initial(self): + initial = super().get_initial() + parent_model = self.kwargs.get('parent_model') + parent_content_type = None + parent_object_id = self.kwargs.get('parent_id') + if parent_model == 'forum': + parent_content_type = ContentType.objects.get(app_label='forums', model='forum') + initial.update({ + 'moderators': self.request.user, + 'parent_content_type': parent_content_type, + 'parent_object_id': parent_object_id, + }) + return initial + + +class MeetingCreateView(ForumCreateView): + model = Meeting + form_class = MeetingForm + + +class ForumUpdateView(PermissionRequiredMixin, UpdateView): + permission_required = 'forums.update_forum' + template_name = 'forums/forum_form.html' + + def get_object(self, queryset=None): + try: + return Meeting.objects.get(slug=self.kwargs['slug']) + except Meeting.DoesNotExist: + return Forum.objects.get(slug=self.kwargs['slug']) + + def get_form(self, form_class=None): + try: + self.object.meeting + return MeetingForm(**self.get_form_kwargs()) + except Meeting.DoesNotExist: + return ForumForm(**self.get_form_kwargs()) + + +class ForumDeleteView(PermissionRequiredMixin, DeleteView): + permission_required = 'forums.delete_forum' + model = Forum + success_url = reverse_lazy('forums:forums') + + def delete(self, request, *args, **kwargs): + """ + A Forum can only be deleted if it does not have any descendants. + Upon deletion, all object-level permissions associated to the + Forum are explicitly removed, to avoid orphaned permissions. + """ + forum = get_object_or_404(Forum, slug=self.kwargs.get('slug')) + groups_perms_dict = get_groups_with_perms(forum, attach_perms=True) + if forum.child_forums.all().count() > 0: + messages.warning(request, 'A Forum with descendants cannot be deleted.') + return redirect(forum.get_absolute_url()) + for group, perms_list in groups_perms_dict.items(): + for perm in perms_list: + remove_perm(perm, group, forum) + return super().delete(request, *args, **kwargs) + + +class ForumDetailView(PermissionRequiredMixin, DetailView): + permission_required = 'forums.can_view_forum' + model = Forum + template_name = 'forums/forum_detail.html' + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + context['groups_with_perms'] = get_groups_with_perms(self.object).order_by('name') + context['users_with_perms'] = get_users_with_perms(self.object) + context['group_permissions_form'] = ForumGroupPermissionsForm() + context['organization_permissions_form'] = ForumOrganizationPermissionsForm() + return context + + +class ForumPermissionsView(PermissionRequiredMixin, UpdateView): + permission_required = 'forums.can_change_forum' + model = Forum + form_class = ForumGroupPermissionsForm + template_name = 'forums/forum_permissions.html' + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + try: + context['group'] = Group.objects.get(pk=self.kwargs.get('group_id')) + except Group.DoesNotExist: + pass + return context + + def get_initial(self, *args, **kwargs): + initial = super().get_initial(*args, **kwargs) + try: + group = Group.objects.get(pk=self.kwargs.get('group_id')) + perms = get_perms (group, self.object) + initial['group'] = group.id + initial['can_view'] = 'can_view_forum' in perms + initial['can_post'] = 'can_post_to_forum' in perms + except Group.DoesNotExist: + pass + return initial + + def form_valid(self, form): + if form.cleaned_data['can_view']: + assign_perm('can_view_forum', form.cleaned_data['group'], self.object) + else: + remove_perm('can_view_forum', form.cleaned_data['group'], self.object) + if form.cleaned_data['can_post']: + assign_perm('can_post_to_forum', form.cleaned_data['group'], self.object) + else: + remove_perm('can_post_to_forum', form.cleaned_data['group'], self.object) + return super().form_valid(form) + + +class ForumListView(LoginRequiredMixin, ListView): + model = Forum + template_name = 'forum_list.html' + + def get_queryset(self): + queryset = get_objects_for_user(self.request.user, 'forums.can_view_forum').anchors() + return queryset + + +class PostCreateView(UserPassesTestMixin, CreateView): + """ + First step of a two-step Post creation process. + This view, upon successful POST, redirects to the + PostConfirmCreateView confirmation view. + + To transfer form data from this view to the next (confirmation) one, + two session variables are used, ``post_subject`` and ``post_text``. + """ + model = Post + form_class= PostForm + + def test_func(self): + if self.request.user.has_perm('forums.add_forum'): + return True + forum = get_object_or_404(Forum, slug=self.kwargs.get('slug')) + if not self.request.user.has_perm('can_post_to_forum', forum): + raise PermissionDenied + # Only allow posting if it's within a Forum, or within an ongoing meeting. + try: + if datetime.date.today() > forum.meeting.date_until: + raise Http404('You cannot Post to a Meeting which is finished.') + elif datetime.date.today () < forum.meeting.date_from: + raise Http404('This meeting has not started yet, please come back later!') + except Meeting.DoesNotExist: + pass + return True + + + def get_initial(self, *args, **kwargs): + initial = super().get_initial(*args, **kwargs) + parent_model = self.kwargs.get('parent_model') + parent_object_id = self.kwargs.get('parent_id') + subject = '' + if parent_model == 'forum': + parent_content_type = ContentType.objects.get(app_label='forums', model='forum') + elif parent_model == 'post': + parent_content_type = ContentType.objects.get(app_label='forums', model='post') + parent = parent_content_type.get_object_for_this_type(pk=parent_object_id) + if parent.subject.startswith('Re: ...'): + subject = parent.subject + elif parent.subject.startswith('Re:'): + subject = '%s%s' % ('Re: ...', parent.subject.lstrip('Re:')) + else: + subject = 'Re: %s' % parent.subject + else: + raise Http404 + initial.update({ + 'posted_by': self.request.user, + 'posted_on': timezone.now(), + 'parent_content_type': parent_content_type, + 'parent_object_id': parent_object_id, + 'subject': subject, + }) + return initial + + def form_valid(self, form): + """ + Save the form data to session variables only, redirect to confirmation view. + """ + self.request.session['post_subject'] = form.cleaned_data['subject'] + self.request.session['post_text'] = form.cleaned_data['text'] + return redirect(reverse('forums:post_confirm_create', + kwargs={'slug': self.kwargs.get('slug'), + 'parent_model': self.kwargs.get('parent_model'), + 'parent_id': self.kwargs.get('parent_id')})) + + +class MotionCreateView(PostCreateView): + """ + Specialization of PostCreateView to Motion-class objects. + + By default, all users who can create a Post on the associated + Forum are given voting rights. + """ + model = Motion + form_class = MotionForm + template_name = 'forums/post_form.html' + + def get_initial(self, *args, **kwargs): + initial = super().get_initial(*args, **kwargs) + forum = get_object_or_404(Forum, slug=self.kwargs.get('slug')) + voters = get_users_with_perms(forum) + ineligible_ids = [] + for voter in voters.all(): + if not voter.has_perm('can_post_to_forum', forum): + ineligible_ids.append(voter.id) + initial.update({ + 'eligible_for_voting': voters.exclude(id__in=ineligible_ids), + }) + return initial + + def form_valid(self, form): + """ + Save the form data to session variables only, redirect to confirmation view. + """ + self.request.session['post_subject'] = form.cleaned_data['subject'] + self.request.session['post_text'] = form.cleaned_data['text'] + self.request.session['eligible_for_voting_ids'] = list( + form.cleaned_data['eligible_for_voting'].values_list('pk', flat=True)) + self.request.session['voting_deadline_year'] = form.cleaned_data['voting_deadline'].year + self.request.session['voting_deadline_month'] = form.cleaned_data['voting_deadline'].month + self.request.session['voting_deadline_day'] = form.cleaned_data['voting_deadline'].day + return redirect(reverse('forums:motion_confirm_create', + kwargs={'slug': self.kwargs.get('slug'), + 'parent_model': self.kwargs.get('parent_model'), + 'parent_id': self.kwargs.get('parent_id')})) + + +class PostConfirmCreateView(PostCreateView): + """ + Second (confirmation) step of Post creation process. + + Upon successful POST, the Post object is saved and the + two session variables ``post_subject`` and ``post_text`` are deleted. + """ + form_class = PostForm + template_name = 'forums/post_confirm_create.html' + + def get_form(self, form_class=None): + form = super().get_form(form_class) + form.fields['subject'].widget = forms.HiddenInput() + form.fields['text'].widget = forms.HiddenInput() + return form + + def get_initial(self, *args, **kwargs): + initial = super().get_initial(*args, **kwargs) + initial.update({ + 'subject': self.request.session.get('post_subject'), + 'text': self.request.session.get('post_text'), + }) + return initial + + def form_valid(self, form): + """ + After deleting the session variables used for the confirmation step, + simply perform the form_valid calls of form_valid from ancestor classes + ModelFormMixin and FormMixin, due to the fact that the form_valid + method in the PostCreateView superclass was overriden to a redirect. + """ + del self.request.session['post_subject'] + del self.request.session['post_text'] + self.object = form.save() + return redirect(self.get_success_url()) + + +class MotionConfirmCreateView(PostConfirmCreateView): + """ + Specialization of PostConfirmCreateView to Motion-class objects. + """ + form_class = MotionForm + + def get_initial(self, *args, **kwargs): + initial = super().get_initial(*args, **kwargs) + voting_deadline = datetime.date(self.request.session.get('voting_deadline_year'), + self.request.session.get('voting_deadline_month'), + self.request.session.get('voting_deadline_day')) + eligible_for_voting_ids = self.request.session.get('eligible_for_voting_ids') + eligible_for_voting = User.objects.filter(id__in=eligible_for_voting_ids) + initial.update({ + 'eligible_for_voting': eligible_for_voting, + 'voting_deadline': voting_deadline, + }) + return initial + + def form_valid(self, form): + del self.request.session['eligible_for_voting_ids'] + del self.request.session['voting_deadline_year'] + del self.request.session['voting_deadline_month'] + del self.request.session['voting_deadline_day'] + self.object = form.save() + return super().form_valid(form) + + +@permission_required_or_403('forums.can_post_to_forum', + (Forum, 'slug', 'slug')) +def motion_vote(request, slug, motion_id, vote): + motion = get_object_or_404(Motion, pk=motion_id) + if datetime.date.today() > motion.voting_deadline: + messages.warning(request, 'The voting deadline on this Motion has passed.') + elif motion.eligible_for_voting.filter(pk=request.user.id).exists(): + motion.in_agreement.remove(request.user) + motion.in_doubt.remove(request.user) + motion.in_disagreement.remove(request.user) + motion.in_abstain.remove(request.user) + if vote == 'Y': + motion.in_agreement.add(request.user) + elif vote == 'M': + motion.in_doubt.add(request.user) + elif vote == 'N': + motion.in_disagreement.add(request.user) + elif vote == 'A': + motion.in_abstain.add(request.user) + motion.save() + else: + messages.warning(request, 'You do not have voting rights on this Motion.') + return redirect(motion.get_absolute_url()) diff --git a/organizations/templates/organizations/dashboard.html b/organizations/templates/organizations/dashboard.html index 50201f2bc1a5cbdd2a222a361ad23c5bb0a98507..d0f01b9b68adfb988bdc291544e3ff610dafad44 100644 --- a/organizations/templates/organizations/dashboard.html +++ b/organizations/templates/organizations/dashboard.html @@ -45,6 +45,9 @@ $(document).ready(function($) { <li class="nav-item btn btn-outline-secondary"> <a href="{% url 'finances:subsidies' %}" class="nav-link" target="_blank">Subsidies<br/><span class="small text-muted">[full list]</span></a> </li> + <li class="nav-item btn btn-outline-secondary"> + <a href="{% url 'forums:forums' %}" class="nav-link" target="_blank">Forums<br/><span class="small text-muted">[discussion boards<br/>and meetings]</span></a> + </li> <li class="nav-item btn btn-outline-secondary"> <a href="#board" class="nav-link" data-toggle="tab">Sponsors Board<br/><span class="small text-muted">[registered Contacts]</span></a> </li> diff --git a/organizations/views.py b/organizations/views.py index df80d6f32dd7fee4722a2eae33c087c1283857c3..e93a4f1b7cf9efb5b44df8f0bf6451011dd297c1 100644 --- a/organizations/views.py +++ b/organizations/views.py @@ -8,7 +8,8 @@ from django.contrib.auth.mixins import UserPassesTestMixin from django.core.exceptions import PermissionDenied from django.core.urlresolvers import reverse_lazy from django.db import transaction -from django.shortcuts import get_object_or_404, render, reverse, redirect +from django.shortcuts import get_object_or_404, render, redirect +from django.urls import reverse from django.utils import timezone from django.views.generic.detail import DetailView from django.views.generic.edit import CreateView, UpdateView, DeleteView diff --git a/requirements.txt b/requirements.txt index 1cb0595fbc83ac7f0fb4fbe8f5aa25faa3b1ab6d..6484cb07dbde59a6afc55e5ffebdcc73ad982e1e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,7 @@ django-maintenancemode-2==1.1.11 # Documentation Packages -docutils==0.12 # What's this thing? +docutils==0.14 Pygments==2.2.0 # Syntax highlighter Sphinx==1.4.9 sphinx-rtd-theme==0.1.9 # Sphinx theme diff --git a/scipost/static/scipost/assets/css/_forums.scss b/scipost/static/scipost/assets/css/_forums.scss new file mode 100644 index 0000000000000000000000000000000000000000..f7629b9103c4be03c8b39f45753da737d214edd1 --- /dev/null +++ b/scipost/static/scipost/assets/css/_forums.scss @@ -0,0 +1,24 @@ +/** +* For forums +*/ + +.postInfo { + float: right; + font-size: 90%; +} + +.followupPosts { + border-left: 1px solid black; + margin: 1rem; + padding-left: 0.4rem; +} + +.followupPostsAfterMotion { + border-left: 1px solid white; + margin: 1rem; + padding-left: 0.4rem; +} + +.forumList { + padding-left: 0.4rem; +} diff --git a/scipost/static/scipost/assets/css/style.scss b/scipost/static/scipost/assets/css/style.scss index 20477d6909fb0efb2e305fa57f8abfae4e100525..0b24c7e6dcb36cce1f8fe3393b5a0252bde91952 100644 --- a/scipost/static/scipost/assets/css/style.scss +++ b/scipost/static/scipost/assets/css/style.scss @@ -22,6 +22,7 @@ @import "cards"; @import "code"; @import "form"; +@import "forums"; @import "grid"; @import "homepage"; @import "labels"; diff --git a/scipost/templatetags/lookup.py b/scipost/templatetags/lookup.py new file mode 100644 index 0000000000000000000000000000000000000000..5f481a1426a96e34f77660436cf5f96600eefdf6 --- /dev/null +++ b/scipost/templatetags/lookup.py @@ -0,0 +1,35 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django.contrib.auth.models import User, Group + +from ajax_select import register, LookupChannel + + +@register('user_lookup') +class UserLookup(LookupChannel): + model = User + + def get_query(self, q, request): + return self.model.objects.filter(last_name__icontains=q)[:10] + + def format_item_display(self, item): + return "<span class='auto_lookup_display'>%s, %s</span>" % (item.last_name, item.first_name) + + def format_match(self, item): + return "%s, %s" % (item.last_name, item.first_name) + + +@register('group_lookup') +class GroupLookup(LookupChannel): + model = Group + + def get_query(self, q, request): + return self.model.objects.filter(name__icontains=q)[:10] + + def format_item_display(self, item): + return "<span class='auto_lookup_display'>%s</span>" % item.name + + def format_match(self, item): + return item.name diff --git a/scipost/templatetags/restructuredtext.py b/scipost/templatetags/restructuredtext.py new file mode 100644 index 0000000000000000000000000000000000000000..a13399059e21a450dc9e25be57dcf159a253094a --- /dev/null +++ b/scipost/templatetags/restructuredtext.py @@ -0,0 +1,20 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django import template +from django.utils.encoding import force_text +from django.utils.safestring import mark_safe + + +register = template.Library() + + +@register.filter(name='restructuredtext') +def restructuredtext(text): + if not text: + return '' + from docutils.core import publish_parts + parts = publish_parts(source=text, + writer_name='html5_polyglot') + return mark_safe(force_text(parts['html_body']))