From 54f246f9c75b8f1ebdc637c6000f3e4773d702d5 Mon Sep 17 00:00:00 2001 From: Carlos Valverde Date: Mon, 1 Aug 2022 11:38:31 +0000 Subject: [PATCH] [IMP] website_hr_recruitment: add pager + search and improve filters 1. Pager + search Before this commit the jobs list had no pager, and the search parameter was neither used for filtering nor propagated to the rendering. After this commit the jobs list supports pager and the search parameter is passed through all layers. 2. Filters Before this commit, filters were displayed as a set of `nav-pills` items in a column on the left of the main template. After this commit, filters have been moved to the top as dropdown elements, displaying counts inside badges, which are obtained from the list of filtered jobs. Filters have also been adapted to requested change: each filter content must be based on results that would be obtained if that filter was not applied. Additionally, `Remote` filterable location has been included, and can be set either by using the backend or directly with the website builder. task-2406626 closes odoo/odoo#67913 Signed-off-by: Quentin Smetz (qsm) Co-authored-by: Benoit Socias --- .../controllers/main.py | 245 +++++++++++++--- .../website_hr_recruitment/models/hr_job.py | 61 +++- .../website_hr_recruitment/models/website.py | 6 + .../src/scss/website_hr_recruitment.scss | 17 ++ .../website_hr_recruitment/views/snippets.xml | 23 +- .../website_hr_recruitment_templates.xml | 267 +++++++++++++----- 6 files changed, 507 insertions(+), 112 deletions(-) diff --git a/addons/website_hr_recruitment/controllers/main.py b/addons/website_hr_recruitment/controllers/main.py index d9989ee2588..1bf5155e38a 100644 --- a/addons/website_hr_recruitment/controllers/main.py +++ b/addons/website_hr_recruitment/controllers/main.py @@ -1,73 +1,189 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import warnings + from odoo import http, _ from odoo.addons.http_routing.models.ir_http import slug +from odoo.osv.expression import AND from odoo.http import request -from werkzeug.exceptions import NotFound +from odoo.tools.misc import groupby class WebsiteHrRecruitment(http.Controller): + _jobs_per_page = 12 + def sitemap_jobs(env, rule, qs): if not qs or qs.lower() in '/jobs': yield {'loc': '/jobs'} @http.route([ '/jobs', - '/jobs/country/', - '/jobs/department/', - '/jobs/country//department/', - '/jobs/office/', - '/jobs/country//office/', - '/jobs/department//office/', - '/jobs/country//department//office/', - '/jobs/employment_type/', - '/jobs/country//employment_type/', - '/jobs/department//employment_type/', - '/jobs/office//employment_type/', - '/jobs/country//department//employment_type/', - '/jobs/country//office//employment_type/', - '/jobs/department//office//employment_type/', - '/jobs/country//department//office//employment_type/', + '/jobs/page/', ], type='http', auth="public", website=True, sitemap=sitemap_jobs) - def jobs(self, country=None, department=None, office_id=None, contract_type_id=None, **kwargs): + def jobs(self, country_id=None, department_id=None, office_id=None, contract_type_id=None, + is_remote=False, is_other_department=False, is_untyped=None, page=1, search=None, **kwargs): env = request.env(context=dict(request.env.context, show_address=True, no_tag_br=True)) Country = env['res.country'] Jobs = env['hr.job'] + Department = env['hr.department'] - # List jobs available to current UID - domain = request.website.website_domain() - job_ids = Jobs.search(domain, order="is_published desc, sequence, no_of_recruitment desc").ids - # Browse jobs as superuser, because address is restricted - jobs = Jobs.sudo().browse(job_ids) + country = Country.browse(int(country_id)) if country_id else None + department = Department.browse(int(department_id)) if department_id else None + office_id = int(office_id) if office_id else None + contract_type_id = int(contract_type_id) if contract_type_id else None # Default search by user country if not (country or department or office_id or contract_type_id or kwargs.get('all_countries')): if request.geoip.country_code: countries_ = Country.search([('code', '=', request.geoip.country_code)]) country = countries_[0] if countries_ else None - if not any(j for j in jobs if j.address_id and j.address_id.country_id == country): - country = False + if country: + country_count = Jobs.search_count(AND([ + request.website.website_domain(), + [('address_id.country_id', '=', country.id)] + ])) + if not country_count: + country = False - # Filter job / office for country - if country and not kwargs.get('all_countries'): - jobs = jobs.filtered(lambda j: not j.address_id or j.address_id.country_id.id == country.id) - offices = jobs.address_id.sorted(lambda a: (a.country_id.name or '', a.city or '')) + options = { + 'displayDescription': True, + 'allowFuzzy': not request.params.get('noFuzzy'), + 'country_id': country.id if country else None, + 'department_id': department.id if department else None, + 'office_id': office_id, + 'contract_type_id': contract_type_id, + 'is_remote': is_remote, + 'is_other_department': is_other_department, + 'is_untyped': is_untyped, + } + total, details, fuzzy_search_term = request.website._search_with_fuzzy("jobs", search, + limit=1000, order="is_published desc, sequence, no_of_recruitment desc", options=options) + # Browse jobs as superuser, because address is restricted + jobs = details[0].get('results', Jobs).sudo() - # Deduce departments and countries offices of those jobs - departments = jobs.department_id.sorted('name') - countries = offices.country_id.sorted('name') - employment_types = jobs.contract_type_id.sorted('name') + def sort(records_list, field_name): + """ Sort records in the given collection according to the given + field name, alphabetically. None values instead of records are + placed at the end. - if department: - jobs = [j for j in jobs if j.department_id and j.department_id.id == department.id] - if office_id and office_id in [x.id for x in offices]: - jobs = [j for j in jobs if j.address_id and j.address_id.id == office_id] + :param list records_list: collection of records or None values + :param str field_name: field on which to sort + :return: sorted list + """ + return sorted( + records_list, + key=lambda item: (item is None, item and item[field_name] or ''), + ) + + # Countries + if country or is_remote: + cross_country_options = options.copy() + cross_country_options.update({ + 'allowFuzzy': False, + 'country_id': None, + 'is_remote': False, + }) + cross_country_total, cross_country_details, _ = request.website._search_with_fuzzy("jobs", + fuzzy_search_term or search, limit=1000, order="is_published desc, sequence, no_of_recruitment desc", + options=cross_country_options) + # Browse jobs as superuser, because address is restricted + cross_country_jobs = cross_country_details[0].get('results', Jobs).sudo() else: - office_id = False - if contract_type_id: - jobs = [j for j in jobs if j.contract_type_id and j.contract_type_id.id == contract_type_id] + cross_country_total = total + cross_country_jobs = jobs + country_offices = set(j.address_id or None for j in cross_country_jobs) + countries = sort(set(o and o.country_id or None for o in country_offices), 'name') + count_per_country = {'all': cross_country_total} + for c, jobs_list in groupby(cross_country_jobs, lambda job: job.address_id.country_id): + count_per_country[c] = len(jobs_list) + count_remote = len(cross_country_jobs.filtered(lambda job: not job.address_id)) + if count_remote: + count_per_country[None] = count_remote + + # Departments + if department or is_other_department: + cross_department_options = options.copy() + cross_department_options.update({ + 'allowFuzzy': False, + 'department_id': None, + 'is_other_department': False, + }) + cross_department_total, cross_department_details, _ = request.website._search_with_fuzzy("jobs", + fuzzy_search_term or search, limit=1000, order="is_published desc, sequence, no_of_recruitment desc", + options=cross_department_options) + cross_department_jobs = cross_department_details[0].get('results', Jobs) + else: + cross_department_total = total + cross_department_jobs = jobs + departments = sort(set(j.department_id or None for j in cross_department_jobs), 'name') + count_per_department = {'all': cross_department_total} + for d, jobs_list in groupby(cross_department_jobs, lambda job: job.department_id): + count_per_department[d] = len(jobs_list) + count_other_department = len(cross_department_jobs.filtered(lambda job: not job.department_id)) + if count_other_department: + count_per_department[None] = count_other_department + + # Offices + if office_id or is_remote: + cross_office_options = options.copy() + cross_office_options.update({ + 'allowFuzzy': False, + 'office_id': None, + 'is_remote': False, + }) + cross_office_total, cross_office_details, _ = request.website._search_with_fuzzy("jobs", + fuzzy_search_term or search, limit=1000, order="is_published desc, sequence, no_of_recruitment desc", + options=cross_office_options) + # Browse jobs as superuser, because address is restricted + cross_office_jobs = cross_office_details[0].get('results', Jobs).sudo() + else: + cross_office_total = total + cross_office_jobs = jobs + offices = sort(set(j.address_id or None for j in cross_office_jobs), 'city') + count_per_office = {'all': cross_office_total} + for o, jobs_list in groupby(cross_office_jobs, lambda job: job.address_id): + count_per_office[o] = len(jobs_list) + count_remote = len(cross_office_jobs.filtered(lambda job: not job.address_id)) + if count_remote: + count_per_office[None] = count_remote + + # Employment types + if contract_type_id or is_untyped: + cross_type_options = options.copy() + cross_type_options.update({ + 'allowFuzzy': False, + 'contract_type_id': None, + 'is_untyped': False, + }) + cross_type_total, cross_type_details, _ = request.website._search_with_fuzzy("jobs", + fuzzy_search_term or search, limit=1000, order="is_published desc, sequence, no_of_recruitment desc", + options=cross_type_options) + cross_type_jobs = cross_type_details[0].get('results', Jobs) + else: + cross_type_total = total + cross_type_jobs = jobs + employment_types = sort(set(j.contract_type_id for j in jobs if j.contract_type_id), 'name') + count_per_employment_type = {'all': cross_type_total} + for t, jobs_list in groupby(cross_type_jobs, lambda job: job.contract_type_id): + count_per_employment_type[t] = len(jobs_list) + count_untyped = len(cross_type_jobs.filtered(lambda job: not job.contract_type_id)) + if count_untyped: + count_per_employment_type[None] = count_untyped + + pager = request.website.pager( + url=request.httprequest.path.partition('/page/')[0], + url_args=request.httprequest.args, + total=total, + page=page, + step=self._jobs_per_page, + ) + offset = pager['offset'] + jobs = jobs[offset:offset + self._jobs_per_page] + + office = env['res.partner'].browse(int(office_id)) if office_id else None + contract_type = env['hr.contract.type'].browse(int(contract_type_id)) if contract_type_id else None # Render page return request.render("website_hr_recruitment.index", { @@ -78,8 +194,19 @@ class WebsiteHrRecruitment(http.Controller): 'employment_types': employment_types, 'country_id': country, 'department_id': department, - 'office_id': office_id, - 'contract_type_id': contract_type_id, + 'office_id': office, + 'contract_type_id': contract_type, + 'is_remote': is_remote, + 'is_other_department': is_other_department, + 'is_untyped': is_untyped, + 'pager': pager, + 'search': fuzzy_search_term or search, + 'search_count': total, + 'original_search': fuzzy_search_term and search, + 'count_per_country': count_per_country, + 'count_per_department': count_per_department, + 'count_per_office': count_per_office, + 'count_per_employment_type': count_per_employment_type, }) @http.route('/jobs/add', type='json', auth="user", website=True) @@ -109,3 +236,39 @@ class WebsiteHrRecruitment(http.Controller): 'error': error, 'default': default, }) + + # Compatibility routes + + @http.route([ + '/jobs/country/', + '/jobs/department/', + '/jobs/country//department/', + '/jobs/office/', + '/jobs/country//office/', + '/jobs/department//office/', + '/jobs/country//department//office/', + '/jobs/employment_type/', + '/jobs/country//employment_type/', + '/jobs/department//employment_type/', + '/jobs/office//employment_type/', + '/jobs/country//department//employment_type/', + '/jobs/country//office//employment_type/', + '/jobs/department//office//employment_type/', + '/jobs/country//department//office//employment_type/', + ], type='http', auth="public", website=True, sitemap=False) + def jobs_compatibility(self, country=None, department=None, office_id=None, contract_type_id=None, **kwargs): + """ + Deprecated since Odoo 16.3: those routes are kept by compatibility. + They should not be used in Odoo code anymore. + """ + warnings.warn( + "This route is deprecated since Odoo 16.3: the jobs list is now available at /jobs or /jobs/page/XXX", + DeprecationWarning + ) + return self.jobs( + country_id=country.id if country else None, + department_id=department.id if department else None, + office_id=office_id, + contract_type_id=contract_type_id, + **kwargs + ) diff --git a/addons/website_hr_recruitment/models/hr_job.py b/addons/website_hr_recruitment/models/hr_job.py index 400eec530c0..1c77bcabb89 100644 --- a/addons/website_hr_recruitment/models/hr_job.py +++ b/addons/website_hr_recruitment/models/hr_job.py @@ -9,7 +9,12 @@ from odoo.tools.translate import html_translate class Job(models.Model): _name = 'hr.job' - _inherit = ['hr.job', 'website.seo.metadata', 'website.published.multi.mixin'] + _inherit = [ + 'hr.job', + 'website.seo.metadata', + 'website.published.multi.mixin', + 'website.searchable.mixin', + ] @mute_logger('odoo.addons.base.models.ir_qweb') def _get_default_description(self): @@ -65,3 +70,57 @@ class Job(models.Model): def toggle_active(self): self.filtered('active').website_published = False return super().toggle_active() + + @api.model + def _search_get_detail(self, website, order, options): + requires_sudo = False + with_description = options['displayDescription'] + country_id = options.get('country_id') + department_id = options.get('department_id') + office_id = options.get('office_id') + contract_type_id = options.get('contract_type_id') + is_remote = options.get('is_remote') + is_other_department = options.get('is_other_department') + is_untyped = options.get('is_untyped') + + domain = [website.website_domain()] + if country_id: + domain.append([('address_id.country_id', '=', int(country_id))]) + requires_sudo = True + if department_id: + domain.append([('department_id', '=', int(department_id))]) + elif is_other_department: + domain.append([('department_id', '=', None)]) + if office_id: + domain.append([('address_id', '=', int(office_id))]) + elif is_remote: + domain.append([('address_id', '=', None)]) + if contract_type_id: + domain.append([('contract_type_id', '=', int(contract_type_id))]) + elif is_untyped: + domain.append([('contract_type_id', '=', None)]) + + if requires_sudo and not self.env.user.has_group('hr_recruitment.group_hr_recruitment_user'): + # Rule must be reinforced because of sudo. + domain.append([('website_published', '=', True)]) + + + search_fields = ['name'] + fetch_fields = ['name', 'website_url'] + mapping = { + 'name': {'name': 'name', 'type': 'text', 'match': True}, + 'website_url': {'name': 'website_url', 'type': 'text', 'truncate': False}, + } + if with_description: + search_fields.append('description') + fetch_fields.append('description') + mapping['description'] = {'name': 'description', 'type': 'text', 'html': True, 'match': True} + return { + 'model': 'hr.job', + 'requires_sudo': requires_sudo, + 'base_domain': domain, + 'search_fields': search_fields, + 'fetch_fields': fetch_fields, + 'mapping': mapping, + 'icon': 'fa-briefcase', + } diff --git a/addons/website_hr_recruitment/models/website.py b/addons/website_hr_recruitment/models/website.py index 5a2cd5d37d5..5c3fdb486c5 100644 --- a/addons/website_hr_recruitment/models/website.py +++ b/addons/website_hr_recruitment/models/website.py @@ -12,3 +12,9 @@ class Website(models.Model): suggested_controllers = super(Website, self).get_suggested_controllers() suggested_controllers.append((_('Jobs'), url_for('/jobs'), 'website_hr_recruitment')) return suggested_controllers + + def _search_get_details(self, search_type, order, options): + result = super()._search_get_details(search_type, order, options) + if search_type in ['jobs', 'all']: + result.append(self.env['hr.job']._search_get_detail(self, order, options)) + return result diff --git a/addons/website_hr_recruitment/static/src/scss/website_hr_recruitment.scss b/addons/website_hr_recruitment/static/src/scss/website_hr_recruitment.scss index 5ccc2396564..9a5c92c0caa 100644 --- a/addons/website_hr_recruitment/static/src/scss/website_hr_recruitment.scss +++ b/addons/website_hr_recruitment/static/src/scss/website_hr_recruitment.scss @@ -1,4 +1,21 @@ +.o_jobs_topbar_filters { + .dropdown-toggle { + .fa { + margin-right: .4em; + } + + &:after { + margin-left: 1.2em; + } + } + + .dropdown-menu { + margin-top: $navbar-padding-y; + min-width: 12rem; + } +} + .o_website_hr_recruitment_jobs_list { #jobs_grid { .o_job_infos { diff --git a/addons/website_hr_recruitment/views/snippets.xml b/addons/website_hr_recruitment/views/snippets.xml index 2ca85342fab..f0f043fe30a 100644 --- a/addons/website_hr_recruitment/views/snippets.xml +++ b/addons/website_hr_recruitment/views/snippets.xml @@ -1,6 +1,17 @@ + + +