Skip to content

Commit

Permalink
Merge branch 'jobalerts' into master-jobalerts
Browse files Browse the repository at this point in the history
  • Loading branch information
shreyas-satish committed Apr 16, 2018
2 parents 41450f7 + 661d6c7 commit b5edf60
Show file tree
Hide file tree
Showing 16 changed files with 427 additions and 19 deletions.
2 changes: 1 addition & 1 deletion hasjob/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,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
Expand Down
3 changes: 3 additions & 0 deletions hasjob/jobs/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-

from . import job_alerts # NOQA
48 changes: 48 additions & 0 deletions hasjob/jobs/job_alerts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# -*- 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)
try:
mail.send(msg)
jobpost_alert.register_delivery()
except Exception as exc:
jobpost_alert.register_failure(unicode(exc))
db.session.add(jobpost_alert)
db.session.commit()
1 change: 1 addition & 0 deletions hasjob/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,4 @@ class CANDIDATE_FEEDBACK(LabeledEnum):
from .flags import *
from .campaign import *
from .filterset import *
from .jobpost_alert import *
36 changes: 36 additions & 0 deletions hasjob/models/filterset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down Expand Up @@ -52,6 +53,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)
Expand All @@ -71,6 +74,39 @@ class Filterset(BaseScopedNameMixin, db.Model):
def __repr__(self):
return '<Filterset %s "%s">' % (self.board.title, self.title)

def __init__(self, **kwargs):
filters = kwargs.pop('filters') if kwargs.get('filters') else {}
super(Filterset, self).__init__(**kwargs)

if not self.title:
self.title = buid()

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()
Expand Down
99 changes: 99 additions & 0 deletions hasjob/models/jobpost_alert.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# -*- 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']


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'))
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)
failed_at = db.Column(db.DateTime, nullable=True)
fail_reason = db.Column(db.Unicode(255), nullable=True)

def register_delivery(self):
self.sent_at = db.func.utcnow()

def register_failure(self, fail_reason):
self.failed_at = db.func.utcnow()
self.fail_reason = fail_reason
17 changes: 17 additions & 0 deletions hasjob/templates/job_alert_email_confirmation.html.jinja2
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{% extends "inc/email_layout_lite.html.jinja2" %}

{% block content %}
<div itemscope itemtype="http://schema.org/EmailMessage">
<div itemprop="action" itemscope itemtype="http://schema.org/ViewAction">
<meta itemprop="name" content="Confirm your email for job alerts"/>
</div>
<meta itemprop="description" content="Job alerts"/>
<div itemprop="publisher" itemscope itemtype="http://schema.org/Organization">
<meta itemprop="name" content="Hasjob"/>
<link itemprop="url" href="{{ url_for('index', subdomain=none, _external=true) }}"/>
</div>
</div>
Please click <a href="{{url_for('confirm_subscription_to_job_alerts', token=token, _external=True)}}">here</a> to confirm your subscription.
<hr>
<p><a href="{{ url_for('index', subdomain=none, _external=true) }}">Hasjob</a> is a service of <a href="https://hasgeek.com/">HasGeek</a>. Write to us at <a href="mailto:{{ config['SUPPORT_EMAIL'] }}">{{ config['SUPPORT_EMAIL'] }}</a> if you have suggestions or questions on this service.</p>
{% endblock %}
21 changes: 21 additions & 0 deletions hasjob/templates/job_alert_mailer.html.jinja2
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{% extends "inc/email_layout_lite.html.jinja2" %}

{% block content %}
<div itemscope itemtype="http://schema.org/EmailMessage">
<div itemprop="action" itemscope itemtype="http://schema.org/ViewAction">
<meta itemprop="name" content="Job Posts"/>
</div>
<meta itemprop="description" content="Job alerts"/>
<div itemprop="publisher" itemscope itemtype="http://schema.org/Organization">
<meta itemprop="name" content="Hasjob"/>
<link itemprop="url" href="{{ url_for('index', subdomain=none, _external=true) }}"/>
</div>
</div>
<ul>
{%- for post in posts %}
<li><a href={{post.url_for(_external=True)}}>{{post.headline}}</a></li>
{%- endfor %}
</ul>
<hr>
<p><a href="{{ url_for('index', subdomain=none, _external=true) }}">Hasjob</a> is a service of <a href="https://hasgeek.com/">HasGeek</a>. Write to us at <a href="mailto:{{ config['SUPPORT_EMAIL'] }}">{{ config['SUPPORT_EMAIL'] }}</a> if you have suggestions or questions on this service.</p>
{% endblock %}
2 changes: 1 addition & 1 deletion hasjob/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 1 addition & 1 deletion hasjob/views/admin_filterset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion hasjob/views/board.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand Down
10 changes: 6 additions & 4 deletions hasjob/views/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,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

Expand All @@ -434,15 +434,17 @@ 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:
if showall:
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),
Expand All @@ -456,7 +458,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))
Expand Down
Loading

0 comments on commit b5edf60

Please sign in to comment.