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

Invite users to a bounty based on skills #5060

Merged
merged 20 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
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)
Copy link
Member

Choose a reason for hiding this comment

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

Oh why did we move this from def users_fetch_filters ? to keep that more generic ?

Copy link
Contributor

Choose a reason for hiding this comment

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

To be DRY instead of copy paste the same filters, both users filters and bulk invite user users_fetch_filters to filter and have the same result


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

Choose a reason for hiding this comment

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

should we add the @staff_member_required decorator here to restrict access?

Copy link
Contributor

Choose a reason for hiding this comment

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

humm can be, Im restricting it using request.user.is_staff, seems to me there are plans to do this a feature for users so not sure if worst changing it.

@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')
octavioamu marked this conversation as resolved.
Show resolved Hide resolved

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)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I wonder how many emails we can send within the request-response window before the request times out? I'm guessing this will be somewhat buggy, may have to refactor to use celery soon but for now we'll see since its internal only.

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