Display Tier / Group Config - Architecture Plan
Overview
This document describes the architecture for Display Tier Overrides, a feature that allows themes/lists to define different display configurations for segments of places based on matching criteria (membership level, category, tags, list membership, etc.).
The core concept is cascading configuration: a base Theme defines default display rules, and TierOverrides specify only what's different for matching places.
Theme (base config)
└─► TierOverride "default" (non-members)
└─► TierOverride "member" (basic members)
└─► TierOverride "member-plus" (enhanced members)
└─► TierOverride "member-premium" (full access)
Use Case: Lynnwood Chamber of Commerce
The Lynnwood example demonstrates tiered membership where businesses get progressively more features:
| Feature | Non-Member | Member | Member Plus | Premium |
|---|---|---|---|---|
| Name | ✓ | ✓ | ✓ | ✓ |
| Address | ✓ | ✓ | ✓ | ✓ |
| Phone | ✓ | ✓ | ✓ | ✓ |
| Hours | ✗ | ✓ | ✓ | ✓ |
| Description | ✗ | 150 chars | Full | Full |
| Vibes | 0 | 3 | 8 | Unlimited |
| Photos | 0 | 1 | 6 | Unlimited |
| Website | ✗ | ✓ | ✓ | ✓ |
| Social Media | ✗ | ✗ | ✓ | ✓ |
| Map Marker | None | Basic | Image | Image |
| Similar Places | ✗ | ✗ | ✗ | ✓ |
| Venue Events | ✗ | ✗ | ✗ | ✓ |
Data Model
DisplayTierOverride Model
Lives in accounts/models/display_tier.py. Each override belongs to a Theme and specifies:
- Matching criteria - which places this tier applies to
- Display overrides - only fields that differ from the base theme (null = inherit)
class DisplayTierOverride(models.Model):
"""
Partial theme override for a segment of places.
Only non-null fields override the parent theme/tier values.
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4)
name = models.CharField(max_length=100) # "member", "member-plus"
slug = AutoSlugField(populate_from='name', unique_with='theme')
theme = models.ForeignKey('Theme', on_delete=models.CASCADE, related_name='tier_overrides')
description = models.TextField(blank=True)
priority = models.IntegerField(default=0) # Higher wins
# Optional inheritance from another tier
parent_tier = models.ForeignKey('self', null=True, blank=True, on_delete=models.SET_NULL)
# === MATCHING CRITERIA ===
match_categories = models.ManyToManyField('api.Category', blank=True)
match_tags = models.JSONField(null=True, blank=True) # ["member", "premium"]
match_lists = models.ManyToManyField('api.List', blank=True)
match_vibes = models.ManyToManyField('api.Vibe', blank=True)
match_field_conditions = models.JSONField(null=True, blank=True) # {"membership_level": "premium"}
# === DISPLAY OVERRIDES (all nullable - null means inherit) ===
# Card/Detail visibility
show_name = models.BooleanField(null=True)
show_address = models.BooleanField(null=True)
show_phone = models.BooleanField(null=True)
show_hours = models.BooleanField(null=True)
show_categories = models.BooleanField(null=True)
show_website = models.BooleanField(null=True)
show_social_media = models.BooleanField(null=True)
show_links = models.BooleanField(null=True)
show_aggregate_rating = models.BooleanField(null=True)
show_reviews = models.BooleanField(null=True)
show_offers = models.BooleanField(null=True)
show_price = models.BooleanField(null=True)
show_events = models.BooleanField(null=True)
show_description = models.BooleanField(null=True)
show_vibes = models.BooleanField(null=True)
show_photos = models.BooleanField(null=True)
# Limits: null=inherit, -1=unlimited, 0=hidden, >0=limit
description_limit = models.IntegerField(null=True)
vibes_limit = models.IntegerField(null=True)
photos_limit = models.IntegerField(null=True)
# Map marker
map_marker_style = models.CharField(max_length=20, null=True, choices=[...])
map_marker_color = models.CharField(max_length=50, null=True)
map_marker_size = models.IntegerField(null=True)
map_marker_priority = models.IntegerField(null=True)
# Visibility
show_in_details_panel = models.BooleanField(null=True)
show_in_grid = models.BooleanField(null=True)
show_in_search = models.BooleanField(null=True)
show_in_map = models.BooleanField(null=True)
show_minimap = models.BooleanField(null=True)
show_similar_places = models.BooleanField(null=True)
show_venue_events = models.BooleanField(null=True)
show_action_pills = models.BooleanField(null=True)
# Styling
card_accent_color = models.CharField(max_length=50, null=True)
card_background_color = models.CharField(max_length=50, null=True)
card_badge_text = models.CharField(max_length=50, null=True) # "Premium", "Featured"
card_badge_color = models.CharField(max_length=50, null=True)
# Escape hatch for additional overrides
extra_overrides = models.JSONField(null=True)
Tier Assignment on ListPlace
Places can be explicitly assigned a tier within a list context:
class ListPlace(RevisionMixin, models.Model):
list = models.ForeignKey('List', on_delete=models.CASCADE)
place = models.ForeignKey('HotspotsPlace', on_delete=models.CASCADE)
# NEW: Explicit tier assignment for this place in this list
display_tier = models.ForeignKey(
'accounts.DisplayTierOverride',
null=True, blank=True,
on_delete=models.SET_NULL,
help_text='Explicit tier override. If not set, tier is determined by matching rules.'
)
# Existing fields...
revised_name = models.CharField(...)
revised_description = models.TextField(...)
This allows:
- Automatic tier assignment via matching rules (categories, tags, etc.)
- Manual tier assignment per-place within a list via
ListPlace.display_tier
Configuration Resolution
Cascade Order
Configuration resolves in this order (later wins):
1. System Defaults (hardcoded fallbacks)
▼
2. Theme Base Config (Theme model fields)
▼
3. Parent Tier Override (if tier has parent_tier set)
▼
4. Matching Tier Override (highest priority match)
▼
5. Explicit ListPlace.display_tier (if set)
Resolution Algorithm
class DisplayConfigResolver:
DEFAULTS = {
'show_name': True,
'show_address': True,
'show_phone': True,
'show_hours': False,
# ... etc
}
def resolve(self, place, theme, list_context=None):
# 1. Start with defaults
config = self.DEFAULTS.copy()
# 2. Apply theme base config
config.update(self._get_theme_base(theme))
# 3. Check for explicit tier on ListPlace
explicit_tier = self._get_explicit_tier(place, list_context)
if explicit_tier:
# Use explicit tier (with its inheritance chain)
config.update(explicit_tier.get_resolved_overrides())
else:
# 4. Find matching tier by rules
tier = self._find_matching_tier(place, theme, list_context)
if tier:
config.update(tier.get_resolved_overrides())
return config
Matching Logic
Tiers are matched by ANY of their criteria (OR logic):
def matches_place(self, place, list_context=None):
# If no criteria defined, doesn't match
has_criteria = any([
self.match_categories.exists(),
self.match_tags,
self.match_lists.exists(),
self.match_vibes.exists(),
self.match_field_conditions,
])
if not has_criteria:
return False
# Match if ANY criterion matches
if self.match_categories.exists():
if self.match_categories.filter(pk__in=place.categories.all()).exists():
return True
if self.match_tags and place.tags:
if any(t in place.tags for t in self.match_tags):
return True
if self.match_lists.exists() and list_context:
if self.match_lists.filter(pk=list_context.pk).exists():
return True
# ... etc
return False
When multiple tiers match, the one with highest priority wins.
Tier Inheritance
Tiers can inherit from a parent tier to reduce repetition:
default (priority: 0)
└─► member (priority: 10, parent: default)
└─► member-plus (priority: 20, parent: member)
└─► member-premium (priority: 30, parent: member-plus)
Each tier only defines what's different from its parent:
# default tier - restrictive baseline
default = DisplayTierOverride(
name='default',
show_hours=False,
show_website=False,
description_limit=0,
vibes_limit=0,
photos_limit=0,
map_marker_style='none',
)
# member tier - inherits from default, adds features
member = DisplayTierOverride(
name='member',
parent_tier=default,
match_tags=['member'],
# Only override what changes from default:
show_hours=True,
show_website=True,
description_limit=150,
vibes_limit=3,
photos_limit=1,
map_marker_style='basic',
)
# member-plus - inherits from member
member_plus = DisplayTierOverride(
name='member-plus',
parent_tier=member,
match_tags=['member-plus'],
# Only changes from member:
description_limit=-1, # unlimited
vibes_limit=8,
photos_limit=6,
show_social_media=True,
map_marker_style='image',
)
API Response Format
Design Principle: Tier Reference, Not Inline Config
Since styling is applied by group/tier (not per-place), embedding full config on every place is wasteful. Instead:
- Places return only their tier identifier
- Tier configs are fetched once (per theme) and cached client-side
- Client looks up styling by tier slug
This keeps place payloads small and avoids redundant data.
Theme/Tier Config Endpoint
Fetch tier definitions once when loading a theme:
GET /api/v1/themes/{theme_slug}/tiers/
{
"theme": "lynnwood",
"tiers": {
"default": {
"display_name": "Default",
"priority": 0,
"config": {
"show_name": true,
"show_address": true,
"show_phone": true,
"show_hours": false,
"show_website": false,
"show_social_media": false,
"description_limit": 0,
"vibes_limit": 0,
"photos_limit": 0,
"map_marker_style": "none",
"show_similar_places": false,
"card_badge_text": null,
"card_badge_color": null
}
},
"member": {
"display_name": "Member",
"priority": 10,
"config": {
"show_hours": true,
"show_website": true,
"description_limit": 150,
"vibes_limit": 3,
"photos_limit": 1,
"map_marker_style": "basic",
"card_badge_text": "Member",
"card_badge_color": "#4CAF50"
}
},
"member-plus": { ... },
"member-premium": { ... }
}
}
Place Response (Lean)
Places include only the tier slug:
{
"id": "abc-123",
"name": "Joe's Coffee",
"address": "123 Main St",
"display_tier": "member"
}
Client-Side Usage
// Fetch tier config once on theme load
const tierConfig = await fetchTierConfig(themeSlug);
// For each place, look up its styling
function getPlaceConfig(place) {
const tier = place.display_tier || 'default';
return tierConfig.tiers[tier]?.config || tierConfig.tiers['default'].config;
}
// Apply styling
const config = getPlaceConfig(place);
if (config.show_hours) { ... }
if (config.card_badge_text) { ... }
Benefits
- Smaller payloads: Places don't repeat the same config object
- Single source of truth: Tier definitions live in one place
- Easy updates: Change tier config without re-fetching all places
- Cacheable: Tier config can be cached aggressively (changes rarely)
- Simpler API: Places just need a string field, not nested config
Fallback Behavior
If display_tier is null/missing, client falls back to "default" tier.
Admin Interface
Theme Admin
Add inline for tier overrides on Theme:
class DisplayTierOverrideInline(admin.TabularInline):
model = DisplayTierOverride
extra = 0
fields = ['name', 'priority', 'parent_tier', 'match_tags', 'description_limit', 'map_marker_style']
class ThemeAdmin(admin.ModelAdmin):
inlines = [DisplayTierOverrideInline]
ListPlace Admin
Add tier selection:
class ListPlaceAdmin(admin.ModelAdmin):
list_display = ['place', 'list', 'display_tier', 'has_revisions']
list_filter = ['display_tier']
raw_id_fields = ['place', 'list']
Bulk Assignment
Management command for bulk tier assignment:
# Assign tier by tag
python manage.py assign_display_tiers --theme=lynnwood --tag=member --tier=member
# Assign tier by list
python manage.py assign_display_tiers --list=lynnwood-members --tier=member-plus
Implementation Steps
Phase 1: Core Models
- Create
accounts/models/display_tier.pywithDisplayTierOverridemodel - Add migration
- Add
display_tierFK toListPlacemodel - Add migration
Phase 2: Resolution Logic
- Create
accounts/services/display_config.pywithDisplayConfigResolver - Add unit tests for resolution logic
Phase 3: API Integration
- Update
PlaceSerializerto includedisplay_config - Update list/place views to pass theme context
- Add bulk resolution for list endpoints
Phase 4: Admin
- Add admin for
DisplayTierOverride - Add inline to Theme admin
- Update ListPlace admin with tier field
Phase 5: Management Tools
- Create
assign_display_tiersmanagement command - Add data validation/audit command
Example: Lynnwood Configuration
# Create tiers for Lynnwood theme
lynnwood_theme = Theme.objects.get(slug='lynnwood')
# Default (non-member) - most restrictive
default_tier = DisplayTierOverride.objects.create(
theme=lynnwood_theme,
name='Default',
slug='default',
priority=0,
# No match criteria - this is the fallback
show_name=True,
show_address=True,
show_phone=True,
show_hours=False,
show_categories=True,
description_limit=0,
vibes_limit=0,
photos_limit=0,
show_website=False,
show_social_media=False,
map_marker_style='none',
show_in_details_panel=True,
show_in_grid=True,
show_in_search=True,
show_minimap=True,
show_similar_places=False,
show_venue_events=False,
show_action_pills=False,
)
# Member tier
member_tier = DisplayTierOverride.objects.create(
theme=lynnwood_theme,
name='Member',
slug='member',
priority=10,
parent_tier=default_tier,
match_tags=['member'],
# Override from default:
show_hours=True,
description_limit=150,
vibes_limit=3,
photos_limit=1,
show_website=True,
map_marker_style='basic',
show_action_pills=True,
card_badge_text='Member',
card_badge_color='#4CAF50',
)
# Member Plus tier
member_plus_tier = DisplayTierOverride.objects.create(
theme=lynnwood_theme,
name='Member Plus',
slug='member-plus',
priority=20,
parent_tier=member_tier,
match_tags=['member-plus'],
# Override from member:
description_limit=-1,
vibes_limit=8,
photos_limit=6,
show_social_media=True,
map_marker_style='image',
card_badge_text='Member Plus',
card_badge_color='#2196F3',
)
# Premium tier
premium_tier = DisplayTierOverride.objects.create(
theme=lynnwood_theme,
name='Premium',
slug='member-premium',
priority=30,
parent_tier=member_plus_tier,
match_tags=['member-premium'],
# Override from member-plus:
vibes_limit=-1,
photos_limit=-1,
show_links=True,
show_aggregate_rating=True,
show_similar_places=True,
show_venue_events=True,
card_badge_text='Premium',
card_badge_color='#FFD700',
)
Future Considerations
Time-Based Tiers
Could add valid_from / valid_until fields for promotional periods.
A/B Testing
Could add variant support for testing different configurations.
Analytics
Track which tier configs drive engagement to optimize defaults.
Frontend Caching
Config resolution could be cached per theme+tier combo for performance.