From 23519c078fe3f8ea10b982c73a294469fe3c962a Mon Sep 17 00:00:00 2001 From: Shreyas Satish Date: Thu, 5 Apr 2018 21:22:16 +0530 Subject: [PATCH 1/7] subscriptions [wip] --- hasjob/models/user.py | 33 +++++++++++++++++++++++++ hasjob/views/index.py | 57 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/hasjob/models/user.py b/hasjob/models/user.py index 91086ec35..7786d5ec9 100644 --- a/hasjob/models/user.py +++ b/hasjob/models/user.py @@ -228,3 +228,36 @@ class UserEvent(UserEventBase, BaseMixin, db.Model): name = db.Column(db.Unicode(80), nullable=False) #: Custom event data (null = no data saved) data = db.Column(JsonDict, nullable=True) + + +# class JobPostSubscription(BaseMixin, db.Model): +# __tablename__ = 'jobpost_subscription' + +# user_id = db.Column(None, db.ForeignKey('user.id'), primary_key=True, index=True) +# user = db.relationship(User, backref=db.backref('subscriptions', +# lazy='dynamic', cascade='all, delete-orphan')) + +# filterset_id = db.Column(None, db.ForeignKey('filterset.id'), primary_key=True, index=True) +# filterset = db.relationship('Filterset', backref=db.backref('subscriptions', +# lazy='dynamic', cascade='all, delete-orphan')) +# active = db.Column(db.Boolean, nullable=False, default=True) +# email = db.Column(db.Boolean, nullable=False, default=False) +# email_frequency = db.Column(db.SmallInteger, nullable=True) +# email_preferred_time = db.Column(db.Time, nullable=False, default=db.func.utcnow(), primary_key=True) + + +# jobpost_alert_table = db.Table('jobpost_alert', db.Model.metadata, +# db.Column('jobpost_id', None, db.ForeignKey('jobpost.id'), primary_key=True, index=True), +# db.Column('jobpost_alert', None, db.ForeignKey('jobpost_alert.id'), primary_key=True, index=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'), +# primary_key=True, index=True) +# jobpost_subscription = db.relationship(JobPostSubscription, backref=db.backref('alerts', +# lazy='dynamic', cascade='all, delete-orphan')) +# sent_at = db.Column(db.DateTime, nullable=False, default=db.func.utcnow(), primary_key=True) diff --git a/hasjob/views/index.py b/hasjob/views/index.py index 892f4cd10..c1dcc20fa 100644 --- a/hasjob/views/index.py +++ b/hasjob/views/index.py @@ -90,7 +90,7 @@ 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, board, board_jobs, gkiosk, basequery, md5sum, domain, location, title, showall, statusfilter, batched, ageless, template_vars, search_query=None, query_string=None, posts_only=False): if basequery is None: basequery = JobPost.query @@ -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: @@ -569,6 +571,55 @@ def filterset_view(name): filterset=filterset) +# @app.route('/subscribe', subdomain='', methods=['POST']) +# @app.route('/subscribe', methods=['POST']) +# def subscribe_to_jobposts(): +# filterset = Filterset.from_filters(g.board, filters) +# if not filterset: +# form = FiltersetForm(parent=g.board) +# if form.validate_on_submit(): +# filterset = Filterset(board=g.board, title=u"") +# form.populate_obj(filterset) +# db.session.add(filterset) + +# if g.user not in filterset.users: +# filterset.users.append(g.user) +# db.session.commit() +# return index(filters=filterset.to_filters(translate_geonameids=True), +# query_string=filterset.keywords, +# filterset=filterset) + + +# def send_alerts_to_subscribers(): +# subscriptions = JobPostSubscription.query.filter_by(active=True).all() +# for sub in subscriptions: +# posts = get_filtered_posts(filters=sub.filterset.to_filters()) +# now = datetime.utcnow() +# if sub.daily: +# days_delta = 1 +# elif sub.weekly: +# days_delta = 7 +# elif sub.monthly: +# days_delta = 30 + +# last_reference_date = now - timedelta(days=days_delta) +# recent_alert = JobPostAlert.query.filter( +# JobPostAlert.jobpost_subscription == sub, JobPostAlert.sent_at >= last_reference_date +# ).order_by('created_at desc').first() +# if recent_alert: +# break + +# sent_jobpostids = [jobpost.id for jobpost in recent_alert.jobposts] +# unsent_posts = [post for post in posts if post.id not in sent_jobpostids] +# if not unsent_posts: +# break + +# jobpost_alert = JobPostAlert(jobpost_subscription=sub, sent_at=now) +# jobpost_alert.jobposts = unsent_posts +# db.session.add(jobpost_alert) +# db.session.commit() + + @app.route('/opensearch.xml', subdomain='') @app.route('/opensearch.xml') def opensearch(): From 620d0453d07b7512aa6dfd21ba9fa3c30d10f746 Mon Sep 17 00:00:00 2001 From: Shreyas Satish Date: Mon, 9 Apr 2018 18:40:19 +0530 Subject: [PATCH 2/7] working backend for job alerts --- hasjob/__init__.py | 2 +- hasjob/jobs/__init__.py | 3 + hasjob/jobs/job_alerts.py | 41 +++++++++++ hasjob/models/__init__.py | 1 + hasjob/models/filterset.py | 33 +++++++++ hasjob/models/jobpost_alert.py | 67 +++++++++++++++++ hasjob/models/user.py | 33 --------- .../job_alert_email_confirmation.html.jinja2 | 17 +++++ hasjob/templates/job_alert_mailer.html.jinja2 | 21 ++++++ hasjob/views/board.py | 2 +- hasjob/views/helper.py | 10 +-- hasjob/views/index.py | 65 +++-------------- hasjob/views/job_alerts.py | 71 +++++++++++++++++++ manage.py | 7 ++ .../efdbaaf67b26_add_tables_for_job_alerts.py | 70 ++++++++++++++++++ 15 files changed, 347 insertions(+), 96 deletions(-) create mode 100644 hasjob/jobs/__init__.py create mode 100644 hasjob/jobs/job_alerts.py create mode 100644 hasjob/models/jobpost_alert.py create mode 100644 hasjob/templates/job_alert_email_confirmation.html.jinja2 create mode 100644 hasjob/templates/job_alert_mailer.html.jinja2 create mode 100644 hasjob/views/job_alerts.py create mode 100644 migrations/versions/efdbaaf67b26_add_tables_for_job_alerts.py diff --git a/hasjob/__init__.py b/hasjob/__init__.py index 952bfa3ca..8b311f722 100644 --- a/hasjob/__init__.py +++ b/hasjob/__init__.py @@ -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 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..a6e89215d --- /dev/null +++ b/hasjob/jobs/job_alerts.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- + +from datetime import datetime, timedelta +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 + + +@job('hasjob') +def send_email_alerts(): + subscriptions = JobPostSubscription.query.filter(JobPostSubscription.active == True, + JobPostSubscription.email_verified_at != None).all() + for sub in subscriptions: + if JobPostAlert.query.filter( + JobPostAlert.jobpost_subscription == sub, + JobPostAlert.sent_at >= datetime.utcnow() - timedelta(days=sub.email_frequency.value) + ).order_by('created_at desc').notempty(): + print 'alert was recently sent so skipping' + break + + posts = fetch_jobposts(filters=sub.filterset.to_filters(), posts_only=True) + sent_jobpostids = JobPost.query.join(jobpost_alert_table).join(JobPostAlert).filter(JobPostAlert.jobpost_subscription == sub).options(db.load_only('id')).all() + unseen_posts = [post for post in posts if post.id not in sent_jobpostids] + if not unseen_posts: + print "no unseen posts" + break + + jobpost_alert = JobPostAlert(jobpost_subscription=sub) + jobpost_alert.jobposts = unseen_posts + db.session.commit() + + msg = Message(subject=u"Job alerts", recipients=[sub.user.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) diff --git a/hasjob/models/__init__.py b/hasjob/models/__init__.py index 034867e3e..06942a38a 100644 --- a/hasjob/models/__init__.py +++ b/hasjob/models/__init__.py @@ -88,3 +88,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..15ac360a6 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'] @@ -79,6 +80,38 @@ def url_for(self, action='view', _external=True, **kwargs): kwargs.setdefault('subdomain', self.board.name if self.board.not_root else None) return super(Filterset, self).url_for(action, name=self.name, _external=_external, **kwargs) + @classmethod + def init_from_filters(cls, board, filters): + obj = cls(parent=board, title=buid()) + if filters.get('t'): + obj.types = JobType.query.filter(JobType.name.in_(filters['t'])).group_by(JobType.id).having( + db.func.count(JobType.name) == len(filters['t'])).all() + + if filters.get('c'): + obj.categories = JobCategory.query.filter(JobCategory.name.in_(filters['c'])).group_by(JobCategory.id).having( + db.func.count(JobCategory.name) == len(filters['c'])).all() + + if filters.get('l'): + geonameids = [] + for loc in filters.get('l'): + geonameids.append(location_geodata(loc)['geonameid']) + obj.geonameids = geonameids + + if getbool(filters.get('anywhere')): + obj.remote_location = True + + if getbool(filters.get('equity')): + obj.equity = True + + if filters.get('currency') and filters.get('pay'): + obj.pay_currency = filters.get('currency') + obj.pay_cash = filters.get('pay') + + if filters.get('q'): + obj.keywords = filters.get('q') + + return obj + def to_filters(self, translate_geonameids=False): location_names = [] if translate_geonameids and self.geonameids: diff --git a/hasjob/models/jobpost_alert.py b/hasjob/models/jobpost_alert.py new file mode 100644 index 000000000..0f0375e52 --- /dev/null +++ b/hasjob/models/jobpost_alert.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- + +from coaster.sqlalchemy import StateManager +from ..utils import random_long_key +from . import db, BaseMixin, LabeledEnum + +__all__ = ['JobPostSubscription', 'JobPostAlert', 'jobpost_alert_table'] + + +class EMAIL_FREQUENCY(LabeledEnum): + DAILY = (1, 'Daily') + WEEKLY = (7, 'Weekly') + MONTHLY = (30, 'Monthly') + + +class JobPostSubscription(BaseMixin, db.Model): + __tablename__ = 'jobpost_subscription' + __table_args__ = (db.UniqueConstraint('user_id', 'user_type', 'filterset_id'),) + + user_id = db.Column(None, db.ForeignKey('user.id')) + user = db.relationship('User', backref=db.backref('subscriptions', + lazy='dynamic', cascade='all, delete-orphan')) + user_type = db.Column(db.Unicode(8), nullable=False, default=u'User') + filterset_id = db.Column(None, db.ForeignKey('filterset.id')) + filterset = db.relationship('Filterset', backref=db.backref('subscriptions', + lazy='dynamic')) + active = db.Column(db.Boolean, nullable=False, default=True, index=True) + email = db.Column(db.Boolean, nullable=True, default=True, index=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") + email_verify_key = db.Column(db.String(40), nullable=True, default=random_long_key) + email_verified_at = db.Column(db.DateTime, nullable=True, index=True) + deactivated_at = db.Column(db.DateTime, nullable=True) + reactivated_at = db.Column(db.DateTime, nullable=True) + + def verify_email(self): + self.email_verified_at = db.func.utcnow() + + def deactivate(self): + self.active = False + self.deactivated = db.func.utcnow() + + def reactivate(self): + if self.email_verified: + self.active = True + self.reactivated_at = db.func.utcnow() + + +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=False, default=db.func.utcnow()) diff --git a/hasjob/models/user.py b/hasjob/models/user.py index 7786d5ec9..91086ec35 100644 --- a/hasjob/models/user.py +++ b/hasjob/models/user.py @@ -228,36 +228,3 @@ class UserEvent(UserEventBase, BaseMixin, db.Model): name = db.Column(db.Unicode(80), nullable=False) #: Custom event data (null = no data saved) data = db.Column(JsonDict, nullable=True) - - -# class JobPostSubscription(BaseMixin, db.Model): -# __tablename__ = 'jobpost_subscription' - -# user_id = db.Column(None, db.ForeignKey('user.id'), primary_key=True, index=True) -# user = db.relationship(User, backref=db.backref('subscriptions', -# lazy='dynamic', cascade='all, delete-orphan')) - -# filterset_id = db.Column(None, db.ForeignKey('filterset.id'), primary_key=True, index=True) -# filterset = db.relationship('Filterset', backref=db.backref('subscriptions', -# lazy='dynamic', cascade='all, delete-orphan')) -# active = db.Column(db.Boolean, nullable=False, default=True) -# email = db.Column(db.Boolean, nullable=False, default=False) -# email_frequency = db.Column(db.SmallInteger, nullable=True) -# email_preferred_time = db.Column(db.Time, nullable=False, default=db.func.utcnow(), primary_key=True) - - -# jobpost_alert_table = db.Table('jobpost_alert', db.Model.metadata, -# db.Column('jobpost_id', None, db.ForeignKey('jobpost.id'), primary_key=True, index=True), -# db.Column('jobpost_alert', None, db.ForeignKey('jobpost_alert.id'), primary_key=True, index=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'), -# primary_key=True, index=True) -# jobpost_subscription = db.relationship(JobPostSubscription, backref=db.backref('alerts', -# lazy='dynamic', cascade='all, delete-orphan')) -# sent_at = db.Column(db.DateTime, nullable=False, default=db.func.utcnow(), primary_key=True) 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..00ebdba22 --- /dev/null +++ b/hasjob/templates/job_alert_mailer.html.jinja2 @@ -0,0 +1,21 @@ +{% extends "inc/email_layout_lite.html.jinja2" %} + +{% block content %} +
+
+ +
+ +
+ + +
+
+ +
+

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/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 ff9802ee7..a04132e98 100644 --- a/hasjob/views/helper.py +++ b/hasjob/views/helper.py @@ -398,7 +398,7 @@ def load_viewcounts(posts): g.viewcounts = dict(zip(viewcounts_keys, viewcounts_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 @@ -410,7 +410,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: @@ -418,7 +420,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), @@ -432,7 +434,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 c1dcc20fa..9497e01e3 100644 --- a/hasjob/views/index.py +++ b/hasjob/views/index.py @@ -10,7 +10,7 @@ from baseframe import _ # , dogpile from .. import app, lastuser -from ..models import (db, JobCategory, JobPost, JobType, POST_STATE, newlimit, agelimit, JobLocation, Board, Filterset, +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) @@ -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, posts_only=False): +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 = [] @@ -269,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: @@ -571,55 +571,6 @@ def filterset_view(name): filterset=filterset) -# @app.route('/subscribe', subdomain='', methods=['POST']) -# @app.route('/subscribe', methods=['POST']) -# def subscribe_to_jobposts(): -# filterset = Filterset.from_filters(g.board, filters) -# if not filterset: -# form = FiltersetForm(parent=g.board) -# if form.validate_on_submit(): -# filterset = Filterset(board=g.board, title=u"") -# form.populate_obj(filterset) -# db.session.add(filterset) - -# if g.user not in filterset.users: -# filterset.users.append(g.user) -# db.session.commit() -# return index(filters=filterset.to_filters(translate_geonameids=True), -# query_string=filterset.keywords, -# filterset=filterset) - - -# def send_alerts_to_subscribers(): -# subscriptions = JobPostSubscription.query.filter_by(active=True).all() -# for sub in subscriptions: -# posts = get_filtered_posts(filters=sub.filterset.to_filters()) -# now = datetime.utcnow() -# if sub.daily: -# days_delta = 1 -# elif sub.weekly: -# days_delta = 7 -# elif sub.monthly: -# days_delta = 30 - -# last_reference_date = now - timedelta(days=days_delta) -# recent_alert = JobPostAlert.query.filter( -# JobPostAlert.jobpost_subscription == sub, JobPostAlert.sent_at >= last_reference_date -# ).order_by('created_at desc').first() -# if recent_alert: -# break - -# sent_jobpostids = [jobpost.id for jobpost in recent_alert.jobposts] -# unsent_posts = [post for post in posts if post.id not in sent_jobpostids] -# if not unsent_posts: -# break - -# jobpost_alert = JobPostAlert(jobpost_subscription=sub, sent_at=now) -# jobpost_alert.jobposts = unsent_posts -# db.session.add(jobpost_alert) -# db.session.commit() - - @app.route('/opensearch.xml', subdomain='') @app.route('/opensearch.xml') def opensearch(): diff --git a/hasjob/views/job_alerts.py b/hasjob/views/job_alerts.py new file mode 100644 index 000000000..05ed0f1ab --- /dev/null +++ b/hasjob/views/job_alerts.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- + + +from flask import abort, redirect, render_template, request, url_for, g +from flask_mail import Message +from flask_rq import job +from premailer import transform as email_transform +from html2text import html2text +from .. import app, mail +from ..models import (db, User, AnonUser, JobPostSubscription, Filterset) + + +@app.route('/confirm_subscription_to_job_alerts', subdomain='') +@app.route('/confirm_subscription_to_job_alerts') +def confirm_subscription_to_job_alerts(): + sub = JobPostSubscription.query.filter_by(email_verify_key=request.args.get('token')).one_or_none() + if not sub: + abort(404) + if sub.email_verified_at: + abort(400) + sub.verify_email() + db.session.commit() + return redirect(url_for('index'), code=302) + + +@job('hasjob') +def send_email_confirmation_for_job_alerts(to_address, token): + msg = Message(subject=u"Job alerts", 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('/subscribe_to_job_alerts', subdomain='', methods=['POST']) +@app.route('/subscribe_to_job_alerts', methods=['POST']) +def subscribe_to_job_alerts(): + if not request.json or not request.json.get('filters'): + abort(400) + + if g.user and g.user.email: + email = g.user.email + # elif request.json.get('email') and valid_email(request.json.get('email')): + elif request.json.get('email'): + email = request.json.get('email') + else: + abort(400) + + user_type = User.__name__ + if g.user: + user = g.user + if not g.user.email: + # TODO Should we update email on g.user? + pass + else: + user = User.query.filter_by(email=email).one_or_none() + if not user: + user = g.anon_user + user_type = AnonUser.__name__ + + filterset = Filterset.from_filters(g.board, request.json.get('filters')) + if not filterset: + filterset = Filterset.init_from_filters(g.board, request.json.get('filters')) + db.session.add(filterset) + + sub = JobPostSubscription(user=user, user_type=user_type, filterset=filterset) + db.session.add(sub) + db.session.commit() + + send_email_confirmation_for_job_alerts.delay(to_address=email, token=sub.email_verify_key) + return redirect(url_for('index'), code=302) diff --git a/manage.py b/manage.py index 434e28d1b..d326b2537 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 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_job_alerts(): + """Run email alerts very morning at 8 AM""" + 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/efdbaaf67b26_add_tables_for_job_alerts.py b/migrations/versions/efdbaaf67b26_add_tables_for_job_alerts.py new file mode 100644 index 000000000..d693b23f1 --- /dev/null +++ b/migrations/versions/efdbaaf67b26_add_tables_for_job_alerts.py @@ -0,0 +1,70 @@ +"""add_tables_for_job_alerts + +Revision ID: efdbaaf67b26 +Revises: 859f6f33c02d +Create Date: 2018-04-09 14:35:47.960246 + +""" + +# revision identifiers, used by Alembic. +revision = 'efdbaaf67b26' +down_revision = '859f6f33c02d' + +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('user_id', sa.Integer(), nullable=True), + sa.Column('user_type', sa.Unicode(length=8), nullable=False), + sa.Column('filterset_id', sa.Integer(), nullable=True), + sa.Column('active', sa.Boolean(), nullable=False), + sa.Column('email', sa.Boolean(), nullable=True), + sa.Column('email_frequency', sa.Integer(), nullable=True), + sa.Column('email_verify_key', sa.String(length=40), nullable=True), + sa.Column('email_verified_at', sa.DateTime(), nullable=True), + sa.Column('deactivated_at', sa.DateTime(), nullable=True), + sa.Column('reactivated_at', sa.DateTime(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['filterset_id'], ['filterset.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'user_type', 'filterset_id') + ) + op.create_index(op.f('ix_jobpost_subscription_active'), 'jobpost_subscription', ['active'], unique=False) + op.create_index(op.f('ix_jobpost_subscription_email'), 'jobpost_subscription', ['email'], unique=False) + op.create_index(op.f('ix_jobpost_subscription_email_verified_at'), 'jobpost_subscription', ['email_verified_at'], 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=False), + 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') + ) + + +def downgrade(): + 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_email_verified_at'), table_name='jobpost_subscription') + op.drop_index(op.f('ix_jobpost_subscription_email'), table_name='jobpost_subscription') + op.drop_index(op.f('ix_jobpost_subscription_active'), table_name='jobpost_subscription') + op.drop_table('jobpost_subscription') From 3c457f0f6d51e3bdc856f3a3013a00ad17ceac86 Mon Sep 17 00:00:00 2001 From: Shreyas Satish Date: Mon, 9 Apr 2018 20:50:24 +0530 Subject: [PATCH 3/7] simplify and cleanup --- hasjob/jobs/job_alerts.py | 29 ++++--- hasjob/models/filterset.py | 2 + hasjob/models/jobpost_alert.py | 41 +++++---- hasjob/views/__init__.py | 2 +- hasjob/views/index.py | 2 +- hasjob/views/job_alerts.py | 83 +++++++++++-------- manage.py | 2 +- ...0cdbddf0_schema_changes_for_job_alerts.py} | 31 +++---- 8 files changed, 106 insertions(+), 86 deletions(-) rename migrations/versions/{efdbaaf67b26_add_tables_for_job_alerts.py => 41890cdbddf0_schema_changes_for_job_alerts.py} (75%) diff --git a/hasjob/jobs/job_alerts.py b/hasjob/jobs/job_alerts.py index a6e89215d..11239e21a 100644 --- a/hasjob/jobs/job_alerts.py +++ b/hasjob/jobs/job_alerts.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- -from datetime import datetime, timedelta from flask_mail import Message from flask import render_template from flask_rq import job @@ -11,30 +10,30 @@ 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(): - subscriptions = JobPostSubscription.query.filter(JobPostSubscription.active == True, - JobPostSubscription.email_verified_at != None).all() - for sub in subscriptions: - if JobPostAlert.query.filter( - JobPostAlert.jobpost_subscription == sub, - JobPostAlert.sent_at >= datetime.utcnow() - timedelta(days=sub.email_frequency.value) - ).order_by('created_at desc').notempty(): - print 'alert was recently sent so skipping' + for subscription in JobPostSubscription.get_active_subscriptions: + if subscription.has_recent_alert(): + # Alert was sent recently, break out of loop break - posts = fetch_jobposts(filters=sub.filterset.to_filters(), posts_only=True) - sent_jobpostids = JobPost.query.join(jobpost_alert_table).join(JobPostAlert).filter(JobPostAlert.jobpost_subscription == sub).options(db.load_only('id')).all() - unseen_posts = [post for post in posts if post.id not in sent_jobpostids] + unseen_posts = get_unseen_posts(subscription) if not unseen_posts: - print "no unseen posts" + # Nothing new to see, break out of loop break - jobpost_alert = JobPostAlert(jobpost_subscription=sub) + jobpost_alert = JobPostAlert(jobpost_subscription=subscription) jobpost_alert.jobposts = unseen_posts db.session.commit() - msg = Message(subject=u"Job alerts", recipients=[sub.user.email]) + 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) diff --git a/hasjob/models/filterset.py b/hasjob/models/filterset.py index 15ac360a6..493cef67d 100644 --- a/hasjob/models/filterset.py +++ b/hasjob/models/filterset.py @@ -53,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) diff --git a/hasjob/models/jobpost_alert.py b/hasjob/models/jobpost_alert.py index 0f0375e52..2e32aff03 100644 --- a/hasjob/models/jobpost_alert.py +++ b/hasjob/models/jobpost_alert.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- +from datetime import datetime, timedelta from coaster.sqlalchemy import StateManager from ..utils import random_long_key from . import db, BaseMixin, LabeledEnum @@ -15,37 +16,41 @@ class EMAIL_FREQUENCY(LabeledEnum): class JobPostSubscription(BaseMixin, db.Model): __tablename__ = 'jobpost_subscription' - __table_args__ = (db.UniqueConstraint('user_id', 'user_type', 'filterset_id'),) + __table_args__ = (db.UniqueConstraint('filterset_id', 'email'),) - user_id = db.Column(None, db.ForeignKey('user.id')) - user = db.relationship('User', backref=db.backref('subscriptions', - lazy='dynamic', cascade='all, delete-orphan')) - user_type = db.Column(db.Unicode(8), nullable=False, default=u'User') - filterset_id = db.Column(None, db.ForeignKey('filterset.id')) + filterset_id = db.Column(None, db.ForeignKey('filterset.id'), nullable=False) filterset = db.relationship('Filterset', backref=db.backref('subscriptions', lazy='dynamic')) - active = db.Column(db.Boolean, nullable=False, default=True, index=True) - email = db.Column(db.Boolean, nullable=True, default=True, index=True) + email = db.Column(db.Unicode(254), nullable=False) + + 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) + 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") - email_verify_key = db.Column(db.String(40), nullable=True, default=random_long_key) - email_verified_at = db.Column(db.DateTime, nullable=True, index=True) - deactivated_at = db.Column(db.DateTime, nullable=True) - reactivated_at = db.Column(db.DateTime, nullable=True) def verify_email(self): + self.active = True self.email_verified_at = db.func.utcnow() - def deactivate(self): + def unsubscribe(self): self.active = False - self.deactivated = db.func.utcnow() + 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 reactivate(self): - if self.email_verified: - self.active = True - self.reactivated_at = db.func.utcnow() + 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() jobpost_alert_table = db.Table('jobpost_jobpost_alert', db.Model.metadata, 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/index.py b/hasjob/views/index.py index 9497e01e3..f3353916c 100644 --- a/hasjob/views/index.py +++ b/hasjob/views/index.py @@ -765,7 +765,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 index 05ed0f1ab..8790d419c 100644 --- a/hasjob/views/job_alerts.py +++ b/hasjob/views/job_alerts.py @@ -1,31 +1,20 @@ # -*- coding: utf-8 -*- -from flask import abort, redirect, render_template, request, url_for, g +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, User, AnonUser, JobPostSubscription, Filterset) - - -@app.route('/confirm_subscription_to_job_alerts', subdomain='') -@app.route('/confirm_subscription_to_job_alerts') -def confirm_subscription_to_job_alerts(): - sub = JobPostSubscription.query.filter_by(email_verify_key=request.args.get('token')).one_or_none() - if not sub: - abort(404) - if sub.email_verified_at: - abort(400) - sub.verify_email() - db.session.commit() - return redirect(url_for('index'), code=302) +from ..models import (db, JobPostSubscription, Filterset) @job('hasjob') -def send_email_confirmation_for_job_alerts(to_address, token): - msg = Message(subject=u"Job alerts", recipients=[to_address]) +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) @@ -40,32 +29,56 @@ def subscribe_to_job_alerts(): if g.user and g.user.email: email = g.user.email - # elif request.json.get('email') and valid_email(request.json.get('email')): - elif request.json.get('email'): + message = _(u"Thank you for signing up to receive job alerts from us! We'll keep you posted.") + verified_user = True + elif request.json.get('email') and is_email(request.json.get('email')): email = request.json.get('email') + 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 else: - abort(400) - - user_type = User.__name__ - if g.user: - user = g.user - if not g.user.email: - # TODO Should we update email on g.user? - pass - else: - user = User.query.filter_by(email=email).one_or_none() - if not user: - user = g.anon_user - user_type = AnonUser.__name__ + flash(_(u"Oops! Sorry, we need an email address to send you alerts."), 'danger') + return redirect(url_for('index'), code=302) filterset = Filterset.from_filters(g.board, request.json.get('filters')) if not filterset: filterset = Filterset.init_from_filters(g.board, request.json.get('filters')) db.session.add(filterset) - sub = JobPostSubscription(user=user, user_type=user_type, filterset=filterset) - db.session.add(sub) + subscription = JobPostSubscription(filterset=filterset, email=email) + 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('/confirm_subscription_to_job_alerts', subdomain='') +@app.route('/confirm_subscription_to_job_alerts') +def confirm_subscription_to_job_alerts(): + sub = JobPostSubscription.query.filter_by(email_verify_key=request.args.get('token')).one_or_none() + if not sub: + abort(404) + if sub.email_verified_at: + abort(400) + sub.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) + - send_email_confirmation_for_job_alerts.delay(to_address=email, token=sub.email_verify_key) +@app.route('/unsubscribe_from_job_alerts', subdomain='') +@app.route('/unsubscribe_from_job_alerts') +def unsubscribe_from_job_alerts(): + sub = JobPostSubscription.query.filter_by(unsubscribe_key=request.args.get('token')).one_or_none() + if not sub: + abort(404) + if not sub.email_verified_at: + abort(400) + sub.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 d326b2537..ada7dc5a8 100755 --- a/manage.py +++ b/manage.py @@ -8,7 +8,7 @@ import hasjob.views as views from hasjob.models import db from hasjob import app -from hasjob.jobs import send_email_alerts +from hasjob.jobs.job_alerts import send_email_alerts from datetime import datetime, timedelta periodic = Manager(usage="Periodic tasks from cron (with recommended intervals)") diff --git a/migrations/versions/efdbaaf67b26_add_tables_for_job_alerts.py b/migrations/versions/41890cdbddf0_schema_changes_for_job_alerts.py similarity index 75% rename from migrations/versions/efdbaaf67b26_add_tables_for_job_alerts.py rename to migrations/versions/41890cdbddf0_schema_changes_for_job_alerts.py index d693b23f1..383561146 100644 --- a/migrations/versions/efdbaaf67b26_add_tables_for_job_alerts.py +++ b/migrations/versions/41890cdbddf0_schema_changes_for_job_alerts.py @@ -1,13 +1,13 @@ -"""add_tables_for_job_alerts +"""schema_changes_for_job_alerts -Revision ID: efdbaaf67b26 +Revision ID: 41890cdbddf0 Revises: 859f6f33c02d -Create Date: 2018-04-09 14:35:47.960246 +Create Date: 2018-04-09 20:43:57.810337 """ # revision identifiers, used by Alembic. -revision = 'efdbaaf67b26' +revision = '41890cdbddf0' down_revision = '859f6f33c02d' from alembic import op @@ -19,24 +19,22 @@ 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('user_id', sa.Integer(), nullable=True), - sa.Column('user_type', sa.Unicode(length=8), nullable=False), - sa.Column('filterset_id', sa.Integer(), nullable=True), + sa.Column('filterset_id', sa.Integer(), nullable=False), + sa.Column('email', sa.Unicode(length=254), nullable=False), sa.Column('active', sa.Boolean(), nullable=False), - sa.Column('email', sa.Boolean(), nullable=True), - sa.Column('email_frequency', sa.Integer(), nullable=True), sa.Column('email_verify_key', sa.String(length=40), nullable=True), + sa.Column('unsubscribe_key', sa.String(length=40), nullable=True), sa.Column('email_verified_at', sa.DateTime(), nullable=True), - sa.Column('deactivated_at', sa.DateTime(), nullable=True), - sa.Column('reactivated_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.ForeignKeyConstraint(['filterset_id'], ['filterset.id'], ), - sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('user_id', 'user_type', 'filterset_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_email'), 'jobpost_subscription', ['email'], unique=False) op.create_index(op.f('ix_jobpost_subscription_email_verified_at'), 'jobpost_subscription', ['email_verified_at'], unique=False) op.create_table('jobpost_alert', @@ -58,13 +56,16 @@ def upgrade(): 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) def downgrade(): + 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_email_verified_at'), table_name='jobpost_subscription') - op.drop_index(op.f('ix_jobpost_subscription_email'), table_name='jobpost_subscription') op.drop_index(op.f('ix_jobpost_subscription_active'), table_name='jobpost_subscription') op.drop_table('jobpost_subscription') From df699d73046105b4878307a7c8c66d51ea9ea91a Mon Sep 17 00:00:00 2001 From: Bibhas Date: Wed, 11 Apr 2018 13:06:44 +0530 Subject: [PATCH 4/7] Using StateManager for JobApplication model (#432) * initial changes for state manager for job application * added transitions for application processing * more fixes * transition fixes --- hasjob/forms/jobpost.py | 4 +- hasjob/models/__init__.py | 21 +++-- hasjob/models/flags.py | 34 ++++---- hasjob/models/jobpost.py | 86 ++++++++----------- hasjob/templates/application.html.jinja2 | 22 ++--- hasjob/templates/respond_email.html.jinja2 | 8 +- hasjob/views/listing.py | 45 +++++----- ...54_jobapplication_response_statemanager.py | 29 +++++++ 8 files changed, 136 insertions(+), 113 deletions(-) create mode 100644 migrations/versions/625415764254_jobapplication_response_statemanager.py diff --git a/hasjob/forms/jobpost.py b/hasjob/forms/jobpost.py index e64bf3d25..c2a9a974d 100644 --- a/hasjob/forms/jobpost.py +++ b/hasjob/forms/jobpost.py @@ -11,7 +11,7 @@ from coaster.utils import getbool, get_email_domain from flask_lastuser import LastuserResourceException -from ..models import User, JobType, JobApplication, EMPLOYER_RESPONSE, PAY_TYPE, CURRENCY, Domain +from ..models import User, JobType, JobApplication, PAY_TYPE, CURRENCY, Domain from ..uploads import process_image, UploadNotAllowed from .. import app, lastuser @@ -399,7 +399,7 @@ def validate_apply_message(form, field): words = get_word_bag(field.data) form.words = words similar = False - for oldapp in JobApplication.query.filter_by(response=EMPLOYER_RESPONSE.SPAM).all(): + for oldapp in JobApplication.query.filter(JobApplication.response.SPAM).all(): if oldapp.words: s = SequenceMatcher(None, words, oldapp.words) if s.ratio() > 0.8: diff --git a/hasjob/models/__init__.py b/hasjob/models/__init__.py index 06942a38a..d5132a7e9 100644 --- a/hasjob/models/__init__.py +++ b/hasjob/models/__init__.py @@ -26,7 +26,7 @@ class POST_STATE(LabeledEnum): ANNOUNCEMENT = (9, 'announcement', __("Announcement")) # Special announcement CLOSED = (10, 'closed', __("Closed")) # Not accepting applications, but publicly viewable - __order__ = (DRAFT, PENDING, CONFIRMED, REVIEWED, ANNOUNCEMENT, CLOSED, + __order__ = (DRAFT, PENDING, CONFIRMED, REVIEWED, ANNOUNCEMENT, CLOSED, FLAGGED, MODERATED, REJECTED, SPAM, WITHDRAWN) UNPUBLISHED = {DRAFT, PENDING} @@ -46,16 +46,21 @@ class CURRENCY(LabeledEnum): class EMPLOYER_RESPONSE(LabeledEnum): - NEW = (0, __("New")) # New application - PENDING = (1, __("Pending")) # Employer viewed on website - IGNORED = (2, __("Ignored")) # Dismissed as not worth responding to - REPLIED = (3, __("Replied")) # Employer replied to candidate - FLAGGED = (4, __("Flagged")) # Employer reported a spammer - SPAM = (5, __("Spam")) # Admin marked this as spam - REJECTED = (6, __("Rejected")) # Employer rejected candidate with a message + NEW = (0, 'new', __("New")) # New application + PENDING = (1, 'pending', __("Pending")) # Employer viewed on website + IGNORED = (2, 'ignored', __("Ignored")) # Dismissed as not worth responding to + REPLIED = (3, 'replied', __("Replied")) # Employer replied to candidate + FLAGGED = (4, 'flagged', __("Flagged")) # Employer reported a spammer + SPAM = (5, 'spam', __("Spam")) # Admin marked this as spam + REJECTED = (6, 'rejected', __("Rejected")) # Employer rejected candidate with a message __order__ = (NEW, PENDING, IGNORED, REPLIED, FLAGGED, SPAM, REJECTED) + CAN_REPLY = {NEW, PENDING, IGNORED} + CAN_REJECT = CAN_REPLY + CAN_IGNORE = {NEW, PENDING} + CAN_REPORT = {NEW, PENDING, IGNORED, REJECTED} + class PAY_TYPE(LabeledEnum): NOCASH = (0, __("Nothing")) diff --git a/hasjob/models/flags.py b/hasjob/models/flags.py index 79ecdc0e9..898595bc4 100644 --- a/hasjob/models/flags.py +++ b/hasjob/models/flags.py @@ -5,7 +5,7 @@ from sqlalchemy import distinct from werkzeug import cached_property from baseframe import __, cache -from . import db, agelimit, newlimit, POST_STATE, EMPLOYER_RESPONSE +from . import db, agelimit, newlimit from .user import User from .jobpost import JobPost, JobApplication from .board import Board @@ -91,10 +91,10 @@ class UserFlags(object): __("Is a candidate who received a response (at any time)"), lambda user: JobApplication.query.filter( JobApplication.user == user, - JobApplication.response == EMPLOYER_RESPONSE.REPLIED + JobApplication.response.REPLIED ).notempty(), lambda: db.session.query(distinct(JobApplication.user_id).label('id')).filter( - JobApplication.response == EMPLOYER_RESPONSE.REPLIED + JobApplication.response.REPLIED ) ) @@ -103,10 +103,10 @@ class UserFlags(object): lambda user: JobApplication.query.filter( JobApplication.user == user, JobApplication.replied_at >= datetime.utcnow() - newlimit, - JobApplication.response == EMPLOYER_RESPONSE.REPLIED + JobApplication.response.REPLIED ).notempty(), lambda: db.session.query(distinct(JobApplication.user_id).label('id')).filter( - JobApplication.response == EMPLOYER_RESPONSE.REPLIED, + JobApplication.response.REPLIED, JobApplication.replied_at >= datetime.utcnow() - newlimit ) ) @@ -116,10 +116,10 @@ class UserFlags(object): lambda user: JobApplication.query.filter( JobApplication.user == user, JobApplication.replied_at >= datetime.utcnow() - agelimit, - JobApplication.response == EMPLOYER_RESPONSE.REPLIED + JobApplication.response.REPLIED ).notempty(), lambda: db.session.query(distinct(JobApplication.user_id).label('id')).filter( - JobApplication.response == EMPLOYER_RESPONSE.REPLIED, + JobApplication.response.REPLIED, JobApplication.replied_at >= datetime.utcnow() - agelimit ) ) @@ -129,10 +129,10 @@ class UserFlags(object): lambda user: JobApplication.query.filter( JobApplication.user == user, JobApplication.replied_at < datetime.utcnow() - agelimit, - JobApplication.response == EMPLOYER_RESPONSE.REPLIED + JobApplication.response.REPLIED ).notempty(), lambda: db.session.query(distinct(JobApplication.user_id).label('id')).filter( - JobApplication.response == EMPLOYER_RESPONSE.REPLIED, + JobApplication.response.REPLIED, JobApplication.replied_at < datetime.utcnow() - agelimit ) ) @@ -234,10 +234,10 @@ class UserFlags(object): __("Is an employer who responded to a candidate (at any time)"), lambda user: JobApplication.query.filter( JobApplication.replied_by == user, - JobApplication.response == EMPLOYER_RESPONSE.REPLIED + JobApplication.response.REPLIED ).notempty(), lambda: db.session.query(distinct(JobApplication.replied_by_id).label('id')).filter( - JobApplication.response == EMPLOYER_RESPONSE.REPLIED + JobApplication.response.REPLIED ) ) @@ -246,10 +246,10 @@ class UserFlags(object): lambda user: JobApplication.query.filter( JobApplication.replied_by == user, JobApplication.replied_at >= datetime.utcnow() - newlimit, - JobApplication.response == EMPLOYER_RESPONSE.REPLIED + JobApplication.response.REPLIED ).notempty(), lambda: db.session.query(distinct(JobApplication.replied_by_id).label('id')).filter( - JobApplication.response == EMPLOYER_RESPONSE.REPLIED, + JobApplication.response.REPLIED, JobApplication.replied_at >= datetime.utcnow() - newlimit ) ) @@ -259,10 +259,10 @@ class UserFlags(object): lambda user: JobApplication.query.filter( JobApplication.replied_by == user, JobApplication.replied_at >= datetime.utcnow() - agelimit, - JobApplication.response == EMPLOYER_RESPONSE.REPLIED + JobApplication.response.REPLIED ).notempty(), lambda: db.session.query(distinct(JobApplication.replied_by_id).label('id')).filter( - JobApplication.response == EMPLOYER_RESPONSE.REPLIED, + JobApplication.response.REPLIED, JobApplication.replied_at >= datetime.utcnow() - agelimit ) ) @@ -272,10 +272,10 @@ class UserFlags(object): lambda user: JobApplication.query.filter( JobApplication.replied_by == user, JobApplication.replied_at < datetime.utcnow() - agelimit, - JobApplication.response == EMPLOYER_RESPONSE.REPLIED + JobApplication.response.REPLIED ).notempty(), lambda: db.session.query(distinct(JobApplication.replied_by_id).label('id')).filter( - JobApplication.response == EMPLOYER_RESPONSE.REPLIED, + JobApplication.response.REPLIED, JobApplication.replied_at < datetime.utcnow() - agelimit ) ) diff --git a/hasjob/models/jobpost.py b/hasjob/models/jobpost.py index 02fb23d55..07737de0f 100644 --- a/hasjob/models/jobpost.py +++ b/hasjob/models/jobpost.py @@ -753,7 +753,10 @@ class JobApplication(BaseMixin, db.Model): #: User opted-in to experimental features optin = db.Column(db.Boolean, default=False, nullable=False) #: Employer's response code - response = db.Column(db.Integer, nullable=False, default=EMPLOYER_RESPONSE.NEW) + _response = db.Column('response', db.Integer, + StateManager.check_constraint('response', EMPLOYER_RESPONSE), + nullable=False, default=EMPLOYER_RESPONSE.NEW) + response = StateManager('_response', EMPLOYER_RESPONSE, doc="Employer's response") #: Employer's response message response_message = db.Column(db.UnicodeText, nullable=True) #: Bag of words, for spam analysis @@ -771,43 +774,33 @@ def __init__(self, **kwargs): if self.hashid is None: self.hashid = unique_long_hash() - @property - def status(self): - return EMPLOYER_RESPONSE[self.response] - - def is_new(self): - return self.response == EMPLOYER_RESPONSE.NEW - - def is_pending(self): - return self.response == EMPLOYER_RESPONSE.PENDING - - def is_ignored(self): - return self.response == EMPLOYER_RESPONSE.IGNORED - - def is_replied(self): - return self.response == EMPLOYER_RESPONSE.REPLIED - - def is_flagged(self): - return self.response == EMPLOYER_RESPONSE.FLAGGED - - def is_spam(self): - return self.response == EMPLOYER_RESPONSE.SPAM + @response.transition(response.NEW, response.PENDING, title=__("Mark read"), message=__("This job application has been read"), type='success') + def mark_read(self): + pass - def is_rejected(self): - return self.response == EMPLOYER_RESPONSE.REJECTED + @response.transition(response.CAN_REPLY, response.REPLIED, title=__("Reply"), message=__("This job application has been replied to"), type='success') + def reply(self, message, user): + self.response_message = message + self.replied_by = user + self.replied_at = db.func.utcnow() - def can_reply(self): - return self.response in (EMPLOYER_RESPONSE.NEW, EMPLOYER_RESPONSE.PENDING, EMPLOYER_RESPONSE.IGNORED) + @response.transition(response.CAN_REJECT, response.REJECTED, title=__("Reject"), message=__("This job application has been rejected"), type='danger') + def reject(self, message, user): + self.response_message = message + self.replied_by = user + self.replied_at = db.func.utcnow() - def can_reject(self): - return self.response in (EMPLOYER_RESPONSE.NEW, EMPLOYER_RESPONSE.PENDING, EMPLOYER_RESPONSE.IGNORED) + @response.transition(response.CAN_IGNORE, response.IGNORED, title=__("Ignore"), message=__("This job application has been ignored"), type='danger') + def ignore(self): + pass - def can_ignore(self): - return self.response in (EMPLOYER_RESPONSE.NEW, EMPLOYER_RESPONSE.PENDING) + @response.transition(response.CAN_REPORT, response.FLAGGED, title=__("Report"), message=__("This job application has been reported"), type='danger') + def flag(self): + pass - def can_report(self): - return self.response in (EMPLOYER_RESPONSE.NEW, EMPLOYER_RESPONSE.PENDING, - EMPLOYER_RESPONSE.IGNORED, EMPLOYER_RESPONSE.REJECTED) + @response.transition(response.FLAGGED, response.PENDING, title=__("Unflag"), message=__("This job application has been unflagged"), type='success') + def unflag(self): + pass def application_count(self): """Number of jobs candidate has applied to around this one""" @@ -823,19 +816,14 @@ def application_count(self): } date_min = self.created_at - timedelta(days=7) date_max = self.created_at + timedelta(days=7) - counts = defaultdict(int) - for r in db.session.query(JobApplication.response).filter(JobApplication.user == self.user).filter( - JobApplication.created_at > date_min, JobApplication.created_at < date_max): - counts[r.response] += 1 - - return { - 'count': sum(counts.values()), - 'ignored': counts[EMPLOYER_RESPONSE.IGNORED], - 'replied': counts[EMPLOYER_RESPONSE.REPLIED], - 'flagged': counts[EMPLOYER_RESPONSE.FLAGGED], - 'spam': counts[EMPLOYER_RESPONSE.SPAM], - 'rejected': counts[EMPLOYER_RESPONSE.REJECTED], - } + grouped = JobApplication.response.group( + JobApplication.query.filter(JobApplication.user == self.user).filter( + JobApplication.created_at > date_min, JobApplication.created_at < date_max + ).options(db.load_only('id')) + ) + counts = {k.label.name: len(v) for k, v in grouped.items()} + counts['count'] = sum(counts.values()) + return counts def url_for(self, action='view', _external=False, **kwargs): domain = self.jobpost.email_domain @@ -852,7 +840,7 @@ def url_for(self, action='view', _external=False, **kwargs): JobApplication.jobpost = db.relationship(JobPost, backref=db.backref('applications', lazy='dynamic', order_by=( - db.case(value=JobApplication.response, whens={ + db.case(value=JobApplication._response, whens={ EMPLOYER_RESPONSE.NEW: 0, EMPLOYER_RESPONSE.PENDING: 1, EMPLOYER_RESPONSE.IGNORED: 2, @@ -866,12 +854,12 @@ def url_for(self, action='view', _external=False, **kwargs): JobPost.new_applications = db.column_property( db.select([db.func.count(JobApplication.id)]).where( - db.and_(JobApplication.jobpost_id == JobPost.id, JobApplication.response == EMPLOYER_RESPONSE.NEW))) + db.and_(JobApplication.jobpost_id == JobPost.id, JobApplication.response.NEW))) JobPost.replied_applications = db.column_property( db.select([db.func.count(JobApplication.id)]).where( - db.and_(JobApplication.jobpost_id == JobPost.id, JobApplication.response == EMPLOYER_RESPONSE.REPLIED))) + db.and_(JobApplication.jobpost_id == JobPost.id, JobApplication.response.REPLIED))) JobPost.viewcounts_viewed = db.column_property( diff --git a/hasjob/templates/application.html.jinja2 b/hasjob/templates/application.html.jinja2 index 59aa0983c..102275f40 100644 --- a/hasjob/templates/application.html.jinja2 +++ b/hasjob/templates/application.html.jinja2 @@ -29,16 +29,16 @@ {{ response_form.hidden_tag() }} - {%- if job_application.is_new() or job_application.is_pending() or job_application.is_ignored() %} + {%- if job_application.response.CAN_REJECT %}

- - - {% if not job_application.is_ignored() %}{% endif %} + + + {% if not job_application.response.IGNORED %}{% endif %}

- {%- if job_application.is_ignored() %} + {%- if job_application.response.IGNORED %} You have ignored this candidate. {%- endif %} Respond to the candidate to see their contact information. @@ -46,18 +46,18 @@ will not be shared. Spam reports are manually processed.

- {%- elif job_application.is_flagged() %} + {%- elif job_application.response.FLAGGED %}

You have flagged this application as spam.

- {%- elif job_application.is_spam() %} + {%- elif job_application.response.SPAM %}

An administrator flagged this application as spam.

- {%- elif job_application.is_replied() %} + {%- elif job_application.response.REPLIED %}

Email: {{ job_application.email }}
Phone: {{ job_application.phone }} @@ -68,7 +68,7 @@ {%- if job_application.response_message %} {{ job_application.response_message|safe }} {%- endif %} - {%- elif job_application.is_rejected() %} + {%- elif job_application.response.REJECTED %} {%- if job_application.replied_by -%}

Correspondent: {{ job_application.replied_by.pickername }}

{%- endif %} @@ -96,14 +96,14 @@
{%- for appl in post.applications %} - + {%- if appl == job_application -%} {{ appl.fullname }} {%- else -%} {{ appl.fullname }} {%- endif -%}
{%- endfor %} diff --git a/hasjob/templates/respond_email.html.jinja2 b/hasjob/templates/respond_email.html.jinja2 index d90d5a264..5b6f41b13 100644 --- a/hasjob/templates/respond_email.html.jinja2 +++ b/hasjob/templates/respond_email.html.jinja2 @@ -6,9 +6,9 @@
- {%- if job_application.is_replied() %} + {%- if job_application.response.REPLIED %} - {%- elif job_application.is_rejected() %} + {%- elif job_application.response.REJECTED %} {%- endif %}
@@ -17,9 +17,9 @@

- {%- if job_application.is_replied() %} + {%- if job_application.response.REPLIED %} {{ g.user.fullname if post.admin_is(g.user) else post.fullname or post.company_name }} has responded to your application for {{ post.headline }}. You can reply to this email to continue the conversation - {%- elif job_application.is_rejected() %} + {%- elif job_application.response.REJECTED %} {{ g.user.fullname if post.admin_is(g.user) else post.fullname or post.company_name }} has declined your application for {{ post.headline }} {%- endif %}

diff --git a/hasjob/views/listing.py b/hasjob/views/listing.py index 63bec2f57..2bb671efb 100644 --- a/hasjob/views/listing.py +++ b/hasjob/views/listing.py @@ -383,8 +383,8 @@ def view_application_email_gif(domain, hashid, application): job_application = None if job_application is not None: - if job_application.response == EMPLOYER_RESPONSE.NEW: - job_application.response = EMPLOYER_RESPONSE.PENDING + if job_application.mark_read.is_available: + job_application.mark_read() db.session.commit() return gif1x1, 200, { 'Content-Type': 'image/gif', @@ -419,16 +419,14 @@ def view_application(domain, hashid, application): if post.email_domain != domain: return redirect(job_application.url_for(), code=301) - if job_application.response == EMPLOYER_RESPONSE.NEW: + if job_application.response.NEW: # If the application is pending, mark it as opened. # However, don't do this if the user is a siteadmin, unless they also own the post. - if post.admin_is(g.user) or not lastuser.has_permission('siteadmin'): - job_application.response = EMPLOYER_RESPONSE.PENDING + if job_application.mark_read.is_available: + job_application.mark_read() db.session.commit() response_form = forms.ApplicationResponseForm() - statuses = set([app.status for app in post.applications]) - if not g.kiosk: if g.preview_campaign: header_campaign = g.preview_campaign @@ -440,7 +438,7 @@ def view_application(domain, hashid, application): return render_template('application.html.jinja2', post=post, job_application=job_application, header_campaign=header_campaign, - response_form=response_form, statuses=statuses, is_siteadmin=lastuser.has_permission('siteadmin')) + response_form=response_form, is_siteadmin=lastuser.has_permission('siteadmin')) @app.route('///appl//process', methods=['POST'], subdomain='') @@ -459,18 +457,21 @@ def process_application(domain, hashid, application): flashmsg = '' if response_form.validate_on_submit(): - if (request.form.get('action') == 'reply' and job_application.can_reply()) or ( - request.form.get('action') == 'reject' and job_application.can_reject()): + if (request.form.get('action') == 'reply' and job_application.response.CAN_REPLY) or ( + request.form.get('action') == 'reject' and job_application.response.CAN_REJECT): if not response_form.response_message.data: flashmsg = "You need to write a message to the candidate." else: if request.form.get('action') == 'reply': - job_application.response = EMPLOYER_RESPONSE.REPLIED + job_application.reply( + message=response_form.response_message.data, + user=g.user + ) else: - job_application.response = EMPLOYER_RESPONSE.REJECTED - job_application.response_message = response_form.response_message.data - job_application.replied_by = g.user - job_application.replied_at = datetime.utcnow() + job_application.reject( + message=response_form.response_message.data, + user=g.user + ) email_html = email_transform( render_template('respond_email.html.jinja2', @@ -484,7 +485,7 @@ def process_application(domain, hashid, application): sender=sender_name, site=app.config['SITE_TITLE']) - if job_application.is_replied(): + if job_application.response.REPLIED: msg = Message( subject=u"{candidate}: {headline}".format( candidate=job_application.user.fullname, headline=post.headline), @@ -502,14 +503,14 @@ def process_application(domain, hashid, application): msg.html = email_html mail.send(msg) db.session.commit() - elif request.form.get('action') == 'ignore' and job_application.can_ignore(): - job_application.response = EMPLOYER_RESPONSE.IGNORED + elif request.form.get('action') == 'ignore' and job_application.response.CAN_IGNORE: + job_application.ignore() db.session.commit() - elif request.form.get('action') == 'flag' and job_application.can_report(): - job_application.response = EMPLOYER_RESPONSE.FLAGGED + elif request.form.get('action') == 'flag' and job_application.response.CAN_REPORT: + job_application.flag() db.session.commit() - elif request.form.get('action') == 'unflag' and job_application.is_flagged(): - job_application.response = EMPLOYER_RESPONSE.NEW + elif request.form.get('action') == 'unflag' and job_application.response.FLAGGED: + job_application.unflag() db.session.commit() if flashmsg: diff --git a/migrations/versions/625415764254_jobapplication_response_statemanager.py b/migrations/versions/625415764254_jobapplication_response_statemanager.py new file mode 100644 index 000000000..5957501da --- /dev/null +++ b/migrations/versions/625415764254_jobapplication_response_statemanager.py @@ -0,0 +1,29 @@ +"""job_application response statemanager + +Revision ID: 625415764254 +Revises: 859f6f33c02d +Create Date: 2018-03-24 03:14:19.250467 + +""" + +# revision identifiers, used by Alembic. +revision = '625415764254' +down_revision = '859f6f33c02d' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.create_check_constraint( + 'job_application_response_check', + 'job_application', + "response IN (0, 1, 2, 3, 4, 5, 6)" + ) + + +def downgrade(): + op.drop_constraint( + 'job_application_response_check', + 'job_application' + ) From 7e87088657aa0ca7f04df1eb56342fce9ed29de2 Mon Sep 17 00:00:00 2001 From: Shreyas Satish Date: Wed, 11 Apr 2018 13:57:29 +0530 Subject: [PATCH 5/7] turn off autocomplete for the filters form --- hasjob/templates/layout.html.jinja2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hasjob/templates/layout.html.jinja2 b/hasjob/templates/layout.html.jinja2 index 77c9b1ff9..f06b16bdd 100644 --- a/hasjob/templates/layout.html.jinja2 +++ b/hasjob/templates/layout.html.jinja2 @@ -81,7 +81,7 @@