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: