Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
.vscode
.vscode
.env
142 changes: 142 additions & 0 deletions backend/users/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,3 +297,145 @@ def test_expired_user_blocked_by_middleware(client):

res_expired = client.get("/api/users/", HTTP_AUTHORIZATION=f"Bearer {token}")
assert res_expired.status_code == status.HTTP_403_FORBIDDEN


# --- New tests for volunteer lifecycle ---


@pytest.mark.django_db
def test_approve_application_sets_expiry():
"""Approving an application with access_expires_at creates the user with that expiry."""
client = Client()

application = VolunteerApplication.objects.create(
name="Expiry Volunteer",
email="[email protected]",
motivation_text="I want to help",
status="PENDING",
)

expires = (timezone.now() + timedelta(days=30)).replace(microsecond=0)
url = f"/api/users/volunteer-applications/{application.id}/"
response = client.patch(
url,
{"status": "APPROVED", "access_expires_at": expires.isoformat()},
content_type="application/json",
)
assert response.status_code in (200, 202)

user = User.objects.get(email="[email protected]")
assert user.role == "VOLUNTEER"
assert user.access_expires_at is not None
assert abs((user.access_expires_at - expires).total_seconds()) < 2


@pytest.mark.django_db
def test_approve_application_no_expiry():
"""Approving an application without access_expires_at creates user with no expiry."""
client = Client()

application = VolunteerApplication.objects.create(
name="No Expiry Vol",
email="[email protected]",
motivation_text="Happy to help",
status="PENDING",
)

url = f"/api/users/volunteer-applications/{application.id}/"
response = client.patch(url, {"status": "APPROVED"}, content_type="application/json")
assert response.status_code in (200, 202)

user = User.objects.get(email="[email protected]")
assert user.role == "VOLUNTEER"
assert user.access_expires_at is None


@pytest.mark.django_db
def test_reapprove_application_updates_expiry():
"""Re-approving an application for an existing user updates their access_expires_at."""
client = Client()

existing_user = User.objects.create_user(
email="[email protected]",
name="Existing Vol",
password="pass",
role="VOLUNTEER",
access_expires_at=timezone.now() + timedelta(days=5),
)

application = VolunteerApplication.objects.create(
name="Existing Vol",
email="[email protected]",
motivation_text="Previously approved",
status="PENDING",
)

new_expires = (timezone.now() + timedelta(days=60)).replace(microsecond=0)
url = f"/api/users/volunteer-applications/{application.id}/"
response = client.patch(
url,
{"status": "APPROVED", "access_expires_at": new_expires.isoformat()},
content_type="application/json",
)
assert response.status_code in (200, 202)

existing_user.refresh_from_db()
assert abs((existing_user.access_expires_at - new_expires).total_seconds()) < 2


@pytest.mark.django_db
def test_volunteer_applications_list_enriched_with_user_data(client):
"""Approved applications in the list endpoint include user_id, expires_at, days_remaining."""
admin = User.objects.create_user(email="[email protected]", name="Admin", password="adminpass", role="ADMIN")

expires = timezone.now() + timedelta(days=10)
volunteer_user = User.objects.create_user(
email="[email protected]",
name="List Vol",
password="pass",
role="VOLUNTEER",
access_expires_at=expires,
)
VolunteerApplication.objects.create(
name="List Vol",
email="[email protected]",
motivation_text="Help",
status="APPROVED",
)

res_login = client.post(
"/api/auth/login/",
{"email": "[email protected]", "password": "adminpass"},
content_type="application/json",
)
token = res_login.data["access"]

res = client.get("/api/users/volunteer-applications/", HTTP_AUTHORIZATION=f"Bearer {token}")
assert res.status_code == 200

data = res.json()
items = data.get("results", data) if isinstance(data, dict) else data
approved = [i for i in items if i.get("status") == "APPROVED" and i.get("email") == "[email protected]"]
assert len(approved) == 1
row = approved[0]
assert row["user_id"] == volunteer_user.id
assert row["expires_at"] is not None
assert row["days_remaining"] is not None
assert 9 <= row["days_remaining"] <= 10


@pytest.mark.django_db
def test_volunteer_stats_includes_warning_days(client):
"""The volunteer-stats endpoint returns warning_days: 7."""
admin = User.objects.create_user(email="[email protected]", name="Admin", password="adminpass", role="ADMIN")

res_login = client.post(
"/api/auth/login/",
{"email": "[email protected]", "password": "adminpass"},
content_type="application/json",
)
token = res_login.data["access"]

res = client.get("/api/users/volunteer-stats/", HTTP_AUTHORIZATION=f"Bearer {token}")
assert res.status_code == 200
assert res.json()["warning_days"] == 7
75 changes: 61 additions & 14 deletions backend/users/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from django.utils import timezone
from django.db import transaction
from django.db.models import Q
from datetime import timedelta
from datetime import datetime, timedelta
import secrets

from .permissions import IsAdmin
Expand Down Expand Up @@ -38,14 +38,6 @@ def get_serializer_class(self):
return VolunteerApplicationSerializer
return VolunteerApplicationSerializer

def list(self, request, *args, **kwargs):
"""List applications; restrict to admin users using role."""
user = getattr(request, "user", None)
if not (user is not None and getattr(user, "is_authenticated", False) and self._is_admin(user)):
return Response({"detail": "Admin only"}, status=status.HTTP_403_FORBIDDEN)

return super().list(request, *args, **kwargs)

def _is_admin(self, user):
"""Check for admin users based on role."""
return getattr(user, "role", None) == "ADMIN"
Expand All @@ -61,13 +53,29 @@ def _handle_review_metadata(self, application):

application.save()

def _handle_volunteer_user_creation(self, application):
"""Create a VOLUNTEER user when an application is approved, if needed."""
def _parse_access_expires_at(self, value):
"""Parse an ISO datetime string or None from request data."""
if value is None or value == "":
return None
if isinstance(value, str):
try:
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
if timezone.is_naive(dt):
dt = timezone.make_aware(dt)
return dt
except (ValueError, TypeError):
return None
return None

def _handle_volunteer_user_creation(self, application, access_expires_at=None):
"""Create or update a VOLUNTEER user when an application is approved."""
if application.status != "APPROVED":
return

exists = User.objects.filter(email=application.email).exists()
if exists:
existing = User.objects.filter(email=application.email).first()
if existing:
existing.access_expires_at = access_expires_at
existing.save(update_fields=["access_expires_at", "updated_at"])
return

temp_password = secrets.token_urlsafe(12)
Expand All @@ -76,8 +84,41 @@ def _handle_volunteer_user_creation(self, application):
name=application.name,
password=temp_password,
role="VOLUNTEER",
access_expires_at=access_expires_at,
)

def list(self, request, *args, **kwargs):
"""List applications; restrict to admin users. Enrich APPROVED rows with user data."""
user = getattr(request, "user", None)
if not (user is not None and getattr(user, "is_authenticated", False) and self._is_admin(user)):
return Response({"detail": "Admin only"}, status=status.HTTP_403_FORBIDDEN)

response = super().list(request, *args, **kwargs)

# Enrich approved applications with linked user info
data = response.data
items = data.get("results", data) if isinstance(data, dict) else data
now = timezone.now()

for item in items:
if item.get("status") != "APPROVED":
continue
volunteer_user = User.objects.filter(email=item.get("email"), role="VOLUNTEER").first()
if volunteer_user:
item["user_id"] = volunteer_user.id
item["expires_at"] = volunteer_user.access_expires_at.isoformat() if volunteer_user.access_expires_at else None
if volunteer_user.access_expires_at:
delta = volunteer_user.access_expires_at - now
item["days_remaining"] = max(delta.days, 0)
else:
item["days_remaining"] = None
else:
item["user_id"] = None
item["expires_at"] = None
item["days_remaining"] = None

return response

@transaction.atomic
def perform_update(self, serializer):
old_status = serializer.instance.status
Expand All @@ -88,7 +129,12 @@ def perform_update(self, serializer):
"REJECTED",
}:
self._handle_review_metadata(application)
self._handle_volunteer_user_creation(application)
if application.status == "APPROVED":
raw_expiry = self.request.data.get("access_expires_at")
access_expires_at = self._parse_access_expires_at(raw_expiry)
self._handle_volunteer_user_creation(application, access_expires_at=access_expires_at)
else:
self._handle_volunteer_user_creation(application)


# Register new account
Expand Down Expand Up @@ -218,6 +264,7 @@ def get(self, request):
"expired_count": expired_count,
"total_count": total_count,
"expiring_volunteers": expiring_volunteers,
"warning_days": 7,
}
)

Expand Down
2 changes: 0 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
version: '3.8'

services:
backend:
build: ./backend
Expand Down
32 changes: 27 additions & 5 deletions frontend/src/actions/useVolunteers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,45 @@ export const useVolunteerApplications = () => {
export const useUpdateVolunteerStatus = (onSuccessCallback?: () => void, onErrorCallback?: (error: unknown) => void) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, action }: { id: number; action: 'APPROVED' | 'REJECTED' }) => {
await apiClient.patch(`/api/users/volunteer-applications/${id}/`, { status: action });
mutationFn: async ({ id, action, access_expires_at }: { id: number; action: 'APPROVED' | 'REJECTED'; access_expires_at?: string | null }) => {
const body: Record<string, unknown> = { status: action };
if (action === 'APPROVED') {
body.access_expires_at = access_expires_at ?? null;
}
await apiClient.patch(`/users/volunteer-applications/${id}/`, body);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['volunteerApplications'] });
queryClient.invalidateQueries({ queryKey: ['volunteerStats'] });
onSuccessCallback?.();
},
onError: (error) => {
onErrorCallback?.(error)
}
onErrorCallback?.(error);
},
});
};

export const useExtendVolunteerAccess = (onSuccessCallback?: () => void, onErrorCallback?: (error: unknown) => void) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ userId, access_expires_at }: { userId: number; access_expires_at: string | null }) => {
await apiClient.patch(`/users/${userId}/`, { access_expires_at });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['volunteerApplications'] });
queryClient.invalidateQueries({ queryKey: ['volunteerStats'] });
onSuccessCallback?.();
},
onError: (error) => {
onErrorCallback?.(error);
},
});
};

export const useCreateVolunteer = (onSuccessCallback?: () => void, onErrorCallback?: (error: AxiosError) => void) => {
return useMutation<void, AxiosError, VolunteerApplicationInput>({
mutationFn: async (applicationData) => {
await apiClient.post('/api/users/volunteer-applications/', applicationData);
await apiClient.post('/users/volunteer-applications/', applicationData);
},
onSuccess: () => {
onSuccessCallback?.();
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/api/items.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ export const itemsApi = {
queryParams.page_size = '10000';

const response = await apiClient.get('/inventory/public/items/', { params: queryParams });
return response.data.results ?? response.data;
const data = response?.data;
const raw = data?.results ?? data;
return Array.isArray(raw) ? raw : [];
},

getById: async (id: string | number): Promise<PublicCollectionItem> => {
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/components/items/ItemList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ interface ItemListProps {
items: PublicCollectionItem[];
}

const ItemList: React.FC<ItemListProps> = ({items}) => {
const ItemList: React.FC<ItemListProps> = ({ items }) => {
const list = Array.isArray(items) ? items : [];
return (
<div className="item-list">
{items.map((item) => (
{list.map((item) => (
<ItemCard key={item.id} item={item} />
))}
</div>
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ export interface Volunteer {
reviewed_at: string | null;
reviewed_by: string | null;
created_at?: string;
expires_at?: string;
days_remaining?: number;
user_id?: number;
expires_at?: string | null;
days_remaining?: number | null;
}

export interface VolunteerApplicationInput {
Expand Down
Loading
Loading