diff --git a/hasjob/__init__.py b/hasjob/__init__.py index 93ead0be9..2a1c3a2e4 100644 --- a/hasjob/__init__.py +++ b/hasjob/__init__.py @@ -28,7 +28,7 @@ # Third, after config, import the models and views -from . import models, views # NOQA +from . import models, views, jobs # NOQA from .models import db # NOQA # Configure the app diff --git a/hasjob/assets/js/app.js b/hasjob/assets/js/app.js index 31abdbd28..878764454 100644 --- a/hasjob/assets/js/app.js +++ b/hasjob/assets/js/app.js @@ -26,6 +26,35 @@ Hasjob.Util = { } }; +window.Hasjob.Subscribe = { + handleEmailSubscription: function() { + $("#subscribe-jobalerts").on('submit', function(event) { + event.preventDefault(); + var form = this; + $.ajax({ + url: $(this).attr('action') + '?' + window.Hasjob.Filters.toParam(), + type: 'POST', + data: $(form).serialize(), + dataType: 'json', + beforeSend: function() { + $(form).find('button[type="submit"]').prop('disabled', true); + $(form).find(".loading").removeClass('hidden'); + } + }).complete(function (remoteData) { + window.location.href = window.location.href; + }); + }); + + $('#close-subscribe-form').on('click', function(e) { + e.preventDefault(); + $("#subscribe-jb-form").slideUp(); + }); + }, + init: function() { + this.handleEmailSubscription(); + } +} + window.Hasjob.JobPost = { handleStarClick: function () { $('#main-content').on('click', '.pstar', function(e) { @@ -694,6 +723,7 @@ $(function() { }); window.Hasjob.Filters.init(); + window.Hasjob.Subscribe.init(); window.Hasjob.JobPost.handleStarClick(); window.Hasjob.JobPost.handleGroupClick(); window.Hasjob.StickieList.initFunnelViz(); diff --git a/hasjob/assets/sass/_header.sass b/hasjob/assets/sass/_header.sass index 71770c5a1..9eaf18fba 100644 --- a/hasjob/assets/sass/_header.sass +++ b/hasjob/assets/sass/_header.sass @@ -343,3 +343,17 @@ header #nprogress .spinner-icon border-top-color: $color-title-has border-left-color: $color-title-has + +#subscribe-jb-form + .form-inline + .listwidget ul input + top: 6px + + .field-email + width: 100% + +@media (min-width: 768px) + #subscribe-jb-form + .form-inline + .no-left-padding .controls + padding: 0 diff --git a/hasjob/assets/sass/_sheet.sass b/hasjob/assets/sass/_sheet.sass index 47b2ad21f..d423759af 100644 --- a/hasjob/assets/sass/_sheet.sass +++ b/hasjob/assets/sass/_sheet.sass @@ -215,7 +215,8 @@ tr > div // Fix for ParsleyJS inserting div tags in table rows in the campaign e line-height: 24px color: #666 -.form-horizontal .help-required +.form-horizontal .help-required, +.form-inline .help-required display: none .sliderholder diff --git a/hasjob/forms/__init__.py b/hasjob/forms/__init__.py index 84950c1fb..a206957aa 100644 --- a/hasjob/forms/__init__.py +++ b/hasjob/forms/__init__.py @@ -33,3 +33,4 @@ def optional_url(form, field): from .domain import * # NOQA from .jobpost import * # NOQA from .filterset import * # NOQA +from .subscribe import * # NOQA diff --git a/hasjob/forms/subscribe.py b/hasjob/forms/subscribe.py new file mode 100644 index 000000000..3319e8f46 --- /dev/null +++ b/hasjob/forms/subscribe.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- + +from baseframe import __ +import baseframe.forms as forms +from ..models import EMAIL_FREQUENCY + + +class JobPostSubscriptionForm(forms.Form): + email_frequency = forms.RadioField(__("Frequency"), coerce=int, + choices=EMAIL_FREQUENCY.items(), default=EMAIL_FREQUENCY.DAILY, + validators=[forms.validators.InputRequired(__(u"Pick one"))]) + email = forms.EmailField(__("Email"), validators=[forms.validators.DataRequired(__("Specify an email address")), + forms.validators.Length(min=5, max=80, message=__("%%(max)d characters maximum")), + forms.validators.ValidEmail(__("This does not appear to be a valid email address"))], + filters=[forms.filters.strip()]) diff --git a/hasjob/jobs/__init__.py b/hasjob/jobs/__init__.py new file mode 100644 index 000000000..347e0a89c --- /dev/null +++ b/hasjob/jobs/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import job_alerts # NOQA diff --git a/hasjob/jobs/job_alerts.py b/hasjob/jobs/job_alerts.py new file mode 100644 index 000000000..1d73652ea --- /dev/null +++ b/hasjob/jobs/job_alerts.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- + +from flask_mail import Message +from flask import render_template +from flask_rq import job +from html2text import html2text +from premailer import transform as email_transform +from hasjob import mail +from hasjob.models import db, JobPost, JobPostSubscription, JobPostAlert, jobpost_alert_table +from hasjob.views.index import fetch_jobposts + + +def get_unseen_posts(subscription): + posts = fetch_jobposts(filters=subscription.filterset.to_filters(), posts_only=True) + seen_jobpostids = JobPost.query.join(jobpost_alert_table).join(JobPostAlert).filter( + JobPostAlert.jobpost_subscription == subscription).options(db.load_only('id')).all() + return [post for post in posts if post.id not in seen_jobpostids] + + +@job('hasjob') +def send_email_alerts(): + for subscription in JobPostSubscription.get_active_subscriptions(): + if subscription.has_recent_alert(): + # Alert was sent recently, break out of loop + break + + if not subscription.is_right_time_to_send_alert(): + break + + unseen_posts = get_unseen_posts(subscription) + if not unseen_posts: + # Nothing new to see, break out of loop + break + + jobpost_alert = JobPostAlert(jobpost_subscription=subscription) + jobpost_alert.jobposts = unseen_posts + + msg = Message(subject=u"New jobs on Hasjob", recipients=[subscription.email]) + html = email_transform(render_template('job_alert_mailer.html.jinja2', posts=jobpost_alert.jobposts)) + msg.html = html + msg.body = html2text(html) + mail.send(msg) + jobpost_alert.register_delivery() + db.session.add(jobpost_alert) + db.session.commit() diff --git a/hasjob/models/__init__.py b/hasjob/models/__init__.py index f12c21ffd..d5132a7e9 100644 --- a/hasjob/models/__init__.py +++ b/hasjob/models/__init__.py @@ -93,3 +93,4 @@ class CANDIDATE_FEEDBACK(LabeledEnum): from .flags import * from .campaign import * from .filterset import * +from .jobpost_alert import * diff --git a/hasjob/models/filterset.py b/hasjob/models/filterset.py index 2efbadcfc..05ecceca3 100644 --- a/hasjob/models/filterset.py +++ b/hasjob/models/filterset.py @@ -5,6 +5,7 @@ from sqlalchemy.ext.associationproxy import association_proxy from . import db, BaseScopedNameMixin, JobType, JobCategory, Tag, Domain, Board from ..extapi import location_geodata +from coaster.utils import buid, getbool __all__ = ['Filterset'] @@ -45,6 +46,7 @@ class Filterset(BaseScopedNameMixin, db.Model): """ __tablename__ = 'filterset' + __title_blank_allowed__ = True board_id = db.Column(None, db.ForeignKey('board.id'), nullable=False, index=True) board = db.relationship(Board) @@ -52,6 +54,8 @@ class Filterset(BaseScopedNameMixin, db.Model): #: Welcome text description = db.Column(db.UnicodeText, nullable=False, default=u'') + #: Display on sitemap + sitemap = db.Column(db.Boolean, default=False, nullable=True, index=True) #: Associated job types types = db.relationship(JobType, secondary=filterset_jobtype_table) @@ -71,6 +75,36 @@ class Filterset(BaseScopedNameMixin, db.Model): def __repr__(self): return '' % (self.board.title, self.title) + def __init__(self, **kwargs): + filters = kwargs.pop('filters') if kwargs.get('filters') else {} + super(Filterset, self).__init__(**kwargs) + + if filters: + if filters.get('t'): + self.types = JobType.query.filter(JobType.name.in_(filters['t'])).all() + + if filters.get('c'): + self.categories = JobCategory.query.filter(JobCategory.name.in_(filters['c'])).all() + + if filters.get('l'): + geonameids = [] + for loc in filters.get('l'): + geonameids.append(location_geodata(loc)['geonameid']) + self.geonameids = geonameids + + if getbool(filters.get('anywhere')): + self.remote_location = True + + if getbool(filters.get('equity')): + self.equity = True + + if filters.get('currency') and filters.get('pay'): + self.pay_currency = filters.get('currency') + self.pay_cash = filters.get('pay') + + if filters.get('q'): + self.keywords = filters.get('q') + @classmethod def get(cls, board, name): return cls.query.filter(cls.board == board, cls.name == name).one_or_none() diff --git a/hasjob/models/jobpost_alert.py b/hasjob/models/jobpost_alert.py new file mode 100644 index 000000000..76265052e --- /dev/null +++ b/hasjob/models/jobpost_alert.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- + +from datetime import datetime, timedelta +from coaster.sqlalchemy import StateManager +from ..utils import random_long_key +from . import db, BaseMixin, LabeledEnum, User, AnonUser + +__all__ = ['JobPostSubscription', 'JobPostAlert', 'jobpost_alert_table', 'EMAIL_FREQUENCY'] + + +class EMAIL_FREQUENCY(LabeledEnum): + DAILY = (1, 'Daily') + WEEKLY = (7, 'Weekly') + + +class JobPostSubscription(BaseMixin, db.Model): + __tablename__ = 'jobpost_subscription' + + filterset_id = db.Column(None, db.ForeignKey('filterset.id'), nullable=False) + filterset = db.relationship('Filterset', backref=db.backref('subscriptions', lazy='dynamic', cascade='all, delete-orphan')) + email = db.Column(db.Unicode(254), nullable=True) + user_id = db.Column(None, db.ForeignKey('user.id'), nullable=True, index=True) + user = db.relationship(User) + anon_user_id = db.Column(None, db.ForeignKey('anon_user.id'), nullable=True, index=True) + anon_user = db.relationship(AnonUser) + + active = db.Column(db.Boolean, nullable=False, default=False, index=True) + email_verify_key = db.Column(db.String(40), nullable=True, default=random_long_key, unique=True) + unsubscribe_key = db.Column(db.String(40), nullable=True, default=random_long_key, unique=True) + subscribed_at = db.Column(db.DateTime, nullable=False, default=db.func.utcnow()) + email_verified_at = db.Column(db.DateTime, nullable=True, index=True) + unsubscribed_at = db.Column(db.DateTime, nullable=True) + + _email_frequency = db.Column('email_frequency', + db.Integer, StateManager.check_constraint('email_frequency', EMAIL_FREQUENCY), + default=EMAIL_FREQUENCY.DAILY, nullable=True) + email_frequency = StateManager('_email_frequency', EMAIL_FREQUENCY, doc="Email frequency") + + __table_args__ = (db.UniqueConstraint('filterset_id', 'email'), + db.CheckConstraint( + db.case([(user_id != None, 1)], else_=0) + db.case([(anon_user_id != None, 1)], else_=0) == 1, # NOQA + name='jobpost_subscription_user_id_or_anon_user_id')) + + @classmethod + def get(cls, filterset, email): + return cls.query.filter(cls.filterset == filterset, cls.email == email).one_or_none() + + def verify_email(self): + self.active = True + self.email_verified_at = db.func.utcnow() + + def unsubscribe(self): + self.active = False + self.unsubscribed_at = db.func.utcnow() + + @classmethod + def get_active_subscriptions(cls): + return cls.query.filter(cls.active == True, cls.email_verified_at != None) + + def has_recent_alert(self): + return JobPostAlert.query.filter( + JobPostAlert.jobpost_subscription == self, + JobPostAlert.sent_at >= datetime.utcnow() - timedelta(days=self.email_frequency.value) + ).order_by('created_at desc').notempty() + + def is_right_time_to_send_alert(self): + """ + Checks if it's the right time to send this subscriber an alert. + For now, the time at which the subscription was initiated is taken as the "preferred time" and + uses a tolerance of 30 minutes + """ + return ((datetime.utcnow() - self.subscribed_at.time()).total_seconds()/60) <= 30 + + +jobpost_alert_table = db.Table('jobpost_jobpost_alert', db.Model.metadata, + db.Column('jobpost_id', None, db.ForeignKey('jobpost.id'), primary_key=True), + db.Column('jobpost_alert_id', None, db.ForeignKey('jobpost_alert.id'), primary_key=True), + db.Column('created_at', db.DateTime, nullable=False, default=db.func.utcnow()) +) + + +class JobPostAlert(BaseMixin, db.Model): + __tablename__ = 'jobpost_alert' + + jobpost_subscription_id = db.Column(None, db.ForeignKey('jobpost_subscription.id'), + index=True) + jobpost_subscription = db.relationship(JobPostSubscription, backref=db.backref('alerts', + lazy='dynamic', cascade='all, delete-orphan')) + jobposts = db.relationship('JobPost', lazy='dynamic', secondary=jobpost_alert_table, + backref=db.backref('alerts', lazy='dynamic')) + sent_at = db.Column(db.DateTime, nullable=True) diff --git a/hasjob/static/build/css/stylesheet-app-css.3246dcaf06972fc8f57c.css b/hasjob/static/build/css/stylesheet-app-css.37ea9237db434b9a038f.css similarity index 98% rename from hasjob/static/build/css/stylesheet-app-css.3246dcaf06972fc8f57c.css rename to hasjob/static/build/css/stylesheet-app-css.37ea9237db434b9a038f.css index f2d7ca3a8..6d271bf46 100644 --- a/hasjob/static/build/css/stylesheet-app-css.3246dcaf06972fc8f57c.css +++ b/hasjob/static/build/css/stylesheet-app-css.37ea9237db434b9a038f.css @@ -294,6 +294,16 @@ header { border-top-color: #df5e0e; border-left-color: #df5e0e; } +#subscribe-jb-form .form-inline .listwidget ul input { + top: 6px; } + +#subscribe-jb-form .form-inline .field-email { + width: 100%; } + +@media (min-width: 768px) { + #subscribe-jb-form .form-inline .no-left-padding .controls { + padding: 0; } } + .input-group-btn > .btn { border-bottom: 1px solid #ccc; } .input-group-btn > .btn:hover, .input-group-btn > .btn:focus, .input-group-btn > .btn:active { @@ -537,7 +547,8 @@ tr > div { line-height: 24px; color: #666; } -.form-horizontal .help-required { +.form-horizontal .help-required, +.form-inline .help-required { display: none; } .sliderholder { diff --git a/hasjob/static/build/js/app-css.3246dcaf06972fc8f57c.js b/hasjob/static/build/js/app-css.37ea9237db434b9a038f.js similarity index 100% rename from hasjob/static/build/js/app-css.3246dcaf06972fc8f57c.js rename to hasjob/static/build/js/app-css.37ea9237db434b9a038f.js diff --git a/hasjob/static/build/js/app.3246dcaf06972fc8f57c.js b/hasjob/static/build/js/app.3246dcaf06972fc8f57c.js deleted file mode 100644 index 66d3db670..000000000 --- a/hasjob/static/build/js/app.3246dcaf06972fc8f57c.js +++ /dev/null @@ -1 +0,0 @@ -webpackJsonp([2],{yZ5m:function(module,exports,__webpack_require__){"use strict";Hasjob.Util={updateGA:function(){if(window.ga){var path=window.location.href.split(window.location.host)[1];window.ga("set","page",path),window.ga("send","pageview")}},createCustomEvent:function(eventName){if("function"==typeof window.Event)var customEvent=new Event(eventName);else{var customEvent=document.createEvent("Event");customEvent.initEvent(eventName,!0,!0)}return customEvent}},window.Hasjob.JobPost={handleStarClick:function(){$("#main-content").on("click",".pstar",function(e){var starlink=$(this).find("i"),csrf_token=$('meta[name="csrf-token"]').attr("content");return starlink.addClass("fa-spin"),$.ajax("/star/"+starlink.data("id"),{type:"POST",data:{csrf_token:csrf_token},dataType:"json",complete:function(){starlink.removeClass("fa-spin")},success:function(data){!0===data.is_starred?starlink.removeClass("fa-star-o").addClass("fa-star").parent().find(".pstar-caption").html("Bookmarked"):starlink.removeClass("fa-star").addClass("fa-star-o").parent().find(".pstar-caption").html("Bookmark this")}}),!1})},handleGroupClick:function(){var node,outer,inner,outerTemplate=document.createElement("li"),innerTemplate=document.createElement("a");outerTemplate.setAttribute("class","col-xs-12 col-md-3 col-sm-4 animated shake"),innerTemplate.setAttribute("class","stickie"),innerTemplate.setAttribute("rel","bookmark"),$("#main-content").on("click","#stickie-area li.grouped",function(e){e.preventDefault();for(var group=this,parent=group.parentNode,i=0;i255||rgba[1]>255||rgba[2]>255?"#FFFFFF":0==rgba[0]&&0==rgba[1]&&0==rgba[2]?window.Hasjob.Config[funnelName].maxColour:"#"+("000000"+(rgba[0]<<16)|rgba[1]<<8|rgba[2]).toString(16).slice(-6);var element=document.getElementById(elementId);element.classList.add("funnel-color-set"),element.style.backgroundColor=colourHex},renderGradientColour:function(){$(".js-funnel").each(function(){$(this).hasClass("funnel-color-set")||Hasjob.StickieList.setGradientColour($(this).data("funnel-name"),$(this).data("funnel-value"),$(this).attr("id"))})},createGradientColour:function(){Hasjob.StickieList.createGradientColourScale("impressions",Hasjob.Config.MaxCounts.max_impressions),Hasjob.StickieList.createGradientColourScale("views",Hasjob.Config.MaxCounts.max_views),Hasjob.StickieList.createGradientColourScale("opens",Hasjob.Config.MaxCounts.max_opens),Hasjob.StickieList.createGradientColourScale("applied",Hasjob.Config.MaxCounts.max_applied)},initFunnelViz:function(){window.addEventListener("onStickiesInit",function(e){window.Hasjob.Config.MaxCounts&&(Hasjob.StickieList.createGradientColour(),Hasjob.StickieList.renderGradientColour())},!1),window.addEventListener("onStickiesRefresh",function(e){window.Hasjob.Config.MaxCounts&&Hasjob.StickieList.renderGradientColour()},!1),window.addEventListener("onStickiesPagination",function(e){window.Hasjob.Config.MaxCounts&&Hasjob.StickieList.renderGradientColour()},!1)}},window.Hasjob.Filters={toParam:function(){var sortedFilterParams=this.formatFilterParams($("#js-job-filters").serializeArray());return sortedFilterParams.length?$.param(sortedFilterParams):""},getCurrentState:function(){return Object.keys(window.Hasjob.Config.selectedFilters).length||(window.Hasjob.Config.selectedFilters={selectedLocations:[],selectedTypes:[],selectedCategories:[],selectedQuery:"",selectedCurrency:"",pay:0,equity:""}),{jobLocations:window.Hasjob.Config.allFilters.job_location_filters,jobTypes:window.Hasjob.Config.allFilters.job_type_filters,jobCategories:window.Hasjob.Config.allFilters.job_category_filters,jobsArchive:window.Hasjob.Config.selectedFilters.archive,selectedLocations:window.Hasjob.Config.selectedFilters.location_names,selectedTypes:window.Hasjob.Config.selectedFilters.types,selectedCategories:window.Hasjob.Config.selectedFilters.categories,selectedQuery:window.Hasjob.Config.selectedFilters.query_string,selectedCurrency:window.Hasjob.Config.selectedFilters.currency,pay:window.Hasjob.Config.selectedFilters.pay,equity:window.Hasjob.Config.selectedFilters.equity,isMobile:$(window).width()<768}},init:function(){var keywordTimeout,pageScrollTimerId,filters=this,isFilterDropdownClosed=!0,filterMenuHeight=$("#hg-sitenav").height();filters.dropdownMenu=new Ractive({el:"job-filters-ractive-template",template:"#filters-ractive",data:this.getCurrentState(),openOnMobile:function(event){event.original.preventDefault(),filters.dropdownMenu.set("show",!0)},closeOnMobile:function(event){event&&event.original.preventDefault(),filters.dropdownMenu.set("show",!1)},complete:function(){$(window).resize(function(){$(window).width()<768?filters.dropdownMenu.set("isMobile",!0):filters.dropdownMenu.set("isMobile",!1)}),$(document).on("click",function(event){$("#js-job-filters")===event.target||$(event.target).parents("#filter-dropdown").length||filters.dropdownMenu.closeOnMobile()})}});var pageScrollTimer=function(){return setInterval(function(){isFilterDropdownClosed&&($(window).scrollTop()>filterMenuHeight?$("#hg-sitenav").slideUp():$("#hg-sitenav").slideDown())},250)};$(window).width()>767&&(pageScrollTimerId=pageScrollTimer()),$(window).resize(function(){$(window).width()<768?($("#hg-sitenav").show(),pageScrollTimerId&&(clearInterval(pageScrollTimerId),pageScrollTimerId=0)):(filterMenuHeight=$("#hg-sitenav").height(),pageScrollTimerId||(pageScrollTimerId=pageScrollTimer()))}),$("#job-filters-keywords").on("change",function(){$(this).val($(this).val().trim())}),$(".js-handle-filter-change").on("change",function(e){window.Hasjob.StickieList.refresh()});var lastKeyword="";$(".js-handle-keyword-update").on("keyup",function(){$(this).val()!==lastKeyword&&(window.clearTimeout(keywordTimeout),lastKeyword=$(this).val(),keywordTimeout=window.setTimeout(window.Hasjob.StickieList.refresh,1e3))}),$("#job-filters-location").multiselect({nonSelectedText:"Location",numberDisplayed:1,buttonWidth:"100%",enableFiltering:!0,enableCaseInsensitiveFiltering:!0,templates:{filter:'
  • ',filterClearBtn:'
  • '},optionClass:function(element){if($(element).hasClass("unavailable"))return"unavailable"},onDropdownShow:function(event,ui){isFilterDropdownClosed=!1},onDropdownHide:function(event,ui){isFilterDropdownClosed=!0}}),$(".job-filter-location-search-clear").click(function(e){$("#job-filter-location-search").val("")}),$("#job-filters-type").multiselect({nonSelectedText:"Job Type",numberDisplayed:1,buttonWidth:"100%",optionClass:function(element){if($(element).hasClass("unavailable"))return"unavailable"},onDropdownShow:function(event,ui){isFilterDropdownClosed=!1},onDropdownHide:function(event,ui){isFilterDropdownClosed=!0}}),$("#job-filters-category").multiselect({nonSelectedText:"Job Category",numberDisplayed:1,buttonWidth:"100%",optionClass:function(element){if($(element).hasClass("unavailable"))return"unavailable"},onDropdownShow:function(event,ui){isFilterDropdownClosed=!1},onDropdownHide:function(event,ui){isFilterDropdownClosed=!0}}),$("#job-filters-pay").on("shown.bs.dropdown",function(){isFilterDropdownClosed=!1}),$("#job-filters-pay").on("hidden.bs.dropdown",function(){isFilterDropdownClosed=!0}),$(document).keydown(function(event){27===event.keyCode&&(event.preventDefault(),filters.dropdownMenu.closeOnMobile())})},formatFilterParams:function(formParams){for(var sortedFilterParams=[],currencyVal="",fpIndex=0;fpIndex0&&(afterPoint=value.substring(value.indexOf("."),value.length)),value=Math.floor(value),value=value.toString();var lastThree=value.substring(value.length-3),otherNumbers=value.substring(0,value.length-3);return""!==otherNumbers&&(lastThree=","+lastThree),"₹"+otherNumbers.replace(/\B(?=(\d{2})+(?!\d))/g,",")+lastThree+afterPoint},window.Hasjob.Currency.prefix=function(currency){var currencyMap={default:"¤",inr:"₹",usd:"$",sgd:"S$",aud:"A$",eur:"€",gbp:"£"};return void 0===currency||"na"==currency.toLowerCase()?currencyMap.default:currencyMap[currency.toLowerCase()]},window.Hasjob.Currency.isRupee=function(currency){return"inr"===currency.toLowerCase()},window.Hasjob.Currency.wNumbFormat=function(currency){var prefix="¤",encoder=null;return currency&&window.Hasjob.Currency.isRupee(currency)&&(encoder=Hasjob.Currency.indian_rupee_encoder),prefix=Hasjob.Currency.prefix(currency),null===encoder?window.wNumb({decimals:0,thousand:",",prefix:prefix}):window.wNumb({decimals:0,thousand:",",prefix:prefix,edit:encoder})},window.Hasjob.Currency.formatTo=function(currency,value){return window.Hasjob.Currency.wNumbFormat(currency).to(value)},window.Hasjob.Currency.formatFrom=function(currency,value){return window.Hasjob.Currency.wNumbFormat(currency).from(value)},window.Hasjob.PaySlider.toNumeric=function(str){return str.slice(1).replace(/,/g,"")},window.Hasjob.PaySlider.range=function(currency){return"INR"===currency?{min:[0,5e3],"15%":[1e5,1e4],"30%":[2e5,5e4],"70%":[2e6,1e5],"85%":[1e7,1e6],max:[1e8]}:{min:[0,5e3],"2%":[2e5,5e4],"10%":[1e6,1e5],max:[1e7,1e5]}},window.Hasjob.PaySlider.prototype.init=function(){return this.slider=$(this.selector).noUiSlider({start:this.start,connect:this.start.constructor===Array,behaviour:"tap",range:{min:[0,5e4],"10%":[1e6,1e5],max:[1e7,1e5]},format:window.wNumb({decimals:0,thousand:",",prefix:"¤"})}),this.slider.Link("lower").to($(this.minField)),void 0!==this.maxField&&this.slider.Link("upper").to($(this.maxField)),this},window.Hasjob.PaySlider.prototype.resetSlider=function(currency){var start,startval=this.slider.val();start=startval.constructor===Array?[Hasjob.PaySlider.toNumeric(startval[0]),Hasjob.PaySlider.toNumeric(startval[1])]:Hasjob.PaySlider.toNumeric(startval),this.slider.noUiSlider({start:start,connect:start.constructor===Array,range:Hasjob.PaySlider.range(currency),format:Hasjob.Currency.wNumbFormat(currency)},!0),this.slider.Link("lower").to($(this.minField)),void 0!==this.maxField&&this.slider.Link("upper").to($(this.maxField))},$(function(){Ractive.DEBUG=!1,$(window).on("popstate",function(event){if(!event.originalEvent.state||!event.originalEvent.state.reloadOnPop)return!1;location.reload(!0)}),window.Hasjob.Filters.init(),window.Hasjob.JobPost.handleStarClick(),window.Hasjob.JobPost.handleGroupClick(),window.Hasjob.StickieList.initFunnelViz();var getCurrencyVal=function(){return $("input[type='radio'][name='currency']:checked").val()},setPayTextField=function(){var payFieldLabel,currencyLabel="Pay",equityLabel="";if($("#job-filters-equity").is(":checked")&&(equityLabel+=" + %"),"na"===getCurrencyVal().toLowerCase())currencyLabel="Pay";else{currencyLabel="0"===Hasjob.PaySlider.toNumeric($("#job-filters-payval").val())?"Pay "+getCurrencyVal():$("#job-filters-payval").val()+" per year"}payFieldLabel="Pay"===currencyLabel&&""!==equityLabel?"Equity (%)":currencyLabel+equityLabel,$("#job-filters-pay-text").html(payFieldLabel)};$("#job-filters-equity").on("change",function(){setPayTextField()});var presetCurrency=window.Hasjob.Config&&window.Hasjob.Config.selectedFilters.currency||"NA";$.each($("input[type='radio'][name='currency']"),function(index,currencyRadio){$(currencyRadio).val()===presetCurrency&&$(currencyRadio).attr("checked","checked")}),window.Hasjob.Config&&window.Hasjob.Config.selectedFilters.equity&&$("input[type='checkbox'][name='equity']").attr("checked","checked"),$("input[type='radio'][name='currency']").on("change",function(){setPaySliderVisibility(),paySlider.resetSlider(getCurrencyVal()),setPayTextField()}),$("ul.pay-filter-dropdown").click(function(e){e.stopPropagation()});var setPaySliderVisibility=function(){"na"===getCurrencyVal().toLowerCase()?$(".pay-filter-slider").slideUp():$(".pay-filter-slider").slideDown()},paySlider=new Hasjob.PaySlider({start:window.Hasjob.Config&&window.Hasjob.Config.selectedFilters.pay||0,selector:"#pay-slider",minField:"#job-filters-payval"});$("#pay-slider").on("slide",function(){setPayTextField()}),$("#pay-slider").on("change",function(){window.Hasjob.StickieList.refresh()}),setPaySliderVisibility(),paySlider.resetSlider(getCurrencyVal()),setPayTextField()})}},["yZ5m"]); \ No newline at end of file diff --git a/hasjob/static/build/js/app.37ea9237db434b9a038f.js b/hasjob/static/build/js/app.37ea9237db434b9a038f.js new file mode 100644 index 000000000..211c8f1da --- /dev/null +++ b/hasjob/static/build/js/app.37ea9237db434b9a038f.js @@ -0,0 +1 @@ +webpackJsonp([2],{yZ5m:function(module,exports,__webpack_require__){"use strict";Hasjob.Util={updateGA:function(){if(window.ga){var path=window.location.href.split(window.location.host)[1];window.ga("set","page",path),window.ga("send","pageview")}},createCustomEvent:function(eventName){if("function"==typeof window.Event)var customEvent=new Event(eventName);else{var customEvent=document.createEvent("Event");customEvent.initEvent(eventName,!0,!0)}return customEvent}},window.Hasjob.Subscribe={handleEmailSubscription:function(){$("#subscribe-jobalerts").on("submit",function(event){event.preventDefault();var form=this;$.ajax({url:$(this).attr("action")+"?"+window.Hasjob.Filters.toParam(),type:"POST",data:$(form).serialize(),dataType:"json",beforeSend:function(){$(form).find('button[type="submit"]').prop("disabled",!0),$(form).find(".loading").removeClass("hidden")}}).complete(function(remoteData){console.log("done"),window.location.href=window.location.href})}),$("#close-subscribe-form").on("click",function(e){e.preventDefault(),$("#subscribe-jb-form").slideUp()})},init:function(){this.handleEmailSubscription()}},window.Hasjob.JobPost={handleStarClick:function(){$("#main-content").on("click",".pstar",function(e){var starlink=$(this).find("i"),csrf_token=$('meta[name="csrf-token"]').attr("content");return starlink.addClass("fa-spin"),$.ajax("/star/"+starlink.data("id"),{type:"POST",data:{csrf_token:csrf_token},dataType:"json",complete:function(){starlink.removeClass("fa-spin")},success:function(data){!0===data.is_starred?starlink.removeClass("fa-star-o").addClass("fa-star").parent().find(".pstar-caption").html("Bookmarked"):starlink.removeClass("fa-star").addClass("fa-star-o").parent().find(".pstar-caption").html("Bookmark this")}}),!1})},handleGroupClick:function(){var node,outer,inner,outerTemplate=document.createElement("li"),innerTemplate=document.createElement("a");outerTemplate.setAttribute("class","col-xs-12 col-md-3 col-sm-4 animated shake"),innerTemplate.setAttribute("class","stickie"),innerTemplate.setAttribute("rel","bookmark"),$("#main-content").on("click","#stickie-area li.grouped",function(e){e.preventDefault();for(var group=this,parent=group.parentNode,i=0;i255||rgba[1]>255||rgba[2]>255?"#FFFFFF":0==rgba[0]&&0==rgba[1]&&0==rgba[2]?window.Hasjob.Config[funnelName].maxColour:"#"+("000000"+(rgba[0]<<16)|rgba[1]<<8|rgba[2]).toString(16).slice(-6);var element=document.getElementById(elementId);element.classList.add("funnel-color-set"),element.style.backgroundColor=colourHex},renderGradientColour:function(){$(".js-funnel").each(function(){$(this).hasClass("funnel-color-set")||Hasjob.StickieList.setGradientColour($(this).data("funnel-name"),$(this).data("funnel-value"),$(this).attr("id"))})},createGradientColour:function(){Hasjob.StickieList.createGradientColourScale("impressions",Hasjob.Config.MaxCounts.max_impressions),Hasjob.StickieList.createGradientColourScale("views",Hasjob.Config.MaxCounts.max_views),Hasjob.StickieList.createGradientColourScale("opens",Hasjob.Config.MaxCounts.max_opens),Hasjob.StickieList.createGradientColourScale("applied",Hasjob.Config.MaxCounts.max_applied)},initFunnelViz:function(){window.addEventListener("onStickiesInit",function(e){window.Hasjob.Config.MaxCounts&&(Hasjob.StickieList.createGradientColour(),Hasjob.StickieList.renderGradientColour())},!1),window.addEventListener("onStickiesRefresh",function(e){window.Hasjob.Config.MaxCounts&&Hasjob.StickieList.renderGradientColour()},!1),window.addEventListener("onStickiesPagination",function(e){window.Hasjob.Config.MaxCounts&&Hasjob.StickieList.renderGradientColour()},!1)}},window.Hasjob.Filters={toParam:function(){var sortedFilterParams=this.formatFilterParams($("#js-job-filters").serializeArray());return sortedFilterParams.length?$.param(sortedFilterParams):""},getCurrentState:function(){return Object.keys(window.Hasjob.Config.selectedFilters).length||(window.Hasjob.Config.selectedFilters={selectedLocations:[],selectedTypes:[],selectedCategories:[],selectedQuery:"",selectedCurrency:"",pay:0,equity:""}),{jobLocations:window.Hasjob.Config.allFilters.job_location_filters,jobTypes:window.Hasjob.Config.allFilters.job_type_filters,jobCategories:window.Hasjob.Config.allFilters.job_category_filters,jobsArchive:window.Hasjob.Config.selectedFilters.archive,selectedLocations:window.Hasjob.Config.selectedFilters.location_names,selectedTypes:window.Hasjob.Config.selectedFilters.types,selectedCategories:window.Hasjob.Config.selectedFilters.categories,selectedQuery:window.Hasjob.Config.selectedFilters.query_string,selectedCurrency:window.Hasjob.Config.selectedFilters.currency,pay:window.Hasjob.Config.selectedFilters.pay,equity:window.Hasjob.Config.selectedFilters.equity,isMobile:$(window).width()<768}},init:function(){var keywordTimeout,pageScrollTimerId,filters=this,isFilterDropdownClosed=!0,filterMenuHeight=$("#hg-sitenav").height();filters.dropdownMenu=new Ractive({el:"job-filters-ractive-template",template:"#filters-ractive",data:this.getCurrentState(),openOnMobile:function(event){event.original.preventDefault(),filters.dropdownMenu.set("show",!0)},closeOnMobile:function(event){event&&event.original.preventDefault(),filters.dropdownMenu.set("show",!1)},complete:function(){$(window).resize(function(){$(window).width()<768?filters.dropdownMenu.set("isMobile",!0):filters.dropdownMenu.set("isMobile",!1)}),$(document).on("click",function(event){$("#js-job-filters")===event.target||$(event.target).parents("#filter-dropdown").length||filters.dropdownMenu.closeOnMobile()})}});var pageScrollTimer=function(){return setInterval(function(){isFilterDropdownClosed&&($(window).scrollTop()>filterMenuHeight?$("#hg-sitenav").slideUp():$("#hg-sitenav").slideDown())},250)};$(window).width()>767&&(pageScrollTimerId=pageScrollTimer()),$(window).resize(function(){$(window).width()<768?($("#hg-sitenav").show(),pageScrollTimerId&&(clearInterval(pageScrollTimerId),pageScrollTimerId=0)):(filterMenuHeight=$("#hg-sitenav").height(),pageScrollTimerId||(pageScrollTimerId=pageScrollTimer()))}),$("#job-filters-keywords").on("change",function(){$(this).val($(this).val().trim())}),$(".js-handle-filter-change").on("change",function(e){window.Hasjob.StickieList.refresh()});var lastKeyword="";$(".js-handle-keyword-update").on("keyup",function(){$(this).val()!==lastKeyword&&(window.clearTimeout(keywordTimeout),lastKeyword=$(this).val(),keywordTimeout=window.setTimeout(window.Hasjob.StickieList.refresh,1e3))}),$("#job-filters-location").multiselect({nonSelectedText:"Location",numberDisplayed:1,buttonWidth:"100%",enableFiltering:!0,enableCaseInsensitiveFiltering:!0,templates:{filter:'
  • ',filterClearBtn:'
  • '},optionClass:function(element){if($(element).hasClass("unavailable"))return"unavailable"},onDropdownShow:function(event,ui){isFilterDropdownClosed=!1},onDropdownHide:function(event,ui){isFilterDropdownClosed=!0}}),$(".job-filter-location-search-clear").click(function(e){$("#job-filter-location-search").val("")}),$("#job-filters-type").multiselect({nonSelectedText:"Job Type",numberDisplayed:1,buttonWidth:"100%",optionClass:function(element){if($(element).hasClass("unavailable"))return"unavailable"},onDropdownShow:function(event,ui){isFilterDropdownClosed=!1},onDropdownHide:function(event,ui){isFilterDropdownClosed=!0}}),$("#job-filters-category").multiselect({nonSelectedText:"Job Category",numberDisplayed:1,buttonWidth:"100%",optionClass:function(element){if($(element).hasClass("unavailable"))return"unavailable"},onDropdownShow:function(event,ui){isFilterDropdownClosed=!1},onDropdownHide:function(event,ui){isFilterDropdownClosed=!0}}),$("#job-filters-pay").on("shown.bs.dropdown",function(){isFilterDropdownClosed=!1}),$("#job-filters-pay").on("hidden.bs.dropdown",function(){isFilterDropdownClosed=!0}),$(document).keydown(function(event){27===event.keyCode&&(event.preventDefault(),filters.dropdownMenu.closeOnMobile())})},formatFilterParams:function(formParams){for(var sortedFilterParams=[],currencyVal="",fpIndex=0;fpIndex0&&(afterPoint=value.substring(value.indexOf("."),value.length)),value=Math.floor(value),value=value.toString();var lastThree=value.substring(value.length-3),otherNumbers=value.substring(0,value.length-3);return""!==otherNumbers&&(lastThree=","+lastThree),"₹"+otherNumbers.replace(/\B(?=(\d{2})+(?!\d))/g,",")+lastThree+afterPoint},window.Hasjob.Currency.prefix=function(currency){var currencyMap={default:"¤",inr:"₹",usd:"$",sgd:"S$",aud:"A$",eur:"€",gbp:"£"};return void 0===currency||"na"==currency.toLowerCase()?currencyMap.default:currencyMap[currency.toLowerCase()]},window.Hasjob.Currency.isRupee=function(currency){return"inr"===currency.toLowerCase()},window.Hasjob.Currency.wNumbFormat=function(currency){var prefix="¤",encoder=null;return currency&&window.Hasjob.Currency.isRupee(currency)&&(encoder=Hasjob.Currency.indian_rupee_encoder),prefix=Hasjob.Currency.prefix(currency),null===encoder?window.wNumb({decimals:0,thousand:",",prefix:prefix}):window.wNumb({decimals:0,thousand:",",prefix:prefix,edit:encoder})},window.Hasjob.Currency.formatTo=function(currency,value){return window.Hasjob.Currency.wNumbFormat(currency).to(value)},window.Hasjob.Currency.formatFrom=function(currency,value){return window.Hasjob.Currency.wNumbFormat(currency).from(value)},window.Hasjob.PaySlider.toNumeric=function(str){return str.slice(1).replace(/,/g,"")},window.Hasjob.PaySlider.range=function(currency){return"INR"===currency?{min:[0,5e3],"15%":[1e5,1e4],"30%":[2e5,5e4],"70%":[2e6,1e5],"85%":[1e7,1e6],max:[1e8]}:{min:[0,5e3],"2%":[2e5,5e4],"10%":[1e6,1e5],max:[1e7,1e5]}},window.Hasjob.PaySlider.prototype.init=function(){return this.slider=$(this.selector).noUiSlider({start:this.start,connect:this.start.constructor===Array,behaviour:"tap",range:{min:[0,5e4],"10%":[1e6,1e5],max:[1e7,1e5]},format:window.wNumb({decimals:0,thousand:",",prefix:"¤"})}),this.slider.Link("lower").to($(this.minField)),void 0!==this.maxField&&this.slider.Link("upper").to($(this.maxField)),this},window.Hasjob.PaySlider.prototype.resetSlider=function(currency){var start,startval=this.slider.val();start=startval.constructor===Array?[Hasjob.PaySlider.toNumeric(startval[0]),Hasjob.PaySlider.toNumeric(startval[1])]:Hasjob.PaySlider.toNumeric(startval),this.slider.noUiSlider({start:start,connect:start.constructor===Array,range:Hasjob.PaySlider.range(currency),format:Hasjob.Currency.wNumbFormat(currency)},!0),this.slider.Link("lower").to($(this.minField)),void 0!==this.maxField&&this.slider.Link("upper").to($(this.maxField))},$(function(){Ractive.DEBUG=!1,$(window).on("popstate",function(event){if(!event.originalEvent.state||!event.originalEvent.state.reloadOnPop)return!1;location.reload(!0)}),window.Hasjob.Filters.init(),window.Hasjob.Subscribe.init(),window.Hasjob.JobPost.handleStarClick(),window.Hasjob.JobPost.handleGroupClick(),window.Hasjob.StickieList.initFunnelViz();var getCurrencyVal=function(){return $("input[type='radio'][name='currency']:checked").val()},setPayTextField=function(){var payFieldLabel,currencyLabel="Pay",equityLabel="";if($("#job-filters-equity").is(":checked")&&(equityLabel+=" + %"),"na"===getCurrencyVal().toLowerCase())currencyLabel="Pay";else{currencyLabel="0"===Hasjob.PaySlider.toNumeric($("#job-filters-payval").val())?"Pay "+getCurrencyVal():$("#job-filters-payval").val()+" per year"}payFieldLabel="Pay"===currencyLabel&&""!==equityLabel?"Equity (%)":currencyLabel+equityLabel,$("#job-filters-pay-text").html(payFieldLabel)};$("#job-filters-equity").on("change",function(){setPayTextField()});var presetCurrency=window.Hasjob.Config&&window.Hasjob.Config.selectedFilters.currency||"NA";$.each($("input[type='radio'][name='currency']"),function(index,currencyRadio){$(currencyRadio).val()===presetCurrency&&$(currencyRadio).attr("checked","checked")}),window.Hasjob.Config&&window.Hasjob.Config.selectedFilters.equity&&$("input[type='checkbox'][name='equity']").attr("checked","checked"),$("input[type='radio'][name='currency']").on("change",function(){setPaySliderVisibility(),paySlider.resetSlider(getCurrencyVal()),setPayTextField()}),$("ul.pay-filter-dropdown").click(function(e){e.stopPropagation()});var setPaySliderVisibility=function(){"na"===getCurrencyVal().toLowerCase()?$(".pay-filter-slider").slideUp():$(".pay-filter-slider").slideDown()},paySlider=new Hasjob.PaySlider({start:window.Hasjob.Config&&window.Hasjob.Config.selectedFilters.pay||0,selector:"#pay-slider",minField:"#job-filters-payval"});$("#pay-slider").on("slide",function(){setPayTextField()}),$("#pay-slider").on("change",function(){window.Hasjob.StickieList.refresh()}),setPaySliderVisibility(),paySlider.resetSlider(getCurrencyVal()),setPayTextField()})}},["yZ5m"]); \ No newline at end of file diff --git a/hasjob/static/build/js/manifest.3246dcaf06972fc8f57c.js b/hasjob/static/build/js/manifest.37ea9237db434b9a038f.js similarity index 100% rename from hasjob/static/build/js/manifest.3246dcaf06972fc8f57c.js rename to hasjob/static/build/js/manifest.37ea9237db434b9a038f.js diff --git a/hasjob/static/build/js/vendor.3246dcaf06972fc8f57c.js b/hasjob/static/build/js/vendor.37ea9237db434b9a038f.js similarity index 100% rename from hasjob/static/build/js/vendor.3246dcaf06972fc8f57c.js rename to hasjob/static/build/js/vendor.37ea9237db434b9a038f.js diff --git a/hasjob/static/build/manifest.json b/hasjob/static/build/manifest.json index 0b53a00c1..75c5a84f9 100644 --- a/hasjob/static/build/manifest.json +++ b/hasjob/static/build/manifest.json @@ -1 +1 @@ -{"assets":{"vendor":"js/vendor.3246dcaf06972fc8f57c.js","app-css":"css/stylesheet-app-css.3246dcaf06972fc8f57c.css","app":"js/app.3246dcaf06972fc8f57c.js","manifest":"js/manifest.3246dcaf06972fc8f57c.js"}} \ No newline at end of file +{"assets":{"vendor":"js/vendor.37ea9237db434b9a038f.js","app-css":"css/stylesheet-app-css.37ea9237db434b9a038f.css","app":"js/app.37ea9237db434b9a038f.js","manifest":"js/manifest.37ea9237db434b9a038f.js"}} \ No newline at end of file diff --git a/hasjob/static/img/form-border.png b/hasjob/static/img/form-border.png new file mode 100644 index 000000000..da2dea399 Binary files /dev/null and b/hasjob/static/img/form-border.png differ diff --git a/hasjob/static/service-worker.js b/hasjob/static/service-worker.js index e65312a89..efcaad996 100644 --- a/hasjob/static/service-worker.js +++ b/hasjob/static/service-worker.js @@ -8,19 +8,19 @@ const workboxSW = new self.WorkboxSW({ workboxSW.precache([ { - "url": "/static/build/css/stylesheet-app-css.3246dcaf06972fc8f57c.css", - "revision": "d8defbd537b7a3a67a53c13519660661" + "url": "/static/build/css/stylesheet-app-css.37ea9237db434b9a038f.css", + "revision": "693a35aed682b0dfed873cec4d519d1a" }, { - "url": "/static/build/js/app.3246dcaf06972fc8f57c.js", - "revision": "3deea6e4c0f55310d56b380ab35e66bd" + "url": "/static/build/js/app.37ea9237db434b9a038f.js", + "revision": "4fff106ee15be62700803edd0c89ce11" }, { - "url": "/static/build/js/manifest.3246dcaf06972fc8f57c.js", + "url": "/static/build/js/manifest.37ea9237db434b9a038f.js", "revision": "fe684bf23b9518850a7a3dd90492001d" }, { - "url": "/static/build/js/vendor.3246dcaf06972fc8f57c.js", + "url": "/static/build/js/vendor.37ea9237db434b9a038f.js", "revision": "12a1ca8d2cb2caab35d21438cb595e94" } ]); diff --git a/hasjob/templates/index.html.jinja2 b/hasjob/templates/index.html.jinja2 index 66a463d4f..8cf856ebc 100644 --- a/hasjob/templates/index.html.jinja2 +++ b/hasjob/templates/index.html.jinja2 @@ -1,5 +1,5 @@ {%- from "domain.html.jinja2" import org_profile, user_profile with context %} -{% from "baseframe/forms.html.jinja2" import renderfield, widgetscripts %} +{% from "baseframe/forms.html.jinja2" import renderfield, rendersubmit, widgetscripts %} {%- macro pagetitle() %}{% if title %}{% if domain %}Jobs at {% endif%}{{ title }} | {% endif %}{% if g.board %}{{ g.board.title }}{% if g.board.not_root %} ({{ config['SITE_TITLE'] }}){% endif %}{% else %}{{ config['SITE_TITLE'] }}{% endif %}{%- endmacro %} {%- if not request.is_xhr -%} {% extends "layout.html.jinja2" %} @@ -53,6 +53,29 @@ {%- endif %} {% endblock %} {% endif %} +{%- if not paginated %} +{% block subscribeform %} +
    +
    +
    +

    Subscribe to job alerts

    +

    Get an email alert when a new job matches your criteria:

    +
    + {{ subscription_form.hidden_tag() }} + {{ renderfield(subscription_form.email_frequency, style='horizlist', nolabel=true, css_class='no-left-padding') }} + {%- if g.user and g.user.email %} + + {%- else %} + {{ renderfield(subscription_form.email, autofocus=true, css_class='clearfix') }} + {%- endif %} + {{ rendersubmit([(None, "Subscribe", 'btn-primary')], style='') }} + +
    +
    +
    +
    +{% endblock %} +{%- endif %} {% block content %} {%- from "macros.html.jinja2" import stickie %} {% with gkiosk=g.kiosk, gboard=g.board, guser=g.user, gstarred_ids=g.starred_ids %} @@ -197,7 +220,9 @@ window.Hasjob.StickieList.loadmore({enable: false}); } {%- if not paginated and not request.is_xhr -%} - window.Hasjob.StickieList.init(); + window.addEventListener('load', function() { + window.Hasjob.StickieList.init(); + }); {%- endif %} }); diff --git a/hasjob/templates/job_alert_email_confirmation.html.jinja2 b/hasjob/templates/job_alert_email_confirmation.html.jinja2 new file mode 100644 index 000000000..62e379a8c --- /dev/null +++ b/hasjob/templates/job_alert_email_confirmation.html.jinja2 @@ -0,0 +1,17 @@ +{% extends "inc/email_layout_lite.html.jinja2" %} + +{% block content %} +
    +
    + +
    + +
    + + +
    +
    + Please click here to confirm your subscription. +
    +

    Hasjob is a service of HasGeek. Write to us at {{ config['SUPPORT_EMAIL'] }} if you have suggestions or questions on this service.

    +{% endblock %} diff --git a/hasjob/templates/job_alert_mailer.html.jinja2 b/hasjob/templates/job_alert_mailer.html.jinja2 new file mode 100644 index 000000000..b7cec553f --- /dev/null +++ b/hasjob/templates/job_alert_mailer.html.jinja2 @@ -0,0 +1,125 @@ +{% block stylesheet -%} + +{%- endblock %} + +{% block content %} +
    +
    + +
    + +
    + + +
    +
    + +

    View more job post

    +
    +

    Hasjob is a service of HasGeek. Write to us at {{ config['SUPPORT_EMAIL'] }} if you have suggestions or questions on this service.

    +{% endblock %} diff --git a/hasjob/templates/layout.html.jinja2 b/hasjob/templates/layout.html.jinja2 index e7e08ff27..cca5e9c8c 100644 --- a/hasjob/templates/layout.html.jinja2 +++ b/hasjob/templates/layout.html.jinja2 @@ -215,6 +215,7 @@ {% endblock %} {% block basecontent -%} + {% block subscribeform %}{% endblock %} {%- if header_campaign %}
    {% endif %}
    {% block pagecontent %}{% block content %}{% endblock %}{% endblock %} diff --git a/hasjob/templates/macros.html.jinja2 b/hasjob/templates/macros.html.jinja2 index 96f427ce4..9d736f771 100644 --- a/hasjob/templates/macros.html.jinja2 +++ b/hasjob/templates/macros.html.jinja2 @@ -118,7 +118,7 @@ {%- endif %} {%- endif %}{%- endfor %} - +

    diff --git a/hasjob/views/__init__.py b/hasjob/views/__init__.py index 46f04ad1a..91d8a8a22 100644 --- a/hasjob/views/__init__.py +++ b/hasjob/views/__init__.py @@ -30,4 +30,4 @@ def root_paths(): ] from . import (index, error_handling, helper, listing, location, static, login, board, kiosk, campaign, # NOQA - admindash, domain, api, admin_filterset) + admindash, domain, api, admin_filterset, job_alerts) diff --git a/hasjob/views/admin_filterset.py b/hasjob/views/admin_filterset.py index 242d8033a..7fb669e3f 100644 --- a/hasjob/views/admin_filterset.py +++ b/hasjob/views/admin_filterset.py @@ -26,7 +26,7 @@ def new(self): form = FiltersetForm(parent=g.board) if form.validate_on_submit(): - filterset = Filterset(board=g.board, title=form.title.data) + filterset = Filterset(board=g.board, title=form.title.data, sitemap=True) form.populate_obj(filterset) try: db.session.add(filterset) diff --git a/hasjob/views/board.py b/hasjob/views/board.py index 7a7d3f995..404ff0ba3 100644 --- a/hasjob/views/board.py +++ b/hasjob/views/board.py @@ -33,7 +33,7 @@ def remove_subdomain_parameter(endpoint, values): def add_subdomain_parameter(endpoint, values): if app.url_map.is_endpoint_expecting(endpoint, 'subdomain'): if 'subdomain' not in values: - values['subdomain'] = g.board.name if g.board and g.board.not_root else None + values['subdomain'] = g.board.name if 'board' in g and g.board.not_root else None @app.route('/board', methods=['GET', 'POST']) diff --git a/hasjob/views/helper.py b/hasjob/views/helper.py index 832d3ef91..630cc8d09 100644 --- a/hasjob/views/helper.py +++ b/hasjob/views/helper.py @@ -430,7 +430,7 @@ def load_viewcounts(posts): g.maxcounts = maxcounts_values -def getposts(basequery=None, pinned=False, showall=False, statusfilter=None, ageless=False, limit=2000, order=True): +def getposts(basequery=None, pinned=False, showall=False, statusfilter=None, ageless=False, limit=2000, board=None, order=True): if ageless: pinned = False # No pinning when browsing archives @@ -442,7 +442,9 @@ def getposts(basequery=None, pinned=False, showall=False, statusfilter=None, age query = basequery.filter(statusfilter).options(*JobPost._defercols).options(db.joinedload('domain')) - if g.board: + if 'board' in g: + board = g.board + if board: query = query.join(JobPost.postboards).filter(BoardJobPost.board == g.board) if not ageless: @@ -450,7 +452,7 @@ def getposts(basequery=None, pinned=False, showall=False, statusfilter=None, age query = query.filter(JobPost.state.LISTED) else: if pinned: - if g.board: + if board: query = query.filter( db.or_( db.and_(BoardJobPost.pinned == True, JobPost.state.LISTED), @@ -464,7 +466,7 @@ def getposts(basequery=None, pinned=False, showall=False, statusfilter=None, age query = query.filter(JobPost.state.NEW) if pinned: - if g.board: + if board: query = query.order_by(db.desc(BoardJobPost.pinned)) else: query = query.order_by(db.desc(JobPost.pinned)) diff --git a/hasjob/views/index.py b/hasjob/views/index.py index a00b901ef..ca2a947a6 100644 --- a/hasjob/views/index.py +++ b/hasjob/views/index.py @@ -9,8 +9,8 @@ from coaster.views import render_with from baseframe import _ # , dogpile -from .. import app, lastuser -from ..models import (db, JobCategory, JobPost, JobType, POST_STATE, newlimit, agelimit, JobLocation, Board, Filterset, +from .. import app, lastuser, forms +from ..models import (db, JobCategory, JobPost, JobType, newlimit, agelimit, JobLocation, Board, Filterset, Domain, Location, Tag, JobPostTag, Campaign, CAMPAIGN_POSITION, CURRENCY, JobApplication, starred_job_table, BoardJobPost) from ..views.helper import (getposts, getallposts, gettags, location_geodata, load_viewcounts, session_jobpost_ab, bgroup, make_pay_graph, index_is_paginated, get_post_viewcounts, get_max_counts) @@ -90,38 +90,38 @@ def json_index(data): return jsonify(result) -def fetch_jobposts(request_args, request_values, filters, is_index, board, board_jobs, gkiosk, basequery, md5sum, domain, location, title, showall, statusfilter, batched, ageless, template_vars, search_query=None, query_string=None): +def fetch_jobposts(request_args={}, request_values={}, filters={}, is_index=False, board=None, board_jobs={}, gkiosk=False, basequery=None, md5sum=None, domain=None, location=None, title=None, showall=True, statusfilter=None, batched=True, ageless=False, template_vars={}, search_query=None, query_string=None, posts_only=False): if basequery is None: basequery = JobPost.query # Apply request.args filters data_filters = {} - f_types = filters.get('t') or request_args.getlist('t') + f_types = filters.get('t') or (request_args and request_args.getlist('t')) while '' in f_types: f_types.remove('') if f_types: data_filters['types'] = f_types basequery = basequery.join(JobType).filter(JobType.name.in_(f_types)) - f_categories = filters.get('c') or request_args.getlist('c') + f_categories = filters.get('c') or (request_args and request_args.getlist('c')) while '' in f_categories: f_categories.remove('') if f_categories: data_filters['categories'] = f_categories basequery = basequery.join(JobCategory).filter(JobCategory.name.in_(f_categories)) - f_domains = filters.get('d') or request_args.getlist('d') + f_domains = filters.get('d') or (request_args and request_args.getlist('d')) while '' in f_domains: f_domains.remove('') if f_domains: basequery = basequery.join(Domain).filter(Domain.name.in_(f_domains)) - f_tags = filters.get('k') or request_args.getlist('k') + f_tags = filters.get('k') or (request_args and request_args.getlist('k')) while '' in f_tags: f_tags.remove('') if f_tags: basequery = basequery.join(JobPostTag).join(Tag).filter(Tag.name.in_(f_tags)) - data_filters['location_names'] = r_locations = filters.get('l') or request_args.getlist('l') + data_filters['location_names'] = r_locations = filters.get('l') or (request_args and request_args.getlist('l')) if location: r_locations.append(location['geonameid']) f_locations = [] @@ -201,12 +201,14 @@ def fetch_jobposts(request_args, request_values, filters, is_index, board, board data_filters['query_string'] = query_string basequery = basequery.filter(JobPost.search_vector.match(search_query, postgresql_regconfig='english')) + posts = getposts(basequery, pinned=True, showall=showall, statusfilter=statusfilter, ageless=ageless).all() + if posts_only: + return posts + if data_filters: showall = True batched = True - posts = getposts(basequery, pinned=True, showall=showall, statusfilter=statusfilter, ageless=ageless).all() - if posts: employer_name = posts[0].company_name else: @@ -267,7 +269,7 @@ def fetch_jobposts(request_args, request_values, filters, is_index, board, board batchsize = 32 # list of posts that were pinned at the time of first load - pinned_hashids = request_args.getlist('ph') + pinned_hashids = (request_args and request_args.getlist('ph')) # Depending on the display mechanism (grouped or ungrouped), extract the batch if grouped: if not startdate: @@ -445,6 +447,8 @@ def index(basequery=None, filters={}, md5sum=None, tag=None, domain=None, locati data['max_views'] = max_counts['max_views'] data['max_opens'] = max_counts['max_opens'] data['max_applied'] = max_counts['max_applied'] + data['max_applied'] = max_counts['max_applied'] + data['subscription_form'] = forms.JobPostSubscriptionForm() if filterset: data['filterset'] = filterset @@ -772,7 +776,7 @@ def sitemap(key): ' \n' # Add filtered views to sitemap - for item in Filterset.query.all(): + for item in Filterset.query.filter(Filterset.sitemap == True): sitemapxml += ' \n'\ ' %s\n' % item.url_for(_external=True) + \ ' %s\n' % (item.updated_at.isoformat() + 'Z') + \ diff --git a/hasjob/views/job_alerts.py b/hasjob/views/job_alerts.py new file mode 100644 index 000000000..089524758 --- /dev/null +++ b/hasjob/views/job_alerts.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- + + +from flask import abort, redirect, render_template, request, url_for, g, flash +from flask_mail import Message +from flask_rq import job +from premailer import transform as email_transform +from pyisemail import is_email +from html2text import html2text +from baseframe import _ +from .. import app, mail +from ..models import (db, JobPostSubscription, Filterset) +from ..forms import JobPostSubscriptionForm + + +@job('hasjob') +def send_confirmation_email_for_job_alerts(to_address, token): + msg = Message(subject=u"Please confirm your email to receive alerts on new jobs", recipients=[to_address]) + html = email_transform(render_template('job_alert_email_confirmation.html.jinja2', token=token)) + msg.html = html + msg.body = html2text(html) + mail.send(msg) + + +@app.route('/api/1/subscribe_to_job_alerts', subdomain='', methods=['POST']) +@app.route('/api/1/subscribe_to_job_alerts', methods=['POST']) +def subscribe_to_job_alerts(): + form = JobPostSubscriptionForm() + if not form.validate_on_submit(): + flash(_(u"Oops! Sorry, we need an valid email address to send you alerts."), 'danger') + return redirect(url_for('index'), code=302) + + if g.user and g.user.email: + email = g.user.email + message = _(u"Thank you for signing up to receive job alerts from us! We'll keep you posted.") + verified_user = True + elif form.email.data: + email = form.email.data + message = _(u"Thank you for signing up to receive job alerts from us! We've sent you a confirmation email, please do confirm it so we can keep you posted.") + verified_user = False + + filters = { + 'l': request.args.getlist('l'), + 't': request.args.getlist('t'), + 'c': request.args.getlist('c'), + 'currency': request.args.get('currency'), + 'pay': request.args.get('pay'), + 'equity': request.args.get('equity'), + 'q': request.args.get('q') + } + filterset = Filterset.from_filters(g.board, filters) + if filterset: + existing_subscription = JobPostSubscription.get(filterset, email) + if existing_subscription: + flash(_(u"You've already subscribed to receive alerts for jobs that match this filtering criteria."), 'danger') + return redirect(url_for('index'), code=302) + else: + filterset = Filterset(board=g.board, filters=filters) + db.session.add(filterset) + + subscription = JobPostSubscription(filterset=filterset, email=email, user=g.user, anon_user=g.anon_user) + + if verified_user: + subscription.verify_email() + + db.session.add(subscription) + db.session.commit() + if not verified_user: + send_confirmation_email_for_job_alerts.delay(to_address=subscription.email, token=subscription.email_verify_key) + + flash(message, 'success') + return redirect(url_for('index'), code=302) + + +@app.route('/api/1/confirm_subscription_to_job_alerts', subdomain='') +@app.route('/api/1/confirm_subscription_to_job_alerts') +def confirm_subscription_to_job_alerts(): + subscription = JobPostSubscription.query.filter_by(email_verify_key=request.args.get('token')).one_or_none() + if not subscription: + abort(404) + if subscription.email_verified_at: + abort(400) + subscription.verify_email() + db.session.commit() + flash(_(u"You've just subscribed to receive alerts from us! We'll keep you posted."), 'success') + return redirect(url_for('index'), code=302) + + +@app.route('/api/1/unsubscribe_from_job_alerts', subdomain='') +@app.route('/api/1/unsubscribe_from_job_alerts') +def unsubscribe_from_job_alerts(): + subscription = JobPostSubscription.query.filter_by(unsubscribe_key=request.args.get('token')).one_or_none() + if not subscription: + abort(404) + if not subscription.email_verified_at: + abort(400) + subscription.unsubscribe() + db.session.commit() + flash(_(u"You've just unsubscribed from receiving alerts! Hope they were useful."), 'success') + return redirect(url_for('index'), code=302) diff --git a/manage.py b/manage.py index 434e28d1b..114357dfd 100755 --- a/manage.py +++ b/manage.py @@ -8,6 +8,7 @@ import hasjob.views as views from hasjob.models import db from hasjob import app +from hasjob.jobs.job_alerts import send_email_alerts from datetime import datetime, timedelta periodic = Manager(usage="Periodic tasks from cron (with recommended intervals)") @@ -36,6 +37,12 @@ def campaignviews(): views.helper.reset_campaign_views() +@periodic.command +def send_jobpost_alerts(): + """Run email alerts every 10 minutes""" + send_email_alerts.delay() + + if __name__ == '__main__': db.init_app(app) manager = init_manager(app, db, hasjob=hasjob, models=models, forms=forms, views=views) diff --git a/migrations/versions/62a7a0ded059_schema_changes_for_job_alerts.py b/migrations/versions/62a7a0ded059_schema_changes_for_job_alerts.py new file mode 100644 index 000000000..5d2accb4a --- /dev/null +++ b/migrations/versions/62a7a0ded059_schema_changes_for_job_alerts.py @@ -0,0 +1,84 @@ +"""schema_changes_for_job_alerts + +Revision ID: 62a7a0ded059 +Revises: 625415764254 +Create Date: 2018-04-11 14:37:35.020056 + +""" + +# revision identifiers, used by Alembic. +revision = '62a7a0ded059' +down_revision = '625415764254' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.create_table('jobpost_subscription', + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('filterset_id', sa.Integer(), nullable=False), + sa.Column('email', sa.Unicode(length=254), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('anon_user_id', sa.Integer(), nullable=True), + sa.Column('active', sa.Boolean(), nullable=False), + sa.Column('email_verify_key', sa.String(length=40), nullable=True), + sa.Column('unsubscribe_key', sa.String(length=40), nullable=True), + sa.Column('subscribed_at', sa.DateTime(), nullable=False), + sa.Column('email_verified_at', sa.DateTime(), nullable=True), + sa.Column('unsubscribed_at', sa.DateTime(), nullable=True), + sa.Column('email_frequency', sa.Integer(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.CheckConstraint(u'CASE WHEN (jobpost_subscription.user_id IS NOT NULL) THEN 1 ELSE 0 END + CASE WHEN (jobpost_subscription.anon_user_id IS NOT NULL) THEN 1 ELSE 0 END = 1', name='jobpost_subscription_user_id_or_anon_user_id'), + sa.ForeignKeyConstraint(['anon_user_id'], ['anon_user.id'], ), + sa.ForeignKeyConstraint(['filterset_id'], ['filterset.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email_verify_key'), + sa.UniqueConstraint('filterset_id', 'email'), + sa.UniqueConstraint('unsubscribe_key') + ) + op.create_index(op.f('ix_jobpost_subscription_active'), 'jobpost_subscription', ['active'], unique=False) + op.create_index(op.f('ix_jobpost_subscription_anon_user_id'), 'jobpost_subscription', ['anon_user_id'], unique=False) + op.create_index(op.f('ix_jobpost_subscription_email_verified_at'), 'jobpost_subscription', ['email_verified_at'], unique=False) + op.create_index(op.f('ix_jobpost_subscription_user_id'), 'jobpost_subscription', ['user_id'], unique=False) + + op.create_table('jobpost_alert', + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('jobpost_subscription_id', sa.Integer(), nullable=True), + sa.Column('sent_at', sa.DateTime(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['jobpost_subscription_id'], ['jobpost_subscription.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_jobpost_alert_jobpost_subscription_id'), 'jobpost_alert', ['jobpost_subscription_id'], unique=False) + + op.create_table('jobpost_jobpost_alert', + sa.Column('jobpost_id', sa.Integer(), nullable=False), + sa.Column('jobpost_alert_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['jobpost_alert_id'], ['jobpost_alert.id'], ), + sa.ForeignKeyConstraint(['jobpost_id'], ['jobpost.id'], ), + sa.PrimaryKeyConstraint('jobpost_id', 'jobpost_alert_id') + ) + op.add_column(u'filterset', sa.Column('sitemap', sa.Boolean(), nullable=True)) + op.create_index(op.f('ix_filterset_sitemap'), 'filterset', ['sitemap'], unique=False) + op.alter_column('filterset', 'name', existing_type=sa.Unicode(), nullable=True) + op.alter_column('filterset', 'title', existing_type=sa.Unicode(), nullable=True) + + +def downgrade(): + op.alter_column('filterset', 'name', existing_type=sa.Unicode(), nullable=False) + op.alter_column('filterset', 'title', existing_type=sa.Unicode(), nullable=False) + op.drop_index(op.f('ix_filterset_sitemap'), table_name='filterset') + op.drop_column(u'filterset', 'sitemap') + op.drop_table('jobpost_jobpost_alert') + op.drop_index(op.f('ix_jobpost_alert_jobpost_subscription_id'), table_name='jobpost_alert') + op.drop_table('jobpost_alert') + op.drop_index(op.f('ix_jobpost_subscription_user_id'), table_name='jobpost_subscription') + op.drop_index(op.f('ix_jobpost_subscription_email_verified_at'), table_name='jobpost_subscription') + op.drop_index(op.f('ix_jobpost_subscription_anon_user_id'), table_name='jobpost_subscription') + op.drop_index(op.f('ix_jobpost_subscription_active'), table_name='jobpost_subscription') + op.drop_table('jobpost_subscription')