diff --git a/CHANGELOG.md b/CHANGELOG.md index e91bad48ada..06758ad3b38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,7 +72,7 @@ - Epic 1 [\#1125](https://github.com/gitcoinco/web/issues/1125) - nginx Doesn't Seem to Recognize \(or Direct Properly\) www.gitcoin.co [\#1122](https://github.com/gitcoinco/web/issues/1122) - As a user, I want to be prompted to authenticate before seeing a start work modal [\#1107](https://github.com/gitcoinco/web/issues/1107) -- How to surface work schemes to the community [\#1106](https://github.com/gitcoinco/web/issues/1106) +- How to surface Project Types to the community [\#1106](https://github.com/gitcoinco/web/issues/1106) - Make Image a Thumbnail for Long Descriptions [\#1105](https://github.com/gitcoinco/web/issues/1105) - BUILD - As a funder \(Project recruiter\), I want a place where I can track all my stuff, so i can have a flow of web3.0 talent [\#1092](https://github.com/gitcoinco/web/issues/1092) - As a member of the community, I want these Profile Privacy Features, so I can be more private [\#1091](https://github.com/gitcoinco/web/issues/1091) diff --git a/app/app/urls.py b/app/app/urls.py index 8afe60ea7b6..0828ad686c6 100644 --- a/app/app/urls.py +++ b/app/app/urls.py @@ -249,7 +249,38 @@ tdi.views.process_accesscode_request, name='process_accesscode_request' ), - url( + re_path( + r'^_administration/process_faucet_request/(.*)$', + faucet.views.process_faucet_request, + name='process_faucet_request' + ), + re_path( + r'^_administration/email/start_work_approved$', retail.emails.start_work_approved, name='start_work_approved' + ), + re_path( + r'^_administration/email/start_work_rejected$', retail.emails.start_work_rejected, name='start_work_rejected' + ), + re_path( + r'^_administration/email/start_work_new_applicant$', + retail.emails.start_work_new_applicant, + name='start_work_new_applicant' + ), + re_path( + r'^_administration/email/start_work_applicant_about_to_expire$', + retail.emails.start_work_applicant_about_to_expire, + name='start_work_applicant_about_to_expire' + ), + re_path( + r'^_administration/email/start_work_applicant_expired$', + retail.emails.start_work_applicant_expired, + name='start_work_applicant_expired' + ), + re_path( + r'^_administration/process_accesscode_request/(.*)$', + tdi.views.process_accesscode_request, + name='process_accesscode_request' + ), + re_path( r'^_administration/process_faucet_request/(.*)$', faucet.views.process_faucet_request, name='process_faucet_request' diff --git a/app/assets/v2/css/bounty.css b/app/assets/v2/css/bounty.css index 9c03da74dae..e7bfdfc2ff7 100644 --- a/app/assets/v2/css/bounty.css +++ b/app/assets/v2/css/bounty.css @@ -191,6 +191,7 @@ body { padding: 5px; } +.red_warning, #network { background-color: #fbe0d6; color: #fb9470; @@ -200,6 +201,7 @@ body { display: block; } +.red_warning a, #network a{ color: #fb9470; font-weight: bold; diff --git a/app/assets/v2/js/pages/bounty_details.js b/app/assets/v2/js/pages/bounty_details.js index 4d810b76a40..681dba9aa31 100644 --- a/app/assets/v2/js/pages/bounty_details.js +++ b/app/assets/v2/js/pages/bounty_details.js @@ -59,6 +59,8 @@ var rows = [ 'token_value_time_peg', 'web3_created', 'status', + 'project_type', + 'permission_type', 'bounty_owner_address', 'bounty_owner_email', 'issue_description', @@ -136,6 +138,15 @@ var callbacks = { 'bounty_owner_name': function(key, val, result) { return [ 'bounty_owner_name', result.bounty_owner_name ]; }, + 'permission_type': function(key, val, result) { + if (val == 'approval') { + val = 'Approval Required'; + } + return [ 'permission_type', ucwords(val) ]; + }, + 'project_type': function(key, val, result) { + return [ 'project_type', ucwords(result.project_type) ]; + }, 'issue_keywords': function(key, val, result) { if (!result.keywords || result.keywords.length == 0) return [ 'issue_keywords', null ]; @@ -416,7 +427,7 @@ var attach_work_actions = function() { e.preventDefault(); if ($(this).attr('href') == '/interested') { show_interest_modal.call(this); - } else if (confirm(gettext('Are you sure you want to remove your interest?'))) { + } else if (confirm(gettext('Are you sure you want to stop work?'))) { $(this).attr('href', '/interested'); $(this).find('span').text('Start Work'); remove_interest(document.result['pk']); @@ -429,7 +440,9 @@ var show_interest_modal = function() { var self = this; setTimeout(function() { - $.get('/interest/modal?redirect=' + window.location.pathname, function(newHTML) { + var url = '/interest/modal?redirect=' + window.location.pathname + '&pk=' + document.result['pk']; + + $.get(url, function(newHTML) { var modal = $(newHTML).appendTo('body').modal({ modalClass: 'modal add-interest-modal' }); @@ -538,7 +551,8 @@ var do_actions = function(result) { pull_interest_list(result['pk'], function(is_interested) { // which actions should we show? - var show_start_stop_work = is_still_on_happy_path; + var should_block_from_starting_work = !is_interested && result['project_type'] == 'traditional' && (result['status'] == 'started' || result['status'] == 'submitted'); + var show_start_stop_work = is_still_on_happy_path && !should_block_from_starting_work; var show_github_link = result['github_url'].substring(0, 4) == 'http'; var show_submit_work = true; var show_kill_bounty = !is_status_done && !is_status_expired && !is_status_cancelled; @@ -626,8 +640,7 @@ var do_actions = function(result) { href: result['action_urls']['increase'], text: gettext('Add Contribution'), parent: 'right_actions', - title: gettext('Increase the funding for this issue'), - color: 'white' + title: gettext('Increase the funding for this issue') }; actions.push(_entry); @@ -746,7 +759,7 @@ var render_activity = function(result) { activities.push({ profileId: _interested.profile.id, name: _interested.profile.handle, - text: gettext('Work Started'), + text: _interested.pending ? gettext('Worker Applied') : gettext('Work Started'), created_on: _interested.created, age: timeDifference(new Date(result['now']), new Date(_interested.created)), status: 'started', diff --git a/app/assets/v2/js/pages/dashboard.js b/app/assets/v2/js/pages/dashboard.js index 23c58da612c..c2acecd0a08 100644 --- a/app/assets/v2/js/pages/dashboard.js +++ b/app/assets/v2/js/pages/dashboard.js @@ -15,7 +15,9 @@ var sidebar_keys = [ 'bounty_filter', 'network', 'idx_status', - 'tech_stack' + 'tech_stack', + 'project_type', + 'permission_type' ]; var localStorage; @@ -457,9 +459,10 @@ var refreshBounties = function(event) { result.action = result['url']; result['title'] = result['title'] ? result['title'] : result['github_url']; + var project_type = ucwords(result['project_type']) + ' • '; - result['p'] = ((result['experience_level'] ? result['experience_level'] + ' • ' : '')); - + result['p'] = project_type + (result['experience_level'] ? result['experience_level'] + ' • ' : ''); + if (result['status'] === 'done') result['p'] += 'Done'; if (result['fulfillment_accepted_on']) { diff --git a/app/assets/v2/js/pages/new_bounty.js b/app/assets/v2/js/pages/new_bounty.js index e4e76fffae2..37f454ddf7f 100644 --- a/app/assets/v2/js/pages/new_bounty.js +++ b/app/assets/v2/js/pages/new_bounty.js @@ -42,6 +42,22 @@ $(document).ready(function() { } else if (localStorage['issueURL']) { $('input[name=issueURL]').val(localStorage['issueURL']); } + if (localStorage['project_type']) { + $('select[name=project_type] option').prop('selected', false); + $( + "select[name=project_type] option[value='" + + localStorage['project_type'] + + "']" + ).prop('selected', true); + } + if (localStorage['permission_type']) { + $('select[name=permission_type] option').prop('selected', false); + $( + "select[name=permission_type] option[value='" + + localStorage['permission_type'] + + "']" + ).prop('selected', true); + } if (localStorage['expirationTimeDelta']) { $('select[name=expirationTimeDelta] option').prop('selected', false); $( @@ -84,10 +100,21 @@ $(document).ready(function() { } $('input[name=issueURL]').focus(); - $('select[name=deonomination]').select2(); + // all js select 2 fields $('.js-select2').each(function() { $(this).select2(); }); + // removes tooltip + $('select').on('change', function(evt) { + $('.select2-selection__rendered').removeAttr('title'); + }); + // removes search field in all but the 'denomination' dropdown + $('.select2-container').click(function() { + $('.select2-container .select2-search__field').remove(); + }); + // denomination field + $('select[name=deonomination]').select2(); + $('#advancedLink a').click(function(e) { e.preventDefault(); @@ -172,6 +199,10 @@ $(document).ready(function() { githubUsername: metadata.githubUsername, address: '' // Fill this in later }, + schemes: { + project_type: data.project_type, + permission_type: data.permission_type + }, privacy_preferences: privacy_preferences, funders: [], categories: metadata.issueKeywords.split(','), @@ -196,6 +227,8 @@ $(document).ready(function() { $(this).attr('disabled', 'disabled'); // save off local state for later + localStorage['project_type'] = data.project_type; + localStorage['permission_type'] = data.permission_type; localStorage['issueURL'] = issueURL; localStorage['amount'] = amount; localStorage['notificationEmail'] = notificationEmail; diff --git a/app/assets/v2/js/shared.js b/app/assets/v2/js/shared.js index bda55a4c04f..91fed75269b 100644 --- a/app/assets/v2/js/shared.js +++ b/app/assets/v2/js/shared.js @@ -95,6 +95,12 @@ var sanitizeAPIResults = function(results) { return results; }; +function ucwords(str) { + return (str + '').replace(/^([a-z])|\s+([a-z])/g, function($1) { + return $1.toUpperCase(); + }); +} + var sanitize = function(str) { if (typeof str != 'string') { return str; @@ -193,17 +199,17 @@ var mutate_interest = function(bounty_pk, direction, data) { .toggleClass('button') .toggleClass('button--primary'); - if (direction === 'new') { - _alert({ message: gettext("Thanks for letting us know that you're ready to start work.") }, 'success'); - $('#interest a').attr('id', 'btn-white'); - } else if (direction === 'remove') { - _alert({ message: gettext("You've stopped working on this, thanks for letting us know.") }, 'success'); - $('#interest a').attr('id', ''); - } - $.post(request_url, data).then(function(result) { result = sanitizeAPIResults(result); if (result.success) { + if (direction === 'new') { + _alert({ message: result.msg }, 'success'); + $('#interest a').attr('id', 'btn-white'); + } else if (direction === 'remove') { + _alert({ message: result.msg }, 'success'); + $('#interest a').attr('id', ''); + } + pull_interest_list(bounty_pk); return true; } diff --git a/app/dashboard/helpers.py b/app/dashboard/helpers.py index 53544199bb2..e0d4ba87155 100644 --- a/app/dashboard/helpers.py +++ b/app/dashboard/helpers.py @@ -459,6 +459,8 @@ def create_new_bounty(old_bounties, bounty_payload, bounty_details, bounty_id): accepted=accepted, interested_comment=interested_comment_id, submissions_comment=submissions_comment_id, + project_type=bounty_payload.get('schemes', {}).get('project_type', 'traditional'), + permission_type=bounty_payload.get('schemes', {}).get('permission_type', 'permissionless'), privacy_preferences=bounty_payload.get('privacy_preferences', {}), # These fields are after initial bounty creation, in bounty_details.js expires_date=timezone.make_aware( diff --git a/app/dashboard/migrations/0084_auto_20180604_1723.py b/app/dashboard/migrations/0084_auto_20180604_1723.py new file mode 100644 index 00000000000..1a3305ec412 --- /dev/null +++ b/app/dashboard/migrations/0084_auto_20180604_1723.py @@ -0,0 +1,39 @@ +# Generated by Django 2.0.5 on 2018-06-04 17:23 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dashboard', '0083_merge_20180601_1945'), + ] + + operations = [ + migrations.AddField( + model_name='bounty', + name='permission_type', + field=models.CharField(choices=[('permissionless', 'permissionless'), ('approval', 'approval')], default='permissionless', max_length=50), + ), + migrations.AddField( + model_name='bounty', + name='project_type', + field=models.CharField(choices=[('traditional', 'traditional'), ('contest', 'contest'), ('cooperative', 'cooperative')], default='traditional', max_length=50), + ), + migrations.AddField( + model_name='interest', + name='acceptance_date', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='interest', + name='pending', + field=models.BooleanField(default=False, help_text='If this option is chosen, this interest is pending and not yet active'), + ), + migrations.AlterField( + model_name='bounty', + name='bounty_owner_profile', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bounties_funded', to='dashboard.Profile'), + ), + ] diff --git a/app/dashboard/models.py b/app/dashboard/models.py index 4bbb5b71eec..49d6c873902 100644 --- a/app/dashboard/models.py +++ b/app/dashboard/models.py @@ -97,6 +97,15 @@ class Bounty(SuperModel): """ + PERMISSION_TYPES = [ + ('permissionless', 'permissionless'), + ('approval', 'approval'), + ] + PROJECT_TYPES = [ + ('traditional', 'traditional'), + ('contest', 'contest'), + ('cooperative', 'cooperative'), + ] BOUNTY_TYPES = [ ('Bug', 'Bug'), ('Security', 'Security'), @@ -174,6 +183,8 @@ class Bounty(SuperModel): fulfillment_submitted_on = models.DateTimeField(null=True, blank=True) fulfillment_started_on = models.DateTimeField(null=True, blank=True) canceled_on = models.DateTimeField(null=True, blank=True) + project_type = models.CharField(max_length=50, choices=PROJECT_TYPES, default='traditional') + permission_type = models.CharField(max_length=50, choices=PERMISSION_TYPES, default='permissionless') snooze_warnings_for_days = models.IntegerField(default=0) token_value_time_peg = models.DateTimeField(blank=True, null=True) @@ -207,6 +218,15 @@ def save(self, *args, **kwargs): self.bounty_owner_github_username = self.bounty_owner_github_username.lstrip('@') super().save(*args, **kwargs) + @property + def profile_pairs(self): + profile_handles = [] + + for profile in self.interested.select_related('profile').all().order_by('pk'): + profile_handles.append((profile.profile.handle, profile.profile.absolute_url)) + + return profile_handles + def get_absolute_url(self): """Get the absolute URL for the Bounty. @@ -257,6 +277,30 @@ def snooze_url(self, num_days): """ return f'{self.get_absolute_url()}?snooze={num_days}' + def approve_worker_url(self, worker): + """Get the bounty work approval URL. + + Args: + worker (string): The handle to approve + + Returns: + str: The work approve URL based on the worker name + + """ + return f'{self.get_absolute_url()}?mutate_worker_action=approve&worker={worker}' + + def reject_worker_url(self, worker): + """Get the bounty work rejection URL. + + Args: + worker (string): The handle to reject + + Returns: + str: The work reject URL based on the worker name + + """ + return f'{self.get_absolute_url()}?mutate_worker_action=reject&worker={worker}' + @property def can_submit_after_expiration_date(self): if self.is_legacy: @@ -394,40 +438,29 @@ def status(self): if self.override_status: return self.override_status if self.is_legacy: - # TODO: Remove following full deprecation of legacy bounties - try: - fulfillments = self.fulfillments \ - .exclude(fulfiller_address='0x0000000000000000000000000000000000000000') \ - .exists() - if not self.is_open: - if timezone.now() > self.expires_date and fulfillments: - return 'expired' + return self.idx_status + + # standard bounties + try: + if not self.is_open: + if self.accepted: return 'done' - elif not fulfillments: - if self.pk and self.interested.exists(): - return 'started' - return 'open' - return 'submitted' - except Exception as e: - logger.warning(e) - return 'unknown' - else: - try: - if not self.is_open: - if self.accepted: - return 'done' - elif self.past_hard_expiration_date: - return 'expired' - # If its not expired or done, it must be cancelled. - return 'cancelled' - if self.num_fulfillments == 0: - if self.pk and self.interested.exists(): - return 'started' - return 'open' - return 'submitted' - except Exception as e: - logger.warning(e) - return 'unknown' + elif self.past_hard_expiration_date: + return 'expired' + # If its not expired or done, it must be cancelled. + return 'cancelled' + # per https://github.com/gitcoinco/web/pull/1098 , + # contests are open no matter how much started/submitted work they have + if self.pk and self.project_type == 'contest': + return 'open' + if self.num_fulfillments == 0: + if self.pk and self.interested.filter(pending=False).exists(): + return 'started' + return 'open' + return 'submitted' + except Exception as e: + logger.warning(e) + return 'unknown' @property def get_value_true(self): @@ -694,6 +727,22 @@ def is_notification_eligible(self, var_to_check=True): return False return True + @property + def is_project_type_fulfilled(self): + """Determine whether or not the Project Type is currently fulfilled. + + Todo: + * Add remaining Project Type fulfillment handling. + + Returns: + bool: Whether or not the Bounty Project Type is fully staffed. + + """ + fulfilled = False + if self.project_type == 'traditional': + fulfilled = self.interested.filter(pending=False).exists() + return fulfilled + class BountyFulfillmentQuerySet(models.QuerySet): """Handle the manager queryset for BountyFulfillments.""" @@ -936,10 +985,19 @@ class Interest(models.Model): profile = models.ForeignKey('dashboard.Profile', related_name='interested', on_delete=models.CASCADE) created = models.DateTimeField(auto_now_add=True, blank=True, null=True) issue_message = models.TextField(default='', blank=True) + pending = models.BooleanField( + default=False, + help_text='If this option is chosen, this interest is pending and not yet active', + ) + acceptance_date = models.DateTimeField(blank=True, null=True) def __str__(self): """Define the string representation of an interested profile.""" - return self.profile.handle + return f"{self.profile.handle} / pending: {self.pending}" + + @property + def bounties(self): + return Bounty.objects.filter(interested=self) @receiver(post_save, sender=Interest, dispatch_uid="psave_interest") @@ -1037,7 +1095,7 @@ def repos_data(self): from github.utils import get_user from app.utils import add_contributors # TODO: maybe rewrite this so it doesnt have to go to the internet to get the info - # but in a way that is respectful of db size too + # but in a way that is respectful of db size too repos_data = get_user(self.handle, '/repos') repos_data = sorted(repos_data, key=lambda repo: repo['stargazers_count'], reverse=True) repos_data = [add_contributors(repo_data) for repo_data in repos_data] @@ -1254,6 +1312,10 @@ def get_relative_url(self, preceding_slash=True): def get_absolute_url(self): return settings.BASE_URL + self.get_relative_url(preceding_slash=False) + @property + def url(self): + return self.get_absolute_url() + def get_access_token(self, save=True): """Get the Github access token from User. diff --git a/app/dashboard/notifications.py b/app/dashboard/notifications.py index ca634d01ceb..0bb02604610 100644 --- a/app/dashboard/notifications.py +++ b/app/dashboard/notifications.py @@ -112,6 +112,14 @@ def maybe_market_to_twitter(bounty, event_name): tweet_txts = [ 'Bounty killed on {} {} {}\n{}' ] + elif event_name == 'worker_rejected': + tweet_txts = [ + 'Worked rejected on {} {} {}\n{}' + ] + elif event_name == 'worker_approved': + tweet_txts = [ + 'Worked approved on {} {} {}\n{}' + ] random.shuffle(tweet_txts) tweet_txt = tweet_txts[0] @@ -350,6 +358,7 @@ def build_github_notification(bounty, event_name, profile_pairs=None): bool: Whether or not the Github comment was posted successfully. """ + from dashboard.utils import get_ordinal_repr # hack for circular import issue from dashboard.models import BountyFulfillment, Interest msg = '' usdt_value = "" @@ -360,17 +369,9 @@ def build_github_notification(bounty, event_name, profile_pairs=None): natural_value = round(bounty.get_natural_value(), 4) absolute_url = bounty.get_absolute_url() amount_open_work = "{:,}".format(amount_usdt_open_work()) - profiles = "" bounty_owner = f"@{bounty.bounty_owner_github_username}" if bounty.bounty_owner_github_username else "" status_header = get_status_header(bounty) - if profile_pairs: - from dashboard.utils import get_ordinal_repr # hack for circular import issue - for i, profile in enumerate(profile_pairs, start=1): - show_dibs = event_name == 'work_started' and len(profile_pairs) > 1 - dibs = f" ({get_ordinal_repr(i)} precedence)" if show_dibs else "" - profiles = profiles + f"\n {i}. [@{profile[0]}]({profile[1]}) {dibs}" - profiles += "\n\n" if event_name == 'new_bounty': msg = f"{status_header}__This issue now has a funding of {natural_value} " \ f"{bounty.token_name} {usdt_value} attached to it.__\n\n * If you would " \ @@ -399,21 +400,44 @@ def build_github_notification(bounty, event_name, profile_pairs=None): "* Questions? Checkout Gitcoin Help or Gitcoin Slack\n * " \ f"${amount_open_work} more funded OSS Work available on the [Gitcoin Issue Explorer](https://gitcoin.co/explorer)\n" elif event_name == 'work_started': + interested = bounty.interested.all().order_by('created') + # interested_plural = "s" if interested.count() != 0 else "" from_now = naturaltime(bounty.expires_date) - msg = f"{status_header}__Work has been started__.\n{profiles} has committed to working on this project to be " \ - f"completed {from_now}.\n\n" + started_work = bounty.interested.filter(pending=False).all() + # pending_approval = bounty.interested.filter(pending=True).all() bounty_owner_clear = f"@{bounty.bounty_owner_github_username}" if bounty.bounty_owner_github_username else "" - try: - if profile_pairs: - msg += f"\n{bounty_owner_clear}, __please see the below comments / questions regarding approach for " \ - "this ticket from the bounty hunter(s):__ " - for profile in profile_pairs: - interests = Interest.objects.filter(profile__handle=profile[0], bounty=bounty) - for interest in interests: - if interest.issue_message.strip(): - msg += f"\n- [@{profile[0]}]({profile[1]}): {interest.issue_message}\n\n" - except Exception as e: - print(e) + approval_required = bounty.permission_type == 'approval' + + if started_work.exists(): + msg = f"{status_header}__Work has been started__.\n\n" + else: + msg = f"{status_header}__Workers have applied to start work__.\n\n" + + msg += f"\nThese users each claimed they can complete the work by {from_now}. " \ + "Please review their questions below:\n\n" + + for i, interest in enumerate(interested, start=1): + + profile_link = f"[{interest.profile.handle}]({interest.profile.url})" + action = "started work" + if interest.pending: + action = 'applied to start work' + action += f" _(Funders only: [approve worker]({bounty.approve_worker_url(interest.profile.handle)})" + action += f" | [reject worker]({bounty.reject_worker_url(interest.profile.handle)}))_" + if not interest.pending and approval_required: + action = 'been approved to start work' + + show_dibs = len(interested.count()) > 1 and bounty.project_type == 'traditional' + dibs = f" ({get_ordinal_repr(i)} dibs)" if show_dibs else "" + + msg += f"\n{i}. {profile_link} has {action}{dibs}. " + + issue_message = interest.issue_message.strip() + if issue_message: + msg += f"\t\n * Q: " \ + f"{issue_message}" + msg += "\n\n" + elif event_name == 'work_submitted': sub_msg = "" if bounty.fulfillments.exists(): @@ -424,6 +448,13 @@ def build_github_notification(bounty, event_name, profile_pairs=None): link_to_work = f"[PR]({bf.fulfiller_github_url})" if bf.fulfiller_github_url else "(Link Not Provided)" sub_msg += f"* {link_to_work} by {username}\n" + profiles = "" + if profile_pairs: + for i, profile in enumerate(profile_pairs, start=1): + profiles = profiles + f"\n {i}. [@{profile[0]}]({profile[1]})" + profiles += "\n\n" + + msg = f"{status_header}__Work for {natural_value} {bounty.token_name} {usdt_value} has been submitted by__:\n" \ f"{profiles}{sub_msg}\n
{% trans "The funder is approving workers for this issue. Please submit the form below, and if you are selected to work on this bouty, you will be notified via email." %}
+