From dcd42ae667310b1df42d28e23cbfa3e3b7d1a501 Mon Sep 17 00:00:00 2001 From: leojay-net Date: Mon, 28 Apr 2025 04:48:05 +0100 Subject: [PATCH 01/39] fix(user): simplify username retrieval in User model by removing web3 conditionals --- web3auth/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web3auth/models.py b/web3auth/models.py index 288b707..e112649 100644 --- a/web3auth/models.py +++ b/web3auth/models.py @@ -9,7 +9,7 @@ class User(AbstractUser): user_type = models.CharField(max_length=12, choices=[('recipient', 'Recipient'), ('organization', 'Organization')], default='organization') def __str__(self): - return self.wallet_address if self.is_web3 else self.username + return self.wallet_address def get_username(self): - return self.wallet_address if self.is_web3 else super().get_username() \ No newline at end of file + return self.wallet_address \ No newline at end of file From 349efd3b390322e46c42b8d8afe9930ee1a9d9e8 Mon Sep 17 00:00:00 2001 From: leojay-net Date: Mon, 28 Apr 2025 10:15:12 +0100 Subject: [PATCH 02/39] fix(serializers): require address and signature fields in EthereumAuthSerializer fix(views): use validated_data directly in EthereumLoginView for cleaner code --- web3auth/serializers.py | 4 ++-- web3auth/views.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web3auth/serializers.py b/web3auth/serializers.py index 3a15baf..8b9f212 100644 --- a/web3auth/serializers.py +++ b/web3auth/serializers.py @@ -12,8 +12,8 @@ User = get_user_model() class EthereumAuthSerializer(serializers.Serializer): - address = serializers.CharField() - signature = serializers.CharField() + address = serializers.CharField(required=True) + signature = serializers.CharField(required=True) def validate(self, data): address = data['address'].lower() diff --git a/web3auth/views.py b/web3auth/views.py index 96e390c..350b0a4 100644 --- a/web3auth/views.py +++ b/web3auth/views.py @@ -41,7 +41,7 @@ def post(self, request): serializer = EthereumAuthSerializer(data=request.data) serializer.is_valid(raise_exception=True) - validated_data = serializer.validate(serializer.validated_data) + validated_data = serializer.validated_data user = validated_data['user'] token = validated_data['token'] From 9cb80fef6059a333aa4495bfc9e254c656d50b9c Mon Sep 17 00:00:00 2001 From: leojay-net Date: Mon, 28 Apr 2025 10:32:58 +0100 Subject: [PATCH 03/39] second fix --- web3auth/serializers.py | 20 ++++++++++++-------- web3auth/views.py | 5 ++--- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/web3auth/serializers.py b/web3auth/serializers.py index 8b9f212..740318b 100644 --- a/web3auth/serializers.py +++ b/web3auth/serializers.py @@ -7,6 +7,7 @@ import secrets import string import logging +from rest_framework_simplejwt.tokens import RefreshToken logger = logging.getLogger(__name__) User = get_user_model() @@ -57,17 +58,20 @@ def validate(self, data): user.nonce = ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(32)) user.save() - # Generate JWT token - payload = { - 'user_id': user.id, - 'address': user.wallet_address, - 'username': user.get_username() - } - token = jwt.encode(payload, settings.SECRET_KEY, algorithm='HS256') + # # Generate JWT token + # payload = { + # 'user_id': user.id, + # 'address': user.wallet_address, + # 'username': user.get_username() + # } + # token = jwt.encode(payload, settings.SECRET_KEY, algorithm='HS256') + refresh = RefreshToken.for_user(user) + refresh['address'] = user.wallet_address return { 'user': user, - 'token': token + 'refresh': str(refresh), + 'access': str(refresh.access_token) } class UserSerializer(serializers.ModelSerializer): diff --git a/web3auth/views.py b/web3auth/views.py index 350b0a4..ce53da2 100644 --- a/web3auth/views.py +++ b/web3auth/views.py @@ -43,7 +43,6 @@ def post(self, request): validated_data = serializer.validated_data user = validated_data['user'] - token = validated_data['token'] if user is None: return Response({'error': 'User not found'}, status=status.HTTP_400_BAD_REQUEST) @@ -52,8 +51,8 @@ def post(self, request): return Response({ 'user': user_serializer.data, - 'token': token, - + 'refresh': validated_data['refresh'], + 'access': validated_data['access'] }) class UserDetailView(APIView): From f5169ed4d71f99901f58350fb0a2acac15ceac55 Mon Sep 17 00:00:00 2001 From: leojay-net Date: Mon, 28 Apr 2025 10:54:25 +0100 Subject: [PATCH 04/39] fix(user_profile): ensure user is saved during organization and recipient profile creation --- user_profile/views.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/user_profile/views.py b/user_profile/views.py index bee89e4..73a8861 100644 --- a/user_profile/views.py +++ b/user_profile/views.py @@ -22,6 +22,9 @@ def get_queryset(self): by filtering against a `user` query parameter in the URL. """ return super().get_queryset().filter(user=self.request.user) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) class RecipientProfileView(ModelViewSet): @@ -48,3 +51,6 @@ def batch_create(self, request, *args, **kwargs): serializer.is_valid(raise_exception=True) self.perform_create(serializer) return Response(serializer.data, status=status.HTTP_201_CREATED) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) From 4e5b656f20515bbac5cbdbf994d793f97069cf04 Mon Sep 17 00:00:00 2001 From: leojay-net Date: Tue, 29 Apr 2025 02:04:40 +0100 Subject: [PATCH 05/39] feat(notifications): add notifications app with models, serializers, and views for user notifications --- HR_Backend/settings.py | 1 + HR_Backend/urls.py | 1 + notifications/__init__.py | 0 notifications/admin.py | 3 + notifications/apps.py | 6 + notifications/migrations/0001_initial.py | 57 ++++++ notifications/migrations/__init__.py | 0 notifications/models.py | 26 +++ notifications/serializers.py | 13 ++ notifications/tests.py | 3 + notifications/urls.py | 12 ++ notifications/views.py | 23 +++ schema.yml | 229 +++++++++++++++++++++++ user_profile/views.py | 34 ++++ web3auth/views.py | 8 + 15 files changed, 416 insertions(+) create mode 100644 notifications/__init__.py create mode 100644 notifications/admin.py create mode 100644 notifications/apps.py create mode 100644 notifications/migrations/0001_initial.py create mode 100644 notifications/migrations/__init__.py create mode 100644 notifications/models.py create mode 100644 notifications/serializers.py create mode 100644 notifications/tests.py create mode 100644 notifications/urls.py create mode 100644 notifications/views.py diff --git a/HR_Backend/settings.py b/HR_Backend/settings.py index d14aceb..63d3351 100644 --- a/HR_Backend/settings.py +++ b/HR_Backend/settings.py @@ -46,6 +46,7 @@ 'waitlist.apps.WaitlistConfig', 'web3auth.apps.Web3AuthConfig', 'user_profile.apps.UserProfileConfig', + 'notifications.apps.NotificationsConfig', # Third-party apps 'corsheaders', diff --git a/HR_Backend/urls.py b/HR_Backend/urls.py index fa19429..6819e3e 100644 --- a/HR_Backend/urls.py +++ b/HR_Backend/urls.py @@ -28,5 +28,6 @@ path('api/v1/waitlist/', include('waitlist.urls')), path('api/v1/web3auth/', include('web3auth.urls')), path('api/v1/profile/', include('user_profile.urls')), + path('api/v1/notifications/', include('notifications.urls')), ] diff --git a/notifications/__init__.py b/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notifications/admin.py b/notifications/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/notifications/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/notifications/apps.py b/notifications/apps.py new file mode 100644 index 0000000..3a08476 --- /dev/null +++ b/notifications/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "notifications" diff --git a/notifications/migrations/0001_initial.py b/notifications/migrations/0001_initial.py new file mode 100644 index 0000000..0401e23 --- /dev/null +++ b/notifications/migrations/0001_initial.py @@ -0,0 +1,57 @@ +# Generated by Django 5.2 on 2025-04-29 01:01 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Notification", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "type", + models.CharField( + choices=[ + ("recipientAdded", "Recipient Added"), + ("recipientRemoved", "Recipient Removed"), + ("recipientUpdated", "Recipient Updated"), + ("organizationUpdated", "Organization Updated"), + ("organizationAdded", "Organization Added"), + ("organizationRemoved", "Organization Removed"), + ("login", "Login"), + ], + max_length=100, + ), + ), + ("message", models.TextField()), + ("is_read", models.BooleanField(default=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="notifications", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/notifications/migrations/__init__.py b/notifications/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notifications/models.py b/notifications/models.py new file mode 100644 index 0000000..fde4b5d --- /dev/null +++ b/notifications/models.py @@ -0,0 +1,26 @@ +from django.db import models +from django.conf import settings + +class Notification(models.Model): + """ + Model to store notifications for users. + """ + choices = [ + ('recipientAdded', 'Recipient Added'), + ('recipientRemoved', 'Recipient Removed'), + ('recipientUpdated', 'Recipient Updated'), + ('organizationUpdated', 'Organization Updated'), + ('organizationAdded', 'Organization Added'), + ('organizationRemoved', 'Organization Removed'), + ('login', 'Login'), + + + ] + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='notifications') + type = models.CharField(max_length=100, choices=choices) + message = models.TextField() + is_read = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Notification for {self.user.username} - {self.created_at}" \ No newline at end of file diff --git a/notifications/serializers.py b/notifications/serializers.py new file mode 100644 index 0000000..e87fae2 --- /dev/null +++ b/notifications/serializers.py @@ -0,0 +1,13 @@ +from rest_framework import serializers + +from .models import Notification + + +class NotificationSerializer(serializers.ModelSerializer): + """ + Serializer for the Notification model. + """ + class Meta: + model = Notification + fields = '__all__' + read_only_fields = ['user', 'id' ,'is_read', 'created_at'] \ No newline at end of file diff --git a/notifications/tests.py b/notifications/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/notifications/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/notifications/urls.py b/notifications/urls.py new file mode 100644 index 0000000..f232c51 --- /dev/null +++ b/notifications/urls.py @@ -0,0 +1,12 @@ +from rest_framework.routers import DefaultRouter +from django.urls import path, include + +from .views import NotificationView + +router = DefaultRouter() + +router.register(r'notifications', NotificationView, basename='notification') + +urlpatterns = [ + path('', include(router.urls)), +] \ No newline at end of file diff --git a/notifications/views.py b/notifications/views.py new file mode 100644 index 0000000..2e90d25 --- /dev/null +++ b/notifications/views.py @@ -0,0 +1,23 @@ +from rest_framework.viewsets import ModelViewSet +from rest_framework.response import Response +from rest_framework import status +from .models import Notification +from .serializers import NotificationSerializer +from rest_framework.permissions import IsAuthenticated + + + +class NotificationView(ModelViewSet): + """ + Viewset for handling notification operations. + """ + queryset = Notification.objects.all() + serializer_class = NotificationSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + """ + Optionally restricts the returned notifications to a given user, + by filtering against a `user` query parameter in the URL. + """ + return super().get_queryset().filter(user=self.request.user).order_by('-created_at') \ No newline at end of file diff --git a/schema.yml b/schema.yml index 94993c4..814fa7e 100644 --- a/schema.yml +++ b/schema.yml @@ -4,6 +4,153 @@ info: version: 1.0.0 description: API for managing HR operations paths: + /api/v1/notifications/notifications/: + get: + operationId: notifications_notifications_list + description: Viewset for handling notification operations. + tags: + - notifications + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Notification' + description: '' + post: + operationId: notifications_notifications_create + description: Viewset for handling notification operations. + tags: + - notifications + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Notification' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Notification' + multipart/form-data: + schema: + $ref: '#/components/schemas/Notification' + required: true + security: + - jwtAuth: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/Notification' + description: '' + /api/v1/notifications/notifications/{id}/: + get: + operationId: notifications_notifications_retrieve + description: Viewset for handling notification operations. + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this notification. + required: true + tags: + - notifications + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Notification' + description: '' + put: + operationId: notifications_notifications_update + description: Viewset for handling notification operations. + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this notification. + required: true + tags: + - notifications + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Notification' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Notification' + multipart/form-data: + schema: + $ref: '#/components/schemas/Notification' + required: true + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Notification' + description: '' + patch: + operationId: notifications_notifications_partial_update + description: Viewset for handling notification operations. + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this notification. + required: true + tags: + - notifications + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedNotification' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedNotification' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedNotification' + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Notification' + description: '' + delete: + operationId: notifications_notifications_destroy + description: Viewset for handling notification operations. + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this notification. + required: true + tags: + - notifications + security: + - jwtAuth: [] + responses: + '204': + description: No response body /api/v1/profile/organization-profile/: get: operationId: profile_organization_profile_list @@ -151,6 +298,21 @@ paths: responses: '204': description: No response body + /api/v1/profile/organization-profile/get_organization_recipients/: + get: + operationId: profile_organization_profile_get_organization_recipients_retrieve + description: Retrieve all recipients associated with the authenticated organization. + tags: + - profile + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/OrganizationProfile' + description: '' /api/v1/profile/recipient-profile/: get: operationId: profile_recipient_profile_list @@ -537,6 +699,34 @@ paths: description: No response body components: schemas: + Notification: + type: object + description: Serializer for the Notification model. + properties: + id: + type: integer + readOnly: true + type: + $ref: '#/components/schemas/TypeEnum' + message: + type: string + is_read: + type: boolean + readOnly: true + created_at: + type: string + format: date-time + readOnly: true + user: + type: integer + readOnly: true + required: + - created_at + - id + - is_read + - message + - type + - user OrganizationProfile: type: object description: Serializer for OrganizationProfile model. @@ -578,6 +768,27 @@ components: - name - recipients - updated_at + PatchedNotification: + type: object + description: Serializer for the Notification model. + properties: + id: + type: integer + readOnly: true + type: + $ref: '#/components/schemas/TypeEnum' + message: + type: string + is_read: + type: boolean + readOnly: true + created_at: + type: string + format: date-time + readOnly: true + user: + type: integer + readOnly: true PatchedOrganizationProfile: type: object description: Serializer for OrganizationProfile model. @@ -688,6 +899,24 @@ components: - organization - recipient_ethereum_address - updated_at + TypeEnum: + enum: + - recipientAdded + - recipientRemoved + - recipientUpdated + - organizationUpdated + - organizationAdded + - organizationRemoved + - login + type: string + description: |- + * `recipientAdded` - Recipient Added + * `recipientRemoved` - Recipient Removed + * `recipientUpdated` - Recipient Updated + * `organizationUpdated` - Organization Updated + * `organizationAdded` - Organization Added + * `organizationRemoved` - Organization Removed + * `login` - Login WaitlistEntry: type: object properties: diff --git a/user_profile/views.py b/user_profile/views.py index 73a8861..c4e459a 100644 --- a/user_profile/views.py +++ b/user_profile/views.py @@ -5,6 +5,7 @@ from .serializers import OrganizationProfileSerializer, RecipientProfileSerializer from rest_framework.permissions import IsAuthenticated from rest_framework.decorators import action +from notifications.models import Notification @@ -24,7 +25,32 @@ def get_queryset(self): return super().get_queryset().filter(user=self.request.user) def perform_create(self, serializer): + serializer.save(user=self.request.user) + notification = Notification.objects.create( + user=self.request.user, + type="organizationAdded", + message="Your organization profile has been created successfully.", + is_read=False + ) + notification.save() + + @action(detail=False, methods=['get']) + def get_organization_recipients(self, request): + """ + Retrieve all recipients associated with the authenticated organization. + """ + try: + organization = OrganizationProfile.objects.get(user=request.user) + recipients = organization.recipients.all() + serializer = RecipientProfileSerializer(recipients, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + except OrganizationProfile.DoesNotExist: + return Response( + {"detail": "Organization profile not found."}, + status=status.HTTP_404_NOT_FOUND + ) + class RecipientProfileView(ModelViewSet): @@ -48,8 +74,16 @@ def batch_create(self, request, *args, **kwargs): Create multiple recipient profiles in a single request. """ serializer = self.get_serializer(data=request.data, many=True) + serializer.is_valid(raise_exception=True) self.perform_create(serializer) + notification = Notification.objects.create( + user=self.request.user, + type="recipientAdded", + message="New recipients have been added successfully.", + is_read=False + ) + notification.save() return Response(serializer.data, status=status.HTTP_201_CREATED) def perform_create(self, serializer): diff --git a/web3auth/views.py b/web3auth/views.py index ce53da2..2ad09fa 100644 --- a/web3auth/views.py +++ b/web3auth/views.py @@ -6,6 +6,7 @@ from django.contrib.auth import get_user_model import secrets import string +from notifications.models import Notification User = get_user_model() @@ -48,6 +49,13 @@ def post(self, request): return Response({'error': 'User not found'}, status=status.HTTP_400_BAD_REQUEST) user_serializer = UserSerializer(user) + + notification = Notification.objects.create( + user=user, + type="login", + message="Login Succefully", + is_read=False + ) return Response({ 'user': user_serializer.data, From 6b33fb45c06909172fdf61ae1cdc558539a29ef2 Mon Sep 17 00:00:00 2001 From: leojay-net Date: Tue, 29 Apr 2025 03:39:26 +0100 Subject: [PATCH 06/39] feat(user_profile): enhance create method to support updating existing profiles for organization and recipient --- user_profile/views.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/user_profile/views.py b/user_profile/views.py index c4e459a..58f2729 100644 --- a/user_profile/views.py +++ b/user_profile/views.py @@ -23,6 +23,20 @@ def get_queryset(self): by filtering against a `user` query parameter in the URL. """ return super().get_queryset().filter(user=self.request.user) + + def create(self, request, *args, **kwargs): + # Check if user already has an organization profile + try: + instance = OrganizationProfile.objects.get(user=request.user) + serializer = self.get_serializer(instance, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + self.perform_update(serializer) + return Response(serializer.data, status=status.HTTP_200_OK) + except OrganizationProfile.DoesNotExist: + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + return Response(serializer.data, status=status.HTTP_201_CREATED) def perform_create(self, serializer): @@ -68,6 +82,20 @@ def get_queryset(self): """ return super().get_queryset().filter(user=self.request.user) + def create(self, request, *args, **kwargs): + # Check if user already has an organization profile + try: + instance = RecipientProfile.objects.get(user=request.user) + serializer = self.get_serializer(instance, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + self.perform_update(serializer) + return Response(serializer.data, status=status.HTTP_200_OK) + except RecipientProfile.DoesNotExist: + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + return Response(serializer.data, status=status.HTTP_201_CREATED) + @action(detail=True, methods=['post']) def batch_create(self, request, *args, **kwargs): """ From 1adcab8620a228f0a206b31e037026addc331c17 Mon Sep 17 00:00:00 2001 From: leojay-net Date: Tue, 29 Apr 2025 13:44:16 +0100 Subject: [PATCH 07/39] feat(recipient_profile): add salary, position, and status fields to RecipientProfile model and update serializer --- schema.yml | 30 +++++++++++++++++ ...sition_recipientprofile_salary_and_more.py | 32 +++++++++++++++++++ user_profile/models.py | 10 ++++++ user_profile/serializers.py | 5 ++- web3auth/serializers.py | 7 ---- 5 files changed, 76 insertions(+), 8 deletions(-) create mode 100644 user_profile/migrations/0003_recipientprofile_position_recipientprofile_salary_and_more.py diff --git a/schema.yml b/schema.yml index 814fa7e..7b45cd9 100644 --- a/schema.yml +++ b/schema.yml @@ -846,6 +846,17 @@ components: type: string nullable: true maxLength: 15 + salary: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + position: + type: string + nullable: true + maxLength: 100 + status: + $ref: '#/components/schemas/StatusEnum' created_at: type: string format: date-time @@ -883,6 +894,17 @@ components: type: string nullable: true maxLength: 15 + salary: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + position: + type: string + nullable: true + maxLength: 100 + status: + $ref: '#/components/schemas/StatusEnum' created_at: type: string format: date-time @@ -899,6 +921,14 @@ components: - organization - recipient_ethereum_address - updated_at + StatusEnum: + enum: + - active + - on_leave + type: string + description: |- + * `active` - Active + * `on_leave` - On Leave TypeEnum: enum: - recipientAdded diff --git a/user_profile/migrations/0003_recipientprofile_position_recipientprofile_salary_and_more.py b/user_profile/migrations/0003_recipientprofile_position_recipientprofile_salary_and_more.py new file mode 100644 index 0000000..0ff1f69 --- /dev/null +++ b/user_profile/migrations/0003_recipientprofile_position_recipientprofile_salary_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2 on 2025-04-29 12:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("user_profile", "0002_initial"), + ] + + operations = [ + migrations.AddField( + model_name="recipientprofile", + name="position", + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AddField( + model_name="recipientprofile", + name="salary", + field=models.IntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name="recipientprofile", + name="status", + field=models.CharField( + choices=[("active", "Active"), ("on_leave", "On Leave")], + default="active", + max_length=30, + ), + ), + ] diff --git a/user_profile/models.py b/user_profile/models.py index af2be5a..59529aa 100644 --- a/user_profile/models.py +++ b/user_profile/models.py @@ -32,6 +32,16 @@ class RecipientProfile(models.Model): organization = models.ForeignKey(OrganizationProfile, on_delete=models.CASCADE, related_name='recipients') recipient_ethereum_address = models.CharField(max_length=42, unique=True) recipient_phone = models.CharField(max_length=15, blank=True, null=True) + salary = models.IntegerField(blank=True, null=True) + position = models.CharField(max_length=100, blank=True, null=True) + status = models.CharField( + max_length=30, + choices=[ + ('active', 'Active'), + ('on_leave', 'On Leave') + ], + default='active' + ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/user_profile/serializers.py b/user_profile/serializers.py index 40a5be0..93f9bbf 100644 --- a/user_profile/serializers.py +++ b/user_profile/serializers.py @@ -12,7 +12,7 @@ class RecipientProfileSerializer(serializers.ModelSerializer): class Meta: model = RecipientProfile fields = ['id', 'name', 'email', 'organization', 'recipient_ethereum_address', - 'recipient_phone', 'created_at', 'updated_at'] + 'recipient_phone', 'salary', 'position','status','created_at', 'updated_at'] read_only_fields = ['id', 'created_at', 'updated_at'] def to_representation(self, instance): @@ -24,6 +24,9 @@ def to_representation(self, instance): "name": instance.name, "organization": instance.organization.name, "wallet_address": instance.user.wallet_address, + "salary": instance.salary, + "position": instance.position, + "status": instance.status, "created_at": instance.created_at.isoformat() if instance.created_at else None, "updated_at": instance.updated_at.isoformat() if instance.updated_at else None, } diff --git a/web3auth/serializers.py b/web3auth/serializers.py index 740318b..b803526 100644 --- a/web3auth/serializers.py +++ b/web3auth/serializers.py @@ -58,13 +58,6 @@ def validate(self, data): user.nonce = ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(32)) user.save() - # # Generate JWT token - # payload = { - # 'user_id': user.id, - # 'address': user.wallet_address, - # 'username': user.get_username() - # } - # token = jwt.encode(payload, settings.SECRET_KEY, algorithm='HS256') refresh = RefreshToken.for_user(user) refresh['address'] = user.wallet_address From 847f0bd68ce063395333c69c7426556afac51c6c Mon Sep 17 00:00:00 2001 From: leojay-net Date: Wed, 30 Apr 2025 10:50:51 +0100 Subject: [PATCH 08/39] feat(payroll): add payroll app with models, serializers, and views for managing payroll information --- HR_Backend/settings.py | 1 + payroll/__init__.py | 0 payroll/admin.py | 3 ++ payroll/apps.py | 6 ++++ payroll/migrations/__init__.py | 0 payroll/models.py | 14 +++++++++ payroll/serializers.py | 13 ++++++++ payroll/tests.py | 3 ++ payroll/urls.py | 0 payroll/views.py | 3 ++ schema.yml | 54 ++++++++++++++++++++++++++++++++++ user_profile/serializers.py | 40 ++++++++++++++++++++++--- user_profile/views.py | 2 +- 13 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 payroll/__init__.py create mode 100644 payroll/admin.py create mode 100644 payroll/apps.py create mode 100644 payroll/migrations/__init__.py create mode 100644 payroll/models.py create mode 100644 payroll/serializers.py create mode 100644 payroll/tests.py create mode 100644 payroll/urls.py create mode 100644 payroll/views.py diff --git a/HR_Backend/settings.py b/HR_Backend/settings.py index 63d3351..09f9def 100644 --- a/HR_Backend/settings.py +++ b/HR_Backend/settings.py @@ -47,6 +47,7 @@ 'web3auth.apps.Web3AuthConfig', 'user_profile.apps.UserProfileConfig', 'notifications.apps.NotificationsConfig', + 'payroll.apps.PayrollConfig', # Third-party apps 'corsheaders', diff --git a/payroll/__init__.py b/payroll/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/payroll/admin.py b/payroll/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/payroll/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/payroll/apps.py b/payroll/apps.py new file mode 100644 index 0000000..7b64e72 --- /dev/null +++ b/payroll/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PayrollConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "payroll" diff --git a/payroll/migrations/__init__.py b/payroll/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/payroll/models.py b/payroll/models.py new file mode 100644 index 0000000..2b8eb3f --- /dev/null +++ b/payroll/models.py @@ -0,0 +1,14 @@ +from django.db import models +from user_profile.models import RecipientProfile + +class PayRoll(models.Model): + """ + Model to store payroll information for users. + """ + user = models.ForeignKey(RecipientProfile, on_delete=models.CASCADE, related_name='payrolls') + amount = models.DecimalField(max_digits=10, decimal_places=2) + date = models.DateField() + is_paid = models.BooleanField(default=False) + + def __str__(self): + return f"Payroll for {self.user.username} - {self.date}" \ No newline at end of file diff --git a/payroll/serializers.py b/payroll/serializers.py new file mode 100644 index 0000000..bb66d91 --- /dev/null +++ b/payroll/serializers.py @@ -0,0 +1,13 @@ +from rest_framework import serializers + +from .models import PayRoll + +class PayRollSerializer(serializers.ModelSerializer): + """ + Serializer for the PayRoll model. + """ + class Meta: + model = PayRoll + fields = '__all__' + read_only_fields = ['user', 'id' ,'is_read', 'created_at'] + \ No newline at end of file diff --git a/payroll/tests.py b/payroll/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/payroll/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/payroll/urls.py b/payroll/urls.py new file mode 100644 index 0000000..e69de29 diff --git a/payroll/views.py b/payroll/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/payroll/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/schema.yml b/schema.yml index 7b45cd9..2fcabd2 100644 --- a/schema.yml +++ b/schema.yml @@ -741,6 +741,10 @@ components: type: string format: email maxLength: 254 + user: + allOf: + - $ref: '#/components/schemas/User' + readOnly: true organization_address: type: string nullable: true @@ -753,6 +757,9 @@ components: items: $ref: '#/components/schemas/RecipientProfile' readOnly: true + total_recipients: + type: string + readOnly: true created_at: type: string format: date-time @@ -767,7 +774,9 @@ components: - id - name - recipients + - total_recipients - updated_at + - user PatchedNotification: type: object description: Serializer for the Notification model. @@ -803,6 +812,10 @@ components: type: string format: email maxLength: 254 + user: + allOf: + - $ref: '#/components/schemas/User' + readOnly: true organization_address: type: string nullable: true @@ -815,6 +828,9 @@ components: items: $ref: '#/components/schemas/RecipientProfile' readOnly: true + total_recipients: + type: string + readOnly: true created_at: type: string format: date-time @@ -837,6 +853,10 @@ components: type: string format: email maxLength: 254 + user: + allOf: + - $ref: '#/components/schemas/User' + readOnly: true organization: type: integer recipient_ethereum_address: @@ -885,6 +905,10 @@ components: type: string format: email maxLength: 254 + user: + allOf: + - $ref: '#/components/schemas/User' + readOnly: true organization: type: integer recipient_ethereum_address: @@ -921,6 +945,7 @@ components: - organization - recipient_ethereum_address - updated_at + - user StatusEnum: enum: - active @@ -947,6 +972,35 @@ components: * `organizationAdded` - Organization Added * `organizationRemoved` - Organization Removed * `login` - Login + User: + type: object + properties: + id: + type: integer + readOnly: true + username: + type: string + description: Required. 150 characters or fewer. Letters, digits and @/./+/-/_ + only. + pattern: ^[\w.@+-]+$ + maxLength: 150 + wallet_address: + type: string + nullable: true + maxLength: 42 + user_type: + $ref: '#/components/schemas/UserTypeEnum' + required: + - id + - username + UserTypeEnum: + enum: + - recipient + - organization + type: string + description: |- + * `recipient` - Recipient + * `organization` - Organization WaitlistEntry: type: object properties: diff --git a/user_profile/serializers.py b/user_profile/serializers.py index 93f9bbf..89bf3fd 100644 --- a/user_profile/serializers.py +++ b/user_profile/serializers.py @@ -1,5 +1,6 @@ from rest_framework import serializers from .models import OrganizationProfile, RecipientProfile +from web3auth.serializers import UserSerializer @@ -8,10 +9,11 @@ class RecipientProfileSerializer(serializers.ModelSerializer): """ Serializer for RecipientProfile model. """ + user = UserSerializer(read_only=True) class Meta: model = RecipientProfile - fields = ['id', 'name', 'email', 'organization', 'recipient_ethereum_address', + fields = ['id', 'name', 'email', 'user','organization', 'recipient_ethereum_address', 'recipient_phone', 'salary', 'position','status','created_at', 'updated_at'] read_only_fields = ['id', 'created_at', 'updated_at'] @@ -22,7 +24,11 @@ def to_representation(self, instance): return { "id": instance.id, "name": instance.name, + "email": instance.email, # Added missing email field "organization": instance.organization.name, + "user": UserSerializer(instance.user).data, # Use serializer instead of direct query + "recipient_ethereum_address": instance.recipient_ethereum_address, # Added missing field + "recipient_phone": instance.recipient_phone, # Added missing field "wallet_address": instance.user.wallet_address, "salary": instance.salary, "position": instance.position, @@ -39,9 +45,35 @@ class OrganizationProfileSerializer(serializers.ModelSerializer): created_at = serializers.DateTimeField(read_only=True) updated_at = serializers.DateTimeField(read_only=True) recipients = RecipientProfileSerializer(many=True, read_only=True) - + user = UserSerializer(read_only=True) + total_recipients = serializers.SerializerMethodField() class Meta: model = OrganizationProfile - fields = ['id', 'name', 'email', 'organization_address', 'organization_phone', 'recipients', 'created_at', 'updated_at'] - + fields = [ + 'id', + 'name', + 'email', + 'user', + 'organization_address', + 'organization_phone', + 'recipients', + 'total_recipients', + 'created_at', + 'updated_at' + ] + read_only_fields = ['id', 'created_at', 'updated_at'] + + def get_total_recipients(self, obj): + """ + Get the total number of recipients in the organization. + """ + return obj.recipients.count() + + def validate_email(self, value): + """ + Validate email format and uniqueness. + """ + if OrganizationProfile.objects.filter(email=value).exists(): + raise serializers.ValidationError("This email is already registered.") + return value.lower() \ No newline at end of file diff --git a/user_profile/views.py b/user_profile/views.py index 58f2729..0cb912d 100644 --- a/user_profile/views.py +++ b/user_profile/views.py @@ -55,7 +55,7 @@ def get_organization_recipients(self, request): Retrieve all recipients associated with the authenticated organization. """ try: - organization = OrganizationProfile.objects.get(user=request.user) + organization = OrganizationProfile.objects.get(user=self.request.user.id) recipients = organization.recipients.all() serializer = RecipientProfileSerializer(recipients, many=True) return Response(serializer.data, status=status.HTTP_200_OK) From 7bdd38a55c12b7731759ae0a7b2845be61c99bf8 Mon Sep 17 00:00:00 2001 From: leojay-net Date: Wed, 30 Apr 2025 11:37:17 +0100 Subject: [PATCH 09/39] feat(settings): add JWT configuration for token management and authentication --- HR_Backend/settings.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/HR_Backend/settings.py b/HR_Backend/settings.py index 09f9def..39dc3a0 100644 --- a/HR_Backend/settings.py +++ b/HR_Backend/settings.py @@ -14,6 +14,7 @@ import os from decouple import config from dotenv import load_dotenv +from django.utils.timezone import timedelta # Load environment variables from .env file load_dotenv() @@ -190,4 +191,44 @@ AUTH_USER_MODEL = 'web3auth.User' +IMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=300), + "REFRESH_TOKEN_LIFETIME": timedelta(days=2), + "ROTATE_REFRESH_TOKENS": True, + "BLACKLIST_AFTER_ROTATION": True, + "UPDATE_LAST_LOGIN": True, + + "ALGORITHM": "HS256", + "SIGNING_KEY": SECRET_KEY, + "VERIFYING_KEY": "", + "AUDIENCE": None, + "ISSUER": None, + "JSON_ENCODER": None, + "JWK_URL": None, + "LEEWAY": 0, + + "AUTH_HEADER_TYPES": ("Bearer",), + "AUTH_HEADER_NAME": "HTTP_AUTHORIZATION", + "USER_ID_FIELD": "id", + "USER_ID_CLAIM": "user_id", + "USER_AUTHENTICATION_RULE": "rest_framework_simplejwt.authentication.default_user_authentication_rule", + + "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), + "TOKEN_TYPE_CLAIM": "token_type", + "TOKEN_USER_CLASS": "rest_framework_simplejwt.models.TokenUser", + + "JTI_CLAIM": "jti", + + "SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp", + "SLIDING_TOKEN_LIFETIME": timedelta(minutes=5), + "SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1), + + "TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainPairSerializer", + "TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSerializer", + "TOKEN_VERIFY_SERIALIZER": "rest_framework_simplejwt.serializers.TokenVerifySerializer", + "TOKEN_BLACKLIST_SERIALIZER": "rest_framework_simplejwt.serializers.TokenBlacklistSerializer", + "SLIDING_TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainSlidingSerializer", + "SLIDING_TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSlidingSerializer", +} + CORS_ALLOW_ALL_ORIGINS = True \ No newline at end of file From 12998b05b5e1da974a7a378d393decb93a29f42b Mon Sep 17 00:00:00 2001 From: leojay-net Date: Wed, 30 Apr 2025 13:38:26 +0100 Subject: [PATCH 10/39] feat(payroll): create initial migration for PayRoll model with fields for amount, date, and payment status feat(user_profile): update OrganizationProfile and RecipientProfile models to remove unique constraints on name and email fields --- payroll/migrations/0001_initial.py | 41 +++++++++++++++++++ ...lter_organizationprofile_email_and_more.py | 36 ++++++++++++++++ user_profile/models.py | 8 ++-- 3 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 payroll/migrations/0001_initial.py create mode 100644 user_profile/migrations/0004_alter_organizationprofile_email_and_more.py diff --git a/payroll/migrations/0001_initial.py b/payroll/migrations/0001_initial.py new file mode 100644 index 0000000..bd0053f --- /dev/null +++ b/payroll/migrations/0001_initial.py @@ -0,0 +1,41 @@ +# Generated by Django 5.2 on 2025-04-30 12:36 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("user_profile", "0004_alter_organizationprofile_email_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="PayRoll", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("amount", models.DecimalField(decimal_places=2, max_digits=10)), + ("date", models.DateField()), + ("is_paid", models.BooleanField(default=False)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="payrolls", + to="user_profile.recipientprofile", + ), + ), + ], + ), + ] diff --git a/user_profile/migrations/0004_alter_organizationprofile_email_and_more.py b/user_profile/migrations/0004_alter_organizationprofile_email_and_more.py new file mode 100644 index 0000000..0757ff6 --- /dev/null +++ b/user_profile/migrations/0004_alter_organizationprofile_email_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 5.2 on 2025-04-30 12:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "user_profile", + "0003_recipientprofile_position_recipientprofile_salary_and_more", + ), + ] + + operations = [ + migrations.AlterField( + model_name="organizationprofile", + name="email", + field=models.EmailField(max_length=254), + ), + migrations.AlterField( + model_name="organizationprofile", + name="name", + field=models.CharField(max_length=150), + ), + migrations.AlterField( + model_name="recipientprofile", + name="email", + field=models.EmailField(max_length=254), + ), + migrations.AlterField( + model_name="recipientprofile", + name="name", + field=models.CharField(max_length=150), + ), + ] diff --git a/user_profile/models.py b/user_profile/models.py index 59529aa..b1e0e74 100644 --- a/user_profile/models.py +++ b/user_profile/models.py @@ -7,8 +7,8 @@ class OrganizationProfile(models.Model): Organization profile model to store organization information. """ user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - name = models.CharField(max_length=150, unique=True) - email = models.EmailField(unique=True) + name = models.CharField(max_length=150) + email = models.EmailField() organization_address = models.TextField(blank=True, null=True) organization_phone = models.CharField(max_length=15, blank=True, null=True) created_at = models.DateTimeField(auto_now_add=True) @@ -27,8 +27,8 @@ class RecipientProfile(models.Model): Recipient profile model to store recipient information. """ user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - name = models.CharField(max_length=150, unique=True) - email = models.EmailField(unique=True) + name = models.CharField(max_length=150) + email = models.EmailField() organization = models.ForeignKey(OrganizationProfile, on_delete=models.CASCADE, related_name='recipients') recipient_ethereum_address = models.CharField(max_length=42, unique=True) recipient_phone = models.CharField(max_length=15, blank=True, null=True) From bd8b87ea5da9eb6a658e7fe87e523956c2a7363d Mon Sep 17 00:00:00 2001 From: leojay-net Date: Wed, 30 Apr 2025 14:13:15 +0100 Subject: [PATCH 11/39] feat(auth): set expiration for refresh and access tokens in EthereumAuthSerializer --- web3auth/serializers.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web3auth/serializers.py b/web3auth/serializers.py index b803526..7d6f039 100644 --- a/web3auth/serializers.py +++ b/web3auth/serializers.py @@ -8,6 +8,7 @@ import string import logging from rest_framework_simplejwt.tokens import RefreshToken +from datetime import timedelta logger = logging.getLogger(__name__) User = get_user_model() @@ -59,6 +60,8 @@ def validate(self, data): user.save() refresh = RefreshToken.for_user(user) + refresh.set_exp(lifetime=timedelta(days=7)) + refresh.access_token.set_exp(lifetime=timedelta(hours=24)) refresh['address'] = user.wallet_address return { From af577b0621c6e5d6d0c54f554acfaddaa108f246 Mon Sep 17 00:00:00 2001 From: leojay-net Date: Wed, 30 Apr 2025 15:27:52 +0100 Subject: [PATCH 12/39] feat(payroll): implement payroll management with CRUD operations, validation, and summary reporting --- HR_Backend/urls.py | 1 + ...ll_options_remove_payroll_user_and_more.py | 102 ++++++ payroll/models.py | 34 +- payroll/serializers.py | 55 ++- payroll/urls.py | 10 + payroll/views.py | 121 ++++++- schema.yml | 335 +++++++++++++++++- 7 files changed, 646 insertions(+), 12 deletions(-) create mode 100644 payroll/migrations/0002_alter_payroll_options_remove_payroll_user_and_more.py diff --git a/HR_Backend/urls.py b/HR_Backend/urls.py index 6819e3e..e3c1a0d 100644 --- a/HR_Backend/urls.py +++ b/HR_Backend/urls.py @@ -29,5 +29,6 @@ path('api/v1/web3auth/', include('web3auth.urls')), path('api/v1/profile/', include('user_profile.urls')), path('api/v1/notifications/', include('notifications.urls')), + path('api/v1/payroll/', include('payroll.urls')), ] diff --git a/payroll/migrations/0002_alter_payroll_options_remove_payroll_user_and_more.py b/payroll/migrations/0002_alter_payroll_options_remove_payroll_user_and_more.py new file mode 100644 index 0000000..5fcdbf2 --- /dev/null +++ b/payroll/migrations/0002_alter_payroll_options_remove_payroll_user_and_more.py @@ -0,0 +1,102 @@ +# Generated by Django 5.2 on 2025-04-30 14:21 + +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("payroll", "0001_initial"), + ("user_profile", "0004_alter_organizationprofile_email_and_more"), + ] + + operations = [ + migrations.AlterModelOptions( + name="payroll", + options={"ordering": ["-date", "-created_at"]}, + ), + migrations.RemoveField( + model_name="payroll", + name="user", + ), + migrations.AddField( + model_name="payroll", + name="batch_reference", + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AddField( + model_name="payroll", + name="created_at", + field=models.DateTimeField( + auto_now_add=True, default=django.utils.timezone.now + ), + preserve_default=False, + ), + migrations.AddField( + model_name="payroll", + name="description", + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name="payroll", + name="organization", + field=models.ForeignKey( + default=0, + on_delete=django.db.models.deletion.CASCADE, + related_name="organization", + to="user_profile.organizationprofile", + ), + preserve_default=False, + ), + migrations.AddField( + model_name="payroll", + name="paid_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="payroll", + name="recipient", + field=models.ForeignKey( + default=2, + on_delete=django.db.models.deletion.CASCADE, + related_name="recipients", + to="user_profile.recipientprofile", + ), + preserve_default=False, + ), + migrations.AddField( + model_name="payroll", + name="status", + field=models.CharField( + choices=[ + ("pending", "Pending"), + ("completed", "Completed"), + ("failed", "Failed"), + ], + default="pending", + max_length=30, + ), + ), + migrations.AlterField( + model_name="payroll", + name="amount", + field=models.DecimalField( + decimal_places=2, + max_digits=10, + validators=[django.core.validators.MinValueValidator(0.01)], + ), + ), + migrations.AddIndex( + model_name="payroll", + index=models.Index( + fields=["batch_reference"], name="payroll_pay_batch_r_d942fb_idx" + ), + ), + migrations.AddIndex( + model_name="payroll", + index=models.Index(fields=["status"], name="payroll_pay_status_465a76_idx"), + ), + ] diff --git a/payroll/models.py b/payroll/models.py index 2b8eb3f..6a7a652 100644 --- a/payroll/models.py +++ b/payroll/models.py @@ -1,14 +1,40 @@ from django.db import models -from user_profile.models import RecipientProfile +from django.core.validators import MinValueValidator +from user_profile.models import RecipientProfile, OrganizationProfile class PayRoll(models.Model): """ Model to store payroll information for users. """ - user = models.ForeignKey(RecipientProfile, on_delete=models.CASCADE, related_name='payrolls') - amount = models.DecimalField(max_digits=10, decimal_places=2) + recipient = models.ForeignKey(RecipientProfile, on_delete=models.CASCADE, related_name='recipients') + organization = models.ForeignKey(OrganizationProfile, on_delete=models.CASCADE, related_name='organization') + amount = models.DecimalField( + max_digits=10, + decimal_places=2, + validators=[MinValueValidator(0.01)] + ) + batch_reference = models.CharField(max_length=50, blank=True, null=True) + description = models.TextField(blank=True) date = models.DateField() + created_at = models.DateTimeField(auto_now_add=True) + paid_at = models.DateTimeField(null=True, blank=True) + status = models.CharField( + max_length=30, + choices=[ + ('pending', 'Pending'), + ('completed', 'Completed'), + ('failed', 'Failed') + ], + default='pending' + ) is_paid = models.BooleanField(default=False) + class Meta: + ordering = ['-date', '-created_at'] + indexes = [ + models.Index(fields=['batch_reference']), + models.Index(fields=['status']), + ] + def __str__(self): - return f"Payroll for {self.user.username} - {self.date}" \ No newline at end of file + return f"Payroll for {self.recipient.recipient_ethereum_address} - {self.date}" \ No newline at end of file diff --git a/payroll/serializers.py b/payroll/serializers.py index bb66d91..b175715 100644 --- a/payroll/serializers.py +++ b/payroll/serializers.py @@ -1,13 +1,60 @@ from rest_framework import serializers - from .models import PayRoll +from user_profile.serializers import RecipientProfileSerializer, OrganizationProfileSerializer class PayRollSerializer(serializers.ModelSerializer): """ Serializer for the PayRoll model. """ + recipient_details = RecipientProfileSerializer(source='recipient', read_only=True) + organization_details = OrganizationProfileSerializer(source='organization', read_only=True) + class Meta: model = PayRoll - fields = '__all__' - read_only_fields = ['user', 'id' ,'is_read', 'created_at'] - \ No newline at end of file + fields = [ + 'id', + 'recipient', + 'recipient_details', + 'organization', + 'organization_details', + 'amount', + 'batch_reference', + 'description', + 'date', + 'created_at', + 'paid_at', + 'status', + 'is_paid' + ] + read_only_fields = ['id', 'created_at', 'paid_at', 'is_paid'] + + def validate_amount(self, value): + """ + Validate that amount is positive + """ + if value <= 0: + raise serializers.ValidationError("Amount must be greater than zero") + return value + + def validate(self, data): + """ + Custom validation to ensure organization can't create duplicate payrolls + for the same recipient on the same date + """ + recipient = data.get('recipient') + organization = data.get('organization') + date = data.get('date') + + # Check if this is an update operation + instance = self.instance + if instance is None: # This is a create operation + if PayRoll.objects.filter( + recipient=recipient, + organization=organization, + date=date + ).exists(): + raise serializers.ValidationError( + "A payroll entry already exists for this recipient on this date" + ) + + return data diff --git a/payroll/urls.py b/payroll/urls.py index e69de29..09ea199 100644 --- a/payroll/urls.py +++ b/payroll/urls.py @@ -0,0 +1,10 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import PayRollViewSet + +router = DefaultRouter() +router.register(r'payrolls', PayRollViewSet, basename='payroll') + +urlpatterns = [ + path('', include(router.urls)), +] \ No newline at end of file diff --git a/payroll/views.py b/payroll/views.py index 91ea44a..cc18b1f 100644 --- a/payroll/views.py +++ b/payroll/views.py @@ -1,3 +1,122 @@ from django.shortcuts import render +from rest_framework import viewsets, status +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from rest_framework.decorators import action +from django.db.models import Sum +from datetime import datetime +from .models import PayRoll +from .serializers import PayRollSerializer +from user_profile.models import RecipientProfile, OrganizationProfile -# Create your views here. +class PayRollViewSet(viewsets.ModelViewSet): + """ + ViewSet for handling PayRoll operations. + Provides different views based on user type (organization or recipient) + """ + serializer_class = PayRollSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + """ + Filter queryset based on user type: + - Organizations see all their payrolls + - Recipients see only their payrolls + """ + user = self.request.user + + try: + # Check if user is an organization + org_profile = OrganizationProfile.objects.get(user=user) + return PayRoll.objects.filter(organization=org_profile) + except OrganizationProfile.DoesNotExist: + try: + # Check if user is a recipient + recipient_profile = RecipientProfile.objects.get(user=user) + return PayRoll.objects.filter(recipient=recipient_profile) + except RecipientProfile.DoesNotExist: + return PayRoll.objects.none() + + def perform_create(self, serializer): + """ + Ensure organization can only create payrolls for their own account + """ + try: + org_profile = OrganizationProfile.objects.get(user=self.request.user) + serializer.save(organization=org_profile) + except OrganizationProfile.DoesNotExist: + raise PermissionError("Only organizations can create payroll entries") + + @action(detail=False, methods=['get']) + def summary(self, request): + """ + Get summary of payrolls including total amount paid and pending + """ + queryset = self.get_queryset() + total_paid = queryset.filter(is_paid=True).aggregate(Sum('amount')) + total_pending = queryset.filter(is_paid=False).aggregate(Sum('amount')) + + return Response({ + 'total_paid': total_paid['amount__sum'] or 0, + 'total_pending': total_pending['amount__sum'] or 0, + 'total_entries': queryset.count() + }) + + @action(detail=False, methods=['get']) + def monthly_report(self, request): + """ + Get monthly breakdown of payrolls + """ + year = request.query_params.get('year', datetime.now().year) + queryset = self.get_queryset().filter(date__year=year) + + monthly_data = {} + for month in range(1, 13): + monthly_amount = queryset.filter( + date__month=month, + is_paid=True + ).aggregate(Sum('amount'))['amount__sum'] or 0 + + monthly_data[month] = monthly_amount + + return Response(monthly_data) + + def update(self, request, *args, **kwargs): + """ + Update payroll entry with additional validation + """ + partial = kwargs.pop('partial', False) + instance = self.get_object() + + # Prevent updating certain fields after payment + if instance.is_paid and not request.user.is_staff: + return Response( + {"detail": "Paid payrolls cannot be modified"}, + status=status.HTTP_400_BAD_REQUEST + ) + + serializer = self.get_serializer(instance, data=request.data, partial=partial) + serializer.is_valid(raise_exception=True) + self.perform_update(serializer) + + return Response(serializer.data) + + @action(detail=True, methods=['post']) + def mark_as_paid(self, request, pk=None): + """ + Mark a payroll entry as paid + """ + payroll = self.get_object() + if payroll.is_paid: + return Response( + {"detail": "Payroll already marked as paid"}, + status=status.HTTP_400_BAD_REQUEST + ) + + payroll.is_paid = True + payroll.paid_at = datetime.now() + payroll.status = 'completed' + payroll.save() + + serializer = self.get_serializer(payroll) + return Response(serializer.data) diff --git a/schema.yml b/schema.yml index 2fcabd2..268ce9d 100644 --- a/schema.yml +++ b/schema.yml @@ -151,6 +151,222 @@ paths: responses: '204': description: No response body + /api/v1/payroll/payrolls/: + get: + operationId: payroll_payrolls_list + description: |- + ViewSet for handling PayRoll operations. + Provides different views based on user type (organization or recipient) + tags: + - payroll + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PayRoll' + description: '' + post: + operationId: payroll_payrolls_create + description: |- + ViewSet for handling PayRoll operations. + Provides different views based on user type (organization or recipient) + tags: + - payroll + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PayRoll' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PayRoll' + multipart/form-data: + schema: + $ref: '#/components/schemas/PayRoll' + required: true + security: + - jwtAuth: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/PayRoll' + description: '' + /api/v1/payroll/payrolls/{id}/: + get: + operationId: payroll_payrolls_retrieve + description: |- + ViewSet for handling PayRoll operations. + Provides different views based on user type (organization or recipient) + parameters: + - in: path + name: id + schema: + type: string + required: true + tags: + - payroll + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PayRoll' + description: '' + put: + operationId: payroll_payrolls_update + description: Update payroll entry with additional validation + parameters: + - in: path + name: id + schema: + type: string + required: true + tags: + - payroll + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PayRoll' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PayRoll' + multipart/form-data: + schema: + $ref: '#/components/schemas/PayRoll' + required: true + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PayRoll' + description: '' + patch: + operationId: payroll_payrolls_partial_update + description: |- + ViewSet for handling PayRoll operations. + Provides different views based on user type (organization or recipient) + parameters: + - in: path + name: id + schema: + type: string + required: true + tags: + - payroll + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedPayRoll' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedPayRoll' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedPayRoll' + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PayRoll' + description: '' + delete: + operationId: payroll_payrolls_destroy + description: |- + ViewSet for handling PayRoll operations. + Provides different views based on user type (organization or recipient) + parameters: + - in: path + name: id + schema: + type: string + required: true + tags: + - payroll + security: + - jwtAuth: [] + responses: + '204': + description: No response body + /api/v1/payroll/payrolls/{id}/mark_as_paid/: + post: + operationId: payroll_payrolls_mark_as_paid_create + description: Mark a payroll entry as paid + parameters: + - in: path + name: id + schema: + type: string + required: true + tags: + - payroll + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PayRoll' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PayRoll' + multipart/form-data: + schema: + $ref: '#/components/schemas/PayRoll' + required: true + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PayRoll' + description: '' + /api/v1/payroll/payrolls/monthly_report/: + get: + operationId: payroll_payrolls_monthly_report_retrieve + description: Get monthly breakdown of payrolls + tags: + - payroll + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PayRoll' + description: '' + /api/v1/payroll/payrolls/summary/: + get: + operationId: payroll_payrolls_summary_retrieve + description: Get summary of payrolls including total amount paid and pending + tags: + - payroll + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PayRoll' + description: '' /api/v1/profile/organization-profile/: get: operationId: profile_organization_profile_list @@ -839,6 +1055,52 @@ components: type: string format: date-time readOnly: true + PatchedPayRoll: + type: object + description: Serializer for the PayRoll model. + properties: + id: + type: integer + readOnly: true + recipient: + type: integer + recipient_details: + allOf: + - $ref: '#/components/schemas/RecipientProfile' + readOnly: true + organization: + type: integer + organization_details: + allOf: + - $ref: '#/components/schemas/OrganizationProfile' + readOnly: true + amount: + type: string + format: decimal + pattern: ^-?\d{0,8}(?:\.\d{0,2})?$ + batch_reference: + type: string + nullable: true + maxLength: 50 + description: + type: string + date: + type: string + format: date + created_at: + type: string + format: date-time + readOnly: true + paid_at: + type: string + format: date-time + readOnly: true + nullable: true + status: + $ref: '#/components/schemas/PayRollStatusEnum' + is_paid: + type: boolean + readOnly: true PatchedRecipientProfile: type: object description: Serializer for RecipientProfile model. @@ -876,7 +1138,7 @@ components: nullable: true maxLength: 100 status: - $ref: '#/components/schemas/StatusEnum' + $ref: '#/components/schemas/RecipientProfileStatusEnum' created_at: type: string format: date-time @@ -891,6 +1153,73 @@ components: email: type: string format: email + PayRoll: + type: object + description: Serializer for the PayRoll model. + properties: + id: + type: integer + readOnly: true + recipient: + type: integer + recipient_details: + allOf: + - $ref: '#/components/schemas/RecipientProfile' + readOnly: true + organization: + type: integer + organization_details: + allOf: + - $ref: '#/components/schemas/OrganizationProfile' + readOnly: true + amount: + type: string + format: decimal + pattern: ^-?\d{0,8}(?:\.\d{0,2})?$ + batch_reference: + type: string + nullable: true + maxLength: 50 + description: + type: string + date: + type: string + format: date + created_at: + type: string + format: date-time + readOnly: true + paid_at: + type: string + format: date-time + readOnly: true + nullable: true + status: + $ref: '#/components/schemas/PayRollStatusEnum' + is_paid: + type: boolean + readOnly: true + required: + - amount + - created_at + - date + - id + - is_paid + - organization + - organization_details + - paid_at + - recipient + - recipient_details + PayRollStatusEnum: + enum: + - pending + - completed + - failed + type: string + description: |- + * `pending` - Pending + * `completed` - Completed + * `failed` - Failed RecipientProfile: type: object description: Serializer for RecipientProfile model. @@ -928,7 +1257,7 @@ components: nullable: true maxLength: 100 status: - $ref: '#/components/schemas/StatusEnum' + $ref: '#/components/schemas/RecipientProfileStatusEnum' created_at: type: string format: date-time @@ -946,7 +1275,7 @@ components: - recipient_ethereum_address - updated_at - user - StatusEnum: + RecipientProfileStatusEnum: enum: - active - on_leave From 0ee5e52148e68044faeb7d809625472afe269a46 Mon Sep 17 00:00:00 2001 From: leojay-net Date: Wed, 30 Apr 2025 16:44:22 +0100 Subject: [PATCH 13/39] feat(auth): update JWT token lifetimes and customize token creation in EthereumAuthSerializer --- HR_Backend/settings.py | 39 ++------------------------------------- web3auth/serializers.py | 16 +++++++++++----- 2 files changed, 13 insertions(+), 42 deletions(-) diff --git a/HR_Backend/settings.py b/HR_Backend/settings.py index 39dc3a0..0577bfd 100644 --- a/HR_Backend/settings.py +++ b/HR_Backend/settings.py @@ -192,43 +192,8 @@ AUTH_USER_MODEL = 'web3auth.User' IMPLE_JWT = { - "ACCESS_TOKEN_LIFETIME": timedelta(minutes=300), - "REFRESH_TOKEN_LIFETIME": timedelta(days=2), - "ROTATE_REFRESH_TOKENS": True, - "BLACKLIST_AFTER_ROTATION": True, - "UPDATE_LAST_LOGIN": True, - - "ALGORITHM": "HS256", - "SIGNING_KEY": SECRET_KEY, - "VERIFYING_KEY": "", - "AUDIENCE": None, - "ISSUER": None, - "JSON_ENCODER": None, - "JWK_URL": None, - "LEEWAY": 0, - - "AUTH_HEADER_TYPES": ("Bearer",), - "AUTH_HEADER_NAME": "HTTP_AUTHORIZATION", - "USER_ID_FIELD": "id", - "USER_ID_CLAIM": "user_id", - "USER_AUTHENTICATION_RULE": "rest_framework_simplejwt.authentication.default_user_authentication_rule", - - "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), - "TOKEN_TYPE_CLAIM": "token_type", - "TOKEN_USER_CLASS": "rest_framework_simplejwt.models.TokenUser", - - "JTI_CLAIM": "jti", - - "SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp", - "SLIDING_TOKEN_LIFETIME": timedelta(minutes=5), - "SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1), - - "TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainPairSerializer", - "TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSerializer", - "TOKEN_VERIFY_SERIALIZER": "rest_framework_simplejwt.serializers.TokenVerifySerializer", - "TOKEN_BLACKLIST_SERIALIZER": "rest_framework_simplejwt.serializers.TokenBlacklistSerializer", - "SLIDING_TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainSlidingSerializer", - "SLIDING_TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSlidingSerializer", + "ACCESS_TOKEN_LIFETIME": timedelta(hours=24), + "REFRESH_TOKEN_LIFETIME": timedelta(days=7) } CORS_ALLOW_ALL_ORIGINS = True \ No newline at end of file diff --git a/web3auth/serializers.py b/web3auth/serializers.py index 7d6f039..d863728 100644 --- a/web3auth/serializers.py +++ b/web3auth/serializers.py @@ -9,6 +9,7 @@ import logging from rest_framework_simplejwt.tokens import RefreshToken from datetime import timedelta +from rest_framework_simplejwt.settings import api_settings logger = logging.getLogger(__name__) User = get_user_model() @@ -59,15 +60,20 @@ def validate(self, data): user.nonce = ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(32)) user.save() - refresh = RefreshToken.for_user(user) - refresh.set_exp(lifetime=timedelta(days=7)) - refresh.access_token.set_exp(lifetime=timedelta(hours=24)) - refresh['address'] = user.wallet_address + # Create a token with custom lifetime + refresh = RefreshToken() + refresh.payload["user_id"] = user.id + refresh.payload["exp"] = refresh.current_time + timedelta(days=7).total_seconds() + refresh["address"] = user.wallet_address + + # Set access token lifetime + access_token = refresh.access_token + access_token.payload["exp"] = access_token.current_time + timedelta(hours=24).total_seconds() return { 'user': user, 'refresh': str(refresh), - 'access': str(refresh.access_token) + 'access': str(access_token) } class UserSerializer(serializers.ModelSerializer): From 4b3ecb5d66710017ecd9b775c85b2c090d3a5f6a Mon Sep 17 00:00:00 2001 From: leojay-net Date: Wed, 30 Apr 2025 16:55:56 +0100 Subject: [PATCH 14/39] feat(auth): refactor token creation to use user instance and correctly set expiration times for refresh and access tokens --- web3auth/serializers.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/web3auth/serializers.py b/web3auth/serializers.py index d863728..b544f2e 100644 --- a/web3auth/serializers.py +++ b/web3auth/serializers.py @@ -61,14 +61,20 @@ def validate(self, data): user.save() # Create a token with custom lifetime - refresh = RefreshToken() - refresh.payload["user_id"] = user.id - refresh.payload["exp"] = refresh.current_time + timedelta(days=7).total_seconds() + refresh = RefreshToken.for_user(user) + + # Calculate the expiration times correctly + from datetime import datetime, timezone + + # For refresh token (7 days) + refresh_lifetime = timedelta(days=7) + refresh.payload["exp"] = int((datetime.now(timezone.utc) + refresh_lifetime).timestamp()) refresh["address"] = user.wallet_address - # Set access token lifetime + # For access token (24 hours) access_token = refresh.access_token - access_token.payload["exp"] = access_token.current_time + timedelta(hours=24).total_seconds() + access_lifetime = timedelta(hours=24) + access_token.payload["exp"] = int((datetime.now(timezone.utc) + access_lifetime).timestamp()) return { 'user': user, From 6cc8a689f066d0d8a21f1c5eaaca0ab42f0c4590 Mon Sep 17 00:00:00 2001 From: leojay-net Date: Wed, 30 Apr 2025 17:06:39 +0100 Subject: [PATCH 15/39] feat(auth): update JWT settings for access and refresh token lifetimes, and streamline token creation in EthereumAuthSerializer --- HR_Backend/settings.py | 41 ++++++++++++++++++++++++++++++++++++++--- web3auth/serializers.py | 20 ++++---------------- 2 files changed, 42 insertions(+), 19 deletions(-) diff --git a/HR_Backend/settings.py b/HR_Backend/settings.py index 0577bfd..9868767 100644 --- a/HR_Backend/settings.py +++ b/HR_Backend/settings.py @@ -191,9 +191,44 @@ AUTH_USER_MODEL = 'web3auth.User' -IMPLE_JWT = { - "ACCESS_TOKEN_LIFETIME": timedelta(hours=24), - "REFRESH_TOKEN_LIFETIME": timedelta(days=7) +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=300), + "REFRESH_TOKEN_LIFETIME": timedelta(days=2), + "ROTATE_REFRESH_TOKENS": True, + "BLACKLIST_AFTER_ROTATION": True, + "UPDATE_LAST_LOGIN": True, + + "ALGORITHM": "HS256", + "SIGNING_KEY": SECRET_KEY, + "VERIFYING_KEY": "", + "AUDIENCE": None, + "ISSUER": None, + "JSON_ENCODER": None, + "JWK_URL": None, + "LEEWAY": 0, + + "AUTH_HEADER_TYPES": ("Bearer",), + "AUTH_HEADER_NAME": "HTTP_AUTHORIZATION", + "USER_ID_FIELD": "id", + "USER_ID_CLAIM": "user_id", + "USER_AUTHENTICATION_RULE": "rest_framework_simplejwt.authentication.default_user_authentication_rule", + + "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), + "TOKEN_TYPE_CLAIM": "token_type", + "TOKEN_USER_CLASS": "rest_framework_simplejwt.models.TokenUser", + + "JTI_CLAIM": "jti", + + "SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp", + "SLIDING_TOKEN_LIFETIME": timedelta(minutes=5), + "SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1), + + "TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainPairSerializer", + "TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSerializer", + "TOKEN_VERIFY_SERIALIZER": "rest_framework_simplejwt.serializers.TokenVerifySerializer", + "TOKEN_BLACKLIST_SERIALIZER": "rest_framework_simplejwt.serializers.TokenBlacklistSerializer", + "SLIDING_TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainSlidingSerializer", + "SLIDING_TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSlidingSerializer", } CORS_ALLOW_ALL_ORIGINS = True \ No newline at end of file diff --git a/web3auth/serializers.py b/web3auth/serializers.py index b544f2e..7d6f039 100644 --- a/web3auth/serializers.py +++ b/web3auth/serializers.py @@ -9,7 +9,6 @@ import logging from rest_framework_simplejwt.tokens import RefreshToken from datetime import timedelta -from rest_framework_simplejwt.settings import api_settings logger = logging.getLogger(__name__) User = get_user_model() @@ -60,26 +59,15 @@ def validate(self, data): user.nonce = ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(32)) user.save() - # Create a token with custom lifetime refresh = RefreshToken.for_user(user) - - # Calculate the expiration times correctly - from datetime import datetime, timezone - - # For refresh token (7 days) - refresh_lifetime = timedelta(days=7) - refresh.payload["exp"] = int((datetime.now(timezone.utc) + refresh_lifetime).timestamp()) - refresh["address"] = user.wallet_address - - # For access token (24 hours) - access_token = refresh.access_token - access_lifetime = timedelta(hours=24) - access_token.payload["exp"] = int((datetime.now(timezone.utc) + access_lifetime).timestamp()) + refresh.set_exp(lifetime=timedelta(days=7)) + refresh.access_token.set_exp(lifetime=timedelta(hours=24)) + refresh['address'] = user.wallet_address return { 'user': user, 'refresh': str(refresh), - 'access': str(access_token) + 'access': str(refresh.access_token) } class UserSerializer(serializers.ModelSerializer): From d838acc5ef4a16a4608ca977196f4d87abc92de2 Mon Sep 17 00:00:00 2001 From: leojay-net Date: Wed, 30 Apr 2025 22:45:44 +0100 Subject: [PATCH 16/39] feat(payroll): enhance validation message and clarify duplicate payroll check for pending or paid status --- payroll/serializers.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/payroll/serializers.py b/payroll/serializers.py index b175715..675d888 100644 --- a/payroll/serializers.py +++ b/payroll/serializers.py @@ -39,7 +39,7 @@ def validate_amount(self, value): def validate(self, data): """ Custom validation to ensure organization can't create duplicate payrolls - for the same recipient on the same date + for the same recipient on the same date (only checking pending or paid status) """ recipient = data.get('recipient') organization = data.get('organization') @@ -51,10 +51,11 @@ def validate(self, data): if PayRoll.objects.filter( recipient=recipient, organization=organization, - date=date + date=date, + status__in=['pending', 'completed'] # Use status__in to check multiple values ).exists(): raise serializers.ValidationError( - "A payroll entry already exists for this recipient on this date" + "A pending or paid payroll entry already exists for this recipient on this date" ) return data From 0d61f769adae4e84edd64f287aa1144ef7389b64 Mon Sep 17 00:00:00 2001 From: leojay-net Date: Thu, 1 May 2025 13:01:13 +0100 Subject: [PATCH 17/39] feat(profile): update recipient profile creation logic and improve batch creation endpoint --- schema.yml | 11 +--- user_profile/views.py | 114 +++++++++++++++++++++++++++++------------- 2 files changed, 82 insertions(+), 43 deletions(-) diff --git a/schema.yml b/schema.yml index 268ce9d..05453dd 100644 --- a/schema.yml +++ b/schema.yml @@ -548,7 +548,7 @@ paths: description: '' post: operationId: profile_recipient_profile_create - description: Viewset for handling recipient profile operations. + description: Create a new recipient profile tags: - profile requestBody: @@ -676,17 +676,10 @@ paths: responses: '204': description: No response body - /api/v1/profile/recipient-profile/{id}/batch_create/: + /api/v1/profile/recipient-profile/batch_create/: post: operationId: profile_recipient_profile_batch_create_create description: Create multiple recipient profiles in a single request. - parameters: - - in: path - name: id - schema: - type: integer - description: A unique integer value identifying this recipient profile. - required: true tags: - profile requestBody: diff --git a/user_profile/views.py b/user_profile/views.py index 0cb912d..f17a8d5 100644 --- a/user_profile/views.py +++ b/user_profile/views.py @@ -8,11 +8,11 @@ from notifications.models import Notification - class OrganizationProfileView(ModelViewSet): """ Viewset for handling organization profile operations. """ + queryset = OrganizationProfile.objects.all() serializer_class = OrganizationProfileSerializer permission_classes = [IsAuthenticated] @@ -37,19 +37,19 @@ def create(self, request, *args, **kwargs): serializer.is_valid(raise_exception=True) self.perform_create(serializer) return Response(serializer.data, status=status.HTTP_201_CREATED) - + def perform_create(self, serializer): - + serializer.save(user=self.request.user) notification = Notification.objects.create( user=self.request.user, type="organizationAdded", message="Your organization profile has been created successfully.", - is_read=False + is_read=False, ) notification.save() - @action(detail=False, methods=['get']) + @action(detail=False, methods=["get"]) def get_organization_recipients(self, request): """ Retrieve all recipients associated with the authenticated organization. @@ -61,58 +61,104 @@ def get_organization_recipients(self, request): return Response(serializer.data, status=status.HTTP_200_OK) except OrganizationProfile.DoesNotExist: return Response( - {"detail": "Organization profile not found."}, - status=status.HTTP_404_NOT_FOUND + {"detail": "Organization profile not found."}, + status=status.HTTP_404_NOT_FOUND, ) - class RecipientProfileView(ModelViewSet): """ Viewset for handling recipient profile operations. """ + queryset = RecipientProfile.objects.all() serializer_class = RecipientProfileSerializer permission_classes = [IsAuthenticated] def get_queryset(self): """ - Optionally restricts the returned profiles to a given user, - by filtering against a `user` query parameter in the URL. + Filter recipients based on user type: + - Organizations see their recipients + - Recipients see their own profile """ - return super().get_queryset().filter(user=self.request.user) - + user = self.request.user + try: + # If user is an organization + organization = OrganizationProfile.objects.get(user=user) + return RecipientProfile.objects.filter(organization=organization) + except OrganizationProfile.DoesNotExist: + # If user is a recipient + return RecipientProfile.objects.filter(user=user) + def create(self, request, *args, **kwargs): - # Check if user already has an organization profile + """ + Create a new recipient profile + """ try: - instance = RecipientProfile.objects.get(user=request.user) - serializer = self.get_serializer(instance, data=request.data, partial=True) - serializer.is_valid(raise_exception=True) - self.perform_update(serializer) - return Response(serializer.data, status=status.HTTP_200_OK) - except RecipientProfile.DoesNotExist: + # Get the organization profile of the requesting user + organization = OrganizationProfile.objects.get(user=request.user) + + # Add organization to the request data + request.data["organization"] = organization.id + serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) self.perform_create(serializer) + + notification = Notification.objects.create( + user=request.user, + type="recipientAdded", + message="New recipient has been added successfully.", + is_read=False, + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) - - @action(detail=True, methods=['post']) + + except OrganizationProfile.DoesNotExist: + return Response( + {"detail": "Only organizations can create recipient profiles"}, + status=status.HTTP_403_FORBIDDEN, + ) + + @action(detail=False, methods=["post"]) def batch_create(self, request, *args, **kwargs): """ Create multiple recipient profiles in a single request. """ - serializer = self.get_serializer(data=request.data, many=True) + try: + organization = OrganizationProfile.objects.get(user=request.user) + + # Add organization to each recipient data + for recipient_data in request.data: + recipient_data["organization"] = organization.id + + serializer = self.get_serializer(data=request.data, many=True) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + + notification = Notification.objects.create( + user=request.user, + type="recipientAdded", + message=f"{len(request.data)} recipients have been added successfully.", + is_read=False, + ) + + return Response(serializer.data, status=status.HTTP_201_CREATED) + + except OrganizationProfile.DoesNotExist: + return Response( + {"detail": "Only organizations can create recipient profiles"}, + status=status.HTTP_403_FORBIDDEN, + ) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - notification = Notification.objects.create( - user=self.request.user, - type="recipientAdded", - message="New recipients have been added successfully.", - is_read=False - ) - notification.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - def perform_create(self, serializer): - serializer.save(user=self.request.user) + """ + Associate the recipient with the organization during creation + """ + try: + organization = OrganizationProfile.objects.get(user=self.request.user) + serializer.save() + except OrganizationProfile.DoesNotExist: + raise serializers.ValidationError( + "Only organizations can create recipient profiles" + ) From c9b7d3e0088f8c9edc28f4149ac63d2ba07aed6b Mon Sep 17 00:00:00 2001 From: leojay-net Date: Thu, 1 May 2025 13:14:34 +0100 Subject: [PATCH 18/39] feat(recipient): create user for recipient during profile creation and handle exceptions --- user_profile/views.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/user_profile/views.py b/user_profile/views.py index f17a8d5..2e44740 100644 --- a/user_profile/views.py +++ b/user_profile/views.py @@ -6,6 +6,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.decorators import action from notifications.models import Notification +from django.contrib.auth.models import User class OrganizationProfileView(ModelViewSet): @@ -98,8 +99,16 @@ def create(self, request, *args, **kwargs): # Get the organization profile of the requesting user organization = OrganizationProfile.objects.get(user=request.user) - # Add organization to the request data + # Create a new user for the recipient + user = User.objects.create( + username=request.data.get("recipient_ethereum_address"), + wallet_address=request.data.get("recipient_ethereum_address"), + user_type="recipient", + ) + + # Add organization and user to the request data request.data["organization"] = organization.id + request.data["user"] = user.id serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -119,6 +128,8 @@ def create(self, request, *args, **kwargs): {"detail": "Only organizations can create recipient profiles"}, status=status.HTTP_403_FORBIDDEN, ) + except Exception as e: + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) @action(detail=False, methods=["post"]) def batch_create(self, request, *args, **kwargs): From 798af8ec94b99e9781ef89e46bbbfc2153722a65 Mon Sep 17 00:00:00 2001 From: leojay-net Date: Thu, 1 May 2025 13:22:14 +0100 Subject: [PATCH 19/39] feat(recipient): streamline recipient profile creation and enhance error handling --- user_profile/views.py | 128 +++++++++++------------------------------- 1 file changed, 32 insertions(+), 96 deletions(-) diff --git a/user_profile/views.py b/user_profile/views.py index 2e44740..7eb380e 100644 --- a/user_profile/views.py +++ b/user_profile/views.py @@ -6,14 +6,13 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.decorators import action from notifications.models import Notification -from django.contrib.auth.models import User + class OrganizationProfileView(ModelViewSet): """ Viewset for handling organization profile operations. """ - queryset = OrganizationProfile.objects.all() serializer_class = OrganizationProfileSerializer permission_classes = [IsAuthenticated] @@ -38,19 +37,19 @@ def create(self, request, *args, **kwargs): serializer.is_valid(raise_exception=True) self.perform_create(serializer) return Response(serializer.data, status=status.HTTP_201_CREATED) - + def perform_create(self, serializer): - + serializer.save(user=self.request.user) notification = Notification.objects.create( user=self.request.user, type="organizationAdded", message="Your organization profile has been created successfully.", - is_read=False, + is_read=False ) notification.save() - @action(detail=False, methods=["get"]) + @action(detail=False, methods=['get']) def get_organization_recipients(self, request): """ Retrieve all recipients associated with the authenticated organization. @@ -62,114 +61,51 @@ def get_organization_recipients(self, request): return Response(serializer.data, status=status.HTTP_200_OK) except OrganizationProfile.DoesNotExist: return Response( - {"detail": "Organization profile not found."}, - status=status.HTTP_404_NOT_FOUND, + {"detail": "Organization profile not found."}, + status=status.HTTP_404_NOT_FOUND ) + class RecipientProfileView(ModelViewSet): """ Viewset for handling recipient profile operations. """ - queryset = RecipientProfile.objects.all() serializer_class = RecipientProfileSerializer permission_classes = [IsAuthenticated] def get_queryset(self): """ - Filter recipients based on user type: - - Organizations see their recipients - - Recipients see their own profile + Optionally restricts the returned profiles to a given user, + by filtering against a `user` query parameter in the URL. """ - user = self.request.user - try: - # If user is an organization - organization = OrganizationProfile.objects.get(user=user) - return RecipientProfile.objects.filter(organization=organization) - except OrganizationProfile.DoesNotExist: - # If user is a recipient - return RecipientProfile.objects.filter(user=user) - + return super().get_queryset().filter(user=self.request.user) + def create(self, request, *args, **kwargs): - """ - Create a new recipient profile - """ - try: - # Get the organization profile of the requesting user - organization = OrganizationProfile.objects.get(user=request.user) - - # Create a new user for the recipient - user = User.objects.create( - username=request.data.get("recipient_ethereum_address"), - wallet_address=request.data.get("recipient_ethereum_address"), - user_type="recipient", - ) - - # Add organization and user to the request data - request.data["organization"] = organization.id - request.data["user"] = user.id - - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - - notification = Notification.objects.create( - user=request.user, - type="recipientAdded", - message="New recipient has been added successfully.", - is_read=False, - ) - - return Response(serializer.data, status=status.HTTP_201_CREATED) - - except OrganizationProfile.DoesNotExist: - return Response( - {"detail": "Only organizations can create recipient profiles"}, - status=status.HTTP_403_FORBIDDEN, - ) - except Exception as e: - return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) - - @action(detail=False, methods=["post"]) + # Check if user already has an organization profile + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @action(detail=True, methods=['post']) def batch_create(self, request, *args, **kwargs): """ Create multiple recipient profiles in a single request. """ - try: - organization = OrganizationProfile.objects.get(user=request.user) - - # Add organization to each recipient data - for recipient_data in request.data: - recipient_data["organization"] = organization.id - - serializer = self.get_serializer(data=request.data, many=True) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - - notification = Notification.objects.create( - user=request.user, - type="recipientAdded", - message=f"{len(request.data)} recipients have been added successfully.", - is_read=False, - ) - - return Response(serializer.data, status=status.HTTP_201_CREATED) - - except OrganizationProfile.DoesNotExist: - return Response( - {"detail": "Only organizations can create recipient profiles"}, - status=status.HTTP_403_FORBIDDEN, - ) + serializer = self.get_serializer(data=request.data, many=True) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + notification = Notification.objects.create( + user=self.request.user, + type="recipientAdded", + message="New recipients have been added successfully.", + is_read=False + ) + notification.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + def perform_create(self, serializer): - """ - Associate the recipient with the organization during creation - """ - try: - organization = OrganizationProfile.objects.get(user=self.request.user) - serializer.save() - except OrganizationProfile.DoesNotExist: - raise serializers.ValidationError( - "Only organizations can create recipient profiles" - ) + serializer.save(user=self.request.user) From 4c2cc4b83845050825826f09fe5fbe0d08df6b2b Mon Sep 17 00:00:00 2001 From: leojay-net Date: Thu, 1 May 2025 13:31:45 +0100 Subject: [PATCH 20/39] feat(recipient): implement recipient profile creation with organization association and user validation --- user_profile/views.py | 111 +++++++++++++++++++++++++++++------------- 1 file changed, 77 insertions(+), 34 deletions(-) diff --git a/user_profile/views.py b/user_profile/views.py index 7eb380e..d65b0d2 100644 --- a/user_profile/views.py +++ b/user_profile/views.py @@ -6,13 +6,16 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.decorators import action from notifications.models import Notification - +from web3auth.models import User +import secrets +import string class OrganizationProfileView(ModelViewSet): """ Viewset for handling organization profile operations. """ + queryset = OrganizationProfile.objects.all() serializer_class = OrganizationProfileSerializer permission_classes = [IsAuthenticated] @@ -37,19 +40,19 @@ def create(self, request, *args, **kwargs): serializer.is_valid(raise_exception=True) self.perform_create(serializer) return Response(serializer.data, status=status.HTTP_201_CREATED) - + def perform_create(self, serializer): - + serializer.save(user=self.request.user) notification = Notification.objects.create( user=self.request.user, type="organizationAdded", message="Your organization profile has been created successfully.", - is_read=False + is_read=False, ) notification.save() - @action(detail=False, methods=['get']) + @action(detail=False, methods=["get"]) def get_organization_recipients(self, request): """ Retrieve all recipients associated with the authenticated organization. @@ -61,51 +64,91 @@ def get_organization_recipients(self, request): return Response(serializer.data, status=status.HTTP_200_OK) except OrganizationProfile.DoesNotExist: return Response( - {"detail": "Organization profile not found."}, - status=status.HTTP_404_NOT_FOUND + {"detail": "Organization profile not found."}, + status=status.HTTP_404_NOT_FOUND, ) - class RecipientProfileView(ModelViewSet): """ Viewset for handling recipient profile operations. """ + queryset = RecipientProfile.objects.all() serializer_class = RecipientProfileSerializer permission_classes = [IsAuthenticated] def get_queryset(self): """ - Optionally restricts the returned profiles to a given user, - by filtering against a `user` query parameter in the URL. + Filter recipients based on user type: + - Organizations see their recipients + - Recipients see their own profile """ - return super().get_queryset().filter(user=self.request.user) - + user = self.request.user + try: + organization = OrganizationProfile.objects.get(user=user) + return RecipientProfile.objects.filter(organization=organization) + except OrganizationProfile.DoesNotExist: + return RecipientProfile.objects.filter(user=user) + def create(self, request, *args, **kwargs): - # Check if user already has an organization profile - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - return Response(serializer.data, status=status.HTTP_201_CREATED) - - @action(detail=True, methods=['post']) - def batch_create(self, request, *args, **kwargs): """ - Create multiple recipient profiles in a single request. + Create a new recipient profile """ - serializer = self.get_serializer(data=request.data, many=True) + try: + # Get the organization profile + organization = OrganizationProfile.objects.get(user=request.user) + + # Create new user for recipient + ethereum_address = request.data.get("recipient_ethereum_address") + + # Check if a user with this ethereum address already exists + user, created = User.objects.get_or_create( + wallet_address=ethereum_address, + defaults={ + "username": ethereum_address, + "user_type": "recipient", + "nonce": "".join( + secrets.choice(string.ascii_letters + string.digits) + for _ in range(32) + ), + }, + ) + + # Check if recipient profile already exists for this user + if RecipientProfile.objects.filter(user=user).exists(): + return Response( + { + "detail": "A recipient profile already exists for this ethereum address" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Add organization and user to request data + request.data["organization"] = organization.id + request.data["user"] = user.id + + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + + notification = Notification.objects.create( + user=request.user, + type="recipientAdded", + message="New recipient has been added successfully.", + is_read=False, + ) + + return Response(serializer.data, status=status.HTTP_201_CREATED) + + except OrganizationProfile.DoesNotExist: + return Response( + {"detail": "Only organizations can create recipient profiles"}, + status=status.HTTP_403_FORBIDDEN, + ) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - notification = Notification.objects.create( - user=self.request.user, - type="recipientAdded", - message="New recipients have been added successfully.", - is_read=False - ) - notification.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - def perform_create(self, serializer): - serializer.save(user=self.request.user) + """ + Associate the recipient with the organization during creation + """ + serializer.save() From 1022f136b65f2b571520d873874dda64873f7b4f Mon Sep 17 00:00:00 2001 From: leojay-net Date: Thu, 1 May 2025 13:59:41 +0100 Subject: [PATCH 21/39] feat(recipient): update recipient profile serializer to include user_id and modify profile creation logic --- schema.yml | 33 ++++++--------------------------- user_profile/serializers.py | 6 ++++-- user_profile/views.py | 16 +++++++--------- 3 files changed, 17 insertions(+), 38 deletions(-) diff --git a/schema.yml b/schema.yml index 05453dd..7e816f0 100644 --- a/schema.yml +++ b/schema.yml @@ -676,33 +676,6 @@ paths: responses: '204': description: No response body - /api/v1/profile/recipient-profile/batch_create/: - post: - operationId: profile_recipient_profile_batch_create_create - description: Create multiple recipient profiles in a single request. - tags: - - profile - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/RecipientProfile' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/RecipientProfile' - multipart/form-data: - schema: - $ref: '#/components/schemas/RecipientProfile' - required: true - security: - - jwtAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/RecipientProfile' - description: '' /api/v1/waitlist/waitlist/: get: operationId: waitlist_waitlist_list @@ -1112,6 +1085,9 @@ components: allOf: - $ref: '#/components/schemas/User' readOnly: true + user_id: + type: integer + writeOnly: true organization: type: integer recipient_ethereum_address: @@ -1231,6 +1207,9 @@ components: allOf: - $ref: '#/components/schemas/User' readOnly: true + user_id: + type: integer + writeOnly: true organization: type: integer recipient_ethereum_address: diff --git a/user_profile/serializers.py b/user_profile/serializers.py index 89bf3fd..baf941f 100644 --- a/user_profile/serializers.py +++ b/user_profile/serializers.py @@ -10,11 +10,13 @@ class RecipientProfileSerializer(serializers.ModelSerializer): Serializer for RecipientProfile model. """ user = UserSerializer(read_only=True) + user_id = serializers.IntegerField(write_only=True, required=False) # Add this line class Meta: model = RecipientProfile - fields = ['id', 'name', 'email', 'user','organization', 'recipient_ethereum_address', - 'recipient_phone', 'salary', 'position','status','created_at', 'updated_at'] + fields = ['id', 'name', 'email', 'user', 'user_id', 'organization', + 'recipient_ethereum_address', 'recipient_phone', 'salary', + 'position', 'status', 'created_at', 'updated_at'] read_only_fields = ['id', 'created_at', 'updated_at'] def to_representation(self, instance): diff --git a/user_profile/views.py b/user_profile/views.py index d65b0d2..5455afc 100644 --- a/user_profile/views.py +++ b/user_profile/views.py @@ -110,25 +110,23 @@ def create(self, request, *args, **kwargs): "user_type": "recipient", "nonce": "".join( secrets.choice(string.ascii_letters + string.digits) - for _ in range(32) - ), + for _ in range(32)) }, ) # Check if recipient profile already exists for this user if RecipientProfile.objects.filter(user=user).exists(): return Response( - { - "detail": "A recipient profile already exists for this ethereum address" - }, + {"detail": "A recipient profile already exists for this ethereum address"}, status=status.HTTP_400_BAD_REQUEST, ) - # Add organization and user to request data - request.data["organization"] = organization.id - request.data["user"] = user.id + # Create mutable copy of request.data + data = request.data.copy() + data["organization"] = organization.id + data["user"] = user.id - serializer = self.get_serializer(data=request.data) + serializer = self.get_serializer(data=data) # Use the copied data serializer.is_valid(raise_exception=True) self.perform_create(serializer) From 59ee8af349591d8c08fd895d79eaae58d3e6ae08 Mon Sep 17 00:00:00 2001 From: leojay-net Date: Tue, 6 May 2025 14:52:50 +0100 Subject: [PATCH 22/39] feat(user_profile): update recipient and organization profiles with unique constraints and validation; enhance serializers and views for better user management --- ...5_alter_recipientprofile_email_and_more.py | 24 +++ ..._alter_recipientprofile_unique_together.py | 17 ++ user_profile/models.py | 28 +-- user_profile/serializers.py | 61 +++--- user_profile/urls.py | 12 +- user_profile/views.py | 175 ++++++++++++------ web3auth/models.py | 9 +- 7 files changed, 233 insertions(+), 93 deletions(-) create mode 100644 user_profile/migrations/0005_alter_recipientprofile_email_and_more.py create mode 100644 user_profile/migrations/0006_alter_recipientprofile_unique_together.py diff --git a/user_profile/migrations/0005_alter_recipientprofile_email_and_more.py b/user_profile/migrations/0005_alter_recipientprofile_email_and_more.py new file mode 100644 index 0000000..2874ffd --- /dev/null +++ b/user_profile/migrations/0005_alter_recipientprofile_email_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2 on 2025-05-06 13:48 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("user_profile", "0004_alter_organizationprofile_email_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name="recipientprofile", + name="email", + field=models.EmailField(max_length=254, unique=True), + ), + migrations.AlterUniqueTogether( + name="recipientprofile", + unique_together={("user", "organization")}, + ), + ] diff --git a/user_profile/migrations/0006_alter_recipientprofile_unique_together.py b/user_profile/migrations/0006_alter_recipientprofile_unique_together.py new file mode 100644 index 0000000..e6782af --- /dev/null +++ b/user_profile/migrations/0006_alter_recipientprofile_unique_together.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2 on 2025-05-06 13:48 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("user_profile", "0005_alter_recipientprofile_email_and_more"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="recipientprofile", + unique_together=set(), + ), + ] diff --git a/user_profile/models.py b/user_profile/models.py index b1e0e74..7efd972 100644 --- a/user_profile/models.py +++ b/user_profile/models.py @@ -6,6 +6,7 @@ class OrganizationProfile(models.Model): """ Organization profile model to store organization information. """ + user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) name = models.CharField(max_length=150) email = models.EmailField() @@ -14,10 +15,9 @@ class OrganizationProfile(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - def __str__(self): return self.name - + class Meta: verbose_name_plural = "Organization Profiles" @@ -25,29 +25,33 @@ class Meta: class RecipientProfile(models.Model): """ Recipient profile model to store recipient information. + Each recipient is a user and belongs to an organization. """ + user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + organization = models.ForeignKey( + OrganizationProfile, on_delete=models.CASCADE, related_name="recipients" + ) name = models.CharField(max_length=150) - email = models.EmailField() - organization = models.ForeignKey(OrganizationProfile, on_delete=models.CASCADE, related_name='recipients') + email = models.EmailField(unique=True) recipient_ethereum_address = models.CharField(max_length=42, unique=True) recipient_phone = models.CharField(max_length=15, blank=True, null=True) salary = models.IntegerField(blank=True, null=True) position = models.CharField(max_length=100, blank=True, null=True) status = models.CharField( max_length=30, - choices=[ - ('active', 'Active'), - ('on_leave', 'On Leave') - ], - default='active' + choices=[("active", "Active"), ("on_leave", "On Leave")], + default="active", ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - def __str__(self): return self.name - + class Meta: - verbose_name_plural = "Recipient Profiles" \ No newline at end of file + verbose_name_plural = "Recipient Profiles" + # unique_together = [ + # "user", + # "organization", + # ] # Ensure user belongs to only one organization diff --git a/user_profile/serializers.py b/user_profile/serializers.py index 89bf3fd..5c1e5d4 100644 --- a/user_profile/serializers.py +++ b/user_profile/serializers.py @@ -3,19 +3,30 @@ from web3auth.serializers import UserSerializer - - class RecipientProfileSerializer(serializers.ModelSerializer): """ Serializer for RecipientProfile model. """ + user = UserSerializer(read_only=True) class Meta: model = RecipientProfile - fields = ['id', 'name', 'email', 'user','organization', 'recipient_ethereum_address', - 'recipient_phone', 'salary', 'position','status','created_at', 'updated_at'] - read_only_fields = ['id', 'created_at', 'updated_at'] + fields = [ + "id", + "name", + "email", + "user", + "organization", + "recipient_ethereum_address", + "recipient_phone", + "salary", + "position", + "status", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "created_at", "updated_at"] def to_representation(self, instance): """ @@ -26,21 +37,29 @@ def to_representation(self, instance): "name": instance.name, "email": instance.email, # Added missing email field "organization": instance.organization.name, - "user": UserSerializer(instance.user).data, # Use serializer instead of direct query + "user": UserSerializer( + instance.user + ).data, # Use serializer instead of direct query "recipient_ethereum_address": instance.recipient_ethereum_address, # Added missing field "recipient_phone": instance.recipient_phone, # Added missing field "wallet_address": instance.user.wallet_address, "salary": instance.salary, "position": instance.position, "status": instance.status, - "created_at": instance.created_at.isoformat() if instance.created_at else None, - "updated_at": instance.updated_at.isoformat() if instance.updated_at else None, + "created_at": ( + instance.created_at.isoformat() if instance.created_at else None + ), + "updated_at": ( + instance.updated_at.isoformat() if instance.updated_at else None + ), } - + + class OrganizationProfileSerializer(serializers.ModelSerializer): """ Serializer for OrganizationProfile model. """ + id = serializers.IntegerField(read_only=True) created_at = serializers.DateTimeField(read_only=True) updated_at = serializers.DateTimeField(read_only=True) @@ -51,18 +70,18 @@ class OrganizationProfileSerializer(serializers.ModelSerializer): class Meta: model = OrganizationProfile fields = [ - 'id', - 'name', - 'email', - 'user', - 'organization_address', - 'organization_phone', - 'recipients', - 'total_recipients', - 'created_at', - 'updated_at' + "id", + "name", + "email", + "user", + "organization_address", + "organization_phone", + "recipients", + "total_recipients", + "created_at", + "updated_at", ] - read_only_fields = ['id', 'created_at', 'updated_at'] + read_only_fields = ["id", "created_at", "updated_at"] def get_total_recipients(self, obj): """ @@ -76,4 +95,4 @@ def validate_email(self, value): """ if OrganizationProfile.objects.filter(email=value).exists(): raise serializers.ValidationError("This email is already registered.") - return value.lower() \ No newline at end of file + return value.lower() diff --git a/user_profile/urls.py b/user_profile/urls.py index 75929cf..34019a8 100644 --- a/user_profile/urls.py +++ b/user_profile/urls.py @@ -3,9 +3,13 @@ from .views import OrganizationProfileView, RecipientProfileView router = DefaultRouter() -router.register(r'organization-profile', OrganizationProfileView, basename='organization-profile') -router.register(r'recipient-profile', RecipientProfileView, basename='recipient-profile') +router.register( + r"organization-profile", OrganizationProfileView, basename="organization-profile" +) +router.register( + r"recipient-profile", RecipientProfileView, basename="recipient-profile" +) urlpatterns = [ - path('', include(router.urls)), -] \ No newline at end of file + path("", include(router.urls)), +] diff --git a/user_profile/views.py b/user_profile/views.py index 0cb912d..edecd0b 100644 --- a/user_profile/views.py +++ b/user_profile/views.py @@ -1,21 +1,28 @@ from rest_framework.viewsets import ModelViewSet from rest_framework.response import Response from rest_framework import status +from rest_framework import serializers +from rest_framework.permissions import IsAuthenticated, BasePermission +from rest_framework.decorators import action +from django.db import transaction from .models import OrganizationProfile, RecipientProfile from .serializers import OrganizationProfileSerializer, RecipientProfileSerializer -from rest_framework.permissions import IsAuthenticated -from rest_framework.decorators import action from notifications.models import Notification +class IsOrganization(BasePermission): + def has_permission(self, request, view): + return request.user.user_type == "organization" + class OrganizationProfileView(ModelViewSet): """ Viewset for handling organization profile operations. """ + queryset = OrganizationProfile.objects.all() serializer_class = OrganizationProfileSerializer - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, IsOrganization] def get_queryset(self): """ @@ -25,7 +32,11 @@ def get_queryset(self): return super().get_queryset().filter(user=self.request.user) def create(self, request, *args, **kwargs): - # Check if user already has an organization profile + if request.user.user_type != "organization": + raise serializers.ValidationError( + "Only organization type users can create organization profiles" + ) + try: instance = OrganizationProfile.objects.get(user=request.user) serializer = self.get_serializer(instance, data=request.data, partial=True) @@ -37,82 +48,138 @@ def create(self, request, *args, **kwargs): serializer.is_valid(raise_exception=True) self.perform_create(serializer) return Response(serializer.data, status=status.HTTP_201_CREATED) - + def perform_create(self, serializer): - - serializer.save(user=self.request.user) - notification = Notification.objects.create( - user=self.request.user, - type="organizationAdded", - message="Your organization profile has been created successfully.", - is_read=False - ) - notification.save() + with transaction.atomic(): + serializer.save(user=self.request.user) + Notification.objects.create( + user=self.request.user, + type="organizationAdded", + message="Your organization profile has been created successfully.", + is_read=False, + ) - @action(detail=False, methods=['get']) + @action(detail=False, methods=["get"]) def get_organization_recipients(self, request): """ Retrieve all recipients associated with the authenticated organization. """ try: - organization = OrganizationProfile.objects.get(user=self.request.user.id) + organization = OrganizationProfile.objects.get(user=self.request.user) recipients = organization.recipients.all() serializer = RecipientProfileSerializer(recipients, many=True) return Response(serializer.data, status=status.HTTP_200_OK) except OrganizationProfile.DoesNotExist: return Response( - {"detail": "Organization profile not found."}, - status=status.HTTP_404_NOT_FOUND + {"detail": "Organization profile not found."}, + status=status.HTTP_404_NOT_FOUND, ) - class RecipientProfileView(ModelViewSet): """ Viewset for handling recipient profile operations. """ + queryset = RecipientProfile.objects.all() serializer_class = RecipientProfileSerializer permission_classes = [IsAuthenticated] def get_queryset(self): """ - Optionally restricts the returned profiles to a given user, - by filtering against a `user` query parameter in the URL. + If user is organization, return all recipients in their organization + If user is recipient, return only their profile """ - return super().get_queryset().filter(user=self.request.user) - - def create(self, request, *args, **kwargs): - # Check if user already has an organization profile - try: - instance = RecipientProfile.objects.get(user=request.user) - serializer = self.get_serializer(instance, data=request.data, partial=True) - serializer.is_valid(raise_exception=True) - self.perform_update(serializer) - return Response(serializer.data, status=status.HTTP_200_OK) - except RecipientProfile.DoesNotExist: - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - return Response(serializer.data, status=status.HTTP_201_CREATED) - - @action(detail=True, methods=['post']) - def batch_create(self, request, *args, **kwargs): + if self.request.user.user_type == "organization": + try: + organization = OrganizationProfile.objects.get(user=self.request.user) + return RecipientProfile.objects.filter(organization=organization) + except OrganizationProfile.DoesNotExist: + return RecipientProfile.objects.none() + return RecipientProfile.objects.filter(user=self.request.user) + + def perform_create(self, serializer): """ - Create multiple recipient profiles in a single request. + Create a new recipient profile. + Organization users can create recipients for their organization. """ - serializer = self.get_serializer(data=request.data, many=True) - - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - notification = Notification.objects.create( - user=self.request.user, - type="recipientAdded", - message="New recipients have been added successfully.", - is_read=False + if self.request.user.user_type != "organization": + raise serializers.ValidationError( + "Only organizations can create recipient profiles" + ) + + try: + organization = OrganizationProfile.objects.get(user=self.request.user) + serializer.save(organization=organization) + notification = Notification.objects.create( + user=self.request.user, + type="recipientAdded", + message="New recipient has been added successfully.", + is_read=False, + ) + notification.save() + except OrganizationProfile.DoesNotExist: + raise serializers.ValidationError("Organization profile not found") + + @action(detail=False, methods=["post"]) + def batch_create(self, request): + if self.request.user.user_type != "organization": + raise serializers.ValidationError( + "Only organizations can create recipient profiles" + ) + + try: + organization = OrganizationProfile.objects.get(user=self.request.user) + except OrganizationProfile.DoesNotExist: + raise serializers.ValidationError("Organization profile not found") + + recipients_data = request.data.get("recipients", []) + if not recipients_data: + raise serializers.ValidationError("No recipients data provided") + + created_recipients = [] + errors = [] + + with transaction.atomic(): + for recipient_data in recipients_data: + serializer = self.get_serializer(data=recipient_data) + try: + if serializer.is_valid(raise_exception=True): + recipient = serializer.save( + organization=organization, user_type="recipient" + ) + created_recipients.append(recipient) + except serializers.ValidationError as e: + errors.append( + { + "recipient": recipient_data.get("email", "Unknown"), + "errors": e.detail, + } + ) + continue + + if created_recipients: + Notification.objects.create( + user=self.request.user, + type="recipientsAdded", + message=f"Successfully added {len(created_recipients)} recipients.", + is_read=False, + ) + + response_data = { + "success": len(created_recipients), + "failed": len(errors), + "created_recipients": RecipientProfileSerializer( + created_recipients, many=True + ).data, + "errors": errors if errors else None, + } + + return Response( + response_data, + status=( + status.HTTP_201_CREATED + if created_recipients + else status.HTTP_400_BAD_REQUEST + ), ) - notification.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - - def perform_create(self, serializer): - serializer.save(user=self.request.user) diff --git a/web3auth/models.py b/web3auth/models.py index e112649..1d29690 100644 --- a/web3auth/models.py +++ b/web3auth/models.py @@ -2,14 +2,19 @@ from django.contrib.auth.models import AbstractUser from django.utils.translation import gettext_lazy as _ + class User(AbstractUser): wallet_address = models.CharField(max_length=42, unique=True, null=True, blank=True) nonce = models.CharField(max_length=100, null=True, blank=True) - user_type = models.CharField(max_length=12, choices=[('recipient', 'Recipient'), ('organization', 'Organization')], default='organization') + user_type = models.CharField( + max_length=12, + choices=[("recipient", "Recipient"), ("organization", "Organization")], + default="organization", + ) def __str__(self): return self.wallet_address def get_username(self): - return self.wallet_address \ No newline at end of file + return self.wallet_address From cf6aab1a6d4d90c83e5d1e41e476fcbe2c0c8947 Mon Sep 17 00:00:00 2001 From: leojay-net Date: Tue, 6 May 2025 15:15:07 +0100 Subject: [PATCH 23/39] feat(recipient_profile): update recipient profile creation endpoint and add batch creation support; remove unused user_id field from serializer --- schema.yml | 35 ++++++++++++++++++++++++++++------- user_profile/serializers.py | 2 +- user_profile/views.py | 2 -- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/schema.yml b/schema.yml index 7e816f0..6a7e88f 100644 --- a/schema.yml +++ b/schema.yml @@ -548,7 +548,7 @@ paths: description: '' post: operationId: profile_recipient_profile_create - description: Create a new recipient profile + description: Viewset for handling recipient profile operations. tags: - profile requestBody: @@ -676,6 +676,33 @@ paths: responses: '204': description: No response body + /api/v1/profile/recipient-profile/batch_create/: + post: + operationId: profile_recipient_profile_batch_create_create + description: Viewset for handling recipient profile operations. + tags: + - profile + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RecipientProfile' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/RecipientProfile' + multipart/form-data: + schema: + $ref: '#/components/schemas/RecipientProfile' + required: true + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/RecipientProfile' + description: '' /api/v1/waitlist/waitlist/: get: operationId: waitlist_waitlist_list @@ -1085,9 +1112,6 @@ components: allOf: - $ref: '#/components/schemas/User' readOnly: true - user_id: - type: integer - writeOnly: true organization: type: integer recipient_ethereum_address: @@ -1207,9 +1231,6 @@ components: allOf: - $ref: '#/components/schemas/User' readOnly: true - user_id: - type: integer - writeOnly: true organization: type: integer recipient_ethereum_address: diff --git a/user_profile/serializers.py b/user_profile/serializers.py index ac0c32c..5307214 100644 --- a/user_profile/serializers.py +++ b/user_profile/serializers.py @@ -9,7 +9,7 @@ class RecipientProfileSerializer(serializers.ModelSerializer): """ user = UserSerializer(read_only=True) - user_id = serializers.IntegerField(write_only=True, required=False) # Add this line + # user_id = serializers.IntegerField(write_only=True, required=False) # Add this line class Meta: model = RecipientProfile diff --git a/user_profile/views.py b/user_profile/views.py index d3912e1..fabcfef 100644 --- a/user_profile/views.py +++ b/user_profile/views.py @@ -76,8 +76,6 @@ def get_organization_recipients(self, request): return Response( {"detail": "Organization profile not found."}, status=status.HTTP_404_NOT_FOUND, - {"detail": "Organization profile not found."}, - status=status.HTTP_404_NOT_FOUND, ) From 1d8f016f1db96bd3512bb45e9907d496369450bd Mon Sep 17 00:00:00 2001 From: leojay-net Date: Wed, 7 May 2025 15:28:49 +0100 Subject: [PATCH 24/39] feat(user_auth): enhance user model with 'both' user type; update serializers and views for nonce and address verification --- payroll/views.py | 62 +++++++------ schema.yml | 76 ++++++++++++++-- user_profile/serializers.py | 4 + user_profile/views.py | 56 ++++++------ .../migrations/0003_alter_user_user_type.py | 26 ++++++ web3auth/models.py | 15 +++- web3auth/serializers.py | 69 +++++++++----- web3auth/views.py | 90 +++++++++++-------- 8 files changed, 278 insertions(+), 120 deletions(-) create mode 100644 web3auth/migrations/0003_alter_user_user_type.py diff --git a/payroll/views.py b/payroll/views.py index cc18b1f..8b778f4 100644 --- a/payroll/views.py +++ b/payroll/views.py @@ -5,15 +5,23 @@ from rest_framework.decorators import action from django.db.models import Sum from datetime import datetime +from drf_spectacular.utils import extend_schema_view, extend_schema from .models import PayRoll from .serializers import PayRollSerializer from user_profile.models import RecipientProfile, OrganizationProfile + +@extend_schema_view( + retrieve=extend_schema( + parameters=[{"name": "id", "in": "path", "type": "integer", "required": True}] + ) +) class PayRollViewSet(viewsets.ModelViewSet): """ ViewSet for handling PayRoll operations. Provides different views based on user type (organization or recipient) """ + serializer_class = PayRollSerializer permission_classes = [IsAuthenticated] @@ -24,7 +32,7 @@ def get_queryset(self): - Recipients see only their payrolls """ user = self.request.user - + try: # Check if user is an organization org_profile = OrganizationProfile.objects.get(user=user) @@ -47,36 +55,40 @@ def perform_create(self, serializer): except OrganizationProfile.DoesNotExist: raise PermissionError("Only organizations can create payroll entries") - @action(detail=False, methods=['get']) + @action(detail=False, methods=["get"]) def summary(self, request): """ Get summary of payrolls including total amount paid and pending """ queryset = self.get_queryset() - total_paid = queryset.filter(is_paid=True).aggregate(Sum('amount')) - total_pending = queryset.filter(is_paid=False).aggregate(Sum('amount')) - - return Response({ - 'total_paid': total_paid['amount__sum'] or 0, - 'total_pending': total_pending['amount__sum'] or 0, - 'total_entries': queryset.count() - }) - - @action(detail=False, methods=['get']) + total_paid = queryset.filter(is_paid=True).aggregate(Sum("amount")) + total_pending = queryset.filter(is_paid=False).aggregate(Sum("amount")) + + return Response( + { + "total_paid": total_paid["amount__sum"] or 0, + "total_pending": total_pending["amount__sum"] or 0, + "total_entries": queryset.count(), + } + ) + + @action(detail=False, methods=["get"]) def monthly_report(self, request): """ Get monthly breakdown of payrolls """ - year = request.query_params.get('year', datetime.now().year) + year = request.query_params.get("year", datetime.now().year) queryset = self.get_queryset().filter(date__year=year) - + monthly_data = {} for month in range(1, 13): - monthly_amount = queryset.filter( - date__month=month, - is_paid=True - ).aggregate(Sum('amount'))['amount__sum'] or 0 - + monthly_amount = ( + queryset.filter(date__month=month, is_paid=True).aggregate( + Sum("amount") + )["amount__sum"] + or 0 + ) + monthly_data[month] = monthly_amount return Response(monthly_data) @@ -85,14 +97,14 @@ def update(self, request, *args, **kwargs): """ Update payroll entry with additional validation """ - partial = kwargs.pop('partial', False) + partial = kwargs.pop("partial", False) instance = self.get_object() - + # Prevent updating certain fields after payment if instance.is_paid and not request.user.is_staff: return Response( {"detail": "Paid payrolls cannot be modified"}, - status=status.HTTP_400_BAD_REQUEST + status=status.HTTP_400_BAD_REQUEST, ) serializer = self.get_serializer(instance, data=request.data, partial=partial) @@ -101,7 +113,7 @@ def update(self, request, *args, **kwargs): return Response(serializer.data) - @action(detail=True, methods=['post']) + @action(detail=True, methods=["post"]) def mark_as_paid(self, request, pk=None): """ Mark a payroll entry as paid @@ -110,12 +122,12 @@ def mark_as_paid(self, request, pk=None): if payroll.is_paid: return Response( {"detail": "Payroll already marked as paid"}, - status=status.HTTP_400_BAD_REQUEST + status=status.HTTP_400_BAD_REQUEST, ) payroll.is_paid = True payroll.paid_at = datetime.now() - payroll.status = 'completed' + payroll.status = "completed" payroll.save() serializer = self.get_serializer(payroll) diff --git a/schema.yml b/schema.yml index 6a7e88f..22f4301 100644 --- a/schema.yml +++ b/schema.yml @@ -856,12 +856,28 @@ paths: operationId: web3auth_login_create tags: - web3auth + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/EthereumAuth' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/EthereumAuth' + multipart/form-data: + schema: + $ref: '#/components/schemas/EthereumAuth' + required: true security: - jwtAuth: [] - {} responses: '200': - description: No response body + content: + application/json: + schema: + $ref: '#/components/schemas/EthereumAuth' + description: '' /api/v1/web3auth/nonce/{address}/: get: operationId: web3auth_nonce_retrieve @@ -878,7 +894,11 @@ paths: - {} responses: '200': - description: No response body + content: + application/json: + schema: + $ref: '#/components/schemas/Nonce' + description: '' /api/v1/web3auth/user/: get: operationId: web3auth_user_retrieve @@ -888,7 +908,11 @@ paths: - jwtAuth: [] responses: '200': - description: No response body + content: + application/json: + schema: + $ref: '#/components/schemas/User' + description: '' /api/v1/web3auth/verify-address/{address}/: get: operationId: web3auth_verify_address_retrieve @@ -905,9 +929,31 @@ paths: - {} responses: '200': - description: No response body + content: + application/json: + schema: + $ref: '#/components/schemas/VerifyAddress' + description: '' components: schemas: + EthereumAuth: + type: object + properties: + address: + type: string + signature: + type: string + required: + - address + - signature + Nonce: + type: object + properties: + wallet_address: + type: string + maxLength: 42 + required: + - wallet_address Notification: type: object description: Serializer for the Notification model. @@ -967,7 +1013,11 @@ components: $ref: '#/components/schemas/RecipientProfile' readOnly: true total_recipients: - type: string + type: integer + description: |- + Get the total number of recipients in the organization. + Returns: + int: The total number of recipients readOnly: true created_at: type: string @@ -1038,7 +1088,11 @@ components: $ref: '#/components/schemas/RecipientProfile' readOnly: true total_recipients: - type: string + type: integer + description: |- + Get the total number of recipients in the organization. + Returns: + int: The total number of recipients readOnly: true created_at: type: string @@ -1319,10 +1373,20 @@ components: enum: - recipient - organization + - both type: string description: |- * `recipient` - Recipient * `organization` - Organization + * `both` - Both + VerifyAddress: + type: object + properties: + wallet_address: + type: string + maxLength: 42 + required: + - wallet_address WaitlistEntry: type: object properties: diff --git a/user_profile/serializers.py b/user_profile/serializers.py index 5307214..e552da7 100644 --- a/user_profile/serializers.py +++ b/user_profile/serializers.py @@ -1,4 +1,5 @@ from rest_framework import serializers +from drf_spectacular.utils import extend_schema_field from .models import OrganizationProfile, RecipientProfile from web3auth.serializers import UserSerializer @@ -84,9 +85,12 @@ class Meta: ] read_only_fields = ["id", "created_at", "updated_at"] + @extend_schema_field(int) def get_total_recipients(self, obj): """ Get the total number of recipients in the organization. + Returns: + int: The total number of recipients """ return obj.recipients.count() diff --git a/user_profile/views.py b/user_profile/views.py index fabcfef..bc3ac6e 100644 --- a/user_profile/views.py +++ b/user_profile/views.py @@ -12,7 +12,12 @@ class IsOrganization(BasePermission): def has_permission(self, request, view): - return request.user.user_type == "organization" + return request.user.is_organization + + +class IsRecipient(BasePermission): + def has_permission(self, request, view): + return request.user.is_recipient class OrganizationProfileView(ModelViewSet): @@ -20,7 +25,6 @@ class OrganizationProfileView(ModelViewSet): Viewset for handling organization profile operations. """ - queryset = OrganizationProfile.objects.all() serializer_class = OrganizationProfileSerializer permission_classes = [IsAuthenticated, IsOrganization] @@ -33,9 +37,9 @@ def get_queryset(self): return super().get_queryset().filter(user=self.request.user) def create(self, request, *args, **kwargs): - if request.user.user_type != "organization": + if not request.user.is_organization: raise serializers.ValidationError( - "Only organization type users can create organization profiles" + "User must have organization privileges to create organization profiles" ) try: @@ -47,10 +51,14 @@ def create(self, request, *args, **kwargs): except OrganizationProfile.DoesNotExist: serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - self.perform_create(serializer) + with transaction.atomic(): + self.perform_create(serializer) + # Update user type if they're becoming both + if request.user.user_type == "recipient": + request.user.user_type = "both" + request.user.save() return Response(serializer.data, status=status.HTTP_201_CREATED) - def perform_create(self, serializer): with transaction.atomic(): serializer.save(user=self.request.user) @@ -61,7 +69,6 @@ def perform_create(self, serializer): is_read=False, ) - @action(detail=False, methods=["get"]) @action(detail=False, methods=["get"]) def get_organization_recipients(self, request): """ @@ -84,17 +91,12 @@ class RecipientProfileView(ModelViewSet): Viewset for handling recipient profile operations. """ - queryset = RecipientProfile.objects.all() serializer_class = RecipientProfileSerializer permission_classes = [IsAuthenticated] def get_queryset(self): - """ - If user is organization, return all recipients in their organization - If user is recipient, return only their profile - """ - if self.request.user.user_type == "organization": + if self.request.user.is_organization: try: organization = OrganizationProfile.objects.get(user=self.request.user) return RecipientProfile.objects.filter(organization=organization) @@ -103,25 +105,25 @@ def get_queryset(self): return RecipientProfile.objects.filter(user=self.request.user) def perform_create(self, serializer): - """ - Create a new recipient profile. - Organization users can create recipients for their organization. - """ - if self.request.user.user_type != "organization": + if not self.request.user.is_organization: raise serializers.ValidationError( - "Only organizations can create recipient profiles" + "Only users with organization privileges can create recipient profiles" ) try: organization = OrganizationProfile.objects.get(user=self.request.user) - serializer.save(organization=organization) - notification = Notification.objects.create( - user=self.request.user, - type="recipientAdded", - message="New recipient has been added successfully.", - is_read=False, - ) - notification.save() + with transaction.atomic(): + recipient = serializer.save(organization=organization) + # Update user type if they're becoming both + if recipient.user.user_type == "organization": + recipient.user.user_type = "both" + recipient.user.save() + Notification.objects.create( + user=self.request.user, + type="recipientAdded", + message=f"New recipient {recipient.name} has been added successfully.", + is_read=False, + ) except OrganizationProfile.DoesNotExist: raise serializers.ValidationError("Organization profile not found") diff --git a/web3auth/migrations/0003_alter_user_user_type.py b/web3auth/migrations/0003_alter_user_user_type.py new file mode 100644 index 0000000..5f1bc1c --- /dev/null +++ b/web3auth/migrations/0003_alter_user_user_type.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2 on 2025-05-07 14:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("web3auth", "0002_user_user_type"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="user_type", + field=models.CharField( + choices=[ + ("recipient", "Recipient"), + ("organization", "Organization"), + ("both", "Both"), + ], + default="organization", + max_length=12, + ), + ), + ] diff --git a/web3auth/models.py b/web3auth/models.py index 1d29690..eb999e1 100644 --- a/web3auth/models.py +++ b/web3auth/models.py @@ -4,12 +4,15 @@ class User(AbstractUser): - wallet_address = models.CharField(max_length=42, unique=True, null=True, blank=True) nonce = models.CharField(max_length=100, null=True, blank=True) user_type = models.CharField( max_length=12, - choices=[("recipient", "Recipient"), ("organization", "Organization")], + choices=[ + ("recipient", "Recipient"), + ("organization", "Organization"), + ("both", "Both"), # Add this option + ], default="organization", ) @@ -18,3 +21,11 @@ def __str__(self): def get_username(self): return self.wallet_address + + @property + def is_organization(self): + return self.user_type in ["organization", "both"] + + @property + def is_recipient(self): + return self.user_type in ["recipient", "both"] diff --git a/web3auth/serializers.py b/web3auth/serializers.py index 7d6f039..b8974fa 100644 --- a/web3auth/serializers.py +++ b/web3auth/serializers.py @@ -13,64 +13,87 @@ logger = logging.getLogger(__name__) User = get_user_model() + +class NonceSerializer(serializers.Serializer): + wallet_address = serializers.CharField(max_length=42) + + +# class EthereumLoginSerializer(serializers.Serializer): +# wallet_address = serializers.CharField(max_length=42) +# signature = serializers.CharField() + + +# class UserDetailSerializer(serializers.Serializer): +# wallet_address = serializers.CharField(max_length=42) +# user_type = serializers.CharField() +# is_organization = serializers.BooleanField() +# is_recipient = serializers.BooleanField() + + +class VerifyAddressSerializer(serializers.Serializer): + wallet_address = serializers.CharField(max_length=42) + + class EthereumAuthSerializer(serializers.Serializer): address = serializers.CharField(required=True) signature = serializers.CharField(required=True) def validate(self, data): - address = data['address'].lower() - signature = data['signature'] - + address = data["address"].lower() + signature = data["signature"] + # Get user with case-insensitive match try: user = User.objects.get(wallet_address__iexact=address) except User.DoesNotExist: logger.warning(f"User with address {address} not found") - return {'user': None, 'token': None} - + return {"user": None, "token": None} + # Prepare the exact message that was signed message = f"I'm signing my one-time nonce: {user.nonce}" logger.debug(f"Verifying message: {message}") - + try: # Use encode_defunct for text messages message_hash = encode_defunct(text=message) - + # Recover the address recovered_address = Account.recover_message( - message_hash, - signature=signature + message_hash, signature=signature ).lower() - + logger.debug(f"Recovered address: {recovered_address}, Expected: {address}") - + if recovered_address != address: logger.error("Address mismatch in signature verification") raise serializers.ValidationError("Address and signature don't match.") - + except ValueError as e: logger.error(f"Signature verification failed: {str(e)}") raise serializers.ValidationError("Invalid signature or message.") from e except Exception as e: logger.error(f"Unexpected error during verification: {str(e)}") raise serializers.ValidationError("Signature verification failed.") from e - + # Update nonce for future logins - user.nonce = ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(32)) + user.nonce = "".join( + secrets.choice(string.ascii_letters + string.digits) for _ in range(32) + ) user.save() - + refresh = RefreshToken.for_user(user) - refresh.set_exp(lifetime=timedelta(days=7)) - refresh.access_token.set_exp(lifetime=timedelta(hours=24)) - refresh['address'] = user.wallet_address - + refresh.set_exp(lifetime=timedelta(days=7)) + refresh.access_token.set_exp(lifetime=timedelta(hours=24)) + refresh["address"] = user.wallet_address + return { - 'user': user, - 'refresh': str(refresh), - 'access': str(refresh.access_token) + "user": user, + "refresh": str(refresh), + "access": str(refresh.access_token), } + class UserSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ('id', 'username', 'wallet_address', 'user_type') \ No newline at end of file + fields = ("id", "username", "wallet_address", "user_type") diff --git a/web3auth/views.py b/web3auth/views.py index 2ad09fa..2fed263 100644 --- a/web3auth/views.py +++ b/web3auth/views.py @@ -2,7 +2,13 @@ from rest_framework.response import Response from rest_framework import status from rest_framework.permissions import AllowAny, IsAuthenticated -from .serializers import EthereumAuthSerializer, UserSerializer +from rest_framework.generics import GenericAPIView +from .serializers import ( + NonceSerializer, + EthereumAuthSerializer, + UserSerializer, + VerifyAddressSerializer, +) from django.contrib.auth import get_user_model import secrets import string @@ -10,72 +16,82 @@ User = get_user_model() -class NonceView(APIView): + +class NonceView(GenericAPIView): + serializer_class = NonceSerializer permission_classes = [AllowAny] - + def get(self, request, address): # Generate a random nonce alphabet = string.ascii_letters + string.digits - nonce = ''.join(secrets.choice(alphabet) for i in range(32)) - + nonce = "".join(secrets.choice(alphabet) for i in range(32)) + # Get or create user and update nonce user, created = User.objects.get_or_create( wallet_address__iexact=address, defaults={ - 'wallet_address': address.lower(), - 'user_type': 'organization', - 'username': address.lower(), - 'nonce': nonce - } + "wallet_address": address.lower(), + "user_type": "organization", + "username": address.lower(), + "nonce": nonce, + }, ) - + if not created: user.nonce = nonce user.save() - - return Response({'nonce': nonce}) -class EthereumLoginView(APIView): + return Response({"nonce": nonce}) + + +class EthereumLoginView(GenericAPIView): + serializer_class = EthereumAuthSerializer permission_classes = [AllowAny] - + def post(self, request): serializer = EthereumAuthSerializer(data=request.data) serializer.is_valid(raise_exception=True) - + validated_data = serializer.validated_data - user = validated_data['user'] + user = validated_data["user"] if user is None: - return Response({'error': 'User not found'}, status=status.HTTP_400_BAD_REQUEST) - + return Response( + {"error": "User not found"}, status=status.HTTP_400_BAD_REQUEST + ) + user_serializer = UserSerializer(user) notification = Notification.objects.create( - user=user, - type="login", - message="Login Succefully", - is_read=False + user=user, type="login", message="Login Succefully", is_read=False ) - - return Response({ - 'user': user_serializer.data, - 'refresh': validated_data['refresh'], - 'access': validated_data['access'] - }) - -class UserDetailView(APIView): + + return Response( + { + "user": user_serializer.data, + "refresh": validated_data["refresh"], + "access": validated_data["access"], + } + ) + + +class UserDetailView(GenericAPIView): + serializer_class = UserSerializer permission_classes = [IsAuthenticated] - + def get(self, request): serializer = UserSerializer(request.user) return Response(serializer.data) -class VerifyAddressView(APIView): - + +class VerifyAddressView(GenericAPIView): + serializer_class = VerifyAddressSerializer + def get(self, request, address): try: user = User.objects.get(wallet_address__iexact=address) - return Response({'exists': True, 'user_type': user.user_type}, status=status.HTTP_200_OK) + return Response( + {"exists": True, "user_type": user.user_type}, status=status.HTTP_200_OK + ) except User.DoesNotExist: - return Response({'exists': False}, status=status.HTTP_404_NOT_FOUND) - \ No newline at end of file + return Response({"exists": False}, status=status.HTTP_404_NOT_FOUND) From 003d1dd66da137cc895903e5fa811b409db5e828 Mon Sep 17 00:00:00 2001 From: leojay-net Date: Tue, 13 May 2025 18:36:55 +0100 Subject: [PATCH 25/39] feat(recipient_profile): change user field from OneToOneField to ForeignKey in RecipientProfile model --- .../0007_alter_recipientprofile_user.py | 23 +++++++++++++++++++ user_profile/models.py | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 user_profile/migrations/0007_alter_recipientprofile_user.py diff --git a/user_profile/migrations/0007_alter_recipientprofile_user.py b/user_profile/migrations/0007_alter_recipientprofile_user.py new file mode 100644 index 0000000..e9b9896 --- /dev/null +++ b/user_profile/migrations/0007_alter_recipientprofile_user.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2 on 2025-05-13 17:19 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("user_profile", "0006_alter_recipientprofile_unique_together"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name="recipientprofile", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), + ), + ] diff --git a/user_profile/models.py b/user_profile/models.py index 7efd972..bb1c925 100644 --- a/user_profile/models.py +++ b/user_profile/models.py @@ -28,7 +28,7 @@ class RecipientProfile(models.Model): Each recipient is a user and belongs to an organization. """ - user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) organization = models.ForeignKey( OrganizationProfile, on_delete=models.CASCADE, related_name="recipients" ) From d0c0893e03fe203ef0924d15dcb6bc414fe1dd7a Mon Sep 17 00:00:00 2001 From: leojay-net Date: Wed, 14 May 2025 01:02:50 +0100 Subject: [PATCH 26/39] feat(recipient_profile): enhance recipient profile creation with user auto-creation and notification; update permission classes in views --- user_profile/serializers.py | 31 +++++++++- user_profile/views.py | 110 +++++++++++++----------------------- web3auth/urls.py | 14 +++-- 3 files changed, 77 insertions(+), 78 deletions(-) diff --git a/user_profile/serializers.py b/user_profile/serializers.py index e552da7..8593405 100644 --- a/user_profile/serializers.py +++ b/user_profile/serializers.py @@ -2,6 +2,9 @@ from drf_spectacular.utils import extend_schema_field from .models import OrganizationProfile, RecipientProfile from web3auth.serializers import UserSerializer +from django.contrib.auth import get_user_model +import secrets +import string class RecipientProfileSerializer(serializers.ModelSerializer): @@ -10,7 +13,7 @@ class RecipientProfileSerializer(serializers.ModelSerializer): """ user = UserSerializer(read_only=True) - # user_id = serializers.IntegerField(write_only=True, required=False) # Add this line + #wallet_address = serializers.CharField(max_length=42, write_only=True) class Meta: model = RecipientProfile @@ -19,7 +22,7 @@ class Meta: "name", "email", "user", - "organization", + #"wallet_address", # Added for creating new users "recipient_ethereum_address", "recipient_phone", "salary", @@ -30,6 +33,28 @@ class Meta: ] read_only_fields = ["id", "created_at", "updated_at"] + def create(self, validated_data): + recipient_ethereum_address = validated_data.pop('recipient_ethereum_address') + + # Try to get existing user or create new one + User = get_user_model() + user, created = User.objects.get_or_create( + wallet_address__iexact=recipient_ethereum_address, + defaults={ + "wallet_address": recipient_ethereum_address.lower(), + "username": recipient_ethereum_address.lower(), + "user_type": "recipient", + "nonce": "".join(secrets.choice(string.ascii_letters + string.digits) for i in range(32)) + } + ) + + # Create recipient profile + recipient_profile = RecipientProfile.objects.create( + user=user, + **validated_data + ) + return recipient_profile + def to_representation(self, instance): """ Customize the representation of the RecipientProfile instance. @@ -44,7 +69,7 @@ def to_representation(self, instance): ).data, # Use serializer instead of direct query "recipient_ethereum_address": instance.recipient_ethereum_address, # Added missing field "recipient_phone": instance.recipient_phone, # Added missing field - "wallet_address": instance.user.wallet_address, + #"wallet_address": instance.user.wallet_address, "salary": instance.salary, "position": instance.position, "status": instance.status, diff --git a/user_profile/views.py b/user_profile/views.py index bc3ac6e..c71dec2 100644 --- a/user_profile/views.py +++ b/user_profile/views.py @@ -93,49 +93,35 @@ class RecipientProfileView(ModelViewSet): queryset = RecipientProfile.objects.all() serializer_class = RecipientProfileSerializer - permission_classes = [IsAuthenticated] - - def get_queryset(self): - if self.request.user.is_organization: - try: - organization = OrganizationProfile.objects.get(user=self.request.user) - return RecipientProfile.objects.filter(organization=organization) - except OrganizationProfile.DoesNotExist: - return RecipientProfile.objects.none() - return RecipientProfile.objects.filter(user=self.request.user) + permission_classes = [IsAuthenticated, IsOrganization] + @transaction.atomic def perform_create(self, serializer): - if not self.request.user.is_organization: - raise serializers.ValidationError( - "Only users with organization privileges can create recipient profiles" - ) - try: organization = OrganizationProfile.objects.get(user=self.request.user) - with transaction.atomic(): - recipient = serializer.save(organization=organization) - # Update user type if they're becoming both - if recipient.user.user_type == "organization": - recipient.user.user_type = "both" - recipient.user.save() - Notification.objects.create( - user=self.request.user, - type="recipientAdded", - message=f"New recipient {recipient.name} has been added successfully.", - is_read=False, - ) + recipient = serializer.save(organization=organization) + + # Create notification + Notification.objects.create( + user=self.request.user, + type="recipientAdded", + message=f"New recipient {recipient.name} has been added successfully.", + is_read=False + ) + except OrganizationProfile.DoesNotExist: raise serializers.ValidationError("Organization profile not found") @action(detail=False, methods=["post"]) + @transaction.atomic def batch_create(self, request): - if self.request.user.user_type != "organization": + if not request.user.is_organization: raise serializers.ValidationError( "Only organizations can create recipient profiles" ) try: - organization = OrganizationProfile.objects.get(user=self.request.user) + organization = OrganizationProfile.objects.get(user=request.user) except OrganizationProfile.DoesNotExist: raise serializers.ValidationError("Organization profile not found") @@ -146,46 +132,30 @@ def batch_create(self, request): created_recipients = [] errors = [] - with transaction.atomic(): - for recipient_data in recipients_data: - serializer = self.get_serializer(data=recipient_data) - try: - if serializer.is_valid(raise_exception=True): - recipient = serializer.save( - organization=organization, user_type="recipient" - ) - created_recipients.append(recipient) - except serializers.ValidationError as e: - errors.append( - { - "recipient": recipient_data.get("email", "Unknown"), - "errors": e.detail, - } - ) - continue - - if created_recipients: - Notification.objects.create( - user=self.request.user, - type="recipientsAdded", - message=f"Successfully added {len(created_recipients)} recipients.", - is_read=False, - ) - - response_data = { + for recipient_data in recipients_data: + serializer = self.get_serializer(data=recipient_data) + try: + if serializer.is_valid(raise_exception=True): + recipient = serializer.save(organization=organization) + created_recipients.append(recipient) + except Exception as e: + errors.append({ + "recipient": recipient_data.get("email", "Unknown"), + "errors": str(e) + }) + continue + + if created_recipients: + Notification.objects.create( + user=request.user, + type="recipientsAdded", + message=f"Successfully added {len(created_recipients)} recipients.", + is_read=False + ) + + return Response({ "success": len(created_recipients), "failed": len(errors), - "created_recipients": RecipientProfileSerializer( - created_recipients, many=True - ).data, - "errors": errors if errors else None, - } - - return Response( - response_data, - status=( - status.HTTP_201_CREATED - if created_recipients - else status.HTTP_400_BAD_REQUEST - ), - ) + "created_recipients": RecipientProfileSerializer(created_recipients, many=True).data, + "errors": errors if errors else None + }, status=status.HTTP_201_CREATED if created_recipients else status.HTTP_400_BAD_REQUEST) diff --git a/web3auth/urls.py b/web3auth/urls.py index 3204fc7..1913016 100644 --- a/web3auth/urls.py +++ b/web3auth/urls.py @@ -2,8 +2,12 @@ from .views import NonceView, EthereumLoginView, UserDetailView, VerifyAddressView urlpatterns = [ - path('nonce//', NonceView.as_view(), name='nonce'), - path('login/', EthereumLoginView.as_view(), name='ethereum-login'), - path('user/', UserDetailView.as_view(), name='user-detail'), - path('verify-address//', VerifyAddressView.as_view(), name='verify-address-detail'), -] \ No newline at end of file + path("nonce//", NonceView.as_view(), name="nonce"), + path("login/", EthereumLoginView.as_view(), name="ethereum-login"), + path("user/", UserDetailView.as_view(), name="user-detail"), + path( + "verify-address//", + VerifyAddressView.as_view(), + name="verify-address-detail", + ), +] From 659b9e0c16c62d968f0f7551cd6e3f4ed9115c57 Mon Sep 17 00:00:00 2001 From: leojay-net Date: Wed, 14 May 2025 03:43:24 +0100 Subject: [PATCH 27/39] feat(recipient_profile): update recipient profile serializer and view to enforce required fields; add organization context for profile creation --- schema.yml | 2 + user_profile/models.py | 2 +- user_profile/serializers.py | 100 +++++++++++++++++++++--------------- user_profile/views.py | 45 ++++++++++------ 4 files changed, 90 insertions(+), 59 deletions(-) diff --git a/schema.yml b/schema.yml index 22f4301..ec8a2b9 100644 --- a/schema.yml +++ b/schema.yml @@ -1168,6 +1168,7 @@ components: readOnly: true organization: type: integer + readOnly: true recipient_ethereum_address: type: string maxLength: 42 @@ -1287,6 +1288,7 @@ components: readOnly: true organization: type: integer + readOnly: true recipient_ethereum_address: type: string maxLength: 42 diff --git a/user_profile/models.py b/user_profile/models.py index bb1c925..21ee182 100644 --- a/user_profile/models.py +++ b/user_profile/models.py @@ -34,7 +34,7 @@ class RecipientProfile(models.Model): ) name = models.CharField(max_length=150) email = models.EmailField(unique=True) - recipient_ethereum_address = models.CharField(max_length=42, unique=True) + recipient_ethereum_address = models.CharField(max_length=42, unique=True, null=False, blank=False) recipient_phone = models.CharField(max_length=15, blank=True, null=True) salary = models.IntegerField(blank=True, null=True) position = models.CharField(max_length=100, blank=True, null=True) diff --git a/user_profile/serializers.py b/user_profile/serializers.py index 8593405..af6266c 100644 --- a/user_profile/serializers.py +++ b/user_profile/serializers.py @@ -13,7 +13,7 @@ class RecipientProfileSerializer(serializers.ModelSerializer): """ user = UserSerializer(read_only=True) - #wallet_address = serializers.CharField(max_length=42, write_only=True) + organization = serializers.PrimaryKeyRelatedField(read_only=True) class Meta: model = RecipientProfile @@ -22,7 +22,7 @@ class Meta: "name", "email", "user", - #"wallet_address", # Added for creating new users + "organization", "recipient_ethereum_address", "recipient_phone", "salary", @@ -32,54 +32,70 @@ class Meta: "updated_at", ] read_only_fields = ["id", "created_at", "updated_at"] + extra_kwargs = { + "recipient_ethereum_address": {"required": True}, + "email": {"required": True}, + "name": {"required": True}, + } + + def validate(self, attrs): + # Validate required fields first + if not attrs.get("recipient_ethereum_address"): + raise serializers.ValidationError( + {"recipient_ethereum_address": "This field is required."} + ) + if not attrs.get("email"): + raise serializers.ValidationError({"email": "This field is required."}) + + # Convert ethereum address to lowercase + attrs["recipient_ethereum_address"] = attrs[ + "recipient_ethereum_address" + ].lower() + + # Check for existing ethereum address + if RecipientProfile.objects.filter( + recipient_ethereum_address__iexact=attrs["recipient_ethereum_address"] + ).exists(): + raise serializers.ValidationError( + { + "recipient_ethereum_address": "This ethereum address is already registered." + } + ) + + return attrs def create(self, validated_data): - recipient_ethereum_address = validated_data.pop('recipient_ethereum_address') - - # Try to get existing user or create new one + organization = self.context.get("organization") + if not organization: + raise serializers.ValidationError( + {"organization": "Organization is required"} + ) + + # Create user first User = get_user_model() user, created = User.objects.get_or_create( - wallet_address__iexact=recipient_ethereum_address, + wallet_address__iexact=validated_data["recipient_ethereum_address"], defaults={ - "wallet_address": recipient_ethereum_address.lower(), - "username": recipient_ethereum_address.lower(), + "wallet_address": validated_data["recipient_ethereum_address"], + "username": validated_data["recipient_ethereum_address"], "user_type": "recipient", - "nonce": "".join(secrets.choice(string.ascii_letters + string.digits) for i in range(32)) - } + "nonce": "".join( + secrets.choice(string.ascii_letters + string.digits) + for i in range(32) + ), + }, ) - # Create recipient profile - recipient_profile = RecipientProfile.objects.create( - user=user, - **validated_data - ) - return recipient_profile - - def to_representation(self, instance): - """ - Customize the representation of the RecipientProfile instance. - """ - return { - "id": instance.id, - "name": instance.name, - "email": instance.email, # Added missing email field - "organization": instance.organization.name, - "user": UserSerializer( - instance.user - ).data, # Use serializer instead of direct query - "recipient_ethereum_address": instance.recipient_ethereum_address, # Added missing field - "recipient_phone": instance.recipient_phone, # Added missing field - #"wallet_address": instance.user.wallet_address, - "salary": instance.salary, - "position": instance.position, - "status": instance.status, - "created_at": ( - instance.created_at.isoformat() if instance.created_at else None - ), - "updated_at": ( - instance.updated_at.isoformat() if instance.updated_at else None - ), - } + try: + # Create recipient profile + recipient_profile = RecipientProfile.objects.create( + user=user, organization=organization, **validated_data + ) + return recipient_profile + except Exception as e: + if created: + user.delete() # Cleanup if user was just created + raise class OrganizationProfileSerializer(serializers.ModelSerializer): diff --git a/user_profile/views.py b/user_profile/views.py index c71dec2..c7d9e93 100644 --- a/user_profile/views.py +++ b/user_profile/views.py @@ -99,18 +99,20 @@ class RecipientProfileView(ModelViewSet): def perform_create(self, serializer): try: organization = OrganizationProfile.objects.get(user=self.request.user) - recipient = serializer.save(organization=organization) - - # Create notification + serializer.context["organization"] = organization + recipient = serializer.save() + Notification.objects.create( user=self.request.user, type="recipientAdded", message=f"New recipient {recipient.name} has been added successfully.", - is_read=False + is_read=False, ) except OrganizationProfile.DoesNotExist: - raise serializers.ValidationError("Organization profile not found") + raise serializers.ValidationError( + {"detail": "Organization profile not found"} + ) @action(detail=False, methods=["post"]) @transaction.atomic @@ -139,10 +141,12 @@ def batch_create(self, request): recipient = serializer.save(organization=organization) created_recipients.append(recipient) except Exception as e: - errors.append({ - "recipient": recipient_data.get("email", "Unknown"), - "errors": str(e) - }) + errors.append( + { + "recipient": recipient_data.get("email", "Unknown"), + "errors": str(e), + } + ) continue if created_recipients: @@ -150,12 +154,21 @@ def batch_create(self, request): user=request.user, type="recipientsAdded", message=f"Successfully added {len(created_recipients)} recipients.", - is_read=False + is_read=False, ) - return Response({ - "success": len(created_recipients), - "failed": len(errors), - "created_recipients": RecipientProfileSerializer(created_recipients, many=True).data, - "errors": errors if errors else None - }, status=status.HTTP_201_CREATED if created_recipients else status.HTTP_400_BAD_REQUEST) + return Response( + { + "success": len(created_recipients), + "failed": len(errors), + "created_recipients": RecipientProfileSerializer( + created_recipients, many=True + ).data, + "errors": errors if errors else None, + }, + status=( + status.HTTP_201_CREATED + if created_recipients + else status.HTTP_400_BAD_REQUEST + ), + ) From b7419e3c1616e5674fff939ec397f5439058ec88 Mon Sep 17 00:00:00 2001 From: web-ghost-dotcom Date: Fri, 16 May 2025 00:22:49 +0100 Subject: [PATCH 28/39] feat(payroll): change amount field from DecimalField to BigIntegerField with updated validators and help text --- .../migrations/0003_alter_payroll_amount.py | 22 +++++++++++++++++++ payroll/models.py | 7 +++--- 2 files changed, 25 insertions(+), 4 deletions(-) create mode 100644 payroll/migrations/0003_alter_payroll_amount.py diff --git a/payroll/migrations/0003_alter_payroll_amount.py b/payroll/migrations/0003_alter_payroll_amount.py new file mode 100644 index 0000000..11ff49c --- /dev/null +++ b/payroll/migrations/0003_alter_payroll_amount.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2 on 2025-05-15 23:20 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("payroll", "0002_alter_payroll_options_remove_payroll_user_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="payroll", + name="amount", + field=models.BigIntegerField( + help_text="Amount in smallest currency unit (e.g., cents for USD)", + validators=[django.core.validators.MinValueValidator(0)], + ), + ), + ] diff --git a/payroll/models.py b/payroll/models.py index 6a7a652..151c551 100644 --- a/payroll/models.py +++ b/payroll/models.py @@ -8,10 +8,9 @@ class PayRoll(models.Model): """ recipient = models.ForeignKey(RecipientProfile, on_delete=models.CASCADE, related_name='recipients') organization = models.ForeignKey(OrganizationProfile, on_delete=models.CASCADE, related_name='organization') - amount = models.DecimalField( - max_digits=10, - decimal_places=2, - validators=[MinValueValidator(0.01)] + amount = models.BigIntegerField( + validators=[MinValueValidator(0)], + help_text="Amount in smallest currency unit (e.g., cents for USD)" ) batch_reference = models.CharField(max_length=50, blank=True, null=True) description = models.TextField(blank=True) From 57e6589792221a35130ca3a63afd7915a79b32c7 Mon Sep 17 00:00:00 2001 From: web-ghost-dotcom Date: Sun, 18 May 2025 00:54:44 +0100 Subject: [PATCH 29/39] feat(leaveRequest): add leave request model, views, serializers, and API endpoints --- HR_Backend/settings.py | 1 + HR_Backend/urls.py | 1 + leaveRequest/__init__.py | 0 leaveRequest/admin.py | 3 + leaveRequest/apps.py | 6 + leaveRequest/migrations/0001_initial.py | 67 ++++++ leaveRequest/migrations/__init__.py | 0 leaveRequest/models.py | 40 ++++ leaveRequest/serializers.py | 26 ++ leaveRequest/tests.py | 3 + leaveRequest/urls.py | 10 + leaveRequest/views.py | 90 +++++++ schema.yml | 305 +++++++++++++++++++++++- 13 files changed, 546 insertions(+), 6 deletions(-) create mode 100644 leaveRequest/__init__.py create mode 100644 leaveRequest/admin.py create mode 100644 leaveRequest/apps.py create mode 100644 leaveRequest/migrations/0001_initial.py create mode 100644 leaveRequest/migrations/__init__.py create mode 100644 leaveRequest/models.py create mode 100644 leaveRequest/serializers.py create mode 100644 leaveRequest/tests.py create mode 100644 leaveRequest/urls.py create mode 100644 leaveRequest/views.py diff --git a/HR_Backend/settings.py b/HR_Backend/settings.py index 9868767..3f6b45d 100644 --- a/HR_Backend/settings.py +++ b/HR_Backend/settings.py @@ -49,6 +49,7 @@ 'user_profile.apps.UserProfileConfig', 'notifications.apps.NotificationsConfig', 'payroll.apps.PayrollConfig', + 'leaveRequest.apps.LeaverequestConfig', # Third-party apps 'corsheaders', diff --git a/HR_Backend/urls.py b/HR_Backend/urls.py index e3c1a0d..1f01106 100644 --- a/HR_Backend/urls.py +++ b/HR_Backend/urls.py @@ -30,5 +30,6 @@ path('api/v1/profile/', include('user_profile.urls')), path('api/v1/notifications/', include('notifications.urls')), path('api/v1/payroll/', include('payroll.urls')), + path('api/v1/leave_requests/', include('leaveRequest.urls')), ] diff --git a/leaveRequest/__init__.py b/leaveRequest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leaveRequest/admin.py b/leaveRequest/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/leaveRequest/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/leaveRequest/apps.py b/leaveRequest/apps.py new file mode 100644 index 0000000..2298113 --- /dev/null +++ b/leaveRequest/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LeaverequestConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "leaveRequest" diff --git a/leaveRequest/migrations/0001_initial.py b/leaveRequest/migrations/0001_initial.py new file mode 100644 index 0000000..a0b1ac5 --- /dev/null +++ b/leaveRequest/migrations/0001_initial.py @@ -0,0 +1,67 @@ +# Generated by Django 5.2 on 2025-05-17 23:32 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("user_profile", "0007_alter_recipientprofile_user"), + ] + + operations = [ + migrations.CreateModel( + name="LeaveRequest", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("start_date", models.DateField()), + ("end_date", models.DateField()), + ( + "leave_type", + models.CharField( + choices=[ + ("sick", "Sick Leave"), + ("vacation", "Vacation Leave"), + ("personal", "Personal Leave"), + ("other", "Other"), + ], + max_length=30, + ), + ), + ("reason", models.TextField()), + ( + "status", + models.CharField( + choices=[ + ("pending", "Pending"), + ("approved", "Approved"), + ("rejected", "Rejected"), + ], + default="pending", + max_length=30, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "recipient", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="leave_requests", + to="user_profile.recipientprofile", + ), + ), + ], + ), + ] diff --git a/leaveRequest/migrations/__init__.py b/leaveRequest/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leaveRequest/models.py b/leaveRequest/models.py new file mode 100644 index 0000000..10d0f26 --- /dev/null +++ b/leaveRequest/models.py @@ -0,0 +1,40 @@ +from django.db import models +from user_profile.models import RecipientProfile + +class LeaveRequest(models.Model): + """ + Model to store leave request information for users. + """ + + recipient = models.ForeignKey( + RecipientProfile, + on_delete=models.CASCADE, + related_name="leave_requests", + ) + start_date = models.DateField() + end_date = models.DateField() + leave_type = models.CharField( + max_length=30, + choices=[ + ("sick", "Sick Leave"), + ("vacation", "Vacation Leave"), + ("personal", "Personal Leave"), + ("other", "Other"), + ], + ) + reason = models.TextField() + status = models.CharField( + max_length=30, + choices=[ + ("pending", "Pending"), + ("approved", "Approved"), + ("rejected", "Rejected"), + ], + default="pending", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + + def __str__(self): + return f"Leave Request from {self.recipient.name} - {self.status}" \ No newline at end of file diff --git a/leaveRequest/serializers.py b/leaveRequest/serializers.py new file mode 100644 index 0000000..bc20be7 --- /dev/null +++ b/leaveRequest/serializers.py @@ -0,0 +1,26 @@ +from rest_framework import serializers +from .models import LeaveRequest +from user_profile.serializers import RecipientProfileSerializer + + +class LeaveRequestSerializer(serializers.ModelSerializer): + """ + Serializer for LeaveRequest model. + """ + + recipient = RecipientProfileSerializer(read_only=True) + + class Meta: + model = LeaveRequest + fields = [ + "id", + "recipient", + "start_date", + "end_date", + "leave_type", + "reason", + "status", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "created_at", "updated_at"] \ No newline at end of file diff --git a/leaveRequest/tests.py b/leaveRequest/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/leaveRequest/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/leaveRequest/urls.py b/leaveRequest/urls.py new file mode 100644 index 0000000..1d0e22b --- /dev/null +++ b/leaveRequest/urls.py @@ -0,0 +1,10 @@ +from .views import LeaveRequestView +from django.urls import path, include +from rest_framework.routers import DefaultRouter + + +router = DefaultRouter() +router.register(r"leave_requests", LeaveRequestView, basename="leave_requests") +urlpatterns = [ + path("", include(router.urls)), +] \ No newline at end of file diff --git a/leaveRequest/views.py b/leaveRequest/views.py new file mode 100644 index 0000000..2f5f94d --- /dev/null +++ b/leaveRequest/views.py @@ -0,0 +1,90 @@ +from .models import LeaveRequest +from .serializers import LeaveRequestSerializer +from rest_framework import viewsets +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework import status, serializers +from rest_framework.decorators import action +from user_profile.views import IsOrganization, IsRecipient + + + +class LeaveRequestView(viewsets.ModelViewSet): + """ + Viewset for handling leave request operations. + """ + + queryset = LeaveRequest.objects.all() + serializer_class = LeaveRequestSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + """ + Optionally restricts the returned leave requests to a given user, + by filtering against a `user` query parameter in the URL. + """ + if self.request.user.is_recipient: + return super().get_queryset().filter(recipient=self.request.user) + elif self.request.user.is_organization: + return super().get_queryset().filter(recipient__organization=self.request.user) + elif self.request.user.usertype == "both": + return super().get_queryset().filter( + recipient__organization=self.request.user + ) + return [] + + def perform_create(self, serializer): + """ + Ensure recipient can only create leave requests for their own account + """ + if not self.request.user.is_recipient: + raise serializers.ValidationError( + "User must have recipient privileges to create leave requests" + ) + serializer.save(recipient=self.request.user) + + + @action(detail=False, methods=["get"], permission_classes=[IsAuthenticated]) + def get_leave_requests(self, request): + """ + Get leave requests based on user type. + """ + usertype = request.query_params.get('usertype', None) + if usertype == "recipient": + return LeaveRequest.objects.filter(recipient=request.user) + elif usertype == "organization": + return LeaveRequest.objects.filter( + recipient__organization=request.user + ) + return [] + + @action(detail=True, methods=["post"], permission_classes=[IsOrganization]) + def approve_leave_request(self, request, pk=None): + """ + Approve a leave request. + """ + try: + leave_request = self.get_object() + leave_request.status = "approved" + leave_request.save() + return Response({"status": "Leave request approved"}, status=status.HTTP_200_OK) + except LeaveRequest.DoesNotExist: + return Response({"error": "Leave request not found"}, status=status.HTTP_404_NOT_FOUND) + + + def patch_update_leave_request(self, request, pk=None): + """ + Update a leave request. + """ + if not request.user.is_recipient: + raise serializers.ValidationError( + "User must have recipient privileges to update leave requests" + ) + try: + leave_request = self.get_object() + serializer = self.get_serializer(leave_request, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + except LeaveRequest.DoesNotExist: + return Response({"error": "Leave request not found"}, status=status.HTTP_404_NOT_FOUND) \ No newline at end of file diff --git a/schema.yml b/schema.yml index ec8a2b9..1ffa5a8 100644 --- a/schema.yml +++ b/schema.yml @@ -4,6 +4,202 @@ info: version: 1.0.0 description: API for managing HR operations paths: + /api/v1/leave_requests/leave_requests/: + get: + operationId: leave_requests_leave_requests_list + description: Viewset for handling leave request operations. + tags: + - leave_requests + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/LeaveRequest' + description: '' + post: + operationId: leave_requests_leave_requests_create + description: Viewset for handling leave request operations. + tags: + - leave_requests + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/LeaveRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/LeaveRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/LeaveRequest' + required: true + security: + - jwtAuth: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/LeaveRequest' + description: '' + /api/v1/leave_requests/leave_requests/{id}/: + get: + operationId: leave_requests_leave_requests_retrieve + description: Viewset for handling leave request operations. + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this leave request. + required: true + tags: + - leave_requests + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/LeaveRequest' + description: '' + put: + operationId: leave_requests_leave_requests_update + description: Viewset for handling leave request operations. + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this leave request. + required: true + tags: + - leave_requests + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/LeaveRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/LeaveRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/LeaveRequest' + required: true + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/LeaveRequest' + description: '' + patch: + operationId: leave_requests_leave_requests_partial_update + description: Viewset for handling leave request operations. + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this leave request. + required: true + tags: + - leave_requests + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedLeaveRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedLeaveRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedLeaveRequest' + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/LeaveRequest' + description: '' + delete: + operationId: leave_requests_leave_requests_destroy + description: Viewset for handling leave request operations. + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this leave request. + required: true + tags: + - leave_requests + security: + - jwtAuth: [] + responses: + '204': + description: No response body + /api/v1/leave_requests/leave_requests/{id}/approve_leave_request/: + post: + operationId: leave_requests_leave_requests_approve_leave_request_create + description: Approve a leave request. + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this leave request. + required: true + tags: + - leave_requests + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/LeaveRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/LeaveRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/LeaveRequest' + required: true + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/LeaveRequest' + description: '' + /api/v1/leave_requests/leave_requests/get_leave_requests/: + get: + operationId: leave_requests_leave_requests_get_leave_requests_retrieve + description: Get leave requests based on user type. + tags: + - leave_requests + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/LeaveRequest' + description: '' /api/v1/notifications/notifications/: get: operationId: notifications_notifications_list @@ -946,6 +1142,68 @@ components: required: - address - signature + LeaveRequest: + type: object + description: Serializer for LeaveRequest model. + properties: + id: + type: integer + readOnly: true + recipient: + allOf: + - $ref: '#/components/schemas/RecipientProfile' + readOnly: true + start_date: + type: string + format: date + end_date: + type: string + format: date + leave_type: + $ref: '#/components/schemas/LeaveTypeEnum' + reason: + type: string + status: + $ref: '#/components/schemas/LeaveRequestStatusEnum' + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + required: + - created_at + - end_date + - id + - leave_type + - reason + - recipient + - start_date + - updated_at + LeaveRequestStatusEnum: + enum: + - pending + - approved + - rejected + type: string + description: |- + * `pending` - Pending + * `approved` - Approved + * `rejected` - Rejected + LeaveTypeEnum: + enum: + - sick + - vacation + - personal + - other + type: string + description: |- + * `sick` - Sick Leave + * `vacation` - Vacation Leave + * `personal` - Personal Leave + * `other` - Other Nonce: type: object properties: @@ -1036,6 +1294,37 @@ components: - total_recipients - updated_at - user + PatchedLeaveRequest: + type: object + description: Serializer for LeaveRequest model. + properties: + id: + type: integer + readOnly: true + recipient: + allOf: + - $ref: '#/components/schemas/RecipientProfile' + readOnly: true + start_date: + type: string + format: date + end_date: + type: string + format: date + leave_type: + $ref: '#/components/schemas/LeaveTypeEnum' + reason: + type: string + status: + $ref: '#/components/schemas/LeaveRequestStatusEnum' + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true PatchedNotification: type: object description: Serializer for the Notification model. @@ -1122,9 +1411,11 @@ components: - $ref: '#/components/schemas/OrganizationProfile' readOnly: true amount: - type: string - format: decimal - pattern: ^-?\d{0,8}(?:\.\d{0,2})?$ + type: integer + maximum: 9223372036854775807 + minimum: 0 + format: int64 + description: Amount in smallest currency unit (e.g., cents for USD) batch_reference: type: string nullable: true @@ -1221,9 +1512,11 @@ components: - $ref: '#/components/schemas/OrganizationProfile' readOnly: true amount: - type: string - format: decimal - pattern: ^-?\d{0,8}(?:\.\d{0,2})?$ + type: integer + maximum: 9223372036854775807 + minimum: 0 + format: int64 + description: Amount in smallest currency unit (e.g., cents for USD) batch_reference: type: string nullable: true From 32c2690fa9ab3de1445db5c5bdba113baf942c0c Mon Sep 17 00:00:00 2001 From: web-ghost-dotcom Date: Sun, 18 May 2025 14:42:48 +0100 Subject: [PATCH 30/39] feat(leaveRequest): enhance get_leave_requests to filter by user type with improved error handling and documentation --- leaveRequest/views.py | 34 ++++++++++++++++++++++++++++------ schema.yml | 6 +++++- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/leaveRequest/views.py b/leaveRequest/views.py index 2f5f94d..9ae9930 100644 --- a/leaveRequest/views.py +++ b/leaveRequest/views.py @@ -48,15 +48,37 @@ def perform_create(self, serializer): def get_leave_requests(self, request): """ Get leave requests based on user type. + + Query Parameters: + usertype (str, optional): Filter by user type ('recipient' or 'organization') """ usertype = request.query_params.get('usertype', None) - if usertype == "recipient": - return LeaveRequest.objects.filter(recipient=request.user) - elif usertype == "organization": - return LeaveRequest.objects.filter( - recipient__organization=request.user + queryset = None + + try: + if usertype == "recipient": + recipient_profile = getattr(request.user, 'recipientprofile', None) + if not recipient_profile: + raise ValueError("No recipient profile found") + queryset = LeaveRequest.objects.filter(recipient=recipient_profile) + + elif usertype == "organization": + org_profile = getattr(request.user, 'organizationprofile', None) + if not org_profile: + raise ValueError("No organization profile found") + queryset = LeaveRequest.objects.filter(recipient__organization=org_profile) + + else: + queryset = self.get_queryset() + + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + except (AttributeError, ValueError) as e: + return Response( + {"error": str(e) or "Invalid user type or user profile not found"}, + status=status.HTTP_400_BAD_REQUEST ) - return [] @action(detail=True, methods=["post"], permission_classes=[IsOrganization]) def approve_leave_request(self, request, pk=None): diff --git a/schema.yml b/schema.yml index 1ffa5a8..d2a3741 100644 --- a/schema.yml +++ b/schema.yml @@ -188,7 +188,11 @@ paths: /api/v1/leave_requests/leave_requests/get_leave_requests/: get: operationId: leave_requests_leave_requests_get_leave_requests_retrieve - description: Get leave requests based on user type. + description: |- + Get leave requests based on user type. + + Query Parameters: + usertype (str, optional): Filter by user type ('recipient' or 'organization') tags: - leave_requests security: From add742503a5f45e303c9e1e8daffa7fa7f6db90a Mon Sep 17 00:00:00 2001 From: web-ghost-dotcom Date: Sun, 18 May 2025 21:30:18 +0100 Subject: [PATCH 31/39] feat(recipient_profile): update permission handling for recipient profile creation and ensure only recipients can update their own profiles with appropriate validation --- user_profile/views.py | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/user_profile/views.py b/user_profile/views.py index c7d9e93..f4e8931 100644 --- a/user_profile/views.py +++ b/user_profile/views.py @@ -93,11 +93,15 @@ class RecipientProfileView(ModelViewSet): queryset = RecipientProfile.objects.all() serializer_class = RecipientProfileSerializer - permission_classes = [IsAuthenticated, IsOrganization] + permission_classes = [IsAuthenticated] @transaction.atomic def perform_create(self, serializer): try: + if not self.request.user.is_organization: + raise serializers.ValidationError( + "User must have organization privileges to create recipient profiles" + ) organization = OrganizationProfile.objects.get(user=self.request.user) serializer.context["organization"] = organization recipient = serializer.save() @@ -172,3 +176,37 @@ def batch_create(self, request): else status.HTTP_400_BAD_REQUEST ), ) + + def perform_update(self, serializer): + """ + Ensure only recipients can update their own profiles + """ + try: + # Get the recipient profile being updated + instance = self.get_object() + + # Check if the user is updating their own profile + if instance.user != self.request.user: + raise serializers.ValidationError( + {"detail": "You can only update your own profile"} + ) + + # Check if user is a recipient + if not self.request.user.is_recipient: + raise serializers.ValidationError( + {"detail": "User must have recipient privileges to update recipient profiles"} + ) + + # Save and create notification + serializer.save() + Notification.objects.create( + user=self.request.user, + type="recipientUpdated", + message="Recipient profile updated successfully.", + is_read=False + ) + + except RecipientProfile.DoesNotExist: + raise serializers.ValidationError( + {"detail": "Recipient profile not found"} + ) \ No newline at end of file From 7875e6c9f02d69775ce398f0106ca108dab24626 Mon Sep 17 00:00:00 2001 From: web-ghost-dotcom Date: Wed, 21 May 2025 01:24:16 +0100 Subject: [PATCH 32/39] feat(leaveRequest): enforce recipient privileges for leave request creation and save with recipient profile --- leaveRequest/views.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/leaveRequest/views.py b/leaveRequest/views.py index 9ae9930..9007abf 100644 --- a/leaveRequest/views.py +++ b/leaveRequest/views.py @@ -6,6 +6,7 @@ from rest_framework import status, serializers from rest_framework.decorators import action from user_profile.views import IsOrganization, IsRecipient +from user_profile.models import RecipientProfile @@ -37,11 +38,22 @@ def perform_create(self, serializer): """ Ensure recipient can only create leave requests for their own account """ - if not self.request.user.is_recipient: + try: + if not self.request.user.is_recipient: + raise serializers.ValidationError( + "User must have recipient privileges to create leave requests" + ) + + # Get the recipient profile for the user + recipient_profile = RecipientProfile.objects.get(user=self.request.user) + + # Save with the recipient profile instead of the user + serializer.save(recipient=recipient_profile) + + except RecipientProfile.DoesNotExist: raise serializers.ValidationError( - "User must have recipient privileges to create leave requests" + {"detail": "No recipient profile found for this user"} ) - serializer.save(recipient=self.request.user) @action(detail=False, methods=["get"], permission_classes=[IsAuthenticated]) From 85545cfbd87c63f3176d59c1c251c4927ce6e93f Mon Sep 17 00:00:00 2001 From: web-ghost-dotcom Date: Wed, 21 May 2025 01:27:06 +0100 Subject: [PATCH 33/39] refactor(leaveRequest): remove redundant comment when retrieving recipient profile --- leaveRequest/views.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/leaveRequest/views.py b/leaveRequest/views.py index 9007abf..430b95b 100644 --- a/leaveRequest/views.py +++ b/leaveRequest/views.py @@ -43,8 +43,7 @@ def perform_create(self, serializer): raise serializers.ValidationError( "User must have recipient privileges to create leave requests" ) - - # Get the recipient profile for the user + recipient_profile = RecipientProfile.objects.get(user=self.request.user) # Save with the recipient profile instead of the user From ae273c1225576cd3d37a2a6abe392fdc3b8fe158 Mon Sep 17 00:00:00 2001 From: web-ghost-dotcom Date: Wed, 21 May 2025 03:36:52 +0100 Subject: [PATCH 34/39] feat(leaveRequest): enhance leave request handling with improved user profile validation and notifications --- leaveRequest/views.py | 160 +++++++++++++++++++++++++++--------------- 1 file changed, 102 insertions(+), 58 deletions(-) diff --git a/leaveRequest/views.py b/leaveRequest/views.py index 430b95b..274b080 100644 --- a/leaveRequest/views.py +++ b/leaveRequest/views.py @@ -6,8 +6,8 @@ from rest_framework import status, serializers from rest_framework.decorators import action from user_profile.views import IsOrganization, IsRecipient -from user_profile.models import RecipientProfile - +from user_profile.models import RecipientProfile, OrganizationProfile +from notifications.models import Notification class LeaveRequestView(viewsets.ModelViewSet): @@ -21,103 +21,147 @@ class LeaveRequestView(viewsets.ModelViewSet): def get_queryset(self): """ - Optionally restricts the returned leave requests to a given user, - by filtering against a `user` query parameter in the URL. + Filter leave requests based on user type and profile. """ - if self.request.user.is_recipient: - return super().get_queryset().filter(recipient=self.request.user) - elif self.request.user.is_organization: - return super().get_queryset().filter(recipient__organization=self.request.user) - elif self.request.user.usertype == "both": - return super().get_queryset().filter( - recipient__organization=self.request.user - ) - return [] - + try: + if self.request.user.is_recipient: + recipient_profile = RecipientProfile.objects.get(user=self.request.user) + return super().get_queryset().filter(recipient=recipient_profile) + elif self.request.user.is_organization: + org_profile = OrganizationProfile.objects.get(user=self.request.user) + return super().get_queryset().filter(recipient__organization=org_profile) + return LeaveRequest.objects.none() + except (RecipientProfile.DoesNotExist, OrganizationProfile.DoesNotExist): + return LeaveRequest.objects.none() + def perform_create(self, serializer): """ - Ensure recipient can only create leave requests for their own account + Create leave request with recipient profile. """ try: if not self.request.user.is_recipient: - raise serializers.ValidationError( - "User must have recipient privileges to create leave requests" - ) + raise serializers.ValidationError({ + "detail": "User must have recipient privileges to create leave requests" + }) recipient_profile = RecipientProfile.objects.get(user=self.request.user) + leave_request = serializer.save(recipient=recipient_profile) + + # Create notification for organization + if recipient_profile.organization: + Notification.objects.create( + user=recipient_profile.organization.user, + type="leaveRequestCreated", + message=f"New leave request from {recipient_profile.name}", + is_read=False + ) - # Save with the recipient profile instead of the user - serializer.save(recipient=recipient_profile) + return leave_request except RecipientProfile.DoesNotExist: - raise serializers.ValidationError( - {"detail": "No recipient profile found for this user"} - ) - + raise serializers.ValidationError({ + "detail": "No recipient profile found for this user" + }) @action(detail=False, methods=["get"], permission_classes=[IsAuthenticated]) def get_leave_requests(self, request): """ - Get leave requests based on user type. - - Query Parameters: - usertype (str, optional): Filter by user type ('recipient' or 'organization') + Get leave requests based on user type and profile. """ - usertype = request.query_params.get('usertype', None) - queryset = None - try: - if usertype == "recipient": - recipient_profile = getattr(request.user, 'recipientprofile', None) - if not recipient_profile: - raise ValueError("No recipient profile found") + if request.user.is_recipient: + recipient_profile = RecipientProfile.objects.get(user=request.user) queryset = LeaveRequest.objects.filter(recipient=recipient_profile) - - elif usertype == "organization": - org_profile = getattr(request.user, 'organizationprofile', None) - if not org_profile: - raise ValueError("No organization profile found") + elif request.user.is_organization: + org_profile = OrganizationProfile.objects.get(user=request.user) queryset = LeaveRequest.objects.filter(recipient__organization=org_profile) - else: - queryset = self.get_queryset() + queryset = LeaveRequest.objects.none() serializer = self.get_serializer(queryset, many=True) return Response(serializer.data, status=status.HTTP_200_OK) - except (AttributeError, ValueError) as e: - return Response( - {"error": str(e) or "Invalid user type or user profile not found"}, - status=status.HTTP_400_BAD_REQUEST - ) - + except (RecipientProfile.DoesNotExist, OrganizationProfile.DoesNotExist): + return Response({ + "error": "User profile not found" + }, status=status.HTTP_400_BAD_REQUEST) + @action(detail=True, methods=["post"], permission_classes=[IsOrganization]) def approve_leave_request(self, request, pk=None): """ Approve a leave request. """ try: + # Get organization profile + org_profile = OrganizationProfile.objects.get(user=request.user) + + # Get and validate leave request leave_request = self.get_object() + + # Verify organization owns this leave request + if leave_request.recipient.organization != org_profile: + raise serializers.ValidationError({ + "detail": "You don't have permission to approve this leave request" + }) + leave_request.status = "approved" leave_request.save() - return Response({"status": "Leave request approved"}, status=status.HTTP_200_OK) + + # Create notification for recipient + Notification.objects.create( + user=leave_request.recipient.user, + type="leaveRequestApproved", + message="Your leave request has been approved", + is_read=False + ) + + return Response({ + "status": "Leave request approved" + }, status=status.HTTP_200_OK) + + except OrganizationProfile.DoesNotExist: + return Response({ + "error": "Organization profile not found" + }, status=status.HTTP_400_BAD_REQUEST) except LeaveRequest.DoesNotExist: - return Response({"error": "Leave request not found"}, status=status.HTTP_404_NOT_FOUND) - - + return Response({ + "error": "Leave request not found" + }, status=status.HTTP_404_NOT_FOUND) + def patch_update_leave_request(self, request, pk=None): """ Update a leave request. """ - if not request.user.is_recipient: - raise serializers.ValidationError( - "User must have recipient privileges to update leave requests" - ) try: + if not request.user.is_recipient: + raise serializers.ValidationError({ + "detail": "User must have recipient privileges to update leave requests" + }) + + recipient_profile = RecipientProfile.objects.get(user=request.user) leave_request = self.get_object() - serializer = self.get_serializer(leave_request, data=request.data, partial=True) + + # Verify ownership + if leave_request.recipient != recipient_profile: + raise serializers.ValidationError({ + "detail": "You can only update your own leave requests" + }) + + serializer = self.get_serializer( + leave_request, + data=request.data, + partial=True + ) serializer.is_valid(raise_exception=True) serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + + except RecipientProfile.DoesNotExist: + return Response({ + "error": "Recipient profile not found" + }, status=status.HTTP_400_BAD_REQUEST) except LeaveRequest.DoesNotExist: - return Response({"error": "Leave request not found"}, status=status.HTTP_404_NOT_FOUND) \ No newline at end of file + return Response({ + "error": "Leave request not found" + }, status=status.HTTP_404_NOT_FOUND) \ No newline at end of file From 86038f652d39f66d8a4aae69752443dff42d47a2 Mon Sep 17 00:00:00 2001 From: web-ghost-dotcom Date: Wed, 21 May 2025 03:37:06 +0100 Subject: [PATCH 35/39] feat(leaveRequest): update get_leave_requests description to include profile filtering --- schema.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/schema.yml b/schema.yml index d2a3741..ac41a10 100644 --- a/schema.yml +++ b/schema.yml @@ -188,11 +188,7 @@ paths: /api/v1/leave_requests/leave_requests/get_leave_requests/: get: operationId: leave_requests_leave_requests_get_leave_requests_retrieve - description: |- - Get leave requests based on user type. - - Query Parameters: - usertype (str, optional): Filter by user type ('recipient' or 'organization') + description: Get leave requests based on user type and profile. tags: - leave_requests security: From a023a2ef6e2bdc35d38727feac5dc9434c092d41 Mon Sep 17 00:00:00 2001 From: web-ghost-dotcom Date: Fri, 23 May 2025 01:53:44 +0100 Subject: [PATCH 36/39] feat(payroll): enhance payroll operations with notification creation for various actions --- .../0002_alter_notification_type.py | 33 ++++++++++++++ notifications/models.py | 28 +++++++----- payroll/views.py | 43 +++++++++++++++++-- schema.yml | 10 ++++- 4 files changed, 99 insertions(+), 15 deletions(-) create mode 100644 notifications/migrations/0002_alter_notification_type.py diff --git a/notifications/migrations/0002_alter_notification_type.py b/notifications/migrations/0002_alter_notification_type.py new file mode 100644 index 0000000..3b61609 --- /dev/null +++ b/notifications/migrations/0002_alter_notification_type.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2 on 2025-05-23 00:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("notifications", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="notification", + name="type", + field=models.CharField( + choices=[ + ("recipientAdded", "Recipient Added"), + ("recipientRemoved", "Recipient Removed"), + ("recipientUpdated", "Recipient Updated"), + ("organizationUpdated", "Organization Updated"), + ("organizationAdded", "Organization Added"), + ("organizationRemoved", "Organization Removed"), + ("login", "Login"), + ("payrollCreated", "Payroll Created"), + ("payrollUpdated", "Payroll Updated"), + ("payrollPaid", "Payroll Paid"), + ("payrollDeleted", "Payroll Deleted"), + ], + max_length=100, + ), + ), + ] diff --git a/notifications/models.py b/notifications/models.py index fde4b5d..089c253 100644 --- a/notifications/models.py +++ b/notifications/models.py @@ -1,26 +1,32 @@ from django.db import models from django.conf import settings + class Notification(models.Model): """ Model to store notifications for users. """ + choices = [ - ('recipientAdded', 'Recipient Added'), - ('recipientRemoved', 'Recipient Removed'), - ('recipientUpdated', 'Recipient Updated'), - ('organizationUpdated', 'Organization Updated'), - ('organizationAdded', 'Organization Added'), - ('organizationRemoved', 'Organization Removed'), - ('login', 'Login'), - - + ("recipientAdded", "Recipient Added"), + ("recipientRemoved", "Recipient Removed"), + ("recipientUpdated", "Recipient Updated"), + ("organizationUpdated", "Organization Updated"), + ("organizationAdded", "Organization Added"), + ("organizationRemoved", "Organization Removed"), + ("login", "Login"), + ("payrollCreated", "Payroll Created"), + ("payrollUpdated", "Payroll Updated"), + ("payrollPaid", "Payroll Paid"), + ("payrollDeleted", "Payroll Deleted"), ] - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='notifications') + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="notifications" + ) type = models.CharField(max_length=100, choices=choices) message = models.TextField() is_read = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) def __str__(self): - return f"Notification for {self.user.username} - {self.created_at}" \ No newline at end of file + return f"Notification for {self.user.username} - {self.created_at}" diff --git a/payroll/views.py b/payroll/views.py index 8b778f4..5729a6b 100644 --- a/payroll/views.py +++ b/payroll/views.py @@ -9,6 +9,7 @@ from .models import PayRoll from .serializers import PayRollSerializer from user_profile.models import RecipientProfile, OrganizationProfile +from notifications.models import Notification @extend_schema_view( @@ -47,11 +48,18 @@ def get_queryset(self): def perform_create(self, serializer): """ - Ensure organization can only create payrolls for their own account + Create payroll and notify recipient """ try: org_profile = OrganizationProfile.objects.get(user=self.request.user) - serializer.save(organization=org_profile) + payroll = serializer.save(organization=org_profile) + + # Create notification for recipient + Notification.objects.create( + user=payroll.recipient.user, + type="payrollCreated", + message=f"New payroll of {payroll.amount} created by {org_profile.organization_name}", + ) except OrganizationProfile.DoesNotExist: raise PermissionError("Only organizations can create payroll entries") @@ -113,10 +121,21 @@ def update(self, request, *args, **kwargs): return Response(serializer.data) + def perform_update(self, serializer): + """ + Update payroll and notify recipient + """ + payroll = serializer.save() + Notification.objects.create( + user=payroll.recipient.user, + type="payrollUpdated", + message=f"Your payroll of {payroll.amount} has been updated", + ) + @action(detail=True, methods=["post"]) def mark_as_paid(self, request, pk=None): """ - Mark a payroll entry as paid + Mark payroll as paid and notify recipient """ payroll = self.get_object() if payroll.is_paid: @@ -130,5 +149,23 @@ def mark_as_paid(self, request, pk=None): payroll.status = "completed" payroll.save() + # Create notification for payment + Notification.objects.create( + user=payroll.recipient.user, + type="payrollPaid", + message=f"Payment of {payroll.amount} has been processed", + ) + serializer = self.get_serializer(payroll) return Response(serializer.data) + + def perform_destroy(self, instance): + """ + Delete payroll and notify recipient + """ + Notification.objects.create( + user=instance.recipient.user, + type="payrollDeleted", + message=f"Payroll of {instance.amount} has been cancelled", + ) + instance.delete() diff --git a/schema.yml b/schema.yml index ac41a10..86fe7b9 100644 --- a/schema.yml +++ b/schema.yml @@ -503,7 +503,7 @@ paths: /api/v1/payroll/payrolls/{id}/mark_as_paid/: post: operationId: payroll_payrolls_mark_as_paid_create - description: Mark a payroll entry as paid + description: Mark payroll as paid and notify recipient parameters: - in: path name: id @@ -1634,6 +1634,10 @@ components: - organizationAdded - organizationRemoved - login + - payrollCreated + - payrollUpdated + - payrollPaid + - payrollDeleted type: string description: |- * `recipientAdded` - Recipient Added @@ -1643,6 +1647,10 @@ components: * `organizationAdded` - Organization Added * `organizationRemoved` - Organization Removed * `login` - Login + * `payrollCreated` - Payroll Created + * `payrollUpdated` - Payroll Updated + * `payrollPaid` - Payroll Paid + * `payrollDeleted` - Payroll Deleted User: type: object properties: From 03d16a86b4a8d94c6d67d85510927a3b3f625795 Mon Sep 17 00:00:00 2001 From: web-ghost-dotcom Date: Sat, 24 May 2025 22:51:46 +0100 Subject: [PATCH 37/39] fix(verifyAddress): change response status to 200 for non-existent user case --- db.sqlite3 | Bin 139264 -> 217088 bytes web3auth/views.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/db.sqlite3 b/db.sqlite3 index 2a04121f2bd32f60d034c4302be8c06eddc3732d..6a3d6c28c4d5ffb68b53c158318f01cdd96de1a2 100644 GIT binary patch literal 217088 zcmeI5du$_FcAr@iMT(NhQa?HUF0osq=17|6Qxs>snbmZ2dgNBCXEbWhbh495ku0gJ zha%M^)jd6v4M4d!vuhv;U?FT#Ip?19-E(g}ZmIar+iON!SGF3>9j&co1JeOX z3VdEs0)ar0e)9BljDAMx=Og+F(vS5|vOWy>T@1YZ=qqC+Fe3jR6I+n~K>oeRH^YxZ zzZRMr|EE*8#`MWw9Dh2xJ^806S4L(7zcj#>w`za;OgO%}Dm`6oYvr2$STAR_z4m=| zucbH5&92_uFD=CS=!U^SS!l)(`qu$oH^`^3ZXH!|f zySAphwXu48W#gW5t8`CUxx0C1b)C%KF0F4aC<*Qq(<&R=q-ZN`l-?+9l-948-tn|6 znn@}*n8XJ6Y;}Egb9H5H?Vczs-5}F;l&Y^=&K5WzTVc31lKZc{5RT{b($g6q(%a3( z-mZtQS$2F7Ra4}%#*%wABt|YfB*7p4_*6JPHz$3P?vjlm-Nj%L*C#xnVw-I#E;t!-`fI!a+^mA3JS zJoU!FB32(XjJi&U?C`~yCaaINT1{`OTD98LTP?fwT!tEF!@e73y-}|?S)>>Xl1O&0 z91q9epO-qfje1r8&@%|tN`q#SdRuKj+0`lc)XY|{u%ze89=QpT8msF!O7AHNPi@|Gx@wdEhhKZC&KY!QF>aiIrU_K&DD`>ErskBzk7V?@so7q}p{iiWo zXh_qJ8O+vYFuX&VW-!Y+nyKFmh2xE^)cKf4FgNWO|8wYEk)#Z!HCOlqNo0_T>i%pT0&YgoaeWc z3GsE>oH`q&trFe}$p1?IqP!-L#C|#UajX#i-RR$ms?pO^zdrRBrb<)Oli!&9>yvLy zo}T#4iN7^bi2P>c??rwva%%jm<6Glr!v8J&cgX<$fdB}A00@8p2zUs5UKtBqcx}Zx zhfk$aSyijGt^3sc<}kbalSyUri>dr#I;*4$ucV8w?@bZLe^;^){TxqFW6 zYF@kfes=l&LshF+=|sKh3!84MWQw`$Sq`j4_xsrS-9f-IOX)%;`~I1+z`2iCcC{zX zMy*CV8R3Q~Q5(DbY*B6MJB>%WY87+BgM7;5v&*SM<^?i8>o7mC57XInHkEoWPI$Jx zcyhw8Msr)M8z1p=SgRWkCX`NPmUGz;PKN@sok_3hECuIDjNL|yGObbXt!ZgBt@&hN zccchyx==VpVa$5l%Jj|QT=C^%VX2r+7f$*q_6Nrl)2U^O@WfbPZg!>KXd7Eb#XQR& zm<6P{T+9_Rxz*!if%v;CT6M>$lSY=^Sf^pa;;dHrO=v=~RnxZB%Ke5xS0p{j%}fr-Umwq+qDa^1Y)#IBMKa)X9{Ds^*`jNBDQQbl2e2gFxP zhB1^b&~*961Q~kAGPLY8#HL;@lZ_>j>exFU3;DuRrqXRGwxYiO+N%O9RJyZmEz@Wux-f0ulMti2O_Pf0s|m z|3LoBa#Q{R`7QY+&nXOcK>!3m00ck)1V8`;KmY_l00cnbs1Z0ZIyx`0%Xr5vr-kX$ zqjTfd9ejSj`}m2``ElzuuK2B-8ofEr?_pTCRK0gZFHer%c1!r&h&?ejx;QSL8{qd6 z+0DObaCByzyTxy3osvecNj=XNDAXAzBBEH_4~UP9W-UqQO@+yNYl6flW8V(Q|4aTi z@^8rhNDjrmE&o;draUYE8M$`UIu`MO00@8p2!H?xfB*=900@8p2s}RoP6W@Llf>1! z_%(SkI5#V~p7wF>Ow0sl7bWLKz4w|vawRyMbxHVK(~lnuUN|LLcMzDl@Y&#nImvo1 z&-%?D7YdyWUN|RNPoRn4W9NgjmnG*SzvFW-7CaT_cP;qcfzcDeQ!{+m{$rPsW5M`^ z2*00TJ(k3-{}-lC2ISwNtN(u~|GfN@@|WZt`CYmLkdtTR>Dd2?eKYo}v7d|mRP0A$ zd$Drt2V>V_C!>E5{jKP4M1M83e@4NU#lsb87;nW?`t_3>0?>g}l? zn3^NQ_y+HIq zqBOy*JBw$}1*L)%;Z1li&8}PsO0P;0(TunDvRNyd^42EK2Bo}27fjWjzh0TS7?eII zjq@gbuJsfpD6L52qE#P(OQwKm*hk3P3q0zOu;r?Xn^nT7tLilG<`A>)DnG@uX^b}` zE@Myf#2FI}h{6+Qp)f59kMkhLm{C!3jAuZQn{!Ps*t7~qrh}1Lp3hbZ`}_aN z*tY`mZ_8ho|F!%N<-bDj0r)_EL%t%9Q8E6300@8p2!H?xfB*=900@8p2!Oz|N#OWo zFmUdid;R~yM37$h;k={Na_%URU|@EU-7`_eL;cekK7#o$O$pyZh=i8U~Hp#XVSNV31Z(rowS+MlY1AOHd& z00JNY0w4eaAOHd&00JNY0^fTAj=%r^-W!KS5C8!X009sH0T2KI5C8!X009sHfzJd1 zT>pP2x&cmr00@8p2!H?xfB*=900@8p2!O!%l>n~)zpr><90WiB1V8`;KmY_l00ck) z1V8`;J`)7k-~TU+mjm*DmVaLUvRsy5k*8wci2XwB$79XddhF%cX!L8*zY%RmH=-{^ zN2b0p^)pi+O_iojPX7Mnf0+D*$seEm6O(5rzCQ626WbFDk#9x*S>(@0z7Tn7{5#`c zAOG3$PslL-fdB}A00@8p2t0=bE-#D)q}gkej|}~>x~n~DHfpu&T3uoL3hgVbU~Zm- zlG(|sUenvUQ_L)}1z$1+EBCefwoA|~=?cyyN$~RGq*kpu&CzaGWZ^1_Tq{g)&+3h~ zv1L@Ww$Z3}-E$VE=14mG>V$C7S;gdqpU&dci>8L~)mg+3ioZqnxAK%kyConpH1i;Sy&}3E{gXA)IBqXU~wth1oE3&Z=hK zu+P_Cpt|&{A>R69&1lz*R-1CG`K0THRjTmfY@8JGuZ4s|R%KU3cxIIgrPHR8@X@O5 zDhX$;a#LyX6e(RRhM3z{O;>~YZxx#2iIXHgyEw)pY3Z$&b8w6O7f+B(GCd}ocSs4h zExF^SobcBnCp;A;$}y6=JU_-6Q+#tuPY-w>|?CQ-O!`-oV z(L$IcuN6jwKXwIkTR3GGE`&^J;h9~*q=kES;frG=olK7~AMH{m${e*zt^~&dk-0Q? zw$|A0W-BXDsKCtBtD__}pBIig<%FBI;KQ; zDH%cr0T2KI5C8!X009sH0T2KI5O`h*j8Nd?^b>ww0U&M=009sH0T2KI5C8!X009sH z0T4LK1RUT0ca+0KY#;yvAOHd&00JNY0w4eaAOHd&@LUtX_5X9-{RkceKmY_l00ck) z1V8`;KmY_l;5i|HzyE(uIvQbu00@8p2!H?xfB*=900@8p2t3yWqN6_>m=0tD@|D=v zVxj1(lmBz#4=3(Kesugn_%DWjJ9IB}Wuz_zNx=M1tU7njhU2Zgv_ERptNMqJ^>S9* zYv1Q@5j9`d*fKvG;@yoZeaKX@DJ_%BsC#wep`Xgh>l>w&&609=ef90TC1rK}M(I5z z;U{^3@q}_`-Cr@GTurdoo030?1tq~>CCNS~_qWf4vuMl^}B0p%3B+&w^ugqDYr`Zl$E=icUITQ z?CsL}Cb?}zpp=bmQnZyeN^g`lO6%84?|9l3%_Nl@Ok#t3wz|H$xw^8pc25+RZjfm^ zN`|G^EoTe7%WZ|>+DPud_Ch$G&r45dd`R;S@$favjt`=0ihR~scC%{;j9hj|f`PzD z`}Uj^;A5}#ux8NPvZ*f`d|!1ZXU4UAj8u!ms-|74^kxw0|HkL$q)+m_{pOMk59p~w zba-&gmYO>P0wxc_2=To+AF&B}UEu%?o3=p;47%ZaiXhzL$ER)aKjR|Ml+SXRD zqZEc#X&aBoQ*R6`V)X%azfOqk@Wo6*Rv&9MdZoHbFMVy&`;YC`a~W!!O*ZC?OOo|Q zz2an%Vk}4^nLQbb-<;`}b9^Q+(>-l}p6(nw5suHyNSz&?GIovqY0{l6cEzDGWdM5- zW3qGQcsTz4ywthPhYN4ep2Mt{AFJ%O%lx3Vl`Aaixw1!YLbxrCQJ&hqR?GvFN18<{ zUa)L6n@D!vITnsTnV0tEq1>!AsyYi^O{KMRwvgBOLDtjIm~h&CeDw(R`0j6d=twB3 zEDLm$ot+NH=jWxTlARDXe2*uxPiol0hsqBx=D`zl^#OgqK*I$@w0iJyv>UTMXYsLQ z4@!5gX7YJ^B1m>#k;C!rqSVps-ZzJr4WQ+C<21|Ea2D%ETzUryP>ttZl{{-dr()~4cOs=AsPIyM@5E;BU#)+fU8Vo`cp zu*YAI6aF0$@fnRhs)rhsrj_B&Ho5H3gU}uQ;KA;he1>Zy*?BV*jyJMW=VP8cJ~M`@ z?e3~It*qD79h#(TYGJ8d%~rEzo-#fPLEn7w5gcsU%n~0p7FpLPb`iwgqJgkF7LI4L z(teTWhL5ICQOKPYJ`#sY3ERKE)2HdRe|7S$kq|q~+~bjmGvt>3a8IYR8_({jD*{jD zRW?a4kY=)TPYTDcU6b}TC!HN*yUD+eq$MJ^3r1Xa6Fv2Z#ZfE zHN*D=F^Tp6=lb>!f(HQ*009sH0T2KI5C8!X009tqP6$NDeq9EB#2MEd9RplhW%X_#Xo``hVzuMkW;h;=BFUk?QpIz~F>U&%ur3ebnoR*eCKArsLVBg^GkVqzWG+oI;y|4upW-zyeWO< zU4ECzQKR=L4y;VL?>-HdJP`*s;ag9*E{!i*(zxNdgs^evL%aw&r!J8IW@3fPVB!Lccrb8OY51GwlrAjoI8-g!k%R%s|7Qp!Ky4Q_d*A) zN?Z)bKUkGI#xTJNCTf0ZS~R z^Ob^|u4(o35KemS4EC{`QgrSzKh!Bm=Z^YjDBij~)N$SC$e^ap>=H+QABp4-|Ip2F z{Pu0>&tB?1;QPoQWbdk=CLTRJNL(CPdKX3qJ$!h;k>BaHeavs`AJSur<^jhg@%=l< zTm9X0aQ~hiZruxR?nC4L_KV^8%8K;VT!ZX?RO(-I(cPK-1rODY>;Wh5d4m0L$__mz zrfrje*>QFSJ%&anU+k30^B~=U=Vaok+n(C-so$1+d{LZ4(N%I*;EVOFX9iz@3 z3G7bJmHbjpFKbI)feY@L##`N^Z_YAaG1doG#w9yfuY}_&x%9TbOPg(UgrZa+fFF09eU1@JrBKCZyDS4G-UTdPv!cXrR%q-i(K-5>~l&g zNe5bbq_n=Rt9^6c3(cM{X+l$+`73xh6$FxlpBS^U=X*UsC_oW4dn(rFf(h&YhrXKu zMGyc15C8!X009sH0T2KI5C8!XcyAoT&+|{%fB*=900@8p2!H?xfB*=900@8p2plN_j_dy; z6&Hd50T2KI5C8!X009sH0T2KI5C8!XunFM#f13q9fdB}A00@8p2!H?xfB*=900@A< zQ6+%u|D)QohzkTj00ck)1V8`;KmY_l00ck)1Z)C${@-SSPapsSAOHd&00JNY0w4ea zAOHd&a8wCk{r{-;EaCzI5C8!X009sH0T2KI5C8!X00EnTAKmY_l00ck)1V8`; zKmY_l00evpVEx|*3W`7g1V8`;KmY_l00ck)1V8`;K;VcG!216Y>rjLP0w4eaAOHd& z00JNY0w4eaAOHeB1fnB;Fe>+|n`|{YL{L`^t32u!p z#2yCrN4_=kkA2h+Q}lFUHynRDN1Jq7`sB&e3-oDjjy|=ua!r4%m$TYl`@Xu@(wnZG z?DdV(%4SK~TzP%1q$FH*3FT_Ss3sJn-qyGErm}u#Q(3>ewx+zbv3h%DbWd5i zyLo4Iovhq0t#2+UiCwMLdfaG|+9R!5xvw>^rZdaQt}aQ`v{qZKHMWg0C1 z+%DA_Ew#0`t2ddS38ma<)SQ~Ew65*w4%_*ZZCkm!zWVmv5}DaDn$*SsQOk|NBKnSI z)a=GG`JCODaJH>&ZS^`zVQ7`M@rXS2#=s(0A2f`*PKfOA1zNKDSgY0awyIUDO}*8! zThC>vaYnxzWxY|aI9a3^3z8rsjFj4bvg@p7`l6X+?1R9`chge9s8{t5TMugnIaO?i z;%uq9~-F)%k@TzH-YHq(!566o|>1o8wRLyAD4C=hc;)7mqH=kHL z?$Wd;cYRe4mBfQO-sWFv>eQK4RcrhAWTjE3MBHH=?acyFrL~PlU2Qi$&}l62!IE|K z67^%(gr^beQS?&Bm5d)77S4F?J1NXBF^TqpB8{t0`U2rUy!2 z6JS@@Z$z&Ni&~ie#vwnaM>l5KnB6;sYD86&EU)l}yQym<}(+0~PX4X9XJY=V1w`WwneP3_tdpmt55o;RnP9(PG?)^sHIcMa%Gs;29 zzPHOJv;Kpj(cISR#z$txv!hDcpd&!LJ0&R_r8i0&rSe5{XxcGLwzQhoeBwC3nUiO$t+n?YGjG;$jIrj{ zgCcNEa>Kcs?3}EGI1F5-B4R}6kxN2{^73C)Kz}`(DId~%+gZUEs}B% zTX&_ezByLig3MV*TZ!bWz;uEgVlKrTsZR1l_gny+!vRb5}C2 zED|5+)MwV&Mb6PV&Jz>rC`+D@X$hsRe`seL)jDJBJrE~5V}FX~j(Mpw%R6INd(v#w zYAXF!&HPYvOL?uZq#D%^9hFRI-rWz5+Fn(&2OJ{I01wto!enP!4aZl>4>Ktol4@CN zSMIA#wOY(&w#q(^%=LGqSGTVxLR8JsmtYDs+!5BJPytdcCeq7zCL;s z63M4Gz7USj&r4r^jSmb@^Pb(8gg4?W-BT;3mrr+|uI)7T>g>dY&J=bu*J(DJV$3s| z?(9tCh0bYrH9qZm&dR;{(H)xn+}xZ}4?22O^;V^6?3z=xccgmR{eL=iP_VscT?E3F zZo~W1Rr|EZutA5TWbCVfQ2fcQMkuZp`%TgQXU}TdzdS>ipX2NjwDW~pIR50Ov_HlN z(!i4*)ze36u9(eh)nZ|w){J|Q4V3NMf;r>{>aqdiagfeCKREn(XJ%k?SMdD*VQyhS z2LwO>1V8`;KmY_l00ck)1V8`;o-G1+{{PwPFL(E|9^?&wl3TgVeMnCvl|6$-k1_VF= z1V8`;KmY_l00ck)1V8`;o)H3A|9?h03T}b`2!H?xfB*=900@8p2!H?xfWUV_0N4ND f1w5#N00@8p2!H?xfB*=900@8p2!OydLg4=a&DsRS delta 4513 zcmc&&Yj7LY72dPE(#n!{uVmSlZON7##{t`swNE{qgjg8IPC{%!- zN1;Z)sRPRDqpG5bL*h_;Zzg?9V(;{5eD73hGCney-8(Vm!VlSN_2ej2-DeEf8PPkOs?IDk+~1baojux4*U!AHgFQV3BPN0;vL+2Ky%ySCV~(`+`@UVV7Zpb ztrhfhTc8sVe$*M0Ryeb}yCKhsk6Wr-7Lq2$mzrH`;@qvRaBxPS?tw-y>tFAItMxNm zKwvbnS+B^#I-C(KB#w4{TNXCzJGX+5ZO-+u`ei%dDtyddg%9(C*4zlgxfKOw_hN_8 zs^7Zy;!;`Da=AqDOG>4L?L3l;@if}pq5&s0gpI7Z~ z+K<_ND{toUW6U6k52u}uz#qH0Jf$L^YoAt@4; zdz`r2tCo(^A|Wx_;lTHM*Ox+yDr(_Qgn#P|mO@fAq>9}FuC7x`AcAPo$QB+a>f}-= z9FA(??RNY~UAPp|qN=FIZ20}UKq(|g#jt$26-VpaE`ma`qI8tuJL*fFm&8y=jC5M? z)Agm!i&0tC#2yZ_zS3xjVJQ+)d(3#|Vn_{XN@S}EU+vLK+z`b`L<`4Q{D5bb^J=s$ z8rDQPA~N_-9`DLg2Bc{bMGMln*XOBN@hS~S32V_PKJ9C2DFH+=Dn)tpZHhy?DD)Tf z5_$sdLtjSUM7tK8yRSf|S{oAuy?=qO+D^MzrWNvQWg;~>o}QXYXER>DjA^js8}pl( z-OBh1x5mb1taK&s$dU|k`SdH50bI(`fNJ$c^t37C-p5 zn?uBIY-RD2Z+keQXunQup4fFw?9sCo3vKVb0vo;jNeaD7-ouYc{2oAu&^Wpdbt4ru zAO`~BUEww1m%<6*LE(^)5pEE=1zGS39DkPo4S$lv?pA)Lngt2Cd?fZK($izd1S*7Kx1(RA?zO8ZCmrLLIOMQ$D{@-}wc0_lLp953mjkv$gW9k>RFFx5VRAYzgTfM6oLpW>J(YqA;s2}^ zTP~aZwDsHdqHP^@)qg7B{EpoIN{c&J7K7PfA++p@r(aQ2zcf ztaq(KSH||fVX*689Rb&jtAca)lf2aOq@|fyNXU3P87F6}7S&X>v`5wITrGt!CMdYE z)K3qMLKRaKmc7tKR!mhvSzZvgBa^vE73UrzSv9bnRgIQJR7)k2(qJK>>?1^pFD)37 zKaIi?UNUbU{ZG~+QKox7rmpyJYKQwyyx0ptr^IxVjp|{_y0PpL%WIZ$^IK%s8K)PR z0p=O{034<}slN$7Asv=J50f%$+PqycyW>$qdDrMio}vTnnp}mXx1XdpfnWdU7*y)_ zoTOt+E)>*-Q}m@^)x)Rgd*}_K{=!MRowG@8pIi-Ca?V!$hArSH3z^9OiE8Fvs=U7y zsC33GoAbUX>sN_?&S!m-l8Y6_6-yZ~89%57ICpb1DQFJ15bKjl8}8!L=0~Qn#ME>g z$5@ZP;|H`4|BM~bbKj*K$QxO^rcl!fg_^D*U(=b5nvR)m^6TS0&_e16i+-#J-lKbb zMqzOV>T#JMLml2I%rQAfo&M|?Y|_6mPNG&nPEKF1zH=P9$gW0W=+jS)Lnl#R%pZ*! z?=#;WKh0(F@f%F_sx4&coy~@2J-9$epg|v9p#O1vUOYbUKYD;I=TMeHi|BXg40;kB zLx)k8oTCGXuHo=Zytc~BQgxo9T3C{y9uxjf+#}doma42Y9$=i)3uodn2cW{iJntaX z78-R^=s4;ogd5Pz0L@XGVwN4$P--YWmQG|R_oUl~vK=GiiS!sT#${2~0wNLgs$Ue_ zWtG%zZDjjWm5`Ji*0h8g6(h2&CX>m8qR2{u)G1LDx1lUXGiyB zr&1H6qH=+K%QAhwCX}^yDcq)pq;N=1BvMIfXiyytsj@m8O(w`66^bZ_hr(fPFsvjD gtxluoDf9+ Date: Thu, 29 May 2025 10:09:31 +0100 Subject: [PATCH 38/39] fix(payroll): update notification message to use organization name instead of organization name --- payroll/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/payroll/views.py b/payroll/views.py index 5729a6b..582549a 100644 --- a/payroll/views.py +++ b/payroll/views.py @@ -58,7 +58,7 @@ def perform_create(self, serializer): Notification.objects.create( user=payroll.recipient.user, type="payrollCreated", - message=f"New payroll of {payroll.amount} created by {org_profile.organization_name}", + message=f"New payroll of {payroll.amount} created by {org_profile.name}", ) except OrganizationProfile.DoesNotExist: raise PermissionError("Only organizations can create payroll entries") From 4424333996b508b4146ddc21c8bd974a1d907652 Mon Sep 17 00:00:00 2001 From: leojay-net Date: Thu, 29 May 2025 10:25:36 +0100 Subject: [PATCH 39/39] refactor(payroll): clean up serializer code and remove duplicate payroll restriction --- payroll/serializers.py | 86 ++++++++++++++++++++---------------------- 1 file changed, 41 insertions(+), 45 deletions(-) diff --git a/payroll/serializers.py b/payroll/serializers.py index 675d888..d490618 100644 --- a/payroll/serializers.py +++ b/payroll/serializers.py @@ -1,61 +1,57 @@ from rest_framework import serializers from .models import PayRoll -from user_profile.serializers import RecipientProfileSerializer, OrganizationProfileSerializer +from user_profile.serializers import ( + RecipientProfileSerializer, + OrganizationProfileSerializer, +) + class PayRollSerializer(serializers.ModelSerializer): """ Serializer for the PayRoll model. """ - recipient_details = RecipientProfileSerializer(source='recipient', read_only=True) - organization_details = OrganizationProfileSerializer(source='organization', read_only=True) - + + recipient_details = RecipientProfileSerializer(source="recipient", read_only=True) + organization_details = OrganizationProfileSerializer( + source="organization", read_only=True + ) + class Meta: model = PayRoll fields = [ - 'id', - 'recipient', - 'recipient_details', - 'organization', - 'organization_details', - 'amount', - 'batch_reference', - 'description', - 'date', - 'created_at', - 'paid_at', - 'status', - 'is_paid' + "id", + "recipient", + "recipient_details", + "organization", + "organization_details", + "amount", + "batch_reference", + "description", + "date", + "created_at", + "paid_at", + "status", + "is_paid", + ] + read_only_fields = [ + "id", + "created_at", + "paid_at", + "is_paid", + "recipient_details", + "organization_details", ] - read_only_fields = ['id', 'created_at', 'paid_at', 'is_paid'] - - def validate_amount(self, value): - """ - Validate that amount is positive - """ - if value <= 0: - raise serializers.ValidationError("Amount must be greater than zero") - return value def validate(self, data): """ - Custom validation to ensure organization can't create duplicate payrolls - for the same recipient on the same date (only checking pending or paid status) + Custom validation - Removed duplicate payroll restriction + Organizations can now create multiple payrolls for the same recipient on the same date """ - recipient = data.get('recipient') - organization = data.get('organization') - date = data.get('date') - - # Check if this is an update operation - instance = self.instance - if instance is None: # This is a create operation - if PayRoll.objects.filter( - recipient=recipient, - organization=organization, - date=date, - status__in=['pending', 'completed'] # Use status__in to check multiple values - ).exists(): - raise serializers.ValidationError( - "A pending or paid payroll entry already exists for this recipient on this date" - ) - + # You can add other validation rules here if needed + # For example, validate amount is positive, date is not in future, etc. + + amount = data.get("amount") + if amount and amount <= 0: + raise serializers.ValidationError("Amount must be greater than 0") + return data