diff --git a/HR_Backend/settings.py b/HR_Backend/settings.py index d14aceb..3f6b45d 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() @@ -46,6 +47,9 @@ 'waitlist.apps.WaitlistConfig', 'web3auth.apps.Web3AuthConfig', 'user_profile.apps.UserProfileConfig', + 'notifications.apps.NotificationsConfig', + 'payroll.apps.PayrollConfig', + 'leaveRequest.apps.LeaverequestConfig', # Third-party apps 'corsheaders', @@ -188,4 +192,44 @@ AUTH_USER_MODEL = 'web3auth.User' +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/HR_Backend/urls.py b/HR_Backend/urls.py index fa19429..1f01106 100644 --- a/HR_Backend/urls.py +++ b/HR_Backend/urls.py @@ -28,5 +28,8 @@ 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')), + path('api/v1/payroll/', include('payroll.urls')), + path('api/v1/leave_requests/', include('leaveRequest.urls')), ] diff --git a/db.sqlite3 b/db.sqlite3 index 2a04121..6a3d6c2 100644 Binary files a/db.sqlite3 and b/db.sqlite3 differ 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..274b080 --- /dev/null +++ b/leaveRequest/views.py @@ -0,0 +1,167 @@ +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 +from user_profile.models import RecipientProfile, OrganizationProfile +from notifications.models import Notification + + +class LeaveRequestView(viewsets.ModelViewSet): + """ + Viewset for handling leave request operations. + """ + + queryset = LeaveRequest.objects.all() + serializer_class = LeaveRequestSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + """ + Filter leave requests based on user type and profile. + """ + 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): + """ + Create leave request with recipient profile. + """ + try: + if not self.request.user.is_recipient: + 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 + ) + + return leave_request + + except RecipientProfile.DoesNotExist: + 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 and profile. + """ + try: + if request.user.is_recipient: + recipient_profile = RecipientProfile.objects.get(user=request.user) + queryset = LeaveRequest.objects.filter(recipient=recipient_profile) + elif request.user.is_organization: + org_profile = OrganizationProfile.objects.get(user=request.user) + queryset = LeaveRequest.objects.filter(recipient__organization=org_profile) + else: + queryset = LeaveRequest.objects.none() + + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + 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() + + # 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) + + def patch_update_leave_request(self, request, pk=None): + """ + Update a leave request. + """ + 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() + + # 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 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/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/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..089c253 --- /dev/null +++ b/notifications/models.py @@ -0,0 +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"), + ("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" + ) + 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}" 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/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/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/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/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/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..151c551 --- /dev/null +++ b/payroll/models.py @@ -0,0 +1,39 @@ +from django.db import models +from django.core.validators import MinValueValidator +from user_profile.models import RecipientProfile, OrganizationProfile + +class PayRoll(models.Model): + """ + Model to store payroll information for users. + """ + recipient = models.ForeignKey(RecipientProfile, on_delete=models.CASCADE, related_name='recipients') + organization = models.ForeignKey(OrganizationProfile, on_delete=models.CASCADE, related_name='organization') + 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) + 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.recipient.recipient_ethereum_address} - {self.date}" \ No newline at end of file diff --git a/payroll/serializers.py b/payroll/serializers.py new file mode 100644 index 0000000..d490618 --- /dev/null +++ b/payroll/serializers.py @@ -0,0 +1,57 @@ +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 = [ + "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", + ] + + def validate(self, data): + """ + Custom validation - Removed duplicate payroll restriction + Organizations can now create multiple payrolls for the same recipient on the same 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 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..09ea199 --- /dev/null +++ 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 new file mode 100644 index 0000000..582549a --- /dev/null +++ b/payroll/views.py @@ -0,0 +1,171 @@ +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 drf_spectacular.utils import extend_schema_view, extend_schema +from .models import PayRoll +from .serializers import PayRollSerializer +from user_profile.models import RecipientProfile, OrganizationProfile +from notifications.models import Notification + + +@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] + + 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): + """ + Create payroll and notify recipient + """ + try: + org_profile = OrganizationProfile.objects.get(user=self.request.user) + 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.name}", + ) + 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) + + 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 payroll as paid and notify recipient + """ + 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() + + # 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 94993c4..86fe7b9 100644 --- a/schema.yml +++ b/schema.yml @@ -4,6 +4,565 @@ 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 and profile. + 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 + 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/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 payroll as paid and notify recipient + 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 @@ -151,6 +710,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 @@ -298,17 +872,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 + description: Viewset for handling recipient profile operations. tags: - profile requestBody: @@ -485,12 +1052,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 @@ -507,7 +1090,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 @@ -517,7 +1104,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 @@ -534,9 +1125,121 @@ 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 + 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: + wallet_address: + type: string + maxLength: 42 + required: + - wallet_address + 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. @@ -551,6 +1254,10 @@ components: type: string format: email maxLength: 254 + user: + allOf: + - $ref: '#/components/schemas/User' + readOnly: true organization_address: type: string nullable: true @@ -563,6 +1270,13 @@ components: items: $ref: '#/components/schemas/RecipientProfile' readOnly: true + total_recipients: + 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 format: date-time @@ -577,7 +1291,61 @@ components: - id - name - recipients + - 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. + 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. @@ -592,6 +1360,10 @@ components: type: string format: email maxLength: 254 + user: + allOf: + - $ref: '#/components/schemas/User' + readOnly: true organization_address: type: string nullable: true @@ -604,6 +1376,13 @@ components: items: $ref: '#/components/schemas/RecipientProfile' readOnly: true + total_recipients: + 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 format: date-time @@ -612,6 +1391,54 @@ 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: integer + maximum: 9223372036854775807 + minimum: 0 + format: int64 + description: Amount in smallest currency unit (e.g., cents for USD) + 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. @@ -626,8 +1453,13 @@ components: type: string format: email maxLength: 254 + user: + allOf: + - $ref: '#/components/schemas/User' + readOnly: true organization: type: integer + readOnly: true recipient_ethereum_address: type: string maxLength: 42 @@ -635,6 +1467,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/RecipientProfileStatusEnum' created_at: type: string format: date-time @@ -649,6 +1492,75 @@ 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: integer + maximum: 9223372036854775807 + minimum: 0 + format: int64 + description: Amount in smallest currency unit (e.g., cents for USD) + 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. @@ -663,8 +1575,13 @@ components: type: string format: email maxLength: 254 + user: + allOf: + - $ref: '#/components/schemas/User' + readOnly: true organization: type: integer + readOnly: true recipient_ethereum_address: type: string maxLength: 42 @@ -672,6 +1589,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/RecipientProfileStatusEnum' created_at: type: string format: date-time @@ -688,6 +1616,80 @@ components: - organization - recipient_ethereum_address - updated_at + - user + RecipientProfileStatusEnum: + enum: + - active + - on_leave + type: string + description: |- + * `active` - Active + * `on_leave` - On Leave + TypeEnum: + enum: + - recipientAdded + - recipientRemoved + - recipientUpdated + - organizationUpdated + - organizationAdded + - organizationRemoved + - login + - payrollCreated + - payrollUpdated + - payrollPaid + - payrollDeleted + type: string + description: |- + * `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: + 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 + - 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/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/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/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/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 af2be5a..21ee182 100644 --- a/user_profile/models.py +++ b/user_profile/models.py @@ -6,18 +6,18 @@ 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) updated_at = models.DateTimeField(auto_now=True) - def __str__(self): return self.name - + class Meta: verbose_name_plural = "Organization Profiles" @@ -25,19 +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) - name = models.CharField(max_length=150, unique=True) + + user = models.ForeignKey(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(unique=True) - organization = models.ForeignKey(OrganizationProfile, on_delete=models.CASCADE, related_name='recipients') - 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) + 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) - 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 40a5be0..af6266c 100644 --- a/user_profile/serializers.py +++ b/user_profile/serializers.py @@ -1,7 +1,10 @@ from rest_framework import serializers +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): @@ -9,36 +12,133 @@ class RecipientProfileSerializer(serializers.ModelSerializer): Serializer for RecipientProfile model. """ + user = UserSerializer(read_only=True) + organization = serializers.PrimaryKeyRelatedField(read_only=True) + class Meta: model = RecipientProfile - fields = ['id', 'name', 'email', 'organization', 'recipient_ethereum_address', - 'recipient_phone', 'created_at', 'updated_at'] - read_only_fields = ['id', 'created_at', 'updated_at'] - - def to_representation(self, instance): - """ - Customize the representation of the RecipientProfile instance. - """ - return { - "id": instance.id, - "name": instance.name, - "organization": instance.organization.name, - "wallet_address": instance.user.wallet_address, - "created_at": instance.created_at.isoformat() if instance.created_at else None, - "updated_at": instance.updated_at.isoformat() if instance.updated_at else None, + 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"] + 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): + 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=validated_data["recipient_ethereum_address"], + defaults={ + "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) + ), + }, + ) + + 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): """ Serializer for OrganizationProfile model. """ + id = serializers.IntegerField(read_only=True) 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"] + + @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() + + 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() 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 bee89e4..f4e8931 100644 --- a/user_profile/views.py +++ b/user_profile/views.py @@ -1,20 +1,33 @@ 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.is_organization + +class IsRecipient(BasePermission): + def has_permission(self, request, view): + return request.user.is_recipient 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): """ @@ -23,28 +36,177 @@ def get_queryset(self): """ return super().get_queryset().filter(user=self.request.user) + def create(self, request, *args, **kwargs): + if not request.user.is_organization: + raise serializers.ValidationError( + "User must have organization privileges to create organization profiles" + ) + + 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) + 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) + 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"]) + def get_organization_recipients(self, request): + """ + Retrieve all recipients associated with the authenticated organization. + """ + try: + 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, + ) + 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. - """ - return super().get_queryset().filter(user=self.request.user) - - @action(detail=True, methods=['post']) - def batch_create(self, request, *args, **kwargs): + @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() + + 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( + {"detail": "Organization profile not found"} + ) + + @action(detail=False, methods=["post"]) + @transaction.atomic + def batch_create(self, request): + if not request.user.is_organization: + raise serializers.ValidationError( + "Only organizations can create recipient profiles" + ) + + try: + organization = OrganizationProfile.objects.get(user=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 = [] + + 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, + }, + status=( + status.HTTP_201_CREATED + if created_recipients + else status.HTTP_400_BAD_REQUEST + ), + ) + + def perform_update(self, serializer): """ - Create multiple recipient profiles in a single request. + Ensure only recipients can update their own profiles """ - serializer = self.get_serializer(data=request.data, many=True) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - return Response(serializer.data, status=status.HTTP_201_CREATED) + 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 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 288b707..eb999e1 100644 --- a/web3auth/models.py +++ b/web3auth/models.py @@ -2,14 +2,30 @@ from django.contrib.auth.models import AbstractUser from django.utils.translation import gettext_lazy as _ -class User(AbstractUser): +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"), + ("both", "Both"), # Add this option + ], + 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 + + @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 3a15baf..b8974fa 100644 --- a/web3auth/serializers.py +++ b/web3auth/serializers.py @@ -7,70 +7,93 @@ import secrets import string import logging +from rest_framework_simplejwt.tokens import RefreshToken +from datetime import timedelta 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() - signature = serializers.CharField() + 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() - - # 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.set_exp(lifetime=timedelta(days=7)) + refresh.access_token.set_exp(lifetime=timedelta(hours=24)) + refresh["address"] = user.wallet_address + return { - 'user': user, - 'token': 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/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", + ), +] diff --git a/web3auth/views.py b/web3auth/views.py index 96e390c..269384c 100644 --- a/web3auth/views.py +++ b/web3auth/views.py @@ -2,73 +2,96 @@ 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 +from notifications.models import Notification 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.validate(serializer.validated_data) - user = validated_data['user'] - token = validated_data['token'] + + validated_data = serializer.validated_data + 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) - - return Response({ - 'user': user_serializer.data, - 'token': token, - - }) - -class UserDetailView(APIView): + + notification = Notification.objects.create( + 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(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_200_OK)