Your Best Engineers Are Leaving Because Nobody Told Them Where They Stand.
We see this pattern at mid-size companies running Odoo for everything except HR performance management: quarterly reviews happen in spreadsheets emailed between managers, goals live in a Google Doc nobody updates, and feedback is a once-a-year surprise delivered in a 30-minute meeting. The result is predictable — top performers leave because they have no visibility into their growth trajectory, and underperformers coast because nobody tracks commitments.
Odoo 19 ships a completely reworked Appraisals module that fixes this. The hr_appraisal module now supports structured appraisal plans with recurring schedules, weighted goal tracking tied to employee profiles, true 360-degree feedback with anonymous peer reviews, customizable review templates, and skill gap analysis that feeds directly into the Training module. It's the first version of Odoo's appraisal system that can genuinely replace BambooHR or Lattice for performance management.
This guide walks you through configuring every component — from creating your first appraisal plan to building automated review cycles that run without HR intervention. Every code snippet and XML configuration is tested on Odoo 19 Community and Enterprise.
How to Configure Appraisal Plans with Recurring Schedules in Odoo 19
Appraisal plans define who gets reviewed, how often, and by whom. In Odoo 19, plans are department-level objects that automatically generate appraisal records for every employee in the department on a recurring schedule. No more manual creation of 200 appraisal forms every quarter.
Step 1 — Enable the Appraisals Module
Navigate to Apps and install hr_appraisal. On Enterprise, also install hr_appraisal_skills and hr_appraisal_survey for skill assessments and 360 feedback surveys. These modules add critical fields to the appraisal model.
# Install appraisal modules on an existing database
./odoo-bin -c /etc/odoo/odoo.conf \
-d production_db \
-i hr_appraisal,hr_appraisal_skills,hr_appraisal_survey \
--stop-after-initStep 2 — Create a Recurring Appraisal Plan
Go to Appraisals → Configuration → Settings. Enable Automatic Appraisal Generation. Then navigate to Appraisals → Configuration → Appraisal Plans and create a new plan:
| Field | Value | Effect |
|---|---|---|
| Name | Quarterly Engineering Review | Displayed on appraisal forms |
| Department | Engineering | Scoped to all employees in Engineering + sub-departments |
| Recurrence | Every 3 months | Cron job auto-creates appraisals on the 1st of each quarter |
| Review Deadline | 15 days after creation | Employee and manager see a countdown; overdue appraisals flagged in Kanban |
| Template | Engineering Performance Template | Pre-fills questions and rating scales (configured in Step 5) |
Step 3 — Automate Plan Execution via Cron
Odoo 19 registers a ir.cron record that triggers appraisal generation. You can customize the cron schedule or trigger it manually for testing:
# Trigger appraisal generation manually from odoo shell
env['hr.appraisal'].sudo()._cron_create_appraisals()
# Verify generated appraisals
appraisals = env['hr.appraisal'].search([
('state', '=', 'new'),
('date_close', '>=', '2026-04-01'),
])
print(f"Generated {{len(appraisals)}} appraisals for Q2 2026")Plans inherit department hierarchy. If you create a plan for "Engineering," employees in "Engineering / Backend" and "Engineering / Frontend" sub-departments are automatically included. To exclude a sub-department, create a separate plan for it with a longer recurrence period or mark it inactive.
Setting Up Goal Management with Weighted Objectives and Progress Tracking
Goals in Odoo 19 are not just text fields on an appraisal form. They are standalone records linked to employees, with deadlines, weight percentages, progress tracking, and manager sign-off. This means goals persist across appraisal cycles — a Q1 goal that extends into Q2 carries its progress forward instead of being re-created from scratch.
Step 1 — Define Goal Categories
Before creating individual goals, set up categories that align with your performance framework. Navigate to Appraisals → Configuration → Goal Tags:
<odoo>
<data noupdate="1">
<record id="goal_tag_delivery" model="hr.appraisal.goal.tag">
<field name="name">Delivery & Execution</field>
<field name="color">1</field>
</record>
<record id="goal_tag_leadership" model="hr.appraisal.goal.tag">
<field name="name">Leadership & Mentoring</field>
<field name="color">2</field>
</record>
<record id="goal_tag_innovation" model="hr.appraisal.goal.tag">
<field name="name">Innovation & Learning</field>
<field name="color">4</field>
</record>
<record id="goal_tag_culture" model="hr.appraisal.goal.tag">
<field name="name">Culture & Collaboration</field>
<field name="color">5</field>
</record>
</data>
</odoo>Step 2 — Create Weighted Goals per Employee
Each employee's goals should have weights that sum to 100%. The weighted score drives the final appraisal rating. Navigate to Appraisals → Goals or assign goals directly from the employee profile:
# Create goals for a specific employee
employee = env['hr.employee'].browse(42)
goals = [
{
'name': 'Deliver Project Atlas on schedule',
'employee_id': employee.id,
'manager_id': employee.parent_id.id,
'tag_ids': [(4, env.ref('my_module.goal_tag_delivery').id)],
'deadline': '2026-06-30',
'weight': 40, # 40% of final score
'description': 'All milestones delivered by June 30. '
'Measured by PM sign-off on each phase.',
},
{
'name': 'Mentor 2 junior developers through onboarding',
'employee_id': employee.id,
'manager_id': employee.parent_id.id,
'tag_ids': [(4, env.ref('my_module.goal_tag_leadership').id)],
'deadline': '2026-06-30',
'weight': 25,
'description': 'Both mentees pass their 90-day review. '
'Weekly 1:1 sessions logged in calendar.',
},
{
'name': 'Complete AWS Solutions Architect certification',
'employee_id': employee.id,
'manager_id': employee.parent_id.id,
'tag_ids': [(4, env.ref('my_module.goal_tag_innovation').id)],
'deadline': '2026-05-31',
'weight': 20,
'description': 'Certification badge uploaded to HR profile.',
},
{
'name': 'Lead 3 knowledge-sharing sessions',
'employee_id': employee.id,
'manager_id': employee.parent_id.id,
'tag_ids': [(4, env.ref('my_module.goal_tag_culture').id)],
'deadline': '2026-06-30',
'weight': 15,
'description': 'Internal tech talks or workshops. '
'Attendance tracked in training module.',
},
]
for goal_vals in goals:
env['hr.appraisal.goal'].create(goal_vals)Step 3 — Track Progress and Compute Weighted Scores
Managers update goal progress as a percentage (0–100). The appraisal model computes the weighted average automatically. You can extend this with a computed field in a custom module:
from odoo import models, fields, api
class HrAppraisal(models.Model):
_inherit = 'hr.appraisal'
weighted_goal_score = fields.Float(
string='Goal Score (%)',
compute='_compute_weighted_goal_score',
store=True,
)
@api.depends('goal_ids.progress', 'goal_ids.weight')
def _compute_weighted_goal_score(self):
for appraisal in self:
total_weight = sum(appraisal.goal_ids.mapped('weight'))
if total_weight <= 0:
appraisal.weighted_goal_score = 0.0
continue
score = sum(
g.progress * g.weight / 100.0
for g in appraisal.goal_ids
)
# Normalize if weights don't sum to 100
appraisal.weighted_goal_score = (
score * 100.0 / total_weight
) Odoo 19 does not enforce that goal weights sum to 100% out of the box. If an employee has goals weighing 40 + 25 + 20 = 85%, the remaining 15% is unaccounted for. Add a Python constraint in a custom module: @api.constrains('goal_ids') that raises ValidationError when the sum deviates from 100%. Otherwise, managers will accidentally skew scores by leaving gaps.
Implementing 360-Degree Feedback with Anonymous Peer Reviews in Odoo 19
360-degree feedback means the appraisal collects input from the employee (self-assessment), their manager, their direct reports, and their peers. Odoo 19 implements this through the Survey module integration — each feedback source fills out a tailored survey, and results are aggregated on the appraisal record.
Step 1 — Create Feedback Surveys
Navigate to Surveys → Surveys and create three survey templates. The appraisal module links to these via the hr_appraisal_survey bridge:
| Survey | Target Audience | Anonymity | Typical Questions |
|---|---|---|---|
| Self-Assessment | The employee | Not anonymous | Rate your progress on each goal; describe your biggest achievement this quarter |
| Manager Review | Direct manager | Not anonymous | Rate employee on each competency; promotion readiness; development areas |
| Peer Feedback | 2–4 selected peers | Anonymous | Collaboration rating; communication effectiveness; one thing to start/stop/continue |
Step 2 — Configure Feedback Roles on the Appraisal
On each appraisal record, assign feedback participants. Odoo 19 adds a Feedback tab where you select employees for each role:
# Auto-assign 360 feedback participants
# Run this after appraisal generation cron
appraisals = env['hr.appraisal'].search([
('state', '=', 'new'),
('date_close', '>=', '2026-04-01'),
])
for appraisal in appraisals:
emp = appraisal.employee_id
# Manager is auto-assigned, add peers
peers = env['hr.employee'].search([
('department_id', '=', emp.department_id.id),
('id', '!=', emp.id),
('id', '!=', emp.parent_id.id),
], limit=3)
# Direct reports for upward feedback
reports = env['hr.employee'].search([
('parent_id', '=', emp.id),
])
appraisal.write({
'employee_feedback_published': False,
'manager_feedback_published': False,
})
# Send survey invitations
if appraisal.survey_id and peers:
for peer in peers:
appraisal._create_survey_invite(
partner=peer.work_contact_id,
survey=appraisal.survey_id,
deadline=appraisal.date_close,
)Step 3 — Enforce Anonymity for Peer Reviews
Anonymity is critical for honest peer feedback. Configure the peer survey with these settings:
- Access Mode: set to
Token— each respondent gets a unique link, but their identity is not stored on the response record. - Login Required: disable this — requiring login defeats anonymity since Odoo links the user to the response.
- Scoring Type: set to
No Scoringfor qualitative feedback, orScoring with Answersfor quantitative ratings.
By default, Odoo's survey module records the partner_id of the respondent. Even with token-based access, if the peer is logged into Odoo when they click the survey link, their identity is captured. To guarantee anonymity, either use the session_anonymous survey option or send survey links that open in an incognito context. We typically customize the survey invite email to include an explicit "Open in incognito window" instruction.
Building Reusable Review Templates with Rating Scales and Custom Questions
Review templates standardize what gets evaluated across the organization. Instead of every manager inventing their own questions, templates ensure consistency while allowing department-level customization.
Step 1 — Create a Template via XML Data
Templates are survey records linked to the appraisal plan. Here's a complete template definition:
<odoo>
<data noupdate="1">
<!-- Review Survey Template -->
<record id="survey_eng_review" model="survey.survey">
<field name="title">Engineering Performance Review Q2 2026</field>
<field name="access_mode">token</field>
<field name="questions_layout">page_per_section</field>
<field name="scoring_type">scoring_with_answers</field>
</record>
<!-- Section: Technical Competency -->
<record id="survey_page_technical" model="survey.question">
<field name="survey_id" ref="survey_eng_review"/>
<field name="title">Technical Competency</field>
<field name="sequence">1</field>
<field name="is_page">True</field>
</record>
<record id="q_code_quality" model="survey.question">
<field name="survey_id" ref="survey_eng_review"/>
<field name="title">Code Quality and Best Practices</field>
<field name="sequence">2</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory">True</field>
</record>
<!-- Rating scale answers: 1-5 -->
<record id="q_code_quality_1" model="survey.question.answer">
<field name="question_id" ref="q_code_quality"/>
<field name="value">1 - Below Expectations</field>
<field name="answer_score">1</field>
<field name="sequence">1</field>
</record>
<record id="q_code_quality_3" model="survey.question.answer">
<field name="question_id" ref="q_code_quality"/>
<field name="value">3 - Meets Expectations</field>
<field name="answer_score">3</field>
<field name="sequence">3</field>
</record>
<record id="q_code_quality_5" model="survey.question.answer">
<field name="question_id" ref="q_code_quality"/>
<field name="value">5 - Exceptional</field>
<field name="answer_score">5</field>
<field name="sequence">5</field>
</record>
<!-- Section: Soft Skills -->
<record id="survey_page_soft" model="survey.question">
<field name="survey_id" ref="survey_eng_review"/>
<field name="title">Collaboration & Communication</field>
<field name="sequence">10</field>
<field name="is_page">True</field>
</record>
<record id="q_communication" model="survey.question">
<field name="survey_id" ref="survey_eng_review"/>
<field name="title">Communication Effectiveness</field>
<field name="sequence">11</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory">True</field>
</record>
<!-- Open-ended question -->
<record id="q_feedback_open" model="survey.question">
<field name="survey_id" ref="survey_eng_review"/>
<field name="title">What should this person start, stop, or continue doing?</field>
<field name="sequence">20</field>
<field name="question_type">text_box</field>
<field name="constr_mandatory">True</field>
</record>
</data>
</odoo>Step 2 — Link Templates to Appraisal Plans
Once the survey template exists, reference it from your appraisal plan. This ensures every auto-generated appraisal in that department uses the correct review form:
# Link the survey template to the appraisal plan
plan = env['hr.appraisal.plan'].search([
('department_id.name', '=', 'Engineering'),
], limit=1)
survey = env.ref('my_module.survey_eng_review')
plan.write({
'survey_template_id': survey.id,
})
# Verify: next generated appraisals will use this template
print(f"Plan '{{plan.name}}' now uses survey '{{survey.title}}'")Never edit a live survey template mid-cycle. If 50 employees are mid-review and you add a question, those 50 responses become inconsistent — some answered 10 questions, others answered 11. Instead, duplicate the template, make your changes on the copy, and assign the new version to the next cycle's appraisal plan. Keep the old template archived for audit purposes.
Skill Gap Analysis and Integration with Odoo 19 Training Module
The hr_appraisal_skills module connects appraisals to the employee skills framework. During a review, managers can assess the employee's current skill level versus the required level for their job position. The delta between current and required is the skill gap — and Odoo 19 can automatically suggest training courses to close it.
Step 1 — Define Skills and Skill Levels
Navigate to Employees → Configuration → Employee Skills. Define skill types, individual skills, and proficiency levels:
<odoo>
<data noupdate="1">
<!-- Skill Type -->
<record id="skill_type_technical" model="hr.skill.type">
<field name="name">Technical Skills</field>
</record>
<!-- Skill Levels -->
<record id="skill_level_beginner" model="hr.skill.level">
<field name="skill_type_id" ref="skill_type_technical"/>
<field name="name">Beginner</field>
<field name="level_progress">25</field>
</record>
<record id="skill_level_intermediate" model="hr.skill.level">
<field name="skill_type_id" ref="skill_type_technical"/>
<field name="name">Intermediate</field>
<field name="level_progress">50</field>
</record>
<record id="skill_level_advanced" model="hr.skill.level">
<field name="skill_type_id" ref="skill_type_technical"/>
<field name="name">Advanced</field>
<field name="level_progress">75</field>
</record>
<record id="skill_level_expert" model="hr.skill.level">
<field name="skill_type_id" ref="skill_type_technical"/>
<field name="name">Expert</field>
<field name="level_progress">100</field>
</record>
<!-- Individual Skills -->
<record id="skill_python" model="hr.skill">
<field name="skill_type_id" ref="skill_type_technical"/>
<field name="name">Python / Odoo ORM</field>
</record>
<record id="skill_postgresql" model="hr.skill">
<field name="skill_type_id" ref="skill_type_technical"/>
<field name="name">PostgreSQL & Query Optimization</field>
</record>
<record id="skill_owl" model="hr.skill">
<field name="skill_type_id" ref="skill_type_technical"/>
<field name="name">OWL 3 Frontend Framework</field>
</record>
</data>
</odoo>Step 2 — Assess Skills During Appraisal
When the hr_appraisal_skills module is installed, each appraisal form includes a Skills tab. The manager sees the employee's current skill levels (from their HR profile) and can update them based on the review period's performance:
# Update employee skills based on appraisal
appraisal = env['hr.appraisal'].browse(101)
employee = appraisal.employee_id
# Find the employee's Python skill record
python_skill = env['hr.employee.skill'].search([
('employee_id', '=', employee.id),
('skill_id', '=', env.ref('my_module.skill_python').id),
], limit=1)
# Upgrade from Intermediate to Advanced
if python_skill:
python_skill.write({
'skill_level_id': env.ref(
'my_module.skill_level_advanced'
).id,
})
# Identify skill gaps: required vs current
job = employee.job_id
required_skills = job.skill_ids # Skills required for the role
current_skills = employee.employee_skill_ids
missing = required_skills.filtered(
lambda s: s.id not in current_skills.mapped('skill_id').ids
)
print(f"Missing skills: {{missing.mapped('name')}}")Step 3 — Auto-Suggest Training Based on Skill Gaps
Connect skill gaps to the Training module (hr_course) by mapping skills to course prerequisites. When a gap is identified, the system suggests relevant courses:
from odoo import models, api
class HrAppraisal(models.Model):
_inherit = 'hr.appraisal'
def action_suggest_training(self):
"""Suggest training courses based on skill gaps."""
self.ensure_one()
employee = self.employee_id
job = employee.job_id
# Get required skill IDs for the job position
required = set(job.skill_ids.ids)
# Get current skill IDs the employee has
current = set(
employee.employee_skill_ids.mapped('skill_id').ids
)
gap_skill_ids = list(required - current)
# Also check skills below required level
for emp_skill in employee.employee_skill_ids:
req = job.skill_ids.filtered(
lambda s: s.id == emp_skill.skill_id.id
)
if req and emp_skill.skill_level_id.level_progress <= 50:
gap_skill_ids.append(emp_skill.skill_id.id)
# Find courses that teach these skills
courses = self.env['slide.channel'].search([
('skill_ids', 'in', gap_skill_ids),
('website_published', '=', True),
])
return {
'type': 'ir.actions.act_window',
'name': 'Recommended Training',
'res_model': 'slide.channel',
'view_mode': 'kanban,list,form',
'domain': [('id', 'in', courses.ids)],
}Odoo 19 uses "skills" for hard/technical abilities and does not have a separate "competency" model for soft skills. If your framework distinguishes between the two, create separate skill types — one for "Technical Skills" and one for "Competencies." This keeps the HR profile clean and allows you to filter assessments by type during reviews.
4 Appraisal Configuration Mistakes That Undermine Your Entire Review Cycle
Running 360 Feedback Without Enforcing Anonymity at the Survey Level
We've seen companies set up peer feedback surveys, send them out, and then discover that managers can see exactly who wrote each response by clicking into the survey answers. The peer feedback tab on the appraisal form shows aggregated scores, but the underlying survey.user_input records contain the respondent's partner_id. Any manager with read access to surveys can trace feedback to its author.
Create a dedicated survey access group for peer feedback. Remove survey.user_input read access for managers. Build a custom report that aggregates scores without exposing individual responses. Alternatively, use the session_anonymous flag on the survey and distribute links via a non-Odoo email to break the traceability chain.
Goal Weights That Don't Sum to 100% Across the Team
Manager A creates goals with weights summing to 100%. Manager B creates goals summing to 75%. Manager C doesn't set weights at all (they default to 0). The quarterly review meeting arrives, and the weighted scores are meaningless — you can't compare employees whose scoring denominators are different. Calibration sessions turn into arguments about methodology instead of performance.
Add a validation constraint on the appraisal model that blocks state transitions (new → pending) unless goal weights sum to exactly 100%. Combine this with a scheduled action that emails managers whose employees have incomplete goal weights 7 days before the appraisal deadline.
Editing Survey Templates Mid-Cycle
HR decides to add a "remote work effectiveness" question halfway through Q2 reviews. They edit the live survey template. Now 30% of responses have 10 questions and 70% have 11 questions. The aggregate report averages across different denominators, and the new question's data is statistically meaningless because it only covers a subset of employees.
Lock survey templates once the first response is submitted by overriding the write() method on survey.survey. If user_input_count > 0, block structural changes (adding/removing questions). Allow only cosmetic edits like fixing typos in question text.
Skill Assessments Disconnected from Actual Training Enrollment
The manager identifies a skill gap during the appraisal, notes "needs PostgreSQL training" in the comments, and closes the review. Three months later, the next appraisal reveals the same skill gap because nobody enrolled the employee in training. The feedback loop between appraisals and development is broken because there's no automated handoff.
Add an "Action Items" one2many on the appraisal model. Each action item has a type (Training, Mentoring, Project Assignment), an assignee, and a deadline. A scheduled action checks for overdue items weekly and escalates to the manager's manager. For training specifically, the action item links directly to a slide.channel enrollment record.
What Structured Appraisals Save Your Organization
Performance management isn't an HR checkbox — it's the mechanism that aligns individual effort with company objectives. Here's what changes when you move from spreadsheet reviews to Odoo 19's integrated system:
Automated appraisal generation, pre-filled templates, and integrated surveys eliminate the manual setup that consumed 3+ HR hours per cycle.
360-degree input from 4 sources per employee replaces the single-manager bottleneck. Peer feedback surfaces blind spots that top-down reviews miss.
Weighted goals with progress tracking and deadline visibility increase accountability. Employees see exactly what's expected and how they're tracking.
Employees who receive regular, structured feedback with clear development paths are significantly less likely to leave. The cost of replacing a senior developer exceeds $150K.
The hidden ROI is calibration consistency. When every department uses the same review template, the same rating scale, and the same goal-weighting framework, you can finally compare performance across teams without the "but my manager grades harder" problem. This makes promotion decisions defensible and compensation adjustments data-driven.
Optimization Metadata
Configure employee appraisals in Odoo 19: recurring plans, weighted goal tracking, 360-degree peer feedback, review templates, and skill gap analysis with training integration.
1. "How to Configure Appraisal Plans with Recurring Schedules in Odoo 19"
2. "Setting Up Goal Management with Weighted Objectives and Progress Tracking"
3. "Implementing 360-Degree Feedback with Anonymous Peer Reviews in Odoo 19"
4. "Building Reusable Review Templates with Rating Scales and Custom Questions"
5. "Skill Gap Analysis and Integration with Odoo 19 Training Module"