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 034867e3e..f12c21ffd 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' + )