Skip to content

Commit

Permalink
Using StateManager for JobApplication model (#432)
Browse files Browse the repository at this point in the history
* initial changes for state manager for job application

* added transitions for application processing

* more fixes

* transition fixes
  • Loading branch information
Bibhas authored Apr 11, 2018
1 parent c01404f commit 8705491
Show file tree
Hide file tree
Showing 8 changed files with 136 additions and 113 deletions.
4 changes: 2 additions & 2 deletions hasjob/forms/jobpost.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
21 changes: 13 additions & 8 deletions hasjob/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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"))
Expand Down
34 changes: 17 additions & 17 deletions hasjob/models/flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
)
)

Expand All @@ -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
)
)
Expand All @@ -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
)
)
Expand All @@ -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
)
)
Expand Down Expand Up @@ -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
)
)

Expand All @@ -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
)
)
Expand All @@ -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
)
)
Expand All @@ -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
)
)
Expand Down
86 changes: 37 additions & 49 deletions hasjob/models/jobpost.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"""
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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(
Expand Down
22 changes: 11 additions & 11 deletions hasjob/templates/application.html.jinja2
Original file line number Diff line number Diff line change
Expand Up @@ -29,35 +29,35 @@
<input type="hidden" name="_charset_"/>
<input type="hidden" name="form.id" value="process_application_form"/>
{{ 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 %}
<div id="appl-options">
<p>
<button class="btn btn-success" id="appl-reply" name="action" value="reply">Respond...</button>
<button class="btn btn-info" id="appl-reject" name="action" value="reject">Reject...</button>
{% if not job_application.is_ignored() %}<button class="btn btn-info" type="submit" name="action" value="ignore">Ignore candidate</button>{% endif %}
<button class="btn btn-success" id="appl-reply" name="action" value="reply">Respond</button>
<button class="btn btn-info" id="appl-reject" name="action" value="reject">Reject</button>
{% if not job_application.response.IGNORED %}<button class="btn btn-info" type="submit" name="action" value="ignore">Ignore candidate</button>{% endif %}
<button class="btn btn-danger" type="submit" name="action" value="flag">Report spam</button>
</p>
<p id="appl-instructions">
{%- 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.
Rejected candidates will receive a message, but your contact information
will not be shared. Spam reports are manually processed.
</p>
</div>
{%- elif job_application.is_flagged() %}
{%- elif job_application.response.FLAGGED %}
<p>
<button class="btn btn-info" type="submit" name="action" value="unflag">Report not spam</button>
</p>
<p>
You have flagged this application as spam.
</p>
{%- elif job_application.is_spam() %}
{%- elif job_application.response.SPAM %}
<p>
An administrator flagged this application as spam.
</p>
{%- elif job_application.is_replied() %}
{%- elif job_application.response.REPLIED %}
<p>
<strong>Email:</strong> {{ job_application.email }}<br>
<strong>Phone:</strong> {{ job_application.phone }}
Expand All @@ -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 -%}
<p><strong>Correspondent:</strong> {{ job_application.replied_by.pickername }}</p>
{%- endif %}
Expand Down Expand Up @@ -96,14 +96,14 @@
<div class="col-md-3">
<div class="list-group">
{%- for appl in post.applications %}
<a class="list-group-item status_{{ appl.status|lower }}" href="{{ appl.url_for() }}">
<a class="list-group-item status_{{ appl.response.label.name }}" href="{{ appl.url_for() }}">
{%- if appl == job_application -%}
<strong>{{ appl.fullname }}</strong>
{%- else -%}
{{ appl.fullname }}
{%- endif -%}
<br><span class="post-date">{{ appl.created_at|shortdate }} &middot;
{%- if appl.is_new() %} <strong>{{ appl.status }}</strong>{% else %} {{ appl.status }}{% endif %}
{%- if appl.response.NEW %} <strong>{{ appl.response.label.title }}</strong>{% else %} {{ appl.response.label.title }}{% endif %}
</span>
</a>
{%- endfor %}
Expand Down
8 changes: 4 additions & 4 deletions hasjob/templates/respond_email.html.jinja2
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
<meta itemprop="name" content="View Employer Response"/>
<link itemprop="url" href="{{ post.url_for(_external=true) }}"/>
</div>
{%- if job_application.is_replied() %}
{%- if job_application.response.REPLIED %}
<meta itemprop="description" content="Employer has responded to your application."/>
{%- elif job_application.is_rejected() %}
{%- elif job_application.response.REJECTED %}
<meta itemprop="description" content="Employer has declined your application."/>
{%- endif %}
<div itemprop="publisher" itemscope itemtype="http://schema.org/Organization">
Expand All @@ -17,9 +17,9 @@
</div>
</div>
<p>
{%- if job_application.is_replied() %}
{%- if job_application.response.REPLIED %}
<em>{{ g.user.fullname if post.admin_is(g.user) else post.fullname or post.company_name }} has responded to your application for <a href="{{ post.url_for(_external=true) }}">{{ post.headline }}</a>. You can reply to this email to continue the conversation</em>
{%- elif job_application.is_rejected() %}
{%- elif job_application.response.REJECTED %}
<em>{{ g.user.fullname if post.admin_is(g.user) else post.fullname or post.company_name }} has declined your application for <a href="{{ post.url_for(_external=true) }}">{{ post.headline }}</a></em>
{%- endif %}
</p>
Expand Down
Loading

0 comments on commit 8705491

Please sign in to comment.