Skip to content

Commit 4df6a78

Browse files
authored
Yash/Automatic Task Creation (#89)
# Automatic Task Creation and Task Management Improvements ## Summary This PR implements automatic task creation for all form submission types and includes several improvements to task management, performance, and UX. ## Key Changes ### 1. Automatic Task Creation Tasks are now automatically created when users submit forms: - **Intake Forms** (`/intake/submissions`): Creates `INTAKE_FORM_REVIEW` tasks - **Ranking Forms** (`/ranking/preferences`): Creates `VOLUNTEER_APP_REVIEW` tasks - **Secondary Application Forms** (`/volunteer-data/submit`): Creates `VOLUNTEER_APP_REVIEW` tasks - **Profile Updates** (`/user-data/me`): Creates `PROFILE_UPDATE` tasks ### 2. Performance Optimizations **Backend:** - Added eager loading (`joinedload`) for participant and assignee relationships in task queries - Extended `TaskResponse` schema to include `participant_name`, `participant_email`, `participant_role_id`, `assignee_name`, and `assignee_email` **Frontend:** - Removed individual participant API calls - Parallelized admin and task fetching using `Promise.all()` ### 3. Task Navigation Improvements - Task links now navigate to appropriate tabs based on task type: - **Intake/Secondary App/Ranking tasks** → `/admin/users/{id}?tab=forms` - **Matching tasks** → `/admin/users/{id}?tab=matches` - **Profile Update tasks** → `/admin/users/{id}?tab=profile` - Created shared utility `getParticipantLink()` in `frontend/src/utils/taskLinkHelpers.ts` - Applied to both task list rows and task detail modal ### 4. Bug Fixes - **Task Description**: Fixed hardcoded "Task for matching" text - now displays actual description from database - **Gender Editing**: Added missing save buttons for user gender and loved one's gender fields in participant/volunteer dashboards ## Technical Details ### Backend Changes - `backend/app/routes/intake.py`: Removed task creation for ranking/secondary forms - `backend/app/routes/ranking.py`: Added task creation with proper error handling - `backend/app/routes/volunteer_data.py`: Added task creation with proper error handling - `backend/app/routes/user_data.py`: Improved transaction handling for profile update tasks - `backend/app/services/implementations/task_service.py`: Added eager loading and participant role extraction - `backend/app/schemas/task.py`: Extended `TaskResponse` with participant/assignee data - `backend/app/utilities/task_utils.py`: New utility for creating volunteer app review tasks ### Frontend Changes - `frontend/src/pages/admin/tasks.tsx`: Removed N+1 queries, parallelized API calls, fixed participant/volunteer classification - `frontend/src/components/admin/TaskRow.tsx`: Updated participant link to use task type-based navigation - `frontend/src/components/admin/TaskEditModal.tsx`: Updated participant link in modal - `frontend/src/components/dashboard/PersonalDetails.tsx`: Added save buttons for gender fields - `frontend/src/utils/taskLinkHelpers.ts`: New utility for generating task-based navigation links - `frontend/src/APIClients/taskAPIClient.ts`: Updated `BackendTask` interface with new fields ## Notion ticket link <!-- Please replace with your ticket's URL --> [Ticket Name](https://www.notion.so/uwblueprintexecs/Task-Board-db95cd7b93f245f78ee85e3a8a6a316d) <!-- Give a quick summary of the implementation details, provide design justifications if necessary --> ## Implementation description * <!-- What should the reviewer do to verify your changes? Describe expected results and include screenshots when appropriate --> ## Steps to test 1. <!-- Draw attention to the substantial parts of your PR or anything you'd like a second opinion on --> ## What should reviewers focus on? * ## Checklist - [ ] My PR name is descriptive and in imperative tense - [ ] My commit messages are descriptive and in imperative tense. My commits are atomic and trivial commits are squashed or fixup'd into non-trivial commits - [ ] I have run the appropriate linter(s) - [ ] I have requested a review from the PL, as well as other devs who have background knowledge on this PR or who will be building on top of this PR
1 parent d80d9a1 commit 4df6a78

File tree

20 files changed

+518
-69
lines changed

20 files changed

+518
-69
lines changed

backend/app/routes/intake.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from sqlalchemy.orm import Session, joinedload
99

1010
from app.middleware.auth import has_roles
11-
from app.models import Experience, Form, FormSubmission, Treatment, User
11+
from app.models import Experience, Form, FormSubmission, Task, TaskType, Treatment, User
1212
from app.models.User import FormStatus, Language
1313
from app.schemas.user import UserRole
1414
from app.services.implementations.form_processor import FormProcessor
@@ -285,6 +285,27 @@ async def create_form_submission(
285285
# Log error but don't fail the request
286286
print(f"Failed to send intake form confirmation email: {str(e)}")
287287

288+
# Create INTAKE_FORM_REVIEW task for intake forms and role change forms
289+
if form and form.type in ("intake", "become_participant", "become_volunteer"):
290+
try:
291+
user_name = f"{target_user.first_name} {target_user.last_name}".strip() or target_user.email
292+
form_type_label = {
293+
"intake": "intake form",
294+
"become_participant": "become participant form",
295+
"become_volunteer": "become volunteer form",
296+
}.get(form.type, "form")
297+
298+
intake_task = Task(
299+
participant_id=target_user.id,
300+
type=TaskType.INTAKE_FORM_REVIEW,
301+
description=f"{user_name} submitted {form_type_label} for review",
302+
)
303+
db.add(intake_task)
304+
db.commit()
305+
except Exception as e:
306+
# Log error but don't fail the request
307+
print(f"Failed to create INTAKE_FORM_REVIEW task: {str(e)}")
308+
288309
# Build response dict
289310
response_dict = {
290311
"id": db_submission.id,

backend/app/routes/ranking.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
from app.models.RankingPreference import RankingPreference
1111
from app.schemas.user import UserRole
1212
from app.services.implementations.ranking_service import RankingService
13+
from app.services.implementations.user_service import UserService
1314
from app.utilities.db_utils import get_db
15+
from app.utilities.service_utils import get_user_service
16+
from app.utilities.task_utils import create_volunteer_app_review_task
1417

1518

1619
class StaticQualityOption(BaseModel):
@@ -66,6 +69,7 @@ async def put_ranking_preferences(
6669
target: str = Query(..., pattern="^(patient|caregiver)$"),
6770
items: List[PreferenceItem] = [],
6871
db: Session = Depends(get_db),
72+
user_service: UserService = Depends(get_user_service),
6973
authorized: bool = has_roles([UserRole.PARTICIPANT, UserRole.ADMIN]),
7074
) -> None:
7175
try:
@@ -74,6 +78,15 @@ async def put_ranking_preferences(
7478
# Convert Pydantic models to dicts
7579
payload = [i.model_dump() for i in items]
7680
service.save_preferences(user_auth_id=user_auth_id, target=target, items=payload)
81+
82+
# Create task for admin review
83+
try:
84+
user_id_str = await user_service.get_user_id_by_auth_id(user_auth_id)
85+
create_volunteer_app_review_task(db, user_id_str, "ranking")
86+
except Exception:
87+
# Log error but don't fail the request
88+
pass
89+
7790
return None
7891
except HTTPException:
7992
raise

backend/app/routes/user_data.py

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from sqlalchemy.orm import Session, joinedload
1010

1111
from app.middleware.auth import has_roles
12-
from app.models import Experience, Treatment, User, UserData
12+
from app.models import Experience, Task, TaskType, Treatment, User, UserData
1313
from app.models.User import Language
1414
from app.models.VolunteerData import VolunteerData
1515
from app.schemas.user import UserRole
@@ -227,6 +227,50 @@ async def update_my_user_data(
227227
if not user_data:
228228
raise HTTPException(status_code=404, detail="User data not found")
229229

230+
# Capture old values for task description (before any updates)
231+
old_values = {}
232+
# Store simple fields
233+
for field in [
234+
"first_name",
235+
"last_name",
236+
"email",
237+
"phone",
238+
"city",
239+
"province",
240+
"postal_code",
241+
"gender_identity",
242+
"marital_status",
243+
"has_kids",
244+
"other_ethnic_group",
245+
"gender_identity_custom",
246+
"diagnosis",
247+
"loved_one_gender_identity",
248+
"loved_one_age",
249+
"loved_one_diagnosis",
250+
"has_blood_cancer",
251+
"caring_for_someone",
252+
"pronouns",
253+
"ethnic_group",
254+
"date_of_birth",
255+
"date_of_diagnosis",
256+
"loved_one_date_of_diagnosis",
257+
"timezone",
258+
]:
259+
old_values[field] = getattr(user_data, field, None)
260+
261+
# Store treatments/experiences
262+
old_values["treatments"] = [t.name for t in user_data.treatments]
263+
old_values["experiences"] = [e.name for e in user_data.experiences]
264+
old_values["loved_one_treatments"] = [t.name for t in user_data.loved_one_treatments]
265+
old_values["loved_one_experiences"] = [e.name for e in user_data.loved_one_experiences]
266+
267+
# Store language from User model
268+
old_values["language"] = current_user.language.value if current_user.language else None
269+
270+
# Store volunteer_experience
271+
volunteer_data = db.query(VolunteerData).filter(VolunteerData.user_id == current_user.id).first()
272+
old_values["volunteer_experience"] = volunteer_data.experience if volunteer_data else None
273+
230274
# Update simple fields
231275
simple_fields = [
232276
"first_name",
@@ -350,9 +394,119 @@ async def update_my_user_data(
350394
)
351395
db.add(volunteer_data)
352396

397+
# Commit the main profile update first
353398
db.commit()
354399
db.refresh(user_data)
355400

401+
# Create PROFILE_UPDATE task if user is not an admin (after main commit to avoid rollback)
402+
if current_user.role and current_user.role.name != "admin":
403+
try:
404+
changes = []
405+
406+
# Compare simple fields
407+
for field in update_data:
408+
if field in [
409+
"first_name",
410+
"last_name",
411+
"email",
412+
"phone",
413+
"city",
414+
"province",
415+
"postal_code",
416+
"gender_identity",
417+
"marital_status",
418+
"has_kids",
419+
"other_ethnic_group",
420+
"gender_identity_custom",
421+
"diagnosis",
422+
"loved_one_gender_identity",
423+
"loved_one_age",
424+
"loved_one_diagnosis",
425+
"has_blood_cancer",
426+
"caring_for_someone",
427+
"timezone",
428+
]:
429+
new_value = getattr(user_data, field, None)
430+
old_value = old_values.get(field)
431+
if new_value != old_value:
432+
changes.append(f"{field}: '{old_value}' → '{new_value}'")
433+
434+
# Compare array fields
435+
elif field in ["pronouns", "ethnic_group"]:
436+
new_value = getattr(user_data, field, [])
437+
old_value = old_values.get(field, [])
438+
if new_value != old_value:
439+
changes.append(f"{field}: {old_value}{new_value}")
440+
441+
# Compare date fields
442+
elif field in ["date_of_birth", "date_of_diagnosis", "loved_one_date_of_diagnosis"]:
443+
new_value = getattr(user_data, field, None)
444+
old_value = old_values.get(field)
445+
if new_value != old_value:
446+
new_str = new_value.isoformat() if new_value else None
447+
old_str = old_value.isoformat() if old_value else None
448+
changes.append(f"{field}: '{old_str}' → '{new_str}'")
449+
450+
# Compare treatments/experiences
451+
elif field == "treatments":
452+
new_value = [t.name for t in user_data.treatments]
453+
old_value = old_values.get("treatments", [])
454+
if sorted(new_value) != sorted(old_value):
455+
changes.append(f"treatments: {old_value}{new_value}")
456+
457+
elif field == "experiences":
458+
new_value = [e.name for e in user_data.experiences]
459+
old_value = old_values.get("experiences", [])
460+
if sorted(new_value) != sorted(old_value):
461+
changes.append(f"experiences: {old_value}{new_value}")
462+
463+
elif field == "loved_one_treatments":
464+
new_value = [t.name for t in user_data.loved_one_treatments]
465+
old_value = old_values.get("loved_one_treatments", [])
466+
if sorted(new_value) != sorted(old_value):
467+
changes.append(f"loved_one_treatments: {old_value}{new_value}")
468+
469+
elif field == "loved_one_experiences":
470+
new_value = [e.name for e in user_data.loved_one_experiences]
471+
old_value = old_values.get("loved_one_experiences", [])
472+
if sorted(new_value) != sorted(old_value):
473+
changes.append(f"loved_one_experiences: {old_value}{new_value}")
474+
475+
# Compare language
476+
elif field == "language":
477+
new_value = current_user.language.value if current_user.language else None
478+
old_value = old_values.get("language")
479+
if new_value != old_value:
480+
changes.append(f"language: '{old_value}' → '{new_value}'")
481+
482+
# Compare volunteer_experience
483+
elif field == "volunteer_experience":
484+
volunteer_data_check = (
485+
db.query(VolunteerData).filter(VolunteerData.user_id == current_user.id).first()
486+
)
487+
new_value = volunteer_data_check.experience if volunteer_data_check else None
488+
old_value = old_values.get("volunteer_experience")
489+
if new_value != old_value:
490+
changes.append(f"volunteer_experience: '{old_value}' → '{new_value}'")
491+
492+
# Only create task if there are actual changes
493+
if changes:
494+
user_name = f"{user_data.first_name} {user_data.last_name}".strip() or user_data.email
495+
description = f"{user_name} updated profile: " + ", ".join(changes)
496+
497+
profile_task = Task(
498+
participant_id=current_user.id,
499+
type=TaskType.PROFILE_UPDATE,
500+
description=description,
501+
)
502+
db.add(profile_task)
503+
# Commit task creation in isolated try/except to prevent rollback of profile update
504+
db.commit()
505+
except Exception as e:
506+
# Log error but don't fail the request - profile update already committed
507+
db.rollback() # Rollback only the task creation attempt
508+
print(f"Failed to create PROFILE_UPDATE task: {str(e)}")
509+
356510
# Return updated data using the same logic as GET
357511
availability_templates = [
358512
AvailabilityTemplateResponse(

backend/app/routes/volunteer_data.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from uuid import UUID
22

33
from fastapi import APIRouter, Depends, HTTPException, Request
4+
from sqlalchemy.orm import Session
45

56
from app.middleware.auth import has_roles
67
from app.schemas.user import UserRole
@@ -13,7 +14,9 @@
1314
)
1415
from app.services.implementations.user_service import UserService
1516
from app.services.implementations.volunteer_data_service import VolunteerDataService
17+
from app.utilities.db_utils import get_db
1618
from app.utilities.service_utils import get_user_service, get_volunteer_data_service
19+
from app.utilities.task_utils import create_volunteer_app_review_task
1720

1821
router = APIRouter(
1922
prefix="/volunteer-data",
@@ -26,6 +29,7 @@
2629
async def submit_volunteer_data(
2730
volunteer_data: VolunteerDataPublicSubmission,
2831
request: Request,
32+
db: Session = Depends(get_db),
2933
volunteer_data_service: VolunteerDataService = Depends(get_volunteer_data_service),
3034
user_service: UserService = Depends(get_user_service),
3135
authorized: bool = has_roles([UserRole.VOLUNTEER, UserRole.ADMIN]),
@@ -47,7 +51,12 @@ async def submit_volunteer_data(
4751
references_json=volunteer_data.references_json,
4852
additional_comments=volunteer_data.additional_comments,
4953
)
50-
return await volunteer_data_service.create_volunteer_data(create_request)
54+
result = await volunteer_data_service.create_volunteer_data(create_request)
55+
56+
# Create task for admin review
57+
create_volunteer_app_review_task(db, str(user_id), "secondary")
58+
59+
return result
5160
except HTTPException as http_ex:
5261
raise http_ex
5362
except Exception as e:

backend/app/schemas/task.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,14 +99,20 @@ class TaskAssignRequest(BaseModel):
9999
class TaskResponse(BaseModel):
100100
"""
101101
Response schema for task data.
102+
Includes participant and assignee names for efficient loading.
102103
"""
103104

104105
id: UUID
105106
participant_id: Optional[UUID]
107+
participant_name: Optional[str] = None
108+
participant_email: Optional[str] = None
109+
participant_role_id: Optional[int] = None
106110
type: TaskType
107111
priority: TaskPriority
108112
status: TaskStatus
109113
assignee_id: Optional[UUID]
114+
assignee_name: Optional[str] = None
115+
assignee_email: Optional[str] = None
110116
start_date: datetime
111117
end_date: Optional[datetime]
112118
created_at: datetime

backend/app/services/implementations/__init__.py

Whitespace-only changes.

backend/app/services/implementations/form_processor.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
RankingPreference,
2222
Role,
2323
Task,
24+
TaskType,
2425
User,
2526
suggested_times,
2627
)
@@ -134,6 +135,19 @@ def _process_ranking_form(self, submission: FormSubmission, user: User) -> None:
134135
if user.form_status in (FormStatus.RANKING_TODO, FormStatus.RANKING_SUBMITTED):
135136
user.form_status = FormStatus.COMPLETED
136137

138+
# Create a MATCHING task so admins know this participant is ready to be matched
139+
try:
140+
matching_task = Task(
141+
participant_id=user.id,
142+
type=TaskType.MATCHING,
143+
description=f"Participant {user.first_name} {user.last_name} completed ranking form and is ready for matching",
144+
)
145+
self.db.add(matching_task)
146+
self.logger.info(f"Created MATCHING task for user {user.id}")
147+
except Exception as e:
148+
# Log error but don't fail the ranking form approval
149+
self.logger.error(f"Failed to create MATCHING task for user {user.id}: {str(e)}")
150+
137151
def _process_secondary_form(self, submission: FormSubmission, user: User) -> None:
138152
"""Process secondary application form - creates VolunteerData."""
139153
service = VolunteerDataService(self.db)

0 commit comments

Comments
 (0)