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 @@ + + +