diff --git a/app/app/settings.py b/app/app/settings.py index 09eeb442425..c8c933c91a6 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -92,6 +92,7 @@ 'gitcoinbot', 'external_bounties', 'dataviz', + 'jobs', 'impersonate', ] diff --git a/app/app/urls.py b/app/app/urls.py index 34339070f6a..18f3141ac85 100644 --- a/app/app/urls.py +++ b/app/app/urls.py @@ -38,6 +38,7 @@ import external_bounties.views import faucet.views import gitcoinbot.views +import jobs.views import linkshortener.views import marketing.views import marketing.webhookviews @@ -46,6 +47,7 @@ import tdi.views from dashboard.router import router as dbrouter from external_bounties.router import router as ebrouter +from jobs.routers import router as job_router from .sitemaps import sitemaps @@ -58,6 +60,7 @@ 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(ebrouter.urls)), + url(r'^api/v0.1/', include(job_router.urls)), url(r'^actions/api/v0.1/', include(dbrouter.urls)), # same as active, but not cached in cluodfront # dashboard views @@ -364,6 +367,11 @@ # gitcoinbot url(settings.GITHUB_EVENT_HOOK_URL, gitcoinbot.views.payload, name='payload'), + + # Job urls goes below + path('jobs', jobs.views.list_jobs, name='job-list-template'), + path('jobs//', jobs.views.job_detail, name='job-detail-template'), + path('jobs/new/', jobs.views.create_job, name='job-create-template'), url(r'^impersonate/', include('impersonate.urls')), ] diff --git a/app/assets/v2/css/jobs.css b/app/assets/v2/css/jobs.css new file mode 100644 index 00000000000..ee8d939a789 --- /dev/null +++ b/app/assets/v2/css/jobs.css @@ -0,0 +1,206 @@ +body { + font-family: 'Muli', sans-serif; + background: #FFFFFF; +} + +h3, h4 { + text-transform: none; +} + +h4 { + text-align: left; +} + +#filter { + color: #3e24fb; +} + +#matches { + font-family: 'Muli', sans-serif; + font-weight: 300; + padding-left: 10px; +} + +.title { + text-transform: capitalize; +} + +.title-row { + display: flex; + margin-top: 2em; + padding-bottom: 5px; + border-bottom: 2px solid #3E24FB; +} + +.body .row { + margin-left: 0px; +} + +.body .avatar { + max-height: 50px; + max-width: 50px; +} + +.job-row { + color: #000000; + text-decoration: none; + padding: 10px 20px 10px 4em; +} + +.job-row:hover { + background-color: #F9F9F9; +} + +.job-row .avatar-container { + text-align: right; + margin-top: auto; + margin-bottom: auto; +} + +.job-row img { + margin-top: 6px; + margin-right: 20px; +} + +.job-row .title { + margin-bottom: 2px; +} + +.job-company-detail { + text-align: right; +} + +.job-info { + top: 4px; + text-align: right; +} + +.job-title { + padding-top: 2em; +} + +.job-header { + padding-bottom: 6em; +} + +#job-board-title { + text-transform: uppercase; + color: #00A55E; + text-align: center; + font-size: 3em; +} + +.company-name, +#exp-lang, +#job-post-duration { + color: #717171; +} + +.job-detail { + margin-top: 10px; + margin-bottom: 10px; +} + +.job-type { + color: #00A55E; + text-transform: uppercase; + flex-grow: 1; +} + +.job-location { + flex-grow: 1; + color: #666666; +} + +.flex-grow-8 { + flex-grow: 8; +} + +.job-type-loc { + display: flex; + flex-direction: row; + justify-content: space-between; + margin: 1em 0 1em 0; +} + +.job-company-exp { + display: flex; + flex-direction: row; + justify-content: space-between; + margin: 1em 0 1em 0; + padding-bottom: 2em; +} + +.job-posted { + flex-grow: 8; +} + +.job-experience { + flex-grow: 1; +} + +.job-company { + margin-right: 5px; + font-weight: bold; + color: #666666; +} + +.job-skill { + color: #666666; +} + +.job-tags { + margin: 1em 0 1em 0; +} + +.tags { + display: flex; +} + +.box { + padding: 14px; + border: 1px solid #DBDBDB; + border-radius: 3px; + margin: 0; +} + +.btn-white { + border: 1px solid #979797; + color: #0D0764; + border-radius: 3px; + position: relative; +} + +.job-apply-btn { + background-color: #0D0764; + color: #FFFFFF; + padding-left: 2em; + padding-right:2em; + margin-right: 1em; +} + +.github-profile-btn { + color: #0D0764; +} + +.tag.keyword { + min-width: 0; + font-size: 11px; + padding: 5px 10px; + background-color: #F2F6F9; + color: #6184AC; +} + +@media (max-width: 768px) { + + .job-info { + top: 10px; + font-size: 10px; + text-align: left; + } + + .job-row { + padding-left: 1em; + padding-right: 2em; + } +} diff --git a/app/assets/v2/js/pages/job_details.js b/app/assets/v2/js/pages/job_details.js new file mode 100644 index 00000000000..3c4eba35836 --- /dev/null +++ b/app/assets/v2/js/pages/job_details.js @@ -0,0 +1,48 @@ +/* eslint block-scoped-var: "warn" */ +/* eslint no-redeclare: "warn" */ + +var build_detail_page = function(result) { + result['created_at'] = timeDifference(new Date(), new Date(result['created_at'])); + var template = $.templates('#job_detail'); + + html = template.render(result); + + $('#rendered_job_detail').html(html); +}; + +var pull_job_from_api = function() { + var uri = '/api/v0.1/jobs/' + document.job_id + '/'; + + $.get(uri, function(result) { + result = sanitizeAPIResults(result); + var nonefound = true; + + if (result) { + nonefound = false; + build_detail_page(result); + document.result = result; + $('#rendered_job_detail').css('display', 'block'); + } + if (nonefound) { + $('#rendered_job_detail').css('display', 'none'); + // is there a pending issue or not? + $('.nonefound').css('display', 'block'); + } + }).fail(function() { + _alert({message: gettext('got an error. please try again, or contact support@gitcoin.co')}, 'error'); + $('#rendered_job_detail').css('display', 'none'); + }).always(function() { + $('.loading').css('display', 'none'); + }); +}; + +var main = function() { + setTimeout(function() { + pull_job_from_api(); + }, 100); +}; + + +window.addEventListener('load', function() { + main(); +}); diff --git a/app/assets/v2/js/pages/jobs.js b/app/assets/v2/js/pages/jobs.js new file mode 100644 index 00000000000..c6dc55023f8 --- /dev/null +++ b/app/assets/v2/js/pages/jobs.js @@ -0,0 +1,657 @@ +/* eslint-disable no-loop-func */ +// helper functions + +var sidebar_keys = [ + 'employment_type', + 'job_type' +]; + +var localStorage; + +try { + localStorage = window.localStorage; +} catch (e) { + localStorage = {}; +} + +function debounce(func, wait, immediate) { + var timeout; + + return function() { + var context = this; + var args = arguments; + var later = function() { + timeout = null; + if (!immediate) + func.apply(context, args); + }; + var callNow = immediate && !timeout; + + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) + func.apply(context, args); + }; +} + +// sets search information default +var save_sidebar_latest = function() { + localStorage['order_by'] = $('#sort_option').val(); + + for (var i = 0; i < sidebar_keys.length; i++) { + var key = sidebar_keys[i]; + + if (key !== 'tech_stack') { + localStorage[key] = $('input[name=' + key + ']:checked').val(); + } else { + localStorage[key] = ''; + + $('input[name=' + key + ']:checked').each(function() { + localStorage[key] += $(this).val() + ','; + }); + + // Removing the start and last comma to avoid empty element when splitting with comma + localStorage[key] = localStorage[key].replace(/^,|,\s*$/g, ''); + } + } +}; + +// saves search information default +var set_sidebar_defaults = function() { + + // Special handling to support adding keywords from url query param + var q = getParam('q'); + var keywords; + + if (q) { + keywords = decodeURIComponent(q).replace(/^,|\s|,\s*$/g, ''); + + if (localStorage['jobs_keywords']) { + keywords.split(',').forEach(function(v, k) { + if (localStorage['jobs_keywords'].indexOf(v) === -1) { + localStorage['jobs_keywords'] += ',' + v; + } + }); + } else { + localStorage['jobs_keywords'] = keywords; + } + + window.history.replaceState(history.state, 'Issue Explorer | Gitcoin', '/explorer'); + } + + if (localStorage['order_by']) { + $('#sort_option').val(localStorage['order_by']); + $('#sort_option').selectmenu('refresh'); + } + + for (var i = 0; i < sidebar_keys.length; i++) { + var key = sidebar_keys[i]; + + if (localStorage[key]) { + if (key !== 'tech_stack') { + $('input[name=' + key + '][value=' + localStorage[key] + ']').prop('checked', true); + } else { + localStorage[key].split(',').forEach(function(v, k) { + $('input[name=' + key + '][value=' + v + ']').prop('checked', true); + }); + } + } + } +}; + +var set_filter_header = function() { + var idxStatusEl = $('input[name=idx_status]:checked'); + var filter_status = idxStatusEl.attr('val-ui') ? idxStatusEl.attr('val-ui') : 'All'; + + // TODO: See what all filters are to be displayed from designs + + $('#filter').html('All'); +}; + +var toggleAny = function(event) { + if (!event) + return; +}; + +var getFilters = function() { + var _filters = []; + + for (var i = 0; i < sidebar_keys.length; i++) { + var key = sidebar_keys[i]; + + $.each($('input[name=' + key + ']:checked'), function() { + if ($(this).attr('val-ui')) { + _filters.push('' + $(this).attr('val-ui') + '' + + ''); + } + }); + } + + if (localStorage['jobs_keywords']) { + localStorage['jobs_keywords'].split(',').forEach(function(v, k) { + _filters.push('' + v + '' + + ''); + }); + } + + $('.filter-tags').html(_filters); +}; + +var removeFilter = function(key, value) { + if (key !== 'jobs_keywords') { + $('input[name=' + key + '][value=' + value + ']').prop('checked', false); + } else { + localStorage['jobs_keywords'] = localStorage['jobs_keywords'].replace(value, '').replace(',,', ','); + + // Removing the start and last comma to avoid empty element when splitting with comma + localStorage['jobs_keywords'] = localStorage['jobs_keywords'].replace(/^,|,\s*$/g, ''); + } + + refreshjobs(); +}; + +var get_search_URI = function() { + var uri = '/api/v0.1/jobs/?'; + var keywords = ''; + + for (var i = 0; i < sidebar_keys.length; i++) { + var key = sidebar_keys[i]; + var filters = []; + + $.each ($('input[name=' + key + ']:checked'), function() { + if (key === 'tech_stack' && $(this).val()) { + keywords += $(this).val() + ', '; + } else if ($(this).val()) { + filters.push($(this).val()); + } + }); + + var val = filters.toString(); + + if ((key === 'bounty_filter') && val) { + var values = val.split(','); + + values.forEach(function(_value) { + var _key; + + if (_value === 'createdByMe') { + _key = 'bounty_owner_github_username'; + _value = document.contxt.github_handle; + } else if (_value === 'startedByMe') { + _key = 'interested_github_username'; + _value = document.contxt.github_handle; + } else if (_value === 'fulfilledByMe') { + _key = 'fulfiller_github_username'; + _value = document.contxt.github_handle; + } + + if (_value !== 'any') + uri += '&' + _key + '=' + _value; + }); + + // TODO: Check if value myself is needed for coinbase + if (val === 'fulfilledByMe') { + key = 'bounty_owner_address'; + val = 'myself'; + } + } + + if (val !== 'any' && + key !== 'bounty_filter' && + key !== 'bounty_owner_address') { + uri += '&' + key + '=' + val; + } + } + + if (localStorage['jobs_keywords']) { + localStorage['jobs_keywords'].split(',').forEach(function(v, k) { + keywords += v + ', '; + }); + } + + if (keywords) { + uri += '&raw_data=' + keywords; + } + + if (typeof web3 != 'undefined' && web3.eth.coinbase) { + uri += '&coinbase=' + web3.eth.coinbase; + } else { + uri += '&coinbase=unknown'; + } + + var order_by = localStorage['order_by']; + + if (order_by) { + uri += '&order_by=' + order_by; + } + + return uri; +}; + +var process_stats = function(results) { + var num = results.length; + var worth_usdt = 0; + var worth_eth = 0; + var currencies_to_value = {}; + + for (var i = 0; i < results.length; i++) { + var result = results[i]; + + var this_worth_usdt = Number.parseFloat(result['value_in_usdt']); + var this_worth_eth = Number.parseFloat(result['value_in_eth']); + + if (this_worth_usdt) { + worth_usdt += this_worth_usdt; + } + if (this_worth_eth) { + worth_eth += this_worth_eth; + } + var token = result['token_name']; + + if (token !== 'ETH') { + if (!currencies_to_value[token]) { + currencies_to_value[token] = 0; + } + currencies_to_value[token] += Number.parseFloat(result['value_true']); + } + } + + worth_usdt = worth_usdt.toFixed(2); + worth_eth = (worth_eth / Math.pow(10, 18)).toFixed(2); + var stats = worth_usdt + ' USD, ' + worth_eth + ' ETH'; + + for (var t in currencies_to_value) { + if (Object.prototype.hasOwnProperty.call(currencies_to_value, t)) { + stats += ', ' + currencies_to_value[t].toFixed(2) + ' ' + t; + } + } + + var matchesEl = $('#matches'); + var + listingInfoEl = $('#listing-info'); + + switch (num) { + case 0: + matchesEl.html(gettext('No Results')); + + listingInfoEl.html(''); + break; + case 1: + // matchesEl.html(num + gettext(' Matching Result')); + // + listingInfoEl.html("Funded Issue(" + stats + ')'); + + listingInfoEl.html(num + gettext(' Active Job Listing')); + break; + default: + // matchesEl.html(num + gettext(' Matching Results')); + // + listingInfoEl.html("Funded Issues(" + stats + ')'); + // + listingInfoEl.html("Funded Issues(" + stats + ')'); + + listingInfoEl.html(num + gettext(' Active Job Listings')); + } +}; + +var paint_jobs_in_viewport = function(start, max) { + document.is_painting_now = true; + var num_jobs = document.jobs_html.length; + + for (var i = start; i < num_jobs && i < max; i++) { + var html = document.jobs_html[i]; + + document.last_bounty_rendered = i; + $('#jobs').append(html); + } + + $('div.job-row.result').each(function() { + var href = $(this).attr('href'); + + if (typeof $(this).changeElementType !== 'undefined') { + $(this).changeElementType('a'); // hack so that users can right click on the element + } + + $(this).attr('href', href); + }); + document.is_painting_now = false; + + if (document.referrer.search('/onboard') != -1) { + $('.job-row').each(function(index) { + if (index > 2) + $(this).addClass('hidden'); + }); + } +}; + +var trigger_scroll = debounce(function() { + if (typeof document.jobs_html == 'undefined' || document.jobs_html.length == 0) { + return; + } + var scrollPos = $(document).scrollTop(); + var last_active_bounty = $('.job-row.result:last-child'); + + if (last_active_bounty.length == 0) { + return; + } + + var window_height = $(window).height(); + var have_painted_all_jobs = document.jobs_html.length <= document.last_bounty_rendered; + var buffer = 500; + var does_need_to_paint_more = !document.is_painting_now && !have_painted_all_jobs && ((last_active_bounty.offset().top) < (scrollPos + buffer + window_height)); + + if (does_need_to_paint_more) { + paint_jobs_in_viewport(document.last_bounty_rendered + 1, document.last_bounty_rendered + 6); + } +}, 200); + +$(window).scroll(trigger_scroll); +$('body').bind('touchmove', trigger_scroll); + +var refreshjobs = function(event) { + save_sidebar_latest(); + set_filter_header(); + toggleAny(event); + getFilters(); + + $('.nonefound').css('display', 'none'); + $('.loading').css('display', 'block'); + $('.job-row').remove(); + + // filter + var uri = get_search_URI(); + + // analytics + var params = { uri: uri }; + + mixpanel.track('Refresh jobs', params); + + // order + $.get(uri, function(results) { + results = sanitizeAPIResults(results); + + if (results.length === 0) { + $('.nonefound').css('display', 'block'); + } + + document.is_painting_now = false; + document.last_bounty_rendered = 0; + document.jobs_html = []; + + for (var i = 0; i < results.length; i++) { + // setup + var result = results[i]; + var related_token_details = tokenAddressToDetails(result['token_address']); + var decimals = 18; + + if (related_token_details && related_token_details.decimals) { + decimals = related_token_details.decimals; + } + + var divisor = Math.pow(10, decimals); + + result['rounded_amount'] = Math.round(result['value_in_token'] / divisor * 100) / 100; + var is_expired = new Date(result['expires_date']) < new Date() && !result['is_open']; + + result.action = result['url']; + result['title'] = result['title'] ? result['title'] : result['github_url']; + + var timeLeft = timeDifference(new Date(result['expiry_date']), new Date(), true); + + result['job_company'] = ((result['company'] ? result['company'] : 'Company Hidden') + ' • '); + + result['job_skill'] = result['skills'] ? result['skills'] : ''; + + result['watch'] = 'Watch'; + + // render the template + var tmpl = $.templates('#result'); + var html = tmpl.render(result); + + document.jobs_html[i] = html; + } + + paint_jobs_in_viewport(0, 10); + + process_stats(results); + }).fail(function() { + _alert({message: 'got an error. please try again, or contact support@gitcoin.co'}, 'error'); + }).always(function() { + $('.loading').css('display', 'none'); + }); +}; + +window.addEventListener('load', function() { + set_sidebar_defaults(); + refreshjobs(); +}); + +var getNextDayOfWeek = function(date, dayOfWeek) { + var resultDate = new Date(date.getTime()); + + resultDate.setDate(date.getDate() + (7 + dayOfWeek - date.getDay() - 1) % 7 + 1); + return resultDate; +}; + +function getURLParams(k) { + var p = {}; + + location.search.replace(/[?&]+([^=&]+)=([^&]*)/gi, function(s, k, v) { + p[k] = v; + }); + return k ? p[k] : p; +} + +var resetFilters = function() { + for (var i = 0; i < sidebar_keys.length; i++) { + var key = sidebar_keys[i]; + var tag = ($('input[name=' + key + '][value]')); + } +}; + +(function() { + if (document.referrer.search('/onboard') != -1) { + $('#sidebar_container').addClass('invisible'); + $('#dashboard-title').addClass('hidden'); + $('#onboard-dashboard').removeClass('hidden'); + resetFilters(); + $('input[name=idx_status][value=open]').prop('checked', true); + $('.search-area input[type=text]').text(getURLParams('q')); + document.referrer = ''; + + $('#onboard-alert').click(function(e) { + $('.job-row').each(function(index) { + $(this).removeClass('hidden'); + }); + $('#onboard-dashboard').addClass('hidden'); + $('#sidebar_container').removeClass('invisible'); + $('#dashboard-title').removeClass('hidden'); + e.preventDefault(); + }); + } else { + $('#onboard-dashboard').addClass('hidden'); + $('#sidebar_container').removeClass('invisible'); + $('#dashboard-title').removeClass('hidden'); + } +})(); + +$(document).ready(function() { + + // Sort select menu + $('#sort_option').selectmenu({ + select: function(event, ui) { + refreshjobs(); + event.preventDefault(); + } + }); + + // TODO: DRY + function split(val) { + return val.split(/,\s*/); + } + + function extractLast(term) { + return split(term).pop(); + } + + // Handle search input clear + $('.close-icon') + .on('click', function(e) { + e.preventDefault(); + $('#keywords').val(''); + $(this).hide(); + }); + + $('#keywords') + .on('input', function() { + if ($(this).val()) { + $('.close-icon').show(); + } else { + $('.close-icon').hide(); + } + }) + // don't navigate away from the field on tab when selecting an item + .on('keydown', function(event) { + if (event.keyCode === $.ui.keyCode.TAB && $(this).autocomplete('instance').menu.active) { + event.preventDefault(); + } + }) + .autocomplete({ + minLength: 0, + source: function(request, response) { + // delegate back to autocomplete, but extract the last term + response($.ui.autocomplete.filter(document.keywords, extractLast(request.term))); + }, + focus: function() { + // prevent value inserted on focus + return false; + }, + select: function(event, ui) { + var terms = split(this.value); + var isTechStack = false; + + $('.close-icon').hide(); + + // remove the current input + terms.pop(); + + // add the selected item + terms.push(ui.item.value); + + // add placeholder to get the comma-and-space at the end + terms.push(''); + + // this.value = terms.join(', '); + this.value = ''; + + if (!isTechStack) { + if (localStorage['jobs_keywords']) { + localStorage['jobs_keywords'] += ',' + ui.item.value; + } else { + localStorage['jobs_keywords'] += ui.item.value; + } + + $('.filter-tags').append('' + ui.item.value + '' + + ''); + } + + return false; + } + }); + + // sidebar clear + $('.dashboard #clear').click(function(e) { + e.preventDefault(); + + for (var i = 0; i < sidebar_keys.length; i++) { + var key = sidebar_keys[i]; + var tag = ($('input[name=' + key + '][value]')); + + for (var j = 0; j < tag.length; j++) { + if (tag[j].value === 'any') + $('input[name=' + key + '][value=any]').prop('checked', true); + else + $('input[name=' + key + '][value=' + tag[j].value + ']').prop('checked', false); + } + } + + refreshjobs(); + }); + + // search bar + $('#jobs').delegate('#new_search', 'click', function(e) { + refreshjobs(); + e.preventDefault(); + }); + + $('.search-area input[type=text]').keypress(function(e) { + if (e.which == 13) { + refreshjobs(); + e.preventDefault(); + } + }); + + // sidebar filters + $('.sidebar_search input[type=radio], .sidebar_search label').change(function(e) { + refreshjobs(); + e.preventDefault(); + }); + + // sidebar filters + $('.sidebar_search input[type=checkbox], .sidebar_search label').change(function(e) { + refreshjobs(e); + e.preventDefault(); + }); + + // email subscribe functionality + $('.save_search').click(function(e) { + e.preventDefault(); + $('#save').remove(); + var url = '/sync/search_save'; + + setTimeout(function() { + $.get(url, function(newHTML) { + $(newHTML).appendTo('body').modal(); + $('#save').append(""); + $('#save_email').focus(); + }); + }, 300); + }); + + var emailSubscribe = function() { + var email = $('#save input[type=email]').val(); + var raw_data = $('#save input[type=hidden]').val(); + var is_validated = validateEmail(email); + + if (!is_validated) { + _alert({ message: gettext('Please enter a valid email address.') }, 'warning'); + } else { + var url = '/sync/search_save'; + + $.post(url, { + email: email, + raw_data: raw_data + }, function(response) { + var status = response['status']; + + if (status == 200) { + _alert({message: gettext("You're in! Keep an eye on your inbox for the next funding listing.")}, 'success'); + $.modal.close(); + } else { + _alert({message: response['msg']}, 'error'); + } + }); + } + }; + + $('body').delegate('#save input[type=email]', 'keypress', function(e) { + if (e.which == 13) { + emailSubscribe(); + e.preventDefault(); + } + }); + $('body').delegate('#save a', 'click', function(e) { + emailSubscribe(); + e.preventDefault(); + }); +}); diff --git a/app/dashboard/migrations/0105_auto_20180812_1031.py b/app/dashboard/migrations/0105_auto_20180812_1031.py new file mode 100644 index 00000000000..d26c2986f3d --- /dev/null +++ b/app/dashboard/migrations/0105_auto_20180812_1031.py @@ -0,0 +1,70 @@ +# Generated by Django 2.1 on 2018-08-12 10:31 + +import django.contrib.postgres.fields +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dashboard', '0104_auto_20180802_1804'), + ] + + operations = [ + migrations.AlterField( + model_name='activity', + name='metadata', + field=django.contrib.postgres.fields.jsonb.JSONField(default=dict), + ), + migrations.AlterField( + model_name='bounty', + name='github_issue_details', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=dict, null=True), + ), + migrations.AlterField( + model_name='bounty', + name='metadata', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=dict), + ), + migrations.AlterField( + model_name='bounty', + name='privacy_preferences', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=dict), + ), + migrations.AlterField( + model_name='bountyfulfillment', + name='fulfiller_metadata', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=dict), + ), + migrations.AlterField( + model_name='profile', + name='discord_repos', + field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=200), blank=True, default=list, size=None), + ), + migrations.AlterField( + model_name='profile', + name='form_submission_records', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=list), + ), + migrations.AlterField( + model_name='profile', + name='slack_repos', + field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=200), blank=True, default=list, size=None), + ), + migrations.AlterField( + model_name='tip', + name='metadata', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=dict), + ), + migrations.AlterField( + model_name='useraction', + name='location_data', + field=django.contrib.postgres.fields.jsonb.JSONField(default=dict), + ), + migrations.AlterField( + model_name='useraction', + name='metadata', + field=django.contrib.postgres.fields.jsonb.JSONField(default=dict), + ), + ] diff --git a/app/dashboard/templates/shared/search_bar.html b/app/dashboard/templates/shared/search_bar.html index 9312f98f922..74736b43033 100644 --- a/app/dashboard/templates/shared/search_bar.html +++ b/app/dashboard/templates/shared/search_bar.html @@ -27,6 +27,9 @@ {% trans "Search" %}
+ {% if jobs_board %} + + {% else %}
{% trans "SORT" %}
+ {% endif %}
@@ -41,7 +45,10 @@
+ {% if jobs_board %} + {% else %} {% trans "Save Search" %} + {% endif %}
diff --git a/app/jobs/__init__.py b/app/jobs/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/app/jobs/admin.py b/app/jobs/admin.py new file mode 100644 index 00000000000..102aa62ad60 --- /dev/null +++ b/app/jobs/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin + +from .models import Job + + +@admin.register(Job) +class JobAdmin(admin.ModelAdmin): + """Define the Jobs administration layout.""" + list_display = ['id', 'title', 'skills', 'is_active'] diff --git a/app/jobs/api.py b/app/jobs/api.py new file mode 100644 index 00000000000..52cc2ab56fb --- /dev/null +++ b/app/jobs/api.py @@ -0,0 +1,13 @@ +from rest_framework import mixins, viewsets + +from . import filters, models, serializers + + +class JobViewSet(mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.CreateModelMixin, + viewsets.GenericViewSet): + queryset = models.Job.objects.filter(is_active=True) + serializer_class = serializers.JobSerializer + filter_backends = (filters.JobPostedFilter, filters.EmploymentTypeFilter,) + ordering = ('-created_at', ) diff --git a/app/jobs/apps.py b/app/jobs/apps.py new file mode 100644 index 00000000000..14c323ab59f --- /dev/null +++ b/app/jobs/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class JobsConfig(AppConfig): + name = 'jobs' diff --git a/app/jobs/filters.py b/app/jobs/filters.py new file mode 100644 index 00000000000..07d90c1cead --- /dev/null +++ b/app/jobs/filters.py @@ -0,0 +1,27 @@ +from datetime import datetime, timedelta + +from rest_framework import filters + + +class JobPostedFilter(filters.BaseFilterBackend): + """ + Filter on job_posted time of the Job. + """ + def filter_queryset(self, request, queryset, view): + job_posted = request.query_params.get('job_posted', None) + if job_posted: + job_posted_days_ago = int(job_posted) + job_posted = datetime.now() - timedelta(days=job_posted_days_ago) + return queryset.filter(created_at__gte=job_posted) + return queryset + + +class EmploymentTypeFilter(filters.BaseFilterBackend): + """ + Filter on job_type for the Job. + """ + def filter_queryset(self, request, queryset, view): + job_type = request.query_params.get('employment_type', None) + if job_type: + return queryset.filter(job_type__iexact=job_type) + return queryset diff --git a/app/jobs/forms.py b/app/jobs/forms.py new file mode 100644 index 00000000000..ac16fde1426 --- /dev/null +++ b/app/jobs/forms.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +"""Define external bounty related forms. + +Copyright (C) 2018 Gitcoin Core + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +""" +from django import forms + +from dashboard.views import profile_keywords_helper + +from .models import Job +from .services import clean_html_data + + +class JobForm(forms.ModelForm): + """Define the Job form handling.""" + skills = forms.ChoiceField(choices=(), required=True) + + def __init__(self, user, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['skills'].choices = [(x, x) for x in profile_keywords_helper(user.profile.handle)] + + def clean_description(self): + description = self.cleaned_data['description'] + # Allow `strong`, `em` & `p` tags. + description = clean_html_data(description, ['strong', 'em', 'p']) + return description + + def clean_title(self): + title = self.cleaned_data['title'] + # Allow `strong`, `em` & `p` tags. + title = clean_html_data(title, ['strong', 'em']) + return title + + class Meta: + """Define the JOB form metadata.""" + + model = Job + fields = [ + 'title', 'job_type', 'location', 'skills', 'company', 'description' + ] diff --git a/app/jobs/migrations/0001_initial.py b/app/jobs/migrations/0001_initial.py new file mode 100644 index 00000000000..5a87aef91a2 --- /dev/null +++ b/app/jobs/migrations/0001_initial.py @@ -0,0 +1,44 @@ +# Generated by Django 2.1 on 2018-08-12 10:31 + +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion +import economy.models +import jobs.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('dashboard', '0105_auto_20180812_1031'), + ] + + operations = [ + migrations.CreateModel( + name='Job', + 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)), + ('title', models.CharField(max_length=200, verbose_name='Title')), + ('description', models.TextField(verbose_name='Description')), + ('github_profile_link', models.URLField(blank=True, verbose_name='Github Profile Link')), + ('location', models.CharField(blank=True, max_length=50, verbose_name='Location')), + ('job_type', models.CharField(choices=[('full_time', 'Full-Time'), ('part_time', 'Part-Time'), ('contract', 'Contract'), ('intern', 'Intern')], max_length=50, verbose_name='Job Type')), + ('apply_url', models.URLField(blank=True)), + ('is_active', models.BooleanField(default=False, verbose_name='Is this job active?')), + ('skills', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(blank=True, max_length=60, null=True), size=None)), + ('expiry_date', models.DateTimeField(default=jobs.models.get_expiry_time, verbose_name='Expiry Date')), + ('company', models.CharField(blank=True, max_length=50, null=True, verbose_name='Company')), + ('apply_email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='Contact Email for Job')), + ('posted_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='posted_jobs', to='dashboard.Profile')), + ], + options={ + 'verbose_name': 'Job', + 'verbose_name_plural': 'Jobs', + 'db_table': 'job', + }, + ), + ] diff --git a/app/jobs/migrations/__init__.py b/app/jobs/migrations/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/app/jobs/models.py b/app/jobs/models.py new file mode 100644 index 00000000000..d84a26b671f --- /dev/null +++ b/app/jobs/models.py @@ -0,0 +1,89 @@ +from datetime import timedelta + +from django.conf import settings +from django.db import models +from django.urls import reverse +from django.utils import timezone +from django.contrib.postgres.fields import ArrayField +from django.utils.translation import ugettext_lazy as _ + +from economy.models import SuperModel + + +def get_expiry_time(): + return timezone.now() + timedelta(days=30) + + +class Job(SuperModel): + JOB_TYPE_CHOICES = ( + ('full_time', _('Full-Time')), + ('part_time', _('Part-Time')), + ('contract', _('Contract')), + ('intern', _('Intern')), + ) + + title = models.CharField( + verbose_name=_('Title'), max_length=200, null=False, blank=False + ) + description = models.TextField(verbose_name=_('Description')) + github_profile_link = models.URLField( + verbose_name=_('Github Profile Link'), null=False, blank=True + ) + location = models.CharField( + _('Location'), max_length=50, null=False, blank=True + ) + job_type = models.CharField( + _('Job Type'), max_length=50, choices=JOB_TYPE_CHOICES, null=False, + blank=False + ) + apply_url = models.URLField(null=False, blank=True) + is_active = models.BooleanField( + verbose_name=_('Is this job active?'), default=False + ) + skills = ArrayField(models.CharField(max_length=60, null=True, blank=True)) + expiry_date = models.DateTimeField( + _('Expiry Date'), null=False, blank=False, default=get_expiry_time + ) + company = models.CharField(_('Company'), max_length=50, null=True, blank=True) + apply_email = models.EmailField(_('Contact Email for Job'), null=True, blank=True) + posted_by = models.ForeignKey( + 'dashboard.Profile', null=False, blank=False, related_name='posted_jobs', + on_delete=models.CASCADE + ) + + @property + def posted_by_user_profile_url(self): + if self.posted_by: + return reverse('profile', args=[self.posted_by]) + return None + + def get_absolute_url(self): + """Get the absolute URL for the Job. + + Returns: + str: The absolute URL for the Job. + + """ + return settings.BASE_URL + self.get_relative_url(preceding_slash=False) + + def get_relative_url(self, preceding_slash=True): + """Get the relative URL for the Job. + + Attributes: + preceding_slash (bool): Whether or not to include a preceding slash. + + Returns: + str: The relative URL for the Job. + + """ + job_id = self.id + return f"{'/' if preceding_slash else ''}jobs/{job_id}/" + + @property + def url(self): + return self.get_absolute_url() + + class Meta: + db_table = 'job' + verbose_name = _('Job') + verbose_name_plural = _('Jobs') diff --git a/app/jobs/routers.py b/app/jobs/routers.py new file mode 100644 index 00000000000..50253f9b2d8 --- /dev/null +++ b/app/jobs/routers.py @@ -0,0 +1,6 @@ +from rest_framework import routers + +from .api import JobViewSet + +router = routers.DefaultRouter() +router.register(r'jobs', JobViewSet) diff --git a/app/jobs/serializers.py b/app/jobs/serializers.py new file mode 100644 index 00000000000..7df751e55d6 --- /dev/null +++ b/app/jobs/serializers.py @@ -0,0 +1,24 @@ + +# Third Party Stuff +from django.urls import reverse + +from rest_framework import serializers + +from .models import Job + + +class JobSerializer(serializers.ModelSerializer): + company_avatar = serializers.SerializerMethodField() + job_type = serializers.CharField(source='get_job_type_display') + + def get_company_avatar(self, obj): + return reverse('org_avatar', args=[obj.posted_by]) + + class Meta: + model = Job + fields = ( + 'id', 'title', 'description', 'github_profile_link', 'apply_url', + 'is_active', 'skills', 'expiry_date', 'location', 'job_type', 'url', + 'company', 'apply_email', 'posted_by_user_profile_url', + 'posted_by', 'company_avatar', 'created_at' + ) diff --git a/app/jobs/services.py b/app/jobs/services.py new file mode 100644 index 00000000000..5968209af2c --- /dev/null +++ b/app/jobs/services.py @@ -0,0 +1,6 @@ +import bleach + + +def clean_html_data(html_data, allowed_tags=['strong', 'em']): + html_data = bleach.clean(html_data, tags=allowed_tags) + return html_data diff --git a/app/jobs/templates/jobs/create_job.html b/app/jobs/templates/jobs/create_job.html new file mode 100644 index 00000000000..e7f6e0451f3 --- /dev/null +++ b/app/jobs/templates/jobs/create_job.html @@ -0,0 +1,120 @@ +{% comment %} + Copyright (C) 2017 Gitcoin Core + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +{% endcomment %} +{% load i18n static %} + + + + + {% include 'shared/head.html' %} + + + + + {% include 'shared/tag_manager_2.html' %} +
+ {% include 'shared/nav.html' %} + {% if messages %} +
    + {% for message in messages %} + {{ message }} + {% endfor %} +
+ {% endif %} +
Post a Job
+
+ +
+
+
+
+
+
+ {% csrf_token %} + + +
+ {{ form.title.errors }} +
+ + + {{ form.job_type }} +
+ {{ form.job_type.errors }} +
+ + + +
+ {{ form.location.errors }} +
+ + + {{ form.skills }} +
+ {{ form.skills.errors }} +
+ + + +
+ {{ form.company.errors }} +
+ + + +
+ {{ form.description.errors }} +
+ +
+
+
+
+
+
+ {% include 'shared/analytics.html' %} + {% include 'shared/footer_scripts.html' %} + {% include 'shared/rollbar.html' %} + {% include 'shared/footer.html' %} + + + + + + + + + + diff --git a/app/jobs/templates/jobs/detail.html b/app/jobs/templates/jobs/detail.html new file mode 100644 index 00000000000..35c359e7fae --- /dev/null +++ b/app/jobs/templates/jobs/detail.html @@ -0,0 +1,121 @@ +{% load i18n static %} + + + + + {% include 'shared/head.html' %} + {% include 'shared/cards_pic.html' %} + + + + + + {% include 'shared/tag_manager_2.html' %} +
+ {% include 'shared/nav.html' %} +
+
+ + +
+ + + + + +
+ + + +
+
+ + {% include 'shared/bottom_notification.html' %} + {% include 'shared/analytics.html' %} + {% include 'shared/footer_scripts.html' %} + {% include 'shared/rollbar.html' %} + {% include 'shared/footer.html' %} + + + + + + + + + + + + + diff --git a/app/jobs/templates/jobs/list.html b/app/jobs/templates/jobs/list.html new file mode 100644 index 00000000000..f3aa2b752f3 --- /dev/null +++ b/app/jobs/templates/jobs/list.html @@ -0,0 +1,111 @@ +{% comment %} + Copyright (C) 2017 Gitcoin Core + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +{% endcomment %} +{% load i18n static %} + + + + + {% include 'shared/head.html' %} + {% include 'shared/cards.html' %} + + + + + + {% include 'shared/tag_manager_2.html' %} +
+ {% include 'shared/nav.html' %} +
Job Board
+
+ +
+ + +
+
+ +
+ +
+ +
+
+
+ + + + + {% include 'shared/analytics.html' %} + {% include 'shared/footer_scripts.html' %} + {% include 'shared/rollbar.html' %} + {% include 'shared/footer.html' %} + + + + + + + diff --git a/app/jobs/templates/jobs/sidebar_search.html b/app/jobs/templates/jobs/sidebar_search.html new file mode 100644 index 00000000000..c292c0aa1a0 --- /dev/null +++ b/app/jobs/templates/jobs/sidebar_search.html @@ -0,0 +1,87 @@ +{% comment %} + Copyright (C) 2018 Gitcoin Core + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +{% endcomment %} +{% load i18n static %} + + diff --git a/app/jobs/tests.py b/app/jobs/tests.py new file mode 100644 index 00000000000..7ce503c2dd9 --- /dev/null +++ b/app/jobs/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/app/jobs/views.py b/app/jobs/views.py new file mode 100644 index 00000000000..b93fad9cdbd --- /dev/null +++ b/app/jobs/views.py @@ -0,0 +1,33 @@ +# Third-Party imports +from django.contrib import messages +from django.shortcuts import render +from django.template.response import TemplateResponse + +# gitcoin-web imports +from .forms import JobForm + + +def list_jobs(request): + context = {} + return TemplateResponse(request, 'jobs/list.html', context=context) + + +def job_detail(request, pk): + context = { + 'pk': pk + } + + return TemplateResponse(request, 'jobs/detail.html', context=context) + + +def create_job(request): + context = {} + if request.method == 'POST': + form = JobForm(user=request.user, data=request.POST) + if form.is_valid(): + form.save() + messages.success(request, 'Job has been submitted!') + else: + form = JobForm(user=request.user) + context['form'] = form + return render(request, 'jobs/create_job.html', context) diff --git a/requirements/base.txt b/requirements/base.txt index f030df68c56..0b8acd7cae1 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -59,6 +59,7 @@ django-classy-tags==0.8.0 django-cookie-law==2.0.1 django-impersonate==1.3 pg_activity +bleach==1.5.0 PyYAML==4.2b4 ecdsa pysha3