Badge System Implementation¶
Status: Complete ✅¶
The badge system allows admins to create badges (global or corpus-specific) that can be manually awarded or automatically awarded based on configurable criteria.
Overview¶
Key Design Decisions:
- Two badge types: GLOBAL (site-wide) and CORPUS (specific to a corpus)
- Two award modes: Manual (admin awards) and Auto-award (triggers on user actions)
- Registry-based criteria: Extensible system with declarative criteria type definitions
- Multi-layer validation: Frontend, GraphQL, Django model, and database constraints
- Signal-driven: Automatic badge checks triggered by user actions (messages, annotations)
- Dynamic UI: Frontend adapts to selected criteria type via GraphQL introspection
Default Badges¶
The system automatically installs 9 default global badges via migration 0005_install_default_badges.py:
Engagement Badges: - First Steps (🟢 green) - First message posted - Conversationalist (🔵 blue) - 10 messages posted - Active Contributor (🟣 purple) - 50 messages posted - Discussion Leader (🎀 pink) - 100 messages posted
Reputation Badges: - Helpful (🟢 green) - Earned 10 reputation points - Valued Contributor (🟡 yellow) - Earned 50 reputation points - Expert (🟠 orange) - Earned 100 reputation points - Master (🔴 red) - Earned 500 reputation points
Quality Badge: - Popular Post (🟡 amber) - Received 10 upvotes on a single message
All default badges are auto-awarded and created by a system user on first migration.
Backend Implementation¶
1. Models (opencontractserver/badges/models.py)¶
Badge Model: - Fields: name, description, icon (lucide-react icon name), color (hex), badge_type, corpus FK (nullable) - Auto-award fields: is_auto_awarded, criteria_config (JSONField) - Validation in clean(): criteria_config validated against registry, scope compatibility checked
UserBadge Model: - Junction table: user FK, badge FK, awarded_by FK (nullable for auto-awards), corpus FK (nullable) - Unique constraints: unique_user_badge_global, unique_user_badge_corpus
2. Criteria Registry (opencontractserver/badges/criteria_registry.py)¶
Registry Pattern:
@dataclass
class CriteriaField:
name: str
label: str
field_type: str # "number", "text", "boolean"
required: bool
description: str
min_value: Optional[int] = None
max_value: Optional[int] = None
allowed_values: Optional[List[str]] = None
@dataclass
class CriteriaTypeDefinition:
type_id: str
name: str
description: str
scope: str # "global", "corpus", "both"
fields: List[CriteriaField]
implemented: bool
Pre-configured Criteria Types: - first_post: User's first message (no config needed) - message_count: User reaches N messages (config: value) - corpus_contribution: User adds N documents/annotations to corpus (config: value) - reputation_threshold: User reaches reputation score (config: value) - ✅ IMPLEMENTED - message_upvotes: User gets message with N upvotes (config: value) - ✅ IMPLEMENTED
Adding New Criteria: 1. Register in criteria_registry.py:
BadgeCriteriaRegistry.register(
CriteriaTypeDefinition(
type_id="my_criteria",
name="My Criteria",
description="Description",
scope="both",
fields=[CriteriaField(name="threshold", ...)],
implemented=True
)
)
badge_tasks.py: elif criteria_type == "my_criteria":
threshold = badge.criteria_config.get("threshold")
return check_my_condition(user, threshold, corpus)
3. GraphQL Schema¶
Types (config/graphql/graphene_types.py): - BadgeType: Badge details with permission fields - UserBadgeType: Badge award record - CriteriaFieldType: Field definition from registry - CriteriaTypeDefinitionType: Complete criteria type metadata
Queries (config/graphql/queries.py): - badges(corpusId, limit, offset): List badges - userBadges(userId, corpusId): List user's earned badges - badgeCriteriaTypes(scope): Get available criteria types from registry
Mutations (config/graphql/badge_mutations.py): - createBadge: Validates criteria_config against registry before creation - updateBadge: Validates final state after updates - deleteBadge: Remove badge and all awards - awardBadge: Manually award badge to user - revokeBadge: Remove badge from user
4. Signal Handlers (opencontractserver/badges/signals.py)¶
Automatic Badge Checks: - @receiver(post_save, sender="conversations.ChatMessage"): Triggers badge checks on message creation - @receiver(post_save, sender="annotations.Annotation"): Triggers badge checks on annotation creation - Both check global badges and corpus-specific badges (if applicable) - Use instance._skip_signals = True in tests to prevent automatic awards
5. Badge Evaluation (opencontractserver/tasks/badge_tasks.py)¶
Key Tasks: - check_auto_badges(user_id, corpus_id): Check all auto-award badges for user - check_badges_for_all_users(corpus_id): Bulk check for all active users - revoke_badges_by_criteria(badge_id): Remove badges from users who no longer meet criteria
Evaluation Flow: 1. Validate criteria_config against registry 2. Extract criteria type and parameters 3. Query database for user's stats 4. Compare against threshold 5. Award badge if met (and not already awarded)
Frontend Implementation¶
1. GraphQL Query (frontend/src/graphql/queries.ts)¶
export const GET_BADGE_CRITERIA_TYPES = gql`
query GetBadgeCriteriaTypes($scope: String) {
badgeCriteriaTypes(scope: $scope) {
typeId
name
description
scope
fields { name, label, fieldType, required, description, minValue, maxValue, allowedValues }
implemented
}
}
`;
2. Dynamic Criteria Config Component (frontend/src/components/badges/BadgeCriteriaConfig.tsx)¶
Purpose: Dynamically render form fields based on selected criteria type
Flow: 1. Fetch criteria types from backend via badgeCriteriaTypes query 2. Filter to scope (global or corpus) based on badge type 3. Render dropdown with available criteria types 4. When type selected, render dynamic fields based on field definitions 5. Validate inputs in real-time 6. Pass validated config to parent via onChange({ config, isValid })
Field Rendering: - number: <Input type="number" min={minValue} max={maxValue} /> - text (no allowedValues): <Input type="text" /> - text (with allowedValues): <Dropdown options={allowedValues} /> - boolean: <Dropdown options={[true, false]} />
3. Badge Management UI (frontend/src/components/badges/BadgeManagement.tsx)¶
Integration: - Auto-award checkbox shows/hides BadgeCriteriaConfig component - Create button disabled if auto-award enabled but criteria invalid - Passes criteria config to createBadge mutation
Validation Layers¶
1. Frontend Validation: - Required field checks - Type validation (number, text, boolean) - Range validation (min/max values) - Allowed values validation - Real-time error display
2. GraphQL Mutation Validation: - Registry validation before database operations - Returns user-friendly error messages - Prevents invalid configs from reaching database
3. Django Model Validation: - Badge.clean() validates criteria_config on save - Ensures auto-awarded badges have valid config - Validates scope compatibility
4. Database Constraints: - Unique constraints on UserBadge - Foreign key constraints ensure referential integrity
Testing¶
Backend Tests (opencontractserver/tests/test_badges.py): - 35 tests covering models, GraphQL, auto-awards, permissions - Use instance._skip_signals = True to prevent signal interference - Tests validate multi-layer validation works correctly
Key Test Patterns:
# Prevent automatic badge awards in tests
msg = ChatMessage(conversation=conv, content="Test", creator=user)
msg._skip_signals = True
msg.save()
# Test validation errors
with self.assertRaises(ValidationError) as cm:
Badge.objects.create(..., criteria_config={"invalid": "config"})
self.assertIn("criteria_config", cm.exception.message_dict)
Architecture Notes¶
Why Registry Pattern? - Single source of truth for criteria definitions - Frontend UI adapts automatically to backend changes - Type-safe validation - Self-documenting (descriptions in registry) - Easy to add new criteria types
Why Multi-Layer Validation? - Frontend: Fast feedback, better UX - GraphQL: Prevent invalid API calls - Model: Ensure database integrity - Database: Final safety net
Why Signals? - Automatic badge awards without manual task calls - Decoupled from core business logic - Can be disabled for testing with _skip_signals
File Locations¶
Backend: - Models: opencontractserver/badges/models.py - Registry: opencontractserver/badges/criteria_registry.py - Tasks: opencontractserver/tasks/badge_tasks.py - Signals: opencontractserver/badges/signals.py - GraphQL Types: config/graphql/graphene_types.py - GraphQL Queries: config/graphql/queries.py - GraphQL Mutations: config/graphql/badge_mutations.py - Default Badges Migration: opencontractserver/badges/migrations/0005_install_default_badges.py - Tests: opencontractserver/tests/test_badges.py
Frontend: - Badge Component: frontend/src/components/badges/Badge.tsx - Management UI: frontend/src/components/badges/BadgeManagement.tsx - Criteria Config: frontend/src/components/badges/BadgeCriteriaConfig.tsx - GraphQL Queries: frontend/src/graphql/queries.ts - GraphQL Mutations: frontend/src/graphql/mutations.ts
Last Updated: 2026-01-09