feat(badges): implement volunteer badge system for engagement & gamification
Merge Request — Volunteer Badge System
Overview
Implements the Volunteer Badge System — a gamification feature that automatically rewards volunteers with badges based on their medical camp attendance and participation consistency. Badges are awarded only when attendance is marked True, not on signup.
What does this MR do and why?
Motivation: Enhance volunteer engagement and retention by recognizing contributions through a structured badge system with 9 achievement levels.
Approach: Badges are evaluated and assigned automatically when a coordinator marks a user's attendance. A manual evaluation endpoint is also available. Badge calculation counts total attended visits and longest consecutive attendance streak.
Trade-offs: Badge evaluation is synchronous with attendance marking — acceptable for current user volume. Can be moved async later.
Changes Made
Files Modified
-
app/api/v1/routes/badge_routes.py— Added 3 new endpoints (/me/badges,/me/badge-progress,/{user_id}/evaluate-badges) -
app/services/badge_service.py— Fixed 2 bugs in existing badge logic (consecutive visits was broken, progress flag was inverted) -
app/services/medical_camp_service.py— Removed badge assignment from signup; badges now trigger only on attendance -
tests/test_services/test_medical_camp_service.py— Updated 6 test mocks to handle badge service queries
Files Added
-
alembic/versions/e3f5a6b7c8d9_fix_badgecriteriaenum_typo.py— Migration that renames typo'd enumbadgcriteriaenum→badgecriteriaenum -
tests/test_services/test_badge_service.py— 20 unit tests covering all badge service functions
Type of Change
-
🐛 Bug fix (non-breaking change that fixes an issue) -
✨ New feature (non-breaking change that adds functionality) -
💥 Breaking change -
📝 Documentation update -
♻ ️ Refactor (no functional changes) -
⚡ Performance improvement -
🧪 Test update -
🔧 Configuration change -
🚨 Security fix -
🗑 ️ Deprecation
Related Issues / References
Screenshots
API Documentation (/docs)
5 badge endpoints visible under the users tag:
| Method | Path | Description |
|---|---|---|
GET |
/users/me/badges |
Get own badges |
GET |
/users/me/badge-progress |
Get own badge progress |
GET |
/users/{user_id}/badges |
Get any user's badges |
GET |
/users/{user_id}/badge-progress |
Get any user's progress |
POST |
/users/{user_id}/evaluate-badges |
Manually trigger evaluation |
Example: GET /users/me/badges
{
"badges": [
{
"name": "First Step",
"description": "Awarded for first camp visit",
"earned_at": "2026-04-10T14:30:00"
},
{
"name": "Certified Volunteer",
"description": "Awarded for 3 camp visits",
"earned_at": "2026-04-11T09:00:00"
}
],
"count": 2
}
Example: GET /users/me/badge-progress
{
"progress": [
{ "badge": "First Step", "current": 5, "required": 1 },
{ "badge": "Certified Volunteer", "current": 5, "required": 3 },
{ "badge": "Consistent Volunteer", "current": 5, "required": 5 },
{ "badge": "On a Roll", "current": 2, "required": 3 },
{ "badge": "Century Contributor", "current": 5, "required": 10 }
],
"next_badge": { "badge": "On a Roll", "current": 2, "required": 3 }
}
How to Validate Locally
Previous Behavior
- Signup triggered badge evaluation (incorrect — user got badges without attending)
- Consecutive visit logic always returned
1(broken date comparison) - Badge progress showed earned badges as unearned (inverted flag)
- Enum
badgecriteriaenumdidn't exist (typobadgcriteriaenumin migration)
Changes Made
- Badge assignment removed from
signup_user_for_camp()— signup creates records only - Badge assignment retained in
update_user_attendance()— fires only whenattendance = True - Consecutive visits rewritten: fetches ALL records, builds streak from attendance flag
- Migration created to rename
badgcriteriaenum→badgecriteriaenum - Added 3 new endpoints for self-lookup and manual evaluation
New Behavior
- Admin/Coordinator marks attendance →
attendance = True→ badges evaluated - Volunteer signs up → NO badge assigned
- Consecutive streak correctly counts attended camps, resets on missed attendance
- Badge progress correctly shows earned vs unearned
Testing done
-
Unit tests added/updated -
API endpoint tests passing
Test Cases Covered:
| Scenario | Expected Result | Status |
|---|---|---|
| Invalid UUID returns empty/zero | Graceful response, no crash | |
| User with 0 visits gets no badges | Empty badge list | |
| User with 1 visit gets First Step badge | Badge assigned | |
| User with 3 visits gets First Step + Certified | Multiple badges at once | |
| User with 5 consecutive visits gets On a Roll | Streak correctly calculated | |
| Missed attendance breaks streak | Streak resets to 0 | |
| Already-earned badge not re-assigned | Idempotent, no duplicates | |
| Signup does NOT trigger badges | No badge service call in signup | |
| Attendance DOES trigger badges | Badge assigned on attendance mark | |
| Badge progress shows correct earned/unearned |
earned flag is accurate |
|
next_badge shows closest unearned |
Correct next target | |
| Multiple badges awarded simultaneously | All eligible badges assigned in one call |
Test Commands Run:
# Run all tests
uv run pytest tests/
# Run badge-specific tests
uv run pytest tests/test_services/test_badge_service.py -v
# Run medical camp service tests (signup/attendance)
uv run pytest tests/test_services/test_medical_camp_service.py -v
# Result: 1185 passed, 6 skipped, 0 failed
Code Quality Checklist
Code Standards
-
Code follows project conventions (naming, structure, formatting) -
No debug statements or commented-out code left -
No unused imports, variables, or functions -
No duplicate code (DRY principle followed) -
Type hints are properly defined -
Ruff checks pass: ruff check . ruff format . --check
Python & FastAPI Best Practices
-
Functions follow single-responsibility principle -
Async/await used correctly (no blocking calls in async functions) -
Dependency injection used appropriately -
Pydantic models used for request/response validation -
SQLAlchemy queries are optimized (no N+1 queries) -
Error handling is comprehensive (try/except with proper logging)
API Design
-
RESTful conventions followed -
Proper HTTP status codes returned -
Input validation implemented (UUID format check) -
Authentication/authorization enforced -
Role-based access control used for user restriction -
API documentation (docstrings) updated
Database & Migrations
-
Database migrations created -
Database migration version points to latest -
Migrations are reversible (downgrade scripts included) -
Indexes added for frequently queried fields -
No raw SQL queries (using SQLAlchemy ORM) -
Data integrity constraints maintained (unique constraint on user_id + badge_id)
Security
-
No sensitive data logged (passwords, tokens, PII) -
SQL injection prevention verified (ORM used) -
Input sanitization implemented -
Authentication tokens handled securely -
CORS settings appropriate -
Security scan passes: bandit -r app/
Error Handling
-
Errors are caught and handled gracefully -
User-friendly error messages returned -
Errors are logged appropriately -
HTTP error responses follow API standards
Documentation
-
README.md updated (if setup steps changed) -
.env.exampleupdated — N/A (no new env vars) -
API documentation updated (docstrings, OpenAPI specs) -
CHANGELOG.md will be updated (if applicable) -
Code comments explain complex logic (not what, but why)
Known Limitations / Technical Debt
- Badge evaluation is synchronous — runs in the same request as attendance update. For high-volume environments, consider moving to an async background task.
-
Race condition on concurrent badge evaluation — handled by unique constraint, but results in
IntegrityErrorinstead of graceful handling. Recommend addingtry/except IntegrityErrorin a follow-up. -
Visit count includes all roles — if a user attends as both patient and volunteer, both count toward badges. If badges should be volunteer-role-only, add
camp_rolefilter in a follow-up.
Additional Notes
- The existing badge foundation (models, 2 endpoints, seed script) was already implemented by a previous developer. This MR fixes 2 critical bugs, corrects the badge trigger location, adds 3 endpoints, and adds comprehensive tests.
- Migration
e3f5a6b7c8d9safely renames a typo'd enum (badgcriteriaenum→badgecriteriaenum) using conditional PostgreSQL logic — works for teammates who have or haven't run the old migration. - No existing functionality was broken. All 1185 existing tests pass.
Badge Flow
User signs up for camp → NO badge
↓
Admin/Coordinator marks attendance → attendance = True
↓
badge_service.assign_badges_to_user() called
↓
1. Count total attended visits (attendance = True)
2. Calculate longest consecutive attendance streak
3. Check all 9 badge criteria
4. Skip already-earned badges (unique constraint prevents duplicates)
5. Assign all eligible badges → commit
↓
Badge records inserted into user_badges
9 Badge Definitions
| Badge | Type | Threshold |
|---|---|---|
|
|
visits | 1 |
|
|
visits | 3 |
|
|
visits | 5 |
|
|
consecutive | 3 |
|
|
visits | 10 |
|
|
visits | 6 |
|
|
visits | 12 |
|
|
visits | 20 |
|
|
visits | 36 |
MR Acceptance Checklist
Quality & Correctness
-
Code works as intended and solves the stated problem -
No bugs introduced (existing functionality not broken) -
Edge cases handled appropriately
Maintainability
-
Code is readable and well-organized -
Code is testable and well-tested -
Follows project patterns and conventions
Acceptance Review
-
Reviewed by at least 1 teammate -
Reviewed by product owner closes #94 (closed)

