diff --git a/addons_extensions/grace_period/models/attendance_data.py b/addons_extensions/grace_period/models/attendance_data.py
index 85af345f2..bccfa6d11 100644
--- a/addons_extensions/grace_period/models/attendance_data.py
+++ b/addons_extensions/grace_period/models/attendance_data.py
@@ -1,4 +1,5 @@
from odoo import api, fields, models, tools, _
+from odoo.exceptions import UserError
class AttendanceAnalytics(models.Model):
@@ -64,6 +65,8 @@ class AttendanceAnalytics(models.Model):
('absent', 'Absent'),
+ ('invalid_attendance', 'Invalid Attendance'),
+
('half_day', 'Half Day'),
('late_in', 'Late In'),
@@ -192,7 +195,7 @@ class AttendanceAnalytics(models.Model):
for rec in self:
if rec.status == 'present':
rec.color = 10
- elif rec.status == 'absent':
+ elif rec.status in ('absent', 'invalid_attendance'):
rec.color = 1
elif rec.status == 'half_day':
rec.color = 2
@@ -280,6 +283,11 @@ class AttendanceAnalytics(models.Model):
def action_create_shiftswap_request(self):
self.ensure_one()
+ if self.date and self.date < fields.Date.context_today(self):
+ raise UserError(
+ _("Shift Swap cannot be requested for past dates.")
+ )
+
request = self.env['shift.swap.request'].search([
('employee_id', '=', self.employee_id.id),
('roster_date', '=', self.date),
@@ -341,7 +349,11 @@ class AttendanceAnalytics(models.Model):
att.employee_id,
- DATE(att.check_in)
+ DATE(
+ att.check_in
+ AT TIME ZONE 'UTC'
+ AT TIME ZONE 'Asia/Kolkata'
+ )
AS attendance_date,
MIN(att.check_in)
@@ -350,6 +362,18 @@ class AttendanceAnalytics(models.Model):
MAX(att.check_out)
AS max_check_out,
+ MIN(
+ att.check_in
+ AT TIME ZONE 'UTC'
+ AT TIME ZONE 'Asia/Kolkata'
+ ) AS min_check_in_local,
+
+ MAX(
+ att.check_out
+ AT TIME ZONE 'UTC'
+ AT TIME ZONE 'Asia/Kolkata'
+ ) AS max_check_out_local,
+
SUM(att.worked_hours)
AS worked_hours
@@ -359,7 +383,11 @@ class AttendanceAnalytics(models.Model):
att.employee_id,
- DATE(att.check_in)
+ DATE(
+ att.check_in
+ AT TIME ZONE 'UTC'
+ AT TIME ZONE 'Asia/Kolkata'
+ )
),
@@ -432,6 +460,32 @@ class AttendanceAnalytics(models.Model):
WHERE rl.date_from IS NOT NULL
AND rl.date_to IS NOT NULL
AND rl.resource_id IS NULL
+ ),
+
+ calendar_day_schedule AS (
+
+ SELECT
+
+ rca.calendar_id,
+
+ rca.dayofweek,
+
+ MIN(rca.hour_from) * 60
+ AS shift_start_minutes,
+
+ MAX(rca.hour_to) * 60
+ AS shift_end_minutes,
+
+ SUM(rca.hour_to - rca.hour_from)
+ AS hours_per_day
+
+ FROM resource_calendar_attendance rca
+
+ GROUP BY
+
+ rca.calendar_id,
+
+ rca.dayofweek
)
SELECT
@@ -450,20 +504,20 @@ class AttendanceAnalytics(models.Model):
ats.worked_hours,
0
) AS worked_hours,
- rc.hours_per_day AS hours_per_day,
+ es.hours_per_day AS hours_per_day,
(
- rc.hours_per_day
+ es.hours_per_day
+
COALESCE(rc.over_time_hrs, 0)
) AS allowed_ot_limit,
CASE
- WHEN ats.worked_hours > rc.hours_per_day
+ WHEN ats.worked_hours > es.hours_per_day
THEN
- ats.worked_hours - rc.hours_per_day
+ ats.worked_hours - es.hours_per_day
ELSE 0
@@ -474,7 +528,7 @@ class AttendanceAnalytics(models.Model):
WHEN ats.worked_hours >
(
- rc.hours_per_day
+ es.hours_per_day
+
COALESCE(rc.over_time_hrs, 0)
)
@@ -527,19 +581,11 @@ class AttendanceAnalytics(models.Model):
AS department_grace_period,
(
- (
- rc.shift_start_time * 60
- )
- +
- COALESCE(
- dg.grace_period,
- rc.late_grace_period,
- 0
- )
+ es.shift_start_minutes
) AS expected_check_in,
(
- rc.shift_end_time * 60
+ es.shift_end_minutes
) AS expected_check_out,
CASE
@@ -551,28 +597,20 @@ class AttendanceAnalytics(models.Model):
(
(
EXTRACT(
- HOUR FROM ats.min_check_in
+ HOUR FROM ats.min_check_in_local
) * 60
)
+
EXTRACT(
MINUTE
- FROM ats.min_check_in
+ FROM ats.min_check_in_local
)
)
-
(
- (
- rc.shift_start_time * 60
- )
- +
- COALESCE(
- dg.grace_period,
- rc.late_grace_period,
- 0
- )
+ es.shift_start_minutes
),
0
@@ -591,28 +629,20 @@ class AttendanceAnalytics(models.Model):
(
(
EXTRACT(
- HOUR FROM ats.min_check_in
+ HOUR FROM ats.min_check_in_local
) * 60
)
+
EXTRACT(
MINUTE
- FROM ats.min_check_in
+ FROM ats.min_check_in_local
)
)
>
(
- (
- rc.shift_start_time * 60
- )
- +
- COALESCE(
- dg.grace_period,
- rc.late_grace_period,
- 0
- )
+ es.shift_start_minutes
)
THEN TRUE
@@ -624,7 +654,7 @@ class AttendanceAnalytics(models.Model):
(
(
- rc.shift_end_time * 60
+ es.shift_end_minutes
)
+
@@ -640,28 +670,20 @@ class AttendanceAnalytics(models.Model):
(
EXTRACT(
HOUR
- FROM ats.min_check_in
+ FROM ats.min_check_in_local
) * 60
)
+
EXTRACT(
MINUTE
- FROM ats.min_check_in
+ FROM ats.min_check_in_local
)
)
-
(
- (
- rc.shift_start_time * 60
- )
- +
- COALESCE(
- dg.grace_period,
- rc.late_grace_period,
- 0
- )
+ es.shift_start_minutes
),
0
@@ -671,7 +693,7 @@ class AttendanceAnalytics(models.Model):
END
- ) AS required_checkout_time,
+ ) / 60.0 AS required_checkout_time,
CASE
@@ -683,7 +705,7 @@ class AttendanceAnalytics(models.Model):
(
(
- rc.shift_end_time * 60
+ es.shift_end_minutes
)
+
@@ -699,29 +721,20 @@ class AttendanceAnalytics(models.Model):
(
EXTRACT(
HOUR
- FROM ats.min_check_in
+ FROM ats.min_check_in_local
) * 60
)
+
EXTRACT(
MINUTE
- FROM ats.min_check_in
+ FROM ats.min_check_in_local
)
)
-
(
- (
- rc.shift_start_time
- * 60
- )
- +
- COALESCE(
- dg.grace_period,
- rc.late_grace_period,
- 0
- )
+ es.shift_start_minutes
),
0
@@ -739,13 +752,13 @@ class AttendanceAnalytics(models.Model):
(
EXTRACT(
HOUR
- FROM ats.max_check_out
+ FROM ats.max_check_out_local
) * 60
)
+
EXTRACT(
MINUTE
- FROM ats.max_check_out
+ FROM ats.max_check_out_local
)
),
@@ -767,13 +780,13 @@ class AttendanceAnalytics(models.Model):
(
EXTRACT(
HOUR
- FROM ats.max_check_out
+ FROM ats.max_check_out_local
) * 60
)
+
EXTRACT(
MINUTE
- FROM ats.max_check_out
+ FROM ats.max_check_out_local
)
)
@@ -782,7 +795,7 @@ class AttendanceAnalytics(models.Model):
(
(
- rc.shift_end_time * 60
+ es.shift_end_minutes
)
+
@@ -798,29 +811,20 @@ class AttendanceAnalytics(models.Model):
(
EXTRACT(
HOUR
- FROM ats.min_check_in
+ FROM ats.min_check_in_local
) * 60
)
+
EXTRACT(
MINUTE
- FROM ats.min_check_in
+ FROM ats.min_check_in_local
)
)
-
(
- (
- rc.shift_start_time
- * 60
- )
- +
- COALESCE(
- dg.grace_period,
- rc.late_grace_period,
- 0
- )
+ es.shift_start_minutes
),
0
@@ -842,7 +846,7 @@ class AttendanceAnalytics(models.Model):
WHEN
(
(
- rc.shift_end_time * 60
+ es.shift_end_minutes
)
+
@@ -858,29 +862,20 @@ class AttendanceAnalytics(models.Model):
(
EXTRACT(
HOUR
- FROM ats.min_check_in
+ FROM ats.min_check_in_local
) * 60
)
+
EXTRACT(
MINUTE
- FROM ats.min_check_in
+ FROM ats.min_check_in_local
)
)
-
(
- (
- rc.shift_start_time
- * 60
- )
- +
- COALESCE(
- dg.grace_period,
- rc.late_grace_period,
- 0
- )
+ es.shift_start_minutes
),
0
@@ -897,13 +892,13 @@ class AttendanceAnalytics(models.Model):
(
EXTRACT(
HOUR
- FROM ats.max_check_out
+ FROM ats.max_check_out_local
) * 60
)
+
EXTRACT(
MINUTE
- FROM ats.max_check_out
+ FROM ats.max_check_out_local
)
)
@@ -932,35 +927,30 @@ class AttendanceAnalytics(models.Model):
WHEN ats.min_check_in IS NULL
THEN 'absent'
- WHEN ats.worked_hours < (rc.hours_per_day / 2.0)
+ WHEN ats.worked_hours < 1.0
+ THEN 'invalid_attendance'
+
+ WHEN ats.worked_hours < (es.hours_per_day / 2.0)
THEN 'half_day'
WHEN
(
(
EXTRACT(
- HOUR FROM ats.min_check_in
+ HOUR FROM ats.min_check_in_local
) * 60
)
+
EXTRACT(
MINUTE
- FROM ats.min_check_in
+ FROM ats.min_check_in_local
)
)
>
(
- (
- rc.shift_start_time * 60
- )
- +
- COALESCE(
- dg.grace_period,
- rc.late_grace_period,
- 0
- )
+ es.shift_start_minutes
)
THEN 'late_in'
@@ -970,20 +960,20 @@ class AttendanceAnalytics(models.Model):
(
EXTRACT(
HOUR
- FROM ats.max_check_out
+ FROM ats.max_check_out_local
) * 60
)
+
EXTRACT(
MINUTE
- FROM ats.max_check_out
+ FROM ats.max_check_out_local
)
)
<
(
- rc.shift_end_time * 60
+ es.shift_end_minutes
)
THEN 'early_out'
@@ -1026,6 +1016,36 @@ class AttendanceAnalytics(models.Model):
LEFT JOIN resource_calendar rc
ON rc.id = ed.resource_calendar_id
+ LEFT JOIN calendar_day_schedule cds
+ ON cds.calendar_id = ed.resource_calendar_id
+ AND cds.dayofweek = (
+ (
+ (
+ EXTRACT(DOW FROM ed.date)::integer
+ + 6
+ ) % 7
+ )::text
+ )
+
+ LEFT JOIN LATERAL (
+ SELECT
+ COALESCE(
+ NULLIF(rc.shift_start_time * 60, 0),
+ cds.shift_start_minutes,
+ 0
+ ) AS shift_start_minutes,
+ COALESCE(
+ NULLIF(rc.shift_end_time * 60, 0),
+ cds.shift_end_minutes,
+ 0
+ ) AS shift_end_minutes,
+ COALESCE(
+ NULLIF(rc.hours_per_day, 0),
+ cds.hours_per_day,
+ 0
+ ) AS hours_per_day
+ ) es ON TRUE
+
LEFT JOIN department_grace dg
ON dg.calendar_id
= ed.resource_calendar_id
diff --git a/addons_extensions/grace_period/views/attendance_data.xml b/addons_extensions/grace_period/views/attendance_data.xml
index badbd4118..69314f192 100644
--- a/addons_extensions/grace_period/views/attendance_data.xml
+++ b/addons_extensions/grace_period/views/attendance_data.xml
@@ -7,7 +7,8 @@
attendance.analytics.list
attendance.analytics
-
+
@@ -28,7 +29,7 @@
@@ -146,4 +147,4 @@
action="action_attendance_analytics"
sequence="-51"/>
-
\ No newline at end of file
+
diff --git a/addons_extensions/hr_recruitment_auto_doc/wizard/hr_recruitment_auto_doc_wizard.py b/addons_extensions/hr_recruitment_auto_doc/wizard/hr_recruitment_auto_doc_wizard.py
index 085fe5274..175f49e87 100644
--- a/addons_extensions/hr_recruitment_auto_doc/wizard/hr_recruitment_auto_doc_wizard.py
+++ b/addons_extensions/hr_recruitment_auto_doc/wizard/hr_recruitment_auto_doc_wizard.py
@@ -152,7 +152,10 @@ class HrRecruitmentAutoDocWizard(models.TransientModel):
parsed_data = self._post_process_jd_data(parsed_data, parsed_payload["text"])
else:
parsed_data = self._post_process_resume_data(parsed_data, parsed_payload["text"], line.file_name)
- line.extracted_payload = json.dumps(parsed_data, indent=2, ensure_ascii=False)
+ line.write(dict(
+ line._prepare_editable_vals(parsed_data),
+ extracted_payload=json.dumps(parsed_data, indent=2, ensure_ascii=False),
+ ))
try:
processed += 1
@@ -255,6 +258,7 @@ class HrRecruitmentAutoDocWizard(models.TransientModel):
try:
parsed_data = json.loads(line.extracted_payload)
+ parsed_data = line._get_edited_parsed_data(parsed_data)
with self.env.cr.savepoint():
@@ -642,10 +646,13 @@ class HrRecruitmentAutoDocWizard(models.TransientModel):
data["email"] = email_match.group(1)
phone_matches = re.findall(r"(\+?\d[\d\-\s()]{7,}\d)", extracted_text)
- if phone_matches and not data.get("phone"):
- data["phone"] = phone_matches[0].strip()
- if len(phone_matches) > 1 and not data.get("alternate_phone"):
- data["alternate_phone"] = phone_matches[1].strip()
+ phone_values = []
+ for phone_candidate in [data.get("phone"), data.get("alternate_phone")] + phone_matches:
+ phone_value = self._clean_resume_phone_value(phone_candidate)
+ if phone_value and phone_value not in phone_values:
+ phone_values.append(phone_value)
+ data["phone"] = phone_values[0] if phone_values else False
+ data["alternate_phone"] = phone_values[1] if len(phone_values) > 1 else False
linkedin_match = re.search(r"(https?://(?:www\.)?linkedin\.com/[^\s]+)", extracted_text, re.I)
if linkedin_match:
@@ -654,11 +661,12 @@ class HrRecruitmentAutoDocWizard(models.TransientModel):
if not data.get("total_experience_years"):
data["total_experience_years"] = self._guess_total_experience(extracted_text)
+ resume_skills = self._normalize_resume_skill_values(data.get("skills") or [], extracted_text)
data["skills"] = self.env[
"document.parser.service"
].validate_explicit_skills(
extracted_text,
- data.get("skills") or []
+ resume_skills
)
data["education_history"] = self._normalize_resume_list(data.get("education_history"))
data["employer_history"] = self._normalize_resume_list(data.get("employer_history"))
@@ -1431,8 +1439,57 @@ class HrRecruitmentAutoDocWizard(models.TransientModel):
normalized = re.sub(r"[^\d+]", "", value)
return normalized or False
+ def _clean_resume_phone_value(self, value):
+ if not value:
+ return False
+ raw_value = str(value).strip()
+ if re.fullmatch(r"(?:19|20)\d{2}\s*[-/]\s*(?:19|20)\d{2}", raw_value):
+ return False
+ normalized = self._normalize_phone(raw_value)
+ digits = re.sub(r"\D", "", normalized or "")
+ if len(digits) < 10 or len(digits) > 15:
+ return False
+ return normalized
+
+ def _normalize_resume_skill_values(self, skills, extracted_text):
+ skill_parts = []
+ for skill in skills:
+ for skill_part in self._split_resume_skill_value(skill):
+ normalized = self._normalize_skill_name(skill_part)
+ if normalized and self._is_valid_resume_skill(normalized, extracted_text):
+ skill_parts.append(normalized)
+ normalized_skills = []
+ index = 0
+ while index < len(skill_parts):
+ current_skill = skill_parts[index]
+ next_skill = skill_parts[index + 1] if index + 1 < len(skill_parts) else False
+ combined_skill = "%s %s" % (current_skill, next_skill) if next_skill else False
+ if combined_skill and self._is_valid_resume_skill(combined_skill, extracted_text):
+ normalized_skills.append(combined_skill)
+ index += 2
+ continue
+ normalized_skills.append(current_skill)
+ index += 1
+ return self._deduplicate_skill_names(normalized_skills)
+
+ def _split_resume_skill_value(self, value):
+ value = re.sub(r"^[\s\-*\u2022•]+", "", str(value or "")).strip()
+ if not value:
+ return []
+ if ":" in value:
+ label, value = [item.strip() for item in value.split(":", 1)]
+ if not re.search(r"(programming|frameworks?|databases?|analytics?|cloud|devops|tools?|technologies|skills?)", label, re.I):
+ value = "%s %s" % (label, value)
+ value = re.sub(r"\((?:advanced|intermediate|beginner|expert)\)", "", value, flags=re.I)
+ return [item for item in re.split(r"[,;|/]", value) if item.strip()]
+
def _normalize_skill_name(self, value):
- value = re.sub(r"\s+", " ", (value or "")).strip(" -,:;")
+ value = re.sub(r"^[\s\-*\u2022•]+", "", value or "")
+ value = re.sub(r"\s+", " ", value).strip(" -,:;()")
+ if ":" in value:
+ label, skill_value = [item.strip() for item in value.split(":", 1)]
+ if re.search(r"(programming|frameworks?|databases?|analytics?|cloud|devops|tools?|technologies|skills?)", label, re.I):
+ value = skill_value
value = re.sub(r"^[0-9.)\-(\s]+", "", value).strip()
if not value:
return False
@@ -2034,13 +2091,12 @@ class HrRecruitmentAutoDocWizard(models.TransientModel):
"",
]
for row in rows:
- html_parts.append(
- "- %s: %s
" % (
- escape(row["filename"]),
- row["level"],
- escape(row["message"]),
- )
- )
+ html_parts.append("- %s: %s" % (
+ escape(row["filename"]),
+ row["level"],
+ escape(row["message"]),
+ ))
+ html_parts.append("
")
html_parts.extend(["
", ""])
return "".join(html_parts)
@@ -2072,3 +2128,128 @@ class HrRecruitmentAutoDocWizardLine(models.TransientModel):
extracted_payload = fields.Text(readonly=True)
candidate_id = fields.Many2one("hr.candidate", readonly=True)
applicant_id = fields.Many2one("hr.applicant", readonly=True)
+ target_model = fields.Selection(related="wizard_id.target_model", readonly=True)
+
+ full_name = fields.Char()
+ first_name = fields.Char()
+ last_name = fields.Char()
+ email = fields.Char()
+ phone = fields.Char()
+ alternate_phone = fields.Char()
+ linkedin_profile = fields.Char()
+ current_location = fields.Char()
+ current_organization = fields.Char()
+ total_experience_years = fields.Float()
+ relevant_experience_years = fields.Float()
+ notice_period = fields.Char()
+ degree = fields.Char()
+ skills_text = fields.Text(string="Skills")
+ summary = fields.Text()
+
+ request_id = fields.Char(string="Request ID")
+ start_date = fields.Char()
+ end_date = fields.Char()
+ site_location = fields.Char()
+ job_title = fields.Char()
+ job_summary = fields.Text()
+ requirements = fields.Text()
+ primary_skills_text = fields.Text(string="Primary Skills")
+ secondary_skills_text = fields.Text(string="Secondary Skills")
+ budget = fields.Char()
+ experience_years = fields.Float()
+ job_category = fields.Char()
+
+ def action_save_line_changes(self):
+ self.ensure_one()
+ parsed_data = self._get_edited_parsed_data()
+ self.extracted_payload = json.dumps(parsed_data, indent=2, ensure_ascii=False)
+ return {"type": "ir.actions.act_window_close"}
+
+ def _prepare_editable_vals(self, parsed_data):
+ parsed_data = parsed_data or {}
+ return {
+ "full_name": parsed_data.get("full_name"),
+ "first_name": parsed_data.get("first_name"),
+ "last_name": parsed_data.get("last_name"),
+ "email": parsed_data.get("email"),
+ "phone": parsed_data.get("phone"),
+ "alternate_phone": parsed_data.get("alternate_phone"),
+ "linkedin_profile": parsed_data.get("linkedin_profile"),
+ "current_location": parsed_data.get("current_location"),
+ "current_organization": parsed_data.get("current_organization"),
+ "total_experience_years": self._float_or_zero(parsed_data.get("total_experience_years")),
+ "relevant_experience_years": self._float_or_zero(parsed_data.get("relevant_experience_years")),
+ "notice_period": parsed_data.get("notice_period"),
+ "degree": parsed_data.get("degree"),
+ "skills_text": self._list_to_text(parsed_data.get("skills")),
+ "summary": parsed_data.get("summary"),
+ "request_id": parsed_data.get("request_id"),
+ "start_date": parsed_data.get("start_date"),
+ "end_date": parsed_data.get("end_date"),
+ "site_location": parsed_data.get("site_location"),
+ "job_title": parsed_data.get("job_title"),
+ "job_summary": parsed_data.get("job_summary"),
+ "requirements": parsed_data.get("requirements"),
+ "primary_skills_text": self._list_to_text(parsed_data.get("primary_skills")),
+ "secondary_skills_text": self._list_to_text(parsed_data.get("secondary_skills")),
+ "budget": parsed_data.get("budget"),
+ "experience_years": self._float_or_zero(parsed_data.get("experience_years")),
+ "job_category": parsed_data.get("job_category"),
+ }
+
+ def _get_edited_parsed_data(self, base_data=None):
+ data = dict(base_data or {})
+ if self.target_model == "job_recruitment":
+ data.update({
+ "request_id": self.request_id,
+ "start_date": self.start_date,
+ "end_date": self.end_date,
+ "site_location": self.site_location,
+ "job_title": self.job_title,
+ "job_summary": self.job_summary,
+ "requirements": self.requirements,
+ "primary_skills": self._text_to_list(self.primary_skills_text),
+ "secondary_skills": self._text_to_list(self.secondary_skills_text),
+ "budget": self.budget,
+ "experience_years": self.experience_years,
+ "job_category": self.job_category,
+ })
+ else:
+ data.update({
+ "full_name": self.full_name,
+ "first_name": self.first_name,
+ "last_name": self.last_name,
+ "email": self.email,
+ "phone": self.phone,
+ "alternate_phone": self.alternate_phone,
+ "linkedin_profile": self.linkedin_profile,
+ "current_location": self.current_location,
+ "current_organization": self.current_organization,
+ "total_experience_years": self.total_experience_years,
+ "relevant_experience_years": self.relevant_experience_years,
+ "notice_period": self.notice_period,
+ "degree": self.degree,
+ "skills": self._text_to_list(self.skills_text),
+ "summary": self.summary,
+ })
+ return data
+
+ def _list_to_text(self, value):
+ if not value:
+ return False
+ if isinstance(value, list):
+ return "\n".join(str(item) for item in value if item not in (False, None, ""))
+ return str(value)
+
+ def _text_to_list(self, value):
+ if not value:
+ return []
+ return [item.strip(" -,\t") for item in str(value).splitlines() if item.strip(" -,\t")]
+
+ def _float_or_zero(self, value):
+ if value in (False, None, ""):
+ return 0.0
+ try:
+ return float(value)
+ except Exception:
+ return 0.0
diff --git a/addons_extensions/hr_recruitment_auto_doc/wizard/hr_recruitment_auto_doc_wizard_views.xml b/addons_extensions/hr_recruitment_auto_doc/wizard/hr_recruitment_auto_doc_wizard_views.xml
index 83038e1b5..cf31d36c1 100644
--- a/addons_extensions/hr_recruitment_auto_doc/wizard/hr_recruitment_auto_doc_wizard_views.xml
+++ b/addons_extensions/hr_recruitment_auto_doc/wizard/hr_recruitment_auto_doc_wizard_views.xml
@@ -48,7 +48,89 @@
-
+
+
diff --git a/addons_extensions/roster_management/models/shift_swap.py b/addons_extensions/roster_management/models/shift_swap.py
index 134632d53..c49747af2 100644
--- a/addons_extensions/roster_management/models/shift_swap.py
+++ b/addons_extensions/roster_management/models/shift_swap.py
@@ -34,9 +34,24 @@ class ShiftSwapRequest(models.Model):
store=True
)
+ def _validate_roster_date_not_past(self):
+ today = fields.Date.context_today(self)
+
+ for rec in self:
+ if rec.roster_date and rec.roster_date < today:
+ raise UserError(
+ _("Shift Swap cannot be requested for past dates.")
+ )
+
+ @api.constrains('roster_date')
+ def _check_roster_date_not_past(self):
+ self._validate_roster_date_not_past()
+
def action_submit(self):
for rec in self:
+ rec._validate_roster_date_not_past()
+
if not rec.reason:
raise UserError(
_("Please enter reason.")