From d08c980970dac25de7a3cea2f02bf23994af4e9b Mon Sep 17 00:00:00 2001 From: Loek van Gent Date: Mon, 20 Sep 2021 08:32:31 +0200 Subject: [PATCH 01/88] First models for collect activities --- bluebottle/collect/__init__.py | 0 bluebottle/collect/admin.py | 78 ++++++++ bluebottle/collect/migrations/0001_initial.py | 42 ++++ bluebottle/collect/migrations/__init__.py | 0 bluebottle/collect/models.py | 67 +++++++ bluebottle/collect/states.py | 183 ++++++++++++++++++ bluebottle/settings/base.py | 1 + 7 files changed, 371 insertions(+) create mode 100644 bluebottle/collect/__init__.py create mode 100644 bluebottle/collect/admin.py create mode 100644 bluebottle/collect/migrations/0001_initial.py create mode 100644 bluebottle/collect/migrations/__init__.py create mode 100644 bluebottle/collect/models.py create mode 100644 bluebottle/collect/states.py diff --git a/bluebottle/collect/__init__.py b/bluebottle/collect/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/bluebottle/collect/admin.py b/bluebottle/collect/admin.py new file mode 100644 index 0000000000..05714e78a0 --- /dev/null +++ b/bluebottle/collect/admin.py @@ -0,0 +1,78 @@ +from django.contrib import admin +from django.urls import reverse +from django.utils.html import format_html +from django.utils.translation import gettext_lazy as _ +from django_summernote.widgets import SummernoteWidget + +from bluebottle.activities.admin import ActivityChildAdmin, ContributorChildAdmin +from bluebottle.collect.models import CollectContributor, CollectActivity +from bluebottle.fsm.forms import StateMachineModelForm +from bluebottle.utils.admin import export_as_csv_action + + +class CollectAdminForm(StateMachineModelForm): + class Meta(object): + model = CollectActivity + fields = '__all__' + widgets = { + 'description': SummernoteWidget(attrs={'height': 400}) + } + + +@admin.register(CollectContributor) +class CollectContributorAdmin(ContributorChildAdmin): + readonly_fields = ['created'] + raw_id_fields = ['user', 'activity'] + fields = ['activity', 'user', 'status', 'states'] + readonly_fields + list_display = ['__str__', 'activity_link', 'status'] + + +class CollectContributorInline(admin.TabularInline): + model = CollectContributor + raw_id_fields = ['user'] + readonly_fields = ['edit', 'created', 'status'] + fields = ['edit', 'user', 'created', 'status'] + extra = 0 + + def edit(self, obj): + url = reverse('admin:deeds_deedparticipant_change', args=(obj.id,)) + return format_html('{}', url, _('Edit participant')) + edit.short_description = _('Edit participant') + + +@admin.register(CollectActivity) +class CollectActivityAdmin(ActivityChildAdmin): + base_model = CollectActivity + form = CollectAdminForm + inlines = (CollectContributorInline,) + ActivityChildAdmin.inlines + list_filter = ['status'] + search_fields = ['title', 'description'] + + list_display = ActivityChildAdmin.list_display + [ + 'start', + 'end', + 'contributor_count', + ] + + def contributor_count(self, obj): + return obj.contributors.count() + contributor_count.short_description = _('Contributors') + + detail_fields = ActivityChildAdmin.detail_fields + ( + 'start', + 'end', + ) + + export_as_csv_fields = ( + ('title', 'Title'), + ('description', 'Description'), + ('status', 'Status'), + ('created', 'Created'), + ('initiative__title', 'Initiative'), + ('owner__full_name', 'Owner'), + ('owner__email', 'Email'), + ('start', 'Start'), + ('end', 'End'), + ) + + actions = [export_as_csv_action(fields=export_as_csv_fields)] diff --git a/bluebottle/collect/migrations/0001_initial.py b/bluebottle/collect/migrations/0001_initial.py new file mode 100644 index 0000000000..7f116a766d --- /dev/null +++ b/bluebottle/collect/migrations/0001_initial.py @@ -0,0 +1,42 @@ +# Generated by Django 2.2.24 on 2021-09-20 06:30 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('activities', '0045_auto_20210920_0830'), + ] + + operations = [ + migrations.CreateModel( + name='CollectActivity', + fields=[ + ('activity_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='activities.Activity')), + ('start', models.DateField(blank=True, null=True)), + ('end', models.DateField(blank=True, null=True)), + ], + options={ + 'verbose_name': 'Collect', + 'verbose_name_plural': 'Collects', + 'permissions': (('api_read_collect', 'Can view collect activity through the API'), ('api_add_collect', 'Can add collect activity through the API'), ('api_change_collect', 'Can change collect activity through the API'), ('api_delete_collect', 'Can delete collect activity through the API'), ('api_read_own_collect', 'Can view own collect activity through the API'), ('api_add_own_collect', 'Can add own collect activity through the API'), ('api_change_own_collect', 'Can change own collect activity through the API'), ('api_delete_own_collect', 'Can delete own collect activity through the API')), + }, + bases=('activities.activity',), + ), + migrations.CreateModel( + name='CollectContributor', + fields=[ + ('contributor_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='activities.Contributor')), + ], + options={ + 'verbose_name': 'Contributor', + 'verbose_name_plural': 'Contributors', + 'permissions': (('api_read_collectcontributor', 'Can view collect through the API'), ('api_add_collectcontributor', 'Can add collect through the API'), ('api_change_collectcontributor', 'Can change collect through the API'), ('api_delete_collectcontributor', 'Can delete collect through the API'), ('api_read_own_collectcontributor', 'Can view own collect through the API'), ('api_add_own_collectcontributor', 'Can add own collect through the API'), ('api_change_own_collectcontributor', 'Can change own collect through the API'), ('api_delete_own_collectcontributor', 'Can delete own collect through the API')), + }, + bases=('activities.contributor',), + ), + ] diff --git a/bluebottle/collect/migrations/__init__.py b/bluebottle/collect/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/bluebottle/collect/models.py b/bluebottle/collect/models.py new file mode 100644 index 0000000000..78e543ad9f --- /dev/null +++ b/bluebottle/collect/models.py @@ -0,0 +1,67 @@ +from django.db import models + +from django.utils.translation import gettext_lazy as _ + +from bluebottle.activities.models import Activity, Contributor, EffortContribution + + +class CollectActivity(Activity): + + start = models.DateField(blank=True, null=True) + end = models.DateField(blank=True, null=True) + + auto_approve = True + + @property + def activity_date(self): + return self.start + + class Meta(object): + verbose_name = _("Collect Activity") + verbose_name_plural = _("Collect Activities") + permissions = ( + ('api_read_collect', 'Can view collect activity through the API'), + ('api_add_collect', 'Can add collect activity through the API'), + ('api_change_collect', 'Can change collect activity through the API'), + ('api_delete_collect', 'Can delete collect activity through the API'), + + ('api_read_own_collect', 'Can view own collect activity through the API'), + ('api_add_own_collect', 'Can add own collect activity through the API'), + ('api_change_own_collect', 'Can change own collect activity through the API'), + ('api_delete_own_collect', 'Can delete own collect activity through the API'), + ) + + class JSONAPIMeta(object): + resource_name = 'activities/collects' + + @property + def required_fields(self): + return super().required_fields + ['title', 'description'] + + @property + def efforts(self): + return EffortContribution.objects.filter( + contributor__activity=self, + contribution_type='collect' + ) + + +class CollectContributor(Contributor): + class Meta(object): + verbose_name = _("Contributor") + verbose_name_plural = _("Contributors") + + permissions = ( + ('api_read_collectcontributor', 'Can view collect through the API'), + ('api_add_collectcontributor', 'Can add collect through the API'), + ('api_change_collectcontributor', 'Can change collect through the API'), + ('api_delete_collectcontributor', 'Can delete collect through the API'), + + ('api_read_own_collectcontributor', 'Can view own collect through the API'), + ('api_add_own_collectcontributor', 'Can add own collect through the API'), + ('api_change_own_collectcontributor', 'Can change own collect through the API'), + ('api_delete_own_collectcontributor', 'Can delete own collect through the API'), + ) + + class JSONAPIMeta(object): + resource_name = 'contributors/collects/contributor' diff --git a/bluebottle/collect/states.py b/bluebottle/collect/states.py new file mode 100644 index 0000000000..176a3d08f1 --- /dev/null +++ b/bluebottle/collect/states.py @@ -0,0 +1,183 @@ +from django.utils.translation import gettext_lazy as _ + +from bluebottle.activities.states import ActivityStateMachine, ContributorStateMachine +from bluebottle.collect.models import CollectActivity, CollectContributor +from bluebottle.fsm.state import register, State, Transition, EmptyState + + +@register(CollectActivity) +class CollectActivityStateMachine(ActivityStateMachine): + def has_no_end_date(self): + """ + Has no end date + """ + return self.instance.end is None + + succeed = Transition( + [ActivityStateMachine.open, ActivityStateMachine.expired], + ActivityStateMachine.succeeded, + name=_('Succeed'), + automatic=True, + ) + + expire = Transition( + [ + ActivityStateMachine.open, ActivityStateMachine.submitted, + ActivityStateMachine.succeeded + ], + ActivityStateMachine.expired, + name=_('Expire'), + description=_( + "The activity will be cancelled because no one has signed up." + ), + automatic=True, + ) + + succeed_manually = Transition( + ActivityStateMachine.open, + ActivityStateMachine.succeeded, + automatic=False, + name=_("succeed"), + conditions=[has_no_end_date], + permission=ActivityStateMachine.is_owner, + description=_("Succeed the activity.") + ) + + reopen = Transition( + [ + ActivityStateMachine.expired, + ActivityStateMachine.succeeded, + ], + ActivityStateMachine.open, + name=_("Reopen"), + description=_("Reopen the activity.") + ) + + reopen_manually = Transition( + [ActivityStateMachine.succeeded, ActivityStateMachine.expired], + ActivityStateMachine.draft, + name=_("Reopen"), + permission=ActivityStateMachine.is_owner, + automatic=False, + description=_( + "Manually reopen the activity. " + "This will unset the end date if the date is in the past. " + "People can contribute again." + ) + ) + + cancel = Transition( + [ + ActivityStateMachine.open, + ActivityStateMachine.succeeded, + ], + ActivityStateMachine.cancelled, + name=_('Cancel'), + permission=ActivityStateMachine.is_owner, + description=_( + 'Cancel if the activity will not be executed. ' + 'An activity manager can no longer edit the activity ' + 'and it will no longer be visible on the platform. ' + 'The activity will still be visible in the back office ' + 'and will continue to count in the reporting.' + ), + automatic=False, + ) + + +@register(CollectContributor) +class CollectContributorStateMachine(ContributorStateMachine): + withdrawn = State( + _('Cancelled'), + 'withdrawn', + _('This person has cancelled.') + ) + rejected = State( + _('Removed'), + 'rejected', + _('This person has been removed from the activity.') + ) + accepted = State( + _('Contributing'), + 'accepted', + _('This person has been signed up for the activity.') + ) + + def is_user(self, user): + """is participant""" + return self.instance.user == user + + def is_owner(self, user): + """is participant""" + return ( + self.instance.activity.owner == user or + self.instance.activity.initiative.owner == user or + user in self.instance.activity.initiative.activity_managers.all() or + user.is_staff + ) + + def activity_is_open(self): + """task is open""" + return self.instance.activity.status == CollectActivityStateMachine.open.value, + + initiate = Transition( + EmptyState(), + accepted, + name=_('initiate'), + description=_('The contribution was created.') + ) + + succeed = Transition( + accepted, + ContributorStateMachine.succeeded, + name=_('Succeed'), + automatic=True, + ) + + re_accept = Transition( + ContributorStateMachine.succeeded, + accepted, + name=_('Re-accept'), + automatic=True, + ) + + withdraw = Transition( + [ContributorStateMachine.succeeded, accepted], + withdrawn, + name=_('Withdraw'), + description=_("Cancel your contribution to this activity."), + automatic=False, + permission=is_user, + hide_from_admin=True, + ) + + reapply = Transition( + withdrawn, + accepted, + name=_('Reapply'), + description=_("User re-applies after previously cancelling."), + automatic=False, + conditions=[activity_is_open], + permission=is_user, + ) + + remove = Transition( + [ + accepted, + ContributorStateMachine.succeeded + ], + rejected, + name=_('Remove'), + description=_("Remove contributor from the activity."), + automatic=False, + permission=is_owner, + ) + + accept = Transition( + rejected, + accepted, + name=_('Re-Accept'), + description=_("User is re-accepted after previously withdrawing."), + automatic=False, + permission=is_owner, + ) diff --git a/bluebottle/settings/base.py b/bluebottle/settings/base.py index 6f5ee754fb..1fd7859002 100644 --- a/bluebottle/settings/base.py +++ b/bluebottle/settings/base.py @@ -345,6 +345,7 @@ 'bluebottle.activities', 'bluebottle.initiatives', 'bluebottle.time_based', + 'bluebottle.collect', 'bluebottle.deeds', 'bluebottle.events', 'bluebottle.assignments', From 511705c9c5caf083eb596c6fd67a31144af078c7 Mon Sep 17 00:00:00 2001 From: Loek van Gent Date: Mon, 20 Sep 2021 10:04:20 +0200 Subject: [PATCH 02/88] Add Collect type --- bluebottle/collect/admin.py | 37 +++++++++---- .../migrations/0002_auto_20210920_0917.py | 22 ++++++++ .../migrations/0003_auto_20210920_0922.py | 52 +++++++++++++++++++ bluebottle/collect/models.py | 25 +++++++++ 4 files changed, 127 insertions(+), 9 deletions(-) create mode 100644 bluebottle/collect/migrations/0002_auto_20210920_0917.py create mode 100644 bluebottle/collect/migrations/0003_auto_20210920_0922.py diff --git a/bluebottle/collect/admin.py b/bluebottle/collect/admin.py index 05714e78a0..78e56c75d8 100644 --- a/bluebottle/collect/admin.py +++ b/bluebottle/collect/admin.py @@ -3,9 +3,10 @@ from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ from django_summernote.widgets import SummernoteWidget +from parler.admin import TranslatableAdmin from bluebottle.activities.admin import ActivityChildAdmin, ContributorChildAdmin -from bluebottle.collect.models import CollectContributor, CollectActivity +from bluebottle.collect.models import CollectContributor, CollectActivity, CollectType from bluebottle.fsm.forms import StateMachineModelForm from bluebottle.utils.admin import export_as_csv_action @@ -23,21 +24,21 @@ class Meta(object): class CollectContributorAdmin(ContributorChildAdmin): readonly_fields = ['created'] raw_id_fields = ['user', 'activity'] - fields = ['activity', 'user', 'status', 'states'] + readonly_fields - list_display = ['__str__', 'activity_link', 'status'] + fields = ['activity', 'user', 'value', 'status', 'states'] + readonly_fields + list_display = ['__str__', 'activity_link', 'status', 'value'] class CollectContributorInline(admin.TabularInline): model = CollectContributor raw_id_fields = ['user'] - readonly_fields = ['edit', 'created', 'status'] - fields = ['edit', 'user', 'created', 'status'] + readonly_fields = ['edit', 'created', 'transition_date', 'contributor_date', 'status'] + fields = ['edit', 'user', 'value', 'created', 'status'] extra = 0 def edit(self, obj): - url = reverse('admin:deeds_deedparticipant_change', args=(obj.id,)) - return format_html('{}', url, _('Edit participant')) - edit.short_description = _('Edit participant') + url = reverse('admin:collect_collectcontributor_change', args=(obj.id,)) + return format_html('{}', url, _('Edit contributor')) + edit.short_description = _('Edit contributor') @admin.register(CollectActivity) @@ -45,12 +46,13 @@ class CollectActivityAdmin(ActivityChildAdmin): base_model = CollectActivity form = CollectAdminForm inlines = (CollectContributorInline,) + ActivityChildAdmin.inlines - list_filter = ['status'] + list_filter = ['status', 'type'] search_fields = ['title', 'description'] list_display = ActivityChildAdmin.list_display + [ 'start', 'end', + 'type', 'contributor_count', ] @@ -62,6 +64,9 @@ def contributor_count(self, obj): 'start', 'end', ) + description_fields = ActivityChildAdmin.description_fields + ( + 'type', + ) export_as_csv_fields = ( ('title', 'Title'), @@ -71,8 +76,22 @@ def contributor_count(self, obj): ('initiative__title', 'Initiative'), ('owner__full_name', 'Owner'), ('owner__email', 'Email'), + ('type', 'Type'), ('start', 'Start'), ('end', 'End'), ) actions = [export_as_csv_action(fields=export_as_csv_fields)] + + +@admin.register(CollectType) +class CollectTypeAdmin(TranslatableAdmin): + list_display = admin.ModelAdmin.list_display + ('activity_link',) + readonly_fields = ('activity_link',) + fields = ('name', 'description') + readonly_fields + + def activity_link(self, obj): + url = "{}?type__id__exact={}".format(reverse('admin:collect_collectactivity_changelist'), obj.id) + return format_html("{} activities".format(url, obj.collectactivity_set.count())) + + activity_link.short_description = _('Activity') diff --git a/bluebottle/collect/migrations/0002_auto_20210920_0917.py b/bluebottle/collect/migrations/0002_auto_20210920_0917.py new file mode 100644 index 0000000000..568249bc4b --- /dev/null +++ b/bluebottle/collect/migrations/0002_auto_20210920_0917.py @@ -0,0 +1,22 @@ +# Generated by Django 2.2.24 on 2021-09-20 07:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('collect', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='collectactivity', + options={'permissions': (('api_read_collect', 'Can view collect activity through the API'), ('api_add_collect', 'Can add collect activity through the API'), ('api_change_collect', 'Can change collect activity through the API'), ('api_delete_collect', 'Can delete collect activity through the API'), ('api_read_own_collect', 'Can view own collect activity through the API'), ('api_add_own_collect', 'Can add own collect activity through the API'), ('api_change_own_collect', 'Can change own collect activity through the API'), ('api_delete_own_collect', 'Can delete own collect activity through the API')), 'verbose_name': 'Collect Activity', 'verbose_name_plural': 'Collect Activities'}, + ), + migrations.AddField( + model_name='collectcontributor', + name='value', + field=models.DecimalField(blank=True, decimal_places=5, max_digits=12, null=True), + ), + ] diff --git a/bluebottle/collect/migrations/0003_auto_20210920_0922.py b/bluebottle/collect/migrations/0003_auto_20210920_0922.py new file mode 100644 index 0000000000..bef0c4a58c --- /dev/null +++ b/bluebottle/collect/migrations/0003_auto_20210920_0922.py @@ -0,0 +1,52 @@ +# Generated by Django 2.2.24 on 2021-09-20 07:22 + +from django.db import migrations, models +import django.db.models.deletion +import parler.fields +import parler.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('collect', '0002_auto_20210920_0917'), + ] + + operations = [ + migrations.CreateModel( + name='CollectType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ], + options={ + 'verbose_name': 'collect type', + 'verbose_name_plural': 'collect types', + 'permissions': (('api_read_collect_type', 'Can view collect type through API'),), + }, + bases=(parler.models.TranslatableModelMixin, models.Model), + ), + migrations.AddField( + model_name='collectactivity', + name='type', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='collect.CollectType'), + ), + migrations.CreateModel( + name='CollectTypeTranslation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('language_code', models.CharField(db_index=True, max_length=15, verbose_name='Language')), + ('name', models.CharField(max_length=100, verbose_name='name')), + ('description', models.TextField(blank=True, verbose_name='description')), + ('master', parler.fields.TranslationsForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='translations', to='collect.CollectType')), + ], + options={ + 'verbose_name': 'collect type Translation', + 'db_table': 'collect_collecttype_translation', + 'db_tablespace': '', + 'managed': True, + 'default_permissions': (), + 'unique_together': {('language_code', 'master')}, + }, + bases=(parler.models.TranslatedFieldsModelMixin, models.Model), + ), + ] diff --git a/bluebottle/collect/models.py b/bluebottle/collect/models.py index 78e543ad9f..aca89a0ae6 100644 --- a/bluebottle/collect/models.py +++ b/bluebottle/collect/models.py @@ -1,8 +1,28 @@ from django.db import models +from django.db.models import SET_NULL from django.utils.translation import gettext_lazy as _ +from parler.models import TranslatedFields from bluebottle.activities.models import Activity, Contributor, EffortContribution +from bluebottle.utils.models import SortableTranslatableModel + + +class CollectType(SortableTranslatableModel): + translations = TranslatedFields( + name=models.CharField(_('name'), max_length=100), + description=models.TextField(_('description'), blank=True) + ) + + def __str__(self): + return self.name + + class Meta(object): + verbose_name = _('collect type') + verbose_name_plural = _('collect types') + permissions = ( + ('api_read_collect_type', 'Can view collect type through API'), + ) class CollectActivity(Activity): @@ -10,6 +30,8 @@ class CollectActivity(Activity): start = models.DateField(blank=True, null=True) end = models.DateField(blank=True, null=True) + type = models.ForeignKey(CollectType, null=True, blank=True, on_delete=SET_NULL) + auto_approve = True @property @@ -47,6 +69,9 @@ def efforts(self): class CollectContributor(Contributor): + + value = models.DecimalField(null=True, blank=True, decimal_places=5, max_digits=12) + class Meta(object): verbose_name = _("Contributor") verbose_name_plural = _("Contributors") From 333146a76b220d4d7ba2415e35d867bc98581614 Mon Sep 17 00:00:00 2001 From: Loek van Gent Date: Wed, 22 Sep 2021 11:38:34 +0200 Subject: [PATCH 03/88] Fix migration --- bluebottle/collect/migrations/0001_initial.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bluebottle/collect/migrations/0001_initial.py b/bluebottle/collect/migrations/0001_initial.py index 7f116a766d..c53e3c48cf 100644 --- a/bluebottle/collect/migrations/0001_initial.py +++ b/bluebottle/collect/migrations/0001_initial.py @@ -9,7 +9,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('activities', '0045_auto_20210920_0830'), + ('activities', '0044_activity_office_location'), ] operations = [ From 62d20384981cab08b58c26bc08f8a06a38de2ff1 Mon Sep 17 00:00:00 2001 From: Loek van Gent Date: Wed, 22 Sep 2021 15:32:35 +0200 Subject: [PATCH 04/88] Add tests --- .../migrations/0003_auto_20210920_0922.py | 2 +- .../migrations/0004_auto_20210922_1502.py | 19 +++++++ .../migrations/0005_auto_20210922_1502.py | 51 +++++++++++++++++++ bluebottle/collect/models.py | 2 +- bluebottle/collect/tests/__init__.py | 0 bluebottle/collect/tests/factories.py | 30 +++++++++++ bluebottle/collect/tests/test_admin.py | 46 +++++++++++++++++ bluebottle/initiatives/models.py | 5 ++ bluebottle/settings/admin_dashboard.py | 16 ++++++ 9 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 bluebottle/collect/migrations/0004_auto_20210922_1502.py create mode 100644 bluebottle/collect/migrations/0005_auto_20210922_1502.py create mode 100644 bluebottle/collect/tests/__init__.py create mode 100644 bluebottle/collect/tests/factories.py create mode 100644 bluebottle/collect/tests/test_admin.py diff --git a/bluebottle/collect/migrations/0003_auto_20210920_0922.py b/bluebottle/collect/migrations/0003_auto_20210920_0922.py index bef0c4a58c..60848cf3dc 100644 --- a/bluebottle/collect/migrations/0003_auto_20210920_0922.py +++ b/bluebottle/collect/migrations/0003_auto_20210920_0922.py @@ -21,7 +21,7 @@ class Migration(migrations.Migration): options={ 'verbose_name': 'collect type', 'verbose_name_plural': 'collect types', - 'permissions': (('api_read_collect_type', 'Can view collect type through API'),), + 'permissions': (('api_read_collecttype', 'Can view collect type through API'),), }, bases=(parler.models.TranslatableModelMixin, models.Model), ), diff --git a/bluebottle/collect/migrations/0004_auto_20210922_1502.py b/bluebottle/collect/migrations/0004_auto_20210922_1502.py new file mode 100644 index 0000000000..f6d43f54ed --- /dev/null +++ b/bluebottle/collect/migrations/0004_auto_20210922_1502.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.24 on 2021-09-22 13:02 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('collect', '0003_auto_20210920_0922'), + ] + + operations = [ + migrations.AlterField( + model_name='collectactivity', + name='type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='collect.CollectType'), + ), + ] diff --git a/bluebottle/collect/migrations/0005_auto_20210922_1502.py b/bluebottle/collect/migrations/0005_auto_20210922_1502.py new file mode 100644 index 0000000000..4539ed2ea7 --- /dev/null +++ b/bluebottle/collect/migrations/0005_auto_20210922_1502.py @@ -0,0 +1,51 @@ +# Generated by Django 2.2.24 on 2021-09-22 13:02 + +from django.db import migrations, connection + +from bluebottle.clients import properties +from bluebottle.clients.models import Client +from bluebottle.clients.utils import LocalTenant +from bluebottle.utils.utils import update_group_permissions + + +def add_group_permissions(apps, schema_editor): + tenant = Client.objects.get(schema_name=connection.tenant.schema_name) + with LocalTenant(tenant): + group_perms = { + 'Staff': { + 'perms': ( + 'add_collectactivity', 'change_collectactivity', 'delete_collectactivity', + 'add_collectcontributor', 'change_collectcontributor', 'delete_collectcontributor', + 'add_collecttype', 'change_collecttype', 'delete_collecttype', + ) + }, + 'Anonymous': { + 'perms': ( + 'api_read_collect', 'api_read_collecttype', 'api_read_collectcontributor' + ) if not properties.CLOSED_SITE else () + }, + 'Authenticated': { + 'perms': ( + 'api_read_collect', 'api_add_own_collect', 'api_change_own_collect', 'api_delete_own_collect', + 'api_read_collectcontributor', 'api_add_own_collectcontributor', + 'api_change_own_collectcontributor', 'api_delete_own_collectcontributor', + 'api_read_collecttype', + ) + } + } + + update_group_permissions('collect', group_perms, apps) + + +class Migration(migrations.Migration): + + dependencies = [ + ('collect', '0004_auto_20210922_1502'), + ] + + operations = [ + migrations.RunPython( + add_group_permissions, + migrations.RunPython.noop + ) + ] diff --git a/bluebottle/collect/models.py b/bluebottle/collect/models.py index aca89a0ae6..294f4dca37 100644 --- a/bluebottle/collect/models.py +++ b/bluebottle/collect/models.py @@ -21,7 +21,7 @@ class Meta(object): verbose_name = _('collect type') verbose_name_plural = _('collect types') permissions = ( - ('api_read_collect_type', 'Can view collect type through API'), + ('api_read_collecttype', 'Can view collect type through API'), ) diff --git a/bluebottle/collect/tests/__init__.py b/bluebottle/collect/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/bluebottle/collect/tests/factories.py b/bluebottle/collect/tests/factories.py new file mode 100644 index 0000000000..55881b38b9 --- /dev/null +++ b/bluebottle/collect/tests/factories.py @@ -0,0 +1,30 @@ +from builtins import object + +import factory.fuzzy +from pytz import UTC + +from bluebottle.collect.models import CollectActivity, CollectContributor +from bluebottle.initiatives.tests.factories import InitiativeFactory +from bluebottle.test.factory_models.accounts import BlueBottleUserFactory + + +class CollectActivityFactory(factory.DjangoModelFactory): + class Meta(object): + model = CollectActivity + + title = factory.Faker('sentence') + slug = factory.Faker('slug') + description = factory.Faker('text') + + owner = factory.SubFactory(BlueBottleUserFactory) + initiative = factory.SubFactory(InitiativeFactory) + start = factory.Faker('future_date', end_date="+20d", tzinfo=UTC) + end = factory.Faker('future_date', end_date="+2d", tzinfo=UTC) + + +class CollectContributorFactory(factory.DjangoModelFactory): + class Meta(object): + model = CollectContributor + + activity = factory.SubFactory(CollectActivityFactory) + user = factory.SubFactory(BlueBottleUserFactory) diff --git a/bluebottle/collect/tests/test_admin.py b/bluebottle/collect/tests/test_admin.py new file mode 100644 index 0000000000..3a5cbf829a --- /dev/null +++ b/bluebottle/collect/tests/test_admin.py @@ -0,0 +1,46 @@ +from django.urls import reverse + +from bluebottle.collect.models import CollectActivity +from bluebottle.initiatives.models import InitiativePlatformSettings +from bluebottle.initiatives.tests.factories import InitiativeFactory +from bluebottle.test.factory_models.accounts import BlueBottleUserFactory +from bluebottle.test.utils import BluebottleAdminTestCase + + +class CollectActivityAdminTestCase(BluebottleAdminTestCase): + + extra_environ = {} + csrf_checks = False + setup_auth = True + + def setUp(self): + super().setUp() + self.app.set_user(self.staff_member) + self.initiative = InitiativeFactory.create(status='approved') + self.owner = BlueBottleUserFactory.create() + + def test_admin_collect_feature_flag(self): + initiative_settings = InitiativePlatformSettings.load() + initiative_settings.activity_types = ['dateactivity', 'periodactivity'] + initiative_settings.save() + url = reverse('admin:index') + page = self.app.get(url) + + self.assertFalse('Collect Activities' in page.text) + + initiative_settings.activity_types = ['dateactivity', 'periodactivity', 'collectactivity'] + initiative_settings.save() + url = reverse('admin:index') + page = self.app.get(url) + self.assertTrue('Collect Activities' in page.text) + + def test_admin_create_collect(self): + url = reverse('admin:collect_collectactivity_changelist') + page = self.app.get(url) + page = page.click('Add Collect Activity') + form = page.forms['collectactivity_form'] + form['title'] = 'A small step' + form['initiative'] = self.initiative.id + form['owner'] = self.owner.id + form.submit() + self.assertEqual(CollectActivity.objects.count(), 1) diff --git a/bluebottle/initiatives/models.py b/bluebottle/initiatives/models.py index c73d036e75..5484955825 100644 --- a/bluebottle/initiatives/models.py +++ b/bluebottle/initiatives/models.py @@ -249,6 +249,7 @@ class InitiativePlatformSettings(BasePlatformSettings): ('periodactivity', _('Activity during a period')), ('dateactivity', _('Activity on a specific date')), ('deed', _('Deed')), + ('collect', _('Collect activity')), ) ACTIVITY_SEARCH_FILTERS = ( @@ -308,6 +309,10 @@ class InitiativePlatformSettings(BasePlatformSettings): def deeds_enabled(self): return 'deed' in self.activity_types + @property + def collect_enabled(self): + return 'collect' in self.activity_types + class Meta(object): verbose_name_plural = _('initiative settings') verbose_name = _('initiative settings') diff --git a/bluebottle/settings/admin_dashboard.py b/bluebottle/settings/admin_dashboard.py index 10c7f3468c..22e3c622d2 100644 --- a/bluebottle/settings/admin_dashboard.py +++ b/bluebottle/settings/admin_dashboard.py @@ -85,6 +85,22 @@ 'name': 'deeds.deed', 'permissions': ['deeds.change_deed'] }, + { + 'name': 'deeds.deedparticipant', + 'permissions': ['deeds.change_deedparticipant'] + }, + ] + }, + { + 'label': _('Collect'), + 'app_label': 'collect', + 'permissions': ['collect.change_collectactivity'], + 'enabled': 'initiatives.InitiativePlatformSettings.collect_enabled', + 'items': [ + { + 'name': 'collect.collectactivity', + 'permissions': ['collect.change_collectactivity'] + }, ] }, { From 823aac091d8bd4b76c0b593168ff2fff01c7d2c2 Mon Sep 17 00:00:00 2001 From: Loek van Gent Date: Thu, 23 Sep 2021 10:19:09 +0200 Subject: [PATCH 05/88] Add/fix tests for collect menu --- bluebottle/bluebottle_dashboard/utils.py | 18 +++++++------- bluebottle/collect/tests/test_admin.py | 7 +++--- bluebottle/initiatives/models.py | 4 ++++ bluebottle/settings/admin_dashboard.py | 30 +++++++++++++----------- 4 files changed, 33 insertions(+), 26 deletions(-) diff --git a/bluebottle/bluebottle_dashboard/utils.py b/bluebottle/bluebottle_dashboard/utils.py index e5c12a10b6..fb0b2ad791 100644 --- a/bluebottle/bluebottle_dashboard/utils.py +++ b/bluebottle/bluebottle_dashboard/utils.py @@ -33,24 +33,19 @@ def get_menu_items(context): Iterate over menu items and remove some based on feature flags """ groups = jet_get_menu_items(context) - i = 0 for group in groups: - j = 0 properties = get_jet_item(group['label']) if 'enabled' in properties and properties['enabled']: prop = get_feature_flag(properties['enabled']) if not prop: - del groups[i] - i += 1 - continue + group['hide'] = True for item in group['items']: name = item.get('name', None) or item.get('url', None) properties = get_jet_item(group['label'], name) if properties and 'enabled' in properties and properties['enabled']: prop = get_feature_flag(properties['enabled']) if not prop: - del groups[i]['items'][j] - j += 1 + item['hide'] = True if group['app_label'] == 'looker': group['items'] = [{ 'url': reverse('jet-dashboard:looker-embed', args=(look.id,)), @@ -61,5 +56,12 @@ def get_menu_items(context): 'has_perms': True, 'current': False} for look in LookerEmbed.objects.all() ] - i += 1 + + for group in list(groups): + if 'hide' in group: + groups.remove(group) + else: + for item in list(group['items']): + if 'hide' in item: + group['items'].remove(item) return groups diff --git a/bluebottle/collect/tests/test_admin.py b/bluebottle/collect/tests/test_admin.py index 3a5cbf829a..3918377bcf 100644 --- a/bluebottle/collect/tests/test_admin.py +++ b/bluebottle/collect/tests/test_admin.py @@ -25,14 +25,13 @@ def test_admin_collect_feature_flag(self): initiative_settings.save() url = reverse('admin:index') page = self.app.get(url) + self.assertFalse('Collect' in page.text) - self.assertFalse('Collect Activities' in page.text) - - initiative_settings.activity_types = ['dateactivity', 'periodactivity', 'collectactivity'] + initiative_settings.activity_types = ['dateactivity', 'periodactivity', 'collect'] initiative_settings.save() url = reverse('admin:index') page = self.app.get(url) - self.assertTrue('Collect Activities' in page.text) + self.assertTrue('Collect' in page.text) def test_admin_create_collect(self): url = reverse('admin:collect_collectactivity_changelist') diff --git a/bluebottle/initiatives/models.py b/bluebottle/initiatives/models.py index 5484955825..8fa7df740c 100644 --- a/bluebottle/initiatives/models.py +++ b/bluebottle/initiatives/models.py @@ -313,6 +313,10 @@ def deeds_enabled(self): def collect_enabled(self): return 'collect' in self.activity_types + @property + def funding_enabled(self): + return 'funding' in self.activity_types + class Meta(object): verbose_name_plural = _('initiative settings') verbose_name = _('initiative settings') diff --git a/bluebottle/settings/admin_dashboard.py b/bluebottle/settings/admin_dashboard.py index 22e3c622d2..3b7ba28724 100644 --- a/bluebottle/settings/admin_dashboard.py +++ b/bluebottle/settings/admin_dashboard.py @@ -75,10 +75,24 @@ }, ] }, + { + 'label': _('Collect'), + 'app_label': 'collect', + 'permissions': ['activities.change_activity'], + 'enabled': 'initiatives.InitiativePlatformSettings.collect_enabled', + 'items': [ + { + 'name': 'collect.collectactivity', + }, + { + 'name': 'collect.collectcontributor', + }, + ] + }, { 'label': _('Deeds'), 'app_label': 'deeds', - 'permissions': ['deeds.change_deed'], + 'permissions': ['activities.change_activity'], 'enabled': 'initiatives.InitiativePlatformSettings.deeds_enabled', 'items': [ { @@ -91,22 +105,10 @@ }, ] }, - { - 'label': _('Collect'), - 'app_label': 'collect', - 'permissions': ['collect.change_collectactivity'], - 'enabled': 'initiatives.InitiativePlatformSettings.collect_enabled', - 'items': [ - { - 'name': 'collect.collectactivity', - 'permissions': ['collect.change_collectactivity'] - }, - ] - }, { 'label': _('Funding'), 'app_label': 'funding', - 'permissions': ['activities.change_activity'], + 'permissions': ['funding.change_funding'], 'items': [ { 'name': 'funding.funding', From 1f802362fa65dd1715bc644668104836c542c776 Mon Sep 17 00:00:00 2001 From: Loek van Gent Date: Thu, 23 Sep 2021 11:00:19 +0200 Subject: [PATCH 06/88] Add API + tests --- bluebottle/collect/models.py | 4 +- bluebottle/collect/serializers.py | 157 +++++++++ bluebottle/collect/tests/steps.py | 96 ++++++ bluebottle/collect/tests/test_api.py | 491 +++++++++++++++++++++++++++ bluebottle/collect/urls/__init__.py | 0 bluebottle/collect/urls/api.py | 41 +++ bluebottle/collect/views.py | 153 +++++++++ 7 files changed, 940 insertions(+), 2 deletions(-) create mode 100644 bluebottle/collect/serializers.py create mode 100644 bluebottle/collect/tests/steps.py create mode 100644 bluebottle/collect/tests/test_api.py create mode 100644 bluebottle/collect/urls/__init__.py create mode 100644 bluebottle/collect/urls/api.py create mode 100644 bluebottle/collect/views.py diff --git a/bluebottle/collect/models.py b/bluebottle/collect/models.py index 294f4dca37..0045f79ba1 100644 --- a/bluebottle/collect/models.py +++ b/bluebottle/collect/models.py @@ -54,7 +54,7 @@ class Meta(object): ) class JSONAPIMeta(object): - resource_name = 'activities/collects' + resource_name = 'activities/collectactivities' @property def required_fields(self): @@ -89,4 +89,4 @@ class Meta(object): ) class JSONAPIMeta(object): - resource_name = 'contributors/collects/contributor' + resource_name = 'contributors/collectactivities/contributor' diff --git a/bluebottle/collect/serializers.py b/bluebottle/collect/serializers.py new file mode 100644 index 0000000000..b79cae25a2 --- /dev/null +++ b/bluebottle/collect/serializers.py @@ -0,0 +1,157 @@ +from rest_framework.validators import UniqueTogetherValidator + +from rest_framework_json_api.relations import ( + ResourceRelatedField, + SerializerMethodResourceRelatedField +) + +from bluebottle.bluebottle_drf2.serializers import PrivateFileSerializer + +from bluebottle.activities.utils import ( + BaseActivitySerializer, BaseActivityListSerializer, BaseContributorSerializer +) +from bluebottle.collect.models import CollectActivity, CollectContributor +from bluebottle.collect.states import CollectContributorStateMachine +from bluebottle.fsm.serializers import TransitionSerializer +from bluebottle.time_based.permissions import CanExportParticipantsPermission +from bluebottle.utils.serializers import ResourcePermissionField + + +class CollectActivitySerializer(BaseActivitySerializer): + permissions = ResourcePermissionField('deed-detail', view_args=('pk',)) + my_contributor = SerializerMethodResourceRelatedField( + model=CollectContributor, + read_only=True, + source='get_my_contributor' + ) + + contributors = SerializerMethodResourceRelatedField( + model=CollectContributor, + many=True, + related_link_view_name='related-deed-participants', + related_link_url_kwarg='activity_id' + ) + + participants_export_url = PrivateFileSerializer( + 'deed-participant-export', + url_args=('pk', ), + filename='participant.csv', + permission=CanExportParticipantsPermission, + read_only=True + ) + + def get_contributors(self, instance): + user = self.context['request'].user + return [ + contributor for contributor in instance.contributors.all() if ( + isinstance(contributor, CollectContributor) and ( + contributor.status in [ + CollectContributorStateMachine.new.value, + CollectContributorStateMachine.accepted.value, + CollectContributorStateMachine.succeeded.value + ] or + user in (instance.owner, instance.initiative.owner, contributor.user) + ) + ) + ] + + def get_my_contributor(self, instance): + user = self.context['request'].user + if user.is_authenticated: + return instance.contributors.filter(user=user).instance_of(CollectContributor).first() + + class Meta(BaseActivitySerializer.Meta): + model = CollectActivity + fields = BaseActivitySerializer.Meta.fields + ( + 'my_contributor', + 'contributors', + 'start', + 'end', + 'participants_export_url', + ) + + class JSONAPIMeta(BaseActivitySerializer.JSONAPIMeta): + resource_name = 'activities/collect' + included_resources = BaseActivitySerializer.JSONAPIMeta.included_resources + [ + 'my_contributor', + ] + + included_serializers = dict( + BaseActivitySerializer.included_serializers, + **{ + 'my_contributor': 'bluebottle.collect.serializers.CollectContributorSerializer', + } + ) + + +class CollectActivityListSerializer(BaseActivityListSerializer): + permissions = ResourcePermissionField('deed-detail', view_args=('pk',)) + + class Meta(BaseActivityListSerializer.Meta): + model = CollectActivity + fields = BaseActivityListSerializer.Meta.fields + ( + 'start', + 'end', + ) + + class JSONAPIMeta(BaseActivityListSerializer.JSONAPIMeta): + resource_name = 'activities/collect' + + +class CollectActivityTransitionSerializer(TransitionSerializer): + resource = ResourceRelatedField(queryset=CollectActivity.objects.all()) + included_serializers = { + 'resource': 'bluebottle.collect.serializers.CollectActivitySerializer', + } + + class JSONAPIMeta(object): + included_resources = ['resource', ] + resource_name = 'activities/deed-transitions' + + +class CollectContributorSerializer(BaseContributorSerializer): + activity = ResourceRelatedField( + queryset=CollectActivity.objects.all() + ) + permissions = ResourcePermissionField('deed-participant-detail', view_args=('pk',)) + + class Meta(BaseContributorSerializer.Meta): + model = CollectContributor + meta_fields = BaseContributorSerializer.Meta.meta_fields + ('permissions', ) + + validators = [ + UniqueTogetherValidator( + queryset=CollectContributor.objects.all(), + fields=('activity', 'user') + ) + ] + + class JSONAPIMeta(BaseContributorSerializer.JSONAPIMeta): + resource_name = 'contributors/collect/participants' + included_resources = [ + 'user', 'activity', + ] + + included_serializers = { + 'user': 'bluebottle.initiatives.serializers.MemberSerializer', + 'activity': 'bluebottle.collect.serializers.CollectActivitySerializer', + } + + +class CollectContributorListSerializer(CollectContributorSerializer): + pass + + +class CollectContributorTransitionSerializer(TransitionSerializer): + resource = ResourceRelatedField(queryset=CollectContributor.objects.all()) + field = 'states' + + included_serializers = { + 'resource': 'bluebottle.collect.serializers.CollectContributorSerializer', + } + + class JSONAPIMeta(object): + resource_name = 'contributors/collect/participant-transitions' + included_resources = [ + 'resource', + ] diff --git a/bluebottle/collect/tests/steps.py b/bluebottle/collect/tests/steps.py new file mode 100644 index 0000000000..ab2be836d5 --- /dev/null +++ b/bluebottle/collect/tests/steps.py @@ -0,0 +1,96 @@ +import json + +from django.urls import reverse + +from bluebottle.collect.models import CollectActivity +from bluebottle.deeds.models import Deed + + +def api_create_collect_activity( + test, initiative, attributes, + request_user=None, status_code=201, msg=None): + if not request_user: + request_user = initiative.owner + test.data = { + 'data': { + 'type': 'activities/collectactivities', + 'attributes': attributes, + 'relationships': { + 'initiative': { + 'data': { + 'type': 'initiatives', + 'id': initiative.pk + } + } + } + } + } + url = reverse('collect-activity-list') + response = test.client.post(url, json.dumps(test.data), user=request_user) + test.assertEqual(response.status_code, status_code, msg) + if status_code == 201: + return CollectActivity.objects.get(id=response.data['id']) + + +def api_update_collect_activity( + test, activity, attributes, + request_user=None, status_code=200, msg=None): + if not request_user: + request_user = activity.owner + test.data = { + 'data': { + 'type': 'activities/collectactivities', + 'id': activity.id, + 'attributes': attributes, + 'relationships': { + 'initiative': { + 'data': { + 'type': 'initiatives', + 'id': activity.initiative.pk + } + } + } + } + } + url = reverse('collect-activity-detail', args=(activity.id,)) + response = test.client.patch(url, json.dumps(test.data), user=request_user) + test.assertEqual(response.status_code, status_code, msg) + if status_code == 200: + return CollectActivity.objects.get(id=response.data['id']) + + +def api_collect_activity_transition( + test, activity, transition, + request_user=None, status_code=201, msg=None): + if not request_user: + request_user = activity.owner + test.data = { + 'data': { + 'type': 'activities/collect-activity-transitions', + 'attributes': { + 'transition': transition + }, + 'relationships': { + 'resource': { + 'data': { + 'type': 'activities/collectactivities', + 'id': activity.pk + } + } + } + } + } + url = reverse('collect-activities-transition-list') + response = test.client.post(url, json.dumps(test.data), user=request_user) + test.assertEqual(response.status_code, status_code, msg) + + +def api_read_collect_activity( + test, activity, request_user=None, status_code=200, msg=None): + if not request_user: + request_user = activity.owner + url = reverse('collect-activity-detail', args=(activity.id,)) + response = test.client.get(url, user=request_user) + test.assertEqual(response.status_code, status_code, msg) + if status_code == 200: + return Deed.objects.get(id=response.data['id']) diff --git a/bluebottle/collect/tests/test_api.py b/bluebottle/collect/tests/test_api.py new file mode 100644 index 0000000000..86b9fd27f1 --- /dev/null +++ b/bluebottle/collect/tests/test_api.py @@ -0,0 +1,491 @@ +import csv +from datetime import timedelta, date +import io + +from rest_framework import status + +from bluebottle.initiatives.models import InitiativePlatformSettings + +from bluebottle.test.utils import APITestCase +from bluebottle.deeds.serializers import ( + DeedListSerializer, DeedSerializer, DeedTransitionSerializer, + DeedParticipantSerializer, DeedParticipantTransitionSerializer +) +from bluebottle.deeds.tests.factories import DeedFactory, DeedParticipantFactory +from bluebottle.initiatives.tests.factories import InitiativeFactory +from bluebottle.test.factory_models.accounts import BlueBottleUserFactory + +from django.urls import reverse + + +class CollectActivityListViewAPITestCase(APITestCase): + def setUp(self): + super().setUp() + + self.url = reverse('deed-list') + self.serializer = DeedListSerializer + self.factory = DeedFactory + + self.defaults = { + 'initiative': InitiativeFactory.create(status='approved', owner=self.user), + 'start': date.today() + timedelta(days=10), + 'end': date.today() + timedelta(days=20), + } + + self.fields = ['initiative', 'start', 'end', 'title', 'description'] + + settings = InitiativePlatformSettings.objects.get() + settings.activity_types.append('deed') + settings.save() + + def test_create_complete(self): + self.perform_create(user=self.user) + self.assertStatus(status.HTTP_201_CREATED) + + self.assertIncluded('initiative') + self.assertIncluded('owner') + + self.assertAttribute('start') + self.assertAttribute('end') + + self.assertPermission('PUT', True) + self.assertPermission('GET', True) + self.assertPermission('PATCH', True) + + self.assertTransition('submit') + self.assertTransition('delete') + + def test_create_incomplete(self): + self.defaults['description'] = '' + self.perform_create(user=self.user) + + self.assertStatus(status.HTTP_201_CREATED) + self.assertRequired('description') + + def test_create_error(self): + self.defaults['start'] = self.defaults['end'] + timedelta(days=2) + self.perform_create(user=self.user) + + self.assertStatus(status.HTTP_201_CREATED) + self.assertHasError('end', 'The end date should be after the start date') + + def test_create_other_user(self): + self.perform_create(user=BlueBottleUserFactory.create()) + self.assertStatus(status.HTTP_403_FORBIDDEN) + + def test_create_other_user_is_open(self): + self.defaults['initiative'].is_open = True + self.defaults['initiative'].save() + + self.perform_create(user=BlueBottleUserFactory.create()) + self.assertStatus(status.HTTP_201_CREATED) + + def test_create_other_user_is_open_not_approved(self): + self.defaults['initiative'].is_open = True + self.defaults['initiative'].states.cancel(save=True) + + self.perform_create(user=BlueBottleUserFactory.create()) + self.assertStatus(status.HTTP_403_FORBIDDEN) + + def test_create_anonymous(self): + self.perform_create() + + self.assertStatus(status.HTTP_401_UNAUTHORIZED) + + def test_create_disabled_activity_type(self): + settings = InitiativePlatformSettings.objects.get() + settings.activity_types.remove('deed') + settings.save() + + self.perform_create(user=self.user) + + self.assertStatus(status.HTTP_403_FORBIDDEN) + + +class DeedsDetailViewAPITestCase(APITestCase): + def setUp(self): + super().setUp() + + self.serializer = DeedSerializer + self.factory = DeedFactory + + self.defaults = { + 'initiative': InitiativeFactory.create(status='approved'), + 'start': date.today() + timedelta(days=10), + 'end': date.today() + timedelta(days=20), + } + self.model = self.factory.create(**self.defaults) + + self.accepted_participants = DeedParticipantFactory.create_batch( + 5, activity=self.model, status='accepted' + ) + self.withdrawn_participants = DeedParticipantFactory.create_batch( + 5, activity=self.model, status='withdrawn' + ) + + self.url = reverse('deed-detail', args=(self.model.pk, )) + + self.fields = ['initiative', 'start', 'end', 'title', 'description'] + + def test_get(self): + self.perform_get(user=self.model.owner) + + self.assertStatus(status.HTTP_200_OK) + + self.assertIncluded('initiative') + self.assertIncluded('owner') + + self.assertAttribute('start') + self.assertAttribute('end') + + self.assertPermission('PUT', True) + self.assertPermission('GET', True) + self.assertPermission('PATCH', True) + + self.assertTransition('submit') + self.assertTransition('delete') + self.assertRelationship( + 'contributors', + self.accepted_participants + self.withdrawn_participants + ) + + def test_get_with_participant(self): + participant = DeedParticipantFactory.create(activity=self.model, user=self.user) + self.perform_get(user=self.user) + + self.assertStatus(status.HTTP_200_OK) + + self.assertIncluded('initiative') + self.assertIncluded('owner') + self.assertIncluded('my-contributor', participant) + + self.assertPermission('PUT', False) + self.assertPermission('GET', True) + self.assertPermission('PATCH', False) + self.assertRelationship( + 'contributors', + self.accepted_participants + [participant] + ) + + def test_get_anonymous(self): + self.perform_get() + + self.assertStatus(status.HTTP_200_OK) + + self.assertIncluded('initiative') + self.assertIncluded('owner') + + self.assertPermission('PUT', False) + self.assertPermission('GET', True) + self.assertPermission('PATCH', False) + + self.assertRelationship('contributors', self.accepted_participants) + + def test_get_closed_site(self): + with self.closed_site(): + self.perform_get() + + self.assertStatus(status.HTTP_401_UNAUTHORIZED) + + def test_put(self): + new_description = 'Test description' + self.perform_update({'description': new_description}, user=self.model.owner) + + self.assertStatus(status.HTTP_200_OK) + + self.assertAttribute('description', new_description) + + def test_put_initiative_owner(self): + new_description = 'Test description' + self.perform_update({'description': new_description}, user=self.model.initiative.owner) + + self.assertStatus(status.HTTP_200_OK) + + self.assertAttribute('description', new_description) + + def test_put_initiative_activity_manager(self): + new_description = 'Test description' + self.perform_update( + {'description': new_description}, + user=self.model.initiative.activity_managers.first() + ) + + self.assertStatus(status.HTTP_200_OK) + + self.assertAttribute('description', new_description) + + def test_other_user(self): + new_description = 'Test description' + self.perform_update({'description': new_description}, user=self.user) + + self.assertStatus(status.HTTP_403_FORBIDDEN) + + def test_no_user(self): + new_description = 'Test description' + self.perform_update({'description': new_description}) + + self.assertStatus(status.HTTP_401_UNAUTHORIZED) + + +class DeedTranistionListViewAPITestCase(APITestCase): + def setUp(self): + super().setUp() + + self.url = reverse('deed-transition-list') + self.serializer = DeedTransitionSerializer + + self.activity = DeedFactory.create( + initiative=InitiativeFactory.create(status='approved'), + start=date.today() + timedelta(days=10), + end=date.today() + timedelta(days=20), + ) + + self.defaults = { + 'resource': self.activity, + 'transition': 'submit', + } + + self.fields = ['resource', 'transition', ] + + def test_submit(self): + self.perform_create(user=self.activity.owner) + self.assertStatus(status.HTTP_201_CREATED) + self.assertIncluded('resource', self.activity) + + self.activity.refresh_from_db() + self.assertEqual(self.defaults['resource'].status, 'open') + + def test_submit_other_user(self): + self.perform_create(user=self.user) + self.assertStatus(status.HTTP_400_BAD_REQUEST) + + self.activity.refresh_from_db() + self.assertEqual(self.defaults['resource'].status, 'draft') + + def test_submit_no_user(self): + self.perform_create() + self.assertStatus(status.HTTP_400_BAD_REQUEST) + + self.activity.refresh_from_db() + self.assertEqual(self.defaults['resource'].status, 'draft') + + +class RelatedDeedParticipantViewAPITestCase(APITestCase): + def setUp(self): + super().setUp() + + self.serializer = DeedParticipantSerializer + self.factory = DeedParticipantFactory + + self.activity = DeedFactory.create( + initiative=InitiativeFactory.create(status='approved'), + status='open', + start=date.today() + timedelta(days=10), + end=date.today() + timedelta(days=20), + ) + + DeedParticipantFactory.create_batch(5, activity=self.activity, status='accepted') + DeedParticipantFactory.create_batch(5, activity=self.activity, status='withdrawn') + + self.url = reverse('related-deed-participants', args=(self.activity.pk, )) + + def test_get(self): + self.perform_get(user=self.activity.owner) + self.assertStatus(status.HTTP_200_OK) + + self.assertTotal(10) + + self.assertTrue( + all( + participant['attributes']['status'] in ('accepted', 'withdrawn') + for participant in self.response.json()['data'] + ) + ) + + def test_get_user(self): + self.perform_get(user=self.user) + self.assertStatus(status.HTTP_200_OK) + + self.assertTotal(5) + + self.assertTrue( + all( + participant['attributes']['status'] == 'accepted' + for participant in self.response.json()['data'] + ) + ) + + def test_get_user_succeeded(self): + self.activity.start = date.today() - timedelta(days=10) + self.activity.end = date.today() - timedelta(days=5) + self.activity.save() + + self.perform_get(user=self.user) + self.assertStatus(status.HTTP_200_OK) + + self.assertTotal(5) + + self.assertTrue( + all( + participant['attributes']['status'] == 'succeeded' + for participant in self.response.json()['data'] + ) + ) + + def test_get_anonymous(self): + self.perform_get() + self.assertStatus(status.HTTP_200_OK) + + self.assertTotal(5) + + self.assertTrue( + all( + participant['attributes']['status'] == 'accepted' + for participant in self.response.json()['data'] + ) + ) + + def test_get_closed_site(self): + with self.closed_site(): + self.perform_get() + self.assertStatus(status.HTTP_401_UNAUTHORIZED) + + +class DeedParticipantListViewAPITestCase(APITestCase): + def setUp(self): + super().setUp() + + self.url = reverse('deed-participant-list') + self.serializer = DeedParticipantSerializer + self.factory = DeedParticipantFactory + + self.activity = DeedFactory.create( + initiative=InitiativeFactory.create(status='approved'), + status='open', + start=date.today() + timedelta(days=10), + end=date.today() + timedelta(days=20), + ) + + self.defaults = { + 'activity': self.activity + } + + self.fields = ['activity'] + + def test_create(self): + self.perform_create(user=self.user) + + self.assertStatus(status.HTTP_201_CREATED) + + self.assertIncluded('activity') + self.assertIncluded('user') + + self.assertPermission('PUT', True) + self.assertPermission('GET', True) + self.assertPermission('PATCH', True) + + self.assertTransition('withdraw') + + def test_create_anonymous(self): + self.perform_create() + + self.assertStatus(status.HTTP_401_UNAUTHORIZED) + + +class DeedParticipantTranistionListViewAPITestCase(APITestCase): + def setUp(self): + super().setUp() + + self.url = reverse('deed-participant-transition-list') + self.serializer = DeedParticipantTransitionSerializer + + self.participant = DeedParticipantFactory.create( + activity=DeedFactory.create( + initiative=InitiativeFactory.create(status='approved'), + start=date.today() + timedelta(days=10), + end=date.today() + timedelta(days=20), + ) + ) + + self.defaults = { + 'resource': self.participant, + 'transition': 'withdraw', + } + + self.fields = ['resource', 'transition', ] + + def test_create(self): + self.perform_create(user=self.participant.user) + self.assertStatus(status.HTTP_201_CREATED) + self.assertIncluded('resource', self.participant) + + self.participant.refresh_from_db() + self.assertEqual(self.participant.status, 'withdrawn') + + def test_create_other_user(self): + self.perform_create(user=self.user) + self.assertStatus(status.HTTP_400_BAD_REQUEST) + + self.participant.refresh_from_db() + self.assertEqual(self.participant.status, 'accepted') + + def test_create_no_user(self): + self.perform_create() + self.assertStatus(status.HTTP_400_BAD_REQUEST) + + self.participant.refresh_from_db() + self.assertEqual(self.participant.status, 'accepted') + + +class ParticipantExportViewAPITestCase(APITestCase): + def setUp(self): + super().setUp() + + initiative_settings = InitiativePlatformSettings.load() + initiative_settings.enable_participant_exports = True + initiative_settings.save() + + self.activity = DeedFactory.create( + start=date.today() + timedelta(days=10), + end=date.today() + timedelta(days=20), + ) + + self.participants = DeedParticipantFactory.create_batch( + 5, activity=self.activity + ) + self.url = reverse('deed-detail', args=(self.activity.pk, )) + + @property + def export_url(self): + if self.response and self.response.json()['data']['attributes']['participants-export-url']: + return self.response.json()['data']['attributes']['participants-export-url']['url'] + + def test_get_owner(self): + self.perform_get(user=self.activity.owner) + self.assertStatus(status.HTTP_200_OK) + response = self.client.get(self.export_url) + reader = csv.DictReader(io.StringIO(response.content.decode())) + + for row in reader: + self.assertTrue('Email' in row) + self.assertTrue('Name' in row) + self.assertTrue('Registration Date' in row) + self.assertTrue('Status' in row) + + def test_get_owner_incorrect_hash(self): + self.perform_get(user=self.activity.owner) + self.assertStatus(status.HTTP_200_OK) + response = self.client.get(self.export_url + 'test') + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_get_participant(self): + self.perform_get(user=self.participants[0].user) + self.assertIsNone(self.export_url) + + def test_get_other_user(self): + self.perform_get(user=self.user) + self.assertIsNone(self.export_url) + + def test_get_no_user(self): + self.perform_get() + self.assertIsNone(self.export_url) diff --git a/bluebottle/collect/urls/__init__.py b/bluebottle/collect/urls/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/bluebottle/collect/urls/api.py b/bluebottle/collect/urls/api.py new file mode 100644 index 0000000000..824b286d5c --- /dev/null +++ b/bluebottle/collect/urls/api.py @@ -0,0 +1,41 @@ +from django.conf.urls import url + +from bluebottle.collect.views import ( + CollectContributorExportView, CollectContributorTransitionList, + CollectContributorDetail, CollectContributorList, + CollectActivityRelatedCollectContributorList, + CollectActivityTransitionList, CollectActivityDetailView, CollectActivityListView, +) + + +urlpatterns = [ + url(r'^$', + CollectActivityListView.as_view(), + name='collect-activity-list'), + + url(r'^/(?P\d+)$', + CollectActivityDetailView.as_view(), + name='deed-detail'), + + url(r'^/transitions$', + CollectActivityTransitionList.as_view(), + name='collect-activity-transition-list'), + + url(r'^/(?P\d+)/contributors$', + CollectActivityRelatedCollectContributorList.as_view(), + name='related-collect-activity-contributors'), + + url(r'^/contributors$', + CollectContributorList.as_view(), + name='collect-activity-contributor-list'), + url(r'^/contributors/(?P\d+)$', + CollectContributorDetail.as_view(), + name='collect-activity-participant-detail'), + url(r'^/contributors/transitions$', + CollectContributorTransitionList.as_view(), + name='collect-activity-contributor-transition-list'), + + url(r'^/export/(?P[\d]+)$', + CollectContributorExportView.as_view(), + name='collect-activity-contributor-export'), +] diff --git a/bluebottle/collect/views.py b/bluebottle/collect/views.py new file mode 100644 index 0000000000..f597b3ccfe --- /dev/null +++ b/bluebottle/collect/views.py @@ -0,0 +1,153 @@ +import csv + +from django.db.models import Q +from django.http import HttpResponse + +from bluebottle.activities.permissions import ( + ActivityOwnerPermission, ActivityTypePermission, ActivityStatusPermission, + DeleteActivityPermission, ContributorPermission +) +from bluebottle.collect.models import CollectActivity, CollectContributor +from bluebottle.collect.serializers import ( + CollectActivitySerializer, CollectActivityTransitionSerializer, CollectContributorSerializer, + CollectContributorTransitionSerializer +) +from bluebottle.transitions.views import TransitionList +from bluebottle.utils.admin import prep_field +from bluebottle.utils.permissions import ( + OneOf, ResourcePermission, ResourceOwnerPermission +) +from bluebottle.utils.views import ( + RetrieveUpdateDestroyAPIView, ListAPIView, ListCreateAPIView, RetrieveUpdateAPIView, + JsonApiViewMixin, PrivateFileView +) + + +class CollectActivityListView(JsonApiViewMixin, ListCreateAPIView): + queryset = CollectActivity.objects.all() + serializer_class = CollectActivitySerializer + + permission_classes = ( + ActivityTypePermission, + OneOf(ResourcePermission, ActivityOwnerPermission), + ) + + def perform_create(self, serializer): + self.check_related_object_permissions( + self.request, + serializer.Meta.model(**serializer.validated_data) + ) + + self.check_object_permissions( + self.request, + serializer.Meta.model(**serializer.validated_data) + ) + serializer.save(owner=self.request.user) + + +class CollectActivityDetailView(JsonApiViewMixin, RetrieveUpdateDestroyAPIView): + permission_classes = ( + ActivityStatusPermission, + OneOf(ResourcePermission, ActivityOwnerPermission), + DeleteActivityPermission + ) + + queryset = CollectActivity.objects.all() + serializer_class = CollectActivitySerializer + + +class CollectActivityTransitionList(TransitionList): + serializer_class = CollectActivityTransitionSerializer + queryset = CollectActivity.objects.all() + + +class CollectActivityRelatedCollectContributorList(JsonApiViewMixin, ListAPIView): + permission_classes = ( + OneOf(ResourcePermission, ResourceOwnerPermission), + ) + pagination_class = None + + queryset = CollectContributor.objects.prefetch_related('user') + serializer_class = CollectContributorSerializer + + def get_queryset(self): + if self.request.user.is_authenticated: + queryset = self.queryset.filter( + Q(user=self.request.user) | + Q(activity__owner=self.request.user) | + Q(activity__initiative__activity_manager=self.request.user) | + Q(status__in=('accepted', 'succeeded', )) + ) + else: + queryset = self.queryset.filter( + status__in=('accepted', 'succeeded', ) + ) + + return queryset.filter( + activity_id=self.kwargs['activity_id'] + ) + + +class CollectContributorList(JsonApiViewMixin, ListCreateAPIView): + permission_classes = ( + OneOf(ResourcePermission, ResourceOwnerPermission), + ) + queryset = CollectContributor.objects.all() + serializer_class = CollectContributorSerializer + + def perform_create(self, serializer): + self.check_related_object_permissions( + self.request, + serializer.Meta.model(**serializer.validated_data) + ) + + self.check_object_permissions( + self.request, + serializer.Meta.model(**serializer.validated_data) + ) + + serializer.save(user=self.request.user) + + +class CollectContributorDetail(JsonApiViewMixin, RetrieveUpdateAPIView): + permission_classes = ( + OneOf(ResourcePermission, ResourceOwnerPermission, ContributorPermission), + ) + queryset = CollectContributor.objects.all() + serializer_class = CollectContributorSerializer + + +class CollectContributorTransitionList(TransitionList): + serializer_class = CollectContributorTransitionSerializer + queryset = CollectContributor.objects.all() + + +class CollectContributorExportView(PrivateFileView): + fields = ( + ('user__email', 'Email'), + ('user__full_name', 'Name'), + ('created', 'Registration Date'), + ('status', 'Status'), + ) + + model = CollectActivity + + def get(self, request, *args, **kwargs): + activity = self.get_object() + + response = HttpResponse() + response['Content-Disposition'] = 'attachment; filename="participants.csv"' + response['Content-Type'] = 'text/csv' + + writer = csv.writer(response) + + row = [field[1] for field in self.fields] + writer.writerow(row) + + for participant in activity.contributors.instance_of( + CollectContributor + ): + row = [prep_field(request, participant, field[0]) for field in self.fields] + writer.writerow(row) + + return response From 15bc0de85e385725f3f2ab610b727298797bdcca Mon Sep 17 00:00:00 2001 From: Loek van Gent Date: Thu, 23 Sep 2021 12:13:00 +0200 Subject: [PATCH 07/88] Fix a bunch of api endpoints + tests for collect --- bluebottle/collect/migrations/0001_initial.py | 16 ++- .../migrations/0002_auto_20210920_0917.py | 12 +- .../migrations/0005_auto_20210922_1502.py | 7 +- bluebottle/collect/serializers.py | 22 +-- bluebottle/collect/states.py | 4 +- bluebottle/collect/tests/steps.py | 3 +- bluebottle/collect/tests/test_admin.py | 2 +- bluebottle/collect/tests/test_api.py | 136 +++++++++--------- bluebottle/collect/urls/api.py | 12 +- bluebottle/collect/views.py | 6 +- bluebottle/initiatives/models.py | 4 +- bluebottle/urls/core.py | 2 + 12 files changed, 123 insertions(+), 103 deletions(-) diff --git a/bluebottle/collect/migrations/0001_initial.py b/bluebottle/collect/migrations/0001_initial.py index c53e3c48cf..d72d557c8f 100644 --- a/bluebottle/collect/migrations/0001_initial.py +++ b/bluebottle/collect/migrations/0001_initial.py @@ -20,10 +20,18 @@ class Migration(migrations.Migration): ('start', models.DateField(blank=True, null=True)), ('end', models.DateField(blank=True, null=True)), ], - options={ - 'verbose_name': 'Collect', - 'verbose_name_plural': 'Collects', - 'permissions': (('api_read_collect', 'Can view collect activity through the API'), ('api_add_collect', 'Can add collect activity through the API'), ('api_change_collect', 'Can change collect activity through the API'), ('api_delete_collect', 'Can delete collect activity through the API'), ('api_read_own_collect', 'Can view own collect activity through the API'), ('api_add_own_collect', 'Can add own collect activity through the API'), ('api_change_own_collect', 'Can change own collect activity through the API'), ('api_delete_own_collect', 'Can delete own collect activity through the API')), + options={'permissions': ( + ('api_read_collectactivity', 'Can view collect activity through the API'), + ('api_add_collectactivity', 'Can add collect activity through the API'), + ('api_change_collectactivity', 'Can change collect activity through the API'), + ('api_delete_collectactivity', 'Can delete collect activity through the API'), + ('api_read_own_collectactivity', 'Can view own collect activity through the API'), + ('api_add_own_collectactivity', 'Can add own collect activity through the API'), + ('api_change_own_collectactivity', 'Can change own collect activity through the API'), + ('api_delete_own_collectactivity', 'Can delete own collect activity through the API') + ), + 'verbose_name': 'Collect Activity', + 'verbose_name_plural': 'Collect Activities' }, bases=('activities.activity',), ), diff --git a/bluebottle/collect/migrations/0002_auto_20210920_0917.py b/bluebottle/collect/migrations/0002_auto_20210920_0917.py index 568249bc4b..054f2c78d9 100644 --- a/bluebottle/collect/migrations/0002_auto_20210920_0917.py +++ b/bluebottle/collect/migrations/0002_auto_20210920_0917.py @@ -12,7 +12,17 @@ class Migration(migrations.Migration): operations = [ migrations.AlterModelOptions( name='collectactivity', - options={'permissions': (('api_read_collect', 'Can view collect activity through the API'), ('api_add_collect', 'Can add collect activity through the API'), ('api_change_collect', 'Can change collect activity through the API'), ('api_delete_collect', 'Can delete collect activity through the API'), ('api_read_own_collect', 'Can view own collect activity through the API'), ('api_add_own_collect', 'Can add own collect activity through the API'), ('api_change_own_collect', 'Can change own collect activity through the API'), ('api_delete_own_collect', 'Can delete own collect activity through the API')), 'verbose_name': 'Collect Activity', 'verbose_name_plural': 'Collect Activities'}, + options={'permissions': ( + ('api_read_collectactivity', 'Can view collect activity through the API'), + ('api_add_collectactivity', 'Can add collect activity through the API'), + ('api_change_collectactivity', 'Can change collect activity through the API'), + ('api_delete_collectactivity', 'Can delete collect activity through the API'), + ('api_read_own_collectactivity', 'Can view own collect activity through the API'), + ('api_add_own_collectactivity', 'Can add own collect activity through the API'), + ('api_change_own_collectactivity', 'Can change own collect activity through the API'), + ('api_delete_own_collectactivity', 'Can delete own collect activity through the API') + ), + 'verbose_name': 'Collect Activity', 'verbose_name_plural': 'Collect Activities'}, ), migrations.AddField( model_name='collectcontributor', diff --git a/bluebottle/collect/migrations/0005_auto_20210922_1502.py b/bluebottle/collect/migrations/0005_auto_20210922_1502.py index 4539ed2ea7..52258cfea3 100644 --- a/bluebottle/collect/migrations/0005_auto_20210922_1502.py +++ b/bluebottle/collect/migrations/0005_auto_20210922_1502.py @@ -21,12 +21,15 @@ def add_group_permissions(apps, schema_editor): }, 'Anonymous': { 'perms': ( - 'api_read_collect', 'api_read_collecttype', 'api_read_collectcontributor' + 'api_read_collectactivity', + 'api_read_collecttype', + 'api_read_collectcontributor' ) if not properties.CLOSED_SITE else () }, 'Authenticated': { 'perms': ( - 'api_read_collect', 'api_add_own_collect', 'api_change_own_collect', 'api_delete_own_collect', + 'api_read_collectactivity', 'api_add_own_collectactivity', + 'api_change_own_collectactivity', 'api_delete_own_collectactivity', 'api_read_collectcontributor', 'api_add_own_collectcontributor', 'api_change_own_collectcontributor', 'api_delete_own_collectcontributor', 'api_read_collecttype', diff --git a/bluebottle/collect/serializers.py b/bluebottle/collect/serializers.py index b79cae25a2..a0f87d1113 100644 --- a/bluebottle/collect/serializers.py +++ b/bluebottle/collect/serializers.py @@ -18,7 +18,7 @@ class CollectActivitySerializer(BaseActivitySerializer): - permissions = ResourcePermissionField('deed-detail', view_args=('pk',)) + permissions = ResourcePermissionField('collect-activity-detail', view_args=('pk',)) my_contributor = SerializerMethodResourceRelatedField( model=CollectContributor, read_only=True, @@ -28,14 +28,14 @@ class CollectActivitySerializer(BaseActivitySerializer): contributors = SerializerMethodResourceRelatedField( model=CollectContributor, many=True, - related_link_view_name='related-deed-participants', + related_link_view_name='related-collect-contributors', related_link_url_kwarg='activity_id' ) - participants_export_url = PrivateFileSerializer( - 'deed-participant-export', + contributors_export_url = PrivateFileSerializer( + 'collect-contributors-export', url_args=('pk', ), - filename='participant.csv', + filename='contributors.csv', permission=CanExportParticipantsPermission, read_only=True ) @@ -67,7 +67,7 @@ class Meta(BaseActivitySerializer.Meta): 'contributors', 'start', 'end', - 'participants_export_url', + 'contributors_export_url', ) class JSONAPIMeta(BaseActivitySerializer.JSONAPIMeta): @@ -85,7 +85,7 @@ class JSONAPIMeta(BaseActivitySerializer.JSONAPIMeta): class CollectActivityListSerializer(BaseActivityListSerializer): - permissions = ResourcePermissionField('deed-detail', view_args=('pk',)) + permissions = ResourcePermissionField('collect-activity-detail', view_args=('pk',)) class Meta(BaseActivityListSerializer.Meta): model = CollectActivity @@ -106,14 +106,14 @@ class CollectActivityTransitionSerializer(TransitionSerializer): class JSONAPIMeta(object): included_resources = ['resource', ] - resource_name = 'activities/deed-transitions' + resource_name = 'activities/collect-activity-transitions' class CollectContributorSerializer(BaseContributorSerializer): activity = ResourceRelatedField( queryset=CollectActivity.objects.all() ) - permissions = ResourcePermissionField('deed-participant-detail', view_args=('pk',)) + permissions = ResourcePermissionField('collect-contributor-detail', view_args=('pk',)) class Meta(BaseContributorSerializer.Meta): model = CollectContributor @@ -127,7 +127,7 @@ class Meta(BaseContributorSerializer.Meta): ] class JSONAPIMeta(BaseContributorSerializer.JSONAPIMeta): - resource_name = 'contributors/collect/participants' + resource_name = 'contributors/collect/contributors' included_resources = [ 'user', 'activity', ] @@ -151,7 +151,7 @@ class CollectContributorTransitionSerializer(TransitionSerializer): } class JSONAPIMeta(object): - resource_name = 'contributors/collect/participant-transitions' + resource_name = 'contributors/collect/collect-contributor-transitions' included_resources = [ 'resource', ] diff --git a/bluebottle/collect/states.py b/bluebottle/collect/states.py index 176a3d08f1..be4e36c684 100644 --- a/bluebottle/collect/states.py +++ b/bluebottle/collect/states.py @@ -104,11 +104,11 @@ class CollectContributorStateMachine(ContributorStateMachine): ) def is_user(self, user): - """is participant""" + """is contributor""" return self.instance.user == user def is_owner(self, user): - """is participant""" + """is contributor""" return ( self.instance.activity.owner == user or self.instance.activity.initiative.owner == user or diff --git a/bluebottle/collect/tests/steps.py b/bluebottle/collect/tests/steps.py index ab2be836d5..77769688d1 100644 --- a/bluebottle/collect/tests/steps.py +++ b/bluebottle/collect/tests/steps.py @@ -3,7 +3,6 @@ from django.urls import reverse from bluebottle.collect.models import CollectActivity -from bluebottle.deeds.models import Deed def api_create_collect_activity( @@ -93,4 +92,4 @@ def api_read_collect_activity( response = test.client.get(url, user=request_user) test.assertEqual(response.status_code, status_code, msg) if status_code == 200: - return Deed.objects.get(id=response.data['id']) + return CollectActivity.objects.get(id=response.data['id']) diff --git a/bluebottle/collect/tests/test_admin.py b/bluebottle/collect/tests/test_admin.py index 3918377bcf..03722a7943 100644 --- a/bluebottle/collect/tests/test_admin.py +++ b/bluebottle/collect/tests/test_admin.py @@ -27,7 +27,7 @@ def test_admin_collect_feature_flag(self): page = self.app.get(url) self.assertFalse('Collect' in page.text) - initiative_settings.activity_types = ['dateactivity', 'periodactivity', 'collect'] + initiative_settings.activity_types = ['dateactivity', 'periodactivity', 'collectactivity'] initiative_settings.save() url = reverse('admin:index') page = self.app.get(url) diff --git a/bluebottle/collect/tests/test_api.py b/bluebottle/collect/tests/test_api.py index 86b9fd27f1..87b0687808 100644 --- a/bluebottle/collect/tests/test_api.py +++ b/bluebottle/collect/tests/test_api.py @@ -4,14 +4,12 @@ from rest_framework import status +from bluebottle.collect.serializers import CollectActivityListSerializer, CollectActivitySerializer, \ + CollectActivityTransitionSerializer, CollectContributorSerializer, CollectContributorTransitionSerializer +from bluebottle.collect.tests.factories import CollectActivityFactory, CollectContributorFactory from bluebottle.initiatives.models import InitiativePlatformSettings from bluebottle.test.utils import APITestCase -from bluebottle.deeds.serializers import ( - DeedListSerializer, DeedSerializer, DeedTransitionSerializer, - DeedParticipantSerializer, DeedParticipantTransitionSerializer -) -from bluebottle.deeds.tests.factories import DeedFactory, DeedParticipantFactory from bluebottle.initiatives.tests.factories import InitiativeFactory from bluebottle.test.factory_models.accounts import BlueBottleUserFactory @@ -22,9 +20,9 @@ class CollectActivityListViewAPITestCase(APITestCase): def setUp(self): super().setUp() - self.url = reverse('deed-list') - self.serializer = DeedListSerializer - self.factory = DeedFactory + self.url = reverse('collect-activity-list') + self.serializer = CollectActivityListSerializer + self.factory = CollectActivityFactory self.defaults = { 'initiative': InitiativeFactory.create(status='approved', owner=self.user), @@ -35,7 +33,7 @@ def setUp(self): self.fields = ['initiative', 'start', 'end', 'title', 'description'] settings = InitiativePlatformSettings.objects.get() - settings.activity_types.append('deed') + settings.activity_types.append('collectactivity') settings.save() def test_create_complete(self): @@ -94,7 +92,7 @@ def test_create_anonymous(self): def test_create_disabled_activity_type(self): settings = InitiativePlatformSettings.objects.get() - settings.activity_types.remove('deed') + settings.activity_types.remove('collectactivity') settings.save() self.perform_create(user=self.user) @@ -102,12 +100,12 @@ def test_create_disabled_activity_type(self): self.assertStatus(status.HTTP_403_FORBIDDEN) -class DeedsDetailViewAPITestCase(APITestCase): +class CollectActivitysDetailViewAPITestCase(APITestCase): def setUp(self): super().setUp() - self.serializer = DeedSerializer - self.factory = DeedFactory + self.serializer = CollectActivitySerializer + self.factory = CollectActivityFactory self.defaults = { 'initiative': InitiativeFactory.create(status='approved'), @@ -116,14 +114,14 @@ def setUp(self): } self.model = self.factory.create(**self.defaults) - self.accepted_participants = DeedParticipantFactory.create_batch( + self.accepted_contributors = CollectContributorFactory.create_batch( 5, activity=self.model, status='accepted' ) - self.withdrawn_participants = DeedParticipantFactory.create_batch( + self.withdrawn_contributors = CollectContributorFactory.create_batch( 5, activity=self.model, status='withdrawn' ) - self.url = reverse('deed-detail', args=(self.model.pk, )) + self.url = reverse('collect-activity-detail', args=(self.model.pk, )) self.fields = ['initiative', 'start', 'end', 'title', 'description'] @@ -146,25 +144,25 @@ def test_get(self): self.assertTransition('delete') self.assertRelationship( 'contributors', - self.accepted_participants + self.withdrawn_participants + self.accepted_contributors + self.withdrawn_contributors ) - def test_get_with_participant(self): - participant = DeedParticipantFactory.create(activity=self.model, user=self.user) + def test_get_with_contributor(self): + contributor = CollectContributorFactory.create(activity=self.model, user=self.user) self.perform_get(user=self.user) self.assertStatus(status.HTTP_200_OK) self.assertIncluded('initiative') self.assertIncluded('owner') - self.assertIncluded('my-contributor', participant) + self.assertIncluded('my-contributor', contributor) self.assertPermission('PUT', False) self.assertPermission('GET', True) self.assertPermission('PATCH', False) self.assertRelationship( 'contributors', - self.accepted_participants + [participant] + self.accepted_contributors + [contributor] ) def test_get_anonymous(self): @@ -179,7 +177,7 @@ def test_get_anonymous(self): self.assertPermission('GET', True) self.assertPermission('PATCH', False) - self.assertRelationship('contributors', self.accepted_participants) + self.assertRelationship('contributors', self.accepted_contributors) def test_get_closed_site(self): with self.closed_site(): @@ -227,14 +225,14 @@ def test_no_user(self): self.assertStatus(status.HTTP_401_UNAUTHORIZED) -class DeedTranistionListViewAPITestCase(APITestCase): +class CollectActivityTranistionListViewAPITestCase(APITestCase): def setUp(self): super().setUp() - self.url = reverse('deed-transition-list') - self.serializer = DeedTransitionSerializer + self.url = reverse('collect-activity-transition-list') + self.serializer = CollectActivityTransitionSerializer - self.activity = DeedFactory.create( + self.activity = CollectActivityFactory.create( initiative=InitiativeFactory.create(status='approved'), start=date.today() + timedelta(days=10), end=date.today() + timedelta(days=20), @@ -270,24 +268,24 @@ def test_submit_no_user(self): self.assertEqual(self.defaults['resource'].status, 'draft') -class RelatedDeedParticipantViewAPITestCase(APITestCase): +class RelatedCollectActivityContributorViewAPITestCase(APITestCase): def setUp(self): super().setUp() - self.serializer = DeedParticipantSerializer - self.factory = DeedParticipantFactory + self.serializer = CollectContributorSerializer + self.factory = CollectContributorFactory - self.activity = DeedFactory.create( + self.activity = CollectActivityFactory.create( initiative=InitiativeFactory.create(status='approved'), status='open', start=date.today() + timedelta(days=10), end=date.today() + timedelta(days=20), ) - DeedParticipantFactory.create_batch(5, activity=self.activity, status='accepted') - DeedParticipantFactory.create_batch(5, activity=self.activity, status='withdrawn') + CollectContributorFactory.create_batch(5, activity=self.activity, status='accepted') + CollectContributorFactory.create_batch(5, activity=self.activity, status='withdrawn') - self.url = reverse('related-deed-participants', args=(self.activity.pk, )) + self.url = reverse('related-collect-contributors', args=(self.activity.pk, )) def test_get(self): self.perform_get(user=self.activity.owner) @@ -297,8 +295,8 @@ def test_get(self): self.assertTrue( all( - participant['attributes']['status'] in ('accepted', 'withdrawn') - for participant in self.response.json()['data'] + contributor['attributes']['status'] in ('accepted', 'withdrawn') + for contributor in self.response.json()['data'] ) ) @@ -310,8 +308,8 @@ def test_get_user(self): self.assertTrue( all( - participant['attributes']['status'] == 'accepted' - for participant in self.response.json()['data'] + contributor['attributes']['status'] == 'accepted' + for contributor in self.response.json()['data'] ) ) @@ -327,8 +325,8 @@ def test_get_user_succeeded(self): self.assertTrue( all( - participant['attributes']['status'] == 'succeeded' - for participant in self.response.json()['data'] + contributor['attributes']['status'] == 'succeeded' + for contributor in self.response.json()['data'] ) ) @@ -340,8 +338,8 @@ def test_get_anonymous(self): self.assertTrue( all( - participant['attributes']['status'] == 'accepted' - for participant in self.response.json()['data'] + contributor['attributes']['status'] == 'accepted' + for contributor in self.response.json()['data'] ) ) @@ -351,15 +349,15 @@ def test_get_closed_site(self): self.assertStatus(status.HTTP_401_UNAUTHORIZED) -class DeedParticipantListViewAPITestCase(APITestCase): +class CollectActivityContributorListViewAPITestCase(APITestCase): def setUp(self): super().setUp() - self.url = reverse('deed-participant-list') - self.serializer = DeedParticipantSerializer - self.factory = DeedParticipantFactory + self.url = reverse('collect-contributor-list') + self.serializer = CollectContributorSerializer + self.factory = CollectContributorFactory - self.activity = DeedFactory.create( + self.activity = CollectActivityFactory.create( initiative=InitiativeFactory.create(status='approved'), status='open', start=date.today() + timedelta(days=10), @@ -392,15 +390,15 @@ def test_create_anonymous(self): self.assertStatus(status.HTTP_401_UNAUTHORIZED) -class DeedParticipantTranistionListViewAPITestCase(APITestCase): +class CollectActivityContributorTranistionListViewAPITestCase(APITestCase): def setUp(self): super().setUp() - self.url = reverse('deed-participant-transition-list') - self.serializer = DeedParticipantTransitionSerializer + self.url = reverse('collect-contributor-transition-list') + self.serializer = CollectContributorTransitionSerializer - self.participant = DeedParticipantFactory.create( - activity=DeedFactory.create( + self.contributor = CollectContributorFactory.create( + activity=CollectActivityFactory.create( initiative=InitiativeFactory.create(status='approved'), start=date.today() + timedelta(days=10), end=date.today() + timedelta(days=20), @@ -408,57 +406,57 @@ def setUp(self): ) self.defaults = { - 'resource': self.participant, + 'resource': self.contributor, 'transition': 'withdraw', } self.fields = ['resource', 'transition', ] def test_create(self): - self.perform_create(user=self.participant.user) + self.perform_create(user=self.contributor.user) self.assertStatus(status.HTTP_201_CREATED) - self.assertIncluded('resource', self.participant) + self.assertIncluded('resource', self.contributor) - self.participant.refresh_from_db() - self.assertEqual(self.participant.status, 'withdrawn') + self.contributor.refresh_from_db() + self.assertEqual(self.contributor.status, 'withdrawn') def test_create_other_user(self): self.perform_create(user=self.user) self.assertStatus(status.HTTP_400_BAD_REQUEST) - self.participant.refresh_from_db() - self.assertEqual(self.participant.status, 'accepted') + self.contributor.refresh_from_db() + self.assertEqual(self.contributor.status, 'accepted') def test_create_no_user(self): self.perform_create() self.assertStatus(status.HTTP_400_BAD_REQUEST) - self.participant.refresh_from_db() - self.assertEqual(self.participant.status, 'accepted') + self.contributor.refresh_from_db() + self.assertEqual(self.contributor.status, 'accepted') -class ParticipantExportViewAPITestCase(APITestCase): +class ContributorExportViewAPITestCase(APITestCase): def setUp(self): super().setUp() initiative_settings = InitiativePlatformSettings.load() - initiative_settings.enable_participant_exports = True + initiative_settings.enable_contributor_exports = True initiative_settings.save() - self.activity = DeedFactory.create( + self.activity = CollectActivityFactory.create( start=date.today() + timedelta(days=10), end=date.today() + timedelta(days=20), ) - self.participants = DeedParticipantFactory.create_batch( + self.contributors = CollectContributorFactory.create_batch( 5, activity=self.activity ) - self.url = reverse('deed-detail', args=(self.activity.pk, )) + self.url = reverse('collect-activity-detail', args=(self.activity.pk, )) @property def export_url(self): - if self.response and self.response.json()['data']['attributes']['participants-export-url']: - return self.response.json()['data']['attributes']['participants-export-url']['url'] + if self.response and self.response.json()['data']['attributes']['contributors-export-url']: + return self.response.json()['data']['attributes']['contributors-export-url']['url'] def test_get_owner(self): self.perform_get(user=self.activity.owner) @@ -478,8 +476,8 @@ def test_get_owner_incorrect_hash(self): response = self.client.get(self.export_url + 'test') self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_get_participant(self): - self.perform_get(user=self.participants[0].user) + def test_get_contributor(self): + self.perform_get(user=self.contributors[0].user) self.assertIsNone(self.export_url) def test_get_other_user(self): diff --git a/bluebottle/collect/urls/api.py b/bluebottle/collect/urls/api.py index 824b286d5c..40dfd8f400 100644 --- a/bluebottle/collect/urls/api.py +++ b/bluebottle/collect/urls/api.py @@ -15,7 +15,7 @@ url(r'^/(?P\d+)$', CollectActivityDetailView.as_view(), - name='deed-detail'), + name='collect-activity-detail'), url(r'^/transitions$', CollectActivityTransitionList.as_view(), @@ -23,19 +23,19 @@ url(r'^/(?P\d+)/contributors$', CollectActivityRelatedCollectContributorList.as_view(), - name='related-collect-activity-contributors'), + name='related-collect-contributors'), url(r'^/contributors$', CollectContributorList.as_view(), - name='collect-activity-contributor-list'), + name='collect-contributor-list'), url(r'^/contributors/(?P\d+)$', CollectContributorDetail.as_view(), - name='collect-activity-participant-detail'), + name='collect-contributor-detail'), url(r'^/contributors/transitions$', CollectContributorTransitionList.as_view(), - name='collect-activity-contributor-transition-list'), + name='collect-contributor-transition-list'), url(r'^/export/(?P[\d]+)$', CollectContributorExportView.as_view(), - name='collect-activity-contributor-export'), + name='collect-contributors-export'), ] diff --git a/bluebottle/collect/views.py b/bluebottle/collect/views.py index f597b3ccfe..2d8507d369 100644 --- a/bluebottle/collect/views.py +++ b/bluebottle/collect/views.py @@ -136,7 +136,7 @@ def get(self, request, *args, **kwargs): activity = self.get_object() response = HttpResponse() - response['Content-Disposition'] = 'attachment; filename="participants.csv"' + response['Content-Disposition'] = 'attachment; filename="contributors.csv"' response['Content-Type'] = 'text/csv' writer = csv.writer(response) @@ -144,10 +144,10 @@ def get(self, request, *args, **kwargs): row = [field[1] for field in self.fields] writer.writerow(row) - for participant in activity.contributors.instance_of( + for contributor in activity.contributors.instance_of( CollectContributor ): - row = [prep_field(request, participant, field[0]) for field in self.fields] + row = [prep_field(request, contributor, field[0]) for field in self.fields] writer.writerow(row) return response diff --git a/bluebottle/initiatives/models.py b/bluebottle/initiatives/models.py index 8fa7df740c..ff51adbacd 100644 --- a/bluebottle/initiatives/models.py +++ b/bluebottle/initiatives/models.py @@ -249,7 +249,7 @@ class InitiativePlatformSettings(BasePlatformSettings): ('periodactivity', _('Activity during a period')), ('dateactivity', _('Activity on a specific date')), ('deed', _('Deed')), - ('collect', _('Collect activity')), + ('collectactivity', _('Collect activity')), ) ACTIVITY_SEARCH_FILTERS = ( @@ -311,7 +311,7 @@ def deeds_enabled(self): @property def collect_enabled(self): - return 'collect' in self.activity_types + return 'collectactivity' in self.activity_types @property def funding_enabled(self): diff --git a/bluebottle/urls/core.py b/bluebottle/urls/core.py index 15720aeeb8..678f48299b 100644 --- a/bluebottle/urls/core.py +++ b/bluebottle/urls/core.py @@ -53,6 +53,8 @@ include('bluebottle.time_based.urls.api')), url(r'^api/deeds', include('bluebottle.deeds.urls.api')), + url(r'^api/collect', + include('bluebottle.collect.urls.api')), url(r'^api/assignments', include('bluebottle.time_based.urls.old_assignments')), url(r'^api/funding', From b7af7c82f2f8489c1c4f2ac5dafe301eba2111c1 Mon Sep 17 00:00:00 2001 From: Loek van Gent Date: Thu, 23 Sep 2021 14:43:05 +0200 Subject: [PATCH 08/88] Fix export url --- bluebottle/collect/tests/test_api.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/bluebottle/collect/tests/test_api.py b/bluebottle/collect/tests/test_api.py index 87b0687808..560b95a304 100644 --- a/bluebottle/collect/tests/test_api.py +++ b/bluebottle/collect/tests/test_api.py @@ -372,9 +372,7 @@ def setUp(self): def test_create(self): self.perform_create(user=self.user) - self.assertStatus(status.HTTP_201_CREATED) - self.assertIncluded('activity') self.assertIncluded('user') @@ -386,7 +384,6 @@ def test_create(self): def test_create_anonymous(self): self.perform_create() - self.assertStatus(status.HTTP_401_UNAUTHORIZED) @@ -440,7 +437,7 @@ def setUp(self): super().setUp() initiative_settings = InitiativePlatformSettings.load() - initiative_settings.enable_contributor_exports = True + initiative_settings.enable_participant_exports = True initiative_settings.save() self.activity = CollectActivityFactory.create( @@ -461,14 +458,10 @@ def export_url(self): def test_get_owner(self): self.perform_get(user=self.activity.owner) self.assertStatus(status.HTTP_200_OK) + self.assertTrue(self.export_url) response = self.client.get(self.export_url) reader = csv.DictReader(io.StringIO(response.content.decode())) - - for row in reader: - self.assertTrue('Email' in row) - self.assertTrue('Name' in row) - self.assertTrue('Registration Date' in row) - self.assertTrue('Status' in row) + self.assertEqual(reader.fieldnames, ['Email', 'Name', 'Registration Date', 'Status']) def test_get_owner_incorrect_hash(self): self.perform_get(user=self.activity.owner) From 1a6c5881fb3d88340729dd79b5a3c1c9a3164d77 Mon Sep 17 00:00:00 2001 From: Loek van Gent Date: Fri, 24 Sep 2021 09:27:44 +0200 Subject: [PATCH 09/88] Fix triggers and tests --- bluebottle/collect/messages.py | 60 +++ bluebottle/collect/migrations/0001_initial.py | 11 +- .../migrations/0002_auto_20210920_0917.py | 2 +- .../migrations/0005_auto_20210922_1502.py | 2 +- bluebottle/collect/models.py | 31 +- bluebottle/collect/periodic_tasks.py | 77 ++++ .../collect_activity_date_changed.html | 17 + .../collect_activity_date_changed.txt | 12 + .../messages/collect_activity_reminder.html | 10 + .../messages/collect_activity_reminder.txt | 7 + bluebottle/collect/triggers.py | 352 ++++++++++++++++++ bluebottle/collect/views.py | 2 - bluebottle/deeds/triggers.py | 8 +- 13 files changed, 571 insertions(+), 20 deletions(-) create mode 100644 bluebottle/collect/messages.py create mode 100644 bluebottle/collect/periodic_tasks.py create mode 100644 bluebottle/collect/templates/mails/messages/collect_activity_date_changed.html create mode 100644 bluebottle/collect/templates/mails/messages/collect_activity_date_changed.txt create mode 100644 bluebottle/collect/templates/mails/messages/collect_activity_reminder.html create mode 100644 bluebottle/collect/templates/mails/messages/collect_activity_reminder.txt create mode 100644 bluebottle/collect/triggers.py diff --git a/bluebottle/collect/messages.py b/bluebottle/collect/messages.py new file mode 100644 index 0000000000..d905fab547 --- /dev/null +++ b/bluebottle/collect/messages.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +from django.utils.translation import pgettext_lazy as pgettext + +from bluebottle.notifications.messages import TransitionMessage + + +class CollectActivityDateChangedNotification(TransitionMessage): + + subject = pgettext('email', 'The date for the activity "{title}" has changed') + template = 'messages/collect_activity_date_changed' + + context = { + 'title': 'title', + } + + def get_context(self, recipient): + context = super().get_context(recipient) + if self.obj.start: + context['start'] = self.obj.start.strftime('%x') + else: + context['start'] = pgettext('email', 'Today') + + if self.obj.end: + context['end'] = self.obj.end.strftime('%x') + else: + context['end'] = pgettext('email', 'Runs indefinitely') + return context + + @property + def action_link(self): + return self.obj.get_absolute_url() + + action_title = pgettext('email', 'View activity') + + def get_recipients(self): + """contributors that signed up""" + return [ + participant.user for participant in self.obj.contributors + ] + + +class CollectActivityReminderNotification(TransitionMessage): + + subject = pgettext('email', 'Your activity "{title}" will start tomorrow!') + template = 'messages/collect_activity_reminder' + send_once = True + + context = { + 'title': 'title', + } + + @property + def action_link(self): + return self.obj.get_absolute_url() + + action_title = pgettext('email', 'Open your activity') + + def get_recipients(self): + """activity owner""" + return [self.obj.owner] diff --git a/bluebottle/collect/migrations/0001_initial.py b/bluebottle/collect/migrations/0001_initial.py index d72d557c8f..4f9ef9e5af 100644 --- a/bluebottle/collect/migrations/0001_initial.py +++ b/bluebottle/collect/migrations/0001_initial.py @@ -43,7 +43,16 @@ class Migration(migrations.Migration): options={ 'verbose_name': 'Contributor', 'verbose_name_plural': 'Contributors', - 'permissions': (('api_read_collectcontributor', 'Can view collect through the API'), ('api_add_collectcontributor', 'Can add collect through the API'), ('api_change_collectcontributor', 'Can change collect through the API'), ('api_delete_collectcontributor', 'Can delete collect through the API'), ('api_read_own_collectcontributor', 'Can view own collect through the API'), ('api_add_own_collectcontributor', 'Can add own collect through the API'), ('api_change_own_collectcontributor', 'Can change own collect through the API'), ('api_delete_own_collectcontributor', 'Can delete own collect through the API')), + 'permissions': ( + ('api_read_collectcontributor', 'Can view collect contributor through the API'), + ('api_add_collectcontributor', 'Can add collect contributor through the API'), + ('api_change_collectcontributor', 'Can change collect contributor through the API'), + ('api_delete_collectcontributor', 'Can delete collect contributor through the API'), + ('api_read_own_collectcontributor', 'Can view own collect contributor through the API'), + ('api_add_own_collectcontributor', 'Can add own collect contributor through the API'), + ('api_change_own_collectcontributor', 'Can change own collect contributor through the API'), + ('api_delete_own_collectcontributor', 'Can delete own collect contributor through the API') + ), }, bases=('activities.contributor',), ), diff --git a/bluebottle/collect/migrations/0002_auto_20210920_0917.py b/bluebottle/collect/migrations/0002_auto_20210920_0917.py index 054f2c78d9..265e2d812f 100644 --- a/bluebottle/collect/migrations/0002_auto_20210920_0917.py +++ b/bluebottle/collect/migrations/0002_auto_20210920_0917.py @@ -11,7 +11,7 @@ class Migration(migrations.Migration): operations = [ migrations.AlterModelOptions( - name='collectactivity', + name='CollectActivity', options={'permissions': ( ('api_read_collectactivity', 'Can view collect activity through the API'), ('api_add_collectactivity', 'Can add collect activity through the API'), diff --git a/bluebottle/collect/migrations/0005_auto_20210922_1502.py b/bluebottle/collect/migrations/0005_auto_20210922_1502.py index 52258cfea3..937baff48d 100644 --- a/bluebottle/collect/migrations/0005_auto_20210922_1502.py +++ b/bluebottle/collect/migrations/0005_auto_20210922_1502.py @@ -32,7 +32,7 @@ def add_group_permissions(apps, schema_editor): 'api_change_own_collectactivity', 'api_delete_own_collectactivity', 'api_read_collectcontributor', 'api_add_own_collectcontributor', 'api_change_own_collectcontributor', 'api_delete_own_collectcontributor', - 'api_read_collecttype', + 'api_add_collectcontributor', 'api_read_collecttype', ) } } diff --git a/bluebottle/collect/models.py b/bluebottle/collect/models.py index 0045f79ba1..6757e503ca 100644 --- a/bluebottle/collect/models.py +++ b/bluebottle/collect/models.py @@ -5,6 +5,7 @@ from parler.models import TranslatedFields from bluebottle.activities.models import Activity, Contributor, EffortContribution +from bluebottle.deeds.validators import EndDateValidator from bluebottle.utils.models import SortableTranslatableModel @@ -53,9 +54,17 @@ class Meta(object): ('api_delete_own_collect', 'Can delete own collect activity through the API'), ) + validators = [EndDateValidator] + class JSONAPIMeta(object): resource_name = 'activities/collectactivities' + @property + def accepted_contributors(self): + return self.contributors.instance_of(CollectContributor).filter( + status__in=('accepted', 'succeeded', ) + ) + @property def required_fields(self): return super().required_fields + ['title', 'description'] @@ -73,19 +82,19 @@ class CollectContributor(Contributor): value = models.DecimalField(null=True, blank=True, decimal_places=5, max_digits=12) class Meta(object): - verbose_name = _("Contributor") - verbose_name_plural = _("Contributors") + verbose_name = _("Collect contributor") + verbose_name_plural = _("Collect contributors") permissions = ( - ('api_read_collectcontributor', 'Can view collect through the API'), - ('api_add_collectcontributor', 'Can add collect through the API'), - ('api_change_collectcontributor', 'Can change collect through the API'), - ('api_delete_collectcontributor', 'Can delete collect through the API'), - - ('api_read_own_collectcontributor', 'Can view own collect through the API'), - ('api_add_own_collectcontributor', 'Can add own collect through the API'), - ('api_change_own_collectcontributor', 'Can change own collect through the API'), - ('api_delete_own_collectcontributor', 'Can delete own collect through the API'), + ('api_read_collectcontributor', 'Can view collect contributor through the API'), + ('api_add_collectcontributor', 'Can add collect contributor through the API'), + ('api_change_collectcontributor', 'Can change collect contributor through the API'), + ('api_delete_collectcontributor', 'Can delete collect contributor through the API'), + + ('api_read_own_collectcontributor', 'Can view own collect contributor through the API'), + ('api_add_own_collectcontributor', 'Can add own collect contributor through the API'), + ('api_change_own_collectcontributor', 'Can change own collect contributor through the API'), + ('api_delete_own_collectcontributor', 'Can delete own collect contributor through the API'), ) class JSONAPIMeta(object): diff --git a/bluebottle/collect/periodic_tasks.py b/bluebottle/collect/periodic_tasks.py new file mode 100644 index 0000000000..7417cfd6d2 --- /dev/null +++ b/bluebottle/collect/periodic_tasks.py @@ -0,0 +1,77 @@ +from datetime import date, timedelta + +from django.utils.timezone import now + +from bluebottle.collect.messages import CollectActivityReminderNotification +from bluebottle.notifications.effects import NotificationEffect +from django.utils.translation import gettext_lazy as _ + +from bluebottle.fsm.effects import TransitionEffect, RelatedTransitionEffect +from bluebottle.fsm.periodic_tasks import ModelPeriodicTask +from bluebottle.collect.models import ( + CollectActivity +) +from bluebottle.collect.states import ( + CollectActivityStateMachine, CollectContributorStateMachine +) +from bluebottle.collect.triggers import has_contributors, has_no_contributors, has_no_end_date + + +class CollectActivityStartedTask(ModelPeriodicTask): + + def get_queryset(self): + return self.model.objects.filter( + start__lte=date.today(), + status__in=['open'] + ) + + effects = [ + RelatedTransitionEffect( + 'contributors', + CollectContributorStateMachine.succeed, + conditions=[has_no_end_date] + ), + ] + + def __str__(self): + return str(_("Start the activity when the start date has passed")) + + +class CollectActivityFinishedTask(ModelPeriodicTask): + + def get_queryset(self): + return self.model.objects.filter( + end__lte=date.today(), + status__in=['running', 'open'] + ) + + effects = [ + TransitionEffect(CollectActivityStateMachine.succeed, conditions=[has_contributors]), + TransitionEffect(CollectActivityStateMachine.expire, conditions=[has_no_contributors]) + ] + + def __str__(self): + return str(_("Finish the activity when the start date has passed")) + + +class CollectActivityReminderTask(ModelPeriodicTask): + + def get_queryset(self): + return CollectActivity.objects.filter( + start__lte=now() + timedelta(hours=24), + status__in=['open', 'full'] + ) + + effects = [ + NotificationEffect( + CollectActivityReminderNotification + ), + ] + + def __str__(self): + return str(_("Send a reminder a day before the activity.")) + + +CollectActivity.periodic_tasks = [ + CollectActivityStartedTask, CollectActivityFinishedTask, CollectActivityReminderTask +] diff --git a/bluebottle/collect/templates/mails/messages/collect_activity_date_changed.html b/bluebottle/collect/templates/mails/messages/collect_activity_date_changed.html new file mode 100644 index 0000000000..2bc7bf8f08 --- /dev/null +++ b/bluebottle/collect/templates/mails/messages/collect_activity_date_changed.html @@ -0,0 +1,17 @@ +{% extends "mails/messages/activity_base.html" %} +{% load i18n %} +{% block message %} +

+ {% blocktrans context 'email' %}The start and/or end date of the activity "{{ title }}", in which you are participating, has changed.{% endblocktrans %} +

+

+ {% blocktrans context 'email' %}Start: {{ start }}{% endblocktrans %}
+ {% blocktrans context 'email' %}End: {{ end }}{% endblocktrans %} +

+

+ {% blocktrans context 'email' %}Head over to the activity page for more information.{% endblocktrans %} +

+

+ {% blocktrans context 'email' %}If you are unable to participate, please withdraw via the activity page so that others can take your place.{% endblocktrans %} +

+{% endblock %} diff --git a/bluebottle/collect/templates/mails/messages/collect_activity_date_changed.txt b/bluebottle/collect/templates/mails/messages/collect_activity_date_changed.txt new file mode 100644 index 0000000000..ec87d3f81a --- /dev/null +++ b/bluebottle/collect/templates/mails/messages/collect_activity_date_changed.txt @@ -0,0 +1,12 @@ +{% extends "mails/messages/activity_base.txt" %} +{% load i18n %} +{% block message %} +{% blocktrans context 'email' %}The start and/or end date of the activity "{{ title }}", in which you are participating, has changed.{% endblocktrans %} + +{% blocktrans context 'email' %}Start: {{ start }}{% endblocktrans %} +{% blocktrans context 'email' %}End: {{ end }}{% endblocktrans %} + +{% blocktrans context 'email' %}Head over to the activity page for more information.{% endblocktrans %} + +{% blocktrans context 'email' %}If you are unable to participate, please withdraw via the activity page so that others can take your place.{% endblocktrans %} +{% endblock %} diff --git a/bluebottle/collect/templates/mails/messages/collect_activity_reminder.html b/bluebottle/collect/templates/mails/messages/collect_activity_reminder.html new file mode 100644 index 0000000000..0a5df42781 --- /dev/null +++ b/bluebottle/collect/templates/mails/messages/collect_activity_reminder.html @@ -0,0 +1,10 @@ +{% extends "mails/messages/activity_base.html" %} +{% load i18n %} +{% block message %} +

+ {% blocktrans context 'email' %}Tomorrow is the big day on which your activity "{{ title }}" starts!{% endblocktrans %} +

+

+ {% blocktrans context 'email' %}Help your contributors to start tomorrow fully motivated. Send them a message via the 'update wall' on the activity page.{% endblocktrans %} +

+{% endblock %} diff --git a/bluebottle/collect/templates/mails/messages/collect_activity_reminder.txt b/bluebottle/collect/templates/mails/messages/collect_activity_reminder.txt new file mode 100644 index 0000000000..815c1e4fec --- /dev/null +++ b/bluebottle/collect/templates/mails/messages/collect_activity_reminder.txt @@ -0,0 +1,7 @@ +{% extends "mails/messages/activity_base.txt" %} +{% load i18n %} +{% block message %} + {% blocktrans context 'email' %}Tomorrow is the big day on which your activity "{{ title }}" starts!{% endblocktrans %} + + {% blocktrans context 'email' %}Help your contributors to start tomorrow fully motivated. Send them a message via the 'update wall' on the activity page.{% endblocktrans %} +{% endblock %} diff --git a/bluebottle/collect/triggers.py b/bluebottle/collect/triggers.py new file mode 100644 index 0000000000..2c8f0c688f --- /dev/null +++ b/bluebottle/collect/triggers.py @@ -0,0 +1,352 @@ +from datetime import date + +from bluebottle.activities.messages import ActivityExpiredNotification, ActivitySucceededNotification, \ + ActivityRejectedNotification, ActivityCancelledNotification, ActivityRestoredNotification +from bluebottle.activities.states import OrganizerStateMachine, EffortContributionStateMachine +from bluebottle.activities.triggers import ( + ActivityTriggers, ContributorTriggers +) +from bluebottle.deeds.effects import CreateEffortContribution, RescheduleEffortsEffect +from bluebottle.collect.messages import CollectActivityDateChangedNotification +from bluebottle.collect.models import CollectActivity, CollectContributor +from bluebottle.collect.states import ( + CollectActivityStateMachine, CollectContributorStateMachine +) +from bluebottle.fsm.effects import RelatedTransitionEffect, TransitionEffect +from bluebottle.fsm.triggers import ( + register, TransitionTrigger, ModelChangedTrigger +) +from bluebottle.notifications.effects import NotificationEffect +from bluebottle.time_based.messages import ParticipantRemovedNotification, ParticipantFinishedNotification, \ + ParticipantWithdrewNotification, NewParticipantNotification, ParticipantAddedOwnerNotification, \ + ParticipantRemovedOwnerNotification, ParticipantAddedNotification +from bluebottle.time_based.triggers import is_not_owner, is_not_user, is_user + + +def is_started(effect): + """ + has started + """ + return ( + effect.instance.start and + effect.instance.start < date.today() + ) + + +def is_not_started(effect): + """ + hasn't started yet + """ + return not is_started(effect) + + +def is_finished(effect): + """ + has finished + """ + return ( + effect.instance.end and + effect.instance.end < date.today() + ) + + +def is_not_finished(effect): + """ + hasn't finished yet + """ + return not is_finished(effect) + + +def has_contributors(effect): + """ has contributors""" + return len(effect.instance.accepted_contributors) > 0 + + +def has_no_contributors(effect): + """ has no contributors""" + return not has_contributors(effect) + + +def has_no_start_date(effect): + """has no start date""" + return not effect.instance.start + + +def has_start_date(effect): + """has start date""" + return effect.instance.start + + +def has_no_end_date(effect): + """has no end date""" + return not effect.instance.end + + +@register(CollectActivity) +class CollectActivityTriggers(ActivityTriggers): + triggers = ActivityTriggers.triggers + [ + ModelChangedTrigger( + 'end', + effects=[ + TransitionEffect(CollectActivityStateMachine.reopen, conditions=[is_not_finished]), + TransitionEffect(CollectActivityStateMachine.succeed, conditions=[is_finished, has_contributors]), + TransitionEffect(CollectActivityStateMachine.expire, conditions=[is_finished, has_no_contributors]), + RescheduleEffortsEffect, + NotificationEffect( + CollectActivityDateChangedNotification, + conditions=[ + is_not_finished + ] + ) + ] + ), + + ModelChangedTrigger( + 'start', + effects=[ + RelatedTransitionEffect( + 'contributors', + CollectContributorStateMachine.re_accept, + conditions=[has_start_date, is_not_started] + ), + RelatedTransitionEffect( + 'contributors', + CollectContributorStateMachine.succeed, + conditions=[has_no_end_date, is_started] + ), + RescheduleEffortsEffect, + NotificationEffect( + CollectActivityDateChangedNotification, + conditions=[ + is_not_started + ] + ) + ] + ), + + TransitionTrigger( + CollectActivityStateMachine.auto_approve, + effects=[ + TransitionEffect(CollectActivityStateMachine.reopen, conditions=[is_not_finished]), + TransitionEffect(CollectActivityStateMachine.succeed, conditions=[is_finished, has_contributors]), + TransitionEffect(CollectActivityStateMachine.expire, conditions=[is_finished, has_no_contributors]), + ] + ), + + TransitionTrigger( + CollectActivityStateMachine.reopen, + effects=[ + RelatedTransitionEffect( + 'contributors', + CollectContributorStateMachine.re_accept, + conditions=[is_not_finished] + ), + ] + ), + + TransitionTrigger( + CollectActivityStateMachine.succeed, + effects=[ + RelatedTransitionEffect( + 'contributors', + CollectContributorStateMachine.succeed + ), + NotificationEffect(ActivitySucceededNotification) + ] + ), + + TransitionTrigger( + CollectActivityStateMachine.expire, + effects=[ + RelatedTransitionEffect('organizer', OrganizerStateMachine.fail), + NotificationEffect(ActivityExpiredNotification) + ] + ), + + TransitionTrigger( + CollectActivityStateMachine.reject, + effects=[ + RelatedTransitionEffect('organizer', OrganizerStateMachine.fail), + NotificationEffect(ActivityRejectedNotification), + ] + ), + + TransitionTrigger( + CollectActivityStateMachine.cancel, + effects=[ + RelatedTransitionEffect('organizer', OrganizerStateMachine.fail), + NotificationEffect(ActivityCancelledNotification), + ] + ), + + TransitionTrigger( + CollectActivityStateMachine.restore, + effects=[ + RelatedTransitionEffect('organizer', OrganizerStateMachine.reset), + NotificationEffect(ActivityRestoredNotification), + ] + ), + + ] + + +def activity_is_finished(effect): + """activity is finished""" + return ( + effect.instance.activity.end and + effect.instance.activity.end < date.today() + ) + + +def activity_is_started(effect): + """activity is finished""" + return ( + effect.instance.activity.start and + effect.instance.activity.start < date.today() + ) + + +def activity_will_be_empty(effect): + """activity will be empty""" + return len(effect.instance.activity.contributors) == 1 + + +def activity_has_no_start(effect): + """activity has no start""" + return not effect.instance.activity.start + + +def activity_has_no_end(effect): + """activity has no start""" + return not effect.instance.activity.end + + +@register(CollectContributor) +class CollectContributorTriggers(ContributorTriggers): + triggers = ContributorTriggers.triggers + [ + TransitionTrigger( + CollectContributorStateMachine.initiate, + effects=[ + TransitionEffect( + CollectContributorStateMachine.succeed, + conditions=[activity_has_no_start, activity_has_no_end] + ), + + TransitionEffect( + CollectContributorStateMachine.succeed, + conditions=[activity_is_started, activity_has_no_end] + ), + + TransitionEffect( + CollectContributorStateMachine.succeed, + conditions=[activity_is_finished] + ), + CreateEffortContribution, + NotificationEffect( + NewParticipantNotification, + conditions=[is_user] + ), + NotificationEffect( + ParticipantAddedNotification, + conditions=[is_not_user] + ), + NotificationEffect( + ParticipantAddedOwnerNotification, + conditions=[is_not_user, is_not_owner] + ) + + ] + ), + TransitionTrigger( + CollectContributorStateMachine.remove, + effects=[ + RelatedTransitionEffect( + 'activity', + CollectActivityStateMachine.expire, + conditions=[activity_is_finished, activity_will_be_empty] + ), + RelatedTransitionEffect('contributions', EffortContributionStateMachine.fail), + NotificationEffect(ParticipantRemovedNotification), + NotificationEffect( + ParticipantRemovedOwnerNotification, + conditions=[is_not_owner] + ) + ] + ), + + TransitionTrigger( + CollectContributorStateMachine.succeed, + effects=[ + RelatedTransitionEffect( + 'activity', + CollectActivityStateMachine.succeed, + conditions=[activity_is_finished] + ), + RelatedTransitionEffect('contributions', EffortContributionStateMachine.succeed), + ] + ), + + TransitionTrigger( + CollectContributorStateMachine.accept, + effects=[ + TransitionEffect( + CollectContributorStateMachine.succeed, + conditions=[activity_has_no_start, activity_has_no_end] + ), + TransitionEffect( + CollectContributorStateMachine.succeed, + conditions=[activity_is_started, activity_has_no_end] + ), + TransitionEffect( + CollectContributorStateMachine.succeed, + conditions=[activity_is_finished] + ), + ] + ), + + TransitionTrigger( + CollectContributorStateMachine.re_accept, + effects=[ + RelatedTransitionEffect('contributions', EffortContributionStateMachine.reset), + ] + ), + TransitionTrigger( + CollectContributorStateMachine.withdraw, + effects=[ + RelatedTransitionEffect('contributions', EffortContributionStateMachine.fail), + NotificationEffect(ParticipantWithdrewNotification), + ] + ), + + TransitionTrigger( + CollectContributorStateMachine.reapply, + effects=[ + TransitionEffect( + CollectContributorStateMachine.succeed, + conditions=[activity_has_no_start, activity_has_no_end] + ), + TransitionEffect( + CollectContributorStateMachine.succeed, + conditions=[activity_is_started, activity_has_no_end] + ), + ] + ), + + TransitionTrigger( + CollectContributorStateMachine.succeed, + effects=[ + RelatedTransitionEffect('contributions', EffortContributionStateMachine.succeed), + NotificationEffect(ParticipantFinishedNotification), + ] + ), + TransitionTrigger( + CollectContributorStateMachine.accept, + effects=[ + RelatedTransitionEffect( + 'activity', + CollectActivityStateMachine.succeed, + conditions=[activity_is_finished] + ), + ] + ), + ] diff --git a/bluebottle/collect/views.py b/bluebottle/collect/views.py index 2d8507d369..4c3c6a354c 100644 --- a/bluebottle/collect/views.py +++ b/bluebottle/collect/views.py @@ -100,12 +100,10 @@ def perform_create(self, serializer): self.request, serializer.Meta.model(**serializer.validated_data) ) - self.check_object_permissions( self.request, serializer.Meta.model(**serializer.validated_data) ) - serializer.save(user=self.request.user) diff --git a/bluebottle/deeds/triggers.py b/bluebottle/deeds/triggers.py index 1e0fb9434e..a4634e6946 100644 --- a/bluebottle/deeds/triggers.py +++ b/bluebottle/deeds/triggers.py @@ -63,22 +63,22 @@ def has_participants(effect): def has_no_participants(effect): - """ has accepted participants""" + """ has no participants""" return not has_participants(effect) def has_no_start_date(effect): - """ has accepted participants""" + """ has no start date""" return not effect.instance.start def has_start_date(effect): - """ has accepted participants""" + """ has start date""" return effect.instance.start def has_no_end_date(effect): - """ has accepted participants""" + """ has no end date""" return not effect.instance.end From b90d8943d647079c03b03cd8f104e54ccd9ac709 Mon Sep 17 00:00:00 2001 From: Loek van Gent Date: Fri, 24 Sep 2021 10:36:26 +0200 Subject: [PATCH 10/88] Change json api type --- bluebottle/collect/models.py | 4 ++-- bluebottle/collect/serializers.py | 15 +++------------ bluebottle/collect/tests/test_api.py | 2 +- 3 files changed, 6 insertions(+), 15 deletions(-) diff --git a/bluebottle/collect/models.py b/bluebottle/collect/models.py index 6757e503ca..46f28ad971 100644 --- a/bluebottle/collect/models.py +++ b/bluebottle/collect/models.py @@ -57,7 +57,7 @@ class Meta(object): validators = [EndDateValidator] class JSONAPIMeta(object): - resource_name = 'activities/collectactivities' + resource_name = 'activities/collects' @property def accepted_contributors(self): @@ -98,4 +98,4 @@ class Meta(object): ) class JSONAPIMeta(object): - resource_name = 'contributors/collectactivities/contributor' + resource_name = 'contributors/collects/contributor' diff --git a/bluebottle/collect/serializers.py b/bluebottle/collect/serializers.py index a0f87d1113..cf9ff0d588 100644 --- a/bluebottle/collect/serializers.py +++ b/bluebottle/collect/serializers.py @@ -1,15 +1,12 @@ -from rest_framework.validators import UniqueTogetherValidator - from rest_framework_json_api.relations import ( ResourceRelatedField, SerializerMethodResourceRelatedField ) -from bluebottle.bluebottle_drf2.serializers import PrivateFileSerializer - from bluebottle.activities.utils import ( BaseActivitySerializer, BaseActivityListSerializer, BaseContributorSerializer ) +from bluebottle.bluebottle_drf2.serializers import PrivateFileSerializer from bluebottle.collect.models import CollectActivity, CollectContributor from bluebottle.collect.states import CollectContributorStateMachine from bluebottle.fsm.serializers import TransitionSerializer @@ -71,7 +68,7 @@ class Meta(BaseActivitySerializer.Meta): ) class JSONAPIMeta(BaseActivitySerializer.JSONAPIMeta): - resource_name = 'activities/collect' + resource_name = 'activities/collects' included_resources = BaseActivitySerializer.JSONAPIMeta.included_resources + [ 'my_contributor', ] @@ -118,13 +115,7 @@ class CollectContributorSerializer(BaseContributorSerializer): class Meta(BaseContributorSerializer.Meta): model = CollectContributor meta_fields = BaseContributorSerializer.Meta.meta_fields + ('permissions', ) - - validators = [ - UniqueTogetherValidator( - queryset=CollectContributor.objects.all(), - fields=('activity', 'user') - ) - ] + fields = BaseContributorSerializer.Meta.fields + ('value',) class JSONAPIMeta(BaseContributorSerializer.JSONAPIMeta): resource_name = 'contributors/collect/contributors' diff --git a/bluebottle/collect/tests/test_api.py b/bluebottle/collect/tests/test_api.py index 560b95a304..dcfd675767 100644 --- a/bluebottle/collect/tests/test_api.py +++ b/bluebottle/collect/tests/test_api.py @@ -225,7 +225,7 @@ def test_no_user(self): self.assertStatus(status.HTTP_401_UNAUTHORIZED) -class CollectActivityTranistionListViewAPITestCase(APITestCase): +class CollectActivityTransitionListViewAPITestCase(APITestCase): def setUp(self): super().setUp() From 56be25c2f73903df7026e9294b3d6c5a267088b1 Mon Sep 17 00:00:00 2001 From: Loek van Gent Date: Fri, 24 Sep 2021 10:38:21 +0200 Subject: [PATCH 11/88] Add collect activity serializer to polymporhic --- bluebottle/activities/serializers.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bluebottle/activities/serializers.py b/bluebottle/activities/serializers.py index 03d774d326..f71826c372 100644 --- a/bluebottle/activities/serializers.py +++ b/bluebottle/activities/serializers.py @@ -4,6 +4,7 @@ from rest_framework_json_api.serializers import PolymorphicModelSerializer, ModelSerializer from bluebottle.activities.models import Contributor, Activity +from bluebottle.collect.serializers import CollectActivityListSerializer, CollectActivitySerializer from bluebottle.deeds.serializers import ( DeedListSerializer, DeedSerializer, DeedParticipantListSerializer ) @@ -40,6 +41,7 @@ class ActivityListSerializer(PolymorphicModelSerializer): polymorphic_serializers = [ FundingListSerializer, DeedListSerializer, + CollectActivityListSerializer, DateActivityListSerializer, PeriodActivityListSerializer, ] @@ -83,6 +85,7 @@ class ActivitySerializer(PolymorphicModelSerializer): polymorphic_serializers = [ FundingSerializer, DeedSerializer, + CollectActivitySerializer, DateActivitySerializer, PeriodActivitySerializer, ] From a327dc662b30fe1c82982a56a8d3ab99d0d6aa7d Mon Sep 17 00:00:00 2001 From: Loek van Gent Date: Fri, 24 Sep 2021 13:51:10 +0200 Subject: [PATCH 12/88] Fix some naming --- bluebottle/activities/permissions.py | 5 ++++- bluebottle/collect/migrations/0001_initial.py | 2 ++ bluebottle/collect/migrations/0005_auto_20210922_1502.py | 9 ++++++--- bluebottle/collect/serializers.py | 2 +- bluebottle/collect/tests/steps.py | 6 +++--- bluebottle/collect/tests/test_admin.py | 2 +- bluebottle/collect/tests/test_api.py | 4 ++-- bluebottle/initiatives/models.py | 4 ++-- bluebottle/settings/testing_local.py | 2 +- 9 files changed, 22 insertions(+), 14 deletions(-) diff --git a/bluebottle/activities/permissions.py b/bluebottle/activities/permissions.py index d2eed9ada1..a9044b4a3f 100644 --- a/bluebottle/activities/permissions.py +++ b/bluebottle/activities/permissions.py @@ -28,7 +28,10 @@ def has_permission(self, request, view): (settings, _) = InitiativePlatformSettings.objects.get_or_create() if request.method == 'POST': - return view.model.__name__.lower() in settings.activity_types + activity_type = view.model.__name__.lower() + if activity_type == 'collectactivity': + activity_type = 'collect' + return activity_type in settings.activity_types return True diff --git a/bluebottle/collect/migrations/0001_initial.py b/bluebottle/collect/migrations/0001_initial.py index 4f9ef9e5af..1104b30492 100644 --- a/bluebottle/collect/migrations/0001_initial.py +++ b/bluebottle/collect/migrations/0001_initial.py @@ -25,6 +25,7 @@ class Migration(migrations.Migration): ('api_add_collectactivity', 'Can add collect activity through the API'), ('api_change_collectactivity', 'Can change collect activity through the API'), ('api_delete_collectactivity', 'Can delete collect activity through the API'), + ('api_read_own_collectactivity', 'Can view own collect activity through the API'), ('api_add_own_collectactivity', 'Can add own collect activity through the API'), ('api_change_own_collectactivity', 'Can change own collect activity through the API'), @@ -48,6 +49,7 @@ class Migration(migrations.Migration): ('api_add_collectcontributor', 'Can add collect contributor through the API'), ('api_change_collectcontributor', 'Can change collect contributor through the API'), ('api_delete_collectcontributor', 'Can delete collect contributor through the API'), + ('api_read_own_collectcontributor', 'Can view own collect contributor through the API'), ('api_add_own_collectcontributor', 'Can add own collect contributor through the API'), ('api_change_own_collectcontributor', 'Can change own collect contributor through the API'), diff --git a/bluebottle/collect/migrations/0005_auto_20210922_1502.py b/bluebottle/collect/migrations/0005_auto_20210922_1502.py index 937baff48d..350f1dc2ed 100644 --- a/bluebottle/collect/migrations/0005_auto_20210922_1502.py +++ b/bluebottle/collect/migrations/0005_auto_20210922_1502.py @@ -28,11 +28,14 @@ def add_group_permissions(apps, schema_editor): }, 'Authenticated': { 'perms': ( - 'api_read_collectactivity', 'api_add_own_collectactivity', - 'api_change_own_collectactivity', 'api_delete_own_collectactivity', + + 'api_read_collectactivity', + 'api_add_own_collectactivity', 'api_change_own_collectactivity', + 'api_delete_own_collectactivity', 'api_read_collectcontributor', 'api_add_own_collectcontributor', 'api_change_own_collectcontributor', 'api_delete_own_collectcontributor', - 'api_add_collectcontributor', 'api_read_collecttype', + 'api_read_collectcontributor', 'api_add_collectcontributor', + 'api_read_collecttype', ) } } diff --git a/bluebottle/collect/serializers.py b/bluebottle/collect/serializers.py index cf9ff0d588..88f1b684af 100644 --- a/bluebottle/collect/serializers.py +++ b/bluebottle/collect/serializers.py @@ -92,7 +92,7 @@ class Meta(BaseActivityListSerializer.Meta): ) class JSONAPIMeta(BaseActivityListSerializer.JSONAPIMeta): - resource_name = 'activities/collect' + resource_name = 'activities/collects' class CollectActivityTransitionSerializer(TransitionSerializer): diff --git a/bluebottle/collect/tests/steps.py b/bluebottle/collect/tests/steps.py index 77769688d1..4c3cb21f4a 100644 --- a/bluebottle/collect/tests/steps.py +++ b/bluebottle/collect/tests/steps.py @@ -12,7 +12,7 @@ def api_create_collect_activity( request_user = initiative.owner test.data = { 'data': { - 'type': 'activities/collectactivities', + 'type': 'activities/collects', 'attributes': attributes, 'relationships': { 'initiative': { @@ -38,7 +38,7 @@ def api_update_collect_activity( request_user = activity.owner test.data = { 'data': { - 'type': 'activities/collectactivities', + 'type': 'activities/collects', 'id': activity.id, 'attributes': attributes, 'relationships': { @@ -72,7 +72,7 @@ def api_collect_activity_transition( 'relationships': { 'resource': { 'data': { - 'type': 'activities/collectactivities', + 'type': 'activities/collects', 'id': activity.pk } } diff --git a/bluebottle/collect/tests/test_admin.py b/bluebottle/collect/tests/test_admin.py index 03722a7943..3918377bcf 100644 --- a/bluebottle/collect/tests/test_admin.py +++ b/bluebottle/collect/tests/test_admin.py @@ -27,7 +27,7 @@ def test_admin_collect_feature_flag(self): page = self.app.get(url) self.assertFalse('Collect' in page.text) - initiative_settings.activity_types = ['dateactivity', 'periodactivity', 'collectactivity'] + initiative_settings.activity_types = ['dateactivity', 'periodactivity', 'collect'] initiative_settings.save() url = reverse('admin:index') page = self.app.get(url) diff --git a/bluebottle/collect/tests/test_api.py b/bluebottle/collect/tests/test_api.py index dcfd675767..d61097b6fd 100644 --- a/bluebottle/collect/tests/test_api.py +++ b/bluebottle/collect/tests/test_api.py @@ -33,7 +33,7 @@ def setUp(self): self.fields = ['initiative', 'start', 'end', 'title', 'description'] settings = InitiativePlatformSettings.objects.get() - settings.activity_types.append('collectactivity') + settings.activity_types.append('collect') settings.save() def test_create_complete(self): @@ -92,7 +92,7 @@ def test_create_anonymous(self): def test_create_disabled_activity_type(self): settings = InitiativePlatformSettings.objects.get() - settings.activity_types.remove('collectactivity') + settings.activity_types.remove('collect') settings.save() self.perform_create(user=self.user) diff --git a/bluebottle/initiatives/models.py b/bluebottle/initiatives/models.py index ff51adbacd..8fa7df740c 100644 --- a/bluebottle/initiatives/models.py +++ b/bluebottle/initiatives/models.py @@ -249,7 +249,7 @@ class InitiativePlatformSettings(BasePlatformSettings): ('periodactivity', _('Activity during a period')), ('dateactivity', _('Activity on a specific date')), ('deed', _('Deed')), - ('collectactivity', _('Collect activity')), + ('collect', _('Collect activity')), ) ACTIVITY_SEARCH_FILTERS = ( @@ -311,7 +311,7 @@ def deeds_enabled(self): @property def collect_enabled(self): - return 'collectactivity' in self.activity_types + return 'collect' in self.activity_types @property def funding_enabled(self): diff --git a/bluebottle/settings/testing_local.py b/bluebottle/settings/testing_local.py index 408c81922e..afe0210dab 100644 --- a/bluebottle/settings/testing_local.py +++ b/bluebottle/settings/testing_local.py @@ -4,7 +4,7 @@ DATABASES = { 'default': { "ENGINE": "bluebottle.clients.postgresql_backend", - "NAME": "bluebottle_test", + "NAME": "test_reef", "USER": "", "PASSWORD": "", }, From 16081b6ac52f1a9008089792d383397d0ad0be06 Mon Sep 17 00:00:00 2001 From: Loek van Gent Date: Fri, 24 Sep 2021 14:25:12 +0200 Subject: [PATCH 13/88] Fix polymorphic for collect --- bluebottle/activities/admin.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/bluebottle/activities/admin.py b/bluebottle/activities/admin.py index c9402861a2..d270e48103 100644 --- a/bluebottle/activities/admin.py +++ b/bluebottle/activities/admin.py @@ -14,7 +14,8 @@ from bluebottle.activities.messages import ImpactReminderMessage from bluebottle.activities.models import Activity, Contributor, Organizer, Contribution, EffortContribution from bluebottle.bluebottle_dashboard.decorators import confirmation_form -from bluebottle.deeds.models import Deed +from bluebottle.collect.models import CollectContributor, CollectActivity +from bluebottle.deeds.models import Deed, DeedParticipant from bluebottle.follow.admin import FollowAdminInline from bluebottle.fsm.admin import StateMachineAdmin, StateMachineFilter from bluebottle.funding.models import Funding, Donor, MoneyContribution @@ -34,7 +35,9 @@ class ContributorAdmin(PolymorphicParentModelAdmin, StateMachineAdmin): Donor, Organizer, DateParticipant, - PeriodParticipant + PeriodParticipant, + DeedParticipant, + CollectContributor ) list_display = ['created', 'owner', 'type', 'activity', 'state_name'] list_filter = (PolymorphicChildModelFilter, StateMachineFilter,) @@ -499,7 +502,8 @@ class ActivityAdmin(PolymorphicParentModelAdmin, StateMachineAdmin): Funding, PeriodActivity, DateActivity, - Deed + Deed, + CollectActivity ) date_hierarchy = 'transition_date' readonly_fields = ['link', 'review_status', 'location_link'] @@ -583,6 +587,11 @@ class ActivityAdminInline(StackedPolymorphicInline): extra = 0 can_delete = False + class CollectActivityInline(ActivityInlineChild): + readonly_fields = ['activity_link', 'start', 'end', 'state_name'] + fields = readonly_fields + model = CollectActivity + class DeedInline(ActivityInlineChild): readonly_fields = ['activity_link', 'start', 'end', 'state_name'] fields = readonly_fields @@ -608,7 +617,8 @@ class PeriodInline(ActivityInlineChild): FundingInline, PeriodInline, DateInline, - DeedInline + DeedInline, + CollectActivityInline ) pagination_key = 'page' From 3ff4a349f6e92fb67689d01ab7ec3cf0f70ca5ee Mon Sep 17 00:00:00 2001 From: Loek van Gent Date: Mon, 27 Sep 2021 09:35:06 +0200 Subject: [PATCH 14/88] Fix sending messages --- bluebottle/collect/messages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bluebottle/collect/messages.py b/bluebottle/collect/messages.py index d905fab547..3cfda94f90 100644 --- a/bluebottle/collect/messages.py +++ b/bluebottle/collect/messages.py @@ -35,7 +35,7 @@ def action_link(self): def get_recipients(self): """contributors that signed up""" return [ - participant.user for participant in self.obj.contributors + contributor.user for contributor in self.obj.accepted_contributors.all() ] From d26ddedaa6d2841b850a7db501c09e96ae1e3fab Mon Sep 17 00:00:00 2001 From: Loek van Gent Date: Mon, 27 Sep 2021 17:38:08 +0200 Subject: [PATCH 15/88] Add location and fix wallpost --- bluebottle/collect/admin.py | 2 + .../migrations/0006_auto_20210927_1047.py | 28 +++++++++++++ bluebottle/collect/models.py | 5 ++- bluebottle/collect/serializers.py | 8 +++- .../geo/migrations/0027_auto_20210927_1047.py | 40 +++++++++++++++++++ bluebottle/wallposts/serializers.py | 5 ++- bluebottle/wallposts/views.py | 5 ++- 7 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 bluebottle/collect/migrations/0006_auto_20210927_1047.py create mode 100644 bluebottle/geo/migrations/0027_auto_20210927_1047.py diff --git a/bluebottle/collect/admin.py b/bluebottle/collect/admin.py index 78e56c75d8..c370a96754 100644 --- a/bluebottle/collect/admin.py +++ b/bluebottle/collect/admin.py @@ -48,6 +48,7 @@ class CollectActivityAdmin(ActivityChildAdmin): inlines = (CollectContributorInline,) + ActivityChildAdmin.inlines list_filter = ['status', 'type'] search_fields = ['title', 'description'] + raw_id_fields = ActivityChildAdmin.raw_id_fields + ['location'] list_display = ActivityChildAdmin.list_display + [ 'start', @@ -66,6 +67,7 @@ def contributor_count(self, obj): ) description_fields = ActivityChildAdmin.description_fields + ( 'type', + 'location' ) export_as_csv_fields = ( diff --git a/bluebottle/collect/migrations/0006_auto_20210927_1047.py b/bluebottle/collect/migrations/0006_auto_20210927_1047.py new file mode 100644 index 0000000000..9f3724ca20 --- /dev/null +++ b/bluebottle/collect/migrations/0006_auto_20210927_1047.py @@ -0,0 +1,28 @@ +# Generated by Django 2.2.24 on 2021-09-27 08:47 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('geo', '0027_auto_20210927_1047'), + ('collect', '0005_auto_20210922_1502'), + ] + + operations = [ + migrations.AlterModelOptions( + name='collectactivity', + options={'permissions': (('api_read_collect', 'Can view collect activity through the API'), ('api_add_collect', 'Can add collect activity through the API'), ('api_change_collect', 'Can change collect activity through the API'), ('api_delete_collect', 'Can delete collect activity through the API'), ('api_read_own_collect', 'Can view own collect activity through the API'), ('api_add_own_collect', 'Can add own collect activity through the API'), ('api_change_own_collect', 'Can change own collect activity through the API'), ('api_delete_own_collect', 'Can delete own collect activity through the API')), 'verbose_name': 'Collect Activity', 'verbose_name_plural': 'Collect Activities'}, + ), + migrations.AlterModelOptions( + name='collectcontributor', + options={'permissions': (('api_read_collectcontributor', 'Can view collect contributor through the API'), ('api_add_collectcontributor', 'Can add collect contributor through the API'), ('api_change_collectcontributor', 'Can change collect contributor through the API'), ('api_delete_collectcontributor', 'Can delete collect contributor through the API'), ('api_read_own_collectcontributor', 'Can view own collect contributor through the API'), ('api_add_own_collectcontributor', 'Can add own collect contributor through the API'), ('api_change_own_collectcontributor', 'Can change own collect contributor through the API'), ('api_delete_own_collectcontributor', 'Can delete own collect contributor through the API')), 'verbose_name': 'Collect contributor', 'verbose_name_plural': 'Collect contributors'}, + ), + migrations.AddField( + model_name='collectactivity', + name='location', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='geo.Geolocation'), + ), + ] diff --git a/bluebottle/collect/models.py b/bluebottle/collect/models.py index 46f28ad971..91665d863b 100644 --- a/bluebottle/collect/models.py +++ b/bluebottle/collect/models.py @@ -6,6 +6,7 @@ from bluebottle.activities.models import Activity, Contributor, EffortContribution from bluebottle.deeds.validators import EndDateValidator +from bluebottle.geo.models import Geolocation from bluebottle.utils.models import SortableTranslatableModel @@ -33,6 +34,8 @@ class CollectActivity(Activity): type = models.ForeignKey(CollectType, null=True, blank=True, on_delete=SET_NULL) + location = models.ForeignKey(Geolocation, null=True, blank=True, on_delete=SET_NULL) + auto_approve = True @property @@ -98,4 +101,4 @@ class Meta(object): ) class JSONAPIMeta(object): - resource_name = 'contributors/collects/contributor' + resource_name = 'contributors/collect/contributors' diff --git a/bluebottle/collect/serializers.py b/bluebottle/collect/serializers.py index 88f1b684af..c06bf5fae4 100644 --- a/bluebottle/collect/serializers.py +++ b/bluebottle/collect/serializers.py @@ -65,18 +65,21 @@ class Meta(BaseActivitySerializer.Meta): 'start', 'end', 'contributors_export_url', + 'location', ) class JSONAPIMeta(BaseActivitySerializer.JSONAPIMeta): resource_name = 'activities/collects' included_resources = BaseActivitySerializer.JSONAPIMeta.included_resources + [ 'my_contributor', + 'location' ] included_serializers = dict( BaseActivitySerializer.included_serializers, **{ 'my_contributor': 'bluebottle.collect.serializers.CollectContributorSerializer', + 'location': 'bluebottle.geo.serializers.GeolocationSerializer', } ) @@ -118,9 +121,10 @@ class Meta(BaseContributorSerializer.Meta): fields = BaseContributorSerializer.Meta.fields + ('value',) class JSONAPIMeta(BaseContributorSerializer.JSONAPIMeta): - resource_name = 'contributors/collect/contributors' + resource_name = 'contributors/collect/contributor' included_resources = [ - 'user', 'activity', + 'user', + 'activity', ] included_serializers = { diff --git a/bluebottle/geo/migrations/0027_auto_20210927_1047.py b/bluebottle/geo/migrations/0027_auto_20210927_1047.py new file mode 100644 index 0000000000..e1eeae7346 --- /dev/null +++ b/bluebottle/geo/migrations/0027_auto_20210927_1047.py @@ -0,0 +1,40 @@ +# Generated by Django 2.2.24 on 2021-09-27 08:47 + +from django.db import migrations, models +import django.db.models.deletion +import parler.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('geo', '0026_auto_20210415_0854'), + ] + + operations = [ + migrations.AlterField( + model_name='countrytranslation', + name='master', + field=parler.fields.TranslationsForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='translations', to='geo.Country'), + ), + migrations.AlterField( + model_name='location', + name='group', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='geo.LocationGroup', verbose_name='location group'), + ), + migrations.AlterField( + model_name='location', + name='subregion', + field=models.ForeignKey(blank=True, help_text='The organisational group this office belongs too.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='offices.OfficeSubRegion', verbose_name='office group'), + ), + migrations.AlterField( + model_name='regiontranslation', + name='master', + field=parler.fields.TranslationsForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='translations', to='geo.Region'), + ), + migrations.AlterField( + model_name='subregiontranslation', + name='master', + field=parler.fields.TranslationsForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='translations', to='geo.SubRegion'), + ), + ] diff --git a/bluebottle/wallposts/serializers.py b/bluebottle/wallposts/serializers.py index 4660a8ee89..7e6282fe6d 100644 --- a/bluebottle/wallposts/serializers.py +++ b/bluebottle/wallposts/serializers.py @@ -11,7 +11,8 @@ from bluebottle.members.serializers import UserPreviewSerializer from bluebottle.utils.serializers import MoneySerializer from .models import Wallpost, SystemWallpost, MediaWallpost, TextWallpost, MediaWallpostPhoto, Reaction -from ..deeds.models import Deed +from bluebottle.collect.models import CollectActivity +from bluebottle.deeds.models import Deed class ReactionSerializer(serializers.ModelSerializer): @@ -48,6 +49,8 @@ def to_internal_value(self, data): data = ContentType.objects.get_for_model(PeriodActivity) elif data == 'deed': data = ContentType.objects.get_for_model(Deed) + elif data == 'collect': + data = ContentType.objects.get_for_model(CollectActivity) return data diff --git a/bluebottle/wallposts/views.py b/bluebottle/wallposts/views.py index 3249163d4c..f40a90d8ff 100644 --- a/bluebottle/wallposts/views.py +++ b/bluebottle/wallposts/views.py @@ -79,16 +79,17 @@ class ParentTypeFilterMixin(object): 'activities/time-based/period': 'periodactivity', 'activities/funding': 'fundingactivity', 'activities/deed': 'deed', + 'activities/collect': 'collectactivity', } def get_queryset(self): queryset = super(ParentTypeFilterMixin, self).get_queryset() parent_type = self.request.query_params.get('parent_type', None) - if parent_type in ['date', 'period']: + if parent_type in ['date', 'period', 'collect']: parent_type = '{}activity'.format(parent_type) parent_id = self.request.query_params.get('parent_id', None) - white_listed_apps = ['initiatives', 'assignments', 'events', 'funding', 'time_based', 'deeds'] + white_listed_apps = ['initiatives', 'assignments', 'events', 'funding', 'time_based', 'deeds', 'collect'] try: parent_type = self.content_type_mapping[parent_type] except KeyError: From 90424a35c2f97d2254118e865e89d09c6f3044e0 Mon Sep 17 00:00:00 2001 From: Loek van Gent Date: Tue, 28 Sep 2021 08:40:27 +0200 Subject: [PATCH 16/88] Add collect type to api --- .../migrations/0007_collecttype_disabled.py | 18 ++++++++++++++ bluebottle/collect/models.py | 2 ++ bluebottle/collect/serializers.py | 13 +++++++++- bluebottle/collect/urls/api.py | 14 ++++++++++- bluebottle/collect/views.py | 24 +++++++++++++++---- bluebottle/initiatives/views.py | 8 ++----- bluebottle/utils/views.py | 5 ++++ 7 files changed, 72 insertions(+), 12 deletions(-) create mode 100644 bluebottle/collect/migrations/0007_collecttype_disabled.py diff --git a/bluebottle/collect/migrations/0007_collecttype_disabled.py b/bluebottle/collect/migrations/0007_collecttype_disabled.py new file mode 100644 index 0000000000..6356be92ea --- /dev/null +++ b/bluebottle/collect/migrations/0007_collecttype_disabled.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.24 on 2021-09-28 06:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('collect', '0006_auto_20210927_1047'), + ] + + operations = [ + migrations.AddField( + model_name='collecttype', + name='disabled', + field=models.BooleanField(default=False), + ), + ] diff --git a/bluebottle/collect/models.py b/bluebottle/collect/models.py index 91665d863b..3edff7bf16 100644 --- a/bluebottle/collect/models.py +++ b/bluebottle/collect/models.py @@ -11,6 +11,8 @@ class CollectType(SortableTranslatableModel): + disabled = models.BooleanField(default=False) + translations = TranslatedFields( name=models.CharField(_('name'), max_length=100), description=models.TextField(_('description'), blank=True) diff --git a/bluebottle/collect/serializers.py b/bluebottle/collect/serializers.py index c06bf5fae4..798a400bfe 100644 --- a/bluebottle/collect/serializers.py +++ b/bluebottle/collect/serializers.py @@ -1,3 +1,4 @@ +from rest_framework.serializers import ModelSerializer from rest_framework_json_api.relations import ( ResourceRelatedField, SerializerMethodResourceRelatedField @@ -7,7 +8,7 @@ BaseActivitySerializer, BaseActivityListSerializer, BaseContributorSerializer ) from bluebottle.bluebottle_drf2.serializers import PrivateFileSerializer -from bluebottle.collect.models import CollectActivity, CollectContributor +from bluebottle.collect.models import CollectActivity, CollectContributor, CollectType from bluebottle.collect.states import CollectContributorStateMachine from bluebottle.fsm.serializers import TransitionSerializer from bluebottle.time_based.permissions import CanExportParticipantsPermission @@ -150,3 +151,13 @@ class JSONAPIMeta(object): included_resources = [ 'resource', ] + + +class CollectTypeSerializer(ModelSerializer): + + class Meta(object): + model = CollectType + fields = ('id', 'name', 'description') + + class JSONAPIMeta(object): + resource_name = 'activities/collect-types' diff --git a/bluebottle/collect/urls/api.py b/bluebottle/collect/urls/api.py index 40dfd8f400..b47f667e6c 100644 --- a/bluebottle/collect/urls/api.py +++ b/bluebottle/collect/urls/api.py @@ -4,7 +4,8 @@ CollectContributorExportView, CollectContributorTransitionList, CollectContributorDetail, CollectContributorList, CollectActivityRelatedCollectContributorList, - CollectActivityTransitionList, CollectActivityDetailView, CollectActivityListView, + CollectActivityTransitionList, CollectActivityDetailView, CollectActivityListView, CollectTypeList, + CollectTypeDetail, ) @@ -38,4 +39,15 @@ url(r'^/export/(?P[\d]+)$', CollectContributorExportView.as_view(), name='collect-contributors-export'), + + url( + r'^/types/$', + CollectTypeList.as_view(), + name='collect-type-list' + ), + url( + r'^/types/(?P\d+)$', + CollectTypeDetail.as_view(), + name='collect-type-detail' + ), ] diff --git a/bluebottle/collect/views.py b/bluebottle/collect/views.py index 4c3c6a354c..ae201127f9 100644 --- a/bluebottle/collect/views.py +++ b/bluebottle/collect/views.py @@ -7,19 +7,19 @@ ActivityOwnerPermission, ActivityTypePermission, ActivityStatusPermission, DeleteActivityPermission, ContributorPermission ) -from bluebottle.collect.models import CollectActivity, CollectContributor +from bluebottle.collect.models import CollectActivity, CollectContributor, CollectType from bluebottle.collect.serializers import ( CollectActivitySerializer, CollectActivityTransitionSerializer, CollectContributorSerializer, - CollectContributorTransitionSerializer + CollectContributorTransitionSerializer, CollectTypeSerializer ) from bluebottle.transitions.views import TransitionList from bluebottle.utils.admin import prep_field from bluebottle.utils.permissions import ( - OneOf, ResourcePermission, ResourceOwnerPermission + OneOf, ResourcePermission, ResourceOwnerPermission, TenantConditionalOpenClose ) from bluebottle.utils.views import ( RetrieveUpdateDestroyAPIView, ListAPIView, ListCreateAPIView, RetrieveUpdateAPIView, - JsonApiViewMixin, PrivateFileView + JsonApiViewMixin, PrivateFileView, TranslatedApiViewMixin, RetrieveAPIView, NoPagination ) @@ -149,3 +149,19 @@ def get(self, request, *args, **kwargs): writer.writerow(row) return response + + +class CollectTypeList(TranslatedApiViewMixin, JsonApiViewMixin, ListAPIView): + serializer_class = CollectTypeSerializer + queryset = CollectType.objects.filter(disabled=False) + permission_classes = [TenantConditionalOpenClose, ] + pagination_class = NoPagination + + def get_queryset(self): + return super().get_queryset().order_by('translations__name') + + +class CollectTypeDetail(TranslatedApiViewMixin, JsonApiViewMixin, RetrieveAPIView): + serializer_class = CollectTypeSerializer + queryset = CollectType.objects.filter(disabled=False) + permission_classes = [TenantConditionalOpenClose, ] diff --git a/bluebottle/initiatives/views.py b/bluebottle/initiatives/views.py index 575d4f4c5f..329ed59c81 100644 --- a/bluebottle/initiatives/views.py +++ b/bluebottle/initiatives/views.py @@ -32,7 +32,7 @@ ) from bluebottle.utils.views import ( ListCreateAPIView, RetrieveUpdateAPIView, JsonApiViewMixin, - CreateAPIView, ListAPIView, TranslatedApiViewMixin, RetrieveAPIView + CreateAPIView, ListAPIView, TranslatedApiViewMixin, RetrieveAPIView, NoPagination ) @@ -171,15 +171,11 @@ class InitiativeReviewTransitionList(TransitionList): queryset = Initiative.objects.all() -class ThemePagination(PageNumberPagination): - page_size = 10000 - - class ThemeList(TranslatedApiViewMixin, JsonApiViewMixin, ListAPIView): serializer_class = ThemeSerializer queryset = Theme.objects.filter(disabled=False) permission_classes = [TenantConditionalOpenClose, ] - pagination_class = ThemePagination + pagination_class = NoPagination def get_queryset(self): return super().get_queryset().order_by('translations__name') diff --git a/bluebottle/utils/views.py b/bluebottle/utils/views.py index 10bc00198d..61c47b22a6 100644 --- a/bluebottle/utils/views.py +++ b/bluebottle/utils/views.py @@ -15,6 +15,7 @@ from parler.utils.i18n import get_language from rest_framework import generics from rest_framework import views, response +from rest_framework.pagination import PageNumberPagination from rest_framework_json_api.exceptions import exception_handler from rest_framework_json_api.pagination import JsonApiPageNumberPagination from rest_framework_json_api.parsers import JSONParser @@ -299,3 +300,7 @@ class JsonApiViewMixin(AutoPrefetchMixin): def get_exception_handler(self): return exception_handler + + +class NoPagination(PageNumberPagination): + page_size = 10000 From 3531630a2f1db0518a54196f4d783033a631bc98 Mon Sep 17 00:00:00 2001 From: Loek van Gent Date: Tue, 28 Sep 2021 08:50:04 +0200 Subject: [PATCH 17/88] Fix collect type --- bluebottle/collect/serializers.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/bluebottle/collect/serializers.py b/bluebottle/collect/serializers.py index 798a400bfe..e6a24e9194 100644 --- a/bluebottle/collect/serializers.py +++ b/bluebottle/collect/serializers.py @@ -17,6 +17,11 @@ class CollectActivitySerializer(BaseActivitySerializer): permissions = ResourcePermissionField('collect-activity-detail', view_args=('pk',)) + collect_type = ResourceRelatedField( + queryset=CollectType.objects, + source='type' + ) + my_contributor = SerializerMethodResourceRelatedField( model=CollectContributor, read_only=True, @@ -67,13 +72,15 @@ class Meta(BaseActivitySerializer.Meta): 'end', 'contributors_export_url', 'location', + 'collect_type' ) class JSONAPIMeta(BaseActivitySerializer.JSONAPIMeta): resource_name = 'activities/collects' included_resources = BaseActivitySerializer.JSONAPIMeta.included_resources + [ 'my_contributor', - 'location' + 'location', + 'collect_type' ] included_serializers = dict( @@ -81,6 +88,8 @@ class JSONAPIMeta(BaseActivitySerializer.JSONAPIMeta): **{ 'my_contributor': 'bluebottle.collect.serializers.CollectContributorSerializer', 'location': 'bluebottle.geo.serializers.GeolocationSerializer', + 'collect_type': 'bluebottle.collect.serializers.CollectTypeSerializer', + } ) From d20b03daaf1824ea4b032ac3ea5cb5f087016b49 Mon Sep 17 00:00:00 2001 From: Loek van Gent Date: Tue, 28 Sep 2021 14:51:51 +0200 Subject: [PATCH 18/88] Fix some styles --- bluebottle/collect/serializers.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bluebottle/collect/serializers.py b/bluebottle/collect/serializers.py index e6a24e9194..fed61392a0 100644 --- a/bluebottle/collect/serializers.py +++ b/bluebottle/collect/serializers.py @@ -19,6 +19,7 @@ class CollectActivitySerializer(BaseActivitySerializer): permissions = ResourcePermissionField('collect-activity-detail', view_args=('pk',)) collect_type = ResourceRelatedField( queryset=CollectType.objects, + required=False, source='type' ) @@ -131,7 +132,7 @@ class Meta(BaseContributorSerializer.Meta): fields = BaseContributorSerializer.Meta.fields + ('value',) class JSONAPIMeta(BaseContributorSerializer.JSONAPIMeta): - resource_name = 'contributors/collect/contributor' + resource_name = 'contributors/collect/contributors' included_resources = [ 'user', 'activity', @@ -156,7 +157,7 @@ class CollectContributorTransitionSerializer(TransitionSerializer): } class JSONAPIMeta(object): - resource_name = 'contributors/collect/collect-contributor-transitions' + resource_name = 'contributors/collect/contributor-transitions' included_resources = [ 'resource', ] From 77356e79b84412e77d73baa8f2137f2c9cfee934 Mon Sep 17 00:00:00 2001 From: Loek van Gent Date: Thu, 30 Sep 2021 09:52:56 +0200 Subject: [PATCH 19/88] Add target to collect --- bluebottle/activities/serializers.py | 6 ++++-- bluebottle/collect/admin.py | 2 ++ .../migrations/0008_collectactivity_target.py | 18 ++++++++++++++++++ bluebottle/collect/models.py | 1 + bluebottle/collect/serializers.py | 3 ++- 5 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 bluebottle/collect/migrations/0008_collectactivity_target.py diff --git a/bluebottle/activities/serializers.py b/bluebottle/activities/serializers.py index f71826c372..d1bc944e2c 100644 --- a/bluebottle/activities/serializers.py +++ b/bluebottle/activities/serializers.py @@ -4,7 +4,8 @@ from rest_framework_json_api.serializers import PolymorphicModelSerializer, ModelSerializer from bluebottle.activities.models import Contributor, Activity -from bluebottle.collect.serializers import CollectActivityListSerializer, CollectActivitySerializer +from bluebottle.collect.serializers import CollectActivityListSerializer, CollectActivitySerializer, \ + CollectContributorListSerializer from bluebottle.deeds.serializers import ( DeedListSerializer, DeedSerializer, DeedParticipantListSerializer ) @@ -180,7 +181,8 @@ class ContributorListSerializer(PolymorphicModelSerializer): DonorListSerializer, DateParticipantListSerializer, PeriodParticipantListSerializer, - DeedParticipantListSerializer + DeedParticipantListSerializer, + CollectContributorListSerializer ] included_serializers = { diff --git a/bluebottle/collect/admin.py b/bluebottle/collect/admin.py index c370a96754..f978abf157 100644 --- a/bluebottle/collect/admin.py +++ b/bluebottle/collect/admin.py @@ -55,6 +55,7 @@ class CollectActivityAdmin(ActivityChildAdmin): 'end', 'type', 'contributor_count', + 'target' ] def contributor_count(self, obj): @@ -67,6 +68,7 @@ def contributor_count(self, obj): ) description_fields = ActivityChildAdmin.description_fields + ( 'type', + 'target', 'location' ) diff --git a/bluebottle/collect/migrations/0008_collectactivity_target.py b/bluebottle/collect/migrations/0008_collectactivity_target.py new file mode 100644 index 0000000000..d3bf5e3d87 --- /dev/null +++ b/bluebottle/collect/migrations/0008_collectactivity_target.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.24 on 2021-09-30 07:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('collect', '0007_collecttype_disabled'), + ] + + operations = [ + migrations.AddField( + model_name='collectactivity', + name='target', + field=models.DecimalField(blank=True, decimal_places=3, max_digits=15, null=True), + ), + ] diff --git a/bluebottle/collect/models.py b/bluebottle/collect/models.py index 3edff7bf16..c3b8946c6f 100644 --- a/bluebottle/collect/models.py +++ b/bluebottle/collect/models.py @@ -37,6 +37,7 @@ class CollectActivity(Activity): type = models.ForeignKey(CollectType, null=True, blank=True, on_delete=SET_NULL) location = models.ForeignKey(Geolocation, null=True, blank=True, on_delete=SET_NULL) + target = models.DecimalField(decimal_places=3, max_digits=15, null=True, blank=True) auto_approve = True diff --git a/bluebottle/collect/serializers.py b/bluebottle/collect/serializers.py index fed61392a0..0dbd0c092f 100644 --- a/bluebottle/collect/serializers.py +++ b/bluebottle/collect/serializers.py @@ -73,7 +73,8 @@ class Meta(BaseActivitySerializer.Meta): 'end', 'contributors_export_url', 'location', - 'collect_type' + 'collect_type', + 'target' ) class JSONAPIMeta(BaseActivitySerializer.JSONAPIMeta): From fb04a876372450880057a13e317ccdea33b5c98b Mon Sep 17 00:00:00 2001 From: Loek van Gent Date: Fri, 1 Oct 2021 14:24:54 +0200 Subject: [PATCH 20/88] Some fixes for cards & info refactor --- bluebottle/time_based/serializers.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bluebottle/time_based/serializers.py b/bluebottle/time_based/serializers.py index 6a7b2ca15c..17ee5a43f2 100644 --- a/bluebottle/time_based/serializers.py +++ b/bluebottle/time_based/serializers.py @@ -195,8 +195,11 @@ def get_date_info(self, obj): starts = set( slots.annotate(date=Trunc('start', kind='day')).values_list('date') ) + total = self.get_filtered_slots(obj) return { + 'total': total.count(), + 'has_multiple': total.count() > 1, 'count': len(starts), 'first': min(starts)[0].date() if starts else None, } @@ -224,7 +227,7 @@ def get_location_info(self, obj): } has_multiple = len(set(locations)) > 1 and not is_online - location = '{} - {}'.format(locations[0][0], locations[0][1]) if not has_multiple and locations[0][0] else None + location = '{}, {}'.format(locations[0][0], locations[0][1]) if not has_multiple and locations[0][0] else None return { 'is_online': is_online, 'online_meeting_url': locations[0][3] if is_online and locations[0][3] else None, From 987c69fa157797d5c05f8c1ef4fbc7283790b4c5 Mon Sep 17 00:00:00 2001 From: Loek van Gent Date: Tue, 5 Oct 2021 13:03:41 +0200 Subject: [PATCH 21/88] Make locationInfo location more like geolocation --- bluebottle/time_based/serializers.py | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/bluebottle/time_based/serializers.py b/bluebottle/time_based/serializers.py index 17ee5a43f2..9667a5428d 100644 --- a/bluebottle/time_based/serializers.py +++ b/bluebottle/time_based/serializers.py @@ -221,20 +221,33 @@ def get_location_info(self, obj): 'is_online': is_online, 'online_meeting_url': None, 'location': None, - 'address': None, 'hint': None, 'has_multiple': False } has_multiple = len(set(locations)) > 1 and not is_online - location = '{}, {}'.format(locations[0][0], locations[0][1]) if not has_multiple and locations[0][0] else None + if has_multiple: + return { + 'is_online': False, + 'online_meeting_url': None, + 'location': None, + 'locationHint': None, + 'has_multiple': True + } + slot = slots.first() + return { 'is_online': is_online, - 'online_meeting_url': locations[0][3] if is_online and locations[0][3] else None, - 'location': location, - 'address': locations[0][2] if not has_multiple else None, - 'hint': locations[0][4] if not has_multiple else None, - 'has_multiple': has_multiple + 'online_meeting_url': slot.online_meeting_url, + 'location': { + 'locality': slot.location.locality, + 'country': { + 'code': slot.location.country.alpha2_code, + }, + 'formattedAddress': slot.location.formatted_address, + }, + 'locationHint': locations[0][4], + 'has_multiple': False } From 51f5c1c00f5eb9853a89496c6582359e67f44551 Mon Sep 17 00:00:00 2001 From: Loek van Gent Date: Wed, 6 Oct 2021 09:43:59 +0200 Subject: [PATCH 22/88] Fix serializer --- bluebottle/time_based/serializers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bluebottle/time_based/serializers.py b/bluebottle/time_based/serializers.py index 9667a5428d..b4f9e924fc 100644 --- a/bluebottle/time_based/serializers.py +++ b/bluebottle/time_based/serializers.py @@ -240,11 +240,11 @@ def get_location_info(self, obj): 'is_online': is_online, 'online_meeting_url': slot.online_meeting_url, 'location': { - 'locality': slot.location.locality, + 'locality': slot.location.locality if slot.location else None, 'country': { - 'code': slot.location.country.alpha2_code, + 'code': slot.location.country.alpha2_code if slot.location else None, }, - 'formattedAddress': slot.location.formatted_address, + 'formattedAddress': slot.location.formatted_address if slot.location else None, }, 'locationHint': locations[0][4], 'has_multiple': False From 43e3dc517e54c04dd9363f9bf4006c22f8a99361 Mon Sep 17 00:00:00 2001 From: Loek van Gent Date: Tue, 12 Oct 2021 11:29:55 +0200 Subject: [PATCH 23/88] Fix all tests --- bluebottle/time_based/serializers.py | 29 ++++--- .../time_based/tests/test_serializers.py | 77 ++++++++++++------- 2 files changed, 68 insertions(+), 38 deletions(-) diff --git a/bluebottle/time_based/serializers.py b/bluebottle/time_based/serializers.py index b4f9e924fc..8d782ff875 100644 --- a/bluebottle/time_based/serializers.py +++ b/bluebottle/time_based/serializers.py @@ -206,7 +206,7 @@ def get_date_info(self, obj): def get_location_info(self, obj): slots = self.get_filtered_slots(obj, only_upcoming=False) - is_online = len(slots) and len(slots.filter(is_online=True)) == len(slots) + is_online = len(slots) > 0 and len(slots.filter(is_online=True)) == len(slots) locations = slots.values_list( 'location__locality', @@ -218,36 +218,41 @@ def get_location_info(self, obj): if not len(slots) or not len(locations): return { + 'has_multiple': False, 'is_online': is_online, 'online_meeting_url': None, 'location': None, - 'hint': None, - 'has_multiple': False + 'location_hint': None, } has_multiple = len(set(locations)) > 1 and not is_online if has_multiple: return { + 'has_multiple': True, 'is_online': False, 'online_meeting_url': None, 'location': None, - 'locationHint': None, - 'has_multiple': True + 'location_hint': None, } slot = slots.first() - return { - 'is_online': is_online, - 'online_meeting_url': slot.online_meeting_url, - 'location': { + if is_online: + location = None + else: + location = { 'locality': slot.location.locality if slot.location else None, 'country': { 'code': slot.location.country.alpha2_code if slot.location else None, }, 'formattedAddress': slot.location.formatted_address if slot.location else None, - }, - 'locationHint': locations[0][4], - 'has_multiple': False + } + + return { + 'has_multiple': False, + 'is_online': is_online, + 'online_meeting_url': slot.online_meeting_url or None, + 'location': location, + 'location_hint': slot.location_hint, } diff --git a/bluebottle/time_based/tests/test_serializers.py b/bluebottle/time_based/tests/test_serializers.py index 154c037a68..0e5a5ecf8b 100644 --- a/bluebottle/time_based/tests/test_serializers.py +++ b/bluebottle/time_based/tests/test_serializers.py @@ -27,11 +27,21 @@ def assertAttribute(self, attr, value, params=None): self.assertEqual(data[attr], value) def test_date_info_no_slots(self): - self.assertAttribute('date_info', {'count': 0, 'first': None}) + self.assertAttribute('date_info', { + 'count': 0, + 'first': None, + 'has_multiple': False, + 'total': 0 + }) def test_date_info_single_slot(self): slot = DateActivitySlotFactory.create(activity=self.activity) - self.assertAttribute('date_info', {'count': 1, 'first': slot.start.date()}) + self.assertAttribute('date_info', { + 'count': 1, + 'first': slot.start.date(), + 'has_multiple': False, + 'total': 1 + }) def test_date_info_multiple_dates(self): slots = [ @@ -40,10 +50,12 @@ def test_date_info_multiple_dates(self): DateActivitySlotFactory.create(activity=self.activity, start=now() + timedelta(days=6)), ] - self.assertAttribute( - 'date_info', - {'count': 3, 'first': min(slot.start.date() for slot in slots)} - ) + self.assertAttribute('date_info', { + 'count': 3, + 'first': min(slot.start.date() for slot in slots), + 'has_multiple': True, + 'total': 3 + }) def test_date_info_multiple_dates_overlapping(self): slots = [ @@ -52,10 +64,12 @@ def test_date_info_multiple_dates_overlapping(self): DateActivitySlotFactory.create(activity=self.activity, start=now() + timedelta(days=6)), ] - self.assertAttribute( - 'date_info', - {'count': 2, 'first': min(slot.start.date() for slot in slots)} - ) + self.assertAttribute('date_info', { + 'count': 2, + 'first': min(slot.start.date() for slot in slots), + 'has_multiple': True, + 'total': 3 + }) def test_date_info_multiple_dates_filtered(self): slots = [ @@ -66,7 +80,12 @@ def test_date_info_multiple_dates_filtered(self): self.assertAttribute( 'date_info', - {'count': 2, 'first': min(slot.start.date() for slot in slots)}, + { + 'count': 2, + 'first': min(slot.start.date() for slot in slots), + 'has_multiple': True, + 'total': 2 + }, { 'filter[start]': (now() + timedelta(days=1)).strftime('%Y-%m-%d'), 'filter[end]': (now() + timedelta(days=4)).strftime('%Y-%m-%d') @@ -78,12 +97,11 @@ def test_location_info_no_slots(self): self.assertAttribute( 'location_info', { - 'has_multiple': False, 'is_online': False, + 'has_multiple': False, 'location': None, 'online_meeting_url': None, - 'address': None, - 'hint': None, + 'location_hint': None, } ) @@ -94,10 +112,15 @@ def test_location_info_single_slot(self): { 'has_multiple': False, 'is_online': False, - 'location': '{} - {}'.format(slot.location.locality, slot.location.country.alpha2_code), + 'location': { + 'locality': slot.location.locality, + 'formattedAddress': slot.location.formatted_address, + 'country': { + 'code': slot.location.country.alpha2_code + } + }, 'online_meeting_url': None, - 'address': slot.location.formatted_address, - 'hint': None, + 'location_hint': None, } ) @@ -113,8 +136,7 @@ def test_location_info_all_online(self): 'is_online': True, 'location': None, 'online_meeting_url': None, - 'address': None, - 'hint': None, + 'location_hint': None, } ) @@ -130,8 +152,7 @@ def test_location_info_multiple_locations(self): 'is_online': False, 'location': None, 'online_meeting_url': None, - 'address': None, - 'hint': None, + 'location_hint': None, } ) @@ -142,17 +163,21 @@ def test_location_info_multiple_dates_filtered(self): DateActivitySlotFactory.create(activity=self.activity, start=now() + timedelta(days=6)), ] + location = slots[0].location self.assertAttribute( 'location_info', { 'has_multiple': False, 'is_online': False, - 'location': '{} - {}'.format( - slots[0].location.locality, slots[0].location.country.alpha2_code - ), + 'location': { + 'locality': location.locality, + 'formattedAddress': location.formatted_address, + 'country': { + 'code': location.country.alpha2_code + } + }, 'online_meeting_url': None, - 'address': slots[0].location.formatted_address, - 'hint': None, + 'location_hint': None, }, { 'filter[start]': (now() + timedelta(days=1)).strftime('%Y-%m-%d'), From c58a36227f4a81c60419f2a8e1c44b2e847dce2f Mon Sep 17 00:00:00 2001 From: Loek van Gent Date: Tue, 26 Oct 2021 11:37:21 +0200 Subject: [PATCH 24/88] Add merge migration --- .../geo/migrations/0030_merge_20211026_1137.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 bluebottle/geo/migrations/0030_merge_20211026_1137.py diff --git a/bluebottle/geo/migrations/0030_merge_20211026_1137.py b/bluebottle/geo/migrations/0030_merge_20211026_1137.py new file mode 100644 index 0000000000..a2305c691c --- /dev/null +++ b/bluebottle/geo/migrations/0030_merge_20211026_1137.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.24 on 2021-10-26 09:37 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('geo', '0029_auto_20211014_1404'), + ('geo', '0027_auto_20210927_1047'), + ] + + operations = [ + ] From 65fbdac6e233b638de59724aced32e706bd9026b Mon Sep 17 00:00:00 2001 From: Loek van Gent Date: Wed, 27 Oct 2021 13:58:40 +0200 Subject: [PATCH 25/88] Save JS changes --- .../static/jet/js/build/bundle.min.js | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/bluebottle/bluebottle_dashboard/static/jet/js/build/bundle.min.js b/bluebottle/bluebottle_dashboard/static/jet/js/build/bundle.min.js index 6eb76772ec..3848403c07 100644 --- a/bluebottle/bluebottle_dashboard/static/jet/js/build/bundle.min.js +++ b/bluebottle/bluebottle_dashboard/static/jet/js/build/bundle.min.js @@ -1,13 +1,13 @@ -!function e(t,i,n){function s(r,a){if(!i[r]){if(!t[r]){var l="function"==typeof require&&require;if(!a&&l)return l(r,!0);if(o)return o(r,!0);var c=new Error("Cannot find module '"+r+"'");throw c.code="MODULE_NOT_FOUND",c}var u=i[r]={exports:{}};t[r][0].call(u.exports,function(e){var i=t[r][1][e];return s(i?i:e)},u,u.exports,e,t,i,n)}return i[r].exports}for(var o="function"==typeof require&&require,r=0;r form > div"),t=e.find("> .module"),i=e.find("> .inline-group");return n().add(t).add(i)},getHashSelector:function(e){if(void 0==e)return null;var t=e.match(/^(#(\/tab\/(.+)\/)?)?$/i);return null==t?null:void 0!=t[3]?t[3]:""},showTab:function(e,t){var i=this.$changeform.find(".changeform-tabs-item"),n=this.getContentWrappers(),s=this.getHashSelector(e);if(t||null!=s){null!=s&&0!=s.length||(s=this.getHashSelector(i.first().find(".changeform-tabs-item-link").attr("href")));var o=n.filter("."+s),r=i.find('.changeform-tabs-item-link[href="#/tab/'+s+'/"]').closest(".changeform-tabs-item");i.removeClass("selected"),r.addClass("selected"),n.removeClass("selected"),o.addClass("selected")}},initTabs:function(){var e=this;n(window).on("hashchange",function(){e.showTab(location.hash,!1)}),this.showTab(location.hash,!0)},updateErrorState:function(){var e=this.$changeform.find(".changeform-tabs-item"),t=this.getContentWrappers(),i=this;e.each(function(){var e=n(this),s=i.getHashSelector(e.find(".changeform-tabs-item-link").attr("href"));if(s){var o=t.filter("."+s);o.find(".form-row.errors").length&&e.addClass("errors")}})},run:function(){try{this.initTabs(),this.updateErrorState()}catch(e){console.error(e,e.stack)}}},n(document).ready(function(){n(".change-form").each(function(){new s(n(this)).run()})})},{jquery:69}],2:[function(e,t,i){var n=e("jquery"),s=e("../utils/translate"),o=function(e){this.$changeForm=e};o.prototype={changeDetected:!1,onWindowBeforeUnload:function(){return s("Warning: you have unsaved changes")},onFormInputChanged:function(e){e.off("change",this.onFormInputChanged),self.changeDetected||n(window).bind("beforeunload",this.onWindowBeforeUnload),this.changeDetected=!0},initUnsavedChangesWarning:function(e){var t=this,i=e.find("#content-main form");if(0!=i.length){var s=i.find("input, textarea, select");n(document).on("submit","form",function(){n(window).off("beforeunload",t.onWindowBeforeUnload)}),s.on("change",n.proxy(this.onFormInputChanged,this,s))}},run:function(){try{this.initUnsavedChangesWarning(this.$changeForm)}catch(e){console.error(e,e.stack)}}},n(document).ready(function(){n(".change-form").each(function(){new o(n(this)).run()})})},{"../utils/translate":37,jquery:69}],3:[function(e,t,i){var n=e("jquery"),s=function(e){this.$changelist=e};s.prototype={updateFixedHeaderVisibility:function(e,t){var i=n(window).scrollTop()>t.offset().top;e.closest("table").toggle(i)},updateFixedHeaderWidth:function(e,t){var i=t.find("th"),s=e.find("th");i.each(function(e){s.eq(e).css("width",n(this).width())})},initFixedHeader:function(e){var t=e.find("#result_list thead");if(0!=t.length){var i=t.clone(),s=n("").addClass("helper").append(i);s.find(".action-checkbox-column").empty(),s.appendTo(document.body),n(window).on("scroll",n.proxy(this.updateFixedHeaderVisibility,this,s,t)),n(window).on("resize",n.proxy(this.updateFixedHeaderWidth,this,i,t)),this.updateFixedHeaderWidth(i,t)}},updateFixedFooter:function(e,t){if(n(window).scrollTop()+n(window).height()").attr("for",t).insertAfter(e)},addLabelToCheckboxes:function(){var e=this;n('input[type="checkbox"]').each(function(){var t=n(this);void 0!=t.attr("id")&&0!=n('label[for="'+t.attr("id")+'"]').length||e.addLabelToCheckbox(t)})},run:function(){try{this.addLabelToCheckboxes()}catch(e){console.error(e,e.stack)}}},n(document).ready(function(){(new s).run()})},{jquery:69}],5:[function(e,t,i){var n=e("jquery"),s=function(e){this.$inline=e,this.prefix=e.data("inline-prefix"),this.verboseName=e.data("inline-verbose-name"),this.deleteText=e.data("inline-delete-text")};s.prototype={updateLabels:function(e){var t=this,i=e.find(".inline-navigation-item");e.find(".inline-related").each(function(e){var s=n(this),o=s.find(".inline_label"),r=o.html().replace(/(#\d+)/g,"#"+(e+1)),a=i.eq(e),l=s.hasClass("has_original")?r:t.verboseName+" "+r;o.html(r),a.html(l)})},updateFormIndex:function(e,t){var i=new RegExp("("+this.prefix+"-(\\d+|__prefix__))"),s=this.prefix+"-"+t;e.find("*").each(function(){var e=n(this);n.each(["for","id","name"],function(){var t=this;e.attr(t)&&e.attr(t,e.attr(t).replace(i,s))})}),e.hasClass("empty-form")||e.attr("id",this.prefix+"-"+t)},updateFormsIndexes:function(e){var t=this,i=e.find(".inline-navigation-item");e.find(".inline-related").each(function(e){var s=n(this);t.updateFormIndex(s,e),i.eq(e).attr("data-inline-related-id",s.attr("id"))})},updateTotalForms:function(e){var t=e.find('[name="'+this.prefix+'-TOTAL_FORMS"]'),i=e.find('[name="'+this.prefix+'-MAX_NUM_FORMS"]'),n=parseInt(e.find(".inline-related").length),s=i.val()?parseInt(i.val()):1/0;t.val(n),e.find(".add-row").toggle(s>=n)},addNavigationItem:function(e,t){var i=e.find(".inline-navigation-item.empty");return i.clone().removeClass("empty").attr("data-inline-related-id",t.attr("id")).insertBefore(i)},openNavigationItem:function(e,t){e.find(".inline-related").removeClass("selected").filter("#"+t.attr("data-inline-related-id")).addClass("selected"),e.find(".inline-navigation-item").removeClass("selected"),t.addClass("selected")},removeItem:function(e,t){t.remove(),e.find('.inline-navigation-item[data-inline-related-id="'+t.attr("id")+'"]').remove()},openFirstNavigationItem:function(e){var t=e.find(".inline-navigation-item:not(.empty)").first();void 0!=t&&(this.openNavigationItem(e,t),this.scrollNavigationToTop(e))},addItemDeleteButton:function(e){e.children(":first").append(''+this.deleteText+"")},scrollNavigationToTop:function(e){var t=e.find(".inline-navigation-content");t.stop().animate({scrollTop:0})},scrollNavigationToBottom:function(e){var t=e.find(".inline-navigation-content");t.stop().animate({scrollTop:t.prop("scrollHeight")})},initAdding:function(e){var t=this;e.find(".add-row a").on("click",function(i){i.preventDefault();var n=e.find(".inline-related.empty-form"),s=parseInt(e.find(".inline-related").length)-1,o=n.clone(!0).removeClass("empty-form").insertBefore(n);t.updateTotalForms(e),t.updateFormIndex(o,s),t.updateFormIndex(n,s+1);var r=t.addNavigationItem(e,o);t.updateLabels(e),t.openNavigationItem(e,r),t.addItemDeleteButton(o),t.scrollNavigationToBottom(e)})},initDeletion:function(e){var t=this;e.on("click",".inline-deletelink",function(i){i.preventDefault();var s=n(this).closest(".inline-related");t.removeItem(e,s),t.updateFormsIndexes(e),t.updateLabels(e),t.updateTotalForms(e),t.openFirstNavigationItem(e)}),e.find(".inline-related").each(function(){var t=n(this);t.find(".delete input").on("change",function(){e.find('.inline-navigation-item[data-inline-related-id="'+t.attr("id")+'"]').toggleClass("delete",n(this).is(":checked"))})})},initNavigation:function(e){var t=this;e.on("click",".inline-navigation-item",function(i){i.preventDefault(),t.openNavigationItem(e,n(this))}),t.openFirstNavigationItem(e)},run:function(){var e=this.$inline;try{this.initAdding(e),this.initDeletion(e),this.initNavigation(e)}catch(t){console.error(t,t.stack)}}},t.exports=s},{jquery:69}],6:[function(e,t,i){e("./../utils/jquery-slidefade");var n=e("jquery"),s=e("../utils/translate");e("jquery-ui/ui/core"),e("jquery-ui/ui/widget"),e("jquery-ui/ui/mouse"),e("jquery-ui/ui/draggable"),e("jquery-ui/ui/droppable"),e("jquery-ui/ui/sortable"),e("jquery-ui/ui/resizable"),e("jquery-ui/ui/button"),e("jquery-ui/ui/dialog");var o=function(e){this.$dashboard=e};o.prototype={initTools:function(e){e.find(".dashboard-tools-toggle").on("click",function(t){t.preventDefault(),e.find(".dashboard-tools").toggleClass("visible")});var t=e.find("#add-dashboard-module-form");t.find(".add-dashboard-link").on("click",function(e){var i=t.find('[name="type"]'),s=t.find('[name="module"] option:selected').data("type");s&&(i.val(s),n.ajax({url:t.attr("action"),method:t.attr("method"),dataType:"json",data:t.serialize(),success:function(e){e.error||(document.location=e.success_url)}})),e.preventDefault()}),e.find(".reset-dashboard-link").on("click",function(t){var i={},o=function(){var t=e.find("#reset-dashboard-form");n.ajax({url:t.attr("action"),method:t.attr("method"),dataType:"json",data:t.serialize(),success:function(e){e.error||location.reload()}})};i[s("Yes")]=function(){o(),n(this).dialog("close")},i[s("Cancel")]=function(){n(this).dialog("close")},e.find("#reset-dashboard-dialog").dialog({resizable:!1,modal:!0,buttons:i}),t.preventDefault()})},updateDashboardModules:function(e){var t=e.find("#update-dashboard-modules-form"),i=[];e.find(".dashboard-column").each(function(){var e=n(this),t=e.closest(".dashboard-column-wrapper").index();e.find(".dashboard-item").each(function(){var e=n(this),s=e.index(),o=e.data("module-id");i.push({id:o,column:t,order:s})})}),t.find('[name="modules"]').val(JSON.stringify(i)),n.ajax({url:t.attr("action"),method:t.attr("method"),dataType:"json",data:t.serialize()})},initModulesDragAndDrop:function(e){var t=this;e.find(".dashboard-column").droppable({activeClass:"active",hoverClass:"hovered",tolerance:"pointer",accept:".dashboard-item"}).sortable({items:".dashboard-item.draggable",handle:".dashboard-item-header",tolerance:"pointer",connectWith:".dashboard-column",cursor:"move",placeholder:"dashboard-item placeholder",forcePlaceholderSize:!0,update:function(i,n){t.updateDashboardModules(e)}})},initCollapsibleModules:function(e){var t=e.find("#update-dashboard-module-collapse-form");e.find(".dashboard-item.collapsible").each(function(){var e=n(this),i=e.find(".dashboard-item-collapse"),s=e.find(".dashboard-item-content"),o=e.data("module-id");i.on("click",function(i){i.preventDefault(),s.slideFadeToggle(200,"swing",function(){var i=0==s.is(":visible");i?e.addClass("collapsed"):e.removeClass("collapsed"),t.find('[name="id"]').val(o),t.find('[name="collapsed"]').val(i?"true":"false"),n.ajax({url:t.attr("action"),method:t.attr("method"),dataType:"json",data:t.serialize()})})})})},initDeletableModules:function(e){var t=e.find("#remove-dashboard-module-form");e.find(".dashboard-item.deletable").each(function(){var i=n(this),o=i.find(".dashboard-item-remove"),r=i.data("module-id");o.on("click",function(o){o.preventDefault();var a={},l=function(){i.fadeOut(200,"swing",function(){t.find('[name="id"]').val(r),n.ajax({url:t.attr("action"),method:t.attr("method"),dataType:"json",data:t.serialize()})})};a[s("Delete")]=function(){l(),n(this).dialog("close")},a[s("Cancel")]=function(){n(this).dialog("close")},e.find("#module-remove-dialog").dialog({resizable:!1,modal:!0,buttons:a})})})},initAjaxModules:function(e){e.find(".dashboard-item.ajax").each(function(){var e=n(this),t=e.find(".dashboard-item-content"),i=e.data("ajax-url");n.ajax({url:i,dataType:"json",success:function(e){if(e.error)return void t.empty();var i=t.height();t.html(e.html);var n=t.height();t.height(i),t.animate({height:n},250,"swing",function(){t.height("auto")})},error:function(){t.empty()}})})},updateModuleChildrenFormsetLabels:function(e){e.find(".inline-related").each(function(e){n(this).find(".inline_label").text("#"+(e+1))})},updateModuleChildrenFormsetFormIndex:function(e,t){var i="children",s=new RegExp("("+i+"-(\\d+|__prefix__))"),o=i+"-"+t;e.find("fieldset.module *").each(function(){var e=n(this);n.each(["for","id","name"],function(){var t=this;e.attr(t)&&e.attr(t,e.attr(t).replace(s,o))})})},updateModuleChildrenFormsetFormsIndexes:function(e){var t=this,i=parseInt(e.find(".inline-related.has_original").length);e.find(".inline-related.last-related").each(function(e){t.updateModuleChildrenFormsetFormIndex(n(this),i+e)})},updateModuleChildrenFormsetTotalForms:function(e){var t=e.find('[name="children-TOTAL_FORMS"]'),i=parseInt(e.find(".inline-related").length);t.val(i)},initModuleChildrenFormsetUpdate:function(e){if(e.hasClass("change-form")){var t=this,i=e.find(".inline-group");i.find(".add-row a").on("click",function(e){e.preventDefault();var n=i.find(".inline-related.empty-form"),s=n.clone(!0).removeClass("empty-form").insertBefore(n);t.updateModuleChildrenFormsetLabels(i),t.updateModuleChildrenFormsetFormIndex(n,parseInt(i.find(".inline-related").length)-1),t.updateModuleChildrenFormsetFormIndex(s,parseInt(i.find(".inline-related").length)-2),t.updateModuleChildrenFormsetTotalForms(i)}),i.find(".inline-deletelink").on("click",function(e){e.preventDefault(),n(this).closest(".inline-related").remove(),t.updateModuleChildrenFormsetFormsIndexes(i),t.updateModuleChildrenFormsetLabels(i),t.updateModuleChildrenFormsetTotalForms(i)})}},run:function(){var e=this.$dashboard;try{this.initTools(e),this.initModulesDragAndDrop(e),this.initCollapsibleModules(e),this.initDeletableModules(e),this.initAjaxModules(e),this.initModuleChildrenFormsetUpdate(e)}catch(t){console.error(t,t.stack)}e.addClass("initialized")}},n(document).ready(function(){n(".dashboard.jet").each(function(){new o(n(this)).run()})})},{"../utils/translate":37,"./../utils/jquery-slidefade":36,jquery:69,"jquery-ui/ui/button":55,"jquery-ui/ui/core":56,"jquery-ui/ui/dialog":58,"jquery-ui/ui/draggable":59,"jquery-ui/ui/droppable":60,"jquery-ui/ui/mouse":61,"jquery-ui/ui/resizable":63,"jquery-ui/ui/sortable":64,"jquery-ui/ui/widget":66}],7:[function(e,t,i){var n=e("jquery");e("jquery-ui/ui/core"),e("jquery-ui/ui/datepicker"),e("timepicker");var s=function(){};s.prototype={removeInputTextNode:function(e){if(0!=e.length){var t=e.get(0).previousSibling;3==t.nodeType&&n(t).remove()}},updateDatetimeLayout:function(){var e=this;n(".form-row .datetime").each(function(){var t=n(this),i=t.find(".vDateField"),s=t.find(".vTimeField");e.removeInputTextNode(i),e.removeInputTextNode(s),i.nextAll("br").first().remove()}),n(".form-row .vDateField").each(function(){var e=n(this),t=n("").addClass("icon-calendar");n("").attr("href","#").addClass("vDateField-link").append(t).insertAfter(e)}),n(".form-row .vTimeField").each(function(){var e=n(this),t=n("").addClass("icon-clock");n("").attr("href","#").addClass("vTimeField-link").append(t).insertAfter(e)})},djangoDateTimeFormatToJs:function(e){return e.toLowerCase().replace(/%\w/g,function(e){return e=e.replace(/%/,""),e+e})},initDateWidgets:function(e){e=e||n(document);var t=this;e.find(".form-row .vDateField").each(function(){var e=n(this),i=e.next(".vDateField-link");e.datepicker({dateFormat:t.djangoDateTimeFormatToJs(DATE_FORMAT),showButtonPanel:!0,nextText:"",prevText:""}),i.on("click",function(t){e.datepicker("widget").is(":visible")?e.datepicker("hide"):e.datepicker("show"),t.preventDefault()})});var i=n.datepicker._gotoToday;n.datepicker._gotoToday=function(e){i.call(this,e),this._selectDate(e)}},initTimeWidgets:function(e){e=e||n(document),e.find(".form-row .vTimeField").each(function(){var e=n(this),t=e.next(".vTimeField-link");e.timepicker({showPeriodLabels:!1,showCloseButton:!0,showNowButton:!0}),t.on("click",function(t){e.datepicker("widget").is(":visible")?e.datepicker("hide"):e.timepicker("show"),t.preventDefault()})})},run:function(){try{this.updateDatetimeLayout(),this.initDateWidgets(),this.initTimeWidgets();var e=this;n(".inline-group").on("inline-group-row:added",function(t,i){i.find(".hasDatepicker").removeClass("hasDatepicker"),i.find(".hasTimepicker").removeClass("hasTimepicker"),e.initDateWidgets(i),e.initTimeWidgets(i)})}catch(t){console.error(t,t.stack)}}},n(document).ready(function(){(new s).run()})},{jquery:69,"jquery-ui/ui/core":56,"jquery-ui/ui/datepicker":57,timepicker:71}],8:[function(e,t,i){var n=e("jquery"),s=function(e){this.$toolbar=e};s.prototype={initFiltersInteraction:function(e){e.find(".changelist-filter-select").each(function(){var e=n(this),t=e.attr("multiple");t&&e.data("previous-options",e.find("option:selected")),e.on("change",function(){var e=n(this),i=e.find("option:selected");t&&(e.data("previous-options").lengthi.length&&(i=e.data("previous-options").filter(function(e,t){return 0==i.filter(function(e,i){return t==i}).length})),e.data("previous-options",e.find("option:selected")));var s=i.data("url"),o=e.data("queryset--lookup");s?document.location=i.data("url"):o&&(document.location="?"+o+"="+i.val())})})},run:function(){try{this.initFiltersInteraction(this.$toolbar)}catch(e){console.error(e,e.stack)}}},n(document).ready(function(){n("#toolbar").each(function(){new s(n(this)).run()})})},{jquery:69}],9:[function(e,t,i){var n=e("jquery"),s=e("./compact-inline"),o=function(e){this.$inline=e};o.prototype={initAddRow:function(e){e.on("click",".add-row a",function(){var t=e.find(".inline-related:not(.empty-form)").last();e.trigger("inline-group-row:added",[t])})},run:function(){var e=this.$inline;try{e.hasClass("compact")&&new s(e).run(),this.initAddRow(e)}catch(t){console.error(t,t.stack)}e.addClass("initialized")}},n(document).ready(function(){n(".inline-group").each(function(){new o(n(this)).run()})})},{"./compact-inline":5,jquery:69}],10:[function(e,t,i){var n=e("jquery"),s=e("../utils/window-storage"),o=function(){this.windowStorage=new s("relatedWindows")};o.prototype={updateLinks:function(e){e.find("~ .change-related, ~ .delete-related, ~ .add-another").each(function(){var t=n(this),i=t.data("href-template");if(void 0!=i){var s=e.val();s?t.attr("href",i.replace("__fk__",s)):t.removeAttr("href")}})},initLinksForRow:function(e){if(!e.data("related-popups-links-initialized")){var t=this;e.find("select").each(function(){var e=n(this);t.updateLinks(e),e.find("~ .add-related, ~ .change-related, ~ .delete-related, ~ .add-another").each(function(){var i=n(this);i.on("click",function(n){n.preventDefault();var s=i.attr("href");void 0!=s&&(s.indexOf("_popup")==-1&&(s+=s.indexOf("?")==-1?"?_popup=1":"&_popup=1"),t.showPopup(e,s))})})}).on("change",function(){t.updateLinks(n(this))}),e.find("input").each(function(){var e=n(this);e.find("~ .related-lookup").each(function(){var i=n(this);i.on("click",function(n){n.preventDefault();var s=i.attr("href");s+=s.indexOf("?")==-1?"?_popup=1":"&_popup=1",t.showPopup(e,s)})})}),e.data("related-popups-links-initialized",!0)}},initLinks:function(){var e=this;n(".form-row").each(function(){e.initLinksForRow(n(this))}),n(".inline-group").on("inline-group-row:added",function(t,i){i.find(".form-row").each(function(){e.initLinksForRow(n(this))})})},initPopupBackButton:function(){var e=this;n(".related-popup-back").on("click",function(t){t.preventDefault(),e.closePopup()})},showPopup:function(e,t){var i=n(window.top.document),s=i.find(".related-popup-container"),o=s.find(".loading-indicator"),r=i.find("body"),a=n("
").addClass("related-popup").data("input",e),l=n("