Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reserve bounty improvements #5066

Merged
merged 6 commits into from
Sep 19, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/assets/v2/css/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,10 @@ body {
color: #00A652;
}

.status-reserved {
color: #d58512;
}

.status-started {
color: #0D0764;
}
Expand Down
10 changes: 10 additions & 0 deletions app/assets/v2/css/submit_bounty.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
29 changes: 19 additions & 10 deletions app/assets/v2/js/pages/bounty_details.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<a href="/profile/' + val + '">' + val + '</a>';
const reservedForAvatar = '<img class="rounded-circle" src="/dynamic/avatar/' + val + '" width="25" height="25"/>';

Expand All @@ -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;
}
}
Expand Down Expand Up @@ -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'];
Expand Down Expand Up @@ -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');

Expand All @@ -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 = {
Expand Down
3 changes: 3 additions & 0 deletions app/assets/v2/js/pages/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
16 changes: 15 additions & 1 deletion app/assets/v2/js/pages/new_bounty.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
});
Expand All @@ -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
Expand Down
12 changes: 9 additions & 3 deletions app/dashboard/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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('[email protected]', new_bounty.bounty_reserved_for_user, new_bounty)

Expand Down
28 changes: 28 additions & 0 deletions app/dashboard/migrations/0052_auto_20190919_1445.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
59 changes: 54 additions & 5 deletions app/dashboard/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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()
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -1158,15 +1163,59 @@ 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:
result = False

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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a simpler way we can humanize the timedelta? https://anandnalya.com/2009/05/humanizing-the-time-difference-in-django/

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey Dan. I'm not sure what you mean? I've had a look at the link you sent and we would still need to do calcs for hours. Additionally, the humanised timedelta outputs seem different compared to the current requirement of allowing a funder to reserve for '3 days', '1 week' or 'Keep reserved'. I tried to make the code as scalable as possible and struggled to find easier ways of doing this. It's a shame the timedelta object doesn't have an hours getter attribute or even a weeks one which is strange as you can construct a timedelta with weeks, days, hours, minutes etc.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@danlipert any thoughts on my comment?

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."""
Expand Down
15 changes: 12 additions & 3 deletions app/dashboard/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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'))
Expand Down
Loading