From e9b31e167871e6e37b7ac9da192c12b496777797 Mon Sep 17 00:00:00 2001
From: fatmaebrahim
Date: Tue, 5 May 2026 09:47:47 +0300
Subject: [PATCH 1/8] feat: restricted excuse to be of 2 hours only
Co-authored-by: Copilot
---
client/src/components/cards/vacationCard.vue | 2 +-
.../src/components/requests/leaveRequest.vue | 27 +++++++++++++++++--
client/src/views/RequestsView.vue | 2 +-
server/cshr/utils/balance_calculator.py | 7 +++++
4 files changed, 34 insertions(+), 4 deletions(-)
diff --git a/client/src/components/cards/vacationCard.vue b/client/src/components/cards/vacationCard.vue
index a8cf74ef..349207b0 100644
--- a/client/src/components/cards/vacationCard.vue
+++ b/client/src/components/cards/vacationCard.vue
@@ -88,7 +88,7 @@
-
diff --git a/client/src/components/requests/leaveRequest.vue b/client/src/components/requests/leaveRequest.vue
index 5c600a91..b6027d0d 100644
--- a/client/src/components/requests/leaveRequest.vue
+++ b/client/src/components/requests/leaveRequest.vue
@@ -5,6 +5,8 @@
The actual number of vacation days requested is zero. Please note that the selected days may include weekends or
public holidays.
+
+ The vacation request submitted is for a total of 1 excuse.
The vacation request submitted is for a total of {{ vacationDays.state.value }}
{{ vacationDays.state.value <= 1 ? 'day' : 'days' }}.
@@ -47,14 +49,14 @@
@@ -75,6 +77,7 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { ApiClientBase } from '@/clients/api/base'
import { fieldRequired } from '@/utils'
import { debounce } from "lodash";
+import { co } from 'node_modules/@fullcalendar/core/internal-common'
export default {
name: 'leaveRequest',
@@ -222,6 +225,25 @@ export default {
return true
}
+ watch(leaveReason, () => {
+ excuseStartField.value?.validate()
+ excuseEndField.value?.validate()
+ })
+ const validateExcuse = (): string | boolean => {
+ if (leaveReason.value && leaveReason.value.reason === 'excuse') {
+ let endHour = Number(excuseEnd.value.split(':')[0])
+ let startHour = Number(excuseStart.value.split(':')[0])
+
+ let endMinute = Number(excuseEnd.value.split(':')[1])
+ let startMinute = Number(excuseStart.value.split(':')[1])
+
+ let diff = (endHour - startHour) * 60 + (endMinute - startMinute)
+ if (diff > 120) return 'Excuse time cannot exceed 2 hours in a day.'
+ }
+ return true
+ }
+
+
onMounted(async () => {
startDateField.value.validate()
@@ -301,6 +323,7 @@ export default {
createLeave,
validateDates,
validateTimes,
+ validateExcuse,
useOldBalance,
balance,
couldApplyUsingOldBalance,
diff --git a/client/src/views/RequestsView.vue b/client/src/views/RequestsView.vue
index 1382dd0c..8656a4b1 100644
--- a/client/src/views/RequestsView.vue
+++ b/client/src/views/RequestsView.vue
@@ -86,7 +86,7 @@
Reason: {{
formatVacationReason(request.reason) }}
-
+
Actual Days: {{ request.actual_days || 'N/A' }}
diff --git a/server/cshr/utils/balance_calculator.py b/server/cshr/utils/balance_calculator.py
index 98165136..b597e92c 100644
--- a/server/cshr/utils/balance_calculator.py
+++ b/server/cshr/utils/balance_calculator.py
@@ -96,6 +96,10 @@ def calculate_times(self) -> float:
Calculate the fraction of a day based on vacation duration.
"""
duration_hours = self.vacation.end_date.hour - self.vacation.from_date.hour
+ if self.vacation.reason == "excuse":
+ if duration_hours <= 2:
+ return 1.0
+ raise ValueError("Excuse time cannot exceed 2 hours in a day.")
if duration_hours > 4:
return 1.0
if 2 < duration_hours <= 4:
@@ -118,6 +122,9 @@ def get_vacation_days(
if self.vacation.from_date > self.vacation.end_date:
raise ValueError("The start date cannot be after the end date.")
+ if self.vacation.reason == "excuse" and self.vacation.from_date.date() != self.vacation.end_date.date():
+ raise ValueError("Excuse must be within a single day.")
+
if self.vacation.from_date.date() == self.vacation.end_date.date():
if self._is_weekend() or self._is_holiday():
self.vacation_days = 0
From 4824d9fbb38d0d6e658c3d038a3e90347578b394 Mon Sep 17 00:00:00 2001
From: fatmaebrahim
Date: Tue, 5 May 2026 10:04:45 +0300
Subject: [PATCH 2/8] feat: changed excuses default balance to 12
---
server/cshr/models/vacations.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/server/cshr/models/vacations.py b/server/cshr/models/vacations.py
index 7dce7055..00073d94 100644
--- a/server/cshr/models/vacations.py
+++ b/server/cshr/models/vacations.py
@@ -71,7 +71,7 @@ class VacationBalanceModel(TimeStamp):
unpaid = models.FloatField(default=365)
annual = models.FloatField(default=15)
emergency = models.FloatField(default=6)
- excuse = models.FloatField(default=6)
+ excuse = models.FloatField(default=12)
is_locked = models.BooleanField(default=False)
def __str__(self):
From 6d5f05db3c5f858895c871406315f497740cde5c Mon Sep 17 00:00:00 2001
From: fatmaebrahim
Date: Tue, 5 May 2026 10:50:44 +0300
Subject: [PATCH 3/8] feat: added migration to update old users balance
---
.../migrations/0036_update_excuse_balance.py | 41 +++++++++++++++++++
1 file changed, 41 insertions(+)
create mode 100644 server/cshr/migrations/0036_update_excuse_balance.py
diff --git a/server/cshr/migrations/0036_update_excuse_balance.py b/server/cshr/migrations/0036_update_excuse_balance.py
new file mode 100644
index 00000000..45b84b87
--- /dev/null
+++ b/server/cshr/migrations/0036_update_excuse_balance.py
@@ -0,0 +1,41 @@
+from django.db import migrations, models
+
+
+def update_excuse_balance(apps, schema_editor):
+ UserVacationBalance = apps.get_model("cshr", "UserVacationBalance")
+
+ for user_balance in UserVacationBalance.objects.select_related("total_days", "remaining_days").all():
+ if user_balance.total_days:
+ user_balance.total_days.excuse = 12
+ user_balance.total_days.save(update_fields=["excuse"])
+ if user_balance.remaining_days:
+ user_balance.remaining_days.excuse = min(user_balance.remaining_days.excuse * 4, 12)
+ user_balance.remaining_days.save(update_fields=["excuse"])
+
+
+def reverse_excuse_balance(apps, schema_editor):
+ UserVacationBalance = apps.get_model("cshr", "UserVacationBalance")
+
+ for user_balance in UserVacationBalance.objects.select_related("total_days", "remaining_days").all():
+ if user_balance.total_days:
+ user_balance.total_days.excuse = 6
+ user_balance.total_days.save(update_fields=["excuse"])
+ if user_balance.remaining_days:
+ user_balance.remaining_days.excuse = user_balance.remaining_days.excuse / 4
+ user_balance.remaining_days.save(update_fields=["excuse"])
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("cshr", "0035_alter_uservacationbalance_year"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="vacationbalancemodel",
+ name="excuse",
+ field=models.FloatField(default=12),
+ ),
+ migrations.RunPython(update_excuse_balance, reverse_excuse_balance),
+ ]
From 748314eb5e6ebd6be35a7459ecf529ba1520ce4e Mon Sep 17 00:00:00 2001
From: fatmaebrahim
Date: Tue, 5 May 2026 12:23:56 +0300
Subject: [PATCH 4/8] feat: restricted excuse to be 1 per day
Co-authored-by: Copilot
---
server/cshr/utils/balance_calculator.py | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/server/cshr/utils/balance_calculator.py b/server/cshr/utils/balance_calculator.py
index b597e92c..46fda356 100644
--- a/server/cshr/utils/balance_calculator.py
+++ b/server/cshr/utils/balance_calculator.py
@@ -153,6 +153,11 @@ def check_requests_in_selected_dates(self, is_update: bool = False):
if is_update:
requests = requests.exclude(id=self.vacation.id)
+ if self.vacation.reason == "excuse":
+ existing_excuses = requests.filter(reason="excuse", from_date__date=self.vacation.from_date.date())
+ if existing_excuses.exists():
+ raise ValueError("There is already an excuse on the selected date.")
+
if requests.exists():
grammar = "is a request" if len(requests) == 1 else "are requests"
raise ValueError(f"There {grammar} on the selected dates.")
From b53784731589568431f2ce541228424754ec1253 Mon Sep 17 00:00:00 2001
From: fatmaebrahim
Date: Tue, 5 May 2026 14:26:59 +0300
Subject: [PATCH 5/8] test: added unit tests to server
Co-authored-by: Copilot
---
server/cshr/tests/test_vacation.py | 234 +++++++++++++++++++-----
server/cshr/utils/balance_calculator.py | 78 +++++---
2 files changed, 241 insertions(+), 71 deletions(-)
diff --git a/server/cshr/tests/test_vacation.py b/server/cshr/tests/test_vacation.py
index 2cb8a777..96dfdd5d 100644
--- a/server/cshr/tests/test_vacation.py
+++ b/server/cshr/tests/test_vacation.py
@@ -23,6 +23,7 @@ def setUp(self):
location=office,
team="Development",
user_type="Admin",
+ joining_at="2022-08-23",
)
User.objects.create(
@@ -36,6 +37,7 @@ def setUp(self):
location=office,
team="Development",
user_type="User",
+ joining_at="2022-08-23",
)
User.objects.create(
@@ -49,8 +51,18 @@ def setUp(self):
location=office,
team="Development",
user_type="Supervisor",
+ joining_at="2022-08-23",
)
+ admin = User.objects.get(email="ahmed@gmail.com")
+ user = User.objects.get(email="andrew@gmail.com")
+ supervisor = User.objects.get(email="helmy@gmail.com")
+ user.reporting_to.add(supervisor)
+ user.reporting_to.add(admin)
+
+ self.admin = admin
+ self.user = user
+ self.supervisor = supervisor
self.access_token_admin = self.get_token_admin()
self.access_token_user = self.get_token_user()
self.access_token_supervisor = self.get_token_supervisor()
@@ -79,7 +91,7 @@ def get_token_supervisor(self):
def test_create_vacation(self) -> Vacation:
url = "/api/vacations/"
data = {
- "reason": "annual_leaves",
+ "reason": "annual",
"from_date": "2022-08-23",
"end_date": "2022-08-23",
"change_log": [],
@@ -106,7 +118,7 @@ def test_create_vacation_with_invalid_reason(self) -> Vacation:
def test_create_vacation_with_no_end_date(self) -> Vacation:
url = "/api/vacations/"
- data = {"reason": "annual_leaves", "from_date": "2022-08-23", "change_log": 123}
+ data = {"reason": "annual", "from_date": "2022-08-23", "change_log": 123}
self.headers = client.credentials(
HTTP_AUTHORIZATION="Bearer " + self.access_token_user
)
@@ -118,11 +130,11 @@ def test_create_vacation_with_no_from_date(self) -> Vacation:
data = {
"applying_user": 1,
"end_date": "2022-10-14",
- "reason": "sick_leaves",
+ "reason": "sick",
"status": "pending",
"type": "vacations",
}
- # data = {"reason": "annual_leaves", "end_date": "2022-08-23", "change_log": 123}
+ # data = {"reason": "annual", "end_date": "2022-08-23", "change_log": 123}
self.headers = client.credentials(
HTTP_AUTHORIZATION="Bearer " + self.access_token_user
)
@@ -134,7 +146,7 @@ def test_get_certain_vacation_request(self) -> Vacation:
"""add vacation"""
url = "/api/vacations/"
data = {
- "reason": "annual_leaves",
+ "reason": "annual",
"from_date": "2022-08-23",
"end_date": "2022-08-23",
"change_log": [],
@@ -153,7 +165,7 @@ def test_get_invalid_vacation_request(self) -> Vacation:
"""add vacation"""
url = "/api/vacations/"
data = {
- "reason": "annual_leaves",
+ "reason": "annual",
"from_date": "2022-08-23",
"end_date": "2022-08-23",
"change_log": [],
@@ -167,12 +179,11 @@ def test_get_invalid_vacation_request(self) -> Vacation:
response = client.get(url, format="json")
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
- def test_delete_certain_vacation_request(self) -> Vacation:
- """test to delete a valid vacation request"""
- """add vacation request"""
+ def test_cancel_certain_vacation_request(self) -> Vacation:
+ """test to cancel a valid vacation request"""
url = "/api/vacations/"
data = {
- "reason": "annual_leaves",
+ "reason": "annual",
"from_date": "2022-08-23",
"end_date": "2022-08-23",
"change_log": [],
@@ -182,27 +193,18 @@ def test_delete_certain_vacation_request(self) -> Vacation:
)
response = client.post(url, data, format="json")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
- url = "/api/vacations/1/"
- response = client.delete(url, format="json")
- self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
+ vacation_id = response.data["results"]["id"]
+ url = f"/api/vacations/cancel/{vacation_id}/"
+ response = client.put(url, format="json")
+ self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
- def test_delete_invalid_vacation_request(self) -> Vacation:
- """test to delete a invalid vacation request"""
- """add vacation request"""
- url = "/api/vacations/"
- data = {
- "reason": "annual_leaves",
- "from_date": "2022-08-23",
- "end_date": "2022-08-23",
- "change_log": [],
- }
+ def test_cancel_invalid_vacation_request(self) -> Vacation:
+ """test to cancel an invalid vacation request"""
self.headers = client.credentials(
HTTP_AUTHORIZATION="Bearer " + self.access_token_user
)
- response = client.post(url, data, format="json")
- self.assertEqual(response.status_code, status.HTTP_201_CREATED)
- url = "/api/vacations/10/"
- response = client.delete(url, format="json")
+ url = "/api/vacations/cancel/9999/"
+ response = client.put(url, format="json")
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_get_all_vacation_requests(self) -> Vacation:
@@ -218,7 +220,7 @@ def test_update_vacation_request_invalid_user_id(self) -> Vacation:
"""add vacation request"""
url = "/api/vacations/"
data = {
- "reason": "annual_leaves",
+ "reason": "annual",
"from_date": "2022-08-23",
"end_date": "2022-08-23",
"change_log": [],
@@ -237,7 +239,7 @@ def test_user_updates_status(self) -> Vacation:
"""user trying to update status"""
url = "/api/vacations/"
data = {
- "reason": "annual_leaves",
+ "reason": "annual",
"from_date": "2022-08-23",
"end_date": "2022-08-23",
"change_log": [],
@@ -250,13 +252,13 @@ def test_user_updates_status(self) -> Vacation:
url = "/api/vacations/edit/1/"
data = {"status": "Approved"}
response = client.put(url, data, format="json")
- self.assertEqual(response.status_code, 403)
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_get_vacations_for_user_with_data(self) -> Vacation:
"""add vacation request"""
url = "/api/vacations/"
data = {
- "reason": "annual_leaves",
+ "reason": "annual",
"from_date": "2022-08-23",
"end_date": "2022-08-23",
"change_log": [],
@@ -266,7 +268,7 @@ def test_get_vacations_for_user_with_data(self) -> Vacation:
)
response = client.post(url, data, format="json")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
- url = "/api/vacations/user/"
+ url = f"/api/vacations/user/?user_id={self.user.id}"
response = client.get(url, format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -274,7 +276,7 @@ def test_get_vacations_for_user_without_data(self) -> Vacation:
self.headers = client.credentials(
HTTP_AUTHORIZATION="Bearer " + self.access_token_user
)
- url = "/api/vacations/user/"
+ url = f"/api/vacations/user/?user_id={self.user.id}"
response = client.get(url, format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -282,7 +284,7 @@ def test_accept_vacation_for_unauthorized_user(self) -> Vacation:
"""add vacation request"""
url = "/api/vacations/"
data = {
- "reason": "annual_leaves",
+ "reason": "annual",
"from_date": "2022-08-23",
"end_date": "2022-08-23",
"change_log": [],
@@ -300,7 +302,7 @@ def test_reject_vacation_for_unauthorized_user(self) -> Vacation:
"""add vacation request"""
url = "/api/vacations/"
data = {
- "reason": "annual_leaves",
+ "reason": "annual",
"from_date": "2022-08-23",
"end_date": "2022-08-23",
"change_log": [],
@@ -315,38 +317,42 @@ def test_reject_vacation_for_unauthorized_user(self) -> Vacation:
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_accept_vacation_for_supervisor_auth(self) -> Vacation:
- """add vacation request"""
+ """supervisor approves user's vacation request"""
url = "/api/vacations/"
data = {
- "reason": "annual_leaves",
+ "reason": "annual",
"from_date": "2022-08-23",
"end_date": "2022-08-23",
"change_log": [],
}
self.headers = client.credentials(
- HTTP_AUTHORIZATION="Bearer " + self.access_token_supervisor
+ HTTP_AUTHORIZATION="Bearer " + self.access_token_user
)
response = client.post(url, data, format="json")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
- url = "/api/vacations/approve/1/"
+ vacation_id = response.data["results"]["id"]
+ client.credentials(HTTP_AUTHORIZATION="Bearer " + self.access_token_supervisor)
+ url = f"/api/vacations/approve/{vacation_id}/"
response = client.put(url, format="json")
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
def test_reject_vacation_for_supervisor_auth(self) -> Vacation:
- """add vacation request"""
+ """supervisor rejects user's vacation request"""
url = "/api/vacations/"
data = {
- "reason": "annual_leaves",
+ "reason": "annual",
"from_date": "2022-08-23",
"end_date": "2022-08-23",
"change_log": [],
}
self.headers = client.credentials(
- HTTP_AUTHORIZATION="Bearer " + self.access_token_supervisor
+ HTTP_AUTHORIZATION="Bearer " + self.access_token_user
)
response = client.post(url, data, format="json")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
- url = "/api/vacations/reject/1/"
+ vacation_id = response.data["results"]["id"]
+ client.credentials(HTTP_AUTHORIZATION="Bearer " + self.access_token_supervisor)
+ url = f"/api/vacations/reject/{vacation_id}/"
response = client.put(url, format="json")
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
@@ -354,7 +360,7 @@ def test_reject_invalid_vacation(self) -> Vacation:
"""add vacation request"""
url = "/api/vacations/"
data = {
- "reason": "annual_leaves",
+ "reason": "annual",
"from_date": "2022-08-23",
"end_date": "2022-08-23",
"change_log": [],
@@ -372,7 +378,7 @@ def test_accept_invalid_vacation(self) -> Vacation:
"""add vacation request"""
url = "/api/vacations/"
data = {
- "reason": "annual_leaves",
+ "reason": "annual",
"from_date": "2022-08-23",
"end_date": "2022-08-23",
"change_log": [],
@@ -385,3 +391,141 @@ def test_accept_invalid_vacation(self) -> Vacation:
url = "/api/vacations/approve/-1/"
response = client.put(url, format="json")
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+ def test_add_valid_excuse(self) -> Vacation:
+ url = "/api/vacations/"
+ data = {
+ "reason": "excuse",
+ "from_date": "2022-08-23:08:00:00",
+ "end_date": "2022-08-23:10:00:00",
+ "change_log": [],
+ }
+ self.headers = client.credentials(
+ HTTP_AUTHORIZATION="Bearer " + self.access_token_user
+ )
+ response = client.post(url, data, format="json")
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+ def test_add_invalid_excuse(self) -> Vacation:
+ url = "/api/vacations/"
+ data = {
+ "reason": "excuse",
+ "from_date": "2022-08-23:08:00:00",
+ "end_date": "2022-08-23:11:00:00",
+ "change_log": [],
+ }
+ self.headers = client.credentials(
+ HTTP_AUTHORIZATION="Bearer " + self.access_token_user
+ )
+ response = client.post(url, data, format="json")
+ self.assertEqual(
+ response.data.get("message"), "Excuse time cannot exceed 2 hours in a day."
+ )
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+ def test_add_two_excuses_in_same_day(self) -> Vacation:
+ url = "/api/vacations/"
+ data = {
+ "reason": "excuse",
+ "from_date": "2022-08-23:08:00:00",
+ "end_date": "2022-08-23:10:00:00",
+ "change_log": [],
+ }
+ self.headers = client.credentials(
+ HTTP_AUTHORIZATION="Bearer " + self.access_token_user
+ )
+ response = client.post(url, data, format="json")
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+ data = {
+ "reason": "excuse",
+ "from_date": "2022-08-23:11:00:00",
+ "end_date": "2022-08-23:13:00:00",
+ "change_log": [],
+ }
+ self.headers = client.credentials(
+ HTTP_AUTHORIZATION="Bearer " + self.access_token_user
+ )
+ response = client.post(url, data, format="json")
+ self.assertEqual(
+ response.data.get("message"),
+ "There is already an excuse on the selected date.",
+ )
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+ def test_excuse_balance_on_approve(self) -> Vacation:
+ url = "/api/vacations/"
+ data = {
+ "reason": "excuse",
+ "from_date": "2022-08-23:08:00:00",
+ "end_date": "2022-08-23:10:00:00",
+ "change_log": [],
+ }
+ self.headers = client.credentials(
+ HTTP_AUTHORIZATION="Bearer " + self.access_token_user
+ )
+ response = client.post(url, data, format="json")
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+ vacation_id = response.data["results"]["id"]
+ client.credentials(HTTP_AUTHORIZATION="Bearer " + self.access_token_supervisor)
+ url = f"/api/vacations/approve/{vacation_id}/"
+ response = client.put(url, format="json")
+ actual_days = response.data["results"]["actual_days"]
+ self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
+ self.assertEqual(actual_days, 1.0)
+
+ url = f"/api/vacations/balance/{self.user.id}/?year=2022"
+ response = client.get(url, format="json")
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ remaining_excuse = response.data["results"]["remaining_days"]["excuse"]
+ self.assertEqual(remaining_excuse, 11.0)
+
+ def test_update_annual_to_excuse_with_valid_duration(self) -> Vacation:
+ url = "/api/vacations/"
+ data = {
+ "reason": "annual",
+ "from_date": "2022-08-23:08:00:00",
+ "end_date": "2022-08-23:10:00:00",
+ "change_log": [],
+ }
+ self.headers = client.credentials(
+ HTTP_AUTHORIZATION="Bearer " + self.access_token_user
+ )
+ response = client.post(url, data, format="json")
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+ vacation_id = response.data["results"]["id"]
+ url = f"/api/vacations/edit/{vacation_id}/"
+ data = {
+ "reason": "excuse",
+ "from_date": "2022-08-23:08:00:00",
+ "end_date": "2022-08-23:10:00:00",
+ }
+ response = client.put(url, data, format="json")
+ self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
+ updated_reason = response.data["results"]["reason"]
+ self.assertEqual(updated_reason, "excuse")
+
+ def test_update_annual_to_excuse_with_invalid_duration(self) -> Vacation:
+ url = "/api/vacations/"
+ data = {
+ "reason": "annual",
+ "from_date": "2022-08-23:08:00:00",
+ "end_date": "2022-08-23:12:00:00",
+ "change_log": [],
+ }
+ self.headers = client.credentials(
+ HTTP_AUTHORIZATION="Bearer " + self.access_token_user
+ )
+ response = client.post(url, data, format="json")
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+ vacation_id = response.data["results"]["id"]
+ url = f"/api/vacations/edit/{vacation_id}/"
+ data = {
+ "reason": "excuse",
+ "from_date": "2022-08-23:08:00:00",
+ "end_date": "2022-08-23:12:00:00",
+ }
+ response = client.put(url, data, format="json")
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
diff --git a/server/cshr/utils/balance_calculator.py b/server/cshr/utils/balance_calculator.py
index 46fda356..c71436d1 100644
--- a/server/cshr/utils/balance_calculator.py
+++ b/server/cshr/utils/balance_calculator.py
@@ -60,9 +60,8 @@ def check_user_balance(self):
year = self.vacation.from_date.year
transferred_days = (
- UserVacationBalance.objects.filter(
- user=self.applying_user, year=year - 1
- ).select_related("remaining_days")
+ UserVacationBalance.objects.filter(user=self.applying_user, year=year - 1)
+ .select_related("remaining_days")
.first()
)
@@ -77,11 +76,13 @@ def check_user_balance(self):
year=year,
defaults={
"total_days": VacationBalanceModel.objects.create(),
- "remaining_days": VacationBalanceModel.objects.create(compensation=0, paternity=0, maternity=0),
+ "remaining_days": VacationBalanceModel.objects.create(
+ compensation=0, paternity=0, maternity=0
+ ),
"transferred_days": transferred_days,
},
)
-
+
# Refresh from database to ensure we have the latest data
user_balance.refresh_from_db()
if user_balance.remaining_days:
@@ -122,7 +123,10 @@ def get_vacation_days(
if self.vacation.from_date > self.vacation.end_date:
raise ValueError("The start date cannot be after the end date.")
- if self.vacation.reason == "excuse" and self.vacation.from_date.date() != self.vacation.end_date.date():
+ if (
+ self.vacation.reason == "excuse"
+ and self.vacation.from_date.date() != self.vacation.end_date.date()
+ ):
raise ValueError("Excuse must be within a single day.")
if self.vacation.from_date.date() == self.vacation.end_date.date():
@@ -144,22 +148,29 @@ def check_requests_in_selected_dates(self, is_update: bool = False):
"""
Validate if there are conflicting vacation requests for the selected dates.
"""
- requests = Vacation.objects.filter(
+ if self.vacation.reason == "excuse":
+ existing_excuses = Vacation.objects.filter(
+ applying_user=self.applying_user,
+ reason="excuse",
+ from_date__date=self.vacation.from_date.date(),
+ end_date__date=self.vacation.end_date.date(),
+ ).exclude(status__in=[STATUS_CHOICES.CANCELED, STATUS_CHOICES.REJECTED])
+ if existing_excuses.exists():
+ raise ValueError("There is already an excuse on the selected date.")
+
+ conflicting_requests = Vacation.objects.filter(
from_date__lte=self.vacation.end_date,
end_date__gte=self.vacation.from_date,
applying_user=self.applying_user,
).exclude(status__in=[STATUS_CHOICES.CANCELED, STATUS_CHOICES.REJECTED])
if is_update:
- requests = requests.exclude(id=self.vacation.id)
+ conflicting_requests = conflicting_requests.exclude(id=self.vacation.id)
- if self.vacation.reason == "excuse":
- existing_excuses = requests.filter(reason="excuse", from_date__date=self.vacation.from_date.date())
- if existing_excuses.exists():
- raise ValueError("There is already an excuse on the selected date.")
-
- if requests.exists():
- grammar = "is a request" if len(requests) == 1 else "are requests"
+ if conflicting_requests.exists():
+ grammar = (
+ "is a request" if len(conflicting_requests) == 1 else "are requests"
+ )
raise ValueError(f"There {grammar} on the selected dates.")
@validate_applying_user
@@ -179,9 +190,8 @@ def check_locked_balance(self, is_update: bool = False):
pending_vacations = pending_vacations.filter(
Q(
from_date__year=datetime.now().year,
- ) | Q(
- end_date__year=datetime.now().year
)
+ | Q(end_date__year=datetime.now().year)
)
if is_update:
@@ -191,9 +201,13 @@ def check_locked_balance(self, is_update: bool = False):
reason_days = 0
if self.vacation.is_old_balance:
- reason_days = getattr(self.user_balance.transferred_days, self.vacation.reason)
+ reason_days = getattr(
+ self.user_balance.transferred_days, self.vacation.reason
+ )
else:
- reason_days = getattr(self.user_balance.remaining_days, self.vacation.reason)
+ reason_days = getattr(
+ self.user_balance.remaining_days, self.vacation.reason
+ )
total_days = self.get_vacation_days()
@@ -213,33 +227,45 @@ def adjust_balance(self, increase: bool = False) -> Optional[float]:
self.user_balance.remaining_days.refresh_from_db()
if self.user_balance.transferred_days:
self.user_balance.transferred_days.refresh_from_db()
-
+
if increase:
total_days = self.vacation.actual_days
else:
total_days = self.get_vacation_days()
-
+
reason_days = 0
try:
if self.vacation.is_old_balance:
if self.user_balance.transferred_days.is_locked:
- raise ValueError("Transferred balance is locked and cannot be adjusted.")
+ raise ValueError(
+ "Transferred balance is locked and cannot be adjusted."
+ )
reason_days = getattr(
self.user_balance.transferred_days, self.vacation.reason
)
else:
- reason_days = getattr(self.user_balance.remaining_days, self.vacation.reason)
+ reason_days = getattr(
+ self.user_balance.remaining_days, self.vacation.reason
+ )
adjustment = total_days if not increase else -total_days
new_balance = reason_days - adjustment
if new_balance < 0:
- raise ValueError("Insufficient balance for the requested vacation days.")
+ raise ValueError(
+ "Insufficient balance for the requested vacation days."
+ )
if self.vacation.is_old_balance:
- setattr(self.user_balance.transferred_days, self.vacation.reason, new_balance)
+ setattr(
+ self.user_balance.transferred_days,
+ self.vacation.reason,
+ new_balance,
+ )
self.user_balance.transferred_days.save()
else:
- setattr(self.user_balance.remaining_days, self.vacation.reason, new_balance)
+ setattr(
+ self.user_balance.remaining_days, self.vacation.reason, new_balance
+ )
self.user_balance.remaining_days.save()
self.user_balance.save()
return new_balance
From 156a0df4f8f069e69db78cbaf2ebb5c751c8f2c2 Mon Sep 17 00:00:00 2001
From: fatmaebrahim
Date: Tue, 5 May 2026 14:30:28 +0300
Subject: [PATCH 6/8] fix: fixed ci build error
---
client/src/components/requests/leaveRequest.vue | 1 -
1 file changed, 1 deletion(-)
diff --git a/client/src/components/requests/leaveRequest.vue b/client/src/components/requests/leaveRequest.vue
index b6027d0d..7bf91108 100644
--- a/client/src/components/requests/leaveRequest.vue
+++ b/client/src/components/requests/leaveRequest.vue
@@ -77,7 +77,6 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { ApiClientBase } from '@/clients/api/base'
import { fieldRequired } from '@/utils'
import { debounce } from "lodash";
-import { co } from 'node_modules/@fullcalendar/core/internal-common'
export default {
name: 'leaveRequest',
From 387fb52b7fc5a5cdb2ab3b956c9b543087ab99d2 Mon Sep 17 00:00:00 2001
From: fatmaebrahim
Date: Wed, 6 May 2026 10:10:04 +0300
Subject: [PATCH 7/8] fix: changed migration to update only the current year
balance
---
server/cshr/migrations/0036_update_excuse_balance.py | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/server/cshr/migrations/0036_update_excuse_balance.py b/server/cshr/migrations/0036_update_excuse_balance.py
index 45b84b87..daccc756 100644
--- a/server/cshr/migrations/0036_update_excuse_balance.py
+++ b/server/cshr/migrations/0036_update_excuse_balance.py
@@ -1,10 +1,12 @@
from django.db import migrations, models
+from django.utils import timezone
def update_excuse_balance(apps, schema_editor):
UserVacationBalance = apps.get_model("cshr", "UserVacationBalance")
+ current_year = timezone.now().year
- for user_balance in UserVacationBalance.objects.select_related("total_days", "remaining_days").all():
+ for user_balance in UserVacationBalance.objects.filter(year=current_year).select_related("total_days", "remaining_days"):
if user_balance.total_days:
user_balance.total_days.excuse = 12
user_balance.total_days.save(update_fields=["excuse"])
@@ -15,8 +17,9 @@ def update_excuse_balance(apps, schema_editor):
def reverse_excuse_balance(apps, schema_editor):
UserVacationBalance = apps.get_model("cshr", "UserVacationBalance")
+ current_year = timezone.now().year
- for user_balance in UserVacationBalance.objects.select_related("total_days", "remaining_days").all():
+ for user_balance in UserVacationBalance.objects.filter(year=current_year).select_related("total_days", "remaining_days"):
if user_balance.total_days:
user_balance.total_days.excuse = 6
user_balance.total_days.save(update_fields=["excuse"])
From abb1d56647b497b936931b8ac2b51152d48c514a Mon Sep 17 00:00:00 2001
From: fatmaebrahim
Date: Wed, 6 May 2026 14:50:58 +0300
Subject: [PATCH 8/8] feat: restricted non excuse leaves not to be 0.25 day
Co-authored-by: Copilot
---
.../src/components/requests/leaveRequest.vue | 38 +++++-----
server/cshr/tests/test_vacation.py | 76 ++++++++++++-------
server/cshr/utils/balance_calculator.py | 12 +++
server/cshr/views/vacations.py | 3 +
4 files changed, 82 insertions(+), 47 deletions(-)
diff --git a/client/src/components/requests/leaveRequest.vue b/client/src/components/requests/leaveRequest.vue
index 7bf91108..370ba944 100644
--- a/client/src/components/requests/leaveRequest.vue
+++ b/client/src/components/requests/leaveRequest.vue
@@ -5,7 +5,7 @@
The actual number of vacation days requested is zero. Please note that the selected days may include weekends or
public holidays.
-
+
The vacation request submitted is for a total of 1 excuse.
The vacation request submitted is for a total of {{ vacationDays.state.value }}
@@ -49,15 +49,15 @@
+ label="Vacation Start Time" v-model="excuseStart" hide-details="auto" type="time"
+ :rules="[validateTimes, validateLeave,]" :readonly="startDate !== endDate">
+ label="Vacation End Time" v-model="excuseEnd" hide-details="auto" type="time"
+ :rules="[validateTimes, validateLeave,]" :readonly="startDate !== endDate">
@@ -224,26 +224,28 @@ export default {
return true
}
- watch(leaveReason, () => {
- excuseStartField.value?.validate()
- excuseEndField.value?.validate()
- })
- const validateExcuse = (): string | boolean => {
- if (leaveReason.value && leaveReason.value.reason === 'excuse') {
- let endHour = Number(excuseEnd.value.split(':')[0])
- let startHour = Number(excuseStart.value.split(':')[0])
+ watch(leaveReason, () => {
+ excuseStartField.value?.validate()
+ excuseEndField.value?.validate()
+ })
+ const validateLeave = (): string | boolean => {
+ let endHour = Number(excuseEnd.value.split(':')[0])
+ let startHour = Number(excuseStart.value.split(':')[0])
- let endMinute = Number(excuseEnd.value.split(':')[1])
- let startMinute = Number(excuseStart.value.split(':')[1])
+ let endMinute = Number(excuseEnd.value.split(':')[1])
+ let startMinute = Number(excuseStart.value.split(':')[1])
- let diff = (endHour - startHour) * 60 + (endMinute - startMinute)
+ let diff = (endHour - startHour) * 60 + (endMinute - startMinute)
+
+ if (leaveReason.value && leaveReason.value.reason === 'excuse') {
if (diff > 120) return 'Excuse time cannot exceed 2 hours in a day.'
+ } else {
+ if (diff < 180) return 'Vacation duration cannot be less than 0.5 days.'
}
return true
}
-
onMounted(async () => {
startDateField.value.validate()
endDateField.value.validate()
@@ -322,7 +324,7 @@ export default {
createLeave,
validateDates,
validateTimes,
- validateExcuse,
+ validateLeave,
useOldBalance,
balance,
couldApplyUsingOldBalance,
diff --git a/server/cshr/tests/test_vacation.py b/server/cshr/tests/test_vacation.py
index 96dfdd5d..5f588fc2 100644
--- a/server/cshr/tests/test_vacation.py
+++ b/server/cshr/tests/test_vacation.py
@@ -92,8 +92,8 @@ def test_create_vacation(self) -> Vacation:
url = "/api/vacations/"
data = {
"reason": "annual",
- "from_date": "2022-08-23",
- "end_date": "2022-08-23",
+ "from_date": "2022-08-23:08:00:00",
+ "end_date": "2022-08-23:17:00:00",
"change_log": [],
}
self.headers = client.credentials(
@@ -106,8 +106,8 @@ def test_create_vacation_with_invalid_reason(self) -> Vacation:
url = "/api/vacations/"
data = {
"reason": "invalid",
- "from_date": "2022-08-23",
- "end_date": "2022-08-23",
+ "from_date": "2022-08-23:08:00:00",
+ "end_date": "2022-08-23:17:00:00",
"change_log": [],
}
self.headers = client.credentials(
@@ -147,8 +147,8 @@ def test_get_certain_vacation_request(self) -> Vacation:
url = "/api/vacations/"
data = {
"reason": "annual",
- "from_date": "2022-08-23",
- "end_date": "2022-08-23",
+ "from_date": "2022-08-23:08:00:00",
+ "end_date": "2022-08-23:17:00:00",
"change_log": [],
}
self.headers = client.credentials(
@@ -166,8 +166,8 @@ def test_get_invalid_vacation_request(self) -> Vacation:
url = "/api/vacations/"
data = {
"reason": "annual",
- "from_date": "2022-08-23",
- "end_date": "2022-08-23",
+ "from_date": "2022-08-23:08:00:00",
+ "end_date": "2022-08-23:17:00:00",
"change_log": [],
}
self.headers = client.credentials(
@@ -184,8 +184,8 @@ def test_cancel_certain_vacation_request(self) -> Vacation:
url = "/api/vacations/"
data = {
"reason": "annual",
- "from_date": "2022-08-23",
- "end_date": "2022-08-23",
+ "from_date": "2022-08-23:08:00:00",
+ "end_date": "2022-08-23:17:00:00",
"change_log": [],
}
self.headers = client.credentials(
@@ -221,8 +221,8 @@ def test_update_vacation_request_invalid_user_id(self) -> Vacation:
url = "/api/vacations/"
data = {
"reason": "annual",
- "from_date": "2022-08-23",
- "end_date": "2022-08-23",
+ "from_date": "2022-08-23:08:00:00",
+ "end_date": "2022-08-23:17:00:00",
"change_log": [],
}
self.headers = client.credentials(
@@ -240,8 +240,8 @@ def test_user_updates_status(self) -> Vacation:
url = "/api/vacations/"
data = {
"reason": "annual",
- "from_date": "2022-08-23",
- "end_date": "2022-08-23",
+ "from_date": "2022-08-23:08:00:00",
+ "end_date": "2022-08-23:17:00:00",
"change_log": [],
}
self.headers = client.credentials(
@@ -259,8 +259,8 @@ def test_get_vacations_for_user_with_data(self) -> Vacation:
url = "/api/vacations/"
data = {
"reason": "annual",
- "from_date": "2022-08-23",
- "end_date": "2022-08-23",
+ "from_date": "2022-08-23:08:00:00",
+ "end_date": "2022-08-23:17:00:00",
"change_log": [],
}
self.headers = client.credentials(
@@ -285,8 +285,8 @@ def test_accept_vacation_for_unauthorized_user(self) -> Vacation:
url = "/api/vacations/"
data = {
"reason": "annual",
- "from_date": "2022-08-23",
- "end_date": "2022-08-23",
+ "from_date": "2022-08-23:08:00:00",
+ "end_date": "2022-08-23:17:00:00",
"change_log": [],
}
self.headers = client.credentials(
@@ -303,8 +303,8 @@ def test_reject_vacation_for_unauthorized_user(self) -> Vacation:
url = "/api/vacations/"
data = {
"reason": "annual",
- "from_date": "2022-08-23",
- "end_date": "2022-08-23",
+ "from_date": "2022-08-23:08:00:00",
+ "end_date": "2022-08-23:17:00:00",
"change_log": [],
}
self.headers = client.credentials(
@@ -321,8 +321,8 @@ def test_accept_vacation_for_supervisor_auth(self) -> Vacation:
url = "/api/vacations/"
data = {
"reason": "annual",
- "from_date": "2022-08-23",
- "end_date": "2022-08-23",
+ "from_date": "2022-08-23:08:00:00",
+ "end_date": "2022-08-23:17:00:00",
"change_log": [],
}
self.headers = client.credentials(
@@ -341,8 +341,8 @@ def test_reject_vacation_for_supervisor_auth(self) -> Vacation:
url = "/api/vacations/"
data = {
"reason": "annual",
- "from_date": "2022-08-23",
- "end_date": "2022-08-23",
+ "from_date": "2022-08-23:08:00:00",
+ "end_date": "2022-08-23:17:00:00",
"change_log": [],
}
self.headers = client.credentials(
@@ -361,8 +361,8 @@ def test_reject_invalid_vacation(self) -> Vacation:
url = "/api/vacations/"
data = {
"reason": "annual",
- "from_date": "2022-08-23",
- "end_date": "2022-08-23",
+ "from_date": "2022-08-23:08:00:00",
+ "end_date": "2022-08-23:17:00:00",
"change_log": [],
}
self.headers = client.credentials(
@@ -379,8 +379,8 @@ def test_accept_invalid_vacation(self) -> Vacation:
url = "/api/vacations/"
data = {
"reason": "annual",
- "from_date": "2022-08-23",
- "end_date": "2022-08-23",
+ "from_date": "2022-08-23:08:00:00",
+ "end_date": "2022-08-23:17:00:00",
"change_log": [],
}
self.headers = client.credentials(
@@ -485,7 +485,7 @@ def test_update_annual_to_excuse_with_valid_duration(self) -> Vacation:
data = {
"reason": "annual",
"from_date": "2022-08-23:08:00:00",
- "end_date": "2022-08-23:10:00:00",
+ "end_date": "2022-08-23:17:00:00",
"change_log": [],
}
self.headers = client.credentials(
@@ -529,3 +529,21 @@ def test_update_annual_to_excuse_with_invalid_duration(self) -> Vacation:
}
response = client.put(url, data, format="json")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+ def test_invalid_annual_vacation_duration(self) -> Vacation:
+ url = "/api/vacations/"
+ data = {
+ "reason": "annual",
+ "from_date": "2022-08-23:08:00:00",
+ "end_date": "2022-08-23:10:00:00",
+ "change_log": [],
+ }
+ self.headers = client.credentials(
+ HTTP_AUTHORIZATION="Bearer " + self.access_token_user
+ )
+ response = client.post(url, data, format="json")
+ self.assertEqual(
+ response.data.get("message"),
+ "Vacation duration cannot be less than 0.5 days.",
+ )
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
\ No newline at end of file
diff --git a/server/cshr/utils/balance_calculator.py b/server/cshr/utils/balance_calculator.py
index c71436d1..94f76ef9 100644
--- a/server/cshr/utils/balance_calculator.py
+++ b/server/cshr/utils/balance_calculator.py
@@ -213,6 +213,18 @@ def check_locked_balance(self, is_update: bool = False):
if total_days + locked_balance > reason_days:
raise ValueError("Insufficient balance for the requested vacation days.")
+
+
+ @validate_applying_user
+ @validate_vacation
+ @validate_user_balance
+ def check_vacation_validations(self):
+ """
+ Run all necessary validations for the vacation request.
+ """
+ total_days = self.get_vacation_days()
+ if self.vacation.reason != "excuse" and total_days < 0.5:
+ raise ValueError("Vacation duration cannot be less than 0.5 days.")
@validate_applying_user
@validate_vacation
diff --git a/server/cshr/views/vacations.py b/server/cshr/views/vacations.py
index 28f51371..1881721d 100644
--- a/server/cshr/views/vacations.py
+++ b/server/cshr/views/vacations.py
@@ -75,6 +75,7 @@ def post(self, request: Request) -> Response:
try:
balance_calculator.check_requests_in_selected_dates()
balance_calculator.check_locked_balance()
+ balance_calculator.check_vacation_validations()
except Exception as e:
return CustomResponse.bad_request(message=str(e))
@@ -219,6 +220,7 @@ def put(self, request: Request, id: str, format=None) -> Response:
try:
balance_calculator.check_user_balance()
+ balance_calculator.check_vacation_validations()
balance_calculator.check_requests_in_selected_dates(is_update=True)
balance_calculator.check_locked_balance(is_update=True)
except Exception as e:
@@ -491,6 +493,7 @@ def post(self, request: Request, user_id: str) -> Response:
try:
balance_calculator.check_requests_in_selected_dates()
balance_calculator.check_locked_balance()
+ balance_calculator.check_vacation_validations()
vacation_days = balance_calculator.get_vacation_days()
balance_calculator.adjust_balance()
except ValueError as e: