diff --git a/client/src/App.vue b/client/src/App.vue index 57b9d4315..c5142862f 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -6,13 +6,8 @@ diff --git a/client/src/stores/WSConnection.ts b/client/src/stores/WSConnection.ts index 55d6feb74..39c25cc0c 100644 --- a/client/src/stores/WSConnection.ts +++ b/client/src/stores/WSConnection.ts @@ -1,26 +1,99 @@ import { defineStore } from 'pinia'; -import { ref } from 'vue'; +import { ref, type Ref } from 'vue'; import { ApiClientBase } from "@/clients/api/base"; +import { useAsyncState } from '@vueuse/core'; +import { $api } from '@/clients'; +import { useNotificationStore } from './notifications'; +import { useNotifier } from 'vue3-notifier'; +import type { notificationType, WSErrorType } from '@/types'; +import { useHomeEventsStore } from './homeEvents'; +import { useApi } from '@/hooks'; // Define the store export const useWSConnectionStore = defineStore('WSConnectionStore', () => { const me = ApiClientBase.user.value?.fullUser; const WSConnection = ref(null); + // Initialize notifier and api inside the store + const notifier = useNotifier(); + const api = useApi(); + + if (api && notifier) { + notifier.notify; + api.setNotifier(notifier); + } + // Actions - const connect = () => { + const connect = (): Ref => { if (me) { WSConnection.value = new WebSocket(`${window.env.SERVER_DOMAIN_NAME_WS}/${me.id}/?token=Bearer ` + localStorage.getItem("USER_ACCESS_KEY")); } return WSConnection; }; - const reconnect = () => { - return connect() - } + const reconnect = async (): Promise> => { + const user = await useAsyncState(() => $api.myprofile.getUser(), null).execute(); + if (user) { + WSConnection.value = new WebSocket(`${window.env.SERVER_DOMAIN_NAME_WS}/${user.id}/?token=Bearer ` + localStorage.getItem("USER_ACCESS_KEY")); + } + return WSConnection; + }; + + const handleIncomingMessage = (event: MessageEvent) => { + const notifications = useNotificationStore(); + const data: notificationType | WSErrorType = JSON.parse(event.data as string); + + if ('code' in data && 'message' in data) { + // Handle error + const error: WSErrorType = data as WSErrorType; + console.error('Error received from the WebSocket:', error); + // const noti = notifier.notify({ + // title: 'An error received from the WebSocket', + // description: error.message, + // type: 'error', + // }); + + // setTimeout(() => { + // noti?.destroy(); + // }, 4000); + } else { + // Handle notification + const notification: notificationType = data as notificationType; + const homeEventsStore = useHomeEventsStore(); + notifications.addNotification(notification); + + if (notification.request.type === "vacation") { + homeEventsStore.reload = true; + } + + const noti = notifier.notify({ + title: notification.title, + description: notification.body, + type: 'success', + }); + + setTimeout(() => { + noti?.destroy(); + }, 4000); + } + }; + + const WSHandleConnection = () => { + if (window.connections.ws.value) { + window.connections.ws.value!.onmessage = (event: MessageEvent) => + handleIncomingMessage(event); + window.connections.ws.value!.onerror = (error) => { + console.error('WebSocket error:', error); + }; + window.connections.ws.value!.onclose = (event) => { + console.log('WebSocket connection closed:', event); + }; + } + }; return { connect, - reconnect + reconnect, + WSHandleConnection, }; }); diff --git a/client/src/types/api.ts b/client/src/types/api.ts index 7efcc9334..37a3e7ed6 100644 --- a/client/src/types/api.ts +++ b/client/src/types/api.ts @@ -14,6 +14,11 @@ export module Api { $http: AxiosInstance } + export interface ApproveOrRejectAllTeamPendingRequets{ + // ids: number[]; + action: "approve" | "reject" + } + export type Path = `/${string}` export type LoginUser = Api.Returns.Login['results'] & { fullUser: Api.User } @@ -357,6 +362,10 @@ export module Api { job_title: string address: string user_type: Users + image: string + filename: string + location: number + reporting_to: number[] } export type UsersActive = { user_id: number } diff --git a/client/src/utils/helpers.ts b/client/src/utils/helpers.ts index 3cee54046..7fa1ce73d 100644 --- a/client/src/utils/helpers.ts +++ b/client/src/utils/helpers.ts @@ -1,7 +1,7 @@ import moment from 'moment' -import type { Api } from "@/types" -import type { JWTokenObject } from "@/types" +import type { Api } from '@/types' +import type { JWTokenObject } from '@/types' import type { ApiClient } from '@/clients/api' import { capitalize, ref } from 'vue' @@ -23,30 +23,38 @@ export function devLog(...args: any[]): void { } export const DASHBOARD_ITEMS = ref([ - { id: 1, name: 'Set Vacations', active: true, }, - { id: 2, name: 'Set User Vacations', active: false, }, + { id: 1, name: 'Set Vacations', active: true }, + { id: 2, name: 'Set User Vacations', active: false }, // { id: 3, name: 'Update Office Vacations' }, - { id: 4, name: 'Add Office', active: false, }, - { id: 5, name: 'Add User', active: false, }, - { id: 6, name: 'Update User Profile', active: false, } + { id: 4, name: 'Add Office', active: false }, + { id: 5, name: 'Add User', active: false }, + { id: 6, name: 'Update User Profile', active: false } ]) export const formatDate = (date: any) => moment(date).format('YYYY-MM-DD') +export const formatDateString = (date: any) => + new Date(date).toLocaleString('en-US', { year: '2-digit', month: 'long', day: 'numeric' }) export function formatDateTime(dateString: string) { - const date = new Date(dateString); - return date.toLocaleString('en-US', { - year: 'numeric', month: 'long', day: 'numeric', - hour: 'numeric', minute: 'numeric', second: 'numeric', - hour12: true - }).replace(' at', ' | '); + const date = new Date(dateString) + return date + .toLocaleString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + hour12: true + }) + .replace(' at', ' | ') } export const fieldRequired = [(v: string) => !!v || 'Field is required.'] export function validURL(value: string) { - const pattern = /^(https?:\/\/)?([\w\d-]+\.)+[a-z]{2,6}(:\d{1,5})?(\/.*)?$/i; - return pattern.test(value) || 'Please enter a valid URL.'; + const pattern = /^(https?:\/\/)?([\w\d-]+\.)+[a-z]{2,6}(:\d{1,5})?(\/.*)?$/i + return pattern.test(value) || 'Please enter a valid URL.' } export function handelDates(start: any, end: any): any { @@ -79,7 +87,6 @@ export function handelDates(start: any, end: any): any { return dates } - export function normalizeEvent(e: Api.Event): any { const dates = handelDates(e.from_date, e.end_date) @@ -97,9 +104,9 @@ export function normalizeEvent(e: Api.Event): any { } function formatTitle(v: Api.Vacation) { - const fullName = v.applying_user_full_name ? v.applying_user_full_name : v.applying_user.full_name; - const reason = v.reason.replace("_", " ").replace(/s$/, ""); - return `${fullName} ${reason}`; + const fullName = v.applying_user_full_name ? v.applying_user_full_name : v.applying_user.full_name + const reason = v.reason.replace('_', ' ').replace(/s$/, '') + return `${fullName} ${reason}` } export function normalizeVacation(v: Api.Vacation) { @@ -145,7 +152,6 @@ export function normalizedBirthday(u: Api.User) { } } - export function normalizeMeeting(m: Api.Meeting): any { const dates = handelDates(m.date, m.date) @@ -190,64 +196,71 @@ export function formatVacationReason(reason: string) { } export function decodeAccessToken(token: string): JWTokenObject { - const base64Url = token.split('.')[1]; - const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); - const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) { - return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); - }).join('')); - return JSON.parse(jsonPayload); + const base64Url = token.split('.')[1] + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/') + const jsonPayload = decodeURIComponent( + atob(base64) + .split('') + .map(function (c) { + return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2) + }) + .join('') + ) + return JSON.parse(jsonPayload) } export function isValidToken(token: string): boolean { - const decodedToken = decodeAccessToken(token); - return Date.now() < decodedToken.exp * 1000; -} - -export async function listUsers($api: ApiClient, page: number, count: number): Promise<{ page: number, count: number, users: any[]}> { - - const res = await $api.users.admin.office_users.list({ page }); - const users: any[] = []; - if (res.count) { - count = Math.ceil(res.count / 10) - } else { - count = 0 - } - res.results.forEach((user: any) => { - users.push(user) - }) - return { page, count, users }; + const decodedToken = decodeAccessToken(token) + return Date.now() < decodedToken.exp * 1000 +} +export async function listUsers( + $api: ApiClient, + page: number, + count: number +): Promise<{ page: number; count: number; users: any[] }> { + const res = await $api.users.admin.office_users.list({ page }) + const users: any[] = [] + if (res.count) { + count = Math.ceil(res.count / 10) + } else { + count = 0 + } + res.results.forEach((user: any) => { + users.push(user) + }) + return { page, count, users } } export function convertToTimeOnly(datetime: string) { // Use a regular expression to capture the time part - const match = datetime.match(/(?:T| )(\d{2}:\d{2})/); - return match ? match[1] : ''; + const match = datetime.match(/(?:T| )(\d{2}:\d{2})/) + return match ? match[1] : '' } function timeStringToHours(time: string): number { - const [hours, minutes] = time.split(':').map(Number); - return hours + minutes / 60; + const [hours, minutes] = time.split(':').map(Number) + return hours + minutes / 60 } export function calculateTimes(excuseStart: string, excuseEnd: string) { - const startTimeInHours = timeStringToHours(excuseStart); - const endTimeInHours = timeStringToHours(excuseEnd); - - const CORE_HOURS = 8; + const startTimeInHours = timeStringToHours(excuseStart) + const endTimeInHours = timeStringToHours(excuseEnd) + + const CORE_HOURS = 8 const days = (endTimeInHours - startTimeInHours) / CORE_HOURS - - if (days <= .25) { - return .25 + + if (days <= 0.25) { + return 0.25 } - if (days <= .5) { - return .5 + if (days <= 0.5) { + return 0.5 } - if (days <= .75) { - return .75 + if (days <= 0.75) { + return 0.75 } - + return 1 } diff --git a/client/src/views/LoginView.vue b/client/src/views/LoginView.vue index c947ea317..abf172918 100644 --- a/client/src/views/LoginView.vue +++ b/client/src/views/LoginView.vue @@ -76,8 +76,9 @@ export default defineComponent({ null, { immediate: false, - onSuccess: () => { - WSConnection.reconnect(); + onSuccess: async () => { + window.connections.ws = await WSConnection.reconnect(); + WSConnection.WSHandleConnection(); return router.push('/'); } } diff --git a/client/src/views/PendingRequests.vue b/client/src/views/PendingRequests.vue index 43e10fb47..78e1f1f33 100644 --- a/client/src/views/PendingRequests.vue +++ b/client/src/views/PendingRequests.vue @@ -4,10 +4,10 @@ Pending Requests @@ -18,44 +18,73 @@ - - + + + Approve all + + + Reject all + + + + + - - - - - - - - {{ request.applying_user.full_name }} - {{ formatRequestStatus(request.status) }} - - - Applied for a vacation request from - ( {{ formatDateTime(request.from_date) }} ) - to - ( {{ formatDateTime(request.end_date) }} ) - - Reason: {{ formatVacationReason(request.reason) }} - - - - - - - - - + + + + + + + + + + {{ request.applying_user.full_name }} + {{ + formatRequestStatus(request.status) + }} + + + + Vacation request from + {{ formatDateString(request.from_date) }} + - + {{ formatDateString(request.end_date) }} + + Reason: {{ formatVacationReason(request.reason) }} + + + + + request.status = $event.status"/> + + + + + + No requests were found @@ -67,7 +96,7 @@ import type { Api } from '@/types' import { ApiClientBase } from '@/clients/api/base' import { useAsyncState } from '@vueuse/core' import { computed, defineComponent, ref, watch } from 'vue' -import { formatDateTime, formatRequestStatus, formatVacationReason, getStatusColor } from '@/utils' +import { formatDateString, formatRequestStatus, formatVacationReason, getStatusColor } from '@/utils' import profileImage from '@/components/profileImage.vue' import ActionButtons from '@/components/requests/ActionButtons.vue' @@ -84,30 +113,90 @@ export default defineComponent({ const isTeamlead = computed(() => user.value?.fullUser.user_type.toLowerCase() === 'supervisor') const isAdmin = computed(() => user.value?.fullUser.user_type.toLowerCase() === 'admin') const page = ref(1) - const count = computed(() => (state.value ? Math.ceil(state.value!.count / 3) : 0)) const requestStatus = ref([ { title: 'All', value: 'all' }, { title: formatRequestStatus('pending'), value: 'pending' }, { title: formatRequestStatus('requested_to_cancel'), value: 'requested_to_cancel' } ]) + const pendingRequests = ref([]); const selectedStatus = ref(requestStatus.value[0].value) - const { execute, state, isLoading } = useAsyncState( + const pendingRequestsTask = useAsyncState( () => { if (tab.value === 1) { - return $api.vacations.myPendingRequests({ page: page.value, status: selectedStatus.value }) + return $api.vacations.myPendingRequests({ + page: page.value, + status: selectedStatus.value + }) } - return $api.vacations.myTeamPendingRequests({ page: page.value, status: selectedStatus.value }) + return $api.vacations.myTeamPendingRequests({ + page: page.value, + status: selectedStatus.value + }) }, undefined, { - immediate: true + immediate: false } ) - watch([tab,selectedStatus], () => page.value = 1) - watch([tab, page, selectedStatus], () => execute()) + watch([tab, selectedStatus], () => (page.value = 1)) + watch([tab, page, selectedStatus], async () => { + await pendingRequestsTask.execute() + pendingRequests.value = pendingRequestsTask.state.value?.results as Api.Vacation[] + }, {immediate: true}) + + const changeStateTask = useAsyncState((action: "approve" | "reject") => { + return $api.vacations.approveOrRejectAllTeamPendingRequets({ + action, + }) + }, undefined, + { + immediate: false + } + ) + + const couldApproveAll = computed(() => pendingRequests.value!.filter((req) => req.status === "pending" || req.status === "requested_to_cancel").length) + const loading = computed( () => changeStateTask.isLoading.value || pendingRequestsTask.isLoading.value) + const count = computed(() => (pendingRequestsTask.state.value ? Math.ceil(pendingRequestsTask.state.value!.count / 10) : 0)) + const approveAll = async () => { + const STATUS = 'approve' + await changeStateTask.execute(undefined, STATUS) + + const result = pendingRequests.value.map(r => ({ + [r.applying_user.id.toFixed()]: r.id.toFixed() + })) + + pendingRequests.value = changeStateTask.state.value?.results as Api.Vacation[] + + window.connections.ws.value!.send( + JSON.stringify({ + event: 'approve_or_reject_all_pending_requests', + data: result, + status: STATUS, + }) + ) + } + + + const rejectAll = async () => { + const STATUS = 'reject' + await changeStateTask.execute(undefined, STATUS) + const result = pendingRequests.value.map(r => ({ + [r.applying_user.id.toFixed()]: r.id.toFixed() + })) + + pendingRequests.value = changeStateTask.state.value?.results as Api.Vacation[] + + window.connections.ws.value!.send( + JSON.stringify({ + event: 'approve_or_reject_all_pending_requests', + data: result, + status: STATUS, + }) + ) + } return { tab, @@ -115,17 +204,27 @@ export default defineComponent({ isTeamlead, isAdmin, page, - state, - isLoading, + pendingRequests, + loading, count, selectedStatus, requestStatus, + couldApproveAll, - formatDateTime, + formatDateString, formatVacationReason, formatRequestStatus, getStatusColor, + approveAll, + rejectAll, } } }) + + diff --git a/client/src/views/UsersView.vue b/client/src/views/UsersView.vue index 4d0700685..59c4a9680 100644 --- a/client/src/views/UsersView.vue +++ b/client/src/views/UsersView.vue @@ -4,7 +4,7 @@ ThreeFold Team - + @@ -42,6 +42,7 @@ import UserCard from '@/components/userCard.vue' import officeFilters from '@/components/filters.vue' import { useRoute } from 'vue-router' import { useAsyncState } from '@vueuse/core' + export default { name: 'UsersView', components: { @@ -49,7 +50,7 @@ export default { officeFilters }, setup() { - const offices = useAsyncState($api.office.list(), [], { immediate: true }) + const offices = useAsyncState($api.office.list(), [] as unknown as Api.Returns.List, { immediate: true }) const teams = [ {name: 'Business Development'}, {name: 'Development'}, diff --git a/server/cshr/celery/send_email.py b/server/cshr/celery/send_email.py index bbd313ad0..7be042b37 100644 --- a/server/cshr/celery/send_email.py +++ b/server/cshr/celery/send_email.py @@ -152,10 +152,10 @@ def send_quarter_evaluation_email(): # @app.task(name="user_old_balance_format") # def user_old_balance_format(): # from cshr.models.users import User -# from cshr.utils.vacation_balance_helper import StanderdVacationBalance +# from cshr.utils.vacation_balance_helper import StandardVacationBalance # users = User.objects.all() -# v = StanderdVacationBalance() +# v = StandardVacationBalance() # for user in users: # v.old_balance_format(user) @@ -163,10 +163,10 @@ def send_quarter_evaluation_email(): # @app.task(name="user_resetting_old_balance") # def user_resetting_old_balance(): # from cshr.models.users import User -# from cshr.utils.vacation_balance_helper import StanderdVacationBalance +# from cshr.utils.vacation_balance_helper import StandardVacationBalance # users = User.objects.all() -# v = StanderdVacationBalance() +# v = StandardVacationBalance() # for user in users: # v.resetting_old_balance(user) diff --git a/server/cshr/consumers/notifications_consumer.py b/server/cshr/consumers/notifications_consumer.py index e59de59e1..43c495d1d 100644 --- a/server/cshr/consumers/notifications_consumer.py +++ b/server/cshr/consumers/notifications_consumer.py @@ -23,6 +23,7 @@ class RequestEvents(enum.Enum): REQUEST_TO_CANCEL_REQUEST = "request_to_cancel_request" APPROVE_CANCEL_REQUEST = "approve_cancel_request" REJECT_CANCEL_REQUEST = "reject_cancel_request" + APPROVE_ALL_OR_REJECT_PENDING_REQUESTS = "approve_or_reject_all_pending_requests" class WSErrorWrapper: @@ -54,11 +55,11 @@ def _get_request_notification_for_receiver_based_on_status( request_id: int, receiver_id: int, status: STATUS_CHOICES ) -> Optional[Notification]: try: - return Notification.objects.get( + return Notification.objects.filter( request__id=request_id, receiver__id=receiver_id, request_status=status, - ) + ).first() except Notification.DoesNotExist: return None @@ -110,6 +111,7 @@ async def receive(self, text_data, bytes_data=None): RequestEvents.REJECT_CANCEL_REQUEST.value: self.reject_cancel_request, RequestEvents.APPROVE_REQUEST.value: self.approve_request, RequestEvents.REJECT_REQUEST.value: self.reject_request, + RequestEvents.APPROVE_ALL_OR_REJECT_PENDING_REQUESTS.value: self.approve_or_reject_all_pending_requests, }.get(request_event) if event_handler: @@ -151,6 +153,54 @@ async def reject_request(self, data: Dict): data, RequestEvents.REJECT_REQUEST, STATUS_CHOICES.REJECTED ) + async def approve_or_reject_all_pending_requests(self, data: Dict): + await self.handle_approve_or_reject_all_pending_requests( + data, + RequestEvents.APPROVE_ALL_OR_REJECT_PENDING_REQUESTS, + ) + + async def handle_approve_or_reject_all_pending_requests( + self, data: Dict, event: RequestEvents + ): + result: List = data.get( + "data" + ) # Should be a list of Dict, Where the key of each map is the user id and the value is the request id + status: str = data.get("status") + status = ( + STATUS_CHOICES.APPROVED if status == "approve" else STATUS_CHOICES.REJECTED + ) + + if not data: + await self.handle_error( + 400, + f"The request event type is '{event.value}', but no data has been submitted.", + ) + return + + users_ids = [] + request_ids = [] + + for i in result: + users_ids.append(int(list(i.keys())[0])) + request_ids.append(int(list(i.values())[0])) + + for idx, receiver_id in enumerate(users_ids): + group_name = f"room_{receiver_id}" + notification = await _get_request_notification_for_receiver_based_on_status( + request_ids[idx], receiver_id, status + ) + if notification: + notification_serializer = await get_notification_serializer( + notification + ) + notification_serializer["request"]["from_date"] = ( + notification_serializer["request"]["from_date"].isoformat() + ) + notification_serializer["request"]["end_date"] = ( + notification_serializer["request"]["end_date"].isoformat() + ) + await self.send_to_group_name(notification_serializer, group_name) + async def approve_cancel_request(self, data: Dict): await self.handle_single_receiver_request( data, RequestEvents.APPROVE_CANCEL_REQUEST, STATUS_CHOICES.CANCELED diff --git a/server/cshr/migrations/0018_alter_user_location.py b/server/cshr/migrations/0018_alter_user_location.py new file mode 100644 index 000000000..e723b302c --- /dev/null +++ b/server/cshr/migrations/0018_alter_user_location.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.13 on 2024-10-20 12:45 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("cshr", "0017_alter_publicholiday_location"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="location", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="user_office", + to="cshr.office", + ), + ), + ] diff --git a/server/cshr/migrations/0019_merge_20241030_1720.py b/server/cshr/migrations/0019_merge_20241030_1720.py new file mode 100644 index 000000000..f0a9597cd --- /dev/null +++ b/server/cshr/migrations/0019_merge_20241030_1720.py @@ -0,0 +1,13 @@ +# Generated by Django 4.2.13 on 2024-10-30 17:20 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("cshr", "0018_alter_user_location"), + ("cshr", "0018_alter_user_location_and_more"), + ] + + operations = [] diff --git a/server/cshr/migrations/0020_alter_notification_unique_together.py b/server/cshr/migrations/0020_alter_notification_unique_together.py new file mode 100644 index 000000000..b08e534eb --- /dev/null +++ b/server/cshr/migrations/0020_alter_notification_unique_together.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.13 on 2024-10-30 17:20 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("cshr", "0019_merge_20241030_1720"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="notification", + unique_together={ + ("request_status", "request", "created_at", "receiver", "sender") + }, + ), + ] diff --git a/server/cshr/models/notification.py b/server/cshr/models/notification.py index c602301e4..44b3506cb 100644 --- a/server/cshr/models/notification.py +++ b/server/cshr/models/notification.py @@ -48,6 +48,7 @@ class Meta: unique_together = ( "request_status", "request", + "created_at", "receiver", "sender", ) diff --git a/server/cshr/routes/vacations.py b/server/cshr/routes/vacations.py index 5227b51e1..af6d619b3 100644 --- a/server/cshr/routes/vacations.py +++ b/server/cshr/routes/vacations.py @@ -19,13 +19,15 @@ RejectCancelVacationRequestApiView, GetMyPendingRequestsAPIView, GetMyTeamPendingRequestsAPIView, + ActionTeamPendingRequestsAPIView, ) urlpatterns = [ path("", BaseVacationsApiView.as_view()), path("user/", VacationUserApiView.as_view()), - path("my-peneding-requests/", GetMyPendingRequestsAPIView.as_view()), - path("my-team-peneding-requests/", GetMyTeamPendingRequestsAPIView.as_view()), + path("my-pending-requests/", GetMyPendingRequestsAPIView.as_view()), + path("action-team-pending-requests/", ActionTeamPendingRequestsAPIView.as_view()), + path("my-team-pending-requests/", GetMyTeamPendingRequestsAPIView.as_view()), path("calculate/", CalculateVacationDaysApiView.as_view()), path("post-admin-balance/", PostAdminVacationBalanceApiView.as_view()), path("get-admin-balance/", GetAdminVacationBalanceApiView.as_view()), diff --git a/server/cshr/serializers/vacations.py b/server/cshr/serializers/vacations.py index a33331bdc..cf4304ee7 100644 --- a/server/cshr/serializers/vacations.py +++ b/server/cshr/serializers/vacations.py @@ -89,6 +89,7 @@ class Meta: "change_log", "type", "approvals", + "actual_days", ] def get_applying_user(self, obj: Vacation) -> BaseUserSerializer: @@ -286,3 +287,8 @@ class AdminApplyVacationForUserSerializer(serializers.Serializer): reason = serializers.CharField() from_date = serializers.DateTimeField() end_date = serializers.DateTimeField() + +class ActionTeamPendingRequestsSerializer(serializers.Serializer): + action = serializers.CharField() + # ids = serializers.ListField() + diff --git a/server/cshr/services/notifications.py b/server/cshr/services/notifications.py index 7df39bb27..6969ad77c 100644 --- a/server/cshr/services/notifications.py +++ b/server/cshr/services/notifications.py @@ -217,6 +217,18 @@ def push(self, notification: Notification) -> Notification: """ return self.create(notification=notification) + def bulk_push(self, notifications: List[Notification]) -> List[Notification]: + """ + Pushes multiple notifications at once. + + Args: + notifications (List[Notification]): A list of notification objects to be sent. + + Returns: + List[Notification]: The list of saved notification objects. + """ + return self.bulk_create(notifications) + def create(self, notification: Notification) -> Notification: """ Creates a new notification without pushing it. @@ -233,6 +245,15 @@ def create(self, notification: Notification) -> Notification: request_status=notification.request_status, ) + def bulk_create(self, notifications: List[Notification]) -> List[Notification]: + """ + Creates a list of notifications. + + Returns: + List[Notification]: The created notifications list. + """ + return Notification.objects.bulk_create(notifications) + def get_all(self) -> List[Notification]: """ Retrieves all notifications. diff --git a/server/cshr/services/users.py b/server/cshr/services/users.py index 56bef8ef8..39fc37e20 100644 --- a/server/cshr/services/users.py +++ b/server/cshr/services/users.py @@ -1,6 +1,6 @@ """This file will containes everything related to User model.""" -from typing import Dict, List, Union +from typing import Dict, List, Union, Optional from django.contrib.auth.hashers import check_password from django.db.models import Q, F @@ -84,10 +84,11 @@ def get_users_filter( return users -def get_all_of_users(options=None) -> User: - """Return all users""" +def get_all_of_users(options: Optional[Dict] = None): + """Return all users based on filtering options.""" queryset = User.objects.all() + # Handle optional filtering if options: location_id = options.get("location", {}).get("id") team_name = options.get("team", {}).get("name") @@ -101,10 +102,19 @@ def get_all_of_users(options=None) -> User: elif location_id: queryset = queryset.filter(location__id=location_id) - return queryset.exclude(user_type=USER_TYPE.ADMIN).order_by( - "first_name", F("is_active").desc(nulls_last=True) + # Exclude users with first name or last name containing "admin", case-insensitive + queryset = queryset.exclude( + Q(first_name__icontains="admin") | Q(last_name__icontains="admin") ) + # Order by first_name (case insensitive) and is_active (nulls_last=True) + queryset = queryset.order_by( + "first_name".lower(), # Order by lowercased first_name + F("is_active").desc(nulls_last=True) # Order by is_active with nulls last + ) + + return queryset + def get_admin_office_users(admin: User) -> User: """Return all users who working in the same office of the admin""" @@ -207,7 +217,7 @@ def filter_admins_same_office_of_the_user(user: User) -> QuerySet[User]: return User.objects.filter(location__id=user.location.id, user_type=USER_TYPE.ADMIN) -def build_user_reporting_to_hierarchy_down(user: User) -> List[int]: +def build_user_reporting_to_hierarchy_down(user: User, visited=None) -> List[int]: """ Function to get all users reporting to a specific user, including indirect reports. @@ -219,6 +229,15 @@ def build_user_reporting_to_hierarchy_down(user: User) -> List[int]: The function iterates through the direct reports of the user and recursively adds the IDs of all users who report to them. """ + if visited is None: + visited = set() + + # Avoid infinite loops by checking if the user has already been visited + if user.id in visited: + return [] + + visited.add(user.id) + reports = [] # Get all direct reports @@ -227,7 +246,7 @@ def build_user_reporting_to_hierarchy_down(user: User) -> List[int]: for report in direct_reports: reports.append(report.id) # Add the direct report's ID reports.extend( - build_user_reporting_to_hierarchy_down(report) + build_user_reporting_to_hierarchy_down(report, visited) ) # Recursively add all indirect reports return reports diff --git a/server/cshr/utils/vacation_balance_helper.py b/server/cshr/utils/vacation_balance_helper.py index b7915e11f..e18dc65d0 100644 --- a/server/cshr/utils/vacation_balance_helper.py +++ b/server/cshr/utils/vacation_balance_helper.py @@ -13,7 +13,7 @@ from cshr.services.public_holidays import filter_office_public_holidays_based_on_dates -class StanderdVacationBalance: +class StandardVacationBalance: def check(self, user) -> VacationBalance: self.user = user balance = VacationBalance.objects.filter(user=self.user) @@ -121,8 +121,6 @@ def check_and_update_balance( applying_user: User, vacation: Vacation, reason: str, - start_date: datetime, - end_date: datetime, delete: bool = False, ): if reason == "public_holidays": @@ -136,11 +134,11 @@ def check_and_update_balance( curr_balance = getattr(v, reason) if ( vacation.status == STATUS_CHOICES.APPROVED - or vacation.status == STATUS_CHOICES.CANCEL_APPROVED - ): - if delete: - new_value: int = curr_balance + vacation_days - return self.update_user_balance(applying_user, reason, new_value) + or vacation.status == STATUS_CHOICES.REQUESTED_TO_CANCEL + ) and delete is True: + # if delete: + new_value: int = curr_balance + vacation_days + return self.update_user_balance(applying_user, reason, new_value) if curr_balance >= vacation_days: if vacation.status == STATUS_CHOICES.PENDING: new_value: int = curr_balance - vacation_days diff --git a/server/cshr/views/users.py b/server/cshr/views/users.py index 844c435a2..3457f2e6d 100644 --- a/server/cshr/views/users.py +++ b/server/cshr/views/users.py @@ -38,7 +38,7 @@ class BaseGeneralUserAPIView(ListAPIView, GenericAPIView): - permission_classes = [UserIsAuthenticated] + # permission_classes = [UserIsAuthenticated] serializer_class = GeneralUserSerializer pagination_class = BaseGeneralUserPagination diff --git a/server/cshr/views/vacations.py b/server/cshr/views/vacations.py index 92e8688e8..4cb5295b9 100644 --- a/server/cshr/views/vacations.py +++ b/server/cshr/views/vacations.py @@ -1,5 +1,6 @@ from cshr.serializers.users import TeamSerializer from cshr.serializers.vacations import ( + ActionTeamPendingRequestsSerializer, AdminApplyVacationForUserSerializer, PostOfficeVacationBalanceSerializer, GetOfficeVacationBalanceSerializer, @@ -9,7 +10,7 @@ VacationsCommentsSerializer, VacationsSerializer, ) -from typing import Dict, List +from typing import Dict, List, Union from cshr.serializers.vacations import ( VacationsUpdateSerializer, VacationBalanceSerializer, @@ -24,7 +25,7 @@ from cshr.models.requests import TYPE_CHOICES, STATUS_CHOICES, Requests from cshr.models.users import USER_TYPE, User from cshr.services.office import get_office_by_id -from cshr.utils.vacation_balance_helper import StanderdVacationBalance +from cshr.utils.vacation_balance_helper import StandardVacationBalance from cshr.services.users import ( build_user_reporting_to_hierarchy, filter_admins_same_office_of_the_user, @@ -51,6 +52,7 @@ OfficeVacationBalance, PublicHoliday, Vacation, + VacationBalance, ) from cshr.services.vacations import get_vacations_by_user from cshr.utils.redis_functions import ( @@ -61,7 +63,6 @@ from cshr.utils.wrappers import wrap_vacation_request from cshr.services.notifications import NotificationsService from cshr.services.requests import get_request_by_id -from cshr.api.pagination import PendingRequestsPagination class GetAdminVacationBalanceApiView(GenericAPIView): @@ -98,7 +99,7 @@ def put(self, request: Request) -> Response: users_in_office = User.objects.filter(location=office) # Check the balance if created for all users - v: StanderdVacationBalance = StanderdVacationBalance() + v: StandardVacationBalance = StandardVacationBalance() for user in users_in_office: v.check(user) @@ -189,6 +190,8 @@ def post(self, request: Request) -> Response: from_date__lte=end_date, end_date__gte=start_date, applying_user=applying_user, + ).exclude( + status__in=[STATUS_CHOICES.CANCELED, STATUS_CHOICES.REJECTED], ) if len(pending_requests) > 0: @@ -197,7 +200,7 @@ def post(self, request: Request) -> Response: ) # Check balance. - v = StanderdVacationBalance() + v = StandardVacationBalance() v.check(applying_user) reason = serializer.validated_data.get("reason") @@ -245,11 +248,12 @@ def post(self, request: Request) -> Response: reason=reason, ).values_list("actual_days", flat=True) - chcked_balance = curr_balance - sum(pending_vacations) - + sum_balance = int(sum(pending_vacations)) + chcked_balance = curr_balance - sum_balance + days_or_day = "days" if sum_balance > 1 else "day" if chcked_balance < vacation_days: return CustomResponse.bad_request( - message=f"You have an additional pending request that deducts {sum(pending_vacations)} days from your balance even though the current balance for the '{reason.capitalize().replace('_', ' ')}' category is only {curr_balance} days." + message=f"You have an additional pending request that deducts {sum_balance} {days_or_day} from your balance even though the current balance for the '{reason.capitalize().replace('_', ' ')}' category is only {curr_balance} {days_or_day}." ) vacation = serializer.save( @@ -331,21 +335,25 @@ class VacationsUpdateApiView(ListAPIView, GenericAPIView): def put(self, request: Request, id: str, format=None) -> Response: vacation = get_vacation_by_id(id=id) - v = StanderdVacationBalance() if vacation is None: return CustomResponse.not_found(message="Vacation not found") - serializer = self.get_serializer(vacation, data=request.data, partial=True) + serializer = self.get_serializer(vacation, data=request.data, partial=True) if serializer.is_valid(): start_date = serializer.validated_data.get("from_date") end_date = serializer.validated_data.get("end_date") + applying_user = request.user # Check if there are pending vacations in the same day pending_requests = Vacation.objects.filter( from_date__lte=end_date, end_date__gte=start_date, - status=STATUS_CHOICES.PENDING, - ).exclude(id=int(id)) + applying_user=applying_user, + ).exclude( + id=vacation.id + ).exclude( + status__in=[STATUS_CHOICES.CANCELED, STATUS_CHOICES.REJECTED], + ) if len(pending_requests) > 0: return CustomResponse.bad_request( @@ -353,9 +361,8 @@ def put(self, request: Request, id: str, format=None) -> Response: ) # Check balance. - v = StanderdVacationBalance() + v = StandardVacationBalance() reason: str = serializer.validated_data.get("reason") - applying_user = request.user user_reason_balance = applying_user.vacationbalance vacation_days = v.get_actual_days(applying_user, start_date, end_date) @@ -379,6 +386,8 @@ def put(self, request: Request, id: str, format=None) -> Response: status=STATUS_CHOICES.PENDING, applying_user=applying_user, reason=reason, + ).exclude( + id=vacation.id ).values_list("actual_days", flat=True) chcked_balance = curr_balance - sum(pending_vacations) @@ -390,7 +399,7 @@ def put(self, request: Request, id: str, format=None) -> Response: if chcked_balance < vacation_days: return CustomResponse.bad_request( - message=f"You have an additional pending request that deducts {sum(pending_vacations)} days from your balance even though the current balance for the '{reason.capitalize().replace('_', ' ')}' category is only {curr_balance} days." + message=f"You have a request that deducts {sum(pending_vacations)} days from your balance even though the current balance for the '{reason.capitalize().replace('_', ' ')}' category is only {curr_balance} days." ) serializer.save(actual_days=vacation_days) @@ -449,7 +458,7 @@ def put(self, request: Request): yourdata = request.data serializer = VacationBalanceSerializer(data=yourdata) if serializer.is_valid(): - v = StanderdVacationBalance() + v = StandardVacationBalance() v.bulk_write(dict(serializer.data)) return CustomResponse.success( data=serializer.data, status_code=202, message="base balance updated" @@ -467,7 +476,7 @@ def put(self, request: Request): yourdata = request.data serializer = UserBalanceUpdateSerializer(data=yourdata) if serializer.is_valid(): - vh = StanderdVacationBalance() + vh = StandardVacationBalance() ids = serializer.data["ids"] type = serializer.data["type"] new_value = serializer.data["new_value"] @@ -506,7 +515,7 @@ def get(self, request: Request) -> Response: user_ids = user_ids.split(",") users: User = get_users_by_id(user_ids) - v: StanderdVacationBalance = StanderdVacationBalance() + v: StandardVacationBalance = StandardVacationBalance() balances = [] for user in users: @@ -530,7 +539,7 @@ def put(self, request: Request) -> CustomResponse: user_ids = user_ids.split(",") users: User = get_users_by_id(user_ids) - v: StanderdVacationBalance = StanderdVacationBalance() + v: StandardVacationBalance = StandardVacationBalance() balances = filter_balances_by_users(users) @@ -583,7 +592,7 @@ def parse_date(self, date_str: str) -> datetime: def get(self, request: Request) -> Response: """Use this endpoint to calculate the actual vacation days taken between 2 dates.""" user: User = get_user_by_id(request.user.id) - v: StanderdVacationBalance = StanderdVacationBalance() + v: StandardVacationBalance = StandardVacationBalance() v.check(user) start_date_str: str = request.query_params.get("start_date") @@ -670,7 +679,7 @@ def post(self, request: Request, user_id: str) -> Response: ) # Check balance. - v = StanderdVacationBalance() + v = StandardVacationBalance() v.check(applying_user) user_reason_balance = applying_user.vacationbalance @@ -698,10 +707,11 @@ def post(self, request: Request, user_id: str) -> Response: ).values_list("actual_days", flat=True) chcked_balance = curr_balance - sum(pending_vacations) + days_or_day = "days" if chcked_balance > 1 else "day" if curr_balance < vacation_days: return CustomResponse.bad_request( - message=f"The user have only {curr_balance} days left of reason '{reason.capitalize().replace('_', ' ')}'" + message=f"The user have only {curr_balance} {days_or_day} left of reason '{reason.capitalize().replace('_', ' ')}'" ) if chcked_balance < vacation_days: @@ -740,8 +750,6 @@ def post(self, request: Request, user_id: str) -> Response: applying_user=saved_vacation.applying_user, vacation=saved_vacation, reason=saved_vacation.reason, - start_date=saved_vacation.from_date, - end_date=saved_vacation.end_date, ) except ValueError as e: return CustomResponse.bad_request(message=str(e)) @@ -843,14 +851,12 @@ def put(self, request: Request, id: str, format=None) -> Response: ) if vacation.status == STATUS_CHOICES.CANCEL_APPROVED: - v = StanderdVacationBalance() + v = StandardVacationBalance() try: v.check_and_update_balance( applying_user=vacation.applying_user, vacation=vacation, reason=vacation.reason, - start_date=vacation.from_date, - end_date=vacation.end_date, delete=True, ) except ValueError as e: @@ -992,14 +998,12 @@ def put(self, request: Request, id: str, format=None) -> Response: vacation.save() # Return the balance back. - v = StanderdVacationBalance() + v = StandardVacationBalance() try: v.check_and_update_balance( applying_user=vacation.applying_user, vacation=vacation, reason=vacation.reason, - start_date=vacation.from_date, - end_date=vacation.end_date, delete=True, ) except ValueError as e: @@ -1188,14 +1192,12 @@ def put(self, request: Request, id: str, format=None) -> Response: message=f"You are not allowed to perform this action, the request status is '{status}'." ) - v = StanderdVacationBalance() + v = StandardVacationBalance() try: v.check_and_update_balance( applying_user=vacation.applying_user, vacation=vacation, reason=vacation.reason, - start_date=vacation.from_date, - end_date=vacation.end_date, ) except ValueError as e: return CustomResponse.bad_request(message=str(e)) @@ -1228,7 +1230,6 @@ class GetMyPendingRequestsAPIView(ListAPIView, GenericAPIView): permission_classes = [ UserIsAuthenticated, ] - pagination_class = PendingRequestsPagination def get_pending_requests(self, request: Request) -> CustomResponse: status = request.query_params.get('status') @@ -1263,7 +1264,6 @@ class GetMyTeamPendingRequestsAPIView(ListAPIView, GenericAPIView): permission_classes = [ UserIsAuthenticated, ] - pagination_class = PendingRequestsPagination def get_team_pending_requests(self, request: Request) -> CustomResponse: status = request.query_params.get('status') @@ -1278,17 +1278,108 @@ def get_team_pending_requests(self, request: Request) -> CustomResponse: vacations = Vacation.objects.filter( applying_user__id__in=users, - ) + ).select_related('applying_user') if status == "all": status = [STATUS_CHOICES.PENDING, STATUS_CHOICES.REQUESTED_TO_CANCEL] else: status = [status] - vacations = vacations.filter(status__in=status) + vacations = vacations.filter(status__in=status).select_related('applying_user') return vacations def get_queryset(self) -> Response: """method to get all vacations""" query_set: List[Vacation] = self.get_team_pending_requests(self.request) return query_set + +class ActionTeamPendingRequestsAPIView(ListAPIView, GenericAPIView): + serializer_class = ActionTeamPendingRequestsSerializer + permission_classes = [IsAdmin | IsSupervisor] + + def put(self, request: Request) -> Response: + # Ensure request user exists + if not request.user: + return CustomResponse.not_found(message="User not found.") + + # Validate request data + serializer = self.serializer_class(data=request.data) + if not serializer.is_valid(): + return CustomResponse.bad_request(serializer.errors) + + # Retrieve action and determine status + action = serializer.validated_data.get('action') + status_needed = self._get_status_from_action(action) + balance_handler = StandardVacationBalance() + + # Fetch users who have pending requests + users_with_pending_requests = build_user_reporting_to_hierarchy_down(request.user) + vacations = Vacation.objects.filter( + applying_user__id__in=users_with_pending_requests, + status__in=[STATUS_CHOICES.PENDING, STATUS_CHOICES.REQUESTED_TO_CANCEL] + ).select_related('applying_user') + + # Prepare batch updates for vacation and notifications + vacation_updates = [] + notifications = [] + + for vacation in vacations: + vacation.approval_user = request.user + is_remove_balance = (action == 'approve' and vacation.status == STATUS_CHOICES.REQUESTED_TO_CANCEL) + + # Update vacation balance as necessary + balance_handler.check_and_update_balance( + applying_user=vacation.applying_user, + vacation=vacation, + reason=vacation.reason, + delete=is_remove_balance + ) + + # Set status and prepare notification based on the action + notification_service = NotificationsService( + sender=vacation.approval_user, + receiver=vacation.applying_user, + status=status_needed + ) + + if vacation.status == STATUS_CHOICES.REQUESTED_TO_CANCEL: + vacation.status = STATUS_CHOICES.CANCELED + notification = notification_service.vacations.approve_cancel_request( + vacation.reason, vacation + ) + else: + vacation.status = status_needed + if status_needed == STATUS_CHOICES.APPROVED: + notification = notification_service.vacations.approve_vacation( + vacation.reason, vacation + ) + elif status_needed == STATUS_CHOICES.REJECTED: + notification = notification_service.vacations.reject_vacation( + vacation.reason, vacation + ) + + # Add updates and notifications for batch processing + vacation_updates.append(vacation) + if notification: + notification.sender = vacation.approval_user + notification.receiver = vacation.applying_user + notifications.append(notification) + + # Batch update vacations and send notifications + Vacation.objects.bulk_update(vacation_updates, ['status', 'approval_user']) + notification_service.bulk_push(notifications=notifications) + + # Paginate and serialize response + paginated_queryset = self.paginate_queryset(vacations) + serialized_data = LandingPageVacationsSerializer(paginated_queryset, many=True).data + message = f"The pending requests have been successfully {status_needed}." + return CustomResponse.success(message=message, data=serialized_data) + + + def _get_status_from_action(self, action: str) -> str: + """Return the status based on the action.""" + if action == 'approve': + return STATUS_CHOICES.APPROVED + elif action == 'reject': + return STATUS_CHOICES.REJECTED + return STATUS_CHOICES.PENDING
- Reason: {{ formatVacationReason(request.reason) }} -
+ Reason: {{ formatVacationReason(request.reason) }} +
No requests were found