Skip to main content

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:

FeatureNon-MemberMemberMember PlusPremium
Name
Address
Phone
Hours
Description150 charsFullFull
Vibes038Unlimited
Photos016Unlimited
Website
Social Media
Map MarkerNoneBasicImageImage
Similar Places
Venue Events

Data Model

DisplayTierOverride Model

Lives in accounts/models/display_tier.py. Each override belongs to a Theme and specifies:

  1. Matching criteria - which places this tier applies to
  2. 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:

  1. Places return only their tier identifier
  2. Tier configs are fetched once (per theme) and cached client-side
  3. 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

  1. Create accounts/models/display_tier.py with DisplayTierOverride model
  2. Add migration
  3. Add display_tier FK to ListPlace model
  4. Add migration

Phase 2: Resolution Logic

  1. Create accounts/services/display_config.py with DisplayConfigResolver
  2. Add unit tests for resolution logic

Phase 3: API Integration

  1. Update PlaceSerializer to include display_config
  2. Update list/place views to pass theme context
  3. Add bulk resolution for list endpoints

Phase 4: Admin

  1. Add admin for DisplayTierOverride
  2. Add inline to Theme admin
  3. Update ListPlace admin with tier field

Phase 5: Management Tools

  1. Create assign_display_tiers management command
  2. 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.