diff --git a/bluebottle/activities/tests/test_triggers.py b/bluebottle/activities/tests/test_triggers.py index 34c69c3a21..150d71cb66 100644 --- a/bluebottle/activities/tests/test_triggers.py +++ b/bluebottle/activities/tests/test_triggers.py @@ -123,6 +123,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 10f97e3875..2c1a2d5a3d 100644 --- a/bluebottle/activities/triggers.py +++ b/bluebottle/activities/triggers.py @@ -1,27 +1,24 @@ -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, TeamAcceptedMessage, TeamAppliedMessage, + TeamWithdrawnMessage, TeamWithdrawnActivityOwnerMessage, TeamReappliedMessage, TeamCancelledMessage, + TeamCancelledTeamCaptainMessage +) +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): @@ -232,6 +229,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 = [ @@ -266,13 +288,23 @@ class TeamTriggers(TriggerManager): '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) @@ -282,6 +314,11 @@ class TeamTriggers(TriggerManager): TransitionTrigger( TeamStateMachine.withdraw, effects=[ + RelatedTransitionEffect( + 'activity', + TimeBasedStateMachine.reopen, + conditions=[team_activity_will_not_be_full] + ), TeamContributionTransitionEffect(ContributionStateMachine.fail), NotificationEffect(TeamWithdrawnMessage), NotificationEffect(TeamWithdrawnActivityOwnerMessage) @@ -294,7 +331,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 + ] ), ] @@ -305,7 +345,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/test/test_runner.py b/bluebottle/test/test_runner.py index 02ad07d88e..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,7 +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 6b158c727b..304a54c3b2 100644 --- a/bluebottle/test/utils.py +++ b/bluebottle/test/utils.py @@ -687,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/models.py b/bluebottle/time_based/models.py index 166efb8e64..826d49f6b5 100644 --- a/bluebottle/time_based/models.py +++ b/bluebottle/time_based/models.py @@ -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'), 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/test_notifications.py b/bluebottle/time_based/tests/test_notifications.py index 4b2d050955..7c35874bf4 100644 --- a/bluebottle/time_based/tests/test_notifications.py +++ b/bluebottle/time_based/tests/test_notifications.py @@ -12,7 +12,7 @@ ParticipantWithdrewNotification, NewParticipantNotification, ParticipantAddedOwnerNotification, ParticipantRemovedOwnerNotification, ParticipantJoinedNotification, ParticipantAppliedNotification, SlotCancelledNotification, ParticipantAddedNotification, TeamParticipantAddedNotification, - TeamSlotChangedNotification + TeamSlotChangedNotification, TeamParticipantJoinedNotification ) from bluebottle.time_based.tests.factories import DateActivityFactory, DateParticipantFactory, \ DateActivitySlotFactory, PeriodActivityFactory, PeriodParticipantFactory, TeamSlotFactory @@ -232,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() diff --git a/bluebottle/time_based/triggers.py b/bluebottle/time_based/triggers.py index 0966a2a288..ed6e4810e2 100644 --- a/bluebottle/time_based/triggers.py +++ b/bluebottle/time_based/triggers.py @@ -62,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 @@ -86,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) ) @@ -190,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 + ] + ), ] ), @@ -997,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 @@ -1016,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) ) @@ -1204,9 +1237,10 @@ class ParticipantTriggers(ContributorTriggers): RelatedTransitionEffect( 'activity', TimeBasedStateMachine.lock, - conditions=[activity_will_be_full] + conditions=[ + activity_will_be_full + ] ), - RelatedTransitionEffect( 'activity', TimeBasedStateMachine.succeed, @@ -1285,7 +1319,9 @@ class ParticipantTriggers(ContributorTriggers): RelatedTransitionEffect( 'activity', TimeBasedStateMachine.lock, - conditions=[activity_will_be_full] + conditions=[ + activity_will_be_full + ] ), RelatedTransitionEffect( 'activity', @@ -1407,7 +1443,6 @@ class ParticipantTriggers(ContributorTriggers): ), ] ), - ]