Skip to content

Commit

Permalink
Merge pull request #5060 from gitcoinco/skill-based-invites
Browse files Browse the repository at this point in the history
Invite users to a bounty based on skills
  • Loading branch information
thelostone-mc authored Sep 19, 2019
2 parents 9e3940a + 2b69f46 commit 4731117
Show file tree
Hide file tree
Showing 6 changed files with 227 additions and 42 deletions.
2 changes: 1 addition & 1 deletion app/app/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'app.middleware.drop_accept_langauge',
'app.middleware.bleach_requests',
# 'app.middleware.bleach_requests',
'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
Expand Down
5 changes: 5 additions & 0 deletions app/app/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@
dashboard.views.social_contribution_email,
name='social_contribution_email'
),
url(
r'^api/v0.1/bulk_invite',
dashboard.views.bulk_invite,
name='bulk_invite'
),
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)),
Expand Down
52 changes: 51 additions & 1 deletion app/assets/v2/js/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,53 @@ Vue.mixin({
}
});
},
sendInviteAll: function(bountyUrl) {
let vm = this;
const apiUrlInvite = '/api/v0.1/bulk_invite/';
const postInvite = fetchData(
apiUrlInvite,
'POST',
{ 'params': vm.params, 'bountyId': bountyUrl},
{'X-CSRFToken': csrftoken}
);

$.when(postInvite).then((response) => {
console.log(response);
if (response.status !== 200) {
_alert(response.msg, 'error');

} else {
vm.$refs['user-modal'].closeModal();
_alert('The invitation has been sent', 'info');
}
});

},
getIssueDetails: function(url) {
let vm = this;
const apiUrldetails = `/actions/api/v0.1/bounties/?github_url=${encodeURIComponent(url)}`;

vm.errorIssueDetails = undefined;

if (url.indexOf('github.com/') < 0) {
vm.issueDetails = null;
vm.errorIssueDetails = 'Please paste a github issue url';
return;
}
vm.issueDetails = undefined;
const getIssue = fetchData(apiUrldetails, 'GET');

$.when(getIssue).then((response) => {
if (response[0]) {
vm.issueDetails = response[0];
vm.errorIssueDetails = undefined;
} else {
vm.issueDetails = null;
vm.errorIssueDetails = 'This issue wasn\'t bountied yet.';
}
});

},
closeModal() {
this.$refs['user-modal'].closeModal();
},
Expand Down Expand Up @@ -187,7 +234,10 @@ if (document.getElementById('gc-users-directory')) {
skills: document.keywords,
selectedSkills: [],
noResults: false,
isLoading: true
isLoading: true,
gitcoinIssueUrl: '',
issueDetails: undefined,
errorIssueDetails: undefined
},
mounted() {
this.fetchUsers();
Expand Down
8 changes: 5 additions & 3 deletions app/assets/v2/js/vue-components.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Vue.component('modal', {
props: [ 'user', 'size' ],
template: `<div class="vue-modal modal fade" id="userModal" tabindex="-1" role="dialog" aria-labelledby="userModalLabel" aria-hidden="true">
props: [ 'user', 'size', 'id', 'issueDetails' ],
template: `<div class="vue-modal modal fade" :id="id" tabindex="-1" role="dialog" aria-labelledby="userModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" :class="size" role="document">
<div class="modal-content">
<div class="modal-header border-0">
Expand Down Expand Up @@ -52,7 +52,9 @@ Vue.component('select2', {
},
watch: {
value: function(value) {
if ([...value].sort().join(',') !== [...$(this.$el).val()].sort().join(',')) {
if (value === undefined) {
$(this.$el).empty().select2({ data: this.options });
} else if ([...value].sort().join(',') !== [...$(this.$el).val()].sort().join(',')) {
$(this.$el).val(value).trigger('change');
}
},
Expand Down
42 changes: 41 additions & 1 deletion app/dashboard/templates/dashboard/users.html
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,14 @@
</div>
</div>
<button class="btn blue" @click="params = {};">Reset Filters</button>
{% if is_staff %}
<p class="font-body mx-2 mb-2 mt-4">
<span class="font-weight-bold">[[ numUsers ]]</span> users found
</p>
<button v-show="numUsers > 0" data-toggle="modal" data-target="#inviteAll" :disabled="!(params.skills && params.skills.length)" class="font-body btn btn-gc-blue ml-2" id="bulk-invite-modal">
Invite all to bounty
</button>
{% endif %}
</nav>
</transition>
<div class="container pt-4" @scroll.passive="onScroll($event)">
Expand Down Expand Up @@ -192,7 +200,7 @@ <h5 class="text-center card-user_name">
</div>
</div>
</div>
<modal ref="user-modal" >
<modal ref="user-modal" id="userModal">
<div slot="header" v-if="userSelected.handle">
<div class="d-flex flex-column align-items-center">
<h6 class="font-weight-bold mb-3">Invite User to Bounty</h6>
Expand Down Expand Up @@ -237,6 +245,38 @@ <h6 class="font-weight-bold mb-3">Invite User to Bounty</h6>
</div>
</div>
</modal>
{% if is_staff %}
<modal ref="user-modal" id="inviteAll" size="modal-lg" v-bind:issueDetails="issueDetails">
<div slot="header">
<div class="d-flex flex-column align-items-center">
<h6 class="font-weight-bold mb-3">Invite [[numUsers]] Users to the Bounty</h6>
</div>
</div>
<div slot="body">
<div class="form-group">
<label class="font-weight-semibold">Github Issue URL</label>
<input class="form-control" v-model="gitcoinIssueUrl" type="text" placeholder="https://github.com/gitcoinco/web/issues/5045" v-on:keyup="getIssueDetails(gitcoinIssueUrl)"/>
</div>
<span class="font-weight-semibold d-block mb-2" v-if="issueDetails">Description</span>
<div v-if="issueDetails" class="pre-scrollable p-3 bg-light rounded font-body" style="white-space: pre-line;">
<span class="text-black-60 text-uppercase d-block mt-3">ISSUE TITLE</span>
[[issueDetails.title]] <span class="badge">[[issueDetails.status]]</span>
<span class="text-black-60 text-uppercase d-block mt-3">ISSUE DESCRIPTION</span>
[[issueDetails.issue_description_text]]
</div>
<loading-screen v-else-if="gitcoinIssueUrl && !errorIssueDetails"></loading-screen>
<div class="alert-warning p-3" v-if="errorIssueDetails">
[[errorIssueDetails]]
</div>
</div>
<div slot="footer" class="d-flex align-items-center flex-fill">
<div class="ml-auto">
<button class="btn btn-outline-gc-blue" data-dismiss="modal">Cancel</button>
<button :disabled="gitcoinIssueUrl === '' && !issueDetails" class="btn btn-gc-blue" @click="sendInviteAll(issueDetails.pk)">Invite</button>
</div>
</div>
</modal>
{% endif %}
</div>

<script type="text/x-template" id="select2-template">
Expand Down
160 changes: 124 additions & 36 deletions app/dashboard/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@
from kudos.utils import humanize_name
from marketing.mails import admin_contact_funder, bounty_uninterested
from marketing.mails import funder_payout_reminder as funder_payout_reminder_mail
from marketing.mails import new_reserved_issue, start_work_approved, start_work_new_applicant, start_work_rejected
from marketing.mails import (
new_reserved_issue, share_bounty, start_work_approved, start_work_new_applicant, start_work_rejected,
)
from marketing.models import Keyword
from pytz import UTC
from ratelimit.decorators import ratelimit
Expand Down Expand Up @@ -742,6 +744,7 @@ def users_directory(request):
keywords = programming_languages + programming_languages_full

params = {
'is_staff': request.user.is_staff,
'active': 'users',
'title': 'Users',
'meta_title': "",
Expand All @@ -751,41 +754,11 @@ def users_directory(request):
return TemplateResponse(request, 'dashboard/users.html', params)


@require_GET
def users_fetch(request):
"""Handle displaying users."""
q = request.GET.get('search', '')
skills = request.GET.get('skills', '')
limit = int(request.GET.get('limit', 10))
page = int(request.GET.get('page', 1))
order_by = request.GET.get('order_by', '-actions_count')
bounties_completed = request.GET.get('bounties_completed', '').strip().split(',')
leaderboard_rank = request.GET.get('leaderboard_rank', '').strip().split(',')
rating = int(request.GET.get('rating', '0'))
organisation = request.GET.get('organisation', '')

user_id = request.GET.get('user', None)
if user_id:
current_user = User.objects.get(id=int(user_id))
else:
current_user = request.user if hasattr(request, 'user') and request.user.is_authenticated else None

context = {}
def users_fetch_filters(profile_list, skills, bounties_completed, leaderboard_rank, rating, organisation ):
if not settings.DEBUG:
network = 'mainnet'
else:
network = 'rinkeby'
if current_user:
profile_list = Profile.objects.prefetch_related(
'fulfilled', 'leaderboard_ranks', 'feedbacks_got'
).exclude(hide_profile=True)
else:
profile_list = Profile.objects.prefetch_related(
'fulfilled', 'leaderboard_ranks', 'feedbacks_got'
).exclude(hide_profile=True)

if q:
profile_list = profile_list.filter(Q(handle__icontains=q) | Q(keywords__icontains=q))

if skills:
profile_list = profile_list.filter(keywords__icontains=skills)
Expand Down Expand Up @@ -821,6 +794,54 @@ def users_fetch(request):
fulfilled__bounty__github_url__icontains=organisation
).distinct()

return profile_list



@require_GET
def users_fetch(request):
"""Handle displaying users."""
q = request.GET.get('search', '')
skills = request.GET.get('skills', '')
limit = int(request.GET.get('limit', 10))
page = int(request.GET.get('page', 1))
order_by = request.GET.get('order_by', '-actions_count')
bounties_completed = request.GET.get('bounties_completed', '').strip().split(',')
leaderboard_rank = request.GET.get('leaderboard_rank', '').strip().split(',')
rating = int(request.GET.get('rating', '0'))
organisation = request.GET.get('organisation', '')

user_id = request.GET.get('user', None)
if user_id:
current_user = User.objects.get(id=int(user_id))
else:
current_user = request.user if hasattr(request, 'user') and request.user.is_authenticated else None

context = {}
if not settings.DEBUG:
network = 'mainnet'
else:
network = 'rinkeby'
if current_user:
profile_list = Profile.objects.prefetch_related(
'fulfilled', 'leaderboard_ranks', 'feedbacks_got'
).exclude(hide_profile=True)
else:
profile_list = Profile.objects.prefetch_related(
'fulfilled', 'leaderboard_ranks', 'feedbacks_got'
).exclude(hide_profile=True)

if q:
profile_list = profile_list.filter(Q(handle__icontains=q) | Q(keywords__icontains=q))

profile_list = users_fetch_filters(
profile_list,
skills,
bounties_completed,
leaderboard_rank,
rating,
organisation)

def previous_worked():
if current_user.profile.persona_is_funder:
return Count(
Expand Down Expand Up @@ -861,8 +882,7 @@ def previous_worked():
).order_by('-previous_worked_count')
for user in this_page:
previously_worked_with = 0
count_work_completed = Activity.objects.filter(profile=user, activity_type='work_done').count()
count_work_in_progress = Activity.objects.filter(profile=user, activity_type='start_work').count()
count_work_completed = user.get_fulfilled_bounties(network=network).count()
profile_json = {
k: getattr(user, k) for k in
['id', 'actions_count', 'created_on', 'handle', 'hide_profile',
Expand All @@ -875,7 +895,6 @@ def previous_worked():
profile_json['position_contributor'] = user.get_contributor_leaderboard_index()
profile_json['position_funder'] = user.get_funder_leaderboard_index()
profile_json['work_done'] = count_work_completed
profile_json['work_inprogress'] = count_work_in_progress
profile_json['verification'] = user.get_my_verified_check
profile_json['avg_rating'] = user.get_average_star_rating

Expand Down Expand Up @@ -1145,6 +1164,76 @@ def social_contribution_modal(request):
return TemplateResponse(request, 'social_contribution_modal.html', params)


@csrf_exempt
@require_POST
def bulk_invite(request):
"""Invite users with matching skills to a bounty.
Args:
bounty_id (int): The primary key of the bounty to be accepted.
skills (string): Comma separated list of matching keywords.
Raises:
Http403: The exception is raised if the user is not authenticated or
the args are missing.
Http401: The exception is raised if the user is not a staff member.
Returns:
Http200: Json response with {'status': 200, 'msg': 'email_sent'}.
"""
from .utils import get_bounty_invite_url

if not request.user.is_staff:
return JsonResponse({'status': 401,
'msg': 'Unauthorized'})

inviter = request.user if request.user.is_authenticated else None
skills = ','.join(request.POST.getlist('params[skills][]', []))
bounties_completed = request.POST.get('params[bounties_completed]', '').strip().split(',')
leaderboard_rank = request.POST.get('params[leaderboard_rank]', '').strip().split(',')
rating = int(request.POST.get('params[rating]', '0'))
organisation = request.POST.get('params[organisation]', '')
bounty_id = request.POST.get('bountyId')

if None in (skills, bounty_id, inviter):
return JsonResponse({'success': False}, status=400)

bounty = Bounty.objects.current().get(id=int(bounty_id))

profiles = Profile.objects.prefetch_related(
'fulfilled', 'leaderboard_ranks', 'feedbacks_got'
).exclude(hide_profile=True)

profiles = users_fetch_filters(
profiles,
skills,
bounties_completed,
leaderboard_rank,
rating,
organisation)

invite_url = f'{settings.BASE_URL}issue/{get_bounty_invite_url(request.user.username, bounty_id)}'

if len(profiles):
for profile in profiles:
bounty_invite = BountyInvites.objects.create(
status='pending'
)
bounty_invite.bounty.add(bounty)
bounty_invite.inviter.add(inviter)
bounty_invite.invitee.add(profile.user)
try:
msg = request.POST.get('msg', '')
share_bounty([profile.email], msg, inviter.profile, invite_url, False)
except Exception as e:
logging.exception(e)
else:
return JsonResponse({'success': False}, status=403)
return JsonResponse({'status': 200,
'msg': 'email_sent'})


@csrf_exempt
@require_POST
def social_contribution_email(request):
Expand All @@ -1153,7 +1242,6 @@ def social_contribution_email(request):
Returns:
JsonResponse: Success in sending email.
"""
from marketing.mails import share_bounty
from .utils import get_bounty_invite_url

emails = []
Expand Down

0 comments on commit 4731117

Please sign in to comment.