diff --git a/app/assets/v2/css/dashboard.css b/app/assets/v2/css/dashboard.css index 68f24d31c82..b06e4073608 100644 --- a/app/assets/v2/css/dashboard.css +++ b/app/assets/v2/css/dashboard.css @@ -245,6 +245,10 @@ body { color: #00A652; } +.status-reserved { + color: #d58512; +} + .status-started { color: #0D0764; } diff --git a/app/assets/v2/css/submit_bounty.css b/app/assets/v2/css/submit_bounty.css index 9b667d17bf7..e05744e1105 100644 --- a/app/assets/v2/css/submit_bounty.css +++ b/app/assets/v2/css/submit_bounty.css @@ -227,3 +227,13 @@ input:read-only { .letter-spacing { letter-spacing: 0.1em; } + +.release-after-form-group { + display: none; + margin-top: 0.75em; +} + +.release-after-form-group-required { + border: 1px solid red; + padding: 5px; +} diff --git a/app/assets/v2/js/pages/bounty_details.js b/app/assets/v2/js/pages/bounty_details.js index face40e526b..c1f6e5022c1 100644 --- a/app/assets/v2/js/pages/bounty_details.js +++ b/app/assets/v2/js/pages/bounty_details.js @@ -448,10 +448,7 @@ var callbacks = { }, 'reserved_for_user_handle': function(key, val, bounty) { if (val) { - const reservedForHoursLeft = 72 - Math.abs(new Date() - new Date(bounty['created_on'])) / 3600000; - - // check if 24 hours have passed before setting the issue as reserved - if (Math.round(reservedForHoursLeft) > 0) { + if (bounty['is_reserved'] && bounty['status'] === 'reserved') { const reservedForHtmlLink = '' + val + ''; const reservedForAvatar = ''; @@ -475,10 +472,7 @@ const isAvailableIfReserved = function(bounty) { return true; } - const reservedForHoursLeft = 72 - Math.abs(new Date() - new Date(bounty['created_on'])) / 3600000; - - // check if 24 hours have passed before setting the issue as reserved - if (Math.round(reservedForHoursLeft) > 0) { + if (bounty['is_reserved']) { return false; } } @@ -1037,7 +1031,7 @@ var do_actions = function(result) { var is_status_done = result['status'] == 'done'; var is_status_cancelled = result['status'] == 'cancelled'; var can_submit_after_expiration_date = result['can_submit_after_expiration_date']; - var is_still_on_happy_path = result['status'] == 'open' || result['status'] == 'started' || result['status'] == 'submitted' || (can_submit_after_expiration_date && result['status'] == 'expired'); + var is_still_on_happy_path = result['status'] == 'reserved' || result['status'] == 'open' || result['status'] == 'started' || result['status'] == 'submitted' || (can_submit_after_expiration_date && result['status'] == 'expired'); var needs_review = result['needs_review']; const is_open = result['is_open']; let bounty_path = result['network'] + '/' + result['standard_bounties_id']; @@ -1132,7 +1126,7 @@ var do_actions = function(result) { let text; if (result['permission_type'] === 'approval') - text = is_interested ? gettext('Stop') : gettext('Express Interest'); + text = is_interested ? gettext('Stop Work') : gettext('Express Interest'); else text = is_interested ? gettext('Stop Work') : gettext('Start Work'); @@ -1149,6 +1143,21 @@ var do_actions = function(result) { actions.push(interest_entry); } + if (result['is_reserved'] && result['status'] === 'reserved' && + caseInsensitiveCompare(result['reserved_for_user_handle'], document.contxt['github_handle'])) { + const connector_char = result['url'].indexOf('?') == -1 ? '?' : '&'; + + const release_to_public_entry = { + enabled: true, + href: result['url'] + connector_char + 'release_to_public=1', + text: gettext('Release Bounty'), + parent: 'bounty_actions', + title: gettext('Release this reserved bounty to the public') + }; + + actions.push(release_to_public_entry); + } + if (show_kill_bounty) { const enabled = isBountyOwner(result); const _entry = { diff --git a/app/assets/v2/js/pages/dashboard.js b/app/assets/v2/js/pages/dashboard.js index 2dc243a1c98..35bc5e0402d 100644 --- a/app/assets/v2/js/pages/dashboard.js +++ b/app/assets/v2/js/pages/dashboard.js @@ -363,6 +363,9 @@ var get_search_URI = function(offset, order) { } else if (_value === 'fulfilledByMe') { _key = 'fulfiller_github_username'; _value = document.contxt.github_handle; + } else if (_value === 'reservedForMe') { + _key = 'reserved_for_user_handle'; + _value = document.contxt.github_handle; } if (_value !== 'any') { diff --git a/app/assets/v2/js/pages/new_bounty.js b/app/assets/v2/js/pages/new_bounty.js index f6f7e8a1314..f639755d6f4 100644 --- a/app/assets/v2/js/pages/new_bounty.js +++ b/app/assets/v2/js/pages/new_bounty.js @@ -421,8 +421,20 @@ $(function() { }); -$('#reservedFor').on('select2:select', function(e) { +$('#reservedFor').on('select2:select', (e) => { $('#permissionless').click(); + $('#releaseAfterFormGroup').show(); + $('#releaseAfter').attr('required', true); +}); + +$('#reservedFor').on('select2:unselect', (e) => { + $('#releaseAfterFormGroup').hide(); + $('#releaseAfter').attr('required', false); + $('#releaseAfterFormGroup').addClass('releaseAfterFormGroupRequired'); +}); + +$('#releaseAfter').on('change', () => { + $('#releaseAfterFormGroup').removeClass('releaseAfterFormGroupRequired'); }); $('#sync-issue').on('click', function(event) { @@ -566,6 +578,7 @@ $('#submitBounty').validate({ var decimalDivisor = Math.pow(10, decimals); var expirationTimeDelta = $('#expirationTimeDelta').data('daterangepicker').endDate.utc().unix(); let reservedFor = $('.username-search').select2('data')[0]; + let releaseAfter = $('#releaseAfter').children('option:selected').val(); let inviteContributors = $('#invite-contributors.js-select2').select2('data').map((user) => { return user.profile__id; }); @@ -587,6 +600,7 @@ $('#submitBounty').validate({ repo_type: data.repo_type, featuring_date: data.featuredBounty && ((new Date().getTime() / 1000) | 0) || 0, reservedFor: reservedFor ? reservedFor.text : '', + releaseAfter: releaseAfter !== 'Release To Public After' ? releaseAfter : '', tokenName, invite: inviteContributors, bounty_categories: data.bounty_category diff --git a/app/dashboard/helpers.py b/app/dashboard/helpers.py index d721b594b76..ab324053063 100644 --- a/app/dashboard/helpers.py +++ b/app/dashboard/helpers.py @@ -601,12 +601,18 @@ def merge_bounty(latest_old_bounty, new_bounty, metadata, bounty_details, verbos for activity in latest_old_bounty.activities.all().nocache(): new_bounty.activities.add(activity) - bounty_reserved_for_user = metadata.get('reservedFor', '') - if bounty_reserved_for_user: + release_to_public_after = metadata.get('releaseAfter', '') + if bounty_reserved_for_user and release_to_public_after: + new_bounty.reserved_for_user_from = timezone.now() + if release_to_public_after == "3-days": + new_bounty.reserved_for_user_expiration = new_bounty.reserved_for_user_from + timezone.timedelta(days=3) + elif release_to_public_after == "1-week": + new_bounty.reserved_for_user_expiration = new_bounty.reserved_for_user_from + timezone.timedelta(weeks=1) + new_bounty.reserved_for_user_handle = bounty_reserved_for_user new_bounty.save() - if new_bounty.bounty_reserved_for_user: + if new_bounty.bounty_reserved_for_user and new_bounty.status == 'reserved': # notify a user that a bounty has been reserved for them new_reserved_issue('founders@gitcoin.co', new_bounty.bounty_reserved_for_user, new_bounty) diff --git a/app/dashboard/migrations/0052_auto_20190919_1445.py b/app/dashboard/migrations/0052_auto_20190919_1445.py new file mode 100644 index 00000000000..2064e509f82 --- /dev/null +++ b/app/dashboard/migrations/0052_auto_20190919_1445.py @@ -0,0 +1,28 @@ +# Generated by Django 2.2.4 on 2019-09-19 14:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dashboard', '0051_profile_gitcoin_discord_username'), + ] + + operations = [ + migrations.AddField( + model_name='bounty', + name='reserved_for_user_expiration', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='bounty', + name='reserved_for_user_from', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name='bounty', + name='idx_status', + field=models.CharField(choices=[('cancelled', 'cancelled'), ('done', 'done'), ('expired', 'expired'), ('reserved', 'reserved'), ('open', 'open'), ('started', 'started'), ('submitted', 'submitted'), ('unknown', 'unknown')], db_index=True, default='open', max_length=9), + ), + ] diff --git a/app/dashboard/models.py b/app/dashboard/models.py index ae3dfb90601..fa7b84f8c6c 100644 --- a/app/dashboard/models.py +++ b/app/dashboard/models.py @@ -247,15 +247,16 @@ class Bounty(SuperModel): ('cancelled', 'cancelled'), ('done', 'done'), ('expired', 'expired'), + ('reserved', 'reserved'), ('open', 'open'), ('started', 'started'), ('submitted', 'submitted'), ('unknown', 'unknown'), ) - FUNDED_STATUSES = ['open', 'started', 'submitted', 'done'] - OPEN_STATUSES = ['open', 'started', 'submitted'] + FUNDED_STATUSES = ['reserved', 'open', 'started', 'submitted', 'done'] + OPEN_STATUSES = ['reserved', 'open', 'started', 'submitted'] CLOSED_STATUSES = ['expired', 'unknown', 'cancelled', 'done'] - WORK_IN_PROGRESS_STATUSES = ['open', 'started', 'submitted'] + WORK_IN_PROGRESS_STATUSES = ['reserved', 'open', 'started', 'submitted'] TERMINAL_STATUSES = ['done', 'expired', 'cancelled'] web3_type = models.CharField(max_length=50, default='bounties_network') @@ -281,6 +282,8 @@ class Bounty(SuperModel): bounty_reserved_for_user = models.ForeignKey( 'dashboard.Profile', null=True, on_delete=models.SET_NULL, related_name='reserved_bounties', blank=True ) + reserved_for_user_from = models.DateTimeField(blank=True, null=True) + reserved_for_user_expiration = models.DateTimeField(blank=True, null=True) is_open = models.BooleanField(help_text=_('Whether the bounty is still open for fulfillments.')) expires_date = models.DateTimeField() raw_data = JSONField() @@ -727,6 +730,8 @@ def status(self): if self.num_fulfillments == 0: if self.pk and self.interested.filter(pending=False).exists(): return 'started' + elif self.is_reserved: + return 'reserved' return 'open' return 'submitted' except Exception as e: @@ -1158,8 +1163,8 @@ def can_remarket(self): result = False if self.last_remarketed: - one_hour_after_remarketing = self.last_remarketed + timezone.timedelta(hours=1) - if timezone.now() < one_hour_after_remarketing: + minimum_wait_after_remarketing = self.last_remarketed + timezone.timedelta(minutes=settings.MINUTES_BETWEEN_RE_MARKETING) + if timezone.now() < minimum_wait_after_remarketing: result = False if self.interested.count() > 0: @@ -1167,6 +1172,50 @@ def can_remarket(self): return result + @property + def is_reserved(self): + if self.bounty_reserved_for_user and self.reserved_for_user_from: + if timezone.now() < self.reserved_for_user_from: + return False + + if self.reserved_for_user_expiration and timezone.now() > self.reserved_for_user_expiration: + return False + + return True + + @property + def total_reserved_length_label(self): + if self.bounty_reserved_for_user and self.reserved_for_user_from: + if self.reserved_for_user_expiration is None: + return 'indefinitely' + + if self.reserved_for_user_from == self.reserved_for_user_expiration: + return '' + + delta = self.reserved_for_user_expiration - self.reserved_for_user_from + days = delta.days + + if days > 0: + if days % 7 == 0: + if days == 7: + return '1 week' + else: + weeks = int(days / 7) + return f'{weeks} weeks' + + if days == 1: + return '1 day' + else: + return f'{days} days' + else: + hours = int(int(delta.total_seconds()) / 3600) + if hours == 1: + return '1 hour' + else: + return f'{hours} hours' + else: + return '' + class BountyFulfillmentQuerySet(models.QuerySet): """Handle the manager queryset for BountyFulfillments.""" diff --git a/app/dashboard/router.py b/app/dashboard/router.py index 71dd9359309..5feff114f44 100644 --- a/app/dashboard/router.py +++ b/app/dashboard/router.py @@ -29,8 +29,8 @@ from retail.helpers import get_ip from .models import ( - Activity, Bounty, BountyDocuments, BountyFulfillment, BountyInvites, HackathonEvent, Interest, ProfileSerializer, - SearchHistory, + Activity, Bounty, BountyDocuments, BountyFulfillment, BountyInvites, HackathonEvent, Interest, Profile, + ProfileSerializer, SearchHistory, ) logger = logging.getLogger(__name__) @@ -157,7 +157,7 @@ class Meta: 'attached_job_description', 'needs_review', 'github_issue_state', 'is_issue_closed', 'additional_funding_summary', 'funding_organisation', 'paid', 'event', 'admin_override_suspend_auto_approval', 'reserved_for_user_handle', 'is_featured', - 'featuring_date', 'repo_type', 'unsigned_nda', 'funder_last_messaged_on', 'can_remarket' + 'featuring_date', 'repo_type', 'unsigned_nda', 'funder_last_messaged_on', 'can_remarket', 'is_reserved' ) def create(self, validated_data): @@ -269,6 +269,15 @@ def get_queryset(self): _queryset = _queryset | queryset.filter(**args) queryset = _queryset + if 'reserved_for_user_handle' in param_keys: + handle = self.request.query_params.get('reserved_for_user_handle', '') + if handle: + try: + profile = Profile.objects.filter(handle__iexact=handle).first() + queryset = queryset.filter(bounty_reserved_for_user=profile) + except: + logger.warning(f'reserved_for_user_handle: Unknown handle: ${handle}') + # filter by PK if 'pk__gt' in param_keys: queryset = queryset.filter(pk__gt=self.request.query_params.get('pk__gt')) diff --git a/app/dashboard/templates/dashboard/sidebar_search.html b/app/dashboard/templates/dashboard/sidebar_search.html index b92acd11763..95baeb23cf7 100644 --- a/app/dashboard/templates/dashboard/sidebar_search.html +++ b/app/dashboard/templates/dashboard/sidebar_search.html @@ -166,6 +166,67 @@ +
{% trans "Hello" %} @{{ user.handle }},
- {% trans "Good news! An "%} {% trans "issue" %}. {% trans " has been reserved for you on gitcoin. "%} - {% trans "Please start working on the issue, in the next 72 hours, before it is opened up for other bounty hunters to try as well." %} + {% trans "Good news! An "%} {% trans "issue" %} {% trans " has been reserved for you on gitcoin. "%} + {% if bounty.total_reserved_length_label == "indefinitely" %} + {% trans "Please start working on the issue and release it to the public if you cannot commit to the work." %} + {% else %} + {% blocktrans with total_reserved_length_label=bounty.total_reserved_length_label %} + Please start working on the issue, within the next {{ total_reserved_length_label }}, before it is opened up for other bounty hunters to try as well. + {% endblocktrans %} + {% endif %}