mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-24 13:31:09 -05:00
Add secret management guide, client-side performance monitoring, and search accessibility enhancements
- Introduced a comprehensive Secret Management Guide detailing best practices, secret classification, development setup, production management, rotation procedures, and emergency protocols. - Implemented a client-side performance monitoring script to track various metrics including page load performance, paint metrics, layout shifts, and memory usage. - Enhanced search accessibility with keyboard navigation support for search results, ensuring compliance with WCAG standards and improving user experience.
This commit is contained in:
57
docs/architecture/README.md
Normal file
57
docs/architecture/README.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Architecture Decision Records (ADRs)
|
||||
|
||||
This directory contains Architecture Decision Records (ADRs) documenting the key architectural decisions made in the ThrillWiki project.
|
||||
|
||||
## What is an ADR?
|
||||
|
||||
An Architecture Decision Record is a document that captures an important architectural decision made along with its context and consequences.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Future Work](../FUTURE_WORK.md) - Deferred features and implementation plans
|
||||
|
||||
## ADR Index
|
||||
|
||||
| ADR | Title | Status | Date |
|
||||
|-----|-------|--------|------|
|
||||
| [ADR-001](./adr-001-django-htmx-architecture.md) | Django + HTMX Architecture | Accepted | 2025-01 |
|
||||
| [ADR-002](./adr-002-hybrid-api-design.md) | Hybrid API Design Pattern | Accepted | 2025-01 |
|
||||
| [ADR-003](./adr-003-state-machine-pattern.md) | State Machine Pattern | Accepted | 2025-01 |
|
||||
| [ADR-004](./adr-004-caching-strategy.md) | Caching Strategy | Accepted | 2025-01 |
|
||||
| [ADR-005](./adr-005-authentication-approach.md) | Authentication Approach | Accepted | 2025-01 |
|
||||
| [ADR-006](./adr-006-media-handling-cloudflare.md) | Media Handling with Cloudflare | Accepted | 2025-01 |
|
||||
|
||||
## ADR Template
|
||||
|
||||
New ADRs should follow this template:
|
||||
|
||||
```markdown
|
||||
# ADR-XXX: Title
|
||||
|
||||
## Status
|
||||
|
||||
[Proposed | Accepted | Deprecated | Superseded]
|
||||
|
||||
## Context
|
||||
|
||||
What is the issue that we're seeing that is motivating this decision or change?
|
||||
|
||||
## Decision
|
||||
|
||||
What is the change that we're proposing and/or doing?
|
||||
|
||||
## Consequences
|
||||
|
||||
What becomes easier or more difficult to do because of this change?
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
What other options were considered and why were they rejected?
|
||||
```
|
||||
|
||||
## Decision Status
|
||||
|
||||
- **Proposed**: The decision is under discussion
|
||||
- **Accepted**: The decision has been accepted and implemented
|
||||
- **Deprecated**: The decision is no longer relevant
|
||||
- **Superseded**: The decision has been replaced by a newer ADR
|
||||
108
docs/architecture/adr-001-django-htmx-architecture.md
Normal file
108
docs/architecture/adr-001-django-htmx-architecture.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# ADR-001: Django + HTMX Architecture
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
ThrillWiki needed to choose a frontend architecture for building an interactive web application. The options considered were:
|
||||
|
||||
1. **Single Page Application (SPA)** with React/Vue.js and a separate API backend
|
||||
2. **Django monolith with HTMX** for server-driven interactivity
|
||||
3. **Traditional Multi-Page Application (MPA)** with full page reloads
|
||||
|
||||
The team needed an architecture that would:
|
||||
- Minimize development complexity
|
||||
- Provide good SEO out of the box
|
||||
- Enable fast initial page loads
|
||||
- Support dynamic interactions without full page reloads
|
||||
- Be maintainable by a small team
|
||||
|
||||
## Decision
|
||||
|
||||
We chose to build ThrillWiki as a **Django monolith with HTMX** for dynamic interactivity, supplemented by minimal Alpine.js for client-side UI state.
|
||||
|
||||
### Key Components
|
||||
|
||||
1. **Django Templates**: Server-side rendering for all pages
|
||||
2. **HTMX**: Dynamic partial updates without full page reloads
|
||||
3. **Alpine.js**: Minimal client-side state (form validation, UI toggles)
|
||||
4. **Tailwind CSS**: Utility-first styling
|
||||
5. **REST API**: Available for programmatic access (mobile apps, integrations)
|
||||
|
||||
### Architecture Pattern
|
||||
|
||||
```
|
||||
Browser Request
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ Django │
|
||||
│ Views │
|
||||
└─────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
│ HTMX Request? │
|
||||
│ ├── Yes: Render partial │
|
||||
│ └── No: Render full page │
|
||||
└─────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ Response │
|
||||
│ (HTML) │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Benefits
|
||||
|
||||
1. **Reduced Complexity**: Single codebase, no separate frontend build process
|
||||
2. **SEO-Friendly**: Server-rendered HTML by default
|
||||
3. **Fast Initial Load**: No JavaScript bundle to download before content appears
|
||||
4. **Progressive Enhancement**: Works without JavaScript, enhanced with HTMX
|
||||
5. **Easier Debugging**: Server logs show all application state
|
||||
6. **Simpler Deployment**: Single Django container
|
||||
7. **Django Ecosystem**: Full access to Django's batteries-included features
|
||||
|
||||
### Trade-offs
|
||||
|
||||
1. **Learning Curve**: Developers need to learn HTMX patterns
|
||||
2. **Limited Offline Support**: No client-side data caching
|
||||
3. **Network Dependency**: Every interaction requires a server round-trip
|
||||
4. **Complex Client State**: Harder to manage complex client-side state (mitigated by Alpine.js)
|
||||
|
||||
### HTMX Patterns Adopted
|
||||
|
||||
1. **Partial Templates**: Views return partial HTML for HTMX requests
|
||||
2. **HX-Trigger Events**: Cross-component communication via custom events
|
||||
3. **Loading Indicators**: Skeleton loaders shown during requests
|
||||
4. **Field Validation**: Real-time form validation via HTMX
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### React/Vue.js SPA
|
||||
|
||||
**Rejected because:**
|
||||
- Increased development complexity with separate codebases
|
||||
- SEO requires server-side rendering setup (Next.js, Nuxt.js)
|
||||
- Larger bundle sizes for initial load
|
||||
- More complex deployment with API + frontend containers
|
||||
- Overkill for this application's interactivity needs
|
||||
|
||||
### Traditional MPA
|
||||
|
||||
**Rejected because:**
|
||||
- Poor user experience with full page reloads
|
||||
- Higher server load for every interaction
|
||||
- Slower perceived performance
|
||||
|
||||
## References
|
||||
|
||||
- [HTMX Documentation](https://htmx.org/docs/)
|
||||
- [Django + HTMX Guide](https://htmx.org/essays/hypermedia-applications/)
|
||||
- [Alpine.js Documentation](https://alpinejs.dev/)
|
||||
- [ThrillWiki HTMX Patterns](../htmx-patterns.md)
|
||||
160
docs/architecture/adr-002-hybrid-api-design.md
Normal file
160
docs/architecture/adr-002-hybrid-api-design.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# ADR-002: Hybrid API Design Pattern
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
ThrillWiki serves two types of clients:
|
||||
|
||||
1. **Web browsers**: Need HTML responses for rendering pages
|
||||
2. **API clients**: Need JSON responses for mobile apps and integrations
|
||||
|
||||
We needed to decide how to handle these different client types efficiently without duplicating business logic.
|
||||
|
||||
## Decision
|
||||
|
||||
We implemented a **Hybrid API Design Pattern** where views can serve both HTML and JSON responses based on content negotiation.
|
||||
|
||||
### Implementation Strategy
|
||||
|
||||
1. **API-first approach**: All business logic is implemented in serializers and services
|
||||
2. **Content negotiation**: Views check request headers to determine response format
|
||||
3. **Dual endpoints**: Some resources are available at both web and API URLs
|
||||
4. **Shared serializers**: Same serializers used for both API responses and template context
|
||||
|
||||
### URL Structure
|
||||
|
||||
```
|
||||
# Web URLs (HTML responses)
|
||||
/parks/ # Park list page
|
||||
/parks/cedar-point/ # Park detail page
|
||||
|
||||
# API URLs (JSON responses)
|
||||
/api/v1/parks/ # Park list (JSON)
|
||||
/api/v1/parks/cedar-point/ # Park detail (JSON)
|
||||
```
|
||||
|
||||
### View Implementation
|
||||
|
||||
```python
|
||||
class HybridViewMixin:
|
||||
"""
|
||||
Mixin that enables views to serve both HTML and JSON responses.
|
||||
"""
|
||||
serializer_class = None
|
||||
|
||||
def get_response_format(self, request):
|
||||
"""Determine response format from Accept header or query param."""
|
||||
if request.htmx:
|
||||
return 'html'
|
||||
if 'application/json' in request.headers.get('Accept', ''):
|
||||
return 'json'
|
||||
if request.GET.get('format') == 'json':
|
||||
return 'json'
|
||||
return 'html'
|
||||
|
||||
def render_response(self, request, context, **kwargs):
|
||||
"""Render appropriate response based on format."""
|
||||
format = self.get_response_format(request)
|
||||
if format == 'json':
|
||||
serializer = self.serializer_class(context['object'])
|
||||
return JsonResponse(serializer.data)
|
||||
return super().render_to_response(context, **kwargs)
|
||||
```
|
||||
|
||||
### Serializer Patterns
|
||||
|
||||
```python
|
||||
# API serializers use camelCase for JSON responses
|
||||
class ParkSerializer(serializers.ModelSerializer):
|
||||
operatorName = serializers.CharField(source='operator.name')
|
||||
rideCount = serializers.IntegerField(source='ride_count')
|
||||
|
||||
class Meta:
|
||||
model = Park
|
||||
fields = ['id', 'name', 'slug', 'operatorName', 'rideCount']
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Benefits
|
||||
|
||||
1. **Code Reuse**: Single set of business logic serves both web and API
|
||||
2. **Consistency**: API and web views always return consistent data
|
||||
3. **Flexibility**: Easy to add new response formats
|
||||
4. **Progressive Enhancement**: API available without duplicating work
|
||||
5. **Mobile-Ready**: API ready for mobile app development
|
||||
|
||||
### Trade-offs
|
||||
|
||||
1. **Complexity**: Views need to handle multiple response formats
|
||||
2. **Testing**: Need to test both HTML and JSON responses
|
||||
3. **Documentation**: Must document both web and API interfaces
|
||||
|
||||
### Response Format Decision
|
||||
|
||||
| Request Type | Response Format |
|
||||
|-------------|-----------------|
|
||||
| HTMX request | HTML partial |
|
||||
| Browser (Accept: text/html) | Full HTML page |
|
||||
| API (Accept: application/json) | JSON |
|
||||
| Query param (?format=json) | JSON |
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Separate API and Web Views
|
||||
|
||||
**Rejected because:**
|
||||
- Duplicate business logic
|
||||
- Risk of divergence between API and web
|
||||
- More code to maintain
|
||||
|
||||
### API-Only with JavaScript Frontend
|
||||
|
||||
**Rejected because:**
|
||||
- Conflicts with ADR-001 (Django + HTMX architecture)
|
||||
- Poor SEO without SSR
|
||||
- Increased complexity
|
||||
|
||||
### GraphQL
|
||||
|
||||
**Rejected because:**
|
||||
- Overkill for current requirements
|
||||
- Steeper learning curve
|
||||
- Less mature Django ecosystem support
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Hybrid Loader Services
|
||||
|
||||
For complex data loading scenarios, we use hybrid loader services:
|
||||
|
||||
```python
|
||||
# backend/apps/parks/services/hybrid_loader.py
|
||||
class ParkHybridLoader:
|
||||
"""
|
||||
Loads park data optimized for both API and web contexts.
|
||||
"""
|
||||
def load_park_detail(self, slug, context='web'):
|
||||
park = Park.objects.optimized_for_detail().get(slug=slug)
|
||||
if context == 'api':
|
||||
return ParkSerializer(park).data
|
||||
return {'park': park}
|
||||
```
|
||||
|
||||
### API Versioning
|
||||
|
||||
API endpoints are versioned to allow breaking changes:
|
||||
|
||||
```
|
||||
/api/v1/parks/ # Current version
|
||||
/api/v2/parks/ # Future version (if needed)
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [Django REST Framework](https://www.django-rest-framework.org/)
|
||||
- [Content Negotiation](https://www.django-rest-framework.org/api-guide/content-negotiation/)
|
||||
- [ThrillWiki API Documentation](../THRILLWIKI_API_DOCUMENTATION.md)
|
||||
186
docs/architecture/adr-003-state-machine-pattern.md
Normal file
186
docs/architecture/adr-003-state-machine-pattern.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# ADR-003: State Machine Pattern
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Parks and rides in ThrillWiki go through various operational states:
|
||||
- Parks: Operating, Closed Temporarily, Closed Permanently, Under Construction
|
||||
- Rides: Operating, Closed, Under Construction, Removed, Relocated
|
||||
|
||||
Managing these state transitions requires:
|
||||
- Valid state transition enforcement
|
||||
- Audit trail of state changes
|
||||
- Business logic tied to state changes (notifications, cache invalidation)
|
||||
|
||||
## Decision
|
||||
|
||||
We implemented a **Finite State Machine (FSM) pattern** for managing entity states, using django-fsm with custom enhancements.
|
||||
|
||||
### State Model
|
||||
|
||||
```python
|
||||
from django_fsm import FSMField, transition
|
||||
|
||||
class Park(models.Model):
|
||||
status = FSMField(default='OPERATING')
|
||||
|
||||
@transition(
|
||||
field=status,
|
||||
source=['OPERATING', 'CLOSED_TEMP'],
|
||||
target='CLOSED_PERM'
|
||||
)
|
||||
def close_permanently(self, reason=None):
|
||||
"""Close the park permanently."""
|
||||
self.closure_reason = reason
|
||||
self.closure_date = timezone.now()
|
||||
|
||||
@transition(
|
||||
field=status,
|
||||
source='CLOSED_TEMP',
|
||||
target='OPERATING'
|
||||
)
|
||||
def reopen(self):
|
||||
"""Reopen a temporarily closed park."""
|
||||
self.closure_reason = None
|
||||
```
|
||||
|
||||
### State Diagram
|
||||
|
||||
```
|
||||
Park States:
|
||||
┌──────────────┐
|
||||
│ PLANNED │
|
||||
└──────┬───────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ UNDER_CONSTRUCTION │
|
||||
└──────────────────┬───────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ OPERATING │◄────┐
|
||||
└──────────────────┬───────────────────┘ │
|
||||
│ │
|
||||
┌───────────┼───────────┐ │
|
||||
│ │ │ │
|
||||
▼ ▼ ▼ │
|
||||
┌────────────┐ ┌────────┐ ┌────────────┐ │
|
||||
│CLOSED_TEMP │ │SEASONAL│ │CLOSED_PERM │ │
|
||||
└─────┬──────┘ └────────┘ └────────────┘ │
|
||||
│ │
|
||||
└───────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Transition Validation
|
||||
|
||||
```python
|
||||
class ParkStateTransition(models.Model):
|
||||
"""Audit log for park state transitions."""
|
||||
park = models.ForeignKey(Park, on_delete=models.CASCADE)
|
||||
from_state = models.CharField(max_length=20)
|
||||
to_state = models.CharField(max_length=20)
|
||||
transition_date = models.DateTimeField(auto_now_add=True)
|
||||
transitioned_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
|
||||
reason = models.TextField(blank=True)
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Benefits
|
||||
|
||||
1. **Valid Transitions Only**: Invalid state changes are rejected at the model level
|
||||
2. **Audit Trail**: All transitions are logged with timestamps and users
|
||||
3. **Business Logic Encapsulation**: Transition methods contain related logic
|
||||
4. **Testability**: State machines are easy to unit test
|
||||
5. **Documentation**: State diagrams document valid workflows
|
||||
|
||||
### Trade-offs
|
||||
|
||||
1. **Learning Curve**: Developers need to understand FSM concepts
|
||||
2. **Migration Complexity**: Adding new states requires careful migration
|
||||
3. **Flexibility**: Rigid state transitions can be limiting for edge cases
|
||||
|
||||
### State Change Hooks
|
||||
|
||||
```python
|
||||
from django.db.models.signals import pre_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
@receiver(pre_save, sender=Park)
|
||||
def park_state_change(sender, instance, **kwargs):
|
||||
if instance.pk:
|
||||
old_instance = Park.objects.get(pk=instance.pk)
|
||||
if old_instance.status != instance.status:
|
||||
# Log transition
|
||||
ParkStateTransition.objects.create(
|
||||
park=instance,
|
||||
from_state=old_instance.status,
|
||||
to_state=instance.status,
|
||||
)
|
||||
# Invalidate caches
|
||||
invalidate_park_caches(instance)
|
||||
# Send notifications
|
||||
notify_state_change(instance, old_instance.status)
|
||||
```
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Simple Status Field
|
||||
|
||||
**Rejected because:**
|
||||
- No validation of state transitions
|
||||
- Business logic scattered across codebase
|
||||
- No built-in audit trail
|
||||
|
||||
### Event Sourcing
|
||||
|
||||
**Rejected because:**
|
||||
- Overkill for current requirements
|
||||
- Significant complexity increase
|
||||
- Steeper learning curve
|
||||
|
||||
### Workflow Engine
|
||||
|
||||
**Rejected because:**
|
||||
- External dependency overhead
|
||||
- More complex than needed
|
||||
- FSM sufficient for current use cases
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Ride Status States
|
||||
|
||||
```python
|
||||
class RideStatus(models.TextChoices):
|
||||
OPERATING = 'OPERATING', 'Operating'
|
||||
CLOSED_TEMP = 'CLOSED_TEMP', 'Temporarily Closed'
|
||||
CLOSED_PERM = 'CLOSED_PERM', 'Permanently Closed'
|
||||
UNDER_CONSTRUCTION = 'UNDER_CONSTRUCTION', 'Under Construction'
|
||||
REMOVED = 'REMOVED', 'Removed'
|
||||
RELOCATED = 'RELOCATED', 'Relocated'
|
||||
```
|
||||
|
||||
### Testing State Transitions
|
||||
|
||||
```python
|
||||
class ParkStateTransitionTest(TestCase):
|
||||
def test_cannot_reopen_permanently_closed_park(self):
|
||||
park = ParkFactory(status='CLOSED_PERM')
|
||||
with self.assertRaises(TransitionNotAllowed):
|
||||
park.reopen()
|
||||
|
||||
def test_can_close_operating_park_temporarily(self):
|
||||
park = ParkFactory(status='OPERATING')
|
||||
park.close_temporarily(reason='Maintenance')
|
||||
self.assertEqual(park.status, 'CLOSED_TEMP')
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [django-fsm Documentation](https://github.com/viewflow/django-fsm)
|
||||
- [State Machine Diagrams](../state_machines/diagrams.md)
|
||||
- [Finite State Machine Wikipedia](https://en.wikipedia.org/wiki/Finite-state_machine)
|
||||
222
docs/architecture/adr-004-caching-strategy.md
Normal file
222
docs/architecture/adr-004-caching-strategy.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# ADR-004: Caching Strategy
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
ThrillWiki serves data that is:
|
||||
- Read-heavy (browsing parks and rides)
|
||||
- Moderately updated (user contributions, moderation)
|
||||
- Geographically queried (map views, location searches)
|
||||
|
||||
We needed a caching strategy that would:
|
||||
- Reduce database load for common queries
|
||||
- Provide fast response times for users
|
||||
- Handle cache invalidation correctly
|
||||
- Support different caching needs (sessions, API, geographic)
|
||||
|
||||
## Decision
|
||||
|
||||
We implemented a **Multi-Layer Caching Strategy** using Redis with multiple cache backends for different purposes.
|
||||
|
||||
### Cache Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Application │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Default Cache │ │ Session Cache │ │ API Cache │
|
||||
│ (General data) │ │ (User sessions)│ │ (API responses)│
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│ │ │
|
||||
└────────────────┼────────────────┘
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Redis │
|
||||
│ (with pools) │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
### Cache Configuration
|
||||
|
||||
```python
|
||||
# backend/config/django/production.py
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": redis_url,
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
"PARSER_CLASS": "redis.connection.HiredisParser",
|
||||
"COMPRESSOR": "django_redis.compressors.zlib.ZlibCompressor",
|
||||
"CONNECTION_POOL_CLASS_KWARGS": {
|
||||
"max_connections": 100,
|
||||
"timeout": 20,
|
||||
},
|
||||
},
|
||||
"KEY_PREFIX": "thrillwiki",
|
||||
},
|
||||
"sessions": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": redis_sessions_url,
|
||||
"KEY_PREFIX": "sessions",
|
||||
},
|
||||
"api": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": redis_api_url,
|
||||
"OPTIONS": {
|
||||
"COMPRESSOR": "django_redis.compressors.zlib.ZlibCompressor",
|
||||
},
|
||||
"KEY_PREFIX": "api",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Caching Layers
|
||||
|
||||
| Layer | Purpose | TTL | Invalidation |
|
||||
|-------|---------|-----|--------------|
|
||||
| QuerySet | Expensive database queries | 1 hour | On model save |
|
||||
| API Response | Serialized API responses | 30 min | On data change |
|
||||
| Geographic | Map data and location queries | 30 min | On location update |
|
||||
| Template Fragment | Rendered template parts | 15 min | On context change |
|
||||
|
||||
## Consequences
|
||||
|
||||
### Benefits
|
||||
|
||||
1. **Reduced Database Load**: Common queries served from cache
|
||||
2. **Fast Response Times**: Sub-millisecond cache hits
|
||||
3. **Scalability**: Cache can be distributed across Redis cluster
|
||||
4. **Flexibility**: Different TTLs for different data types
|
||||
5. **Compression**: Reduced memory usage with zlib compression
|
||||
|
||||
### Trade-offs
|
||||
|
||||
1. **Cache Invalidation**: Must carefully invalidate on data changes
|
||||
2. **Memory Usage**: Redis memory must be monitored
|
||||
3. **Consistency**: Potential for stale data during TTL window
|
||||
4. **Complexity**: Multiple cache backends to manage
|
||||
|
||||
### Cache Key Naming Convention
|
||||
|
||||
```
|
||||
{prefix}:{entity_type}:{identifier}:{context}
|
||||
|
||||
Examples:
|
||||
thrillwiki:park:123:detail
|
||||
thrillwiki:park:123:rides
|
||||
api:parks:list:page1:filter_operating
|
||||
geo:bounds:40.7:-74.0:41.0:-73.5:z10
|
||||
```
|
||||
|
||||
### Cache Invalidation Patterns
|
||||
|
||||
```python
|
||||
# Model signal for cache invalidation
|
||||
@receiver(post_save, sender=Park)
|
||||
def invalidate_park_cache(sender, instance, **kwargs):
|
||||
cache_service = EnhancedCacheService()
|
||||
|
||||
# Invalidate specific park cache
|
||||
cache_service.invalidate_model_cache('park', instance.id)
|
||||
|
||||
# Invalidate list caches
|
||||
cache_service.invalidate_pattern('api:parks:list:*')
|
||||
|
||||
# Invalidate geographic caches if location changed
|
||||
if instance.location_changed:
|
||||
cache_service.invalidate_pattern('geo:*')
|
||||
```
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Database-Only (No Caching)
|
||||
|
||||
**Rejected because:**
|
||||
- High database load for read-heavy traffic
|
||||
- Slower response times
|
||||
- Database as bottleneck for scaling
|
||||
|
||||
### Memcached
|
||||
|
||||
**Rejected because:**
|
||||
- Less feature-rich than Redis
|
||||
- No data persistence
|
||||
- No built-in data structures
|
||||
|
||||
### Application-Level Caching Only
|
||||
|
||||
**Rejected because:**
|
||||
- Not shared across application instances
|
||||
- Memory per-instance overhead
|
||||
- Cache cold on restart
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### EnhancedCacheService
|
||||
|
||||
```python
|
||||
# backend/apps/core/services/enhanced_cache_service.py
|
||||
class EnhancedCacheService:
|
||||
"""Comprehensive caching service with multiple cache backends."""
|
||||
|
||||
def cache_queryset(self, cache_key, queryset_func, timeout=3600, **kwargs):
|
||||
"""Cache expensive querysets with logging."""
|
||||
cached = self.default_cache.get(cache_key)
|
||||
if cached is None:
|
||||
result = queryset_func(**kwargs)
|
||||
self.default_cache.set(cache_key, result, timeout)
|
||||
return result
|
||||
return cached
|
||||
|
||||
def invalidate_pattern(self, pattern):
|
||||
"""Invalidate cache keys matching pattern."""
|
||||
if hasattr(self.default_cache, 'delete_pattern'):
|
||||
return self.default_cache.delete_pattern(pattern)
|
||||
```
|
||||
|
||||
### Cache Warming
|
||||
|
||||
```python
|
||||
# Proactive cache warming for common queries
|
||||
class CacheWarmer:
|
||||
"""Context manager for batch cache warming."""
|
||||
|
||||
def warm_popular_parks(self):
|
||||
parks = Park.objects.operating()[:100]
|
||||
for park in parks:
|
||||
self.cache_service.warm_cache(
|
||||
f'park:{park.id}:detail',
|
||||
lambda: ParkSerializer(park).data,
|
||||
timeout=3600
|
||||
)
|
||||
```
|
||||
|
||||
### Cache Monitoring
|
||||
|
||||
```python
|
||||
class CacheMonitor:
|
||||
"""Monitor cache performance and statistics."""
|
||||
|
||||
def get_cache_stats(self):
|
||||
redis_client = self.cache_service.default_cache._cache.get_client()
|
||||
info = redis_client.info()
|
||||
hits = info.get('keyspace_hits', 0)
|
||||
misses = info.get('keyspace_misses', 0)
|
||||
return {
|
||||
'used_memory': info.get('used_memory_human'),
|
||||
'hit_rate': hits / (hits + misses) * 100 if hits + misses > 0 else 0,
|
||||
}
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [Django Redis](https://github.com/jazzband/django-redis)
|
||||
- [Redis Documentation](https://redis.io/documentation)
|
||||
- [Cache Invalidation Strategies](https://en.wikipedia.org/wiki/Cache_invalidation)
|
||||
216
docs/architecture/adr-005-authentication-approach.md
Normal file
216
docs/architecture/adr-005-authentication-approach.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# ADR-005: Authentication Approach
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
ThrillWiki needs to authenticate users for:
|
||||
- Web browsing (session-based)
|
||||
- API access (token-based)
|
||||
- Social login (Google, Discord)
|
||||
|
||||
We needed an authentication approach that would:
|
||||
- Support multiple authentication methods
|
||||
- Provide secure token handling for API
|
||||
- Enable social authentication
|
||||
- Work seamlessly with Django + HTMX architecture
|
||||
|
||||
## Decision
|
||||
|
||||
We implemented a **Hybrid Authentication System** using django-allauth for social auth and djangorestframework-simplejwt for API tokens.
|
||||
|
||||
### Authentication Methods
|
||||
|
||||
| Context | Method | Library |
|
||||
|---------|--------|---------|
|
||||
| Web browsing | Session-based | Django sessions |
|
||||
| API access | JWT tokens | djangorestframework-simplejwt |
|
||||
| Social login | OAuth2 | django-allauth |
|
||||
| Password reset | Email tokens | Django built-in |
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ User Request │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌───────────────┼───────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Web Request │ │ API Request │ │ Social Auth │
|
||||
│ │ │ │ │ │
|
||||
│ Session Cookie │ │ Bearer Token │ │ OAuth Flow │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│ │ │
|
||||
└───────────────┼───────────────┘
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Django User │
|
||||
│ (Authenticated) │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
### JWT Token Configuration
|
||||
|
||||
```python
|
||||
# backend/config/settings/rest_framework.py
|
||||
SIMPLE_JWT = {
|
||||
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15),
|
||||
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
|
||||
'ROTATE_REFRESH_TOKENS': True,
|
||||
'BLACKLIST_AFTER_ROTATION': True,
|
||||
'ALGORITHM': 'HS256',
|
||||
'SIGNING_KEY': SECRET_KEY,
|
||||
'AUTH_HEADER_TYPES': ('Bearer',),
|
||||
}
|
||||
```
|
||||
|
||||
### Social Authentication
|
||||
|
||||
```python
|
||||
# backend/config/settings/third_party.py
|
||||
SOCIALACCOUNT_PROVIDERS = {
|
||||
'google': {
|
||||
'SCOPE': ['profile', 'email'],
|
||||
'AUTH_PARAMS': {'access_type': 'online'},
|
||||
},
|
||||
'discord': {
|
||||
'SCOPE': ['identify', 'email'],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Benefits
|
||||
|
||||
1. **Flexibility**: Multiple auth methods for different use cases
|
||||
2. **Security**: JWT with short-lived access tokens
|
||||
3. **User Experience**: Social login reduces friction
|
||||
4. **Standards-Based**: OAuth2 and JWT are industry standards
|
||||
5. **Django Integration**: Seamless with Django's user model
|
||||
|
||||
### Trade-offs
|
||||
|
||||
1. **Complexity**: Multiple auth systems to maintain
|
||||
2. **Token Management**: Must handle token refresh client-side
|
||||
3. **Social Provider Dependency**: Reliance on third-party OAuth providers
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
#### Web Session Authentication
|
||||
|
||||
```
|
||||
1. User visits /login/
|
||||
2. User submits credentials
|
||||
3. Django validates credentials
|
||||
4. Session created, cookie set
|
||||
5. Subsequent requests include session cookie
|
||||
```
|
||||
|
||||
#### API JWT Authentication
|
||||
|
||||
```
|
||||
1. Client POST /api/v1/auth/login/
|
||||
{username, password}
|
||||
2. Server validates, returns tokens
|
||||
{access: "...", refresh: "..."}
|
||||
3. Client includes in requests:
|
||||
Authorization: Bearer <access_token>
|
||||
4. On 401, client refreshes:
|
||||
POST /api/v1/auth/token/refresh/
|
||||
{refresh: "..."}
|
||||
```
|
||||
|
||||
#### Social Authentication
|
||||
|
||||
```
|
||||
1. User clicks "Login with Google"
|
||||
2. Redirect to Google OAuth
|
||||
3. User authorizes application
|
||||
4. Google redirects with auth code
|
||||
5. Server exchanges code for tokens
|
||||
6. Server creates/updates user
|
||||
7. Session created
|
||||
```
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Session-Only Authentication
|
||||
|
||||
**Rejected because:**
|
||||
- Not suitable for mobile apps
|
||||
- Not RESTful for API access
|
||||
- CSRF complexity for API clients
|
||||
|
||||
### JWT-Only Authentication
|
||||
|
||||
**Rejected because:**
|
||||
- More complex for web browsing
|
||||
- Token storage in browser has security concerns
|
||||
- Session logout not immediate
|
||||
|
||||
### OAuth2 Server (Self-Hosted)
|
||||
|
||||
**Rejected because:**
|
||||
- Significant complexity for current needs
|
||||
- django-oauth-toolkit overkill
|
||||
- django-allauth sufficient for social auth
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Permission Classes
|
||||
|
||||
```python
|
||||
# API views use JWT or session authentication
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
],
|
||||
'DEFAULT_PERMISSION_CLASSES': [
|
||||
'rest_framework.permissions.IsAuthenticatedOrReadOnly',
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### Custom User Model
|
||||
|
||||
```python
|
||||
# backend/apps/accounts/models.py
|
||||
class User(AbstractUser):
|
||||
email = models.EmailField(unique=True)
|
||||
display_name = models.CharField(max_length=50, blank=True)
|
||||
avatar_url = models.URLField(blank=True)
|
||||
email_verified = models.BooleanField(default=False)
|
||||
|
||||
USERNAME_FIELD = 'email'
|
||||
REQUIRED_FIELDS = ['username']
|
||||
```
|
||||
|
||||
### Email Verification
|
||||
|
||||
```python
|
||||
# Required before full access
|
||||
ACCOUNT_EMAIL_VERIFICATION = 'mandatory'
|
||||
ACCOUNT_EMAIL_REQUIRED = True
|
||||
ACCOUNT_AUTHENTICATION_METHOD = 'email'
|
||||
```
|
||||
|
||||
### Security Measures
|
||||
|
||||
1. **Password Hashing**: Django's PBKDF2 with SHA256
|
||||
2. **Token Blacklisting**: Invalidated refresh tokens stored
|
||||
3. **Rate Limiting**: Login attempts limited
|
||||
4. **HTTPS Required**: Tokens only sent over secure connections
|
||||
|
||||
## References
|
||||
|
||||
- [django-allauth Documentation](https://django-allauth.readthedocs.io/)
|
||||
- [djangorestframework-simplejwt](https://django-rest-framework-simplejwt.readthedocs.io/)
|
||||
- [OAuth 2.0 Specification](https://oauth.net/2/)
|
||||
- [JWT Best Practices](https://auth0.com/blog/jwt-security-best-practices/)
|
||||
226
docs/architecture/adr-006-media-handling-cloudflare.md
Normal file
226
docs/architecture/adr-006-media-handling-cloudflare.md
Normal file
@@ -0,0 +1,226 @@
|
||||
# ADR-006: Media Handling with Cloudflare Images
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
ThrillWiki handles user-uploaded images for:
|
||||
- Park photos
|
||||
- Ride photos
|
||||
- User avatars
|
||||
|
||||
We needed a media handling solution that would:
|
||||
- Handle image uploads efficiently
|
||||
- Optimize images for different devices
|
||||
- Provide CDN delivery globally
|
||||
- Support image transformations (resizing, cropping)
|
||||
- Minimize storage costs
|
||||
|
||||
## Decision
|
||||
|
||||
We chose **Cloudflare Images** as our image hosting and transformation service.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ User Upload │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Django Backend │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ MediaService │ │
|
||||
│ │ - Validate upload │ │
|
||||
│ │ - Generate upload URL │ │
|
||||
│ │ - Store Cloudflare ID │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Cloudflare Images │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ - Image storage │ │
|
||||
│ │ - On-the-fly transformations │ │
|
||||
│ │ - Global CDN delivery │ │
|
||||
│ │ - Multiple variants │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Image Variants
|
||||
|
||||
| Variant | Dimensions | Use Case |
|
||||
|---------|------------|----------|
|
||||
| `thumbnail` | 150x150 | List views, avatars |
|
||||
| `card` | 400x300 | Card components |
|
||||
| `hero` | 1200x600 | Park/ride headers |
|
||||
| `public` | Original (max 2000px) | Full size view |
|
||||
|
||||
### Configuration
|
||||
|
||||
```python
|
||||
# Environment variables
|
||||
CLOUDFLARE_IMAGES_ACCOUNT_ID = config('CLOUDFLARE_IMAGES_ACCOUNT_ID')
|
||||
CLOUDFLARE_IMAGES_API_TOKEN = config('CLOUDFLARE_IMAGES_API_TOKEN')
|
||||
CLOUDFLARE_IMAGES_ACCOUNT_HASH = config('CLOUDFLARE_IMAGES_ACCOUNT_HASH')
|
||||
```
|
||||
|
||||
### Upload Flow
|
||||
|
||||
```python
|
||||
# backend/apps/core/services/media_service.py
|
||||
class CloudflareImagesService:
|
||||
"""Service for handling image uploads to Cloudflare Images."""
|
||||
|
||||
def get_direct_upload_url(self):
|
||||
"""Get a one-time upload URL for direct uploads."""
|
||||
response = self._api_request('POST', 'direct_upload')
|
||||
return {
|
||||
'upload_url': response['uploadURL'],
|
||||
'image_id': response['id'],
|
||||
}
|
||||
|
||||
def get_image_url(self, image_id, variant='public'):
|
||||
"""Get the delivery URL for an image."""
|
||||
return f"https://imagedelivery.net/{self.account_hash}/{image_id}/{variant}"
|
||||
|
||||
def delete_image(self, image_id):
|
||||
"""Delete an image from Cloudflare."""
|
||||
return self._api_request('DELETE', f'images/{image_id}')
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Benefits
|
||||
|
||||
1. **Global CDN**: Fast image delivery worldwide
|
||||
2. **Automatic Optimization**: WebP/AVIF conversion as appropriate
|
||||
3. **On-Demand Transformations**: Resize/crop without pre-generating
|
||||
4. **Cost Effective**: Pay per image stored, not per transformation
|
||||
5. **Reduced Server Load**: Direct uploads bypass our servers
|
||||
6. **Security**: Signed URLs for private content if needed
|
||||
|
||||
### Trade-offs
|
||||
|
||||
1. **Vendor Lock-in**: Cloudflare-specific API
|
||||
2. **External Dependency**: Service availability dependency
|
||||
3. **Cost Scaling**: Costs increase with storage volume
|
||||
4. **Migration Complexity**: Moving away requires re-uploading
|
||||
|
||||
### URL Structure
|
||||
|
||||
```
|
||||
https://imagedelivery.net/{account_hash}/{image_id}/{variant}
|
||||
|
||||
Examples:
|
||||
https://imagedelivery.net/abc123/img-xyz/thumbnail
|
||||
https://imagedelivery.net/abc123/img-xyz/hero
|
||||
https://imagedelivery.net/abc123/img-xyz/public
|
||||
```
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Django + S3 + CloudFront
|
||||
|
||||
**Rejected because:**
|
||||
- Requires pre-generating all sizes
|
||||
- More complex infrastructure
|
||||
- Higher storage costs for variants
|
||||
- Manual CDN configuration
|
||||
|
||||
### Self-Hosted with ImageMagick
|
||||
|
||||
**Rejected because:**
|
||||
- Server CPU overhead for transformations
|
||||
- No built-in CDN
|
||||
- Storage management complexity
|
||||
- Scaling challenges
|
||||
|
||||
### Imgix
|
||||
|
||||
**Rejected because:**
|
||||
- Higher costs for our volume
|
||||
- Already using Cloudflare for other services
|
||||
- Similar feature set to Cloudflare Images
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Photo Model
|
||||
|
||||
```python
|
||||
class Photo(models.Model):
|
||||
"""Base photo model using Cloudflare Images."""
|
||||
|
||||
cloudflare_id = models.CharField(max_length=100, unique=True)
|
||||
original_filename = models.CharField(max_length=255)
|
||||
uploaded_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
|
||||
uploaded_at = models.DateTimeField(auto_now_add=True)
|
||||
is_approved = models.BooleanField(default=False)
|
||||
caption = models.TextField(blank=True)
|
||||
|
||||
@property
|
||||
def thumbnail_url(self):
|
||||
return get_image_url(self.cloudflare_id, 'thumbnail')
|
||||
|
||||
@property
|
||||
def public_url(self):
|
||||
return get_image_url(self.cloudflare_id, 'public')
|
||||
```
|
||||
|
||||
### Direct Upload API
|
||||
|
||||
```python
|
||||
# backend/apps/api/v1/views/upload.py
|
||||
class DirectUploadView(APIView):
|
||||
"""Get a direct upload URL for Cloudflare Images."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
service = CloudflareImagesService()
|
||||
upload_data = service.get_direct_upload_url()
|
||||
return Response({
|
||||
'upload_url': upload_data['upload_url'],
|
||||
'image_id': upload_data['image_id'],
|
||||
})
|
||||
```
|
||||
|
||||
### Cleanup Task
|
||||
|
||||
```python
|
||||
# Celery task for cleaning up orphaned images
|
||||
@shared_task
|
||||
def cleanup_orphaned_images():
|
||||
"""Delete images not referenced by any model."""
|
||||
cutoff = timezone.now() - timedelta(hours=24)
|
||||
orphaned = CloudflareImage.objects.filter(
|
||||
created_at__lt=cutoff,
|
||||
park_photos__isnull=True,
|
||||
ride_photos__isnull=True,
|
||||
user_avatars__isnull=True,
|
||||
)
|
||||
for image in orphaned:
|
||||
service.delete_image(image.cloudflare_id)
|
||||
image.delete()
|
||||
```
|
||||
|
||||
### Fallback Strategy
|
||||
|
||||
```python
|
||||
def get_image_url(image_id, variant='public', fallback='/static/images/placeholder.jpg'):
|
||||
"""Get image URL with fallback for missing images."""
|
||||
if not image_id:
|
||||
return fallback
|
||||
return f"https://imagedelivery.net/{ACCOUNT_HASH}/{image_id}/{variant}"
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [Cloudflare Images Documentation](https://developers.cloudflare.com/images/)
|
||||
- [Direct Creator Upload](https://developers.cloudflare.com/images/cloudflare-images/upload-images/direct-creator-upload/)
|
||||
- [Image Variants](https://developers.cloudflare.com/images/cloudflare-images/transform/flexible-variants/)
|
||||
Reference in New Issue
Block a user