Badge System Implementation
January 10, 2026 · View on GitHub
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) - ✅ IMPLEMENTEDmessage_upvotes: User gets message with N upvotes (config: value) - ✅ IMPLEMENTED
Adding New Criteria:
- 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 ) ) - Implement evaluation logic in
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 fieldsUserBadgeType: Badge award recordCriteriaFieldType: Field definition from registryCriteriaTypeDefinitionType: Complete criteria type metadata
Queries (config/graphql/queries.py):
badges(corpusId, limit, offset): List badgesuserBadges(userId, corpusId): List user's earned badgesbadgeCriteriaTypes(scope): Get available criteria types from registry
Mutations (config/graphql/badge_mutations.py):
createBadge: Validates criteria_config against registry before creationupdateBadge: Validates final state after updatesdeleteBadge: Remove badge and all awardsawardBadge: Manually award badge to userrevokeBadge: 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 = Truein 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 usercheck_badges_for_all_users(corpus_id): Bulk check for all active usersrevoke_badges_by_criteria(badge_id): Remove badges from users who no longer meet criteria
Evaluation Flow:
- Validate criteria_config against registry
- Extract criteria type and parameters
- Query database for user's stats
- Compare against threshold
- 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:
- Fetch criteria types from backend via
badgeCriteriaTypesquery - Filter to scope (global or corpus) based on badge type
- Render dropdown with available criteria types
- When type selected, render dynamic fields based on field definitions
- Validate inputs in real-time
- 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 = Trueto 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