diff --git a/.travis.yml b/.travis.yml index c9118ddf68..ebe22c7cc1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -47,4 +47,4 @@ notifications: secure: TOveMBh9HePYKWuGTrWF+hTXzxGZvbVsa3KU0sB1yv6qkcixb5/ggvmkTeRddYEd/zyWyMenicFsrXVBgsP0SmbNgke6kq5+EN0U5oJWse998lvCVCpwmJQMdwDHvYsOtbFEOppQrbRK4vmH8qibx3x2YVg+u+61ePHvWYF9z6U= after_success: - bash post_travis.sh -- python -m coverage combine; python -m coverage report --omit **/migrations/**; coveralls +- python -m coverage combine; python -m coverage report --omit "**/migrations/**"; coveralls diff --git a/bluebottle/activities/admin.py b/bluebottle/activities/admin.py index 6272e8648d..d3b5b92a5f 100644 --- a/bluebottle/activities/admin.py +++ b/bluebottle/activities/admin.py @@ -1,3 +1,5 @@ +from pytz import timezone + from django import forms from django.conf.urls import url from django.contrib import admin @@ -230,10 +232,11 @@ def __init__(self, *args, **kwargs): class TeamInline(admin.TabularInline): model = Team raw_id_fields = ('owner',) - readonly_fields = ('team_link', 'created', 'status') + readonly_fields = ('team_link', 'slot_link', 'created', 'status') fields = readonly_fields + ('owner',) extra = 0 + ordering = ['slot__start'] def team_link(self, obj): return format_html( @@ -241,9 +244,29 @@ def team_link(self, obj): reverse('admin:activities_team_change', args=(obj.id,)), obj ) - team_link.short_description = _('Edit') + def slot_link(self, obj): + if getattr(obj, 'slot', None): + if obj.slot.location: + return format_html( + '{}', + reverse('admin:activities_team_change', args=(obj.id,)), + obj.slot.start.astimezone(timezone(obj.slot.location.timezone)).strftime('%c') + ) + else: + return format_html( + '{}', + reverse('admin:activities_team_change', args=(obj.id,)), + obj.slot.start.strftime('%c') + ) + return format_html( + '{}', + reverse('admin:activities_team_change', args=(obj.id,)), + _('Add time slot') + ) + slot_link.short_description = _('Time slot') + class ActivityChildAdmin(PolymorphicChildModelAdmin, StateMachineAdmin): base_model = Activity @@ -589,6 +612,8 @@ def link(self, obj): ordering = ('-created',) def type(self, obj): + if obj.team_activity == 'teams': + return _('Team activity') return obj.get_real_instance_class()._meta.verbose_name @@ -675,16 +700,17 @@ class PaginationFormSet(PaginationFormSetBase, formset_class): class TeamAdmin(StateMachineAdmin): raw_id_fields = ['owner', 'activity'] readonly_fields = ['created', 'activity_link', 'invite_link'] - fields = ['activity', 'invite_link', 'created', 'owner', 'states'] + fields = ['activity', 'invite_link', 'created', 'owner', 'status', 'states'] superadmin_fields = ['force_status'] list_display = ['__str__', 'activity_link', 'status'] def get_inline_instances(self, request, obj=None): self.inlines = [] if isinstance(obj.activity, PeriodActivity): - from bluebottle.time_based.admin import PeriodParticipantAdminInline + from bluebottle.time_based.admin import PeriodParticipantAdminInline, TeamSlotInline self.inlines = [ - PeriodParticipantAdminInline + PeriodParticipantAdminInline, + TeamSlotInline ] return super(TeamAdmin, self).get_inline_instances(request, obj) diff --git a/bluebottle/activities/effects.py b/bluebottle/activities/effects.py index 19e3057281..5bcc6af512 100644 --- a/bluebottle/activities/effects.py +++ b/bluebottle/activities/effects.py @@ -82,7 +82,7 @@ def post_save(self, **kwargs): if not self.instance.team: self.instance.team = Team.objects.create( owner=self.instance.user, - activity=self.instance.activity + activity=self.instance.activity, ) self.instance.save() @@ -128,19 +128,19 @@ def is_valid(self): ) def pre_save(self, effects): - self.transitioned_conributions = [] + self.transitioned_contributions = [] for contribution in self.contributions: effect = TransitionEffect(self.transition)(contribution) if effect.is_valid: - self.transitioned_conributions.append(contribution) + self.transitioned_contributions.append(contribution) effect.pre_save(effects=effects) effects.append(effect) contribution.execute_triggers(effects=effects) def post_save(self): - for contribution in self.transitioned_conributions: + for contribution in self.transitioned_contributions: try: contribution.contributor.refresh_from_db() contribution.save() diff --git a/bluebottle/activities/messages.py b/bluebottle/activities/messages.py index e44f655426..27d50c0449 100644 --- a/bluebottle/activities/messages.py +++ b/bluebottle/activities/messages.py @@ -276,7 +276,8 @@ class TeamNotification(ActivityNotification): context = { 'title': 'activity.title', 'team_captain_email': 'owner.email', - 'team_name': 'name' + 'team_name': 'name', + 'team_captain_name': 'owner.full_name' } @property @@ -286,7 +287,7 @@ def action_link(self): action_title = pgettext('email', 'View activity') def get_recipients(self): - """acitvity mananager""" + """activity manager""" return [self.obj.activity.owner] @@ -300,13 +301,19 @@ class TeamAppliedMessage(TeamNotification): template = 'messages/team_applied' -class TeamAcceptedMessage(TeamNotification): +class TeamCaptainAcceptedMessage(TeamNotification): subject = pgettext('email', 'Your team has been accepted for "{title}"') template = 'messages/team_accepted' + context = { + 'title': 'activity.title', + 'team_captain_email': 'team.owner.email', + 'team_name': 'team.name' + } + def get_recipients(self): """team captain""" - return [self.obj.owner] + return [self.obj.user] class TeamCancelledMessage(TeamNotification): @@ -321,12 +328,18 @@ def get_recipients(self): class TeamCancelledTeamCaptainMessage(TeamNotification): - subject = pgettext('email', "Your team has been rejected for '{title}'") + subject = pgettext('email', 'Your team has been rejected for "{title}"') template = 'messages/team_cancelled_team_captain' + context = { + 'title': 'activity.title', + 'team_captain_email': 'team.owner.email', + 'team_name': 'team.name' + } + def get_recipients(self): """team captain""" - return [self.obj.owner] + return [self.obj.user] class TeamWithdrawnMessage(TeamNotification): @@ -391,7 +404,7 @@ def get_recipients(self): class TeamMemberWithdrewMessage(ActivityNotification): - subject = pgettext('email', "Withdrawal for '{title}'") + subject = pgettext('email', 'A participant has withdrawn from your team for "{title}"') template = 'messages/team_member_withdrew' context = { diff --git a/bluebottle/activities/migrations/0058_auto_20220622_1050.py b/bluebottle/activities/migrations/0058_auto_20220622_1050.py new file mode 100644 index 0000000000..574db89232 --- /dev/null +++ b/bluebottle/activities/migrations/0058_auto_20220622_1050.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.24 on 2022-06-22 08:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activities', '0057_auto_20220608_1513'), + ] + + operations = [ + migrations.AlterField( + model_name='activity', + name='team_activity', + field=models.CharField(blank=True, choices=[('teams', 'Teams'), ('individuals', 'Individuals')], default='individuals', help_text='Is this activity open for individuals or can only teams sign up?', max_length=100, verbose_name='participation'), + ), + ] diff --git a/bluebottle/activities/migrations/0059_auto_20220804_1214.py b/bluebottle/activities/migrations/0059_auto_20220804_1214.py new file mode 100644 index 0000000000..07a346a629 --- /dev/null +++ b/bluebottle/activities/migrations/0059_auto_20220804_1214.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.24 on 2022-08-04 10:14 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('activities', '0058_auto_20220622_1050'), + ] + + operations = [ + migrations.AlterField( + model_name='contributor', + name='team', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='members', to='activities.Team', verbose_name='team'), + ), + ] diff --git a/bluebottle/activities/models.py b/bluebottle/activities/models.py index 9d5b017ed6..cfb8aca2dc 100644 --- a/bluebottle/activities/models.py +++ b/bluebottle/activities/models.py @@ -183,7 +183,7 @@ class Contributor(TriggerMixin, AnonymizationMixin, PolymorphicModel): team = models.ForeignKey( 'activities.Team', verbose_name=_('team'), - null=True, blank=True, related_name='members', on_delete=models.SET_NULL + null=True, blank=True, related_name='members', on_delete=models.CASCADE ) user = models.ForeignKey( 'members.Member', verbose_name=_('user'), @@ -290,6 +290,14 @@ class Team(TriggerMixin, models.Model): 'members.Member', related_name='teams', null=True, on_delete=models.SET_NULL ) + @property + def accepted_participants(self): + return self.members.filter(status='accepted') + + @property + def accepted_participants_count(self): + return len(self.accepted_participants) + class Meta(object): ordering = ('-created',) verbose_name = _("Team") diff --git a/bluebottle/activities/serializers.py b/bluebottle/activities/serializers.py index 2e69f6ea73..aacb7f27d9 100644 --- a/bluebottle/activities/serializers.py +++ b/bluebottle/activities/serializers.py @@ -70,7 +70,7 @@ class Meta(object): 'matching_properties', ) - class JSONAPIMeta(object): + class JSONAPIMeta: included_resources = [ 'owner', 'initiative', diff --git a/bluebottle/activities/states.py b/bluebottle/activities/states.py index 94677b6f28..765611436a 100644 --- a/bluebottle/activities/states.py +++ b/bluebottle/activities/states.py @@ -373,6 +373,18 @@ class TeamStateMachine(ModelStateMachine): _('The team is cancelled. Contributors can no longer register') ) + running = State( + _('running'), + 'running', + _('The team is currently running the activity.') + ) + + finished = State( + _('finished'), + 'finished', + _('The team has completed the activity.') + ) + def is_team_captain(self, user): return user == self.instance.owner @@ -428,7 +440,10 @@ def is_activity_owner(self, user): ) cancel = Transition( - open, + [ + open, + new + ], cancelled, automatic=False, permission=is_activity_owner, @@ -437,10 +452,28 @@ def is_activity_owner(self, user): ) reopen = Transition( - cancelled, + [cancelled, running, finished], open, automatic=False, permission=is_activity_owner, name=_('accept'), description=_('The team is reopened. Contributors can apply again') ) + + start = Transition( + [open, finished], + running, + name=_("Start"), + description=_( + "The slot is currently taking place." + ) + ) + finish = Transition( + [open, running], + finished, + name=_("Finish"), + description=_( + "The slot has ended. " + "Triggered when slot has ended." + ) + ) diff --git a/bluebottle/activities/templates/mails/messages/team_accepted.html b/bluebottle/activities/templates/mails/messages/team_accepted.html index 88a9952d29..816a9aadb1 100644 --- a/bluebottle/activities/templates/mails/messages/team_accepted.html +++ b/bluebottle/activities/templates/mails/messages/team_accepted.html @@ -1,7 +1,7 @@ {% extends "mails/messages/activity_base.html" %} {% load i18n %} -{% block message%} +{% block message %}

{% if custom_message %} {{custom_message|linebreaks}} diff --git a/bluebottle/activities/templates/mails/messages/team_added.html b/bluebottle/activities/templates/mails/messages/team_added.html index 5c351f48fe..c7c2b1a624 100644 --- a/bluebottle/activities/templates/mails/messages/team_added.html +++ b/bluebottle/activities/templates/mails/messages/team_added.html @@ -6,6 +6,12 @@ {% blocktrans context 'email' %}{{team_name}} has joined your activity "{{title}}".{% endblocktrans %}

- {% blocktrans context 'email' %}Please contact them to sort out any details via {{team_captain_email}}.{% endblocktrans %} + {% blocktrans context 'email' %}Next steps{% endblocktrans %}

+
    +
  1. {% blocktrans context 'email' %}Contact team captain {{team_captain_name}} to settle on a date, time and location for when they will participate in this activity.{% endblocktrans %}
  2. +
  3. {% blocktrans context 'email' %}Add this information to the team via the unscheduled team list on the activity page so it is visible for the team members.{% endblocktrans %}
  4. +
+

{% blocktrans context 'email' %}Team captain contact details:{% endblocktrans %}

+

{{team_captain_email}}

{% endblock %} diff --git a/bluebottle/activities/templates/mails/messages/team_applied.html b/bluebottle/activities/templates/mails/messages/team_applied.html index e9f0a6b812..71d4fe13e5 100644 --- a/bluebottle/activities/templates/mails/messages/team_applied.html +++ b/bluebottle/activities/templates/mails/messages/team_applied.html @@ -6,9 +6,13 @@ {% blocktrans context 'email' %}{{team_name}} has applied to your activity "{{title}}".{% endblocktrans %}

- {% blocktrans context 'email' %}Please contact them to sort out any details via {{team_captain_email}}.{% endblocktrans %} -

-

- {% blocktrans context 'email' %}You can accept or reject the team on the activity page.{% endblocktrans %} + {% blocktrans context 'email' %}Next steps{% endblocktrans %}

+
    +
  1. {% blocktrans context 'email' %}Contact team captain {{team_captain_name}} to settle on a date, time and location for when they will participate in this activity.{% endblocktrans %}
  2. +
  3. {% blocktrans context 'email' %}Add this information to the team via the unscheduled team list on the activity page so it is visible for the team members.{% endblocktrans %}
  4. +
  5. {% blocktrans context 'email' %}Accept the team after you have added the details so when the team members are invited to join the activity it includes the time, date and location.{% endblocktrans %}
  6. +
+

{% blocktrans context 'email' %}Team captain contact details:{% endblocktrans %}

+

{{team_captain_email}}

{% endblock %} diff --git a/bluebottle/activities/templates/mails/messages/team_cancelled_team_captain.html b/bluebottle/activities/templates/mails/messages/team_cancelled_team_captain.html index 24ddad4002..16be1d0c04 100644 --- a/bluebottle/activities/templates/mails/messages/team_cancelled_team_captain.html +++ b/bluebottle/activities/templates/mails/messages/team_cancelled_team_captain.html @@ -3,6 +3,10 @@ {% block message%}

- {% blocktrans context 'email' %}Unfortunately, your team has been rejected for the activity '{{title}}'.{% endblocktrans %} + {% if custom_message %} + {{custom_message|linebreaks}} + {% else %} + {% blocktrans context 'email' %}Unfortunately, your team has been rejected for the activity '{{title}}'.{% endblocktrans %} + {% endif %}

{% endblock %} diff --git a/bluebottle/activities/tests/test_api.py b/bluebottle/activities/tests/test_api.py index 7571975167..24cb9e0885 100644 --- a/bluebottle/activities/tests/test_api.py +++ b/bluebottle/activities/tests/test_api.py @@ -26,7 +26,7 @@ from bluebottle.time_based.serializers import PeriodParticipantSerializer from bluebottle.time_based.tests.factories import ( DateActivityFactory, PeriodActivityFactory, DateParticipantFactory, PeriodParticipantFactory, - DateActivitySlotFactory, SkillFactory + DateActivitySlotFactory, SkillFactory, TeamSlotFactory ) from bluebottle.initiatives.tests.factories import InitiativeFactory from bluebottle.initiatives.models import InitiativePlatformSettings @@ -1726,7 +1726,7 @@ def test_participants_over_max_age(self): ) -class RelatedTeamListViewAPITestCase(APITestCase): +class TeamListViewAPITestCase(APITestCase): serializer = TeamSerializer def setUp(self): @@ -1737,6 +1737,14 @@ def setUp(self): self.approved_teams = TeamFactory.create_batch(5, activity=self.activity) for team in self.approved_teams: PeriodParticipantFactory.create(activity=self.activity, team=team, user=team.owner) + + for team in self.approved_teams[:2]: + TeamSlotFactory.create(activity=self.activity, team=team, start=now() + timedelta(days=5)) + + TeamSlotFactory.create( + activity=self.activity, team=self.approved_teams[2], start=now() - timedelta(days=5) + ) + self.cancelled_teams = TeamFactory.create_batch( 5, activity=self.activity, status='cancelled' ) @@ -1760,7 +1768,6 @@ def test_get_activity_owner(self): self.assertStatus(status.HTTP_200_OK) self.assertTotal(len(self.approved_teams) + len(self.cancelled_teams)) self.assertObjectList(self.approved_teams) - self.assertRelationship('activity', [self.activity]) self.assertRelationship('owner') self.assertMeta('status') @@ -1774,6 +1781,41 @@ def test_get_activity_owner(self): 'We should have a unique list of team ids' ) + def test_get_filtered_status(self): + new_teams = TeamFactory.create_batch(2, activity=self.activity, status='new') + self.perform_get(user=self.activity.owner, query={'filter[status]': 'new'}) + + self.assertStatus(status.HTTP_200_OK) + for resource in self.response.json()['data']: + self.assertTrue( + resource['id'] in [str(team.pk) for team in new_teams] + ) + + def test_get_filtered_has_no_slot(self): + self.perform_get(user=self.activity.owner, query={'filter[has_slot]': 'false'}) + + self.assertStatus(status.HTTP_200_OK) + for resource in self.response.json()['data']: + self.assertIsNone(resource['relationships']['slot']['data']) + + def test_get_filtered_future(self): + self.perform_get(user=self.activity.owner, query={'filter[start]': 'future'}) + + self.assertStatus(status.HTTP_200_OK) + for resource in self.response.json()['data']: + self.assertTrue( + resource['id'] in [str(team.pk) for team in self.approved_teams[:2]] + ) + + def test_get_filtered_passed(self): + self.perform_get(user=self.activity.owner, query={'filter[start]': 'passed'}) + + self.assertStatus(status.HTTP_200_OK) + for resource in self.response.json()['data']: + self.assertEqual( + resource['id'], str(self.approved_teams[2].pk) + ) + def test_get_cancelled_team_captain(self): team = self.cancelled_teams[0] self.perform_get(user=team.owner) @@ -1782,7 +1824,6 @@ def test_get_cancelled_team_captain(self): self.assertTotal(len(self.approved_teams) + 1) self.assertObjectList(self.approved_teams + [team]) - self.assertRelationship('activity', [self.activity]) self.assertRelationship('owner') self.assertEqual( @@ -1797,7 +1838,6 @@ def test_get_team_captain(self): self.assertStatus(status.HTTP_200_OK) self.assertTotal(len(self.approved_teams)) self.assertObjectList(self.approved_teams) - self.assertRelationship('activity', [self.activity]) self.assertRelationship('owner') self.assertEqual( @@ -1817,7 +1857,6 @@ def test_get_anonymous(self): self.assertStatus(status.HTTP_200_OK) self.assertTotal(len(self.approved_teams)) self.assertObjectList(self.approved_teams) - self.assertRelationship('activity', [self.activity]) self.assertRelationship('owner') for resource in self.response.json()['data']: self.assertTrue(resource['meta']['participants-export-url'] is None) @@ -1838,7 +1877,6 @@ def test_other_user_anonymous(self): self.assertStatus(status.HTTP_200_OK) self.assertTotal(len(self.approved_teams)) self.assertObjectList(self.approved_teams) - self.assertRelationship('activity', [self.activity]) self.assertRelationship('owner') def test_get_anonymous_closed_site(self): diff --git a/bluebottle/activities/tests/test_notifications.py b/bluebottle/activities/tests/test_notifications.py index cfb13b88ff..f1c6421343 100644 --- a/bluebottle/activities/tests/test_notifications.py +++ b/bluebottle/activities/tests/test_notifications.py @@ -2,10 +2,10 @@ ActivityRejectedNotification, ActivityCancelledNotification, ActivitySucceededNotification, ActivityRestoredNotification, ActivityExpiredNotification, TeamAddedMessage, - TeamAppliedMessage, TeamAcceptedMessage, TeamCancelledMessage, + TeamAppliedMessage, TeamCancelledMessage, TeamCancelledTeamCaptainMessage, TeamWithdrawnActivityOwnerMessage, TeamWithdrawnMessage, TeamMemberAddedMessage, TeamMemberWithdrewMessage, - TeamMemberRemovedMessage, TeamReappliedMessage + TeamMemberRemovedMessage, TeamReappliedMessage, TeamCaptainAcceptedMessage ) from bluebottle.activities.tests.factories import TeamFactory from bluebottle.test.factory_models.accounts import BlueBottleUserFactory @@ -97,7 +97,10 @@ def test_team_added_notification(self): self.assertRecipients([self.activity.owner]) self.assertSubject("A new team has joined \"Save the world!\"") self.assertTextBodyContains("Team William Shatner has joined your activity \"Save the world!\".") - self.assertBodyContains('Please contact them to sort out any details via kirk@enterprise.com.') + self.assertBodyContains( + 'Add this information to the team via the unscheduled team list on the activity ' + 'page so it is visible for the team members.' + ) self.assertActionLink(self.obj.activity.get_absolute_url()) self.assertActionTitle('View activity') @@ -109,18 +112,25 @@ def test_team_applied_notification(self): self.assertRecipients([self.activity.owner]) self.assertSubject("A new team has applied to \"Save the world!\"") self.assertTextBodyContains("Team William Shatner has applied to your activity \"Save the world!\".") - self.assertBodyContains('Please contact them to sort out any details via kirk@enterprise.com.') - self.assertBodyContains('You can accept or reject the team on the activity page.') + self.assertBodyContains( + 'Add this information to the team via the unscheduled team list on the activity ' + 'page so it is visible for the team members.' + ) self.assertActionLink(self.obj.activity.get_absolute_url()) self.assertActionTitle('View activity') def test_team_accepted_notification(self): + self.obj = PeriodParticipantFactory.create( + user=self.captain, + activity=self.activity, + team=self.obj + ) self.activity.review = True self.activity.save() - self.message_class = TeamAcceptedMessage + self.message_class = TeamCaptainAcceptedMessage self.create() - self.assertRecipients([self.obj.owner]) + self.assertRecipients([self.obj.user]) self.assertSubject("Your team has been accepted for \"Save the world!\"") self.assertBodyContains('On the activity page you will find the link to invite your team members.') self.assertBodyContains(f"Your team has been accepted for the activity '{self.activity.title}'.") @@ -140,10 +150,15 @@ def test_team_cancelled_notification(self): self.assertActionTitle('View activity') def test_team_cancelled_team_captain_notification(self): + self.obj = PeriodParticipantFactory.create( + user=self.captain, + activity=self.activity, + team=self.obj + ) self.message_class = TeamCancelledTeamCaptainMessage self.create() - self.assertRecipients([self.obj.owner]) - self.assertSubject("Your team has been rejected for 'Save the world!'") + self.assertRecipients([self.obj.user]) + self.assertSubject('Your team has been rejected for "Save the world!"') self.assertHtmlBodyContains( "Unfortunately, your team has been rejected for the activity 'Save the world!'." ) @@ -220,7 +235,7 @@ def test_team_member_withdrew_notification(self): self.message_class = TeamMemberWithdrewMessage self.create() self.assertRecipients([self.captain]) - self.assertSubject("Withdrawal for 'Save the world!'") + self.assertSubject('A participant has withdrawn from your team for "Save the world!"') self.assertHtmlBodyContains( f"{self.obj.user.full_name} has withdrawn from your team for the activity ‘Save the world!’." ) diff --git a/bluebottle/activities/tests/test_triggers.py b/bluebottle/activities/tests/test_triggers.py index 34c69c3a21..1dca2ff7a8 100644 --- a/bluebottle/activities/tests/test_triggers.py +++ b/bluebottle/activities/tests/test_triggers.py @@ -1,19 +1,15 @@ -from django.core import mail - -from bluebottle.test.utils import TriggerTestCase -from bluebottle.test.factory_models.accounts import BlueBottleUserFactory - +from bluebottle.activities.effects import TeamContributionTransitionEffect, ResetTeamParticipantsEffect from bluebottle.activities.messages import ( TeamAddedMessage, TeamCancelledMessage, TeamReopenedMessage, - TeamAppliedMessage, TeamAcceptedMessage, TeamCancelledTeamCaptainMessage, TeamWithdrawnMessage, + TeamAppliedMessage, TeamCaptainAcceptedMessage, TeamCancelledTeamCaptainMessage, TeamWithdrawnMessage, TeamWithdrawnActivityOwnerMessage, TeamReappliedMessage ) -from bluebottle.activities.effects import TeamContributionTransitionEffect, ResetTeamParticipantsEffect -from bluebottle.time_based.models import PeriodParticipant from bluebottle.activities.tests.factories import TeamFactory - -from bluebottle.time_based.tests.factories import PeriodActivityFactory, PeriodParticipantFactory +from bluebottle.test.factory_models.accounts import BlueBottleUserFactory +from bluebottle.test.utils import TriggerTestCase +from bluebottle.time_based.models import PeriodParticipant from bluebottle.time_based.states import TimeContributionStateMachine +from bluebottle.time_based.tests.factories import PeriodActivityFactory, PeriodParticipantFactory class TeamTriggersTestCase(TriggerTestCase): @@ -71,13 +67,34 @@ def test_accept(self): with self.execute(message=message): self.assertEqual(self.model.status, 'open') - self.assertNotificationEffect(TeamAcceptedMessage) + self.model.save() + + def test_accept_team_captain(self): + self.activity.review = True + self.activity.save() + captain = BlueBottleUserFactory.create() + self.model = PeriodParticipantFactory.create( + activity=self.activity, + team=TeamFactory.create( + activity=self.activity, + owner=captain + ), + user=captain, + as_relation='user' + ) + self.model.states.accept() + + message = 'You were accepted, because you were great' + + with self.execute(message=message): + self.assertEqual(self.model.status, 'accepted') + self.assertEqual(self.model.team.status, 'open') + self.assertNotificationEffect(TeamCaptainAcceptedMessage) self.assertEqual( self.effects[0].options['message'], message ) self.model.save() - self.assertTrue(message in mail.outbox[-1].body) def test_cancel(self): self.create() @@ -93,9 +110,6 @@ def test_cancel(self): self.assertNotificationEffect( TeamCancelledMessage, [other_participant.user] ) - self.assertNotificationEffect( - TeamCancelledTeamCaptainMessage, [self.model.owner] - ) self.model.save() self.participant.refresh_from_db() @@ -103,6 +117,28 @@ def test_cancel(self): for contribution in self.participant.contributions.all(): self.assertEqual(contribution.status, TimeContributionStateMachine.failed.value) + def test_cancel_team_captain(self): + self.activity.review = True + self.activity.save() + + captain = BlueBottleUserFactory.create() + self.model = PeriodParticipantFactory.create( + activity=self.activity, + team=TeamFactory.create( + activity=self.activity, + owner=captain + ), + user=captain, + as_relation='user', + status='new' + ) + self.model.states.reject() + + with self.execute(): + self.assertNotificationEffect( + TeamCancelledTeamCaptainMessage, [self.model.owner] + ) + def test_withdrawn(self): self.create() @@ -123,6 +159,62 @@ def test_withdrawn(self): for contribution in self.participant.contributions.all(): self.assertEqual(contribution.status, TimeContributionStateMachine.failed.value) + def test_fill_team_activity(self): + self.activity.capacity = 2 + self.activity.team_activity = 'teams' + self.activity.save() + + captain = PeriodParticipantFactory.create( + activity=self.activity, + user=BlueBottleUserFactory.create(), + as_relation='user', + ) + participant = PeriodParticipantFactory.create( + activity=self.activity, + user=BlueBottleUserFactory.create(), + as_relation='user', + team=captain.team + ) + self.activity.refresh_from_db() + self.assertEqual( + self.activity.status, + 'open' + ) + PeriodParticipantFactory.create( + activity=self.activity, + user=BlueBottleUserFactory.create(), + as_relation='user', + ) + self.assertEqual( + self.activity.status, + 'full' + ) + + captain.states.withdraw(save=True) + self.assertEqual( + captain.team.status, + 'open', + ) + self.assertEqual( + self.activity.status, + 'full', + ) + self.assertEqual( + participant.status, + 'accepted', + ) + self.model = captain.team + self.model.states.withdraw(save=True) + + self.assertStatus( + captain.team, + 'withdrawn', + ) + self.assertStatus( + self.activity, + 'open', + ) + def test_reapply(self): self.create() diff --git a/bluebottle/activities/triggers.py b/bluebottle/activities/triggers.py index 93c5b020ca..b4ca2ed50e 100644 --- a/bluebottle/activities/triggers.py +++ b/bluebottle/activities/triggers.py @@ -1,27 +1,23 @@ -from bluebottle.activities.models import Organizer, EffortContribution, Team -from bluebottle.fsm.triggers import ( - TriggerManager, TransitionTrigger, ModelDeletedTrigger, register -) -from bluebottle.fsm.effects import TransitionEffect, RelatedTransitionEffect -from bluebottle.notifications.effects import NotificationEffect - -from bluebottle.activities.states import ( - ActivityStateMachine, OrganizerStateMachine, ContributionStateMachine, - EffortContributionStateMachine, TeamStateMachine -) from bluebottle.activities.effects import ( CreateOrganizer, CreateOrganizerContribution, SetContributionDateEffect, TeamContributionTransitionEffect, ResetTeamParticipantsEffect ) - from bluebottle.activities.messages import ( - TeamAddedMessage, TeamCancelledMessage, TeamReopenedMessage, TeamAcceptedMessage, TeamAppliedMessage, - TeamWithdrawnMessage, TeamWithdrawnActivityOwnerMessage, TeamCancelledTeamCaptainMessage, - TeamReappliedMessage + TeamAddedMessage, TeamReopenedMessage, TeamAppliedMessage, + TeamWithdrawnMessage, TeamWithdrawnActivityOwnerMessage, TeamReappliedMessage, TeamCancelledMessage +) +from bluebottle.activities.models import Organizer, EffortContribution, Team +from bluebottle.activities.states import ( + ActivityStateMachine, OrganizerStateMachine, ContributionStateMachine, + EffortContributionStateMachine, TeamStateMachine +) +from bluebottle.fsm.effects import TransitionEffect, RelatedTransitionEffect +from bluebottle.fsm.triggers import ( + TriggerManager, TransitionTrigger, ModelDeletedTrigger, register ) - -from bluebottle.time_based.states import ParticipantStateMachine from bluebottle.impact.effects import UpdateImpactGoalEffect +from bluebottle.notifications.effects import NotificationEffect +from bluebottle.time_based.states import ParticipantStateMachine, TimeBasedStateMachine def initiative_is_approved(effect): @@ -213,9 +209,16 @@ def contributor_is_active(contribution): def automatically_accept(effect): """ - automatically accept participants + automatically accept team """ - return not hasattr(effect.instance.activity, 'review') or not effect.instance.activity.review + captain = effect.instance.activity\ + .contributors.not_instance_of(Organizer)\ + .filter(user=effect.instance.owner).first() + return ( + not hasattr(effect.instance.activity, 'review') or + not effect.instance.activity.review or + (captain and captain.status == 'accepted') + ) def needs_review(effect): @@ -225,6 +228,31 @@ def needs_review(effect): return hasattr(effect.instance.activity, 'review') and effect.instance.activity.review +def team_activity_will_be_full(effect): + """ + the activity is full + """ + activity = effect.instance.activity + accepted_teams = activity.teams.filter(status__in=['open', 'running', 'finished']).count() + 1 + return not hasattr(activity, 'capacity') or ( + activity.capacity and + activity.capacity <= accepted_teams + ) + + +def team_activity_will_not_be_full(effect): + """ + the activity is full + """ + activity = effect.instance.activity + accepted_teams = activity.teams.filter(status__in=['open', 'running', 'finished']).count() - 1 + + return ( + not getattr(activity, 'capacity', False) or + activity.capacity > accepted_teams + ) + + @register(Team) class TeamTriggers(TriggerManager): triggers = [ @@ -241,7 +269,9 @@ class TeamTriggers(TriggerManager): ), TransitionEffect( TeamStateMachine.accept, - conditions=[automatically_accept] + conditions=[ + automatically_accept + ] ) ] ), @@ -249,30 +279,40 @@ class TeamTriggers(TriggerManager): TransitionTrigger( TeamStateMachine.accept, effects=[ - NotificationEffect( - TeamAcceptedMessage, - conditions=[needs_review] - ), RelatedTransitionEffect( 'members', ParticipantStateMachine.accept, conditions=[needs_review] - ) + ), + RelatedTransitionEffect( + 'activity', + TimeBasedStateMachine.lock, + conditions=[team_activity_will_be_full] + ), ] ), TransitionTrigger( TeamStateMachine.cancel, effects=[ + RelatedTransitionEffect( + 'activity', + TimeBasedStateMachine.reopen, + conditions=[team_activity_will_not_be_full] + ), TeamContributionTransitionEffect(ContributionStateMachine.fail), NotificationEffect(TeamCancelledMessage), - NotificationEffect(TeamCancelledTeamCaptainMessage) ] ), TransitionTrigger( TeamStateMachine.withdraw, effects=[ + RelatedTransitionEffect( + 'activity', + TimeBasedStateMachine.reopen, + conditions=[team_activity_will_not_be_full] + ), TeamContributionTransitionEffect(ContributionStateMachine.fail), NotificationEffect(TeamWithdrawnMessage), NotificationEffect(TeamWithdrawnActivityOwnerMessage) @@ -285,7 +325,10 @@ class TeamTriggers(TriggerManager): NotificationEffect(TeamReopenedMessage), TeamContributionTransitionEffect( ContributionStateMachine.reset, - contribution_conditions=[activity_is_active, contributor_is_active] + contribution_conditions=[ + activity_is_active, + contributor_is_active + ] ), ] @@ -296,7 +339,10 @@ class TeamTriggers(TriggerManager): effects=[ TeamContributionTransitionEffect( ContributionStateMachine.reset, - contribution_conditions=[activity_is_active, contributor_is_active] + contribution_conditions=[ + activity_is_active, + contributor_is_active + ] ), NotificationEffect(TeamReappliedMessage), NotificationEffect(TeamAddedMessage) diff --git a/bluebottle/activities/utils.py b/bluebottle/activities/utils.py index f13fdca6d3..2ae5924ac9 100644 --- a/bluebottle/activities/utils.py +++ b/bluebottle/activities/utils.py @@ -9,7 +9,8 @@ from rest_framework import serializers from rest_framework.fields import SerializerMethodField from rest_framework_json_api.relations import ( - ResourceRelatedField, SerializerMethodHyperlinkedRelatedField, SerializerMethodResourceRelatedField + ResourceRelatedField, SerializerMethodResourceRelatedField, + HyperlinkedRelatedField ) from rest_framework_json_api.serializers import ModelSerializer @@ -17,6 +18,7 @@ Activity, Contributor, Contribution, Organizer, EffortContribution, Team, Invite ) from bluebottle.activities.permissions import CanExportTeamParticipantsPermission +from bluebottle.bluebottle_drf2.serializers import PrivateFileSerializer from bluebottle.clients import properties from bluebottle.collect.models import CollectContribution from bluebottle.fsm.serializers import AvailableTransitionsField @@ -25,20 +27,18 @@ from bluebottle.initiatives.models import InitiativePlatformSettings from bluebottle.members.models import Member from bluebottle.segments.models import Segment -from bluebottle.time_based.models import TimeContribution, PeriodParticipant -from bluebottle.time_based.states import ParticipantStateMachine +from bluebottle.time_based.models import TimeContribution, TeamSlot from bluebottle.utils.exchange_rates import convert from bluebottle.utils.fields import FSMField, ValidationErrorsField, RequiredErrorsField from bluebottle.utils.serializers import ResourcePermissionField, AnonymizedResourceRelatedField -from bluebottle.bluebottle_drf2.serializers import PrivateFileSerializer class TeamSerializer(ModelSerializer): status = FSMField(read_only=True) transitions = AvailableTransitionsField(source='states') - members = SerializerMethodHyperlinkedRelatedField( - model=Contributor, + members = HyperlinkedRelatedField( + read_only=True, many=True, related_link_view_name='team-members', related_link_url_kwarg='team_id' @@ -46,51 +46,37 @@ class TeamSerializer(ModelSerializer): participants_export_url = PrivateFileSerializer( 'team-members-export', - url_args=('pk', ), + url_args=('pk',), filename='participants.csv', permission=CanExportTeamParticipantsPermission, read_only=True ) - - def get_members(self, instance): - user = self.context['request'].user - return [ - contributor for contributor in instance.members.all() if ( - isinstance(contributor, PeriodParticipant) and ( - contributor.status in [ - ParticipantStateMachine.new.value, - ParticipantStateMachine.accepted.value, - ParticipantStateMachine.succeeded.value - ] or - user in ( - instance.owner, - instance.activity.owner, - instance.activity.initiative.owner, - contributor.user - ) - ) - ) - ] + slot = ResourceRelatedField(queryset=TeamSlot.objects) class Meta(object): model = Team - fields = ('owner', 'members', 'activity') + fields = ('owner', 'slot', 'members') meta_fields = ( 'status', 'transitions', 'created', 'participants_export_url', + ) class JSONAPIMeta(object): included_resources = [ 'owner', + 'slot', + 'slot.location', ] resource_name = 'activities/teams' included_serializers = { 'owner': 'bluebottle.initiatives.serializers.MemberSerializer', + 'slot': 'bluebottle.time_based.serializers.TeamSlotSerializer', + 'slot.location': 'bluebottle.geo.serializers.GeolocationSerializer', } @@ -228,7 +214,7 @@ def get_contributor_count(self, instance): ).count() def get_team_count(self, instance): - return instance.teams.filter(status='open').count() + return instance.teams.filter(status__in=['open', 'finished']).count() class Meta(object): model = Activity @@ -284,7 +270,6 @@ class JSONAPIMeta(object): 'segments', 'segments.segment_type' ] - resource_name = 'activities' class BaseActivityListSerializer(ModelSerializer): @@ -354,7 +339,6 @@ class JSONAPIMeta(object): 'goals', 'goals.type', ] - resource_name = 'activities' class BaseTinyActivitySerializer(ModelSerializer): @@ -411,7 +395,7 @@ class BaseContributorListSerializer(ModelSerializer): class Meta(object): model = Contributor fields = ('user', 'activity', 'status', 'created', 'updated', 'accepted_invite', 'invite') - meta_fields = ('created', 'updated', ) + meta_fields = ('created', 'updated',) class JSONAPIMeta(object): included_resources = [ @@ -439,8 +423,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if ( - isinstance(self.instance, Iterable) or - ( + isinstance(self.instance, Iterable) or ( self.instance and ( self.instance.accepted_invite or self.instance.user != self.context['request'].user @@ -451,8 +434,15 @@ def __init__(self, *args, **kwargs): class Meta(object): model = Contributor - fields = ('user', 'activity', 'status', 'team', 'accepted_invite', 'invite',) - meta_fields = ('transitions', 'created', 'updated', ) + fields = ( + 'user', + 'activity', + 'status', + 'team', + 'accepted_invite', + 'invite', + ) + meta_fields = ('transitions', 'created', 'updated',) class JSONAPIMeta(object): included_resources = [ @@ -470,15 +460,14 @@ class BaseContributionSerializer(ModelSerializer): class Meta(object): model = Contribution - fields = ('value', 'status', ) - meta_fields = ('created', ) + fields = ('value', 'status',) + meta_fields = ('created',) class JSONAPIMeta(object): resource_name = 'contributors' def get_stats_for_activities(activities): - ids = activities.values_list('id', flat=True) default_currency = properties.DEFAULT_CURRENCY @@ -585,7 +574,7 @@ def get_team(self, obj): class Meta(object): model = Invite - fields = ('id', 'team', ) + fields = ('id', 'team',) class JSONAPIMeta(object): included_resources = [ diff --git a/bluebottle/activities/views.py b/bluebottle/activities/views.py index 6ed8ca719c..26c04fac19 100644 --- a/bluebottle/activities/views.py +++ b/bluebottle/activities/views.py @@ -1,6 +1,7 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.aggregates import BoolOr from django.db.models import Sum, Q, ExpressionWrapper, BooleanField, Case, When, Value, Count +from django.utils import timezone from rest_framework.permissions import IsAuthenticated from rest_framework_json_api.views import AutoPrefetchMixin @@ -173,9 +174,25 @@ def get_queryset(self, *args, **kwargs): activity_id=activity_id ) + has_slot = self.request.query_params.get('filter[has_slot]') + start = self.request.query_params.get('filter[start]') status = self.request.query_params.get('filter[status]') if status: queryset = queryset.filter(status=status) + elif has_slot == 'false': + queryset = queryset.filter(slot__start__isnull=True).exclude( + status__in=['new', 'withdrawn', 'cancelled'] + ) + elif start == 'future': + queryset = queryset.filter( + slot__start__gt=timezone.now() + ) + elif start == 'passed': + queryset = queryset.filter( + slot__start__lt=timezone.now() + ).exclude( + slot__start__isnull=True + ) if self.request.user.is_authenticated: queryset = queryset.filter( @@ -198,7 +215,14 @@ def get_queryset(self, *args, **kwargs): ) ) ) - ).order_by('-current_user', '-id') + ).distinct().order_by('-current_user') + if has_slot == 'false': + queryset = queryset.order_by('-current_user', 'id') + elif start == 'future': + queryset = queryset.order_by('-current_user', 'slot__start') + elif start == 'passed': + queryset = queryset.order_by('-current_user', '-slot__start') + else: queryset = self.queryset.filter( status='open' diff --git a/bluebottle/bluebottle_dashboard/static/admin/js/dashboard.js b/bluebottle/bluebottle_dashboard/static/admin/js/dashboard.js index 1369982dcd..d3dd38f771 100644 --- a/bluebottle/bluebottle_dashboard/static/admin/js/dashboard.js +++ b/bluebottle/bluebottle_dashboard/static/admin/js/dashboard.js @@ -1,42 +1,63 @@ function removeRedundantTabs() { - var t = 0; - if (django && django.jQuery) { - django.jQuery('.changeform-tabs-item:contains("General")').each(function(index, tab){ - t++; - if (t > 1) { - tab.remove(); - } - }); - } + var t = 0; + if (django && django.jQuery) { + django + .jQuery('.changeform-tabs-item:contains("General")') + .each(function(index, tab) { + t++; + if (t > 1) { + tab.remove(); + } + }); + } } function addHashToInlinePaginator() { - // Make sure nested inline paginator links to the same inline tab - jQuery('.paginator a').each(function(index, btn){ - if (btn.href) { - btn.href = btn.href.split('#')[0] - btn.href += document.location.hash; - } - }); + // Make sure nested inline paginator links to the same inline tab + jQuery(".paginator a").each(function(index, btn) { + if (btn.href) { + btn.href = btn.href.split("#")[0]; + btn.href += document.location.hash; + } + }); } function replaceInlineActivityAddButton() { - django.jQuery('#activities-group .add-row a').unbind(); - django.jQuery('#activities-group .add-row a').click(function(e) { - e.preventDefault(); - var path = document.location.pathname; - path = path.replace('initiatives/initiative/', 'activities/activity/add/?initiative='); - path = path.replace('/change/', ''); - document.location.href = path - }); + django.jQuery("#activities-group .add-row a").unbind(); + django.jQuery("#activities-group .add-row a").click(function(e) { + e.preventDefault(); + var path = document.location.pathname; + path = path.replace( + "initiatives/initiative/", + "activities/activity/add/?initiative=" + ); + path = path.replace("/change/", ""); + document.location.href = path; + }); +} + +function toggleDeleteButton() { + if (window.location.hash.startsWith("#/tab/inline")) { + django.jQuery(".deletelink").hide(); + } else { + django.jQuery(".deletelink").show(); + } +} + +function hideDeleteButton() { + toggleDeleteButton(); + django.jQuery(window).on("hashchange", function(e) { + toggleDeleteButton(); + }); } window.onload = function() { - if (!django.jQuery && jQuery) { - django.jQuery = jQuery; - } - replaceInlineActivityAddButton(); - removeRedundantTabs(); - addHashToInlinePaginator(); - window.onhashchange = addHashToInlinePaginator; + if (!django.jQuery && jQuery) { + django.jQuery = jQuery; + } + replaceInlineActivityAddButton(); + removeRedundantTabs(); + addHashToInlinePaginator(); + hideDeleteButton(); + window.onhashchange = addHashToInlinePaginator; }; diff --git a/bluebottle/deeds/tests/test_periodic_tasks.py b/bluebottle/deeds/tests/test_periodic_tasks.py index 16cdc4c388..d4bd6825fc 100644 --- a/bluebottle/deeds/tests/test_periodic_tasks.py +++ b/bluebottle/deeds/tests/test_periodic_tasks.py @@ -111,7 +111,7 @@ def test_reminder(self): self.run_tasks(self.activity.start - timedelta(days=1)) self.assertEqual(len(mail.outbox), 2) - self.assertEqual(mail.outbox[0].to[0], self.activity.owner.email) + self.assertEqual(mail.outbox[1].to[0], self.activity.owner.email) mail.outbox = [] self.assertEqual(len(mail.outbox), 0) self.run_tasks(self.activity.start - timedelta(days=1)) diff --git a/bluebottle/fsm/factory.py b/bluebottle/fsm/factory.py new file mode 100644 index 0000000000..7b1dc9466b --- /dev/null +++ b/bluebottle/fsm/factory.py @@ -0,0 +1,35 @@ +from factory import DjangoModelFactory + + +class FSMModelFactory(DjangoModelFactory): + + @classmethod + def create(cls, as_user=None, as_relation=None, **kwargs): + if as_user: + model = super(FSMModelFactory, cls).build(**kwargs) + model.execute_triggers(user=as_user) + model.save() + return model + if as_relation: + model = super(FSMModelFactory, cls).build(**kwargs) + model.execute_triggers(user=getattr(model, as_relation)) + model.save() + return model + + return super(FSMModelFactory, cls).create(**kwargs) + + @classmethod + def create_batch(cls, size, as_user=None, as_relation=None, **kwargs): + if as_user: + batch = super(FSMModelFactory, cls).build_batch(size, **kwargs) + for model in batch: + model.execute_triggers(user=as_user) + model.save() + return batch + if as_relation: + batch = super(FSMModelFactory, cls).build_batch(size, **kwargs) + for model in batch: + model.execute_triggers(user=getattr(model, as_relation)) + model.save() + return batch + return super(FSMModelFactory, cls).create_batch(size, **kwargs) diff --git a/bluebottle/fsm/triggers.py b/bluebottle/fsm/triggers.py index 65fd05a2bf..967d7bbf3c 100644 --- a/bluebottle/fsm/triggers.py +++ b/bluebottle/fsm/triggers.py @@ -5,6 +5,7 @@ from builtins import zip from builtins import object from django.utils.translation import gettext_lazy as _ +from django_tools.middlewares.ThreadLocal import get_current_user from future.utils import python_2_unicode_compatible @@ -185,6 +186,8 @@ def _check_model_changed_triggers(self): self._triggers.append(BoundTrigger(self, trigger)) def execute_triggers(self, effects=None, **options): + if 'user' not in options and get_current_user(): + options['user'] = get_current_user() if hasattr(self, '_state_machines'): for machine_name in self._state_machines: machine = getattr(self, machine_name) diff --git a/bluebottle/geo/models.py b/bluebottle/geo/models.py index 8fe480210e..af4fe2f1ce 100644 --- a/bluebottle/geo/models.py +++ b/bluebottle/geo/models.py @@ -237,7 +237,7 @@ class Geolocation(models.Model): anonymized = False class JSONAPIMeta(object): - resource_name = 'geo-locations' + resource_name = 'geolocations' def __str__(self): if self.locality: diff --git a/bluebottle/projects/migrations/0091_project_to_initiatives.py b/bluebottle/projects/migrations/0091_project_to_initiatives.py index f49c0c385d..d89ecec14d 100644 --- a/bluebottle/projects/migrations/0091_project_to_initiatives.py +++ b/bluebottle/projects/migrations/0091_project_to_initiatives.py @@ -10,430 +10,6 @@ from bluebottle.clients import properties - -def map_status(status): - mapping = { - 'plan-new': 'draft', - 'plan-submitted': 'submitted', - 'plan-needs-work': 'needs_work', - 'voting': 'approved', - 'voting-done': 'approved', - 'campaign': 'approved', - 'to-be-continued': 'approved', - 'done-complete': 'approved', - 'done-incomplete': 'approved', - 'closed': 'closed', - 'refunded': 'approved', - } - return mapping[status.slug] - - -def map_funding_status(status): - mapping = { - 'plan-new': 'in_review', - 'plan-submitted': 'in_review', - 'plan-needs-work': 'in_review', - 'voting': 'open', - 'voting-done': 'open', - 'campaign': 'open', - 'to-be-continued': 'open', - 'done-complete': 'succeeded', - 'done-incomplete': 'succeeded', - 'closed': 'closed', - 'refunded': 'refunded', - } - return mapping[status.slug] - - -def map_funding_review_status(status): - mapping = { - 'plan-new': 'draft', - 'plan-submitted': 'submitted', - 'plan-needs-work': 'needs_work', - 'voting': 'approved', - 'voting-done': 'approved', - 'campaign': 'approved', - 'to-be-continued': 'approved', - 'done-complete': 'approved', - 'done-incomplete': 'approved', - 'closed': 'approved', - 'refunded': 'approved', - } - return mapping[status.slug] - - -def truncate(number, limit): - return old_div(int(number * pow(10, limit)), 10) ^ pow(10, limit) - - -def set_currencies(apps, provider, name): - PaymentCurrency = apps.get_model('funding', 'PaymentCurrency') - defaults = properties.DONATION_AMOUNTS - for method in properties.PAYMENT_METHODS: - if method['provider'] == name: - for cur in method['currencies']: - val = method['currencies'][cur] - PaymentCurrency.objects.get_or_create( - provider=provider, - code=cur, - defaults={ - 'min_amount': getattr(val, 'min_amount', 5.0), - 'default1': defaults[cur][0], - 'default2': defaults[cur][1], - 'default3': defaults[cur][2], - 'default4': defaults[cur][3], - } - ) - - -def migrate_payment_providers(apps): - - PledgePaymentProvider = apps.get_model('funding_pledge', 'PledgePaymentProvider') - StripePaymentProvider = apps.get_model('funding_stripe', 'StripePaymentProvider') - FlutterwavePaymentProvider = apps.get_model('funding_flutterwave', 'FlutterwavePaymentProvider') - VitepayPaymentProvider = apps.get_model('funding_vitepay', 'VitepayPaymentProvider') - LipishaPaymentProvider = apps.get_model('funding_lipisha', 'LipishaPaymentProvider') - - Client = apps.get_model('clients', 'Client') - ContentType = apps.get_model('contenttypes', 'ContentType') - - tenant = Client.objects.get(schema_name=connection.tenant.schema_name) - properties.set_tenant(tenant) - - for provider in properties.MERCHANT_ACCOUNTS: - pp = None - if provider['merchant'] == 'stripe': - content_type = ContentType.objects.get_for_model(StripePaymentProvider) - pp = StripePaymentProvider.objects.create( - polymorphic_ctype=content_type, - ) - for payment_methods in properties.PAYMENT_METHODS: - if payment_methods['id'] == 'stripe-creditcard': - pp.credit_card = True - elif payment_methods['id'] == 'stripe-ideal': - pp.ideal = True - elif payment_methods['id'] == 'stripe-directdebit': - pp.direct_debit = True - elif payment_methods['id'] == 'stripe-bancontact': - pp.bancontact = True - pp.save() - set_currencies(apps, pp, 'stripe') - - elif provider['merchant'] == 'vitepay': - content_type = ContentType.objects.get_for_model(VitepayPaymentProvider) - pp = VitepayPaymentProvider.objects.create( - polymorphic_ctype=content_type, - api_secret=provider['api_secret'], - api_key=provider['api_key'], - api_url=provider['api_url'], - prefix='new' - ) - set_currencies(apps, pp, 'vitepay') - elif provider['merchant'] == 'lipisha': - content_type = ContentType.objects.get_for_model(LipishaPaymentProvider) - pp = LipishaPaymentProvider.objects.create( - polymorphic_ctype=content_type, - api_key=provider['api_key'], - api_signature=provider['api_signature'], - paybill=provider['business_number'], - prefix='new' - ) - set_currencies(apps, pp, 'lipisha') - elif provider['merchant'] == 'flutterwave': - content_type = ContentType.objects.get_for_model(FlutterwavePaymentProvider) - pp = FlutterwavePaymentProvider.objects.create( - polymorphic_ctype=content_type, - pub_key=provider['pub_key'], - sec_key=provider['sec_key'], - prefix='new' - ) - set_currencies(apps, pp, 'flutterwave') - elif provider['merchant'] == 'pledge': - content_type = ContentType.objects.get_for_model(PledgePaymentProvider) - pp = PledgePaymentProvider.objects.create( - polymorphic_ctype=content_type, - ) - set_currencies(apps, pp, 'pledge') - - -def migrate_projects(apps, schema_editor): - migrate_payment_providers(apps) - - Project = apps.get_model('projects', 'Project') - Initiative = apps.get_model('initiatives', 'Initiative') - Funding = apps.get_model('funding', 'Funding') - Geolocation = apps.get_model('geo', 'Geolocation') - Country = apps.get_model('geo', 'Country') - Image = apps.get_model('files', 'Image') - Client = apps.get_model('clients', 'Client') - OrganizationContact = apps.get_model('organizations', 'OrganizationContact') - StripePayoutAccount = apps.get_model('funding_stripe', 'StripePayoutAccount') - ExternalAccount = apps.get_model('funding_stripe', 'ExternalAccount') - PlainPayoutAccount = apps.get_model('funding', 'PlainPayoutAccount') - PayoutAccount = apps.get_model('funding', 'PayoutAccount') - BudgetLine = apps.get_model('funding', 'BudgetLine') - Reward = apps.get_model('funding', 'Reward') - Fundraiser = apps.get_model('funding', 'Fundraiser') - OldPayoutAccount = apps.get_model('payouts', 'PayoutAccount') - FlutterwaveBankAccount = apps.get_model('funding_flutterwave', 'FlutterwaveBankAccount') - VitepayBankAccount = apps.get_model('funding_vitepay', 'VitepayBankAccount') - LipishaBankAccount = apps.get_model('funding_lipisha', 'LipishaBankAccount') - PledgeBankAccount = apps.get_model('funding_pledge', 'PledgeBankAccount') - - Wallpost = apps.get_model('wallposts', 'Wallpost') - - ContentType = apps.get_model('contenttypes', 'ContentType') - - # Clean-up previous migrations of projects to initiatives - Initiative.objects.all().delete() - - tenant = Client.objects.get(schema_name=connection.tenant.schema_name) - properties.set_tenant(tenant) - - stripe_bank_ct = ContentType.objects.get_for_model(ExternalAccount) - stripe_account_ct = ContentType.objects.get_for_model(StripePayoutAccount) - plain_account_ct = ContentType.objects.get_for_model(PlainPayoutAccount) - flutterwave_ct = ContentType.objects.get_for_model(FlutterwaveBankAccount) - lipisha_ct = ContentType.objects.get_for_model(LipishaBankAccount) - vitepay_ct = ContentType.objects.get_for_model(VitepayBankAccount) - pledge_ct = ContentType.objects.get_for_model(PledgeBankAccount) - funding_ct = ContentType.objects.get_for_model(Funding) - project_ct = ContentType.objects.get_for_model(Project) - initiative_ct = ContentType.objects.get_for_model(Initiative) - - for project in Project.objects.select_related('payout_account', 'projectlocation').iterator(): - if hasattr(project, 'projectlocation') and project.projectlocation.country: - if project.projectlocation.latitude and project.projectlocation.longitude: - point = Point( - float(project.projectlocation.longitude), - float(project.projectlocation.latitude) - ) - else: - point = Point(0, 0) - - country = project.country - - if not country and project.projectlocation.country and Country.objects.filter( - translations__name=project.projectlocation.country - ).count(): - country = Country.objects.filter( - translations__name__contains=project.projectlocation.country - ).first() - - if not country: - country = Country.objects.filter(alpha2_code=properties.DEFAULT_COUNTRY_CODE).first() - - if country: - place = Geolocation.objects.create( - street=project.projectlocation.street, - postal_code=project.projectlocation.postal_code, - country=country, - locality=project.projectlocation.city, - position=point - ) - else: - place = None - else: - if project.country: - place = Geolocation.objects.create( - country=project.country, - position=Point(0, 0) - ) - else: - place = None - - initiative = Initiative.objects.create( - title=project.title, - slug=project.slug, - pitch=project.pitch or '', - story=project.story or '', - theme_id=project.theme_id, - video_url=project.video_url, - place=place, - location_id=project.location_id, - owner_id=project.owner_id, - reviewer_id=project.reviewer_id, - activity_manager_id=project.task_manager_id, - promoter_id=project.promoter_id, - status=map_status(project.status) - - ) - if project.image: - try: - image = Image.objects.create(owner=project.owner) - image.file.save(project.image.name, project.image.file, save=True) - initiative.image = image - except IOError: - pass - - if project.organization: - initiative.organization = project.organization - - contact = OrganizationContact.objects.filter(organization=project.organization).first() - if contact: - initiative.organization_contact = contact - - initiative.created = project.created - initiative.categories = project.categories.all() - initiative.save() - - # Create Funding event if there are donations - if project.project_type in ['both', 'funding'] \ - or project.donation_set.count() \ - or project.amount_asked.amount: - account = None - if project.payout_account: - try: - stripe_account = project.payout_account.stripepayoutaccount - if stripe_account.account_id: - payout_account = StripePayoutAccount.objects.create( - polymorphic_ctype=stripe_account_ct, - account_id=stripe_account.account_id, - owner=stripe_account.user, - # country=stripe_account.country.alpha2_code - ) - account = ExternalAccount.objects.create( - polymorphic_ctype=stripe_bank_ct, - connect_account=payout_account, - # account_id=stripe_account.bank_details.account - ) - except OldPayoutAccount.DoesNotExist: - plain_account = project.payout_account.plainpayoutaccount - payout_account = PlainPayoutAccount.objects.create( - polymorphic_ctype=plain_account_ct, - owner=plain_account.user, - reviewed=plain_account.reviewed - ) - - if str(project.amount_asked.currency) == 'NGN': - country = None - if plain_account.account_bank_country: - country = plain_account.account_bank_country.alpha2_code - account = FlutterwaveBankAccount.objects.create( - polymorphic_ctype=flutterwave_ct, - connect_account=payout_account, - account_holder_name=plain_account.account_holder_name, - bank_country_code=country, - account_number=plain_account.account_number - ) - elif str(project.amount_asked.currency) == 'KES': - account = LipishaBankAccount.objects.create( - polymorphic_ctype=lipisha_ct, - connect_account=payout_account, - account_number=plain_account.account_number, - account_name=plain_account.account_holder_name, - address=plain_account.account_holder_address - ) - elif str(project.amount_asked.currency) == 'XOF': - account = VitepayBankAccount.objects.create( - polymorphic_ctype=vitepay_ct, - connect_account=payout_account, - account_name=plain_account.account_holder_name, - ) - else: - account = PledgeBankAccount.objects.create( - polymorphic_ctype=pledge_ct, - connect_account=payout_account, - account_holder_name=plain_account.account_holder_name, - account_holder_address=plain_account.account_holder_address, - account_holder_postal_code=plain_account.account_holder_postal_code, - account_holder_city=plain_account.account_holder_city, - account_holder_country_id=plain_account.account_holder_country_id, - account_number=plain_account.account_number, - account_details=plain_account.account_details, - account_bank_country_id=plain_account.account_bank_country_id, - ) - - funding = Funding.objects.create( - # Common activity fields - polymorphic_ctype=funding_ct, # This does not get set automatically in migrations - initiative=initiative, - owner_id=project.owner_id, - highlight=project.is_campaign, - created=project.created, - updated=project.updated, - status=map_funding_status(project.status), - review_status=map_funding_review_status(project.status), - title=project.title, - slug=project.slug, - description=project.pitch or '', - transition_date=project.campaign_ended, - - # Funding specific fields - deadline=project.deadline, - target=project.amount_asked, - amount_matching=project.amount_extra, - country=project.country, - bank_account=account - ) - project.funding_id = funding.id - project.save() - - Wallpost.objects.filter(content_type=project_ct, object_id=project.id).\ - update(content_type=funding_ct, object_id=funding.id) - - for budget_line in project.projectbudgetline_set.all(): - new_budget_line = BudgetLine.objects.create( - activity=funding, - amount=budget_line.amount, - description=budget_line.description, - created=budget_line.created, - updated=budget_line.updated - ) - - fundraiser_ct = ContentType.objects.get_for_model(Initiative) - old_fundraiser_ct = ContentType.objects.get_for_model(Project) - - for fundraiser in project.fundraiser_set.all(): - new_fundraiser = Fundraiser.objects.create( - owner_id=fundraiser.owner_id, - activity=funding, - title=fundraiser.title, - description=fundraiser.description, - amount=fundraiser.amount, - deadline=fundraiser.deadline, - created=fundraiser.created, - updated=fundraiser.updated - ) - new_fundraiser.save() - if fundraiser.image: - try: - image = Image.objects.create(owner=fundraiser.owner) - image.file.save(fundraiser.image.name, fundraiser.image.file, save=True) - initiative.image = image - except IOError: - pass - Wallpost.objects.filter(content_type=old_fundraiser_ct, object_id=fundraiser.id). \ - update(content_type=fundraiser_ct, object_id=new_fundraiser.id) - - for reward in project.reward_set.all(): - new_reward = Reward.objects.create( - activity=funding, - amount=reward.amount, - description=reward.description, - title=reward.title, - limit=reward.limit, - created=reward.created, - updated=reward.updated - ) - reward.new_reward_id = new_reward.id - reward.save() - else: - Wallpost.objects.filter(content_type=project_ct, object_id=project.id).\ - update(content_type=initiative_ct, object_id=initiative.id) - - -def wipe_initiatives(apps, schema_editor): - - Initiative = apps.get_model('initiatives', 'Initiative') - Funding = apps.get_model('funding', 'Funding') - PaymentProvider = apps.get_model('funding', 'PaymentProvider') - - PaymentProvider.objects.all().delete() - Initiative.objects.all().delete() - Funding.objects.all().delete() - - class Migration(migrations.Migration): dependencies = [ @@ -456,5 +32,4 @@ class Migration(migrations.Migration): name='funding_id', field=models.IntegerField(null=True), ), - migrations.RunPython(migrate_projects, wipe_initiatives) ] diff --git a/bluebottle/tasks/migrations/0042_migrate_tasks_to_activities.py b/bluebottle/tasks/migrations/0042_migrate_tasks_to_activities.py index b7046bae37..fd07de5ea8 100644 --- a/bluebottle/tasks/migrations/0042_migrate_tasks_to_activities.py +++ b/bluebottle/tasks/migrations/0042_migrate_tasks_to_activities.py @@ -12,219 +12,6 @@ from bluebottle.clients import properties - -def map_event_status(task): - - mapping = { - 'open': 'open', - 'full': 'full', - 'running': 'open', - 'realised': 'succeeded', - 'realized': 'succeeded', - 'in progress': 'open', - 'closed': 'closed' - } - status = mapping[task.status] - if task.project.status in ['plan-new', 'draft', 'submitted']: - status = 'draft' - - return status - - -def map_participant_status(member): - mapping = { - 'applied': 'new', - 'accepted': 'new', - 'rejected': 'rejected', - 'stopped': 'closed', - 'withdrew': 'withdrawn', - 'realized': 'succeeded', - 'absent': 'no_show' - } - status = mapping[member.status] - return status - - -def map_applicant_status(member): - mapping = { - 'applied': 'new', - 'accepted': 'accepted', - 'rejected': 'rejected', - 'stopped': 'closed', - 'withdrew': 'withdrawn', - 'realized': 'succeeded', - 'absent': 'no_show' - } - status = mapping[member.status] - return status - - -def migrate_tasks(apps, schema_editor): - Client = apps.get_model('clients', 'Client') - tenant = Client.objects.get(schema_name=connection.tenant.schema_name) - properties.set_tenant(tenant) - - Task = apps.get_model('tasks', 'Task') - - Activity = apps.get_model('activities', 'Activity') - Event = apps.get_model('events', 'Event') - Assignment = apps.get_model('assignments', 'Assignment') - - Contribution = apps.get_model('activities', 'Contribution') - Participant = apps.get_model('events', 'Participant') - Applicant = apps.get_model('assignments', 'Applicant') - - Initiative = apps.get_model('initiatives', 'Initiative') - ContentType = apps.get_model('contenttypes', 'ContentType') - Wallpost = apps.get_model('wallposts', 'Wallpost') - - Place = apps.get_model('geo', 'Place') - Geolocation = apps.get_model('geo', 'Geolocation') - # Clean-up previous migrations of projects to initiatives - Event.objects.all().delete() - Assignment.objects.all().delete() - - event_ctype = ContentType.objects.get_for_model(Event) - participant_ctype= ContentType.objects.get_for_model(Participant) - assignment_ctype = ContentType.objects.get_for_model(Assignment) - applicant_ctype= ContentType.objects.get_for_model(Applicant) - task_ctype= ContentType.objects.get_for_model(Task) - - def get_location(task): - place = Place.objects.filter( - content_type=task_ctype, - object_id=task.pk - ).first() - if place: - return Geolocation.objects.create( - street_number=place.street_number, - street=place.street, - postal_code=place.postal_code, - locality=place.locality, - province=place.province, - position=Point(float(place.position.longitude), float(place.position.latitude)), - country_id=place.country_id - ) - else: - return None - - for task in Task.objects.select_related('project').prefetch_related('members').iterator(): - if task.type == 'event' and (not task.skill_id or not task.skill.expertise): - initiative = Initiative.objects.get(slug=task.project.slug) - - geolocation = get_location(task) - status = map_event_status(task) - - event = Event.objects.create( - # activity fields - polymorphic_ctype=event_ctype, - initiative=initiative, - title=task.title, - slug=slugify(task.title), - description=task.description, - review_status=initiative.status, - status=status, - owner_id=task.author_id, - - # event fields - capacity=task.people_needed, - automatically_accept=bool(task.accepting == 'automatic'), - is_online=bool(not task.location), - location=geolocation, - location_hint=task.location, - start_date=task.deadline.date(), - start_time=task.deadline.time(), - duration=task.time_needed, - transition_date=task.deadline - ) - task.activity_id = event.pk - task.save() - - event.created = task.created - event.updated = task.updated - event.polymorphic_ctype = event_ctype - event.save() - - for task_member in task.members.all(): - status = map_participant_status(task_member) - - participant = Participant.objects.create( - activity=event, - user=task_member.member, - status=status, - time_spent=task_member.time_spent, - transition_date=task.updated - ) - participant.created = task.created - participant.updated = task.updated - participant.polymorphic_ctype = participant_ctype - participant.save() - - old_ct = ContentType.objects.get_for_model(Task) - Wallpost.objects.filter(content_type=old_ct, object_id=task.id).\ - update(content_type=event_ctype, object_id=event.id) - - else: - initiative = Initiative.objects.get(slug=task.project.slug) - geolocation = get_location(task) - - status = map_event_status(task) - end_date_type = 'deadline' - if task.type == 'event': - end_date_type = 'on_date' - - assignment = Assignment.objects.create( - # activity fields - polymorphic_ctype=assignment_ctype, - initiative=initiative, - title=task.title, - slug=slugify(task.title), - description=task.description, - status=status, - review_status=initiative.status, - owner=task.author, - - # assignment fields - end_date_type=end_date_type, - registration_deadline=task.deadline_to_apply.date(), - end_date=task.deadline.date(), - capacity=task.people_needed, - is_online=bool(not task.location), - location=geolocation, - duration=task.time_needed, - expertise=task.skill, - transition_date=task.deadline - ) - - task.activity_id = assignment.pk - task.save() - - assignment.created = task.created - assignment.updated = task.updated - assignment.polymorphic_ctype = assignment_ctype - assignment.save() - - for task_member in task.members.all(): - status = map_applicant_status(task_member) - - applicant = Applicant.objects.create( - activity=assignment, - user=task_member.member, - status=status, - time_spent=task_member.time_spent, - motivation=task_member.motivation, - transition_date=task.updated - ) - applicant.created = task.created - applicant.updated = task.updated - applicant.polymorphic_ctype = applicant_ctype - applicant.save() - - old_ct = ContentType.objects.get_for_model(Task) - Wallpost.objects.filter(content_type=old_ct, object_id=task.id). \ - update(content_type=assignment_ctype, object_id=assignment.id) - - class Migration(migrations.Migration): dependencies = [ @@ -240,5 +27,4 @@ class Migration(migrations.Migration): name='activity_id', field=models.IntegerField(null=True), ), - migrations.RunPython(migrate_tasks, migrations.RunPython.noop) ] diff --git a/bluebottle/test/test_runner.py b/bluebottle/test/test_runner.py index 555407fe06..14c4b78f4c 100644 --- a/bluebottle/test/test_runner.py +++ b/bluebottle/test/test_runner.py @@ -1,6 +1,7 @@ import locale from builtins import range +from django.conf import settings from django.db import connection, IntegrityError from django_slowtests.testrunner import DiscoverSlowestTestsRunner from djmoney.contrib.exchange.models import Rate, ExchangeBackend @@ -11,6 +12,7 @@ class MultiTenantRunner(DiscoverSlowestTestsRunner, InitProjectDataMixin): def setup_databases(self, *args, **kwargs): + self.keepdb = getattr(settings, 'KEEPDB', self.keepdb) parallel = self.parallel self.parallel = 0 result = super(MultiTenantRunner, self).setup_databases(**kwargs) diff --git a/bluebottle/test/utils.py b/bluebottle/test/utils.py index a99b401798..304a54c3b2 100644 --- a/bluebottle/test/utils.py +++ b/bluebottle/test/utils.py @@ -3,6 +3,9 @@ from builtins import str from contextlib import contextmanager from importlib import import_module +from urllib.parse import ( + urlencode, urlparse, parse_qsl, ParseResult +) from bs4 import BeautifulSoup from django.conf import settings @@ -206,15 +209,28 @@ def setUp(self): self.user = BlueBottleUserFactory.create() self.client = JSONAPITestClient() - def perform_get(self, user=None): + def perform_get(self, user=None, query=None): """ Perform a get request and save the result in `self.response` If `user` is None, perform an anoymous request """ + + if query: + parsed_url = urlparse(self.url) + current_query = dict(parse_qsl(parsed_url.query)) + current_query.update(query) + + url = ParseResult( + parsed_url.scheme, parsed_url.netloc, parsed_url.path, + parsed_url.params, urlencode(query, doseq=True), parsed_url.fragment + ).geturl() + else: + url = self.url + self.user = user self.response = self.client.get( - self.url, + url, user=user ) @@ -407,6 +423,13 @@ def get_included(self, relationship): if {'type': included['type'], 'id': included['id']} in relations ] + def getRelatedLink(self, relation, data=None): + """ + Get the link to a relationship + """ + data = data or self.response.json()['data'] + return data['relationships'][relation]['links']['related'] + def assertRelationship(self, relation, models=None, data=None): """ Assert that a resource with `relation` is linked in the response @@ -418,7 +441,6 @@ def assertRelationship(self, relation, models=None, data=None): self.assertRelationship(relation, models, resource) else: self.assertTrue(relation in data['relationships']) - if models: relation_data = data['relationships'][relation]['data'] if not isinstance(relation_data, (tuple, list)): @@ -433,7 +455,8 @@ def assertRelationship(self, relation, models=None, data=None): def assertNoRelationship(self, relation): self.assertFalse(relation in self.response.json()['data']['relationships']) - def assertObjectList(self, data, models=None): + def assertObjectList(self, data=None, models=None): + data = data or self.response.json()['data'] if models: ids = [resource['id'] for resource in data] for model in models: @@ -664,6 +687,10 @@ def _hasEffect(self, effect_cls, model=None): if effect == effect_cls(model): return effect + def assertStatus(self, obj, status): + obj.refresh_from_db() + return self.assertEqual(obj.status, status) + def assertTransitionEffect(self, transition, model=None): if not self._hasTransitionEffect(transition, model): self.fail('Transition effect "{}" not triggered'.format(transition)) diff --git a/bluebottle/time_based/admin.py b/bluebottle/time_based/admin.py index 5300970e61..b0c6b87fdd 100644 --- a/bluebottle/time_based/admin.py +++ b/bluebottle/time_based/admin.py @@ -28,7 +28,7 @@ from bluebottle.notifications.admin import MessageAdminInline from bluebottle.time_based.models import ( DateActivity, PeriodActivity, DateParticipant, PeriodParticipant, Participant, TimeContribution, DateActivitySlot, - SlotParticipant, Skill + SlotParticipant, Skill, PeriodActivitySlot, TeamSlot ) from bluebottle.time_based.states import SlotParticipantStateMachine from bluebottle.time_based.utils import nth_weekday, duplicate_slot @@ -40,24 +40,14 @@ class BaseParticipantAdminInline(TabularInlinePaginated): model = Participant per_page = 20 readonly_fields = ('contributor_date', 'motivation', 'document', 'edit', - 'created', 'transition_date', 'status', 'disabled') + 'created', 'transition_date', 'status') raw_id_fields = ('user', 'document') extra = 0 ordering = ['-created'] - def get_fields(self, request, obj=None): - if self.can_edit(obj): - return super().get_fields(request, obj) - else: - return ['disabled'] - def get_template(self): pass - def disabled(self, obj): - return format_html('{}', obj) - disabled.short_description = _('First complete and submit the activity before managing participants.') - def can_edit(self, obj): return obj and obj.id and obj.status in ['open', 'succeeded', 'full', 'submitted'] @@ -251,6 +241,56 @@ def timezone(self, obj): timezone.short_description = _('Timezone') +class TeamSlotForm(ModelForm): + + class Meta: + model = TeamSlot + exclude = [] + + def full_clean(self): + data = super(TeamSlotForm, self).full_clean() + if not self.instance.activity_id and self.instance.team_id: + self.instance.activity = self.instance.team.activity + return data + + +class TeamSlotInline(admin.StackedInline): + model = TeamSlot + + form = TeamSlotForm + verbose_name = _('Time slot') + verbose_name_plural = _('Time slot') + raw_id_fields = ('location', ) + + formfield_overrides = { + models.DurationField: { + 'widget': TimeDurationWidget( + show_days=False, + show_hours=True, + show_minutes=True, + show_seconds=False) + }, + } + + ordering = ['-start'] + readonly_fields = ['link', 'timezone', 'status'] + fields = [ + 'start', + 'duration', + 'timezone', + 'location', + 'is_online', + 'status', + ] + + def timezone(self, obj): + if not obj.is_online and obj.location: + return obj.location.timezone + else: + return str(obj.start.astimezone(get_current_timezone()).tzinfo) + timezone.short_description = _('Timezone') + + @admin.register(DateActivity) class DateActivityAdmin(TimeBasedAdmin): base_model = DateActivity @@ -391,7 +431,7 @@ def smart_status(self, obj): class SlotAdmin(StateMachineAdmin): - inlines = [SlotParticipantInline] + raw_id_fields = ['activity', 'location'] formfield_overrides = { models.DurationField: { @@ -411,6 +451,38 @@ class SlotAdmin(StateMachineAdmin): }, } + def activity_link(self, obj): + url = reverse( + 'admin:time_based_{}_change'.format(obj.activity._meta.model_name), + args=(obj.activity.id,) + ) + return format_html('{}', url, obj.activity) + activity_link.short_description = _('Activity') + + def get_form(self, request, obj=None, **kwargs): + if obj and not obj.is_online and obj.location: + local_start = obj.start.astimezone(timezone(obj.location.timezone)) + platform_start = obj.start.astimezone(get_current_timezone()) + offset = local_start.utcoffset() - platform_start.utcoffset() + + if offset.total_seconds() != 0: + timezone_text = _( + 'Local time in "{location}" is {local_time}. ' + 'This is {offset} hours {relation} compared to the ' + 'standard platform timezone ({current_timezone}).' + ).format( + location=obj.location, + local_time=defaultfilters.time(local_start), + offset=abs(offset.total_seconds() / 3600.0), + relation=_('later') if offset.total_seconds() > 0 else _('earlier'), + current_timezone=get_current_timezone() + ) + + help_texts = {'start': timezone_text} + kwargs.update({'help_texts': help_texts}) + + return super(SlotAdmin, self).get_form(request, obj, **kwargs) + def duration_string(self, obj): duration = get_human_readable_duration(str(obj.duration)).lower() return duration @@ -440,7 +512,13 @@ def valid(self, obj): 'updated', 'valid' ] - detail_fields = [] + detail_fields = [ + 'activity', + 'is_online', + 'location', + 'location_hint', + 'online_meeting_url' + ] status_fields = [ 'status', 'states', @@ -579,8 +657,7 @@ def __init__(self, slot, data=None, *args, **kwargs): @admin.register(DateActivitySlot) class DateSlotAdmin(SlotAdmin): model = DateActivitySlot - - raw_id_fields = ['activity', 'location'] + inlines = [SlotParticipantInline] def lookup_allowed(self, lookup, value): if lookup == 'activity__slot_selection__exact': @@ -597,11 +674,6 @@ def lookup_allowed(self, lookup, value): RequiredSlotFilter, ] - def activity_link(self, obj): - url = reverse('admin:time_based_dateactivity_change', args=(obj.activity.id,)) - return format_html('{}', url, obj.activity) - activity_link.short_description = _('Activity') - def attendee_limit(self, obj): return obj.capacity or obj.activity.capacity @@ -615,40 +687,11 @@ def required(self, obj): return _('Required') required.short_description = _('Required') - def get_form(self, request, obj=None, **kwargs): - if obj and not obj.is_online and obj.location: - local_start = obj.start.astimezone(timezone(obj.location.timezone)) - platform_start = obj.start.astimezone(get_current_timezone()) - offset = local_start.utcoffset() - platform_start.utcoffset() - - if offset.total_seconds() != 0: - timezone_text = _( - 'Local time in "{location}" is {local_time}. ' - 'This is {offset} hours {relation} compared to the ' - 'standard platform timezone ({current_timezone}).' - ).format( - location=obj.location, - local_time=defaultfilters.time(local_start), - offset=abs(offset.total_seconds() / 3600.0), - relation=_('later') if offset.total_seconds() > 0 else _('earlier'), - current_timezone=get_current_timezone() - ) - - help_texts = {'start': timezone_text} - kwargs.update({'help_texts': help_texts}) - - return super(DateSlotAdmin, self).get_form(request, obj, **kwargs) - detail_fields = SlotAdmin.detail_fields + [ - 'activity', 'title', 'capacity', 'start', 'duration', - 'is_online', - 'location', - 'location_hint', - 'online_meeting_url' ] def get_urls(self): @@ -688,6 +731,47 @@ def duplicate_slot(self, request, pk, *args, **kwargs): ) +@admin.register(PeriodActivitySlot) +class PeriodSlotAdmin(SlotAdmin): + model = PeriodActivitySlot + + date_hierarchy = 'start' + list_display = [ + '__str__', 'start', 'activity_link', + ] + list_filter = [ + 'status', + SlotTimeFilter, + RequiredSlotFilter, + ] + + def participants(self, obj): + return obj.accepted_participants.count() + participants.short_description = _('Accepted participants') + + +@admin.register(TeamSlot) +class TeamSlotAdmin(SlotAdmin): + model = TeamSlot + raw_id_fields = SlotAdmin.raw_id_fields + ['team'] + date_hierarchy = 'start' + list_display = [ + '__str__', 'start', 'activity_link', + ] + list_filter = [ + 'status', + SlotTimeFilter, + RequiredSlotFilter, + ] + detail_fields = SlotAdmin.detail_fields + [ + 'team', + ] + + def participants(self, obj): + return obj.accepted_participants.count() + participants.short_description = _('Accepted participants') + + class TimeContributionInlineAdmin(admin.TabularInline): model = TimeContribution extra = 0 diff --git a/bluebottle/time_based/effects.py b/bluebottle/time_based/effects.py index e0917536ce..a59a1445a6 100644 --- a/bluebottle/time_based/effects.py +++ b/bluebottle/time_based/effects.py @@ -1,4 +1,4 @@ -from datetime import datetime, date +from datetime import datetime, date, timedelta from dateutil.relativedelta import relativedelta from django.db.models import F @@ -123,7 +123,7 @@ class SetEndDateEffect(Effect): template = 'admin/set_end_date.html' def pre_save(self, **kwargs): - self.instance.deadline = date.today() + self.instance.deadline = date.today() - timedelta(days=1) class ClearDeadlineEffect(Effect): diff --git a/bluebottle/time_based/messages.py b/bluebottle/time_based/messages.py index efef5ea3c6..466c007b2b 100644 --- a/bluebottle/time_based/messages.py +++ b/bluebottle/time_based/messages.py @@ -54,7 +54,7 @@ def get_context(self, recipient): if isinstance(participant, DateParticipant): slots = [] for slot_participant in participant.slot_participants.filter( - status='registered' + status='registered' ): slots.append(get_slot_info(slot_participant.slot)) @@ -186,6 +186,43 @@ def get_recipients(self): ] +class ReminderTeamSlotNotification(TransitionMessage): + """ + Reminder notification for a team activity slot + """ + subject = pgettext('email', 'The team activity "{title}" will take place in a few days!') + template = 'messages/reminder_team_slot' + send_once = True + + context = { + 'title': 'activity.title', + 'team_name': 'team', + 'start': 'start', + 'duration': 'duration', + 'end': 'end', + } + + def already_send(self, recipient): + return Message.objects.filter( + template=self.get_template(), + recipient=recipient, + content_type=get_content_type_for_model(self.obj), + object_id=self.obj.id + ).count() > 0 + + @property + def action_link(self): + return self.obj.activity.get_absolute_url() + + action_title = pgettext('email', 'View activity') + + def get_recipients(self): + """participants that signed up""" + return [ + participant.user for participant in self.obj.team.accepted_participants + ] + + class ChangedSingleDateNotification(TimeBasedInfoMixin, TransitionMessage): """ Notification when slot details (date, time or location) changed for a single date activity @@ -232,6 +269,33 @@ def get_recipients(self): ] +class TeamSlotChangedNotification(TransitionMessage): + """ + Notification when slot details (date, time or location) changed for a team activity + """ + subject = pgettext('email', 'The details of the team activity "{title}" have changed') + template = 'messages/changed_team_date' + context = { + 'title': 'activity.title', + 'team_name': 'team', + 'start': 'start', + 'duration': 'duration', + 'end': 'end', + } + + @property + def action_link(self): + return self.obj.activity.get_absolute_url() + + action_title = pgettext('email', 'View activity') + + def get_recipients(self): + """team members""" + return [ + participant.user for participant in self.obj.team.accepted_participants + ] + + class ActivitySucceededManuallyNotification(TransitionMessage): """ The activity was set to succeeded manually @@ -446,9 +510,9 @@ def get_recipients(self): participant = DateParticipant.objects.get(pk=self.obj.participant.pk) if ( - participant.status == 'withdrawn' or - joined_message.is_delayed or - changed_message.is_delayed or applied_message.is_delayed + participant.status == 'withdrawn' or + joined_message.is_delayed or + changed_message.is_delayed or applied_message.is_delayed ): return [] diff --git a/bluebottle/time_based/migrations/0036_auto_20201125_1258.py b/bluebottle/time_based/migrations/0036_auto_20201125_1258.py index 48c24224c5..f106ecf48e 100644 --- a/bluebottle/time_based/migrations/0036_auto_20201125_1258.py +++ b/bluebottle/time_based/migrations/0036_auto_20201125_1258.py @@ -8,252 +8,6 @@ from django.db import migrations, transaction, connection -def insert(table, fields, values): - with connection.cursor() as cursor: - query = 'INSERT into {} ({}) VALUES ({})'.format( - table, - ", ".join('"{}"'.format(field) for field in fields), - ','.join('%s' for field in fields) - ) - actual_values = [] - - for value in values: - actual_values.append( - [value[field] for field in fields ] - ) - - cursor.executemany(query, actual_values) - - -def migrate_activities(apps, schema_editor): - Event = apps.get_model('events', 'Event') - Assignment = apps.get_model('assignments', 'Assignment') - DateActivity = apps.get_model('time_based', 'DateActivity') - PeriodActivity = apps.get_model('time_based', 'PeriodActivity') - ContentType = apps.get_model('contenttypes', 'ContentType') - - time_based_fields = ( - 'activity_ptr_id', 'capacity', 'is_online', 'location_hint', - 'registration_deadline', 'location_id', 'review', 'expertise_id', - ) - date_fields = ( - 'timebasedactivity_ptr_id', 'start', 'duration', 'online_meeting_url', - ) - period_fields = ( - 'timebasedactivity_ptr_id', 'duration', 'duration_period', 'deadline' - ) - - date_activities = [] - period_activities = [] - - for event in Event.objects.values( - 'capacity', 'start', 'duration', - 'is_online', 'location_id', 'location_hint', - 'online_meeting_url', 'registration_deadline', - 'activity_ptr_id' - ): - event['timebasedactivity_ptr_id'] = event['activity_ptr_id'] - event['review'] = False - event['expertise_id'] = None - if event['duration'] is not None: - event['duration'] = timedelta(hours=event['duration']) - - date_activities.append(event) - - for assignment in Assignment.objects.values( - 'capacity', 'end_date_type', 'date', 'duration', - 'is_online', 'location_id', - 'online_meeting_url', 'registration_deadline', - 'activity_ptr_id', 'expertise_id' - ): - assignment['timebasedactivity_ptr_id'] = assignment['activity_ptr_id'] - assignment['review'] = True - assignment['duration_period'] = 'overall' - assignment['location_hint'] = '' - assignment['online_meeting_url'] = '' - - if assignment['duration'] is not None: - assignment['duration'] = timedelta(hours=assignment['duration']) - - if assignment['end_date_type'] == 'on_date': - assignment['start'] = assignment.pop('date') - date_activities.append(assignment) - else: - if assignment['date']: - assignment['deadline'] = assignment.pop('date').date() - else: - assignment['deadline'] = None - - period_activities.append(assignment) - - - insert( - 'time_based_timebasedactivity', - time_based_fields, - date_activities + period_activities - ) - - insert( - 'time_based_dateactivity', - date_fields, - date_activities - ) - insert( - 'time_based_periodactivity', - period_fields, - period_activities - ) - - Event.objects.update( - polymorphic_ctype_id=ContentType.objects.get_for_model(DateActivity) - ) - - Assignment.objects.filter(end_date_type='on_date').update( - polymorphic_ctype_id=ContentType.objects.get_for_model(DateActivity) - ) - - Assignment.objects.exclude(end_date_type='on_date').update( - polymorphic_ctype_id=ContentType.objects.get_for_model(PeriodActivity) - ) - - -def migrate_contributors(apps, schema_editor): - Participant = apps.get_model('events', 'Participant') - Applicant = apps.get_model('assignments', 'Applicant') - DateParticipant = apps.get_model('time_based', 'DateParticipant') - PeriodParticipant = apps.get_model('time_based', 'PeriodParticipant') - Contribution = apps.get_model('activities', 'Contribution') - TimeContribution = apps.get_model('time_based', 'TimeContribution') - - ContentType = apps.get_model('contenttypes', 'ContentType') - - date_participant_fields = ( - 'contributor_ptr_id', - ) - period_participant_fields = ( - 'contributor_ptr_id', 'motivation', 'document_id' - ) - contribution_fields = ( - 'contributor_id', 'status', 'created', 'polymorphic_ctype_id' - ) - time_contribution_fields = ( - 'contribution_ptr_id', 'start', 'end', 'value' - ) - - date_participants = [] - period_participants = [] - time_contributions = [] - contributions = [] - time_contributions_ctype = ContentType.objects.get_for_model(TimeContribution).pk - - - for participant in Participant.objects.values( - 'contributor_ptr_id', 'time_spent', 'activity__event__start', 'activity__event__duration', - 'status', 'created', 'contributor_date' - ): - duration = participant['activity__event__duration'] - date_participants.append({'contributor_ptr_id': participant['contributor_ptr_id']}) - - if participant['status'] in ('accepted', 'active'): - status = 'new' - elif participant['status'] in ('withdrawn', 'failed', 'rejected', 'no_show'): - status = 'failed' - else: - status = participant['status'] - - contributions.append({ - 'status': status, - 'contributor_id': participant['contributor_ptr_id'], - 'created': participant['created'], - 'polymorphic_ctype_id': time_contributions_ctype - }) - time_contributions.append({ - 'contributor_id': participant['contributor_ptr_id'], - 'start': participant['contributor_date'], - 'end': None, - 'value': timedelta(hours=participant['time_spent'] or 0) - }) - - for applicant in Applicant.objects.values( - 'contributor_ptr_id', 'time_spent', 'motivation', 'document_id', 'activity__assignment__date', - 'activity__assignment__end_date_type', 'created', 'status', 'contributor_date' - ): - participant = { - 'contributor_ptr_id': applicant['contributor_ptr_id'], - 'motivation': applicant['motivation'], - 'document_id': applicant['document_id'], - } - if applicant['activity__assignment__end_date_type'] == 'on_date': - date_participants.append(participant) - else: - period_participants.append(participant) - - if applicant['status'] in ('accepted', 'active'): - status = 'new' - elif applicant['status'] in ('withdrawn', 'failed', 'rejected', 'no_show'): - status = 'failed' - else: - status = applicant['status'] - - contributions.append({ - 'status': status, - 'contributor_id': applicant['contributor_ptr_id'], - 'created': applicant['created'], - 'polymorphic_ctype_id': time_contributions_ctype - }) - time_contributions.append({ - 'contributor_id': applicant['contributor_ptr_id'], - 'start': applicant['contributor_date'], - 'end': None, - 'value': timedelta(hours=applicant['time_spent'] or 0) - }) - - insert('time_based_dateparticipant', date_participant_fields, date_participants) - insert('time_based_periodparticipant', period_participant_fields, period_participants) - insert('activities_contribution', contribution_fields, contributions) - - contributions = dict(Contribution.objects.values_list('contributor_id', 'id')) - for time_contribution in time_contributions: - time_contribution['contribution_ptr_id'] = contributions[time_contribution['contributor_id']] - - insert('time_based_timecontribution', time_contribution_fields, time_contributions) - - Participant.objects.update( - polymorphic_ctype_id=ContentType.objects.get_for_model(DateParticipant) - ) - - Applicant.objects.filter(activity__assignment__end_date_type='on_date').update( - polymorphic_ctype_id=ContentType.objects.get_for_model(DateParticipant) - ) - - Applicant.objects.exclude(activity__assignment__end_date_type='on_date').update( - polymorphic_ctype_id=ContentType.objects.get_for_model(PeriodParticipant) - ) - - DateParticipant.objects.filter( - status__in=('succeeded', 'active', ) - ).update( - status='accepted' - ) - - PeriodParticipant.objects.filter( - status__in=('succeeded', 'active', ) - ).update( - status='accepted' - ) - - DateParticipant.objects.filter( - status__in=('failed', ) - ).update( - status='rejected' - ) - - PeriodParticipant.objects.filter( - status__in=('failed', ) - ).update( - status='rejected' - ) - class Migration(migrations.Migration): dependencies = [ @@ -263,6 +17,4 @@ class Migration(migrations.Migration): ] operations = [ - migrations.RunPython(migrate_activities, migrations.RunPython.noop), - migrations.RunPython(migrate_contributors, migrations.RunPython.noop), ] diff --git a/bluebottle/time_based/migrations/0069_auto_20220622_0930.py b/bluebottle/time_based/migrations/0069_auto_20220622_0930.py new file mode 100644 index 0000000000..09064cf022 --- /dev/null +++ b/bluebottle/time_based/migrations/0069_auto_20220622_0930.py @@ -0,0 +1,45 @@ +# Generated by Django 2.2.24 on 2022-06-22 07:30 + +from django.db import migrations, models +import django.db.models.deletion +import parler.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('geo', '0030_merge_20211026_1137'), + ('time_based', '0068_merge_20220608_1149'), + ] + + operations = [ + migrations.AlterModelOptions( + name='timebasedactivity', + options={'base_manager_name': 'objects'}, + ), + migrations.AddField( + model_name='periodactivityslot', + name='is_online', + field=models.NullBooleanField(choices=[(None, 'Not set yet'), (True, 'Yes, anywhere/online'), (False, 'No, enter a location')], default=None, verbose_name='is online'), + ), + migrations.AddField( + model_name='periodactivityslot', + name='location', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='geo.Geolocation', verbose_name='location'), + ), + migrations.AddField( + model_name='periodactivityslot', + name='location_hint', + field=models.TextField(blank=True, null=True, verbose_name='location hint'), + ), + migrations.AddField( + model_name='periodactivityslot', + name='online_meeting_url', + field=models.TextField(blank=True, default='', verbose_name='online meeting link'), + ), + migrations.AlterField( + model_name='skilltranslation', + name='master', + field=parler.fields.TranslationsForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='translations', to='time_based.Skill'), + ), + ] diff --git a/bluebottle/time_based/migrations/0070_auto_20220622_1050.py b/bluebottle/time_based/migrations/0070_auto_20220622_1050.py new file mode 100644 index 0000000000..178d799d18 --- /dev/null +++ b/bluebottle/time_based/migrations/0070_auto_20220622_1050.py @@ -0,0 +1,47 @@ +# Generated by Django 2.2.24 on 2022-06-22 08:50 + +import bluebottle.fsm.triggers +import bluebottle.utils.models +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('activities', '0058_auto_20220622_1050'), + ('geo', '0030_merge_20211026_1137'), + ('time_based', '0069_auto_20220622_0930'), + ] + + operations = [ + migrations.AlterModelOptions( + name='periodactivityslot', + options={'permissions': (('api_read_periodactivityslot', 'Can view over a period activity slots through the API'), ('api_add_periodactivityslot', 'Can add over a period activity slots through the API'), ('api_change_periodactivityslot', 'Can change over a period activity slots through the API'), ('api_delete_periodactivityslot', 'Can delete over a period activity slots through the API'), ('api_read_own_periodactivityslot', 'Can view own over a period activity slots through the API'), ('api_add_own_periodactivityslot', 'Can add own over a period activity slots through the API'), ('api_change_own_periodactivityslot', 'Can change own over a period activity slots through the API'), ('api_delete_own_periodactivityslot', 'Can delete own over a period activity slots through the API')), 'verbose_name': 'period activity slot', 'verbose_name_plural': 'period activity slots'}, + ), + migrations.CreateModel( + name='TeamSlot', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(default=django.utils.timezone.now)), + ('updated', models.DateTimeField(auto_now=True)), + ('status', models.CharField(max_length=40)), + ('title', models.CharField(blank=True, max_length=255, null=True, verbose_name='title')), + ('capacity', models.PositiveIntegerField(blank=True, null=True, verbose_name='attendee limit')), + ('is_online', models.NullBooleanField(choices=[(None, 'Not set yet'), (True, 'Yes, anywhere/online'), (False, 'No, enter a location')], default=None, verbose_name='is online')), + ('online_meeting_url', models.TextField(blank=True, default='', verbose_name='online meeting link')), + ('location_hint', models.TextField(blank=True, null=True, verbose_name='location hint')), + ('start', models.DateTimeField(blank=True, null=True, verbose_name='start date and time')), + ('activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='team_slots', to='time_based.PeriodActivity')), + ('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='geo.Geolocation', verbose_name='location')), + ('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='slot', to='activities.Team')), + ], + options={ + 'verbose_name': 'team slot', + 'verbose_name_plural': 'team slots', + 'permissions': (('api_read_teamslot', 'Can view over a team slots through the API'), ('api_add_teamslot', 'Can add over a team slots through the API'), ('api_change_teamslot', 'Can change over a team slots through the API'), ('api_delete_teamslot', 'Can delete over a team slots through the API'), ('api_read_own_teamslot', 'Can view own over a team slots through the API'), ('api_add_own_teamslot', 'Can add own over a team slots through the API'), ('api_change_own_teamslot', 'Can change own over a team slots through the API'), ('api_delete_own_teamslot', 'Can delete own over a team slots through the API')), + }, + bases=(bluebottle.fsm.triggers.TriggerMixin, bluebottle.utils.models.AnonymizationMixin, bluebottle.utils.models.ValidatedModelMixin, models.Model), + ), + ] diff --git a/bluebottle/time_based/migrations/0071_auto_20220622_1132.py b/bluebottle/time_based/migrations/0071_auto_20220622_1132.py new file mode 100644 index 0000000000..fca5177093 --- /dev/null +++ b/bluebottle/time_based/migrations/0071_auto_20220622_1132.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.24 on 2022-06-22 09:32 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('time_based', '0070_auto_20220622_1050'), + ] + + operations = [ + migrations.AlterField( + model_name='teamslot', + name='team', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='slot', to='activities.Team'), + ), + ] diff --git a/bluebottle/time_based/migrations/0072_teamslot_duration.py b/bluebottle/time_based/migrations/0072_teamslot_duration.py new file mode 100644 index 0000000000..c45a654bd0 --- /dev/null +++ b/bluebottle/time_based/migrations/0072_teamslot_duration.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.24 on 2022-06-23 10:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('time_based', '0071_auto_20220622_1132'), + ] + + operations = [ + migrations.AddField( + model_name='teamslot', + name='duration', + field=models.DurationField(blank=True, null=True, verbose_name='duration'), + ), + ] diff --git a/bluebottle/time_based/migrations/0073_auto_20220701_1330.py b/bluebottle/time_based/migrations/0073_auto_20220701_1330.py new file mode 100644 index 0000000000..b618de7d55 --- /dev/null +++ b/bluebottle/time_based/migrations/0073_auto_20220701_1330.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.17 on 2021-01-14 09:56 +from __future__ import unicode_literals + +from django.db import migrations, connection + +from bluebottle.utils.utils import update_group_permissions + +from bluebottle.clients import properties +from bluebottle.clients.models import Client +from bluebottle.clients.utils import LocalTenant + + +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_teamslot', 'change_teamslot', 'delete_teamslot', + ) + }, + 'Anonymous': { + 'perms': ( + 'api_read_teamslot', + ) if not properties.CLOSED_SITE else () + }, + 'Authenticated': { + 'perms': ( + 'api_read_teamslot', 'api_add_own_teamslot', + 'api_change_own_teamslot', 'api_delete_own_teamslot', + ) + } + } + + update_group_permissions('time_based', group_perms, apps) + +class Migration(migrations.Migration): + + dependencies = [ + ('time_based', '0072_teamslot_duration'), + ] + + operations = [ + migrations.RunPython( + add_group_permissions, + migrations.RunPython.noop + ) + ] diff --git a/bluebottle/time_based/migrations/0074_auto_20220720_1349.py b/bluebottle/time_based/migrations/0074_auto_20220720_1349.py new file mode 100644 index 0000000000..915f256f94 --- /dev/null +++ b/bluebottle/time_based/migrations/0074_auto_20220720_1349.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.24 on 2022-07-20 11:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('time_based', '0073_auto_20220701_1330'), + ] + + operations = [ + migrations.AlterField( + model_name='teamslot', + name='duration', + field=models.DurationField(verbose_name='duration'), + ), + migrations.AlterField( + model_name='teamslot', + name='start', + field=models.DateTimeField(verbose_name='start date and time'), + ), + ] diff --git a/bluebottle/time_based/models.py b/bluebottle/time_based/models.py index 23fa70c9f8..826d49f6b5 100644 --- a/bluebottle/time_based/models.py +++ b/bluebottle/time_based/models.py @@ -9,7 +9,7 @@ from parler.models import TranslatableModel, TranslatedFields from timezonefinder import TimezoneFinder -from bluebottle.activities.models import Activity, Contributor, Contribution +from bluebottle.activities.models import Activity, Contributor, Contribution, Team from bluebottle.files.fields import PrivateDocumentField from bluebottle.fsm.triggers import TriggerMixin from bluebottle.geo.models import Geolocation @@ -33,7 +33,10 @@ class TimeBasedActivity(Activity): (True, 'Yes, anywhere/online'), (False, 'No, enter a location') ) - capacity = models.PositiveIntegerField(_('attendee limit'), null=True, blank=True) + capacity = models.PositiveIntegerField( + _('attendee limit'), + help_text=_('Number of participants or teams that can join'), + null=True, blank=True) old_is_online = models.NullBooleanField( _('is online'), @@ -64,7 +67,10 @@ class TimeBasedActivity(Activity): on_delete=models.SET_NULL ) - review = models.NullBooleanField(_('review participants'), null=True, default=None) + review = models.NullBooleanField( + _('review participants'), + help_text=_('Activity manager accepts or rejects participants or teams'), + null=True, default=None) preparation = models.DurationField( _('Preparation time'), @@ -225,8 +231,29 @@ class ActivitySlot(TriggerMixin, AnonymizationMixin, ValidatedModelMixin, models _('title'), max_length=255, null=True, blank=True) + capacity = models.PositiveIntegerField(_('attendee limit'), null=True, blank=True) + is_online = models.NullBooleanField( + _('is online'), + choices=DateActivity.ONLINE_CHOICES, + null=True, default=None + ) + + online_meeting_url = models.TextField( + _('online meeting link'), + blank=True, default='' + ) + + location = models.ForeignKey( + Geolocation, + verbose_name=_('location'), + null=True, blank=True, + on_delete=models.SET_NULL + ) + + location_hint = models.TextField(_('location hint'), null=True, blank=True) + @property def uid(self): return '{}-{}-{}'.format(connection.tenant.client_name, 'dateactivityslot', self.pk) @@ -298,25 +325,6 @@ class DateActivitySlot(ActivitySlot): start = models.DateTimeField(_('start date and time'), null=True, blank=True) duration = models.DurationField(_('duration'), null=True, blank=True) - is_online = models.NullBooleanField( - _('is online'), - choices=DateActivity.ONLINE_CHOICES, - null=True, default=None - ) - - online_meeting_url = models.TextField( - _('online meeting link'), - blank=True, default='' - ) - - location = models.ForeignKey( - Geolocation, - verbose_name=_('location'), - null=True, blank=True, - on_delete=models.SET_NULL - ) - - location_hint = models.TextField(_('location hint'), null=True, blank=True) @property def required_fields(self): @@ -492,8 +500,8 @@ class PeriodActivitySlot(ActivitySlot): end = models.DateTimeField(_('end date and time'), null=True, blank=True) class Meta: - verbose_name = _('slot') - verbose_name_plural = _('slots') + verbose_name = _('period activity slot') + verbose_name_plural = _('period activity slots') permissions = ( ('api_read_periodactivityslot', 'Can view over a period activity slots through the API'), ('api_add_periodactivityslot', 'Can add over a period activity slots through the API'), @@ -507,6 +515,47 @@ class Meta: ) +class TeamSlot(ActivitySlot): + activity = models.ForeignKey(PeriodActivity, related_name='team_slots', on_delete=models.CASCADE) + start = models.DateTimeField(_('start date and time')) + duration = models.DurationField(_('duration')) + team = models.OneToOneField(Team, related_name='slot', on_delete=models.CASCADE) + + @property + def end(self): + if self.start and self.duration: + return self.start + self.duration + + @property + def is_complete(self): + return self.start and self.duration + + class Meta: + verbose_name = _('team slot') + verbose_name_plural = _('team slots') + permissions = ( + ('api_read_teamslot', 'Can view over a team slots through the API'), + ('api_add_teamslot', 'Can add over a team slots through the API'), + ('api_change_teamslot', 'Can change over a team slots through the API'), + ('api_delete_teamslot', 'Can delete over a team slots through the API'), + + ('api_read_own_teamslot', 'Can view own over a team slots through the API'), + ('api_add_own_teamslot', 'Can add own over a team slots through the API'), + ('api_change_own_teamslot', 'Can change own over a team slots through the API'), + ('api_delete_own_teamslot', 'Can delete own over a team slots through the API'), + ) + + def __str__(self): + return str(_('Time slot for {}')).format(self.team) + + class JSONAPIMeta: + resource_name = 'activities/time-based/team-slots' + + @property + def accepted_participants(self): + return self.team.members.filter(status='accepted') + + class Participant(Contributor): @property diff --git a/bluebottle/time_based/periodic_tasks.py b/bluebottle/time_based/periodic_tasks.py index 90e688850f..27088fbe6d 100644 --- a/bluebottle/time_based/periodic_tasks.py +++ b/bluebottle/time_based/periodic_tasks.py @@ -8,12 +8,12 @@ from bluebottle.fsm.periodic_tasks import ModelPeriodicTask from bluebottle.notifications.effects import NotificationEffect from bluebottle.time_based.effects import CreatePeriodTimeContributionEffect -from bluebottle.time_based.messages import ReminderSlotNotification +from bluebottle.time_based.messages import ReminderSlotNotification, ReminderTeamSlotNotification from bluebottle.time_based.models import ( - DateActivity, PeriodActivity, PeriodParticipant, TimeContribution, DateActivitySlot + DateActivity, PeriodActivity, PeriodParticipant, TimeContribution, DateActivitySlot, TeamSlot ) from bluebottle.time_based.states import ( - TimeBasedStateMachine, TimeContributionStateMachine, ActivitySlotStateMachine + TimeBasedStateMachine, TimeContributionStateMachine, ActivitySlotStateMachine, TeamSlotStateMachine ) from bluebottle.time_based.triggers import has_participants, has_no_participants @@ -153,6 +153,40 @@ def __str__(self): return str(_("Send a reminder five days before the activity slot.")) +class TeamSlotReminderTask(ModelPeriodicTask): + + def get_queryset(self): + return TeamSlot.objects.filter( + start__lte=timezone.now() + timedelta(days=5), + start__gt=timezone.now(), + status__in=['open', 'full'], + activity__status__in=['open', 'full'] + ) + + effects = [ + NotificationEffect( + ReminderTeamSlotNotification, + ), + ] + + def __str__(self): + return str(_("Send a reminder five days before the team activity slot.")) + + +class TeamSlotStartedTask(SlotStartedTask): + + effects = [ + TransitionEffect(TeamSlotStateMachine.start), + ] + + +class TeamSlotFinishedTask(SlotFinishedTask): + + effects = [ + TransitionEffect(TeamSlotStateMachine.finish), + ] + + DateActivity.periodic_tasks = [ TimeBasedActivityRegistrationDeadlinePassedTask, ] @@ -163,6 +197,12 @@ def __str__(self): SlotFinishedTask, ] +TeamSlot.periodic_tasks = [ + TeamSlotReminderTask, + TeamSlotStartedTask, + TeamSlotFinishedTask, +] + PeriodActivity.periodic_tasks = [ PeriodActivityFinishedTask, TimeBasedActivityRegistrationDeadlinePassedTask diff --git a/bluebottle/time_based/serializers.py b/bluebottle/time_based/serializers.py index ff669e6cea..e5ee9b1d5b 100644 --- a/bluebottle/time_based/serializers.py +++ b/bluebottle/time_based/serializers.py @@ -21,10 +21,11 @@ from bluebottle.bluebottle_drf2.serializers import PrivateFileSerializer from bluebottle.files.serializers import PrivateDocumentSerializer, PrivateDocumentField from bluebottle.fsm.serializers import TransitionSerializer, AvailableTransitionsField +from bluebottle.geo.models import Geolocation from bluebottle.time_based.models import ( TimeBasedActivity, DateActivity, PeriodActivity, DateParticipant, PeriodParticipant, TimeContribution, DateActivitySlot, - SlotParticipant, Skill + SlotParticipant, Skill, TeamSlot ) from bluebottle.time_based.permissions import ParticipantDocumentPermission, CanExportParticipantsPermission from bluebottle.time_based.states import ParticipantStateMachine @@ -46,8 +47,6 @@ class TimeBasedBaseSerializer(BaseActivitySerializer): review = serializers.BooleanField(required=False) is_online = serializers.BooleanField(required=False, allow_null=True) - teams = TeamsField() - class Meta(BaseActivitySerializer.Meta): fields = BaseActivitySerializer.Meta.fields + ( 'capacity', @@ -55,8 +54,7 @@ class Meta(BaseActivitySerializer.Meta): 'expertise', 'review', 'contributors', - 'teams', - 'my_contributor' + 'my_contributor', ) class JSONAPIMeta(BaseActivitySerializer.JSONAPIMeta): @@ -89,14 +87,18 @@ class ActivitySlotSerializer(ModelSerializer): permissions = ResourcePermissionField('date-slot-detail', view_args=('pk',)) transitions = AvailableTransitionsField(source='states') status = FSMField(read_only=True) + location = ResourceRelatedField(queryset=Geolocation.objects, required=False, allow_null=True) class Meta: fields = ( 'id', 'activity', 'start', - 'duration', 'transitions', + 'is_online', + 'location_hint', + 'online_meeting_url', + 'location' ) meta_fields = ( 'status', @@ -110,9 +112,15 @@ class Meta: class JSONAPIMeta(object): included_resources = [ - 'activity', 'location' + 'activity', + 'location', ] + included_serializers = { + 'location': 'bluebottle.geo.serializers.GeolocationSerializer', + 'activity': 'bluebottle.time_based.serializers.DateActivitySerializer', + } + class DateActivitySlotSerializer(ActivitySlotSerializer): @@ -172,19 +180,55 @@ class Meta(ActivitySlotSerializer.Meta): 'links', 'duration', 'capacity', - 'is_online', - 'location', - 'location_hint', - 'online_meeting_url', 'participants', ) class JSONAPIMeta(ActivitySlotSerializer.JSONAPIMeta): resource_name = 'activities/time-based/date-slots' + +class TeamSlotSerializer(ActivitySlotSerializer): + activity = ResourceRelatedField(read_only=True) + links = serializers.SerializerMethodField() + + def get_links(self, instance): + if instance.start and instance.duration: + return { + 'ical': reverse_signed('team-ical', args=(instance.pk, )), + 'google': instance.google_calendar_link, + } + else: + return {} + + class Meta(ActivitySlotSerializer.Meta): + model = TeamSlot + fields = ActivitySlotSerializer.Meta.fields + ( + 'team', + 'start', + 'duration', + 'location', + 'links' + ) + meta_fields = ( + 'status', + 'permissions', + 'transitions', + 'created', + 'updated', + ) + + class JSONAPIMeta(object): + resource_name = 'activities/time-based/team-slots' + included_resources = [ + 'activity' + 'team', + 'location' + ] + included_serializers = { + 'team': 'bluebottle.activities.utils.TeamSerializer', 'location': 'bluebottle.geo.serializers.GeolocationSerializer', - 'activity': 'bluebottle.time_based.serializers.DateActivitySerializer', + 'activity': 'bluebottle.time_based.serializers.PeriodActivitySerializer', } @@ -379,6 +423,7 @@ class JSONAPIMeta(TimeBasedBaseSerializer.JSONAPIMeta): included_resources = TimeBasedBaseSerializer.JSONAPIMeta.included_resources + [ 'my_contributor', 'my_contributor.user', + 'my_contributor.location', 'my_contributor.slots', 'my_contributor.slots.slot', ] @@ -451,13 +496,19 @@ class JSONAPIMeta(TimeBasedBaseSerializer.JSONAPIMeta): resource_name = 'activities/time-based/periods' included_resources = TimeBasedBaseSerializer.JSONAPIMeta.included_resources + [ 'location', + 'my_contributor.team', + 'my_contributor.team.slot', + 'my_contributor.team.slot.location', ] included_serializers = dict( TimeBasedBaseSerializer.included_serializers, **{ 'location': 'bluebottle.geo.serializers.GeolocationSerializer', - 'my_contributor': 'bluebottle.time_based.serializers.PeriodParticipantSerializer' + 'my_contributor': 'bluebottle.time_based.serializers.PeriodParticipantSerializer', + 'my_contributor.team': 'bluebottle.activities.utils.TeamSerializer', + 'my_contributor.team.slot': 'bluebottle.time_based.serializers.TeamSlotSerializer', + 'my_contributor.team.slot.location': 'bluebottle.geo.serializers.GeolocationSerializer', } ) @@ -638,11 +689,6 @@ class ParticipantSerializer(BaseContributorSerializer): motivation = serializers.CharField(required=False, allow_null=True, allow_blank=True) document = PrivateDocumentField(required=False, allow_null=True, permissions=[ParticipantDocumentPermission]) - activity = PolymorphicResourceRelatedField( - TimeBasedActivitySerializer, - queryset=TimeBasedActivity.objects.all() - ) - def to_representation(self, instance): result = super().to_representation(instance) @@ -673,7 +719,9 @@ class Meta(BaseContributorSerializer.Meta): class JSONAPIMeta(BaseContributorSerializer.JSONAPIMeta): resource_name = 'contributors/time-based/participants' - included_resources = BaseContributorSerializer.JSONAPIMeta.included_resources + [ + included_resources = [ + 'user', + 'invite', 'document', 'team' ] @@ -681,6 +729,11 @@ class JSONAPIMeta(BaseContributorSerializer.JSONAPIMeta): class TeamMemberSerializer(BaseContributorSerializer): + activity = PolymorphicResourceRelatedField( + TimeBasedActivitySerializer, + queryset=TimeBasedActivity.objects.all() + ) + class Meta(BaseContributorSerializer.Meta): model = PeriodParticipant fields = ( @@ -689,7 +742,8 @@ class Meta(BaseContributorSerializer.Meta): 'team', 'accepted_invite', 'invite', - 'team' + 'team', + 'activity' ) class JSONAPIMeta(BaseContributorSerializer.JSONAPIMeta): @@ -697,6 +751,7 @@ class JSONAPIMeta(BaseContributorSerializer.JSONAPIMeta): included_resources = BaseContributorSerializer.JSONAPIMeta.included_resources + [ 'contributions', 'team', + 'team.slot', ] included_serializers = dict( @@ -704,6 +759,7 @@ class JSONAPIMeta(BaseContributorSerializer.JSONAPIMeta): **{ 'document': 'bluebottle.time_based.serializers.PeriodParticipantDocumentSerializer', 'contributions': 'bluebottle.time_based.serializers.TimeContributionSerializer', + 'team.slot': 'bluebottle.time_based.serializers.TeamSlotSerializer', } ) @@ -777,7 +833,8 @@ class JSONAPIMeta(ParticipantSerializer.JSONAPIMeta): resource_name = 'contributors/time-based/period-participants' included_resources = ParticipantSerializer.JSONAPIMeta.included_resources + [ 'contributions', - 'team' + 'team', + 'team.slot' ] included_serializers = dict( @@ -785,6 +842,7 @@ class JSONAPIMeta(ParticipantSerializer.JSONAPIMeta): **{ 'document': 'bluebottle.time_based.serializers.PeriodParticipantDocumentSerializer', 'contributions': 'bluebottle.time_based.serializers.TimeContributionSerializer', + 'team.slot': 'bluebottle.time_based.serializers.TeamSlotSerializer', 'activity': 'bluebottle.time_based.serializers.PeriodActivitySerializer', } ) diff --git a/bluebottle/time_based/states.py b/bluebottle/time_based/states.py index a0d9cc1743..0cfcdc4d90 100644 --- a/bluebottle/time_based/states.py +++ b/bluebottle/time_based/states.py @@ -6,6 +6,7 @@ from bluebottle.time_based.models import ( DateActivity, PeriodActivity, DateParticipant, PeriodParticipant, TimeContribution, DateActivitySlot, PeriodActivitySlot, SlotParticipant, + TeamSlot, ) from bluebottle.fsm.state import ( register, State, Transition, EmptyState, AllStates, ModelStateMachine @@ -244,7 +245,7 @@ class ActivitySlotStateMachine(ModelStateMachine): ) start = Transition( - [open, finished], + [open, finished, full], running, name=_("Start"), description=_( @@ -282,6 +283,18 @@ class PeriodActivitySlotStateMachine(ActivitySlotStateMachine): pass +@register(TeamSlot) +class TeamSlotStateMachine(ActivitySlotStateMachine): + initiate = Transition( + EmptyState(), + ActivitySlotStateMachine.open, + name=_('Initiate'), + description=_( + 'The slot was created.' + ), + ) + + class ParticipantStateMachine(ContributorStateMachine): new = State( _('pending'), diff --git a/bluebottle/time_based/tasks.py b/bluebottle/time_based/tasks.py index e03e3e3fbf..6fd3bc40c6 100644 --- a/bluebottle/time_based/tasks.py +++ b/bluebottle/time_based/tasks.py @@ -7,7 +7,7 @@ from bluebottle.clients.utils import LocalTenant from bluebottle.time_based.models import ( DateActivity, PeriodActivity, - DateParticipant, PeriodParticipant, TimeContribution, DateActivitySlot + DateParticipant, PeriodParticipant, TimeContribution, DateActivitySlot, TeamSlot ) logger = logging.getLogger('bluebottle') @@ -39,6 +39,18 @@ def with_a_deadline_tasks(): task.execute() +@periodic_task( + run_every=(crontab(minute='*/15')), + name="team_slot_tasks", + ignore_result=True +) +def team_slot_tasks(): + for tenant in Client.objects.all(): + with LocalTenant(tenant, clear_tenant=True): + for task in TeamSlot.get_periodic_tasks(): + task.execute() + + @periodic_task( run_every=(crontab(minute='*/15')), name="date_participant_tasks", diff --git a/bluebottle/time_based/templates/mails/messages/changed_team_date.html b/bluebottle/time_based/templates/mails/messages/changed_team_date.html new file mode 100644 index 0000000000..637e17abbd --- /dev/null +++ b/bluebottle/time_based/templates/mails/messages/changed_team_date.html @@ -0,0 +1,39 @@ +{% extends "mails/messages/activity_base.html" %} +{% load i18n %} +{% block message %} +

+{% blocktrans context 'email' %} +Some details of the team activity you are a part of have changed: +{% endblocktrans %} +

+

+{{ team_name }} +

+

+{{ title }} +

+ +{% if duration %} + {% include 'mails/messages/partial/period.html' %} +{% else %} + {% include 'mails/messages/partial/slots.html' %} +{% endif %} +

+

+{% blocktrans context 'email' %} +Please view your team from the activity page to see the changes. +{% endblocktrans %} +

+

+{% endblock %} + +{% block end_message %} +

+ + {% 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/time_based/templates/mails/messages/reminder_team_slot.html b/bluebottle/time_based/templates/mails/messages/reminder_team_slot.html new file mode 100644 index 0000000000..47a4c17866 --- /dev/null +++ b/bluebottle/time_based/templates/mails/messages/reminder_team_slot.html @@ -0,0 +1,32 @@ +{% extends "mails/messages/activity_base.html" %} +{% load i18n %} +{% block message %} +

+{% blocktrans context 'email' %} +The team activity is just a few days away! +{% endblocktrans %} +

+

+{{ team_name }} +

+

+{{ title }} +

+

+{% include 'mails/messages/partial/period.html' %} +

+ +

+{% blocktrans context 'email' %} +Please view your team from the activity page. +{% 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/time_based/templates/mails/messages/team_participant_joined.html b/bluebottle/time_based/templates/mails/messages/team_participant_joined.html index eb77bb62b0..74a2a53d68 100644 --- a/bluebottle/time_based/templates/mails/messages/team_participant_joined.html +++ b/bluebottle/time_based/templates/mails/messages/team_participant_joined.html @@ -12,12 +12,9 @@ {{ title }}

- {% if duration %} - {% include 'mails/messages/partial/period.html' %} - {% else %} - {% include 'mails/messages/partial/slots.html' %} - - {% endif %} +

+ The activity manager will be in touch to confirm details such as time, date and location. You can start inviting team members, or wait until the details have been set. +

diff --git a/bluebottle/time_based/tests/factories.py b/bluebottle/time_based/tests/factories.py index f5114c2901..049e8f33e9 100644 --- a/bluebottle/time_based/tests/factories.py +++ b/bluebottle/time_based/tests/factories.py @@ -1,6 +1,8 @@ from datetime import timedelta, date import factory.fuzzy + +from bluebottle.fsm.factory import FSMModelFactory from bluebottle.utils.models import Language from django.utils.timezone import now @@ -9,7 +11,7 @@ from bluebottle.test.factory_models.geo import GeolocationFactory from bluebottle.time_based.models import ( DateActivity, PeriodActivity, - DateParticipant, PeriodParticipant, TimeContribution, DateActivitySlot, SlotParticipant, Skill + DateParticipant, PeriodParticipant, TimeContribution, DateActivitySlot, SlotParticipant, Skill, TeamSlot ) @@ -82,7 +84,7 @@ class Meta: start = (now() + timedelta(weeks=2)).date() -class DateParticipantFactory(factory.DjangoModelFactory): +class DateParticipantFactory(FSMModelFactory): class Meta(object): model = DateParticipant @@ -90,7 +92,7 @@ class Meta(object): user = factory.SubFactory(BlueBottleUserFactory) -class PeriodParticipantFactory(factory.DjangoModelFactory): +class PeriodParticipantFactory(FSMModelFactory): class Meta(object): model = PeriodParticipant @@ -116,3 +118,13 @@ class Meta(object): slot = factory.SubFactory(DateActivitySlotFactory) participant = factory.SubFactory(DateParticipantFactory) + + +class TeamSlotFactory(factory.DjangoModelFactory): + class Meta(object): + model = TeamSlot + + is_online = False + location = factory.SubFactory(GeolocationFactory) + start = now() + timedelta(weeks=4) + duration = timedelta(hours=2) diff --git a/bluebottle/time_based/tests/test_admin.py b/bluebottle/time_based/tests/test_admin.py index d777de8699..d39ccbbbd5 100644 --- a/bluebottle/time_based/tests/test_admin.py +++ b/bluebottle/time_based/tests/test_admin.py @@ -179,25 +179,17 @@ def test_add_slot_participants(self): self.assertEqual(len(participant.slot_participants.all()), 3) def test_add_participants(self): - activity = DateActivityFactory.create(initiative=self.initiative, status='open') + activity = DateActivityFactory.create( + initiative=self.initiative, + status='open' + ) DateParticipantFactory.create(activity=activity) url = reverse('admin:time_based_dateactivity_change', args=(activity.pk,)) page = self.app.get(url) - self.assertFalse( - 'First complete and submit the activity before managing participants.' in - page.text - ) self.assertTrue( 'Add another Participant' in page.text ) - activity.status = 'rejected' - activity.save() - page = self.app.get(url) - self.assertTrue( - 'First complete and submit the activity before managing participants.' in - page.text - ) class DateParticipantAdminTestCase(BluebottleAdminTestCase): diff --git a/bluebottle/time_based/tests/test_api.py b/bluebottle/time_based/tests/test_api.py index 6b76bd7c63..3d74cdfb22 100644 --- a/bluebottle/time_based/tests/test_api.py +++ b/bluebottle/time_based/tests/test_api.py @@ -6,28 +6,31 @@ import icalendar from django.contrib.auth.models import Group, Permission from django.contrib.gis.geos import Point +from django.core import mail from django.urls import reverse from django.utils.timezone import now, utc from openpyxl import load_workbook from rest_framework import status +from bluebottle.activities.tests.factories import TeamFactory from bluebottle.files.tests.factories import PrivateDocumentFactory from bluebottle.initiatives.models import InitiativePlatformSettings from bluebottle.initiatives.tests.factories import InitiativeFactory, InitiativePlatformSettingsFactory from bluebottle.members.models import MemberPlatformSettings from bluebottle.segments.tests.factories import SegmentTypeFactory, SegmentFactory from bluebottle.test.factory_models.accounts import BlueBottleUserFactory -from bluebottle.test.factory_models.geo import LocationFactory, PlaceFactory +from bluebottle.test.factory_models.geo import LocationFactory, PlaceFactory, GeolocationFactory from bluebottle.test.factory_models.projects import ThemeFactory from bluebottle.test.utils import ( APITestCase ) from bluebottle.test.utils import BluebottleTestCase, JSONAPITestClient, get_first_included_by_type from bluebottle.time_based.models import SlotParticipant, Skill, PeriodActivity +from bluebottle.time_based.serializers import TeamSlotSerializer from bluebottle.time_based.tests.factories import ( DateActivityFactory, PeriodActivityFactory, DateParticipantFactory, PeriodParticipantFactory, - DateActivitySlotFactory, SlotParticipantFactory, SkillFactory + DateActivitySlotFactory, SlotParticipantFactory, SkillFactory, TeamSlotFactory ) @@ -424,7 +427,10 @@ def test_get_owner_export_enabled(self): export_url = data['attributes']['participants-export-url']['url'] export_response = self.client.get(export_url) - sheet = load_workbook(filename=BytesIO(export_response.content)).get_active_sheet() + workbook = load_workbook(filename=BytesIO(export_response.content)) + self.assertEqual(len(workbook.worksheets), 1) + + sheet = workbook.get_active_sheet() if isinstance(self.activity, PeriodActivity): self.assertEqual( @@ -545,7 +551,6 @@ def test_get_contributors(self): self.response_data['relationships']['contributors']['links']['related'], user=self.activity.owner ) - self.response_data = response.json()['data'] self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -941,6 +946,9 @@ def setUp(self): }) def test_get_open(self): + self.activity.team_activity = 'teams' + self.activity.save() + super().test_get_open() self.assertFalse( @@ -948,6 +956,20 @@ def test_get_open(self): in self.data['meta']['transitions'] ) + def test_owner_succeed_manually(self): + self.initiative = InitiativeFactory.create(status='approved') + self.activity.initiative = self.initiative + self.activity.start = None + self.activity.deadline = None + self.activity.states.submit(save=True) + PeriodParticipantFactory.create(activity=self.activity) + response = self.client.get(self.url, user=self.activity.owner) + self.data = response.json()['data'] + self.assertTrue( + {'name': 'succeed_manually', 'target': 'succeeded', 'available': True} + in self.data['meta']['transitions'] + ) + def test_get_open_with_participant(self): self.activity.duration_period = 'weeks' self.activity.save() @@ -1047,7 +1069,6 @@ def test_matching_location_location_too_far(self): self.assertEqual(data['meta']['matching-properties']['location'], False) def test_get_owner_export_teams_enabled(self): - initiative_settings = InitiativePlatformSettings.load() initiative_settings.enable_participant_exports = True initiative_settings.team_activities = True @@ -1060,24 +1081,141 @@ def test_get_owner_export_teams_enabled(self): self.participant_factory.create_batch( 3, activity=self.activity, accepted_invite=team_captain.invite ) + team_captain.team.slot = TeamSlotFactory.create( + team=team_captain.team, activity=self.activity + ) + + # create another team + other_team_captain = self.participant_factory.create(activity=self.activity) response = self.client.get(self.url, user=self.activity.owner) self.assertEqual(response.status_code, status.HTTP_200_OK) data = response.json()['data'] export_url = data['attributes']['participants-export-url']['url'] export_response = self.client.get(export_url) - sheet = load_workbook(filename=BytesIO(export_response.content)).get_active_sheet() + workbook = load_workbook(filename=BytesIO(export_response.content)) + + self.assertEqual(len(workbook.worksheets), 2) + + sheet = workbook.worksheets[0] self.assertEqual( tuple(sheet.values)[0], ('Email', 'Name', 'Motivation', 'Registration Date', 'Status', 'Team', 'Team Captain') ) + teams_sheet = workbook.worksheets[1] + + self.assertEqual( + tuple(teams_sheet.values)[0], + ('Name', 'Owner', 'ID', 'Status', '# Accepted Participants', 'Start', 'duration') + ) + self.assertEqual( + tuple(teams_sheet.values)[1], + ( + other_team_captain.team.name, other_team_captain.user.full_name, + other_team_captain.team.pk, other_team_captain.team.status, + 1, None, None + ) + ) + self.assertEqual( + tuple(teams_sheet.values)[2], + ( + team_captain.team.name, team_captain.user.full_name, + team_captain.team.pk, team_captain.team.status, + team_captain.team.accepted_participants_count, + team_captain.team.slot.start.strftime('%d-%m-%y %H:%M'), + team_captain.team.slot.duration.seconds / (60 * 60), + ) + ) + wrong_signature_response = self.client.get(export_url + '111') self.assertEqual( wrong_signature_response.status_code, 404 ) +class TeamSlotAPIViewTestCase(APITestCase): + + def setUp(self): + super().setUp() + self.manager = BlueBottleUserFactory.create() + self.activity = PeriodActivityFactory.create( + team_activity='teams', + owner=self.manager + ) + self.team_captain = PeriodParticipantFactory.create(activity=self.activity) + self.team = self.team_captain.team + + PeriodParticipantFactory.create_batch( + 3, activity=self.activity, team=self.team + ) + self.activity_url = reverse('period-detail', args=(self.activity.pk,)) + + self.url = reverse('team-slot-list') + self.serializer = TeamSlotSerializer + self.factory = TeamSlotFactory + + self.defaults = { + 'activity': self.activity, + 'team': self.team, + 'start': (now() + timedelta(days=2)).replace(hour=11, minute=0, second=0, microsecond=0), + 'duration': '2:00:00', + 'location': None, + 'is_online': True, + 'location_hint': None + } + + self.fields = [ + 'activity', + 'team', + 'start', + 'duration', + 'location', + 'is_online', + 'location_hint' + ] + + def test_create_team_slot(self): + self.perform_create(user=self.manager) + self.assertStatus(status.HTTP_201_CREATED) + + ical_response = self.client.get(self.response.json()['links']['ical']) + + self.assertEqual(ical_response.status_code, status.HTTP_200_OK) + + def test_create_team_slot_missing_start(self): + self.defaults['start'] = None + self.perform_create(user=self.manager) + self.assertStatus(status.HTTP_400_BAD_REQUEST) + self.assertEqual( + self.response.json()['errors'][0]['source']['pointer'], + '/data/attributes/start' + ) + + def test_create_team_slot_missing_duration(self): + self.defaults['duration'] = None + self.perform_create(user=self.manager) + self.assertStatus(status.HTTP_400_BAD_REQUEST) + self.assertEqual( + self.response.json()['errors'][0]['source']['pointer'], + '/data/attributes/duration' + ) + + def test_update_team_slot(self): + self.perform_create(user=self.manager) + self.assertStatus(status.HTTP_201_CREATED) + self.url = reverse('team-slot-detail', args=(self.model.id,)) + location = GeolocationFactory.create() + to_change = { + 'is_online': False, + 'location_hint': 'Ring top bell', + 'location': location + } + self.perform_update(to_change=to_change, user=self.manager) + self.assertEqual(self.model.location_hint, 'Ring top bell') + self.assertEqual(self.model.location, location) + + class TimeBasedTransitionAPIViewTestCase(): def setUp(self): super().setUp() @@ -1168,6 +1306,26 @@ class PeriodTransitionAPIViewTestCase(TimeBasedTransitionAPIViewTestCase, Bluebo factory = PeriodActivityFactory participant_factory = PeriodParticipantFactory + def test_succeed_manually(self): + self.activity.start = None + self.activity.deadline = None + self.activity.initiative.states.submit() + self.activity.initiative.states.approve(save=True) + self.activity.states.submit(save=True) + PeriodParticipantFactory.create(activity=self.activity) + + self.data['data']['attributes']['transition'] = 'succeed_manually' + + response = self.client.post( + self.url, + json.dumps(self.data), + user=self.activity.owner + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.activity.refresh_from_db() + self.assertEqual(self.activity.status, 'succeeded') + self.assertIsNotNone(self.activity.deadline) + class DateActivitySlotListAPITestCase(BluebottleTestCase): def setUp(self): @@ -2013,15 +2171,176 @@ class PeriodParticipantTransitionAPIViewTestCase(ParticipantTransitionAPIViewTes factory = PeriodActivityFactory participant_factory = PeriodParticipantFactory + def test_accept_by_owner(self): + self.participant.status = 'new' + self.participant.save() + self.activity.review = True + self.activity.save() + self.data['data']['attributes']['transition'] = 'accept' + mail.outbox = [] + + response = self.client.post( + self.url, + json.dumps(self.data), + user=self.activity.owner + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + data = json.loads(response.content) + self.assertEqual(data['included'][0]['attributes']['status'], 'accepted') + message = mail.outbox[0] + self.assertEqual( + message.subject, + f'You have been selected for the activity "{self.activity.title}" 🎉' + ) + + def test_accept_with_custom_message(self): + self.participant.status = 'new' + self.participant.save() + self.activity.review = True + self.activity.save() + self.data['data']['attributes']['transition'] = 'accept' + self.data['data']['attributes']['message'] = 'Great to have you!' + mail.outbox = [] + response = self.client.post( + self.url, + json.dumps(self.data), + user=self.activity.owner + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + data = json.loads(response.content) + self.assertEqual(data['included'][0]['attributes']['status'], 'accepted') + message = mail.outbox[0] + self.assertEqual( + message.subject, + f'You have been selected for the activity "{self.activity.title}" 🎉' + ) + self.assertTrue('Great to have you!' in message.body) + + def test_accept_team_with_custom_message(self): + self.activity.team_activity = 'teams' + self.activity.review = True + self.activity.save() + self.participant.team = TeamFactory.create( + activity=self.activity, + owner=self.participant.user + ) + self.participant.status = 'new' + self.participant.save() + self.data['data']['attributes']['transition'] = 'accept' + self.data['data']['attributes']['message'] = 'Great to have you!' + mail.outbox = [] + response = self.client.post( + self.url, + json.dumps(self.data), + user=self.activity.owner + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + data = json.loads(response.content) + self.assertEqual(data['included'][1]['attributes']['status'], 'accepted') + message = mail.outbox[0] + self.assertEqual( + message.subject, + f'Your team has been accepted for "{self.activity.title}"' + ) + self.assertTrue('Great to have you!' in message.body) + + def test_reject_by_owner(self): + self.participant.status = 'new' + self.participant.save() + self.activity.review = True + self.activity.save() + self.data['data']['attributes']['transition'] = 'reject' + mail.outbox = [] + + response = self.client.post( + self.url, + json.dumps(self.data), + user=self.activity.owner + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + data = json.loads(response.content) + self.assertEqual(data['included'][0]['attributes']['status'], 'rejected') + message = mail.outbox[0] + self.assertEqual( + message.subject, + f'You have not been selected for the activity "{self.activity.title}"' + ) + + def test_reject_with_custom_message(self): + self.participant.status = 'new' + self.participant.save() + self.activity.review = True + self.activity.save() + self.data['data']['attributes']['transition'] = 'reject' + self.data['data']['attributes']['message'] = 'Go away!' + mail.outbox = [] + response = self.client.post( + self.url, + json.dumps(self.data), + user=self.activity.owner + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + data = json.loads(response.content) + self.assertEqual(data['included'][0]['attributes']['status'], 'rejected') + message = mail.outbox[0] + self.assertEqual( + message.subject, + f'You have not been selected for the activity "{self.activity.title}"' + ) + self.assertTrue('Go away!' in message.body) + + def test_reject_team_with_custom_message(self): + self.activity.team_activity = 'teams' + self.activity.review = True + self.activity.save() + self.participant.team = TeamFactory.create( + activity=self.activity, + owner=self.participant.user + ) + self.participant.status = 'new' + self.participant.save() + self.data['data']['attributes']['transition'] = 'reject' + self.data['data']['attributes']['message'] = 'Go away!' + mail.outbox = [] + response = self.client.post( + self.url, + json.dumps(self.data), + user=self.activity.owner + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + data = json.loads(response.content) + self.assertEqual(data['included'][1]['attributes']['status'], 'rejected') + message = mail.outbox[0] + self.assertEqual( + message.subject, + f'Your team has been rejected for "{self.activity.title}"' + ) + self.assertTrue('Go away!' in message.body) + class ReviewParticipantTransitionAPIViewTestCase(): def setUp(self): super().setUp() self.client = JSONAPITestClient() self.user = BlueBottleUserFactory() + self.another_user = BlueBottleUserFactory() self.activity = self.factory.create(review=True) self.participant = self.participant_factory.create( - activity=self.activity + activity=self.activity, + user=self.user, + as_user=self.user ) self.url = reverse(self.url_name) @@ -2047,7 +2366,7 @@ def test_withdraw_by_user(self): response = self.client.post( self.url, json.dumps(self.data), - user=self.participant.user + user=self.user ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) @@ -2066,7 +2385,7 @@ def test_withdraw_by_other_user(self): response = self.client.post( self.url, json.dumps(self.data), - user=self.user + user=self.another_user ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -2290,11 +2609,21 @@ class RelatedPeriodParticipantAPIViewTestCase(RelatedParticipantsAPIViewTestCase participant_factory = PeriodParticipantFactory def test_get_owner(self): + self.participants[2].team = TeamFactory.create(activity=self.activity) + self.participants[2].save() + TeamSlotFactory.create(team=self.participants[2].team, activity=self.activity) + super().test_get_owner() included_contributions = self.included_by_type(self.response, 'contributions/time-contributions') self.assertEqual(len(included_contributions), 8) + included_teams = self.included_by_type(self.response, 'activities/teams') + self.assertEqual(len(included_teams), 1) + + included_team_slots = self.included_by_type(self.response, 'activities/time-based/team-slots') + self.assertEqual(len(included_team_slots), 1) + class SlotParticipantListAPIViewTestCase(BluebottleTestCase): def setUp(self): diff --git a/bluebottle/time_based/tests/test_notifications.py b/bluebottle/time_based/tests/test_notifications.py index e53331dfaf..7c35874bf4 100644 --- a/bluebottle/time_based/tests/test_notifications.py +++ b/bluebottle/time_based/tests/test_notifications.py @@ -1,3 +1,7 @@ +from datetime import timedelta + +from django.utils.timezone import now + from bluebottle.activities.messages import ActivityRejectedNotification, ActivityCancelledNotification, \ ActivitySucceededNotification, ActivityRestoredNotification, ActivityExpiredNotification from bluebottle.activities.tests.factories import TeamFactory @@ -7,10 +11,11 @@ ParticipantRemovedNotification, TeamParticipantRemovedNotification, ParticipantFinishedNotification, ParticipantWithdrewNotification, NewParticipantNotification, ParticipantAddedOwnerNotification, ParticipantRemovedOwnerNotification, ParticipantJoinedNotification, ParticipantAppliedNotification, - SlotCancelledNotification, ParticipantAddedNotification, TeamParticipantAddedNotification + SlotCancelledNotification, ParticipantAddedNotification, TeamParticipantAddedNotification, + TeamSlotChangedNotification, TeamParticipantJoinedNotification ) from bluebottle.time_based.tests.factories import DateActivityFactory, DateParticipantFactory, \ - DateActivitySlotFactory, PeriodActivityFactory, PeriodParticipantFactory + DateActivitySlotFactory, PeriodActivityFactory, PeriodParticipantFactory, TeamSlotFactory class DateActivityNotificationTestCase(NotificationTestCase): @@ -227,6 +232,24 @@ def test_participant_joined_notification(self): 'Go to the activity page to see the times in your own timezone and add them to your calendar.' ) + def test_team_joined_notification(self): + self.activity.team_activity = 'teams' + self.activity.save() + self.obj.team = TeamFactory.create() + self.obj.save() + self.message_class = TeamParticipantJoinedNotification + self.create() + self.assertRecipients([self.supporter]) + self.assertSubject('You have registered your team for "Save the world!"') + self.assertActionLink(self.activity.get_absolute_url()) + self.assertActionTitle('View activity') + self.assertBodyNotContains( + 'Go to the activity page to see the times in your own timezone and add them to your calendar.' + ) + self.assertBodyContains( + 'The activity manager will be in touch to confirm details' + ) + def test_new_participant_notification(self): self.message_class = ParticipantAppliedNotification self.create() @@ -257,3 +280,36 @@ def test_new_participant_notification(self): self.assertSubject('A slot for your activity "Save the world!" has been cancelled') self.assertActionLink(self.activity.get_absolute_url()) self.assertActionTitle('Open your activity') + + +class TeamSlotNotificationTestCase(NotificationTestCase): + + def setUp(self): + self.supporter = BlueBottleUserFactory.create( + first_name='Frans', + last_name='Beckenbauer' + ) + self.activity = PeriodActivityFactory.create( + title="Save the world!", + team_activity='teams' + ) + + self.participant = PeriodParticipantFactory.create( + activity=self.activity, + user=self.supporter + ) + + self.obj = TeamSlotFactory.create( + activity=self.activity, + team=self.participant.team, + start=(now() + timedelta(days=3)).replace(hour=20, minute=0, second=0, microsecond=0), + duration=timedelta(hours=1) + ) + + def test_slot_created(self): + self.message_class = TeamSlotChangedNotification + self.create() + self.assertRecipients([self.supporter]) + self.assertSubject('The details of the team activity "Save the world!" have changed') + self.assertActionLink(self.activity.get_absolute_url()) + self.assertActionTitle('View activity') diff --git a/bluebottle/time_based/tests/test_periodic_tasks.py b/bluebottle/time_based/tests/test_periodic_tasks.py index 7a81ac8897..4a4bf741fd 100644 --- a/bluebottle/time_based/tests/test_periodic_tasks.py +++ b/bluebottle/time_based/tests/test_periodic_tasks.py @@ -21,12 +21,12 @@ from bluebottle.test.utils import BluebottleTestCase from bluebottle.time_based.tasks import ( date_activity_tasks, with_a_deadline_tasks, - period_participant_tasks, time_contribution_tasks + period_participant_tasks, time_contribution_tasks, team_slot_tasks ) from bluebottle.time_based.tests.factories import ( DateActivityFactory, PeriodActivityFactory, DateParticipantFactory, PeriodParticipantFactory, DateActivitySlotFactory, - SlotParticipantFactory + SlotParticipantFactory, TeamSlotFactory ) @@ -843,3 +843,86 @@ def test_contribution_value_is_succeeded(self): len(self.participant.contributions.filter(status='new')), 1 ) + + +class TeamSlotPeriodicTasksTest(BluebottleTestCase): + + def setUp(self): + self.activity = PeriodActivityFactory.create( + team_activity='teams', + status='open' + ) + self.participant = PeriodParticipantFactory.create( + activity=self.activity + ) + self.slot = TeamSlotFactory.create( + activity=self.activity, + team=self.participant.team, + start=datetime.combine((now() + timedelta(days=10)).date(), time(11, 30, tzinfo=UTC)), + duration=timedelta(hours=3) + ) + + def run_task(self, when): + with mock.patch.object(timezone, 'now', return_value=when): + with mock.patch('bluebottle.time_based.periodic_tasks.date') as mock_date: + mock_date.today.return_value = when.date() + mock_date.side_effect = lambda *args, **kw: date(*args, **kw) + team_slot_tasks() + + @property + def nigh(self): + return timezone.get_current_timezone().localize( + datetime( + self.slot.start.year, + self.slot.start.month, + self.slot.start.day + ) - timedelta(days=4) + ) + + @property + def current(self): + return self.slot.start + timedelta(hours=1) + + @property + def after(self): + return self.slot.start + timedelta(days=2) + + @property + def before(self): + return timezone.get_current_timezone().localize( + datetime( + self.activity.registration_deadline.year, + self.activity.registration_deadline.month, + self.activity.registration_deadline.day + ) - timedelta(days=1) + ) + + def assertStatus(self, obj, status): + obj.refresh_from_db() + return self.assertEqual(obj.status, status) + + def test_reminder_team_slot(self): + mail.outbox = [] + self.run_task(self.nigh) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual( + mail.outbox[0].subject, + 'The team activity "{}" will take place in a few days!'.format(self.activity.title) + ) + self.assertTrue('The team activity is just a few days away!' in mail.outbox[0].body) + + mail.outbox = [] + self.run_task(self.nigh) + self.assertEqual(len(mail.outbox), 0, "Reminder mail should not be send again.") + + def test_start_team_slot(self): + mail.outbox = [] + self.run_task(self.current) + self.assertStatus(self.slot, 'running') + self.assertStatus(self.slot.team, 'running') + + def test_finish_team_slot(self): + mail.outbox = [] + self.run_task(self.after) + self.assertStatus(self.slot, 'finished') + self.assertStatus(self.slot.team, 'finished') diff --git a/bluebottle/time_based/tests/test_triggers.py b/bluebottle/time_based/tests/test_triggers.py index 17f8aad08e..5f21af134b 100644 --- a/bluebottle/time_based/tests/test_triggers.py +++ b/bluebottle/time_based/tests/test_triggers.py @@ -7,7 +7,10 @@ from django.utils.timezone import now, get_current_timezone from tenant_extras.utils import TenantLanguage -from bluebottle.activities.messages import TeamMemberRemovedMessage +from bluebottle.activities.messages import ParticipantWithdrewConfirmationNotification, \ + TeamMemberWithdrewMessage +from bluebottle.activities.messages import TeamMemberRemovedMessage, TeamCancelledTeamCaptainMessage, \ + TeamCancelledMessage from bluebottle.activities.models import Organizer, Activity from bluebottle.activities.tests.factories import TeamFactory from bluebottle.initiatives.tests.factories import InitiativeFactory, InitiativePlatformSettingsFactory @@ -17,12 +20,13 @@ ParticipantJoinedNotification, ParticipantChangedNotification, ParticipantAppliedNotification, ParticipantRemovedNotification, ParticipantRemovedOwnerNotification, NewParticipantNotification, TeamParticipantJoinedNotification, ParticipantAddedNotification, - ParticipantAddedOwnerNotification + ParticipantRejectedNotification, ParticipantAddedOwnerNotification, TeamSlotChangedNotification, + ParticipantWithdrewNotification ) from bluebottle.time_based.tests.factories import ( DateActivityFactory, PeriodActivityFactory, DateParticipantFactory, PeriodParticipantFactory, - DateActivitySlotFactory, SlotParticipantFactory + DateActivitySlotFactory, SlotParticipantFactory, TeamSlotFactory ) @@ -552,7 +556,7 @@ def test_succeed_manually(self): self.activity.refresh_from_db() self.activity.states.succeed_manually(save=True) - self.assertEqual(self.activity.deadline, date.today()) + self.assertEqual(self.activity.deadline, date.today() - timedelta(days=1)) for duration in self.activity.durations: self.assertEqual(duration.status, 'succeeded') @@ -583,7 +587,7 @@ def test_succeed_manually_review_new(self): mail.outbox = [] self.activity.states.succeed_manually(save=True) - self.assertEqual(self.activity.deadline, date.today()) + self.assertEqual(self.activity.deadline, date.today() - timedelta(days=1)) for duration in self.activity.durations: self.assertEqual(duration.status, 'succeeded') @@ -918,13 +922,11 @@ def setUp(self): def test_initial_added_through_admin(self): mail.outbox = [] - participant = self.participant_factory.build( + participant = self.participant_factory.create( activity=self.review_activity, - user=BlueBottleUserFactory.create() + user=BlueBottleUserFactory.create(), + as_user=self.admin_user ) - participant.execute_triggers(user=self.admin_user, send_messages=True) - participant.save() - self.assertEqual(participant.status, 'accepted') self.assertEqual(len(mail.outbox), 2) @@ -952,14 +954,15 @@ def test_initial_added_through_admin_team(self): self.review_activity.team_activity = Activity.TeamActivityChoices.teams self.review_activity.save() - participant = self.participant_factory.build( + participant = self.participant_factory.create( activity=self.review_activity, - user=BlueBottleUserFactory.create() + user=BlueBottleUserFactory.create(), + as_user=self.admin_user ) - participant.execute_triggers(user=self.admin_user, send_messages=True) - participant.save() self.assertTrue(participant.team) self.assertEqual(participant.team.owner, participant.user) + self.assertEqual(participant.status, 'accepted') + self.assertEqual(participant.team.status, 'open') def test_initiate_team_invite(self): self.activity.team_activity = Activity.TeamActivityChoices.teams @@ -984,10 +987,13 @@ def test_initiate_team_invite_review(self): self.activity.review = True self.activity.save() + capt = BlueBottleUserFactory.create() team_captain = self.participant_factory.create( activity=self.activity, - user=BlueBottleUserFactory.create() + user=capt, + as_user=capt ) + team_captain.states.accept(save=True) mail.outbox = [] @@ -997,7 +1003,6 @@ def test_initiate_team_invite_review(self): user=BlueBottleUserFactory.create() ) self.assertEqual(participant.team, team_captain.team) - 'New team member' in [message.subject for message in mail.outbox] self.assertEqual(participant.status, 'accepted') def test_initiate_team_invite_review_after_signup(self): @@ -1005,34 +1010,39 @@ def test_initiate_team_invite_review_after_signup(self): self.activity.review = True self.activity.save() + capt = BlueBottleUserFactory.create() + team_captain = self.participant_factory.create( activity=self.activity, - user=BlueBottleUserFactory.create() + user=capt, + as_user=capt ) mail.outbox = [] + user = BlueBottleUserFactory.create() participant = self.participant_factory.create( activity=self.activity, accepted_invite=team_captain.invite, - user=BlueBottleUserFactory.create() + user=user, + as_user=user ) - self.assertEqual(participant.team, team_captain.team) - 'New team member' in [message.subject for message in mail.outbox] - self.assertEqual(participant.status, 'new') + self.assertEqual(participant.team, team_captain.team) team_captain.states.accept(save=True) + + self.assertEqual(team_captain.status, 'accepted') + self.assertEqual(team_captain.team.status, 'open') participant.refresh_from_db() self.assertEqual(participant.status, 'accepted') def test_initial_removed_through_admin(self): mail.outbox = [] - participant = self.participant_factory.build( + participant = self.participant_factory.create( activity=self.review_activity, - user=BlueBottleUserFactory.create() + user=BlueBottleUserFactory.create(), + as_user=self.admin_user ) - participant.execute_triggers(user=self.admin_user, send_messages=True) - participant.save() mail.outbox = [] participant.states.remove() participant.execute_triggers(user=self.admin_user, send_messages=True) @@ -1052,8 +1062,11 @@ def test_initial_removed_through_admin(self): ) def test_accept(self): + user = BlueBottleUserFactory.create() participant = self.participant_factory.create( - activity=self.review_activity + activity=self.review_activity, + user=user, + as_user=user ) mail.outbox = [] @@ -1081,8 +1094,11 @@ def test_accept_team(self): self.review_activity.team_activity = Activity.TeamActivityChoices.teams self.review_activity.save() + user = BlueBottleUserFactory.create() participant = self.participant_factory.create( - activity=self.review_activity + activity=self.review_activity, + user=user, + as_user=user ) participant.states.accept(save=True) @@ -1091,12 +1107,12 @@ def test_accept_team(self): def test_initial_review(self): mail.outbox = [] - participant = self.participant_factory.build( + user = BlueBottleUserFactory.create() + participant = self.participant_factory.create( activity=self.review_activity, - user=BlueBottleUserFactory.create() + user=user, + as_user=user ) - participant.execute_triggers(user=participant.user, send_messages=True) - participant.save() self.assertEqual(participant.status, 'new') self.assertEqual(len(mail.outbox), 2) @@ -1133,12 +1149,12 @@ def test_initial_team_created(self): def test_initial_no_review(self): mail.outbox = [] - participant = self.participant_factory.build( + user = BlueBottleUserFactory.create() + participant = self.participant_factory.create( activity=self.activity, - user=BlueBottleUserFactory.create() + user=user, + as_user=user ) - participant.execute_triggers(user=participant.user, send_messages=True) - participant.save() self.assertEqual(participant.status, 'accepted') self.assertEqual(len(mail.outbox), 2) @@ -1165,15 +1181,13 @@ def test_initial_no_review(self): def test_initial_no_review_team(self): self.activity.team_activity = Activity.TeamActivityChoices.teams self.activity.save() - - participant = self.participant_factory.build( + user = BlueBottleUserFactory.create() + participant = self.participant_factory.create( activity=self.activity, - user=BlueBottleUserFactory.create() + user=user, + as_user=user ) - participant.execute_triggers(user=participant.user, send_messages=True) - participant.save() - self.assertTrue(participant.team) self.assertEqual(participant.team.owner, participant.user) @@ -1182,7 +1196,6 @@ def test_no_review_fill(self): self.activity.capacity, activity=self.activity ) self.activity.refresh_from_db() - self.assertEqual(self.activity.status, 'full') def test_no_review_fill_cancel(self): @@ -1197,16 +1210,20 @@ def test_no_review_fill_cancel(self): self.assertEqual(self.activity.status, 'cancelled') def test_review_fill(self): - participants = self.participant_factory.build_batch( - self.review_activity.capacity, activity=self.review_activity + participants = self.participant_factory.create_batch( + self.review_activity.capacity, + activity=self.review_activity, + user=BlueBottleUserFactory.create(), + as_relation='user' ) self.review_activity.refresh_from_db() self.assertEqual(self.activity.status, 'open') for participant in participants: - participant.user.save() - participant.execute_triggers(user=participant.user, send_messages=True) + user = participant.user + user.save() + participant.execute_triggers(user=user, send_messages=True) participant.save() participant.states.accept(save=True) @@ -1294,9 +1311,19 @@ def test_remove_team(self): ) def test_reject(self): - self.participants = self.participant_factory.create_batch( - self.activity.capacity, activity=self.review_activity - ) + users = BlueBottleUserFactory.create_batch(self.activity.capacity) + self.participants = [] + for user in users: + participant = self.participant_factory.build( + user=user, + activity=self.review_activity, + ) + participant.execute_triggers(user=user) + participant.save() + + self.participants.append( + participant + ) mail.outbox = [] participant = self.participants[0] @@ -1334,7 +1361,9 @@ def test_reaccept(self): def test_withdraw(self): self.participants = self.participant_factory.create_batch( - self.activity.capacity, activity=self.activity + self.activity.capacity, + activity=self.activity, + user=BlueBottleUserFactory.create() ) self.activity.refresh_from_db() @@ -1371,12 +1400,12 @@ def test_withdraw_team(self): user=BlueBottleUserFactory.create() ) - mail.outbox = [] participant = self.participant_factory.create( activity=self.activity, accepted_invite=team_captain.invite, user=BlueBottleUserFactory.create() ) + mail.outbox = [] participant.states.withdraw(save=True) self.activity.refresh_from_db() @@ -1391,11 +1420,7 @@ def test_withdraw_team(self): f'You have withdrawn from the activity "{self.activity.title}"' in subjects ) self.assertTrue( - f'A participant has withdrawn from your activity "{self.activity.title}"' in subjects - ) - - self.assertTrue( - f"Withdrawal for '{self.activity.title}'" in subjects + f'A participant has withdrawn from your team for "{self.activity.title}"' in subjects ) def test_reapply(self): @@ -1413,28 +1438,61 @@ def test_reapply(self): ) self.assertTrue(self.activity.followers.filter(user=self.participants[0].user).exists()) - def test_reapply_cancelled_team(self): - self.activity.team_activity = Activity.TeamActivityChoices.teams - self.test_withdraw() - self.participants[0].team.states.cancel(save=True) - - self.assertEqual( - self.participants[0].contributions. - exclude(timecontribution__contribution_type='preparation').get().status, - 'failed' + def test_reapply_cancelled(self): + self.participants = self.participant_factory.create_batch( + self.activity.capacity, + activity=self.activity, + user=BlueBottleUserFactory.create() ) + self.activity.refresh_from_db() - self.participants[0].states.reapply(save=True) + self.assertEqual(self.activity.status, 'full') + mail.outbox = [] + + self.participants[0].states.withdraw(save=True) self.activity.refresh_from_db() + self.assertEqual(self.activity.status, 'open') - self.assertEqual(self.activity.status, 'full') self.assertEqual( self.participants[0].contributions. exclude(timecontribution__contribution_type='preparation').get().status, 'failed' ) - self.assertTrue(self.activity.followers.filter(user=self.participants[0].user).exists()) + + self.assertFalse(self.activity.followers.filter(user=self.participants[0].user).exists()) + + subjects = [mail.subject for mail in mail.outbox] + self.assertTrue( + f'You have withdrawn from the activity "{self.activity.title}"' in subjects + ) + self.assertTrue( + f'A participant has withdrawn from your activity "{self.activity.title}"' in subjects + ) + + def test_withdraw_from_team(self): + self.activity.team_activity = Activity.TeamActivityChoices.teams + self.captain = self.participant_factory.create( + activity=self.activity, + user=BlueBottleUserFactory.create() + ) + self.participant = self.participant_factory.create( + activity=self.activity, + user=BlueBottleUserFactory.create(), + team=self.captain.team + ) + + mail.outbox = [] + + self.participant.states.withdraw(save=True) + + subjects = [mail.subject for mail in mail.outbox] + self.assertTrue( + f'You have withdrawn from the activity "{self.activity.title}"' in subjects + ) + self.assertTrue( + f'A participant has withdrawn from your team for "{self.activity.title}"' in subjects + ) class DateParticipantTriggerTestCase(ParticipantTriggerTestCase, BluebottleTestCase): @@ -1499,12 +1557,11 @@ def test_join_all(self): self.activity.save() user = BlueBottleUserFactory.create() - participant = self.participant_factory.build( + self.participant_factory.create( activity=self.activity, - user=user + user=user, + as_user=user ) - participant.execute_triggers(user=user, send_messages=True) - participant.save() time.sleep(4) @@ -1531,12 +1588,11 @@ def test_join_free(self): mail.outbox = [] user = BlueBottleUserFactory.create() - participant = self.participant_factory.build( + participant = self.participant_factory.create( activity=self.activity, - user=user + user=user, + as_user=user ) - participant.execute_triggers(user=user, send_messages=True) - participant.save() self.slot_participants = [ SlotParticipantFactory.create(slot=slot, participant=participant) @@ -1563,12 +1619,11 @@ def test_join_free_review(self): self.activity.save() mail.outbox = [] user = BlueBottleUserFactory.create() - participant = self.participant_factory.build( + participant = self.participant_factory.create( activity=self.activity, - user=user + user=user, + as_user=user ) - participant.execute_triggers(user=user, send_messages=True) - participant.save() self.slot_participants = [ SlotParticipantFactory.create(slot=slot, participant=participant) @@ -1674,7 +1729,9 @@ def test_initial_added_with_team_through_admin(self): def test_join(self): mail.outbox = [] participant = self.participant_factory.create( - activity=self.activity + activity=self.activity, + user=BlueBottleUserFactory.create(), + as_relation='user' ) self.assertEqual(len(mail.outbox), 2) self.assertEqual( @@ -1699,8 +1756,11 @@ def test_team_join(self): self.activity.team_activity = Activity.TeamActivityChoices.teams self.activity.save() mail.outbox = [] + user = BlueBottleUserFactory.create() participant = self.participant_factory.create( - activity=self.activity + activity=self.activity, + user=user, + as_user=user ) self.assertEqual(len(mail.outbox), 2) self.assertEqual( @@ -1724,7 +1784,9 @@ def test_team_join(self): def test_apply(self): mail.outbox = [] participant = self.participant_factory.create( - activity=self.review_activity + activity=self.review_activity, + user=BlueBottleUserFactory.create(), + as_relation='user' ) self.assertEqual(len(mail.outbox), 2) self.assertEqual( @@ -1750,7 +1812,9 @@ def test_team_apply(self): self.review_activity.save() mail.outbox = [] participant = self.participant_factory.create( - activity=self.review_activity + activity=self.review_activity, + user=BlueBottleUserFactory.create(), + as_relation='user' ) self.assertEqual(len(mail.outbox), 2) self.assertEqual( @@ -1776,12 +1840,13 @@ def test_team_accept(self): self.review_activity.save() participant = self.participant_factory.create( - activity=self.review_activity + activity=self.review_activity, + user=BlueBottleUserFactory.create(), + as_relation='user' ) mail.outbox = [] participant.states.accept(save=True) - self.assertEqual(participant.status, 'accepted') self.assertEqual(len(mail.outbox), 1) self.assertEqual( @@ -1898,6 +1963,24 @@ def test_remove_participant(self): self.assertNotificationEffect(ParticipantRemovedNotification) self.assertNotificationEffect(ParticipantRemovedOwnerNotification) + def test_withdraw_team_participant(self): + self.activity.team_activity = 'teams' + captain = BlueBottleUserFactory.create() + team = TeamFactory.create( + owner=captain, + activity=self.activity + ) + self.model = self.participant_factory.create( + activity=self.activity, + team=team, + status='accepted' + ) + self.model.states.withdraw() + with self.execute(): + self.assertNoNotificationEffect(ParticipantWithdrewNotification) + self.assertNotificationEffect(TeamMemberWithdrewMessage) + self.assertNotificationEffect(ParticipantWithdrewConfirmationNotification) + def test_remove_team_participant(self): self.activity.team_activity = 'teams' self.activity.save() @@ -2214,3 +2297,102 @@ def test_refill_slot_remove(self): self.test_unfill_slot_remove() self.slot_part.states.accept(save=True) self.assertStatus(self.slot2, 'full') + + +class TeamSlotTriggerTestCase(TriggerTestCase): + + def setUp(self): + super().setUp() + self.user = BlueBottleUserFactory() + self.initiative = InitiativeFactory(owner=self.user) + + self.activity = PeriodActivityFactory.create( + initiative=self.initiative, + team_activity='teams', + status='approved', + review=False) + self.participant = PeriodParticipantFactory.create( + user=self.user, + activity=self.activity + ) + + def assertStatus(self, obj, status): + obj.refresh_from_db() + self.assertEqual(obj.status, status) + + def test_set_date(self): + self.assertTrue(self.participant.team) + start = now() + timedelta(days=4) + self.model = TeamSlotFactory.build( + team=self.participant.team, + activity=self.activity, + start=start, + duration=timedelta(hours=2) + ) + with self.execute(): + self.assertNotificationEffect(TeamSlotChangedNotification) + self.assertEqual(self.model.status, 'open') + + self.model.start = now() + timedelta(days=1) + with self.execute(): + self.assertNotificationEffect(TeamSlotChangedNotification) + self.assertEqual(self.model.status, 'open') + + def test_change_date(self): + self.assertTrue(self.participant.team) + start = now() + timedelta(days=4) + self.model = TeamSlotFactory.build( + team=self.participant.team, + activity=self.activity, + start=start, + duration=timedelta(hours=2) + ) + self.model.start = now() - timedelta(days=1) + with self.execute(): + self.assertNoNotificationEffect(TeamSlotChangedNotification) + self.assertEqual(self.model.status, 'finished') + self.assertEqual(self.model.team.status, 'finished') + + self.model.start = now() + timedelta(days=3) + with self.execute(): + self.assertNotificationEffect(TeamSlotChangedNotification) + self.assertEqual(self.model.status, 'open') + self.assertEqual(self.model.team.status, 'open') + + +class TeamReviewTriggerTestCase(TriggerTestCase): + + def setUp(self): + super().setUp() + self.initiator = BlueBottleUserFactory() + self.user = BlueBottleUserFactory() + self.initiative = InitiativeFactory(owner=self.initiator) + + self.activity = PeriodActivityFactory.create( + initiative=self.initiative, + team_activity='teams', + status='approved', + review=True + ) + self.model = PeriodParticipantFactory.create( + user=self.user, + activity=self.activity, + as_relation='user' + ) + + def assertStatus(self, obj, status): + obj.refresh_from_db() + self.assertEqual(obj.status, status) + + def test_reject(self): + self.assertTrue(self.model.team) + self.assertEqual( + self.model.team.owner, + self.user + ) + self.model.states.reject() + + with self.execute(): + self.assertNoNotificationEffect(ParticipantRejectedNotification) + self.assertNoNotificationEffect(TeamCancelledMessage) + self.assertNotificationEffect(TeamCancelledTeamCaptainMessage) diff --git a/bluebottle/time_based/triggers.py b/bluebottle/time_based/triggers.py index e1137ce175..3ccbae58a5 100644 --- a/bluebottle/time_based/triggers.py +++ b/bluebottle/time_based/triggers.py @@ -8,7 +8,7 @@ ActivityExpiredNotification, ActivityRejectedNotification, ActivityCancelledNotification, ActivityRestoredNotification, ParticipantWithdrewConfirmationNotification, - TeamMemberAddedMessage, TeamMemberWithdrewMessage, TeamMemberRemovedMessage + TeamMemberWithdrewMessage, TeamMemberRemovedMessage, TeamCaptainAcceptedMessage, TeamCancelledTeamCaptainMessage ) from bluebottle.activities.states import OrganizerStateMachine, TeamStateMachine from bluebottle.activities.triggers import ( @@ -43,17 +43,18 @@ ParticipantWithdrewNotification, ParticipantAddedOwnerNotification, TeamParticipantAddedNotification, ParticipantRemovedOwnerNotification, ParticipantJoinedNotification, TeamParticipantJoinedNotification, - ParticipantAppliedNotification, TeamParticipantAppliedNotification, SlotCancelledNotification + ParticipantAppliedNotification, TeamParticipantAppliedNotification, SlotCancelledNotification, + TeamSlotChangedNotification ) from bluebottle.time_based.models import ( DateActivity, PeriodActivity, DateParticipant, PeriodParticipant, TimeContribution, DateActivitySlot, - PeriodActivitySlot, SlotParticipant + PeriodActivitySlot, SlotParticipant, TeamSlot ) from bluebottle.time_based.states import ( TimeBasedStateMachine, DateStateMachine, PeriodStateMachine, ActivitySlotStateMachine, ParticipantStateMachine, TimeContributionStateMachine, SlotParticipantStateMachine, - PeriodParticipantStateMachine + PeriodParticipantStateMachine, TeamSlotStateMachine ) @@ -61,6 +62,13 @@ def is_full(effect): """ the activity is full """ + if getattr(effect.instance, 'team_activity', None) == 'teams': + accepted_teams = effect.instance.teams.filter(status__in=['open', 'running', 'finished']).count() + return ( + effect.instance.capacity and + effect.instance.capacity <= accepted_teams + ) + if ( isinstance(effect.instance, DateActivity) and effect.instance.slots.count() > 1 and @@ -85,8 +93,15 @@ def is_not_full(effect): """ the activity is not full """ + if getattr(effect.instance, 'team_activity', None) == 'teams': + accepted_teams = effect.instance.teams.filter(status__in=['open', 'running', 'finished']).count() + return ( + not effect.instance.capacity or + effect.instance.capacity > accepted_teams + ) + return ( - effect.instance.capacity and + not effect.instance.capacity or effect.instance.capacity > len(effect.instance.accepted_participants) ) @@ -189,14 +204,20 @@ class TimeBasedTriggers(ActivityTriggers): ModelChangedTrigger( 'capacity', effects=[ - TransitionEffect(TimeBasedStateMachine.reopen, conditions=[ - is_not_full, - registration_deadline_is_not_passed - ]), - TransitionEffect(TimeBasedStateMachine.lock, conditions=[ - is_full, - registration_deadline_is_not_passed - ]), + TransitionEffect( + TimeBasedStateMachine.reopen, + conditions=[ + is_not_full, + registration_deadline_is_not_passed + ] + ), + TransitionEffect( + TimeBasedStateMachine.lock, + conditions=[ + is_full, + registration_deadline_is_not_passed + ] + ), ] ), @@ -721,6 +742,83 @@ class DateActivitySlotTriggers(ActivitySlotTriggers): ] +def has_future_date(effect): + """ + team slot has a date set + """ + return effect.instance.start and effect.instance.start > now() + + +@register(TeamSlot) +class TeamSlotTriggers(TriggerManager): + triggers = [ + TransitionTrigger( + TeamSlotStateMachine.initiate, + effects=[ + NotificationEffect( + TeamSlotChangedNotification, + conditions=[has_future_date] + ) + ] + ), + TransitionTrigger( + TeamSlotStateMachine.start, + effects=[ + RelatedTransitionEffect( + 'team', + TeamStateMachine.start + ) + ] + ), + TransitionTrigger( + TeamSlotStateMachine.finish, + effects=[ + RelatedTransitionEffect( + 'team', + TeamStateMachine.finish + ) + ] + ), + TransitionTrigger( + TeamSlotStateMachine.reschedule, + effects=[ + RelatedTransitionEffect( + 'team', + TeamStateMachine.reopen + ) + ] + ), + ModelChangedTrigger( + 'start', + effects=[ + NotificationEffect( + TeamSlotChangedNotification, + conditions=[has_future_date] + ), + TransitionEffect( + TeamSlotStateMachine.reschedule, + conditions=[ + slot_is_not_started + ] + ), + TransitionEffect( + TeamSlotStateMachine.finish, + conditions=[ + slot_is_finished + ] + ), + TransitionEffect( + TeamSlotStateMachine.start, + conditions=[ + slot_is_not_finished, + slot_is_started + ] + ), + ] + ), + ] + + @register(PeriodActivity) class PeriodActivityTriggers(TimeBasedTriggers): triggers = TimeBasedTriggers.triggers + [ @@ -862,6 +960,13 @@ def not_team_captain(effect): return not effect.instance.team_id or effect.instance.team.owner != effect.instance.user +def is_team_captain(effect): + """ + is the team captain + """ + return effect.instance.team_id and effect.instance.team.owner == effect.instance.user + + def user_is_not_team_captain(effect): """ current user is not team captain @@ -877,7 +982,7 @@ def is_not_user(effect): """ if 'user' in effect.options: return effect.instance.user != effect.options['user'] - return False + return True def is_user(effect): @@ -886,7 +991,7 @@ def is_user(effect): """ if 'user' in effect.options: return effect.instance.user == effect.options['user'] - return True + return False def is_owner(effect): @@ -912,6 +1017,12 @@ def activity_will_be_full(effect): the activity is full """ activity = effect.instance.activity + if activity.team_activity == 'teams': + accepted_teams = activity.teams.filter(status__in=['open', 'running', 'finished']).count() + return ( + activity.capacity and + activity.capacity <= accepted_teams + ) if ( isinstance(activity, DateActivity) and @@ -931,8 +1042,15 @@ def activity_will_not_be_full(effect): the activity is full """ activity = effect.instance.activity + if activity.team_activity == 'teams': + accepted_teams = activity.teams.filter(status__in=['open', 'running', 'finished']).count() + return ( + not activity.capacity or + activity.capacity > accepted_teams + ) + return ( - activity.capacity and + not activity.capacity or activity.capacity >= len(activity.accepted_participants) ) @@ -974,10 +1092,6 @@ def is_team_activity(effect): return effect.instance.activity.team_activity == 'teams' -def is_added_to_team(effect): - """Contributor is part of a team""" - - def team_is_open(effect): """Team status is open, or there is no team""" return ( @@ -1034,12 +1148,6 @@ class ParticipantTriggers(ContributorTriggers): is_user ] ), - NotificationEffect( - TeamMemberAddedMessage, - conditions=[ - is_added_to_team - ] - ), TransitionEffect( ParticipantStateMachine.add, conditions=[ @@ -1129,9 +1237,10 @@ class ParticipantTriggers(ContributorTriggers): RelatedTransitionEffect( 'activity', TimeBasedStateMachine.lock, - conditions=[activity_will_be_full] + conditions=[ + activity_will_be_full + ] ), - RelatedTransitionEffect( 'activity', TimeBasedStateMachine.succeed, @@ -1147,7 +1256,6 @@ class ParticipantTriggers(ContributorTriggers): 'finished_contributions', TimeContributionStateMachine.succeed, ), - RelatedTransitionEffect( 'preparation_contributions', TimeContributionStateMachine.succeed, @@ -1162,6 +1270,7 @@ class ParticipantTriggers(ContributorTriggers): TeamParticipantAddedNotification, conditions=[ is_team_activity, + not_team_captain, is_not_user, has_team ] @@ -1182,7 +1291,9 @@ class ParticipantTriggers(ContributorTriggers): RelatedTransitionEffect( 'team', TeamStateMachine.accept, - conditions=[has_team] + conditions=[ + has_team + ] ), NotificationEffect( ParticipantJoinedNotification, @@ -1202,13 +1313,22 @@ class ParticipantTriggers(ContributorTriggers): ParticipantAcceptedNotification, conditions=[ needs_review, - not_team_captain + is_not_team_activity + ] + ), + NotificationEffect( + TeamCaptainAcceptedMessage, + conditions=[ + needs_review, + is_team_captain ] ), RelatedTransitionEffect( 'activity', TimeBasedStateMachine.lock, - conditions=[activity_will_be_full] + conditions=[ + activity_will_be_full + ] ), RelatedTransitionEffect( 'activity', @@ -1237,7 +1357,24 @@ class ParticipantTriggers(ContributorTriggers): ParticipantStateMachine.reject, effects=[ NotificationEffect( - ParticipantRejectedNotification + ParticipantRejectedNotification, + conditions=[ + not_team_captain + ] + ), + NotificationEffect( + TeamCancelledTeamCaptainMessage, + conditions=[ + is_team_captain + ] + ), + + RelatedTransitionEffect( + 'team', + TeamStateMachine.cancel, + conditions=[ + is_team_captain + ] ), RelatedTransitionEffect( 'activity', @@ -1301,12 +1438,25 @@ class ParticipantTriggers(ContributorTriggers): TimeContributionStateMachine.fail, ), UnFollowActivityEffect, - NotificationEffect(ParticipantWithdrewNotification), - NotificationEffect(ParticipantWithdrewConfirmationNotification), - NotificationEffect(TeamMemberWithdrewMessage), + NotificationEffect( + ParticipantWithdrewNotification, + conditions=[ + is_not_team_activity + ] + ), + NotificationEffect( + ParticipantWithdrewConfirmationNotification + ), + NotificationEffect( + TeamMemberWithdrewMessage, + conditions=[ + is_team_activity, + not_team_captain + ] + + ), ] ), - ] diff --git a/bluebottle/time_based/urls/api.py b/bluebottle/time_based/urls/api.py index cfae2ec14f..7790f9008b 100644 --- a/bluebottle/time_based/urls/api.py +++ b/bluebottle/time_based/urls/api.py @@ -12,9 +12,10 @@ TimeContributionDetail, DateSlotDetailView, DateSlotListView, SlotParticipantListView, SlotParticipantDetailView, SlotParticipantTransitionList, - DateActivityIcalView, ActivitySlotIcalView, DateParticipantExportView, PeriodParticipantExportView, + DateActivityIcalView, ActivitySlotIcalView, TeamSlotIcalView, + DateParticipantExportView, PeriodParticipantExportView, SlotRelatedParticipantList, SkillList, SkillDetail, - RelatedSlotParticipantListView + RelatedSlotParticipantListView, TeamSlotListView, TeamSlotDetailView ) urlpatterns = [ @@ -54,6 +55,10 @@ ActivitySlotIcalView.as_view(), name='slot-ical'), + url(r'^/team/ical/(?P\d+)$', + TeamSlotIcalView.as_view(), + name='team-ical'), + url(r'^/period/(?P\d+)$', PeriodActivityDetailView.as_view(), name='period-detail'), @@ -62,6 +67,14 @@ PeriodActivityRelatedParticipantList.as_view(), name='period-participants'), + url(r'^/team/slots$', + TeamSlotListView.as_view(), + name='team-slot-list'), + + url(r'^/team/slots/(?P\d+)$', + TeamSlotDetailView.as_view(), + name='team-slot-detail'), + url(r'^/date/transitions$', DateTransitionList.as_view(), name='date-transition-list'), diff --git a/bluebottle/time_based/views.py b/bluebottle/time_based/views.py index 7307195de6..c3c89d07a7 100644 --- a/bluebottle/time_based/views.py +++ b/bluebottle/time_based/views.py @@ -24,7 +24,7 @@ DateActivity, PeriodActivity, DateParticipant, PeriodParticipant, TimeContribution, - DateActivitySlot, SlotParticipant, Skill + DateActivitySlot, SlotParticipant, Skill, TeamSlot ) from bluebottle.time_based.permissions import ( SlotParticipantPermission, DateSlotActivityStatusPermission @@ -42,7 +42,7 @@ TimeContributionSerializer, DateActivitySlotSerializer, SlotParticipantSerializer, - SlotParticipantTransitionSerializer, SkillSerializer + SlotParticipantTransitionSerializer, SkillSerializer, TeamSlotSerializer ) from bluebottle.transitions.views import TransitionList from bluebottle.utils.admin import prep_field @@ -171,7 +171,9 @@ def filter_queryset(self, queryset): start = self.request.GET.get('start') try: - queryset = queryset.filter(start__gte=dateutil.parser.parse(start).astimezone(tz)) + queryset = queryset.filter( + start__gte=dateutil.parser.parse(start).astimezone(tz) + ) except (ValueError, TypeError): pass @@ -203,6 +205,35 @@ class DateSlotDetailView(JsonApiViewMixin, RetrieveUpdateDestroyAPIView): serializer_class = DateActivitySlotSerializer +class TeamSlotListView(DateSlotListView): + related_permission_classes = { + 'team.activity': [ + ActivityStatusPermission, + OneOf(ResourcePermission, ActivityOwnerPermission), + DeleteActivityPermission + ] + } + + permission_classes = [TenantConditionalOpenClose] + queryset = TeamSlot.objects.all() + serializer_class = TeamSlotSerializer + + def perform_create(self, serializer): + self.check_object_permissions( + self.request, + serializer.Meta.model(**serializer.validated_data) + ) + if 'team' in serializer.validated_data: + serializer.save(activity=serializer.validated_data['team'].activity) + serializer.save() + + +class TeamSlotDetailView(DateSlotDetailView): + permission_classes = [TenantConditionalOpenClose] + queryset = TeamSlot.objects.all() + serializer_class = TeamSlotSerializer + + class DateActivityRelatedParticipantList(RelatedContributorListView): queryset = DateParticipant.objects.prefetch_related( 'user', 'slot_participants', 'slot_participants__slot' @@ -476,11 +507,7 @@ def get(self, *args, **kwargs): return response -class ActivitySlotIcalView(PrivateFileView): - queryset = DateActivitySlot.objects.exclude( - status__in=['cancelled', 'deleted', 'rejected'], - activity__status__in=['cancelled', 'deleted', 'rejected'], - ) +class BaseSlotIcalView(PrivateFileView): max_age = 30 * 60 # half an hour @@ -518,6 +545,20 @@ def get(self, *args, **kwargs): return response +class ActivitySlotIcalView(BaseSlotIcalView): + queryset = DateActivitySlot.objects.exclude( + status__in=['cancelled', 'deleted', 'rejected'], + activity__status__in=['cancelled', 'deleted', 'rejected'], + ) + + +class TeamSlotIcalView(BaseSlotIcalView): + queryset = TeamSlot.objects.exclude( + status__in=['cancelled', 'deleted', 'rejected'], + activity__status__in=['cancelled', 'deleted', 'rejected'], + ) + + class DateParticipantExportView(ExportView): filename = "participants" @@ -619,6 +660,29 @@ def get_instances(self): PeriodParticipant ).prefetch_related('user__segments') + def write_data(self, workbook): + """ Create extra tab with team info""" + super().write_data(workbook) + if self.get_object().team_activity == 'teams': + worksheet = workbook.add_worksheet('Teams') + + fields = [ + ('name', 'Name'), + ('owner__full_name', 'Owner'), + ('id', 'ID'), + ('status', 'Status'), + ('accepted_participants_count', '# Accepted Participants'), + ('slot__start', 'Start'), + ('slot__duration', 'duration'), + ] + + worksheet.write_row(0, 0, [field[1] for field in fields]) + + for index, team in enumerate(self.get_object().teams.all()): + row = [prep_field(self.request, team, field[0]) for field in fields] + + worksheet.write_row(index + 1, 0, row) + class SkillPagination(JsonApiPagination): page_size = 100 diff --git a/bluebottle/utils/admin.py b/bluebottle/utils/admin.py index ac640b606b..6a8112a58a 100644 --- a/bluebottle/utils/admin.py +++ b/bluebottle/utils/admin.py @@ -57,6 +57,9 @@ def prep_field(request, obj, field, manyToManySep=';'): if isinstance(attr, datetime.datetime): attr = attr.strftime('%d-%m-%y %H:%M') + if isinstance(attr, datetime.timedelta): + attr = attr.seconds / (60 * 60) + output = attr() if callable(attr) else attr if isinstance(output, (list, tuple, QuerySet)): diff --git a/bluebottle/utils/views.py b/bluebottle/utils/views.py index 484272a001..20d63b94c2 100644 --- a/bluebottle/utils/views.py +++ b/bluebottle/utils/views.py @@ -1,12 +1,11 @@ import mimetypes import os from io import BytesIO - -import xlsxwriter +from operator import attrgetter import icalendar - import magic +import xlsxwriter from django.core.paginator import Paginator from django.core.signing import TimestampSigner, BadSignature from django.db.models import Case, When, IntegerField @@ -33,6 +32,7 @@ from bluebottle.utils.permissions import ResourcePermission from .models import Language from .serializers import LanguageSerializer +import re mime = magic.Magic(mime=True) @@ -129,7 +129,7 @@ def check_related_object_permissions(self, request, obj): Raises an appropriate exception if the request is not permitted. """ for related, permissions in list(self.related_permission_classes.items()): - related_obj = getattr(obj, related) + related_obj = attrgetter(related)(obj) for permission in permissions: if not permission().has_object_permission(request, None, related_obj): self.permission_denied( @@ -364,21 +364,26 @@ def get_data(self): def get_instances(self): raise NotImplementedError() - def get(self, request, *args, **kwargs): - output = BytesIO() - - workbook = xlsxwriter.Workbook(output, {'remove_timezone': True}) - worksheet = workbook.add_worksheet() + def write_data(self, workbook): + title = re.sub("[\[\]\\:*?/]", '', str(self.get_object())[:30]) + worksheet = workbook.add_worksheet(title) worksheet.write_row(0, 0, [field[1] for field in self.get_fields()]) for (index, row) in enumerate(self.get_data()): worksheet.write_row(index + 1, 0, row) + def get(self, request, *args, **kwargs): + output = BytesIO() + + workbook = xlsxwriter.Workbook(output, {'remove_timezone': True}) + self.write_data(workbook) workbook.close() + output.seek(0) response = HttpResponse(output.read()) + response['Content-Disposition'] = f'attachment; filename="{self.get_filename()}"' response['Content-Type'] = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' diff --git a/coverage.rc b/coverage.rc index ddeec64ed6..61eca85d75 100644 --- a/coverage.rc +++ b/coverage.rc @@ -1,3 +1,3 @@ [run] include = apps* -omit = **/migrations/** +omit = ../*migrations*