-
Notifications
You must be signed in to change notification settings - Fork 0
feat(backend): add volunteer application endpoints #18
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
1783a7d
d328812
adb776f
b9ab29e
d513eac
a780ba5
93e630f
2a47429
9630ede
357a7d4
30bd270
79be2c7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,33 +1,21 @@ | ||
| from rest_framework import serializers | ||
| from .models import VolunteerApplication | ||
|
|
||
| # from .models import User | ||
|
|
||
| # Create your serializers here. | ||
| class VolunteerApplicationSerializer(serializers.ModelSerializer): | ||
| """ | ||
| Serializer for the VolunteerApplication model. | ||
| Handles creating new volunteer applications upon submission. | ||
| """ | ||
|
|
||
| # Example: User Serializer | ||
| # Uncomment and modify as needed | ||
| # | ||
| # class UserSerializer(serializers.ModelSerializer): | ||
| # """ | ||
| # Serializer for the User model. | ||
| # Controls what fields are exposed in the API. | ||
| # """ | ||
| # class Meta: | ||
| # model = User | ||
| # fields = ['id', 'username', 'email', 'first_name', 'last_name', 'date_joined'] | ||
| # read_only_fields = ['id', 'date_joined'] | ||
| # | ||
| # | ||
| # class UserCreateSerializer(serializers.ModelSerializer): | ||
| # """ | ||
| # Serializer for creating new users with password handling. | ||
| # """ | ||
| # password = serializers.CharField(write_only=True, min_length=8, style={'input_type': 'password'}) | ||
| # | ||
| # class Meta: | ||
| # model = User | ||
| # fields = ['username', 'email', 'password', 'first_name', 'last_name'] | ||
| # | ||
| # def create(self, validated_data): | ||
| # """Create user with encrypted password.""" | ||
| # return User.objects.create_user(**validated_data) | ||
| reviewed_by = serializers.StringRelatedField(read_only=True) | ||
|
|
||
| class Meta: | ||
| model = VolunteerApplication | ||
| fields = ["id", "name", "email", "motivation_text", "status", "created_at", "reviewed_at", "reviewed_by"] | ||
| read_only_fields = ["id", "created_at", "reviewed_at", "reviewed_by"] | ||
|
|
||
| def create(self, validated_data): | ||
| """Create a new volunteer application with PENDING status.""" | ||
| validated_data["status"] = "PENDING" | ||
| return super().create(validated_data) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,10 @@ | ||
| import pytest | ||
| from django.test import Client | ||
|
||
| from django.contrib.auth import get_user_model | ||
|
|
||
| from .models import VolunteerApplication | ||
|
|
||
|
|
||
| User = get_user_model() | ||
|
|
||
|
|
||
|
|
@@ -11,3 +15,50 @@ def test_create_user(): | |
| assert user.email == "test@example.com" | ||
| assert user.name == "Test User" | ||
| assert user.check_password("testpass123") | ||
|
|
||
|
|
||
| @pytest.mark.django_db | ||
|
||
| def test_approve_volunteer_application(): | ||
|
||
| client = Client() | ||
|
|
||
| application = VolunteerApplication.objects.create( | ||
| name="Test Volunteer", | ||
| email="volunteer@example.com", | ||
| motivation_text="I want to help", | ||
| status="PENDING", | ||
| ) | ||
|
|
||
| url = f"/api/volunteer-applications/{application.id}/" | ||
|
|
||
| response = client.patch(url, {"status": "APPROVED"}, content_type="application/json") | ||
| assert response.status_code in (200, 202) | ||
|
|
||
| application.refresh_from_db() | ||
| assert application.status == "APPROVED" | ||
| assert application.reviewed_at is not None | ||
|
|
||
|
Comment on lines
+36
to
+39
|
||
| user = User.objects.get(email="volunteer@example.com") | ||
| assert user.role == "VOLUNTEER" | ||
|
Comment on lines
+20
to
+41
|
||
|
|
||
|
|
||
| @pytest.mark.django_db | ||
| def test_reject_application(): | ||
| client = Client() | ||
|
|
||
| application = VolunteerApplication.objects.create( | ||
| name="Test Volunteer 2", | ||
| email="reject@example.com", | ||
| motivation_text="I want to help", | ||
| status="PENDING", | ||
| ) | ||
|
|
||
| url = f"/api/volunteer-applications/{application.id}/" | ||
|
|
||
| response = client.patch(url, {"status": "REJECTED"}, content_type="application/json") | ||
| assert response.status_code in (200, 202) | ||
|
|
||
| application.refresh_from_db() | ||
| assert application.status == "REJECTED" | ||
| assert application.reviewed_at is not None | ||
|
|
||
|
Comment on lines
+60
to
+63
|
||
| assert not User.objects.filter(email="reject@example.com").exists() | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,27 +1,19 @@ | ||||||
| from django.urls import path, include | ||||||
| from rest_framework.routers import DefaultRouter | ||||||
|
|
||||||
| # from .views import UserViewSet | ||||||
| from .views import VolunteerApplicationAPIView | ||||||
|
|
||||||
| # Create your URL patterns here. | ||||||
|
|
||||||
| # Example: Using DRF Router for ViewSets | ||||||
| # Uncomment and modify as needed | ||||||
| # | ||||||
| # router = DefaultRouter() | ||||||
| # router.register(r'users', UserViewSet, basename='user') | ||||||
| # | ||||||
| # urlpatterns = [ | ||||||
| # path('', include(router.urls)), | ||||||
| # ] | ||||||
|
|
||||||
| # This will create the following endpoints: | ||||||
| # GET /api/users/ - List all users | ||||||
| # POST /api/users/ - Create a new user | ||||||
| # GET /api/users/{id}/ - Retrieve a specific user | ||||||
| # PUT /api/users/{id}/ - Update a user | ||||||
| # PATCH /api/users/{id}/ - Partial update | ||||||
| # DELETE /api/users/{id}/ - Delete a user | ||||||
| # GET /api/users/me/ - Custom action (if defined in viewset) | ||||||
| router = DefaultRouter() | ||||||
| router.register(r"volunteer-applications", VolunteerApplicationAPIView, basename="volunteer-application") | ||||||
|
|
||||||
| urlpatterns = [] | ||||||
| urlpatterns = [ | ||||||
| path("", include(router.urls)), | ||||||
| ] | ||||||
|
|
||||||
| # This will create the following endpoints: | ||||||
| # POST /api/volunteer-applications/ - Create a new application (PENDING) | ||||||
| # GET /api/volunteer-applications/ - List all applications | ||||||
| # GET /api/volunteer-applications/{id}/ - Retrieve a specific application (admin only) | ||||||
|
||||||
| # GET /api/volunteer-applications/{id}/ - Retrieve a specific application (admin only) | |
| # GET /api/volunteer-applications/{id}/ - Retrieve a specific application |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,35 +1,80 @@ | ||||||||||||||||||||||||||||||||||||||||||||||
| from rest_framework import viewsets, permissions, status | ||||||||||||||||||||||||||||||||||||||||||||||
| from rest_framework.decorators import action | ||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||
| from rest_framework.decorators import action |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The class name VolunteerApplicationAPIView is misleading. The suffix APIView is typically used for DRF's APIView class, not ModelViewSet. Consider renaming to VolunteerApplicationViewSet to follow Django REST Framework naming conventions.
| class VolunteerApplicationAPIView(viewsets.ModelViewSet): | |
| class VolunteerApplicationViewSet(viewsets.ModelViewSet): |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The docstring is incomplete and doesn't accurately describe the full functionality of this ViewSet. It only mentions the POST endpoint, but the class also implements:
- GET /api/volunteer-applications/ (admin-only list)
- GET /api/volunteer-applications/{id}/ (retrieve)
- PATCH /api/volunteer-applications/{id}/ (approve/reject)
Update the docstring to document all available endpoints and their access restrictions.
| API endpoint for submitting volunteer applications. | |
| Currently contains: | |
| POST /api/volunteer-applications/ | |
| API endpoint for managing volunteer applications. | |
| Endpoints: | |
| - POST /api/volunteer-applications/ | |
| Submit a new volunteer application. (Open to all) | |
| - GET /api/volunteer-applications/ | |
| List all volunteer applications. (Admin only) | |
| - GET /api/volunteer-applications/{id}/ | |
| Retrieve a specific volunteer application. (Admin only) | |
| - PATCH /api/volunteer-applications/{id}/ | |
| Approve or reject a volunteer application. (Admin only) | |
| Access: | |
| - POST: Any user (no authentication required) | |
| - GET (list/retrieve), PATCH: Admin users only (role == "ADMIN") |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The AllowAny permission class is too permissive for a ModelViewSet. This allows unrestricted access to all CRUD operations (GET, POST, PUT, PATCH, DELETE) on all volunteer applications, including retrieving and modifying individual applications. Consider using DRF's get_permissions() method to return different permission classes based on the action (e.g., AllowAny for create, but require authentication/admin for list, retrieve, update, destroy).
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The get_serializer_class() method always returns the same serializer regardless of action, making it redundant. Either remove this method entirely (and rely on the serializer_class attribute) or add a meaningful distinction between actions if different serializers are needed in the future.
| def get_serializer_class(self): | |
| """Return appropriate serializer based on action.""" | |
| if self.action == "create": | |
| return VolunteerApplicationSerializer | |
| return VolunteerApplicationSerializer # Can update this later, just for formality |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The list() method is protected by admin check, but the retrieve(), update(), partial_update(), and destroy() methods inherited from ModelViewSet remain unprotected due to AllowAny permissions. Anyone can view, modify, or delete individual applications. Override these methods with appropriate permission checks or use get_permissions() to enforce proper authorization.
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Only the list() method has admin-only protection, but other methods like retrieve(), update(), partial_update(), and destroy() are not protected. This means:
- Anyone can GET individual applications (potentially exposing PII)
- Anyone can update/delete applications (due to AllowAny permissions)
Override these methods or use get_permissions() to ensure proper access control for all actions. Typically, only admins should be able to view individual applications and update their status.
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The _handle_review_metadata() method calls application.save() which saves the application outside the serializer's save flow. Since this method is called after serializer.save() in perform_update(), it performs a second database write. This can cause issues with the transaction and the fields might not be properly updated through the serializer. Instead, set the fields before calling serializer.save() or pass them to the serializer's save method.
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The application.save() call causes the application to be saved twice within the same transaction: once by serializer.save() (line 76) and again here. This is inefficient and could lead to race conditions or unexpected behavior.
Instead, modify the fields without calling save() and let the transaction commit handle the single save operation. The @transaction.atomic decorator will ensure all changes are committed together.
| application.save() |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's a potential race condition between checking if a user exists and creating the user. If two applications with the same email are approved simultaneously, both threads could pass the exists check before either creates the user, resulting in an IntegrityError due to the unique email constraint. Use get_or_create() or handle the IntegrityError gracefully to prevent crashes.
| exists = User.objects.filter(email=application.email).exists() | |
| if exists: | |
| return | |
| temp_password = secrets.token_urlsafe(12) | |
| User.objects.create_user( | |
| email=application.email, | |
| name=application.name, | |
| password=temp_password, | |
| role="VOLUNTEER", | |
| ) | |
| temp_password = secrets.token_urlsafe(12) | |
| user, created = User.objects.get_or_create( | |
| email=application.email, | |
| defaults={ | |
| "name": application.name, | |
| "role": "VOLUNTEER", | |
| } | |
| ) | |
| if created: | |
| user.set_password(temp_password) | |
| user.save() |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The temporary password generated using secrets.token_urlsafe(12) is created but never stored, logged, or sent to the user. This means the created volunteer user account will be inaccessible since no one knows the password. Consider storing this password securely for later retrieval (e.g., encrypted in the database) or implementing an email invitation flow to allow users to set their own password.
| # Store the temporary password in the application for later retrieval/notification | |
| application.temp_password = temp_password | |
| application.save(update_fields=["temp_password"]) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
statusfield is exposed as writable in the serializer, allowing public users to submit applications with any status (including "APPROVED" or "REJECTED") by including the status in the POST request body. Even though thecreate()method forces status to "PENDING", the field should be added toread_only_fieldsto make the API contract clearer and prevent status manipulation on updates.