diff --git a/app/app/urls.py b/app/app/urls.py index c2d33c8a6bd..ca13331fc47 100644 --- a/app/app/urls.py +++ b/app/app/urls.py @@ -100,6 +100,11 @@ dashboard.views.profile_job_opportunity, name='profile_job_opportunity' ), + url( + r'^api/v0.1/bountydocument', + dashboard.views.bounty_upload_nda, + name='bounty_upload_nda' + ), url(r'^api/v0.1/faucet/save/?', faucet.views.save_faucet, name='save_faucet'), url(r'^api/v0.1/', include(dbrouter.urls)), url(r'^api/v0.1/', include(kdrouter.urls)), diff --git a/app/app/utils.py b/app/app/utils.py index f74f14ed319..9bc6743cf4c 100644 --- a/app/app/utils.py +++ b/app/app/utils.py @@ -1,9 +1,11 @@ import email import imaplib import logging +import os import re import time from hashlib import sha1 +from secrets import token_hex from django.conf import settings from django.contrib.auth.models import User @@ -17,7 +19,6 @@ import requests from avatar.models import SocialAvatar from avatar.utils import get_svg_templates, get_user_github_avatar_image -from dashboard.models import Profile from geoip2.errors import AddressNotFoundError from git.utils import _AUTH, HEADERS, get_user from ipware.ip import get_real_ip @@ -161,6 +162,7 @@ def setup_lang(request, user): DoesNotExist: The exception is raised if no profile is found for the specified handle. """ + from dashboard.models import Profile profile = None if user.is_authenticated and hasattr(user, 'profile'): profile = user.profile @@ -174,7 +176,14 @@ def setup_lang(request, user): request.session.modified = True +def get_upload_filename(instance, filename): + salt = token_hex(16) + file_path = os.path.basename(filename) + return f"docs/{getattr(instance, '_path', '')}/{salt}/{file_path}" + + def sync_profile(handle, user=None, hide_profile=True): + from dashboard.models import Profile handle = handle.strip().replace('@', '').lower() data = get_user(handle) email = '' diff --git a/app/assets/v2/css/base.css b/app/assets/v2/css/base.css index bd2520c93c7..fd2978a90a4 100644 --- a/app/assets/v2/css/base.css +++ b/app/assets/v2/css/base.css @@ -1654,3 +1654,27 @@ div.busyOverlay { border: 0.25em solid currentColor; border-radius: 100px; } + +.cta-blue { + background: #0D0764; + color: white; + padding: 1.8rem; + border-radius: 0.2rem; +} + +.privaterepo-instructions { + background: #F8F8F8; + padding: 1rem; +} + +@media (min-width: 768px) { + .privaterepo-instructions { + width: 75%; + } + +} + +.g-modal .modal-header, +.g-modal .modal-footer { + border: none; +} diff --git a/app/assets/v2/css/forms/checkbox.css b/app/assets/v2/css/forms/checkbox.css index 0278371deea..ed4a7a0bb4e 100644 --- a/app/assets/v2/css/forms/checkbox.css +++ b/app/assets/v2/css/forms/checkbox.css @@ -16,7 +16,7 @@ background: #fff; border: 1px solid #dbdbdb; border-radius: 2px; - content: url('/static/v2/images/check.svg'); + content: ''; display: inline-flex; justify-content: center; height: 18px; @@ -30,6 +30,12 @@ color: #D50000; } +.form__checkbox input:disabled ~ .form__label::before { + background-color: #d0d0d0 !important; + border-color: #d0d0d0 !important; + cursor: not-allowed; +} + .form__checkbox label.error { display: none !important; } @@ -37,4 +43,5 @@ .form__checkbox input:checked ~ .form__label::before { background-color: #0D0764; border-color: #0D0764; + content: url('/static/v2/images/check.svg'); } diff --git a/app/assets/v2/css/forms/select.css b/app/assets/v2/css/forms/select.css index 4db09369b23..2438daed591 100644 --- a/app/assets/v2/css/forms/select.css +++ b/app/assets/v2/css/forms/select.css @@ -52,7 +52,7 @@ .form__select2 .select2-container { display: inline-block !important; - width: 100% !important; + min-width: 100%; height: 100%; font-size: 14px; } @@ -161,4 +161,4 @@ .select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover { color: #ffffff; -} \ No newline at end of file +} diff --git a/app/assets/v2/images/emails/private-repo-1.png b/app/assets/v2/images/emails/private-repo-1.png new file mode 100644 index 00000000000..72a2bd9fc94 Binary files /dev/null and b/app/assets/v2/images/emails/private-repo-1.png differ diff --git a/app/assets/v2/images/emails/private-repo-2.png b/app/assets/v2/images/emails/private-repo-2.png new file mode 100644 index 00000000000..142e89d8ca4 Binary files /dev/null and b/app/assets/v2/images/emails/private-repo-2.png differ diff --git a/app/assets/v2/images/emails/private-repo-3.png b/app/assets/v2/images/emails/private-repo-3.png new file mode 100644 index 00000000000..02ebbaab1ca Binary files /dev/null and b/app/assets/v2/images/emails/private-repo-3.png differ diff --git a/app/assets/v2/images/emails/private-repo-4.png b/app/assets/v2/images/emails/private-repo-4.png new file mode 100644 index 00000000000..52085a0c1ef Binary files /dev/null and b/app/assets/v2/images/emails/private-repo-4.png differ diff --git a/app/assets/v2/images/repo-instructions.png b/app/assets/v2/images/repo-instructions.png new file mode 100644 index 00000000000..4de1b7b6e90 Binary files /dev/null and b/app/assets/v2/images/repo-instructions.png differ diff --git a/app/assets/v2/images/repo-settings.png b/app/assets/v2/images/repo-settings.png new file mode 100644 index 00000000000..b959095f08b Binary files /dev/null and b/app/assets/v2/images/repo-settings.png differ diff --git a/app/assets/v2/images/robots-party.svg b/app/assets/v2/images/robots-party.svg new file mode 100644 index 00000000000..f4c3ef473a6 --- /dev/null +++ b/app/assets/v2/images/robots-party.svg @@ -0,0 +1,537 @@ + + + + robots-party + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/assets/v2/js/ajax-helper.js b/app/assets/v2/js/ajax-helper.js index 102b537ba33..ecdea817a6f 100644 --- a/app/assets/v2/js/ajax-helper.js +++ b/app/assets/v2/js/ajax-helper.js @@ -2,14 +2,14 @@ * Generic function to make an AJAX call avoid DRY * * ex: - * var getdata = fetchData ('/api/v0.1/data/','GET') - * $.when( getdata ).then( function ( response ){ return response }) + * var getdata = fetchData('/api/v0.1/data/','GET') + * $.when(getdata).then(function(response){ return response }) * - * var sendForm = fetchData ( e.currentTarget.action, + * var sendForm = fetchData(e.currentTarget.action, * e.currentTarget.method, * $("#form-wallets").serialize() * ) - * $.when( sendForm ).then( function ( payback ){ return payback }) + * $.when(sendForm).then(function(payback){ return payback }) * */ diff --git a/app/assets/v2/js/pages/bounty_details.js b/app/assets/v2/js/pages/bounty_details.js index 8ac9dfd813b..bb94c2f6561 100644 --- a/app/assets/v2/js/pages/bounty_details.js +++ b/app/assets/v2/js/pages/bounty_details.js @@ -633,51 +633,132 @@ var attach_override_status = function() { }); }; - var show_interest_modal = function() { var self = this; + let modals = $('#modalInterest'); + let modalBody = $('#modalInterest .modal-content'); + let modalUrl = `/interest/modal?redirect=${window.location.pathname}&pk=${document.result['pk']}`; + + modals.on('show.bs.modal', function() { + modalBody.load(modalUrl, ()=> { + if (document.result['repo_type'] === 'private') { + $('#nda-upload').show(); + $('#issueNDA').prop('required', true); + document.result.unsigned_nda ? $('.nda-download-link').attr('href', document.result.unsigned_nda.doc) : $('#nda-upload').hide(); + } - setTimeout(function() { - 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' - }); - - var actionPlanForm = modal.find('form#action_plan'); - var issueMessage = actionPlanForm.find('#issue_message'); + let actionPlanForm = $('#action_plan'); + let issueMessage = $('#issue_message'); issueMessage.attr('placeholder', gettext('What steps will you take to complete this task? (min 30 chars)')); - modal.on('submit', function(event) { + actionPlanForm.on('submit', function(event) { event.preventDefault(); - var msg = issueMessage.val().trim(); + let msg = issueMessage.val().trim(); + let issueNDA = $('#issueNDA')[0].files; if (!msg || msg.length < 30) { _alert({message: gettext('Please provide an action plan for this ticket. (min 30 chars)')}, 'error'); return false; } + if (typeof issueNDA[0] !== 'undefined') { + const formData = new FormData(); + + formData.append('docs', issueNDA[0]); + formData.append('doc_type', 'signed_nda'); + const ndaSend = { + url: '/api/v0.1/bountydocument', + method: 'POST', + data: formData, + processData: false, + dataType: 'json', + contentType: false + }; + + $.ajax(ndaSend).done(function(response) { + _alert(response.message, 'info'); + add_interest(document.result['pk'], { + issue_message: msg, + signed_nda: response.bounty_doc_id + }).then(success => { + if (success) { + $(self).attr('href', '/uninterested'); + $(self).find('span').text(gettext('Stop Work')); + $(self).parent().attr('title', '
' + gettext('Notify the funder that you will not be working on this project') + '
'); + $.modal.close(); + } + }).catch((error) => { + if (error.responseJSON.error === 'You may only work on max of 3 issues at once.') + return; + throw error; + }); + }).fail(function(error) { + _alert(error, 'error'); + }); + } else { + add_interest(document.result['pk'], { + issue_message: msg + }).then(success => { + if (success) { + $(self).attr('href', '/uninterested'); + $(self).find('span').text(gettext('Stop Work')); + $(self).parent().attr('title', '
' + gettext('Notify the funder that you will not be working on this project') + '
'); + $.modal.close(); + } + }).catch((error) => { + if (error.responseJSON.error === 'You may only work on max of 3 issues at once.') + return; + throw error; + }); + } - add_interest(document.result['pk'], { - issue_message: msg - }).then(success => { - if (success) { - $(self).attr('href', '/uninterested'); - $(self).find('span').text(gettext('Stop Work')); - $(self).parent().attr('title', '
' + gettext('Notify the funder that you will not be working on this project') + '
'); - $.modal.close(); - } - }).catch((error) => { - if (error.responseJSON.error === 'You may only work on max of 3 issues at once.') - return; - throw error; - }); }); + }); }); + modals.bootstrapModal('show'); +}; + +const repoInstructions = () => { + let linkToSettings = `https://github.com/${document.result.github_org_name}/${document.result.github_repo_name}/settings/collaboration`; + + + let modalTmp = ` + `; + + $(modalTmp).bootstrapModal('show'); + + $(document, modalTmp).on('hidden.bs.modal', function(e) { + $('#exampleModalCenter').remove(); + $(modalTmp).bootstrapModal('dispose'); + }); }; var set_extended_time_html = function(extendedDuration, currentExpires) { @@ -1055,7 +1136,8 @@ var do_actions = function(result) { const _entry = { enabled: true, href: github_url, - text: gettext('View On Github') + + text: (result['repo_type'] === 'private' ? ' ' + + gettext('Private Repo') : gettext('View On Github')) + (result['is_issue_closed'] ? gettext(' (Issue is closed)') : ''), parent: 'right_actions', title: gettext('View issue details and comments on Github'), @@ -1266,6 +1348,10 @@ var pull_bounty_from_api = function() { render_activity(result, results); document.result = result; + + if (typeof promptPrivateInstructions !== 'undefined' && result.repo_type === 'private') { + repoInstructions(); + } return; } } @@ -1324,6 +1410,12 @@ const process_activities = function(result, bounty_activities) { const fulfillment = meta.fulfillment || {}; const new_bounty = meta.new_bounty || {}; const old_bounty = meta.old_bounty || {}; + const has_signed_nda = result.interested.map(interest => { + if (interest.profile.handle === _activity.profile.handle && interest.signed_nda) { + return interest.signed_nda.doc; + } + return false; + }); const has_pending_interest = !!result.interested.find(interest => interest.profile.handle === _activity.profile.handle && interest.pending); const has_interest = !!result.interested.find(interest => @@ -1357,6 +1449,7 @@ const process_activities = function(result, bounty_activities) { age: timeDifference(now, new Date(_activity.created)), activity_type: _activity.activity_type, status: _activity.activity_type === 'work_started' ? 'started' : 'stopped', + signed_nda: has_signed_nda, uninterest_possible: uninterest_possible, slash_possible: slash_possible, approve_worker_url: meta.approve_worker_url, diff --git a/app/assets/v2/js/pages/new_bounty.js b/app/assets/v2/js/pages/new_bounty.js index 56fd6d41847..42fbd065c21 100644 --- a/app/assets/v2/js/pages/new_bounty.js +++ b/app/assets/v2/js/pages/new_bounty.js @@ -52,6 +52,16 @@ $('#sync-issue').on('click', function(event) { }); $('#issueURL').focusout(function() { + if (isPrivateRepo) { + setPrivateForm(); + if ($('input[name=issueURL]').val() == '' || !validURL($('input[name=issueURL]').val())) { + $('.js-submit').addClass('disabled'); + } else { + $('.js-submit').removeClass('disabled'); + } + return; + } + setInterval(function() { $('#last-synced span').html(timeDifference(new Date(), new_bounty.last_sync)); }, 6000); @@ -101,7 +111,7 @@ $(document).ready(function() { $('input[name=hours]').blur(setUsdAmount); $('select[name=denomination]').change(setUsdAmount); $('select[name=denomination]').change(promptForAuth); - $('input[name=issueURL]').blur(retrieveIssueDetails); + setTimeout(setUsdAmount, 1000); waitforWeb3(function() { promptForAuth(); @@ -144,10 +154,9 @@ $(document).ready(function() { $('input[name=revisions]').val(revision); }); - if ($('input[name=issueURL]').val() != '') { + if ($('input[name=issueURL]').val() != '' && !isPrivateRepo) { retrieveIssueDetails(); } - $('input[name=issueURL]').focus(); // all js select 2 fields $('.js-select2').each(function() { @@ -166,6 +175,7 @@ $(document).ready(function() { if ($('input[name=amount]').val().trim().length > 0) { setUsdAmount(); } + var open_hiring_panel = function(do_focus) { setTimeout(function() { var hiringRightNow = $('#hiringRightNow').is(':checked'); @@ -222,6 +232,10 @@ $(document).ready(function() { data[this.name] = this.value; }); + if (data.repo_type == 'private' && data.project_type != 'traditional' && data.permission_type != 'approval') { + _alert(gettext('The project type and/or permission type of bounty does not validate for a private repo')); + } + disabled.attr('disabled', 'disabled'); // setup @@ -251,6 +265,7 @@ $(document).ready(function() { estimatedHours: data.hours, fundingOrganisation: data.fundingOrganisation, is_featured: data.featuredBounty, + repo_type: data.repo_type, featuring_date: data.featuredBounty && ((new Date().getTime() / 1000) | 0) || 0, reservedFor: reservedFor ? reservedFor.text : '', tokenName @@ -290,6 +305,7 @@ $(document).ready(function() { }, funding_organisation: metadata.fundingOrganisation, is_featured: metadata.is_featured, + repo_type: metadata.repo_type, featuring_date: metadata.featuring_date, privacy_preferences: privacy_preferences, funders: [], @@ -400,7 +416,6 @@ $(document).ready(function() { issuePackage['txid'] = result; localStorage[issueURL] = JSON.stringify(issuePackage); - // sync db syncDb(); } @@ -449,9 +464,29 @@ $(document).ready(function() { } var do_bounty = function(callback) { - // Add data to IPFS and kick off all the callbacks. - ipfsBounty.payload.issuer.address = account; - ipfs.addJson(ipfsBounty, newIpfsCallback); + const formData = new FormData(); + + formData.append('docs', $('#issueNDA')[0].files[0]); + formData.append('doc_type', 'unsigned_nda'); + const settings = { + url: '/api/v0.1/bountydocument', + method: 'POST', + processData: false, + dataType: 'json', + contentType: false, + data: formData + }; + + $.ajax(settings).done(function(response) { + _alert(response.message, 'info'); + // sync db + // Add data to IPFS and kick off all the callbacks. + ipfsBounty.payload.issuer.address = account; + ipfsBounty.payload.unsigned_nda = response.bounty_doc_id; + ipfs.addJson(ipfsBounty, newIpfsCallback); + }).fail(function(error) { + _alert(error, 'error'); + }); }; const payFeaturedBounty = function() { @@ -483,6 +518,21 @@ $(document).ready(function() { }); }); +$(window).on('load', function() { + if (params.has('type')) { + let checked = params.get('type'); + + toggleCtaPlan(checked); + $(`input[name=repo_type][value=${checked}]`).prop('checked', 'true'); + } else { + params.append('type', 'public'); + window.history.replaceState({}, '', location.pathname + '?' + params); + } + $('input[name=repo_type]').change(function() { + toggleCtaPlan($(this).val()); + }); +}); + var check_balance_and_alert_user_if_not_enough = function(tokenAddress, amount) { var token_contract = web3.eth.contract(token_abi).at(tokenAddress); var from = web3.eth.coinbase; @@ -510,3 +560,69 @@ getAmountEstimate(usdFeaturedPrice, 'ETH', (amountEstimate) => { ethFeaturedPrice = amountEstimate['value']; $('.featured-price-eth').text(`+${amountEstimate['value']} ETH`); }); + + +let isPrivateRepo = false; +let params = (new URL(document.location)).searchParams; + +const setPrivateForm = () => { + $('#title').removeClass('hidden'); + $('#description, #title').prop('readonly', false); + $('#description, #title').prop('required', true); + $('#no-issue-banner').hide(); + $('#issue-details, #issue-details-edit').show(); + $('#sync-issue').removeClass('disabled'); + // $('.js-submit').removeClass('disabled'); + $('#last-synced, #edit-issue, #sync-issue, #title--text').hide(); + + $('#admin_override_suspend_auto_approval').prop('checked', false); + $('#admin_override_suspend_auto_approval').attr('disabled', true); + $('#show_email_publicly').attr('disabled', true); + $('#cta-subscription, #private-repo-instructions').removeClass('d-md-none'); + $('#nda-upload').show(); + $('#issueNDA').prop('required', true); + + $('#project_type').select2().val('traditional'); + $('#permission_type').select2().val('approval'); + $('#project_type, #permission_type').select2().prop('disabled', true).trigger('change'); + $('#keywords').select2({ + placeholder: 'Select tags', + tags: 'true', + allowClear: true + }); +}; + +const setPublicForm = () => { + $('#title').addClass('hidden'); + $('#description, #title').prop('readonly', true); + $('#no-issue-banner').show(); + $('#issue-details, #issue-details-edit').hide(); + $('#sync-issue').addClass('disabled'); + $('.js-submit').addClass('disabled'); + $('#last-synced, #edit-issue , #sync-issue, #title--text').show(); + + $('#admin_override_suspend_auto_approval').prop('checked', true); + $('#admin_override_suspend_auto_approval').attr('disabled', false); + $('#show_email_publicly').attr('disabled', false); + $('#cta-subscription, #private-repo-instructions').addClass('d-md-none'); + $('#nda-upload').hide(); + $('#issueNDA').prop('required', false); + + $('#project_type, #permission_type').select2().prop('disabled', false).trigger('change'); + retrieveIssueDetails(); +}; + +const toggleCtaPlan = (value) => { + if (value === 'private') { + + params.set('type', 'private'); + isPrivateRepo = true; + setPrivateForm(); + } else { + + params.set('type', 'public'); + isPrivateRepo = false; + setPublicForm(); + } + window.history.replaceState({}, '', location.pathname + '?' + params); +}; diff --git a/app/dashboard/helpers.py b/app/dashboard/helpers.py index 7ec3866ef99..703006b6e54 100644 --- a/app/dashboard/helpers.py +++ b/app/dashboard/helpers.py @@ -30,7 +30,7 @@ from django.utils import timezone from app.utils import get_semaphore, sync_profile -from dashboard.models import Activity, Bounty, BountyFulfillment, BountySyncRequest, UserAction +from dashboard.models import Activity, Bounty, BountyDocuments, BountyFulfillment, BountySyncRequest, UserAction from dashboard.notifications import ( maybe_market_to_email, maybe_market_to_github, maybe_market_to_slack, maybe_market_to_twitter, maybe_market_to_user_discord, maybe_market_to_user_slack, @@ -364,6 +364,13 @@ def create_new_bounty(old_bounties, bounty_payload, bounty_details, bounty_id): url = normalize_url(url) else: raise UnsupportedSchemaException('No webReferenceURL found. Cannot continue!') + + # check conditions for private repos + if metadata.get('repo_type', None) == 'private' and \ + bounty_payload.get('schemes', {}).get('permission_type', 'permissionless') != 'approval' and \ + bounty_payload.get('schemes', {}).get('project_type', 'traditional') != 'traditional': + raise UnsupportedSchemaException('The project type or permission does not match for private repo') + # Check if we have any fulfillments. If so, check if they are accepted. # If there are no fulfillments, accepted is automatically False. @@ -403,6 +410,11 @@ def create_new_bounty(old_bounties, bounty_payload, bounty_details, bounty_id): } if not latest_old_bounty: schemes = bounty_payload.get('schemes', {}) + unsigned_nda = None + if bounty_payload.get('unsigned_nda', None): + unsigned_nda = BountyDocuments.objects.filter( + pk=bounty_payload.get('unsigned_nda') + ).first() bounty_kwargs.update({ # info to xfr over from latest_old_bounty as override fields (this is because sometimes # ppl dont login when they first submit issue and it needs to be overridden) @@ -433,6 +445,8 @@ def create_new_bounty(old_bounties, bounty_payload, bounty_details, bounty_id): 'featuring_date': timezone.make_aware( timezone.datetime.fromtimestamp(metadata.get('featuring_date', 0)), timezone=UTC), + 'repo_type': metadata.get('repo_type', None), + 'unsigned_nda': unsigned_nda, 'bounty_owner_github_username': bounty_issuer.get('githubUsername', ''), 'bounty_owner_address': bounty_issuer.get('address', ''), 'bounty_owner_email': bounty_issuer.get('email', ''), @@ -448,7 +462,8 @@ def create_new_bounty(old_bounties, bounty_payload, bounty_details, bounty_id): 'bounty_owner_github_username', 'bounty_owner_address', 'bounty_owner_email', 'bounty_owner_name', 'github_comments', 'override_status', 'last_comment_date', 'snooze_warnings_for_days', 'admin_override_and_hide', 'admin_override_suspend_auto_approval', 'admin_mark_as_remarket_ready', - 'funding_organisation', 'bounty_reserved_for_user', 'is_featured', 'featuring_date', + 'funding_organisation', 'bounty_reserved_for_user', 'is_featured', 'featuring_date', 'repo_type', + 'unsigned_nda' ], ) if latest_old_bounty_dict['bounty_reserved_for_user']: diff --git a/app/dashboard/migrations/0021_auto_20190319_1502.py b/app/dashboard/migrations/0021_auto_20190319_1502.py new file mode 100644 index 00000000000..bd6c476d931 --- /dev/null +++ b/app/dashboard/migrations/0021_auto_20190319_1502.py @@ -0,0 +1,49 @@ +# Generated by Django 2.1.7 on 2019-03-19 15:02 + +import app.utils +from django.db import migrations, models +import django.db.models.deletion +import economy.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dashboard', '0020_auto_20190312_0739'), + ] + + operations = [ + migrations.CreateModel( + name='BountyDocuments', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_on', models.DateTimeField(db_index=True, default=economy.models.get_time)), + ('modified_on', models.DateTimeField(default=economy.models.get_time)), + ('doc', models.FileField(blank=True, help_text='Bounty documents.', null=True, upload_to=app.utils.get_upload_filename)), + ('doc_type', models.CharField(max_length=50)), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='bounty', + name='repo_type', + field=models.CharField(choices=[('public', 'public'), ('private', 'private')], default='public', max_length=50), + ), + migrations.AlterField( + model_name='profile', + name='resume', + field=models.FileField(blank=True, help_text='The profile resume.', null=True, upload_to=app.utils.get_upload_filename), + ), + migrations.AddField( + model_name='bounty', + name='unsigned_nda', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='unsignednda', to='dashboard.BountyDocuments'), + ), + migrations.AddField( + model_name='interest', + name='signed_nda', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='signednda', to='dashboard.BountyDocuments'), + ), + ] diff --git a/app/dashboard/migrations/0022_merge_20190325_1402.py b/app/dashboard/migrations/0022_merge_20190325_1402.py new file mode 100644 index 00000000000..4045004127a --- /dev/null +++ b/app/dashboard/migrations/0022_merge_20190325_1402.py @@ -0,0 +1,14 @@ +# Generated by Django 2.1.7 on 2019-03-25 14:02 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dashboard', '0021_auto_20190319_1502'), + ('dashboard', '0021_profile_custom_tagline'), + ] + + operations = [ + ] diff --git a/app/dashboard/migrations/0023_auto_20190325_1404.py b/app/dashboard/migrations/0023_auto_20190325_1404.py new file mode 100644 index 00000000000..f27d292c609 --- /dev/null +++ b/app/dashboard/migrations/0023_auto_20190325_1404.py @@ -0,0 +1,29 @@ +# Generated by Django 2.1.7 on 2019-03-25 14:04 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dashboard', '0022_merge_20190325_1402'), + ] + + operations = [ + migrations.AlterField( + model_name='bounty', + name='unsigned_nda', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bounty', to='dashboard.BountyDocuments'), + ), + migrations.AlterField( + model_name='bountyinvites', + name='bounty', + field=models.ManyToManyField(blank=True, related_name='bountyinvites', to='dashboard.Bounty'), + ), + migrations.AlterField( + model_name='interest', + name='signed_nda', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='interest', to='dashboard.BountyDocuments'), + ), + ] diff --git a/app/dashboard/models.py b/app/dashboard/models.py index 2d60b4aafba..13f73df0f99 100644 --- a/app/dashboard/models.py +++ b/app/dashboard/models.py @@ -42,7 +42,7 @@ import pytz import requests -from avatar.utils import get_upload_filename +from app.utils import get_upload_filename from dashboard.tokens import addr_to_token from economy.models import ConversionRate, SuperModel from economy.utils import ConversionRateNotFoundError, convert_amount, convert_token_to_usdt @@ -177,6 +177,10 @@ class Bounty(SuperModel): ('permissionless', 'permissionless'), ('approval', 'approval'), ] + REPO_TYPES = [ + ('public', 'public'), + ('private', 'private'), + ] PROJECT_TYPES = [ ('traditional', 'traditional'), ('contest', 'contest'), @@ -271,10 +275,12 @@ class Bounty(SuperModel): canceled_bounty_reason = models.TextField(default='', blank=True, verbose_name=_('Cancelation reason')) project_type = models.CharField(max_length=50, choices=PROJECT_TYPES, default='traditional') permission_type = models.CharField(max_length=50, choices=PERMISSION_TYPES, default='permissionless') + repo_type = models.CharField(max_length=50, choices=REPO_TYPES, default='public') snooze_warnings_for_days = models.IntegerField(default=0) is_featured = models.BooleanField( default=False, help_text=_('Whether this bounty is featured')) featuring_date = models.DateTimeField(blank=True, null=True) + unsigned_nda = models.ForeignKey('dashboard.BountyDocuments', blank=True, null=True, related_name='bounty', on_delete=models.SET_NULL) token_value_time_peg = models.DateTimeField(blank=True, null=True) token_value_in_usdt = models.DecimalField(default=0, decimal_places=2, max_digits=50, blank=True, null=True) @@ -1102,6 +1108,12 @@ def __str__(self): return f"{self.email} {self.created_on}" +class BountyDocuments(SuperModel): + + doc = models.FileField(upload_to=get_upload_filename, null=True, blank=True, help_text=_('Bounty documents.')) + doc_type = models.CharField(max_length=50) + + class SendCryptoAssetQuerySet(models.QuerySet): """Handle the manager queryset for SendCryptoAsset.""" @@ -1457,6 +1469,7 @@ class Interest(SuperModel): max_length=7, help_text=_('Whether or not the interest requires review'), verbose_name=_('Needs Review')) + signed_nda = models.ForeignKey('dashboard.BountyDocuments', blank=True, null=True, related_name='interest', on_delete=models.SET_NULL) # Interest QuerySet Manager objects = InterestQuerySet.as_manager() @@ -1745,7 +1758,7 @@ class BountyInvites(SuperModel): ('completed', 'completed'), ] - bounty = models.ManyToManyField('dashboard.Bounty', related_name='bounty', blank=True) + bounty = models.ManyToManyField('dashboard.Bounty', related_name='bountyinvites', blank=True) inviter = models.ManyToManyField(User, related_name='inviter', blank=True) invitee = models.ManyToManyField(User, related_name='invitee', blank=True) status = models.CharField(max_length=20, choices=INVITE_STATUS, blank=True) @@ -1838,7 +1851,7 @@ class Profile(SuperModel): job_salary = models.DecimalField(default=1, decimal_places=2, max_digits=50) job_location = JSONField(default=dict, blank=True) linkedin_url = models.CharField(max_length=255, default='', blank=True, null=True) - resume = models.FileField(upload_to=get_upload_filename, null=True, blank=True, help_text=_('The avatar SVG.')) + resume = models.FileField(upload_to=get_upload_filename, null=True, blank=True, help_text=_('The profile resume.')) objects = ProfileQuerySet.as_manager() diff --git a/app/dashboard/router.py b/app/dashboard/router.py index 25b1d86d399..f8a1c44447d 100644 --- a/app/dashboard/router.py +++ b/app/dashboard/router.py @@ -25,7 +25,9 @@ from rest_framework import routers, serializers, viewsets from retail.helpers import get_ip -from .models import Activity, Bounty, BountyFulfillment, BountyInvites, Interest, ProfileSerializer, SearchHistory +from .models import ( + Activity, Bounty, BountyDocuments, BountyFulfillment, BountyInvites, Interest, ProfileSerializer, SearchHistory, +) class BountyFulfillmentSerializer(serializers.ModelSerializer): @@ -40,16 +42,26 @@ class Meta: 'fulfillment_id', 'accepted', 'profile', 'created_on', 'accepted_on', 'fulfiller_github_url') -class InterestSerializer(serializers.ModelSerializer): - """Handle serializing the Interest object.""" +class ActivitySerializer(serializers.ModelSerializer): + """Handle serializing the Activity object.""" profile = ProfileSerializer() class Meta: - """Define the Interest serializer metadata.""" + """Define the activity serializer metadata.""" - model = Interest - fields = ('profile', 'created', 'pending') + model = Activity + fields = ('activity_type', 'created', 'profile', 'metadata', 'bounty', 'tip') + + +class BountyDocumentsSerializer(serializers.ModelSerializer): + """Handle serializing the Activity object.""" + + class Meta: + """Define the activity serializer metadata.""" + + model = BountyDocuments + fields = ('doc', 'doc_type') class KudosSerializer(serializers.ModelSerializer): @@ -76,6 +88,18 @@ class Meta: fields = ('activity_type', 'created', 'profile', 'metadata', 'bounty', 'tip', 'kudos') +class InterestSerializer(serializers.ModelSerializer): + """Handle serializing the Interest object.""" + + profile = ProfileSerializer() + signed_nda = BountyDocumentsSerializer() + + class Meta: + """Define the Interest serializer metadata.""" + model = Interest + fields = ('profile', 'created', 'pending', 'signed_nda') + + # Serializers define the API representation. class BountySerializer(serializers.HyperlinkedModelSerializer): """Handle serializing the Bounty object.""" @@ -83,6 +107,7 @@ class BountySerializer(serializers.HyperlinkedModelSerializer): fulfillments = BountyFulfillmentSerializer(many=True) interested = InterestSerializer(many=True) activities = ActivitySerializer(many=True) + unsigned_nda = BountyDocumentsSerializer(many=False) bounty_owner_email = serializers.SerializerMethodField('override_bounty_owner_email') bounty_owner_name = serializers.SerializerMethodField('override_bounty_owner_name') @@ -112,8 +137,8 @@ class Meta: 'fulfillment_submitted_on', 'fulfillment_started_on', 'canceled_on', 'canceled_bounty_reason', 'action_urls', 'project_type', 'permission_type', 'attached_job_description', 'needs_review', 'github_issue_state', 'is_issue_closed', 'additional_funding_summary', 'funding_organisation', 'paid', - 'admin_override_suspend_auto_approval', 'reserved_for_user_handle', 'is_featured', 'featuring_date', - 'funder_last_messaged_on', + 'admin_override_suspend_auto_approval', 'reserved_for_user_handle', 'is_featured', 'featuring_date', 'repo_type', + 'unsigned_nda', 'funder_last_messaged_on', ) def create(self, validated_data): @@ -149,7 +174,7 @@ def update(self, validated_data): class BountyViewSet(viewsets.ModelViewSet): """Handle the Bounty view behavior.""" - queryset = Bounty.objects.prefetch_related('fulfillments', 'interested', 'interested__profile', 'activities') \ + queryset = Bounty.objects.prefetch_related('fulfillments', 'interested', 'interested__profile', 'activities', 'unsigned_nda') \ .all().order_by('-web3_created') serializer_class = BountySerializer filter_backends = (django_filters.rest_framework.DjangoFilterBackend,) @@ -163,7 +188,7 @@ def get_queryset(self): """ param_keys = self.request.query_params.keys() queryset = Bounty.objects.prefetch_related( - 'fulfillments', 'interested', 'interested__profile', 'activities') + 'fulfillments', 'interested', 'interested__profile', 'activities', 'unsigned_nda') if 'not_current' not in param_keys: queryset = queryset.current() @@ -286,6 +311,11 @@ def get_queryset(self): is_open=True, ) + if 'repo_type' in param_keys: + queryset = queryset.filter( + repo_type=self.request.query_params.get('repo_type'), + ) + # order order_by = self.request.query_params.get('order_by') if order_by and order_by != 'null': diff --git a/app/dashboard/templates/addinterest.html b/app/dashboard/templates/addinterest.html index b73f8443484..f229e468623 100644 --- a/app/dashboard/templates/addinterest.html +++ b/app/dashboard/templates/addinterest.html @@ -15,10 +15,10 @@ along with this program. If not, see . {% endcomment %} {% load i18n static %} -
+
-
- +
+
{% trans "Submit a Plan" %}
@@ -26,6 +26,18 @@
{% trans "Submit a Plan" %}
{% if user_logged_in %}
+
[[else activity_type == 'start_work' || activity_type == 'worker_approved' || activity_type == 'bounty_abandonment_warning' || activity_type == 'worker_applied']] -
+
[[:text]] + [[if signed_nda ]] + View signed NDA + [[/if]]
[[if can_approve_worker ]]
@@ -402,6 +405,15 @@
{% trans "Funder" %}
+ {% include 'shared/bottom_notification.html' %} {% include 'shared/analytics.html' %} @@ -429,6 +441,7 @@
{% trans "Funder" %}
@@ -437,4 +450,12 @@
{% trans "Funder" %}
+ {% for message in messages %} + {% if message.tags == 'success'%} + + {% endif %} + + {% endfor %} diff --git a/app/dashboard/templates/bounty/new.html b/app/dashboard/templates/bounty/new.html index a4f95cf6184..b5cbc7e69c0 100644 --- a/app/dashboard/templates/bounty/new.html +++ b/app/dashboard/templates/bounty/new.html @@ -52,6 +52,63 @@

{% trans "Fund Issue" %}

+
+

Github Repo Type

+
+ + +
+ +
+ + +
+ + + +
+
+
Important Notes on Private Repo Bounties
+
    +
  • Please make sure you are the repo admin to fund the issue
  • +
  • Gitcoin will NOT have any access to your private repo
  • +
  • You have to approve and give repository access to each contributor before they can start work
  • +
  • You can upload an NDA that each contributor has to sign before they can start work
  • +
+
+
+ +
+
+ +
+
{% trans "About" %}
@@ -67,7 +124,7 @@
{% trans "About" %}
- +

@@ -154,7 +211,7 @@
{% trans "Gas Settings" %}
- + Your transaction is secured by the audited StandardBounties contract on the Ethereum blockchain.
Learn more here. diff --git a/app/dashboard/templates/shared/issue_details.html b/app/dashboard/templates/shared/issue_details.html index 1b6de94d82f..d6d7cff3637 100644 --- a/app/dashboard/templates/shared/issue_details.html +++ b/app/dashboard/templates/shared/issue_details.html @@ -18,7 +18,7 @@
{% trans "Details" %}
- @@ -26,7 +26,7 @@
{% trans "Details" %}
- diff --git a/app/dashboard/views.py b/app/dashboard/views.py index 7524bd33f3e..2edae9987cf 100644 --- a/app/dashboard/views.py +++ b/app/dashboard/views.py @@ -60,8 +60,8 @@ from .helpers import get_bounty_data_for_activity, handle_bounty_views from .models import ( - Activity, Bounty, BountyFulfillment, BountyInvites, CoinRedemption, CoinRedemptionRequest, FeedbackEntry, Interest, - LabsResearch, Profile, ProfileSerializer, Subscription, Tool, ToolVote, UserAction, + Activity, Bounty, BountyDocuments, BountyFulfillment, BountyInvites, CoinRedemption, CoinRedemptionRequest, + FeedbackEntry, Interest, LabsResearch, Profile, ProfileSerializer, Subscription, Tool, ToolVote, UserAction, ) from .notifications import ( maybe_market_tip_to_email, maybe_market_tip_to_github, maybe_market_tip_to_slack, maybe_market_to_email, @@ -158,7 +158,7 @@ def helper_handle_access_token(request, access_token): request.session['profile_id'] = profile.pk -def create_new_interest_helper(bounty, user, issue_message): +def create_new_interest_helper(bounty, user, issue_message, signed_nda=None): approval_required = bounty.permission_type == 'approval' acceptance_date = timezone.now() if not approval_required else None profile_id = user.profile.pk @@ -168,6 +168,7 @@ def create_new_interest_helper(bounty, user, issue_message): issue_message=issue_message, pending=approval_required, acceptance_date=acceptance_date, + signed_nda=signed_nda, ) bounty.interested.add(interest) record_user_action(user, 'start_work', interest) @@ -271,7 +272,12 @@ def new_interest(request, bounty_id): status=401) except Interest.DoesNotExist: issue_message = request.POST.get("issue_message") - interest = create_new_interest_helper(bounty, request.user, issue_message) + signed_nda = None + if request.POST.get("signed_nda", None): + signed_nda = BountyDocuments.objects.filter( + pk=request.POST.get("signed_nda") + ).first() + interest = create_new_interest_helper(bounty, request.user, issue_message, signed_nda) if interest.pending: start_work_new_applicant(interest, bounty) @@ -1335,6 +1341,34 @@ def profile_job_opportunity(request, handle): return JsonResponse(response) +@csrf_exempt +@require_POST +def bounty_upload_nda(request): + """ Save Bounty related docs like NDA. + + Args: + bounty_id (int): The bounty id. + """ + if request.FILES.get('docs', None): + bountydoc = BountyDocuments.objects.create( + doc=request.FILES.get('docs', None), + doc_type=request.POST.get('doc_type', None) + ) + response = { + 'status': 200, + 'bounty_doc_id': bountydoc.pk, + 'message': 'NDA saved' + } + else: + response = { + 'status': 400, + 'message': 'No File Found' + } + return JsonResponse(response) + + + + def profile_filter_activities(activities, activity_name): """A helper function to filter a ActivityQuerySet. diff --git a/app/retail/templates/emails/funded_featured_bounty.html b/app/retail/templates/emails/funded_featured_bounty.html index 741552a1bfb..4d6e275049e 100644 --- a/app/retail/templates/emails/funded_featured_bounty.html +++ b/app/retail/templates/emails/funded_featured_bounty.html @@ -24,7 +24,7 @@

{% trans "You've successfully funded a bounty!" %}

- Funded featured bounty + Funded featured bounty
@@ -51,6 +51,12 @@

{% trans "You've successfully funded a bounty!" %}

+
+
+ {% if bounty.is_featured == 'true' %} +
+ Featured bounty avatar +
{% if bounty.is_featured %}
Featured bounty avatar @@ -60,6 +66,41 @@

{% trans "Your bounty is now fe

{% endif %} + {% if bounty.repo_type == 'private' %} +
+

{% trans "What to do next" %}

+ +
+ Featured bounty avatar +
+

{% trans "Get Applications from Awesome Talents" %}

+

{% trans "Contributors will apply to work on your bounty with a signed NDA. You can view the applications on the Issue Details page." %}

+

({% trans "Note:" %} {% trans "Contributors will NOT have access to your private repo until you approve them)" %}

+
+
+
+ Featured bounty avatar +
+

{% trans "Approve Worker" %}

+

{% trans "Approve Worker to assign them to your bounty." %}

+
+
+
+ Featured bounty avatar +
+

{% trans "Invite Worker to Your Repo" %}

+

{% trans "Invite the approved worker to your GitHub private repo. You can do this through" %} {% trans "GitHub > Settings > Collaborators." %}

+
+
+
+ Featured bounty avatar +
+

{% trans "Complete Bounty" %}

+

{% trans "Once the bounty is completed, you can remove the contributor from your repo through GitHub." %}

+
+
+
+ {% endif %}
diff --git a/app/retail/templates/emails/template.html b/app/retail/templates/emails/template.html index fc7f7bd33db..44f1ff5ba23 100644 --- a/app/retail/templates/emails/template.html +++ b/app/retail/templates/emails/template.html @@ -165,6 +165,12 @@ margin-right: 0; } } + + @media screen and (min-width: 601px) { + .desktop-col { + display: flex; + } + }