diff --git a/MONITORING_SETUP.md b/MONITORING_SETUP.md new file mode 100644 index 00000000..671688bc --- /dev/null +++ b/MONITORING_SETUP.md @@ -0,0 +1,266 @@ +# 🎯 Advanced ML Anomaly Detection & Automated Monitoring + +## ✅ What's Now Active + +### 1. Advanced ML Algorithms + +Your anomaly detection now uses **6 sophisticated algorithms**: + +#### Statistical Algorithms +- **Z-Score**: Standard deviation-based outlier detection +- **Moving Average**: Trend deviation detection +- **Rate of Change**: Sudden change detection + +#### Advanced ML Algorithms (NEW!) +- **Isolation Forest**: Anomaly detection based on data point isolation + - Works by measuring how "isolated" a point is from the rest + - Excellent for detecting outliers in multi-dimensional space + +- **Seasonal Decomposition**: Pattern-aware anomaly detection + - Detects anomalies considering daily/weekly patterns + - Configurable period (default: 24 hours) + - Identifies seasonal spikes and drops + +- **Predictive Anomaly (LSTM-inspired)**: Time-series prediction + - Uses triple exponential smoothing (Holt-Winters) + - Predicts next value based on level and trend + - Flags unexpected deviations from predictions + +- **Ensemble Method**: Multi-algorithm consensus + - Combines all 5 algorithms for maximum accuracy + - Requires 40%+ algorithms to agree for anomaly detection + - Provides weighted confidence scores + +### 2. Automated Cron Jobs + +**NOW RUNNING AUTOMATICALLY:** + +| Job | Schedule | Purpose | +|-----|----------|---------| +| `detect-anomalies-every-5-minutes` | Every 5 minutes (`*/5 * * * *`) | Run ML anomaly detection on all metrics | +| `collect-metrics-every-minute` | Every minute (`* * * * *`) | Collect system metrics (errors, queues, API times) | +| `data-retention-cleanup-daily` | Daily at 3 AM (`0 3 * * *`) | Clean up old data to manage DB size | + +### 3. Algorithm Configuration + +Each metric can be configured with different algorithms in the `anomaly_detection_config` table: + +```sql +-- Example: Configure a metric to use all advanced algorithms +UPDATE anomaly_detection_config +SET detection_algorithms = ARRAY['z_score', 'moving_average', 'isolation_forest', 'seasonal', 'predictive', 'ensemble'] +WHERE metric_name = 'api_response_time'; +``` + +**Algorithm Selection Guide:** + +- **z_score**: Best for normally distributed data, general outlier detection +- **moving_average**: Best for trending data, smooth patterns +- **rate_of_change**: Best for detecting sudden spikes/drops +- **isolation_forest**: Best for complex multi-modal distributions +- **seasonal**: Best for cyclic patterns (hourly, daily, weekly) +- **predictive**: Best for time-series with clear trends +- **ensemble**: Best for maximum accuracy, combines all methods + +### 4. Sensitivity Tuning + +**Sensitivity Parameter** (in `anomaly_detection_config`): +- Lower value (1.5-2.0): More sensitive, catches subtle anomalies, more false positives +- Medium value (2.5-3.0): Balanced, recommended default +- Higher value (3.5-5.0): Less sensitive, only major anomalies, fewer false positives + +### 5. Monitoring Dashboard + +View all anomaly detections in the admin panel: +- Navigate to `/admin/monitoring` +- See the "ML Anomaly Detection" panel +- Real-time updates every 30 seconds +- Manual trigger button available + +**Anomaly Details Include:** +- Algorithm used +- Anomaly type (spike, drop, outlier, seasonal, etc.) +- Severity (low, medium, high, critical) +- Deviation score (how far from normal) +- Confidence score (algorithm certainty) +- Baseline vs actual values + +## 🔍 How It Works + +### Data Flow + +``` +1. Metrics Collection (every minute) + ↓ +2. Store in metric_time_series table + ↓ +3. Anomaly Detection (every 5 minutes) + ↓ +4. Run ML algorithms on recent data + ↓ +5. Detect anomalies & calculate scores + ↓ +6. Insert into anomaly_detections table + ↓ +7. Auto-create system alerts (if critical/high) + ↓ +8. Display in admin dashboard + ↓ +9. Data Retention Cleanup (daily 3 AM) +``` + +### Algorithm Comparison + +| Algorithm | Strength | Best For | Time Complexity | +|-----------|----------|----------|-----------------| +| Z-Score | Simple, fast | Normal distributions | O(n) | +| Moving Average | Trend-aware | Gradual changes | O(n) | +| Rate of Change | Change detection | Sudden shifts | O(1) | +| Isolation Forest | Multi-dimensional | Complex patterns | O(n log n) | +| Seasonal | Pattern-aware | Cyclic data | O(n) | +| Predictive | Forecast-based | Time-series | O(n) | +| Ensemble | Highest accuracy | Any pattern | O(n log n) | + +## 📊 Current Metrics Being Monitored + +### Supabase Metrics (collected every minute) +- `api_error_count`: Recent API errors +- `rate_limit_violations`: Rate limit blocks +- `pending_submissions`: Submissions awaiting moderation +- `active_incidents`: Open/investigating incidents +- `unresolved_alerts`: Unresolved system alerts +- `submission_approval_rate`: Approval percentage +- `avg_moderation_time`: Average moderation time + +### Django Metrics (collected every minute, if configured) +- `error_rate`: Error log percentage +- `api_response_time`: Average API response time (ms) +- `celery_queue_size`: Queued Celery tasks +- `database_connections`: Active DB connections +- `cache_hit_rate`: Cache hit percentage + +## 🎛️ Configuration + +### Add New Metrics for Detection + +```sql +INSERT INTO anomaly_detection_config ( + metric_name, + metric_category, + enabled, + sensitivity, + lookback_window_minutes, + detection_algorithms, + min_data_points, + alert_threshold_score, + auto_create_alert +) VALUES ( + 'custom_metric_name', + 'performance', + true, + 2.5, + 60, + ARRAY['ensemble', 'predictive', 'seasonal'], + 10, + 3.0, + true +); +``` + +### Adjust Sensitivity + +```sql +-- Make detection more sensitive for critical metrics +UPDATE anomaly_detection_config +SET sensitivity = 2.0, alert_threshold_score = 2.5 +WHERE metric_name = 'api_error_count'; + +-- Make detection less sensitive for noisy metrics +UPDATE anomaly_detection_config +SET sensitivity = 4.0, alert_threshold_score = 4.0 +WHERE metric_name = 'cache_hit_rate'; +``` + +### Disable Detection for Specific Metrics + +```sql +UPDATE anomaly_detection_config +SET enabled = false +WHERE metric_name = 'some_metric'; +``` + +## 🔧 Troubleshooting + +### Check Cron Job Status + +```sql +SELECT jobid, jobname, schedule, active, last_run_time, last_run_status +FROM cron.job_run_details +WHERE jobname LIKE '%anomal%' OR jobname LIKE '%metric%' +ORDER BY start_time DESC +LIMIT 20; +``` + +### View Recent Anomalies + +```sql +SELECT * FROM recent_anomalies_view +ORDER BY detected_at DESC +LIMIT 20; +``` + +### Check Metric Collection + +```sql +SELECT metric_name, COUNT(*) as count, + MIN(timestamp) as oldest, + MAX(timestamp) as newest +FROM metric_time_series +WHERE timestamp > NOW() - INTERVAL '1 hour' +GROUP BY metric_name +ORDER BY metric_name; +``` + +### Manual Anomaly Detection Trigger + +```sql +-- Call the edge function directly +SELECT net.http_post( + url := 'https://ydvtmnrszybqnbcqbdcy.supabase.co/functions/v1/detect-anomalies', + headers := '{"Content-Type": "application/json", "Authorization": "Bearer YOUR_ANON_KEY"}'::jsonb, + body := '{}'::jsonb +); +``` + +## 📈 Performance Considerations + +### Data Volume +- Metrics: ~1440 records/day per metric (every minute) +- With 12 metrics: ~17,280 records/day +- 30-day retention: ~518,400 records +- Automatic cleanup prevents unbounded growth + +### Detection Performance +- Each detection run processes all enabled metrics +- Ensemble algorithm is most CPU-intensive +- Recommended: Use ensemble only for critical metrics +- Typical detection time: <5 seconds for 12 metrics + +### Database Impact +- Indexes on timestamp columns optimize queries +- Regular cleanup maintains query performance +- Consider partitioning for very high-volume deployments + +## 🚀 Next Steps + +1. **Monitor the Dashboard**: Visit `/admin/monitoring` to see anomalies +2. **Fine-tune Sensitivity**: Adjust based on false positive rate +3. **Add Custom Metrics**: Monitor application-specific KPIs +4. **Set Up Alerts**: Configure notifications for critical anomalies +5. **Review Weekly**: Check patterns and adjust algorithms + +## 📚 Additional Resources + +- [Edge Function Logs](https://supabase.com/dashboard/project/ydvtmnrszybqnbcqbdcy/functions/detect-anomalies/logs) +- [Cron Jobs Dashboard](https://supabase.com/dashboard/project/ydvtmnrszybqnbcqbdcy/sql/new) +- Django README: `django/README_MONITORING.md` diff --git a/RATE_LIMIT_MONITORING_SETUP.md b/RATE_LIMIT_MONITORING_SETUP.md new file mode 100644 index 00000000..af578a71 --- /dev/null +++ b/RATE_LIMIT_MONITORING_SETUP.md @@ -0,0 +1,210 @@ +# Rate Limit Monitoring Setup + +This document explains how to set up automated rate limit monitoring with alerts. + +## Overview + +The rate limit monitoring system consists of: +1. **Metrics Collection** - Tracks all rate limit checks in-memory +2. **Alert Configuration** - Database table with configurable thresholds +3. **Monitor Function** - Edge function that checks metrics and triggers alerts +4. **Cron Job** - Scheduled job that runs the monitor function periodically + +## Setup Instructions + +### Step 1: Enable Required Extensions + +Run this SQL in your Supabase SQL Editor: + +```sql +-- Enable pg_cron for scheduling +CREATE EXTENSION IF NOT EXISTS pg_cron; + +-- Enable pg_net for HTTP requests +CREATE EXTENSION IF NOT EXISTS pg_net; +``` + +### Step 2: Create the Cron Job + +Run this SQL to schedule the monitor to run every 5 minutes: + +```sql +SELECT cron.schedule( + 'monitor-rate-limits', + '*/5 * * * *', -- Every 5 minutes + $$ + SELECT + net.http_post( + url:='https://api.thrillwiki.com/functions/v1/monitor-rate-limits', + headers:='{"Content-Type": "application/json", "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkdnRtbnJzenlicW5iY3FiZGN5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTgzMjYzNTYsImV4cCI6MjA3MzkwMjM1Nn0.DM3oyapd_omP5ZzIlrT0H9qBsiQBxBRgw2tYuqgXKX4"}'::jsonb, + body:='{}'::jsonb + ) as request_id; + $$ +); +``` + +### Step 3: Verify the Cron Job + +Check that the cron job was created: + +```sql +SELECT * FROM cron.job WHERE jobname = 'monitor-rate-limits'; +``` + +### Step 4: Configure Alert Thresholds + +Visit the admin dashboard at `/admin/rate-limit-metrics` and navigate to the "Configuration" tab to: + +- Enable/disable specific alerts +- Adjust threshold values +- Modify time windows + +Default configurations are automatically created: +- **Block Rate Alert**: Triggers when >50% of requests are blocked in 5 minutes +- **Total Requests Alert**: Triggers when >1000 requests/minute +- **Unique IPs Alert**: Triggers when >100 unique IPs in 5 minutes (disabled by default) + +## How It Works + +### 1. Metrics Collection + +Every rate limit check (both allowed and blocked) is recorded with: +- Timestamp +- Function name +- Client IP +- User ID (if authenticated) +- Result (allowed/blocked) +- Remaining quota +- Rate limit tier + +Metrics are stored in-memory for the last 10,000 checks. + +### 2. Monitoring Process + +Every 5 minutes, the monitor function: +1. Fetches enabled alert configurations from the database +2. Analyzes current metrics for each configuration's time window +3. Compares metrics against configured thresholds +4. For exceeded thresholds: + - Records the alert in `rate_limit_alerts` table + - Sends notification to moderators via Novu + - Skips if a recent unresolved alert already exists (prevents spam) + +### 3. Alert Deduplication + +Alerts are deduplicated using a 15-minute window. If an alert for the same configuration was triggered in the last 15 minutes and hasn't been resolved, no new alert is sent. + +### 4. Notifications + +Alerts are sent to all moderators via the "moderators" topic in Novu, including: +- Email notifications +- In-app notifications (if configured) +- Custom notification channels (if configured) + +## Monitoring the Monitor + +### Check Cron Job Status + +```sql +-- View recent cron job runs +SELECT * FROM cron.job_run_details +WHERE jobid = (SELECT jobid FROM cron.job WHERE jobname = 'monitor-rate-limits') +ORDER BY start_time DESC +LIMIT 10; +``` + +### View Function Logs + +Check the edge function logs in Supabase Dashboard: +`https://supabase.com/dashboard/project/ydvtmnrszybqnbcqbdcy/functions/monitor-rate-limits/logs` + +### Test Manually + +You can test the monitor function manually by calling it via HTTP: + +```bash +curl -X POST https://api.thrillwiki.com/functions/v1/monitor-rate-limits \ + -H "Content-Type: application/json" +``` + +## Adjusting the Schedule + +To change how often the monitor runs, update the cron schedule: + +```sql +-- Update to run every 10 minutes instead +SELECT cron.alter_job('monitor-rate-limits', schedule:='*/10 * * * *'); + +-- Update to run every hour +SELECT cron.alter_job('monitor-rate-limits', schedule:='0 * * * *'); + +-- Update to run every minute (not recommended - may generate too many alerts) +SELECT cron.alter_job('monitor-rate-limits', schedule:='* * * * *'); +``` + +## Removing the Cron Job + +If you need to disable monitoring: + +```sql +SELECT cron.unschedule('monitor-rate-limits'); +``` + +## Troubleshooting + +### No Alerts Being Triggered + +1. Check if any alert configurations are enabled: +```sql +SELECT * FROM rate_limit_alert_config WHERE enabled = true; +``` + +2. Check if metrics are being collected: + - Visit `/admin/rate-limit-metrics` and check the "Recent Activity" tab + - If no activity, the rate limiter might not be in use + +3. Check monitor function logs for errors + +### Too Many Alerts + +- Increase threshold values in the configuration +- Increase time windows for less sensitive detection +- Disable specific alert types that are too noisy + +### Monitor Not Running + +1. Verify cron job exists and is active +2. Check `cron.job_run_details` for error messages +3. Verify edge function deployed successfully +4. Check network connectivity between cron scheduler and edge function + +## Database Tables + +### `rate_limit_alert_config` +Stores alert threshold configurations. Only admins can modify. + +### `rate_limit_alerts` +Stores history of all triggered alerts. Moderators can view and resolve. + +## Security + +- Alert configurations can only be modified by admin/superuser roles +- Alert history is only accessible to moderators and above +- The monitor function runs without JWT verification (as a cron job) +- All database operations respect Row Level Security policies + +## Performance Considerations + +- In-memory metrics store max 10,000 entries (auto-trimmed) +- Metrics older than the longest configured time window are not useful +- Monitor function typically runs in <500ms +- No significant database load (simple queries on small tables) + +## Future Enhancements + +Possible improvements: +- Function-specific alert thresholds +- Alert aggregation (daily/weekly summaries) +- Custom notification channels per alert type +- Machine learning-based anomaly detection +- Integration with external monitoring tools (Datadog, New Relic, etc.) diff --git a/django/.env.example b/django/.env.example deleted file mode 100644 index b86ae45a..00000000 --- a/django/.env.example +++ /dev/null @@ -1,35 +0,0 @@ -# Django Settings -DEBUG=True -SECRET_KEY=your-secret-key-here-change-in-production -ALLOWED_HOSTS=localhost,127.0.0.1 - -# Database -DATABASE_URL=postgresql://user:password@localhost:5432/thrillwiki - -# Redis -REDIS_URL=redis://localhost:6379/0 - -# Celery -CELERY_BROKER_URL=redis://localhost:6379/0 -CELERY_RESULT_BACKEND=redis://localhost:6379/1 - -# CloudFlare Images -CLOUDFLARE_ACCOUNT_ID=your-account-id -CLOUDFLARE_IMAGE_TOKEN=your-token -CLOUDFLARE_IMAGE_HASH=your-hash - -# Novu -NOVU_API_KEY=your-novu-api-key -NOVU_API_URL=https://api.novu.co - -# Sentry -SENTRY_DSN=your-sentry-dsn - -# CORS -CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000 - -# OAuth -GOOGLE_CLIENT_ID= -GOOGLE_CLIENT_SECRET= -DISCORD_CLIENT_ID= -DISCORD_CLIENT_SECRET= diff --git a/django/ADMIN_GUIDE.md b/django/ADMIN_GUIDE.md deleted file mode 100644 index adc041b7..00000000 --- a/django/ADMIN_GUIDE.md +++ /dev/null @@ -1,568 +0,0 @@ -# ThrillWiki Admin Interface Guide - -## Overview - -The ThrillWiki admin interface uses **Django Unfold**, a modern, Tailwind CSS-based admin theme that provides a beautiful and intuitive user experience. This guide covers all features of the enhanced admin interface implemented in Phase 2C. - -## Table of Contents - -1. [Features](#features) -2. [Accessing the Admin](#accessing-the-admin) -3. [Dashboard](#dashboard) -4. [Entity Management](#entity-management) -5. [Import/Export](#importexport) -6. [Advanced Filtering](#advanced-filtering) -7. [Bulk Actions](#bulk-actions) -8. [Geographic Features](#geographic-features) -9. [Customization](#customization) - ---- - -## Features - -### ✨ Modern UI/UX -- **Tailwind CSS-based design** - Clean, modern interface -- **Dark mode support** - Automatic theme switching -- **Responsive layout** - Works on desktop, tablet, and mobile -- **Material Design icons** - Intuitive visual elements -- **Custom green color scheme** - Branded appearance - -### 🎯 Enhanced Entity Management -- **Inline editing** - Edit related objects without leaving the page -- **Visual indicators** - Color-coded status badges and icons -- **Smart search** - Search across multiple fields -- **Advanced filters** - Dropdown filters for easy data navigation -- **Autocomplete fields** - Fast foreign key selection - -### 📊 Dashboard Statistics -- Total entity counts (Parks, Rides, Companies, Models) -- Operating vs. total counts -- Recent additions (last 30 days) -- Top manufacturers by ride count -- Parks by type distribution - -### 📥 Import/Export -- **Multiple formats** - CSV, Excel (XLS/XLSX), JSON, YAML -- **Bulk operations** - Import hundreds of records at once -- **Data validation** - Error checking during import -- **Export filtered data** - Export search results - -### 🗺️ Geographic Features -- **Dual-mode support** - Works with both SQLite (lat/lng) and PostGIS -- **Coordinate display** - Visual representation of park locations -- **Map widgets** - Interactive maps for location editing (PostGIS mode) - ---- - -## Accessing the Admin - -### URL -``` -http://localhost:8000/admin/ -``` - -### Creating a Superuser - -If you don't have an admin account yet: - -```bash -cd django -python manage.py createsuperuser -``` - -Follow the prompts to create your admin account. - -### Login - -Navigate to `/admin/` and log in with your superuser credentials. - ---- - -## Dashboard - -The admin dashboard provides an at-a-glance view of your ThrillWiki data: - -### Statistics Displayed - -1. **Entity Counts** - - Total Parks - - Total Rides - - Total Companies - - Total Ride Models - -2. **Operational Status** - - Operating Parks - - Operating Rides - - Total Roller Coasters - -3. **Recent Activity** - - Parks added in last 30 days - - Rides added in last 30 days - -4. **Top Manufacturers** - - List of manufacturers by ride count - -5. **Parks by Type** - - Distribution chart of park types - -### Navigating from Dashboard - -Use the sidebar navigation to access different sections: -- **Dashboard** - Overview and statistics -- **Entities** - Parks, Rides, Companies, Ride Models -- **User Management** - Users and Groups -- **Content** - Media and Moderation - ---- - -## Entity Management - -### Parks Admin - -#### List View Features -- **Visual indicators**: Icon and emoji for park type -- **Location display**: City/Country with coordinates -- **Status badges**: Color-coded operational status -- **Ride counts**: Total rides and coaster count -- **Operator links**: Quick access to operating company - -#### Detail View -- **Geographic Location section**: Latitude/longitude input with coordinate display -- **Operator selection**: Autocomplete field for company selection -- **Inline rides**: View and manage all rides in the park -- **Date precision**: Separate fields for dates and their precision levels -- **Custom data**: JSON field for additional attributes - -#### Bulk Actions -- `export_admin_action` - Export selected parks -- `activate_parks` - Mark parks as operating -- `close_parks` - Mark parks as temporarily closed - -#### Filters -- Park Type (dropdown) -- Status (dropdown) -- Operator (dropdown with search) -- Opening Date (range filter) -- Closing Date (range filter) - ---- - -### Rides Admin - -#### List View Features -- **Category icons**: Visual ride category identification -- **Status badges**: Color-coded operational status -- **Stats display**: Height, Speed, Inversions at a glance -- **Coaster badge**: Special indicator for roller coasters -- **Park link**: Quick navigation to parent park - -#### Detail View -- **Classification section**: Category, Type, Status -- **Manufacturer & Model**: Autocomplete fields with search -- **Ride Statistics**: Height, Speed, Length, Duration, Inversions, Capacity -- **Auto-coaster detection**: Automatically marks roller coasters -- **Custom data**: JSON field for additional attributes - -#### Bulk Actions -- `export_admin_action` - Export selected rides -- `activate_rides` - Mark rides as operating -- `close_rides` - Mark rides as temporarily closed - -#### Filters -- Ride Category (dropdown) -- Status (dropdown) -- Is Coaster (boolean) -- Park (dropdown with search) -- Manufacturer (dropdown with search) -- Opening Date (range) -- Height (numeric range) -- Speed (numeric range) - ---- - -### Companies Admin - -#### List View Features -- **Type icons**: Manufacturer 🏭, Operator 🎡, Designer ✏️ -- **Type badges**: Color-coded company type indicators -- **Entity counts**: Parks and rides associated -- **Status indicator**: Active (green) or Closed (red) -- **Location display**: Primary location - -#### Detail View -- **Company types**: Multi-select for manufacturer, operator, designer -- **History section**: Founded/Closed dates with precision -- **Inline parks**: View all operated parks -- **Statistics**: Cached counts for performance - -#### Bulk Actions -- `export_admin_action` - Export selected companies - -#### Filters -- Company Types (dropdown) -- Founded Date (range) -- Closed Date (range) - ---- - -### Ride Models Admin - -#### List View Features -- **Model type icons**: Visual identification (🎢, 🌊, 🎡, etc.) -- **Manufacturer link**: Quick access to manufacturer -- **Typical specs**: Height, Speed, Capacity summary -- **Installation count**: Number of installations worldwide - -#### Detail View -- **Manufacturer**: Autocomplete field -- **Typical Specifications**: Standard specifications for the model -- **Inline installations**: List of all rides using this model - -#### Bulk Actions -- `export_admin_action` - Export selected ride models - -#### Filters -- Model Type (dropdown) -- Manufacturer (dropdown with search) -- Typical Height (numeric range) -- Typical Speed (numeric range) - ---- - -## Import/Export - -### Exporting Data - -1. Navigate to the entity list view (e.g., Parks) -2. Optionally apply filters to narrow down data -3. Select records to export (or none for all) -4. Choose action: "Export" -5. Select format: CSV, Excel (XLS/XLSX), JSON, YAML, HTML -6. Click "Go" -7. Download the file - -### Importing Data - -1. Navigate to the entity list view -2. Click "Import" button in the top right -3. Choose file format -4. Select your import file -5. Click "Submit" -6. Review import preview -7. Confirm import - -### Import File Format - -#### CSV/Excel Requirements -- First row must be column headers -- Use field names from the model -- For foreign keys, use the related object's name -- Dates in ISO format (YYYY-MM-DD) - -#### Example Company CSV -```csv -name,slug,location,company_types,founded_date,website -Intamin,intamin,"Schaan, Liechtenstein","[""manufacturer""]",1967-01-01,https://intamin.com -Cedar Fair,cedar-fair,"Sandusky, Ohio, USA","[""operator""]",1983-03-01,https://cedarfair.com -``` - -#### Example Park CSV -```csv -name,slug,park_type,status,latitude,longitude,operator,opening_date -Cedar Point,cedar-point,amusement_park,operating,41.4779,-82.6838,Cedar Fair,1870-01-01 -``` - -### Import Error Handling - -If import fails: -1. Review error messages carefully -2. Check data formatting -3. Verify foreign key references exist -4. Ensure required fields are present -5. Fix issues and try again - ---- - -## Advanced Filtering - -### Filter Types - -#### 1. **Dropdown Filters** -- Single selection from predefined choices -- Examples: Park Type, Status, Ride Category - -#### 2. **Related Dropdown Filters** -- Dropdown with search for foreign keys -- Examples: Operator, Manufacturer, Park -- Supports autocomplete - -#### 3. **Range Date Filters** -- Filter by date range -- Includes "From" and "To" fields -- Examples: Opening Date, Closing Date - -#### 4. **Range Numeric Filters** -- Filter by numeric range -- Includes "Min" and "Max" fields -- Examples: Height, Speed, Capacity - -#### 5. **Boolean Filters** -- Yes/No/All options -- Example: Is Coaster - -### Combining Filters - -Filters can be combined for precise queries: - -**Example: Find all operating roller coasters at Cedar Fair parks over 50m tall** -1. Go to Rides admin -2. Set "Ride Category" = Roller Coaster -3. Set "Status" = Operating -4. Set "Park" = (search for Cedar Fair parks) -5. Set "Height Min" = 50 - -### Search vs. Filters - -- **Search**: Text-based search across multiple fields (name, description, etc.) -- **Filters**: Structured filtering by specific attributes -- **Best Practice**: Use filters to narrow down, then search within results - ---- - -## Bulk Actions - -### Available Actions - -#### All Entities -- **Export** - Export selected records to file - -#### Parks -- **Activate Parks** - Set status to "operating" -- **Close Parks** - Set status to "closed_temporarily" - -#### Rides -- **Activate Rides** - Set status to "operating" -- **Close Rides** - Set status to "closed_temporarily" - -### How to Use Bulk Actions - -1. Select records using checkboxes -2. Choose action from dropdown at bottom of list -3. Click "Go" -4. Confirm action if prompted -5. View success message - -### Tips -- Select all on page: Use checkbox in header row -- Select all in query: Click "Select all X items" link -- Bulk actions respect permissions -- Some actions cannot be undone - ---- - -## Geographic Features - -### SQLite Mode (Default for Local Development) - -**Fields Available:** -- `latitude` - Decimal field for latitude (-90 to 90) -- `longitude` - Decimal field for longitude (-180 to 180) -- `location` - Text field for location name - -**Coordinate Display:** -- Read-only field showing current coordinates -- Format: "Longitude: X.XXXXXX, Latitude: Y.YYYYYY" - -**Search:** -- `/api/v1/parks/nearby/` uses bounding box approximation - -### PostGIS Mode (Production) - -**Additional Features:** -- `location_point` - PointField for geographic data -- Interactive map widget in admin -- Accurate distance calculations -- Optimized geographic queries - -**Setting Up PostGIS:** -See `POSTGIS_SETUP.md` for detailed instructions. - -### Entering Coordinates - -1. Find coordinates using Google Maps or similar -2. Enter latitude in "Latitude" field -3. Enter longitude in "Longitude" field -4. Enter location name in "Location" field -5. Coordinates are automatically synced to `location_point` (PostGIS mode) - -**Coordinate Format:** -- Latitude: -90.000000 to 90.000000 -- Longitude: -180.000000 to 180.000000 -- Use negative for South/West - ---- - -## Customization - -### Settings Configuration - -The Unfold configuration is in `config/settings/base.py`: - -```python -UNFOLD = { - "SITE_TITLE": "ThrillWiki Admin", - "SITE_HEADER": "ThrillWiki Administration", - "SITE_SYMBOL": "🎢", - "SHOW_HISTORY": True, - "SHOW_VIEW_ON_SITE": True, - # ... more settings -} -``` - -### Customizable Options - -#### Branding -- `SITE_TITLE` - Browser title -- `SITE_HEADER` - Header text -- `SITE_SYMBOL` - Emoji or icon in header -- `SITE_ICON` - Logo image paths - -#### Colors -- `COLORS["primary"]` - Primary color palette (currently green) -- Supports full Tailwind CSS color specification - -#### Navigation -- `SIDEBAR["navigation"]` - Custom sidebar menu structure -- Can add custom links and sections - -### Adding Custom Dashboard Widgets - -The dashboard callback is in `apps/entities/admin.py`: - -```python -def dashboard_callback(request, context): - """Customize dashboard statistics.""" - # Add your custom statistics here - context.update({ - 'custom_stat': calculate_custom_stat(), - }) - return context -``` - -### Custom Admin Actions - -Add custom actions to admin classes: - -```python -@admin.register(Park) -class ParkAdmin(ModelAdmin): - actions = ['export_admin_action', 'custom_action'] - - def custom_action(self, request, queryset): - # Your custom logic here - updated = queryset.update(some_field='value') - self.message_user(request, f'{updated} records updated.') - custom_action.short_description = 'Perform custom action' -``` - ---- - -## Tips & Best Practices - -### Performance -1. **Use filters before searching** - Narrow down data set first -2. **Use autocomplete fields** - Faster than raw ID fields -3. **Limit inline records** - Use `show_change_link` for large datasets -4. **Export in batches** - For very large datasets - -### Data Quality -1. **Use import validation** - Preview before confirming -2. **Verify foreign keys** - Ensure related objects exist -3. **Check date precision** - Use appropriate precision levels -4. **Review before bulk actions** - Double-check selections - -### Navigation -1. **Use breadcrumbs** - Navigate back through hierarchy -2. **Bookmark frequently used filters** - Save time -3. **Use keyboard shortcuts** - Unfold supports many shortcuts -4. **Search then filter** - Or filter then search, depending on need - -### Security -1. **Use strong passwords** - For admin accounts -2. **Enable 2FA** - If available (django-otp configured) -3. **Regular backups** - Before major bulk operations -4. **Audit changes** - Review history in change log - ---- - -## Troubleshooting - -### Issue: Can't see Unfold theme - -**Solution:** -```bash -cd django -python manage.py collectstatic --noinput -``` - -### Issue: Import fails with validation errors - -**Solution:** -- Check CSV formatting -- Verify column headers match field names -- Ensure required fields are present -- Check foreign key references exist - -### Issue: Geographic features not working - -**Solution:** -- Verify latitude/longitude are valid decimals -- Check coordinate ranges (-90 to 90, -180 to 180) -- For PostGIS: Verify PostGIS is installed and configured - -### Issue: Filters not appearing - -**Solution:** -- Clear browser cache -- Check admin class has list_filter defined -- Verify filter classes are imported -- Restart development server - -### Issue: Inline records not saving - -**Solution:** -- Check form validation errors -- Verify required fields in inline -- Check permissions for related model -- Review browser console for JavaScript errors - ---- - -## Additional Resources - -### Documentation -- **Django Unfold**: https://unfoldadmin.com/ -- **django-import-export**: https://django-import-export.readthedocs.io/ -- **Django Admin**: https://docs.djangoproject.com/en/4.2/ref/contrib/admin/ - -### ThrillWiki Docs -- `API_GUIDE.md` - REST API documentation -- `POSTGIS_SETUP.md` - Geographic features setup -- `MIGRATION_PLAN.md` - Database migration guide -- `README.md` - Project overview - ---- - -## Support - -For issues or questions: -1. Check this guide first -2. Review Django Unfold documentation -3. Check project README.md -4. Review code comments in `apps/entities/admin.py` - ---- - -**Last Updated:** Phase 2C Implementation -**Version:** 1.0 -**Admin Theme:** Django Unfold 0.40.0 diff --git a/django/API_GUIDE.md b/django/API_GUIDE.md deleted file mode 100644 index 1413956a..00000000 --- a/django/API_GUIDE.md +++ /dev/null @@ -1,542 +0,0 @@ -# ThrillWiki REST API Guide - -## Phase 2B: REST API Development - Complete - -This guide provides comprehensive documentation for the ThrillWiki REST API v1. - -## Overview - -The ThrillWiki API provides programmatic access to amusement park, ride, and company data. It uses django-ninja for fast, modern REST API implementation with automatic OpenAPI documentation. - -## Base URL - -- **Local Development**: `http://localhost:8000/api/v1/` -- **Production**: `https://your-domain.com/api/v1/` - -## Documentation - -- **Interactive API Docs**: `/api/v1/docs` -- **OpenAPI Schema**: `/api/v1/openapi.json` - -## Features - -### Implemented in Phase 2B - -✅ **Full CRUD Operations** for all entities -✅ **Filtering & Search** on all list endpoints -✅ **Pagination** (50 items per page) -✅ **Geographic Search** for parks (dual-mode: SQLite + PostGIS) -✅ **Automatic OpenAPI/Swagger Documentation** -✅ **Pydantic Schema Validation** -✅ **Related Data** (automatic joins and annotations) -✅ **Error Handling** with detailed error responses - -### Coming in Phase 2C - -- JWT Token Authentication -- Role-based Permissions -- Rate Limiting -- Caching -- Webhooks - -## Authentication - -**Current Status**: Authentication placeholders are in place, but not yet enforced. - -- **Read Operations (GET)**: Public access -- **Write Operations (POST, PUT, PATCH, DELETE)**: Will require authentication (JWT tokens) - -## Endpoints - -### System Endpoints - -#### Health Check -``` -GET /api/v1/health -``` -Returns API health status. - -#### API Information -``` -GET /api/v1/info -``` -Returns API metadata and statistics. - ---- - -### Companies - -Companies represent manufacturers, operators, designers, and other entities in the amusement industry. - -#### List Companies -``` -GET /api/v1/companies/ -``` - -**Query Parameters:** -- `page` (int): Page number -- `search` (string): Search by name or description -- `company_type` (string): Filter by type (manufacturer, operator, designer, supplier, contractor) -- `location_id` (UUID): Filter by headquarters location -- `ordering` (string): Sort field (prefix with `-` for descending) - -**Example:** -```bash -curl "http://localhost:8000/api/v1/companies/?search=B%26M&ordering=-park_count" -``` - -#### Get Company -``` -GET /api/v1/companies/{company_id} -``` - -#### Create Company -``` -POST /api/v1/companies/ -``` - -**Request Body:** -```json -{ - "name": "Bolliger & Mabillard", - "description": "Swiss roller coaster manufacturer", - "company_types": ["manufacturer"], - "founded_date": "1988-01-01", - "website": "https://www.bolliger-mabillard.com" -} -``` - -#### Update Company -``` -PUT /api/v1/companies/{company_id} -PATCH /api/v1/companies/{company_id} -``` - -#### Delete Company -``` -DELETE /api/v1/companies/{company_id} -``` - -#### Get Company Parks -``` -GET /api/v1/companies/{company_id}/parks -``` -Returns all parks operated by the company. - -#### Get Company Rides -``` -GET /api/v1/companies/{company_id}/rides -``` -Returns all rides manufactured by the company. - ---- - -### Ride Models - -Ride models represent specific ride types from manufacturers. - -#### List Ride Models -``` -GET /api/v1/ride-models/ -``` - -**Query Parameters:** -- `page` (int): Page number -- `search` (string): Search by model name -- `manufacturer_id` (UUID): Filter by manufacturer -- `model_type` (string): Filter by model type -- `ordering` (string): Sort field - -**Example:** -```bash -curl "http://localhost:8000/api/v1/ride-models/?manufacturer_id=&model_type=coaster_model" -``` - -#### Get Ride Model -``` -GET /api/v1/ride-models/{model_id} -``` - -#### Create Ride Model -``` -POST /api/v1/ride-models/ -``` - -**Request Body:** -```json -{ - "name": "Wing Coaster", - "manufacturer_id": "uuid-here", - "model_type": "coaster_model", - "description": "Winged seating roller coaster", - "typical_height": 164.0, - "typical_speed": 55.0 -} -``` - -#### Update Ride Model -``` -PUT /api/v1/ride-models/{model_id} -PATCH /api/v1/ride-models/{model_id} -``` - -#### Delete Ride Model -``` -DELETE /api/v1/ride-models/{model_id} -``` - -#### Get Model Installations -``` -GET /api/v1/ride-models/{model_id}/installations -``` -Returns all rides using this model. - ---- - -### Parks - -Parks represent theme parks, amusement parks, water parks, and FECs. - -#### List Parks -``` -GET /api/v1/parks/ -``` - -**Query Parameters:** -- `page` (int): Page number -- `search` (string): Search by park name -- `park_type` (string): Filter by type (theme_park, amusement_park, water_park, family_entertainment_center, traveling_park, zoo, aquarium) -- `status` (string): Filter by status (operating, closed, sbno, under_construction, planned) -- `operator_id` (UUID): Filter by operator -- `ordering` (string): Sort field - -**Example:** -```bash -curl "http://localhost:8000/api/v1/parks/?status=operating&park_type=theme_park" -``` - -#### Get Park -``` -GET /api/v1/parks/{park_id} -``` - -#### Find Nearby Parks (Geographic Search) -``` -GET /api/v1/parks/nearby/ -``` - -**Query Parameters:** -- `latitude` (float, required): Center point latitude -- `longitude` (float, required): Center point longitude -- `radius` (float): Search radius in kilometers (default: 50) -- `limit` (int): Maximum results (default: 50) - -**Geographic Modes:** -- **PostGIS (Production)**: Accurate distance-based search using `location_point` -- **SQLite (Local Dev)**: Bounding box approximation using `latitude`/`longitude` - -**Example:** -```bash -curl "http://localhost:8000/api/v1/parks/nearby/?latitude=28.385233&longitude=-81.563874&radius=100" -``` - -#### Create Park -``` -POST /api/v1/parks/ -``` - -**Request Body:** -```json -{ - "name": "Six Flags Magic Mountain", - "park_type": "theme_park", - "status": "operating", - "latitude": 34.4239, - "longitude": -118.5971, - "opening_date": "1971-05-29", - "website": "https://www.sixflags.com/magicmountain" -} -``` - -#### Update Park -``` -PUT /api/v1/parks/{park_id} -PATCH /api/v1/parks/{park_id} -``` - -#### Delete Park -``` -DELETE /api/v1/parks/{park_id} -``` - -#### Get Park Rides -``` -GET /api/v1/parks/{park_id}/rides -``` -Returns all rides at the park. - ---- - -### Rides - -Rides represent individual rides and roller coasters. - -#### List Rides -``` -GET /api/v1/rides/ -``` - -**Query Parameters:** -- `page` (int): Page number -- `search` (string): Search by ride name -- `park_id` (UUID): Filter by park -- `ride_category` (string): Filter by category (roller_coaster, flat_ride, water_ride, dark_ride, transport_ride, other) -- `status` (string): Filter by status -- `is_coaster` (bool): Filter for roller coasters only -- `manufacturer_id` (UUID): Filter by manufacturer -- `ordering` (string): Sort field - -**Example:** -```bash -curl "http://localhost:8000/api/v1/rides/?is_coaster=true&status=operating" -``` - -#### List Roller Coasters Only -``` -GET /api/v1/rides/coasters/ -``` - -**Additional Query Parameters:** -- `min_height` (float): Minimum height in feet -- `min_speed` (float): Minimum speed in mph - -**Example:** -```bash -curl "http://localhost:8000/api/v1/rides/coasters/?min_height=200&min_speed=70" -``` - -#### Get Ride -``` -GET /api/v1/rides/{ride_id} -``` - -#### Create Ride -``` -POST /api/v1/rides/ -``` - -**Request Body:** -```json -{ - "name": "Steel Vengeance", - "park_id": "uuid-here", - "ride_category": "roller_coaster", - "is_coaster": true, - "status": "operating", - "manufacturer_id": "uuid-here", - "height": 205.0, - "speed": 74.0, - "length": 5740.0, - "inversions": 4, - "opening_date": "2018-05-05" -} -``` - -#### Update Ride -``` -PUT /api/v1/rides/{ride_id} -PATCH /api/v1/rides/{ride_id} -``` - -#### Delete Ride -``` -DELETE /api/v1/rides/{ride_id} -``` - ---- - -## Response Formats - -### Success Responses - -#### Single Entity -```json -{ - "id": "uuid", - "name": "Entity Name", - "created": "2025-01-01T00:00:00Z", - "modified": "2025-01-01T00:00:00Z", - ... -} -``` - -#### Paginated List -```json -{ - "items": [...], - "count": 100, - "next": "http://api/endpoint/?page=2", - "previous": null -} -``` - -### Error Responses - -#### 400 Bad Request -```json -{ - "detail": "Invalid input", - "errors": [ - { - "field": "name", - "message": "This field is required" - } - ] -} -``` - -#### 404 Not Found -```json -{ - "detail": "Entity not found" -} -``` - -#### 500 Internal Server Error -```json -{ - "detail": "Internal server error", - "code": "server_error" -} -``` - ---- - -## Data Types - -### UUID -All entity IDs use UUID format: -``` -"550e8400-e29b-41d4-a716-446655440000" -``` - -### Dates -ISO 8601 format (YYYY-MM-DD): -``` -"2025-01-01" -``` - -### Timestamps -ISO 8601 format with timezone: -``` -"2025-01-01T12:00:00Z" -``` - -### Coordinates -Latitude/Longitude as decimal degrees: -```json -{ - "latitude": 28.385233, - "longitude": -81.563874 -} -``` - ---- - -## Testing the API - -### Using curl - -```bash -# Get API info -curl http://localhost:8000/api/v1/info - -# List companies -curl http://localhost:8000/api/v1/companies/ - -# Search parks -curl "http://localhost:8000/api/v1/parks/?search=Six+Flags" - -# Find nearby parks -curl "http://localhost:8000/api/v1/parks/nearby/?latitude=28.385&longitude=-81.563&radius=50" -``` - -### Using the Interactive Docs - -1. Start the development server: - ```bash - cd django - python manage.py runserver - ``` - -2. Open your browser to: - ``` - http://localhost:8000/api/v1/docs - ``` - -3. Explore and test all endpoints interactively! - ---- - -## Geographic Features - -### SQLite Mode (Local Development) - -Uses simple latitude/longitude fields with bounding box approximation: -- Stores coordinates as `DecimalField` -- Geographic search uses bounding box calculation -- Less accurate but works without PostGIS - -### PostGIS Mode (Production) - -Uses advanced geographic features: -- Stores coordinates as `PointField` (geography type) -- Accurate distance-based queries -- Supports spatial indexing -- Full GIS capabilities - -### Switching Between Modes - -The API automatically detects the database backend and uses the appropriate method. No code changes needed! - ---- - -## Next Steps - -### Phase 2C: Admin Interface Enhancements -- Enhanced Django admin for all entities -- Bulk operations -- Advanced filtering -- Custom actions - -### Phase 3: Frontend Integration -- React/Next.js frontend -- Real-time updates -- Interactive maps -- Rich search interface - -### Phase 4: Advanced Features -- JWT authentication -- API rate limiting -- Caching strategies -- Webhooks -- WebSocket support - ---- - -## Support - -For issues or questions about the API: -1. Check the interactive documentation at `/api/v1/docs` -2. Review this guide -3. Check the POSTGIS_SETUP.md for geographic features -4. Refer to the main README.md for project setup - -## Version History - -- **v1.0.0** (Phase 2B): Initial REST API implementation - - Full CRUD for all entities - - Filtering and search - - Geographic queries - - Pagination - - OpenAPI documentation diff --git a/django/COMPLETE_MIGRATION_AUDIT.md b/django/COMPLETE_MIGRATION_AUDIT.md deleted file mode 100644 index e62dde8e..00000000 --- a/django/COMPLETE_MIGRATION_AUDIT.md +++ /dev/null @@ -1,735 +0,0 @@ -# Complete Django Migration Audit Report - -**Audit Date:** November 8, 2025 -**Project:** ThrillWiki Django Backend Migration -**Auditor:** AI Code Analysis -**Status:** Comprehensive audit complete - ---- - -## 🎯 Executive Summary - -The Django backend migration is **65% complete overall** with an **excellent 85% backend implementation**. The project has outstanding core systems (moderation, versioning, authentication, search) but is missing 3 user-interaction models and has not started frontend integration or data migration. - -### Key Findings - -✅ **Strengths:** -- Production-ready moderation system with FSM state machine -- Comprehensive authentication with JWT and MFA -- Automatic versioning for all entities -- Advanced search with PostgreSQL full-text and PostGIS -- 90+ REST API endpoints fully functional -- Background task processing with Celery -- Excellent code quality and documentation - -⚠️ **Gaps:** -- 3 missing models: Reviews, User Ride Credits, User Top Lists -- No frontend integration started (0%) -- No data migration from Supabase executed (0%) -- No automated test suite (0%) -- No deployment configuration - -🔴 **Risks:** -- Frontend integration is 4-6 weeks of work -- Data migration strategy undefined -- No testing creates deployment risk - ---- - -## 📊 Detailed Analysis - -### 1. Backend Implementation: 85% Complete - -#### ✅ **Fully Implemented Systems** - -**Core Entity Models (100%)** -``` -✅ Company - 585 lines - - Manufacturer, operator, designer types - - Location relationships - - Cached statistics (park_count, ride_count) - - CloudFlare logo integration - - Full-text search support - - Admin interface with inline editing - -✅ RideModel - 360 lines - - Manufacturer relationships - - Model categories and types - - Technical specifications (JSONB) - - Installation count tracking - - Full-text search support - - Admin interface - -✅ Park - 720 lines - - PostGIS PointField for production - - SQLite lat/lng fallback for dev - - Status tracking (operating, closed, SBNO, etc.) - - Operator and owner relationships - - Cached ride counts - - Banner/logo images - - Full-text search support - - Location-based queries - -✅ Ride - 650 lines - - Park relationships - - Manufacturer and model relationships - - Extensive statistics (height, speed, length, inversions) - - Auto-set is_coaster flag - - Status tracking - - Full-text search support - - Automatic parent park count updates -``` - -**Location Models (100%)** -``` -✅ Country - ISO 3166-1 with 2 and 3-letter codes -✅ Subdivision - ISO 3166-2 state/province/region data -✅ Locality - City/town with lat/lng coordinates -``` - -**Advanced Systems (100%)** -``` -✅ Moderation System (Phase 3) - - FSM state machine (draft → pending → reviewing → approved/rejected) - - Atomic transaction handling - - Selective approval (approve individual items) - - 15-minute lock mechanism with auto-unlock - - 12 REST API endpoints - - ContentSubmission and SubmissionItem models - - ModerationLock tracking - - Beautiful admin interface with colored badges - - Email notifications via Celery - -✅ Versioning System (Phase 4) - - EntityVersion model with generic relations - - Automatic tracking via lifecycle hooks - - Full JSON snapshots for rollback - - Changed fields tracking with old/new values - - 16 REST API endpoints - - Version comparison and diff generation - - Admin interface (read-only, append-only) - - Integration with moderation workflow - -✅ Authentication System (Phase 5) - - JWT tokens (60-min access, 7-day refresh) - - MFA/2FA with TOTP - - Role-based permissions (user, moderator, admin) - - 23 authentication endpoints - - OAuth ready (Google, Discord) - - User management - - Password reset flow - - django-allauth + django-otp integration - - Permission decorators and helpers - -✅ Media Management (Phase 6) - - Photo model with CloudFlare Images - - Image validation and metadata - - Photo moderation workflow - - Generic relations to entities - - Admin interface with thumbnails - - Photo upload API endpoints - -✅ Background Tasks (Phase 7) - - Celery + Redis configuration - - 20+ background tasks: - * Media processing - * Email notifications - * Statistics updates - * Cleanup tasks - - 10 scheduled tasks with Celery Beat - - Email templates (base, welcome, password reset, moderation) - - Flower monitoring setup (production) - - Task retry logic and error handling - -✅ Search & Filtering (Phase 8) - - PostgreSQL full-text search with ranking - - SQLite fallback with LIKE queries - - SearchVector fields with GIN indexes - - Signal-based auto-update of search vectors - - Global search across all entities - - Entity-specific search endpoints - - Location-based search with PostGIS - - Autocomplete functionality - - Advanced filtering classes - - 6 search API endpoints -``` - -**API Coverage (90+ endpoints)** -``` -✅ Authentication: 23 endpoints - - Register, login, logout, token refresh - - Profile management - - MFA enable/disable/verify - - Password change/reset - - User administration - - Role assignment - -✅ Moderation: 12 endpoints - - Submission CRUD - - Start review, approve, reject - - Selective approval/rejection - - Queue views (pending, reviewing, my submissions) - - Manual unlock - -✅ Versioning: 16 endpoints - - Version history for all entities - - Get specific version - - Compare versions - - Diff with current - - Generic version endpoints - -✅ Search: 6 endpoints - - Global search - - Entity-specific search (companies, models, parks, rides) - - Autocomplete - -✅ Entity CRUD: ~40 endpoints - - Companies: 6 endpoints - - RideModels: 6 endpoints - - Parks: 7 endpoints (including nearby search) - - Rides: 6 endpoints - - Each with list, create, retrieve, update, delete - -✅ Photos: ~10 endpoints - - Photo CRUD - - Entity-specific photo lists - - Photo moderation - -✅ System: 2 endpoints - - Health check - - API info with statistics -``` - -**Admin Interfaces (100%)** -``` -✅ All models have rich admin interfaces: - - List views with custom columns - - Filtering and search - - Inline editing where appropriate - - Colored status badges - - Link navigation between related models - - Import/export functionality - - Bulk actions - - Read-only views for append-only models (versions, locks) -``` - -#### ❌ **Missing Implementation (15%)** - -**1. Reviews System** 🔴 CRITICAL -``` -Supabase Schema: -- reviews table with rating (1-5), title, content -- User → Park or Ride relationship -- Visit date and wait time tracking -- Photo attachments (JSONB array) -- Helpful votes (helpful_votes, total_votes) -- Moderation status and workflow -- Created/updated timestamps - -Django Status: NOT IMPLEMENTED - -Impact: -- Can't migrate user review data from Supabase -- Users can't leave reviews after migration -- Missing key user engagement feature - -Estimated Implementation: 1-2 days -``` - -**2. User Ride Credits** 🟡 IMPORTANT -``` -Supabase Schema: -- user_ride_credits table -- User → Ride relationship -- First ride date tracking -- Ride count per user/ride -- Created/updated timestamps - -Django Status: NOT IMPLEMENTED - -Impact: -- Can't track which rides users have been on -- Missing coaster counting/tracking feature -- Can't preserve user ride history - -Estimated Implementation: 0.5-1 day -``` - -**3. User Top Lists** 🟡 IMPORTANT -``` -Supabase Schema: -- user_top_lists table -- User ownership -- List type (parks, rides, coasters) -- Title and description -- Items array (JSONB with id, position, notes) -- Public/private flag -- Created/updated timestamps - -Django Status: NOT IMPLEMENTED - -Impact: -- Users can't create ranked lists -- Missing personalization feature -- Can't preserve user-created rankings - -Estimated Implementation: 0.5-1 day -``` - ---- - -### 2. Frontend Integration: 0% Complete - -**Current State:** -- React frontend using Supabase client -- All API calls via `@/integrations/supabase/client` -- Supabase Auth for authentication -- Real-time subscriptions (if any) via Supabase Realtime - -**Required Changes:** -```typescript -// Need to create: -1. Django API client (src/lib/djangoClient.ts) -2. JWT auth context (src/contexts/AuthContext.tsx) -3. React Query hooks for Django endpoints -4. Type definitions for Django responses - -// Need to replace: -- ~50-100 Supabase API calls across components -- Authentication flow (Supabase Auth → JWT) -- File uploads (Supabase Storage → CloudFlare) -- Real-time features (polling or WebSockets) -``` - -**Estimated Effort:** 4-6 weeks (160-240 hours) - -**Breakdown:** -``` -Week 1-2: Foundation -- Create Django API client -- Implement JWT auth management -- Replace auth in 2-3 components as proof-of-concept -- Establish patterns - -Week 3-4: Core Entities -- Update Companies pages -- Update Parks pages -- Update Rides pages -- Update RideModels pages -- Test all CRUD operations - -Week 5: Advanced Features -- Update Moderation Queue -- Update User Profiles -- Update Search functionality -- Update Photos/Media - -Week 6: Polish & Testing -- E2E tests -- Bug fixes -- Performance optimization -- User acceptance testing -``` - ---- - -### 3. Data Migration: 0% Complete - -**Supabase Database Analysis:** -``` -Migration Files: 187 files (heavily evolved schema) -Tables: ~15-20 core tables identified - -Core Tables: -✅ companies -✅ locations -✅ parks -✅ rides -✅ ride_models -✅ profiles -❌ reviews (not in Django yet) -❌ user_ride_credits (not in Django yet) -❌ user_top_lists (not in Django yet) -❌ park_operating_hours (deprioritized) -✅ content_submissions (different structure in Django) -``` - -**Critical Questions:** -1. Is there production data? (Unknown) -2. How many records per table? (Unknown) -3. Data quality assessment? (Unknown) -4. Which data to migrate? (Unknown) - -**Migration Strategy Options:** - -**Option A: Fresh Start** (If no production data) -``` -Pros: -- Skip migration complexity -- No data transformation needed -- Faster path to production -- Clean start - -Cons: -- Lose any test data -- Can't preserve user history - -Recommended: YES, if no prod data exists -Timeline: 0 weeks -``` - -**Option B: Full Migration** (If production data exists) -``` -Steps: -1. Audit Supabase database -2. Count records, assess quality -3. Export data (pg_dump or CSV) -4. Transform data (Python script) -5. Import to Django (ORM or bulk_create) -6. Validate integrity (checksums, counts) -7. Test with migrated data - -Timeline: 2-4 weeks -Risk: HIGH (data loss, corruption) -Complexity: HIGH -``` - -**Recommendation:** -- First, determine if production data exists -- If NO → Fresh start (Option A) -- If YES → Carefully execute Option B - ---- - -### 4. Testing: 0% Complete - -**Current State:** -- No unit tests -- No integration tests -- No E2E tests -- Manual testing only - -**Required Testing:** -``` -Backend Unit Tests: -- Model tests (create, update, relationships) -- Service tests (business logic) -- Permission tests (auth, roles) -- Admin tests (basic) - -API Integration Tests: -- Authentication flow -- CRUD operations -- Moderation workflow -- Search functionality -- Error handling - -Frontend Integration Tests: -- Django API client -- Auth context -- React Query hooks - -E2E Tests (Playwright/Cypress): -- User registration/login -- Create/edit entities -- Submit for moderation -- Approve/reject workflow -- Search and filter -``` - -**Estimated Effort:** 2-3 weeks - -**Target:** 80% backend code coverage - ---- - -### 5. Deployment: 0% Complete - -**Current State:** -- No production configuration -- No Docker setup -- No CI/CD pipeline -- No infrastructure planning - -**Required Components:** -``` -Infrastructure: -- Web server (Gunicorn/Daphne) -- PostgreSQL with PostGIS -- Redis (Celery broker + cache) -- Static file serving (WhiteNoise or CDN) -- SSL/TLS certificates - -Services: -- Django application -- Celery worker(s) -- Celery beat (scheduler) -- Flower (monitoring) - -Platform Options: -1. Railway (recommended for MVP) -2. Render.com (recommended for MVP) -3. DigitalOcean/Linode (more control) -4. AWS/GCP (enterprise, complex) - -Configuration: -- Environment variables -- Database connection -- Redis connection -- Email service (SendGrid/Mailgun) -- CloudFlare Images API -- Sentry error tracking -- Monitoring/logging -``` - -**Estimated Effort:** 1 week - ---- - -## 📈 Timeline & Effort Estimates - -### Phase 9: Complete Missing Models -**Duration:** 5-7 days -**Effort:** 40-56 hours -**Risk:** LOW -**Priority:** P0 (Must do before migration) - -``` -Tasks: -- Reviews model + API + admin: 12-16 hours -- User Ride Credits + API + admin: 6-8 hours -- User Top Lists + API + admin: 6-8 hours -- Testing: 8-12 hours -- Documentation: 4-6 hours -- Buffer: 4-6 hours -``` - -### Phase 10: Data Migration (Optional) -**Duration:** 0-14 days -**Effort:** 0-112 hours -**Risk:** HIGH (if doing migration) -**Priority:** P0 (If production data exists) - -``` -If production data exists: -- Database audit: 8 hours -- Export scripts: 16 hours -- Transformation logic: 24 hours -- Import scripts: 16 hours -- Validation: 16 hours -- Testing: 24 hours -- Buffer: 8 hours - -If no production data: -- Skip entirely: 0 hours -``` - -### Phase 11: Frontend Integration -**Duration:** 20-30 days -**Effort:** 160-240 hours -**Risk:** MEDIUM -**Priority:** P0 (Must do for launch) - -``` -Tasks: -- API client foundation: 40 hours -- Auth migration: 40 hours -- Entity pages: 60 hours -- Advanced features: 40 hours -- Testing & polish: 40 hours -- Buffer: 20 hours -``` - -### Phase 12: Testing -**Duration:** 7-10 days -**Effort:** 56-80 hours -**Risk:** LOW -**Priority:** P1 (Highly recommended) - -``` -Tasks: -- Backend unit tests: 24 hours -- API integration tests: 16 hours -- Frontend tests: 16 hours -- E2E tests: 16 hours -- Bug fixes: 8 hours -``` - -### Phase 13: Deployment -**Duration:** 5-7 days -**Effort:** 40-56 hours -**Risk:** MEDIUM -**Priority:** P0 (Must do for launch) - -``` -Tasks: -- Platform setup: 8 hours -- Configuration: 8 hours -- CI/CD pipeline: 8 hours -- Staging deployment: 8 hours -- Testing: 8 hours -- Production deployment: 4 hours -- Monitoring setup: 4 hours -- Buffer: 8 hours -``` - -### Total Remaining Effort - -**Minimum Path** (No data migration, skip testing): -- Phase 9: 40 hours -- Phase 11: 160 hours -- Phase 13: 40 hours -- **Total: 240 hours (6 weeks @ 40hrs/week)** - -**Realistic Path** (No data migration, with testing): -- Phase 9: 48 hours -- Phase 11: 200 hours -- Phase 12: 64 hours -- Phase 13: 48 hours -- **Total: 360 hours (9 weeks @ 40hrs/week)** - -**Full Path** (With data migration and testing): -- Phase 9: 48 hours -- Phase 10: 112 hours -- Phase 11: 200 hours -- Phase 12: 64 hours -- Phase 13: 48 hours -- **Total: 472 hours (12 weeks @ 40hrs/week)** - ---- - -## 🎯 Recommendations - -### Immediate (This Week) -1. ✅ **Implement 3 missing models** (Reviews, Credits, Lists) -2. ✅ **Run Django system check** - ensure 0 issues -3. ✅ **Create basic tests** for new models -4. ❓ **Determine if Supabase has production data** - Critical decision point - -### Short-term (Next 2-3 Weeks) -5. **If NO production data:** Skip data migration, go to frontend -6. **If YES production data:** Execute careful data migration -7. **Start frontend integration** planning -8. **Set up development environment** for testing - -### Medium-term (Next 4-8 Weeks) -9. **Frontend integration** - Create Django API client -10. **Replace all Supabase calls** systematically -11. **Test all user flows** thoroughly -12. **Write comprehensive tests** - -### Long-term (Next 8-12 Weeks) -13. **Deploy to staging** for testing -14. **User acceptance testing** -15. **Deploy to production** -16. **Monitor and iterate** - ---- - -## 🚨 Critical Risks & Mitigation - -### Risk 1: Data Loss During Migration 🔴 -**Probability:** HIGH (if migrating) -**Impact:** CATASTROPHIC - -**Mitigation:** -- Complete Supabase backup before ANY changes -- Multiple dry-run migrations -- Checksum validation at every step -- Keep Supabase running in parallel for 1-2 weeks -- Have rollback plan ready - -### Risk 2: Frontend Breaking Changes 🔴 -**Probability:** VERY HIGH -**Impact:** HIGH - -**Mitigation:** -- Systematic component-by-component migration -- Comprehensive testing at each step -- Feature flags for gradual rollout -- Beta testing with subset of users -- Quick rollback capability - -### Risk 3: Extended Downtime 🟡 -**Probability:** MEDIUM -**Impact:** HIGH - -**Mitigation:** -- Blue-green deployment -- Run systems in parallel temporarily -- Staged rollout by feature -- Monitor closely during cutover - -### Risk 4: Missing Features 🟡 -**Probability:** MEDIUM (after Phase 9) -**Impact:** MEDIUM - -**Mitigation:** -- Complete Phase 9 before any migration -- Test feature parity thoroughly -- User acceptance testing -- Beta testing period - -### Risk 5: No Testing = Production Bugs 🟡 -**Probability:** HIGH (if skipping tests) -**Impact:** MEDIUM - -**Mitigation:** -- Don't skip testing phase -- Minimum 80% backend coverage -- Critical path E2E tests -- Staging environment testing - ---- - -## ✅ Success Criteria - -### Phase 9 Success -- [ ] Reviews model implemented with full functionality -- [ ] User Ride Credits model implemented -- [ ] User Top Lists model implemented -- [ ] All API endpoints working -- [ ] All admin interfaces functional -- [ ] Basic tests passing -- [ ] Django system check: 0 issues -- [ ] Documentation updated - -### Overall Migration Success -- [ ] 100% backend feature parity with Supabase -- [ ] All data migrated (if applicable) with 0 loss -- [ ] Frontend 100% functional with Django backend -- [ ] 80%+ test coverage -- [ ] Production deployed and stable -- [ ] User acceptance testing passed -- [ ] Performance meets or exceeds Supabase -- [ ] Zero critical bugs in production - ---- - -## 📝 Conclusion - -The Django backend migration is in **excellent shape** with 85% completion. The core infrastructure is production-ready with outstanding moderation, versioning, authentication, and search systems. - -**The remaining work is well-defined:** -1. Complete 3 missing models (5-7 days) -2. Decide on data migration approach (0-14 days) -3. Frontend integration (4-6 weeks) -4. Testing (1-2 weeks) -5. Deployment (1 week) - -**Total estimated time to completion: 8-12 weeks** - -**Key Success Factors:** -- Complete Phase 9 (missing models) before ANY migration -- Make data migration decision early -- Don't skip testing -- Deploy to staging before production -- Have rollback plans ready - -**Nothing will be lost** if the data migration strategy is executed carefully with proper backups, validation, and rollback plans. - ---- - -**Audit Complete** -**Next Step:** Implement missing models (Phase 9) -**Last Updated:** November 8, 2025, 3:12 PM EST diff --git a/django/MIGRATION_PLAN.md b/django/MIGRATION_PLAN.md deleted file mode 100644 index e095d7ba..00000000 --- a/django/MIGRATION_PLAN.md +++ /dev/null @@ -1,566 +0,0 @@ -# ThrillWiki Django Backend Migration Plan - -## 🎯 Project Overview - -**Objective**: Migrate ThrillWiki from Supabase backend to Django REST backend while preserving 100% of functionality. - -**Timeline**: 12-16 weeks with 2 developers -**Status**: Foundation Phase - In Progress -**Branch**: `django-backend` - ---- - -## 📊 Architecture Overview - -### Current Stack (Supabase) -- **Frontend**: React 18.3 + TypeScript + Vite + React Query -- **Backend**: Supabase (PostgreSQL + Edge Functions) -- **Database**: PostgreSQL with 80+ tables -- **Auth**: Supabase Auth (OAuth + MFA) -- **Storage**: CloudFlare Images -- **Notifications**: Novu Cloud -- **Real-time**: Supabase Realtime - -### Target Stack (Django) -- **Frontend**: React 18.3 + TypeScript + Vite (unchanged) -- **Backend**: Django 4.2 + django-ninja -- **Database**: PostgreSQL (migrated schema) -- **Auth**: Django + django-allauth + django-otp -- **Storage**: CloudFlare Images (unchanged) -- **Notifications**: Novu Cloud (unchanged) -- **Real-time**: Django Channels + WebSockets -- **Tasks**: Celery + Redis -- **Caching**: Redis + django-cacheops - ---- - -## 🏗️ Project Structure - -``` -django/ -├── manage.py -├── config/ # Project settings -│ ├── settings/ -│ │ ├── __init__.py -│ │ ├── base.py # Shared settings -│ │ ├── local.py # Development -│ │ └── production.py # Production -│ ├── urls.py -│ ├── wsgi.py -│ └── asgi.py # For Channels -│ -├── apps/ -│ ├── core/ # Base models, utilities -│ │ ├── models.py # Abstract base models -│ │ ├── permissions.py # Reusable permissions -│ │ ├── mixins.py # Model mixins -│ │ └── utils.py -│ │ -│ ├── entities/ # Parks, Rides, Companies -│ │ ├── models/ -│ │ │ ├── park.py -│ │ │ ├── ride.py -│ │ │ ├── company.py -│ │ │ └── ride_model.py -│ │ ├── api/ -│ │ │ ├── views.py -│ │ │ ├── serializers.py -│ │ │ └── filters.py -│ │ ├── services.py -│ │ └── tasks.py -│ │ -│ ├── moderation/ # Content moderation -│ │ ├── models.py -│ │ ├── state_machine.py # django-fsm workflow -│ │ ├── services.py -│ │ └── api/ -│ │ -│ ├── versioning/ # Entity versioning -│ │ ├── models.py -│ │ ├── signals.py -│ │ └── services.py -│ │ -│ ├── users/ # User management -│ │ ├── models.py -│ │ ├── managers.py -│ │ └── api/ -│ │ -│ ├── media/ # Photo management -│ │ ├── models.py -│ │ ├── storage.py -│ │ └── tasks.py -│ │ -│ └── notifications/ # Notification system -│ ├── models.py -│ ├── providers/ -│ │ └── novu.py -│ └── tasks.py -│ -├── api/ -│ └── v1/ -│ ├── router.py # Main API router -│ └── schemas.py # Pydantic schemas -│ -└── scripts/ - ├── migrate_from_supabase.py - └── validate_data.py -``` - ---- - -## 📋 Implementation Phases - -### ✅ Phase 0: Foundation (CURRENT - Week 1) -- [x] Create git branch `django-backend` -- [x] Set up Python virtual environment -- [x] Install all dependencies (Django 4.2, django-ninja, celery, etc.) -- [x] Create Django project structure -- [x] Create app directories -- [x] Create .env.example -- [ ] Configure Django settings (base, local, production) -- [ ] Create base models and utilities -- [ ] Set up database connection -- [ ] Create initial migrations - -### Phase 1: Core Models (Week 2-3) -- [ ] Create abstract base models (TimeStamped, Versioned, etc.) -- [ ] Implement entity models (Park, Ride, Company, RideModel) -- [ ] Implement location models -- [ ] Implement user models with custom User -- [ ] Implement photo/media models -- [ ] Create Django migrations -- [ ] Test model relationships - -### Phase 2: Authentication System (Week 3-4) -- [ ] Set up django-allauth for OAuth (Google, Discord) -- [ ] Implement JWT authentication with djangorestframework-simplejwt -- [ ] Set up django-otp for MFA (TOTP) -- [ ] Create user registration/login endpoints -- [ ] Implement permission system (django-guardian) -- [ ] Create role-based access control -- [ ] Test authentication flow - -### Phase 3: Moderation System (Week 5-7) -- [ ] Create ContentSubmission and SubmissionItem models -- [ ] Implement django-fsm state machine -- [ ] Create ModerationService with atomic transactions -- [ ] Implement submission creation endpoints -- [ ] Implement approval/rejection endpoints -- [ ] Implement selective approval logic -- [ ] Create moderation queue API -- [ ] Add rate limiting with django-ratelimit -- [ ] Test moderation workflow end-to-end - -### Phase 4: Versioning System (Week 7-8) -- [ ] Create version models for all entities -- [ ] Implement django-lifecycle hooks for auto-versioning -- [ ] Create VersioningService -- [ ] Implement version history endpoints -- [ ] Add version diff functionality -- [ ] Test versioning with submissions - -### Phase 5: API Layer with django-ninja (Week 8-10) -- [ ] Set up django-ninja router -- [ ] Create Pydantic schemas for all entities -- [ ] Implement CRUD endpoints for parks -- [ ] Implement CRUD endpoints for rides -- [ ] Implement CRUD endpoints for companies -- [ ] Add filtering with django-filter -- [ ] Add search functionality -- [ ] Implement pagination -- [ ] Add API documentation (auto-generated) -- [ ] Test all endpoints - -### Phase 6: Celery Tasks (Week 10-11) -- [ ] Set up Celery with Redis -- [ ] Set up django-celery-beat for periodic tasks -- [ ] Migrate edge functions to Celery tasks: - - [ ] cleanup_old_page_views - - [ ] update_entity_view_counts - - [ ] process_submission_notifications - - [ ] generate_daily_stats -- [ ] Create notification tasks for Novu -- [ ] Set up Flower for monitoring -- [ ] Test async task execution - -### Phase 7: Real-time Features (Week 11-12) -- [ ] Set up Django Channels with Redis -- [ ] Create WebSocket consumers -- [ ] Implement moderation queue real-time updates -- [ ] Implement notification real-time delivery -- [ ] Test WebSocket connections -- [ ] OR: Implement Server-Sent Events as alternative - -### Phase 8: Caching & Performance (Week 12-13) -- [ ] Set up django-redis for caching -- [ ] Configure django-cacheops for automatic ORM caching -- [ ] Add cache invalidation logic -- [ ] Optimize database queries (select_related, prefetch_related) -- [ ] Add database indexes -- [ ] Profile with django-silk -- [ ] Load testing - -### Phase 9: Data Migration (Week 13-14) -- [ ] Export all data from Supabase -- [ ] Create migration script for entities -- [ ] Migrate user data (preserve UUIDs) -- [ ] Migrate submissions (pending only) -- [ ] Migrate version history -- [ ] Migrate photos/media references -- [ ] Validate data integrity -- [ ] Test with migrated data - -### Phase 10: Frontend Integration (Week 14-15) -- [ ] Create new API client (replace Supabase client) -- [ ] Update authentication logic -- [ ] Update all API calls to point to Django -- [ ] Update real-time subscriptions to WebSockets -- [ ] Test all user flows -- [ ] Fix any integration issues - -### Phase 11: Testing & QA (Week 15-16) -- [ ] Write unit tests for all models -- [ ] Write unit tests for all services -- [ ] Write API integration tests -- [ ] Write end-to-end tests -- [ ] Security audit -- [ ] Performance testing -- [ ] Load testing -- [ ] Bug fixes - -### Phase 12: Deployment (Week 16-17) -- [ ] Set up production environment -- [ ] Configure PostgreSQL -- [ ] Configure Redis -- [ ] Set up Celery workers -- [ ] Configure Gunicorn/Daphne -- [ ] Set up Docker containers -- [ ] Configure CI/CD -- [ ] Deploy to staging -- [ ] Final testing -- [ ] Deploy to production -- [ ] Monitor for issues - ---- - -## 🔑 Key Technical Decisions - -### 1. **django-ninja vs Django REST Framework** -**Choice**: django-ninja -- FastAPI-style syntax (modern, intuitive) -- Better performance -- Automatic OpenAPI documentation -- Pydantic integration for validation - -### 2. **State Machine for Moderation** -**Choice**: django-fsm -- Declarative state transitions -- Built-in guards and conditions -- Prevents invalid state changes -- Easy to visualize workflow - -### 3. **Auto-versioning Strategy** -**Choice**: django-lifecycle hooks -- Automatic version creation on model changes -- No manual intervention needed -- Tracks what changed -- Preserves full history - -### 4. **Real-time Communication** -**Primary**: Django Channels (WebSockets) -**Fallback**: Server-Sent Events (SSE) -- WebSockets for bidirectional communication -- SSE as simpler alternative -- Redis channel layer for scaling - -### 5. **Caching Strategy** -**Tool**: django-cacheops -- Automatic ORM query caching -- Transparent invalidation -- Minimal code changes -- Redis backend for consistency - ---- - -## 🚀 Critical Features to Preserve - -### 1. **Moderation System** -- ✅ Atomic transactions for approvals -- ✅ Selective approval (approve individual items) -- ✅ State machine workflow (pending → reviewing → approved/rejected) -- ✅ Lock mechanism (15-minute lock on review) -- ✅ Automatic unlock on timeout -- ✅ Batch operations - -### 2. **Versioning System** -- ✅ Full version history for all entities -- ✅ Track who made changes -- ✅ Track what changed -- ✅ Link versions to submissions -- ✅ Version diffs -- ✅ Rollback capability - -### 3. **Authentication** -- ✅ Password-based login -- ✅ Google OAuth -- ✅ Discord OAuth -- ✅ Two-factor authentication (TOTP) -- ✅ Session management -- ✅ JWT tokens for API - -### 4. **Permissions & Security** -- ✅ Role-based access control (user, moderator, admin, superuser) -- ✅ Object-level permissions -- ✅ Rate limiting -- ✅ CORS configuration -- ✅ Brute force protection - -### 5. **Image Management** -- ✅ CloudFlare direct upload -- ✅ Image validation -- ✅ Image metadata storage -- ✅ Multiple image variants (thumbnails, etc.) - -### 6. **Notifications** -- ✅ Email notifications via Novu -- ✅ In-app notifications -- ✅ Notification templates -- ✅ User preferences - -### 7. **Search & Filtering** -- ✅ Full-text search -- ✅ Advanced filtering -- ✅ Sorting options -- ✅ Pagination - ---- - -## 📊 Database Schema Preservation - -### Core Entity Tables (Must Migrate) -``` -✅ parks (80+ fields including dates, locations, operators) -✅ rides (100+ fields including ride_models, parks, manufacturers) -✅ companies (manufacturers, operators, designers) -✅ ride_models (coaster models, flat ride models) -✅ locations (countries, subdivisions, localities) -✅ profiles (user profiles linked to auth.users) -✅ user_roles (role assignments) -✅ content_submissions (moderation queue) -✅ submission_items (individual changes in submissions) -✅ park_versions, ride_versions, etc. (version history) -✅ photos (image metadata) -✅ photo_submissions (photo approval queue) -✅ reviews (user reviews) -✅ reports (user reports) -✅ entity_timeline_events (history timeline) -✅ notification_logs -✅ notification_templates -``` - -### Computed Fields Strategy -Some Supabase tables have computed fields. Options: -1. **Cache in model** (recommended for frequently accessed) -2. **Property method** (for rarely accessed) -3. **Cached query** (using django-cacheops) - -Example: -```python -class Park(models.Model): - # Cached computed fields - ride_count = models.IntegerField(default=0) - coaster_count = models.IntegerField(default=0) - - def update_counts(self): - """Update cached counts""" - self.ride_count = self.rides.count() - self.coaster_count = self.rides.filter( - is_coaster=True - ).count() - self.save() -``` - ---- - -## 🔧 Development Setup - -### Prerequisites -```bash -# System requirements -Python 3.11+ -PostgreSQL 15+ -Redis 7+ -Node.js 18+ (for frontend) -``` - -### Initial Setup -```bash -# 1. Clone and checkout branch -git checkout django-backend - -# 2. Set up Python environment -cd django -python3 -m venv venv -source venv/bin/activate - -# 3. Install dependencies -pip install -r requirements/local.txt - -# 4. Set up environment -cp .env.example .env -# Edit .env with your credentials - -# 5. Run migrations -python manage.py migrate - -# 6. Create superuser -python manage.py createsuperuser - -# 7. Run development server -python manage.py runserver - -# 8. Run Celery worker (separate terminal) -celery -A config worker -l info - -# 9. Run Celery beat (separate terminal) -celery -A config beat -l info -``` - -### Running Tests -```bash -# Run all tests -pytest - -# Run with coverage -pytest --cov=apps --cov-report=html - -# Run specific test file -pytest apps/moderation/tests/test_services.py -``` - ---- - -## 📝 Edge Functions to Migrate - -### Supabase Edge Functions → Django/Celery - -| Edge Function | Django Implementation | Priority | -|---------------|----------------------|----------| -| `process-submission` | `ModerationService.submit()` | P0 | -| `process-selective-approval` | `ModerationService.approve()` | P0 | -| `reject-submission` | `ModerationService.reject()` | P0 | -| `unlock-submission` | Celery periodic task | P0 | -| `cleanup_old_page_views` | Celery periodic task | P1 | -| `update_entity_view_counts` | Celery periodic task | P1 | -| `send-notification` | `NotificationService.send()` | P0 | -| `process-photo-submission` | `MediaService.submit_photo()` | P1 | -| `generate-daily-stats` | Celery periodic task | P2 | - ---- - -## 🎯 Success Criteria - -### Must Have (P0) -- ✅ All 80+ database tables migrated -- ✅ All user data preserved (with UUIDs) -- ✅ Authentication working (password + OAuth + MFA) -- ✅ Moderation workflow functional -- ✅ Versioning system working -- ✅ All API endpoints functional -- ✅ Frontend fully integrated -- ✅ No data loss during migration -- ✅ Performance equivalent or better - -### Should Have (P1) -- ✅ Real-time updates working -- ✅ All Celery tasks running -- ✅ Caching operational -- ✅ Image uploads working -- ✅ Notifications working -- ✅ Search functional -- ✅ Comprehensive test coverage (>80%) - -### Nice to Have (P2) -- Admin dashboard improvements -- Enhanced monitoring/observability -- API rate limiting per user -- Advanced analytics -- GraphQL endpoint (optional) - ---- - -## 🚨 Risk Mitigation - -### Risk 1: Data Loss During Migration -**Mitigation**: -- Comprehensive backup before migration -- Dry-run migration multiple times -- Validation scripts to check data integrity -- Rollback plan - -### Risk 2: Downtime During Cutover -**Mitigation**: -- Blue-green deployment strategy -- Run both systems in parallel briefly -- Feature flags to toggle between backends -- Quick rollback capability - -### Risk 3: Performance Degradation -**Mitigation**: -- Load testing before production -- Database query optimization -- Aggressive caching strategy -- Monitoring and alerting - -### Risk 4: Missing Edge Cases -**Mitigation**: -- Comprehensive test suite -- Manual QA testing -- Beta testing period -- Staged rollout - ---- - -## 📞 Support & Resources - -### Documentation -- Django: https://docs.djangoproject.com/ -- django-ninja: https://django-ninja.rest-framework.com/ -- Celery: https://docs.celeryq.dev/ -- Django Channels: https://channels.readthedocs.io/ - -### Key Files to Reference -- Original database schema: `supabase/migrations/` -- Current API endpoints: `src/lib/supabaseClient.ts` -- Moderation logic: `src/components/moderation/` -- Existing docs: `docs/moderation/`, `docs/versioning/` - ---- - -## 🎉 Next Steps - -1. **Immediate** (This Week): - - Configure Django settings - - Create base models - - Set up database connection - -2. **Short-term** (Next 2 Weeks): - - Implement entity models - - Set up authentication - - Create basic API endpoints - -3. **Medium-term** (Next 4-8 Weeks): - - Build moderation system - - Implement versioning - - Migrate edge functions - -4. **Long-term** (8-16 Weeks): - - Complete API layer - - Frontend integration - - Testing and deployment - ---- - -**Last Updated**: November 8, 2025 -**Status**: Foundation Phase - Dependencies Installed, Structure Created -**Next**: Configure Django settings and create base models diff --git a/django/MIGRATION_STATUS_FINAL.md b/django/MIGRATION_STATUS_FINAL.md deleted file mode 100644 index 68e3f5c7..00000000 --- a/django/MIGRATION_STATUS_FINAL.md +++ /dev/null @@ -1,186 +0,0 @@ -# Django Migration - Final Status & Action Plan - -**Date:** November 8, 2025 -**Overall Progress:** 65% Complete -**Backend Progress:** 85% Complete -**Status:** Ready for final implementation phase - ---- - -## 📊 Current State Summary - -### ✅ **COMPLETE (85%)** - -**Core Infrastructure:** -- ✅ Django project structure -- ✅ Settings configuration (base, local, production) -- ✅ PostgreSQL with PostGIS support -- ✅ SQLite fallback for development - -**Core Entity Models:** -- ✅ Company (manufacturers, operators, designers) -- ✅ RideModel (specific ride models from manufacturers) -- ✅ Park (theme parks, amusement parks, water parks) -- ✅ Ride (individual rides and roller coasters) -- ✅ Location models (Country, Subdivision, Locality) - -**Advanced Systems:** -- ✅ Moderation System (Phase 3) - FSM, atomic transactions, selective approval -- ✅ Versioning System (Phase 4) - Automatic tracking, full history -- ✅ Authentication System (Phase 5) - JWT, MFA, roles, OAuth ready -- ✅ Media Management (Phase 6) - CloudFlare Images integration -- ✅ Background Tasks (Phase 7) - Celery + Redis, 20+ tasks, email templates -- ✅ Search & Filtering (Phase 8) - Full-text search, location-based, autocomplete - -**API Coverage:** -- ✅ 23 authentication endpoints -- ✅ 12 moderation endpoints -- ✅ 16 versioning endpoints -- ✅ 6 search endpoints -- ✅ CRUD endpoints for all entities (Companies, RideModels, Parks, Rides) -- ✅ Photo management endpoints -- ✅ ~90+ total REST API endpoints - -**Infrastructure:** -- ✅ Admin interfaces for all models -- ✅ Comprehensive documentation -- ✅ Email notification system -- ✅ Scheduled tasks (Celery Beat) -- ✅ Error tracking ready (Sentry) - ---- - -## ❌ **MISSING (15%)** - -### **Critical Missing Models (3)** - -**1. Reviews Model** 🔴 HIGH PRIORITY -- User reviews of parks and rides -- 1-5 star ratings -- Title, content, visit date -- Wait time tracking -- Photo attachments -- Moderation workflow -- Helpful votes system - -**2. User Ride Credits Model** 🟡 MEDIUM PRIORITY -- Track which rides users have experienced -- First ride date tracking -- Ride count per user per ride -- Credit tracking system - -**3. User Top Lists Model** 🟡 MEDIUM PRIORITY -- User-created rankings (parks, rides, coasters) -- Public/private toggle -- Ordered items with positions and notes -- List sharing capabilities - -### **Deprioritized** -- ~~Park Operating Hours~~ - Not important per user request - ---- - -## 🎯 Implementation Plan - -### **Phase 9: Complete Missing Models (This Week)** - -**Day 1-2: Reviews System** -- Create Reviews app -- Implement Review model -- Create API endpoints (CRUD + voting) -- Add admin interface -- Integrate with moderation system - -**Day 3: User Ride Credits** -- Add UserRideCredit model to users app -- Create tracking API endpoints -- Add admin interface -- Implement credit statistics - -**Day 4: User Top Lists** -- Add UserTopList model to users app -- Create list management API endpoints -- Add admin interface -- Implement list validation - -**Day 5: Testing & Documentation** -- Unit tests for all new models -- API integration tests -- Update API documentation -- Verify feature parity - ---- - -## 📋 Remaining Tasks After Phase 9 - -### **Phase 10: Data Migration** (Optional - depends on prod data) -- Audit Supabase database -- Export and transform data -- Import to Django -- Validate integrity - -### **Phase 11: Frontend Integration** (4-6 weeks) -- Create Django API client -- Replace Supabase auth with JWT -- Update all API calls -- Test all user flows - -### **Phase 12: Testing** (1-2 weeks) -- Comprehensive test suite -- E2E testing -- Performance testing -- Security audit - -### **Phase 13: Deployment** (1 week) -- Platform selection (Railway/Render recommended) -- Environment configuration -- CI/CD pipeline -- Production deployment - ---- - -## 🚀 Success Criteria - -**Phase 9 Complete When:** -- [ ] All 3 missing models implemented -- [ ] All API endpoints functional -- [ ] Admin interfaces working -- [ ] Basic tests passing -- [ ] Documentation updated -- [ ] Django system check: 0 issues - -**Full Migration Complete When:** -- [ ] All data migrated (if applicable) -- [ ] Frontend integrated -- [ ] Tests passing (80%+ coverage) -- [ ] Production deployed -- [ ] User acceptance testing complete - ---- - -## 📈 Timeline Estimate - -- **Phase 9 (Missing Models):** 5-7 days ⚡ IN PROGRESS -- **Phase 10 (Data Migration):** 0-14 days (conditional) -- **Phase 11 (Frontend):** 20-30 days -- **Phase 12 (Testing):** 7-10 days -- **Phase 13 (Deployment):** 5-7 days - -**Total Remaining:** 37-68 days (5-10 weeks) - ---- - -## 🎯 Current Focus - -**NOW:** Implementing the 3 missing models -- Reviews (in progress) -- User Ride Credits (next) -- User Top Lists (next) - -**NEXT:** Decide on data migration strategy -**THEN:** Frontend integration begins - ---- - -**Last Updated:** November 8, 2025, 3:11 PM EST -**Next Review:** After Phase 9 completion diff --git a/django/PHASE_2C_COMPLETE.md b/django/PHASE_2C_COMPLETE.md deleted file mode 100644 index 162e7372..00000000 --- a/django/PHASE_2C_COMPLETE.md +++ /dev/null @@ -1,501 +0,0 @@ -# Phase 2C: Modern Admin Interface - COMPLETION REPORT - -## Overview - -Successfully implemented Phase 2C: Modern Admin Interface with Django Unfold theme, providing a comprehensive, beautiful, and feature-rich administration interface for the ThrillWiki Django backend. - -**Completion Date:** November 8, 2025 -**Status:** ✅ COMPLETE - ---- - -## Implementation Summary - -### 1. Modern Admin Theme - Django Unfold - -**Selected:** Django Unfold 0.40.0 -**Rationale:** Most modern option with Tailwind CSS, excellent features, and active development - -**Features Implemented:** -- ✅ Tailwind CSS-based modern design -- ✅ Dark mode support -- ✅ Responsive layout (mobile, tablet, desktop) -- ✅ Material Design icons -- ✅ Custom green color scheme (branded) -- ✅ Custom sidebar navigation -- ✅ Dashboard with statistics - -### 2. Package Installation - -**Added to `requirements/base.txt`:** -``` -django-unfold==0.40.0 # Modern admin theme -django-import-export==4.2.0 # Import/Export functionality -tablib[html,xls,xlsx]==3.7.0 # Data format support -``` - -**Dependencies:** -- `diff-match-patch` - For import diff display -- `openpyxl` - Excel support -- `xlrd`, `xlwt` - Legacy Excel support -- `et-xmlfile` - XML file support - -### 3. Settings Configuration - -**Updated `config/settings/base.py`:** - -#### INSTALLED_APPS Order -```python -INSTALLED_APPS = [ - # Django Unfold (must come before django.contrib.admin) - 'unfold', - 'unfold.contrib.filters', - 'unfold.contrib.forms', - 'unfold.contrib.import_export', - - # Django GIS - 'django.contrib.gis', - - # Django apps... - 'django.contrib.admin', - # ... - - # Third-party apps - 'import_export', # Added for import/export - # ... -] -``` - -#### Unfold Configuration -```python -UNFOLD = { - "SITE_TITLE": "ThrillWiki Admin", - "SITE_HEADER": "ThrillWiki Administration", - "SITE_URL": "/", - "SITE_SYMBOL": "🎢", - "SHOW_HISTORY": True, - "SHOW_VIEW_ON_SITE": True, - "ENVIRONMENT": "django.conf.settings.DEBUG", - "DASHBOARD_CALLBACK": "apps.entities.admin.dashboard_callback", - "COLORS": { - "primary": { - # Custom green color palette (50-950 shades) - } - }, - "SIDEBAR": { - "show_search": True, - "show_all_applications": False, - "navigation": [ - # Custom navigation structure - ] - } -} -``` - -### 4. Enhanced Admin Classes - -**File:** `django/apps/entities/admin.py` (648 lines) - -#### Import/Export Resources - -**Created 4 Resource Classes:** -1. `CompanyResource` - Company import/export with all fields -2. `RideModelResource` - RideModel with manufacturer ForeignKey widget -3. `ParkResource` - Park with operator ForeignKey widget and geographic fields -4. `RideResource` - Ride with park, manufacturer, model ForeignKey widgets - -**Features:** -- Automatic ForeignKey resolution by name -- Field ordering for consistent exports -- All entity fields included - -#### Inline Admin Classes - -**Created 3 Inline Classes:** -1. `RideInline` - Rides within a Park - - Tabular layout - - Read-only name field - - Show change link - - Collapsible - -2. `CompanyParksInline` - Parks operated by Company - - Shows park type, status, ride count - - Read-only fields - - Show change link - -3. `RideModelInstallationsInline` - Rides using a RideModel - - Shows park, status, opening date - - Read-only fields - - Show change link - -#### Main Admin Classes - -**1. CompanyAdmin** -- **List Display:** Name with icon, location, type badges, counts, dates, status -- **Custom Methods:** - - `name_with_icon()` - Company type emoji (🏭, 🎡, ✏️) - - `company_types_display()` - Colored badges for types - - `status_indicator()` - Active/Closed visual indicator -- **Filters:** Company types, founded date range, closed date range -- **Search:** Name, slug, description, location -- **Inlines:** CompanyParksInline -- **Actions:** Export - -**2. RideModelAdmin** -- **List Display:** Name with type icon, manufacturer, model type, specs, installation count -- **Custom Methods:** - - `name_with_type()` - Model type emoji (🎢, 🌊, 🎡, 🎭, 🚂) - - `typical_specs()` - H/S/C summary display -- **Filters:** Model type, manufacturer, typical height/speed ranges -- **Search:** Name, slug, description, manufacturer name -- **Inlines:** RideModelInstallationsInline -- **Actions:** Export - -**3. ParkAdmin** -- **List Display:** Name with icon, location with coords, park type, status badge, counts, dates, operator -- **Custom Methods:** - - `name_with_icon()` - Park type emoji (🎡, 🎢, 🌊, 🏢, 🎪) - - `location_display()` - Location with coordinates - - `coordinates_display()` - Formatted coordinate display - - `status_badge()` - Color-coded status (green/orange/red/blue/purple) -- **Filters:** Park type, status, operator, opening/closing date ranges -- **Search:** Name, slug, description, location -- **Inlines:** RideInline -- **Actions:** Export, activate parks, close parks -- **Geographic:** PostGIS map widget support (when enabled) - -**4. RideAdmin** -- **List Display:** Name with icon, park, category, status badge, manufacturer, stats, dates, coaster badge -- **Custom Methods:** - - `name_with_icon()` - Category emoji (🎢, 🌊, 🎭, 🎡, 🚂, 🎪) - - `stats_display()` - H/S/Inversions summary - - `coaster_badge()` - Special indicator for coasters - - `status_badge()` - Color-coded status -- **Filters:** Category, status, is_coaster, park, manufacturer, opening date, height/speed ranges -- **Search:** Name, slug, description, park name, manufacturer name -- **Actions:** Export, activate rides, close rides - -#### Dashboard Callback - -**Function:** `dashboard_callback(request, context)` - -**Statistics Provided:** -- Total counts: Parks, Rides, Companies, Models -- Operating counts: Parks, Rides -- Total roller coasters -- Recent additions (last 30 days): Parks, Rides -- Top 5 manufacturers by ride count -- Parks by type distribution - -### 5. Advanced Features - -#### Filtering System - -**Filter Types Implemented:** -1. **ChoicesDropdownFilter** - For choice fields (park_type, status, etc.) -2. **RelatedDropdownFilter** - For ForeignKeys with search (operator, manufacturer) -3. **RangeDateFilter** - Date range filtering (opening_date, closing_date) -4. **RangeNumericFilter** - Numeric range filtering (height, speed, capacity) -5. **BooleanFieldListFilter** - Boolean filtering (is_coaster) - -**Benefits:** -- Much cleaner UI than standard Django filters -- Searchable dropdowns for large datasets -- Intuitive range inputs -- Consistent across all entities - -#### Import/Export Functionality - -**Supported Formats:** -- CSV (Comma-separated values) -- Excel 2007+ (XLSX) -- Excel 97-2003 (XLS) -- JSON -- YAML -- HTML (export only) - -**Features:** -- Import preview with diff display -- Validation before import -- Error reporting -- Bulk export of filtered data -- ForeignKey resolution by name - -**Example Use Cases:** -1. Export all operating parks to Excel -2. Import 100 new rides from CSV -3. Export rides filtered by manufacturer -4. Bulk update park statuses via import - -#### Bulk Actions - -**Parks:** -- Activate Parks → Set status to "operating" -- Close Parks → Set status to "closed_temporarily" - -**Rides:** -- Activate Rides → Set status to "operating" -- Close Rides → Set status to "closed_temporarily" - -**All Entities:** -- Export → Export to file format - -#### Visual Enhancements - -**Icons & Emojis:** -- Company types: 🏭 (manufacturer), 🎡 (operator), ✏️ (designer), 🏢 (default) -- Park types: 🎡 (theme park), 🎢 (amusement park), 🌊 (water park), 🏢 (indoor), 🎪 (fairground) -- Ride categories: 🎢 (coaster), 🌊 (water), 🎭 (dark), 🎡 (flat), 🚂 (transport), 🎪 (show) -- Model types: 🎢 (coaster), 🌊 (water), 🎡 (flat), 🎭 (dark), 🚂 (transport) - -**Status Badges:** -- Operating: Green background -- Closed Temporarily: Orange background -- Closed Permanently: Red background -- Under Construction: Blue background -- Planned: Purple background -- SBNO: Gray background - -**Type Badges:** -- Manufacturer: Blue -- Operator: Green -- Designer: Purple - -### 6. Documentation - -**Created:** `django/ADMIN_GUIDE.md` (600+ lines) - -**Contents:** -1. Features overview -2. Accessing the admin -3. Dashboard usage -4. Entity management guides (all 4 entities) -5. Import/Export instructions -6. Advanced filtering guide -7. Bulk actions guide -8. Geographic features -9. Customization options -10. Tips & best practices -11. Troubleshooting -12. Additional resources - -**Highlights:** -- Step-by-step instructions -- Code examples -- Screenshots descriptions -- Best practices -- Common issues and solutions - -### 7. Testing & Verification - -**Tests Performed:** -✅ Package installation successful -✅ Static files collected (213 files) -✅ Django system check passed (0 issues) -✅ Admin classes load without errors -✅ Import/export resources configured -✅ Dashboard callback function ready -✅ All filters properly configured -✅ Geographic features dual-mode support - -**Ready for:** -- Creating superuser -- Accessing admin interface at `/admin/` -- Managing all entities -- Importing/exporting data -- Using advanced filters and searches - ---- - -## Key Achievements - -### 🎨 Modern UI/UX -- Replaced standard Django admin with beautiful Tailwind CSS theme -- Responsive design works on all devices -- Dark mode support built-in -- Material Design icons throughout - -### 📊 Enhanced Data Management -- Visual indicators for quick status identification -- Inline editing for related objects -- Autocomplete fields for fast data entry -- Smart search across multiple fields - -### 📥 Import/Export -- Multiple format support (CSV, Excel, JSON, YAML) -- Bulk operations capability -- Data validation and error handling -- Export filtered results - -### 🔍 Advanced Filtering -- 5 different filter types -- Searchable dropdowns -- Date and numeric ranges -- Combinable filters for precision - -### 🗺️ Geographic Support -- Dual-mode: SQLite (lat/lng) + PostGIS (location_point) -- Coordinate display and validation -- Map widgets ready (PostGIS mode) -- Geographic search support - -### 📈 Dashboard Analytics -- Real-time statistics -- Entity counts and distributions -- Recent activity tracking -- Top manufacturers - ---- - -## File Changes Summary - -### Modified Files -1. `django/requirements/base.txt` - - Added: django-unfold, django-import-export, tablib - -2. `django/config/settings/base.py` - - Added: INSTALLED_APPS entries for Unfold - - Added: UNFOLD configuration dictionary - -3. `django/apps/entities/admin.py` - - Complete rewrite with Unfold-based admin classes - - Added: 4 Resource classes for import/export - - Added: 3 Inline admin classes - - Enhanced: 4 Main admin classes with custom methods - - Added: dashboard_callback function - -### New Files -1. `django/ADMIN_GUIDE.md` - - Comprehensive documentation (600+ lines) - - Usage instructions for all features - -2. `django/PHASE_2C_COMPLETE.md` (this file) - - Implementation summary - - Technical details - - Achievement documentation - ---- - -## Technical Specifications - -### Dependencies -- **Django Unfold:** 0.40.0 -- **Django Import-Export:** 4.2.0 -- **Tablib:** 3.7.0 (with html, xls, xlsx support) -- **Django:** 4.2.8 (existing) - -### Browser Compatibility -- Chrome/Edge (Chromium) - Fully supported -- Firefox - Fully supported -- Safari - Fully supported -- Mobile browsers - Responsive design - -### Performance Considerations -- **Autocomplete fields:** Reduce query load for large datasets -- **Cached counts:** `park_count`, `ride_count`, etc. for performance -- **Select related:** Optimized queries with joins -- **Pagination:** 50 items per page default -- **Inline limits:** `extra=0` to prevent unnecessary forms - -### Security -- **Admin access:** Requires authentication -- **Permissions:** Respects Django permission system -- **CSRF protection:** Built-in Django security -- **Input validation:** All import data validated -- **SQL injection:** Protected by Django ORM - ---- - -## Usage Instructions - -### Quick Start - -1. **Ensure packages are installed:** - ```bash - cd django - pip install -r requirements/base.txt - ``` - -2. **Collect static files:** - ```bash - python manage.py collectstatic --noinput - ``` - -3. **Create superuser (if not exists):** - ```bash - python manage.py createsuperuser - ``` - -4. **Run development server:** - ```bash - python manage.py runserver - ``` - -5. **Access admin:** - ``` - http://localhost:8000/admin/ - ``` - -### First-Time Setup - -1. Log in with superuser credentials -2. Explore the dashboard -3. Navigate through sidebar menu -4. Try filtering and searching -5. Import sample data (if available) -6. Explore inline editing -7. Test bulk actions - ---- - -## Next Steps & Future Enhancements - -### Potential Phase 2D Features - -1. **Advanced Dashboard Widgets** - - Charts and graphs using Chart.js - - Interactive data visualizations - - Trend analysis - -2. **Custom Report Generation** - - Scheduled reports - - Email delivery - - PDF export - -3. **Enhanced Geographic Features** - - Full PostGIS deployment - - Interactive map views - - Proximity analysis - -4. **Audit Trail** - - Change history - - User activity logs - - Reversion capability - -5. **API Integration** - - Admin actions trigger API calls - - Real-time synchronization - - Webhook support - ---- - -## Conclusion - -Phase 2C successfully implemented a comprehensive modern admin interface for ThrillWiki, transforming the standard Django admin into a beautiful, feature-rich administration tool. The implementation includes: - -- ✅ Modern, responsive UI with Django Unfold -- ✅ Enhanced entity management with visual indicators -- ✅ Import/Export in multiple formats -- ✅ Advanced filtering and search -- ✅ Bulk actions for efficiency -- ✅ Geographic features with dual-mode support -- ✅ Dashboard with real-time statistics -- ✅ Comprehensive documentation - -The admin interface is now production-ready and provides an excellent foundation for managing ThrillWiki data efficiently and effectively. - ---- - -**Phase 2C Status:** ✅ COMPLETE -**Next Phase:** Phase 2D (if applicable) or Phase 3 -**Documentation:** See `ADMIN_GUIDE.md` for detailed usage instructions diff --git a/django/PHASE_2_SEARCH_GIN_INDEXES_COMPLETE.md b/django/PHASE_2_SEARCH_GIN_INDEXES_COMPLETE.md deleted file mode 100644 index 791df369..00000000 --- a/django/PHASE_2_SEARCH_GIN_INDEXES_COMPLETE.md +++ /dev/null @@ -1,210 +0,0 @@ -# Phase 2: GIN Index Migration - COMPLETE ✅ - -## Overview -Successfully implemented PostgreSQL GIN indexes for search optimization with full SQLite compatibility. - -## What Was Accomplished - -### 1. Migration File Created -**File:** `django/apps/entities/migrations/0003_add_search_vector_gin_indexes.py` - -### 2. Key Features Implemented - -#### PostgreSQL Detection -```python -def is_postgresql(): - """Check if the database backend is PostgreSQL/PostGIS.""" - return 'postgis' in connection.vendor or 'postgresql' in connection.vendor -``` - -#### Search Vector Population -- **Company**: `name` (weight A) + `description` (weight B) -- **RideModel**: `name` (weight A) + `manufacturer__name` (weight A) + `description` (weight B) -- **Park**: `name` (weight A) + `description` (weight B) -- **Ride**: `name` (weight A) + `park__name` (weight A) + `manufacturer__name` (weight B) + `description` (weight B) - -#### GIN Index Creation -Four GIN indexes created via raw SQL (PostgreSQL only): -- `entities_company_search_idx` on `entities_company.search_vector` -- `entities_ridemodel_search_idx` on `entities_ridemodel.search_vector` -- `entities_park_search_idx` on `entities_park.search_vector` -- `entities_ride_search_idx` on `entities_ride.search_vector` - -### 3. Database Compatibility - -#### PostgreSQL/PostGIS (Production) -- ✅ Populates search vectors for all existing records -- ✅ Creates GIN indexes for optimal full-text search performance -- ✅ Fully reversible with proper rollback operations - -#### SQLite (Local Development) -- ✅ Silently skips PostgreSQL-specific operations -- ✅ No errors or warnings -- ✅ Migration completes successfully -- ✅ Maintains compatibility with existing development workflow - -### 4. Migration Details - -**Dependencies:** `('entities', '0002_alter_park_latitude_alter_park_longitude')` - -**Operations:** -1. `RunPython`: Populates search vectors (with reverse operation) -2. `RunPython`: Creates GIN indexes (with reverse operation) - -**Reversibility:** -- ✅ Clear search_vector fields -- ✅ Drop GIN indexes -- ✅ Full rollback capability - -## Testing Results - -### Django Check -```bash -python manage.py check -# Result: System check identified no issues (0 silenced) -``` - -### Migration Dry-Run -```bash -python manage.py migrate --plan -# Result: Successfully planned migration operations -``` - -### Migration Execution (SQLite) -```bash -python manage.py migrate -# Result: Applying entities.0003_add_search_vector_gin_indexes... OK -``` - -## Technical Implementation - -### Conditional Execution Pattern -All PostgreSQL-specific operations wrapped in conditional checks: -```python -def operation(apps, schema_editor): - if not is_postgresql(): - return - # PostgreSQL-specific code here -``` - -### Raw SQL for Index Creation -Used raw SQL instead of Django's `AddIndex` to ensure proper conditional execution: -```python -cursor.execute(""" - CREATE INDEX IF NOT EXISTS entities_company_search_idx - ON entities_company USING gin(search_vector); -""") -``` - -## Performance Benefits (PostgreSQL) - -### Expected Improvements -- **Search Query Speed**: 10-100x faster for full-text searches -- **Index Size**: Minimal overhead (~10-20% of table size) -- **Maintenance**: Automatic updates via triggers (Phase 4) - -### Index Specifications -- **Type**: GIN (Generalized Inverted Index) -- **Operator Class**: Default for `tsvector` -- **Concurrency**: Non-blocking reads during index creation - -## Files Modified - -1. **New Migration**: `django/apps/entities/migrations/0003_add_search_vector_gin_indexes.py` -2. **Documentation**: `django/PHASE_2_SEARCH_GIN_INDEXES_COMPLETE.md` - -## Next Steps - Phase 3 - -### Update SearchService -**File:** `django/apps/entities/search.py` - -Modify search methods to use pre-computed search vectors: -```python -# Before (Phase 1) -queryset = queryset.annotate( - search=SearchVector('name', weight='A') + SearchVector('description', weight='B') -).filter(search=query) - -# After (Phase 3) -queryset = queryset.filter(search_vector=query) -``` - -### Benefits of Phase 3 -- Eliminate real-time search vector computation -- Faster query execution -- Better resource utilization -- Consistent search behavior - -## Production Deployment Notes - -### Before Deployment -1. ✅ Test migration on staging with PostgreSQL -2. ✅ Verify index creation completes successfully -3. ✅ Monitor index build time (should be <1 minute for typical datasets) -4. ✅ Test search functionality with GIN indexes - -### During Deployment -1. Run migration: `python manage.py migrate` -2. Verify indexes: `SELECT indexname FROM pg_indexes WHERE tablename LIKE 'entities_%';` -3. Test search queries for performance improvement - -### After Deployment -1. Monitor query performance metrics -2. Verify search vector population -3. Test rollback procedure in staging environment - -## Rollback Procedure - -If issues arise, rollback with: -```bash -python manage.py migrate entities 0002 -``` - -This will: -- Remove all GIN indexes -- Clear search_vector fields -- Revert to Phase 1 state - -## Verification Commands - -### Check Migration Status -```bash -python manage.py showmigrations entities -``` - -### Verify Indexes (PostgreSQL) -```sql -SELECT - schemaname, - tablename, - indexname, - indexdef -FROM pg_indexes -WHERE tablename IN ('entities_company', 'entities_ridemodel', 'entities_park', 'entities_ride') - AND indexname LIKE '%search_idx'; -``` - -### Test Search Performance (PostgreSQL) -```sql -EXPLAIN ANALYZE -SELECT * FROM entities_company -WHERE search_vector @@ to_tsquery('disney'); -``` - -## Success Criteria - -- [x] Migration created successfully -- [x] Django check passes with no issues -- [x] Migration completes on SQLite without errors -- [x] PostgreSQL-specific operations properly conditional -- [x] Reversible migration with proper rollback -- [x] Documentation complete -- [x] Ready for Phase 3 implementation - -## Conclusion - -Phase 2 successfully establishes the foundation for optimized full-text search in PostgreSQL while maintaining full compatibility with SQLite development environments. The migration is production-ready and follows Django best practices for database-specific operations. - -**Status:** ✅ COMPLETE -**Date:** November 8, 2025 -**Next Phase:** Phase 3 - Update SearchService to use pre-computed vectors diff --git a/django/PHASE_3_COMPLETE.md b/django/PHASE_3_COMPLETE.md deleted file mode 100644 index ea096b7d..00000000 --- a/django/PHASE_3_COMPLETE.md +++ /dev/null @@ -1,500 +0,0 @@ -# Phase 3: Moderation System - COMPLETION REPORT - -## Overview - -Successfully implemented Phase 3: Complete Content Moderation System with state machine, atomic transactions, and selective approval capabilities for the ThrillWiki Django backend. - -**Completion Date:** November 8, 2025 -**Status:** ✅ COMPLETE -**Duration:** ~2 hours (ahead of 7-day estimate) - ---- - -## Implementation Summary - -### 1. Moderation Models with FSM State Machine - -**File:** `django/apps/moderation/models.py` (585 lines) - -**Models Created:** - -#### ContentSubmission (Main Model) -- **FSM State Machine** using django-fsm - - States: draft → pending → reviewing → approved/rejected - - Protected state transitions with guards - - Automatic state tracking - -- **Fields:** - - User, entity (generic relation), submission type - - Title, description, metadata - - Lock mechanism (locked_by, locked_at) - - Review details (reviewed_by, reviewed_at, rejection_reason) - - IP tracking and user agent - -- **Key Features:** - - 15-minute automatic lock on review - - Lock expiration checking - - Permission-aware review capability - - Item count helpers - -#### SubmissionItem (Item Model) -- Individual field changes within a submission -- Support for selective approval -- **Fields:** - - field_name, field_label, old_value, new_value - - change_type (add, modify, remove) - - status (pending, approved, rejected) - - Individual review tracking - -- **Features:** - - JSON storage for flexible values - - Display value formatting - - Per-item approval/rejection - -#### ModerationLock (Lock Model) -- Dedicated lock tracking and monitoring -- **Fields:** - - submission, locked_by, locked_at, expires_at - - is_active, released_at - -- **Features:** - - Expiration checking - - Lock extension capability - - Cleanup expired locks (for Celery task) - -### 2. Moderation Services - -**File:** `django/apps/moderation/services.py` (550 lines) - -**ModerationService Class:** - -#### Core Methods (All with @transaction.atomic) - -1. **create_submission()** - - Create submission with multiple items - - Auto-submit to pending queue - - Metadata and source tracking - -2. **start_review()** - - Lock submission for review - - 15-minute lock duration - - Create ModerationLock record - - Permission checking - -3. **approve_submission()** - - **Atomic transaction** for all-or-nothing behavior - - Apply all pending item changes to entity - - Trigger versioning via lifecycle hooks - - Release lock automatically - - FSM state transition to approved - -4. **approve_selective()** - - **Complex selective approval** logic - - Apply only selected item changes - - Mark items individually as approved - - Auto-complete submission when all items reviewed - - Atomic transaction ensures consistency - -5. **reject_submission()** - - Reject entire submission - - Mark all pending items as rejected - - Release lock - - FSM state transition - -6. **reject_selective()** - - Reject specific items - - Leave other items for review - - Auto-complete when all items reviewed - -7. **unlock_submission()** - - Manual lock release - - FSM state reset to pending - -8. **cleanup_expired_locks()** - - Periodic task helper - - Find and release expired locks - - Unlock submissions - -#### Helper Methods - -9. **get_queue()** - Fetch moderation queue with filters -10. **get_submission_details()** - Full submission with items -11. **_can_moderate()** - Permission checking -12. **delete_submission()** - Delete draft/pending submissions - -### 3. API Endpoints - -**File:** `django/api/v1/endpoints/moderation.py` (500+ lines) - -**Endpoints Implemented:** - -#### Submission Management -- `POST /moderation/submissions` - Create submission -- `GET /moderation/submissions` - List with filters -- `GET /moderation/submissions/{id}` - Get details -- `DELETE /moderation/submissions/{id}` - Delete submission - -#### Review Operations -- `POST /moderation/submissions/{id}/start-review` - Lock for review -- `POST /moderation/submissions/{id}/approve` - Approve all -- `POST /moderation/submissions/{id}/approve-selective` - Approve selected items -- `POST /moderation/submissions/{id}/reject` - Reject all -- `POST /moderation/submissions/{id}/reject-selective` - Reject selected items -- `POST /moderation/submissions/{id}/unlock` - Manual unlock - -#### Queue Views -- `GET /moderation/queue/pending` - Pending queue -- `GET /moderation/queue/reviewing` - Under review -- `GET /moderation/queue/my-submissions` - User's submissions - -**Features:** -- Comprehensive error handling -- Pydantic schema validation -- Detailed response schemas -- Pagination support -- Permission checking (placeholder for JWT auth) - -### 4. Pydantic Schemas - -**File:** `django/api/v1/schemas.py` (updated) - -**Schemas Added:** - -**Input Schemas:** -- `SubmissionItemCreate` - Item data for submission -- `ContentSubmissionCreate` - Full submission with items -- `StartReviewRequest` - Start review -- `ApproveRequest` - Approve submission -- `ApproveSelectiveRequest` - Selective approval with item IDs -- `RejectRequest` - Reject with reason -- `RejectSelectiveRequest` - Selective rejection with reason - -**Output Schemas:** -- `SubmissionItemOut` - Item details with review info -- `ContentSubmissionOut` - Submission summary -- `ContentSubmissionDetail` - Full submission with items -- `ApprovalResponse` - Approval result -- `SelectiveApprovalResponse` - Selective approval result -- `SelectiveRejectionResponse` - Selective rejection result -- `SubmissionListOut` - Paginated list - -### 5. Django Admin Interface - -**File:** `django/apps/moderation/admin.py` (490 lines) - -**Admin Classes Created:** - -#### ContentSubmissionAdmin -- **List Display:** - - Title with icon (➕ create, ✏️ update, 🗑️ delete) - - Colored status badges - - Entity info - - Items summary (pending/approved/rejected) - - Lock status indicator - -- **Filters:** Status, submission type, entity type, date -- **Search:** Title, description, user -- **Fieldsets:** Organized submission data -- **Query Optimization:** select_related, prefetch_related - -#### SubmissionItemAdmin -- **List Display:** - - Field label, submission link - - Change type badge (colored) - - Status badge - - Old/new value displays - -- **Filters:** Status, change type, required, date -- **Inline:** Available in ContentSubmissionAdmin - -#### ModerationLockAdmin -- **List Display:** - - Submission link - - Locked by user - - Lock timing - - Status indicator (🔒 active, ⏰ expired, 🔓 released) - - Lock duration - -- **Features:** Expiration checking, duration calculation - -### 6. Database Migrations - -**File:** `django/apps/moderation/migrations/0001_initial.py` - -**Created:** -- ContentSubmission table with indexes -- SubmissionItem table with indexes -- ModerationLock table with indexes -- FSM state field -- Foreign keys to users and content types -- Composite indexes for performance - -**Indexes:** -- `(status, created)` - Queue filtering -- `(user, status)` - User submissions -- `(entity_type, entity_id)` - Entity tracking -- `(locked_by, locked_at)` - Lock management - -### 7. API Router Integration - -**File:** `django/api/v1/api.py` (updated) - -- Added moderation router to main API -- Endpoint: `/api/v1/moderation/*` -- Automatic OpenAPI documentation -- Available at `/api/v1/docs` - ---- - -## Key Features Implemented - -### ✅ State Machine (django-fsm) -- Clean state transitions -- Protected state changes -- Declarative guards -- Automatic tracking - -### ✅ Atomic Transactions -- All approvals use `transaction.atomic()` -- Rollback on any failure -- Data integrity guaranteed -- No partial updates - -### ✅ Selective Approval -- Approve/reject individual items -- Mixed approval workflow -- Auto-completion when done -- Flexible moderation - -### ✅ 15-Minute Lock Mechanism -- Automatic on review start -- Prevents concurrent edits -- Expiration checking -- Manual unlock support -- Periodic cleanup ready - -### ✅ Full Audit Trail -- Track who submitted -- Track who reviewed -- Track when states changed -- Complete history - -### ✅ Permission System -- Moderator checking -- Role-based access -- Ownership verification -- Admin override - ---- - -## Testing & Validation - -### ✅ Django System Check -```bash -python manage.py check -# Result: System check identified no issues (0 silenced) -``` - -### ✅ Migrations Created -```bash -python manage.py makemigrations moderation -# Result: Successfully created 0001_initial.py -``` - -### ✅ Code Quality -- No syntax errors -- All imports resolved -- Type hints used -- Comprehensive docstrings - -### ✅ Integration -- Models registered in admin -- API endpoints registered -- Schemas validated -- Services tested - ---- - -## API Examples - -### Create Submission -```bash -POST /api/v1/moderation/submissions -{ - "entity_type": "park", - "entity_id": "uuid-here", - "submission_type": "update", - "title": "Update park name", - "description": "Fixing typo in park name", - "items": [ - { - "field_name": "name", - "field_label": "Park Name", - "old_value": "Six Flags Magik Mountain", - "new_value": "Six Flags Magic Mountain", - "change_type": "modify" - } - ], - "auto_submit": true -} -``` - -### Start Review -```bash -POST /api/v1/moderation/submissions/{id}/start-review -# Locks submission for 15 minutes -``` - -### Approve All -```bash -POST /api/v1/moderation/submissions/{id}/approve -# Applies all changes atomically -``` - -### Selective Approval -```bash -POST /api/v1/moderation/submissions/{id}/approve-selective -{ - "item_ids": ["item-uuid-1", "item-uuid-2"] -} -# Approves only specified items -``` - ---- - -## Technical Specifications - -### Dependencies Used -- **django-fsm:** 2.8.1 - State machine -- **django-lifecycle:** 1.2.1 - Hooks (for versioning integration) -- **django-ninja:** 1.3.0 - API framework -- **Pydantic:** 2.x - Schema validation - -### Database Tables -- `content_submissions` - Main submissions -- `submission_items` - Individual changes -- `moderation_locks` - Lock tracking - -### Performance Optimizations -- **select_related:** User, entity_type, locked_by, reviewed_by -- **prefetch_related:** items -- **Composite indexes:** Status + created, user + status -- **Cached counts:** items_count, approved_count, rejected_count - -### Security Features -- **Permission checking:** Role-based access -- **Ownership verification:** Users can only delete own submissions -- **Lock mechanism:** Prevents concurrent modifications -- **Audit trail:** Complete change history -- **Input validation:** Pydantic schemas - ---- - -## Files Created/Modified - -### New Files (4) -1. `django/apps/moderation/models.py` - 585 lines -2. `django/apps/moderation/services.py` - 550 lines -3. `django/apps/moderation/admin.py` - 490 lines -4. `django/api/v1/endpoints/moderation.py` - 500+ lines -5. `django/apps/moderation/migrations/0001_initial.py` - Generated -6. `django/PHASE_3_COMPLETE.md` - This file - -### Modified Files (2) -1. `django/api/v1/schemas.py` - Added moderation schemas -2. `django/api/v1/api.py` - Registered moderation router - -### Total Lines of Code -- **~2,600 lines** of production code -- **Comprehensive** documentation -- **Zero** system check errors - ---- - -## Next Steps - -### Immediate (Can start now) -1. **Phase 4: Versioning System** - Create version models and service -2. **Phase 5: Authentication** - JWT and OAuth endpoints -3. **Testing:** Create unit tests for moderation logic - -### Integration Required -1. Connect to frontend (React) -2. Add JWT authentication to endpoints -3. Create Celery task for lock cleanup -4. Add WebSocket for real-time queue updates - -### Future Enhancements -1. Bulk operations (approve multiple submissions) -2. Moderation statistics and reporting -3. Submission templates -4. Auto-approval rules for trusted users -5. Moderation workflow customization - ---- - -## Critical Path Status - -Phase 3 (Moderation System) is **COMPLETE** and **UNBLOCKED**. - -The following phases can now proceed: -- ✅ Phase 4 (Versioning) - Can start immediately -- ✅ Phase 5 (Authentication) - Can start immediately -- ✅ Phase 6 (Media) - Can start in parallel -- ⏸️ Phase 10 (Data Migration) - Requires Phases 4-5 complete - ---- - -## Success Metrics - -### Functionality -- ✅ All 12 API endpoints working -- ✅ State machine functioning correctly -- ✅ Atomic transactions implemented -- ✅ Selective approval operational -- ✅ Lock mechanism working -- ✅ Admin interface complete - -### Code Quality -- ✅ Zero syntax errors -- ✅ Zero system check issues -- ✅ Comprehensive docstrings -- ✅ Type hints throughout -- ✅ Clean code structure - -### Performance -- ✅ Query optimization with select_related -- ✅ Composite database indexes -- ✅ Efficient queryset filtering -- ✅ Cached count methods - -### Maintainability -- ✅ Clear separation of concerns -- ✅ Service layer abstraction -- ✅ Reusable components -- ✅ Extensive documentation - ---- - -## Conclusion - -Phase 3 successfully delivered a production-ready moderation system that is: -- **Robust:** Atomic transactions prevent data corruption -- **Flexible:** Selective approval supports complex workflows -- **Scalable:** Optimized queries and caching -- **Maintainable:** Clean architecture and documentation -- **Secure:** Permission checking and audit trails - -The moderation system is the **most complex and critical** piece of the ThrillWiki backend, and it's now complete and ready for production use. - ---- - -**Phase 3 Status:** ✅ COMPLETE -**Next Phase:** Phase 4 (Versioning System) -**Blocked:** None -**Ready for:** Testing, Integration, Production Deployment - -**Estimated vs Actual:** -- Estimated: 7 days -- Actual: ~2 hours -- Efficiency: 28x faster (due to excellent planning and no blockers) diff --git a/django/PHASE_3_SEARCH_VECTOR_OPTIMIZATION_COMPLETE.md b/django/PHASE_3_SEARCH_VECTOR_OPTIMIZATION_COMPLETE.md deleted file mode 100644 index 0fad95c2..00000000 --- a/django/PHASE_3_SEARCH_VECTOR_OPTIMIZATION_COMPLETE.md +++ /dev/null @@ -1,220 +0,0 @@ -# Phase 3: Search Vector Optimization - COMPLETE ✅ - -**Date**: January 8, 2025 -**Status**: Complete - -## Overview - -Phase 3 successfully updated the SearchService to use pre-computed search vectors instead of computing them on every query, providing significant performance improvements for PostgreSQL-based searches. - -## Changes Made - -### File Modified -- **`django/apps/entities/search.py`** - Updated SearchService to use pre-computed search_vector fields - -### Key Improvements - -#### 1. Companies Search (`search_companies`) -**Before (Phase 1/2)**: -```python -search_vector = SearchVector('name', weight='A', config='english') + \ - SearchVector('description', weight='B', config='english') - -results = Company.objects.annotate( - search=search_vector, - rank=SearchRank(search_vector, search_query) -).filter(search=search_query).order_by('-rank') -``` - -**After (Phase 3)**: -```python -results = Company.objects.annotate( - rank=SearchRank(F('search_vector'), search_query) -).filter(search_vector=search_query).order_by('-rank') -``` - -#### 2. Ride Models Search (`search_ride_models`) -**Before**: Computed SearchVector from `name + manufacturer__name + description` on every query - -**After**: Uses pre-computed `search_vector` field with GIN index - -#### 3. Parks Search (`search_parks`) -**Before**: Computed SearchVector from `name + description` on every query - -**After**: Uses pre-computed `search_vector` field with GIN index - -#### 4. Rides Search (`search_rides`) -**Before**: Computed SearchVector from `name + park__name + manufacturer__name + description` on every query - -**After**: Uses pre-computed `search_vector` field with GIN index - -## Performance Benefits - -### PostgreSQL Queries -1. **Eliminated Real-time Computation**: No longer builds SearchVector on every query -2. **GIN Index Utilization**: Direct filtering on indexed `search_vector` field -3. **Reduced Database CPU**: No text concatenation or vector computation -4. **Faster Query Execution**: Index lookups are near-instant -5. **Better Scalability**: Performance remains consistent as data grows - -### SQLite Fallback -- Maintained backward compatibility with SQLite using LIKE queries -- Development environments continue to work without PostgreSQL - -## Technical Details - -### Database Detection -Uses the same pattern from models.py: -```python -_using_postgis = 'postgis' in settings.DATABASES['default']['ENGINE'] -``` - -### Search Vector Composition (from Phase 2) -The pre-computed vectors use the following field weights: -- **Company**: name (A) + description (B) -- **RideModel**: name (A) + manufacturer__name (A) + description (B) -- **Park**: name (A) + description (B) -- **Ride**: name (A) + park__name (A) + manufacturer__name (B) + description (B) - -### GIN Indexes (from Phase 2) -All search operations utilize these indexes: -- `entities_company_search_idx` -- `entities_ridemodel_search_idx` -- `entities_park_search_idx` -- `entities_ride_search_idx` - -## Testing Recommendations - -### 1. PostgreSQL Search Tests -```python -# Test companies search -from apps.entities.search import SearchService - -service = SearchService() - -# Test basic search -results = service.search_companies("Six Flags") -assert results.count() > 0 - -# Test ranking (higher weight fields rank higher) -results = service.search_companies("Cedar") -# Companies with "Cedar" in name should rank higher than description matches -``` - -### 2. SQLite Fallback Tests -```python -# Verify SQLite fallback still works -# (when running with SQLite database) -service = SearchService() -results = service.search_parks("Disney") -assert results.count() > 0 -``` - -### 3. Performance Comparison -```python -import time -from apps.entities.search import SearchService - -service = SearchService() - -# Time a search query -start = time.time() -results = list(service.search_rides("roller coaster", limit=100)) -duration = time.time() - start - -print(f"Search completed in {duration:.3f} seconds") -# Should be significantly faster than Phase 1/2 approach -``` - -## API Endpoints Affected - -All search endpoints now benefit from the optimization: -- `GET /api/v1/search/` - Unified search -- `GET /api/v1/companies/?search=query` -- `GET /api/v1/ride-models/?search=query` -- `GET /api/v1/parks/?search=query` -- `GET /api/v1/rides/?search=query` - -## Integration with Existing Features - -### Works With -- ✅ Phase 1: SearchVectorField on models -- ✅ Phase 2: GIN indexes and vector population -- ✅ Search filters (status, dates, location, etc.) -- ✅ Pagination and limiting -- ✅ Related field filtering -- ✅ Geographic queries (PostGIS) - -### Maintains -- ✅ SQLite compatibility for development -- ✅ All existing search filters -- ✅ Ranking by relevance -- ✅ Autocomplete functionality -- ✅ Multi-entity search - -## Next Steps (Phase 4) - -The next phase will add automatic search vector updates: - -### Signal Handlers -Create signals to auto-update search vectors when models change: -```python -from django.db.models.signals import post_save -from django.dispatch import receiver - -@receiver(post_save, sender=Company) -def update_company_search_vector(sender, instance, **kwargs): - """Update search vector when company is saved.""" - instance.search_vector = SearchVector('name', weight='A') + \ - SearchVector('description', weight='B') - Company.objects.filter(pk=instance.pk).update( - search_vector=instance.search_vector - ) -``` - -### Benefits of Phase 4 -- Automatic search index updates -- No manual re-indexing required -- Always up-to-date search results -- Transparent to API consumers - -## Files Reference - -### Core Files -- `django/apps/entities/models.py` - Model definitions with search_vector fields -- `django/apps/entities/search.py` - SearchService (now optimized) -- `django/apps/entities/migrations/0003_add_search_vector_gin_indexes.py` - Migration - -### Related Files -- `django/api/v1/endpoints/search.py` - Search API endpoint -- `django/apps/entities/filters.py` - Filter classes -- `django/PHASE_2_SEARCH_GIN_INDEXES_COMPLETE.md` - Phase 2 documentation - -## Verification Checklist - -- [x] SearchService uses pre-computed search_vector fields on PostgreSQL -- [x] All four search methods updated (companies, ride_models, parks, rides) -- [x] SQLite fallback maintained for development -- [x] PostgreSQL detection using _using_postgis pattern -- [x] SearchRank uses F('search_vector') for efficiency -- [x] No breaking changes to API or query interface -- [x] Code is clean and well-documented - -## Performance Metrics (Expected) - -Based on typical PostgreSQL full-text search benchmarks: - -| Metric | Before (Phase 1/2) | After (Phase 3) | Improvement | -|--------|-------------------|-----------------|-------------| -| Query Time | ~50-200ms | ~5-20ms | **5-10x faster** | -| CPU Usage | High (text processing) | Low (index lookup) | **80% reduction** | -| Scalability | Degrades with data | Consistent | **Linear → Constant** | -| Concurrent Queries | Limited | High | **5x throughput** | - -*Actual performance depends on database size, hardware, and query complexity* - -## Summary - -Phase 3 successfully optimized the SearchService to leverage pre-computed search vectors and GIN indexes, providing significant performance improvements for PostgreSQL environments while maintaining full backward compatibility with SQLite for development. - -**Result**: Production-ready, high-performance full-text search system. ✅ diff --git a/django/PHASE_4_COMPLETE.md b/django/PHASE_4_COMPLETE.md deleted file mode 100644 index bdae0a80..00000000 --- a/django/PHASE_4_COMPLETE.md +++ /dev/null @@ -1,397 +0,0 @@ -# Phase 4 Complete: Versioning System - -**Date**: November 8, 2025 -**Status**: ✅ Complete -**Django System Check**: 0 issues - -## Overview - -Successfully implemented automatic version tracking for all entity changes with full history, diffs, and rollback capabilities. - -## Files Created - -### 1. Models (`apps/versioning/models.py`) - 325 lines -**EntityVersion Model**: -- Generic version tracking using ContentType (supports all entity types) -- Full JSON snapshot of entity state -- Changed fields tracking with old/new values -- Links to ContentSubmission when changes come from moderation -- Metadata: user, IP address, user agent, comment -- Version numbering (auto-incremented per entity) - -**Key Features**: -- `get_snapshot_dict()` - Returns snapshot as Python dict -- `get_changed_fields_list()` - Lists changed field names -- `get_field_change(field_name)` - Gets old/new values for field -- `compare_with(other_version)` - Compares two versions -- `get_diff_summary()` - Human-readable change summary -- Class methods for version history and retrieval - -**Indexes**: -- `(entity_type, entity_id, -created)` - Fast history lookup -- `(entity_type, entity_id, -version_number)` - Version number lookup -- `(change_type)` - Filter by change type -- `(changed_by)` - Filter by user -- `(submission)` - Link to moderation - -### 2. Services (`apps/versioning/services.py`) - 480 lines -**VersionService Class**: -- `create_version()` - Creates version records (called by lifecycle hooks) -- `get_version_history()` - Retrieves version history with limit -- `get_version_by_number()` - Gets specific version by number -- `get_latest_version()` - Gets most recent version -- `compare_versions()` - Compares two versions -- `get_diff_with_current()` - Compares version with current state -- `restore_version()` - Rollback to previous version (creates new 'restored' version) -- `get_version_count()` - Count versions for entity -- `get_versions_by_user()` - Versions created by user -- `get_versions_by_submission()` - Versions from submission - -**Snapshot Creation**: -- Handles all Django field types (CharField, DecimalField, DateField, ForeignKey, JSONField, etc.) -- Normalizes values for JSON serialization -- Stores complete entity state for rollback - -**Changed Fields Tracking**: -- Extracts dirty fields from DirtyFieldsMixin -- Stores old and new values -- Normalizes for JSON storage - -### 3. API Endpoints (`api/v1/endpoints/versioning.py`) - 370 lines -**16 REST API Endpoints**: - -**Park Versions**: -- `GET /parks/{id}/versions` - Version history -- `GET /parks/{id}/versions/{number}` - Specific version -- `GET /parks/{id}/versions/{number}/diff` - Compare with current - -**Ride Versions**: -- `GET /rides/{id}/versions` - Version history -- `GET /rides/{id}/versions/{number}` - Specific version -- `GET /rides/{id}/versions/{number}/diff` - Compare with current - -**Company Versions**: -- `GET /companies/{id}/versions` - Version history -- `GET /companies/{id}/versions/{number}` - Specific version -- `GET /companies/{id}/versions/{number}/diff` - Compare with current - -**Ride Model Versions**: -- `GET /ride-models/{id}/versions` - Version history -- `GET /ride-models/{id}/versions/{number}` - Specific version -- `GET /ride-models/{id}/versions/{number}/diff` - Compare with current - -**Generic Endpoints**: -- `GET /versions/{id}` - Get version by ID -- `GET /versions/{id}/compare/{other_id}` - Compare two versions -- `POST /versions/{id}/restore` - Restore version (commented out, optional) - -### 4. Schemas (`api/v1/schemas.py`) - Updated -**New Schemas**: -- `EntityVersionSchema` - Version output with metadata -- `VersionHistoryResponseSchema` - Version history list -- `VersionDiffSchema` - Diff comparison -- `VersionComparisonSchema` - Compare two versions -- `MessageSchema` - Generic message response -- `ErrorSchema` - Error response - -### 5. Admin Interface (`apps/versioning/admin.py`) - 260 lines -**EntityVersionAdmin**: -- Read-only view of version history -- List display: version number, entity link, change type, user, submission, field count, date -- Filters: change type, entity type, created date -- Search: entity ID, comment, user email -- Date hierarchy on created date - -**Formatted Display**: -- Entity links to admin detail page -- User links to user admin -- Submission links to submission admin -- Pretty-printed JSON snapshot -- HTML table for changed fields with old/new values color-coded - -**Permissions**: -- No add permission (versions auto-created) -- No delete permission (append-only) -- No change permission (read-only) - -### 6. Migrations (`apps/versioning/migrations/0001_initial.py`) -**Created Tables**: -- `versioning_entityversion` with all fields and indexes -- Foreign keys to ContentType, User, and ContentSubmission - -## Integration Points - -### 1. Core Models Integration -The `VersionedModel` in `apps/core/models.py` already had lifecycle hooks ready: - -```python -@hook(AFTER_CREATE) -def create_version_on_create(self): - self._create_version('created') - -@hook(AFTER_UPDATE) -def create_version_on_update(self): - if self.get_dirty_fields(): - self._create_version('updated') -``` - -These hooks now successfully call `VersionService.create_version()`. - -### 2. Moderation Integration -When `ModerationService.approve_submission()` calls `entity.save()`, the lifecycle hooks automatically: -1. Create a version record -2. Link it to the ContentSubmission -3. Capture the user from submission -4. Track all changed fields - -### 3. Entity Models -All entity models inherit from `VersionedModel`: -- Company -- RideModel -- Park -- Ride - -Every save operation now automatically creates a version. - -## Key Technical Decisions - -### Generic Version Model -- Uses ContentType for flexibility -- Single table for all entity types -- Easier to query version history across entities -- Simpler to maintain - -### JSON Snapshot Storage -- Complete entity state stored as JSON -- Enables full rollback capability -- Includes all fields for historical reference -- Efficient with modern database JSON support - -### Changed Fields Tracking -- Separate from snapshot for quick access -- Shows exactly what changed in each version -- Includes old and new values -- Useful for audit trails and diffs - -### Append-Only Design -- Versions never deleted -- Admin is read-only -- Provides complete audit trail -- Supports compliance requirements - -### Performance Optimizations -- Indexes on (entity_type, entity_id, created) -- Indexes on (entity_type, entity_id, version_number) -- Select_related in queries -- Limited default history (50 versions) - -## API Examples - -### Get Version History -```bash -GET /api/v1/parks/{park_id}/versions?limit=20 -``` - -Response: -```json -{ - "entity_id": "uuid", - "entity_type": "park", - "entity_name": "Cedar Point", - "total_versions": 45, - "versions": [ - { - "id": "uuid", - "version_number": 45, - "change_type": "updated", - "changed_by_email": "user@example.com", - "created": "2025-11-08T12:00:00Z", - "diff_summary": "Updated name, description", - "changed_fields": { - "name": {"old": "Old Name", "new": "New Name"} - } - } - ] -} -``` - -### Compare Version with Current -```bash -GET /api/v1/parks/{park_id}/versions/40/diff -``` - -Response: -```json -{ - "entity_id": "uuid", - "entity_type": "park", - "entity_name": "Cedar Point", - "version_number": 40, - "version_date": "2025-10-01T10:00:00Z", - "differences": { - "name": { - "current": "Cedar Point", - "version": "Cedar Point Amusement Park" - }, - "status": { - "current": "operating", - "version": "closed" - } - }, - "changed_field_count": 2 -} -``` - -### Compare Two Versions -```bash -GET /api/v1/versions/{version_id}/compare/{other_version_id} -``` - -## Admin Interface - -Navigate to `/admin/versioning/entityversion/` to: -- View all version records -- Filter by entity type, change type, date -- Search by entity ID, user, comment -- See formatted snapshots and diffs -- Click links to entity, user, and submission records - -## Success Criteria - -✅ **Version created on every entity save** -✅ **Full snapshot stored in JSON** -✅ **Changed fields tracked** -✅ **Version history API endpoint** -✅ **Diff generation** -✅ **Link to ContentSubmission** -✅ **Django system check: 0 issues** -✅ **Migrations created successfully** - -## Testing the System - -### Create an Entity -```python -from apps.entities.models import Company -company = Company.objects.create(name="Test Company") -# Version 1 created automatically with change_type='created' -``` - -### Update an Entity -```python -company.name = "Updated Company" -company.save() -# Version 2 created automatically with change_type='updated' -# Changed fields captured: {'name': {'old': 'Test Company', 'new': 'Updated Company'}} -``` - -### View Version History -```python -from apps.versioning.services import VersionService -history = VersionService.get_version_history(company, limit=10) -for version in history: - print(f"v{version.version_number}: {version.get_diff_summary()}") -``` - -### Compare Versions -```python -version1 = VersionService.get_version_by_number(company, 1) -version2 = VersionService.get_version_by_number(company, 2) -diff = VersionService.compare_versions(version1, version2) -print(diff['differences']) -``` - -### Restore Version (Optional) -```python -from django.contrib.auth import get_user_model -User = get_user_model() -admin = User.objects.first() - -version1 = VersionService.get_version_by_number(company, 1) -restored = VersionService.restore_version(version1, user=admin, comment="Restored to original name") -# Creates version 3 with change_type='restored' -# Entity now back to original state -``` - -## Dependencies Used - -All dependencies were already installed: -- `django-lifecycle==2.1.1` - Lifecycle hooks (AFTER_CREATE, AFTER_UPDATE) -- `django-dirtyfields` - Track changed fields -- `django-ninja` - REST API framework -- `pydantic` - API schemas -- `unfold` - Admin UI theme - -## Performance Characteristics - -### Version Creation -- **Time**: ~10-20ms per version -- **Transaction**: Atomic with entity save -- **Storage**: ~1-5KB per version (depends on entity size) - -### History Queries -- **Time**: ~5-10ms for 50 versions -- **Optimization**: Indexed on (entity_type, entity_id, created) -- **Pagination**: Default limit of 50 versions - -### Snapshot Size -- **Company**: ~500 bytes -- **Park**: ~1-2KB (includes location data) -- **Ride**: ~1-2KB (includes stats) -- **RideModel**: ~500 bytes - -## Next Steps - -### Optional Enhancements -1. **Version Restoration API**: Uncomment restore endpoint in `versioning.py` -2. **Bulk Version Export**: Add CSV/JSON export for compliance -3. **Version Retention Policy**: Archive old versions after N days -4. **Version Notifications**: Notify on significant changes -5. **Version Search**: Full-text search across version snapshots - -### Integration with Frontend -1. Display "Version History" tab on entity detail pages -2. Show visual diff of changes -3. Allow rollback from UI (if restoration enabled) -4. Show version timeline - -## Statistics - -- **Files Created**: 5 -- **Lines of Code**: ~1,735 -- **API Endpoints**: 16 -- **Database Tables**: 1 -- **Indexes**: 5 -- **Implementation Time**: ~2 hours (vs 6 days estimated) ⚡ - -## Verification - -```bash -# Run Django checks -python manage.py check -# Output: System check identified no issues (0 silenced). - -# Create migrations -python manage.py makemigrations -# Output: Migrations for 'versioning': 0001_initial.py - -# View API docs -# Navigate to: http://localhost:8000/api/v1/docs -# See "Versioning" section with all endpoints -``` - -## Conclusion - -Phase 4 is complete! The versioning system provides: -- ✅ Automatic version tracking on all entity changes -- ✅ Complete audit trail with full snapshots -- ✅ Integration with moderation workflow -- ✅ Rich API for version history and comparison -- ✅ Admin interface for viewing version records -- ✅ Optional rollback capability -- ✅ Zero-configuration operation (works via lifecycle hooks) - -The system is production-ready and follows Django best practices for performance, security, and maintainability. - ---- - -**Next Phase**: Phase 5 - Media Management (if applicable) or Project Completion diff --git a/django/PHASE_4_SEARCH_VECTOR_SIGNALS_COMPLETE.md b/django/PHASE_4_SEARCH_VECTOR_SIGNALS_COMPLETE.md deleted file mode 100644 index 38bb93e9..00000000 --- a/django/PHASE_4_SEARCH_VECTOR_SIGNALS_COMPLETE.md +++ /dev/null @@ -1,401 +0,0 @@ -# Phase 4: Automatic Search Vector Updates - COMPLETE ✅ - -## Overview - -Phase 4 implements Django signal handlers that automatically update search vectors whenever entity models are created or modified. This eliminates the need for manual re-indexing and ensures search results are always up-to-date. - -## Implementation Summary - -### 1. Signal Handler Architecture - -Created `django/apps/entities/signals.py` with comprehensive signal handlers for all entity models. - -**Key Features:** -- ✅ PostgreSQL-only activation (respects `_using_postgis` flag) -- ✅ Automatic search vector updates on create/update -- ✅ Cascading updates for related objects -- ✅ Efficient bulk updates to minimize database queries -- ✅ Change detection to avoid unnecessary updates - -### 2. Signal Registration - -Updated `django/apps/entities/apps.py` to register signals on app startup: - -```python -class EntitiesConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.entities' - verbose_name = 'Entities' - - def ready(self): - """Import signal handlers when app is ready.""" - import apps.entities.signals # noqa -``` - -## Signal Handlers Implemented - -### Company Signals - -**1. `update_company_search_vector`** (post_save) -- Triggers: Company create/update -- Updates: Company's own search vector -- Fields indexed: - - `name` (weight A) - - `description` (weight B) - -**2. `check_company_name_change`** (pre_save) -- Tracks: Company name changes -- Purpose: Enables cascading updates - -**3. `cascade_company_name_updates`** (post_save) -- Triggers: Company name changes -- Updates: - - All RideModels from this manufacturer - - All Rides from this manufacturer -- Ensures: Related objects reflect new company name in search - -### Park Signals - -**1. `update_park_search_vector`** (post_save) -- Triggers: Park create/update -- Updates: Park's own search vector -- Fields indexed: - - `name` (weight A) - - `description` (weight B) - -**2. `check_park_name_change`** (pre_save) -- Tracks: Park name changes -- Purpose: Enables cascading updates - -**3. `cascade_park_name_updates`** (post_save) -- Triggers: Park name changes -- Updates: All Rides in this park -- Ensures: Rides reflect new park name in search - -### RideModel Signals - -**1. `update_ride_model_search_vector`** (post_save) -- Triggers: RideModel create/update -- Updates: RideModel's own search vector -- Fields indexed: - - `name` (weight A) - - `manufacturer__name` (weight A) - - `description` (weight B) - -**2. `check_ride_model_manufacturer_change`** (pre_save) -- Tracks: Manufacturer changes -- Purpose: Future cascading updates if needed - -### Ride Signals - -**1. `update_ride_search_vector`** (post_save) -- Triggers: Ride create/update -- Updates: Ride's own search vector -- Fields indexed: - - `name` (weight A) - - `park__name` (weight A) - - `manufacturer__name` (weight B) - - `description` (weight B) - -**2. `check_ride_relationships_change`** (pre_save) -- Tracks: Park and manufacturer changes -- Purpose: Future cascading updates if needed - -## Search Vector Composition - -Each entity model has a carefully weighted search vector: - -### Company -```sql -search_vector = - setweight(to_tsvector('english', name), 'A') || - setweight(to_tsvector('english', description), 'B') -``` - -### RideModel -```sql -search_vector = - setweight(to_tsvector('english', name), 'A') || - setweight(to_tsvector('english', manufacturer.name), 'A') || - setweight(to_tsvector('english', description), 'B') -``` - -### Park -```sql -search_vector = - setweight(to_tsvector('english', name), 'A') || - setweight(to_tsvector('english', description), 'B') -``` - -### Ride -```sql -search_vector = - setweight(to_tsvector('english', name), 'A') || - setweight(to_tsvector('english', park.name), 'A') || - setweight(to_tsvector('english', manufacturer.name), 'B') || - setweight(to_tsvector('english', description), 'B') -``` - -## Cascading Update Logic - -### When Company Name Changes - -1. **Pre-save signal** captures old name -2. **Post-save signal** compares old vs new name -3. If changed: - - Updates all RideModels from this manufacturer - - Updates all Rides from this manufacturer - -**Example:** -```python -# Rename "Bolliger & Mabillard" to "B&M" -company = Company.objects.get(name="Bolliger & Mabillard") -company.name = "B&M" -company.save() - -# Automatically updates search vectors for: -# - All RideModels (e.g., "B&M Inverted Coaster") -# - All Rides (e.g., "Batman: The Ride at Six Flags") -``` - -### When Park Name Changes - -1. **Pre-save signal** captures old name -2. **Post-save signal** compares old vs new name -3. If changed: - - Updates all Rides in this park - -**Example:** -```python -# Rename park -park = Park.objects.get(name="Cedar Point") -park.name = "Cedar Point Amusement Park" -park.save() - -# Automatically updates search vectors for: -# - All rides in this park (e.g., "Steel Vengeance") -``` - -## Performance Considerations - -### Efficient Update Strategy - -1. **Filter-then-update pattern**: - ```python - Model.objects.filter(pk=instance.pk).update( - search_vector=SearchVector(...) - ) - ``` - - Single database query - - No additional model save overhead - - Bypasses signal recursion - -2. **Change detection**: - - Only cascades updates when names actually change - - Avoids unnecessary database operations - - Checks `created` flag to skip cascades on new objects - -3. **PostgreSQL-only execution**: - - All signals wrapped in `if _using_postgis:` guard - - Zero overhead on SQLite (development) - -### Bulk Operations Consideration - -For large bulk updates, consider temporarily disconnecting signals: - -```python -from django.db.models.signals import post_save -from apps.entities.signals import update_company_search_vector -from apps.entities.models import Company - -# Disconnect signal -post_save.disconnect(update_company_search_vector, sender=Company) - -# Perform bulk operations -Company.objects.bulk_create([...]) - -# Reconnect signal -post_save.connect(update_company_search_vector, sender=Company) - -# Manually update search vectors if needed -from django.contrib.postgres.search import SearchVector -Company.objects.update( - search_vector=SearchVector('name', weight='A') + - SearchVector('description', weight='B') -) -``` - -## Testing Strategy - -### Manual Testing - -1. **Create new entity**: - ```python - company = Company.objects.create( - name="Test Manufacturer", - description="A test company" - ) - # Check: company.search_vector should be populated - ``` - -2. **Update entity**: - ```python - company.description = "Updated description" - company.save() - # Check: company.search_vector should be updated - ``` - -3. **Cascading updates**: - ```python - # Change company name - company.name = "New Name" - company.save() - # Check: Related RideModels and Rides should have updated search vectors - ``` - -### Automated Testing (Recommended) - -Create tests in `django/apps/entities/tests/test_signals.py`: - -```python -from django.test import TestCase -from django.contrib.postgres.search import SearchQuery -from apps.entities.models import Company, Park, Ride - -class SearchVectorSignalTests(TestCase): - def test_company_search_vector_on_create(self): - """Test search vector is populated on company creation.""" - company = Company.objects.create( - name="Intamin", - description="Ride manufacturer" - ) - self.assertIsNotNone(company.search_vector) - - def test_company_name_change_cascades(self): - """Test company name changes cascade to rides.""" - company = Company.objects.create(name="Old Name") - park = Park.objects.create(name="Test Park") - ride = Ride.objects.create( - name="Test Ride", - park=park, - manufacturer=company - ) - - # Change company name - company.name = "New Name" - company.save() - - # Verify ride search vector updated - ride.refresh_from_db() - results = Ride.objects.filter( - search_vector=SearchQuery("New Name") - ) - self.assertIn(ride, results) -``` - -## Benefits - -✅ **Automatic synchronization**: Search vectors always up-to-date -✅ **No manual re-indexing**: Zero maintenance overhead -✅ **Cascading updates**: Related objects stay synchronized -✅ **Performance optimized**: Minimal database queries -✅ **PostgreSQL-only**: No overhead on development (SQLite) -✅ **Transparent**: Works seamlessly with existing code - -## Integration with Previous Phases - -### Phase 1: SearchVectorField Implementation -- ✅ Added `search_vector` fields to models -- ✅ Conditional for PostgreSQL-only - -### Phase 2: GIN Indexes and Population -- ✅ Created GIN indexes for fast search -- ✅ Initial population of search vectors - -### Phase 3: SearchService Optimization -- ✅ Optimized queries to use pre-computed vectors -- ✅ 5-10x performance improvement - -### Phase 4: Automatic Updates (Current) -- ✅ Signal handlers for automatic updates -- ✅ Cascading updates for related objects -- ✅ Zero-maintenance search infrastructure - -## Complete Search Architecture - -``` -┌─────────────────────────────────────────────────────────┐ -│ Phase 1: Foundation │ -│ SearchVectorField added to all entity models │ -└────────────────────┬────────────────────────────────────┘ - │ -┌────────────────────▼────────────────────────────────────┐ -│ Phase 2: Indexing & Population │ -│ - GIN indexes for fast search │ -│ - Initial search vector population via migration │ -└────────────────────┬────────────────────────────────────┘ - │ -┌────────────────────▼────────────────────────────────────┐ -│ Phase 3: Query Optimization │ -│ - SearchService uses pre-computed vectors │ -│ - 5-10x faster than real-time computation │ -└────────────────────┬────────────────────────────────────┘ - │ -┌────────────────────▼────────────────────────────────────┐ -│ Phase 4: Automatic Updates (NEW) │ -│ - Django signals keep vectors synchronized │ -│ - Cascading updates for related objects │ -│ - Zero maintenance required │ -└─────────────────────────────────────────────────────────┘ -``` - -## Files Modified - -1. **`django/apps/entities/signals.py`** (NEW) - - Complete signal handler implementation - - 200+ lines of well-documented code - -2. **`django/apps/entities/apps.py`** (MODIFIED) - - Added `ready()` method to register signals - -## Next Steps (Optional Enhancements) - -1. **Performance Monitoring**: - - Add metrics for signal execution time - - Monitor cascading update frequency - -2. **Bulk Operation Optimization**: - - Create management command for bulk re-indexing - - Add signal disconnect context manager - -3. **Advanced Features**: - - Language-specific search configurations - - Partial word matching - - Synonym support - -## Verification - -Run system check to verify implementation: -```bash -cd django -python manage.py check -``` - -Expected output: `System check identified no issues (0 silenced).` - -## Conclusion - -Phase 4 completes the full-text search infrastructure by adding automatic search vector updates. The system now: - -1. ✅ Has optimized search fields (Phase 1) -2. ✅ Has GIN indexes for performance (Phase 2) -3. ✅ Uses pre-computed vectors (Phase 3) -4. ✅ **Automatically updates vectors (Phase 4)** ← NEW - -The search system is now production-ready with zero maintenance overhead! - ---- - -**Implementation Date**: 2025-11-08 -**Status**: ✅ COMPLETE -**Verified**: Django system check passed diff --git a/django/PHASE_5_AUTHENTICATION_COMPLETE.md b/django/PHASE_5_AUTHENTICATION_COMPLETE.md deleted file mode 100644 index 10203d4a..00000000 --- a/django/PHASE_5_AUTHENTICATION_COMPLETE.md +++ /dev/null @@ -1,578 +0,0 @@ -# Phase 5: Authentication System - COMPLETE ✅ - -**Implementation Date:** November 8, 2025 -**Duration:** ~2 hours -**Status:** Production Ready - ---- - -## 🎯 Overview - -Phase 5 implements a complete, enterprise-grade authentication system with JWT tokens, MFA support, role-based access control, and comprehensive user management. - -## ✅ What Was Implemented - -### 1. **Authentication Services Layer** (`apps/users/services.py`) - -#### AuthenticationService -- ✅ **User Registration** - - Email-based with password validation - - Automatic username generation - - Profile & role creation on signup - - Duplicate email prevention - -- ✅ **User Authentication** - - Email/password login - - Banned user detection - - Last login timestamp tracking - - OAuth user creation (Google, Discord) - -- ✅ **Password Management** - - Secure password changes - - Password reset functionality - - Django password validation integration - -#### MFAService (Multi-Factor Authentication) -- ✅ **TOTP-based 2FA** - - Device creation and management - - QR code generation for authenticator apps - - Token verification - - Enable/disable MFA per user - -#### RoleService -- ✅ **Role Management** - - Three-tier role system (user, moderator, admin) - - Role assignment with audit trail - - Permission checking - - Role-based capabilities - -#### UserManagementService -- ✅ **Profile Management** - - Update user information - - Manage preferences - - User statistics tracking - - Ban/unban functionality - -### 2. **Permission System** (`apps/users/permissions.py`) - -#### JWT Authentication -- ✅ **JWTAuth Class** - - Bearer token authentication - - Token validation and decoding - - Banned user filtering - - Automatic user lookup - -#### Permission Decorators -- ✅ `@require_auth` - Require any authenticated user -- ✅ `@require_role(role)` - Require specific role -- ✅ `@require_moderator` - Require moderator or admin -- ✅ `@require_admin` - Require admin only - -#### Permission Helpers -- ✅ `is_owner_or_moderator()` - Check ownership or moderation rights -- ✅ `can_moderate()` - Check moderation permissions -- ✅ `can_submit()` - Check submission permissions -- ✅ `PermissionChecker` class - Comprehensive permission checks - -### 3. **API Schemas** (`api/v1/schemas.py`) - -#### 26 New Authentication Schemas -- User registration and login -- Token management -- Profile and preferences -- MFA setup and verification -- User administration -- Role management - -### 4. **Authentication API Endpoints** (`api/v1/endpoints/auth.py`) - -#### Public Endpoints -- ✅ `POST /auth/register` - User registration -- ✅ `POST /auth/login` - Login with email/password -- ✅ `POST /auth/token/refresh` - Refresh JWT tokens -- ✅ `POST /auth/logout` - Logout (blacklist token) -- ✅ `POST /auth/password/reset` - Request password reset - -#### Authenticated Endpoints -- ✅ `GET /auth/me` - Get current user profile -- ✅ `PATCH /auth/me` - Update profile -- ✅ `GET /auth/me/role` - Get user role -- ✅ `GET /auth/me/permissions` - Get permissions -- ✅ `GET /auth/me/stats` - Get user statistics -- ✅ `GET /auth/me/preferences` - Get preferences -- ✅ `PATCH /auth/me/preferences` - Update preferences -- ✅ `POST /auth/password/change` - Change password - -#### MFA Endpoints -- ✅ `POST /auth/mfa/enable` - Enable MFA -- ✅ `POST /auth/mfa/confirm` - Confirm MFA setup -- ✅ `POST /auth/mfa/disable` - Disable MFA -- ✅ `POST /auth/mfa/verify` - Verify MFA token - -#### Admin Endpoints -- ✅ `GET /auth/users` - List all users (with filters) -- ✅ `GET /auth/users/{id}` - Get user by ID -- ✅ `POST /auth/users/ban` - Ban user -- ✅ `POST /auth/users/unban` - Unban user -- ✅ `POST /auth/users/assign-role` - Assign role - -**Total:** 23 authentication endpoints - -### 5. **Admin Interface** (`apps/users/admin.py`) - -#### User Admin -- ✅ Rich list view with badges (role, status, MFA, reputation) -- ✅ Advanced filtering (active, staff, banned, MFA, OAuth) -- ✅ Search by email, username, name -- ✅ Inline editing of role and profile -- ✅ Import/export functionality -- ✅ Bulk actions (ban, unban, role assignment) - -#### Role Admin -- ✅ Role assignment tracking -- ✅ Audit trail (who granted role, when) -- ✅ Role filtering - -#### Profile Admin -- ✅ Statistics display -- ✅ Approval rate calculation -- ✅ Preference management -- ✅ Privacy settings - -### 6. **API Documentation Updates** (`api/v1/api.py`) - -- ✅ Added authentication section to API docs -- ✅ JWT workflow explanation -- ✅ Permission levels documentation -- ✅ MFA setup instructions -- ✅ Added `/auth` to endpoint list - ---- - -## 📊 Architecture - -### Authentication Flow - -``` -┌─────────────┐ -│ Register │ -│ /register │ -└──────┬──────┘ - │ - ├─ Create User - ├─ Create UserRole (default: 'user') - ├─ Create UserProfile - └─ Return User - -┌─────────────┐ -│ Login │ -│ /login │ -└──────┬──────┘ - │ - ├─ Authenticate (email + password) - ├─ Check if banned - ├─ Verify MFA if enabled - ├─ Generate JWT tokens - └─ Return access & refresh tokens - -┌─────────────┐ -│ API Request │ -│ with Bearer │ -│ Token │ -└──────┬──────┘ - │ - ├─ JWTAuth.authenticate() - ├─ Decode JWT - ├─ Get User - ├─ Check not banned - └─ Attach user to request.auth - -┌─────────────┐ -│ Protected │ -│ Endpoint │ -└──────┬──────┘ - │ - ├─ @require_auth decorator - ├─ Check request.auth exists - ├─ @require_role decorator (optional) - └─ Execute endpoint -``` - -### Permission Hierarchy - -``` -┌──────────┐ -│ Admin │ ← Full access to everything -└────┬─────┘ - │ -┌────┴─────────┐ -│ Moderator │ ← Can moderate, approve submissions -└────┬─────────┘ - │ -┌────┴─────┐ -│ User │ ← Can submit, edit own content -└──────────┘ -``` - -### Role-Based Permissions - -| Role | Submit | Edit Own | Moderate | Admin | Ban Users | Assign Roles | -|-----------|--------|----------|----------|-------|-----------|--------------| -| User | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | -| Moderator | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | -| Admin | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | - ---- - -## 🔐 Security Features - -### 1. **JWT Token Security** -- HS256 algorithm -- 60-minute access token lifetime -- 7-day refresh token lifetime -- Automatic token rotation -- Token blacklisting on rotation - -### 2. **Password Security** -- Django password validation -- Minimum 8 characters -- Common password prevention -- User attribute similarity check -- Numeric-only prevention - -### 3. **MFA/2FA Support** -- TOTP-based (RFC 6238) -- Compatible with Google Authenticator, Authy, etc. -- QR code generation -- Backup codes (TODO) - -### 4. **Account Protection** -- Failed login tracking (django-defender) -- Account lockout after 5 failed attempts -- 5-minute cooldown period -- Ban system for problematic users - -### 5. **OAuth Integration** -- Google OAuth 2.0 -- Discord OAuth 2.0 -- Automatic account linking -- Provider tracking - ---- - -## 📝 API Usage Examples - -### 1. **Register a New User** - -```bash -POST /api/v1/auth/register -Content-Type: application/json - -{ - "email": "user@example.com", - "password": "SecurePass123", - "password_confirm": "SecurePass123", - "first_name": "John", - "last_name": "Doe" -} - -# Response -{ - "id": "550e8400-e29b-41d4-a716-446655440000", - "email": "user@example.com", - "username": "user", - "display_name": "John Doe", - "reputation_score": 0, - "mfa_enabled": false, - ... -} -``` - -### 2. **Login** - -```bash -POST /api/v1/auth/login -Content-Type: application/json - -{ - "email": "user@example.com", - "password": "SecurePass123" -} - -# Response -{ - "access": "eyJ0eXAiOiJKV1QiLCJhbGc...", - "refresh": "eyJ0eXAiOiJKV1QiLCJhbGc...", - "token_type": "Bearer" -} -``` - -### 3. **Access Protected Endpoint** - -```bash -GET /api/v1/auth/me -Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGc... - -# Response -{ - "id": "550e8400-e29b-41d4-a716-446655440000", - "email": "user@example.com", - "username": "user", - "display_name": "John Doe", - ... -} -``` - -### 4. **Enable MFA** - -```bash -# Step 1: Enable MFA -POST /api/v1/auth/mfa/enable -Authorization: Bearer - -# Response -{ - "secret": "JBSWY3DPEHPK3PXP", - "qr_code_url": "otpauth://totp/ThrillWiki:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=ThrillWiki", - "backup_codes": [] -} - -# Step 2: Scan QR code with authenticator app - -# Step 3: Confirm with 6-digit token -POST /api/v1/auth/mfa/confirm -Authorization: Bearer -Content-Type: application/json - -{ - "token": "123456" -} - -# Response -{ - "message": "MFA enabled successfully", - "success": true -} -``` - -### 5. **Login with MFA** - -```bash -POST /api/v1/auth/login -Content-Type: application/json - -{ - "email": "user@example.com", - "password": "SecurePass123", - "mfa_token": "123456" -} -``` - ---- - -## 🛠️ Integration with Existing Systems - -### Moderation System Integration - -The authentication system integrates seamlessly with the existing moderation system: - -```python -# In moderation endpoints -from apps.users.permissions import jwt_auth, require_moderator - -@router.post("/submissions/{id}/approve", auth=jwt_auth) -@require_moderator -def approve_submission(request: HttpRequest, id: UUID): - user = request.auth # Authenticated user - # Moderator can approve submissions - ... -``` - -### Versioning System Integration - -User information is automatically tracked in version records: - -```python -# Versions automatically track who made changes -version = EntityVersion.objects.create( - entity_type='park', - entity_id=park.id, - changed_by=request.auth, # User from JWT - ... -) -``` - ---- - -## 📈 Statistics - -| Metric | Count | -|--------|-------| -| **New Files Created** | 3 | -| **Files Modified** | 2 | -| **Lines of Code** | ~2,500 | -| **API Endpoints** | 23 | -| **Pydantic Schemas** | 26 | -| **Services** | 4 classes | -| **Permission Decorators** | 4 | -| **Admin Interfaces** | 3 | -| **System Check Issues** | 0 ✅ | - ---- - -## 🎓 Next Steps for Frontend Integration - -### 1. **Authentication Flow** - -```typescript -// Login -const response = await fetch('/api/v1/auth/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - email: 'user@example.com', - password: 'password123' - }) -}); - -const { access, refresh } = await response.json(); - -// Store tokens -localStorage.setItem('access_token', access); -localStorage.setItem('refresh_token', refresh); - -// Use token in requests -const protectedResponse = await fetch('/api/v1/auth/me', { - headers: { - 'Authorization': `Bearer ${access}` - } -}); -``` - -### 2. **Token Refresh** - -```typescript -// Refresh token when access token expires -async function refreshToken() { - const refresh = localStorage.getItem('refresh_token'); - - const response = await fetch('/api/v1/auth/token/refresh', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ refresh }) - }); - - const { access } = await response.json(); - localStorage.setItem('access_token', access); - - return access; -} -``` - -### 3. **Permission Checks** - -```typescript -// Get user permissions -const permissions = await fetch('/api/v1/auth/me/permissions', { - headers: { - 'Authorization': `Bearer ${access_token}` - } -}).then(r => r.json()); - -// { -// can_submit: true, -// can_moderate: false, -// can_admin: false, -// can_edit_own: true, -// can_delete_own: true -// } - -// Conditional rendering -{permissions.can_moderate && ( - -)} -``` - ---- - -## 🔧 Configuration - -### Environment Variables - -Add to `.env`: - -```bash -# JWT Settings (already configured in settings.py) -SECRET_KEY=your-secret-key-here - -# OAuth (if using) -GOOGLE_OAUTH_CLIENT_ID=your-google-client-id -GOOGLE_OAUTH_CLIENT_SECRET=your-google-client-secret -DISCORD_OAUTH_CLIENT_ID=your-discord-client-id -DISCORD_OAUTH_CLIENT_SECRET=your-discord-client-secret - -# Email (for password reset - TODO) -EMAIL_HOST=smtp.gmail.com -EMAIL_PORT=587 -EMAIL_HOST_USER=your-email@gmail.com -EMAIL_HOST_PASSWORD=your-email-password -EMAIL_USE_TLS=True -``` - ---- - -## 🐛 Known Limitations - -1. **Password Reset Email**: Currently a placeholder - needs email backend configuration -2. **OAuth Redirect URLs**: Need to be configured in Google/Discord consoles -3. **Backup Codes**: MFA backup codes generation not yet implemented -4. **Rate Limiting**: Uses django-defender, but API-specific rate limiting to be added -5. **Session Management**: No "view all sessions" or "logout everywhere" yet - ---- - -## ✅ Testing Checklist - -- [x] User can register -- [x] User can login -- [x] JWT tokens are generated -- [x] Protected endpoints require authentication -- [x] Role-based access control works -- [x] MFA can be enabled/disabled -- [x] User profile can be updated -- [x] Preferences can be managed -- [x] Admin can ban/unban users -- [x] Admin can assign roles -- [x] Admin interface works -- [x] Django system check passes -- [ ] Password reset email (needs email backend) -- [ ] OAuth flows (needs provider setup) - ---- - -## 📚 Additional Resources - -- **Django REST JWT**: https://django-rest-framework-simplejwt.readthedocs.io/ -- **Django Allauth**: https://django-allauth.readthedocs.io/ -- **Django OTP**: https://django-otp-official.readthedocs.io/ -- **Django Guardian**: https://django-guardian.readthedocs.io/ -- **TOTP RFC**: https://tools.ietf.org/html/rfc6238 - ---- - -## 🎉 Summary - -Phase 5 delivers a **complete, production-ready authentication system** that: - -- ✅ Provides secure JWT-based authentication -- ✅ Supports MFA/2FA for enhanced security -- ✅ Implements role-based access control -- ✅ Includes comprehensive user management -- ✅ Integrates seamlessly with existing systems -- ✅ Offers a beautiful admin interface -- ✅ Passes all Django system checks -- ✅ Ready for frontend integration - -**The ThrillWiki Django backend now has complete authentication!** 🚀 - -Users can register, login, enable MFA, manage their profiles, and admins have full user management capabilities. The system is secure, scalable, and ready for production use. diff --git a/django/PHASE_6_MEDIA_COMPLETE.md b/django/PHASE_6_MEDIA_COMPLETE.md deleted file mode 100644 index 2ff58047..00000000 --- a/django/PHASE_6_MEDIA_COMPLETE.md +++ /dev/null @@ -1,463 +0,0 @@ -# Phase 6: Media Management System - COMPLETE ✅ - -## Overview - -Phase 6 successfully implements a comprehensive media management system with CloudFlare Images integration, photo moderation, and entity attachment. The system provides a complete API for uploading, managing, and moderating photos with CDN delivery. - -**Completion Date:** November 8, 2025 -**Total Implementation Time:** ~4 hours -**Files Created:** 3 -**Files Modified:** 5 -**Total Lines Added:** ~1,800 lines - ---- - -## ✅ Completed Components - -### 1. CloudFlare Service Layer ✅ -**File:** `django/apps/media/services.py` (~500 lines) - -**CloudFlareService Features:** -- ✅ Image upload to CloudFlare Images API -- ✅ Image deletion from CloudFlare -- ✅ CDN URL generation for image variants -- ✅ Automatic mock mode for development (no CloudFlare credentials needed) -- ✅ Error handling and retry logic -- ✅ Support for multiple image variants (public, thumbnail, banner) - -**PhotoService Features:** -- ✅ Photo creation with CloudFlare upload -- ✅ Entity attachment/detachment -- ✅ Photo moderation (approve/reject/flag) -- ✅ Gallery reordering -- ✅ Photo deletion with CloudFlare cleanup -- ✅ Dimension extraction from uploads - -### 2. Image Validators ✅ -**File:** `django/apps/media/validators.py` (~170 lines) - -**Validation Features:** -- ✅ File type validation (JPEG, PNG, WebP, GIF) -- ✅ File size validation (1KB - 10MB) -- ✅ Image dimension validation (100x100 - 8000x8000) -- ✅ Aspect ratio validation for specific photo types -- ✅ Content type verification with python-magic -- ✅ Placeholder for content safety API integration - -### 3. API Schemas ✅ -**File:** `django/api/v1/schemas.py` (added ~200 lines) - -**New Schemas:** -- ✅ `PhotoBase` - Base photo fields -- ✅ `PhotoUploadRequest` - Multipart upload with entity attachment -- ✅ `PhotoUpdate` - Metadata updates -- ✅ `PhotoOut` - Complete photo response with CDN URLs -- ✅ `PhotoListOut` - Paginated photo list -- ✅ `PhotoUploadResponse` - Upload confirmation -- ✅ `PhotoModerateRequest` - Moderation actions -- ✅ `PhotoReorderRequest` - Gallery reordering -- ✅ `PhotoAttachRequest` - Entity attachment -- ✅ `PhotoStatsOut` - Photo statistics - -### 4. API Endpoints ✅ -**File:** `django/api/v1/endpoints/photos.py` (~650 lines) - -**Public Endpoints (No Auth Required):** -- ✅ `GET /photos` - List approved photos with filters -- ✅ `GET /photos/{id}` - Get photo details -- ✅ `GET /{entity_type}/{entity_id}/photos` - Get entity photos - -**Authenticated Endpoints (JWT Required):** -- ✅ `POST /photos/upload` - Upload new photo with multipart form data -- ✅ `PATCH /photos/{id}` - Update photo metadata -- ✅ `DELETE /photos/{id}` - Delete own photo -- ✅ `POST /{entity_type}/{entity_id}/photos` - Attach photo to entity - -**Moderator Endpoints:** -- ✅ `GET /photos/pending` - List pending photos -- ✅ `POST /photos/{id}/approve` - Approve photo -- ✅ `POST /photos/{id}/reject` - Reject photo with notes -- ✅ `POST /photos/{id}/flag` - Flag photo for review -- ✅ `GET /photos/stats` - Photo statistics - -**Admin Endpoints:** -- ✅ `DELETE /photos/{id}/admin` - Force delete any photo -- ✅ `POST /{entity_type}/{entity_id}/photos/reorder` - Reorder photos - -### 5. Enhanced Admin Interface ✅ -**File:** `django/apps/media/admin.py` (expanded to ~190 lines) - -**PhotoAdmin Features:** -- ✅ Thumbnail previews in list view (60x60px) -- ✅ Entity information display -- ✅ File size and dimension display -- ✅ Moderation status filters -- ✅ Photo statistics in changelist -- ✅ Bulk actions (approve, reject, flag, feature) -- ✅ Date hierarchy navigation -- ✅ Optimized queries with select_related - -**PhotoInline for Entity Admin:** -- ✅ Thumbnail previews (40x40px) -- ✅ Title, type, and status display -- ✅ Display order management -- ✅ Quick delete capability - -### 6. Entity Integration ✅ -**File:** `django/apps/entities/models.py` (added ~100 lines) - -**Added to All Entity Models (Company, RideModel, Park, Ride):** -- ✅ `photos` GenericRelation for photo attachment -- ✅ `get_photos(photo_type, approved_only)` method -- ✅ `main_photo` property -- ✅ Type-specific properties (logo_photo, banner_photo, gallery_photos) - -**File:** `django/apps/entities/admin.py` (modified) -- ✅ PhotoInline added to all entity admin pages -- ✅ Photos manageable directly from entity edit pages - -### 7. API Router Registration ✅ -**File:** `django/api/v1/api.py` (modified) -- ✅ Photos router registered -- ✅ Photo endpoints documented in API info -- ✅ Available at `/api/v1/photos/` and entity-nested routes - ---- - -## 📊 System Capabilities - -### Photo Upload Flow -``` -1. User uploads photo via API → Validation -2. Image validated → CloudFlare upload -3. Photo record created → Moderation status: pending -4. Optional entity attachment -5. Moderator reviews → Approve/Reject -6. Approved photos visible publicly -``` - -### Supported Photo Types -- `main` - Main/hero photo -- `gallery` - Gallery photos -- `banner` - Wide banner images -- `logo` - Square logo images -- `thumbnail` - Thumbnail images -- `other` - Other photo types - -### Supported Formats -- JPEG/JPG -- PNG -- WebP -- GIF - -### File Constraints -- **Size:** 1 KB - 10 MB -- **Dimensions:** 100x100 - 8000x8000 pixels -- **Aspect Ratios:** Enforced for banner (2:1 to 4:1) and logo (1:2 to 2:1) - -### CloudFlare Integration -- **Mock Mode:** Works without CloudFlare credentials (development) -- **Production Mode:** Full CloudFlare Images API integration -- **CDN Delivery:** Global CDN for fast image delivery -- **Image Variants:** Automatic generation of thumbnails, banners, etc. -- **URL Format:** `https://imagedelivery.net/{hash}/{image_id}/{variant}` - ---- - -## 🔒 Security & Permissions - -### Upload Permissions -- **Any Authenticated User:** Can upload photos -- **Photo enters moderation queue automatically** -- **Users can edit/delete own photos** - -### Moderation Permissions -- **Moderators:** Approve, reject, flag photos -- **Admins:** Force delete any photo, reorder galleries - -### API Security -- **JWT Authentication:** Required for uploads and management -- **Permission Checks:** Enforced on all write operations -- **User Isolation:** Users only see/edit own pending photos - ---- - -## 📁 File Structure - -``` -django/apps/media/ -├── models.py # Photo model (already existed) -├── services.py # NEW: CloudFlare + Photo services -├── validators.py # NEW: Image validation -└── admin.py # ENHANCED: Admin with thumbnails - -django/api/v1/ -├── schemas.py # ENHANCED: Photo schemas added -├── endpoints/ -│ └── photos.py # NEW: Photo API endpoints -└── api.py # MODIFIED: Router registration - -django/apps/entities/ -├── models.py # ENHANCED: Photo relationships -└── admin.py # ENHANCED: Photo inlines -``` - ---- - -## 🎯 Usage Examples - -### Upload Photo (API) -```bash -curl -X POST http://localhost:8000/api/v1/photos/upload \ - -H "Authorization: Bearer {token}" \ - -F "file=@photo.jpg" \ - -F "title=Amazing Roller Coaster" \ - -F "photo_type=gallery" \ - -F "entity_type=park" \ - -F "entity_id={park_uuid}" -``` - -### Get Entity Photos (API) -```bash -curl http://localhost:8000/api/v1/park/{park_id}/photos?photo_type=gallery -``` - -### In Python Code -```python -from apps.entities.models import Park -from apps.media.services import PhotoService - -# Get a park -park = Park.objects.get(slug='cedar-point') - -# Get photos -main_photo = park.main_photo -gallery = park.gallery_photos -all_photos = park.get_photos(approved_only=True) - -# Upload programmatically -service = PhotoService() -photo = service.create_photo( - file=uploaded_file, - user=request.user, - entity=park, - photo_type='gallery' -) -``` - ---- - -## ✨ Key Features - -### 1. Development-Friendly -- **Mock Mode:** Works without CloudFlare (uses placeholder URLs) -- **Automatic Fallback:** Detects missing credentials -- **Local Testing:** Full functionality in development - -### 2. Production-Ready -- **CDN Integration:** CloudFlare Images for global delivery -- **Scalable Storage:** No local file storage needed -- **Image Optimization:** Automatic variant generation - -### 3. Moderation System -- **Queue-Based:** All uploads enter moderation -- **Bulk Actions:** Approve/reject multiple photos -- **Status Tracking:** Pending, approved, rejected, flagged -- **Notes:** Moderators can add rejection reasons - -### 4. Entity Integration -- **Generic Relations:** Photos attach to any entity -- **Helper Methods:** Easy photo access on entities -- **Admin Inlines:** Manage photos directly on entity pages -- **Type Filtering:** Get specific photo types (main, gallery, etc.) - -### 5. API Completeness -- **Full CRUD:** Create, Read, Update, Delete -- **Pagination:** All list endpoints paginated -- **Filtering:** Filter by type, status, entity -- **Permission Control:** Role-based access -- **Error Handling:** Comprehensive validation and error responses - ---- - -## 🧪 Testing Checklist - -### Basic Functionality -- [x] Upload photo via API -- [x] Photo enters moderation queue -- [x] Moderator can approve photo -- [x] Approved photo visible publicly -- [x] User can edit own photo metadata -- [x] User can delete own photo - -### CloudFlare Integration -- [x] Mock mode works without credentials -- [x] Upload succeeds in mock mode -- [x] Placeholder URLs generated -- [x] Delete works in mock mode - -### Entity Integration -- [x] Photos attach to entities -- [x] Entity helper methods work -- [x] Photo inlines appear in admin -- [x] Gallery ordering works - -### Admin Interface -- [x] Thumbnail previews display -- [x] Bulk approve works -- [x] Bulk reject works -- [x] Statistics display correctly - -### API Endpoints -- [x] All endpoints registered -- [x] Authentication enforced -- [x] Permission checks work -- [x] Pagination functions -- [x] Filtering works - ---- - -## 📈 Performance Considerations - -### Optimizations Implemented -- ✅ `select_related` for user and content_type -- ✅ Indexed fields (moderation_status, photo_type, content_type) -- ✅ CDN delivery for images (not served through Django) -- ✅ Efficient queryset filtering - -### Recommended Database Indexes -Already in Photo model: -```python -indexes = [ - models.Index(fields=['moderation_status']), - models.Index(fields=['photo_type']), - models.Index(fields=['is_approved']), - models.Index(fields=['created_at']), -] -``` - ---- - -## 🔮 Future Enhancements (Not in Phase 6) - -### Phase 7 Candidates -- [ ] Image processing with Celery (resize, watermark) -- [ ] Automatic thumbnail generation fallback -- [ ] Duplicate detection -- [ ] Bulk upload via ZIP -- [ ] Image metadata extraction (EXIF) -- [ ] Content safety API integration -- [ ] Photo tagging system -- [ ] Advanced search - -### Possible Improvements -- [ ] Integration with ContentSubmission workflow -- [ ] Photo change history tracking -- [ ] Photo usage tracking (which entities use which photos) -- [ ] Photo performance analytics -- [ ] User photo quotas -- [ ] Photo quality scoring - ---- - -## 📝 Configuration Required - -### Environment Variables -Add to `.env`: -```bash -# CloudFlare Images (optional for development) -CLOUDFLARE_ACCOUNT_ID=your-account-id -CLOUDFLARE_IMAGE_TOKEN=your-api-token -CLOUDFLARE_IMAGE_HASH=your-delivery-hash -``` - -### Development Setup -1. **Without CloudFlare:** System works in mock mode automatically -2. **With CloudFlare:** Add credentials to `.env` file - -### Production Setup -1. Create CloudFlare Images account -2. Generate API token -3. Add credentials to production environment -4. Test upload flow -5. Monitor CDN delivery - ---- - -## 🎉 Success Metrics - -### Code Quality -- ✅ Comprehensive docstrings -- ✅ Type hints throughout -- ✅ Error handling on all operations -- ✅ Logging for debugging -- ✅ Consistent code style - -### Functionality -- ✅ All planned features implemented -- ✅ Full API coverage -- ✅ Admin interface complete -- ✅ Entity integration seamless - -### Performance -- ✅ Efficient database queries -- ✅ CDN delivery for images -- ✅ No bottlenecks identified - ---- - -## 🚀 What's Next? - -With Phase 6 complete, the system now has: -1. ✅ Complete entity models (Phases 1-2) -2. ✅ Moderation system (Phase 3) -3. ✅ Version history (Phase 4) -4. ✅ Authentication & permissions (Phase 5) -5. ✅ **Media management (Phase 6)** ← JUST COMPLETED - -### Recommended Next Steps - -**Option A: Phase 7 - Background Tasks with Celery** -- Async image processing -- Email notifications -- Scheduled cleanup tasks -- Stats generation -- Report generation - -**Option B: Phase 8 - Search & Discovery** -- Elasticsearch integration -- Full-text search across entities -- Geographic search improvements -- Related content recommendations -- Advanced filtering - -**Option C: Polish & Testing** -- Comprehensive test suite -- API documentation -- User guides -- Performance optimization -- Bug fixes - ---- - -## 📚 Documentation References - -- **API Guide:** `django/API_GUIDE.md` -- **Admin Guide:** `django/ADMIN_GUIDE.md` -- **Photo Model:** `django/apps/media/models.py` -- **Photo Service:** `django/apps/media/services.py` -- **Photo API:** `django/api/v1/endpoints/photos.py` - ---- - -## ✅ Phase 6 Complete! - -The Media Management System is fully functional and ready for use. Photos can be uploaded, moderated, and displayed across all entities with CloudFlare CDN delivery. - -**Estimated Build Time:** 4 hours -**Actual Build Time:** ~4 hours ✅ -**Lines of Code:** ~1,800 lines -**Files Created:** 3 -**Files Modified:** 5 - -**Status:** ✅ **PRODUCTION READY** diff --git a/django/PHASE_7_CELERY_COMPLETE.md b/django/PHASE_7_CELERY_COMPLETE.md deleted file mode 100644 index 3b279aaf..00000000 --- a/django/PHASE_7_CELERY_COMPLETE.md +++ /dev/null @@ -1,451 +0,0 @@ -# Phase 7: Background Tasks with Celery - COMPLETE ✅ - -**Completion Date:** November 8, 2025 -**Status:** Successfully Implemented - -## Overview - -Phase 7 implements a comprehensive background task processing system using Celery with Redis as the message broker. This phase adds asynchronous processing capabilities for long-running operations, scheduled tasks, and email notifications. - -## What Was Implemented - -### 1. Celery Infrastructure ✅ -- **Celery App Configuration** (`config/celery.py`) - - Auto-discovery of tasks from all apps - - Signal handlers for task failure/success logging - - Integration with Sentry for error tracking - -- **Django Integration** (`config/__init__.py`) - - Celery app loaded on Django startup - - Shared task decorators available throughout the project - -### 2. Email System ✅ -- **Email Templates** (`templates/emails/`) - - `base.html` - Base template with ThrillWiki branding - - `welcome.html` - Welcome email for new users - - `password_reset.html` - Password reset instructions - - `moderation_approved.html` - Submission approved notification - - `moderation_rejected.html` - Submission rejection notification - -- **Email Configuration** - - Development: Console backend (emails print to console) - - Production: SMTP/SendGrid (configurable via environment variables) - -### 3. Background Tasks ✅ - -#### Media Tasks (`apps/media/tasks.py`) -- `process_uploaded_image(photo_id)` - Post-upload image processing -- `cleanup_rejected_photos(days_old=30)` - Remove old rejected photos -- `generate_photo_thumbnails(photo_id)` - On-demand thumbnail generation -- `cleanup_orphaned_cloudflare_images()` - Remove orphaned images -- `update_photo_statistics()` - Update photo-related statistics - -#### Moderation Tasks (`apps/moderation/tasks.py`) -- `send_moderation_notification(submission_id, status)` - Email notifications -- `cleanup_expired_locks()` - Remove stale moderation locks -- `send_batch_moderation_summary(moderator_id)` - Daily moderator summaries -- `update_moderation_statistics()` - Update moderation statistics -- `auto_unlock_stale_reviews(hours=1)` - Auto-unlock stale submissions -- `notify_moderators_of_queue_size()` - Alert on queue threshold - -#### User Tasks (`apps/users/tasks.py`) -- `send_welcome_email(user_id)` - Welcome new users -- `send_password_reset_email(user_id, token, reset_url)` - Password resets -- `cleanup_expired_tokens()` - Remove expired JWT tokens -- `send_account_notification(user_id, type, data)` - Generic notifications -- `cleanup_inactive_users(days_inactive=365)` - Flag inactive accounts -- `update_user_statistics()` - Update user statistics -- `send_bulk_notification(user_ids, subject, message)` - Bulk emails -- `send_email_verification_reminder(user_id)` - Verification reminders - -#### Entity Tasks (`apps/entities/tasks.py`) -- `update_entity_statistics(entity_type, entity_id)` - Update entity stats -- `update_all_statistics()` - Bulk statistics update -- `generate_entity_report(entity_type, entity_id)` - Generate reports -- `cleanup_duplicate_entities()` - Detect duplicates -- `calculate_global_statistics()` - Global statistics -- `validate_entity_data(entity_type, entity_id)` - Data validation - -### 4. Scheduled Tasks (Celery Beat) ✅ - -Configured in `config/settings/base.py`: - -| Task | Schedule | Purpose | -|------|----------|---------| -| `cleanup-expired-locks` | Every 5 minutes | Remove expired moderation locks | -| `cleanup-expired-tokens` | Daily at 2 AM | Clean up expired JWT tokens | -| `update-all-statistics` | Every 6 hours | Update entity statistics | -| `cleanup-rejected-photos` | Weekly Mon 3 AM | Remove old rejected photos | -| `auto-unlock-stale-reviews` | Every 30 minutes | Auto-unlock stale reviews | -| `check-moderation-queue` | Every hour | Check queue size threshold | -| `update-photo-statistics` | Daily at 1 AM | Update photo statistics | -| `update-moderation-statistics` | Daily at 1:30 AM | Update moderation statistics | -| `update-user-statistics` | Daily at 4 AM | Update user statistics | -| `calculate-global-statistics` | Every 12 hours | Calculate global statistics | - -### 5. Service Integration ✅ -- **PhotoService** - Triggers `process_uploaded_image` on photo creation -- **ModerationService** - Sends email notifications on approval/rejection -- Error handling ensures service operations don't fail if tasks fail to queue - -### 6. Monitoring ✅ -- **Flower** - Web-based Celery monitoring (production only) -- **Task Logging** - Success/failure logging for all tasks -- **Sentry Integration** - Error tracking for failed tasks - -## Setup Instructions - -### Development Setup - -1. **Install Redis** (if not using eager mode): - ```bash - # macOS with Homebrew - brew install redis - brew services start redis - - # Or using Docker - docker run -d -p 6379:6379 redis:latest - ``` - -2. **Configure Environment** (`.env`): - ```env - # Redis Configuration - REDIS_URL=redis://localhost:6379/0 - CELERY_BROKER_URL=redis://localhost:6379/0 - CELERY_RESULT_BACKEND=redis://localhost:6379/1 - - # Email Configuration (Development) - EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend - DEFAULT_FROM_EMAIL=noreply@thrillwiki.com - SITE_URL=http://localhost:8000 - ``` - -3. **Run Celery Worker** (in separate terminal): - ```bash - cd django - celery -A config worker --loglevel=info - ``` - -4. **Run Celery Beat** (in separate terminal): - ```bash - cd django - celery -A config beat --loglevel=info - ``` - -5. **Development Mode** (No Redis Required): - - Tasks run synchronously when `CELERY_TASK_ALWAYS_EAGER = True` (default in `local.py`) - - Useful for debugging and testing without Redis - -### Production Setup - -1. **Configure Environment**: - ```env - # Redis Configuration - REDIS_URL=redis://your-redis-host:6379/0 - CELERY_BROKER_URL=redis://your-redis-host:6379/0 - CELERY_RESULT_BACKEND=redis://your-redis-host:6379/1 - - # Email Configuration (Production) - EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend - EMAIL_HOST=smtp.sendgrid.net - EMAIL_PORT=587 - EMAIL_USE_TLS=True - EMAIL_HOST_USER=apikey - EMAIL_HOST_PASSWORD=your-sendgrid-api-key - DEFAULT_FROM_EMAIL=noreply@thrillwiki.com - SITE_URL=https://thrillwiki.com - - # Flower Monitoring (Optional) - FLOWER_ENABLED=True - FLOWER_BASIC_AUTH=username:password - ``` - -2. **Run Celery Worker** (systemd service): - ```ini - [Unit] - Description=ThrillWiki Celery Worker - After=network.target redis.target - - [Service] - Type=forking - User=www-data - Group=www-data - WorkingDirectory=/var/www/thrillwiki/django - Environment="PATH=/var/www/thrillwiki/venv/bin" - ExecStart=/var/www/thrillwiki/venv/bin/celery -A config worker \ - --loglevel=info \ - --logfile=/var/log/celery/worker.log \ - --pidfile=/var/run/celery/worker.pid - - [Install] - WantedBy=multi-user.target - ``` - -3. **Run Celery Beat** (systemd service): - ```ini - [Unit] - Description=ThrillWiki Celery Beat - After=network.target redis.target - - [Service] - Type=forking - User=www-data - Group=www-data - WorkingDirectory=/var/www/thrillwiki/django - Environment="PATH=/var/www/thrillwiki/venv/bin" - ExecStart=/var/www/thrillwiki/venv/bin/celery -A config beat \ - --loglevel=info \ - --logfile=/var/log/celery/beat.log \ - --pidfile=/var/run/celery/beat.pid \ - --schedule=/var/run/celery/celerybeat-schedule - - [Install] - WantedBy=multi-user.target - ``` - -4. **Run Flower** (optional): - ```bash - celery -A config flower --port=5555 --basic_auth=$FLOWER_BASIC_AUTH - ``` - Access at: `https://your-domain.com/flower/` - -## Testing - -### Manual Testing - -1. **Test Photo Upload Task**: - ```python - from apps.media.tasks import process_uploaded_image - result = process_uploaded_image.delay(photo_id) - print(result.get()) # Wait for result - ``` - -2. **Test Email Notification**: - ```python - from apps.moderation.tasks import send_moderation_notification - result = send_moderation_notification.delay(str(submission_id), 'approved') - # Check console output for email - ``` - -3. **Test Scheduled Task**: - ```python - from apps.moderation.tasks import cleanup_expired_locks - result = cleanup_expired_locks.delay() - print(result.get()) - ``` - -### Integration Testing - -Test that services properly queue tasks: - -```python -# Test PhotoService integration -from apps.media.services import PhotoService -service = PhotoService() -photo = service.create_photo(file, user) -# Task should be queued automatically - -# Test ModerationService integration -from apps.moderation.services import ModerationService -ModerationService.approve_submission(submission_id, reviewer) -# Email notification should be queued -``` - -## Task Catalog - -### Task Retry Configuration - -All tasks implement retry logic: -- **Max Retries:** 2-3 (task-dependent) -- **Retry Delay:** 60 seconds base (exponential backoff) -- **Failure Handling:** Logged to Sentry and application logs - -### Task Priority - -Tasks are executed in the order they're queued. For priority queuing, configure Celery with multiple queues: - -```python -# config/celery.py (future enhancement) -CELERY_TASK_ROUTES = { - 'apps.media.tasks.process_uploaded_image': {'queue': 'media'}, - 'apps.moderation.tasks.send_moderation_notification': {'queue': 'notifications'}, -} -``` - -## Monitoring & Debugging - -### View Task Status - -```python -from celery.result import AsyncResult - -result = AsyncResult('task-id-here') -print(result.state) # PENDING, STARTED, SUCCESS, FAILURE -print(result.info) # Result or error details -``` - -### Flower Dashboard - -Access Flower at `/flower/` (production only) to: -- View active tasks -- Monitor worker status -- View task history -- Inspect failed tasks -- Retry failed tasks - -### Logs - -```bash -# View worker logs -tail -f /var/log/celery/worker.log - -# View beat logs -tail -f /var/log/celery/beat.log - -# View Django logs (includes task execution) -tail -f django/logs/django.log -``` - -## Troubleshooting - -### Common Issues - -1. **Tasks not executing** - - Check Redis connection: `redis-cli ping` - - Verify Celery worker is running: `ps aux | grep celery` - - Check for errors in worker logs - -2. **Emails not sending** - - Verify EMAIL_BACKEND configuration - - Check SMTP credentials - - Review email logs in console (development) - -3. **Scheduled tasks not running** - - Ensure Celery Beat is running - - Check Beat logs for scheduling errors - - Verify CELERY_BEAT_SCHEDULE configuration - -4. **Task failures** - - Check Sentry for error reports - - Review worker logs - - Test task in Django shell - -### Performance Tuning - -```python -# Increase worker concurrency -celery -A config worker --concurrency=4 - -# Use different pool implementation -celery -A config worker --pool=gevent - -# Set task time limits -CELERY_TASK_TIME_LIMIT = 30 * 60 # 30 minutes (already configured) -``` - -## Configuration Options - -### Environment Variables - -| Variable | Required | Default | Description | -|----------|----------|---------|-------------| -| `REDIS_URL` | Yes* | `redis://localhost:6379/0` | Redis connection URL | -| `CELERY_BROKER_URL` | Yes* | Same as REDIS_URL | Celery message broker | -| `CELERY_RESULT_BACKEND` | Yes* | `redis://localhost:6379/1` | Task result storage | -| `EMAIL_BACKEND` | No | Console (dev) / SMTP (prod) | Email backend | -| `EMAIL_HOST` | Yes** | - | SMTP host | -| `EMAIL_PORT` | Yes** | 587 | SMTP port | -| `EMAIL_HOST_USER` | Yes** | - | SMTP username | -| `EMAIL_HOST_PASSWORD` | Yes** | - | SMTP password | -| `DEFAULT_FROM_EMAIL` | Yes | `noreply@thrillwiki.com` | From email address | -| `SITE_URL` | Yes | `http://localhost:8000` | Site URL for emails | -| `FLOWER_ENABLED` | No | False | Enable Flower monitoring | -| `FLOWER_BASIC_AUTH` | No** | - | Flower authentication | - -\* Not required if using eager mode in development -\*\* Required for production email sending - -## Next Steps - -### Future Enhancements - -1. **Task Prioritization** - - Implement multiple queues for different priority levels - - Critical tasks (password reset) in high-priority queue - - Bulk operations in low-priority queue - -2. **Advanced Monitoring** - - Set up Prometheus metrics - - Configure Grafana dashboards - - Add task duration tracking - -3. **Email Improvements** - - Add plain text email versions - - Implement email templates for all notification types - - Add email preference management - -4. **Scalability** - - Configure multiple Celery workers - - Implement auto-scaling based on queue size - - Add Redis Sentinel for high availability - -5. **Additional Tasks** - - Backup generation tasks - - Data export tasks - - Analytics report generation - -## Success Criteria ✅ - -All success criteria for Phase 7 have been met: - -- ✅ Celery workers running successfully -- ✅ Tasks executing asynchronously -- ✅ Email notifications working (console backend configured) -- ✅ Scheduled tasks configured and ready -- ✅ Flower monitoring configured for production -- ✅ Error handling and retries implemented -- ✅ Integration with existing services complete -- ✅ Comprehensive documentation created - -## Files Created - -- `config/celery.py` - Celery app configuration -- `config/__init__.py` - Updated to load Celery -- `templates/emails/base.html` - Base email template -- `templates/emails/welcome.html` - Welcome email -- `templates/emails/password_reset.html` - Password reset email -- `templates/emails/moderation_approved.html` - Approval notification -- `templates/emails/moderation_rejected.html` - Rejection notification -- `apps/media/tasks.py` - Media processing tasks -- `apps/moderation/tasks.py` - Moderation workflow tasks -- `apps/users/tasks.py` - User management tasks -- `apps/entities/tasks.py` - Entity statistics tasks -- `PHASE_7_CELERY_COMPLETE.md` - This documentation - -## Files Modified - -- `config/settings/base.py` - Added Celery Beat schedule, SITE_URL, DEFAULT_FROM_EMAIL -- `config/urls.py` - Added Flower URL routing -- `apps/media/services.py` - Integrated photo processing task -- `apps/moderation/services.py` - Integrated email notification tasks - -## Dependencies - -All dependencies were already included in `requirements/base.txt`: -- `celery[redis]==5.3.4` -- `django-celery-beat==2.5.0` -- `django-celery-results==2.5.1` -- `flower==2.0.1` - -## Summary - -Phase 7 successfully implements a complete background task processing system with Celery. The system handles: -- Asynchronous image processing -- Email notifications for moderation workflow -- Scheduled maintenance tasks -- Statistics updates -- Token cleanup - -The implementation is production-ready with proper error handling, retry logic, monitoring, and documentation. - -**Phase 7: COMPLETE** ✅ diff --git a/django/PHASE_8_SEARCH_COMPLETE.md b/django/PHASE_8_SEARCH_COMPLETE.md deleted file mode 100644 index 12280d48..00000000 --- a/django/PHASE_8_SEARCH_COMPLETE.md +++ /dev/null @@ -1,411 +0,0 @@ -# Phase 8: Search & Filtering System - COMPLETE - -**Status:** ✅ Complete -**Date:** November 8, 2025 -**Django Version:** 5.x -**Database:** PostgreSQL (production) / SQLite (development) - ---- - -## Overview - -Phase 8 implements a comprehensive search and filtering system for ThrillWiki entities with PostgreSQL full-text search capabilities and SQLite fallback support. - -## Implementation Summary - -### 1. Search Service (`apps/entities/search.py`) -✅ **Created** - -**Features:** -- PostgreSQL full-text search with ranking and relevance scoring -- SQLite fallback using case-insensitive LIKE queries -- Search across all entity types (Company, RideModel, Park, Ride) -- Global search and entity-specific search methods -- Autocomplete functionality for quick suggestions - -**Key Methods:** -- `search_all()` - Search across all entity types -- `search_companies()` - Company-specific search with filters -- `search_ride_models()` - Ride model search with manufacturer filters -- `search_parks()` - Park search with location-based filtering (PostGIS) -- `search_rides()` - Ride search with extensive filtering options -- `autocomplete()` - Fast name-based suggestions - -**PostgreSQL Features:** -- Uses `SearchVector`, `SearchQuery`, `SearchRank` for full-text search -- Weighted search (name='A', description='B' for relevance) -- `websearch` search type for natural language queries -- English language configuration for stemming/stop words - -**SQLite Fallback:** -- Case-insensitive LIKE queries (`__icontains`) -- Basic text matching without ranking -- Functional but less performant than PostgreSQL - -### 2. Filter Classes (`apps/entities/filters.py`) -✅ **Created** - -**Base Filter Class:** -- `BaseEntityFilter` - Common filtering methods - - Date range filtering - - Status filtering - -**Entity-Specific Filters:** -- `CompanyFilter` - Company types, founding dates, location -- `RideModelFilter` - Manufacturer, model type, height/speed -- `ParkFilter` - Status, park type, operator, dates, location (PostGIS) -- `RideFilter` - Park, manufacturer, model, category, statistics - -**Location-Based Filtering (PostGIS):** -- Distance-based queries using Point geometries -- Radius filtering in kilometers -- Automatic ordering by distance - -### 3. API Schemas (`api/v1/schemas.py`) -✅ **Updated** - -**Added Search Schemas:** -- `SearchResultBase` - Base search result schema -- `CompanySearchResult` - Company search result with counts -- `RideModelSearchResult` - Ride model result with manufacturer -- `ParkSearchResult` - Park result with location and stats -- `RideSearchResult` - Ride result with park and category -- `GlobalSearchResponse` - Combined search results by type -- `AutocompleteItem` - Autocomplete suggestion item -- `AutocompleteResponse` - Autocomplete response wrapper - -**Filter Schemas:** -- `SearchFilters` - Base search filters -- `CompanySearchFilters` - Company-specific filters -- `RideModelSearchFilters` - Ride model filters -- `ParkSearchFilters` - Park filters with location -- `RideSearchFilters` - Extensive ride filters - -### 4. Search API Endpoints (`api/v1/endpoints/search.py`) -✅ **Created** - -**Global Search:** -- `GET /api/v1/search` - Search across all entity types - - Query parameter: `q` (min 2 chars) - - Optional: `entity_types` list to filter results - - Returns results grouped by entity type - -**Entity-Specific Search:** -- `GET /api/v1/search/companies` - Search companies - - Filters: company_types, founded_after, founded_before -- `GET /api/v1/search/ride-models` - Search ride models - - Filters: manufacturer_id, model_type -- `GET /api/v1/search/parks` - Search parks - - Filters: status, park_type, operator_id, dates - - Location: latitude, longitude, radius (PostGIS only) -- `GET /api/v1/search/rides` - Search rides - - Filters: park_id, manufacturer_id, model_id, status - - Category: ride_category, is_coaster - - Stats: min/max height, speed - -**Autocomplete:** -- `GET /api/v1/search/autocomplete` - Fast suggestions - - Query parameter: `q` (min 2 chars) - - Optional: `entity_type` to filter suggestions - - Returns up to 10-20 quick suggestions - -### 5. API Integration (`api/v1/api.py`) -✅ **Updated** - -**Changes:** -- Added search router import -- Registered search router at `/search` -- Updated API info endpoint with search endpoint - -**Available Endpoints:** -``` -GET /api/v1/search - Global search -GET /api/v1/search/companies - Company search -GET /api/v1/search/ride-models - Ride model search -GET /api/v1/search/parks - Park search -GET /api/v1/search/rides - Ride search -GET /api/v1/search/autocomplete - Autocomplete -``` - ---- - -## Database Compatibility - -### PostgreSQL (Production) -- ✅ Full-text search with ranking -- ✅ Location-based filtering with PostGIS -- ✅ SearchVector, SearchQuery, SearchRank -- ✅ Optimized for performance - -### SQLite (Development) -- ✅ Basic text search with LIKE queries -- ⚠️ No search ranking -- ⚠️ No location-based filtering -- ⚠️ Acceptable for development, not production - -**Note:** For full search capabilities in development, you can optionally set up PostgreSQL locally. See `POSTGIS_SETUP.md` for instructions. - ---- - -## Search Features - -### Full-Text Search -- **Natural Language Queries**: "Six Flags roller coaster" -- **Phrase Matching**: Search for exact phrases -- **Stemming**: Matches word variations (PostgreSQL only) -- **Relevance Ranking**: Results ordered by relevance score - -### Filtering Options - -**Companies:** -- Company types (manufacturer, operator, designer, supplier, contractor) -- Founded date range -- Location - -**Ride Models:** -- Manufacturer -- Model type -- Height/speed ranges - -**Parks:** -- Status (operating, closed, SBNO, under construction, planned) -- Park type (theme park, amusement park, water park, FEC, etc.) -- Operator -- Opening/closing dates -- Location + radius (PostGIS) -- Minimum ride/coaster counts - -**Rides:** -- Park, manufacturer, model -- Status -- Ride category (roller coaster, flat ride, water ride, etc.) -- Coaster filter -- Opening/closing dates -- Height, speed, length ranges -- Duration, inversions - -### Autocomplete -- Fast prefix matching on entity names -- Returns id, name, slug, entity_type -- Contextual information (park name for rides, manufacturer for models) -- Sorted by relevance (exact matches first) - ---- - -## API Examples - -### Global Search -```bash -# Search across all entities -curl "http://localhost:8000/api/v1/search?q=six%20flags" - -# Search specific entity types -curl "http://localhost:8000/api/v1/search?q=coaster&entity_types=park&entity_types=ride" -``` - -### Company Search -```bash -# Search companies -curl "http://localhost:8000/api/v1/search/companies?q=bolliger" - -# Filter by company type -curl "http://localhost:8000/api/v1/search/companies?q=manufacturer&company_types=manufacturer" -``` - -### Park Search -```bash -# Basic park search -curl "http://localhost:8000/api/v1/search/parks?q=cedar%20point" - -# Filter by status -curl "http://localhost:8000/api/v1/search/parks?q=park&status=operating" - -# Location-based search (PostGIS only) -curl "http://localhost:8000/api/v1/search/parks?q=park&latitude=41.4779&longitude=-82.6830&radius=50" -``` - -### Ride Search -```bash -# Search rides -curl "http://localhost:8000/api/v1/search/rides?q=millennium%20force" - -# Filter coasters only -curl "http://localhost:8000/api/v1/search/rides?q=coaster&is_coaster=true" - -# Filter by height -curl "http://localhost:8000/api/v1/search/rides?q=coaster&min_height=200&max_height=400" -``` - -### Autocomplete -```bash -# Get suggestions -curl "http://localhost:8000/api/v1/search/autocomplete?q=six" - -# Filter by entity type -curl "http://localhost:8000/api/v1/search/autocomplete?q=cedar&entity_type=park" -``` - ---- - -## Response Examples - -### Global Search Response -```json -{ - "query": "six flags", - "total_results": 15, - "companies": [ - { - "id": "uuid", - "name": "Six Flags Entertainment Corporation", - "slug": "six-flags", - "entity_type": "company", - "description": "...", - "company_types": ["operator"], - "park_count": 27, - "ride_count": 0 - } - ], - "parks": [ - { - "id": "uuid", - "name": "Six Flags Magic Mountain", - "slug": "six-flags-magic-mountain", - "entity_type": "park", - "park_type": "theme_park", - "status": "operating", - "ride_count": 45, - "coaster_count": 19 - } - ], - "ride_models": [], - "rides": [] -} -``` - -### Autocomplete Response -```json -{ - "query": "cedar", - "suggestions": [ - { - "id": "uuid", - "name": "Cedar Point", - "slug": "cedar-point", - "entity_type": "park" - }, - { - "id": "uuid", - "name": "Cedar Creek Mine Ride", - "slug": "cedar-creek-mine-ride", - "entity_type": "ride", - "park_name": "Cedar Point" - } - ] -} -``` - ---- - -## Performance Considerations - -### PostgreSQL Optimization -- Uses GIN indexes for fast full-text search (would be added with migration) -- Weighted search vectors prioritize name matches -- Efficient query execution with proper indexing - -### Query Limits -- Default limit: 20 results per entity type -- Maximum limit: 100 results per entity type -- Autocomplete: 10 suggestions default, max 20 - -### SQLite Performance -- Acceptable for development with small datasets -- LIKE queries can be slow with large datasets -- No search ranking means less relevant results - ---- - -## Testing - -### Manual Testing -```bash -# Run Django server -cd django -python manage.py runserver - -# Test endpoints (requires data) -curl "http://localhost:8000/api/v1/search?q=test" -curl "http://localhost:8000/api/v1/search/autocomplete?q=test" -``` - -### Django Check -```bash -cd django -python manage.py check -# ✅ System check identified no issues (0 silenced) -``` - ---- - -## Future Enhancements - -### Search Analytics (Optional - Not Implemented) -- Track popular searches -- User search history -- Click tracking for search results -- Search term suggestions based on popularity - -### Potential Improvements -1. **Search Vector Fields**: Add SearchVectorField to models with database triggers -2. **Search Indexes**: Create GIN indexes for better performance -3. **Trigram Similarity**: Use pg_trgm for fuzzy matching -4. **Search Highlighting**: Highlight matching terms in results -5. **Saved Searches**: Allow users to save and reuse searches -6. **Advanced Operators**: Support AND/OR/NOT operators -7. **Faceted Search**: Add result facets/filters based on results - ---- - -## Files Created/Modified - -### New Files -- ✅ `django/apps/entities/search.py` - Search service -- ✅ `django/apps/entities/filters.py` - Filter classes -- ✅ `django/api/v1/endpoints/search.py` - Search API endpoints -- ✅ `django/PHASE_8_SEARCH_COMPLETE.md` - This documentation - -### Modified Files -- ✅ `django/api/v1/schemas.py` - Added search schemas -- ✅ `django/api/v1/api.py` - Added search router - ---- - -## Dependencies - -All required dependencies already present in `requirements/base.txt`: -- ✅ Django 5.x with `django.contrib.postgres` -- ✅ psycopg[binary] for PostgreSQL -- ✅ django-ninja for API endpoints -- ✅ pydantic for schemas - ---- - -## Conclusion - -Phase 8 successfully implements a comprehensive search and filtering system with: -- ✅ Full-text search with PostgreSQL (and SQLite fallback) -- ✅ Advanced filtering for all entity types -- ✅ Location-based search with PostGIS -- ✅ Fast autocomplete functionality -- ✅ Clean API with extensive documentation -- ✅ Backward compatible with existing system -- ✅ Production-ready code - -The search system is ready for use and can be further enhanced with search vector fields and indexes when needed. - -**Next Steps:** -- Consider adding SearchVectorField to models for better performance -- Create database migration for GIN indexes -- Implement search analytics if desired -- Test with production data diff --git a/django/POSTGIS_SETUP.md b/django/POSTGIS_SETUP.md deleted file mode 100644 index 5fa2aa32..00000000 --- a/django/POSTGIS_SETUP.md +++ /dev/null @@ -1,297 +0,0 @@ -# PostGIS Integration - Dual-Mode Setup - -## Overview - -ThrillWiki Django backend uses a **conditional PostGIS setup** that allows geographic data to work in both local development (SQLite) and production (PostgreSQL with PostGIS). - -## How It Works - -### Database Backends - -- **Local Development**: Uses regular SQLite without GIS extensions - - Geographic coordinates stored in `latitude` and `longitude` DecimalFields - - No spatial query capabilities - - Simpler setup, easier for local development - -- **Production**: Uses PostgreSQL with PostGIS extension - - Geographic coordinates stored in `location_point` PointField (PostGIS) - - Full spatial query capabilities (distance calculations, geographic searches, etc.) - - Automatically syncs with legacy `latitude`/`longitude` fields - -### Model Implementation - -The `Park` model uses conditional field definition: - -```python -# Conditionally import GIS models only if using PostGIS backend -_using_postgis = ( - 'postgis' in settings.DATABASES['default']['ENGINE'] -) - -if _using_postgis: - from django.contrib.gis.db import models as gis_models - from django.contrib.gis.geos import Point -``` - -**Fields in SQLite mode:** -- `latitude` (DecimalField) - Primary coordinate storage -- `longitude` (DecimalField) - Primary coordinate storage - -**Fields in PostGIS mode:** -- `location_point` (PointField) - Primary coordinate storage with GIS capabilities -- `latitude` (DecimalField) - Deprecated, kept for backward compatibility -- `longitude` (DecimalField) - Deprecated, kept for backward compatibility - -### Helper Methods - -The Park model provides methods that work in both modes: - -#### `set_location(longitude, latitude)` -Sets park location from coordinates. Works in both modes: -- SQLite: Updates latitude/longitude fields -- PostGIS: Updates location_point and syncs to latitude/longitude - -```python -park.set_location(-118.2437, 34.0522) -``` - -#### `coordinates` property -Returns coordinates as `(longitude, latitude)` tuple: -- SQLite: Returns from latitude/longitude fields -- PostGIS: Returns from location_point (falls back to lat/lng if not set) - -```python -coords = park.coordinates # (-118.2437, 34.0522) -``` - -#### `latitude_value` property -Returns latitude value: -- SQLite: Returns from latitude field -- PostGIS: Returns from location_point.y - -#### `longitude_value` property -Returns longitude value: -- SQLite: Returns from longitude field -- PostGIS: Returns from location_point.x - -## Setup Instructions - -### Local Development (SQLite) - -1. **No special setup required!** Just use the standard SQLite database: - ```python - # django/config/settings/local.py - DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', - } - } - ``` - -2. Run migrations as normal: - ```bash - python manage.py migrate - ``` - -3. Use latitude/longitude fields for coordinates: - ```python - park = Park.objects.create( - name="Test Park", - latitude=40.7128, - longitude=-74.0060 - ) - ``` - -### Production (PostgreSQL with PostGIS) - -1. **Install PostGIS extension in PostgreSQL:** - ```sql - CREATE EXTENSION postgis; - ``` - -2. **Configure production settings:** - ```python - # django/config/settings/production.py - DATABASES = { - 'default': { - 'ENGINE': 'django.contrib.gis.db.backends.postgis', - 'NAME': 'thrillwiki', - 'USER': 'your_user', - 'PASSWORD': 'your_password', - 'HOST': 'your_host', - 'PORT': '5432', - } - } - ``` - -3. **Run migrations:** - ```bash - python manage.py migrate - ``` - - This will create the `location_point` PointField in addition to the latitude/longitude fields. - -4. **Use location_point for geographic queries:** - ```python - from django.contrib.gis.geos import Point - from django.contrib.gis.measure import D - - # Create park with PostGIS Point - park = Park.objects.create( - name="Test Park", - location_point=Point(-118.2437, 34.0522, srid=4326) - ) - - # Geographic queries (only in PostGIS mode) - nearby_parks = Park.objects.filter( - location_point__distance_lte=( - Point(-118.2500, 34.0500, srid=4326), - D(km=10) - ) - ) - ``` - -## Migration Strategy - -### From SQLite to PostgreSQL - -When migrating from local development (SQLite) to production (PostgreSQL): - -1. Export your data from SQLite -2. Set up PostgreSQL with PostGIS -3. Run migrations (will create location_point field) -4. Import your data (latitude/longitude fields will be populated) -5. Run a data migration to populate location_point from lat/lng: - -```python -# Example data migration -from django.contrib.gis.geos import Point - -for park in Park.objects.filter(latitude__isnull=False, longitude__isnull=False): - if not park.location_point: - park.location_point = Point( - float(park.longitude), - float(park.latitude), - srid=4326 - ) - park.save(update_fields=['location_point']) -``` - -## Benefits - -1. **Easy Local Development**: No need to install PostGIS or SpatiaLite for local development -2. **Production Power**: Full GIS capabilities in production with PostGIS -3. **Backward Compatible**: Keeps latitude/longitude fields for compatibility -4. **Unified API**: Helper methods work the same in both modes -5. **Gradual Migration**: Can migrate from SQLite to PostGIS without data loss - -## Limitations - -### In SQLite Mode (Local Development) - -- **No spatial queries**: Cannot use PostGIS query features like: - - `distance_lte`, `distance_gte` (distance-based searches) - - `dwithin` (within distance) - - `contains`, `intersects` (geometric operations) - - Geographic indexing for performance - -- **Workarounds for local development:** - - Use simple filters on latitude/longitude ranges - - Implement basic distance calculations in Python if needed - - Most development work doesn't require spatial queries - -### In PostGIS Mode (Production) - -- **Use location_point for queries**: Always use the `location_point` field for geographic queries, not lat/lng -- **Sync fields**: If updating location_point directly, remember to sync to lat/lng if needed for compatibility - -## Testing - -### Test in SQLite (Local) -```bash -cd django -python manage.py shell - -# Test basic CRUD -from apps.entities.models import Park -from decimal import Decimal - -park = Park.objects.create( - name="Test Park", - park_type="theme_park", - latitude=Decimal("40.7128"), - longitude=Decimal("-74.0060") -) - -print(park.coordinates) # Should work -print(park.latitude_value) # Should work -``` - -### Test in PostGIS (Production) -```bash -cd django -python manage.py shell - -# Test GIS features -from apps.entities.models import Park -from django.contrib.gis.geos import Point -from django.contrib.gis.measure import D - -park = Park.objects.create( - name="Test Park", - park_type="theme_park", - location_point=Point(-118.2437, 34.0522, srid=4326) -) - -# Test distance query -nearby = Park.objects.filter( - location_point__distance_lte=( - Point(-118.2500, 34.0500, srid=4326), - D(km=10) - ) -) -``` - -## Future Considerations - -1. **Remove Legacy Fields**: Once fully migrated to PostGIS in production and all code uses location_point, the latitude/longitude fields can be deprecated and eventually removed - -2. **Add Spatial Indexes**: In production, add spatial indexes for better query performance: - ```python - class Meta: - indexes = [ - models.Index(fields=['location_point']), # Spatial index - ] - ``` - -3. **Geographic Search API**: Build geographic search endpoints that work differently based on backend: - - SQLite: Simple bounding box searches - - PostGIS: Advanced spatial queries with distance calculations - -## Troubleshooting - -### "AttributeError: 'DatabaseOperations' object has no attribute 'geo_db_type'" - -This error occurs when trying to use PostGIS PointField with regular SQLite. Solution: -- Ensure you're using the local.py settings which uses regular SQLite -- Make sure migrations were created with SQLite active (no location_point field) - -### "No such column: location_point" - -This occurs when: -- Code tries to access location_point in SQLite mode -- Solution: Use the helper methods (coordinates, latitude_value, longitude_value) instead - -### "GDAL library not found" - -This occurs when django.contrib.gis is loaded but GDAL is not installed: -- Even with SQLite, GDAL libraries must be available because django.contrib.gis is in INSTALLED_APPS -- Install GDAL via Homebrew: `brew install gdal geos` -- Configure paths in settings if needed - -## References - -- [Django GIS Documentation](https://docs.djangoproject.com/en/stable/ref/contrib/gis/) -- [PostGIS Documentation](https://postgis.net/documentation/) -- [GeoDjango Tutorial](https://docs.djangoproject.com/en/stable/ref/contrib/gis/tutorial/) diff --git a/django/README.md b/django/README.md deleted file mode 100644 index f97a2129..00000000 --- a/django/README.md +++ /dev/null @@ -1,281 +0,0 @@ -# ThrillWiki Django Backend - -## 🚀 Overview - -This is the Django REST API backend for ThrillWiki, replacing the previous Supabase backend. Built with modern Django best practices and production-ready packages. - -## 📦 Tech Stack - -- **Framework**: Django 4.2 LTS -- **API**: django-ninja (FastAPI-style) -- **Database**: PostgreSQL 15+ -- **Cache**: Redis + django-cacheops -- **Tasks**: Celery + Redis -- **Real-time**: Django Channels + WebSockets -- **Auth**: django-allauth + django-otp -- **Storage**: CloudFlare Images -- **Monitoring**: Sentry + structlog - -## 🏗️ Project Structure - -``` -django/ -├── manage.py -├── config/ # Django settings -├── apps/ # Django applications -│ ├── core/ # Base models & utilities -│ ├── entities/ # Parks, Rides, Companies -│ ├── moderation/ # Content moderation system -│ ├── versioning/ # Entity versioning -│ ├── users/ # User management -│ ├── media/ # Image/photo management -│ └── notifications/ # Notification system -├── api/ # REST API layer -└── scripts/ # Utility scripts -``` - -## 🛠️ Setup - -### Prerequisites - -- Python 3.11+ -- PostgreSQL 15+ -- Redis 7+ - -### Installation - -```bash -# 1. Create virtual environment -python3 -m venv venv -source venv/bin/activate # On Windows: venv\Scripts\activate - -# 2. Install dependencies -pip install -r requirements/local.txt - -# 3. Set up environment variables -cp .env.example .env -# Edit .env with your configuration - -# 4. Run migrations -python manage.py migrate - -# 5. Create superuser -python manage.py createsuperuser - -# 6. Run development server -python manage.py runserver -``` - -### Running Services - -```bash -# Terminal 1: Django dev server -python manage.py runserver - -# Terminal 2: Celery worker -celery -A config worker -l info - -# Terminal 3: Celery beat (periodic tasks) -celery -A config beat -l info - -# Terminal 4: Flower (task monitoring) -celery -A config flower -``` - -## 📚 Documentation - -- **Migration Plan**: See `MIGRATION_PLAN.md` for full migration details -- **Architecture**: See project documentation in `/docs/` -- **API Docs**: Available at `/api/docs` when server is running - -## 🧪 Testing - -```bash -# Run all tests -pytest - -# Run with coverage -pytest --cov=apps --cov-report=html - -# Run specific app tests -pytest apps/moderation/ - -# Run specific test file -pytest apps/moderation/tests/test_services.py -v -``` - -## 📋 Key Features - -### Moderation System -- State machine workflow with django-fsm -- Atomic transaction handling -- Selective approval support -- Automatic lock/unlock mechanism -- Real-time queue updates - -### Versioning System -- Automatic version tracking with django-lifecycle -- Full change history for all entities -- Diff generation -- Rollback capability - -### Authentication -- JWT-based API authentication -- OAuth2 (Google, Discord) -- Two-factor authentication (TOTP) -- Role-based permissions - -### Performance -- Automatic query caching with django-cacheops -- Redis-based session storage -- Optimized database queries -- Background task processing with Celery - -## 🔧 Management Commands - -```bash -# Create test data -python manage.py seed_data - -# Export data from Supabase -python manage.py export_supabase_data - -# Import data to Django -python manage.py import_supabase_data - -# Update cached counts -python manage.py update_counts - -# Clean old data -python manage.py cleanup_old_data -``` - -## 🚀 Deployment - -### Docker - -```bash -# Build image -docker build -t thrillwiki-backend . - -# Run with docker-compose -docker-compose up -d -``` - -### Production Checklist - -- [ ] Set `DEBUG=False` in production -- [ ] Configure `ALLOWED_HOSTS` -- [ ] Set strong `SECRET_KEY` -- [ ] Configure PostgreSQL connection -- [ ] Set up Redis -- [ ] Configure Celery workers -- [ ] Set up SSL/TLS -- [ ] Configure CORS origins -- [ ] Set up Sentry for error tracking -- [ ] Configure CloudFlare Images -- [ ] Set up monitoring/logging - -## 📊 Development Status - -**Current Phase**: Foundation -**Branch**: `django-backend` - -### Completed -- ✅ Project structure created -- ✅ Dependencies installed -- ✅ Environment configuration - -### In Progress -- 🔄 Django settings configuration -- 🔄 Base models creation -- 🔄 Database connection setup - -### Upcoming -- ⏳ Entity models implementation -- ⏳ Authentication system -- ⏳ Moderation system -- ⏳ API layer with django-ninja - -See `MIGRATION_PLAN.md` for detailed roadmap. - -## 🤝 Contributing - -1. Create a feature branch from `django-backend` -2. Make your changes -3. Write/update tests -4. Run test suite -5. Submit pull request - -## 📝 Environment Variables - -Required environment variables (see `.env.example`): - -```bash -# Django -DEBUG=True -SECRET_KEY=your-secret-key -ALLOWED_HOSTS=localhost - -# Database -DATABASE_URL=postgresql://user:pass@localhost:5432/thrillwiki - -# Redis -REDIS_URL=redis://localhost:6379/0 - -# External Services -CLOUDFLARE_ACCOUNT_ID=xxx -CLOUDFLARE_IMAGE_TOKEN=xxx -NOVU_API_KEY=xxx -SENTRY_DSN=xxx - -# OAuth -GOOGLE_CLIENT_ID=xxx -GOOGLE_CLIENT_SECRET=xxx -DISCORD_CLIENT_ID=xxx -DISCORD_CLIENT_SECRET=xxx -``` - -## 🐛 Troubleshooting - -### Database Connection Issues -```bash -# Check PostgreSQL is running -pg_isready - -# Verify connection string -python manage.py dbshell -``` - -### Celery Not Processing Tasks -```bash -# Check Redis is running -redis-cli ping - -# Restart Celery worker -celery -A config worker --purge -l info -``` - -### Import Errors -```bash -# Ensure virtual environment is activated -which python # Should point to venv/bin/python - -# Reinstall dependencies -pip install -r requirements/local.txt --force-reinstall -``` - -## 📞 Support - -- **Documentation**: See `/docs/` directory -- **Issues**: GitHub Issues -- **Migration Questions**: See `MIGRATION_PLAN.md` - -## 📄 License - -Same as main ThrillWiki project. - ---- - -**Last Updated**: November 8, 2025 -**Status**: Foundation Phase - Active Development diff --git a/django/api/__init__.py b/django/api/__init__.py deleted file mode 100644 index 67cfe727..00000000 --- a/django/api/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -REST API package for ThrillWiki Django backend. -""" diff --git a/django/api/__pycache__/__init__.cpython-313.pyc b/django/api/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index e8237d30..00000000 Binary files a/django/api/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/django/api/v1/__init__.py b/django/api/v1/__init__.py deleted file mode 100644 index 250e0423..00000000 --- a/django/api/v1/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -API v1 package. -""" diff --git a/django/api/v1/__pycache__/__init__.cpython-313.pyc b/django/api/v1/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 44d3f1d4..00000000 Binary files a/django/api/v1/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/django/api/v1/__pycache__/api.cpython-313.pyc b/django/api/v1/__pycache__/api.cpython-313.pyc deleted file mode 100644 index cd6ee4d7..00000000 Binary files a/django/api/v1/__pycache__/api.cpython-313.pyc and /dev/null differ diff --git a/django/api/v1/__pycache__/schemas.cpython-313.pyc b/django/api/v1/__pycache__/schemas.cpython-313.pyc deleted file mode 100644 index 1cf689e4..00000000 Binary files a/django/api/v1/__pycache__/schemas.cpython-313.pyc and /dev/null differ diff --git a/django/api/v1/api.py b/django/api/v1/api.py deleted file mode 100644 index 78914eda..00000000 --- a/django/api/v1/api.py +++ /dev/null @@ -1,158 +0,0 @@ -""" -Main API v1 router. - -This module combines all endpoint routers and provides the main API interface. -""" -from ninja import NinjaAPI -from ninja.security import django_auth - -from .endpoints.companies import router as companies_router -from .endpoints.ride_models import router as ride_models_router -from .endpoints.parks import router as parks_router -from .endpoints.rides import router as rides_router -from .endpoints.moderation import router as moderation_router -from .endpoints.versioning import router as versioning_router -from .endpoints.auth import router as auth_router -from .endpoints.photos import router as photos_router -from .endpoints.search import router as search_router - - -# Create the main API instance -api = NinjaAPI( - title="ThrillWiki API", - version="1.0.0", - description=""" -# ThrillWiki REST API - -A comprehensive API for amusement park, ride, and company data. - -## Features - -- **Companies**: Manufacturers, operators, and designers in the amusement industry -- **Ride Models**: Specific ride models from manufacturers -- **Parks**: Theme parks, amusement parks, water parks, and FECs -- **Rides**: Individual rides and roller coasters - -## Authentication - -The API uses JWT (JSON Web Token) authentication for secure access. - -### Getting Started -1. Register: `POST /api/v1/auth/register` -2. Login: `POST /api/v1/auth/login` (returns access & refresh tokens) -3. Use token: Include `Authorization: Bearer ` header in requests -4. Refresh: `POST /api/v1/auth/token/refresh` when access token expires - -### Permissions -- **Public**: Read operations (GET) on entities -- **Authenticated**: Create submissions, manage own profile -- **Moderator**: Approve/reject submissions, moderate content -- **Admin**: Full access, user management, role assignment - -### Optional: Multi-Factor Authentication (MFA) -Users can enable TOTP-based 2FA for enhanced security: -1. Enable: `POST /api/v1/auth/mfa/enable` -2. Confirm: `POST /api/v1/auth/mfa/confirm` -3. Login with MFA: Include `mfa_token` in login request - -## Pagination - -List endpoints return paginated results: -- Default page size: 50 items -- Use `page` parameter to navigate (e.g., `?page=2`) - -## Filtering & Search - -Most list endpoints support filtering and search parameters. -See individual endpoint documentation for available filters. - -## Geographic Search - -The parks endpoint includes a special `/parks/nearby/` endpoint for geographic searches: -- **Production (PostGIS)**: Uses accurate distance-based queries -- **Local Development (SQLite)**: Uses bounding box approximation - -## Rate Limiting - -Rate limiting will be implemented in future versions. - -## Data Format - -All dates are in ISO 8601 format (YYYY-MM-DD). -All timestamps are in ISO 8601 format with timezone. -UUIDs are used for all entity IDs. - """, - docs_url="/docs", - openapi_url="/openapi.json", -) - -# Add authentication router -api.add_router("/auth", auth_router) - -# Add routers for each entity -api.add_router("/companies", companies_router) -api.add_router("/ride-models", ride_models_router) -api.add_router("/parks", parks_router) -api.add_router("/rides", rides_router) - -# Add moderation router -api.add_router("/moderation", moderation_router) - -# Add versioning router -api.add_router("", versioning_router) # Versioning endpoints are nested under entity paths - -# Add photos router -api.add_router("", photos_router) # Photos endpoints include both /photos and entity-nested routes - -# Add search router -api.add_router("/search", search_router) - - -# Health check endpoint -@api.get("/health", tags=["System"], summary="Health check") -def health_check(request): - """ - Health check endpoint. - - Returns system status and API version. - """ - return { - "status": "healthy", - "version": "1.0.0", - "api": "ThrillWiki API v1" - } - - -# API info endpoint -@api.get("/info", tags=["System"], summary="API information") -def api_info(request): - """ - Get API information and statistics. - - Returns basic API metadata and available endpoints. - """ - from apps.entities.models import Company, RideModel, Park, Ride - - return { - "version": "1.0.0", - "title": "ThrillWiki API", - "endpoints": { - "auth": "/api/v1/auth/", - "companies": "/api/v1/companies/", - "ride_models": "/api/v1/ride-models/", - "parks": "/api/v1/parks/", - "rides": "/api/v1/rides/", - "moderation": "/api/v1/moderation/", - "photos": "/api/v1/photos/", - "search": "/api/v1/search/", - }, - "statistics": { - "companies": Company.objects.count(), - "ride_models": RideModel.objects.count(), - "parks": Park.objects.count(), - "rides": Ride.objects.count(), - "coasters": Ride.objects.filter(is_coaster=True).count(), - }, - "documentation": "/api/v1/docs", - "openapi_schema": "/api/v1/openapi.json", - } diff --git a/django/api/v1/endpoints/__init__.py b/django/api/v1/endpoints/__init__.py deleted file mode 100644 index 37ba6ac0..00000000 --- a/django/api/v1/endpoints/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -API v1 endpoints package. -""" diff --git a/django/api/v1/endpoints/__pycache__/__init__.cpython-313.pyc b/django/api/v1/endpoints/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index c012bc89..00000000 Binary files a/django/api/v1/endpoints/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/django/api/v1/endpoints/__pycache__/auth.cpython-313.pyc b/django/api/v1/endpoints/__pycache__/auth.cpython-313.pyc deleted file mode 100644 index b8858b33..00000000 Binary files a/django/api/v1/endpoints/__pycache__/auth.cpython-313.pyc and /dev/null differ diff --git a/django/api/v1/endpoints/__pycache__/companies.cpython-313.pyc b/django/api/v1/endpoints/__pycache__/companies.cpython-313.pyc deleted file mode 100644 index d6b9fb39..00000000 Binary files a/django/api/v1/endpoints/__pycache__/companies.cpython-313.pyc and /dev/null differ diff --git a/django/api/v1/endpoints/__pycache__/moderation.cpython-313.pyc b/django/api/v1/endpoints/__pycache__/moderation.cpython-313.pyc deleted file mode 100644 index 644c7374..00000000 Binary files a/django/api/v1/endpoints/__pycache__/moderation.cpython-313.pyc and /dev/null differ diff --git a/django/api/v1/endpoints/__pycache__/parks.cpython-313.pyc b/django/api/v1/endpoints/__pycache__/parks.cpython-313.pyc deleted file mode 100644 index 12e8c286..00000000 Binary files a/django/api/v1/endpoints/__pycache__/parks.cpython-313.pyc and /dev/null differ diff --git a/django/api/v1/endpoints/__pycache__/photos.cpython-313.pyc b/django/api/v1/endpoints/__pycache__/photos.cpython-313.pyc deleted file mode 100644 index 606d946e..00000000 Binary files a/django/api/v1/endpoints/__pycache__/photos.cpython-313.pyc and /dev/null differ diff --git a/django/api/v1/endpoints/__pycache__/ride_models.cpython-313.pyc b/django/api/v1/endpoints/__pycache__/ride_models.cpython-313.pyc deleted file mode 100644 index b1a2fdbc..00000000 Binary files a/django/api/v1/endpoints/__pycache__/ride_models.cpython-313.pyc and /dev/null differ diff --git a/django/api/v1/endpoints/__pycache__/rides.cpython-313.pyc b/django/api/v1/endpoints/__pycache__/rides.cpython-313.pyc deleted file mode 100644 index 76b4468b..00000000 Binary files a/django/api/v1/endpoints/__pycache__/rides.cpython-313.pyc and /dev/null differ diff --git a/django/api/v1/endpoints/__pycache__/search.cpython-313.pyc b/django/api/v1/endpoints/__pycache__/search.cpython-313.pyc deleted file mode 100644 index 31f9dd19..00000000 Binary files a/django/api/v1/endpoints/__pycache__/search.cpython-313.pyc and /dev/null differ diff --git a/django/api/v1/endpoints/__pycache__/versioning.cpython-313.pyc b/django/api/v1/endpoints/__pycache__/versioning.cpython-313.pyc deleted file mode 100644 index e43b20cb..00000000 Binary files a/django/api/v1/endpoints/__pycache__/versioning.cpython-313.pyc and /dev/null differ diff --git a/django/api/v1/endpoints/auth.py b/django/api/v1/endpoints/auth.py deleted file mode 100644 index 7baf0cf4..00000000 --- a/django/api/v1/endpoints/auth.py +++ /dev/null @@ -1,596 +0,0 @@ -""" -Authentication API endpoints. - -Provides endpoints for: -- User registration and login -- JWT token management -- MFA/2FA -- Password management -- User profile and preferences -- User administration -""" - -from typing import List, Optional -from django.http import HttpRequest -from django.core.exceptions import ValidationError, PermissionDenied -from django.db.models import Q -from ninja import Router -from rest_framework_simplejwt.tokens import RefreshToken -from rest_framework_simplejwt.exceptions import TokenError -import logging - -from apps.users.models import User, UserRole, UserProfile -from apps.users.services import ( - AuthenticationService, - MFAService, - RoleService, - UserManagementService -) -from apps.users.permissions import ( - jwt_auth, - require_auth, - require_admin, - get_permission_checker -) -from api.v1.schemas import ( - UserRegisterRequest, - UserLoginRequest, - TokenResponse, - TokenRefreshRequest, - UserProfileOut, - UserProfileUpdate, - ChangePasswordRequest, - ResetPasswordRequest, - TOTPEnableResponse, - TOTPConfirmRequest, - TOTPVerifyRequest, - UserRoleOut, - UserPermissionsOut, - UserStatsOut, - UserProfilePreferencesOut, - UserProfilePreferencesUpdate, - BanUserRequest, - UnbanUserRequest, - AssignRoleRequest, - UserListOut, - MessageSchema, - ErrorSchema, -) - -router = Router(tags=["Authentication"]) -logger = logging.getLogger(__name__) - - -# ============================================================================ -# Public Authentication Endpoints -# ============================================================================ - -@router.post("/register", response={201: UserProfileOut, 400: ErrorSchema}) -def register(request: HttpRequest, data: UserRegisterRequest): - """ - Register a new user account. - - - **email**: User's email address (required) - - **password**: Password (min 8 characters, required) - - **password_confirm**: Password confirmation (required) - - **username**: Username (optional, auto-generated if not provided) - - **first_name**: First name (optional) - - **last_name**: Last name (optional) - - Returns the created user profile and automatically logs in the user. - """ - try: - # Register user - user = AuthenticationService.register_user( - email=data.email, - password=data.password, - username=data.username, - first_name=data.first_name or '', - last_name=data.last_name or '' - ) - - logger.info(f"New user registered: {user.email}") - return 201, user - - except ValidationError as e: - error_msg = str(e.message_dict) if hasattr(e, 'message_dict') else str(e) - return 400, {"error": "Registration failed", "detail": error_msg} - except Exception as e: - logger.error(f"Registration error: {e}") - return 400, {"error": "Registration failed", "detail": str(e)} - - -@router.post("/login", response={200: TokenResponse, 401: ErrorSchema}) -def login(request: HttpRequest, data: UserLoginRequest): - """ - Login with email and password. - - - **email**: User's email address - - **password**: Password - - **mfa_token**: MFA token (required if MFA is enabled) - - Returns JWT access and refresh tokens on successful authentication. - """ - try: - # Authenticate user - user = AuthenticationService.authenticate_user(data.email, data.password) - - if not user: - return 401, {"error": "Invalid credentials", "detail": "Email or password is incorrect"} - - # Check MFA if enabled - if user.mfa_enabled: - if not data.mfa_token: - return 401, {"error": "MFA required", "detail": "Please provide MFA token"} - - if not MFAService.verify_totp(user, data.mfa_token): - return 401, {"error": "Invalid MFA token", "detail": "The MFA token is invalid"} - - # Generate tokens - refresh = RefreshToken.for_user(user) - - return 200, { - "access": str(refresh.access_token), - "refresh": str(refresh), - "token_type": "Bearer" - } - - except ValidationError as e: - return 401, {"error": "Authentication failed", "detail": str(e)} - except Exception as e: - logger.error(f"Login error: {e}") - return 401, {"error": "Authentication failed", "detail": str(e)} - - -@router.post("/token/refresh", response={200: TokenResponse, 401: ErrorSchema}) -def refresh_token(request: HttpRequest, data: TokenRefreshRequest): - """ - Refresh JWT access token using refresh token. - - - **refresh**: Refresh token - - Returns new access token and optionally a new refresh token. - """ - try: - refresh = RefreshToken(data.refresh) - - return 200, { - "access": str(refresh.access_token), - "refresh": str(refresh), - "token_type": "Bearer" - } - - except TokenError as e: - return 401, {"error": "Invalid token", "detail": str(e)} - except Exception as e: - logger.error(f"Token refresh error: {e}") - return 401, {"error": "Token refresh failed", "detail": str(e)} - - -@router.post("/logout", auth=jwt_auth, response={200: MessageSchema}) -@require_auth -def logout(request: HttpRequest): - """ - Logout (blacklist refresh token). - - Note: Requires authentication. The client should also discard the access token. - """ - # Note: Token blacklisting is handled by djangorestframework-simplejwt - # when BLACKLIST_AFTER_ROTATION is True in settings - return 200, {"message": "Logged out successfully", "success": True} - - -# ============================================================================ -# User Profile Endpoints -# ============================================================================ - -@router.get("/me", auth=jwt_auth, response={200: UserProfileOut, 401: ErrorSchema}) -@require_auth -def get_my_profile(request: HttpRequest): - """ - Get current user's profile. - - Returns detailed profile information for the authenticated user. - """ - user = request.auth - return 200, user - - -@router.patch("/me", auth=jwt_auth, response={200: UserProfileOut, 400: ErrorSchema}) -@require_auth -def update_my_profile(request: HttpRequest, data: UserProfileUpdate): - """ - Update current user's profile. - - - **first_name**: First name (optional) - - **last_name**: Last name (optional) - - **username**: Username (optional) - - **bio**: User biography (optional, max 500 characters) - - **avatar_url**: Avatar image URL (optional) - """ - try: - user = request.auth - - # Prepare update data - update_data = data.dict(exclude_unset=True) - - # Update profile - updated_user = UserManagementService.update_profile(user, **update_data) - - return 200, updated_user - - except ValidationError as e: - return 400, {"error": "Update failed", "detail": str(e)} - except Exception as e: - logger.error(f"Profile update error: {e}") - return 400, {"error": "Update failed", "detail": str(e)} - - -@router.get("/me/role", auth=jwt_auth, response={200: UserRoleOut, 404: ErrorSchema}) -@require_auth -def get_my_role(request: HttpRequest): - """ - Get current user's role. - - Returns role information including permissions. - """ - try: - user = request.auth - role = user.role - - response_data = { - "role": role.role, - "is_moderator": role.is_moderator, - "is_admin": role.is_admin, - "granted_at": role.granted_at, - "granted_by_email": role.granted_by.email if role.granted_by else None - } - - return 200, response_data - - except UserRole.DoesNotExist: - return 404, {"error": "Role not found", "detail": "User role not assigned"} - - -@router.get("/me/permissions", auth=jwt_auth, response={200: UserPermissionsOut}) -@require_auth -def get_my_permissions(request: HttpRequest): - """ - Get current user's permissions. - - Returns a summary of what the user can do. - """ - user = request.auth - permissions = RoleService.get_user_permissions(user) - return 200, permissions - - -@router.get("/me/stats", auth=jwt_auth, response={200: UserStatsOut}) -@require_auth -def get_my_stats(request: HttpRequest): - """ - Get current user's statistics. - - Returns submission stats, reputation score, and activity information. - """ - user = request.auth - stats = UserManagementService.get_user_stats(user) - return 200, stats - - -# ============================================================================ -# User Preferences Endpoints -# ============================================================================ - -@router.get("/me/preferences", auth=jwt_auth, response={200: UserProfilePreferencesOut}) -@require_auth -def get_my_preferences(request: HttpRequest): - """ - Get current user's preferences. - - Returns notification and privacy preferences. - """ - user = request.auth - profile = user.profile - return 200, profile - - -@router.patch("/me/preferences", auth=jwt_auth, response={200: UserProfilePreferencesOut, 400: ErrorSchema}) -@require_auth -def update_my_preferences(request: HttpRequest, data: UserProfilePreferencesUpdate): - """ - Update current user's preferences. - - - **email_notifications**: Receive email notifications - - **email_on_submission_approved**: Email when submissions approved - - **email_on_submission_rejected**: Email when submissions rejected - - **profile_public**: Make profile publicly visible - - **show_email**: Show email on public profile - """ - try: - user = request.auth - - # Prepare update data - update_data = data.dict(exclude_unset=True) - - # Update preferences - updated_profile = UserManagementService.update_preferences(user, **update_data) - - return 200, updated_profile - - except Exception as e: - logger.error(f"Preferences update error: {e}") - return 400, {"error": "Update failed", "detail": str(e)} - - -# ============================================================================ -# Password Management Endpoints -# ============================================================================ - -@router.post("/password/change", auth=jwt_auth, response={200: MessageSchema, 400: ErrorSchema}) -@require_auth -def change_password(request: HttpRequest, data: ChangePasswordRequest): - """ - Change current user's password. - - - **old_password**: Current password (required) - - **new_password**: New password (min 8 characters, required) - - **new_password_confirm**: New password confirmation (required) - """ - try: - user = request.auth - - AuthenticationService.change_password( - user=user, - old_password=data.old_password, - new_password=data.new_password - ) - - return 200, {"message": "Password changed successfully", "success": True} - - except ValidationError as e: - error_msg = str(e.message_dict) if hasattr(e, 'message_dict') else str(e) - return 400, {"error": "Password change failed", "detail": error_msg} - - -@router.post("/password/reset", response={200: MessageSchema}) -def request_password_reset(request: HttpRequest, data: ResetPasswordRequest): - """ - Request password reset email. - - - **email**: User's email address - - Note: This is a placeholder. In production, this should send a reset email. - For now, it returns success regardless of whether the email exists. - """ - # TODO: Implement email sending with password reset token - # For security, always return success even if email doesn't exist - return 200, { - "message": "If the email exists, a password reset link has been sent", - "success": True - } - - -# ============================================================================ -# MFA/2FA Endpoints -# ============================================================================ - -@router.post("/mfa/enable", auth=jwt_auth, response={200: TOTPEnableResponse, 400: ErrorSchema}) -@require_auth -def enable_mfa(request: HttpRequest): - """ - Enable MFA/2FA for current user. - - Returns TOTP secret and QR code URL for authenticator apps. - User must confirm with a valid token to complete setup. - """ - try: - user = request.auth - - # Create TOTP device - device = MFAService.enable_totp(user) - - # Generate QR code URL - issuer = "ThrillWiki" - qr_url = device.config_url - - return 200, { - "secret": device.key, - "qr_code_url": qr_url, - "backup_codes": [] # TODO: Generate backup codes - } - - except Exception as e: - logger.error(f"MFA enable error: {e}") - return 400, {"error": "MFA setup failed", "detail": str(e)} - - -@router.post("/mfa/confirm", auth=jwt_auth, response={200: MessageSchema, 400: ErrorSchema}) -@require_auth -def confirm_mfa(request: HttpRequest, data: TOTPConfirmRequest): - """ - Confirm MFA setup with verification token. - - - **token**: 6-digit TOTP token from authenticator app - - Completes MFA setup after verifying the token is valid. - """ - try: - user = request.auth - - MFAService.confirm_totp(user, data.token) - - return 200, {"message": "MFA enabled successfully", "success": True} - - except ValidationError as e: - return 400, {"error": "Confirmation failed", "detail": str(e)} - - -@router.post("/mfa/disable", auth=jwt_auth, response={200: MessageSchema}) -@require_auth -def disable_mfa(request: HttpRequest): - """ - Disable MFA/2FA for current user. - - Removes all TOTP devices and disables MFA requirement. - """ - user = request.auth - MFAService.disable_totp(user) - - return 200, {"message": "MFA disabled successfully", "success": True} - - -@router.post("/mfa/verify", auth=jwt_auth, response={200: MessageSchema, 400: ErrorSchema}) -@require_auth -def verify_mfa_token(request: HttpRequest, data: TOTPVerifyRequest): - """ - Verify MFA token (for testing). - - - **token**: 6-digit TOTP token - - Returns whether the token is valid. - """ - user = request.auth - - if MFAService.verify_totp(user, data.token): - return 200, {"message": "Token is valid", "success": True} - else: - return 400, {"error": "Invalid token", "detail": "The token is not valid"} - - -# ============================================================================ -# User Management Endpoints (Admin Only) -# ============================================================================ - -@router.get("/users", auth=jwt_auth, response={200: UserListOut, 403: ErrorSchema}) -@require_admin -def list_users( - request: HttpRequest, - page: int = 1, - page_size: int = 50, - search: Optional[str] = None, - role: Optional[str] = None, - banned: Optional[bool] = None -): - """ - List all users (admin only). - - - **page**: Page number (default: 1) - - **page_size**: Items per page (default: 50, max: 100) - - **search**: Search by email or username - - **role**: Filter by role (user, moderator, admin) - - **banned**: Filter by banned status - """ - # Build query - queryset = User.objects.select_related('role').all() - - # Apply filters - if search: - queryset = queryset.filter( - Q(email__icontains=search) | - Q(username__icontains=search) | - Q(first_name__icontains=search) | - Q(last_name__icontains=search) - ) - - if role: - queryset = queryset.filter(role__role=role) - - if banned is not None: - queryset = queryset.filter(banned=banned) - - # Pagination - page_size = min(page_size, 100) # Max 100 items per page - total = queryset.count() - total_pages = (total + page_size - 1) // page_size - - start = (page - 1) * page_size - end = start + page_size - - users = list(queryset[start:end]) - - return 200, { - "items": users, - "total": total, - "page": page, - "page_size": page_size, - "total_pages": total_pages - } - - -@router.get("/users/{user_id}", auth=jwt_auth, response={200: UserProfileOut, 404: ErrorSchema}) -@require_admin -def get_user(request: HttpRequest, user_id: str): - """ - Get user by ID (admin only). - - Returns detailed profile information for the specified user. - """ - try: - user = User.objects.get(id=user_id) - return 200, user - except User.DoesNotExist: - return 404, {"error": "User not found", "detail": f"No user with ID {user_id}"} - - -@router.post("/users/ban", auth=jwt_auth, response={200: MessageSchema, 400: ErrorSchema}) -@require_admin -def ban_user(request: HttpRequest, data: BanUserRequest): - """ - Ban a user (admin only). - - - **user_id**: User ID to ban - - **reason**: Reason for ban - """ - try: - user = User.objects.get(id=data.user_id) - admin = request.auth - - UserManagementService.ban_user(user, data.reason, admin) - - return 200, {"message": f"User {user.email} has been banned", "success": True} - - except User.DoesNotExist: - return 400, {"error": "User not found", "detail": f"No user with ID {data.user_id}"} - - -@router.post("/users/unban", auth=jwt_auth, response={200: MessageSchema, 400: ErrorSchema}) -@require_admin -def unban_user(request: HttpRequest, data: UnbanUserRequest): - """ - Unban a user (admin only). - - - **user_id**: User ID to unban - """ - try: - user = User.objects.get(id=data.user_id) - - UserManagementService.unban_user(user) - - return 200, {"message": f"User {user.email} has been unbanned", "success": True} - - except User.DoesNotExist: - return 400, {"error": "User not found", "detail": f"No user with ID {data.user_id}"} - - -@router.post("/users/assign-role", auth=jwt_auth, response={200: MessageSchema, 400: ErrorSchema}) -@require_admin -def assign_role(request: HttpRequest, data: AssignRoleRequest): - """ - Assign role to user (admin only). - - - **user_id**: User ID - - **role**: Role to assign (user, moderator, admin) - """ - try: - user = User.objects.get(id=data.user_id) - admin = request.auth - - RoleService.assign_role(user, data.role, admin) - - return 200, {"message": f"Role '{data.role}' assigned to {user.email}", "success": True} - - except User.DoesNotExist: - return 400, {"error": "User not found", "detail": f"No user with ID {data.user_id}"} - except ValidationError as e: - return 400, {"error": "Invalid role", "detail": str(e)} diff --git a/django/api/v1/endpoints/companies.py b/django/api/v1/endpoints/companies.py deleted file mode 100644 index 5bf41350..00000000 --- a/django/api/v1/endpoints/companies.py +++ /dev/null @@ -1,254 +0,0 @@ -""" -Company endpoints for API v1. - -Provides CRUD operations for Company entities with filtering and search. -""" -from typing import List, Optional -from uuid import UUID -from django.shortcuts import get_object_or_404 -from django.db.models import Q -from ninja import Router, Query -from ninja.pagination import paginate, PageNumberPagination - -from apps.entities.models import Company -from ..schemas import ( - CompanyCreate, - CompanyUpdate, - CompanyOut, - CompanyListOut, - ErrorResponse -) - - -router = Router(tags=["Companies"]) - - -class CompanyPagination(PageNumberPagination): - """Custom pagination for companies.""" - page_size = 50 - - -@router.get( - "/", - response={200: List[CompanyOut]}, - summary="List companies", - description="Get a paginated list of companies with optional filtering" -) -@paginate(CompanyPagination) -def list_companies( - request, - search: Optional[str] = Query(None, description="Search by company name"), - company_type: Optional[str] = Query(None, description="Filter by company type"), - location_id: Optional[UUID] = Query(None, description="Filter by location"), - ordering: Optional[str] = Query("-created", description="Sort by field (prefix with - for descending)") -): - """ - List all companies with optional filters. - - **Filters:** - - search: Search company names (case-insensitive partial match) - - company_type: Filter by specific company type - - location_id: Filter by headquarters location - - ordering: Sort results (default: -created) - - **Returns:** Paginated list of companies - """ - queryset = Company.objects.all() - - # Apply search filter - if search: - queryset = queryset.filter( - Q(name__icontains=search) | Q(description__icontains=search) - ) - - # Apply company type filter - if company_type: - queryset = queryset.filter(company_types__contains=[company_type]) - - # Apply location filter - if location_id: - queryset = queryset.filter(location_id=location_id) - - # Apply ordering - valid_order_fields = ['name', 'created', 'modified', 'founded_date', 'park_count', 'ride_count'] - order_field = ordering.lstrip('-') - if order_field in valid_order_fields: - queryset = queryset.order_by(ordering) - else: - queryset = queryset.order_by('-created') - - return queryset - - -@router.get( - "/{company_id}", - response={200: CompanyOut, 404: ErrorResponse}, - summary="Get company", - description="Retrieve a single company by ID" -) -def get_company(request, company_id: UUID): - """ - Get a company by ID. - - **Parameters:** - - company_id: UUID of the company - - **Returns:** Company details - """ - company = get_object_or_404(Company, id=company_id) - return company - - -@router.post( - "/", - response={201: CompanyOut, 400: ErrorResponse}, - summary="Create company", - description="Create a new company (requires authentication)" -) -def create_company(request, payload: CompanyCreate): - """ - Create a new company. - - **Authentication:** Required - - **Parameters:** - - payload: Company data - - **Returns:** Created company - """ - # TODO: Add authentication check - # if not request.auth: - # return 401, {"detail": "Authentication required"} - - company = Company.objects.create(**payload.dict()) - return 201, company - - -@router.put( - "/{company_id}", - response={200: CompanyOut, 404: ErrorResponse, 400: ErrorResponse}, - summary="Update company", - description="Update an existing company (requires authentication)" -) -def update_company(request, company_id: UUID, payload: CompanyUpdate): - """ - Update a company. - - **Authentication:** Required - - **Parameters:** - - company_id: UUID of the company - - payload: Updated company data - - **Returns:** Updated company - """ - # TODO: Add authentication check - # if not request.auth: - # return 401, {"detail": "Authentication required"} - - company = get_object_or_404(Company, id=company_id) - - # Update only provided fields - for key, value in payload.dict(exclude_unset=True).items(): - setattr(company, key, value) - - company.save() - return company - - -@router.patch( - "/{company_id}", - response={200: CompanyOut, 404: ErrorResponse, 400: ErrorResponse}, - summary="Partial update company", - description="Partially update an existing company (requires authentication)" -) -def partial_update_company(request, company_id: UUID, payload: CompanyUpdate): - """ - Partially update a company. - - **Authentication:** Required - - **Parameters:** - - company_id: UUID of the company - - payload: Fields to update - - **Returns:** Updated company - """ - # TODO: Add authentication check - # if not request.auth: - # return 401, {"detail": "Authentication required"} - - company = get_object_or_404(Company, id=company_id) - - # Update only provided fields - for key, value in payload.dict(exclude_unset=True).items(): - setattr(company, key, value) - - company.save() - return company - - -@router.delete( - "/{company_id}", - response={204: None, 404: ErrorResponse}, - summary="Delete company", - description="Delete a company (requires authentication)" -) -def delete_company(request, company_id: UUID): - """ - Delete a company. - - **Authentication:** Required - - **Parameters:** - - company_id: UUID of the company - - **Returns:** No content (204) - """ - # TODO: Add authentication check - # if not request.auth: - # return 401, {"detail": "Authentication required"} - - company = get_object_or_404(Company, id=company_id) - company.delete() - return 204, None - - -@router.get( - "/{company_id}/parks", - response={200: List[dict], 404: ErrorResponse}, - summary="Get company parks", - description="Get all parks operated by a company" -) -def get_company_parks(request, company_id: UUID): - """ - Get parks operated by a company. - - **Parameters:** - - company_id: UUID of the company - - **Returns:** List of parks - """ - company = get_object_or_404(Company, id=company_id) - parks = company.operated_parks.all().values('id', 'name', 'slug', 'status', 'park_type') - return list(parks) - - -@router.get( - "/{company_id}/rides", - response={200: List[dict], 404: ErrorResponse}, - summary="Get company rides", - description="Get all rides manufactured by a company" -) -def get_company_rides(request, company_id: UUID): - """ - Get rides manufactured by a company. - - **Parameters:** - - company_id: UUID of the company - - **Returns:** List of rides - """ - company = get_object_or_404(Company, id=company_id) - rides = company.manufactured_rides.all().values('id', 'name', 'slug', 'status', 'ride_category') - return list(rides) diff --git a/django/api/v1/endpoints/moderation.py b/django/api/v1/endpoints/moderation.py deleted file mode 100644 index aa69df8d..00000000 --- a/django/api/v1/endpoints/moderation.py +++ /dev/null @@ -1,496 +0,0 @@ -""" -Moderation API endpoints. - -Provides REST API for content submission and moderation workflow. -""" -from typing import List, Optional -from uuid import UUID -from ninja import Router -from django.shortcuts import get_object_or_404 -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ValidationError, PermissionDenied - -from apps.moderation.models import ContentSubmission, SubmissionItem -from apps.moderation.services import ModerationService -from api.v1.schemas import ( - ContentSubmissionCreate, - ContentSubmissionOut, - ContentSubmissionDetail, - SubmissionListOut, - StartReviewRequest, - ApproveRequest, - ApproveSelectiveRequest, - RejectRequest, - RejectSelectiveRequest, - ApprovalResponse, - SelectiveApprovalResponse, - SelectiveRejectionResponse, - ErrorResponse, -) - -router = Router(tags=['Moderation']) - - -# ============================================================================ -# Helper Functions -# ============================================================================ - -def _submission_to_dict(submission: ContentSubmission) -> dict: - """Convert submission model to dict for schema.""" - return { - 'id': submission.id, - 'status': submission.status, - 'submission_type': submission.submission_type, - 'title': submission.title, - 'description': submission.description or '', - 'entity_type': submission.entity_type.model, - 'entity_id': submission.entity_id, - 'user_id': submission.user.id, - 'user_email': submission.user.email, - 'locked_by_id': submission.locked_by.id if submission.locked_by else None, - 'locked_by_email': submission.locked_by.email if submission.locked_by else None, - 'locked_at': submission.locked_at, - 'reviewed_by_id': submission.reviewed_by.id if submission.reviewed_by else None, - 'reviewed_by_email': submission.reviewed_by.email if submission.reviewed_by else None, - 'reviewed_at': submission.reviewed_at, - 'rejection_reason': submission.rejection_reason or '', - 'source': submission.source, - 'metadata': submission.metadata, - 'items_count': submission.get_items_count(), - 'approved_items_count': submission.get_approved_items_count(), - 'rejected_items_count': submission.get_rejected_items_count(), - 'created': submission.created, - 'modified': submission.modified, - } - - -def _item_to_dict(item: SubmissionItem) -> dict: - """Convert submission item model to dict for schema.""" - return { - 'id': item.id, - 'submission_id': item.submission.id, - 'field_name': item.field_name, - 'field_label': item.field_label or item.field_name, - 'old_value': item.old_value, - 'new_value': item.new_value, - 'change_type': item.change_type, - 'is_required': item.is_required, - 'order': item.order, - 'status': item.status, - 'reviewed_by_id': item.reviewed_by.id if item.reviewed_by else None, - 'reviewed_by_email': item.reviewed_by.email if item.reviewed_by else None, - 'reviewed_at': item.reviewed_at, - 'rejection_reason': item.rejection_reason or '', - 'old_value_display': item.old_value_display, - 'new_value_display': item.new_value_display, - 'created': item.created, - 'modified': item.modified, - } - - -def _get_entity(entity_type: str, entity_id: UUID): - """Get entity instance from type string and ID.""" - # Map entity type strings to models - type_map = { - 'park': 'entities.Park', - 'ride': 'entities.Ride', - 'company': 'entities.Company', - 'ridemodel': 'entities.RideModel', - } - - app_label, model = type_map.get(entity_type.lower(), '').split('.') - content_type = ContentType.objects.get(app_label=app_label, model=model.lower()) - model_class = content_type.model_class() - - return get_object_or_404(model_class, id=entity_id) - - -# ============================================================================ -# Submission Endpoints -# ============================================================================ - -@router.post('/submissions', response={201: ContentSubmissionOut, 400: ErrorResponse, 401: ErrorResponse}) -def create_submission(request, data: ContentSubmissionCreate): - """ - Create a new content submission. - - Creates a submission with multiple items representing field changes. - If auto_submit is True, the submission is immediately moved to pending state. - """ - # TODO: Require authentication - # For now, use a test user or get from request - from apps.users.models import User - user = User.objects.first() # TEMP: Get first user for testing - - if not user: - return 401, {'detail': 'Authentication required'} - - try: - # Get entity - entity = _get_entity(data.entity_type, data.entity_id) - - # Prepare items data - items_data = [ - { - 'field_name': item.field_name, - 'field_label': item.field_label, - 'old_value': item.old_value, - 'new_value': item.new_value, - 'change_type': item.change_type, - 'is_required': item.is_required, - 'order': item.order, - } - for item in data.items - ] - - # Create submission - submission = ModerationService.create_submission( - user=user, - entity=entity, - submission_type=data.submission_type, - title=data.title, - description=data.description or '', - items_data=items_data, - metadata=data.metadata, - auto_submit=data.auto_submit, - source='api' - ) - - return 201, _submission_to_dict(submission) - - except Exception as e: - return 400, {'detail': str(e)} - - -@router.get('/submissions', response=SubmissionListOut) -def list_submissions( - request, - status: Optional[str] = None, - page: int = 1, - page_size: int = 50 -): - """ - List content submissions with optional filtering. - - Query Parameters: - - status: Filter by status (draft, pending, reviewing, approved, rejected) - - page: Page number (default: 1) - - page_size: Items per page (default: 50, max: 100) - """ - # Validate page_size - page_size = min(page_size, 100) - offset = (page - 1) * page_size - - # Get submissions - submissions = ModerationService.get_queue( - status=status, - limit=page_size, - offset=offset - ) - - # Get total count - total_queryset = ContentSubmission.objects.all() - if status: - total_queryset = total_queryset.filter(status=status) - total = total_queryset.count() - - # Calculate total pages - total_pages = (total + page_size - 1) // page_size - - # Convert to dicts - items = [_submission_to_dict(sub) for sub in submissions] - - return { - 'items': items, - 'total': total, - 'page': page, - 'page_size': page_size, - 'total_pages': total_pages, - } - - -@router.get('/submissions/{submission_id}', response={200: ContentSubmissionDetail, 404: ErrorResponse}) -def get_submission(request, submission_id: UUID): - """ - Get detailed submission information with all items. - """ - try: - submission = ModerationService.get_submission_details(submission_id) - - # Convert to dict with items - data = _submission_to_dict(submission) - data['items'] = [_item_to_dict(item) for item in submission.items.all()] - - return 200, data - - except ContentSubmission.DoesNotExist: - return 404, {'detail': 'Submission not found'} - - -@router.delete('/submissions/{submission_id}', response={204: None, 403: ErrorResponse, 404: ErrorResponse}) -def delete_submission(request, submission_id: UUID): - """ - Delete a submission (only if draft/pending and owned by user). - """ - # TODO: Get current user from request - from apps.users.models import User - user = User.objects.first() # TEMP - - try: - ModerationService.delete_submission(submission_id, user) - return 204, None - - except ContentSubmission.DoesNotExist: - return 404, {'detail': 'Submission not found'} - except PermissionDenied as e: - return 403, {'detail': str(e)} - except ValidationError as e: - return 400, {'detail': str(e)} - - -# ============================================================================ -# Review Endpoints -# ============================================================================ - -@router.post( - '/submissions/{submission_id}/start-review', - response={200: ContentSubmissionOut, 400: ErrorResponse, 403: ErrorResponse, 404: ErrorResponse} -) -def start_review(request, submission_id: UUID, data: StartReviewRequest): - """ - Start reviewing a submission (lock it for 15 minutes). - - Only moderators can start reviews. - """ - # TODO: Get current user (moderator) from request - from apps.users.models import User - user = User.objects.first() # TEMP - - try: - submission = ModerationService.start_review(submission_id, user) - return 200, _submission_to_dict(submission) - - except ContentSubmission.DoesNotExist: - return 404, {'detail': 'Submission not found'} - except PermissionDenied as e: - return 403, {'detail': str(e)} - except ValidationError as e: - return 400, {'detail': str(e)} - - -@router.post( - '/submissions/{submission_id}/approve', - response={200: ApprovalResponse, 400: ErrorResponse, 403: ErrorResponse, 404: ErrorResponse} -) -def approve_submission(request, submission_id: UUID, data: ApproveRequest): - """ - Approve an entire submission and apply all changes. - - Uses atomic transactions - all changes are applied or none are. - Only moderators can approve submissions. - """ - # TODO: Get current user (moderator) from request - from apps.users.models import User - user = User.objects.first() # TEMP - - try: - submission = ModerationService.approve_submission(submission_id, user) - - return 200, { - 'success': True, - 'message': 'Submission approved successfully', - 'submission': _submission_to_dict(submission) - } - - except ContentSubmission.DoesNotExist: - return 404, {'detail': 'Submission not found'} - except PermissionDenied as e: - return 403, {'detail': str(e)} - except ValidationError as e: - return 400, {'detail': str(e)} - - -@router.post( - '/submissions/{submission_id}/approve-selective', - response={200: SelectiveApprovalResponse, 400: ErrorResponse, 403: ErrorResponse, 404: ErrorResponse} -) -def approve_selective(request, submission_id: UUID, data: ApproveSelectiveRequest): - """ - Approve only specific items in a submission. - - Allows moderators to approve some changes while leaving others pending or rejected. - Uses atomic transactions for data integrity. - """ - # TODO: Get current user (moderator) from request - from apps.users.models import User - user = User.objects.first() # TEMP - - try: - result = ModerationService.approve_selective( - submission_id, - user, - [str(item_id) for item_id in data.item_ids] - ) - - return 200, { - 'success': True, - 'message': f"Approved {result['approved']} of {result['total']} items", - **result - } - - except ContentSubmission.DoesNotExist: - return 404, {'detail': 'Submission not found'} - except PermissionDenied as e: - return 403, {'detail': str(e)} - except ValidationError as e: - return 400, {'detail': str(e)} - - -@router.post( - '/submissions/{submission_id}/reject', - response={200: ApprovalResponse, 400: ErrorResponse, 403: ErrorResponse, 404: ErrorResponse} -) -def reject_submission(request, submission_id: UUID, data: RejectRequest): - """ - Reject an entire submission. - - All pending items are rejected with the provided reason. - Only moderators can reject submissions. - """ - # TODO: Get current user (moderator) from request - from apps.users.models import User - user = User.objects.first() # TEMP - - try: - submission = ModerationService.reject_submission(submission_id, user, data.reason) - - return 200, { - 'success': True, - 'message': 'Submission rejected', - 'submission': _submission_to_dict(submission) - } - - except ContentSubmission.DoesNotExist: - return 404, {'detail': 'Submission not found'} - except PermissionDenied as e: - return 403, {'detail': str(e)} - except ValidationError as e: - return 400, {'detail': str(e)} - - -@router.post( - '/submissions/{submission_id}/reject-selective', - response={200: SelectiveRejectionResponse, 400: ErrorResponse, 403: ErrorResponse, 404: ErrorResponse} -) -def reject_selective(request, submission_id: UUID, data: RejectSelectiveRequest): - """ - Reject only specific items in a submission. - - Allows moderators to reject some changes while leaving others pending or approved. - """ - # TODO: Get current user (moderator) from request - from apps.users.models import User - user = User.objects.first() # TEMP - - try: - result = ModerationService.reject_selective( - submission_id, - user, - [str(item_id) for item_id in data.item_ids], - data.reason or '' - ) - - return 200, { - 'success': True, - 'message': f"Rejected {result['rejected']} of {result['total']} items", - **result - } - - except ContentSubmission.DoesNotExist: - return 404, {'detail': 'Submission not found'} - except PermissionDenied as e: - return 403, {'detail': str(e)} - except ValidationError as e: - return 400, {'detail': str(e)} - - -@router.post( - '/submissions/{submission_id}/unlock', - response={200: ContentSubmissionOut, 404: ErrorResponse} -) -def unlock_submission(request, submission_id: UUID): - """ - Manually unlock a submission. - - Removes the review lock. Can be used by moderators or automatically by cleanup tasks. - """ - try: - submission = ModerationService.unlock_submission(submission_id) - return 200, _submission_to_dict(submission) - - except ContentSubmission.DoesNotExist: - return 404, {'detail': 'Submission not found'} - - -# ============================================================================ -# Queue Endpoints -# ============================================================================ - -@router.get('/queue/pending', response=SubmissionListOut) -def get_pending_queue(request, page: int = 1, page_size: int = 50): - """ - Get pending submissions queue. - - Returns all submissions awaiting review. - """ - return list_submissions(request, status='pending', page=page, page_size=page_size) - - -@router.get('/queue/reviewing', response=SubmissionListOut) -def get_reviewing_queue(request, page: int = 1, page_size: int = 50): - """ - Get submissions currently under review. - - Returns all submissions being reviewed by moderators. - """ - return list_submissions(request, status='reviewing', page=page, page_size=page_size) - - -@router.get('/queue/my-submissions', response=SubmissionListOut) -def get_my_submissions(request, page: int = 1, page_size: int = 50): - """ - Get current user's submissions. - - Returns all submissions created by the authenticated user. - """ - # TODO: Get current user from request - from apps.users.models import User - user = User.objects.first() # TEMP - - # Validate page_size - page_size = min(page_size, 100) - offset = (page - 1) * page_size - - # Get user's submissions - submissions = ModerationService.get_queue( - user=user, - limit=page_size, - offset=offset - ) - - # Get total count - total = ContentSubmission.objects.filter(user=user).count() - - # Calculate total pages - total_pages = (total + page_size - 1) // page_size - - # Convert to dicts - items = [_submission_to_dict(sub) for sub in submissions] - - return { - 'items': items, - 'total': total, - 'page': page, - 'page_size': page_size, - 'total_pages': total_pages, - } diff --git a/django/api/v1/endpoints/parks.py b/django/api/v1/endpoints/parks.py deleted file mode 100644 index c1c1d1fd..00000000 --- a/django/api/v1/endpoints/parks.py +++ /dev/null @@ -1,362 +0,0 @@ -""" -Park endpoints for API v1. - -Provides CRUD operations for Park entities with filtering, search, and geographic queries. -Supports both SQLite (lat/lng) and PostGIS (location_point) modes. -""" -from typing import List, Optional -from uuid import UUID -from decimal import Decimal -from django.shortcuts import get_object_or_404 -from django.db.models import Q -from django.conf import settings -from ninja import Router, Query -from ninja.pagination import paginate, PageNumberPagination -import math - -from apps.entities.models import Park, Company, _using_postgis -from ..schemas import ( - ParkCreate, - ParkUpdate, - ParkOut, - ParkListOut, - ErrorResponse -) - - -router = Router(tags=["Parks"]) - - -class ParkPagination(PageNumberPagination): - """Custom pagination for parks.""" - page_size = 50 - - -@router.get( - "/", - response={200: List[ParkOut]}, - summary="List parks", - description="Get a paginated list of parks with optional filtering" -) -@paginate(ParkPagination) -def list_parks( - request, - search: Optional[str] = Query(None, description="Search by park name"), - park_type: Optional[str] = Query(None, description="Filter by park type"), - status: Optional[str] = Query(None, description="Filter by status"), - operator_id: Optional[UUID] = Query(None, description="Filter by operator"), - ordering: Optional[str] = Query("-created", description="Sort by field (prefix with - for descending)") -): - """ - List all parks with optional filters. - - **Filters:** - - search: Search park names (case-insensitive partial match) - - park_type: Filter by park type - - status: Filter by operational status - - operator_id: Filter by operator company - - ordering: Sort results (default: -created) - - **Returns:** Paginated list of parks - """ - queryset = Park.objects.select_related('operator').all() - - # Apply search filter - if search: - queryset = queryset.filter( - Q(name__icontains=search) | Q(description__icontains=search) - ) - - # Apply park type filter - if park_type: - queryset = queryset.filter(park_type=park_type) - - # Apply status filter - if status: - queryset = queryset.filter(status=status) - - # Apply operator filter - if operator_id: - queryset = queryset.filter(operator_id=operator_id) - - # Apply ordering - valid_order_fields = ['name', 'created', 'modified', 'opening_date', 'ride_count', 'coaster_count'] - order_field = ordering.lstrip('-') - if order_field in valid_order_fields: - queryset = queryset.order_by(ordering) - else: - queryset = queryset.order_by('-created') - - # Annotate with operator name - for park in queryset: - park.operator_name = park.operator.name if park.operator else None - - return queryset - - -@router.get( - "/{park_id}", - response={200: ParkOut, 404: ErrorResponse}, - summary="Get park", - description="Retrieve a single park by ID" -) -def get_park(request, park_id: UUID): - """ - Get a park by ID. - - **Parameters:** - - park_id: UUID of the park - - **Returns:** Park details - """ - park = get_object_or_404(Park.objects.select_related('operator'), id=park_id) - park.operator_name = park.operator.name if park.operator else None - park.coordinates = park.coordinates - return park - - -@router.get( - "/nearby/", - response={200: List[ParkOut]}, - summary="Find nearby parks", - description="Find parks within a radius of given coordinates. Uses PostGIS in production, bounding box in SQLite." -) -def find_nearby_parks( - request, - latitude: float = Query(..., description="Latitude coordinate"), - longitude: float = Query(..., description="Longitude coordinate"), - radius: float = Query(50, description="Search radius in kilometers"), - limit: int = Query(50, description="Maximum number of results") -): - """ - Find parks near a geographic point. - - **Geographic Search Modes:** - - **PostGIS (Production)**: Uses accurate distance-based search with location_point field - - **SQLite (Local Dev)**: Uses bounding box approximation with latitude/longitude fields - - **Parameters:** - - latitude: Center point latitude - - longitude: Center point longitude - - radius: Search radius in kilometers (default: 50) - - limit: Maximum results to return (default: 50) - - **Returns:** List of nearby parks - """ - if _using_postgis: - # Use PostGIS for accurate distance-based search - try: - from django.contrib.gis.measure import D - from django.contrib.gis.geos import Point - - user_point = Point(longitude, latitude, srid=4326) - nearby_parks = Park.objects.filter( - location_point__distance_lte=(user_point, D(km=radius)) - ).select_related('operator')[:limit] - except Exception as e: - return {"detail": f"Geographic search error: {str(e)}"}, 500 - else: - # Use bounding box approximation for SQLite - # Calculate rough bounding box (1 degree ≈ 111 km at equator) - lat_offset = radius / 111.0 - lng_offset = radius / (111.0 * math.cos(math.radians(latitude))) - - min_lat = latitude - lat_offset - max_lat = latitude + lat_offset - min_lng = longitude - lng_offset - max_lng = longitude + lng_offset - - nearby_parks = Park.objects.filter( - latitude__gte=Decimal(str(min_lat)), - latitude__lte=Decimal(str(max_lat)), - longitude__gte=Decimal(str(min_lng)), - longitude__lte=Decimal(str(max_lng)) - ).select_related('operator')[:limit] - - # Annotate results - results = [] - for park in nearby_parks: - park.operator_name = park.operator.name if park.operator else None - park.coordinates = park.coordinates - results.append(park) - - return results - - -@router.post( - "/", - response={201: ParkOut, 400: ErrorResponse}, - summary="Create park", - description="Create a new park (requires authentication)" -) -def create_park(request, payload: ParkCreate): - """ - Create a new park. - - **Authentication:** Required - - **Parameters:** - - payload: Park data - - **Returns:** Created park - """ - # TODO: Add authentication check - # if not request.auth: - # return 401, {"detail": "Authentication required"} - - data = payload.dict() - - # Extract coordinates to use set_location method - latitude = data.pop('latitude', None) - longitude = data.pop('longitude', None) - - park = Park.objects.create(**data) - - # Set location using helper method (handles both SQLite and PostGIS) - if latitude is not None and longitude is not None: - park.set_location(longitude, latitude) - park.save() - - park.coordinates = park.coordinates - if park.operator: - park.operator_name = park.operator.name - - return 201, park - - -@router.put( - "/{park_id}", - response={200: ParkOut, 404: ErrorResponse, 400: ErrorResponse}, - summary="Update park", - description="Update an existing park (requires authentication)" -) -def update_park(request, park_id: UUID, payload: ParkUpdate): - """ - Update a park. - - **Authentication:** Required - - **Parameters:** - - park_id: UUID of the park - - payload: Updated park data - - **Returns:** Updated park - """ - # TODO: Add authentication check - # if not request.auth: - # return 401, {"detail": "Authentication required"} - - park = get_object_or_404(Park.objects.select_related('operator'), id=park_id) - - data = payload.dict(exclude_unset=True) - - # Handle coordinates separately - latitude = data.pop('latitude', None) - longitude = data.pop('longitude', None) - - # Update other fields - for key, value in data.items(): - setattr(park, key, value) - - # Update location if coordinates provided - if latitude is not None and longitude is not None: - park.set_location(longitude, latitude) - - park.save() - park.operator_name = park.operator.name if park.operator else None - park.coordinates = park.coordinates - - return park - - -@router.patch( - "/{park_id}", - response={200: ParkOut, 404: ErrorResponse, 400: ErrorResponse}, - summary="Partial update park", - description="Partially update an existing park (requires authentication)" -) -def partial_update_park(request, park_id: UUID, payload: ParkUpdate): - """ - Partially update a park. - - **Authentication:** Required - - **Parameters:** - - park_id: UUID of the park - - payload: Fields to update - - **Returns:** Updated park - """ - # TODO: Add authentication check - # if not request.auth: - # return 401, {"detail": "Authentication required"} - - park = get_object_or_404(Park.objects.select_related('operator'), id=park_id) - - data = payload.dict(exclude_unset=True) - - # Handle coordinates separately - latitude = data.pop('latitude', None) - longitude = data.pop('longitude', None) - - # Update other fields - for key, value in data.items(): - setattr(park, key, value) - - # Update location if coordinates provided - if latitude is not None and longitude is not None: - park.set_location(longitude, latitude) - - park.save() - park.operator_name = park.operator.name if park.operator else None - park.coordinates = park.coordinates - - return park - - -@router.delete( - "/{park_id}", - response={204: None, 404: ErrorResponse}, - summary="Delete park", - description="Delete a park (requires authentication)" -) -def delete_park(request, park_id: UUID): - """ - Delete a park. - - **Authentication:** Required - - **Parameters:** - - park_id: UUID of the park - - **Returns:** No content (204) - """ - # TODO: Add authentication check - # if not request.auth: - # return 401, {"detail": "Authentication required"} - - park = get_object_or_404(Park, id=park_id) - park.delete() - return 204, None - - -@router.get( - "/{park_id}/rides", - response={200: List[dict], 404: ErrorResponse}, - summary="Get park rides", - description="Get all rides at a park" -) -def get_park_rides(request, park_id: UUID): - """ - Get all rides at a park. - - **Parameters:** - - park_id: UUID of the park - - **Returns:** List of rides - """ - park = get_object_or_404(Park, id=park_id) - rides = park.rides.select_related('manufacturer').all().values( - 'id', 'name', 'slug', 'status', 'ride_category', 'is_coaster', 'manufacturer__name' - ) - return list(rides) diff --git a/django/api/v1/endpoints/photos.py b/django/api/v1/endpoints/photos.py deleted file mode 100644 index 8e96bb6f..00000000 --- a/django/api/v1/endpoints/photos.py +++ /dev/null @@ -1,600 +0,0 @@ -""" -Photo management API endpoints. - -Provides endpoints for photo upload, management, moderation, and entity attachment. -""" - -import logging -from typing import List, Optional -from uuid import UUID - -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ValidationError as DjangoValidationError -from django.db.models import Q, Count, Sum -from django.http import HttpRequest -from ninja import Router, File, Form -from ninja.files import UploadedFile -from ninja.pagination import paginate - -from api.v1.schemas import ( - PhotoOut, - PhotoListOut, - PhotoUpdate, - PhotoUploadResponse, - PhotoModerateRequest, - PhotoReorderRequest, - PhotoAttachRequest, - PhotoStatsOut, - MessageSchema, - ErrorSchema, -) -from apps.media.models import Photo -from apps.media.services import PhotoService, CloudFlareError -from apps.media.validators import validate_image -from apps.users.permissions import jwt_auth, require_moderator, require_admin -from apps.entities.models import Park, Ride, Company, RideModel - -logger = logging.getLogger(__name__) - -router = Router(tags=["Photos"]) -photo_service = PhotoService() - - -# ============================================================================ -# Helper Functions -# ============================================================================ - -def serialize_photo(photo: Photo) -> dict: - """ - Serialize a Photo instance to dict for API response. - - Args: - photo: Photo instance - - Returns: - Dict with photo data - """ - # Get entity info if attached - entity_type = None - entity_id = None - entity_name = None - - if photo.content_type and photo.object_id: - entity = photo.content_object - entity_type = photo.content_type.model - entity_id = str(photo.object_id) - entity_name = getattr(entity, 'name', str(entity)) if entity else None - - # Generate variant URLs - cloudflare_service = photo_service.cloudflare - thumbnail_url = cloudflare_service.get_image_url(photo.cloudflare_image_id, 'thumbnail') - banner_url = cloudflare_service.get_image_url(photo.cloudflare_image_id, 'banner') - - return { - 'id': photo.id, - 'cloudflare_image_id': photo.cloudflare_image_id, - 'cloudflare_url': photo.cloudflare_url, - 'title': photo.title, - 'description': photo.description, - 'credit': photo.credit, - 'photo_type': photo.photo_type, - 'is_visible': photo.is_visible, - 'uploaded_by_id': photo.uploaded_by_id, - 'uploaded_by_email': photo.uploaded_by.email if photo.uploaded_by else None, - 'moderation_status': photo.moderation_status, - 'moderated_by_id': photo.moderated_by_id, - 'moderated_by_email': photo.moderated_by.email if photo.moderated_by else None, - 'moderated_at': photo.moderated_at, - 'moderation_notes': photo.moderation_notes, - 'entity_type': entity_type, - 'entity_id': entity_id, - 'entity_name': entity_name, - 'width': photo.width, - 'height': photo.height, - 'file_size': photo.file_size, - 'mime_type': photo.mime_type, - 'display_order': photo.display_order, - 'thumbnail_url': thumbnail_url, - 'banner_url': banner_url, - 'created': photo.created_at, - 'modified': photo.modified_at, - } - - -def get_entity_by_type(entity_type: str, entity_id: UUID): - """ - Get entity instance by type and ID. - - Args: - entity_type: Entity type (park, ride, company, ridemodel) - entity_id: Entity UUID - - Returns: - Entity instance - - Raises: - ValueError: If entity type is invalid or not found - """ - entity_map = { - 'park': Park, - 'ride': Ride, - 'company': Company, - 'ridemodel': RideModel, - } - - model = entity_map.get(entity_type.lower()) - if not model: - raise ValueError(f"Invalid entity type: {entity_type}") - - try: - return model.objects.get(id=entity_id) - except model.DoesNotExist: - raise ValueError(f"{entity_type} with ID {entity_id} not found") - - -# ============================================================================ -# Public Endpoints -# ============================================================================ - -@router.get("/photos", response=List[PhotoOut], auth=None) -@paginate -def list_photos( - request: HttpRequest, - status: Optional[str] = None, - photo_type: Optional[str] = None, - entity_type: Optional[str] = None, - entity_id: Optional[UUID] = None, -): - """ - List approved photos (public endpoint). - - Query Parameters: - - status: Filter by moderation status (defaults to 'approved') - - photo_type: Filter by photo type - - entity_type: Filter by entity type - - entity_id: Filter by entity ID - """ - queryset = Photo.objects.select_related( - 'uploaded_by', 'moderated_by', 'content_type' - ) - - # Default to approved photos for public - if status: - queryset = queryset.filter(moderation_status=status) - else: - queryset = queryset.approved() - - if photo_type: - queryset = queryset.filter(photo_type=photo_type) - - if entity_type and entity_id: - try: - entity = get_entity_by_type(entity_type, entity_id) - content_type = ContentType.objects.get_for_model(entity) - queryset = queryset.filter( - content_type=content_type, - object_id=entity_id - ) - except ValueError as e: - return [] - - queryset = queryset.filter(is_visible=True).order_by('display_order', '-created_at') - - return queryset - - -@router.get("/photos/{photo_id}", response=PhotoOut, auth=None) -def get_photo(request: HttpRequest, photo_id: UUID): - """ - Get photo details by ID (public endpoint). - - Only returns approved photos for non-authenticated users. - """ - try: - photo = Photo.objects.select_related( - 'uploaded_by', 'moderated_by', 'content_type' - ).get(id=photo_id) - - # Only show approved photos to public - if not request.auth and photo.moderation_status != 'approved': - return 404, {"detail": "Photo not found"} - - return serialize_photo(photo) - except Photo.DoesNotExist: - return 404, {"detail": "Photo not found"} - - -@router.get("/{entity_type}/{entity_id}/photos", response=List[PhotoOut], auth=None) -def get_entity_photos( - request: HttpRequest, - entity_type: str, - entity_id: UUID, - photo_type: Optional[str] = None, -): - """ - Get photos for a specific entity (public endpoint). - - Path Parameters: - - entity_type: Entity type (park, ride, company, ridemodel) - - entity_id: Entity UUID - - Query Parameters: - - photo_type: Filter by photo type - """ - try: - entity = get_entity_by_type(entity_type, entity_id) - photos = photo_service.get_entity_photos( - entity, - photo_type=photo_type, - approved_only=not request.auth - ) - return [serialize_photo(photo) for photo in photos] - except ValueError as e: - return 404, {"detail": str(e)} - - -# ============================================================================ -# Authenticated Endpoints -# ============================================================================ - -@router.post("/photos/upload", response=PhotoUploadResponse, auth=jwt_auth) -def upload_photo( - request: HttpRequest, - file: UploadedFile = File(...), - title: Optional[str] = Form(None), - description: Optional[str] = Form(None), - credit: Optional[str] = Form(None), - photo_type: str = Form('gallery'), - entity_type: Optional[str] = Form(None), - entity_id: Optional[str] = Form(None), -): - """ - Upload a new photo. - - Requires authentication. Photo enters moderation queue. - - Form Data: - - file: Image file (required) - - title: Photo title - - description: Photo description - - credit: Photo credit/attribution - - photo_type: Type of photo (main, gallery, banner, logo, thumbnail, other) - - entity_type: Entity type to attach to (optional) - - entity_id: Entity ID to attach to (optional) - """ - user = request.auth - - try: - # Validate image - validate_image(file, photo_type) - - # Get entity if provided - entity = None - if entity_type and entity_id: - try: - entity = get_entity_by_type(entity_type, UUID(entity_id)) - except (ValueError, TypeError) as e: - return 400, {"detail": f"Invalid entity: {str(e)}"} - - # Create photo - photo = photo_service.create_photo( - file=file, - user=user, - entity=entity, - photo_type=photo_type, - title=title or file.name, - description=description or '', - credit=credit or '', - is_visible=True, - ) - - return { - 'success': True, - 'message': 'Photo uploaded successfully and pending moderation', - 'photo': serialize_photo(photo), - } - - except DjangoValidationError as e: - return 400, {"detail": str(e)} - except CloudFlareError as e: - logger.error(f"CloudFlare upload failed: {str(e)}") - return 500, {"detail": "Failed to upload image"} - except Exception as e: - logger.error(f"Photo upload failed: {str(e)}") - return 500, {"detail": "An error occurred during upload"} - - -@router.patch("/photos/{photo_id}", response=PhotoOut, auth=jwt_auth) -def update_photo( - request: HttpRequest, - photo_id: UUID, - payload: PhotoUpdate, -): - """ - Update photo metadata. - - Users can only update their own photos. - Moderators can update any photo. - """ - user = request.auth - - try: - photo = Photo.objects.get(id=photo_id) - - # Check permissions - if photo.uploaded_by_id != user.id and not user.is_moderator: - return 403, {"detail": "Permission denied"} - - # Update fields - update_fields = [] - if payload.title is not None: - photo.title = payload.title - update_fields.append('title') - if payload.description is not None: - photo.description = payload.description - update_fields.append('description') - if payload.credit is not None: - photo.credit = payload.credit - update_fields.append('credit') - if payload.photo_type is not None: - photo.photo_type = payload.photo_type - update_fields.append('photo_type') - if payload.is_visible is not None: - photo.is_visible = payload.is_visible - update_fields.append('is_visible') - if payload.display_order is not None: - photo.display_order = payload.display_order - update_fields.append('display_order') - - if update_fields: - photo.save(update_fields=update_fields) - logger.info(f"Photo {photo_id} updated by user {user.id}") - - return serialize_photo(photo) - - except Photo.DoesNotExist: - return 404, {"detail": "Photo not found"} - - -@router.delete("/photos/{photo_id}", response=MessageSchema, auth=jwt_auth) -def delete_photo(request: HttpRequest, photo_id: UUID): - """ - Delete own photo. - - Users can only delete their own photos. - Photos are soft-deleted and removed from CloudFlare. - """ - user = request.auth - - try: - photo = Photo.objects.get(id=photo_id) - - # Check permissions - if photo.uploaded_by_id != user.id and not user.is_moderator: - return 403, {"detail": "Permission denied"} - - photo_service.delete_photo(photo) - - return { - 'success': True, - 'message': 'Photo deleted successfully', - } - - except Photo.DoesNotExist: - return 404, {"detail": "Photo not found"} - - -@router.post("/{entity_type}/{entity_id}/photos", response=MessageSchema, auth=jwt_auth) -def attach_photo_to_entity( - request: HttpRequest, - entity_type: str, - entity_id: UUID, - payload: PhotoAttachRequest, -): - """ - Attach an existing photo to an entity. - - Requires authentication. - """ - user = request.auth - - try: - # Get entity - entity = get_entity_by_type(entity_type, entity_id) - - # Get photo - photo = Photo.objects.get(id=payload.photo_id) - - # Check permissions (can only attach own photos unless moderator) - if photo.uploaded_by_id != user.id and not user.is_moderator: - return 403, {"detail": "Permission denied"} - - # Attach photo - photo_service.attach_to_entity(photo, entity) - - # Update photo type if provided - if payload.photo_type: - photo.photo_type = payload.photo_type - photo.save(update_fields=['photo_type']) - - return { - 'success': True, - 'message': f'Photo attached to {entity_type} successfully', - } - - except ValueError as e: - return 400, {"detail": str(e)} - except Photo.DoesNotExist: - return 404, {"detail": "Photo not found"} - - -# ============================================================================ -# Moderator Endpoints -# ============================================================================ - -@router.get("/photos/pending", response=List[PhotoOut], auth=require_moderator) -@paginate -def list_pending_photos(request: HttpRequest): - """ - List photos pending moderation (moderators only). - """ - queryset = Photo.objects.select_related( - 'uploaded_by', 'moderated_by', 'content_type' - ).pending().order_by('-created_at') - - return queryset - - -@router.post("/photos/{photo_id}/approve", response=PhotoOut, auth=require_moderator) -def approve_photo(request: HttpRequest, photo_id: UUID): - """ - Approve a photo (moderators only). - """ - user = request.auth - - try: - photo = Photo.objects.get(id=photo_id) - photo = photo_service.moderate_photo( - photo=photo, - status='approved', - moderator=user, - ) - - return serialize_photo(photo) - - except Photo.DoesNotExist: - return 404, {"detail": "Photo not found"} - - -@router.post("/photos/{photo_id}/reject", response=PhotoOut, auth=require_moderator) -def reject_photo( - request: HttpRequest, - photo_id: UUID, - payload: PhotoModerateRequest, -): - """ - Reject a photo (moderators only). - """ - user = request.auth - - try: - photo = Photo.objects.get(id=photo_id) - photo = photo_service.moderate_photo( - photo=photo, - status='rejected', - moderator=user, - notes=payload.notes or '', - ) - - return serialize_photo(photo) - - except Photo.DoesNotExist: - return 404, {"detail": "Photo not found"} - - -@router.post("/photos/{photo_id}/flag", response=PhotoOut, auth=require_moderator) -def flag_photo( - request: HttpRequest, - photo_id: UUID, - payload: PhotoModerateRequest, -): - """ - Flag a photo for review (moderators only). - """ - user = request.auth - - try: - photo = Photo.objects.get(id=photo_id) - photo = photo_service.moderate_photo( - photo=photo, - status='flagged', - moderator=user, - notes=payload.notes or '', - ) - - return serialize_photo(photo) - - except Photo.DoesNotExist: - return 404, {"detail": "Photo not found"} - - -@router.get("/photos/stats", response=PhotoStatsOut, auth=require_moderator) -def get_photo_stats(request: HttpRequest): - """ - Get photo statistics (moderators only). - """ - stats = Photo.objects.aggregate( - total=Count('id'), - pending=Count('id', filter=Q(moderation_status='pending')), - approved=Count('id', filter=Q(moderation_status='approved')), - rejected=Count('id', filter=Q(moderation_status='rejected')), - flagged=Count('id', filter=Q(moderation_status='flagged')), - total_size=Sum('file_size'), - ) - - return { - 'total_photos': stats['total'] or 0, - 'pending_photos': stats['pending'] or 0, - 'approved_photos': stats['approved'] or 0, - 'rejected_photos': stats['rejected'] or 0, - 'flagged_photos': stats['flagged'] or 0, - 'total_size_mb': round((stats['total_size'] or 0) / (1024 * 1024), 2), - } - - -# ============================================================================ -# Admin Endpoints -# ============================================================================ - -@router.delete("/photos/{photo_id}/admin", response=MessageSchema, auth=require_admin) -def admin_delete_photo(request: HttpRequest, photo_id: UUID): - """ - Force delete any photo (admins only). - - Permanently removes photo from database and CloudFlare. - """ - try: - photo = Photo.objects.get(id=photo_id) - photo_service.delete_photo(photo, delete_from_cloudflare=True) - - logger.info(f"Photo {photo_id} force deleted by admin {request.auth.id}") - - return { - 'success': True, - 'message': 'Photo permanently deleted', - } - - except Photo.DoesNotExist: - return 404, {"detail": "Photo not found"} - - -@router.post( - "/{entity_type}/{entity_id}/photos/reorder", - response=MessageSchema, - auth=require_admin -) -def reorder_entity_photos( - request: HttpRequest, - entity_type: str, - entity_id: UUID, - payload: PhotoReorderRequest, -): - """ - Reorder photos for an entity (admins only). - """ - try: - entity = get_entity_by_type(entity_type, entity_id) - - photo_service.reorder_photos( - entity=entity, - photo_ids=payload.photo_ids, - photo_type=payload.photo_type, - ) - - return { - 'success': True, - 'message': 'Photos reordered successfully', - } - - except ValueError as e: - return 400, {"detail": str(e)} diff --git a/django/api/v1/endpoints/ride_models.py b/django/api/v1/endpoints/ride_models.py deleted file mode 100644 index a0541ca4..00000000 --- a/django/api/v1/endpoints/ride_models.py +++ /dev/null @@ -1,247 +0,0 @@ -""" -Ride Model endpoints for API v1. - -Provides CRUD operations for RideModel entities with filtering and search. -""" -from typing import List, Optional -from uuid import UUID -from django.shortcuts import get_object_or_404 -from django.db.models import Q -from ninja import Router, Query -from ninja.pagination import paginate, PageNumberPagination - -from apps.entities.models import RideModel, Company -from ..schemas import ( - RideModelCreate, - RideModelUpdate, - RideModelOut, - RideModelListOut, - ErrorResponse -) - - -router = Router(tags=["Ride Models"]) - - -class RideModelPagination(PageNumberPagination): - """Custom pagination for ride models.""" - page_size = 50 - - -@router.get( - "/", - response={200: List[RideModelOut]}, - summary="List ride models", - description="Get a paginated list of ride models with optional filtering" -) -@paginate(RideModelPagination) -def list_ride_models( - request, - search: Optional[str] = Query(None, description="Search by model name"), - manufacturer_id: Optional[UUID] = Query(None, description="Filter by manufacturer"), - model_type: Optional[str] = Query(None, description="Filter by model type"), - ordering: Optional[str] = Query("-created", description="Sort by field (prefix with - for descending)") -): - """ - List all ride models with optional filters. - - **Filters:** - - search: Search model names (case-insensitive partial match) - - manufacturer_id: Filter by manufacturer - - model_type: Filter by model type - - ordering: Sort results (default: -created) - - **Returns:** Paginated list of ride models - """ - queryset = RideModel.objects.select_related('manufacturer').all() - - # Apply search filter - if search: - queryset = queryset.filter( - Q(name__icontains=search) | Q(description__icontains=search) - ) - - # Apply manufacturer filter - if manufacturer_id: - queryset = queryset.filter(manufacturer_id=manufacturer_id) - - # Apply model type filter - if model_type: - queryset = queryset.filter(model_type=model_type) - - # Apply ordering - valid_order_fields = ['name', 'created', 'modified', 'installation_count'] - order_field = ordering.lstrip('-') - if order_field in valid_order_fields: - queryset = queryset.order_by(ordering) - else: - queryset = queryset.order_by('-created') - - # Annotate with manufacturer name - for model in queryset: - model.manufacturer_name = model.manufacturer.name if model.manufacturer else None - - return queryset - - -@router.get( - "/{model_id}", - response={200: RideModelOut, 404: ErrorResponse}, - summary="Get ride model", - description="Retrieve a single ride model by ID" -) -def get_ride_model(request, model_id: UUID): - """ - Get a ride model by ID. - - **Parameters:** - - model_id: UUID of the ride model - - **Returns:** Ride model details - """ - model = get_object_or_404(RideModel.objects.select_related('manufacturer'), id=model_id) - model.manufacturer_name = model.manufacturer.name if model.manufacturer else None - return model - - -@router.post( - "/", - response={201: RideModelOut, 400: ErrorResponse, 404: ErrorResponse}, - summary="Create ride model", - description="Create a new ride model (requires authentication)" -) -def create_ride_model(request, payload: RideModelCreate): - """ - Create a new ride model. - - **Authentication:** Required - - **Parameters:** - - payload: Ride model data - - **Returns:** Created ride model - """ - # TODO: Add authentication check - # if not request.auth: - # return 401, {"detail": "Authentication required"} - - # Verify manufacturer exists - manufacturer = get_object_or_404(Company, id=payload.manufacturer_id) - - model = RideModel.objects.create(**payload.dict()) - model.manufacturer_name = manufacturer.name - return 201, model - - -@router.put( - "/{model_id}", - response={200: RideModelOut, 404: ErrorResponse, 400: ErrorResponse}, - summary="Update ride model", - description="Update an existing ride model (requires authentication)" -) -def update_ride_model(request, model_id: UUID, payload: RideModelUpdate): - """ - Update a ride model. - - **Authentication:** Required - - **Parameters:** - - model_id: UUID of the ride model - - payload: Updated ride model data - - **Returns:** Updated ride model - """ - # TODO: Add authentication check - # if not request.auth: - # return 401, {"detail": "Authentication required"} - - model = get_object_or_404(RideModel.objects.select_related('manufacturer'), id=model_id) - - # Update only provided fields - for key, value in payload.dict(exclude_unset=True).items(): - setattr(model, key, value) - - model.save() - model.manufacturer_name = model.manufacturer.name if model.manufacturer else None - return model - - -@router.patch( - "/{model_id}", - response={200: RideModelOut, 404: ErrorResponse, 400: ErrorResponse}, - summary="Partial update ride model", - description="Partially update an existing ride model (requires authentication)" -) -def partial_update_ride_model(request, model_id: UUID, payload: RideModelUpdate): - """ - Partially update a ride model. - - **Authentication:** Required - - **Parameters:** - - model_id: UUID of the ride model - - payload: Fields to update - - **Returns:** Updated ride model - """ - # TODO: Add authentication check - # if not request.auth: - # return 401, {"detail": "Authentication required"} - - model = get_object_or_404(RideModel.objects.select_related('manufacturer'), id=model_id) - - # Update only provided fields - for key, value in payload.dict(exclude_unset=True).items(): - setattr(model, key, value) - - model.save() - model.manufacturer_name = model.manufacturer.name if model.manufacturer else None - return model - - -@router.delete( - "/{model_id}", - response={204: None, 404: ErrorResponse}, - summary="Delete ride model", - description="Delete a ride model (requires authentication)" -) -def delete_ride_model(request, model_id: UUID): - """ - Delete a ride model. - - **Authentication:** Required - - **Parameters:** - - model_id: UUID of the ride model - - **Returns:** No content (204) - """ - # TODO: Add authentication check - # if not request.auth: - # return 401, {"detail": "Authentication required"} - - model = get_object_or_404(RideModel, id=model_id) - model.delete() - return 204, None - - -@router.get( - "/{model_id}/installations", - response={200: List[dict], 404: ErrorResponse}, - summary="Get ride model installations", - description="Get all ride installations of this model" -) -def get_ride_model_installations(request, model_id: UUID): - """ - Get all installations of a ride model. - - **Parameters:** - - model_id: UUID of the ride model - - **Returns:** List of rides using this model - """ - model = get_object_or_404(RideModel, id=model_id) - rides = model.rides.select_related('park').all().values( - 'id', 'name', 'slug', 'status', 'park__name', 'park__id' - ) - return list(rides) diff --git a/django/api/v1/endpoints/rides.py b/django/api/v1/endpoints/rides.py deleted file mode 100644 index f1501826..00000000 --- a/django/api/v1/endpoints/rides.py +++ /dev/null @@ -1,360 +0,0 @@ -""" -Ride endpoints for API v1. - -Provides CRUD operations for Ride entities with filtering and search. -""" -from typing import List, Optional -from uuid import UUID -from django.shortcuts import get_object_or_404 -from django.db.models import Q -from ninja import Router, Query -from ninja.pagination import paginate, PageNumberPagination - -from apps.entities.models import Ride, Park, Company, RideModel -from ..schemas import ( - RideCreate, - RideUpdate, - RideOut, - RideListOut, - ErrorResponse -) - - -router = Router(tags=["Rides"]) - - -class RidePagination(PageNumberPagination): - """Custom pagination for rides.""" - page_size = 50 - - -@router.get( - "/", - response={200: List[RideOut]}, - summary="List rides", - description="Get a paginated list of rides with optional filtering" -) -@paginate(RidePagination) -def list_rides( - request, - search: Optional[str] = Query(None, description="Search by ride name"), - park_id: Optional[UUID] = Query(None, description="Filter by park"), - ride_category: Optional[str] = Query(None, description="Filter by ride category"), - status: Optional[str] = Query(None, description="Filter by status"), - is_coaster: Optional[bool] = Query(None, description="Filter for roller coasters only"), - manufacturer_id: Optional[UUID] = Query(None, description="Filter by manufacturer"), - ordering: Optional[str] = Query("-created", description="Sort by field (prefix with - for descending)") -): - """ - List all rides with optional filters. - - **Filters:** - - search: Search ride names (case-insensitive partial match) - - park_id: Filter by park - - ride_category: Filter by ride category - - status: Filter by operational status - - is_coaster: Filter for roller coasters (true/false) - - manufacturer_id: Filter by manufacturer - - ordering: Sort results (default: -created) - - **Returns:** Paginated list of rides - """ - queryset = Ride.objects.select_related('park', 'manufacturer', 'model').all() - - # Apply search filter - if search: - queryset = queryset.filter( - Q(name__icontains=search) | Q(description__icontains=search) - ) - - # Apply park filter - if park_id: - queryset = queryset.filter(park_id=park_id) - - # Apply ride category filter - if ride_category: - queryset = queryset.filter(ride_category=ride_category) - - # Apply status filter - if status: - queryset = queryset.filter(status=status) - - # Apply coaster filter - if is_coaster is not None: - queryset = queryset.filter(is_coaster=is_coaster) - - # Apply manufacturer filter - if manufacturer_id: - queryset = queryset.filter(manufacturer_id=manufacturer_id) - - # Apply ordering - valid_order_fields = ['name', 'created', 'modified', 'opening_date', 'height', 'speed', 'length'] - order_field = ordering.lstrip('-') - if order_field in valid_order_fields: - queryset = queryset.order_by(ordering) - else: - queryset = queryset.order_by('-created') - - # Annotate with related names - for ride in queryset: - ride.park_name = ride.park.name if ride.park else None - ride.manufacturer_name = ride.manufacturer.name if ride.manufacturer else None - ride.model_name = ride.model.name if ride.model else None - - return queryset - - -@router.get( - "/{ride_id}", - response={200: RideOut, 404: ErrorResponse}, - summary="Get ride", - description="Retrieve a single ride by ID" -) -def get_ride(request, ride_id: UUID): - """ - Get a ride by ID. - - **Parameters:** - - ride_id: UUID of the ride - - **Returns:** Ride details - """ - ride = get_object_or_404( - Ride.objects.select_related('park', 'manufacturer', 'model'), - id=ride_id - ) - ride.park_name = ride.park.name if ride.park else None - ride.manufacturer_name = ride.manufacturer.name if ride.manufacturer else None - ride.model_name = ride.model.name if ride.model else None - return ride - - -@router.post( - "/", - response={201: RideOut, 400: ErrorResponse, 404: ErrorResponse}, - summary="Create ride", - description="Create a new ride (requires authentication)" -) -def create_ride(request, payload: RideCreate): - """ - Create a new ride. - - **Authentication:** Required - - **Parameters:** - - payload: Ride data - - **Returns:** Created ride - """ - # TODO: Add authentication check - # if not request.auth: - # return 401, {"detail": "Authentication required"} - - # Verify park exists - park = get_object_or_404(Park, id=payload.park_id) - - # Verify manufacturer if provided - if payload.manufacturer_id: - get_object_or_404(Company, id=payload.manufacturer_id) - - # Verify model if provided - if payload.model_id: - get_object_or_404(RideModel, id=payload.model_id) - - ride = Ride.objects.create(**payload.dict()) - - # Reload with related objects - ride = Ride.objects.select_related('park', 'manufacturer', 'model').get(id=ride.id) - ride.park_name = ride.park.name if ride.park else None - ride.manufacturer_name = ride.manufacturer.name if ride.manufacturer else None - ride.model_name = ride.model.name if ride.model else None - - return 201, ride - - -@router.put( - "/{ride_id}", - response={200: RideOut, 404: ErrorResponse, 400: ErrorResponse}, - summary="Update ride", - description="Update an existing ride (requires authentication)" -) -def update_ride(request, ride_id: UUID, payload: RideUpdate): - """ - Update a ride. - - **Authentication:** Required - - **Parameters:** - - ride_id: UUID of the ride - - payload: Updated ride data - - **Returns:** Updated ride - """ - # TODO: Add authentication check - # if not request.auth: - # return 401, {"detail": "Authentication required"} - - ride = get_object_or_404( - Ride.objects.select_related('park', 'manufacturer', 'model'), - id=ride_id - ) - - # Update only provided fields - for key, value in payload.dict(exclude_unset=True).items(): - setattr(ride, key, value) - - ride.save() - - # Reload to get updated relationships - ride = Ride.objects.select_related('park', 'manufacturer', 'model').get(id=ride.id) - ride.park_name = ride.park.name if ride.park else None - ride.manufacturer_name = ride.manufacturer.name if ride.manufacturer else None - ride.model_name = ride.model.name if ride.model else None - - return ride - - -@router.patch( - "/{ride_id}", - response={200: RideOut, 404: ErrorResponse, 400: ErrorResponse}, - summary="Partial update ride", - description="Partially update an existing ride (requires authentication)" -) -def partial_update_ride(request, ride_id: UUID, payload: RideUpdate): - """ - Partially update a ride. - - **Authentication:** Required - - **Parameters:** - - ride_id: UUID of the ride - - payload: Fields to update - - **Returns:** Updated ride - """ - # TODO: Add authentication check - # if not request.auth: - # return 401, {"detail": "Authentication required"} - - ride = get_object_or_404( - Ride.objects.select_related('park', 'manufacturer', 'model'), - id=ride_id - ) - - # Update only provided fields - for key, value in payload.dict(exclude_unset=True).items(): - setattr(ride, key, value) - - ride.save() - - # Reload to get updated relationships - ride = Ride.objects.select_related('park', 'manufacturer', 'model').get(id=ride.id) - ride.park_name = ride.park.name if ride.park else None - ride.manufacturer_name = ride.manufacturer.name if ride.manufacturer else None - ride.model_name = ride.model.name if ride.model else None - - return ride - - -@router.delete( - "/{ride_id}", - response={204: None, 404: ErrorResponse}, - summary="Delete ride", - description="Delete a ride (requires authentication)" -) -def delete_ride(request, ride_id: UUID): - """ - Delete a ride. - - **Authentication:** Required - - **Parameters:** - - ride_id: UUID of the ride - - **Returns:** No content (204) - """ - # TODO: Add authentication check - # if not request.auth: - # return 401, {"detail": "Authentication required"} - - ride = get_object_or_404(Ride, id=ride_id) - ride.delete() - return 204, None - - -@router.get( - "/coasters/", - response={200: List[RideOut]}, - summary="List roller coasters", - description="Get a paginated list of roller coasters only" -) -@paginate(RidePagination) -def list_coasters( - request, - search: Optional[str] = Query(None, description="Search by ride name"), - park_id: Optional[UUID] = Query(None, description="Filter by park"), - status: Optional[str] = Query(None, description="Filter by status"), - manufacturer_id: Optional[UUID] = Query(None, description="Filter by manufacturer"), - min_height: Optional[float] = Query(None, description="Minimum height in feet"), - min_speed: Optional[float] = Query(None, description="Minimum speed in mph"), - ordering: Optional[str] = Query("-height", description="Sort by field (prefix with - for descending)") -): - """ - List only roller coasters with optional filters. - - **Filters:** - - search: Search coaster names - - park_id: Filter by park - - status: Filter by operational status - - manufacturer_id: Filter by manufacturer - - min_height: Minimum height filter - - min_speed: Minimum speed filter - - ordering: Sort results (default: -height) - - **Returns:** Paginated list of roller coasters - """ - queryset = Ride.objects.filter(is_coaster=True).select_related( - 'park', 'manufacturer', 'model' - ) - - # Apply search filter - if search: - queryset = queryset.filter( - Q(name__icontains=search) | Q(description__icontains=search) - ) - - # Apply park filter - if park_id: - queryset = queryset.filter(park_id=park_id) - - # Apply status filter - if status: - queryset = queryset.filter(status=status) - - # Apply manufacturer filter - if manufacturer_id: - queryset = queryset.filter(manufacturer_id=manufacturer_id) - - # Apply height filter - if min_height is not None: - queryset = queryset.filter(height__gte=min_height) - - # Apply speed filter - if min_speed is not None: - queryset = queryset.filter(speed__gte=min_speed) - - # Apply ordering - valid_order_fields = ['name', 'height', 'speed', 'length', 'opening_date', 'inversions'] - order_field = ordering.lstrip('-') - if order_field in valid_order_fields: - queryset = queryset.order_by(ordering) - else: - queryset = queryset.order_by('-height') - - # Annotate with related names - for ride in queryset: - ride.park_name = ride.park.name if ride.park else None - ride.manufacturer_name = ride.manufacturer.name if ride.manufacturer else None - ride.model_name = ride.model.name if ride.model else None - - return queryset diff --git a/django/api/v1/endpoints/search.py b/django/api/v1/endpoints/search.py deleted file mode 100644 index ecc1f01d..00000000 --- a/django/api/v1/endpoints/search.py +++ /dev/null @@ -1,438 +0,0 @@ -""" -Search and autocomplete endpoints for ThrillWiki API. - -Provides full-text search and filtering across all entity types. -""" -from typing import List, Optional -from uuid import UUID -from datetime import date -from decimal import Decimal - -from django.http import HttpRequest -from ninja import Router, Query - -from apps.entities.search import SearchService -from apps.users.permissions import jwt_auth -from api.v1.schemas import ( - GlobalSearchResponse, - CompanySearchResult, - RideModelSearchResult, - ParkSearchResult, - RideSearchResult, - AutocompleteResponse, - AutocompleteItem, - ErrorResponse, -) - -router = Router(tags=["Search"]) -search_service = SearchService() - - -# ============================================================================ -# Helper Functions -# ============================================================================ - -def _company_to_search_result(company) -> CompanySearchResult: - """Convert Company model to search result.""" - return CompanySearchResult( - id=company.id, - name=company.name, - slug=company.slug, - entity_type='company', - description=company.description, - image_url=company.logo_image_url or None, - company_types=company.company_types or [], - park_count=company.park_count, - ride_count=company.ride_count, - ) - - -def _ride_model_to_search_result(model) -> RideModelSearchResult: - """Convert RideModel to search result.""" - return RideModelSearchResult( - id=model.id, - name=model.name, - slug=model.slug, - entity_type='ride_model', - description=model.description, - image_url=model.image_url or None, - manufacturer_name=model.manufacturer.name if model.manufacturer else '', - model_type=model.model_type, - installation_count=model.installation_count, - ) - - -def _park_to_search_result(park) -> ParkSearchResult: - """Convert Park model to search result.""" - return ParkSearchResult( - id=park.id, - name=park.name, - slug=park.slug, - entity_type='park', - description=park.description, - image_url=park.banner_image_url or park.logo_image_url or None, - park_type=park.park_type, - status=park.status, - operator_name=park.operator.name if park.operator else None, - ride_count=park.ride_count, - coaster_count=park.coaster_count, - coordinates=park.coordinates, - ) - - -def _ride_to_search_result(ride) -> RideSearchResult: - """Convert Ride model to search result.""" - return RideSearchResult( - id=ride.id, - name=ride.name, - slug=ride.slug, - entity_type='ride', - description=ride.description, - image_url=ride.image_url or None, - park_name=ride.park.name if ride.park else '', - park_slug=ride.park.slug if ride.park else '', - manufacturer_name=ride.manufacturer.name if ride.manufacturer else None, - ride_category=ride.ride_category, - status=ride.status, - is_coaster=ride.is_coaster, - ) - - -# ============================================================================ -# Search Endpoints -# ============================================================================ - -@router.get( - "", - response={200: GlobalSearchResponse, 400: ErrorResponse}, - summary="Global search across all entities" -) -def search_all( - request: HttpRequest, - q: str = Query(..., min_length=2, max_length=200, description="Search query"), - entity_types: Optional[List[str]] = Query(None, description="Filter by entity types (company, ride_model, park, ride)"), - limit: int = Query(20, ge=1, le=100, description="Maximum results per entity type"), -): - """ - Search across all entity types with full-text search. - - - **q**: Search query (minimum 2 characters) - - **entity_types**: Optional list of entity types to search (defaults to all) - - **limit**: Maximum results per entity type (1-100, default 20) - - Returns results grouped by entity type. - """ - try: - results = search_service.search_all( - query=q, - entity_types=entity_types, - limit=limit - ) - - # Convert to schema objects - response_data = { - 'query': q, - 'total_results': 0, - 'companies': [], - 'ride_models': [], - 'parks': [], - 'rides': [], - } - - if 'companies' in results: - response_data['companies'] = [ - _company_to_search_result(c) for c in results['companies'] - ] - response_data['total_results'] += len(response_data['companies']) - - if 'ride_models' in results: - response_data['ride_models'] = [ - _ride_model_to_search_result(m) for m in results['ride_models'] - ] - response_data['total_results'] += len(response_data['ride_models']) - - if 'parks' in results: - response_data['parks'] = [ - _park_to_search_result(p) for p in results['parks'] - ] - response_data['total_results'] += len(response_data['parks']) - - if 'rides' in results: - response_data['rides'] = [ - _ride_to_search_result(r) for r in results['rides'] - ] - response_data['total_results'] += len(response_data['rides']) - - return GlobalSearchResponse(**response_data) - - except Exception as e: - return 400, ErrorResponse(detail=str(e)) - - -@router.get( - "/companies", - response={200: List[CompanySearchResult], 400: ErrorResponse}, - summary="Search companies" -) -def search_companies( - request: HttpRequest, - q: str = Query(..., min_length=2, max_length=200, description="Search query"), - company_types: Optional[List[str]] = Query(None, description="Filter by company types"), - founded_after: Optional[date] = Query(None, description="Founded after date"), - founded_before: Optional[date] = Query(None, description="Founded before date"), - limit: int = Query(20, ge=1, le=100, description="Maximum results"), -): - """ - Search companies with optional filters. - - - **q**: Search query - - **company_types**: Filter by types (manufacturer, operator, designer, etc.) - - **founded_after/before**: Filter by founding date range - - **limit**: Maximum results (1-100, default 20) - """ - try: - filters = {} - if company_types: - filters['company_types'] = company_types - if founded_after: - filters['founded_after'] = founded_after - if founded_before: - filters['founded_before'] = founded_before - - results = search_service.search_companies( - query=q, - filters=filters if filters else None, - limit=limit - ) - - return [_company_to_search_result(c) for c in results] - - except Exception as e: - return 400, ErrorResponse(detail=str(e)) - - -@router.get( - "/ride-models", - response={200: List[RideModelSearchResult], 400: ErrorResponse}, - summary="Search ride models" -) -def search_ride_models( - request: HttpRequest, - q: str = Query(..., min_length=2, max_length=200, description="Search query"), - manufacturer_id: Optional[UUID] = Query(None, description="Filter by manufacturer"), - model_type: Optional[str] = Query(None, description="Filter by model type"), - limit: int = Query(20, ge=1, le=100, description="Maximum results"), -): - """ - Search ride models with optional filters. - - - **q**: Search query - - **manufacturer_id**: Filter by specific manufacturer - - **model_type**: Filter by model type - - **limit**: Maximum results (1-100, default 20) - """ - try: - filters = {} - if manufacturer_id: - filters['manufacturer_id'] = manufacturer_id - if model_type: - filters['model_type'] = model_type - - results = search_service.search_ride_models( - query=q, - filters=filters if filters else None, - limit=limit - ) - - return [_ride_model_to_search_result(m) for m in results] - - except Exception as e: - return 400, ErrorResponse(detail=str(e)) - - -@router.get( - "/parks", - response={200: List[ParkSearchResult], 400: ErrorResponse}, - summary="Search parks" -) -def search_parks( - request: HttpRequest, - q: str = Query(..., min_length=2, max_length=200, description="Search query"), - status: Optional[str] = Query(None, description="Filter by status"), - park_type: Optional[str] = Query(None, description="Filter by park type"), - operator_id: Optional[UUID] = Query(None, description="Filter by operator"), - opening_after: Optional[date] = Query(None, description="Opened after date"), - opening_before: Optional[date] = Query(None, description="Opened before date"), - latitude: Optional[float] = Query(None, description="Search center latitude"), - longitude: Optional[float] = Query(None, description="Search center longitude"), - radius: Optional[float] = Query(None, ge=0, le=500, description="Search radius in km (PostGIS only)"), - limit: int = Query(20, ge=1, le=100, description="Maximum results"), -): - """ - Search parks with optional filters including location-based search. - - - **q**: Search query - - **status**: Filter by operational status - - **park_type**: Filter by park type - - **operator_id**: Filter by operator company - - **opening_after/before**: Filter by opening date range - - **latitude/longitude/radius**: Location-based filtering (PostGIS only) - - **limit**: Maximum results (1-100, default 20) - """ - try: - filters = {} - if status: - filters['status'] = status - if park_type: - filters['park_type'] = park_type - if operator_id: - filters['operator_id'] = operator_id - if opening_after: - filters['opening_after'] = opening_after - if opening_before: - filters['opening_before'] = opening_before - - # Location-based search (PostGIS only) - if latitude is not None and longitude is not None and radius is not None: - filters['location'] = (longitude, latitude) - filters['radius'] = radius - - results = search_service.search_parks( - query=q, - filters=filters if filters else None, - limit=limit - ) - - return [_park_to_search_result(p) for p in results] - - except Exception as e: - return 400, ErrorResponse(detail=str(e)) - - -@router.get( - "/rides", - response={200: List[RideSearchResult], 400: ErrorResponse}, - summary="Search rides" -) -def search_rides( - request: HttpRequest, - q: str = Query(..., min_length=2, max_length=200, description="Search query"), - park_id: Optional[UUID] = Query(None, description="Filter by park"), - manufacturer_id: Optional[UUID] = Query(None, description="Filter by manufacturer"), - model_id: Optional[UUID] = Query(None, description="Filter by model"), - status: Optional[str] = Query(None, description="Filter by status"), - ride_category: Optional[str] = Query(None, description="Filter by category"), - is_coaster: Optional[bool] = Query(None, description="Filter coasters only"), - opening_after: Optional[date] = Query(None, description="Opened after date"), - opening_before: Optional[date] = Query(None, description="Opened before date"), - min_height: Optional[Decimal] = Query(None, description="Minimum height in feet"), - max_height: Optional[Decimal] = Query(None, description="Maximum height in feet"), - min_speed: Optional[Decimal] = Query(None, description="Minimum speed in mph"), - max_speed: Optional[Decimal] = Query(None, description="Maximum speed in mph"), - limit: int = Query(20, ge=1, le=100, description="Maximum results"), -): - """ - Search rides with extensive filtering options. - - - **q**: Search query - - **park_id**: Filter by specific park - - **manufacturer_id**: Filter by manufacturer - - **model_id**: Filter by specific ride model - - **status**: Filter by operational status - - **ride_category**: Filter by category (roller_coaster, flat_ride, etc.) - - **is_coaster**: Filter to show only coasters - - **opening_after/before**: Filter by opening date range - - **min_height/max_height**: Filter by height range (feet) - - **min_speed/max_speed**: Filter by speed range (mph) - - **limit**: Maximum results (1-100, default 20) - """ - try: - filters = {} - if park_id: - filters['park_id'] = park_id - if manufacturer_id: - filters['manufacturer_id'] = manufacturer_id - if model_id: - filters['model_id'] = model_id - if status: - filters['status'] = status - if ride_category: - filters['ride_category'] = ride_category - if is_coaster is not None: - filters['is_coaster'] = is_coaster - if opening_after: - filters['opening_after'] = opening_after - if opening_before: - filters['opening_before'] = opening_before - if min_height: - filters['min_height'] = min_height - if max_height: - filters['max_height'] = max_height - if min_speed: - filters['min_speed'] = min_speed - if max_speed: - filters['max_speed'] = max_speed - - results = search_service.search_rides( - query=q, - filters=filters if filters else None, - limit=limit - ) - - return [_ride_to_search_result(r) for r in results] - - except Exception as e: - return 400, ErrorResponse(detail=str(e)) - - -# ============================================================================ -# Autocomplete Endpoint -# ============================================================================ - -@router.get( - "/autocomplete", - response={200: AutocompleteResponse, 400: ErrorResponse}, - summary="Autocomplete suggestions" -) -def autocomplete( - request: HttpRequest, - q: str = Query(..., min_length=2, max_length=100, description="Partial search query"), - entity_type: Optional[str] = Query(None, description="Filter by entity type (company, park, ride, ride_model)"), - limit: int = Query(10, ge=1, le=20, description="Maximum suggestions"), -): - """ - Get autocomplete suggestions for search. - - - **q**: Partial query (minimum 2 characters) - - **entity_type**: Optional entity type filter - - **limit**: Maximum suggestions (1-20, default 10) - - Returns quick name-based suggestions for autocomplete UIs. - """ - try: - suggestions = search_service.autocomplete( - query=q, - entity_type=entity_type, - limit=limit - ) - - # Convert to schema objects - items = [ - AutocompleteItem( - id=s['id'], - name=s['name'], - slug=s['slug'], - entity_type=s['entity_type'], - park_name=s.get('park_name'), - manufacturer_name=s.get('manufacturer_name'), - ) - for s in suggestions - ] - - return AutocompleteResponse( - query=q, - suggestions=items - ) - - except Exception as e: - return 400, ErrorResponse(detail=str(e)) diff --git a/django/api/v1/endpoints/versioning.py b/django/api/v1/endpoints/versioning.py deleted file mode 100644 index fa9244c2..00000000 --- a/django/api/v1/endpoints/versioning.py +++ /dev/null @@ -1,369 +0,0 @@ -""" -Versioning API endpoints for ThrillWiki. - -Provides REST API for: -- Version history for entities -- Specific version details -- Comparing versions -- Diff with current state -- Version restoration (optional) -""" - -from typing import List -from uuid import UUID -from django.shortcuts import get_object_or_404 -from django.http import Http404 -from ninja import Router - -from apps.entities.models import Park, Ride, Company, RideModel -from apps.versioning.models import EntityVersion -from apps.versioning.services import VersionService -from api.v1.schemas import ( - EntityVersionSchema, - VersionHistoryResponseSchema, - VersionDiffSchema, - VersionComparisonSchema, - ErrorSchema, - MessageSchema -) - -router = Router(tags=['Versioning']) - - -# Park Versions - -@router.get( - '/parks/{park_id}/versions', - response={200: VersionHistoryResponseSchema, 404: ErrorSchema}, - summary="Get park version history" -) -def get_park_versions(request, park_id: UUID, limit: int = 50): - """ - Get version history for a park. - - Returns up to `limit` versions in reverse chronological order (newest first). - """ - park = get_object_or_404(Park, id=park_id) - versions = VersionService.get_version_history(park, limit=limit) - - return { - 'entity_id': str(park.id), - 'entity_type': 'park', - 'entity_name': park.name, - 'total_versions': VersionService.get_version_count(park), - 'versions': [ - EntityVersionSchema.from_orm(v) for v in versions - ] - } - - -@router.get( - '/parks/{park_id}/versions/{version_number}', - response={200: EntityVersionSchema, 404: ErrorSchema}, - summary="Get specific park version" -) -def get_park_version(request, park_id: UUID, version_number: int): - """Get a specific version of a park by version number.""" - park = get_object_or_404(Park, id=park_id) - version = VersionService.get_version_by_number(park, version_number) - - if not version: - raise Http404("Version not found") - - return EntityVersionSchema.from_orm(version) - - -@router.get( - '/parks/{park_id}/versions/{version_number}/diff', - response={200: VersionDiffSchema, 404: ErrorSchema}, - summary="Compare park version with current" -) -def get_park_version_diff(request, park_id: UUID, version_number: int): - """ - Compare a specific version with the current park state. - - Returns the differences between the version and current values. - """ - park = get_object_or_404(Park, id=park_id) - version = VersionService.get_version_by_number(park, version_number) - - if not version: - raise Http404("Version not found") - - diff = VersionService.get_diff_with_current(version) - - return { - 'entity_id': str(park.id), - 'entity_type': 'park', - 'entity_name': park.name, - 'version_number': version.version_number, - 'version_date': version.created, - 'differences': diff['differences'], - 'changed_field_count': diff['changed_field_count'] - } - - -# Ride Versions - -@router.get( - '/rides/{ride_id}/versions', - response={200: VersionHistoryResponseSchema, 404: ErrorSchema}, - summary="Get ride version history" -) -def get_ride_versions(request, ride_id: UUID, limit: int = 50): - """Get version history for a ride.""" - ride = get_object_or_404(Ride, id=ride_id) - versions = VersionService.get_version_history(ride, limit=limit) - - return { - 'entity_id': str(ride.id), - 'entity_type': 'ride', - 'entity_name': ride.name, - 'total_versions': VersionService.get_version_count(ride), - 'versions': [ - EntityVersionSchema.from_orm(v) for v in versions - ] - } - - -@router.get( - '/rides/{ride_id}/versions/{version_number}', - response={200: EntityVersionSchema, 404: ErrorSchema}, - summary="Get specific ride version" -) -def get_ride_version(request, ride_id: UUID, version_number: int): - """Get a specific version of a ride by version number.""" - ride = get_object_or_404(Ride, id=ride_id) - version = VersionService.get_version_by_number(ride, version_number) - - if not version: - raise Http404("Version not found") - - return EntityVersionSchema.from_orm(version) - - -@router.get( - '/rides/{ride_id}/versions/{version_number}/diff', - response={200: VersionDiffSchema, 404: ErrorSchema}, - summary="Compare ride version with current" -) -def get_ride_version_diff(request, ride_id: UUID, version_number: int): - """Compare a specific version with the current ride state.""" - ride = get_object_or_404(Ride, id=ride_id) - version = VersionService.get_version_by_number(ride, version_number) - - if not version: - raise Http404("Version not found") - - diff = VersionService.get_diff_with_current(version) - - return { - 'entity_id': str(ride.id), - 'entity_type': 'ride', - 'entity_name': ride.name, - 'version_number': version.version_number, - 'version_date': version.created, - 'differences': diff['differences'], - 'changed_field_count': diff['changed_field_count'] - } - - -# Company Versions - -@router.get( - '/companies/{company_id}/versions', - response={200: VersionHistoryResponseSchema, 404: ErrorSchema}, - summary="Get company version history" -) -def get_company_versions(request, company_id: UUID, limit: int = 50): - """Get version history for a company.""" - company = get_object_or_404(Company, id=company_id) - versions = VersionService.get_version_history(company, limit=limit) - - return { - 'entity_id': str(company.id), - 'entity_type': 'company', - 'entity_name': company.name, - 'total_versions': VersionService.get_version_count(company), - 'versions': [ - EntityVersionSchema.from_orm(v) for v in versions - ] - } - - -@router.get( - '/companies/{company_id}/versions/{version_number}', - response={200: EntityVersionSchema, 404: ErrorSchema}, - summary="Get specific company version" -) -def get_company_version(request, company_id: UUID, version_number: int): - """Get a specific version of a company by version number.""" - company = get_object_or_404(Company, id=company_id) - version = VersionService.get_version_by_number(company, version_number) - - if not version: - raise Http404("Version not found") - - return EntityVersionSchema.from_orm(version) - - -@router.get( - '/companies/{company_id}/versions/{version_number}/diff', - response={200: VersionDiffSchema, 404: ErrorSchema}, - summary="Compare company version with current" -) -def get_company_version_diff(request, company_id: UUID, version_number: int): - """Compare a specific version with the current company state.""" - company = get_object_or_404(Company, id=company_id) - version = VersionService.get_version_by_number(company, version_number) - - if not version: - raise Http404("Version not found") - - diff = VersionService.get_diff_with_current(version) - - return { - 'entity_id': str(company.id), - 'entity_type': 'company', - 'entity_name': company.name, - 'version_number': version.version_number, - 'version_date': version.created, - 'differences': diff['differences'], - 'changed_field_count': diff['changed_field_count'] - } - - -# Ride Model Versions - -@router.get( - '/ride-models/{model_id}/versions', - response={200: VersionHistoryResponseSchema, 404: ErrorSchema}, - summary="Get ride model version history" -) -def get_ride_model_versions(request, model_id: UUID, limit: int = 50): - """Get version history for a ride model.""" - model = get_object_or_404(RideModel, id=model_id) - versions = VersionService.get_version_history(model, limit=limit) - - return { - 'entity_id': str(model.id), - 'entity_type': 'ride_model', - 'entity_name': str(model), - 'total_versions': VersionService.get_version_count(model), - 'versions': [ - EntityVersionSchema.from_orm(v) for v in versions - ] - } - - -@router.get( - '/ride-models/{model_id}/versions/{version_number}', - response={200: EntityVersionSchema, 404: ErrorSchema}, - summary="Get specific ride model version" -) -def get_ride_model_version(request, model_id: UUID, version_number: int): - """Get a specific version of a ride model by version number.""" - model = get_object_or_404(RideModel, id=model_id) - version = VersionService.get_version_by_number(model, version_number) - - if not version: - raise Http404("Version not found") - - return EntityVersionSchema.from_orm(version) - - -@router.get( - '/ride-models/{model_id}/versions/{version_number}/diff', - response={200: VersionDiffSchema, 404: ErrorSchema}, - summary="Compare ride model version with current" -) -def get_ride_model_version_diff(request, model_id: UUID, version_number: int): - """Compare a specific version with the current ride model state.""" - model = get_object_or_404(RideModel, id=model_id) - version = VersionService.get_version_by_number(model, version_number) - - if not version: - raise Http404("Version not found") - - diff = VersionService.get_diff_with_current(version) - - return { - 'entity_id': str(model.id), - 'entity_type': 'ride_model', - 'entity_name': str(model), - 'version_number': version.version_number, - 'version_date': version.created, - 'differences': diff['differences'], - 'changed_field_count': diff['changed_field_count'] - } - - -# Generic Version Endpoints - -@router.get( - '/versions/{version_id}', - response={200: EntityVersionSchema, 404: ErrorSchema}, - summary="Get version by ID" -) -def get_version(request, version_id: UUID): - """Get a specific version by its ID.""" - version = get_object_or_404(EntityVersion, id=version_id) - return EntityVersionSchema.from_orm(version) - - -@router.get( - '/versions/{version_id}/compare/{other_version_id}', - response={200: VersionComparisonSchema, 404: ErrorSchema}, - summary="Compare two versions" -) -def compare_versions(request, version_id: UUID, other_version_id: UUID): - """ - Compare two versions of the same entity. - - Both versions must be for the same entity. - """ - version1 = get_object_or_404(EntityVersion, id=version_id) - version2 = get_object_or_404(EntityVersion, id=other_version_id) - - comparison = VersionService.compare_versions(version1, version2) - - return { - 'version1': EntityVersionSchema.from_orm(version1), - 'version2': EntityVersionSchema.from_orm(version2), - 'differences': comparison['differences'], - 'changed_field_count': comparison['changed_field_count'] - } - - -# Optional: Version Restoration -# Uncomment if you want to enable version restoration via API - -# @router.post( -# '/versions/{version_id}/restore', -# response={200: MessageSchema, 404: ErrorSchema}, -# summary="Restore a version" -# ) -# def restore_version(request, version_id: UUID): -# """ -# Restore an entity to a previous version. -# -# This creates a new version with change_type='restored'. -# Requires authentication and appropriate permissions. -# """ -# version = get_object_or_404(EntityVersion, id=version_id) -# -# # Check authentication -# if not request.user.is_authenticated: -# return 401, {'error': 'Authentication required'} -# -# # Restore version -# restored_version = VersionService.restore_version( -# version, -# user=request.user, -# comment='Restored via API' -# ) -# -# return { -# 'message': f'Successfully restored to version {version.version_number}', -# 'new_version_number': restored_version.version_number -# } diff --git a/django/api/v1/schemas.py b/django/api/v1/schemas.py deleted file mode 100644 index 414d7ede..00000000 --- a/django/api/v1/schemas.py +++ /dev/null @@ -1,969 +0,0 @@ -""" -Pydantic schemas for API v1 endpoints. - -These schemas define the structure of request and response data for the REST API. -""" -from datetime import date, datetime -from typing import Optional, List -from decimal import Decimal -from pydantic import BaseModel, Field, field_validator -from uuid import UUID - - -# ============================================================================ -# Base Schemas -# ============================================================================ - -class TimestampSchema(BaseModel): - """Base schema with timestamps.""" - created: datetime - modified: datetime - - -# ============================================================================ -# Company Schemas -# ============================================================================ - -class CompanyBase(BaseModel): - """Base company schema with common fields.""" - name: str = Field(..., min_length=1, max_length=255) - description: Optional[str] = None - company_types: List[str] = Field(default_factory=list) - founded_date: Optional[date] = None - founded_date_precision: str = Field(default='day') - closed_date: Optional[date] = None - closed_date_precision: str = Field(default='day') - website: Optional[str] = None - logo_image_id: Optional[str] = None - logo_image_url: Optional[str] = None - - -class CompanyCreate(CompanyBase): - """Schema for creating a company.""" - pass - - -class CompanyUpdate(BaseModel): - """Schema for updating a company (all fields optional).""" - name: Optional[str] = Field(None, min_length=1, max_length=255) - description: Optional[str] = None - company_types: Optional[List[str]] = None - founded_date: Optional[date] = None - founded_date_precision: Optional[str] = None - closed_date: Optional[date] = None - closed_date_precision: Optional[str] = None - website: Optional[str] = None - logo_image_id: Optional[str] = None - logo_image_url: Optional[str] = None - - -class CompanyOut(CompanyBase, TimestampSchema): - """Schema for company output.""" - id: UUID - slug: str - park_count: int - ride_count: int - - class Config: - from_attributes = True - - -# ============================================================================ -# RideModel Schemas -# ============================================================================ - -class RideModelBase(BaseModel): - """Base ride model schema.""" - name: str = Field(..., min_length=1, max_length=255) - description: Optional[str] = None - manufacturer_id: UUID - model_type: str - typical_height: Optional[Decimal] = None - typical_speed: Optional[Decimal] = None - typical_capacity: Optional[int] = None - image_id: Optional[str] = None - image_url: Optional[str] = None - - -class RideModelCreate(RideModelBase): - """Schema for creating a ride model.""" - pass - - -class RideModelUpdate(BaseModel): - """Schema for updating a ride model (all fields optional).""" - name: Optional[str] = Field(None, min_length=1, max_length=255) - description: Optional[str] = None - manufacturer_id: Optional[UUID] = None - model_type: Optional[str] = None - typical_height: Optional[Decimal] = None - typical_speed: Optional[Decimal] = None - typical_capacity: Optional[int] = None - image_id: Optional[str] = None - image_url: Optional[str] = None - - -class RideModelOut(RideModelBase, TimestampSchema): - """Schema for ride model output.""" - id: UUID - slug: str - installation_count: int - manufacturer_name: Optional[str] = None - - class Config: - from_attributes = True - - -# ============================================================================ -# Park Schemas -# ============================================================================ - -class ParkBase(BaseModel): - """Base park schema.""" - name: str = Field(..., min_length=1, max_length=255) - description: Optional[str] = None - park_type: str - status: str = Field(default='operating') - opening_date: Optional[date] = None - opening_date_precision: str = Field(default='day') - closing_date: Optional[date] = None - closing_date_precision: str = Field(default='day') - latitude: Optional[Decimal] = None - longitude: Optional[Decimal] = None - operator_id: Optional[UUID] = None - website: Optional[str] = None - banner_image_id: Optional[str] = None - banner_image_url: Optional[str] = None - logo_image_id: Optional[str] = None - logo_image_url: Optional[str] = None - - -class ParkCreate(ParkBase): - """Schema for creating a park.""" - pass - - -class ParkUpdate(BaseModel): - """Schema for updating a park (all fields optional).""" - name: Optional[str] = Field(None, min_length=1, max_length=255) - description: Optional[str] = None - park_type: Optional[str] = None - status: Optional[str] = None - opening_date: Optional[date] = None - opening_date_precision: Optional[str] = None - closing_date: Optional[date] = None - closing_date_precision: Optional[str] = None - latitude: Optional[Decimal] = None - longitude: Optional[Decimal] = None - operator_id: Optional[UUID] = None - website: Optional[str] = None - banner_image_id: Optional[str] = None - banner_image_url: Optional[str] = None - logo_image_id: Optional[str] = None - logo_image_url: Optional[str] = None - - -class ParkOut(ParkBase, TimestampSchema): - """Schema for park output.""" - id: UUID - slug: str - ride_count: int - coaster_count: int - operator_name: Optional[str] = None - coordinates: Optional[tuple[float, float]] = None - - class Config: - from_attributes = True - - -# ============================================================================ -# Ride Schemas -# ============================================================================ - -class RideBase(BaseModel): - """Base ride schema.""" - name: str = Field(..., min_length=1, max_length=255) - description: Optional[str] = None - park_id: UUID - ride_category: str - ride_type: Optional[str] = None - is_coaster: bool = Field(default=False) - status: str = Field(default='operating') - opening_date: Optional[date] = None - opening_date_precision: str = Field(default='day') - closing_date: Optional[date] = None - closing_date_precision: str = Field(default='day') - manufacturer_id: Optional[UUID] = None - model_id: Optional[UUID] = None - height: Optional[Decimal] = None - speed: Optional[Decimal] = None - length: Optional[Decimal] = None - duration: Optional[int] = None - inversions: Optional[int] = None - capacity: Optional[int] = None - image_id: Optional[str] = None - image_url: Optional[str] = None - - -class RideCreate(RideBase): - """Schema for creating a ride.""" - pass - - -class RideUpdate(BaseModel): - """Schema for updating a ride (all fields optional).""" - name: Optional[str] = Field(None, min_length=1, max_length=255) - description: Optional[str] = None - park_id: Optional[UUID] = None - ride_category: Optional[str] = None - ride_type: Optional[str] = None - is_coaster: Optional[bool] = None - status: Optional[str] = None - opening_date: Optional[date] = None - opening_date_precision: Optional[str] = None - closing_date: Optional[date] = None - closing_date_precision: Optional[str] = None - manufacturer_id: Optional[UUID] = None - model_id: Optional[UUID] = None - height: Optional[Decimal] = None - speed: Optional[Decimal] = None - length: Optional[Decimal] = None - duration: Optional[int] = None - inversions: Optional[int] = None - capacity: Optional[int] = None - image_id: Optional[str] = None - image_url: Optional[str] = None - - -class RideOut(RideBase, TimestampSchema): - """Schema for ride output.""" - id: UUID - slug: str - park_name: Optional[str] = None - manufacturer_name: Optional[str] = None - model_name: Optional[str] = None - - class Config: - from_attributes = True - - -# ============================================================================ -# Pagination Schemas -# ============================================================================ - -class PaginatedResponse(BaseModel): - """Generic paginated response schema.""" - items: List - total: int - page: int - page_size: int - total_pages: int - - -class CompanyListOut(BaseModel): - """Paginated company list response.""" - items: List[CompanyOut] - total: int - page: int - page_size: int - total_pages: int - - -class RideModelListOut(BaseModel): - """Paginated ride model list response.""" - items: List[RideModelOut] - total: int - page: int - page_size: int - total_pages: int - - -class ParkListOut(BaseModel): - """Paginated park list response.""" - items: List[ParkOut] - total: int - page: int - page_size: int - total_pages: int - - -class RideListOut(BaseModel): - """Paginated ride list response.""" - items: List[RideOut] - total: int - page: int - page_size: int - total_pages: int - - -# ============================================================================ -# Error Schemas -# ============================================================================ - -class ErrorResponse(BaseModel): - """Standard error response schema.""" - detail: str - code: Optional[str] = None - - -class ValidationErrorResponse(BaseModel): - """Validation error response schema.""" - detail: str - errors: Optional[List[dict]] = None - - -# ============================================================================ -# Moderation Schemas -# ============================================================================ - -class SubmissionItemBase(BaseModel): - """Base submission item schema.""" - field_name: str = Field(..., min_length=1, max_length=100) - field_label: Optional[str] = None - old_value: Optional[dict] = None - new_value: Optional[dict] = None - change_type: str = Field(default='modify') - is_required: bool = Field(default=False) - order: int = Field(default=0) - - -class SubmissionItemCreate(SubmissionItemBase): - """Schema for creating a submission item.""" - pass - - -class SubmissionItemOut(SubmissionItemBase, TimestampSchema): - """Schema for submission item output.""" - id: UUID - submission_id: UUID - status: str - reviewed_by_id: Optional[UUID] = None - reviewed_by_email: Optional[str] = None - reviewed_at: Optional[datetime] = None - rejection_reason: Optional[str] = None - old_value_display: str - new_value_display: str - - class Config: - from_attributes = True - - -class ContentSubmissionBase(BaseModel): - """Base content submission schema.""" - submission_type: str - title: str = Field(..., min_length=1, max_length=255) - description: Optional[str] = None - entity_type: str - entity_id: UUID - - -class ContentSubmissionCreate(BaseModel): - """Schema for creating a content submission.""" - entity_type: str = Field(..., description="Entity type (park, ride, company, ridemodel)") - entity_id: UUID = Field(..., description="ID of entity being modified") - submission_type: str = Field(..., description="Operation type (create, update, delete)") - title: str = Field(..., min_length=1, max_length=255, description="Brief description") - description: Optional[str] = Field(None, description="Detailed description") - items: List[SubmissionItemCreate] = Field(..., min_items=1, description="List of changes") - metadata: Optional[dict] = Field(default_factory=dict) - auto_submit: bool = Field(default=True, description="Auto-submit for review") - - -class ContentSubmissionOut(TimestampSchema): - """Schema for content submission output.""" - id: UUID - status: str - submission_type: str - title: str - description: Optional[str] = None - entity_type: str - entity_id: UUID - user_id: UUID - user_email: str - locked_by_id: Optional[UUID] = None - locked_by_email: Optional[str] = None - locked_at: Optional[datetime] = None - reviewed_by_id: Optional[UUID] = None - reviewed_by_email: Optional[str] = None - reviewed_at: Optional[datetime] = None - rejection_reason: Optional[str] = None - source: str - metadata: dict - items_count: int - approved_items_count: int - rejected_items_count: int - - class Config: - from_attributes = True - - -class ContentSubmissionDetail(ContentSubmissionOut): - """Detailed submission with items.""" - items: List[SubmissionItemOut] - - class Config: - from_attributes = True - - -class StartReviewRequest(BaseModel): - """Schema for starting a review.""" - pass # No additional fields needed - - -class ApproveRequest(BaseModel): - """Schema for approving a submission.""" - pass # No additional fields needed - - -class ApproveSelectiveRequest(BaseModel): - """Schema for selective approval.""" - item_ids: List[UUID] = Field(..., min_items=1, description="List of item IDs to approve") - - -class RejectRequest(BaseModel): - """Schema for rejecting a submission.""" - reason: str = Field(..., min_length=1, description="Reason for rejection") - - -class RejectSelectiveRequest(BaseModel): - """Schema for selective rejection.""" - item_ids: List[UUID] = Field(..., min_items=1, description="List of item IDs to reject") - reason: Optional[str] = Field(None, description="Reason for rejection") - - -class ApprovalResponse(BaseModel): - """Response for approval operations.""" - success: bool - message: str - submission: ContentSubmissionOut - - -class SelectiveApprovalResponse(BaseModel): - """Response for selective approval.""" - success: bool - message: str - approved: int - total: int - pending: int - submission_approved: bool - - -class SelectiveRejectionResponse(BaseModel): - """Response for selective rejection.""" - success: bool - message: str - rejected: int - total: int - pending: int - submission_complete: bool - - -class SubmissionListOut(BaseModel): - """Paginated submission list response.""" - items: List[ContentSubmissionOut] - total: int - page: int - page_size: int - total_pages: int - - -# ============================================================================ -# Versioning Schemas -# ============================================================================ - -class EntityVersionSchema(TimestampSchema): - """Schema for entity version output.""" - id: UUID - entity_type: str - entity_id: UUID - entity_name: str - version_number: int - change_type: str - snapshot: dict - changed_fields: dict - changed_by_id: Optional[UUID] = None - changed_by_email: Optional[str] = None - submission_id: Optional[UUID] = None - comment: Optional[str] = None - diff_summary: str - - class Config: - from_attributes = True - - -class VersionHistoryResponseSchema(BaseModel): - """Response schema for version history.""" - entity_id: str - entity_type: str - entity_name: str - total_versions: int - versions: List[EntityVersionSchema] - - -class VersionDiffSchema(BaseModel): - """Schema for version diff response.""" - entity_id: str - entity_type: str - entity_name: str - version_number: int - version_date: datetime - differences: dict - changed_field_count: int - - -class VersionComparisonSchema(BaseModel): - """Schema for comparing two versions.""" - version1: EntityVersionSchema - version2: EntityVersionSchema - differences: dict - changed_field_count: int - - -# ============================================================================ -# Generic Utility Schemas -# ============================================================================ - -class MessageSchema(BaseModel): - """Generic message response.""" - message: str - success: bool = True - - -class ErrorSchema(BaseModel): - """Standard error response.""" - error: str - detail: Optional[str] = None - - -# ============================================================================ -# Authentication Schemas -# ============================================================================ - -class UserBase(BaseModel): - """Base user schema.""" - email: str = Field(..., description="Email address") - username: Optional[str] = Field(None, description="Username") - first_name: Optional[str] = Field(None, max_length=150) - last_name: Optional[str] = Field(None, max_length=150) - - -class UserRegisterRequest(BaseModel): - """Schema for user registration.""" - email: str = Field(..., description="Email address") - password: str = Field(..., min_length=8, description="Password (min 8 characters)") - password_confirm: str = Field(..., description="Password confirmation") - username: Optional[str] = Field(None, description="Username (auto-generated if not provided)") - first_name: Optional[str] = Field(None, max_length=150) - last_name: Optional[str] = Field(None, max_length=150) - - @field_validator('password_confirm') - def passwords_match(cls, v, info): - if 'password' in info.data and v != info.data['password']: - raise ValueError('Passwords do not match') - return v - - -class UserLoginRequest(BaseModel): - """Schema for user login.""" - email: str = Field(..., description="Email address") - password: str = Field(..., description="Password") - mfa_token: Optional[str] = Field(None, description="MFA token if enabled") - - -class TokenResponse(BaseModel): - """Schema for token response.""" - access: str = Field(..., description="JWT access token") - refresh: str = Field(..., description="JWT refresh token") - token_type: str = Field(default="Bearer") - - -class TokenRefreshRequest(BaseModel): - """Schema for token refresh.""" - refresh: str = Field(..., description="Refresh token") - - -class UserProfileOut(BaseModel): - """Schema for user profile output.""" - id: UUID - email: str - username: str - first_name: str - last_name: str - display_name: str - avatar_url: Optional[str] = None - bio: Optional[str] = None - reputation_score: int - mfa_enabled: bool - banned: bool - date_joined: datetime - last_login: Optional[datetime] = None - oauth_provider: str - - class Config: - from_attributes = True - - -class UserProfileUpdate(BaseModel): - """Schema for updating user profile.""" - first_name: Optional[str] = Field(None, max_length=150) - last_name: Optional[str] = Field(None, max_length=150) - username: Optional[str] = Field(None, max_length=150) - bio: Optional[str] = Field(None, max_length=500) - avatar_url: Optional[str] = None - - -class ChangePasswordRequest(BaseModel): - """Schema for password change.""" - old_password: str = Field(..., description="Current password") - new_password: str = Field(..., min_length=8, description="New password") - new_password_confirm: str = Field(..., description="New password confirmation") - - @field_validator('new_password_confirm') - def passwords_match(cls, v, info): - if 'new_password' in info.data and v != info.data['new_password']: - raise ValueError('Passwords do not match') - return v - - -class ResetPasswordRequest(BaseModel): - """Schema for password reset.""" - email: str = Field(..., description="Email address") - - -class ResetPasswordConfirm(BaseModel): - """Schema for password reset confirmation.""" - token: str = Field(..., description="Reset token") - password: str = Field(..., min_length=8, description="New password") - password_confirm: str = Field(..., description="Password confirmation") - - @field_validator('password_confirm') - def passwords_match(cls, v, info): - if 'password' in info.data and v != info.data['password']: - raise ValueError('Passwords do not match') - return v - - -class UserRoleOut(BaseModel): - """Schema for user role output.""" - role: str - is_moderator: bool - is_admin: bool - granted_at: datetime - granted_by_email: Optional[str] = None - - class Config: - from_attributes = True - - -class UserPermissionsOut(BaseModel): - """Schema for user permissions.""" - can_submit: bool - can_moderate: bool - can_admin: bool - can_edit_own: bool - can_delete_own: bool - - -class UserStatsOut(BaseModel): - """Schema for user statistics.""" - total_submissions: int - approved_submissions: int - reputation_score: int - member_since: datetime - last_active: Optional[datetime] = None - - -class UserProfilePreferencesOut(BaseModel): - """Schema for user preferences.""" - email_notifications: bool - email_on_submission_approved: bool - email_on_submission_rejected: bool - profile_public: bool - show_email: bool - - class Config: - from_attributes = True - - -class UserProfilePreferencesUpdate(BaseModel): - """Schema for updating user preferences.""" - email_notifications: Optional[bool] = None - email_on_submission_approved: Optional[bool] = None - email_on_submission_rejected: Optional[bool] = None - profile_public: Optional[bool] = None - show_email: Optional[bool] = None - - -class TOTPEnableResponse(BaseModel): - """Schema for TOTP enable response.""" - secret: str = Field(..., description="TOTP secret key") - qr_code_url: str = Field(..., description="QR code URL for authenticator apps") - backup_codes: List[str] = Field(default_factory=list, description="Backup codes") - - -class TOTPConfirmRequest(BaseModel): - """Schema for TOTP confirmation.""" - token: str = Field(..., min_length=6, max_length=6, description="6-digit TOTP token") - - -class TOTPVerifyRequest(BaseModel): - """Schema for TOTP verification.""" - token: str = Field(..., min_length=6, max_length=6, description="6-digit TOTP token") - - -class BanUserRequest(BaseModel): - """Schema for banning a user.""" - user_id: UUID = Field(..., description="User ID to ban") - reason: str = Field(..., min_length=1, description="Reason for ban") - - -class UnbanUserRequest(BaseModel): - """Schema for unbanning a user.""" - user_id: UUID = Field(..., description="User ID to unban") - - -class AssignRoleRequest(BaseModel): - """Schema for assigning a role.""" - user_id: UUID = Field(..., description="User ID") - role: str = Field(..., description="Role to assign (user, moderator, admin)") - - -class UserListOut(BaseModel): - """Paginated user list response.""" - items: List[UserProfileOut] - total: int - page: int - page_size: int - total_pages: int - - -# ============================================================================ -# Photo/Media Schemas -# ============================================================================ - -class PhotoBase(BaseModel): - """Base photo schema.""" - title: Optional[str] = Field(None, max_length=255) - description: Optional[str] = None - credit: Optional[str] = Field(None, max_length=255, description="Photo credit/attribution") - photo_type: str = Field(default='gallery', description="Type: main, gallery, banner, logo, thumbnail, other") - is_visible: bool = Field(default=True) - - -class PhotoUploadRequest(PhotoBase): - """Schema for photo upload request (form data).""" - entity_type: Optional[str] = Field(None, description="Entity type to attach to") - entity_id: Optional[UUID] = Field(None, description="Entity ID to attach to") - - -class PhotoUpdate(BaseModel): - """Schema for updating photo metadata.""" - title: Optional[str] = Field(None, max_length=255) - description: Optional[str] = None - credit: Optional[str] = Field(None, max_length=255) - photo_type: Optional[str] = None - is_visible: Optional[bool] = None - display_order: Optional[int] = None - - -class PhotoOut(PhotoBase, TimestampSchema): - """Schema for photo output.""" - id: UUID - cloudflare_image_id: str - cloudflare_url: str - uploaded_by_id: UUID - uploaded_by_email: Optional[str] = None - moderation_status: str - moderated_by_id: Optional[UUID] = None - moderated_by_email: Optional[str] = None - moderated_at: Optional[datetime] = None - moderation_notes: Optional[str] = None - entity_type: Optional[str] = None - entity_id: Optional[str] = None - entity_name: Optional[str] = None - width: int - height: int - file_size: int - mime_type: str - display_order: int - - # Generated URLs for different variants - thumbnail_url: Optional[str] = None - banner_url: Optional[str] = None - - class Config: - from_attributes = True - - -class PhotoListOut(BaseModel): - """Paginated photo list response.""" - items: List[PhotoOut] - total: int - page: int - page_size: int - total_pages: int - - -class PhotoUploadResponse(BaseModel): - """Response for photo upload.""" - success: bool - message: str - photo: PhotoOut - - -class PhotoModerateRequest(BaseModel): - """Schema for moderating a photo.""" - status: str = Field(..., description="Status: approved, rejected, flagged") - notes: Optional[str] = Field(None, description="Moderation notes") - - -class PhotoReorderRequest(BaseModel): - """Schema for reordering photos.""" - photo_ids: List[int] = Field(..., min_items=1, description="Ordered list of photo IDs") - photo_type: Optional[str] = Field(None, description="Optional photo type filter") - - -class PhotoAttachRequest(BaseModel): - """Schema for attaching photo to entity.""" - photo_id: UUID = Field(..., description="Photo ID to attach") - photo_type: Optional[str] = Field('gallery', description="Photo type") - - -class PhotoStatsOut(BaseModel): - """Statistics about photos.""" - total_photos: int - pending_photos: int - approved_photos: int - rejected_photos: int - flagged_photos: int - total_size_mb: float - - -# ============================================================================ -# Search Schemas -# ============================================================================ - -class SearchResultBase(BaseModel): - """Base schema for search results.""" - id: UUID - name: str - slug: str - entity_type: str - description: Optional[str] = None - image_url: Optional[str] = None - - -class CompanySearchResult(SearchResultBase): - """Company search result.""" - company_types: List[str] = Field(default_factory=list) - park_count: int = 0 - ride_count: int = 0 - - -class RideModelSearchResult(SearchResultBase): - """Ride model search result.""" - manufacturer_name: str - model_type: str - installation_count: int = 0 - - -class ParkSearchResult(SearchResultBase): - """Park search result.""" - park_type: str - status: str - operator_name: Optional[str] = None - ride_count: int = 0 - coaster_count: int = 0 - coordinates: Optional[tuple[float, float]] = None - - -class RideSearchResult(SearchResultBase): - """Ride search result.""" - park_name: str - park_slug: str - manufacturer_name: Optional[str] = None - ride_category: str - status: str - is_coaster: bool - - -class GlobalSearchResponse(BaseModel): - """Response for global search across all entities.""" - query: str - total_results: int - companies: List[CompanySearchResult] = Field(default_factory=list) - ride_models: List[RideModelSearchResult] = Field(default_factory=list) - parks: List[ParkSearchResult] = Field(default_factory=list) - rides: List[RideSearchResult] = Field(default_factory=list) - - -class AutocompleteItem(BaseModel): - """Single autocomplete suggestion.""" - id: UUID - name: str - slug: str - entity_type: str - park_name: Optional[str] = None # For rides - manufacturer_name: Optional[str] = None # For ride models - - -class AutocompleteResponse(BaseModel): - """Response for autocomplete suggestions.""" - query: str - suggestions: List[AutocompleteItem] - - -class SearchFilters(BaseModel): - """Base filters for search operations.""" - q: str = Field(..., min_length=2, max_length=200, description="Search query") - entity_types: Optional[List[str]] = Field(None, description="Filter by entity types") - limit: int = Field(20, ge=1, le=100, description="Maximum results per entity type") - - -class CompanySearchFilters(BaseModel): - """Filters for company search.""" - q: str = Field(..., min_length=2, max_length=200, description="Search query") - company_types: Optional[List[str]] = Field(None, description="Filter by company types") - founded_after: Optional[date] = Field(None, description="Founded after date") - founded_before: Optional[date] = Field(None, description="Founded before date") - limit: int = Field(20, ge=1, le=100) - - -class RideModelSearchFilters(BaseModel): - """Filters for ride model search.""" - q: str = Field(..., min_length=2, max_length=200, description="Search query") - manufacturer_id: Optional[UUID] = Field(None, description="Filter by manufacturer") - model_type: Optional[str] = Field(None, description="Filter by model type") - limit: int = Field(20, ge=1, le=100) - - -class ParkSearchFilters(BaseModel): - """Filters for park search.""" - q: str = Field(..., min_length=2, max_length=200, description="Search query") - status: Optional[str] = Field(None, description="Filter by status") - park_type: Optional[str] = Field(None, description="Filter by park type") - operator_id: Optional[UUID] = Field(None, description="Filter by operator") - opening_after: Optional[date] = Field(None, description="Opened after date") - opening_before: Optional[date] = Field(None, description="Opened before date") - latitude: Optional[float] = Field(None, description="Search center latitude") - longitude: Optional[float] = Field(None, description="Search center longitude") - radius: Optional[float] = Field(None, ge=0, le=500, description="Search radius in km") - limit: int = Field(20, ge=1, le=100) - - -class RideSearchFilters(BaseModel): - """Filters for ride search.""" - q: str = Field(..., min_length=2, max_length=200, description="Search query") - park_id: Optional[UUID] = Field(None, description="Filter by park") - manufacturer_id: Optional[UUID] = Field(None, description="Filter by manufacturer") - model_id: Optional[UUID] = Field(None, description="Filter by model") - status: Optional[str] = Field(None, description="Filter by status") - ride_category: Optional[str] = Field(None, description="Filter by category") - is_coaster: Optional[bool] = Field(None, description="Filter coasters only") - opening_after: Optional[date] = Field(None, description="Opened after date") - opening_before: Optional[date] = Field(None, description="Opened before date") - min_height: Optional[Decimal] = Field(None, description="Minimum height in feet") - max_height: Optional[Decimal] = Field(None, description="Maximum height in feet") - min_speed: Optional[Decimal] = Field(None, description="Minimum speed in mph") - max_speed: Optional[Decimal] = Field(None, description="Maximum speed in mph") - limit: int = Field(20, ge=1, le=100) diff --git a/django/apps/__init__.py b/django/apps/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/django/apps/__pycache__/__init__.cpython-313.pyc b/django/apps/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index bf8e4e22..00000000 Binary files a/django/apps/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/django/apps/core/__init__.py b/django/apps/core/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/django/apps/core/__pycache__/__init__.cpython-313.pyc b/django/apps/core/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 2473403c..00000000 Binary files a/django/apps/core/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/django/apps/core/__pycache__/apps.cpython-313.pyc b/django/apps/core/__pycache__/apps.cpython-313.pyc deleted file mode 100644 index d74d5dc9..00000000 Binary files a/django/apps/core/__pycache__/apps.cpython-313.pyc and /dev/null differ diff --git a/django/apps/core/__pycache__/models.cpython-313.pyc b/django/apps/core/__pycache__/models.cpython-313.pyc deleted file mode 100644 index bfd57cca..00000000 Binary files a/django/apps/core/__pycache__/models.cpython-313.pyc and /dev/null differ diff --git a/django/apps/core/apps.py b/django/apps/core/apps.py deleted file mode 100644 index 8acdc11d..00000000 --- a/django/apps/core/apps.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -Core app configuration. -""" - -from django.apps import AppConfig - - -class CoreConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.core' - verbose_name = 'Core' diff --git a/django/apps/core/migrations/0001_initial.py b/django/apps/core/migrations/0001_initial.py deleted file mode 100644 index 4bedf241..00000000 --- a/django/apps/core/migrations/0001_initial.py +++ /dev/null @@ -1,194 +0,0 @@ -# Generated by Django 4.2.8 on 2025-11-08 16:35 - -from django.db import migrations, models -import django.db.models.deletion -import django.utils.timezone -import django_lifecycle.mixins -import model_utils.fields -import uuid - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [] - - operations = [ - migrations.CreateModel( - name="Country", - fields=[ - ( - "created", - model_utils.fields.AutoCreatedField( - default=django.utils.timezone.now, - editable=False, - verbose_name="created", - ), - ), - ( - "modified", - model_utils.fields.AutoLastModifiedField( - default=django.utils.timezone.now, - editable=False, - verbose_name="modified", - ), - ), - ( - "id", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ("name", models.CharField(max_length=255, unique=True)), - ( - "code", - models.CharField( - help_text="ISO 3166-1 alpha-2 country code", - max_length=2, - unique=True, - ), - ), - ( - "code3", - models.CharField( - blank=True, - help_text="ISO 3166-1 alpha-3 country code", - max_length=3, - ), - ), - ], - options={ - "verbose_name_plural": "countries", - "db_table": "countries", - "ordering": ["name"], - }, - bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model), - ), - migrations.CreateModel( - name="Subdivision", - fields=[ - ( - "created", - model_utils.fields.AutoCreatedField( - default=django.utils.timezone.now, - editable=False, - verbose_name="created", - ), - ), - ( - "modified", - model_utils.fields.AutoLastModifiedField( - default=django.utils.timezone.now, - editable=False, - verbose_name="modified", - ), - ), - ( - "id", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ("name", models.CharField(max_length=255)), - ( - "code", - models.CharField( - help_text="ISO 3166-2 subdivision code (without country prefix)", - max_length=10, - ), - ), - ( - "subdivision_type", - models.CharField( - blank=True, - help_text="Type of subdivision (state, province, region, etc.)", - max_length=50, - ), - ), - ( - "country", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="subdivisions", - to="core.country", - ), - ), - ], - options={ - "db_table": "subdivisions", - "ordering": ["country", "name"], - "unique_together": {("country", "code")}, - }, - bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model), - ), - migrations.CreateModel( - name="Locality", - fields=[ - ( - "created", - model_utils.fields.AutoCreatedField( - default=django.utils.timezone.now, - editable=False, - verbose_name="created", - ), - ), - ( - "modified", - model_utils.fields.AutoLastModifiedField( - default=django.utils.timezone.now, - editable=False, - verbose_name="modified", - ), - ), - ( - "id", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ("name", models.CharField(max_length=255)), - ( - "latitude", - models.DecimalField( - blank=True, decimal_places=6, max_digits=9, null=True - ), - ), - ( - "longitude", - models.DecimalField( - blank=True, decimal_places=6, max_digits=9, null=True - ), - ), - ( - "subdivision", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="localities", - to="core.subdivision", - ), - ), - ], - options={ - "verbose_name_plural": "localities", - "db_table": "localities", - "ordering": ["subdivision", "name"], - "indexes": [ - models.Index( - fields=["subdivision", "name"], - name="localities_subdivi_675d5a_idx", - ) - ], - }, - bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model), - ), - ] diff --git a/django/apps/core/migrations/__init__.py b/django/apps/core/migrations/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/django/apps/core/migrations/__pycache__/0001_initial.cpython-313.pyc b/django/apps/core/migrations/__pycache__/0001_initial.cpython-313.pyc deleted file mode 100644 index 88ad8153..00000000 Binary files a/django/apps/core/migrations/__pycache__/0001_initial.cpython-313.pyc and /dev/null differ diff --git a/django/apps/core/migrations/__pycache__/__init__.cpython-313.pyc b/django/apps/core/migrations/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 6da67ee5..00000000 Binary files a/django/apps/core/migrations/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/django/apps/core/models.py b/django/apps/core/models.py deleted file mode 100644 index a98cbe8c..00000000 --- a/django/apps/core/models.py +++ /dev/null @@ -1,264 +0,0 @@ -""" -Core base models and utilities for ThrillWiki. -These abstract models provide common functionality for all entities. -""" - -import uuid -from django.db import models -from model_utils.models import TimeStampedModel -from django_lifecycle import LifecycleModel, hook, AFTER_CREATE, AFTER_UPDATE -from dirtyfields import DirtyFieldsMixin - - -class BaseModel(LifecycleModel, TimeStampedModel): - """ - Abstract base model for all entities. - - Provides: - - UUID primary key - - created_at and updated_at timestamps (from TimeStampedModel) - - Lifecycle hooks for versioning - """ - id = models.UUIDField( - primary_key=True, - default=uuid.uuid4, - editable=False - ) - - class Meta: - abstract = True - - def __str__(self): - return f"{self.__class__.__name__}({self.id})" - - -class VersionedModel(DirtyFieldsMixin, BaseModel): - """ - Abstract base model for entities that need version tracking. - - Automatically creates a version record whenever the model is created or updated. - Uses DirtyFieldsMixin to track which fields changed. - """ - - @hook(AFTER_CREATE) - def create_version_on_create(self): - """Create initial version when entity is created""" - self._create_version('created') - - @hook(AFTER_UPDATE) - def create_version_on_update(self): - """Create version when entity is updated""" - if self.get_dirty_fields(): - self._create_version('updated') - - def _create_version(self, change_type): - """ - Create a version record for this entity. - Deferred import to avoid circular dependencies. - """ - try: - from apps.versioning.services import VersionService - VersionService.create_version( - entity=self, - change_type=change_type, - changed_fields=self.get_dirty_fields() if change_type == 'updated' else {} - ) - except ImportError: - # Versioning app not yet available (e.g., during initial migrations) - pass - - class Meta: - abstract = True - - -# Location Models - -class Country(BaseModel): - """ - Country reference data (ISO 3166-1). - - Examples: United States, Canada, United Kingdom, etc. - """ - name = models.CharField(max_length=255, unique=True) - code = models.CharField( - max_length=2, - unique=True, - help_text="ISO 3166-1 alpha-2 country code" - ) - code3 = models.CharField( - max_length=3, - blank=True, - help_text="ISO 3166-1 alpha-3 country code" - ) - - class Meta: - db_table = 'countries' - ordering = ['name'] - verbose_name_plural = 'countries' - - def __str__(self): - return self.name - - -class Subdivision(BaseModel): - """ - State/Province/Region reference data (ISO 3166-2). - - Examples: California, Ontario, England, etc. - """ - country = models.ForeignKey( - Country, - on_delete=models.CASCADE, - related_name='subdivisions' - ) - name = models.CharField(max_length=255) - code = models.CharField( - max_length=10, - help_text="ISO 3166-2 subdivision code (without country prefix)" - ) - subdivision_type = models.CharField( - max_length=50, - blank=True, - help_text="Type of subdivision (state, province, region, etc.)" - ) - - class Meta: - db_table = 'subdivisions' - ordering = ['country', 'name'] - unique_together = [['country', 'code']] - - def __str__(self): - return f"{self.name}, {self.country.code}" - - -class Locality(BaseModel): - """ - City/Town reference data. - - Examples: Los Angeles, Toronto, London, etc. - """ - subdivision = models.ForeignKey( - Subdivision, - on_delete=models.CASCADE, - related_name='localities' - ) - name = models.CharField(max_length=255) - latitude = models.DecimalField( - max_digits=9, - decimal_places=6, - null=True, - blank=True - ) - longitude = models.DecimalField( - max_digits=9, - decimal_places=6, - null=True, - blank=True - ) - - class Meta: - db_table = 'localities' - ordering = ['subdivision', 'name'] - verbose_name_plural = 'localities' - indexes = [ - models.Index(fields=['subdivision', 'name']), - ] - - def __str__(self): - return f"{self.name}, {self.subdivision.code}" - - @property - def full_location(self): - """Return full location string: City, State, Country""" - return f"{self.name}, {self.subdivision.name}, {self.subdivision.country.name}" - - -# Date Precision Tracking - -class DatePrecisionMixin(models.Model): - """ - Mixin for models that need to track date precision. - - Allows tracking whether a date is known to year, month, or day precision. - This is important for historical records where exact dates may not be known. - """ - - DATE_PRECISION_CHOICES = [ - ('year', 'Year'), - ('month', 'Month'), - ('day', 'Day'), - ] - - class Meta: - abstract = True - - @classmethod - def add_date_precision_field(cls, field_name): - """ - Helper to add a precision field for a date field. - - Usage in subclass: - opening_date = models.DateField(null=True, blank=True) - opening_date_precision = models.CharField(...) - """ - return models.CharField( - max_length=20, - choices=cls.DATE_PRECISION_CHOICES, - default='day', - help_text=f"Precision level for {field_name}" - ) - - -# Soft Delete Mixin - -class SoftDeleteMixin(models.Model): - """ - Mixin for soft-deletable models. - - Instead of actually deleting records, mark them as deleted. - This preserves data integrity and allows for undelete functionality. - """ - is_deleted = models.BooleanField(default=False, db_index=True) - deleted_at = models.DateTimeField(null=True, blank=True) - deleted_by = models.ForeignKey( - 'users.User', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='%(class)s_deletions' - ) - - class Meta: - abstract = True - - def soft_delete(self, user=None): - """Mark this record as deleted""" - from django.utils import timezone - self.is_deleted = True - self.deleted_at = timezone.now() - if user: - self.deleted_by = user - self.save(update_fields=['is_deleted', 'deleted_at', 'deleted_by']) - - def undelete(self): - """Restore a soft-deleted record""" - self.is_deleted = False - self.deleted_at = None - self.deleted_by = None - self.save(update_fields=['is_deleted', 'deleted_at', 'deleted_by']) - - -# Model Managers - -class ActiveManager(models.Manager): - """Manager that filters out soft-deleted records by default""" - - def get_queryset(self): - return super().get_queryset().filter(is_deleted=False) - - -class AllObjectsManager(models.Manager): - """Manager that includes all records, even soft-deleted ones""" - - def get_queryset(self): - return super().get_queryset() diff --git a/django/apps/entities/__init__.py b/django/apps/entities/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/django/apps/entities/__pycache__/__init__.cpython-313.pyc b/django/apps/entities/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 027a3570..00000000 Binary files a/django/apps/entities/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/django/apps/entities/__pycache__/admin.cpython-313.pyc b/django/apps/entities/__pycache__/admin.cpython-313.pyc deleted file mode 100644 index 61f22117..00000000 Binary files a/django/apps/entities/__pycache__/admin.cpython-313.pyc and /dev/null differ diff --git a/django/apps/entities/__pycache__/apps.cpython-313.pyc b/django/apps/entities/__pycache__/apps.cpython-313.pyc deleted file mode 100644 index d334802d..00000000 Binary files a/django/apps/entities/__pycache__/apps.cpython-313.pyc and /dev/null differ diff --git a/django/apps/entities/__pycache__/models.cpython-313.pyc b/django/apps/entities/__pycache__/models.cpython-313.pyc deleted file mode 100644 index 72bddf6c..00000000 Binary files a/django/apps/entities/__pycache__/models.cpython-313.pyc and /dev/null differ diff --git a/django/apps/entities/__pycache__/search.cpython-313.pyc b/django/apps/entities/__pycache__/search.cpython-313.pyc deleted file mode 100644 index 2577d9b6..00000000 Binary files a/django/apps/entities/__pycache__/search.cpython-313.pyc and /dev/null differ diff --git a/django/apps/entities/__pycache__/signals.cpython-313.pyc b/django/apps/entities/__pycache__/signals.cpython-313.pyc deleted file mode 100644 index 17d28fde..00000000 Binary files a/django/apps/entities/__pycache__/signals.cpython-313.pyc and /dev/null differ diff --git a/django/apps/entities/admin.py b/django/apps/entities/admin.py deleted file mode 100644 index f0aa1177..00000000 --- a/django/apps/entities/admin.py +++ /dev/null @@ -1,706 +0,0 @@ -""" -Django Admin configuration for entity models with Unfold theme. -""" -from django.contrib import admin -from django.contrib.gis import admin as gis_admin -from django.db.models import Count, Q -from django.utils.html import format_html -from django.urls import reverse -from django.conf import settings -from unfold.admin import ModelAdmin, TabularInline -from unfold.contrib.filters.admin import RangeDateFilter, RangeNumericFilter, RelatedDropdownFilter, ChoicesDropdownFilter -from unfold.contrib.import_export.forms import ImportForm, ExportForm -from import_export.admin import ImportExportModelAdmin -from import_export import resources, fields -from import_export.widgets import ForeignKeyWidget -from .models import Company, RideModel, Park, Ride -from apps.media.admin import PhotoInline - - -# ============================================================================ -# IMPORT/EXPORT RESOURCES -# ============================================================================ - -class CompanyResource(resources.ModelResource): - """Import/Export resource for Company model.""" - - class Meta: - model = Company - fields = ( - 'id', 'name', 'slug', 'description', 'location', - 'company_types', 'founded_date', 'founded_date_precision', - 'closed_date', 'closed_date_precision', 'website', - 'logo_image_url', 'created', 'modified' - ) - export_order = fields - - -class RideModelResource(resources.ModelResource): - """Import/Export resource for RideModel model.""" - - manufacturer = fields.Field( - column_name='manufacturer', - attribute='manufacturer', - widget=ForeignKeyWidget(Company, 'name') - ) - - class Meta: - model = RideModel - fields = ( - 'id', 'name', 'slug', 'description', 'manufacturer', - 'model_type', 'typical_height', 'typical_speed', - 'typical_capacity', 'image_url', 'created', 'modified' - ) - export_order = fields - - -class ParkResource(resources.ModelResource): - """Import/Export resource for Park model.""" - - operator = fields.Field( - column_name='operator', - attribute='operator', - widget=ForeignKeyWidget(Company, 'name') - ) - - class Meta: - model = Park - fields = ( - 'id', 'name', 'slug', 'description', 'park_type', 'status', - 'latitude', 'longitude', 'operator', 'opening_date', - 'opening_date_precision', 'closing_date', 'closing_date_precision', - 'website', 'banner_image_url', 'logo_image_url', - 'created', 'modified' - ) - export_order = fields - - -class RideResource(resources.ModelResource): - """Import/Export resource for Ride model.""" - - park = fields.Field( - column_name='park', - attribute='park', - widget=ForeignKeyWidget(Park, 'name') - ) - manufacturer = fields.Field( - column_name='manufacturer', - attribute='manufacturer', - widget=ForeignKeyWidget(Company, 'name') - ) - model = fields.Field( - column_name='model', - attribute='model', - widget=ForeignKeyWidget(RideModel, 'name') - ) - - class Meta: - model = Ride - fields = ( - 'id', 'name', 'slug', 'description', 'park', 'ride_category', - 'ride_type', 'status', 'manufacturer', 'model', 'height', - 'speed', 'length', 'duration', 'inversions', 'capacity', - 'opening_date', 'opening_date_precision', 'closing_date', - 'closing_date_precision', 'image_url', 'created', 'modified' - ) - export_order = fields - - -# ============================================================================ -# INLINE ADMIN CLASSES -# ============================================================================ - -class RideInline(TabularInline): - """Inline for Rides within a Park.""" - - model = Ride - extra = 0 - fields = ['name', 'ride_category', 'status', 'manufacturer', 'opening_date'] - readonly_fields = ['name'] - show_change_link = True - classes = ['collapse'] - - def has_add_permission(self, request, obj=None): - return False - - -class CompanyParksInline(TabularInline): - """Inline for Parks operated by a Company.""" - - model = Park - fk_name = 'operator' - extra = 0 - fields = ['name', 'park_type', 'status', 'ride_count', 'opening_date'] - readonly_fields = ['name', 'ride_count'] - show_change_link = True - classes = ['collapse'] - - def has_add_permission(self, request, obj=None): - return False - - -class RideModelInstallationsInline(TabularInline): - """Inline for Ride installations of a RideModel.""" - - model = Ride - fk_name = 'model' - extra = 0 - fields = ['name', 'park', 'status', 'opening_date'] - readonly_fields = ['name', 'park'] - show_change_link = True - classes = ['collapse'] - - def has_add_permission(self, request, obj=None): - return False - - -# ============================================================================ -# MAIN ADMIN CLASSES -# ============================================================================ - -@admin.register(Company) -class CompanyAdmin(ModelAdmin, ImportExportModelAdmin): - """Enhanced admin interface for Company model.""" - - resource_class = CompanyResource - import_form_class = ImportForm - export_form_class = ExportForm - - list_display = [ - 'name_with_icon', - 'location', - 'company_types_display', - 'park_count', - 'ride_count', - 'founded_date', - 'status_indicator', - 'created' - ] - list_filter = [ - ('company_types', ChoicesDropdownFilter), - ('founded_date', RangeDateFilter), - ('closed_date', RangeDateFilter), - ] - search_fields = ['name', 'slug', 'description', 'location'] - readonly_fields = ['id', 'created', 'modified', 'park_count', 'ride_count', 'slug'] - prepopulated_fields = {} # Slug is auto-generated via lifecycle hook - autocomplete_fields = [] - inlines = [CompanyParksInline, PhotoInline] - - list_per_page = 50 - list_max_show_all = 200 - - fieldsets = ( - ('Basic Information', { - 'fields': ('name', 'slug', 'description', 'company_types') - }), - ('Location & Contact', { - 'fields': ('location', 'website') - }), - ('History', { - 'fields': ( - 'founded_date', 'founded_date_precision', - 'closed_date', 'closed_date_precision' - ) - }), - ('Media', { - 'fields': ('logo_image_id', 'logo_image_url'), - 'classes': ['collapse'] - }), - ('Statistics', { - 'fields': ('park_count', 'ride_count'), - 'classes': ['collapse'] - }), - ('System Information', { - 'fields': ('id', 'created', 'modified'), - 'classes': ['collapse'] - }), - ) - - def name_with_icon(self, obj): - """Display name with company type icon.""" - icons = { - 'manufacturer': '🏭', - 'operator': '🎡', - 'designer': '✏️', - } - icon = '🏢' # Default company icon - if obj.company_types: - for ctype in obj.company_types: - if ctype in icons: - icon = icons[ctype] - break - return format_html('{} {}', icon, obj.name) - name_with_icon.short_description = 'Company' - name_with_icon.admin_order_field = 'name' - - def company_types_display(self, obj): - """Display company types as badges.""" - if not obj.company_types: - return '-' - badges = [] - for ctype in obj.company_types: - color = { - 'manufacturer': 'blue', - 'operator': 'green', - 'designer': 'purple', - }.get(ctype, 'gray') - badges.append( - f'{ctype.upper()}' - ) - return format_html(' '.join(badges)) - company_types_display.short_description = 'Types' - - def status_indicator(self, obj): - """Visual status indicator.""" - if obj.closed_date: - return format_html( - ' Closed' - ) - return format_html( - ' Active' - ) - status_indicator.short_description = 'Status' - - actions = ['export_admin_action'] - - -@admin.register(RideModel) -class RideModelAdmin(ModelAdmin, ImportExportModelAdmin): - """Enhanced admin interface for RideModel model.""" - - resource_class = RideModelResource - import_form_class = ImportForm - export_form_class = ExportForm - - list_display = [ - 'name_with_type', - 'manufacturer', - 'model_type', - 'typical_specs', - 'installation_count', - 'created' - ] - list_filter = [ - ('model_type', ChoicesDropdownFilter), - ('manufacturer', RelatedDropdownFilter), - ('typical_height', RangeNumericFilter), - ('typical_speed', RangeNumericFilter), - ] - search_fields = ['name', 'slug', 'description', 'manufacturer__name'] - readonly_fields = ['id', 'created', 'modified', 'installation_count', 'slug'] - prepopulated_fields = {} - autocomplete_fields = ['manufacturer'] - inlines = [RideModelInstallationsInline, PhotoInline] - - list_per_page = 50 - - fieldsets = ( - ('Basic Information', { - 'fields': ('name', 'slug', 'description', 'manufacturer', 'model_type') - }), - ('Typical Specifications', { - 'fields': ( - 'typical_height', 'typical_speed', 'typical_capacity' - ), - 'description': 'Standard specifications for this ride model' - }), - ('Media', { - 'fields': ('image_id', 'image_url'), - 'classes': ['collapse'] - }), - ('Statistics', { - 'fields': ('installation_count',), - 'classes': ['collapse'] - }), - ('System Information', { - 'fields': ('id', 'created', 'modified'), - 'classes': ['collapse'] - }), - ) - - def name_with_type(self, obj): - """Display name with model type icon.""" - icons = { - 'roller_coaster': '🎢', - 'water_ride': '🌊', - 'flat_ride': '🎡', - 'dark_ride': '🎭', - 'transport': '🚂', - } - icon = icons.get(obj.model_type, '🎪') - return format_html('{} {}', icon, obj.name) - name_with_type.short_description = 'Model Name' - name_with_type.admin_order_field = 'name' - - def typical_specs(self, obj): - """Display typical specifications.""" - specs = [] - if obj.typical_height: - specs.append(f'H: {obj.typical_height}m') - if obj.typical_speed: - specs.append(f'S: {obj.typical_speed}km/h') - if obj.typical_capacity: - specs.append(f'C: {obj.typical_capacity}') - return ' | '.join(specs) if specs else '-' - typical_specs.short_description = 'Typical Specs' - - actions = ['export_admin_action'] - - -@admin.register(Park) -class ParkAdmin(ModelAdmin, ImportExportModelAdmin): - """Enhanced admin interface for Park model with geographic features.""" - - resource_class = ParkResource - import_form_class = ImportForm - export_form_class = ExportForm - - list_display = [ - 'name_with_icon', - 'location_display', - 'park_type', - 'status_badge', - 'ride_count', - 'coaster_count', - 'opening_date', - 'operator' - ] - list_filter = [ - ('park_type', ChoicesDropdownFilter), - ('status', ChoicesDropdownFilter), - ('operator', RelatedDropdownFilter), - ('opening_date', RangeDateFilter), - ('closing_date', RangeDateFilter), - ] - search_fields = ['name', 'slug', 'description', 'location'] - readonly_fields = [ - 'id', 'created', 'modified', 'ride_count', 'coaster_count', - 'slug', 'coordinates_display' - ] - prepopulated_fields = {} - autocomplete_fields = ['operator'] - inlines = [RideInline, PhotoInline] - - list_per_page = 50 - - # Use GeoDjango admin for PostGIS mode - if hasattr(settings, 'DATABASES') and 'postgis' in settings.DATABASES['default'].get('ENGINE', ''): - change_form_template = 'gis/admin/change_form.html' - - fieldsets = ( - ('Basic Information', { - 'fields': ('name', 'slug', 'description', 'park_type', 'status') - }), - ('Geographic Location', { - 'fields': ('location', 'latitude', 'longitude', 'coordinates_display'), - 'description': 'Enter latitude and longitude for the park location' - }), - ('Dates', { - 'fields': ( - 'opening_date', 'opening_date_precision', - 'closing_date', 'closing_date_precision' - ) - }), - ('Operator', { - 'fields': ('operator',) - }), - ('Media & Web', { - 'fields': ( - 'banner_image_id', 'banner_image_url', - 'logo_image_id', 'logo_image_url', - 'website' - ), - 'classes': ['collapse'] - }), - ('Statistics', { - 'fields': ('ride_count', 'coaster_count'), - 'classes': ['collapse'] - }), - ('Custom Data', { - 'fields': ('custom_fields',), - 'classes': ['collapse'], - 'description': 'Additional custom data in JSON format' - }), - ('System Information', { - 'fields': ('id', 'created', 'modified'), - 'classes': ['collapse'] - }), - ) - - def name_with_icon(self, obj): - """Display name with park type icon.""" - icons = { - 'theme_park': '🎡', - 'amusement_park': '🎢', - 'water_park': '🌊', - 'indoor_park': '🏢', - 'fairground': '🎪', - } - icon = icons.get(obj.park_type, '🎠') - return format_html('{} {}', icon, obj.name) - name_with_icon.short_description = 'Park Name' - name_with_icon.admin_order_field = 'name' - - def location_display(self, obj): - """Display location with coordinates.""" - if obj.location: - coords = obj.coordinates - if coords: - return format_html( - '{}
({:.4f}, {:.4f})', - obj.location, coords[0], coords[1] - ) - return obj.location - return '-' - location_display.short_description = 'Location' - - def coordinates_display(self, obj): - """Read-only display of coordinates.""" - coords = obj.coordinates - if coords: - return f"Longitude: {coords[0]:.6f}, Latitude: {coords[1]:.6f}" - return "No coordinates set" - coordinates_display.short_description = 'Current Coordinates' - - def status_badge(self, obj): - """Display status as colored badge.""" - colors = { - 'operating': 'green', - 'closed_temporarily': 'orange', - 'closed_permanently': 'red', - 'under_construction': 'blue', - 'planned': 'purple', - } - color = colors.get(obj.status, 'gray') - return format_html( - '' - '{}', - color, obj.get_status_display() - ) - status_badge.short_description = 'Status' - status_badge.admin_order_field = 'status' - - actions = ['export_admin_action', 'activate_parks', 'close_parks'] - - def activate_parks(self, request, queryset): - """Bulk action to activate parks.""" - updated = queryset.update(status='operating') - self.message_user(request, f'{updated} park(s) marked as operating.') - activate_parks.short_description = 'Mark selected parks as operating' - - def close_parks(self, request, queryset): - """Bulk action to close parks temporarily.""" - updated = queryset.update(status='closed_temporarily') - self.message_user(request, f'{updated} park(s) marked as temporarily closed.') - close_parks.short_description = 'Mark selected parks as temporarily closed' - - -@admin.register(Ride) -class RideAdmin(ModelAdmin, ImportExportModelAdmin): - """Enhanced admin interface for Ride model.""" - - resource_class = RideResource - import_form_class = ImportForm - export_form_class = ExportForm - - list_display = [ - 'name_with_icon', - 'park', - 'ride_category', - 'status_badge', - 'manufacturer', - 'stats_display', - 'opening_date', - 'coaster_badge' - ] - list_filter = [ - ('ride_category', ChoicesDropdownFilter), - ('status', ChoicesDropdownFilter), - ('is_coaster', admin.BooleanFieldListFilter), - ('park', RelatedDropdownFilter), - ('manufacturer', RelatedDropdownFilter), - ('opening_date', RangeDateFilter), - ('height', RangeNumericFilter), - ('speed', RangeNumericFilter), - ] - search_fields = [ - 'name', 'slug', 'description', - 'park__name', 'manufacturer__name' - ] - readonly_fields = ['id', 'created', 'modified', 'is_coaster', 'slug'] - prepopulated_fields = {} - autocomplete_fields = ['park', 'manufacturer', 'model'] - inlines = [PhotoInline] - - list_per_page = 50 - - fieldsets = ( - ('Basic Information', { - 'fields': ('name', 'slug', 'description', 'park') - }), - ('Classification', { - 'fields': ('ride_category', 'ride_type', 'is_coaster', 'status') - }), - ('Dates', { - 'fields': ( - 'opening_date', 'opening_date_precision', - 'closing_date', 'closing_date_precision' - ) - }), - ('Manufacturer & Model', { - 'fields': ('manufacturer', 'model') - }), - ('Ride Statistics', { - 'fields': ( - 'height', 'speed', 'length', - 'duration', 'inversions', 'capacity' - ), - 'description': 'Technical specifications and statistics' - }), - ('Media', { - 'fields': ('image_id', 'image_url'), - 'classes': ['collapse'] - }), - ('Custom Data', { - 'fields': ('custom_fields',), - 'classes': ['collapse'] - }), - ('System Information', { - 'fields': ('id', 'created', 'modified'), - 'classes': ['collapse'] - }), - ) - - def name_with_icon(self, obj): - """Display name with category icon.""" - icons = { - 'roller_coaster': '🎢', - 'water_ride': '🌊', - 'dark_ride': '🎭', - 'flat_ride': '🎡', - 'transport': '🚂', - 'show': '🎪', - } - icon = icons.get(obj.ride_category, '🎠') - return format_html('{} {}', icon, obj.name) - name_with_icon.short_description = 'Ride Name' - name_with_icon.admin_order_field = 'name' - - def stats_display(self, obj): - """Display key statistics.""" - stats = [] - if obj.height: - stats.append(f'H: {obj.height}m') - if obj.speed: - stats.append(f'S: {obj.speed}km/h') - if obj.inversions: - stats.append(f'🔄 {obj.inversions}') - return ' | '.join(stats) if stats else '-' - stats_display.short_description = 'Key Stats' - - def coaster_badge(self, obj): - """Display coaster indicator.""" - if obj.is_coaster: - return format_html( - '' - '🎢 COASTER' - ) - return '' - coaster_badge.short_description = 'Type' - - def status_badge(self, obj): - """Display status as colored badge.""" - colors = { - 'operating': 'green', - 'closed_temporarily': 'orange', - 'closed_permanently': 'red', - 'under_construction': 'blue', - 'sbno': 'gray', - } - color = colors.get(obj.status, 'gray') - return format_html( - '' - '{}', - color, obj.get_status_display() - ) - status_badge.short_description = 'Status' - status_badge.admin_order_field = 'status' - - actions = ['export_admin_action', 'activate_rides', 'close_rides'] - - def activate_rides(self, request, queryset): - """Bulk action to activate rides.""" - updated = queryset.update(status='operating') - self.message_user(request, f'{updated} ride(s) marked as operating.') - activate_rides.short_description = 'Mark selected rides as operating' - - def close_rides(self, request, queryset): - """Bulk action to close rides temporarily.""" - updated = queryset.update(status='closed_temporarily') - self.message_user(request, f'{updated} ride(s) marked as temporarily closed.') - close_rides.short_description = 'Mark selected rides as temporarily closed' - - -# ============================================================================ -# DASHBOARD CALLBACK -# ============================================================================ - -def dashboard_callback(request, context): - """ - Callback function for Unfold dashboard. - Provides statistics and overview data. - """ - # Entity counts - total_parks = Park.objects.count() - total_rides = Ride.objects.count() - total_companies = Company.objects.count() - total_models = RideModel.objects.count() - - # Operating counts - operating_parks = Park.objects.filter(status='operating').count() - operating_rides = Ride.objects.filter(status='operating').count() - - # Coaster count - total_coasters = Ride.objects.filter(is_coaster=True).count() - - # Recent additions (last 30 days) - from django.utils import timezone - from datetime import timedelta - thirty_days_ago = timezone.now() - timedelta(days=30) - - recent_parks = Park.objects.filter(created__gte=thirty_days_ago).count() - recent_rides = Ride.objects.filter(created__gte=thirty_days_ago).count() - - # Top manufacturers by ride count - top_manufacturers = Company.objects.filter( - company_types__contains=['manufacturer'] - ).annotate( - ride_count_actual=Count('manufactured_rides') - ).order_by('-ride_count_actual')[:5] - - # Parks by type - parks_by_type = Park.objects.values('park_type').annotate( - count=Count('id') - ).order_by('-count') - - context.update({ - 'total_parks': total_parks, - 'total_rides': total_rides, - 'total_companies': total_companies, - 'total_models': total_models, - 'operating_parks': operating_parks, - 'operating_rides': operating_rides, - 'total_coasters': total_coasters, - 'recent_parks': recent_parks, - 'recent_rides': recent_rides, - 'top_manufacturers': top_manufacturers, - 'parks_by_type': parks_by_type, - }) - - return context diff --git a/django/apps/entities/apps.py b/django/apps/entities/apps.py deleted file mode 100644 index 68234afe..00000000 --- a/django/apps/entities/apps.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -Entities app configuration. -""" - -from django.apps import AppConfig - - -class EntitiesConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.entities' - verbose_name = 'Entities' - - def ready(self): - """Import signal handlers when app is ready.""" - import apps.entities.signals # noqa diff --git a/django/apps/entities/filters.py b/django/apps/entities/filters.py deleted file mode 100644 index 74056f13..00000000 --- a/django/apps/entities/filters.py +++ /dev/null @@ -1,418 +0,0 @@ -""" -Filter classes for advanced entity filtering. - -Provides reusable filter logic for complex queries. -""" -from typing import Optional, Any, Dict -from datetime import date -from django.db.models import QuerySet, Q -from django.conf import settings - - -# Check if using PostGIS for location-based filtering -_using_postgis = 'postgis' in settings.DATABASES['default']['ENGINE'] - -if _using_postgis: - from django.contrib.gis.geos import Point - from django.contrib.gis.measure import D - - -class BaseEntityFilter: - """Base filter class with common filtering methods.""" - - @staticmethod - def filter_by_date_range( - queryset: QuerySet, - field_name: str, - start_date: Optional[date] = None, - end_date: Optional[date] = None - ) -> QuerySet: - """ - Filter by date range. - - Args: - queryset: Base queryset to filter - field_name: Name of the date field - start_date: Start of date range (inclusive) - end_date: End of date range (inclusive) - - Returns: - Filtered queryset - """ - if start_date: - queryset = queryset.filter(**{f"{field_name}__gte": start_date}) - - if end_date: - queryset = queryset.filter(**{f"{field_name}__lte": end_date}) - - return queryset - - @staticmethod - def filter_by_status( - queryset: QuerySet, - status: Optional[str] = None, - exclude_status: Optional[list] = None - ) -> QuerySet: - """ - Filter by status. - - Args: - queryset: Base queryset to filter - status: Single status to filter by - exclude_status: List of statuses to exclude - - Returns: - Filtered queryset - """ - if status: - queryset = queryset.filter(status=status) - - if exclude_status: - queryset = queryset.exclude(status__in=exclude_status) - - return queryset - - -class CompanyFilter(BaseEntityFilter): - """Filter class for Company entities.""" - - @staticmethod - def filter_by_types( - queryset: QuerySet, - company_types: Optional[list] = None - ) -> QuerySet: - """ - Filter companies by type. - - Args: - queryset: Base queryset to filter - company_types: List of company types to filter by - - Returns: - Filtered queryset - """ - if company_types: - # Since company_types is a JSONField containing a list, - # we need to check if any of the requested types are in the field - q = Q() - for company_type in company_types: - q |= Q(company_types__contains=[company_type]) - queryset = queryset.filter(q) - - return queryset - - @staticmethod - def apply_filters( - queryset: QuerySet, - filters: Dict[str, Any] - ) -> QuerySet: - """ - Apply all company filters. - - Args: - queryset: Base queryset to filter - filters: Dictionary of filter parameters - - Returns: - Filtered queryset - """ - # Company types - if filters.get('company_types'): - queryset = CompanyFilter.filter_by_types( - queryset, - company_types=filters['company_types'] - ) - - # Founded date range - queryset = CompanyFilter.filter_by_date_range( - queryset, - 'founded_date', - start_date=filters.get('founded_after'), - end_date=filters.get('founded_before') - ) - - # Closed date range - queryset = CompanyFilter.filter_by_date_range( - queryset, - 'closed_date', - start_date=filters.get('closed_after'), - end_date=filters.get('closed_before') - ) - - # Location - if filters.get('location_id'): - queryset = queryset.filter(location_id=filters['location_id']) - - return queryset - - -class RideModelFilter(BaseEntityFilter): - """Filter class for RideModel entities.""" - - @staticmethod - def apply_filters( - queryset: QuerySet, - filters: Dict[str, Any] - ) -> QuerySet: - """ - Apply all ride model filters. - - Args: - queryset: Base queryset to filter - filters: Dictionary of filter parameters - - Returns: - Filtered queryset - """ - # Manufacturer - if filters.get('manufacturer_id'): - queryset = queryset.filter(manufacturer_id=filters['manufacturer_id']) - - # Model type - if filters.get('model_type'): - queryset = queryset.filter(model_type=filters['model_type']) - - # Height range - if filters.get('min_height'): - queryset = queryset.filter(typical_height__gte=filters['min_height']) - - if filters.get('max_height'): - queryset = queryset.filter(typical_height__lte=filters['max_height']) - - # Speed range - if filters.get('min_speed'): - queryset = queryset.filter(typical_speed__gte=filters['min_speed']) - - if filters.get('max_speed'): - queryset = queryset.filter(typical_speed__lte=filters['max_speed']) - - return queryset - - -class ParkFilter(BaseEntityFilter): - """Filter class for Park entities.""" - - @staticmethod - def filter_by_location( - queryset: QuerySet, - longitude: float, - latitude: float, - radius_km: float - ) -> QuerySet: - """ - Filter parks by proximity to a location (PostGIS only). - - Args: - queryset: Base queryset to filter - longitude: Longitude coordinate - latitude: Latitude coordinate - radius_km: Search radius in kilometers - - Returns: - Filtered queryset ordered by distance - """ - if not _using_postgis: - # Fallback: No spatial filtering in SQLite - return queryset - - point = Point(longitude, latitude, srid=4326) - - # Filter by distance and annotate with distance - queryset = queryset.filter( - location_point__distance_lte=(point, D(km=radius_km)) - ) - - # This will be ordered by distance in the search service - return queryset - - @staticmethod - def apply_filters( - queryset: QuerySet, - filters: Dict[str, Any] - ) -> QuerySet: - """ - Apply all park filters. - - Args: - queryset: Base queryset to filter - filters: Dictionary of filter parameters - - Returns: - Filtered queryset - """ - # Status - queryset = ParkFilter.filter_by_status( - queryset, - status=filters.get('status'), - exclude_status=filters.get('exclude_status') - ) - - # Park type - if filters.get('park_type'): - queryset = queryset.filter(park_type=filters['park_type']) - - # Operator - if filters.get('operator_id'): - queryset = queryset.filter(operator_id=filters['operator_id']) - - # Opening date range - queryset = ParkFilter.filter_by_date_range( - queryset, - 'opening_date', - start_date=filters.get('opening_after'), - end_date=filters.get('opening_before') - ) - - # Closing date range - queryset = ParkFilter.filter_by_date_range( - queryset, - 'closing_date', - start_date=filters.get('closing_after'), - end_date=filters.get('closing_before') - ) - - # Location-based filtering (PostGIS only) - if _using_postgis and filters.get('location') and filters.get('radius'): - longitude, latitude = filters['location'] - queryset = ParkFilter.filter_by_location( - queryset, - longitude=longitude, - latitude=latitude, - radius_km=filters['radius'] - ) - - # Location (locality) - if filters.get('location_id'): - queryset = queryset.filter(location_id=filters['location_id']) - - # Ride counts - if filters.get('min_ride_count'): - queryset = queryset.filter(ride_count__gte=filters['min_ride_count']) - - if filters.get('min_coaster_count'): - queryset = queryset.filter(coaster_count__gte=filters['min_coaster_count']) - - return queryset - - -class RideFilter(BaseEntityFilter): - """Filter class for Ride entities.""" - - @staticmethod - def filter_by_statistics( - queryset: QuerySet, - filters: Dict[str, Any] - ) -> QuerySet: - """ - Filter rides by statistical attributes (height, speed, length, etc.). - - Args: - queryset: Base queryset to filter - filters: Dictionary of filter parameters - - Returns: - Filtered queryset - """ - # Height range - if filters.get('min_height'): - queryset = queryset.filter(height__gte=filters['min_height']) - - if filters.get('max_height'): - queryset = queryset.filter(height__lte=filters['max_height']) - - # Speed range - if filters.get('min_speed'): - queryset = queryset.filter(speed__gte=filters['min_speed']) - - if filters.get('max_speed'): - queryset = queryset.filter(speed__lte=filters['max_speed']) - - # Length range - if filters.get('min_length'): - queryset = queryset.filter(length__gte=filters['min_length']) - - if filters.get('max_length'): - queryset = queryset.filter(length__lte=filters['max_length']) - - # Duration range - if filters.get('min_duration'): - queryset = queryset.filter(duration__gte=filters['min_duration']) - - if filters.get('max_duration'): - queryset = queryset.filter(duration__lte=filters['max_duration']) - - # Inversions - if filters.get('min_inversions') is not None: - queryset = queryset.filter(inversions__gte=filters['min_inversions']) - - if filters.get('max_inversions') is not None: - queryset = queryset.filter(inversions__lte=filters['max_inversions']) - - return queryset - - @staticmethod - def apply_filters( - queryset: QuerySet, - filters: Dict[str, Any] - ) -> QuerySet: - """ - Apply all ride filters. - - Args: - queryset: Base queryset to filter - filters: Dictionary of filter parameters - - Returns: - Filtered queryset - """ - # Park - if filters.get('park_id'): - queryset = queryset.filter(park_id=filters['park_id']) - - # Manufacturer - if filters.get('manufacturer_id'): - queryset = queryset.filter(manufacturer_id=filters['manufacturer_id']) - - # Model - if filters.get('model_id'): - queryset = queryset.filter(model_id=filters['model_id']) - - # Status - queryset = RideFilter.filter_by_status( - queryset, - status=filters.get('status'), - exclude_status=filters.get('exclude_status') - ) - - # Ride category - if filters.get('ride_category'): - queryset = queryset.filter(ride_category=filters['ride_category']) - - # Ride type - if filters.get('ride_type'): - queryset = queryset.filter(ride_type__icontains=filters['ride_type']) - - # Is coaster - if filters.get('is_coaster') is not None: - queryset = queryset.filter(is_coaster=filters['is_coaster']) - - # Opening date range - queryset = RideFilter.filter_by_date_range( - queryset, - 'opening_date', - start_date=filters.get('opening_after'), - end_date=filters.get('opening_before') - ) - - # Closing date range - queryset = RideFilter.filter_by_date_range( - queryset, - 'closing_date', - start_date=filters.get('closing_after'), - end_date=filters.get('closing_before') - ) - - # Statistical filters - queryset = RideFilter.filter_by_statistics(queryset, filters) - - return queryset diff --git a/django/apps/entities/migrations/0001_initial.py b/django/apps/entities/migrations/0001_initial.py deleted file mode 100644 index 1ad70e03..00000000 --- a/django/apps/entities/migrations/0001_initial.py +++ /dev/null @@ -1,846 +0,0 @@ -# Generated by Django 4.2.8 on 2025-11-08 16:41 - -import dirtyfields.dirtyfields -from django.db import migrations, models -import django.db.models.deletion -import django.utils.timezone -import django_lifecycle.mixins -import model_utils.fields -import uuid - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("core", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="Company", - fields=[ - ( - "created", - model_utils.fields.AutoCreatedField( - default=django.utils.timezone.now, - editable=False, - verbose_name="created", - ), - ), - ( - "modified", - model_utils.fields.AutoLastModifiedField( - default=django.utils.timezone.now, - editable=False, - verbose_name="modified", - ), - ), - ( - "id", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ( - "name", - models.CharField( - db_index=True, - help_text="Official company name", - max_length=255, - unique=True, - ), - ), - ( - "slug", - models.SlugField( - help_text="URL-friendly identifier", max_length=255, unique=True - ), - ), - ( - "description", - models.TextField( - blank=True, help_text="Company description and history" - ), - ), - ( - "company_types", - models.JSONField( - default=list, - help_text="List of company types (manufacturer, operator, etc.)", - ), - ), - ( - "founded_date", - models.DateField( - blank=True, help_text="Company founding date", null=True - ), - ), - ( - "founded_date_precision", - models.CharField( - choices=[("year", "Year"), ("month", "Month"), ("day", "Day")], - default="day", - help_text="Precision of founded date", - max_length=20, - ), - ), - ( - "closed_date", - models.DateField( - blank=True, - help_text="Company closure date (if applicable)", - null=True, - ), - ), - ( - "closed_date_precision", - models.CharField( - choices=[("year", "Year"), ("month", "Month"), ("day", "Day")], - default="day", - help_text="Precision of closed date", - max_length=20, - ), - ), - ( - "website", - models.URLField(blank=True, help_text="Official company website"), - ), - ( - "logo_image_id", - models.CharField( - blank=True, - help_text="CloudFlare image ID for company logo", - max_length=255, - ), - ), - ( - "logo_image_url", - models.URLField( - blank=True, help_text="CloudFlare image URL for company logo" - ), - ), - ( - "park_count", - models.IntegerField( - default=0, help_text="Number of parks operated (for operators)" - ), - ), - ( - "ride_count", - models.IntegerField( - default=0, - help_text="Number of rides manufactured (for manufacturers)", - ), - ), - ( - "location", - models.ForeignKey( - blank=True, - help_text="Company headquarters location", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="companies", - to="core.locality", - ), - ), - ], - options={ - "verbose_name": "Company", - "verbose_name_plural": "Companies", - "ordering": ["name"], - }, - bases=( - dirtyfields.dirtyfields.DirtyFieldsMixin, - django_lifecycle.mixins.LifecycleModelMixin, - models.Model, - ), - ), - migrations.CreateModel( - name="Park", - fields=[ - ( - "created", - model_utils.fields.AutoCreatedField( - default=django.utils.timezone.now, - editable=False, - verbose_name="created", - ), - ), - ( - "modified", - model_utils.fields.AutoLastModifiedField( - default=django.utils.timezone.now, - editable=False, - verbose_name="modified", - ), - ), - ( - "id", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ( - "name", - models.CharField( - db_index=True, help_text="Official park name", max_length=255 - ), - ), - ( - "slug", - models.SlugField( - help_text="URL-friendly identifier", max_length=255, unique=True - ), - ), - ( - "description", - models.TextField( - blank=True, help_text="Park description and history" - ), - ), - ( - "park_type", - models.CharField( - choices=[ - ("theme_park", "Theme Park"), - ("amusement_park", "Amusement Park"), - ("water_park", "Water Park"), - ( - "family_entertainment_center", - "Family Entertainment Center", - ), - ("traveling_park", "Traveling Park"), - ("zoo", "Zoo"), - ("aquarium", "Aquarium"), - ], - db_index=True, - help_text="Type of park", - max_length=50, - ), - ), - ( - "status", - models.CharField( - choices=[ - ("operating", "Operating"), - ("closed", "Closed"), - ("sbno", "Standing But Not Operating"), - ("under_construction", "Under Construction"), - ("planned", "Planned"), - ], - db_index=True, - default="operating", - help_text="Current operational status", - max_length=50, - ), - ), - ( - "opening_date", - models.DateField( - blank=True, - db_index=True, - help_text="Park opening date", - null=True, - ), - ), - ( - "opening_date_precision", - models.CharField( - choices=[("year", "Year"), ("month", "Month"), ("day", "Day")], - default="day", - help_text="Precision of opening date", - max_length=20, - ), - ), - ( - "closing_date", - models.DateField( - blank=True, help_text="Park closing date (if closed)", null=True - ), - ), - ( - "closing_date_precision", - models.CharField( - choices=[("year", "Year"), ("month", "Month"), ("day", "Day")], - default="day", - help_text="Precision of closing date", - max_length=20, - ), - ), - ( - "latitude", - models.DecimalField( - blank=True, - decimal_places=7, - help_text="Latitude coordinate", - max_digits=10, - null=True, - ), - ), - ( - "longitude", - models.DecimalField( - blank=True, - decimal_places=7, - help_text="Longitude coordinate", - max_digits=10, - null=True, - ), - ), - ( - "website", - models.URLField(blank=True, help_text="Official park website"), - ), - ( - "banner_image_id", - models.CharField( - blank=True, - help_text="CloudFlare image ID for park banner", - max_length=255, - ), - ), - ( - "banner_image_url", - models.URLField( - blank=True, help_text="CloudFlare image URL for park banner" - ), - ), - ( - "logo_image_id", - models.CharField( - blank=True, - help_text="CloudFlare image ID for park logo", - max_length=255, - ), - ), - ( - "logo_image_url", - models.URLField( - blank=True, help_text="CloudFlare image URL for park logo" - ), - ), - ( - "ride_count", - models.IntegerField(default=0, help_text="Total number of rides"), - ), - ( - "coaster_count", - models.IntegerField( - default=0, help_text="Number of roller coasters" - ), - ), - ( - "custom_fields", - models.JSONField( - blank=True, - default=dict, - help_text="Additional park-specific data", - ), - ), - ( - "location", - models.ForeignKey( - blank=True, - help_text="Park location", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="parks", - to="core.locality", - ), - ), - ( - "operator", - models.ForeignKey( - blank=True, - help_text="Current park operator", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="operated_parks", - to="entities.company", - ), - ), - ], - options={ - "verbose_name": "Park", - "verbose_name_plural": "Parks", - "ordering": ["name"], - }, - bases=( - dirtyfields.dirtyfields.DirtyFieldsMixin, - django_lifecycle.mixins.LifecycleModelMixin, - models.Model, - ), - ), - migrations.CreateModel( - name="RideModel", - fields=[ - ( - "created", - model_utils.fields.AutoCreatedField( - default=django.utils.timezone.now, - editable=False, - verbose_name="created", - ), - ), - ( - "modified", - model_utils.fields.AutoLastModifiedField( - default=django.utils.timezone.now, - editable=False, - verbose_name="modified", - ), - ), - ( - "id", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ( - "name", - models.CharField( - db_index=True, - help_text="Model name (e.g., 'Inverted Coaster', 'Boomerang')", - max_length=255, - ), - ), - ( - "slug", - models.SlugField( - help_text="URL-friendly identifier", max_length=255, unique=True - ), - ), - ( - "description", - models.TextField( - blank=True, help_text="Model description and technical details" - ), - ), - ( - "model_type", - models.CharField( - choices=[ - ("coaster_model", "Roller Coaster Model"), - ("flat_ride_model", "Flat Ride Model"), - ("water_ride_model", "Water Ride Model"), - ("dark_ride_model", "Dark Ride Model"), - ("transport_ride_model", "Transport Ride Model"), - ], - db_index=True, - help_text="Type of ride model", - max_length=50, - ), - ), - ( - "typical_height", - models.DecimalField( - blank=True, - decimal_places=1, - help_text="Typical height in feet", - max_digits=6, - null=True, - ), - ), - ( - "typical_speed", - models.DecimalField( - blank=True, - decimal_places=1, - help_text="Typical speed in mph", - max_digits=6, - null=True, - ), - ), - ( - "typical_capacity", - models.IntegerField( - blank=True, help_text="Typical hourly capacity", null=True - ), - ), - ( - "image_id", - models.CharField( - blank=True, help_text="CloudFlare image ID", max_length=255 - ), - ), - ( - "image_url", - models.URLField(blank=True, help_text="CloudFlare image URL"), - ), - ( - "installation_count", - models.IntegerField( - default=0, help_text="Number of installations worldwide" - ), - ), - ( - "manufacturer", - models.ForeignKey( - help_text="Manufacturer of this ride model", - on_delete=django.db.models.deletion.CASCADE, - related_name="ride_models", - to="entities.company", - ), - ), - ], - options={ - "verbose_name": "Ride Model", - "verbose_name_plural": "Ride Models", - "ordering": ["manufacturer__name", "name"], - }, - bases=( - dirtyfields.dirtyfields.DirtyFieldsMixin, - django_lifecycle.mixins.LifecycleModelMixin, - models.Model, - ), - ), - migrations.CreateModel( - name="Ride", - fields=[ - ( - "created", - model_utils.fields.AutoCreatedField( - default=django.utils.timezone.now, - editable=False, - verbose_name="created", - ), - ), - ( - "modified", - model_utils.fields.AutoLastModifiedField( - default=django.utils.timezone.now, - editable=False, - verbose_name="modified", - ), - ), - ( - "id", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ( - "name", - models.CharField( - db_index=True, help_text="Ride name", max_length=255 - ), - ), - ( - "slug", - models.SlugField( - help_text="URL-friendly identifier", max_length=255, unique=True - ), - ), - ( - "description", - models.TextField( - blank=True, help_text="Ride description and history" - ), - ), - ( - "ride_category", - models.CharField( - choices=[ - ("roller_coaster", "Roller Coaster"), - ("flat_ride", "Flat Ride"), - ("water_ride", "Water Ride"), - ("dark_ride", "Dark Ride"), - ("transport_ride", "Transport Ride"), - ("other", "Other"), - ], - db_index=True, - help_text="Broad ride category", - max_length=50, - ), - ), - ( - "ride_type", - models.CharField( - blank=True, - db_index=True, - help_text="Specific ride type (e.g., 'Inverted Coaster', 'Drop Tower')", - max_length=100, - ), - ), - ( - "is_coaster", - models.BooleanField( - db_index=True, - default=False, - help_text="Is this ride a roller coaster?", - ), - ), - ( - "status", - models.CharField( - choices=[ - ("operating", "Operating"), - ("closed", "Closed"), - ("sbno", "Standing But Not Operating"), - ("relocated", "Relocated"), - ("under_construction", "Under Construction"), - ("planned", "Planned"), - ], - db_index=True, - default="operating", - help_text="Current operational status", - max_length=50, - ), - ), - ( - "opening_date", - models.DateField( - blank=True, - db_index=True, - help_text="Ride opening date", - null=True, - ), - ), - ( - "opening_date_precision", - models.CharField( - choices=[("year", "Year"), ("month", "Month"), ("day", "Day")], - default="day", - help_text="Precision of opening date", - max_length=20, - ), - ), - ( - "closing_date", - models.DateField( - blank=True, help_text="Ride closing date (if closed)", null=True - ), - ), - ( - "closing_date_precision", - models.CharField( - choices=[("year", "Year"), ("month", "Month"), ("day", "Day")], - default="day", - help_text="Precision of closing date", - max_length=20, - ), - ), - ( - "height", - models.DecimalField( - blank=True, - decimal_places=1, - help_text="Height in feet", - max_digits=6, - null=True, - ), - ), - ( - "speed", - models.DecimalField( - blank=True, - decimal_places=1, - help_text="Top speed in mph", - max_digits=6, - null=True, - ), - ), - ( - "length", - models.DecimalField( - blank=True, - decimal_places=1, - help_text="Track/ride length in feet", - max_digits=8, - null=True, - ), - ), - ( - "duration", - models.IntegerField( - blank=True, help_text="Ride duration in seconds", null=True - ), - ), - ( - "inversions", - models.IntegerField( - blank=True, - help_text="Number of inversions (for coasters)", - null=True, - ), - ), - ( - "capacity", - models.IntegerField( - blank=True, - help_text="Hourly capacity (riders per hour)", - null=True, - ), - ), - ( - "image_id", - models.CharField( - blank=True, - help_text="CloudFlare image ID for main photo", - max_length=255, - ), - ), - ( - "image_url", - models.URLField( - blank=True, help_text="CloudFlare image URL for main photo" - ), - ), - ( - "custom_fields", - models.JSONField( - blank=True, - default=dict, - help_text="Additional ride-specific data", - ), - ), - ( - "manufacturer", - models.ForeignKey( - blank=True, - help_text="Ride manufacturer", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="manufactured_rides", - to="entities.company", - ), - ), - ( - "model", - models.ForeignKey( - blank=True, - help_text="Specific ride model", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="rides", - to="entities.ridemodel", - ), - ), - ( - "park", - models.ForeignKey( - help_text="Park where ride is located", - on_delete=django.db.models.deletion.CASCADE, - related_name="rides", - to="entities.park", - ), - ), - ], - options={ - "verbose_name": "Ride", - "verbose_name_plural": "Rides", - "ordering": ["park__name", "name"], - }, - bases=( - dirtyfields.dirtyfields.DirtyFieldsMixin, - django_lifecycle.mixins.LifecycleModelMixin, - models.Model, - ), - ), - migrations.AddIndex( - model_name="ridemodel", - index=models.Index( - fields=["manufacturer", "name"], name="entities_ri_manufac_1fe3c1_idx" - ), - ), - migrations.AddIndex( - model_name="ridemodel", - index=models.Index( - fields=["model_type"], name="entities_ri_model_t_610d23_idx" - ), - ), - migrations.AlterUniqueTogether( - name="ridemodel", - unique_together={("manufacturer", "name")}, - ), - migrations.AddIndex( - model_name="ride", - index=models.Index( - fields=["park", "name"], name="entities_ri_park_id_e73e3b_idx" - ), - ), - migrations.AddIndex( - model_name="ride", - index=models.Index(fields=["slug"], name="entities_ri_slug_d2d6bb_idx"), - ), - migrations.AddIndex( - model_name="ride", - index=models.Index(fields=["status"], name="entities_ri_status_b69114_idx"), - ), - migrations.AddIndex( - model_name="ride", - index=models.Index( - fields=["is_coaster"], name="entities_ri_is_coas_912a4d_idx" - ), - ), - migrations.AddIndex( - model_name="ride", - index=models.Index( - fields=["ride_category"], name="entities_ri_ride_ca_bc4554_idx" - ), - ), - migrations.AddIndex( - model_name="ride", - index=models.Index( - fields=["opening_date"], name="entities_ri_opening_c4fc53_idx" - ), - ), - migrations.AddIndex( - model_name="ride", - index=models.Index( - fields=["manufacturer"], name="entities_ri_manufac_0d9a25_idx" - ), - ), - migrations.AddIndex( - model_name="park", - index=models.Index(fields=["name"], name="entities_pa_name_f8a746_idx"), - ), - migrations.AddIndex( - model_name="park", - index=models.Index(fields=["slug"], name="entities_pa_slug_a21c73_idx"), - ), - migrations.AddIndex( - model_name="park", - index=models.Index(fields=["status"], name="entities_pa_status_805296_idx"), - ), - migrations.AddIndex( - model_name="park", - index=models.Index( - fields=["park_type"], name="entities_pa_park_ty_8eba41_idx" - ), - ), - migrations.AddIndex( - model_name="park", - index=models.Index( - fields=["opening_date"], name="entities_pa_opening_102a60_idx" - ), - ), - migrations.AddIndex( - model_name="park", - index=models.Index( - fields=["location"], name="entities_pa_locatio_20a884_idx" - ), - ), - migrations.AddIndex( - model_name="company", - index=models.Index(fields=["name"], name="entities_co_name_d061e8_idx"), - ), - migrations.AddIndex( - model_name="company", - index=models.Index(fields=["slug"], name="entities_co_slug_00ae5c_idx"), - ), - ] diff --git a/django/apps/entities/migrations/0002_alter_park_latitude_alter_park_longitude.py b/django/apps/entities/migrations/0002_alter_park_latitude_alter_park_longitude.py deleted file mode 100644 index ab3177c2..00000000 --- a/django/apps/entities/migrations/0002_alter_park_latitude_alter_park_longitude.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 4.2.8 on 2025-11-08 17:03 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("entities", "0001_initial"), - ] - - operations = [ - migrations.AlterField( - model_name="park", - name="latitude", - field=models.DecimalField( - blank=True, - decimal_places=7, - help_text="Latitude coordinate. Primary in local dev, use location_point in production.", - max_digits=10, - null=True, - ), - ), - migrations.AlterField( - model_name="park", - name="longitude", - field=models.DecimalField( - blank=True, - decimal_places=7, - help_text="Longitude coordinate. Primary in local dev, use location_point in production.", - max_digits=10, - null=True, - ), - ), - ] diff --git a/django/apps/entities/migrations/0003_add_search_vector_gin_indexes.py b/django/apps/entities/migrations/0003_add_search_vector_gin_indexes.py deleted file mode 100644 index f9351ce4..00000000 --- a/django/apps/entities/migrations/0003_add_search_vector_gin_indexes.py +++ /dev/null @@ -1,141 +0,0 @@ -# Generated migration for Phase 2 - GIN Index Optimization -from django.db import migrations, connection -from django.contrib.postgres.indexes import GinIndex -from django.contrib.postgres.search import SearchVector - - -def is_postgresql(): - """Check if the database backend is PostgreSQL/PostGIS.""" - return 'postgis' in connection.vendor or 'postgresql' in connection.vendor - - -def populate_search_vectors(apps, schema_editor): - """Populate search_vector fields for all existing records.""" - if not is_postgresql(): - return - - # Get models - Company = apps.get_model('entities', 'Company') - RideModel = apps.get_model('entities', 'RideModel') - Park = apps.get_model('entities', 'Park') - Ride = apps.get_model('entities', 'Ride') - - # Update Company search vectors - Company.objects.update( - search_vector=( - SearchVector('name', weight='A') + - SearchVector('description', weight='B') - ) - ) - - # Update RideModel search vectors - RideModel.objects.update( - search_vector=( - SearchVector('name', weight='A') + - SearchVector('manufacturer__name', weight='A') + - SearchVector('description', weight='B') - ) - ) - - # Update Park search vectors - Park.objects.update( - search_vector=( - SearchVector('name', weight='A') + - SearchVector('description', weight='B') - ) - ) - - # Update Ride search vectors - Ride.objects.update( - search_vector=( - SearchVector('name', weight='A') + - SearchVector('park__name', weight='A') + - SearchVector('manufacturer__name', weight='B') + - SearchVector('description', weight='B') - ) - ) - - -def reverse_search_vectors(apps, schema_editor): - """Clear search_vector fields for all records.""" - if not is_postgresql(): - return - - # Get models - Company = apps.get_model('entities', 'Company') - RideModel = apps.get_model('entities', 'RideModel') - Park = apps.get_model('entities', 'Park') - Ride = apps.get_model('entities', 'Ride') - - # Clear all search vectors - Company.objects.update(search_vector=None) - RideModel.objects.update(search_vector=None) - Park.objects.update(search_vector=None) - Ride.objects.update(search_vector=None) - - -def add_gin_indexes(apps, schema_editor): - """Add GIN indexes on search_vector fields (PostgreSQL only).""" - if not is_postgresql(): - return - - # Use raw SQL to add GIN indexes - with schema_editor.connection.cursor() as cursor: - cursor.execute(""" - CREATE INDEX IF NOT EXISTS entities_company_search_idx - ON entities_company USING gin(search_vector); - """) - cursor.execute(""" - CREATE INDEX IF NOT EXISTS entities_ridemodel_search_idx - ON entities_ridemodel USING gin(search_vector); - """) - cursor.execute(""" - CREATE INDEX IF NOT EXISTS entities_park_search_idx - ON entities_park USING gin(search_vector); - """) - cursor.execute(""" - CREATE INDEX IF NOT EXISTS entities_ride_search_idx - ON entities_ride USING gin(search_vector); - """) - - -def remove_gin_indexes(apps, schema_editor): - """Remove GIN indexes (PostgreSQL only).""" - if not is_postgresql(): - return - - # Use raw SQL to drop GIN indexes - with schema_editor.connection.cursor() as cursor: - cursor.execute("DROP INDEX IF EXISTS entities_company_search_idx;") - cursor.execute("DROP INDEX IF EXISTS entities_ridemodel_search_idx;") - cursor.execute("DROP INDEX IF EXISTS entities_park_search_idx;") - cursor.execute("DROP INDEX IF EXISTS entities_ride_search_idx;") - - -class Migration(migrations.Migration): - """ - Phase 2 Migration: Add GIN indexes for search optimization. - - This migration: - 1. Adds GIN indexes on search_vector fields for optimal full-text search - 2. Populates search vectors for all existing database records - 3. Is PostgreSQL-specific and safe for SQLite environments - """ - - dependencies = [ - ('entities', '0002_alter_park_latitude_alter_park_longitude'), - ] - - operations = [ - # First, populate search vectors for existing records - migrations.RunPython( - populate_search_vectors, - reverse_search_vectors, - ), - - # Add GIN indexes for each model's search_vector field - migrations.RunPython( - add_gin_indexes, - remove_gin_indexes, - ), - ] diff --git a/django/apps/entities/migrations/__init__.py b/django/apps/entities/migrations/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/django/apps/entities/migrations/__pycache__/0001_initial.cpython-313.pyc b/django/apps/entities/migrations/__pycache__/0001_initial.cpython-313.pyc deleted file mode 100644 index 01eb3292..00000000 Binary files a/django/apps/entities/migrations/__pycache__/0001_initial.cpython-313.pyc and /dev/null differ diff --git a/django/apps/entities/migrations/__pycache__/0002_add_postgis_location.cpython-313.pyc b/django/apps/entities/migrations/__pycache__/0002_add_postgis_location.cpython-313.pyc deleted file mode 100644 index 47699b83..00000000 Binary files a/django/apps/entities/migrations/__pycache__/0002_add_postgis_location.cpython-313.pyc and /dev/null differ diff --git a/django/apps/entities/migrations/__pycache__/0002_alter_park_latitude_alter_park_longitude.cpython-313.pyc b/django/apps/entities/migrations/__pycache__/0002_alter_park_latitude_alter_park_longitude.cpython-313.pyc deleted file mode 100644 index 99e5fac6..00000000 Binary files a/django/apps/entities/migrations/__pycache__/0002_alter_park_latitude_alter_park_longitude.cpython-313.pyc and /dev/null differ diff --git a/django/apps/entities/migrations/__pycache__/0003_add_search_vector_gin_indexes.cpython-313.pyc b/django/apps/entities/migrations/__pycache__/0003_add_search_vector_gin_indexes.cpython-313.pyc deleted file mode 100644 index 4aeff712..00000000 Binary files a/django/apps/entities/migrations/__pycache__/0003_add_search_vector_gin_indexes.cpython-313.pyc and /dev/null differ diff --git a/django/apps/entities/migrations/__pycache__/__init__.cpython-313.pyc b/django/apps/entities/migrations/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 22a02b67..00000000 Binary files a/django/apps/entities/migrations/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/django/apps/entities/models.py b/django/apps/entities/models.py deleted file mode 100644 index b991714f..00000000 --- a/django/apps/entities/models.py +++ /dev/null @@ -1,930 +0,0 @@ -""" -Entity models for ThrillWiki Django backend. - -This module contains the core entity models: -- Company: Manufacturers, operators, designers -- RideModel: Specific ride models from manufacturers -- Park: Theme parks, amusement parks, water parks, FECs -- Ride: Individual rides and roller coasters -""" -from django.db import models -from django.conf import settings -from django.contrib.contenttypes.fields import GenericRelation -from django.utils.text import slugify -from django_lifecycle import hook, AFTER_CREATE, AFTER_UPDATE, BEFORE_SAVE - -from apps.core.models import VersionedModel, BaseModel - -# Conditionally import GIS models only if using PostGIS backend -# This allows migrations to run on SQLite during local development -_using_postgis = ( - 'postgis' in settings.DATABASES['default']['ENGINE'] -) - -if _using_postgis: - from django.contrib.gis.db import models as gis_models - from django.contrib.gis.geos import Point - from django.contrib.postgres.search import SearchVectorField - - -class Company(VersionedModel): - """ - Represents a company in the amusement industry. - Can be a manufacturer, operator, designer, or combination. - """ - - COMPANY_TYPE_CHOICES = [ - ('manufacturer', 'Manufacturer'), - ('operator', 'Operator'), - ('designer', 'Designer'), - ('supplier', 'Supplier'), - ('contractor', 'Contractor'), - ] - - # Basic Info - name = models.CharField( - max_length=255, - unique=True, - db_index=True, - help_text="Official company name" - ) - slug = models.SlugField( - max_length=255, - unique=True, - db_index=True, - help_text="URL-friendly identifier" - ) - description = models.TextField( - blank=True, - help_text="Company description and history" - ) - - # Company Types (can be multiple) - company_types = models.JSONField( - default=list, - help_text="List of company types (manufacturer, operator, etc.)" - ) - - # Location - location = models.ForeignKey( - 'core.Locality', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='companies', - help_text="Company headquarters location" - ) - - # Dates with precision tracking - founded_date = models.DateField( - null=True, - blank=True, - help_text="Company founding date" - ) - founded_date_precision = models.CharField( - max_length=20, - default='day', - choices=[ - ('year', 'Year'), - ('month', 'Month'), - ('day', 'Day'), - ], - help_text="Precision of founded date" - ) - - closed_date = models.DateField( - null=True, - blank=True, - help_text="Company closure date (if applicable)" - ) - closed_date_precision = models.CharField( - max_length=20, - default='day', - choices=[ - ('year', 'Year'), - ('month', 'Month'), - ('day', 'Day'), - ], - help_text="Precision of closed date" - ) - - # External Links - website = models.URLField( - blank=True, - help_text="Official company website" - ) - - # CloudFlare Images - logo_image_id = models.CharField( - max_length=255, - blank=True, - help_text="CloudFlare image ID for company logo" - ) - logo_image_url = models.URLField( - blank=True, - help_text="CloudFlare image URL for company logo" - ) - - # Cached statistics - park_count = models.IntegerField( - default=0, - help_text="Number of parks operated (for operators)" - ) - ride_count = models.IntegerField( - default=0, - help_text="Number of rides manufactured (for manufacturers)" - ) - - # Generic relation to photos - photos = GenericRelation( - 'media.Photo', - related_query_name='company' - ) - - # Full-text search vector (PostgreSQL only) - # Populated automatically via signals or database triggers - # Includes: name (weight A) + description (weight B) - - class Meta: - verbose_name = 'Company' - verbose_name_plural = 'Companies' - ordering = ['name'] - indexes = [ - models.Index(fields=['name']), - models.Index(fields=['slug']), - ] - - def __str__(self): - return self.name - - @hook(BEFORE_SAVE, when='slug', is_now=None) - def auto_generate_slug(self): - """Auto-generate slug from name if not provided.""" - if not self.slug and self.name: - base_slug = slugify(self.name) - slug = base_slug - counter = 1 - while Company.objects.filter(slug=slug).exists(): - slug = f"{base_slug}-{counter}" - counter += 1 - self.slug = slug - - def update_counts(self): - """Update cached park and ride counts.""" - self.park_count = self.operated_parks.count() - self.ride_count = self.manufactured_rides.count() - self.save(update_fields=['park_count', 'ride_count']) - - def get_photos(self, photo_type=None, approved_only=True): - """Get photos for this company.""" - from apps.media.services import PhotoService - service = PhotoService() - return service.get_entity_photos(self, photo_type=photo_type, approved_only=approved_only) - - @property - def main_photo(self): - """Get the main photo.""" - photos = self.photos.filter(photo_type='main', moderation_status='approved').first() - return photos - - @property - def logo_photo(self): - """Get the logo photo.""" - photos = self.photos.filter(photo_type='logo', moderation_status='approved').first() - return photos - - -class RideModel(VersionedModel): - """ - Represents a specific ride model from a manufacturer. - E.g., "B&M Inverted Coaster", "Vekoma Boomerang", "Zamperla Family Gravity Coaster" - """ - - MODEL_TYPE_CHOICES = [ - ('coaster_model', 'Roller Coaster Model'), - ('flat_ride_model', 'Flat Ride Model'), - ('water_ride_model', 'Water Ride Model'), - ('dark_ride_model', 'Dark Ride Model'), - ('transport_ride_model', 'Transport Ride Model'), - ] - - # Basic Info - name = models.CharField( - max_length=255, - db_index=True, - help_text="Model name (e.g., 'Inverted Coaster', 'Boomerang')" - ) - slug = models.SlugField( - max_length=255, - unique=True, - db_index=True, - help_text="URL-friendly identifier" - ) - description = models.TextField( - blank=True, - help_text="Model description and technical details" - ) - - # Manufacturer - manufacturer = models.ForeignKey( - 'Company', - on_delete=models.CASCADE, - related_name='ride_models', - help_text="Manufacturer of this ride model" - ) - - # Model Type - model_type = models.CharField( - max_length=50, - choices=MODEL_TYPE_CHOICES, - db_index=True, - help_text="Type of ride model" - ) - - # Technical Specifications (common to most instances) - typical_height = models.DecimalField( - max_digits=6, - decimal_places=1, - null=True, - blank=True, - help_text="Typical height in feet" - ) - typical_speed = models.DecimalField( - max_digits=6, - decimal_places=1, - null=True, - blank=True, - help_text="Typical speed in mph" - ) - typical_capacity = models.IntegerField( - null=True, - blank=True, - help_text="Typical hourly capacity" - ) - - # CloudFlare Images - image_id = models.CharField( - max_length=255, - blank=True, - help_text="CloudFlare image ID" - ) - image_url = models.URLField( - blank=True, - help_text="CloudFlare image URL" - ) - - # Cached statistics - installation_count = models.IntegerField( - default=0, - help_text="Number of installations worldwide" - ) - - # Generic relation to photos - photos = GenericRelation( - 'media.Photo', - related_query_name='ride_model' - ) - - class Meta: - verbose_name = 'Ride Model' - verbose_name_plural = 'Ride Models' - ordering = ['manufacturer__name', 'name'] - unique_together = [['manufacturer', 'name']] - indexes = [ - models.Index(fields=['manufacturer', 'name']), - models.Index(fields=['model_type']), - ] - - def __str__(self): - return f"{self.manufacturer.name} {self.name}" - - @hook(BEFORE_SAVE, when='slug', is_now=None) - def auto_generate_slug(self): - """Auto-generate slug from manufacturer and name if not provided.""" - if not self.slug and self.manufacturer and self.name: - base_slug = slugify(f"{self.manufacturer.name} {self.name}") - slug = base_slug - counter = 1 - while RideModel.objects.filter(slug=slug).exists(): - slug = f"{base_slug}-{counter}" - counter += 1 - self.slug = slug - - def update_installation_count(self): - """Update cached installation count.""" - self.installation_count = self.rides.count() - self.save(update_fields=['installation_count']) - - def get_photos(self, photo_type=None, approved_only=True): - """Get photos for this ride model.""" - from apps.media.services import PhotoService - service = PhotoService() - return service.get_entity_photos(self, photo_type=photo_type, approved_only=approved_only) - - @property - def main_photo(self): - """Get the main photo.""" - photos = self.photos.filter(photo_type='main', moderation_status='approved').first() - return photos - - -class Park(VersionedModel): - """ - Represents an amusement park, theme park, water park, or FEC. - - Note: Geographic coordinates are stored differently based on database backend: - - Production (PostGIS): Uses location_point PointField with full GIS capabilities - - Local Dev (SQLite): Uses latitude/longitude DecimalFields (no spatial queries) - """ - - PARK_TYPE_CHOICES = [ - ('theme_park', 'Theme Park'), - ('amusement_park', 'Amusement Park'), - ('water_park', 'Water Park'), - ('family_entertainment_center', 'Family Entertainment Center'), - ('traveling_park', 'Traveling Park'), - ('zoo', 'Zoo'), - ('aquarium', 'Aquarium'), - ] - - STATUS_CHOICES = [ - ('operating', 'Operating'), - ('closed', 'Closed'), - ('sbno', 'Standing But Not Operating'), - ('under_construction', 'Under Construction'), - ('planned', 'Planned'), - ] - - # Basic Info - name = models.CharField( - max_length=255, - db_index=True, - help_text="Official park name" - ) - slug = models.SlugField( - max_length=255, - unique=True, - db_index=True, - help_text="URL-friendly identifier" - ) - description = models.TextField( - blank=True, - help_text="Park description and history" - ) - - # Type & Status - park_type = models.CharField( - max_length=50, - choices=PARK_TYPE_CHOICES, - db_index=True, - help_text="Type of park" - ) - status = models.CharField( - max_length=50, - choices=STATUS_CHOICES, - default='operating', - db_index=True, - help_text="Current operational status" - ) - - # Dates with precision tracking - opening_date = models.DateField( - null=True, - blank=True, - db_index=True, - help_text="Park opening date" - ) - opening_date_precision = models.CharField( - max_length=20, - default='day', - choices=[ - ('year', 'Year'), - ('month', 'Month'), - ('day', 'Day'), - ], - help_text="Precision of opening date" - ) - - closing_date = models.DateField( - null=True, - blank=True, - help_text="Park closing date (if closed)" - ) - closing_date_precision = models.CharField( - max_length=20, - default='day', - choices=[ - ('year', 'Year'), - ('month', 'Month'), - ('day', 'Day'), - ], - help_text="Precision of closing date" - ) - - # Location - location = models.ForeignKey( - 'core.Locality', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='parks', - help_text="Park location" - ) - - # Precise coordinates for mapping - # Primary in local dev (SQLite), deprecated in production (PostGIS) - latitude = models.DecimalField( - max_digits=10, - decimal_places=7, - null=True, - blank=True, - help_text="Latitude coordinate. Primary in local dev, use location_point in production." - ) - longitude = models.DecimalField( - max_digits=10, - decimal_places=7, - null=True, - blank=True, - help_text="Longitude coordinate. Primary in local dev, use location_point in production." - ) - - # NOTE: location_point PointField is added conditionally below if using PostGIS - - # Relationships - operator = models.ForeignKey( - 'Company', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='operated_parks', - help_text="Current park operator" - ) - - # External Links - website = models.URLField( - blank=True, - help_text="Official park website" - ) - - # CloudFlare Images - banner_image_id = models.CharField( - max_length=255, - blank=True, - help_text="CloudFlare image ID for park banner" - ) - banner_image_url = models.URLField( - blank=True, - help_text="CloudFlare image URL for park banner" - ) - logo_image_id = models.CharField( - max_length=255, - blank=True, - help_text="CloudFlare image ID for park logo" - ) - logo_image_url = models.URLField( - blank=True, - help_text="CloudFlare image URL for park logo" - ) - - # Cached statistics (for performance) - ride_count = models.IntegerField( - default=0, - help_text="Total number of rides" - ) - coaster_count = models.IntegerField( - default=0, - help_text="Number of roller coasters" - ) - - # Custom fields for flexible data - custom_fields = models.JSONField( - default=dict, - blank=True, - help_text="Additional park-specific data" - ) - - # Generic relation to photos - photos = GenericRelation( - 'media.Photo', - related_query_name='park' - ) - - class Meta: - verbose_name = 'Park' - verbose_name_plural = 'Parks' - ordering = ['name'] - indexes = [ - models.Index(fields=['name']), - models.Index(fields=['slug']), - models.Index(fields=['status']), - models.Index(fields=['park_type']), - models.Index(fields=['opening_date']), - models.Index(fields=['location']), - ] - - def __str__(self): - return self.name - - @hook(BEFORE_SAVE, when='slug', is_now=None) - def auto_generate_slug(self): - """Auto-generate slug from name if not provided.""" - if not self.slug and self.name: - base_slug = slugify(self.name) - slug = base_slug - counter = 1 - while Park.objects.filter(slug=slug).exists(): - slug = f"{base_slug}-{counter}" - counter += 1 - self.slug = slug - - def update_counts(self): - """Update cached ride counts.""" - self.ride_count = self.rides.count() - self.coaster_count = self.rides.filter(is_coaster=True).count() - self.save(update_fields=['ride_count', 'coaster_count']) - - def set_location(self, longitude, latitude): - """ - Set park location from coordinates. - - Args: - longitude: Longitude coordinate (X) - latitude: Latitude coordinate (Y) - - Note: Works in both PostGIS and non-PostGIS modes. - - PostGIS: Sets location_point and syncs to lat/lng - - SQLite: Sets lat/lng directly - """ - if longitude is not None and latitude is not None: - # Always update lat/lng fields - self.longitude = longitude - self.latitude = latitude - - # If using PostGIS, also update location_point - if _using_postgis and hasattr(self, 'location_point'): - self.location_point = Point(float(longitude), float(latitude), srid=4326) - - @property - def coordinates(self): - """ - Get coordinates as (longitude, latitude) tuple. - - Returns: - tuple: (longitude, latitude) or None if no location set - """ - # Try PostGIS field first if available - if _using_postgis and hasattr(self, 'location_point') and self.location_point: - return (self.location_point.x, self.location_point.y) - # Fall back to lat/lng fields - elif self.longitude and self.latitude: - return (float(self.longitude), float(self.latitude)) - return None - - @property - def latitude_value(self): - """Get latitude value (from location_point if PostGIS, else from latitude field).""" - if _using_postgis and hasattr(self, 'location_point') and self.location_point: - return self.location_point.y - return float(self.latitude) if self.latitude else None - - @property - def longitude_value(self): - """Get longitude value (from location_point if PostGIS, else from longitude field).""" - if _using_postgis and hasattr(self, 'location_point') and self.location_point: - return self.location_point.x - return float(self.longitude) if self.longitude else None - - def get_photos(self, photo_type=None, approved_only=True): - """Get photos for this park.""" - from apps.media.services import PhotoService - service = PhotoService() - return service.get_entity_photos(self, photo_type=photo_type, approved_only=approved_only) - - @property - def main_photo(self): - """Get the main photo.""" - photos = self.photos.filter(photo_type='main', moderation_status='approved').first() - return photos - - @property - def banner_photo(self): - """Get the banner photo.""" - photos = self.photos.filter(photo_type='banner', moderation_status='approved').first() - return photos - - @property - def logo_photo(self): - """Get the logo photo.""" - photos = self.photos.filter(photo_type='logo', moderation_status='approved').first() - return photos - - @property - def gallery_photos(self): - """Get gallery photos.""" - return self.photos.filter(photo_type='gallery', moderation_status='approved').order_by('display_order') - - -# Conditionally add PostGIS PointField to Park model if using PostGIS backend -if _using_postgis: - Park.add_to_class( - 'location_point', - gis_models.PointField( - geography=True, - null=True, - blank=True, - srid=4326, - help_text="Geographic coordinates (PostGIS Point). Production only." - ) - ) - - -class Ride(VersionedModel): - """ - Represents an individual ride or roller coaster. - """ - - RIDE_CATEGORY_CHOICES = [ - ('roller_coaster', 'Roller Coaster'), - ('flat_ride', 'Flat Ride'), - ('water_ride', 'Water Ride'), - ('dark_ride', 'Dark Ride'), - ('transport_ride', 'Transport Ride'), - ('other', 'Other'), - ] - - STATUS_CHOICES = [ - ('operating', 'Operating'), - ('closed', 'Closed'), - ('sbno', 'Standing But Not Operating'), - ('relocated', 'Relocated'), - ('under_construction', 'Under Construction'), - ('planned', 'Planned'), - ] - - # Basic Info - name = models.CharField( - max_length=255, - db_index=True, - help_text="Ride name" - ) - slug = models.SlugField( - max_length=255, - unique=True, - db_index=True, - help_text="URL-friendly identifier" - ) - description = models.TextField( - blank=True, - help_text="Ride description and history" - ) - - # Park Relationship - park = models.ForeignKey( - 'Park', - on_delete=models.CASCADE, - related_name='rides', - db_index=True, - help_text="Park where ride is located" - ) - - # Ride Classification - ride_category = models.CharField( - max_length=50, - choices=RIDE_CATEGORY_CHOICES, - db_index=True, - help_text="Broad ride category" - ) - ride_type = models.CharField( - max_length=100, - blank=True, - db_index=True, - help_text="Specific ride type (e.g., 'Inverted Coaster', 'Drop Tower')" - ) - - # Quick coaster identification - is_coaster = models.BooleanField( - default=False, - db_index=True, - help_text="Is this ride a roller coaster?" - ) - - # Status - status = models.CharField( - max_length=50, - choices=STATUS_CHOICES, - default='operating', - db_index=True, - help_text="Current operational status" - ) - - # Dates with precision tracking - opening_date = models.DateField( - null=True, - blank=True, - db_index=True, - help_text="Ride opening date" - ) - opening_date_precision = models.CharField( - max_length=20, - default='day', - choices=[ - ('year', 'Year'), - ('month', 'Month'), - ('day', 'Day'), - ], - help_text="Precision of opening date" - ) - - closing_date = models.DateField( - null=True, - blank=True, - help_text="Ride closing date (if closed)" - ) - closing_date_precision = models.CharField( - max_length=20, - default='day', - choices=[ - ('year', 'Year'), - ('month', 'Month'), - ('day', 'Day'), - ], - help_text="Precision of closing date" - ) - - # Manufacturer & Model - manufacturer = models.ForeignKey( - 'Company', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='manufactured_rides', - help_text="Ride manufacturer" - ) - model = models.ForeignKey( - 'RideModel', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='rides', - help_text="Specific ride model" - ) - - # Statistics - height = models.DecimalField( - max_digits=6, - decimal_places=1, - null=True, - blank=True, - help_text="Height in feet" - ) - speed = models.DecimalField( - max_digits=6, - decimal_places=1, - null=True, - blank=True, - help_text="Top speed in mph" - ) - length = models.DecimalField( - max_digits=8, - decimal_places=1, - null=True, - blank=True, - help_text="Track/ride length in feet" - ) - duration = models.IntegerField( - null=True, - blank=True, - help_text="Ride duration in seconds" - ) - inversions = models.IntegerField( - null=True, - blank=True, - help_text="Number of inversions (for coasters)" - ) - capacity = models.IntegerField( - null=True, - blank=True, - help_text="Hourly capacity (riders per hour)" - ) - - # CloudFlare Images - image_id = models.CharField( - max_length=255, - blank=True, - help_text="CloudFlare image ID for main photo" - ) - image_url = models.URLField( - blank=True, - help_text="CloudFlare image URL for main photo" - ) - - # Custom fields for flexible data - custom_fields = models.JSONField( - default=dict, - blank=True, - help_text="Additional ride-specific data" - ) - - # Generic relation to photos - photos = GenericRelation( - 'media.Photo', - related_query_name='ride' - ) - - class Meta: - verbose_name = 'Ride' - verbose_name_plural = 'Rides' - ordering = ['park__name', 'name'] - indexes = [ - models.Index(fields=['park', 'name']), - models.Index(fields=['slug']), - models.Index(fields=['status']), - models.Index(fields=['is_coaster']), - models.Index(fields=['ride_category']), - models.Index(fields=['opening_date']), - models.Index(fields=['manufacturer']), - ] - - def __str__(self): - return f"{self.name} ({self.park.name})" - - @hook(BEFORE_SAVE, when='slug', is_now=None) - def auto_generate_slug(self): - """Auto-generate slug from park and name if not provided.""" - if not self.slug and self.park and self.name: - base_slug = slugify(f"{self.park.name} {self.name}") - slug = base_slug - counter = 1 - while Ride.objects.filter(slug=slug).exists(): - slug = f"{base_slug}-{counter}" - counter += 1 - self.slug = slug - - @hook(BEFORE_SAVE) - def set_is_coaster_flag(self): - """Auto-set is_coaster flag based on ride_category.""" - self.is_coaster = (self.ride_category == 'roller_coaster') - - @hook(AFTER_CREATE) - @hook(AFTER_UPDATE, when='park', has_changed=True) - def update_park_counts(self): - """Update parent park's ride counts when ride is created or moved.""" - if self.park: - self.park.update_counts() - - def get_photos(self, photo_type=None, approved_only=True): - """Get photos for this ride.""" - from apps.media.services import PhotoService - service = PhotoService() - return service.get_entity_photos(self, photo_type=photo_type, approved_only=approved_only) - - @property - def main_photo(self): - """Get the main photo.""" - photos = self.photos.filter(photo_type='main', moderation_status='approved').first() - return photos - - @property - def gallery_photos(self): - """Get gallery photos.""" - return self.photos.filter(photo_type='gallery', moderation_status='approved').order_by('display_order') - - -# Add SearchVectorField to all models for full-text search (PostgreSQL only) -# Must be at the very end after ALL class definitions -if _using_postgis: - Company.add_to_class( - 'search_vector', - SearchVectorField( - null=True, - blank=True, - help_text="Pre-computed search vector for full-text search. Auto-updated via signals." - ) - ) - - RideModel.add_to_class( - 'search_vector', - SearchVectorField( - null=True, - blank=True, - help_text="Pre-computed search vector for full-text search. Auto-updated via signals." - ) - ) - - Park.add_to_class( - 'search_vector', - SearchVectorField( - null=True, - blank=True, - help_text="Pre-computed search vector for full-text search. Auto-updated via signals." - ) - ) - - Ride.add_to_class( - 'search_vector', - SearchVectorField( - null=True, - blank=True, - help_text="Pre-computed search vector for full-text search. Auto-updated via signals." - ) - ) diff --git a/django/apps/entities/search.py b/django/apps/entities/search.py deleted file mode 100644 index 9641bfd4..00000000 --- a/django/apps/entities/search.py +++ /dev/null @@ -1,386 +0,0 @@ -""" -Search service for ThrillWiki entities. - -Provides full-text search capabilities with PostgreSQL and fallback for SQLite. -- PostgreSQL: Uses SearchVector, SearchQuery, SearchRank for full-text search -- SQLite: Falls back to case-insensitive LIKE queries -""" -from typing import List, Optional, Dict, Any -from django.db.models import Q, QuerySet, Value, CharField, F -from django.db.models.functions import Concat -from django.conf import settings - -# Conditionally import PostgreSQL search features -_using_postgis = 'postgis' in settings.DATABASES['default']['ENGINE'] - -if _using_postgis: - from django.contrib.postgres.search import SearchVector, SearchQuery, SearchRank, TrigramSimilarity - from django.contrib.postgres.aggregates import StringAgg - - -class SearchService: - """Service for searching across all entity types.""" - - def __init__(self): - self.using_postgres = _using_postgis - - def search_all( - self, - query: str, - entity_types: Optional[List[str]] = None, - limit: int = 20 - ) -> Dict[str, Any]: - """ - Search across all entity types. - - Args: - query: Search query string - entity_types: Optional list to filter by entity types - limit: Maximum results per entity type - - Returns: - Dictionary with results grouped by entity type - """ - results = {} - - # Default to all entity types if not specified - if not entity_types: - entity_types = ['company', 'ride_model', 'park', 'ride'] - - if 'company' in entity_types: - results['companies'] = list(self.search_companies(query, limit=limit)) - - if 'ride_model' in entity_types: - results['ride_models'] = list(self.search_ride_models(query, limit=limit)) - - if 'park' in entity_types: - results['parks'] = list(self.search_parks(query, limit=limit)) - - if 'ride' in entity_types: - results['rides'] = list(self.search_rides(query, limit=limit)) - - return results - - def search_companies( - self, - query: str, - filters: Optional[Dict[str, Any]] = None, - limit: int = 20 - ) -> QuerySet: - """ - Search companies with full-text search. - - Args: - query: Search query string - filters: Optional filters (company_types, founded_after, etc.) - limit: Maximum number of results - - Returns: - QuerySet of Company objects - """ - from apps.entities.models import Company - - if self.using_postgres: - # PostgreSQL full-text search using pre-computed search_vector - search_query = SearchQuery(query, search_type='websearch') - - results = Company.objects.annotate( - rank=SearchRank(F('search_vector'), search_query) - ).filter(search_vector=search_query).order_by('-rank') - else: - # SQLite fallback using LIKE - results = Company.objects.filter( - Q(name__icontains=query) | Q(description__icontains=query) - ).order_by('name') - - # Apply additional filters - if filters: - if filters.get('company_types'): - # Filter by company types (stored in JSONField) - results = results.filter( - company_types__contains=filters['company_types'] - ) - - if filters.get('founded_after'): - results = results.filter(founded_date__gte=filters['founded_after']) - - if filters.get('founded_before'): - results = results.filter(founded_date__lte=filters['founded_before']) - - return results[:limit] - - def search_ride_models( - self, - query: str, - filters: Optional[Dict[str, Any]] = None, - limit: int = 20 - ) -> QuerySet: - """ - Search ride models with full-text search. - - Args: - query: Search query string - filters: Optional filters (manufacturer_id, model_type, etc.) - limit: Maximum number of results - - Returns: - QuerySet of RideModel objects - """ - from apps.entities.models import RideModel - - if self.using_postgres: - # PostgreSQL full-text search using pre-computed search_vector - search_query = SearchQuery(query, search_type='websearch') - - results = RideModel.objects.select_related('manufacturer').annotate( - rank=SearchRank(F('search_vector'), search_query) - ).filter(search_vector=search_query).order_by('-rank') - else: - # SQLite fallback using LIKE - results = RideModel.objects.select_related('manufacturer').filter( - Q(name__icontains=query) | - Q(manufacturer__name__icontains=query) | - Q(description__icontains=query) - ).order_by('manufacturer__name', 'name') - - # Apply additional filters - if filters: - if filters.get('manufacturer_id'): - results = results.filter(manufacturer_id=filters['manufacturer_id']) - - if filters.get('model_type'): - results = results.filter(model_type=filters['model_type']) - - return results[:limit] - - def search_parks( - self, - query: str, - filters: Optional[Dict[str, Any]] = None, - limit: int = 20 - ) -> QuerySet: - """ - Search parks with full-text search and location filtering. - - Args: - query: Search query string - filters: Optional filters (status, park_type, location, radius, etc.) - limit: Maximum number of results - - Returns: - QuerySet of Park objects - """ - from apps.entities.models import Park - - if self.using_postgres: - # PostgreSQL full-text search using pre-computed search_vector - search_query = SearchQuery(query, search_type='websearch') - - results = Park.objects.annotate( - rank=SearchRank(F('search_vector'), search_query) - ).filter(search_vector=search_query).order_by('-rank') - else: - # SQLite fallback using LIKE - results = Park.objects.filter( - Q(name__icontains=query) | Q(description__icontains=query) - ).order_by('name') - - # Apply additional filters - if filters: - if filters.get('status'): - results = results.filter(status=filters['status']) - - if filters.get('park_type'): - results = results.filter(park_type=filters['park_type']) - - if filters.get('operator_id'): - results = results.filter(operator_id=filters['operator_id']) - - if filters.get('opening_after'): - results = results.filter(opening_date__gte=filters['opening_after']) - - if filters.get('opening_before'): - results = results.filter(opening_date__lte=filters['opening_before']) - - # Location-based filtering (PostGIS only) - if self.using_postgres and filters.get('location') and filters.get('radius'): - from django.contrib.gis.geos import Point - from django.contrib.gis.measure import D - - longitude, latitude = filters['location'] - point = Point(longitude, latitude, srid=4326) - radius_km = filters['radius'] - - # Use distance filter - results = results.filter( - location_point__distance_lte=(point, D(km=radius_km)) - ).annotate( - distance=F('location_point__distance') - ).order_by('distance') - - return results[:limit] - - def search_rides( - self, - query: str, - filters: Optional[Dict[str, Any]] = None, - limit: int = 20 - ) -> QuerySet: - """ - Search rides with full-text search. - - Args: - query: Search query string - filters: Optional filters (park_id, manufacturer_id, status, etc.) - limit: Maximum number of results - - Returns: - QuerySet of Ride objects - """ - from apps.entities.models import Ride - - if self.using_postgres: - # PostgreSQL full-text search using pre-computed search_vector - search_query = SearchQuery(query, search_type='websearch') - - results = Ride.objects.select_related('park', 'manufacturer', 'model').annotate( - rank=SearchRank(F('search_vector'), search_query) - ).filter(search_vector=search_query).order_by('-rank') - else: - # SQLite fallback using LIKE - results = Ride.objects.select_related('park', 'manufacturer', 'model').filter( - Q(name__icontains=query) | - Q(park__name__icontains=query) | - Q(manufacturer__name__icontains=query) | - Q(description__icontains=query) - ).order_by('park__name', 'name') - - # Apply additional filters - if filters: - if filters.get('park_id'): - results = results.filter(park_id=filters['park_id']) - - if filters.get('manufacturer_id'): - results = results.filter(manufacturer_id=filters['manufacturer_id']) - - if filters.get('model_id'): - results = results.filter(model_id=filters['model_id']) - - if filters.get('status'): - results = results.filter(status=filters['status']) - - if filters.get('ride_category'): - results = results.filter(ride_category=filters['ride_category']) - - if filters.get('is_coaster') is not None: - results = results.filter(is_coaster=filters['is_coaster']) - - if filters.get('opening_after'): - results = results.filter(opening_date__gte=filters['opening_after']) - - if filters.get('opening_before'): - results = results.filter(opening_date__lte=filters['opening_before']) - - # Height/speed filters - if filters.get('min_height'): - results = results.filter(height__gte=filters['min_height']) - - if filters.get('max_height'): - results = results.filter(height__lte=filters['max_height']) - - if filters.get('min_speed'): - results = results.filter(speed__gte=filters['min_speed']) - - if filters.get('max_speed'): - results = results.filter(speed__lte=filters['max_speed']) - - return results[:limit] - - def autocomplete( - self, - query: str, - entity_type: Optional[str] = None, - limit: int = 10 - ) -> List[Dict[str, Any]]: - """ - Get autocomplete suggestions for search. - - Args: - query: Partial search query - entity_type: Optional specific entity type - limit: Maximum number of suggestions - - Returns: - List of suggestion dictionaries with name and entity_type - """ - suggestions = [] - - if not query or len(query) < 2: - return suggestions - - # Search in names only for autocomplete - if entity_type == 'company' or not entity_type: - from apps.entities.models import Company - companies = Company.objects.filter( - name__istartswith=query - ).values('id', 'name', 'slug')[:limit] - - for company in companies: - suggestions.append({ - 'id': company['id'], - 'name': company['name'], - 'slug': company['slug'], - 'entity_type': 'company' - }) - - if entity_type == 'park' or not entity_type: - from apps.entities.models import Park - parks = Park.objects.filter( - name__istartswith=query - ).values('id', 'name', 'slug')[:limit] - - for park in parks: - suggestions.append({ - 'id': park['id'], - 'name': park['name'], - 'slug': park['slug'], - 'entity_type': 'park' - }) - - if entity_type == 'ride' or not entity_type: - from apps.entities.models import Ride - rides = Ride.objects.select_related('park').filter( - name__istartswith=query - ).values('id', 'name', 'slug', 'park__name')[:limit] - - for ride in rides: - suggestions.append({ - 'id': ride['id'], - 'name': ride['name'], - 'slug': ride['slug'], - 'park_name': ride['park__name'], - 'entity_type': 'ride' - }) - - if entity_type == 'ride_model' or not entity_type: - from apps.entities.models import RideModel - models = RideModel.objects.select_related('manufacturer').filter( - name__istartswith=query - ).values('id', 'name', 'slug', 'manufacturer__name')[:limit] - - for model in models: - suggestions.append({ - 'id': model['id'], - 'name': model['name'], - 'slug': model['slug'], - 'manufacturer_name': model['manufacturer__name'], - 'entity_type': 'ride_model' - }) - - # Sort by relevance (exact matches first, then alphabetically) - suggestions.sort(key=lambda x: ( - not x['name'].lower().startswith(query.lower()), - x['name'].lower() - )) - - return suggestions[:limit] diff --git a/django/apps/entities/signals.py b/django/apps/entities/signals.py deleted file mode 100644 index 7f162262..00000000 --- a/django/apps/entities/signals.py +++ /dev/null @@ -1,252 +0,0 @@ -""" -Signal handlers for automatic search vector updates. - -These signals ensure search vectors stay synchronized with model changes, -eliminating the need for manual re-indexing. - -Signal handlers are only active when using PostgreSQL with PostGIS backend. -""" -from django.db.models.signals import post_save, pre_save -from django.dispatch import receiver -from django.conf import settings -from django.contrib.postgres.search import SearchVector - -from apps.entities.models import Company, RideModel, Park, Ride - -# Only register signals if using PostgreSQL with PostGIS -_using_postgis = 'postgis' in settings.DATABASES['default']['ENGINE'] - - -if _using_postgis: - - # ========================================== - # Company Signals - # ========================================== - - @receiver(post_save, sender=Company) - def update_company_search_vector(sender, instance, created, **kwargs): - """ - Update search vector when company is created or updated. - - Search vector includes: - - name (weight A) - - description (weight B) - """ - # Update the company's own search vector - Company.objects.filter(pk=instance.pk).update( - search_vector=( - SearchVector('name', weight='A', config='english') + - SearchVector('description', weight='B', config='english') - ) - ) - - - @receiver(pre_save, sender=Company) - def check_company_name_change(sender, instance, **kwargs): - """ - Track if company name is changing to trigger cascading updates. - - Stores the old name on the instance for use in post_save signal. - """ - if instance.pk: - try: - old_instance = Company.objects.get(pk=instance.pk) - instance._old_name = old_instance.name - except Company.DoesNotExist: - instance._old_name = None - else: - instance._old_name = None - - - @receiver(post_save, sender=Company) - def cascade_company_name_updates(sender, instance, created, **kwargs): - """ - When company name changes, update search vectors for related objects. - - Updates: - - All RideModels from this manufacturer - - All Rides from this manufacturer - """ - # Skip if this is a new company or name hasn't changed - if created or not hasattr(instance, '_old_name'): - return - - old_name = getattr(instance, '_old_name', None) - if old_name == instance.name: - return - - # Update all RideModels from this manufacturer - ride_models = RideModel.objects.filter(manufacturer=instance) - for ride_model in ride_models: - RideModel.objects.filter(pk=ride_model.pk).update( - search_vector=( - SearchVector('name', weight='A', config='english') + - SearchVector('manufacturer__name', weight='A', config='english') + - SearchVector('description', weight='B', config='english') - ) - ) - - # Update all Rides from this manufacturer - rides = Ride.objects.filter(manufacturer=instance) - for ride in rides: - Ride.objects.filter(pk=ride.pk).update( - search_vector=( - SearchVector('name', weight='A', config='english') + - SearchVector('park__name', weight='A', config='english') + - SearchVector('manufacturer__name', weight='B', config='english') + - SearchVector('description', weight='B', config='english') - ) - ) - - - # ========================================== - # Park Signals - # ========================================== - - @receiver(post_save, sender=Park) - def update_park_search_vector(sender, instance, created, **kwargs): - """ - Update search vector when park is created or updated. - - Search vector includes: - - name (weight A) - - description (weight B) - """ - # Update the park's own search vector - Park.objects.filter(pk=instance.pk).update( - search_vector=( - SearchVector('name', weight='A', config='english') + - SearchVector('description', weight='B', config='english') - ) - ) - - - @receiver(pre_save, sender=Park) - def check_park_name_change(sender, instance, **kwargs): - """ - Track if park name is changing to trigger cascading updates. - - Stores the old name on the instance for use in post_save signal. - """ - if instance.pk: - try: - old_instance = Park.objects.get(pk=instance.pk) - instance._old_name = old_instance.name - except Park.DoesNotExist: - instance._old_name = None - else: - instance._old_name = None - - - @receiver(post_save, sender=Park) - def cascade_park_name_updates(sender, instance, created, **kwargs): - """ - When park name changes, update search vectors for related rides. - - Updates: - - All Rides in this park - """ - # Skip if this is a new park or name hasn't changed - if created or not hasattr(instance, '_old_name'): - return - - old_name = getattr(instance, '_old_name', None) - if old_name == instance.name: - return - - # Update all Rides in this park - rides = Ride.objects.filter(park=instance) - for ride in rides: - Ride.objects.filter(pk=ride.pk).update( - search_vector=( - SearchVector('name', weight='A', config='english') + - SearchVector('park__name', weight='A', config='english') + - SearchVector('manufacturer__name', weight='B', config='english') + - SearchVector('description', weight='B', config='english') - ) - ) - - - # ========================================== - # RideModel Signals - # ========================================== - - @receiver(post_save, sender=RideModel) - def update_ride_model_search_vector(sender, instance, created, **kwargs): - """ - Update search vector when ride model is created or updated. - - Search vector includes: - - name (weight A) - - manufacturer__name (weight A) - - description (weight B) - """ - RideModel.objects.filter(pk=instance.pk).update( - search_vector=( - SearchVector('name', weight='A', config='english') + - SearchVector('manufacturer__name', weight='A', config='english') + - SearchVector('description', weight='B', config='english') - ) - ) - - - @receiver(pre_save, sender=RideModel) - def check_ride_model_manufacturer_change(sender, instance, **kwargs): - """ - Track if ride model manufacturer is changing. - - Stores the old manufacturer on the instance for use in post_save signal. - """ - if instance.pk: - try: - old_instance = RideModel.objects.get(pk=instance.pk) - instance._old_manufacturer = old_instance.manufacturer - except RideModel.DoesNotExist: - instance._old_manufacturer = None - else: - instance._old_manufacturer = None - - - # ========================================== - # Ride Signals - # ========================================== - - @receiver(post_save, sender=Ride) - def update_ride_search_vector(sender, instance, created, **kwargs): - """ - Update search vector when ride is created or updated. - - Search vector includes: - - name (weight A) - - park__name (weight A) - - manufacturer__name (weight B) - - description (weight B) - """ - Ride.objects.filter(pk=instance.pk).update( - search_vector=( - SearchVector('name', weight='A', config='english') + - SearchVector('park__name', weight='A', config='english') + - SearchVector('manufacturer__name', weight='B', config='english') + - SearchVector('description', weight='B', config='english') - ) - ) - - - @receiver(pre_save, sender=Ride) - def check_ride_relationships_change(sender, instance, **kwargs): - """ - Track if ride park or manufacturer are changing. - - Stores old values on the instance for use in post_save signal. - """ - if instance.pk: - try: - old_instance = Ride.objects.get(pk=instance.pk) - instance._old_park = old_instance.park - instance._old_manufacturer = old_instance.manufacturer - except Ride.DoesNotExist: - instance._old_park = None - instance._old_manufacturer = None - else: - instance._old_park = None - instance._old_manufacturer = None diff --git a/django/apps/entities/tasks.py b/django/apps/entities/tasks.py deleted file mode 100644 index d9723cef..00000000 --- a/django/apps/entities/tasks.py +++ /dev/null @@ -1,354 +0,0 @@ -""" -Background tasks for entity statistics and maintenance. -""" - -import logging -from celery import shared_task -from django.db.models import Count, Q -from django.utils import timezone - -logger = logging.getLogger(__name__) - - -@shared_task(bind=True, max_retries=2) -def update_entity_statistics(self, entity_type, entity_id): - """ - Update cached statistics for a specific entity. - - Args: - entity_type: Type of entity ('park', 'ride', 'company', 'ridemodel') - entity_id: ID of the entity - - Returns: - dict: Updated statistics - """ - from apps.entities.models import Park, Ride, Company, RideModel - from apps.media.models import Photo - from apps.moderation.models import ContentSubmission - - try: - # Get the entity model - model_map = { - 'park': Park, - 'ride': Ride, - 'company': Company, - 'ridemodel': RideModel, - } - - model = model_map.get(entity_type.lower()) - if not model: - raise ValueError(f"Invalid entity type: {entity_type}") - - entity = model.objects.get(id=entity_id) - - # Calculate statistics - stats = {} - - # Photo count - stats['photo_count'] = Photo.objects.filter( - content_type__model=entity_type.lower(), - object_id=entity_id, - moderation_status='approved' - ).count() - - # Submission count - stats['submission_count'] = ContentSubmission.objects.filter( - entity_type__model=entity_type.lower(), - entity_id=entity_id - ).count() - - # Entity-specific stats - if entity_type.lower() == 'park': - stats['ride_count'] = entity.rides.count() - elif entity_type.lower() == 'company': - stats['park_count'] = entity.parks.count() - stats['ride_model_count'] = entity.ride_models.count() - elif entity_type.lower() == 'ridemodel': - stats['installation_count'] = entity.rides.count() - - logger.info(f"Updated statistics for {entity_type} {entity_id}: {stats}") - return stats - - except Exception as exc: - logger.error(f"Error updating statistics for {entity_type} {entity_id}: {str(exc)}") - raise self.retry(exc=exc, countdown=300) - - -@shared_task(bind=True, max_retries=2) -def update_all_statistics(self): - """ - Update cached statistics for all entities. - - This task runs periodically (e.g., every 6 hours) to ensure - all entity statistics are up to date. - - Returns: - dict: Update summary - """ - from apps.entities.models import Park, Ride, Company, RideModel - - try: - summary = { - 'parks_updated': 0, - 'rides_updated': 0, - 'companies_updated': 0, - 'ride_models_updated': 0, - } - - # Update parks - for park in Park.objects.all(): - try: - update_entity_statistics.delay('park', park.id) - summary['parks_updated'] += 1 - except Exception as e: - logger.error(f"Failed to queue update for park {park.id}: {str(e)}") - - # Update rides - for ride in Ride.objects.all(): - try: - update_entity_statistics.delay('ride', ride.id) - summary['rides_updated'] += 1 - except Exception as e: - logger.error(f"Failed to queue update for ride {ride.id}: {str(e)}") - - # Update companies - for company in Company.objects.all(): - try: - update_entity_statistics.delay('company', company.id) - summary['companies_updated'] += 1 - except Exception as e: - logger.error(f"Failed to queue update for company {company.id}: {str(e)}") - - # Update ride models - for ride_model in RideModel.objects.all(): - try: - update_entity_statistics.delay('ridemodel', ride_model.id) - summary['ride_models_updated'] += 1 - except Exception as e: - logger.error(f"Failed to queue update for ride model {ride_model.id}: {str(e)}") - - logger.info(f"Statistics update queued: {summary}") - return summary - - except Exception as exc: - logger.error(f"Error updating all statistics: {str(exc)}") - raise self.retry(exc=exc, countdown=300) - - -@shared_task -def generate_entity_report(entity_type, entity_id): - """ - Generate a detailed report for an entity. - - This can be used for admin dashboards, analytics, etc. - - Args: - entity_type: Type of entity - entity_id: ID of the entity - - Returns: - dict: Detailed report - """ - from apps.entities.models import Park, Ride, Company, RideModel - from apps.media.models import Photo - from apps.moderation.models import ContentSubmission - from apps.versioning.models import EntityVersion - - try: - model_map = { - 'park': Park, - 'ride': Ride, - 'company': Company, - 'ridemodel': RideModel, - } - - model = model_map.get(entity_type.lower()) - if not model: - raise ValueError(f"Invalid entity type: {entity_type}") - - entity = model.objects.get(id=entity_id) - - report = { - 'entity': { - 'type': entity_type, - 'id': str(entity_id), - 'name': str(entity), - }, - 'photos': { - 'total': Photo.objects.filter( - content_type__model=entity_type.lower(), - object_id=entity_id - ).count(), - 'approved': Photo.objects.filter( - content_type__model=entity_type.lower(), - object_id=entity_id, - moderation_status='approved' - ).count(), - 'pending': Photo.objects.filter( - content_type__model=entity_type.lower(), - object_id=entity_id, - moderation_status='pending' - ).count(), - }, - 'submissions': { - 'total': ContentSubmission.objects.filter( - entity_type__model=entity_type.lower(), - entity_id=entity_id - ).count(), - 'approved': ContentSubmission.objects.filter( - entity_type__model=entity_type.lower(), - entity_id=entity_id, - status='approved' - ).count(), - 'pending': ContentSubmission.objects.filter( - entity_type__model=entity_type.lower(), - entity_id=entity_id, - status='pending' - ).count(), - }, - 'versions': EntityVersion.objects.filter( - content_type__model=entity_type.lower(), - object_id=entity_id - ).count(), - } - - logger.info(f"Generated report for {entity_type} {entity_id}") - return report - - except Exception as e: - logger.error(f"Error generating report: {str(e)}") - raise - - -@shared_task(bind=True, max_retries=2) -def cleanup_duplicate_entities(self): - """ - Detect and flag potential duplicate entities. - - This helps maintain database quality by identifying - entities that might be duplicates based on name similarity. - - Returns: - dict: Duplicate detection results - """ - from apps.entities.models import Park, Ride, Company, RideModel - - try: - # This is a simplified implementation - # In production, you'd want more sophisticated duplicate detection - - results = { - 'parks_flagged': 0, - 'rides_flagged': 0, - 'companies_flagged': 0, - } - - logger.info(f"Duplicate detection completed: {results}") - return results - - except Exception as exc: - logger.error(f"Error detecting duplicates: {str(exc)}") - raise self.retry(exc=exc, countdown=300) - - -@shared_task -def calculate_global_statistics(): - """ - Calculate global statistics across all entities. - - Returns: - dict: Global statistics - """ - from apps.entities.models import Park, Ride, Company, RideModel - from apps.media.models import Photo - from apps.moderation.models import ContentSubmission - from apps.users.models import User - - try: - stats = { - 'entities': { - 'parks': Park.objects.count(), - 'rides': Ride.objects.count(), - 'companies': Company.objects.count(), - 'ride_models': RideModel.objects.count(), - }, - 'photos': { - 'total': Photo.objects.count(), - 'approved': Photo.objects.filter(moderation_status='approved').count(), - }, - 'submissions': { - 'total': ContentSubmission.objects.count(), - 'pending': ContentSubmission.objects.filter(status='pending').count(), - }, - 'users': { - 'total': User.objects.count(), - 'active': User.objects.filter(is_active=True).count(), - }, - 'timestamp': timezone.now().isoformat(), - } - - logger.info(f"Global statistics calculated: {stats}") - return stats - - except Exception as e: - logger.error(f"Error calculating global statistics: {str(e)}") - raise - - -@shared_task(bind=True, max_retries=2) -def validate_entity_data(self, entity_type, entity_id): - """ - Validate entity data integrity and flag issues. - - Args: - entity_type: Type of entity - entity_id: ID of the entity - - Returns: - dict: Validation results - """ - from apps.entities.models import Park, Ride, Company, RideModel - - try: - model_map = { - 'park': Park, - 'ride': Ride, - 'company': Company, - 'ridemodel': RideModel, - } - - model = model_map.get(entity_type.lower()) - if not model: - raise ValueError(f"Invalid entity type: {entity_type}") - - entity = model.objects.get(id=entity_id) - - issues = [] - - # Check for missing required fields - if not entity.name or entity.name.strip() == '': - issues.append('Missing or empty name') - - # Entity-specific validation - if entity_type.lower() == 'park' and not entity.country: - issues.append('Missing country') - - if entity_type.lower() == 'ride' and not entity.park: - issues.append('Missing park association') - - result = { - 'entity': f"{entity_type} {entity_id}", - 'valid': len(issues) == 0, - 'issues': issues, - } - - if issues: - logger.warning(f"Validation issues for {entity_type} {entity_id}: {issues}") - else: - logger.info(f"Validation passed for {entity_type} {entity_id}") - - return result - - except Exception as exc: - logger.error(f"Error validating {entity_type} {entity_id}: {str(exc)}") - raise self.retry(exc=exc, countdown=300) diff --git a/django/apps/media/__init__.py b/django/apps/media/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/django/apps/media/__pycache__/__init__.cpython-313.pyc b/django/apps/media/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 0ba36b50..00000000 Binary files a/django/apps/media/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/django/apps/media/__pycache__/admin.cpython-313.pyc b/django/apps/media/__pycache__/admin.cpython-313.pyc deleted file mode 100644 index 264c5a5f..00000000 Binary files a/django/apps/media/__pycache__/admin.cpython-313.pyc and /dev/null differ diff --git a/django/apps/media/__pycache__/apps.cpython-313.pyc b/django/apps/media/__pycache__/apps.cpython-313.pyc deleted file mode 100644 index 4296cff5..00000000 Binary files a/django/apps/media/__pycache__/apps.cpython-313.pyc and /dev/null differ diff --git a/django/apps/media/__pycache__/models.cpython-313.pyc b/django/apps/media/__pycache__/models.cpython-313.pyc deleted file mode 100644 index 142d2967..00000000 Binary files a/django/apps/media/__pycache__/models.cpython-313.pyc and /dev/null differ diff --git a/django/apps/media/__pycache__/services.cpython-313.pyc b/django/apps/media/__pycache__/services.cpython-313.pyc deleted file mode 100644 index 85f10a78..00000000 Binary files a/django/apps/media/__pycache__/services.cpython-313.pyc and /dev/null differ diff --git a/django/apps/media/__pycache__/validators.cpython-313.pyc b/django/apps/media/__pycache__/validators.cpython-313.pyc deleted file mode 100644 index 4afce562..00000000 Binary files a/django/apps/media/__pycache__/validators.cpython-313.pyc and /dev/null differ diff --git a/django/apps/media/admin.py b/django/apps/media/admin.py deleted file mode 100644 index f5c41b38..00000000 --- a/django/apps/media/admin.py +++ /dev/null @@ -1,206 +0,0 @@ -""" -Django Admin configuration for media models. -""" -from django.contrib import admin -from django.contrib.contenttypes.admin import GenericTabularInline -from django.utils.html import format_html -from django.utils.safestring import mark_safe -from django.db.models import Count, Q -from .models import Photo - - -@admin.register(Photo) -class PhotoAdmin(admin.ModelAdmin): - """Admin interface for Photo model with enhanced features.""" - - list_display = [ - 'thumbnail_preview', 'title', 'photo_type', 'moderation_status', - 'entity_info', 'uploaded_by', 'dimensions', 'file_size_display', 'created' - ] - list_filter = [ - 'moderation_status', 'is_approved', 'photo_type', - 'is_featured', 'is_public', 'created' - ] - search_fields = [ - 'title', 'description', 'cloudflare_image_id', - 'uploaded_by__email', 'uploaded_by__username' - ] - readonly_fields = [ - 'id', 'created', 'modified', 'content_type', 'object_id', - 'moderated_at' - ] - raw_id_fields = ['uploaded_by', 'moderated_by'] - - fieldsets = ( - ('CloudFlare Image', { - 'fields': ( - 'cloudflare_image_id', 'cloudflare_url', - 'cloudflare_thumbnail_url' - ) - }), - ('Metadata', { - 'fields': ('title', 'description', 'credit', 'photo_type') - }), - ('Associated Entity', { - 'fields': ('content_type', 'object_id') - }), - ('Upload Information', { - 'fields': ('uploaded_by',) - }), - ('Moderation', { - 'fields': ( - 'moderation_status', 'is_approved', - 'moderated_by', 'moderated_at', 'moderation_notes' - ) - }), - ('Image Details', { - 'fields': ('width', 'height', 'file_size'), - 'classes': ('collapse',) - }), - ('Display Settings', { - 'fields': ('display_order', 'is_featured', 'is_public') - }), - ('System', { - 'fields': ('id', 'created', 'modified'), - 'classes': ('collapse',) - }), - ) - - date_hierarchy = 'created' - actions = ['approve_photos', 'reject_photos', 'flag_photos', 'make_featured', 'remove_featured'] - - def get_queryset(self, request): - """Optimize queryset with select_related.""" - qs = super().get_queryset(request) - return qs.select_related( - 'uploaded_by', 'moderated_by', 'content_type' - ) - - def thumbnail_preview(self, obj): - """Display thumbnail preview in list view.""" - if obj.cloudflare_url: - # Use thumbnail variant for preview - from apps.media.services import CloudFlareService - cf = CloudFlareService() - thumbnail_url = cf.get_image_url(obj.cloudflare_image_id, 'thumbnail') - - return format_html( - '', - thumbnail_url - ) - return "-" - thumbnail_preview.short_description = "Preview" - - def entity_info(self, obj): - """Display entity information.""" - if obj.content_type and obj.object_id: - entity = obj.content_object - if entity: - entity_type = obj.content_type.model - entity_name = getattr(entity, 'name', str(entity)) - return format_html( - '{}
{}', - entity_name, - entity_type.upper() - ) - return format_html('Not attached') - entity_info.short_description = "Entity" - - def dimensions(self, obj): - """Display image dimensions.""" - if obj.width and obj.height: - return f"{obj.width}×{obj.height}" - return "-" - dimensions.short_description = "Size" - - def file_size_display(self, obj): - """Display file size in human-readable format.""" - if obj.file_size: - size_kb = obj.file_size / 1024 - if size_kb > 1024: - return f"{size_kb / 1024:.1f} MB" - return f"{size_kb:.1f} KB" - return "-" - file_size_display.short_description = "File Size" - - def changelist_view(self, request, extra_context=None): - """Add statistics to changelist.""" - extra_context = extra_context or {} - - # Get photo statistics - stats = Photo.objects.aggregate( - total=Count('id'), - pending=Count('id', filter=Q(moderation_status='pending')), - approved=Count('id', filter=Q(moderation_status='approved')), - rejected=Count('id', filter=Q(moderation_status='rejected')), - flagged=Count('id', filter=Q(moderation_status='flagged')), - ) - - extra_context['photo_stats'] = stats - - return super().changelist_view(request, extra_context) - - def approve_photos(self, request, queryset): - """Bulk approve selected photos.""" - count = 0 - for photo in queryset: - photo.approve(moderator=request.user, notes='Bulk approved') - count += 1 - self.message_user(request, f"{count} photo(s) approved successfully.") - approve_photos.short_description = "Approve selected photos" - - def reject_photos(self, request, queryset): - """Bulk reject selected photos.""" - count = 0 - for photo in queryset: - photo.reject(moderator=request.user, notes='Bulk rejected') - count += 1 - self.message_user(request, f"{count} photo(s) rejected.") - reject_photos.short_description = "Reject selected photos" - - def flag_photos(self, request, queryset): - """Bulk flag selected photos for review.""" - count = 0 - for photo in queryset: - photo.flag(moderator=request.user, notes='Flagged for review') - count += 1 - self.message_user(request, f"{count} photo(s) flagged for review.") - flag_photos.short_description = "Flag selected photos" - - def make_featured(self, request, queryset): - """Mark selected photos as featured.""" - count = queryset.update(is_featured=True) - self.message_user(request, f"{count} photo(s) marked as featured.") - make_featured.short_description = "Mark as featured" - - def remove_featured(self, request, queryset): - """Remove featured status from selected photos.""" - count = queryset.update(is_featured=False) - self.message_user(request, f"{count} photo(s) removed from featured.") - remove_featured.short_description = "Remove featured status" - - -# Inline admin for use in entity admin pages -class PhotoInline(GenericTabularInline): - """Inline admin for photos in entity pages.""" - model = Photo - ct_field = 'content_type' - ct_fk_field = 'object_id' - extra = 0 - fields = ['thumbnail_preview', 'title', 'photo_type', 'moderation_status', 'display_order'] - readonly_fields = ['thumbnail_preview'] - can_delete = True - - def thumbnail_preview(self, obj): - """Display thumbnail preview in inline.""" - if obj.cloudflare_url: - from apps.media.services import CloudFlareService - cf = CloudFlareService() - thumbnail_url = cf.get_image_url(obj.cloudflare_image_id, 'thumbnail') - - return format_html( - '', - thumbnail_url - ) - return "-" - thumbnail_preview.short_description = "Preview" diff --git a/django/apps/media/apps.py b/django/apps/media/apps.py deleted file mode 100644 index 9eab08e5..00000000 --- a/django/apps/media/apps.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -Media app configuration. -""" - -from django.apps import AppConfig - - -class MediaConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.media' - verbose_name = 'Media' diff --git a/django/apps/media/migrations/0001_initial.py b/django/apps/media/migrations/0001_initial.py deleted file mode 100644 index 8296f42b..00000000 --- a/django/apps/media/migrations/0001_initial.py +++ /dev/null @@ -1,253 +0,0 @@ -# Generated by Django 4.2.8 on 2025-11-08 16:41 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import django.utils.timezone -import django_lifecycle.mixins -import model_utils.fields -import uuid - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("contenttypes", "0002_remove_content_type_name"), - ] - - operations = [ - migrations.CreateModel( - name="Photo", - fields=[ - ( - "created", - model_utils.fields.AutoCreatedField( - default=django.utils.timezone.now, - editable=False, - verbose_name="created", - ), - ), - ( - "modified", - model_utils.fields.AutoLastModifiedField( - default=django.utils.timezone.now, - editable=False, - verbose_name="modified", - ), - ), - ( - "id", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ( - "cloudflare_image_id", - models.CharField( - db_index=True, - help_text="Unique CloudFlare image identifier", - max_length=255, - unique=True, - ), - ), - ( - "cloudflare_url", - models.URLField(help_text="CloudFlare CDN URL for the image"), - ), - ( - "cloudflare_thumbnail_url", - models.URLField( - blank=True, - help_text="CloudFlare thumbnail URL (if different from main URL)", - ), - ), - ( - "title", - models.CharField( - blank=True, help_text="Photo title or caption", max_length=255 - ), - ), - ( - "description", - models.TextField( - blank=True, help_text="Photo description or details" - ), - ), - ( - "credit", - models.CharField( - blank=True, - help_text="Photo credit/photographer name", - max_length=255, - ), - ), - ( - "photo_type", - models.CharField( - choices=[ - ("main", "Main Photo"), - ("gallery", "Gallery Photo"), - ("banner", "Banner Image"), - ("logo", "Logo"), - ("thumbnail", "Thumbnail"), - ("other", "Other"), - ], - db_index=True, - default="gallery", - help_text="Type of photo", - max_length=50, - ), - ), - ( - "object_id", - models.UUIDField( - db_index=True, - help_text="ID of the entity this photo belongs to", - ), - ), - ( - "moderation_status", - models.CharField( - choices=[ - ("pending", "Pending Review"), - ("approved", "Approved"), - ("rejected", "Rejected"), - ("flagged", "Flagged"), - ], - db_index=True, - default="pending", - help_text="Moderation status", - max_length=50, - ), - ), - ( - "is_approved", - models.BooleanField( - db_index=True, - default=False, - help_text="Quick filter for approved photos", - ), - ), - ( - "moderated_at", - models.DateTimeField( - blank=True, help_text="When the photo was moderated", null=True - ), - ), - ( - "moderation_notes", - models.TextField(blank=True, help_text="Notes from moderator"), - ), - ( - "width", - models.IntegerField( - blank=True, help_text="Image width in pixels", null=True - ), - ), - ( - "height", - models.IntegerField( - blank=True, help_text="Image height in pixels", null=True - ), - ), - ( - "file_size", - models.IntegerField( - blank=True, help_text="File size in bytes", null=True - ), - ), - ( - "display_order", - models.IntegerField( - db_index=True, - default=0, - help_text="Order for displaying in galleries (lower numbers first)", - ), - ), - ( - "is_featured", - models.BooleanField( - db_index=True, - default=False, - help_text="Is this a featured photo?", - ), - ), - ( - "is_public", - models.BooleanField( - db_index=True, - default=True, - help_text="Is this photo publicly visible?", - ), - ), - ( - "content_type", - models.ForeignKey( - help_text="Type of entity this photo belongs to", - on_delete=django.db.models.deletion.CASCADE, - to="contenttypes.contenttype", - ), - ), - ( - "moderated_by", - models.ForeignKey( - blank=True, - help_text="Moderator who approved/rejected this photo", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="moderated_photos", - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "uploaded_by", - models.ForeignKey( - blank=True, - help_text="User who uploaded this photo", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="uploaded_photos", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "verbose_name": "Photo", - "verbose_name_plural": "Photos", - "ordering": ["display_order", "-created"], - "indexes": [ - models.Index( - fields=["content_type", "object_id"], - name="media_photo_content_0187f5_idx", - ), - models.Index( - fields=["cloudflare_image_id"], - name="media_photo_cloudfl_63ac12_idx", - ), - models.Index( - fields=["moderation_status"], - name="media_photo_moderat_2033b1_idx", - ), - models.Index( - fields=["is_approved"], name="media_photo_is_appr_13ab34_idx" - ), - models.Index( - fields=["uploaded_by"], name="media_photo_uploade_220d3a_idx" - ), - models.Index( - fields=["photo_type"], name="media_photo_photo_t_b387e7_idx" - ), - models.Index( - fields=["display_order"], name="media_photo_display_04e358_idx" - ), - ], - }, - bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model), - ), - ] diff --git a/django/apps/media/migrations/__init__.py b/django/apps/media/migrations/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/django/apps/media/migrations/__pycache__/0001_initial.cpython-313.pyc b/django/apps/media/migrations/__pycache__/0001_initial.cpython-313.pyc deleted file mode 100644 index fe5e135c..00000000 Binary files a/django/apps/media/migrations/__pycache__/0001_initial.cpython-313.pyc and /dev/null differ diff --git a/django/apps/media/migrations/__pycache__/__init__.cpython-313.pyc b/django/apps/media/migrations/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index d91d417b..00000000 Binary files a/django/apps/media/migrations/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/django/apps/media/models.py b/django/apps/media/models.py deleted file mode 100644 index 76e13f7d..00000000 --- a/django/apps/media/models.py +++ /dev/null @@ -1,266 +0,0 @@ -""" -Media models for ThrillWiki Django backend. - -This module contains models for handling media content: -- Photo: CloudFlare Images integration with generic relations -""" -from django.db import models -from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType -from django_lifecycle import hook, AFTER_CREATE, AFTER_UPDATE, BEFORE_SAVE - -from apps.core.models import BaseModel - - -class Photo(BaseModel): - """ - Represents a photo stored in CloudFlare Images. - Uses generic relations to attach to any entity (Park, Ride, Company, etc.) - """ - - PHOTO_TYPE_CHOICES = [ - ('main', 'Main Photo'), - ('gallery', 'Gallery Photo'), - ('banner', 'Banner Image'), - ('logo', 'Logo'), - ('thumbnail', 'Thumbnail'), - ('other', 'Other'), - ] - - MODERATION_STATUS_CHOICES = [ - ('pending', 'Pending Review'), - ('approved', 'Approved'), - ('rejected', 'Rejected'), - ('flagged', 'Flagged'), - ] - - # CloudFlare Image Integration - cloudflare_image_id = models.CharField( - max_length=255, - unique=True, - db_index=True, - help_text="Unique CloudFlare image identifier" - ) - cloudflare_url = models.URLField( - help_text="CloudFlare CDN URL for the image" - ) - cloudflare_thumbnail_url = models.URLField( - blank=True, - help_text="CloudFlare thumbnail URL (if different from main URL)" - ) - - # Metadata - title = models.CharField( - max_length=255, - blank=True, - help_text="Photo title or caption" - ) - description = models.TextField( - blank=True, - help_text="Photo description or details" - ) - credit = models.CharField( - max_length=255, - blank=True, - help_text="Photo credit/photographer name" - ) - - # Photo Type - photo_type = models.CharField( - max_length=50, - choices=PHOTO_TYPE_CHOICES, - default='gallery', - db_index=True, - help_text="Type of photo" - ) - - # Generic relation to attach to any entity - content_type = models.ForeignKey( - ContentType, - on_delete=models.CASCADE, - help_text="Type of entity this photo belongs to" - ) - object_id = models.UUIDField( - db_index=True, - help_text="ID of the entity this photo belongs to" - ) - content_object = GenericForeignKey('content_type', 'object_id') - - # User who uploaded - uploaded_by = models.ForeignKey( - 'users.User', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='uploaded_photos', - help_text="User who uploaded this photo" - ) - - # Moderation - moderation_status = models.CharField( - max_length=50, - choices=MODERATION_STATUS_CHOICES, - default='pending', - db_index=True, - help_text="Moderation status" - ) - is_approved = models.BooleanField( - default=False, - db_index=True, - help_text="Quick filter for approved photos" - ) - moderated_by = models.ForeignKey( - 'users.User', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='moderated_photos', - help_text="Moderator who approved/rejected this photo" - ) - moderated_at = models.DateTimeField( - null=True, - blank=True, - help_text="When the photo was moderated" - ) - moderation_notes = models.TextField( - blank=True, - help_text="Notes from moderator" - ) - - # Image Metadata - width = models.IntegerField( - null=True, - blank=True, - help_text="Image width in pixels" - ) - height = models.IntegerField( - null=True, - blank=True, - help_text="Image height in pixels" - ) - file_size = models.IntegerField( - null=True, - blank=True, - help_text="File size in bytes" - ) - - # Display Order - display_order = models.IntegerField( - default=0, - db_index=True, - help_text="Order for displaying in galleries (lower numbers first)" - ) - - # Visibility - is_featured = models.BooleanField( - default=False, - db_index=True, - help_text="Is this a featured photo?" - ) - is_public = models.BooleanField( - default=True, - db_index=True, - help_text="Is this photo publicly visible?" - ) - - class Meta: - verbose_name = 'Photo' - verbose_name_plural = 'Photos' - ordering = ['display_order', '-created'] - indexes = [ - models.Index(fields=['content_type', 'object_id']), - models.Index(fields=['cloudflare_image_id']), - models.Index(fields=['moderation_status']), - models.Index(fields=['is_approved']), - models.Index(fields=['uploaded_by']), - models.Index(fields=['photo_type']), - models.Index(fields=['display_order']), - ] - - def __str__(self): - if self.title: - return self.title - return f"Photo {self.cloudflare_image_id[:8]}..." - - @hook(AFTER_UPDATE, when='moderation_status', was='pending', is_now='approved') - def set_approved_flag_on_approval(self): - """Set is_approved flag when status changes to approved.""" - self.is_approved = True - self.save(update_fields=['is_approved']) - - @hook(AFTER_UPDATE, when='moderation_status', was='approved', is_not='approved') - def clear_approved_flag_on_rejection(self): - """Clear is_approved flag when status changes from approved.""" - self.is_approved = False - self.save(update_fields=['is_approved']) - - def approve(self, moderator, notes=''): - """Approve this photo.""" - from django.utils import timezone - - self.moderation_status = 'approved' - self.is_approved = True - self.moderated_by = moderator - self.moderated_at = timezone.now() - self.moderation_notes = notes - self.save(update_fields=[ - 'moderation_status', - 'is_approved', - 'moderated_by', - 'moderated_at', - 'moderation_notes' - ]) - - def reject(self, moderator, notes=''): - """Reject this photo.""" - from django.utils import timezone - - self.moderation_status = 'rejected' - self.is_approved = False - self.moderated_by = moderator - self.moderated_at = timezone.now() - self.moderation_notes = notes - self.save(update_fields=[ - 'moderation_status', - 'is_approved', - 'moderated_by', - 'moderated_at', - 'moderation_notes' - ]) - - def flag(self, moderator, notes=''): - """Flag this photo for review.""" - from django.utils import timezone - - self.moderation_status = 'flagged' - self.is_approved = False - self.moderated_by = moderator - self.moderated_at = timezone.now() - self.moderation_notes = notes - self.save(update_fields=[ - 'moderation_status', - 'is_approved', - 'moderated_by', - 'moderated_at', - 'moderation_notes' - ]) - - -class PhotoManager(models.Manager): - """Custom manager for Photo model.""" - - def approved(self): - """Return only approved photos.""" - return self.filter(is_approved=True) - - def pending(self): - """Return only pending photos.""" - return self.filter(moderation_status='pending') - - def public(self): - """Return only public, approved photos.""" - return self.filter(is_approved=True, is_public=True) - - -# Add custom manager to Photo model -Photo.add_to_class('objects', PhotoManager()) diff --git a/django/apps/media/services.py b/django/apps/media/services.py deleted file mode 100644 index 966b1ccc..00000000 --- a/django/apps/media/services.py +++ /dev/null @@ -1,492 +0,0 @@ -""" -Media services for photo upload, management, and CloudFlare Images integration. -""" - -import logging -import mimetypes -import os -from io import BytesIO -from typing import Optional, Dict, Any, List -from django.conf import settings -from django.contrib.contenttypes.models import ContentType -from django.core.files.uploadedfile import InMemoryUploadedFile, TemporaryUploadedFile -from django.db import transaction -from django.db.models import Model - -import requests -from PIL import Image - -from apps.media.models import Photo - -logger = logging.getLogger(__name__) - - -class CloudFlareError(Exception): - """Base exception for CloudFlare API errors.""" - pass - - -class CloudFlareService: - """ - Service for interacting with CloudFlare Images API. - - Provides image upload, deletion, and URL generation with automatic - fallback to mock mode when CloudFlare credentials are not configured. - """ - - def __init__(self): - self.account_id = settings.CLOUDFLARE_ACCOUNT_ID - self.api_token = settings.CLOUDFLARE_IMAGE_TOKEN - self.delivery_hash = settings.CLOUDFLARE_IMAGE_HASH - - # Enable mock mode if CloudFlare is not configured - self.mock_mode = not all([self.account_id, self.api_token, self.delivery_hash]) - - if self.mock_mode: - logger.warning("CloudFlare Images not configured - using mock mode") - - self.base_url = f"https://api.cloudflare.com/client/v4/accounts/{self.account_id}/images/v1" - self.headers = {"Authorization": f"Bearer {self.api_token}"} - - def upload_image( - self, - file: InMemoryUploadedFile | TemporaryUploadedFile, - metadata: Optional[Dict[str, str]] = None - ) -> Dict[str, Any]: - """ - Upload an image to CloudFlare Images. - - Args: - file: The uploaded file object - metadata: Optional metadata dictionary - - Returns: - Dict containing: - - id: CloudFlare image ID - - url: CDN URL for the image - - variants: Available image variants - - Raises: - CloudFlareError: If upload fails - """ - if self.mock_mode: - return self._mock_upload(file, metadata) - - try: - # Prepare the file for upload - file.seek(0) # Reset file pointer - - # Prepare multipart form data - files = { - 'file': (file.name, file.read(), file.content_type) - } - - # Add metadata if provided - data = {} - if metadata: - data['metadata'] = str(metadata) - - # Make API request - response = requests.post( - self.base_url, - headers=self.headers, - files=files, - data=data, - timeout=30 - ) - - response.raise_for_status() - result = response.json() - - if not result.get('success'): - raise CloudFlareError(f"Upload failed: {result.get('errors', [])}") - - image_data = result['result'] - - return { - 'id': image_data['id'], - 'url': self._get_cdn_url(image_data['id']), - 'variants': image_data.get('variants', []), - 'uploaded': image_data.get('uploaded'), - } - - except requests.exceptions.RequestException as e: - logger.error(f"CloudFlare upload failed: {str(e)}") - raise CloudFlareError(f"Failed to upload image: {str(e)}") - - def delete_image(self, image_id: str) -> bool: - """ - Delete an image from CloudFlare Images. - - Args: - image_id: The CloudFlare image ID - - Returns: - True if deletion was successful - - Raises: - CloudFlareError: If deletion fails - """ - if self.mock_mode: - return self._mock_delete(image_id) - - try: - url = f"{self.base_url}/{image_id}" - response = requests.delete( - url, - headers=self.headers, - timeout=30 - ) - - response.raise_for_status() - result = response.json() - - return result.get('success', False) - - except requests.exceptions.RequestException as e: - logger.error(f"CloudFlare deletion failed: {str(e)}") - raise CloudFlareError(f"Failed to delete image: {str(e)}") - - def get_image_url(self, image_id: str, variant: str = "public") -> str: - """ - Generate a CloudFlare CDN URL for an image. - - Args: - image_id: The CloudFlare image ID - variant: Image variant (public, thumbnail, banner, etc.) - - Returns: - CDN URL for the image - """ - if self.mock_mode: - return self._mock_url(image_id, variant) - - return self._get_cdn_url(image_id, variant) - - def get_image_variants(self, image_id: str) -> List[str]: - """ - Get available variants for an image. - - Args: - image_id: The CloudFlare image ID - - Returns: - List of available variant names - """ - if self.mock_mode: - return ['public', 'thumbnail', 'banner'] - - try: - url = f"{self.base_url}/{image_id}" - response = requests.get( - url, - headers=self.headers, - timeout=30 - ) - - response.raise_for_status() - result = response.json() - - if result.get('success'): - return list(result['result'].get('variants', [])) - - return [] - - except requests.exceptions.RequestException as e: - logger.error(f"Failed to get variants: {str(e)}") - return [] - - def _get_cdn_url(self, image_id: str, variant: str = "public") -> str: - """Generate CloudFlare CDN URL.""" - return f"https://imagedelivery.net/{self.delivery_hash}/{image_id}/{variant}" - - # Mock methods for development without CloudFlare - - def _mock_upload(self, file, metadata) -> Dict[str, Any]: - """Mock upload for development.""" - import uuid - mock_id = str(uuid.uuid4()) - - logger.info(f"[MOCK] Uploaded image: {file.name} -> {mock_id}") - - return { - 'id': mock_id, - 'url': self._mock_url(mock_id), - 'variants': ['public', 'thumbnail', 'banner'], - 'uploaded': 'mock', - } - - def _mock_delete(self, image_id: str) -> bool: - """Mock deletion for development.""" - logger.info(f"[MOCK] Deleted image: {image_id}") - return True - - def _mock_url(self, image_id: str, variant: str = "public") -> str: - """Generate mock URL for development.""" - return f"https://placehold.co/800x600/png?text={image_id[:8]}" - - -class PhotoService: - """ - Service for managing Photo objects with CloudFlare integration. - - Handles photo creation, attachment to entities, moderation, - and gallery management. - """ - - def __init__(self): - self.cloudflare = CloudFlareService() - - def create_photo( - self, - file: InMemoryUploadedFile | TemporaryUploadedFile, - user, - entity: Optional[Model] = None, - photo_type: str = "gallery", - title: str = "", - description: str = "", - credit: str = "", - is_visible: bool = True, - ) -> Photo: - """ - Create a new photo with CloudFlare upload. - - Args: - file: Uploaded file object - user: User uploading the photo - entity: Optional entity to attach photo to - photo_type: Type of photo (main, gallery, banner, etc.) - title: Photo title - description: Photo description - credit: Photo credit/attribution - is_visible: Whether photo is visible - - Returns: - Created Photo instance - - Raises: - ValidationError: If validation fails - CloudFlareError: If upload fails - """ - # Get image dimensions - dimensions = self._get_image_dimensions(file) - - # Upload to CloudFlare - upload_result = self.cloudflare.upload_image( - file, - metadata={ - 'uploaded_by': str(user.id), - 'photo_type': photo_type, - } - ) - - # Create Photo instance - with transaction.atomic(): - photo = Photo.objects.create( - cloudflare_image_id=upload_result['id'], - cloudflare_url=upload_result['url'], - uploaded_by=user, - photo_type=photo_type, - title=title or file.name, - description=description, - credit=credit, - width=dimensions['width'], - height=dimensions['height'], - file_size=file.size, - mime_type=file.content_type, - is_visible=is_visible, - moderation_status='pending', - ) - - # Attach to entity if provided - if entity: - self.attach_to_entity(photo, entity) - - logger.info(f"Photo created: {photo.id} by user {user.id}") - - # Trigger async post-processing - try: - from apps.media.tasks import process_uploaded_image - process_uploaded_image.delay(photo.id) - except Exception as e: - # Don't fail the upload if async task fails to queue - logger.warning(f"Failed to queue photo processing task: {str(e)}") - - return photo - - def attach_to_entity(self, photo: Photo, entity: Model) -> None: - """ - Attach a photo to an entity. - - Args: - photo: Photo instance - entity: Entity to attach to (Park, Ride, Company, etc.) - """ - content_type = ContentType.objects.get_for_model(entity) - photo.content_type = content_type - photo.object_id = entity.pk - photo.save(update_fields=['content_type', 'object_id']) - - logger.info(f"Photo {photo.id} attached to {content_type.model} {entity.pk}") - - def detach_from_entity(self, photo: Photo) -> None: - """ - Detach a photo from its entity. - - Args: - photo: Photo instance - """ - photo.content_type = None - photo.object_id = None - photo.save(update_fields=['content_type', 'object_id']) - - logger.info(f"Photo {photo.id} detached from entity") - - def moderate_photo( - self, - photo: Photo, - status: str, - moderator, - notes: str = "" - ) -> Photo: - """ - Moderate a photo (approve/reject/flag). - - Args: - photo: Photo instance - status: New status (approved, rejected, flagged) - moderator: User performing moderation - notes: Moderation notes - - Returns: - Updated Photo instance - """ - with transaction.atomic(): - photo.moderation_status = status - photo.moderated_by = moderator - photo.moderation_notes = notes - - if status == 'approved': - photo.approve() - elif status == 'rejected': - photo.reject() - elif status == 'flagged': - photo.flag() - - photo.save() - - logger.info( - f"Photo {photo.id} moderated: {status} by user {moderator.id}" - ) - - return photo - - def reorder_photos( - self, - entity: Model, - photo_ids: List[int], - photo_type: Optional[str] = None - ) -> None: - """ - Reorder photos for an entity. - - Args: - entity: Entity whose photos to reorder - photo_ids: List of photo IDs in desired order - photo_type: Optional photo type filter - """ - content_type = ContentType.objects.get_for_model(entity) - - with transaction.atomic(): - for order, photo_id in enumerate(photo_ids): - filters = { - 'id': photo_id, - 'content_type': content_type, - 'object_id': entity.pk, - } - - if photo_type: - filters['photo_type'] = photo_type - - Photo.objects.filter(**filters).update(display_order=order) - - logger.info(f"Reordered {len(photo_ids)} photos for {content_type.model} {entity.pk}") - - def get_entity_photos( - self, - entity: Model, - photo_type: Optional[str] = None, - approved_only: bool = True - ) -> List[Photo]: - """ - Get photos for an entity. - - Args: - entity: Entity to get photos for - photo_type: Optional photo type filter - approved_only: Whether to return only approved photos - - Returns: - List of Photo instances ordered by display_order - """ - content_type = ContentType.objects.get_for_model(entity) - - queryset = Photo.objects.filter( - content_type=content_type, - object_id=entity.pk, - ) - - if photo_type: - queryset = queryset.filter(photo_type=photo_type) - - if approved_only: - queryset = queryset.approved() - - return list(queryset.order_by('display_order', '-created_at')) - - def delete_photo(self, photo: Photo, delete_from_cloudflare: bool = True) -> None: - """ - Delete a photo. - - Args: - photo: Photo instance to delete - delete_from_cloudflare: Whether to also delete from CloudFlare - """ - cloudflare_id = photo.cloudflare_image_id - - with transaction.atomic(): - photo.delete() - - # Delete from CloudFlare after DB deletion succeeds - if delete_from_cloudflare and cloudflare_id: - try: - self.cloudflare.delete_image(cloudflare_id) - except CloudFlareError as e: - logger.error(f"Failed to delete from CloudFlare: {str(e)}") - # Don't raise - photo is already deleted from DB - - logger.info(f"Photo deleted: {cloudflare_id}") - - def _get_image_dimensions( - self, - file: InMemoryUploadedFile | TemporaryUploadedFile - ) -> Dict[str, int]: - """ - Extract image dimensions from uploaded file. - - Args: - file: Uploaded file object - - Returns: - Dict with 'width' and 'height' keys - """ - try: - file.seek(0) - image = Image.open(file) - width, height = image.size - file.seek(0) # Reset for later use - - return {'width': width, 'height': height} - except Exception as e: - logger.warning(f"Failed to get image dimensions: {str(e)}") - return {'width': 0, 'height': 0} diff --git a/django/apps/media/tasks.py b/django/apps/media/tasks.py deleted file mode 100644 index 1ceb5e71..00000000 --- a/django/apps/media/tasks.py +++ /dev/null @@ -1,219 +0,0 @@ -""" -Background tasks for media processing and management. -""" - -import logging -from celery import shared_task -from django.utils import timezone -from datetime import timedelta - -logger = logging.getLogger(__name__) - - -@shared_task(bind=True, max_retries=3, default_retry_delay=60) -def process_uploaded_image(self, photo_id): - """ - Process an uploaded image asynchronously. - - This task runs after a photo is uploaded to perform additional - processing like metadata extraction, validation, etc. - - Args: - photo_id: ID of the Photo to process - - Returns: - str: Processing result message - """ - from apps.media.models import Photo - - try: - photo = Photo.objects.get(id=photo_id) - - # Log processing start - logger.info(f"Processing photo {photo_id}: {photo.title}") - - # Additional processing could include: - # - Generating additional thumbnails - # - Extracting EXIF data - # - Running image quality checks - # - Updating photo metadata - - # For now, just log that processing is complete - logger.info(f"Photo {photo_id} processed successfully") - - return f"Photo {photo_id} processed successfully" - - except Photo.DoesNotExist: - logger.error(f"Photo {photo_id} not found") - raise - except Exception as exc: - logger.error(f"Error processing photo {photo_id}: {str(exc)}") - # Retry with exponential backoff - raise self.retry(exc=exc, countdown=60 * (2 ** self.request.retries)) - - -@shared_task(bind=True, max_retries=2) -def cleanup_rejected_photos(self, days_old=30): - """ - Clean up photos that have been rejected for more than N days. - - This task runs periodically (e.g., weekly) to remove old rejected - photos and free up storage space. - - Args: - days_old: Number of days after rejection to delete (default: 30) - - Returns: - dict: Cleanup statistics - """ - from apps.media.models import Photo - from apps.media.services import PhotoService - - try: - cutoff_date = timezone.now() - timedelta(days=days_old) - - # Find rejected photos older than cutoff - old_rejected = Photo.objects.filter( - moderation_status='rejected', - moderated_at__lt=cutoff_date - ) - - count = old_rejected.count() - logger.info(f"Found {count} rejected photos to cleanup") - - # Delete each photo - photo_service = PhotoService() - deleted_count = 0 - - for photo in old_rejected: - try: - photo_service.delete_photo(photo, delete_from_cloudflare=True) - deleted_count += 1 - except Exception as e: - logger.error(f"Failed to delete photo {photo.id}: {str(e)}") - continue - - result = { - 'found': count, - 'deleted': deleted_count, - 'failed': count - deleted_count, - 'cutoff_date': cutoff_date.isoformat() - } - - logger.info(f"Cleanup complete: {result}") - return result - - except Exception as exc: - logger.error(f"Error during photo cleanup: {str(exc)}") - raise self.retry(exc=exc, countdown=300) # Retry after 5 minutes - - -@shared_task(bind=True, max_retries=3) -def generate_photo_thumbnails(self, photo_id, variants=None): - """ - Generate thumbnails for a photo on demand. - - This can be used to regenerate thumbnails if the original - is updated or if new variants are needed. - - Args: - photo_id: ID of the Photo - variants: List of variant names to generate (None = all) - - Returns: - dict: Generated variants and their URLs - """ - from apps.media.models import Photo - from apps.media.services import CloudFlareService - - try: - photo = Photo.objects.get(id=photo_id) - cloudflare = CloudFlareService() - - if variants is None: - variants = ['public', 'thumbnail', 'banner'] - - result = {} - for variant in variants: - url = cloudflare.get_image_url(photo.cloudflare_image_id, variant) - result[variant] = url - - logger.info(f"Generated thumbnails for photo {photo_id}: {variants}") - return result - - except Photo.DoesNotExist: - logger.error(f"Photo {photo_id} not found") - raise - except Exception as exc: - logger.error(f"Error generating thumbnails for photo {photo_id}: {str(exc)}") - raise self.retry(exc=exc, countdown=60 * (2 ** self.request.retries)) - - -@shared_task(bind=True, max_retries=2) -def cleanup_orphaned_cloudflare_images(self): - """ - Clean up CloudFlare images that no longer have database records. - - This task helps prevent storage bloat by removing images that - were uploaded but their database records were deleted. - - Returns: - dict: Cleanup statistics - """ - from apps.media.models import Photo - from apps.media.services import CloudFlareService - - try: - cloudflare = CloudFlareService() - - # In a real implementation, you would: - # 1. Get list of all images from CloudFlare API - # 2. Check which ones don't have Photo records - # 3. Delete the orphaned images - - # For now, just log that the task ran - logger.info("Orphaned image cleanup task completed (not implemented in mock mode)") - - return { - 'checked': 0, - 'orphaned': 0, - 'deleted': 0 - } - - except Exception as exc: - logger.error(f"Error during orphaned image cleanup: {str(exc)}") - raise self.retry(exc=exc, countdown=300) - - -@shared_task -def update_photo_statistics(): - """ - Update photo-related statistics across the database. - - This task can update cached counts, generate reports, etc. - - Returns: - dict: Updated statistics - """ - from apps.media.models import Photo - from django.db.models import Count - - try: - stats = { - 'total_photos': Photo.objects.count(), - 'pending': Photo.objects.filter(moderation_status='pending').count(), - 'approved': Photo.objects.filter(moderation_status='approved').count(), - 'rejected': Photo.objects.filter(moderation_status='rejected').count(), - 'flagged': Photo.objects.filter(moderation_status='flagged').count(), - 'by_type': dict( - Photo.objects.values('photo_type').annotate(count=Count('id')) - .values_list('photo_type', 'count') - ) - } - - logger.info(f"Photo statistics updated: {stats}") - return stats - - except Exception as e: - logger.error(f"Error updating photo statistics: {str(e)}") - raise diff --git a/django/apps/media/validators.py b/django/apps/media/validators.py deleted file mode 100644 index fb31156a..00000000 --- a/django/apps/media/validators.py +++ /dev/null @@ -1,195 +0,0 @@ -""" -Validators for image uploads. -""" - -import magic -from django.core.exceptions import ValidationError -from django.core.files.uploadedfile import InMemoryUploadedFile, TemporaryUploadedFile -from PIL import Image -from typing import Optional - - -# Allowed file types -ALLOWED_MIME_TYPES = [ - 'image/jpeg', - 'image/jpg', - 'image/png', - 'image/webp', - 'image/gif', -] - -ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp', '.gif'] - -# Size limits (in bytes) -MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB -MIN_FILE_SIZE = 1024 # 1 KB - -# Dimension limits -MIN_WIDTH = 100 -MIN_HEIGHT = 100 -MAX_WIDTH = 8000 -MAX_HEIGHT = 8000 - -# Aspect ratio limits (for specific photo types) -ASPECT_RATIO_LIMITS = { - 'banner': {'min': 2.0, 'max': 4.0}, # Wide banners - 'logo': {'min': 0.5, 'max': 2.0}, # Square-ish logos -} - - -def validate_image_file_type(file: InMemoryUploadedFile | TemporaryUploadedFile) -> None: - """ - Validate that the uploaded file is an allowed image type. - - Uses python-magic to detect actual file type, not just extension. - - Args: - file: The uploaded file object - - Raises: - ValidationError: If file type is not allowed - """ - # Check file extension - file_ext = None - if hasattr(file, 'name') and file.name: - file_ext = '.' + file.name.split('.')[-1].lower() - if file_ext not in ALLOWED_EXTENSIONS: - raise ValidationError( - f"File extension {file_ext} not allowed. " - f"Allowed extensions: {', '.join(ALLOWED_EXTENSIONS)}" - ) - - # Check MIME type from content type - if hasattr(file, 'content_type'): - if file.content_type not in ALLOWED_MIME_TYPES: - raise ValidationError( - f"File type {file.content_type} not allowed. " - f"Allowed types: {', '.join(ALLOWED_MIME_TYPES)}" - ) - - # Verify actual file content using python-magic - try: - file.seek(0) - mime = magic.from_buffer(file.read(2048), mime=True) - file.seek(0) - - if mime not in ALLOWED_MIME_TYPES: - raise ValidationError( - f"File content type {mime} does not match allowed types. " - "File may be corrupted or incorrectly labeled." - ) - except Exception as e: - # If magic fails, we already validated content_type above - pass - - -def validate_image_file_size(file: InMemoryUploadedFile | TemporaryUploadedFile) -> None: - """ - Validate that the file size is within allowed limits. - - Args: - file: The uploaded file object - - Raises: - ValidationError: If file size is not within limits - """ - file_size = file.size - - if file_size < MIN_FILE_SIZE: - raise ValidationError( - f"File size is too small. Minimum: {MIN_FILE_SIZE / 1024:.0f} KB" - ) - - if file_size > MAX_FILE_SIZE: - raise ValidationError( - f"File size is too large. Maximum: {MAX_FILE_SIZE / (1024 * 1024):.0f} MB" - ) - - -def validate_image_dimensions( - file: InMemoryUploadedFile | TemporaryUploadedFile, - photo_type: Optional[str] = None -) -> None: - """ - Validate image dimensions and aspect ratio. - - Args: - file: The uploaded file object - photo_type: Optional photo type for specific validation - - Raises: - ValidationError: If dimensions are not within limits - """ - try: - file.seek(0) - image = Image.open(file) - width, height = image.size - file.seek(0) - except Exception as e: - raise ValidationError(f"Could not read image dimensions: {str(e)}") - - # Check minimum dimensions - if width < MIN_WIDTH or height < MIN_HEIGHT: - raise ValidationError( - f"Image dimensions too small. Minimum: {MIN_WIDTH}x{MIN_HEIGHT}px, " - f"got: {width}x{height}px" - ) - - # Check maximum dimensions - if width > MAX_WIDTH or height > MAX_HEIGHT: - raise ValidationError( - f"Image dimensions too large. Maximum: {MAX_WIDTH}x{MAX_HEIGHT}px, " - f"got: {width}x{height}px" - ) - - # Check aspect ratio for specific photo types - if photo_type and photo_type in ASPECT_RATIO_LIMITS: - aspect_ratio = width / height - limits = ASPECT_RATIO_LIMITS[photo_type] - - if aspect_ratio < limits['min'] or aspect_ratio > limits['max']: - raise ValidationError( - f"Invalid aspect ratio for {photo_type}. " - f"Expected ratio between {limits['min']:.2f} and {limits['max']:.2f}, " - f"got: {aspect_ratio:.2f}" - ) - - -def validate_image( - file: InMemoryUploadedFile | TemporaryUploadedFile, - photo_type: Optional[str] = None -) -> None: - """ - Run all image validations. - - Args: - file: The uploaded file object - photo_type: Optional photo type for specific validation - - Raises: - ValidationError: If any validation fails - """ - validate_image_file_type(file) - validate_image_file_size(file) - validate_image_dimensions(file, photo_type) - - -def validate_image_content_safety(file: InMemoryUploadedFile | TemporaryUploadedFile) -> None: - """ - Placeholder for content safety validation. - - This could integrate with services like: - - AWS Rekognition - - Google Cloud Vision - - Azure Content Moderator - - For now, this is a no-op but provides extension point. - - Args: - file: The uploaded file object - - Raises: - ValidationError: If content is deemed unsafe - """ - # TODO: Integrate with content moderation API - pass diff --git a/django/apps/moderation/__init__.py b/django/apps/moderation/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/django/apps/moderation/__pycache__/__init__.cpython-313.pyc b/django/apps/moderation/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 496944e2..00000000 Binary files a/django/apps/moderation/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/django/apps/moderation/__pycache__/admin.cpython-313.pyc b/django/apps/moderation/__pycache__/admin.cpython-313.pyc deleted file mode 100644 index ce06faab..00000000 Binary files a/django/apps/moderation/__pycache__/admin.cpython-313.pyc and /dev/null differ diff --git a/django/apps/moderation/__pycache__/apps.cpython-313.pyc b/django/apps/moderation/__pycache__/apps.cpython-313.pyc deleted file mode 100644 index a5d69eb8..00000000 Binary files a/django/apps/moderation/__pycache__/apps.cpython-313.pyc and /dev/null differ diff --git a/django/apps/moderation/__pycache__/models.cpython-313.pyc b/django/apps/moderation/__pycache__/models.cpython-313.pyc deleted file mode 100644 index 57defbb2..00000000 Binary files a/django/apps/moderation/__pycache__/models.cpython-313.pyc and /dev/null differ diff --git a/django/apps/moderation/__pycache__/services.cpython-313.pyc b/django/apps/moderation/__pycache__/services.cpython-313.pyc deleted file mode 100644 index 2830b931..00000000 Binary files a/django/apps/moderation/__pycache__/services.cpython-313.pyc and /dev/null differ diff --git a/django/apps/moderation/admin.py b/django/apps/moderation/admin.py deleted file mode 100644 index 935663d8..00000000 --- a/django/apps/moderation/admin.py +++ /dev/null @@ -1,424 +0,0 @@ -""" -Django admin for moderation models. -""" -from django.contrib import admin -from django.utils.html import format_html -from django.urls import reverse -from django.utils import timezone -from unfold.admin import ModelAdmin -from unfold.decorators import display - -from apps.moderation.models import ContentSubmission, SubmissionItem, ModerationLock - - -@admin.register(ContentSubmission) -class ContentSubmissionAdmin(ModelAdmin): - """Admin for ContentSubmission model.""" - - list_display = [ - 'title_with_icon', - 'status_badge', - 'entity_info', - 'user', - 'items_summary', - 'locked_info', - 'created', - ] - - list_filter = [ - 'status', - 'submission_type', - 'entity_type', - 'created', - ] - - search_fields = [ - 'title', - 'description', - 'user__email', - 'user__username', - ] - - readonly_fields = [ - 'id', - 'status', - 'entity_type', - 'entity_id', - 'locked_by', - 'locked_at', - 'reviewed_by', - 'reviewed_at', - 'created', - 'modified', - ] - - fieldsets = ( - ('Submission Info', { - 'fields': ( - 'id', - 'title', - 'description', - 'submission_type', - 'status', - ) - }), - ('Entity', { - 'fields': ( - 'entity_type', - 'entity_id', - ) - }), - ('User Info', { - 'fields': ( - 'user', - 'source', - 'ip_address', - 'user_agent', - ) - }), - ('Review Info', { - 'fields': ( - 'locked_by', - 'locked_at', - 'reviewed_by', - 'reviewed_at', - 'rejection_reason', - ) - }), - ('Metadata', { - 'fields': ( - 'metadata', - 'created', - 'modified', - ), - 'classes': ('collapse',) - }), - ) - - @display(description='Title', ordering='title') - def title_with_icon(self, obj): - """Display title with submission type icon.""" - icons = { - 'create': '➕', - 'update': '✏️', - 'delete': '🗑️', - } - icon = icons.get(obj.submission_type, '📝') - return f"{icon} {obj.title}" - - @display(description='Status', ordering='status') - def status_badge(self, obj): - """Display colored status badge.""" - colors = { - 'draft': 'gray', - 'pending': 'blue', - 'reviewing': 'orange', - 'approved': 'green', - 'rejected': 'red', - } - color = colors.get(obj.status, 'gray') - return format_html( - '{}', - color, - obj.get_status_display() - ) - - @display(description='Entity') - def entity_info(self, obj): - """Display entity type and ID.""" - return f"{obj.entity_type.model} #{str(obj.entity_id)[:8]}" - - @display(description='Items') - def items_summary(self, obj): - """Display item counts.""" - total = obj.get_items_count() - approved = obj.get_approved_items_count() - rejected = obj.get_rejected_items_count() - pending = total - approved - rejected - - return format_html( - '{} / ' - '{} / ' - '{}', - pending, approved, rejected - ) - - @display(description='Lock Status') - def locked_info(self, obj): - """Display lock information.""" - if obj.locked_by: - is_expired = not obj.is_locked() - status = '🔓 Expired' if is_expired else '🔒 Locked' - return f"{status} by {obj.locked_by.email}" - return '✅ Unlocked' - - def get_queryset(self, request): - """Optimize queryset with select_related.""" - qs = super().get_queryset(request) - return qs.select_related( - 'user', - 'entity_type', - 'locked_by', - 'reviewed_by' - ).prefetch_related('items') - - -class SubmissionItemInline(admin.TabularInline): - """Inline admin for submission items.""" - model = SubmissionItem - extra = 0 - fields = [ - 'field_label', - 'old_value_display', - 'new_value_display', - 'change_type', - 'status', - 'reviewed_by', - ] - readonly_fields = [ - 'field_label', - 'old_value_display', - 'new_value_display', - 'change_type', - 'status', - 'reviewed_by', - ] - can_delete = False - - def has_add_permission(self, request, obj=None): - return False - - -@admin.register(SubmissionItem) -class SubmissionItemAdmin(ModelAdmin): - """Admin for SubmissionItem model.""" - - list_display = [ - 'field_label', - 'submission_title', - 'change_type_badge', - 'status_badge', - 'old_value_display', - 'new_value_display', - 'reviewed_by', - ] - - list_filter = [ - 'status', - 'change_type', - 'is_required', - 'created', - ] - - search_fields = [ - 'field_name', - 'field_label', - 'submission__title', - ] - - readonly_fields = [ - 'id', - 'submission', - 'field_name', - 'field_label', - 'old_value', - 'new_value', - 'old_value_display', - 'new_value_display', - 'status', - 'reviewed_by', - 'reviewed_at', - 'created', - 'modified', - ] - - fieldsets = ( - ('Item Info', { - 'fields': ( - 'id', - 'submission', - 'field_name', - 'field_label', - 'change_type', - 'is_required', - 'order', - ) - }), - ('Values', { - 'fields': ( - 'old_value', - 'new_value', - 'old_value_display', - 'new_value_display', - ) - }), - ('Review Info', { - 'fields': ( - 'status', - 'reviewed_by', - 'reviewed_at', - 'rejection_reason', - ) - }), - ('Timestamps', { - 'fields': ( - 'created', - 'modified', - ) - }), - ) - - @display(description='Submission') - def submission_title(self, obj): - """Display submission title with link.""" - url = reverse('admin:moderation_contentsubmission_change', args=[obj.submission.id]) - return format_html('{}', url, obj.submission.title) - - @display(description='Type', ordering='change_type') - def change_type_badge(self, obj): - """Display colored change type badge.""" - colors = { - 'add': 'green', - 'modify': 'blue', - 'remove': 'red', - } - color = colors.get(obj.change_type, 'gray') - return format_html( - '{}', - color, - obj.get_change_type_display() - ) - - @display(description='Status', ordering='status') - def status_badge(self, obj): - """Display colored status badge.""" - colors = { - 'pending': 'orange', - 'approved': 'green', - 'rejected': 'red', - } - color = colors.get(obj.status, 'gray') - return format_html( - '{}', - color, - obj.get_status_display() - ) - - def get_queryset(self, request): - """Optimize queryset with select_related.""" - qs = super().get_queryset(request) - return qs.select_related('submission', 'reviewed_by') - - -@admin.register(ModerationLock) -class ModerationLockAdmin(ModelAdmin): - """Admin for ModerationLock model.""" - - list_display = [ - 'submission_title', - 'locked_by', - 'locked_at', - 'expires_at', - 'status_indicator', - 'lock_duration', - ] - - list_filter = [ - 'is_active', - 'locked_at', - 'expires_at', - ] - - search_fields = [ - 'submission__title', - 'locked_by__email', - 'locked_by__username', - ] - - readonly_fields = [ - 'id', - 'submission', - 'locked_by', - 'locked_at', - 'expires_at', - 'is_active', - 'released_at', - 'lock_duration', - 'is_expired_display', - 'created', - 'modified', - ] - - fieldsets = ( - ('Lock Info', { - 'fields': ( - 'id', - 'submission', - 'locked_by', - 'is_active', - ) - }), - ('Timing', { - 'fields': ( - 'locked_at', - 'expires_at', - 'released_at', - 'lock_duration', - 'is_expired_display', - ) - }), - ('Timestamps', { - 'fields': ( - 'created', - 'modified', - ) - }), - ) - - @display(description='Submission') - def submission_title(self, obj): - """Display submission title with link.""" - url = reverse('admin:moderation_contentsubmission_change', args=[obj.submission.id]) - return format_html('{}', url, obj.submission.title) - - @display(description='Status') - def status_indicator(self, obj): - """Display lock status.""" - if not obj.is_active: - return format_html( - '🔓 Released' - ) - elif obj.is_expired(): - return format_html( - '⏰ Expired' - ) - else: - return format_html( - '🔒 Active' - ) - - @display(description='Duration') - def lock_duration(self, obj): - """Display lock duration.""" - if obj.released_at: - duration = obj.released_at - obj.locked_at - else: - duration = timezone.now() - obj.locked_at - - minutes = int(duration.total_seconds() / 60) - return f"{minutes} minutes" - - @display(description='Expired?') - def is_expired_display(self, obj): - """Display if lock is expired.""" - if not obj.is_active: - return 'N/A (Released)' - return 'Yes' if obj.is_expired() else 'No' - - def get_queryset(self, request): - """Optimize queryset with select_related.""" - qs = super().get_queryset(request) - return qs.select_related('submission', 'locked_by') diff --git a/django/apps/moderation/apps.py b/django/apps/moderation/apps.py deleted file mode 100644 index 7989d7f3..00000000 --- a/django/apps/moderation/apps.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -Moderation app configuration. -""" - -from django.apps import AppConfig - - -class ModerationConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.moderation' - verbose_name = 'Moderation' diff --git a/django/apps/moderation/migrations/0001_initial.py b/django/apps/moderation/migrations/0001_initial.py deleted file mode 100644 index 54d804f6..00000000 --- a/django/apps/moderation/migrations/0001_initial.py +++ /dev/null @@ -1,454 +0,0 @@ -# Generated by Django 4.2.8 on 2025-11-08 17:40 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import django.utils.timezone -import django_fsm -import django_lifecycle.mixins -import model_utils.fields -import uuid - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("contenttypes", "0002_remove_content_type_name"), - ] - - operations = [ - migrations.CreateModel( - name="ContentSubmission", - fields=[ - ( - "created", - model_utils.fields.AutoCreatedField( - default=django.utils.timezone.now, - editable=False, - verbose_name="created", - ), - ), - ( - "modified", - model_utils.fields.AutoLastModifiedField( - default=django.utils.timezone.now, - editable=False, - verbose_name="modified", - ), - ), - ( - "id", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ( - "status", - django_fsm.FSMField( - choices=[ - ("draft", "Draft"), - ("pending", "Pending Review"), - ("reviewing", "Under Review"), - ("approved", "Approved"), - ("rejected", "Rejected"), - ], - db_index=True, - default="draft", - help_text="Current submission state (managed by FSM)", - max_length=20, - protected=True, - ), - ), - ( - "entity_id", - models.UUIDField(help_text="ID of the entity being modified"), - ), - ( - "submission_type", - models.CharField( - choices=[ - ("create", "Create"), - ("update", "Update"), - ("delete", "Delete"), - ], - db_index=True, - help_text="Type of operation (create, update, delete)", - max_length=20, - ), - ), - ( - "title", - models.CharField( - help_text="Brief description of changes", max_length=255 - ), - ), - ( - "description", - models.TextField( - blank=True, help_text="Detailed description of changes" - ), - ), - ( - "locked_at", - models.DateTimeField( - blank=True, - help_text="When the submission was locked for review", - null=True, - ), - ), - ( - "reviewed_at", - models.DateTimeField( - blank=True, - help_text="When the submission was reviewed", - null=True, - ), - ), - ( - "rejection_reason", - models.TextField( - blank=True, help_text="Reason for rejection (if rejected)" - ), - ), - ( - "source", - models.CharField( - default="web", - help_text="Source of submission (web, api, mobile, etc.)", - max_length=50, - ), - ), - ( - "ip_address", - models.GenericIPAddressField( - blank=True, help_text="IP address of submitter", null=True - ), - ), - ( - "user_agent", - models.CharField( - blank=True, help_text="User agent of submitter", max_length=500 - ), - ), - ( - "metadata", - models.JSONField( - blank=True, - default=dict, - help_text="Additional submission metadata", - ), - ), - ( - "entity_type", - models.ForeignKey( - help_text="Type of entity being modified", - on_delete=django.db.models.deletion.CASCADE, - to="contenttypes.contenttype", - ), - ), - ( - "locked_by", - models.ForeignKey( - blank=True, - help_text="Moderator currently reviewing this submission", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="locked_submissions", - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "reviewed_by", - models.ForeignKey( - blank=True, - help_text="Moderator who reviewed this submission", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="reviewed_submissions", - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "user", - models.ForeignKey( - help_text="User who submitted the changes", - on_delete=django.db.models.deletion.CASCADE, - related_name="submissions", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "verbose_name": "Content Submission", - "verbose_name_plural": "Content Submissions", - "db_table": "content_submissions", - "ordering": ["-created"], - }, - bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model), - ), - migrations.CreateModel( - name="SubmissionItem", - fields=[ - ( - "created", - model_utils.fields.AutoCreatedField( - default=django.utils.timezone.now, - editable=False, - verbose_name="created", - ), - ), - ( - "modified", - model_utils.fields.AutoLastModifiedField( - default=django.utils.timezone.now, - editable=False, - verbose_name="modified", - ), - ), - ( - "id", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ( - "field_name", - models.CharField( - help_text="Name of the field being changed", max_length=100 - ), - ), - ( - "field_label", - models.CharField( - blank=True, - help_text="Human-readable field label", - max_length=200, - ), - ), - ( - "old_value", - models.JSONField( - blank=True, - help_text="Previous value (null for new fields)", - null=True, - ), - ), - ( - "new_value", - models.JSONField( - blank=True, - help_text="New value (null for deletions)", - null=True, - ), - ), - ( - "status", - models.CharField( - choices=[ - ("pending", "Pending"), - ("approved", "Approved"), - ("rejected", "Rejected"), - ], - db_index=True, - default="pending", - help_text="Status of this individual item", - max_length=20, - ), - ), - ( - "reviewed_at", - models.DateTimeField( - blank=True, help_text="When this item was reviewed", null=True - ), - ), - ( - "rejection_reason", - models.TextField( - blank=True, help_text="Reason for rejecting this specific item" - ), - ), - ( - "change_type", - models.CharField( - choices=[ - ("add", "Add"), - ("modify", "Modify"), - ("remove", "Remove"), - ], - default="modify", - help_text="Type of change", - max_length=20, - ), - ), - ( - "is_required", - models.BooleanField( - default=False, - help_text="Whether this change is required for the submission", - ), - ), - ( - "order", - models.IntegerField( - default=0, help_text="Display order within submission" - ), - ), - ( - "reviewed_by", - models.ForeignKey( - blank=True, - help_text="Moderator who reviewed this item", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="reviewed_items", - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "submission", - models.ForeignKey( - help_text="Parent submission", - on_delete=django.db.models.deletion.CASCADE, - related_name="items", - to="moderation.contentsubmission", - ), - ), - ], - options={ - "verbose_name": "Submission Item", - "verbose_name_plural": "Submission Items", - "db_table": "submission_items", - "ordering": ["submission", "order", "created"], - "indexes": [ - models.Index( - fields=["submission", "status"], - name="submission__submiss_71cf2f_idx", - ), - models.Index( - fields=["status"], name="submission__status_61deb1_idx" - ), - ], - }, - bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model), - ), - migrations.CreateModel( - name="ModerationLock", - fields=[ - ( - "created", - model_utils.fields.AutoCreatedField( - default=django.utils.timezone.now, - editable=False, - verbose_name="created", - ), - ), - ( - "modified", - model_utils.fields.AutoLastModifiedField( - default=django.utils.timezone.now, - editable=False, - verbose_name="modified", - ), - ), - ( - "id", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ( - "locked_at", - models.DateTimeField( - auto_now_add=True, help_text="When the lock was acquired" - ), - ), - ("expires_at", models.DateTimeField(help_text="When the lock expires")), - ( - "is_active", - models.BooleanField( - db_index=True, - default=True, - help_text="Whether the lock is currently active", - ), - ), - ( - "released_at", - models.DateTimeField( - blank=True, help_text="When the lock was released", null=True - ), - ), - ( - "locked_by", - models.ForeignKey( - help_text="User who holds the lock", - on_delete=django.db.models.deletion.CASCADE, - related_name="moderation_locks", - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "submission", - models.OneToOneField( - help_text="Submission that is locked", - on_delete=django.db.models.deletion.CASCADE, - related_name="lock_record", - to="moderation.contentsubmission", - ), - ), - ], - options={ - "verbose_name": "Moderation Lock", - "verbose_name_plural": "Moderation Locks", - "db_table": "moderation_locks", - "ordering": ["-locked_at"], - "indexes": [ - models.Index( - fields=["is_active", "expires_at"], - name="moderation__is_acti_ecf427_idx", - ), - models.Index( - fields=["locked_by", "is_active"], - name="moderation__locked__d5cdfb_idx", - ), - ], - }, - bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model), - ), - migrations.AddIndex( - model_name="contentsubmission", - index=models.Index( - fields=["status", "created"], name="content_sub_status_a8d552_idx" - ), - ), - migrations.AddIndex( - model_name="contentsubmission", - index=models.Index( - fields=["user", "status"], name="content_sub_user_id_019595_idx" - ), - ), - migrations.AddIndex( - model_name="contentsubmission", - index=models.Index( - fields=["entity_type", "entity_id"], - name="content_sub_entity__d0f313_idx", - ), - ), - migrations.AddIndex( - model_name="contentsubmission", - index=models.Index( - fields=["locked_by", "locked_at"], name="content_sub_locked__feb2b3_idx" - ), - ), - ] diff --git a/django/apps/moderation/migrations/__init__.py b/django/apps/moderation/migrations/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/django/apps/moderation/migrations/__pycache__/0001_initial.cpython-313.pyc b/django/apps/moderation/migrations/__pycache__/0001_initial.cpython-313.pyc deleted file mode 100644 index cc2997dd..00000000 Binary files a/django/apps/moderation/migrations/__pycache__/0001_initial.cpython-313.pyc and /dev/null differ diff --git a/django/apps/moderation/migrations/__pycache__/__init__.cpython-313.pyc b/django/apps/moderation/migrations/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 8898df0f..00000000 Binary files a/django/apps/moderation/migrations/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/django/apps/moderation/models.py b/django/apps/moderation/models.py deleted file mode 100644 index 4a28127b..00000000 --- a/django/apps/moderation/models.py +++ /dev/null @@ -1,477 +0,0 @@ -""" -Moderation models for ThrillWiki. - -This module implements the content submission and approval workflow with: -- State machine using django-fsm -- Atomic transaction support for approvals -- 15-minute review lock mechanism -- Selective approval of individual items -""" - -import uuid -from django.db import models -from django.utils import timezone -from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType -from django_fsm import FSMField, transition -from apps.core.models import BaseModel - - -class ContentSubmission(BaseModel): - """ - Main submission model with FSM state machine. - - Represents a batch of changes submitted by a user for moderation. - Can contain multiple SubmissionItem objects representing individual field changes. - """ - - # State choices for FSM - STATE_DRAFT = 'draft' - STATE_PENDING = 'pending' - STATE_REVIEWING = 'reviewing' - STATE_APPROVED = 'approved' - STATE_REJECTED = 'rejected' - - STATE_CHOICES = [ - (STATE_DRAFT, 'Draft'), - (STATE_PENDING, 'Pending Review'), - (STATE_REVIEWING, 'Under Review'), - (STATE_APPROVED, 'Approved'), - (STATE_REJECTED, 'Rejected'), - ] - - # FSM State field - status = FSMField( - max_length=20, - choices=STATE_CHOICES, - default=STATE_DRAFT, - db_index=True, - protected=True, # Prevents direct status changes - help_text="Current submission state (managed by FSM)" - ) - - # Submitter - user = models.ForeignKey( - 'users.User', - on_delete=models.CASCADE, - related_name='submissions', - help_text="User who submitted the changes" - ) - - # Entity being modified (generic relation) - entity_type = models.ForeignKey( - ContentType, - on_delete=models.CASCADE, - help_text="Type of entity being modified" - ) - entity_id = models.UUIDField( - help_text="ID of the entity being modified" - ) - entity = GenericForeignKey('entity_type', 'entity_id') - - # Submission type - SUBMISSION_TYPE_CHOICES = [ - ('create', 'Create'), - ('update', 'Update'), - ('delete', 'Delete'), - ] - - submission_type = models.CharField( - max_length=20, - choices=SUBMISSION_TYPE_CHOICES, - db_index=True, - help_text="Type of operation (create, update, delete)" - ) - - # Submission details - title = models.CharField( - max_length=255, - help_text="Brief description of changes" - ) - description = models.TextField( - blank=True, - help_text="Detailed description of changes" - ) - - # Review lock mechanism (15-minute lock) - locked_by = models.ForeignKey( - 'users.User', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='locked_submissions', - help_text="Moderator currently reviewing this submission" - ) - locked_at = models.DateTimeField( - null=True, - blank=True, - help_text="When the submission was locked for review" - ) - - # Review details - reviewed_by = models.ForeignKey( - 'users.User', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='reviewed_submissions', - help_text="Moderator who reviewed this submission" - ) - reviewed_at = models.DateTimeField( - null=True, - blank=True, - help_text="When the submission was reviewed" - ) - rejection_reason = models.TextField( - blank=True, - help_text="Reason for rejection (if rejected)" - ) - - # Metadata - source = models.CharField( - max_length=50, - default='web', - help_text="Source of submission (web, api, mobile, etc.)" - ) - ip_address = models.GenericIPAddressField( - null=True, - blank=True, - help_text="IP address of submitter" - ) - user_agent = models.CharField( - max_length=500, - blank=True, - help_text="User agent of submitter" - ) - - # Additional data - metadata = models.JSONField( - default=dict, - blank=True, - help_text="Additional submission metadata" - ) - - class Meta: - db_table = 'content_submissions' - ordering = ['-created'] - indexes = [ - models.Index(fields=['status', 'created']), - models.Index(fields=['user', 'status']), - models.Index(fields=['entity_type', 'entity_id']), - models.Index(fields=['locked_by', 'locked_at']), - ] - verbose_name = 'Content Submission' - verbose_name_plural = 'Content Submissions' - - def __str__(self): - return f"{self.get_submission_type_display()} - {self.title} ({self.get_status_display()})" - - # FSM Transitions - - @transition(field=status, source=STATE_DRAFT, target=STATE_PENDING) - def submit(self): - """Submit for review - moves from draft to pending""" - pass - - @transition(field=status, source=STATE_PENDING, target=STATE_REVIEWING) - def start_review(self, reviewer): - """Lock submission for review""" - self.locked_by = reviewer - self.locked_at = timezone.now() - - @transition(field=status, source=STATE_REVIEWING, target=STATE_APPROVED) - def approve(self, reviewer): - """Approve submission""" - self.reviewed_by = reviewer - self.reviewed_at = timezone.now() - self.locked_by = None - self.locked_at = None - - @transition(field=status, source=STATE_REVIEWING, target=STATE_REJECTED) - def reject(self, reviewer, reason): - """Reject submission""" - self.reviewed_by = reviewer - self.reviewed_at = timezone.now() - self.rejection_reason = reason - self.locked_by = None - self.locked_at = None - - @transition(field=status, source=STATE_REVIEWING, target=STATE_PENDING) - def unlock(self): - """Unlock submission (timeout or manual unlock)""" - self.locked_by = None - self.locked_at = None - - # Helper methods - - def is_locked(self): - """Check if submission is currently locked""" - if not self.locked_by or not self.locked_at: - return False - - # Check if lock has expired (15 minutes) - lock_duration = timezone.now() - self.locked_at - return lock_duration.total_seconds() < 15 * 60 - - def can_review(self, user): - """Check if user can review this submission""" - if self.status != self.STATE_REVIEWING: - return False - - # Check if locked by another user - if self.locked_by and self.locked_by != user: - return not self.is_locked() - - return True - - def get_items_count(self): - """Get count of submission items""" - return self.items.count() - - def get_approved_items_count(self): - """Get count of approved items""" - return self.items.filter(status='approved').count() - - def get_rejected_items_count(self): - """Get count of rejected items""" - return self.items.filter(status='rejected').count() - - -class SubmissionItem(BaseModel): - """ - Individual change within a submission. - - Represents a single field change (or entity creation/deletion). - Supports selective approval - each item can be approved/rejected independently. - """ - - STATUS_CHOICES = [ - ('pending', 'Pending'), - ('approved', 'Approved'), - ('rejected', 'Rejected'), - ] - - # Parent submission - submission = models.ForeignKey( - ContentSubmission, - on_delete=models.CASCADE, - related_name='items', - help_text="Parent submission" - ) - - # Item details - field_name = models.CharField( - max_length=100, - help_text="Name of the field being changed" - ) - field_label = models.CharField( - max_length=200, - blank=True, - help_text="Human-readable field label" - ) - - # Values (stored as JSON for flexibility) - old_value = models.JSONField( - null=True, - blank=True, - help_text="Previous value (null for new fields)" - ) - new_value = models.JSONField( - null=True, - blank=True, - help_text="New value (null for deletions)" - ) - - # Item status (for selective approval) - status = models.CharField( - max_length=20, - choices=STATUS_CHOICES, - default='pending', - db_index=True, - help_text="Status of this individual item" - ) - - # Review details (for selective approval) - reviewed_by = models.ForeignKey( - 'users.User', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='reviewed_items', - help_text="Moderator who reviewed this item" - ) - reviewed_at = models.DateTimeField( - null=True, - blank=True, - help_text="When this item was reviewed" - ) - rejection_reason = models.TextField( - blank=True, - help_text="Reason for rejecting this specific item" - ) - - # Metadata - change_type = models.CharField( - max_length=20, - choices=[ - ('add', 'Add'), - ('modify', 'Modify'), - ('remove', 'Remove'), - ], - default='modify', - help_text="Type of change" - ) - - is_required = models.BooleanField( - default=False, - help_text="Whether this change is required for the submission" - ) - - order = models.IntegerField( - default=0, - help_text="Display order within submission" - ) - - class Meta: - db_table = 'submission_items' - ordering = ['submission', 'order', 'created'] - indexes = [ - models.Index(fields=['submission', 'status']), - models.Index(fields=['status']), - ] - verbose_name = 'Submission Item' - verbose_name_plural = 'Submission Items' - - def __str__(self): - return f"{self.submission.title} - {self.field_label or self.field_name}" - - def approve(self, reviewer): - """Approve this item""" - self.status = 'approved' - self.reviewed_by = reviewer - self.reviewed_at = timezone.now() - self.save(update_fields=['status', 'reviewed_by', 'reviewed_at', 'modified']) - - def reject(self, reviewer, reason=''): - """Reject this item""" - self.status = 'rejected' - self.reviewed_by = reviewer - self.reviewed_at = timezone.now() - self.rejection_reason = reason - self.save(update_fields=['status', 'reviewed_by', 'reviewed_at', 'rejection_reason', 'modified']) - - def get_display_value(self, value): - """Get human-readable display value""" - if value is None: - return 'None' - if isinstance(value, bool): - return 'Yes' if value else 'No' - if isinstance(value, (list, dict)): - return str(value) - return str(value) - - @property - def old_value_display(self): - """Human-readable old value""" - return self.get_display_value(self.old_value) - - @property - def new_value_display(self): - """Human-readable new value""" - return self.get_display_value(self.new_value) - - -class ModerationLock(BaseModel): - """ - Lock record for submissions under review. - - Provides additional tracking beyond the ContentSubmission lock fields. - Helps with monitoring and debugging lock issues. - """ - - submission = models.OneToOneField( - ContentSubmission, - on_delete=models.CASCADE, - related_name='lock_record', - help_text="Submission that is locked" - ) - - locked_by = models.ForeignKey( - 'users.User', - on_delete=models.CASCADE, - related_name='moderation_locks', - help_text="User who holds the lock" - ) - - locked_at = models.DateTimeField( - auto_now_add=True, - help_text="When the lock was acquired" - ) - - expires_at = models.DateTimeField( - help_text="When the lock expires" - ) - - is_active = models.BooleanField( - default=True, - db_index=True, - help_text="Whether the lock is currently active" - ) - - released_at = models.DateTimeField( - null=True, - blank=True, - help_text="When the lock was released" - ) - - class Meta: - db_table = 'moderation_locks' - ordering = ['-locked_at'] - indexes = [ - models.Index(fields=['is_active', 'expires_at']), - models.Index(fields=['locked_by', 'is_active']), - ] - verbose_name = 'Moderation Lock' - verbose_name_plural = 'Moderation Locks' - - def __str__(self): - return f"Lock on {self.submission.title} by {self.locked_by.email}" - - def is_expired(self): - """Check if lock has expired""" - return timezone.now() > self.expires_at - - def release(self): - """Release the lock""" - self.is_active = False - self.released_at = timezone.now() - self.save(update_fields=['is_active', 'released_at', 'modified']) - - def extend(self, minutes=15): - """Extend the lock duration""" - from datetime import timedelta - self.expires_at = timezone.now() + timedelta(minutes=minutes) - self.save(update_fields=['expires_at', 'modified']) - - @classmethod - def cleanup_expired(cls): - """Cleanup expired locks (for periodic task)""" - expired_locks = cls.objects.filter( - is_active=True, - expires_at__lt=timezone.now() - ) - - count = 0 - for lock in expired_locks: - # Release lock - lock.release() - - # Unlock submission if still in reviewing state - submission = lock.submission - if submission.status == ContentSubmission.STATE_REVIEWING: - submission.unlock() - submission.save() - - count += 1 - - return count diff --git a/django/apps/moderation/services.py b/django/apps/moderation/services.py deleted file mode 100644 index 28c6e6aa..00000000 --- a/django/apps/moderation/services.py +++ /dev/null @@ -1,587 +0,0 @@ -""" -Moderation services for ThrillWiki. - -This module provides business logic for the content moderation workflow: -- Creating submissions -- Starting reviews with locks -- Approving submissions with atomic transactions -- Selective approval of individual items -- Rejecting submissions -- Unlocking expired submissions -""" - -import logging -from datetime import timedelta -from django.db import transaction -from django.utils import timezone -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ValidationError, PermissionDenied - -from apps.moderation.models import ContentSubmission, SubmissionItem, ModerationLock - -logger = logging.getLogger(__name__) - - -class ModerationService: - """ - Service class for moderation operations. - - All public methods use atomic transactions to ensure data integrity. - """ - - @staticmethod - @transaction.atomic - def create_submission( - user, - entity, - submission_type, - title, - description='', - items_data=None, - metadata=None, - auto_submit=True, - **kwargs - ): - """ - Create a new content submission with items. - - Args: - user: User creating the submission - entity: Entity being modified (Park, Ride, Company, etc.) - submission_type: 'create', 'update', or 'delete' - title: Brief description of changes - description: Detailed description (optional) - items_data: List of dicts with item details: - [ - { - 'field_name': 'name', - 'field_label': 'Park Name', - 'old_value': 'Old Name', - 'new_value': 'New Name', - 'change_type': 'modify', - 'is_required': False, - 'order': 0 - }, - ... - ] - metadata: Additional metadata dict - auto_submit: Whether to automatically submit (move to pending state) - **kwargs: Additional submission fields (source, ip_address, user_agent) - - Returns: - ContentSubmission instance - - Raises: - ValidationError: If validation fails - """ - # Get ContentType for entity - entity_type = ContentType.objects.get_for_model(entity) - - # Create submission - submission = ContentSubmission.objects.create( - user=user, - entity_type=entity_type, - entity_id=entity.id, - submission_type=submission_type, - title=title, - description=description, - metadata=metadata or {}, - source=kwargs.get('source', 'web'), - ip_address=kwargs.get('ip_address'), - user_agent=kwargs.get('user_agent', '') - ) - - # Create submission items - if items_data: - for item_data in items_data: - SubmissionItem.objects.create( - submission=submission, - field_name=item_data['field_name'], - field_label=item_data.get('field_label', item_data['field_name']), - old_value=item_data.get('old_value'), - new_value=item_data.get('new_value'), - change_type=item_data.get('change_type', 'modify'), - is_required=item_data.get('is_required', False), - order=item_data.get('order', 0) - ) - - # Auto-submit if requested - if auto_submit: - submission.submit() - submission.save() - - return submission - - @staticmethod - @transaction.atomic - def start_review(submission_id, reviewer): - """ - Start reviewing a submission (lock it). - - Args: - submission_id: UUID of submission - reviewer: User starting the review - - Returns: - ContentSubmission instance - - Raises: - ValidationError: If submission cannot be reviewed - PermissionDenied: If user lacks permission - """ - submission = ContentSubmission.objects.select_for_update().get(id=submission_id) - - # Check if user has permission to review - if not ModerationService._can_moderate(reviewer): - raise PermissionDenied("User does not have moderation permission") - - # Check if submission is in correct state - if submission.status != ContentSubmission.STATE_PENDING: - raise ValidationError(f"Submission must be pending to start review (current: {submission.status})") - - # Check if already locked by another user - if submission.locked_by and submission.locked_by != reviewer: - if submission.is_locked(): - raise ValidationError(f"Submission is locked by {submission.locked_by.email}") - - # Start review (FSM transition) - submission.start_review(reviewer) - submission.save() - - # Create lock record - expires_at = timezone.now() + timedelta(minutes=15) - ModerationLock.objects.update_or_create( - submission=submission, - defaults={ - 'locked_by': reviewer, - 'expires_at': expires_at, - 'is_active': True, - 'released_at': None - } - ) - - return submission - - @staticmethod - @transaction.atomic - def approve_submission(submission_id, reviewer): - """ - Approve an entire submission and apply all changes. - - This method uses atomic transactions to ensure all-or-nothing behavior. - If any part fails, the entire operation is rolled back. - - Args: - submission_id: UUID of submission - reviewer: User approving the submission - - Returns: - ContentSubmission instance - - Raises: - ValidationError: If submission cannot be approved - PermissionDenied: If user lacks permission - """ - submission = ContentSubmission.objects.select_for_update().get(id=submission_id) - - # Check permission - if not ModerationService._can_moderate(reviewer): - raise PermissionDenied("User does not have moderation permission") - - # Check if submission can be reviewed - if not submission.can_review(reviewer): - raise ValidationError("Submission cannot be reviewed at this time") - - # Apply all changes - entity = submission.entity - if not entity: - raise ValidationError("Entity no longer exists") - - # Get all pending items - items = submission.items.filter(status='pending') - - for item in items: - # Apply change to entity - if item.change_type in ['add', 'modify']: - setattr(entity, item.field_name, item.new_value) - elif item.change_type == 'remove': - setattr(entity, item.field_name, None) - - # Mark item as approved - item.approve(reviewer) - - # Save entity (this will trigger versioning through lifecycle hooks) - entity.save() - - # Approve submission (FSM transition) - submission.approve(reviewer) - submission.save() - - # Release lock - try: - lock = ModerationLock.objects.get(submission=submission, is_active=True) - lock.release() - except ModerationLock.DoesNotExist: - pass - - # Send notification email asynchronously - try: - from apps.moderation.tasks import send_moderation_notification - send_moderation_notification.delay(str(submission.id), 'approved') - except Exception as e: - # Don't fail the approval if email fails to queue - logger.warning(f"Failed to queue approval notification: {str(e)}") - - return submission - - @staticmethod - @transaction.atomic - def approve_selective(submission_id, reviewer, item_ids): - """ - Approve only specific items in a submission (selective approval). - - This allows moderators to approve some changes while rejecting others. - Uses atomic transactions for data integrity. - - Args: - submission_id: UUID of submission - reviewer: User approving the items - item_ids: List of item UUIDs to approve - - Returns: - dict with counts: {'approved': N, 'total': M} - - Raises: - ValidationError: If submission cannot be reviewed - PermissionDenied: If user lacks permission - """ - submission = ContentSubmission.objects.select_for_update().get(id=submission_id) - - # Check permission - if not ModerationService._can_moderate(reviewer): - raise PermissionDenied("User does not have moderation permission") - - # Check if submission can be reviewed - if not submission.can_review(reviewer): - raise ValidationError("Submission cannot be reviewed at this time") - - # Get entity - entity = submission.entity - if not entity: - raise ValidationError("Entity no longer exists") - - # Get items to approve - items_to_approve = submission.items.filter( - id__in=item_ids, - status='pending' - ) - - approved_count = 0 - for item in items_to_approve: - # Apply change to entity - if item.change_type in ['add', 'modify']: - setattr(entity, item.field_name, item.new_value) - elif item.change_type == 'remove': - setattr(entity, item.field_name, None) - - # Mark item as approved - item.approve(reviewer) - approved_count += 1 - - # Save entity if any changes were made - if approved_count > 0: - entity.save() - - # Check if all items are now reviewed - pending_count = submission.items.filter(status='pending').count() - - if pending_count == 0: - # All items reviewed - mark submission as approved - submission.approve(reviewer) - submission.save() - - # Release lock - try: - lock = ModerationLock.objects.get(submission=submission, is_active=True) - lock.release() - except ModerationLock.DoesNotExist: - pass - - return { - 'approved': approved_count, - 'total': submission.items.count(), - 'pending': pending_count, - 'submission_approved': pending_count == 0 - } - - @staticmethod - @transaction.atomic - def reject_submission(submission_id, reviewer, reason): - """ - Reject an entire submission. - - Args: - submission_id: UUID of submission - reviewer: User rejecting the submission - reason: Reason for rejection - - Returns: - ContentSubmission instance - - Raises: - ValidationError: If submission cannot be rejected - PermissionDenied: If user lacks permission - """ - submission = ContentSubmission.objects.select_for_update().get(id=submission_id) - - # Check permission - if not ModerationService._can_moderate(reviewer): - raise PermissionDenied("User does not have moderation permission") - - # Check if submission can be reviewed - if not submission.can_review(reviewer): - raise ValidationError("Submission cannot be reviewed at this time") - - # Reject all pending items - items = submission.items.filter(status='pending') - for item in items: - item.reject(reviewer, reason) - - # Reject submission (FSM transition) - submission.reject(reviewer, reason) - submission.save() - - # Release lock - try: - lock = ModerationLock.objects.get(submission=submission, is_active=True) - lock.release() - except ModerationLock.DoesNotExist: - pass - - # Send notification email asynchronously - try: - from apps.moderation.tasks import send_moderation_notification - send_moderation_notification.delay(str(submission.id), 'rejected') - except Exception as e: - # Don't fail the rejection if email fails to queue - logger.warning(f"Failed to queue rejection notification: {str(e)}") - - return submission - - @staticmethod - @transaction.atomic - def reject_selective(submission_id, reviewer, item_ids, reason=''): - """ - Reject specific items in a submission. - - Args: - submission_id: UUID of submission - reviewer: User rejecting the items - item_ids: List of item UUIDs to reject - reason: Reason for rejection (optional) - - Returns: - dict with counts: {'rejected': N, 'total': M} - - Raises: - ValidationError: If submission cannot be reviewed - PermissionDenied: If user lacks permission - """ - submission = ContentSubmission.objects.select_for_update().get(id=submission_id) - - # Check permission - if not ModerationService._can_moderate(reviewer): - raise PermissionDenied("User does not have moderation permission") - - # Check if submission can be reviewed - if not submission.can_review(reviewer): - raise ValidationError("Submission cannot be reviewed at this time") - - # Get items to reject - items_to_reject = submission.items.filter( - id__in=item_ids, - status='pending' - ) - - rejected_count = 0 - for item in items_to_reject: - item.reject(reviewer, reason) - rejected_count += 1 - - # Check if all items are now reviewed - pending_count = submission.items.filter(status='pending').count() - - if pending_count == 0: - # All items reviewed - approved_count = submission.items.filter(status='approved').count() - - if approved_count > 0: - # Some items approved - mark submission as approved - submission.approve(reviewer) - submission.save() - else: - # All items rejected - mark submission as rejected - submission.reject(reviewer, "All items rejected") - submission.save() - - # Release lock - try: - lock = ModerationLock.objects.get(submission=submission, is_active=True) - lock.release() - except ModerationLock.DoesNotExist: - pass - - return { - 'rejected': rejected_count, - 'total': submission.items.count(), - 'pending': pending_count, - 'submission_complete': pending_count == 0 - } - - @staticmethod - @transaction.atomic - def unlock_submission(submission_id): - """ - Manually unlock a submission. - - Args: - submission_id: UUID of submission - - Returns: - ContentSubmission instance - """ - submission = ContentSubmission.objects.select_for_update().get(id=submission_id) - - if submission.status == ContentSubmission.STATE_REVIEWING: - submission.unlock() - submission.save() - - # Release lock record - try: - lock = ModerationLock.objects.get(submission=submission, is_active=True) - lock.release() - except ModerationLock.DoesNotExist: - pass - - return submission - - @staticmethod - def cleanup_expired_locks(): - """ - Cleanup expired locks and unlock submissions. - - This should be called periodically (e.g., every 5 minutes via Celery). - - Returns: - int: Number of locks cleaned up - """ - return ModerationLock.cleanup_expired() - - @staticmethod - def get_queue(status=None, user=None, limit=50, offset=0): - """ - Get moderation queue with filters. - - Args: - status: Filter by status (optional) - user: Filter by submitter (optional) - limit: Maximum results - offset: Pagination offset - - Returns: - QuerySet of ContentSubmission objects - """ - queryset = ContentSubmission.objects.select_related( - 'user', - 'entity_type', - 'locked_by', - 'reviewed_by' - ).prefetch_related('items') - - if status: - queryset = queryset.filter(status=status) - - if user: - queryset = queryset.filter(user=user) - - return queryset[offset:offset + limit] - - @staticmethod - def get_submission_details(submission_id): - """ - Get full submission details with all items. - - Args: - submission_id: UUID of submission - - Returns: - ContentSubmission instance with prefetched items - """ - return ContentSubmission.objects.select_related( - 'user', - 'entity_type', - 'locked_by', - 'reviewed_by' - ).prefetch_related( - 'items', - 'items__reviewed_by' - ).get(id=submission_id) - - @staticmethod - def _can_moderate(user): - """ - Check if user has moderation permission. - - Args: - user: User to check - - Returns: - bool: True if user can moderate - """ - if not user or not user.is_authenticated: - return False - - # Check if user is superuser - if user.is_superuser: - return True - - # Check if user has moderator or admin role - try: - return user.role.is_moderator - except: - return False - - @staticmethod - @transaction.atomic - def delete_submission(submission_id, user): - """ - Delete a submission (only if draft or by owner). - - Args: - submission_id: UUID of submission - user: User attempting to delete - - Returns: - bool: True if deleted - - Raises: - PermissionDenied: If user cannot delete - ValidationError: If submission cannot be deleted - """ - submission = ContentSubmission.objects.select_for_update().get(id=submission_id) - - # Check permission - is_owner = submission.user == user - is_moderator = ModerationService._can_moderate(user) - - if not (is_owner or is_moderator): - raise PermissionDenied("Only the owner or a moderator can delete this submission") - - # Check state - if submission.status not in [ContentSubmission.STATE_DRAFT, ContentSubmission.STATE_PENDING]: - if not is_moderator: - raise ValidationError("Only moderators can delete submissions under review") - - # Delete submission (cascades to items and lock) - submission.delete() - return True diff --git a/django/apps/moderation/tasks.py b/django/apps/moderation/tasks.py deleted file mode 100644 index d22fd73a..00000000 --- a/django/apps/moderation/tasks.py +++ /dev/null @@ -1,304 +0,0 @@ -""" -Background tasks for moderation workflows and notifications. -""" - -import logging -from celery import shared_task -from django.core.mail import send_mail -from django.template.loader import render_to_string -from django.conf import settings -from django.utils import timezone - -logger = logging.getLogger(__name__) - - -@shared_task(bind=True, max_retries=3, default_retry_delay=60) -def send_moderation_notification(self, submission_id, status): - """ - Send email notification when a submission is approved or rejected. - - Args: - submission_id: UUID of the ContentSubmission - status: 'approved' or 'rejected' - - Returns: - str: Notification result message - """ - from apps.moderation.models import ContentSubmission - - try: - submission = ContentSubmission.objects.select_related( - 'user', 'reviewed_by', 'entity_type' - ).prefetch_related('items').get(id=submission_id) - - # Get user's submission count - user_submission_count = ContentSubmission.objects.filter( - user=submission.user - ).count() - - # Prepare email context - context = { - 'submission': submission, - 'status': status, - 'user': submission.user, - 'user_submission_count': user_submission_count, - 'submission_url': f"{settings.SITE_URL}/submissions/{submission.id}/", - 'site_url': settings.SITE_URL, - } - - # Choose template based on status - if status == 'approved': - template = 'emails/moderation_approved.html' - subject = f'✅ Submission Approved: {submission.title}' - else: - template = 'emails/moderation_rejected.html' - subject = f'⚠️ Submission Requires Changes: {submission.title}' - - # Render HTML email - html_message = render_to_string(template, context) - - # Send email - send_mail( - subject=subject, - message='', # Plain text version (optional) - html_message=html_message, - from_email=settings.DEFAULT_FROM_EMAIL, - recipient_list=[submission.user.email], - fail_silently=False, - ) - - logger.info( - f"Moderation notification sent: {status} for submission {submission_id} " - f"to {submission.user.email}" - ) - - return f"Notification sent to {submission.user.email}" - - except ContentSubmission.DoesNotExist: - logger.error(f"Submission {submission_id} not found") - raise - except Exception as exc: - logger.error(f"Error sending notification for submission {submission_id}: {str(exc)}") - # Retry with exponential backoff - raise self.retry(exc=exc, countdown=60 * (2 ** self.request.retries)) - - -@shared_task(bind=True, max_retries=2) -def cleanup_expired_locks(self): - """ - Clean up expired moderation locks. - - This task runs periodically to unlock submissions that have - been locked for too long (default: 15 minutes). - - Returns: - int: Number of locks cleaned up - """ - from apps.moderation.models import ModerationLock - - try: - cleaned = ModerationLock.cleanup_expired() - logger.info(f"Cleaned up {cleaned} expired moderation locks") - return cleaned - - except Exception as exc: - logger.error(f"Error cleaning up expired locks: {str(exc)}") - raise self.retry(exc=exc, countdown=300) # Retry after 5 minutes - - -@shared_task(bind=True, max_retries=3) -def send_batch_moderation_summary(self, moderator_id): - """ - Send a daily summary email to a moderator with their moderation stats. - - Args: - moderator_id: ID of the moderator user - - Returns: - str: Email send result - """ - from apps.users.models import User - from apps.moderation.models import ContentSubmission - from datetime import timedelta - - try: - moderator = User.objects.get(id=moderator_id) - - # Get stats for the past 24 hours - yesterday = timezone.now() - timedelta(days=1) - - stats = { - 'reviewed_today': ContentSubmission.objects.filter( - reviewed_by=moderator, - reviewed_at__gte=yesterday - ).count(), - 'approved_today': ContentSubmission.objects.filter( - reviewed_by=moderator, - reviewed_at__gte=yesterday, - status='approved' - ).count(), - 'rejected_today': ContentSubmission.objects.filter( - reviewed_by=moderator, - reviewed_at__gte=yesterday, - status='rejected' - ).count(), - 'pending_queue': ContentSubmission.objects.filter( - status='pending' - ).count(), - } - - context = { - 'moderator': moderator, - 'stats': stats, - 'date': timezone.now(), - 'site_url': settings.SITE_URL, - } - - # For now, just log the stats (template not created yet) - logger.info(f"Moderation summary for {moderator.email}: {stats}") - - # In production, you would send an actual email: - # html_message = render_to_string('emails/moderation_summary.html', context) - # send_mail(...) - - return f"Summary sent to {moderator.email}" - - except User.DoesNotExist: - logger.error(f"Moderator {moderator_id} not found") - raise - except Exception as exc: - logger.error(f"Error sending moderation summary: {str(exc)}") - raise self.retry(exc=exc, countdown=60 * (2 ** self.request.retries)) - - -@shared_task -def update_moderation_statistics(): - """ - Update moderation-related statistics across the database. - - Returns: - dict: Updated statistics - """ - from apps.moderation.models import ContentSubmission - from django.db.models import Count, Avg, F - from datetime import timedelta - - try: - now = timezone.now() - week_ago = now - timedelta(days=7) - - stats = { - 'total_submissions': ContentSubmission.objects.count(), - 'pending': ContentSubmission.objects.filter(status='pending').count(), - 'reviewing': ContentSubmission.objects.filter(status='reviewing').count(), - 'approved': ContentSubmission.objects.filter(status='approved').count(), - 'rejected': ContentSubmission.objects.filter(status='rejected').count(), - 'this_week': ContentSubmission.objects.filter( - created_at__gte=week_ago - ).count(), - 'by_type': dict( - ContentSubmission.objects.values('submission_type') - .annotate(count=Count('id')) - .values_list('submission_type', 'count') - ), - } - - logger.info(f"Moderation statistics updated: {stats}") - return stats - - except Exception as e: - logger.error(f"Error updating moderation statistics: {str(e)}") - raise - - -@shared_task(bind=True, max_retries=2) -def auto_unlock_stale_reviews(self, hours=1): - """ - Automatically unlock submissions that have been in review for too long. - - This helps prevent submissions from getting stuck if a moderator - starts a review but doesn't complete it. - - Args: - hours: Number of hours before auto-unlocking (default: 1) - - Returns: - int: Number of submissions unlocked - """ - from apps.moderation.models import ContentSubmission - from apps.moderation.services import ModerationService - from datetime import timedelta - - try: - cutoff = timezone.now() - timedelta(hours=hours) - - # Find submissions that have been reviewing too long - stale_reviews = ContentSubmission.objects.filter( - status='reviewing', - locked_at__lt=cutoff - ) - - count = 0 - for submission in stale_reviews: - try: - ModerationService.unlock_submission(submission.id) - count += 1 - except Exception as e: - logger.error(f"Failed to unlock submission {submission.id}: {str(e)}") - continue - - logger.info(f"Auto-unlocked {count} stale reviews") - return count - - except Exception as exc: - logger.error(f"Error auto-unlocking stale reviews: {str(exc)}") - raise self.retry(exc=exc, countdown=300) - - -@shared_task -def notify_moderators_of_queue_size(): - """ - Notify moderators when the pending queue gets too large. - - This helps ensure timely review of submissions. - - Returns: - dict: Notification result - """ - from apps.moderation.models import ContentSubmission - from apps.users.models import User - - try: - pending_count = ContentSubmission.objects.filter(status='pending').count() - - # Threshold for notification (configurable) - threshold = getattr(settings, 'MODERATION_QUEUE_THRESHOLD', 50) - - if pending_count >= threshold: - # Get all moderators - moderators = User.objects.filter(role__is_moderator=True) - - logger.warning( - f"Moderation queue size ({pending_count}) exceeds threshold ({threshold}). " - f"Notifying {moderators.count()} moderators." - ) - - # In production, send emails to moderators - # For now, just log - - return { - 'queue_size': pending_count, - 'threshold': threshold, - 'notified': moderators.count(), - } - else: - logger.info(f"Moderation queue size ({pending_count}) is within threshold") - return { - 'queue_size': pending_count, - 'threshold': threshold, - 'notified': 0, - } - - except Exception as e: - logger.error(f"Error checking moderation queue: {str(e)}") - raise diff --git a/django/apps/notifications/__init__.py b/django/apps/notifications/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/django/apps/notifications/__pycache__/__init__.cpython-313.pyc b/django/apps/notifications/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index e6c36319..00000000 Binary files a/django/apps/notifications/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/django/apps/notifications/__pycache__/apps.cpython-313.pyc b/django/apps/notifications/__pycache__/apps.cpython-313.pyc deleted file mode 100644 index 6794673c..00000000 Binary files a/django/apps/notifications/__pycache__/apps.cpython-313.pyc and /dev/null differ diff --git a/django/apps/notifications/__pycache__/models.cpython-313.pyc b/django/apps/notifications/__pycache__/models.cpython-313.pyc deleted file mode 100644 index 70b457c5..00000000 Binary files a/django/apps/notifications/__pycache__/models.cpython-313.pyc and /dev/null differ diff --git a/django/apps/notifications/apps.py b/django/apps/notifications/apps.py deleted file mode 100644 index a581e111..00000000 --- a/django/apps/notifications/apps.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -Notifications app configuration. -""" - -from django.apps import AppConfig - - -class NotificationsConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.notifications' - verbose_name = 'Notifications' diff --git a/django/apps/notifications/models.py b/django/apps/notifications/models.py deleted file mode 100644 index e69de29b..00000000 diff --git a/django/apps/reviews/apps.py b/django/apps/reviews/apps.py deleted file mode 100644 index 4fca05cd..00000000 --- a/django/apps/reviews/apps.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.apps import AppConfig - - -class ReviewsConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.reviews' - verbose_name = 'Reviews' diff --git a/django/apps/users/__init__.py b/django/apps/users/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/django/apps/users/__pycache__/__init__.cpython-313.pyc b/django/apps/users/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index dcee91e3..00000000 Binary files a/django/apps/users/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/django/apps/users/__pycache__/admin.cpython-313.pyc b/django/apps/users/__pycache__/admin.cpython-313.pyc deleted file mode 100644 index 49629c8f..00000000 Binary files a/django/apps/users/__pycache__/admin.cpython-313.pyc and /dev/null differ diff --git a/django/apps/users/__pycache__/apps.cpython-313.pyc b/django/apps/users/__pycache__/apps.cpython-313.pyc deleted file mode 100644 index 62883dc3..00000000 Binary files a/django/apps/users/__pycache__/apps.cpython-313.pyc and /dev/null differ diff --git a/django/apps/users/__pycache__/models.cpython-313.pyc b/django/apps/users/__pycache__/models.cpython-313.pyc deleted file mode 100644 index ebc5caf9..00000000 Binary files a/django/apps/users/__pycache__/models.cpython-313.pyc and /dev/null differ diff --git a/django/apps/users/__pycache__/permissions.cpython-313.pyc b/django/apps/users/__pycache__/permissions.cpython-313.pyc deleted file mode 100644 index cc80fa76..00000000 Binary files a/django/apps/users/__pycache__/permissions.cpython-313.pyc and /dev/null differ diff --git a/django/apps/users/__pycache__/services.cpython-313.pyc b/django/apps/users/__pycache__/services.cpython-313.pyc deleted file mode 100644 index 1110fd74..00000000 Binary files a/django/apps/users/__pycache__/services.cpython-313.pyc and /dev/null differ diff --git a/django/apps/users/admin.py b/django/apps/users/admin.py deleted file mode 100644 index e8824686..00000000 --- a/django/apps/users/admin.py +++ /dev/null @@ -1,372 +0,0 @@ -""" -Django admin configuration for User models. -""" - -from django.contrib import admin -from django.contrib.auth.admin import UserAdmin as BaseUserAdmin -from django.utils.html import format_html -from django.urls import reverse -from django.utils.safestring import mark_safe -from unfold.admin import ModelAdmin -from unfold.decorators import display -from import_export import resources -from import_export.admin import ImportExportModelAdmin - -from .models import User, UserRole, UserProfile - - -class UserResource(resources.ModelResource): - """Resource for importing/exporting users.""" - - class Meta: - model = User - fields = ( - 'id', 'email', 'username', 'first_name', 'last_name', - 'date_joined', 'last_login', 'is_active', 'is_staff', - 'banned', 'reputation_score', 'mfa_enabled' - ) - export_order = fields - - -class UserRoleInline(admin.StackedInline): - """Inline for user role.""" - model = UserRole - can_delete = False - verbose_name_plural = 'Role' - fk_name = 'user' - fields = ('role', 'granted_by', 'granted_at') - readonly_fields = ('granted_at',) - - -class UserProfileInline(admin.StackedInline): - """Inline for user profile.""" - model = UserProfile - can_delete = False - verbose_name_plural = 'Profile & Preferences' - fk_name = 'user' - fields = ( - ('email_notifications', 'email_on_submission_approved', 'email_on_submission_rejected'), - ('profile_public', 'show_email'), - ('total_submissions', 'approved_submissions'), - ) - readonly_fields = ('total_submissions', 'approved_submissions') - - -@admin.register(User) -class UserAdmin(BaseUserAdmin, ModelAdmin, ImportExportModelAdmin): - """Admin interface for User model.""" - - resource_class = UserResource - - list_display = [ - 'email', - 'username', - 'display_name_admin', - 'role_badge', - 'reputation_badge', - 'status_badge', - 'mfa_badge', - 'date_joined', - 'last_login', - ] - - list_filter = [ - 'is_active', - 'is_staff', - 'is_superuser', - 'banned', - 'mfa_enabled', - 'oauth_provider', - 'date_joined', - 'last_login', - ] - - search_fields = [ - 'email', - 'username', - 'first_name', - 'last_name', - ] - - ordering = ['-date_joined'] - - fieldsets = ( - ('Account Information', { - 'fields': ('email', 'username', 'password') - }), - ('Personal Information', { - 'fields': ('first_name', 'last_name', 'avatar_url', 'bio') - }), - ('Permissions', { - 'fields': ( - 'is_active', - 'is_staff', - 'is_superuser', - 'groups', - 'user_permissions', - ) - }), - ('Moderation', { - 'fields': ( - 'banned', - 'ban_reason', - 'banned_at', - 'banned_by', - ) - }), - ('OAuth', { - 'fields': ('oauth_provider', 'oauth_sub'), - 'classes': ('collapse',) - }), - ('Security', { - 'fields': ('mfa_enabled', 'reputation_score'), - }), - ('Timestamps', { - 'fields': ('date_joined', 'last_login'), - 'classes': ('collapse',) - }), - ) - - add_fieldsets = ( - ('Create New User', { - 'classes': ('wide',), - 'fields': ('email', 'username', 'password1', 'password2'), - }), - ) - - readonly_fields = [ - 'date_joined', - 'last_login', - 'banned_at', - 'oauth_provider', - 'oauth_sub', - ] - - inlines = [UserRoleInline, UserProfileInline] - - @display(description="Name", label=True) - def display_name_admin(self, obj): - """Display user's display name.""" - return obj.display_name or '-' - - @display(description="Role", label=True) - def role_badge(self, obj): - """Display user role with badge.""" - try: - role = obj.role.role - colors = { - 'admin': 'red', - 'moderator': 'blue', - 'user': 'green', - } - return format_html( - '{}', - colors.get(role, 'gray'), - role.upper() - ) - except UserRole.DoesNotExist: - return format_html('No Role') - - @display(description="Reputation", label=True) - def reputation_badge(self, obj): - """Display reputation score.""" - score = obj.reputation_score - if score >= 100: - color = 'green' - elif score >= 50: - color = 'blue' - elif score >= 0: - color = 'gray' - else: - color = 'red' - - return format_html( - '{}', - color, - score - ) - - @display(description="Status", label=True) - def status_badge(self, obj): - """Display user status.""" - if obj.banned: - return format_html( - 'BANNED' - ) - elif not obj.is_active: - return format_html( - 'INACTIVE' - ) - else: - return format_html( - 'ACTIVE' - ) - - @display(description="MFA", label=True) - def mfa_badge(self, obj): - """Display MFA status.""" - if obj.mfa_enabled: - return format_html( - '✓ Enabled' - ) - else: - return format_html( - '✗ Disabled' - ) - - def get_queryset(self, request): - """Optimize queryset with select_related.""" - qs = super().get_queryset(request) - return qs.select_related('role', 'banned_by') - - actions = ['ban_users', 'unban_users', 'make_moderator', 'make_user'] - - @admin.action(description="Ban selected users") - def ban_users(self, request, queryset): - """Ban selected users.""" - count = 0 - for user in queryset: - if not user.banned: - user.ban(reason="Banned by admin", banned_by=request.user) - count += 1 - - self.message_user( - request, - f"{count} user(s) have been banned." - ) - - @admin.action(description="Unban selected users") - def unban_users(self, request, queryset): - """Unban selected users.""" - count = 0 - for user in queryset: - if user.banned: - user.unban() - count += 1 - - self.message_user( - request, - f"{count} user(s) have been unbanned." - ) - - @admin.action(description="Set role to Moderator") - def make_moderator(self, request, queryset): - """Set users' role to moderator.""" - from .services import RoleService - - count = 0 - for user in queryset: - RoleService.assign_role(user, 'moderator', request.user) - count += 1 - - self.message_user( - request, - f"{count} user(s) have been set to Moderator role." - ) - - @admin.action(description="Set role to User") - def make_user(self, request, queryset): - """Set users' role to user.""" - from .services import RoleService - - count = 0 - for user in queryset: - RoleService.assign_role(user, 'user', request.user) - count += 1 - - self.message_user( - request, - f"{count} user(s) have been set to User role." - ) - - -@admin.register(UserRole) -class UserRoleAdmin(ModelAdmin): - """Admin interface for UserRole model.""" - - list_display = ['user', 'role', 'is_moderator', 'is_admin', 'granted_at', 'granted_by'] - list_filter = ['role', 'granted_at'] - search_fields = ['user__email', 'user__username'] - ordering = ['-granted_at'] - - readonly_fields = ['granted_at'] - - def get_queryset(self, request): - """Optimize queryset.""" - qs = super().get_queryset(request) - return qs.select_related('user', 'granted_by') - - -@admin.register(UserProfile) -class UserProfileAdmin(ModelAdmin): - """Admin interface for UserProfile model.""" - - list_display = [ - 'user', - 'total_submissions', - 'approved_submissions', - 'approval_rate', - 'email_notifications', - 'profile_public', - ] - - list_filter = [ - 'email_notifications', - 'profile_public', - 'show_email', - ] - - search_fields = ['user__email', 'user__username'] - - readonly_fields = ['created', 'modified', 'total_submissions', 'approved_submissions'] - - fieldsets = ( - ('User', { - 'fields': ('user',) - }), - ('Statistics', { - 'fields': ('total_submissions', 'approved_submissions'), - }), - ('Notification Preferences', { - 'fields': ( - 'email_notifications', - 'email_on_submission_approved', - 'email_on_submission_rejected', - ) - }), - ('Privacy Settings', { - 'fields': ('profile_public', 'show_email'), - }), - ('Timestamps', { - 'fields': ('created', 'modified'), - 'classes': ('collapse',) - }), - ) - - @display(description="Approval Rate") - def approval_rate(self, obj): - """Display approval rate percentage.""" - if obj.total_submissions == 0: - return '-' - - rate = (obj.approved_submissions / obj.total_submissions) * 100 - - if rate >= 80: - color = 'green' - elif rate >= 60: - color = 'blue' - elif rate >= 40: - color = 'orange' - else: - color = 'red' - - return format_html( - '{:.1f}%', - color, - rate - ) - - def get_queryset(self, request): - """Optimize queryset.""" - qs = super().get_queryset(request) - return qs.select_related('user') diff --git a/django/apps/users/apps.py b/django/apps/users/apps.py deleted file mode 100644 index 0a698b2f..00000000 --- a/django/apps/users/apps.py +++ /dev/null @@ -1,17 +0,0 @@ -""" -Users app configuration. -""" - -from django.apps import AppConfig - - -class UsersConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.users' - verbose_name = 'Users' - - def ready(self): - """Import signal handlers when app is ready""" - # Import signals here to avoid circular imports - # import apps.users.signals - pass diff --git a/django/apps/users/migrations/0001_initial.py b/django/apps/users/migrations/0001_initial.py deleted file mode 100644 index 2dc5b86d..00000000 --- a/django/apps/users/migrations/0001_initial.py +++ /dev/null @@ -1,370 +0,0 @@ -# Generated by Django 4.2.8 on 2025-11-08 16:35 - -from django.conf import settings -import django.contrib.auth.models -import django.contrib.auth.validators -from django.db import migrations, models -import django.db.models.deletion -import django.utils.timezone -import django_lifecycle.mixins -import model_utils.fields -import uuid - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("auth", "0012_alter_user_first_name_max_length"), - ] - - operations = [ - migrations.CreateModel( - name="User", - fields=[ - ("password", models.CharField(max_length=128, verbose_name="password")), - ( - "last_login", - models.DateTimeField( - blank=True, null=True, verbose_name="last login" - ), - ), - ( - "is_superuser", - models.BooleanField( - default=False, - help_text="Designates that this user has all permissions without explicitly assigning them.", - verbose_name="superuser status", - ), - ), - ( - "username", - models.CharField( - error_messages={ - "unique": "A user with that username already exists." - }, - help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", - max_length=150, - unique=True, - validators=[ - django.contrib.auth.validators.UnicodeUsernameValidator() - ], - verbose_name="username", - ), - ), - ( - "first_name", - models.CharField( - blank=True, max_length=150, verbose_name="first name" - ), - ), - ( - "last_name", - models.CharField( - blank=True, max_length=150, verbose_name="last name" - ), - ), - ( - "is_staff", - models.BooleanField( - default=False, - help_text="Designates whether the user can log into this admin site.", - verbose_name="staff status", - ), - ), - ( - "is_active", - models.BooleanField( - default=True, - help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", - verbose_name="active", - ), - ), - ( - "date_joined", - models.DateTimeField( - default=django.utils.timezone.now, verbose_name="date joined" - ), - ), - ( - "id", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ( - "email", - models.EmailField( - help_text="Email address for authentication", - max_length=254, - unique=True, - ), - ), - ( - "oauth_provider", - models.CharField( - blank=True, - choices=[ - ("", "None"), - ("google", "Google"), - ("discord", "Discord"), - ], - help_text="OAuth provider used for authentication", - max_length=50, - ), - ), - ( - "oauth_sub", - models.CharField( - blank=True, - help_text="OAuth subject identifier from provider", - max_length=255, - ), - ), - ( - "mfa_enabled", - models.BooleanField( - default=False, - help_text="Whether two-factor authentication is enabled", - ), - ), - ( - "avatar_url", - models.URLField(blank=True, help_text="URL to user's avatar image"), - ), - ( - "bio", - models.TextField( - blank=True, help_text="User biography", max_length=500 - ), - ), - ( - "banned", - models.BooleanField( - db_index=True, - default=False, - help_text="Whether this user is banned", - ), - ), - ( - "ban_reason", - models.TextField(blank=True, help_text="Reason for ban"), - ), - ( - "banned_at", - models.DateTimeField( - blank=True, help_text="When the user was banned", null=True - ), - ), - ( - "reputation_score", - models.IntegerField( - default=0, - help_text="User reputation score based on contributions", - ), - ), - ( - "banned_by", - models.ForeignKey( - blank=True, - help_text="Moderator who banned this user", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="users_banned", - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "groups", - models.ManyToManyField( - blank=True, - help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", - related_name="user_set", - related_query_name="user", - to="auth.group", - verbose_name="groups", - ), - ), - ( - "user_permissions", - models.ManyToManyField( - blank=True, - help_text="Specific permissions for this user.", - related_name="user_set", - related_query_name="user", - to="auth.permission", - verbose_name="user permissions", - ), - ), - ], - options={ - "db_table": "users", - "ordering": ["-date_joined"], - }, - managers=[ - ("objects", django.contrib.auth.models.UserManager()), - ], - ), - migrations.CreateModel( - name="UserRole", - fields=[ - ( - "created", - model_utils.fields.AutoCreatedField( - default=django.utils.timezone.now, - editable=False, - verbose_name="created", - ), - ), - ( - "modified", - model_utils.fields.AutoLastModifiedField( - default=django.utils.timezone.now, - editable=False, - verbose_name="modified", - ), - ), - ( - "id", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ( - "role", - models.CharField( - choices=[ - ("user", "User"), - ("moderator", "Moderator"), - ("admin", "Admin"), - ], - db_index=True, - default="user", - max_length=20, - ), - ), - ("granted_at", models.DateTimeField(auto_now_add=True)), - ( - "granted_by", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="roles_granted", - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "user", - models.OneToOneField( - on_delete=django.db.models.deletion.CASCADE, - related_name="role", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "db_table": "user_roles", - }, - bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model), - ), - migrations.CreateModel( - name="UserProfile", - fields=[ - ( - "created", - model_utils.fields.AutoCreatedField( - default=django.utils.timezone.now, - editable=False, - verbose_name="created", - ), - ), - ( - "modified", - model_utils.fields.AutoLastModifiedField( - default=django.utils.timezone.now, - editable=False, - verbose_name="modified", - ), - ), - ( - "id", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ( - "email_notifications", - models.BooleanField( - default=True, help_text="Receive email notifications" - ), - ), - ( - "email_on_submission_approved", - models.BooleanField( - default=True, help_text="Email when submissions are approved" - ), - ), - ( - "email_on_submission_rejected", - models.BooleanField( - default=True, help_text="Email when submissions are rejected" - ), - ), - ( - "profile_public", - models.BooleanField( - default=True, help_text="Make profile publicly visible" - ), - ), - ( - "show_email", - models.BooleanField( - default=False, help_text="Show email on public profile" - ), - ), - ( - "total_submissions", - models.IntegerField( - default=0, help_text="Total number of submissions made" - ), - ), - ( - "approved_submissions", - models.IntegerField( - default=0, help_text="Number of approved submissions" - ), - ), - ( - "user", - models.OneToOneField( - on_delete=django.db.models.deletion.CASCADE, - related_name="profile", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "db_table": "user_profiles", - }, - bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model), - ), - migrations.AddIndex( - model_name="user", - index=models.Index(fields=["email"], name="users_email_4b85f2_idx"), - ), - migrations.AddIndex( - model_name="user", - index=models.Index(fields=["banned"], name="users_banned_ee00ad_idx"), - ), - ] diff --git a/django/apps/users/migrations/__init__.py b/django/apps/users/migrations/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/django/apps/users/migrations/__pycache__/0001_initial.cpython-313.pyc b/django/apps/users/migrations/__pycache__/0001_initial.cpython-313.pyc deleted file mode 100644 index 19b98e3a..00000000 Binary files a/django/apps/users/migrations/__pycache__/0001_initial.cpython-313.pyc and /dev/null differ diff --git a/django/apps/users/migrations/__pycache__/__init__.cpython-313.pyc b/django/apps/users/migrations/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 85360da1..00000000 Binary files a/django/apps/users/migrations/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/django/apps/users/models.py b/django/apps/users/models.py deleted file mode 100644 index ed4d14be..00000000 --- a/django/apps/users/models.py +++ /dev/null @@ -1,257 +0,0 @@ -""" -User models for ThrillWiki. -Custom user model with OAuth and MFA support. -""" - -import uuid -from django.contrib.auth.models import AbstractUser -from django.db import models -from apps.core.models import BaseModel - - -class User(AbstractUser): - """ - Custom user model with UUID primary key and additional fields. - - Supports: - - Email-based authentication - - OAuth (Google, Discord) - - Two-factor authentication (TOTP) - - User reputation and moderation - """ - - # Override id to use UUID - id = models.UUIDField( - primary_key=True, - default=uuid.uuid4, - editable=False - ) - - # Email as primary identifier - email = models.EmailField( - unique=True, - help_text="Email address for authentication" - ) - - # OAuth fields - oauth_provider = models.CharField( - max_length=50, - blank=True, - choices=[ - ('', 'None'), - ('google', 'Google'), - ('discord', 'Discord'), - ], - help_text="OAuth provider used for authentication" - ) - oauth_sub = models.CharField( - max_length=255, - blank=True, - help_text="OAuth subject identifier from provider" - ) - - # MFA fields - mfa_enabled = models.BooleanField( - default=False, - help_text="Whether two-factor authentication is enabled" - ) - - # Profile fields - avatar_url = models.URLField( - blank=True, - help_text="URL to user's avatar image" - ) - bio = models.TextField( - blank=True, - max_length=500, - help_text="User biography" - ) - - # Moderation fields - banned = models.BooleanField( - default=False, - db_index=True, - help_text="Whether this user is banned" - ) - ban_reason = models.TextField( - blank=True, - help_text="Reason for ban" - ) - banned_at = models.DateTimeField( - null=True, - blank=True, - help_text="When the user was banned" - ) - banned_by = models.ForeignKey( - 'self', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='users_banned', - help_text="Moderator who banned this user" - ) - - # Reputation system - reputation_score = models.IntegerField( - default=0, - help_text="User reputation score based on contributions" - ) - - # Timestamps (inherited from AbstractUser) - # date_joined, last_login - - # Use email for authentication - USERNAME_FIELD = 'email' - REQUIRED_FIELDS = ['username'] - - class Meta: - db_table = 'users' - ordering = ['-date_joined'] - indexes = [ - models.Index(fields=['email']), - models.Index(fields=['banned']), - ] - - def __str__(self): - return self.email - - def ban(self, reason, banned_by=None): - """Ban this user""" - from django.utils import timezone - self.banned = True - self.ban_reason = reason - self.banned_at = timezone.now() - self.banned_by = banned_by - self.save(update_fields=['banned', 'ban_reason', 'banned_at', 'banned_by']) - - def unban(self): - """Unban this user""" - self.banned = False - self.ban_reason = '' - self.banned_at = None - self.banned_by = None - self.save(update_fields=['banned', 'ban_reason', 'banned_at', 'banned_by']) - - @property - def display_name(self): - """Return the user's display name (full name or username)""" - if self.first_name or self.last_name: - return f"{self.first_name} {self.last_name}".strip() - return self.username or self.email.split('@')[0] - - -class UserRole(BaseModel): - """ - User role assignments for permission management. - - Roles: - - user: Standard user (default) - - moderator: Can approve submissions and moderate content - - admin: Full access to admin features - """ - - ROLE_CHOICES = [ - ('user', 'User'), - ('moderator', 'Moderator'), - ('admin', 'Admin'), - ] - - user = models.OneToOneField( - User, - on_delete=models.CASCADE, - related_name='role' - ) - role = models.CharField( - max_length=20, - choices=ROLE_CHOICES, - default='user', - db_index=True - ) - granted_at = models.DateTimeField(auto_now_add=True) - granted_by = models.ForeignKey( - User, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='roles_granted' - ) - - class Meta: - db_table = 'user_roles' - - def __str__(self): - return f"{self.user.email} - {self.role}" - - @property - def is_moderator(self): - """Check if user is a moderator or admin""" - return self.role in ['moderator', 'admin'] - - @property - def is_admin(self): - """Check if user is an admin""" - return self.role == 'admin' - - -class UserProfile(BaseModel): - """ - Extended user profile information. - - Stores additional user preferences and settings. - """ - - user = models.OneToOneField( - User, - on_delete=models.CASCADE, - related_name='profile' - ) - - # Preferences - email_notifications = models.BooleanField( - default=True, - help_text="Receive email notifications" - ) - email_on_submission_approved = models.BooleanField( - default=True, - help_text="Email when submissions are approved" - ) - email_on_submission_rejected = models.BooleanField( - default=True, - help_text="Email when submissions are rejected" - ) - - # Privacy - profile_public = models.BooleanField( - default=True, - help_text="Make profile publicly visible" - ) - show_email = models.BooleanField( - default=False, - help_text="Show email on public profile" - ) - - # Statistics - total_submissions = models.IntegerField( - default=0, - help_text="Total number of submissions made" - ) - approved_submissions = models.IntegerField( - default=0, - help_text="Number of approved submissions" - ) - - class Meta: - db_table = 'user_profiles' - - def __str__(self): - return f"Profile for {self.user.email}" - - def update_submission_stats(self): - """Update submission statistics""" - from apps.moderation.models import ContentSubmission - self.total_submissions = ContentSubmission.objects.filter(user=self.user).count() - self.approved_submissions = ContentSubmission.objects.filter( - user=self.user, - status='approved' - ).count() - self.save(update_fields=['total_submissions', 'approved_submissions']) diff --git a/django/apps/users/permissions.py b/django/apps/users/permissions.py deleted file mode 100644 index d62a8753..00000000 --- a/django/apps/users/permissions.py +++ /dev/null @@ -1,310 +0,0 @@ -""" -Permission utilities and decorators for API endpoints. - -Provides: -- Permission checking decorators -- Role-based access control -- Object-level permissions -""" - -from functools import wraps -from typing import Optional, Callable -from django.http import HttpRequest -from ninja import Router -from ninja.security import HttpBearer -from rest_framework_simplejwt.tokens import AccessToken -from rest_framework_simplejwt.exceptions import TokenError -from django.core.exceptions import PermissionDenied -import logging - -from .models import User, UserRole - -logger = logging.getLogger(__name__) - - -class JWTAuth(HttpBearer): - """JWT authentication for django-ninja""" - - def authenticate(self, request: HttpRequest, token: str) -> Optional[User]: - """ - Authenticate user from JWT token. - - Args: - request: HTTP request - token: JWT access token - - Returns: - User instance if valid, None otherwise - """ - try: - # Decode token - access_token = AccessToken(token) - user_id = access_token['user_id'] - - # Get user - user = User.objects.get(id=user_id) - - # Check if banned - if user.banned: - logger.warning(f"Banned user attempted API access: {user.email}") - return None - - return user - - except TokenError as e: - logger.debug(f"Invalid token: {e}") - return None - except User.DoesNotExist: - logger.warning(f"Token for non-existent user: {user_id}") - return None - except Exception as e: - logger.error(f"Authentication error: {e}") - return None - - -# Global JWT auth instance -jwt_auth = JWTAuth() - - -def require_auth(func: Callable) -> Callable: - """ - Decorator to require authentication. - - Usage: - @api.get("/protected") - @require_auth - def protected_endpoint(request): - return {"user": request.auth.email} - """ - @wraps(func) - def wrapper(request: HttpRequest, *args, **kwargs): - if not request.auth or not isinstance(request.auth, User): - raise PermissionDenied("Authentication required") - return func(request, *args, **kwargs) - return wrapper - - -def require_role(role: str) -> Callable: - """ - Decorator to require specific role. - - Args: - role: Required role (user, moderator, admin) - - Usage: - @api.post("/moderate") - @require_role("moderator") - def moderate_endpoint(request): - return {"message": "Access granted"} - """ - def decorator(func: Callable) -> Callable: - @wraps(func) - def wrapper(request: HttpRequest, *args, **kwargs): - if not request.auth or not isinstance(request.auth, User): - raise PermissionDenied("Authentication required") - - user = request.auth - - try: - user_role = user.role - - # Admin has access to everything - if user_role.is_admin: - return func(request, *args, **kwargs) - - # Check specific role - if role == 'moderator' and user_role.is_moderator: - return func(request, *args, **kwargs) - elif role == 'user': - return func(request, *args, **kwargs) - - raise PermissionDenied(f"Role '{role}' required") - - except UserRole.DoesNotExist: - raise PermissionDenied("User role not assigned") - - return wrapper - return decorator - - -def require_moderator(func: Callable) -> Callable: - """ - Decorator to require moderator or admin role. - - Usage: - @api.post("/approve") - @require_moderator - def approve_endpoint(request): - return {"message": "Access granted"} - """ - return require_role("moderator")(func) - - -def require_admin(func: Callable) -> Callable: - """ - Decorator to require admin role. - - Usage: - @api.delete("/delete-user") - @require_admin - def delete_user_endpoint(request): - return {"message": "Access granted"} - """ - def decorator(func: Callable) -> Callable: - @wraps(func) - def wrapper(request: HttpRequest, *args, **kwargs): - if not request.auth or not isinstance(request.auth, User): - raise PermissionDenied("Authentication required") - - user = request.auth - - try: - user_role = user.role - - if not user_role.is_admin: - raise PermissionDenied("Admin role required") - - return func(request, *args, **kwargs) - - except UserRole.DoesNotExist: - raise PermissionDenied("User role not assigned") - - return wrapper - return decorator - - -def is_owner_or_moderator(user: User, obj_user_id) -> bool: - """ - Check if user is the owner of an object or a moderator. - - Args: - user: User to check - obj_user_id: User ID of the object owner - - Returns: - True if user is owner or moderator - """ - if str(user.id) == str(obj_user_id): - return True - - try: - return user.role.is_moderator - except UserRole.DoesNotExist: - return False - - -def can_moderate(user: User) -> bool: - """ - Check if user can moderate content. - - Args: - user: User to check - - Returns: - True if user is moderator or admin - """ - if user.banned: - return False - - try: - return user.role.is_moderator - except UserRole.DoesNotExist: - return False - - -def can_submit(user: User) -> bool: - """ - Check if user can submit content. - - Args: - user: User to check - - Returns: - True if user is not banned - """ - return not user.banned - - -class PermissionChecker: - """Helper class for checking permissions""" - - def __init__(self, user: User): - self.user = user - try: - self.user_role = user.role - except UserRole.DoesNotExist: - self.user_role = None - - @property - def is_authenticated(self) -> bool: - """Check if user is authenticated""" - return self.user is not None - - @property - def is_moderator(self) -> bool: - """Check if user is moderator or admin""" - if self.user.banned: - return False - return self.user_role and self.user_role.is_moderator - - @property - def is_admin(self) -> bool: - """Check if user is admin""" - if self.user.banned: - return False - return self.user_role and self.user_role.is_admin - - @property - def can_submit(self) -> bool: - """Check if user can submit content""" - return not self.user.banned - - @property - def can_moderate(self) -> bool: - """Check if user can moderate content""" - return self.is_moderator - - def can_edit(self, obj_user_id) -> bool: - """Check if user can edit an object""" - if self.user.banned: - return False - return str(self.user.id) == str(obj_user_id) or self.is_moderator - - def can_delete(self, obj_user_id) -> bool: - """Check if user can delete an object""" - if self.user.banned: - return False - return str(self.user.id) == str(obj_user_id) or self.is_admin - - def require_permission(self, permission: str) -> None: - """ - Raise PermissionDenied if user doesn't have permission. - - Args: - permission: Permission to check (submit, moderate, admin) - - Raises: - PermissionDenied: If user doesn't have permission - """ - if permission == 'submit' and not self.can_submit: - raise PermissionDenied("You are banned from submitting content") - elif permission == 'moderate' and not self.can_moderate: - raise PermissionDenied("Moderator role required") - elif permission == 'admin' and not self.is_admin: - raise PermissionDenied("Admin role required") - - -def get_permission_checker(request: HttpRequest) -> Optional[PermissionChecker]: - """ - Get permission checker for request user. - - Args: - request: HTTP request - - Returns: - PermissionChecker instance or None if not authenticated - """ - if not request.auth or not isinstance(request.auth, User): - return None - - return PermissionChecker(request.auth) diff --git a/django/apps/users/services.py b/django/apps/users/services.py deleted file mode 100644 index 4bc03930..00000000 --- a/django/apps/users/services.py +++ /dev/null @@ -1,592 +0,0 @@ -""" -User authentication and management services. - -Provides business logic for: -- User registration and authentication -- OAuth integration -- MFA/2FA management -- Permission and role management -""" - -from typing import Optional, Dict, Any -from django.contrib.auth import authenticate -from django.contrib.auth.password_validation import validate_password -from django.core.exceptions import ValidationError -from django.db import transaction -from django.utils import timezone -from django_otp.plugins.otp_totp.models import TOTPDevice -from allauth.socialaccount.models import SocialAccount -import logging - -from .models import User, UserRole, UserProfile - -logger = logging.getLogger(__name__) - - -class AuthenticationService: - """Service for handling user authentication operations""" - - @staticmethod - @transaction.atomic - def register_user( - email: str, - password: str, - username: Optional[str] = None, - first_name: str = '', - last_name: str = '' - ) -> User: - """ - Register a new user with email and password. - - Args: - email: User's email address - password: User's password (will be validated and hashed) - username: Optional username (defaults to email prefix) - first_name: User's first name - last_name: User's last name - - Returns: - Created User instance - - Raises: - ValidationError: If email exists or password is invalid - """ - # Normalize email - email = email.lower().strip() - - # Check if user exists - if User.objects.filter(email=email).exists(): - raise ValidationError({'email': 'A user with this email already exists.'}) - - # Set username if not provided - if not username: - username = email.split('@')[0] - # Make unique if needed - base_username = username - counter = 1 - while User.objects.filter(username=username).exists(): - username = f"{base_username}{counter}" - counter += 1 - - # Validate password - try: - validate_password(password) - except ValidationError as e: - raise ValidationError({'password': e.messages}) - - # Create user - user = User.objects.create_user( - email=email, - username=username, - password=password, - first_name=first_name, - last_name=last_name - ) - - # Create role (default: user) - UserRole.objects.create(user=user, role='user') - - # Create profile - UserProfile.objects.create(user=user) - - logger.info(f"New user registered: {user.email}") - return user - - @staticmethod - def authenticate_user(email: str, password: str) -> Optional[User]: - """ - Authenticate user with email and password. - - Args: - email: User's email address - password: User's password - - Returns: - User instance if authentication successful, None otherwise - """ - email = email.lower().strip() - user = authenticate(username=email, password=password) - - if user and user.banned: - logger.warning(f"Banned user attempted login: {email}") - raise ValidationError("This account has been banned.") - - if user: - user.last_login = timezone.now() - user.save(update_fields=['last_login']) - logger.info(f"User authenticated: {email}") - - return user - - @staticmethod - @transaction.atomic - def create_oauth_user( - email: str, - provider: str, - oauth_sub: str, - username: Optional[str] = None, - first_name: str = '', - last_name: str = '', - avatar_url: str = '' - ) -> User: - """ - Create or get user from OAuth provider. - - Args: - email: User's email from OAuth provider - provider: OAuth provider name (google, discord) - oauth_sub: OAuth subject identifier - username: Optional username - first_name: User's first name - last_name: User's last name - avatar_url: URL to user's avatar - - Returns: - User instance - """ - email = email.lower().strip() - - # Check if user exists with this email - try: - user = User.objects.get(email=email) - # Update OAuth info if not set - if not user.oauth_provider: - user.oauth_provider = provider - user.oauth_sub = oauth_sub - user.save(update_fields=['oauth_provider', 'oauth_sub']) - return user - except User.DoesNotExist: - pass - - # Create new user - if not username: - username = email.split('@')[0] - base_username = username - counter = 1 - while User.objects.filter(username=username).exists(): - username = f"{base_username}{counter}" - counter += 1 - - user = User.objects.create( - email=email, - username=username, - first_name=first_name, - last_name=last_name, - avatar_url=avatar_url, - oauth_provider=provider, - oauth_sub=oauth_sub - ) - - # No password needed for OAuth users - user.set_unusable_password() - user.save() - - # Create role and profile - UserRole.objects.create(user=user, role='user') - UserProfile.objects.create(user=user) - - logger.info(f"OAuth user created: {email} via {provider}") - return user - - @staticmethod - def change_password(user: User, old_password: str, new_password: str) -> bool: - """ - Change user's password. - - Args: - user: User instance - old_password: Current password - new_password: New password - - Returns: - True if successful - - Raises: - ValidationError: If old password is incorrect or new password is invalid - """ - # Check old password - if not user.check_password(old_password): - raise ValidationError({'old_password': 'Incorrect password.'}) - - # Validate new password - try: - validate_password(new_password, user=user) - except ValidationError as e: - raise ValidationError({'new_password': e.messages}) - - # Set new password - user.set_password(new_password) - user.save() - - logger.info(f"Password changed for user: {user.email}") - return True - - @staticmethod - def reset_password(user: User, new_password: str) -> bool: - """ - Reset user's password (admin/forgot password flow). - - Args: - user: User instance - new_password: New password - - Returns: - True if successful - - Raises: - ValidationError: If new password is invalid - """ - # Validate new password - try: - validate_password(new_password, user=user) - except ValidationError as e: - raise ValidationError({'password': e.messages}) - - # Set new password - user.set_password(new_password) - user.save() - - logger.info(f"Password reset for user: {user.email}") - return True - - -class MFAService: - """Service for handling multi-factor authentication""" - - @staticmethod - def enable_totp(user: User, device_name: str = 'default') -> TOTPDevice: - """ - Enable TOTP-based MFA for user. - - Args: - user: User instance - device_name: Name for the TOTP device - - Returns: - TOTPDevice instance with QR code data - """ - # Check if device already exists - device = TOTPDevice.objects.filter( - user=user, - name=device_name - ).first() - - if not device: - device = TOTPDevice.objects.create( - user=user, - name=device_name, - confirmed=False - ) - - return device - - @staticmethod - @transaction.atomic - def confirm_totp(user: User, token: str, device_name: str = 'default') -> bool: - """ - Confirm TOTP device with verification token. - - Args: - user: User instance - token: 6-digit TOTP token - device_name: Name of the TOTP device - - Returns: - True if successful - - Raises: - ValidationError: If token is invalid - """ - device = TOTPDevice.objects.filter( - user=user, - name=device_name - ).first() - - if not device: - raise ValidationError("TOTP device not found.") - - # Verify token - if not device.verify_token(token): - raise ValidationError("Invalid verification code.") - - # Confirm device - device.confirmed = True - device.save() - - # Enable MFA on user - user.mfa_enabled = True - user.save(update_fields=['mfa_enabled']) - - logger.info(f"MFA enabled for user: {user.email}") - return True - - @staticmethod - def verify_totp(user: User, token: str) -> bool: - """ - Verify TOTP token for authentication. - - Args: - user: User instance - token: 6-digit TOTP token - - Returns: - True if valid - """ - device = TOTPDevice.objects.filter( - user=user, - confirmed=True - ).first() - - if not device: - return False - - return device.verify_token(token) - - @staticmethod - @transaction.atomic - def disable_totp(user: User) -> bool: - """ - Disable TOTP-based MFA for user. - - Args: - user: User instance - - Returns: - True if successful - """ - # Delete all TOTP devices - TOTPDevice.objects.filter(user=user).delete() - - # Disable MFA on user - user.mfa_enabled = False - user.save(update_fields=['mfa_enabled']) - - logger.info(f"MFA disabled for user: {user.email}") - return True - - -class RoleService: - """Service for managing user roles and permissions""" - - @staticmethod - @transaction.atomic - def assign_role( - user: User, - role: str, - granted_by: Optional[User] = None - ) -> UserRole: - """ - Assign role to user. - - Args: - user: User to assign role to - role: Role name (user, moderator, admin) - granted_by: User granting the role - - Returns: - UserRole instance - - Raises: - ValidationError: If role is invalid - """ - valid_roles = ['user', 'moderator', 'admin'] - if role not in valid_roles: - raise ValidationError(f"Invalid role. Must be one of: {', '.join(valid_roles)}") - - # Get or create role - user_role, created = UserRole.objects.get_or_create( - user=user, - defaults={'role': role, 'granted_by': granted_by} - ) - - if not created and user_role.role != role: - user_role.role = role - user_role.granted_by = granted_by - user_role.granted_at = timezone.now() - user_role.save() - - logger.info(f"Role '{role}' assigned to user: {user.email}") - return user_role - - @staticmethod - def has_role(user: User, role: str) -> bool: - """ - Check if user has specific role. - - Args: - user: User instance - role: Role name to check - - Returns: - True if user has the role - """ - try: - user_role = user.role - if role == 'moderator': - return user_role.is_moderator - elif role == 'admin': - return user_role.is_admin - return user_role.role == role - except UserRole.DoesNotExist: - return False - - @staticmethod - def get_user_permissions(user: User) -> Dict[str, bool]: - """ - Get user's permission summary. - - Args: - user: User instance - - Returns: - Dictionary of permissions - """ - try: - user_role = user.role - is_moderator = user_role.is_moderator - is_admin = user_role.is_admin - except UserRole.DoesNotExist: - is_moderator = False - is_admin = False - - return { - 'can_submit': not user.banned, - 'can_moderate': is_moderator and not user.banned, - 'can_admin': is_admin and not user.banned, - 'can_edit_own': not user.banned, - 'can_delete_own': not user.banned, - } - - -class UserManagementService: - """Service for user profile and account management""" - - @staticmethod - @transaction.atomic - def update_profile( - user: User, - **kwargs - ) -> User: - """ - Update user profile information. - - Args: - user: User instance - **kwargs: Fields to update - - Returns: - Updated User instance - """ - allowed_fields = [ - 'first_name', 'last_name', 'username', - 'avatar_url', 'bio' - ] - - updated_fields = [] - for field, value in kwargs.items(): - if field in allowed_fields and hasattr(user, field): - setattr(user, field, value) - updated_fields.append(field) - - if updated_fields: - user.save(update_fields=updated_fields) - logger.info(f"Profile updated for user: {user.email}") - - return user - - @staticmethod - @transaction.atomic - def update_preferences( - user: User, - **kwargs - ) -> UserProfile: - """ - Update user preferences. - - Args: - user: User instance - **kwargs: Preference fields to update - - Returns: - Updated UserProfile instance - """ - profile = user.profile - - allowed_fields = [ - 'email_notifications', - 'email_on_submission_approved', - 'email_on_submission_rejected', - 'profile_public', - 'show_email' - ] - - updated_fields = [] - for field, value in kwargs.items(): - if field in allowed_fields and hasattr(profile, field): - setattr(profile, field, value) - updated_fields.append(field) - - if updated_fields: - profile.save(update_fields=updated_fields) - logger.info(f"Preferences updated for user: {user.email}") - - return profile - - @staticmethod - @transaction.atomic - def ban_user( - user: User, - reason: str, - banned_by: User - ) -> User: - """ - Ban a user. - - Args: - user: User to ban - reason: Reason for ban - banned_by: User performing the ban - - Returns: - Updated User instance - """ - user.ban(reason=reason, banned_by=banned_by) - logger.warning(f"User banned: {user.email} by {banned_by.email}. Reason: {reason}") - return user - - @staticmethod - @transaction.atomic - def unban_user(user: User) -> User: - """ - Unban a user. - - Args: - user: User to unban - - Returns: - Updated User instance - """ - user.unban() - logger.info(f"User unbanned: {user.email}") - return user - - @staticmethod - def get_user_stats(user: User) -> Dict[str, Any]: - """ - Get user statistics. - - Args: - user: User instance - - Returns: - Dictionary of user stats - """ - profile = user.profile - - return { - 'total_submissions': profile.total_submissions, - 'approved_submissions': profile.approved_submissions, - 'reputation_score': user.reputation_score, - 'member_since': user.date_joined, - 'last_active': user.last_login, - } diff --git a/django/apps/users/tasks.py b/django/apps/users/tasks.py deleted file mode 100644 index c579fdad..00000000 --- a/django/apps/users/tasks.py +++ /dev/null @@ -1,343 +0,0 @@ -""" -Background tasks for user management and notifications. -""" - -import logging -from celery import shared_task -from django.core.mail import send_mail -from django.template.loader import render_to_string -from django.conf import settings -from django.utils import timezone -from datetime import timedelta - -logger = logging.getLogger(__name__) - - -@shared_task(bind=True, max_retries=3, default_retry_delay=60) -def send_welcome_email(self, user_id): - """ - Send a welcome email to a newly registered user. - - Args: - user_id: ID of the User - - Returns: - str: Email send result - """ - from apps.users.models import User - - try: - user = User.objects.get(id=user_id) - - context = { - 'user': user, - 'site_url': getattr(settings, 'SITE_URL', 'https://thrillwiki.com'), - } - - html_message = render_to_string('emails/welcome.html', context) - - send_mail( - subject='Welcome to ThrillWiki! 🎢', - message='', - html_message=html_message, - from_email=settings.DEFAULT_FROM_EMAIL, - recipient_list=[user.email], - fail_silently=False, - ) - - logger.info(f"Welcome email sent to {user.email}") - return f"Welcome email sent to {user.email}" - - except User.DoesNotExist: - logger.error(f"User {user_id} not found") - raise - except Exception as exc: - logger.error(f"Error sending welcome email to user {user_id}: {str(exc)}") - raise self.retry(exc=exc, countdown=60 * (2 ** self.request.retries)) - - -@shared_task(bind=True, max_retries=3, default_retry_delay=60) -def send_password_reset_email(self, user_id, token, reset_url): - """ - Send a password reset email with a secure token. - - Args: - user_id: ID of the User - token: Password reset token - reset_url: Full URL for password reset - - Returns: - str: Email send result - """ - from apps.users.models import User - - try: - user = User.objects.get(id=user_id) - - context = { - 'user': user, - 'reset_url': reset_url, - 'request_time': timezone.now(), - 'expiry_hours': 24, # Configurable - 'site_url': getattr(settings, 'SITE_URL', 'https://thrillwiki.com'), - } - - html_message = render_to_string('emails/password_reset.html', context) - - send_mail( - subject='Reset Your ThrillWiki Password', - message='', - html_message=html_message, - from_email=settings.DEFAULT_FROM_EMAIL, - recipient_list=[user.email], - fail_silently=False, - ) - - logger.info(f"Password reset email sent to {user.email}") - return f"Password reset email sent to {user.email}" - - except User.DoesNotExist: - logger.error(f"User {user_id} not found") - raise - except Exception as exc: - logger.error(f"Error sending password reset email: {str(exc)}") - raise self.retry(exc=exc, countdown=60 * (2 ** self.request.retries)) - - -@shared_task(bind=True, max_retries=2) -def cleanup_expired_tokens(self): - """ - Clean up expired JWT tokens and password reset tokens. - - This task runs daily to remove old tokens from the database. - - Returns: - dict: Cleanup statistics - """ - from rest_framework_simplejwt.token_blacklist.models import OutstandingToken - from django.contrib.auth.tokens import default_token_generator - - try: - # Clean up blacklisted JWT tokens older than 7 days - cutoff = timezone.now() - timedelta(days=7) - - # Note: Actual implementation depends on token storage strategy - # This is a placeholder for the concept - - logger.info("Token cleanup completed") - - return { - 'jwt_tokens_cleaned': 0, - 'reset_tokens_cleaned': 0, - } - - except Exception as exc: - logger.error(f"Error cleaning up tokens: {str(exc)}") - raise self.retry(exc=exc, countdown=300) - - -@shared_task(bind=True, max_retries=3) -def send_account_notification(self, user_id, notification_type, context_data=None): - """ - Send a generic account notification email. - - Args: - user_id: ID of the User - notification_type: Type of notification (e.g., 'security_alert', 'profile_update') - context_data: Additional context data for the email - - Returns: - str: Email send result - """ - from apps.users.models import User - - try: - user = User.objects.get(id=user_id) - - context = { - 'user': user, - 'notification_type': notification_type, - 'site_url': getattr(settings, 'SITE_URL', 'https://thrillwiki.com'), - } - - if context_data: - context.update(context_data) - - # For now, just log (would need specific templates for each type) - logger.info(f"Account notification ({notification_type}) for user {user.email}") - - return f"Notification sent to {user.email}" - - except User.DoesNotExist: - logger.error(f"User {user_id} not found") - raise - except Exception as exc: - logger.error(f"Error sending account notification: {str(exc)}") - raise self.retry(exc=exc, countdown=60 * (2 ** self.request.retries)) - - -@shared_task(bind=True, max_retries=2) -def cleanup_inactive_users(self, days_inactive=365): - """ - Clean up or flag users who haven't logged in for a long time. - - Args: - days_inactive: Number of days of inactivity before flagging (default: 365) - - Returns: - dict: Cleanup statistics - """ - from apps.users.models import User - - try: - cutoff = timezone.now() - timedelta(days=days_inactive) - - inactive_users = User.objects.filter( - last_login__lt=cutoff, - is_active=True - ) - - count = inactive_users.count() - - # For now, just log inactive users - # In production, you might want to send reactivation emails - # or mark accounts for deletion - - logger.info(f"Found {count} inactive users (last login before {cutoff})") - - return { - 'inactive_count': count, - 'cutoff_date': cutoff.isoformat(), - } - - except Exception as exc: - logger.error(f"Error cleaning up inactive users: {str(exc)}") - raise self.retry(exc=exc, countdown=300) - - -@shared_task -def update_user_statistics(): - """ - Update user-related statistics across the database. - - Returns: - dict: Updated statistics - """ - from apps.users.models import User - from django.db.models import Count - from datetime import timedelta - - try: - now = timezone.now() - week_ago = now - timedelta(days=7) - month_ago = now - timedelta(days=30) - - stats = { - 'total_users': User.objects.count(), - 'active_users': User.objects.filter(is_active=True).count(), - 'new_this_week': User.objects.filter(date_joined__gte=week_ago).count(), - 'new_this_month': User.objects.filter(date_joined__gte=month_ago).count(), - 'verified_users': User.objects.filter(email_verified=True).count(), - 'by_role': dict( - User.objects.values('role__name') - .annotate(count=Count('id')) - .values_list('role__name', 'count') - ), - } - - logger.info(f"User statistics updated: {stats}") - return stats - - except Exception as e: - logger.error(f"Error updating user statistics: {str(e)}") - raise - - -@shared_task(bind=True, max_retries=3) -def send_bulk_notification(self, user_ids, subject, message, html_message=None): - """ - Send bulk email notifications to multiple users. - - This is useful for announcements, feature updates, etc. - - Args: - user_ids: List of User IDs - subject: Email subject - message: Plain text message - html_message: HTML version of message (optional) - - Returns: - dict: Send statistics - """ - from apps.users.models import User - - try: - users = User.objects.filter(id__in=user_ids, is_active=True) - - sent_count = 0 - failed_count = 0 - - for user in users: - try: - send_mail( - subject=subject, - message=message, - html_message=html_message, - from_email=settings.DEFAULT_FROM_EMAIL, - recipient_list=[user.email], - fail_silently=False, - ) - sent_count += 1 - except Exception as e: - logger.error(f"Failed to send to {user.email}: {str(e)}") - failed_count += 1 - continue - - result = { - 'total': len(user_ids), - 'sent': sent_count, - 'failed': failed_count, - } - - logger.info(f"Bulk notification sent: {result}") - return result - - except Exception as exc: - logger.error(f"Error sending bulk notification: {str(exc)}") - raise self.retry(exc=exc, countdown=60 * (2 ** self.request.retries)) - - -@shared_task(bind=True, max_retries=2) -def send_email_verification_reminder(self, user_id): - """ - Send a reminder to users who haven't verified their email. - - Args: - user_id: ID of the User - - Returns: - str: Reminder result - """ - from apps.users.models import User - - try: - user = User.objects.get(id=user_id) - - if user.email_verified: - logger.info(f"User {user.email} already verified, skipping reminder") - return "User already verified" - - # Send verification reminder - logger.info(f"Sending email verification reminder to {user.email}") - - # In production, generate new verification token and send email - # For now, just log - - return f"Verification reminder sent to {user.email}" - - except User.DoesNotExist: - logger.error(f"User {user_id} not found") - raise - except Exception as exc: - logger.error(f"Error sending verification reminder: {str(exc)}") - raise self.retry(exc=exc, countdown=60 * (2 ** self.request.retries)) diff --git a/django/apps/versioning/__init__.py b/django/apps/versioning/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/django/apps/versioning/__pycache__/__init__.cpython-313.pyc b/django/apps/versioning/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index aeb90c26..00000000 Binary files a/django/apps/versioning/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/django/apps/versioning/__pycache__/admin.cpython-313.pyc b/django/apps/versioning/__pycache__/admin.cpython-313.pyc deleted file mode 100644 index 180bfb35..00000000 Binary files a/django/apps/versioning/__pycache__/admin.cpython-313.pyc and /dev/null differ diff --git a/django/apps/versioning/__pycache__/apps.cpython-313.pyc b/django/apps/versioning/__pycache__/apps.cpython-313.pyc deleted file mode 100644 index 3f38d574..00000000 Binary files a/django/apps/versioning/__pycache__/apps.cpython-313.pyc and /dev/null differ diff --git a/django/apps/versioning/__pycache__/models.cpython-313.pyc b/django/apps/versioning/__pycache__/models.cpython-313.pyc deleted file mode 100644 index b913ccaf..00000000 Binary files a/django/apps/versioning/__pycache__/models.cpython-313.pyc and /dev/null differ diff --git a/django/apps/versioning/__pycache__/services.cpython-313.pyc b/django/apps/versioning/__pycache__/services.cpython-313.pyc deleted file mode 100644 index ba7149a5..00000000 Binary files a/django/apps/versioning/__pycache__/services.cpython-313.pyc and /dev/null differ diff --git a/django/apps/versioning/admin.py b/django/apps/versioning/admin.py deleted file mode 100644 index 0c84ad5e..00000000 --- a/django/apps/versioning/admin.py +++ /dev/null @@ -1,236 +0,0 @@ -""" -Admin interface for versioning models. - -Provides Django admin interface for viewing version history, -comparing versions, and managing version records. -""" - -from django.contrib import admin -from django.utils.html import format_html -from django.urls import reverse -from unfold.admin import ModelAdmin - -from apps.versioning.models import EntityVersion - - -@admin.register(EntityVersion) -class EntityVersionAdmin(ModelAdmin): - """ - Admin interface for EntityVersion model. - - Provides read-only view of version history with search and filtering. - """ - - # Display settings - list_display = [ - 'version_number', - 'entity_link', - 'change_type', - 'changed_by_link', - 'submission_link', - 'changed_field_count', - 'created', - ] - - list_filter = [ - 'change_type', - 'entity_type', - 'created', - ] - - search_fields = [ - 'entity_id', - 'comment', - 'changed_by__email', - 'changed_by__username', - ] - - ordering = ['-created'] - - date_hierarchy = 'created' - - # Read-only admin (versions should not be modified) - readonly_fields = [ - 'id', - 'entity_type', - 'entity_id', - 'entity_link', - 'version_number', - 'change_type', - 'snapshot_display', - 'changed_fields_display', - 'changed_by', - 'submission', - 'comment', - 'ip_address', - 'user_agent', - 'created', - 'modified', - ] - - fieldsets = ( - ('Version Information', { - 'fields': ( - 'id', - 'version_number', - 'change_type', - 'created', - 'modified', - ) - }), - ('Entity', { - 'fields': ( - 'entity_type', - 'entity_id', - 'entity_link', - ) - }), - ('Changes', { - 'fields': ( - 'changed_fields_display', - 'snapshot_display', - ) - }), - ('Metadata', { - 'fields': ( - 'changed_by', - 'submission', - 'comment', - 'ip_address', - 'user_agent', - ) - }), - ) - - def has_add_permission(self, request): - """Disable adding versions manually.""" - return False - - def has_delete_permission(self, request, obj=None): - """Disable deleting versions.""" - return False - - def has_change_permission(self, request, obj=None): - """Only allow viewing versions, not editing.""" - return False - - def entity_link(self, obj): - """Display link to the entity.""" - try: - entity = obj.entity - if entity: - # Try to get admin URL for entity - admin_url = reverse( - f'admin:{obj.entity_type.app_label}_{obj.entity_type.model}_change', - args=[entity.pk] - ) - return format_html( - '{}', - admin_url, - str(entity) - ) - except: - pass - return f"{obj.entity_type.model}:{obj.entity_id}" - entity_link.short_description = 'Entity' - - def changed_by_link(self, obj): - """Display link to user who made the change.""" - if obj.changed_by: - try: - admin_url = reverse( - 'admin:users_user_change', - args=[obj.changed_by.pk] - ) - return format_html( - '{}', - admin_url, - obj.changed_by.email - ) - except: - return obj.changed_by.email - return '-' - changed_by_link.short_description = 'Changed By' - - def submission_link(self, obj): - """Display link to content submission if applicable.""" - if obj.submission: - try: - admin_url = reverse( - 'admin:moderation_contentsubmission_change', - args=[obj.submission.pk] - ) - return format_html( - '#{}', - admin_url, - obj.submission.pk - ) - except: - return str(obj.submission.pk) - return '-' - submission_link.short_description = 'Submission' - - def changed_field_count(self, obj): - """Display count of changed fields.""" - count = len(obj.changed_fields) - if count == 0: - return '-' - return f"{count} field{'s' if count != 1 else ''}" - changed_field_count.short_description = 'Changed Fields' - - def snapshot_display(self, obj): - """Display snapshot in a formatted way.""" - import json - snapshot = obj.get_snapshot_dict() - - # Format as pretty JSON - formatted = json.dumps(snapshot, indent=2, sort_keys=True) - - return format_html( - '
{}
', - formatted - ) - snapshot_display.short_description = 'Snapshot' - - def changed_fields_display(self, obj): - """Display changed fields in a formatted way.""" - if not obj.changed_fields: - return format_html('No fields changed') - - html_parts = [''] - html_parts.append('') - html_parts.append('') - html_parts.append('') - html_parts.append('') - html_parts.append('') - - for field_name, change in obj.changed_fields.items(): - old_val = change.get('old', '-') - new_val = change.get('new', '-') - - # Truncate long values - if isinstance(old_val, str) and len(old_val) > 100: - old_val = old_val[:97] + '...' - if isinstance(new_val, str) and len(new_val) > 100: - new_val = new_val[:97] + '...' - - html_parts.append('') - html_parts.append(f'') - html_parts.append(f'') - html_parts.append(f'') - html_parts.append('') - - html_parts.append('
FieldOld ValueNew Value
{field_name}{old_val}{new_val}
') - - return format_html(''.join(html_parts)) - changed_fields_display.short_description = 'Changed Fields' - - def get_queryset(self, request): - """Optimize queryset with select_related.""" - qs = super().get_queryset(request) - return qs.select_related( - 'entity_type', - 'changed_by', - 'submission', - 'submission__user' - ) diff --git a/django/apps/versioning/apps.py b/django/apps/versioning/apps.py deleted file mode 100644 index 84c20f0c..00000000 --- a/django/apps/versioning/apps.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -Versioning app configuration. -""" - -from django.apps import AppConfig - - -class VersioningConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.versioning' - verbose_name = 'Versioning' diff --git a/django/apps/versioning/migrations/0001_initial.py b/django/apps/versioning/migrations/0001_initial.py deleted file mode 100644 index f2a2f6b6..00000000 --- a/django/apps/versioning/migrations/0001_initial.py +++ /dev/null @@ -1,165 +0,0 @@ -# Generated by Django 4.2.8 on 2025-11-08 17:51 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import django.utils.timezone -import django_lifecycle.mixins -import model_utils.fields -import uuid - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("contenttypes", "0002_remove_content_type_name"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("moderation", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="EntityVersion", - fields=[ - ( - "created", - model_utils.fields.AutoCreatedField( - default=django.utils.timezone.now, - editable=False, - verbose_name="created", - ), - ), - ( - "modified", - model_utils.fields.AutoLastModifiedField( - default=django.utils.timezone.now, - editable=False, - verbose_name="modified", - ), - ), - ( - "id", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ( - "entity_id", - models.UUIDField(db_index=True, help_text="ID of the entity"), - ), - ( - "version_number", - models.PositiveIntegerField( - default=1, help_text="Sequential version number for this entity" - ), - ), - ( - "change_type", - models.CharField( - choices=[ - ("created", "Created"), - ("updated", "Updated"), - ("deleted", "Deleted"), - ("restored", "Restored"), - ], - db_index=True, - help_text="Type of change", - max_length=20, - ), - ), - ( - "snapshot", - models.JSONField( - help_text="Complete snapshot of entity state as JSON" - ), - ), - ( - "changed_fields", - models.JSONField( - default=dict, - help_text="Dict of changed fields with old/new values: {'field': {'old': ..., 'new': ...}}", - ), - ), - ( - "comment", - models.TextField( - blank=True, help_text="Optional comment about this version" - ), - ), - ( - "ip_address", - models.GenericIPAddressField( - blank=True, help_text="IP address of change origin", null=True - ), - ), - ( - "user_agent", - models.CharField( - blank=True, help_text="User agent string", max_length=500 - ), - ), - ( - "changed_by", - models.ForeignKey( - blank=True, - help_text="User who made the change", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="entity_versions", - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "entity_type", - models.ForeignKey( - help_text="Type of entity (Park, Ride, Company, etc.)", - on_delete=django.db.models.deletion.CASCADE, - related_name="entity_versions", - to="contenttypes.contenttype", - ), - ), - ( - "submission", - models.ForeignKey( - blank=True, - help_text="Submission that caused this version (if applicable)", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="versions", - to="moderation.contentsubmission", - ), - ), - ], - options={ - "verbose_name": "Entity Version", - "verbose_name_plural": "Entity Versions", - "ordering": ["-created"], - "indexes": [ - models.Index( - fields=["entity_type", "entity_id", "-created"], - name="versioning__entity__8eabd9_idx", - ), - models.Index( - fields=["entity_type", "entity_id", "-version_number"], - name="versioning__entity__fe6f1b_idx", - ), - models.Index( - fields=["change_type"], name="versioning__change__17de57_idx" - ), - models.Index( - fields=["changed_by"], name="versioning__changed_39d5fd_idx" - ), - models.Index( - fields=["submission"], name="versioning__submiss_345f6b_idx" - ), - ], - "unique_together": {("entity_type", "entity_id", "version_number")}, - }, - bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model), - ), - ] diff --git a/django/apps/versioning/migrations/__init__.py b/django/apps/versioning/migrations/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/django/apps/versioning/migrations/__pycache__/0001_initial.cpython-313.pyc b/django/apps/versioning/migrations/__pycache__/0001_initial.cpython-313.pyc deleted file mode 100644 index 98c4977d..00000000 Binary files a/django/apps/versioning/migrations/__pycache__/0001_initial.cpython-313.pyc and /dev/null differ diff --git a/django/apps/versioning/migrations/__pycache__/__init__.cpython-313.pyc b/django/apps/versioning/migrations/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 8326cfe4..00000000 Binary files a/django/apps/versioning/migrations/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/django/apps/versioning/models.py b/django/apps/versioning/models.py deleted file mode 100644 index 0db37194..00000000 --- a/django/apps/versioning/models.py +++ /dev/null @@ -1,287 +0,0 @@ -""" -Versioning models for ThrillWiki. - -This module provides automatic version tracking for all entities: -- EntityVersion: Generic version model using ContentType -- Full snapshot storage in JSON -- Changed fields tracking with old/new values -- Link to ContentSubmission when changes come from moderation -""" - -import json -from django.db import models -from django.contrib.contenttypes.models import ContentType -from django.contrib.contenttypes.fields import GenericForeignKey -from django.conf import settings - -from apps.core.models import BaseModel - - -class EntityVersion(BaseModel): - """ - Generic version tracking for all entities. - - Stores a complete snapshot of the entity state at the time of change, - along with metadata about what changed and who made the change. - """ - - CHANGE_TYPE_CHOICES = [ - ('created', 'Created'), - ('updated', 'Updated'), - ('deleted', 'Deleted'), - ('restored', 'Restored'), - ] - - # Entity reference (generic) - entity_type = models.ForeignKey( - ContentType, - on_delete=models.CASCADE, - related_name='entity_versions', - help_text="Type of entity (Park, Ride, Company, etc.)" - ) - entity_id = models.UUIDField( - db_index=True, - help_text="ID of the entity" - ) - entity = GenericForeignKey('entity_type', 'entity_id') - - # Version info - version_number = models.PositiveIntegerField( - default=1, - help_text="Sequential version number for this entity" - ) - change_type = models.CharField( - max_length=20, - choices=CHANGE_TYPE_CHOICES, - db_index=True, - help_text="Type of change" - ) - - # Snapshot of entity state - snapshot = models.JSONField( - help_text="Complete snapshot of entity state as JSON" - ) - - # Changed fields tracking - changed_fields = models.JSONField( - default=dict, - help_text="Dict of changed fields with old/new values: {'field': {'old': ..., 'new': ...}}" - ) - - # User who made the change - changed_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='entity_versions', - help_text="User who made the change" - ) - - # Link to ContentSubmission (if change came from moderation) - submission = models.ForeignKey( - 'moderation.ContentSubmission', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='versions', - help_text="Submission that caused this version (if applicable)" - ) - - # Metadata - comment = models.TextField( - blank=True, - help_text="Optional comment about this version" - ) - ip_address = models.GenericIPAddressField( - null=True, - blank=True, - help_text="IP address of change origin" - ) - user_agent = models.CharField( - max_length=500, - blank=True, - help_text="User agent string" - ) - - class Meta: - verbose_name = 'Entity Version' - verbose_name_plural = 'Entity Versions' - ordering = ['-created'] - indexes = [ - models.Index(fields=['entity_type', 'entity_id', '-created']), - models.Index(fields=['entity_type', 'entity_id', '-version_number']), - models.Index(fields=['change_type']), - models.Index(fields=['changed_by']), - models.Index(fields=['submission']), - ] - unique_together = [['entity_type', 'entity_id', 'version_number']] - - def __str__(self): - return f"{self.entity_type.model} v{self.version_number} ({self.change_type})" - - @property - def entity_name(self): - """Get display name of the entity.""" - try: - entity = self.entity - if entity: - return str(entity) - except: - pass - return f"{self.entity_type.model}:{self.entity_id}" - - def get_snapshot_dict(self): - """ - Get snapshot as Python dict. - - Returns: - dict: Entity snapshot - """ - if isinstance(self.snapshot, str): - return json.loads(self.snapshot) - return self.snapshot - - def get_changed_fields_list(self): - """ - Get list of changed field names. - - Returns: - list: Field names that changed - """ - return list(self.changed_fields.keys()) - - def get_field_change(self, field_name): - """ - Get old and new values for a specific field. - - Args: - field_name: Name of the field - - Returns: - dict: {'old': old_value, 'new': new_value} or None if field didn't change - """ - return self.changed_fields.get(field_name) - - def compare_with(self, other_version): - """ - Compare this version with another version. - - Args: - other_version: EntityVersion to compare with - - Returns: - dict: Comparison result with differences - """ - if not other_version or self.entity_id != other_version.entity_id: - return None - - this_snapshot = self.get_snapshot_dict() - other_snapshot = other_version.get_snapshot_dict() - - differences = {} - all_keys = set(this_snapshot.keys()) | set(other_snapshot.keys()) - - for key in all_keys: - this_val = this_snapshot.get(key) - other_val = other_snapshot.get(key) - - if this_val != other_val: - differences[key] = { - 'this': this_val, - 'other': other_val - } - - return { - 'this_version': self.version_number, - 'other_version': other_version.version_number, - 'differences': differences, - 'changed_field_count': len(differences) - } - - def get_diff_summary(self): - """ - Get human-readable summary of changes in this version. - - Returns: - str: Summary of changes - """ - if self.change_type == 'created': - return f"Created {self.entity_name}" - - if self.change_type == 'deleted': - return f"Deleted {self.entity_name}" - - changed_count = len(self.changed_fields) - if changed_count == 0: - return f"No changes to {self.entity_name}" - - field_names = ', '.join(self.get_changed_fields_list()[:3]) - if changed_count > 3: - field_names += f" and {changed_count - 3} more" - - return f"Updated {field_names}" - - @classmethod - def get_latest_version_number(cls, entity_type, entity_id): - """ - Get the latest version number for an entity. - - Args: - entity_type: ContentType of entity - entity_id: UUID of entity - - Returns: - int: Latest version number (0 if no versions exist) - """ - latest = cls.objects.filter( - entity_type=entity_type, - entity_id=entity_id - ).aggregate( - max_version=models.Max('version_number') - ) - return latest['max_version'] or 0 - - @classmethod - def get_history(cls, entity_type, entity_id, limit=50): - """ - Get version history for an entity. - - Args: - entity_type: ContentType of entity - entity_id: UUID of entity - limit: Maximum number of versions to return - - Returns: - QuerySet: Ordered list of versions (newest first) - """ - return cls.objects.filter( - entity_type=entity_type, - entity_id=entity_id - ).select_related( - 'changed_by', - 'submission', - 'submission__user' - ).order_by('-version_number')[:limit] - - @classmethod - def get_version_by_number(cls, entity_type, entity_id, version_number): - """ - Get a specific version by number. - - Args: - entity_type: ContentType of entity - entity_id: UUID of entity - version_number: Version number to retrieve - - Returns: - EntityVersion or None - """ - try: - return cls.objects.get( - entity_type=entity_type, - entity_id=entity_id, - version_number=version_number - ) - except cls.DoesNotExist: - return None diff --git a/django/apps/versioning/services.py b/django/apps/versioning/services.py deleted file mode 100644 index 9a025dbf..00000000 --- a/django/apps/versioning/services.py +++ /dev/null @@ -1,473 +0,0 @@ -""" -Versioning services for ThrillWiki. - -This module provides the business logic for creating and managing entity versions: -- Creating versions automatically via lifecycle hooks -- Generating snapshots and tracking changed fields -- Linking versions to content submissions -- Retrieving version history and diffs -- Restoring previous versions -""" - -import json -from decimal import Decimal -from datetime import date, datetime -from django.db import models, transaction -from django.contrib.contenttypes.models import ContentType -from django.core.serializers.json import DjangoJSONEncoder -from django.core.exceptions import ValidationError - -from apps.versioning.models import EntityVersion - - -class VersionService: - """ - Service class for versioning operations. - - All methods handle automatic version creation and tracking. - """ - - @staticmethod - @transaction.atomic - def create_version( - entity, - change_type='updated', - changed_fields=None, - user=None, - submission=None, - comment='', - ip_address=None, - user_agent='' - ): - """ - Create a version record for an entity. - - This is called automatically by the VersionedModel lifecycle hooks, - but can also be called manually when needed. - - Args: - entity: Entity instance (Park, Ride, Company, etc.) - change_type: Type of change ('created', 'updated', 'deleted', 'restored') - changed_fields: Dict of dirty fields from DirtyFieldsMixin - user: User who made the change (optional) - submission: ContentSubmission that caused this change (optional) - comment: Optional comment about the change - ip_address: IP address of the change origin - user_agent: User agent string - - Returns: - EntityVersion instance - """ - # Get ContentType for entity - entity_type = ContentType.objects.get_for_model(entity) - - # Get next version number - version_number = EntityVersion.get_latest_version_number( - entity_type, entity.id - ) + 1 - - # Create snapshot of current entity state - snapshot = VersionService._create_snapshot(entity) - - # Build changed_fields dict with old/new values - changed_fields_data = {} - if changed_fields and change_type == 'updated': - changed_fields_data = VersionService._build_changed_fields( - entity, changed_fields - ) - - # Try to get user from submission if not provided - if not user and submission: - user = submission.user - - # Create version record - version = EntityVersion.objects.create( - entity_type=entity_type, - entity_id=entity.id, - version_number=version_number, - change_type=change_type, - snapshot=snapshot, - changed_fields=changed_fields_data, - changed_by=user, - submission=submission, - comment=comment, - ip_address=ip_address, - user_agent=user_agent - ) - - return version - - @staticmethod - def _create_snapshot(entity): - """ - Create a JSON snapshot of the entity's current state. - - Args: - entity: Entity instance - - Returns: - dict: Serializable snapshot of entity - """ - snapshot = {} - - # Get all model fields - for field in entity._meta.get_fields(): - # Skip reverse relations - if field.is_relation and field.many_to_one is False and field.one_to_many is True: - continue - if field.is_relation and field.many_to_many is True: - continue - - field_name = field.name - - try: - value = getattr(entity, field_name) - - # Handle different field types - if value is None: - snapshot[field_name] = None - elif isinstance(value, (str, int, float, bool)): - snapshot[field_name] = value - elif isinstance(value, Decimal): - snapshot[field_name] = float(value) - elif isinstance(value, (date, datetime)): - snapshot[field_name] = value.isoformat() - elif isinstance(value, models.Model): - # Store FK as ID - snapshot[field_name] = str(value.id) if value.id else None - elif isinstance(value, dict): - # JSONField - snapshot[field_name] = value - elif isinstance(value, list): - # JSONField array - snapshot[field_name] = value - else: - # Try to serialize as string - snapshot[field_name] = str(value) - except Exception: - # Skip fields that can't be serialized - continue - - return snapshot - - @staticmethod - def _build_changed_fields(entity, dirty_fields): - """ - Build a dict of changed fields with old and new values. - - Args: - entity: Entity instance - dirty_fields: Dict from DirtyFieldsMixin.get_dirty_fields() - - Returns: - dict: Changed fields with old/new values - """ - changed = {} - - for field_name, old_value in dirty_fields.items(): - try: - new_value = getattr(entity, field_name) - - # Normalize values for JSON - old_normalized = VersionService._normalize_value(old_value) - new_normalized = VersionService._normalize_value(new_value) - - changed[field_name] = { - 'old': old_normalized, - 'new': new_normalized - } - except Exception: - continue - - return changed - - @staticmethod - def _normalize_value(value): - """ - Normalize a value for JSON serialization. - - Args: - value: Value to normalize - - Returns: - Normalized value - """ - if value is None: - return None - elif isinstance(value, (str, int, float, bool)): - return value - elif isinstance(value, Decimal): - return float(value) - elif isinstance(value, (date, datetime)): - return value.isoformat() - elif isinstance(value, models.Model): - return str(value.id) if value.id else None - elif isinstance(value, (dict, list)): - return value - else: - return str(value) - - @staticmethod - def get_version_history(entity, limit=50): - """ - Get version history for an entity. - - Args: - entity: Entity instance - limit: Maximum number of versions to return - - Returns: - QuerySet: Ordered list of versions (newest first) - """ - entity_type = ContentType.objects.get_for_model(entity) - return EntityVersion.get_history(entity_type, entity.id, limit) - - @staticmethod - def get_version_by_number(entity, version_number): - """ - Get a specific version by number. - - Args: - entity: Entity instance - version_number: Version number to retrieve - - Returns: - EntityVersion or None - """ - entity_type = ContentType.objects.get_for_model(entity) - return EntityVersion.get_version_by_number(entity_type, entity.id, version_number) - - @staticmethod - def get_latest_version(entity): - """ - Get the latest version for an entity. - - Args: - entity: Entity instance - - Returns: - EntityVersion or None - """ - entity_type = ContentType.objects.get_for_model(entity) - return EntityVersion.objects.filter( - entity_type=entity_type, - entity_id=entity.id - ).order_by('-version_number').first() - - @staticmethod - def compare_versions(version1, version2): - """ - Compare two versions of the same entity. - - Args: - version1: First EntityVersion - version2: Second EntityVersion - - Returns: - dict: Comparison result with differences - """ - if version1.entity_id != version2.entity_id: - raise ValidationError("Versions must be for the same entity") - - return version1.compare_with(version2) - - @staticmethod - def get_diff_with_current(version): - """ - Compare a version with the current entity state. - - Args: - version: EntityVersion to compare - - Returns: - dict: Differences between version and current state - """ - entity = version.entity - if not entity: - raise ValidationError("Entity no longer exists") - - current_snapshot = VersionService._create_snapshot(entity) - version_snapshot = version.get_snapshot_dict() - - differences = {} - all_keys = set(current_snapshot.keys()) | set(version_snapshot.keys()) - - for key in all_keys: - current_val = current_snapshot.get(key) - version_val = version_snapshot.get(key) - - if current_val != version_val: - differences[key] = { - 'current': current_val, - 'version': version_val - } - - return { - 'version_number': version.version_number, - 'differences': differences, - 'changed_field_count': len(differences) - } - - @staticmethod - @transaction.atomic - def restore_version(version, user=None, comment=''): - """ - Restore an entity to a previous version. - - This creates a new version with change_type='restored'. - - Args: - version: EntityVersion to restore - user: User performing the restore - comment: Optional comment about the restore - - Returns: - EntityVersion: New version created by restore - - Raises: - ValidationError: If entity doesn't exist - """ - entity = version.entity - if not entity: - raise ValidationError("Entity no longer exists") - - # Get snapshot to restore - snapshot = version.get_snapshot_dict() - - # Track which fields are changing - changed_fields = {} - - # Apply snapshot values to entity - for field_name, value in snapshot.items(): - # Skip metadata fields - if field_name in ['id', 'created', 'modified']: - continue - - try: - # Get current value - current_value = getattr(entity, field_name, None) - current_normalized = VersionService._normalize_value(current_value) - - # Check if value is different - if current_normalized != value: - changed_fields[field_name] = { - 'old': current_normalized, - 'new': value - } - - # Apply restored value - # Handle special field types - field = entity._meta.get_field(field_name) - - if isinstance(field, models.ForeignKey): - # FK fields need model instance - if value: - related_model = field.related_model - try: - related_obj = related_model.objects.get(id=value) - setattr(entity, field_name, related_obj) - except: - pass - else: - setattr(entity, field_name, None) - elif isinstance(field, models.DateField): - # Date fields - if value: - setattr(entity, field_name, datetime.fromisoformat(value).date()) - else: - setattr(entity, field_name, None) - elif isinstance(field, models.DateTimeField): - # DateTime fields - if value: - setattr(entity, field_name, datetime.fromisoformat(value)) - else: - setattr(entity, field_name, None) - elif isinstance(field, models.DecimalField): - # Decimal fields - if value is not None: - setattr(entity, field_name, Decimal(str(value))) - else: - setattr(entity, field_name, None) - else: - # Regular fields - setattr(entity, field_name, value) - except Exception: - # Skip fields that can't be restored - continue - - # Save entity (this will trigger lifecycle hooks) - # But we need to create the version manually to mark it as 'restored' - entity.save() - - # Create restore version - entity_type = ContentType.objects.get_for_model(entity) - version_number = EntityVersion.get_latest_version_number( - entity_type, entity.id - ) + 1 - - restored_version = EntityVersion.objects.create( - entity_type=entity_type, - entity_id=entity.id, - version_number=version_number, - change_type='restored', - snapshot=VersionService._create_snapshot(entity), - changed_fields=changed_fields, - changed_by=user, - comment=f"Restored from version {version.version_number}. {comment}".strip() - ) - - return restored_version - - @staticmethod - def get_version_count(entity): - """ - Get total number of versions for an entity. - - Args: - entity: Entity instance - - Returns: - int: Number of versions - """ - entity_type = ContentType.objects.get_for_model(entity) - return EntityVersion.objects.filter( - entity_type=entity_type, - entity_id=entity.id - ).count() - - @staticmethod - def get_versions_by_user(user, limit=50): - """ - Get versions created by a specific user. - - Args: - user: User instance - limit: Maximum number of versions to return - - Returns: - QuerySet: Versions by user (newest first) - """ - return EntityVersion.objects.filter( - changed_by=user - ).select_related( - 'entity_type', - 'submission' - ).order_by('-created')[:limit] - - @staticmethod - def get_versions_by_submission(submission): - """ - Get all versions created by a content submission. - - Args: - submission: ContentSubmission instance - - Returns: - QuerySet: Versions from submission - """ - return EntityVersion.objects.filter( - submission=submission - ).select_related( - 'entity_type', - 'changed_by' - ).order_by('-created') diff --git a/django/config/__init__.py b/django/config/__init__.py deleted file mode 100644 index df33ac9c..00000000 --- a/django/config/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -ThrillWiki Django configuration package. - -This module ensures the Celery app is loaded when Django starts -so that @shared_task decorators can use it. -""" - -# Load Celery app when Django starts -from .celery import app as celery_app - -__all__ = ('celery_app',) diff --git a/django/config/__pycache__/__init__.cpython-313.pyc b/django/config/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index fb51a588..00000000 Binary files a/django/config/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/django/config/__pycache__/celery.cpython-313.pyc b/django/config/__pycache__/celery.cpython-313.pyc deleted file mode 100644 index 996098d8..00000000 Binary files a/django/config/__pycache__/celery.cpython-313.pyc and /dev/null differ diff --git a/django/config/__pycache__/urls.cpython-313.pyc b/django/config/__pycache__/urls.cpython-313.pyc deleted file mode 100644 index fb178131..00000000 Binary files a/django/config/__pycache__/urls.cpython-313.pyc and /dev/null differ diff --git a/django/config/__pycache__/wsgi.cpython-313.pyc b/django/config/__pycache__/wsgi.cpython-313.pyc deleted file mode 100644 index ce2b6965..00000000 Binary files a/django/config/__pycache__/wsgi.cpython-313.pyc and /dev/null differ diff --git a/django/config/asgi.py b/django/config/asgi.py deleted file mode 100644 index 87078af4..00000000 --- a/django/config/asgi.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -ASGI config for config project. - -It exposes the ASGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ -""" - -import os - -from django.core.asgi import get_asgi_application - -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") - -application = get_asgi_application() diff --git a/django/config/celery.py b/django/config/celery.py deleted file mode 100644 index dd8d09f7..00000000 --- a/django/config/celery.py +++ /dev/null @@ -1,54 +0,0 @@ -""" -Celery configuration for ThrillWiki. - -This module initializes the Celery application and configures -task discovery, error handling, and monitoring. -""" - -import os -from celery import Celery -from celery.signals import task_failure, task_success -from django.conf import settings - -# Set default Django settings module -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.local') - -# Create Celery app -app = Celery('thrillwiki') - -# Load configuration from Django settings -app.config_from_object('django.conf:settings', namespace='CELERY') - -# Auto-discover tasks from all installed apps -app.autodiscover_tasks(lambda: settings.INSTALLED_APPS) - - -@app.task(bind=True, ignore_result=True) -def debug_task(self): - """Debug task to test Celery configuration.""" - print(f'Request: {self.request!r}') - - -# Task failure signal handler -@task_failure.connect -def task_failure_handler(sender=None, task_id=None, exception=None, **kwargs): - """Log task failures for monitoring.""" - import logging - logger = logging.getLogger('celery.task') - logger.error( - f'Task {sender.name} ({task_id}) failed: {exception}', - exc_info=True, - extra={'task_id': task_id, 'task_name': sender.name} - ) - - -# Task success signal handler -@task_success.connect -def task_success_handler(sender=None, result=None, **kwargs): - """Log task successes for monitoring.""" - import logging - logger = logging.getLogger('celery.task') - logger.info( - f'Task {sender.name} completed successfully', - extra={'task_name': sender.name, 'result': str(result)[:200]} - ) diff --git a/django/config/settings/__init__.py b/django/config/settings/__init__.py deleted file mode 100644 index 8c404068..00000000 --- a/django/config/settings/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -""" -Django settings package. -Automatically loads the correct settings based on DJANGO_SETTINGS_MODULE environment variable. -""" - -import os - -# Determine which settings to use -settings_module = os.getenv('DJANGO_SETTINGS_MODULE', 'config.settings.local') - -if settings_module == 'config.settings.production': - from .production import * -elif settings_module == 'config.settings.local': - from .local import * -else: - # Default to local for development - from .local import * diff --git a/django/config/settings/__pycache__/__init__.cpython-313.pyc b/django/config/settings/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index f923913e..00000000 Binary files a/django/config/settings/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/django/config/settings/__pycache__/base.cpython-313.pyc b/django/config/settings/__pycache__/base.cpython-313.pyc deleted file mode 100644 index d859c9f2..00000000 Binary files a/django/config/settings/__pycache__/base.cpython-313.pyc and /dev/null differ diff --git a/django/config/settings/__pycache__/local.cpython-313.pyc b/django/config/settings/__pycache__/local.cpython-313.pyc deleted file mode 100644 index f5ee32ef..00000000 Binary files a/django/config/settings/__pycache__/local.cpython-313.pyc and /dev/null differ diff --git a/django/config/settings/base.py b/django/config/settings/base.py deleted file mode 100644 index b75f94e5..00000000 --- a/django/config/settings/base.py +++ /dev/null @@ -1,501 +0,0 @@ -""" -Django base settings for ThrillWiki project. -These settings are common across all environments. -""" - -from pathlib import Path -import environ - -# Build paths -BASE_DIR = Path(__file__).resolve().parent.parent.parent - -# Initialize environment variables -env = environ.Env( - DEBUG=(bool, False), - ALLOWED_HOSTS=(list, []), -) - -# Read .env file if it exists -environ.Env.read_env(BASE_DIR / '.env') - -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = env('SECRET_KEY', default='django-insecure-change-this-in-production') - -# Application definition -INSTALLED_APPS = [ - # Django Unfold (must come before django.contrib.admin) - 'unfold', - 'unfold.contrib.filters', - 'unfold.contrib.forms', - 'unfold.contrib.import_export', - - # Django GIS (must come before admin for proper admin integration) - 'django.contrib.gis', - - # Django apps - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - - # Third-party apps - 'import_export', - 'rest_framework', - 'rest_framework_simplejwt', - 'ninja', - 'django_filters', - 'corsheaders', - 'guardian', - 'django_otp', - 'django_otp.plugins.otp_totp', - 'allauth', - 'allauth.account', - 'allauth.socialaccount', - 'allauth.socialaccount.providers.google', - 'allauth.socialaccount.providers.discord', - 'django_celery_beat', - 'django_celery_results', - 'django_extensions', - 'channels', - 'storages', - 'defender', - - # Local apps - 'apps.core', - 'apps.users', - 'apps.entities', - 'apps.moderation', - 'apps.versioning', - 'apps.media', - 'apps.notifications', -] - -MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'corsheaders.middleware.CorsMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django_otp.middleware.OTPMiddleware', - 'allauth.account.middleware.AccountMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'defender.middleware.FailedLoginMiddleware', -] - -ROOT_URLCONF = 'config.urls' - -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [BASE_DIR / 'templates'], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - ], - }, - }, -] - -WSGI_APPLICATION = 'config.wsgi.application' -ASGI_APPLICATION = 'config.asgi.application' - -# Database -DATABASES = { - 'default': env.db('DATABASE_URL', default='postgresql://localhost/thrillwiki') -} - -# Password validation -AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', - }, -] - -# Internationalization -LANGUAGE_CODE = 'en-us' -TIME_ZONE = 'UTC' -USE_I18N = True -USE_TZ = True - -# Static files -STATIC_URL = 'static/' -STATIC_ROOT = BASE_DIR / 'staticfiles' -STATICFILES_DIRS = [BASE_DIR / 'static'] - -# Media files -MEDIA_URL = 'media/' -MEDIA_ROOT = BASE_DIR / 'media' - -# Default primary key field type -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' - -# Custom User Model -AUTH_USER_MODEL = 'users.User' - -# Authentication Backends -AUTHENTICATION_BACKENDS = [ - 'django.contrib.auth.backends.ModelBackend', - 'allauth.account.auth_backends.AuthenticationBackend', - 'guardian.backends.ObjectPermissionBackend', -] - -# Django REST Framework -REST_FRAMEWORK = { - 'DEFAULT_AUTHENTICATION_CLASSES': [ - 'rest_framework_simplejwt.authentication.JWTAuthentication', - ], - 'DEFAULT_PERMISSION_CLASSES': [ - 'rest_framework.permissions.IsAuthenticatedOrReadOnly', - ], - 'DEFAULT_FILTER_BACKENDS': [ - 'django_filters.rest_framework.DjangoFilterBackend', - ], - 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', - 'PAGE_SIZE': 50, -} - -# JWT Settings -from datetime import timedelta - -SIMPLE_JWT = { - 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), - 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), - 'ROTATE_REFRESH_TOKENS': True, - 'BLACKLIST_AFTER_ROTATION': True, - 'ALGORITHM': 'HS256', - 'SIGNING_KEY': SECRET_KEY, - 'AUTH_HEADER_TYPES': ('Bearer',), -} - -# CORS Settings -CORS_ALLOWED_ORIGINS = env.list( - 'CORS_ALLOWED_ORIGINS', - default=['http://localhost:5173', 'http://localhost:3000'] -) -CORS_ALLOW_CREDENTIALS = True - -# Redis Configuration -REDIS_URL = env('REDIS_URL', default='redis://localhost:6379/0') - -# Caching -CACHES = { - 'default': { - 'BACKEND': 'django_redis.cache.RedisCache', - 'LOCATION': REDIS_URL, - 'OPTIONS': { - 'CLIENT_CLASS': 'django_redis.client.DefaultClient', - 'PARSER_CLASS': 'redis.connection.HiredisParser', - }, - 'KEY_PREFIX': 'thrillwiki', - 'TIMEOUT': 300, - } -} - -# Session Configuration -SESSION_ENGINE = 'django.contrib.sessions.backends.cache' -SESSION_CACHE_ALIAS = 'default' -SESSION_COOKIE_AGE = 86400 * 30 # 30 days - -# Celery Configuration -CELERY_BROKER_URL = env('CELERY_BROKER_URL', default=REDIS_URL) -CELERY_RESULT_BACKEND = env('CELERY_RESULT_BACKEND', default='redis://localhost:6379/1') -CELERY_ACCEPT_CONTENT = ['json'] -CELERY_TASK_SERIALIZER = 'json' -CELERY_RESULT_SERIALIZER = 'json' -CELERY_TIMEZONE = TIME_ZONE -CELERY_TASK_TRACK_STARTED = True -CELERY_TASK_TIME_LIMIT = 30 * 60 # 30 minutes - -# Celery Beat Schedule (Periodic Tasks) -from celery.schedules import crontab - -CELERY_BEAT_SCHEDULE = { - # Clean up expired moderation locks every 5 minutes - 'cleanup-expired-locks': { - 'task': 'apps.moderation.tasks.cleanup_expired_locks', - 'schedule': crontab(minute='*/5'), - }, - # Clean up expired JWT tokens daily at 2 AM - 'cleanup-expired-tokens': { - 'task': 'apps.users.tasks.cleanup_expired_tokens', - 'schedule': crontab(hour=2, minute=0), - }, - # Update entity statistics every 6 hours - 'update-all-statistics': { - 'task': 'apps.entities.tasks.update_all_statistics', - 'schedule': crontab(hour='*/6', minute=0), - }, - # Clean up old rejected photos weekly on Monday at 3 AM - 'cleanup-rejected-photos': { - 'task': 'apps.media.tasks.cleanup_rejected_photos', - 'schedule': crontab(day_of_week=1, hour=3, minute=0), - }, - # Auto-unlock stale reviews every 30 minutes - 'auto-unlock-stale-reviews': { - 'task': 'apps.moderation.tasks.auto_unlock_stale_reviews', - 'schedule': crontab(minute='*/30'), - }, - # Check moderation queue size every hour - 'check-moderation-queue': { - 'task': 'apps.moderation.tasks.notify_moderators_of_queue_size', - 'schedule': crontab(minute=0), - }, - # Update photo statistics daily at 1 AM - 'update-photo-statistics': { - 'task': 'apps.media.tasks.update_photo_statistics', - 'schedule': crontab(hour=1, minute=0), - }, - # Update moderation statistics daily at 1:30 AM - 'update-moderation-statistics': { - 'task': 'apps.moderation.tasks.update_moderation_statistics', - 'schedule': crontab(hour=1, minute=30), - }, - # Update user statistics daily at 4 AM - 'update-user-statistics': { - 'task': 'apps.users.tasks.update_user_statistics', - 'schedule': crontab(hour=4, minute=0), - }, - # Calculate global statistics every 12 hours - 'calculate-global-statistics': { - 'task': 'apps.entities.tasks.calculate_global_statistics', - 'schedule': crontab(hour='*/12', minute=0), - }, -} - -# Django Channels -CHANNEL_LAYERS = { - 'default': { - 'BACKEND': 'channels_redis.core.RedisChannelLayer', - 'CONFIG': { - 'hosts': [REDIS_URL], - }, - }, -} - -# Django Cacheops -CACHEOPS_REDIS = REDIS_URL -CACHEOPS_DEFAULTS = { - 'timeout': 60*15 # 15 minutes -} -CACHEOPS = { - 'entities.park': {'ops': 'all', 'timeout': 60*15}, - 'entities.ride': {'ops': 'all', 'timeout': 60*15}, - 'entities.company': {'ops': 'all', 'timeout': 60*15}, - 'core.*': {'ops': 'all', 'timeout': 60*60}, # 1 hour for reference data - '*.*': {'timeout': 60*60}, -} - -# Django Allauth -ACCOUNT_AUTHENTICATION_METHOD = 'email' -ACCOUNT_EMAIL_REQUIRED = True -ACCOUNT_USERNAME_REQUIRED = False -ACCOUNT_EMAIL_VERIFICATION = 'optional' -SITE_ID = 1 - -# Site Configuration -SITE_URL = env('SITE_URL', default='http://localhost:8000') -DEFAULT_FROM_EMAIL = env('DEFAULT_FROM_EMAIL', default='noreply@thrillwiki.com') - -# CloudFlare Images -CLOUDFLARE_ACCOUNT_ID = env('CLOUDFLARE_ACCOUNT_ID', default='') -CLOUDFLARE_IMAGE_TOKEN = env('CLOUDFLARE_IMAGE_TOKEN', default='') -CLOUDFLARE_IMAGE_HASH = env('CLOUDFLARE_IMAGE_HASH', default='') - -# Novu -NOVU_API_KEY = env('NOVU_API_KEY', default='') -NOVU_API_URL = env('NOVU_API_URL', default='https://api.novu.co') - -# Sentry -SENTRY_DSN = env('SENTRY_DSN', default='') -if SENTRY_DSN: - import sentry_sdk - from sentry_sdk.integrations.django import DjangoIntegration - from sentry_sdk.integrations.celery import CeleryIntegration - - sentry_sdk.init( - dsn=SENTRY_DSN, - integrations=[ - DjangoIntegration(), - CeleryIntegration(), - ], - traces_sample_rate=0.1, - send_default_pii=False, - ) - -# Logging Configuration -LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'formatters': { - 'verbose': { - 'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}', - 'style': '{', - }, - 'simple': { - 'format': '{levelname} {message}', - 'style': '{', - }, - }, - 'handlers': { - 'console': { - 'class': 'logging.StreamHandler', - 'formatter': 'verbose', - }, - 'file': { - 'class': 'logging.FileHandler', - 'filename': BASE_DIR / 'logs' / 'django.log', - 'formatter': 'verbose', - }, - }, - 'root': { - 'handlers': ['console'], - 'level': 'INFO', - }, - 'loggers': { - 'django': { - 'handlers': ['console'], - 'level': 'INFO', - 'propagate': False, - }, - 'apps': { - 'handlers': ['console', 'file'], - 'level': 'INFO', - 'propagate': False, - }, - }, -} - -# Rate Limiting -RATELIMIT_ENABLE = True -RATELIMIT_USE_CACHE = 'default' - -# Django Defender -DEFENDER_LOGIN_FAILURE_LIMIT = 5 -DEFENDER_COOLOFF_TIME = 300 # 5 minutes -DEFENDER_LOCKOUT_TEMPLATE = 'defender/lockout.html' - -# Django Unfold Configuration -UNFOLD = { - "SITE_TITLE": "ThrillWiki Admin", - "SITE_HEADER": "ThrillWiki Administration", - "SITE_URL": "/", - "SITE_ICON": { - "light": lambda request: "/static/logo-light.svg", - "dark": lambda request: "/static/logo-dark.svg", - }, - "SITE_SYMBOL": "🎢", - "SHOW_HISTORY": True, - "SHOW_VIEW_ON_SITE": True, - "ENVIRONMENT": "django.conf.settings.DEBUG", - "DASHBOARD_CALLBACK": "apps.entities.admin.dashboard_callback", - "COLORS": { - "primary": { - "50": "220 252 231", - "100": "187 247 208", - "200": "134 239 172", - "300": "74 222 128", - "400": "34 197 94", - "500": "22 163 74", - "600": "21 128 61", - "700": "22 101 52", - "800": "22 78 43", - "900": "20 83 45", - "950": "5 46 22", - }, - }, - "EXTENSIONS": { - "modeltranslation": { - "flags": { - "en": "🇬🇧", - "fr": "🇫🇷", - "nl": "🇧🇪", - }, - }, - }, - "SIDEBAR": { - "show_search": True, - "show_all_applications": False, - "navigation": [ - { - "title": "Dashboard", - "icon": "dashboard", - "link": lambda request: "/admin/", - }, - { - "title": "Entities", - "icon": "category", - "items": [ - { - "title": "Parks", - "icon": "park", - "link": lambda request: "/admin/entities/park/", - }, - { - "title": "Rides", - "icon": "roller_skating", - "link": lambda request: "/admin/entities/ride/", - }, - { - "title": "Companies", - "icon": "business", - "link": lambda request: "/admin/entities/company/", - }, - { - "title": "Ride Models", - "icon": "construction", - "link": lambda request: "/admin/entities/ridemodel/", - }, - ], - }, - { - "title": "User Management", - "icon": "people", - "items": [ - { - "title": "Users", - "icon": "person", - "link": lambda request: "/admin/users/user/", - }, - { - "title": "Groups", - "icon": "group", - "link": lambda request: "/admin/auth/group/", - }, - ], - }, - { - "title": "Content", - "icon": "folder", - "items": [ - { - "title": "Media", - "icon": "image", - "link": lambda request: "/admin/media/", - }, - { - "title": "Moderation", - "icon": "verified_user", - "link": lambda request: "/admin/moderation/", - }, - ], - }, - ], - }, -} diff --git a/django/config/settings/local.py b/django/config/settings/local.py deleted file mode 100644 index 7a71dd5f..00000000 --- a/django/config/settings/local.py +++ /dev/null @@ -1,63 +0,0 @@ -""" -Django development settings for ThrillWiki project. -These settings are used during local development. -""" - -from .base import * - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = env.bool('DEBUG', default=True) - -ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=['localhost', '127.0.0.1']) - -# Development-specific apps -# INSTALLED_APPS += [ -# 'silk', # Profiling (optional, install django-silk if needed) -# ] - -# MIDDLEWARE += [ -# 'silk.middleware.SilkyMiddleware', -# ] - -# Database - Use regular SQLite for local development -# PostGIS fields will work but without spatial query capabilities -# Full GIS features available in production with PostGIS -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', - } -} - -# Note: For full GIS capabilities in local dev, you would need: -# - SQLite compiled with extension support (pysqlite) -# - SpatiaLite extension installed -# For now, using regular SQLite is simpler for development - -# GDAL library paths - Required even with regular SQLite when using GIS models -# For Homebrew on macOS (Apple Silicon) -GDAL_LIBRARY_PATH = env('GDAL_LIBRARY_PATH', default='/opt/homebrew/opt/gdal/lib/libgdal.dylib') -GEOS_LIBRARY_PATH = env('GEOS_LIBRARY_PATH', default='/opt/homebrew/opt/geos/lib/libgeos_c.dylib') - -# Disable caching in development -CACHEOPS_ENABLED = False - -# Email backend for development (console) -EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' - -# Django Debug Toolbar (optional, install if needed) -# INSTALLED_APPS += ['debug_toolbar'] -# MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware'] -# INTERNAL_IPS = ['127.0.0.1'] - -# Celery - Use eager mode in development -CELERY_TASK_ALWAYS_EAGER = env.bool('CELERY_TASK_ALWAYS_EAGER', default=True) -CELERY_TASK_EAGER_PROPAGATES = True - -# CORS - Allow all origins in development -CORS_ALLOW_ALL_ORIGINS = True - -# Logging - More verbose in development -LOGGING['root']['level'] = 'DEBUG' -LOGGING['loggers']['django']['level'] = 'DEBUG' -LOGGING['loggers']['apps']['level'] = 'DEBUG' diff --git a/django/config/settings/production.py b/django/config/settings/production.py deleted file mode 100644 index 75f11e73..00000000 --- a/django/config/settings/production.py +++ /dev/null @@ -1,80 +0,0 @@ -""" -Django production settings for ThrillWiki project. -These settings are used in production environments. -""" - -from .base import * - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = False - -ALLOWED_HOSTS = env.list('ALLOWED_HOSTS') - -# Security Settings -SECURE_SSL_REDIRECT = True -SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') -SESSION_COOKIE_SECURE = True -CSRF_COOKIE_SECURE = True -SECURE_HSTS_SECONDS = 31536000 # 1 year -SECURE_HSTS_INCLUDE_SUBDOMAINS = True -SECURE_HSTS_PRELOAD = True -SECURE_CONTENT_TYPE_NOSNIFF = True -SECURE_BROWSER_XSS_FILTER = True -X_FRAME_OPTIONS = 'DENY' - -# Static files (WhiteNoise) -STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' -MIDDLEWARE.insert(1, 'whitenoise.middleware.WhiteNoiseMiddleware') - -# Email Configuration (configure for production email backend) -EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' -EMAIL_HOST = env('EMAIL_HOST', default='smtp.gmail.com') -EMAIL_PORT = env.int('EMAIL_PORT', default=587) -EMAIL_USE_TLS = env.bool('EMAIL_USE_TLS', default=True) -EMAIL_HOST_USER = env('EMAIL_HOST_USER', default='') -EMAIL_HOST_PASSWORD = env('EMAIL_HOST_PASSWORD', default='') -DEFAULT_FROM_EMAIL = env('DEFAULT_FROM_EMAIL', default='noreply@thrillwiki.com') - -# Database - Use PostGIS backend for production -DATABASES = { - 'default': { - 'ENGINE': 'django.contrib.gis.db.backends.postgis', - 'NAME': env('DB_NAME'), - 'USER': env('DB_USER'), - 'PASSWORD': env('DB_PASSWORD'), - 'HOST': env('DB_HOST'), - 'PORT': env('DB_PORT', default='5432'), - 'CONN_MAX_AGE': env.int('CONN_MAX_AGE', default=600), - 'OPTIONS': { - 'sslmode': env('DB_SSLMODE', default='require'), - }, - } -} - -# Verify required database credentials -if not all([env('DB_NAME', default=None), env('DB_USER', default=None), env('DB_PASSWORD', default=None)]): - raise ImproperlyConfigured('DB_NAME, DB_USER, and DB_PASSWORD environment variables are required in production') - -# Redis - Require REDIS_URL in production -if not env('REDIS_URL', default=None): - raise ImproperlyConfigured('REDIS_URL environment variable is required in production') - -# Celery - Run tasks asynchronously in production -CELERY_TASK_ALWAYS_EAGER = False - -# Logging - Send errors to file and Sentry -LOGGING['handlers']['file']['filename'] = '/var/log/thrillwiki/django.log' -LOGGING['root']['level'] = 'WARNING' -LOGGING['loggers']['django']['level'] = 'WARNING' -LOGGING['loggers']['apps']['level'] = 'INFO' - -# Admin URL (obfuscate in production) -ADMIN_URL = env('ADMIN_URL', default='admin/') - -# Performance -CACHEOPS_ENABLED = True - -# CORS - Strict in production -CORS_ALLOW_ALL_ORIGINS = False -if not CORS_ALLOWED_ORIGINS: - raise ImproperlyConfigured('CORS_ALLOWED_ORIGINS must be set in production') diff --git a/django/config/urls.py b/django/config/urls.py deleted file mode 100644 index ab6d4d6d..00000000 --- a/django/config/urls.py +++ /dev/null @@ -1,36 +0,0 @@ -""" -URL configuration for config project. - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/4.2/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.urls import include, path - 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) -""" - -from django.contrib import admin -from django.urls import path, include -from django.conf import settings - -from api.v1.api import api as api_v1 - -urlpatterns = [ - path("admin/", admin.site.urls), - path("api/v1/", api_v1.urls), -] - -# Celery Flower monitoring (admin only, production only) -if not settings.DEBUG and settings.env.bool('FLOWER_ENABLED', default=False): - # In production, Flower should be behind authentication - # Access via: /flower/ - # Configure FLOWER_BASIC_AUTH in environment variables - urlpatterns += [ - path('flower/', include('flower.urls')), - ] diff --git a/django/config/wsgi.py b/django/config/wsgi.py deleted file mode 100644 index a9afbb3d..00000000 --- a/django/config/wsgi.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -WSGI config for config project. - -It exposes the WSGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ -""" - -import os - -from django.core.wsgi import get_wsgi_application - -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") - -application = get_wsgi_application() diff --git a/django/db.sqlite3 b/django/db.sqlite3 deleted file mode 100644 index fbbf31c9..00000000 Binary files a/django/db.sqlite3 and /dev/null differ diff --git a/django/manage.py b/django/manage.py deleted file mode 100755 index d28672ea..00000000 --- a/django/manage.py +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env python -"""Django's command-line utility for administrative tasks.""" -import os -import sys - - -def main(): - """Run administrative tasks.""" - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") - try: - from django.core.management import execute_from_command_line - except ImportError as exc: - raise ImportError( - "Couldn't import Django. Are you sure it's installed and " - "available on your PYTHONPATH environment variable? Did you " - "forget to activate a virtual environment?" - ) from exc - execute_from_command_line(sys.argv) - - -if __name__ == "__main__": - main() diff --git a/django/requirements/base.txt b/django/requirements/base.txt deleted file mode 100644 index 963c72b8..00000000 --- a/django/requirements/base.txt +++ /dev/null @@ -1,69 +0,0 @@ -# Core Django -Django==4.2.8 -psycopg[binary]==3.2.3 - -# API Framework (django-ninja for FastAPI-style performance) -django-ninja==1.1.0 -pydantic==2.10.6 - -# Database & ORM utilities -django-model-utils==4.3.1 -django-guardian==2.4.0 -django-lifecycle==1.0.0 -django-fsm==2.8.1 -django-dirtyfields==1.9.2 - -# Async & Background Tasks -celery[redis]==5.3.4 -django-celery-beat==2.5.0 -django-celery-results==2.5.1 -flower==2.0.1 - -# Caching & Performance -django-redis==5.4.0 -django-cacheops==7.0.2 -hiredis==2.3.2 - -# Real-time -channels==4.0.0 -channels-redis==4.1.0 -daphne==4.0.0 - -# Media & Storage -django-storages[s3]==1.14.2 -Pillow==11.0.0 -python-magic==0.4.27 - -# Security -django-cors-headers==4.3.1 -django-ratelimit==4.1.0 -django-otp==1.3.0 -django-allauth==0.58.2 -djangorestframework-simplejwt==5.3.1 -django-defender==0.9.7 - -# Validation & Serialization -marshmallow==3.20.1 - -# Admin Interface -django-unfold==0.40.0 -django-import-export==4.2.0 -tablib[html,xls,xlsx]==3.7.0 - -# Utilities -django-extensions==3.2.3 -django-environ==0.11.2 -django-filter==23.5 -python-slugify==8.0.1 -python-dateutil==2.8.2 - -# Monitoring & Logging -sentry-sdk==1.39.1 -structlog==23.2.0 - -# HTTP & External APIs -requests==2.31.0 -httpx==0.25.2 - -# UUID utilities -shortuuid==1.0.11 diff --git a/django/requirements/local.txt b/django/requirements/local.txt deleted file mode 100644 index 898ce87d..00000000 --- a/django/requirements/local.txt +++ /dev/null @@ -1,23 +0,0 @@ --r base.txt - -# Development & Debugging -django-debug-toolbar==4.2.0 -django-silk==5.0.4 -ipython==8.18.1 -ipdb==0.13.13 - -# Testing -pytest==7.4.3 -pytest-django==4.7.0 -pytest-cov==4.1.0 -pytest-xdist==3.5.0 -factory-boy==3.3.0 -Faker==20.1.0 -coverage==7.3.3 - -# Code Quality -black==23.12.1 -flake8==6.1.0 -isort==5.13.2 -mypy==1.7.1 -django-stubs==4.2.7 diff --git a/django/requirements/production.txt b/django/requirements/production.txt deleted file mode 100644 index b981ea3c..00000000 --- a/django/requirements/production.txt +++ /dev/null @@ -1,11 +0,0 @@ --r base.txt - -# Production Web Server -gunicorn==21.2.0 -whitenoise==6.6.0 - -# Production monitoring -django-prometheus==2.3.1 - -# Security -cryptography==41.0.7 diff --git a/django/reviews/__init__.py b/django/reviews/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/django/reviews/admin.py b/django/reviews/admin.py deleted file mode 100644 index 8c38f3f3..00000000 --- a/django/reviews/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/django/reviews/apps.py b/django/reviews/apps.py deleted file mode 100644 index 31ca5133..00000000 --- a/django/reviews/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class ReviewsConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "reviews" diff --git a/django/reviews/migrations/__init__.py b/django/reviews/migrations/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/django/reviews/models.py b/django/reviews/models.py deleted file mode 100644 index 71a83623..00000000 --- a/django/reviews/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/django/reviews/tests.py b/django/reviews/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/django/reviews/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/django/reviews/views.py b/django/reviews/views.py deleted file mode 100644 index 91ea44a2..00000000 --- a/django/reviews/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/django/staticfiles/admin/css/autocomplete.css b/django/staticfiles/admin/css/autocomplete.css deleted file mode 100644 index 69c94e73..00000000 --- a/django/staticfiles/admin/css/autocomplete.css +++ /dev/null @@ -1,275 +0,0 @@ -select.admin-autocomplete { - width: 20em; -} - -.select2-container--admin-autocomplete.select2-container { - min-height: 30px; -} - -.select2-container--admin-autocomplete .select2-selection--single, -.select2-container--admin-autocomplete .select2-selection--multiple { - min-height: 30px; - padding: 0; -} - -.select2-container--admin-autocomplete.select2-container--focus .select2-selection, -.select2-container--admin-autocomplete.select2-container--open .select2-selection { - border-color: var(--body-quiet-color); - min-height: 30px; -} - -.select2-container--admin-autocomplete.select2-container--focus .select2-selection.select2-selection--single, -.select2-container--admin-autocomplete.select2-container--open .select2-selection.select2-selection--single { - padding: 0; -} - -.select2-container--admin-autocomplete.select2-container--focus .select2-selection.select2-selection--multiple, -.select2-container--admin-autocomplete.select2-container--open .select2-selection.select2-selection--multiple { - padding: 0; -} - -.select2-container--admin-autocomplete .select2-selection--single { - background-color: var(--body-bg); - border: 1px solid var(--border-color); - border-radius: 4px; -} - -.select2-container--admin-autocomplete .select2-selection--single .select2-selection__rendered { - color: var(--body-fg); - line-height: 30px; -} - -.select2-container--admin-autocomplete .select2-selection--single .select2-selection__clear { - cursor: pointer; - float: right; - font-weight: bold; -} - -.select2-container--admin-autocomplete .select2-selection--single .select2-selection__placeholder { - color: var(--body-quiet-color); -} - -.select2-container--admin-autocomplete .select2-selection--single .select2-selection__arrow { - height: 26px; - position: absolute; - top: 1px; - right: 1px; - width: 20px; -} - -.select2-container--admin-autocomplete .select2-selection--single .select2-selection__arrow b { - border-color: #888 transparent transparent transparent; - border-style: solid; - border-width: 5px 4px 0 4px; - height: 0; - left: 50%; - margin-left: -4px; - margin-top: -2px; - position: absolute; - top: 50%; - width: 0; -} - -.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--single .select2-selection__clear { - float: left; -} - -.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--single .select2-selection__arrow { - left: 1px; - right: auto; -} - -.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--single { - background-color: var(--darkened-bg); - cursor: default; -} - -.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--single .select2-selection__clear { - display: none; -} - -.select2-container--admin-autocomplete.select2-container--open .select2-selection--single .select2-selection__arrow b { - border-color: transparent transparent #888 transparent; - border-width: 0 4px 5px 4px; -} - -.select2-container--admin-autocomplete .select2-selection--multiple { - background-color: var(--body-bg); - border: 1px solid var(--border-color); - border-radius: 4px; - cursor: text; -} - -.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__rendered { - box-sizing: border-box; - list-style: none; - margin: 0; - padding: 0 10px 5px 5px; - width: 100%; - display: flex; - flex-wrap: wrap; -} - -.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__rendered li { - list-style: none; -} - -.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__placeholder { - color: var(--body-quiet-color); - margin-top: 5px; - float: left; -} - -.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__clear { - cursor: pointer; - float: right; - font-weight: bold; - margin: 5px; - position: absolute; - right: 0; -} - -.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice { - background-color: var(--darkened-bg); - border: 1px solid var(--border-color); - border-radius: 4px; - cursor: default; - float: left; - margin-right: 5px; - margin-top: 5px; - padding: 0 5px; -} - -.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice__remove { - color: var(--body-quiet-color); - cursor: pointer; - display: inline-block; - font-weight: bold; - margin-right: 2px; -} - -.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice__remove:hover { - color: var(--body-fg); -} - -.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice, .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__placeholder, .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-search--inline { - float: right; -} - -.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice { - margin-left: 5px; - margin-right: auto; -} - -.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove { - margin-left: 2px; - margin-right: auto; -} - -.select2-container--admin-autocomplete.select2-container--focus .select2-selection--multiple { - border: solid var(--body-quiet-color) 1px; - outline: 0; -} - -.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--multiple { - background-color: var(--darkened-bg); - cursor: default; -} - -.select2-container--admin-autocomplete.select2-container--disabled .select2-selection__choice__remove { - display: none; -} - -.select2-container--admin-autocomplete.select2-container--open.select2-container--above .select2-selection--single, .select2-container--admin-autocomplete.select2-container--open.select2-container--above .select2-selection--multiple { - border-top-left-radius: 0; - border-top-right-radius: 0; -} - -.select2-container--admin-autocomplete.select2-container--open.select2-container--below .select2-selection--single, .select2-container--admin-autocomplete.select2-container--open.select2-container--below .select2-selection--multiple { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; -} - -.select2-container--admin-autocomplete .select2-search--dropdown { - background: var(--darkened-bg); -} - -.select2-container--admin-autocomplete .select2-search--dropdown .select2-search__field { - background: var(--body-bg); - color: var(--body-fg); - border: 1px solid var(--border-color); - border-radius: 4px; -} - -.select2-container--admin-autocomplete .select2-search--inline .select2-search__field { - background: transparent; - color: var(--body-fg); - border: none; - outline: 0; - box-shadow: none; - -webkit-appearance: textfield; -} - -.select2-container--admin-autocomplete .select2-results > .select2-results__options { - max-height: 200px; - overflow-y: auto; - color: var(--body-fg); - background: var(--body-bg); -} - -.select2-container--admin-autocomplete .select2-results__option[role=group] { - padding: 0; -} - -.select2-container--admin-autocomplete .select2-results__option[aria-disabled=true] { - color: var(--body-quiet-color); -} - -.select2-container--admin-autocomplete .select2-results__option[aria-selected=true] { - background-color: var(--selected-bg); - color: var(--body-fg); -} - -.select2-container--admin-autocomplete .select2-results__option .select2-results__option { - padding-left: 1em; -} - -.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__group { - padding-left: 0; -} - -.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option { - margin-left: -1em; - padding-left: 2em; -} - -.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option { - margin-left: -2em; - padding-left: 3em; -} - -.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { - margin-left: -3em; - padding-left: 4em; -} - -.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { - margin-left: -4em; - padding-left: 5em; -} - -.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { - margin-left: -5em; - padding-left: 6em; -} - -.select2-container--admin-autocomplete .select2-results__option--highlighted[aria-selected] { - background-color: var(--primary); - color: var(--primary-fg); -} - -.select2-container--admin-autocomplete .select2-results__group { - cursor: default; - display: block; - padding: 6px; -} diff --git a/django/staticfiles/admin/css/base.css b/django/staticfiles/admin/css/base.css deleted file mode 100644 index 93db7d06..00000000 --- a/django/staticfiles/admin/css/base.css +++ /dev/null @@ -1,1145 +0,0 @@ -/* - DJANGO Admin styles -*/ - -/* VARIABLE DEFINITIONS */ -html[data-theme="light"], -:root { - --primary: #79aec8; - --secondary: #417690; - --accent: #f5dd5d; - --primary-fg: #fff; - - --body-fg: #333; - --body-bg: #fff; - --body-quiet-color: #666; - --body-loud-color: #000; - - --header-color: #ffc; - --header-branding-color: var(--accent); - --header-bg: var(--secondary); - --header-link-color: var(--primary-fg); - - --breadcrumbs-fg: #c4dce8; - --breadcrumbs-link-fg: var(--body-bg); - --breadcrumbs-bg: var(--primary); - - --link-fg: #417893; - --link-hover-color: #036; - --link-selected-fg: #5b80b2; - - --hairline-color: #e8e8e8; - --border-color: #ccc; - - --error-fg: #ba2121; - - --message-success-bg: #dfd; - --message-warning-bg: #ffc; - --message-error-bg: #ffefef; - - --darkened-bg: #f8f8f8; /* A bit darker than --body-bg */ - --selected-bg: #e4e4e4; /* E.g. selected table cells */ - --selected-row: #ffc; - - --button-fg: #fff; - --button-bg: var(--primary); - --button-hover-bg: #609ab6; - --default-button-bg: var(--secondary); - --default-button-hover-bg: #205067; - --close-button-bg: #747474; - --close-button-hover-bg: #333; - --delete-button-bg: #ba2121; - --delete-button-hover-bg: #a41515; - - --object-tools-fg: var(--button-fg); - --object-tools-bg: var(--close-button-bg); - --object-tools-hover-bg: var(--close-button-hover-bg); - - --font-family-primary: - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - system-ui, - Roboto, - "Helvetica Neue", - Arial, - sans-serif, - "Apple Color Emoji", - "Segoe UI Emoji", - "Segoe UI Symbol", - "Noto Color Emoji"; - --font-family-monospace: - ui-monospace, - Menlo, - Monaco, - "Cascadia Mono", - "Segoe UI Mono", - "Roboto Mono", - "Oxygen Mono", - "Ubuntu Monospace", - "Source Code Pro", - "Fira Mono", - "Droid Sans Mono", - "Courier New", - monospace, - "Apple Color Emoji", - "Segoe UI Emoji", - "Segoe UI Symbol", - "Noto Color Emoji"; -} - -html, body { - height: 100%; -} - -body { - margin: 0; - padding: 0; - font-size: 0.875rem; - font-family: var(--font-family-primary); - color: var(--body-fg); - background: var(--body-bg); -} - -/* LINKS */ - -a:link, a:visited { - color: var(--link-fg); - text-decoration: none; - transition: color 0.15s, background 0.15s; -} - -a:focus, a:hover { - color: var(--link-hover-color); -} - -a:focus { - text-decoration: underline; -} - -a img { - border: none; -} - -a.section:link, a.section:visited { - color: var(--header-link-color); - text-decoration: none; -} - -a.section:focus, a.section:hover { - text-decoration: underline; -} - -/* GLOBAL DEFAULTS */ - -p, ol, ul, dl { - margin: .2em 0 .8em 0; -} - -p { - padding: 0; - line-height: 140%; -} - -h1,h2,h3,h4,h5 { - font-weight: bold; -} - -h1 { - margin: 0 0 20px; - font-weight: 300; - font-size: 1.25rem; - color: var(--body-quiet-color); -} - -h2 { - font-size: 1rem; - margin: 1em 0 .5em 0; -} - -h2.subhead { - font-weight: normal; - margin-top: 0; -} - -h3 { - font-size: 0.875rem; - margin: .8em 0 .3em 0; - color: var(--body-quiet-color); - font-weight: bold; -} - -h4 { - font-size: 0.75rem; - margin: 1em 0 .8em 0; - padding-bottom: 3px; -} - -h5 { - font-size: 0.625rem; - margin: 1.5em 0 .5em 0; - color: var(--body-quiet-color); - text-transform: uppercase; - letter-spacing: 1px; -} - -ul > li { - list-style-type: square; - padding: 1px 0; -} - -li ul { - margin-bottom: 0; -} - -li, dt, dd { - font-size: 0.8125rem; - line-height: 1.25rem; -} - -dt { - font-weight: bold; - margin-top: 4px; -} - -dd { - margin-left: 0; -} - -form { - margin: 0; - padding: 0; -} - -fieldset { - margin: 0; - min-width: 0; - padding: 0; - border: none; - border-top: 1px solid var(--hairline-color); -} - -blockquote { - font-size: 0.6875rem; - color: #777; - margin-left: 2px; - padding-left: 10px; - border-left: 5px solid #ddd; -} - -code, pre { - font-family: var(--font-family-monospace); - color: var(--body-quiet-color); - font-size: 0.75rem; - overflow-x: auto; -} - -pre.literal-block { - margin: 10px; - background: var(--darkened-bg); - padding: 6px 8px; -} - -code strong { - color: #930; -} - -hr { - clear: both; - color: var(--hairline-color); - background-color: var(--hairline-color); - height: 1px; - border: none; - margin: 0; - padding: 0; - line-height: 1px; -} - -/* TEXT STYLES & MODIFIERS */ - -.small { - font-size: 0.6875rem; -} - -.mini { - font-size: 0.625rem; -} - -.help, p.help, form p.help, div.help, form div.help, div.help li { - font-size: 0.6875rem; - color: var(--body-quiet-color); -} - -div.help ul { - margin-bottom: 0; -} - -.help-tooltip { - cursor: help; -} - -p img, h1 img, h2 img, h3 img, h4 img, td img { - vertical-align: middle; -} - -.quiet, a.quiet:link, a.quiet:visited { - color: var(--body-quiet-color); - font-weight: normal; -} - -.clear { - clear: both; -} - -.nowrap { - white-space: nowrap; -} - -.hidden { - display: none !important; -} - -/* TABLES */ - -table { - border-collapse: collapse; - border-color: var(--border-color); -} - -td, th { - font-size: 0.8125rem; - line-height: 1rem; - border-bottom: 1px solid var(--hairline-color); - vertical-align: top; - padding: 8px; -} - -th { - font-weight: 600; - text-align: left; -} - -thead th, -tfoot td { - color: var(--body-quiet-color); - padding: 5px 10px; - font-size: 0.6875rem; - background: var(--body-bg); - border: none; - border-top: 1px solid var(--hairline-color); - border-bottom: 1px solid var(--hairline-color); -} - -tfoot td { - border-bottom: none; - border-top: 1px solid var(--hairline-color); -} - -thead th.required { - color: var(--body-loud-color); -} - -tr.alt { - background: var(--darkened-bg); -} - -tr:nth-child(odd), .row-form-errors { - background: var(--body-bg); -} - -tr:nth-child(even), -tr:nth-child(even) .errorlist, -tr:nth-child(odd) + .row-form-errors, -tr:nth-child(odd) + .row-form-errors .errorlist { - background: var(--darkened-bg); -} - -/* SORTABLE TABLES */ - -thead th { - padding: 5px 10px; - line-height: normal; - text-transform: uppercase; - background: var(--darkened-bg); -} - -thead th a:link, thead th a:visited { - color: var(--body-quiet-color); -} - -thead th.sorted { - background: var(--selected-bg); -} - -thead th.sorted .text { - padding-right: 42px; -} - -table thead th .text span { - padding: 8px 10px; - display: block; -} - -table thead th .text a { - display: block; - cursor: pointer; - padding: 8px 10px; -} - -table thead th .text a:focus, table thead th .text a:hover { - background: var(--selected-bg); -} - -thead th.sorted a.sortremove { - visibility: hidden; -} - -table thead th.sorted:hover a.sortremove { - visibility: visible; -} - -table thead th.sorted .sortoptions { - display: block; - padding: 9px 5px 0 5px; - float: right; - text-align: right; -} - -table thead th.sorted .sortpriority { - font-size: .8em; - min-width: 12px; - text-align: center; - vertical-align: 3px; - margin-left: 2px; - margin-right: 2px; -} - -table thead th.sorted .sortoptions a { - position: relative; - width: 14px; - height: 14px; - display: inline-block; - background: url(../img/sorting-icons.svg) 0 0 no-repeat; - background-size: 14px auto; -} - -table thead th.sorted .sortoptions a.sortremove { - background-position: 0 0; -} - -table thead th.sorted .sortoptions a.sortremove:after { - content: '\\'; - position: absolute; - top: -6px; - left: 3px; - font-weight: 200; - font-size: 1.125rem; - color: var(--body-quiet-color); -} - -table thead th.sorted .sortoptions a.sortremove:focus:after, -table thead th.sorted .sortoptions a.sortremove:hover:after { - color: var(--link-fg); -} - -table thead th.sorted .sortoptions a.sortremove:focus, -table thead th.sorted .sortoptions a.sortremove:hover { - background-position: 0 -14px; -} - -table thead th.sorted .sortoptions a.ascending { - background-position: 0 -28px; -} - -table thead th.sorted .sortoptions a.ascending:focus, -table thead th.sorted .sortoptions a.ascending:hover { - background-position: 0 -42px; -} - -table thead th.sorted .sortoptions a.descending { - top: 1px; - background-position: 0 -56px; -} - -table thead th.sorted .sortoptions a.descending:focus, -table thead th.sorted .sortoptions a.descending:hover { - background-position: 0 -70px; -} - -/* FORM DEFAULTS */ - -input, textarea, select, .form-row p, form .button { - margin: 2px 0; - padding: 2px 3px; - vertical-align: middle; - font-family: var(--font-family-primary); - font-weight: normal; - font-size: 0.8125rem; -} -.form-row div.help { - padding: 2px 3px; -} - -textarea { - vertical-align: top; -} - -input[type=text], input[type=password], input[type=email], input[type=url], -input[type=number], input[type=tel], textarea, select, .vTextField { - border: 1px solid var(--border-color); - border-radius: 4px; - padding: 5px 6px; - margin-top: 0; - color: var(--body-fg); - background-color: var(--body-bg); -} - -input[type=text]:focus, input[type=password]:focus, input[type=email]:focus, -input[type=url]:focus, input[type=number]:focus, input[type=tel]:focus, -textarea:focus, select:focus, .vTextField:focus { - border-color: var(--body-quiet-color); -} - -select { - height: 1.875rem; -} - -select[multiple] { - /* Allow HTML size attribute to override the height in the rule above. */ - height: auto; - min-height: 150px; -} - -/* FORM BUTTONS */ - -.button, input[type=submit], input[type=button], .submit-row input, a.button { - background: var(--button-bg); - padding: 10px 15px; - border: none; - border-radius: 4px; - color: var(--button-fg); - cursor: pointer; - transition: background 0.15s; -} - -a.button { - padding: 4px 5px; -} - -.button:active, input[type=submit]:active, input[type=button]:active, -.button:focus, input[type=submit]:focus, input[type=button]:focus, -.button:hover, input[type=submit]:hover, input[type=button]:hover { - background: var(--button-hover-bg); -} - -.button[disabled], input[type=submit][disabled], input[type=button][disabled] { - opacity: 0.4; -} - -.button.default, input[type=submit].default, .submit-row input.default { - border: none; - font-weight: 400; - background: var(--default-button-bg); -} - -.button.default:active, input[type=submit].default:active, -.button.default:focus, input[type=submit].default:focus, -.button.default:hover, input[type=submit].default:hover { - background: var(--default-button-hover-bg); -} - -.button[disabled].default, -input[type=submit][disabled].default, -input[type=button][disabled].default { - opacity: 0.4; -} - - -/* MODULES */ - -.module { - border: none; - margin-bottom: 30px; - background: var(--body-bg); -} - -.module p, .module ul, .module h3, .module h4, .module dl, .module pre { - padding-left: 10px; - padding-right: 10px; -} - -.module blockquote { - margin-left: 12px; -} - -.module ul, .module ol { - margin-left: 1.5em; -} - -.module h3 { - margin-top: .6em; -} - -.module h2, .module caption, .inline-group h2 { - margin: 0; - padding: 8px; - font-weight: 400; - font-size: 0.8125rem; - text-align: left; - background: var(--primary); - color: var(--header-link-color); -} - -.module caption, -.inline-group h2 { - font-size: 0.75rem; - letter-spacing: 0.5px; - text-transform: uppercase; -} - -.module table { - border-collapse: collapse; -} - -/* MESSAGES & ERRORS */ - -ul.messagelist { - padding: 0; - margin: 0; -} - -ul.messagelist li { - display: block; - font-weight: 400; - font-size: 0.8125rem; - padding: 10px 10px 10px 65px; - margin: 0 0 10px 0; - background: var(--message-success-bg) url(../img/icon-yes.svg) 40px 12px no-repeat; - background-size: 16px auto; - color: var(--body-fg); - word-break: break-word; -} - -ul.messagelist li.warning { - background: var(--message-warning-bg) url(../img/icon-alert.svg) 40px 14px no-repeat; - background-size: 14px auto; -} - -ul.messagelist li.error { - background: var(--message-error-bg) url(../img/icon-no.svg) 40px 12px no-repeat; - background-size: 16px auto; -} - -.errornote { - font-size: 0.875rem; - font-weight: 700; - display: block; - padding: 10px 12px; - margin: 0 0 10px 0; - color: var(--error-fg); - border: 1px solid var(--error-fg); - border-radius: 4px; - background-color: var(--body-bg); - background-position: 5px 12px; - overflow-wrap: break-word; -} - -ul.errorlist { - margin: 0 0 4px; - padding: 0; - color: var(--error-fg); - background: var(--body-bg); -} - -ul.errorlist li { - font-size: 0.8125rem; - display: block; - margin-bottom: 4px; - overflow-wrap: break-word; -} - -ul.errorlist li:first-child { - margin-top: 0; -} - -ul.errorlist li a { - color: inherit; - text-decoration: underline; -} - -td ul.errorlist { - margin: 0; - padding: 0; -} - -td ul.errorlist li { - margin: 0; -} - -.form-row.errors { - margin: 0; - border: none; - border-bottom: 1px solid var(--hairline-color); - background: none; -} - -.form-row.errors ul.errorlist li { - padding-left: 0; -} - -.errors input, .errors select, .errors textarea, -td ul.errorlist + input, td ul.errorlist + select, td ul.errorlist + textarea { - border: 1px solid var(--error-fg); -} - -.description { - font-size: 0.75rem; - padding: 5px 0 0 12px; -} - -/* BREADCRUMBS */ - -div.breadcrumbs { - background: var(--breadcrumbs-bg); - padding: 10px 40px; - border: none; - color: var(--breadcrumbs-fg); - text-align: left; -} - -div.breadcrumbs a { - color: var(--breadcrumbs-link-fg); -} - -div.breadcrumbs a:focus, div.breadcrumbs a:hover { - color: var(--breadcrumbs-fg); -} - -/* ACTION ICONS */ - -.viewlink, .inlineviewlink { - padding-left: 16px; - background: url(../img/icon-viewlink.svg) 0 1px no-repeat; -} - -.addlink { - padding-left: 16px; - background: url(../img/icon-addlink.svg) 0 1px no-repeat; -} - -.changelink, .inlinechangelink { - padding-left: 16px; - background: url(../img/icon-changelink.svg) 0 1px no-repeat; -} - -.deletelink { - padding-left: 16px; - background: url(../img/icon-deletelink.svg) 0 1px no-repeat; -} - -a.deletelink:link, a.deletelink:visited { - color: #CC3434; /* XXX Probably unused? */ -} - -a.deletelink:focus, a.deletelink:hover { - color: #993333; /* XXX Probably unused? */ - text-decoration: none; -} - -/* OBJECT TOOLS */ - -.object-tools { - font-size: 0.625rem; - font-weight: bold; - padding-left: 0; - float: right; - position: relative; - margin-top: -48px; -} - -.object-tools li { - display: block; - float: left; - margin-left: 5px; - height: 1rem; -} - -.object-tools a { - border-radius: 15px; -} - -.object-tools a:link, .object-tools a:visited { - display: block; - float: left; - padding: 3px 12px; - background: var(--object-tools-bg); - color: var(--object-tools-fg); - font-weight: 400; - font-size: 0.6875rem; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.object-tools a:focus, .object-tools a:hover { - background-color: var(--object-tools-hover-bg); -} - -.object-tools a:focus{ - text-decoration: none; -} - -.object-tools a.viewsitelink, .object-tools a.addlink { - background-repeat: no-repeat; - background-position: right 7px center; - padding-right: 26px; -} - -.object-tools a.viewsitelink { - background-image: url(../img/tooltag-arrowright.svg); -} - -.object-tools a.addlink { - background-image: url(../img/tooltag-add.svg); -} - -/* OBJECT HISTORY */ - -#change-history table { - width: 100%; -} - -#change-history table tbody th { - width: 16em; -} - -#change-history .paginator { - color: var(--body-quiet-color); - border-bottom: 1px solid var(--hairline-color); - background: var(--body-bg); - overflow: hidden; -} - -/* PAGE STRUCTURE */ - -#container { - position: relative; - width: 100%; - min-width: 980px; - padding: 0; - display: flex; - flex-direction: column; - height: 100%; -} - -#container > div { - flex-shrink: 0; -} - -#container > .main { - display: flex; - flex: 1 0 auto; -} - -.main > .content { - flex: 1 0; - max-width: 100%; -} - -.skip-to-content-link { - position: absolute; - top: -999px; - margin: 5px; - padding: 5px; - background: var(--body-bg); - z-index: 1; -} - -.skip-to-content-link:focus { - left: 0px; - top: 0px; -} - -#content { - padding: 20px 40px; -} - -.dashboard #content { - width: 600px; -} - -#content-main { - float: left; - width: 100%; -} - -#content-related { - float: right; - width: 260px; - position: relative; - margin-right: -300px; -} - -#footer { - clear: both; - padding: 10px; -} - -/* COLUMN TYPES */ - -.colMS { - margin-right: 300px; -} - -.colSM { - margin-left: 300px; -} - -.colSM #content-related { - float: left; - margin-right: 0; - margin-left: -300px; -} - -.colSM #content-main { - float: right; -} - -.popup .colM { - width: auto; -} - -/* HEADER */ - -#header { - width: auto; - height: auto; - display: flex; - justify-content: space-between; - align-items: center; - padding: 10px 40px; - background: var(--header-bg); - color: var(--header-color); - overflow: hidden; -} - -#header a:link, #header a:visited, #logout-form button { - color: var(--header-link-color); -} - -#header a:focus , #header a:hover { - text-decoration: underline; -} - -#branding { - display: flex; -} - -#branding h1 { - padding: 0; - margin: 0; - margin-inline-end: 20px; - font-weight: 300; - font-size: 1.5rem; - color: var(--header-branding-color); -} - -#branding h1 a:link, #branding h1 a:visited { - color: var(--accent); -} - -#branding h2 { - padding: 0 10px; - font-size: 0.875rem; - margin: -8px 0 8px 0; - font-weight: normal; - color: var(--header-color); -} - -#branding a:hover { - text-decoration: none; -} - -#logout-form { - display: inline; -} - -#logout-form button { - background: none; - border: 0; - cursor: pointer; - font-family: var(--font-family-primary); -} - -#user-tools { - float: right; - margin: 0 0 0 20px; - text-align: right; -} - -#user-tools, #logout-form button{ - padding: 0; - font-weight: 300; - font-size: 0.6875rem; - letter-spacing: 0.5px; - text-transform: uppercase; -} - -#user-tools a, #logout-form button { - border-bottom: 1px solid rgba(255, 255, 255, 0.25); -} - -#user-tools a:focus, #user-tools a:hover, -#logout-form button:active, #logout-form button:hover { - text-decoration: none; - border-bottom: 0; -} - -#logout-form button:active, #logout-form button:hover { - margin-bottom: 1px; -} - -/* SIDEBAR */ - -#content-related { - background: var(--darkened-bg); -} - -#content-related .module { - background: none; -} - -#content-related h3 { - color: var(--body-quiet-color); - padding: 0 16px; - margin: 0 0 16px; -} - -#content-related h4 { - font-size: 0.8125rem; -} - -#content-related p { - padding-left: 16px; - padding-right: 16px; -} - -#content-related .actionlist { - padding: 0; - margin: 16px; -} - -#content-related .actionlist li { - line-height: 1.2; - margin-bottom: 10px; - padding-left: 18px; -} - -#content-related .module h2 { - background: none; - padding: 16px; - margin-bottom: 16px; - border-bottom: 1px solid var(--hairline-color); - font-size: 1.125rem; - color: var(--body-fg); -} - -.delete-confirmation form input[type="submit"] { - background: var(--delete-button-bg); - border-radius: 4px; - padding: 10px 15px; - color: var(--button-fg); -} - -.delete-confirmation form input[type="submit"]:active, -.delete-confirmation form input[type="submit"]:focus, -.delete-confirmation form input[type="submit"]:hover { - background: var(--delete-button-hover-bg); -} - -.delete-confirmation form .cancel-link { - display: inline-block; - vertical-align: middle; - height: 0.9375rem; - line-height: 0.9375rem; - border-radius: 4px; - padding: 10px 15px; - color: var(--button-fg); - background: var(--close-button-bg); - margin: 0 0 0 10px; -} - -.delete-confirmation form .cancel-link:active, -.delete-confirmation form .cancel-link:focus, -.delete-confirmation form .cancel-link:hover { - background: var(--close-button-hover-bg); -} - -/* POPUP */ -.popup #content { - padding: 20px; -} - -.popup #container { - min-width: 0; -} - -.popup #header { - padding: 10px 20px; -} - -/* PAGINATOR */ - -.paginator { - display: flex; - align-items: center; - gap: 4px; - font-size: 0.8125rem; - padding-top: 10px; - padding-bottom: 10px; - line-height: 22px; - margin: 0; - border-top: 1px solid var(--hairline-color); - width: 100%; -} - -.paginator a:link, .paginator a:visited { - padding: 2px 6px; - background: var(--button-bg); - text-decoration: none; - color: var(--button-fg); -} - -.paginator a.showall { - border: none; - background: none; - color: var(--link-fg); -} - -.paginator a.showall:focus, .paginator a.showall:hover { - background: none; - color: var(--link-hover-color); -} - -.paginator .end { - margin-right: 6px; -} - -.paginator .this-page { - padding: 2px 6px; - font-weight: bold; - font-size: 0.8125rem; - vertical-align: top; -} - -.paginator a:focus, .paginator a:hover { - color: white; - background: var(--link-hover-color); -} - -.paginator input { - margin-left: auto; -} - -.base-svgs { - display: none; -} diff --git a/django/staticfiles/admin/css/changelists.css b/django/staticfiles/admin/css/changelists.css deleted file mode 100644 index a7545131..00000000 --- a/django/staticfiles/admin/css/changelists.css +++ /dev/null @@ -1,328 +0,0 @@ -/* CHANGELISTS */ - -#changelist { - display: flex; - align-items: flex-start; - justify-content: space-between; -} - -#changelist .changelist-form-container { - flex: 1 1 auto; - min-width: 0; -} - -#changelist table { - width: 100%; -} - -.change-list .hiddenfields { display:none; } - -.change-list .filtered table { - border-right: none; -} - -.change-list .filtered { - min-height: 400px; -} - -.change-list .filtered .results, .change-list .filtered .paginator, -.filtered #toolbar, .filtered div.xfull { - width: auto; -} - -.change-list .filtered table tbody th { - padding-right: 1em; -} - -#changelist-form .results { - overflow-x: auto; - width: 100%; -} - -#changelist .toplinks { - border-bottom: 1px solid var(--hairline-color); -} - -#changelist .paginator { - color: var(--body-quiet-color); - border-bottom: 1px solid var(--hairline-color); - background: var(--body-bg); - overflow: hidden; -} - -/* CHANGELIST TABLES */ - -#changelist table thead th { - padding: 0; - white-space: nowrap; - vertical-align: middle; -} - -#changelist table thead th.action-checkbox-column { - width: 1.5em; - text-align: center; -} - -#changelist table tbody td.action-checkbox { - text-align: center; -} - -#changelist table tfoot { - color: var(--body-quiet-color); -} - -/* TOOLBAR */ - -#toolbar { - padding: 8px 10px; - margin-bottom: 15px; - border-top: 1px solid var(--hairline-color); - border-bottom: 1px solid var(--hairline-color); - background: var(--darkened-bg); - color: var(--body-quiet-color); -} - -#toolbar form input { - border-radius: 4px; - font-size: 0.875rem; - padding: 5px; - color: var(--body-fg); -} - -#toolbar #searchbar { - height: 1.1875rem; - border: 1px solid var(--border-color); - padding: 2px 5px; - margin: 0; - vertical-align: top; - font-size: 0.8125rem; - max-width: 100%; -} - -#toolbar #searchbar:focus { - border-color: var(--body-quiet-color); -} - -#toolbar form input[type="submit"] { - border: 1px solid var(--border-color); - font-size: 0.8125rem; - padding: 4px 8px; - margin: 0; - vertical-align: middle; - background: var(--body-bg); - box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset; - cursor: pointer; - color: var(--body-fg); -} - -#toolbar form input[type="submit"]:focus, -#toolbar form input[type="submit"]:hover { - border-color: var(--body-quiet-color); -} - -#changelist-search img { - vertical-align: middle; - margin-right: 4px; -} - -#changelist-search .help { - word-break: break-word; -} - -/* FILTER COLUMN */ - -#changelist-filter { - flex: 0 0 240px; - order: 1; - background: var(--darkened-bg); - border-left: none; - margin: 0 0 0 30px; -} - -#changelist-filter h2 { - font-size: 0.875rem; - text-transform: uppercase; - letter-spacing: 0.5px; - padding: 5px 15px; - margin-bottom: 12px; - border-bottom: none; -} - -#changelist-filter h3, -#changelist-filter details summary { - font-weight: 400; - padding: 0 15px; - margin-bottom: 10px; -} - -#changelist-filter details summary > * { - display: inline; -} - -#changelist-filter details > summary { - list-style-type: none; -} - -#changelist-filter details > summary::-webkit-details-marker { - display: none; -} - -#changelist-filter details > summary::before { - content: '→'; - font-weight: bold; - color: var(--link-hover-color); -} - -#changelist-filter details[open] > summary::before { - content: '↓'; -} - -#changelist-filter ul { - margin: 5px 0; - padding: 0 15px 15px; - border-bottom: 1px solid var(--hairline-color); -} - -#changelist-filter ul:last-child { - border-bottom: none; -} - -#changelist-filter li { - list-style-type: none; - margin-left: 0; - padding-left: 0; -} - -#changelist-filter a { - display: block; - color: var(--body-quiet-color); - word-break: break-word; -} - -#changelist-filter li.selected { - border-left: 5px solid var(--hairline-color); - padding-left: 10px; - margin-left: -15px; -} - -#changelist-filter li.selected a { - color: var(--link-selected-fg); -} - -#changelist-filter a:focus, #changelist-filter a:hover, -#changelist-filter li.selected a:focus, -#changelist-filter li.selected a:hover { - color: var(--link-hover-color); -} - -#changelist-filter #changelist-filter-clear a { - font-size: 0.8125rem; - padding-bottom: 10px; - border-bottom: 1px solid var(--hairline-color); -} - -/* DATE DRILLDOWN */ - -.change-list .toplinks { - display: flex; - padding-bottom: 5px; - flex-wrap: wrap; - gap: 3px 17px; - font-weight: bold; -} - -.change-list .toplinks a { - font-size: 0.8125rem; -} - -.change-list .toplinks .date-back { - color: var(--body-quiet-color); -} - -.change-list .toplinks .date-back:focus, -.change-list .toplinks .date-back:hover { - color: var(--link-hover-color); -} - -/* ACTIONS */ - -.filtered .actions { - border-right: none; -} - -#changelist table input { - margin: 0; - vertical-align: baseline; -} - -/* Once the :has() pseudo-class is supported by all browsers, the tr.selected - selector and the JS adding the class can be removed. */ -#changelist tbody tr.selected { - background-color: var(--selected-row); -} - -#changelist tbody tr:has(.action-select:checked) { - background-color: var(--selected-row); -} - -#changelist .actions { - padding: 10px; - background: var(--body-bg); - border-top: none; - border-bottom: none; - line-height: 1.5rem; - color: var(--body-quiet-color); - width: 100%; -} - -#changelist .actions span.all, -#changelist .actions span.action-counter, -#changelist .actions span.clear, -#changelist .actions span.question { - font-size: 0.8125rem; - margin: 0 0.5em; -} - -#changelist .actions:last-child { - border-bottom: none; -} - -#changelist .actions select { - vertical-align: top; - height: 1.5rem; - color: var(--body-fg); - border: 1px solid var(--border-color); - border-radius: 4px; - font-size: 0.875rem; - padding: 0 0 0 4px; - margin: 0; - margin-left: 10px; -} - -#changelist .actions select:focus { - border-color: var(--body-quiet-color); -} - -#changelist .actions label { - display: inline-block; - vertical-align: middle; - font-size: 0.8125rem; -} - -#changelist .actions .button { - font-size: 0.8125rem; - border: 1px solid var(--border-color); - border-radius: 4px; - background: var(--body-bg); - box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset; - cursor: pointer; - height: 1.5rem; - line-height: 1; - padding: 4px 8px; - margin: 0; - color: var(--body-fg); -} - -#changelist .actions .button:focus, #changelist .actions .button:hover { - border-color: var(--body-quiet-color); -} diff --git a/django/staticfiles/admin/css/dark_mode.css b/django/staticfiles/admin/css/dark_mode.css deleted file mode 100644 index 6d08233a..00000000 --- a/django/staticfiles/admin/css/dark_mode.css +++ /dev/null @@ -1,137 +0,0 @@ -@media (prefers-color-scheme: dark) { - :root { - --primary: #264b5d; - --primary-fg: #f7f7f7; - - --body-fg: #eeeeee; - --body-bg: #121212; - --body-quiet-color: #e0e0e0; - --body-loud-color: #ffffff; - - --breadcrumbs-link-fg: #e0e0e0; - --breadcrumbs-bg: var(--primary); - - --link-fg: #81d4fa; - --link-hover-color: #4ac1f7; - --link-selected-fg: #6f94c6; - - --hairline-color: #272727; - --border-color: #353535; - - --error-fg: #e35f5f; - --message-success-bg: #006b1b; - --message-warning-bg: #583305; - --message-error-bg: #570808; - - --darkened-bg: #212121; - --selected-bg: #1b1b1b; - --selected-row: #00363a; - - --close-button-bg: #333333; - --close-button-hover-bg: #666666; - } - } - - -html[data-theme="dark"] { - --primary: #264b5d; - --primary-fg: #f7f7f7; - - --body-fg: #eeeeee; - --body-bg: #121212; - --body-quiet-color: #e0e0e0; - --body-loud-color: #ffffff; - - --breadcrumbs-link-fg: #e0e0e0; - --breadcrumbs-bg: var(--primary); - - --link-fg: #81d4fa; - --link-hover-color: #4ac1f7; - --link-selected-fg: #6f94c6; - - --hairline-color: #272727; - --border-color: #353535; - - --error-fg: #e35f5f; - --message-success-bg: #006b1b; - --message-warning-bg: #583305; - --message-error-bg: #570808; - - --darkened-bg: #212121; - --selected-bg: #1b1b1b; - --selected-row: #00363a; - - --close-button-bg: #333333; - --close-button-hover-bg: #666666; -} - -/* THEME SWITCH */ -.theme-toggle { - cursor: pointer; - border: none; - padding: 0; - background: transparent; - vertical-align: middle; - margin-inline-start: 5px; - margin-top: -1px; -} - -.theme-toggle svg { - vertical-align: middle; - height: 1rem; - width: 1rem; - display: none; -} - -/* -Fully hide screen reader text so we only show the one matching the current -theme. -*/ -.theme-toggle .visually-hidden { - display: none; -} - -html[data-theme="auto"] .theme-toggle .theme-label-when-auto { - display: block; -} - -html[data-theme="dark"] .theme-toggle .theme-label-when-dark { - display: block; -} - -html[data-theme="light"] .theme-toggle .theme-label-when-light { - display: block; -} - -/* ICONS */ -.theme-toggle svg.theme-icon-when-auto, -.theme-toggle svg.theme-icon-when-dark, -.theme-toggle svg.theme-icon-when-light { - fill: var(--header-link-color); - color: var(--header-bg); -} - -html[data-theme="auto"] .theme-toggle svg.theme-icon-when-auto { - display: block; -} - -html[data-theme="dark"] .theme-toggle svg.theme-icon-when-dark { - display: block; -} - -html[data-theme="light"] .theme-toggle svg.theme-icon-when-light { - display: block; -} - -.visually-hidden { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - overflow: hidden; - clip: rect(0,0,0,0); - white-space: nowrap; - border: 0; - color: var(--body-fg); - background-color: var(--body-bg); -} diff --git a/django/staticfiles/admin/css/dashboard.css b/django/staticfiles/admin/css/dashboard.css deleted file mode 100644 index 242b81a4..00000000 --- a/django/staticfiles/admin/css/dashboard.css +++ /dev/null @@ -1,29 +0,0 @@ -/* DASHBOARD */ -.dashboard td, .dashboard th { - word-break: break-word; -} - -.dashboard .module table th { - width: 100%; -} - -.dashboard .module table td { - white-space: nowrap; -} - -.dashboard .module table td a { - display: block; - padding-right: .6em; -} - -/* RECENT ACTIONS MODULE */ - -.module ul.actionlist { - margin-left: 0; -} - -ul.actionlist li { - list-style-type: none; - overflow: hidden; - text-overflow: ellipsis; -} diff --git a/django/staticfiles/admin/css/forms.css b/django/staticfiles/admin/css/forms.css deleted file mode 100644 index 6cfe9da1..00000000 --- a/django/staticfiles/admin/css/forms.css +++ /dev/null @@ -1,530 +0,0 @@ -@import url('widgets.css'); - -/* FORM ROWS */ - -.form-row { - overflow: hidden; - padding: 10px; - font-size: 0.8125rem; - border-bottom: 1px solid var(--hairline-color); -} - -.form-row img, .form-row input { - vertical-align: middle; -} - -.form-row label input[type="checkbox"] { - margin-top: 0; - vertical-align: 0; -} - -form .form-row p { - padding-left: 0; -} - -.flex-container { - display: flex; -} - -.form-multiline > div { - padding-bottom: 10px; -} - -/* FORM LABELS */ - -label { - font-weight: normal; - color: var(--body-quiet-color); - font-size: 0.8125rem; -} - -.required label, label.required { - font-weight: bold; - color: var(--body-fg); -} - -/* RADIO BUTTONS */ - -form div.radiolist div { - padding-right: 7px; -} - -form div.radiolist.inline div { - display: inline-block; -} - -form div.radiolist label { - width: auto; -} - -form div.radiolist input[type="radio"] { - margin: -2px 4px 0 0; - padding: 0; -} - -form ul.inline { - margin-left: 0; - padding: 0; -} - -form ul.inline li { - float: left; - padding-right: 7px; -} - -/* ALIGNED FIELDSETS */ - -.aligned label { - display: block; - padding: 4px 10px 0 0; - min-width: 160px; - width: 160px; - word-wrap: break-word; - line-height: 1; -} - -.aligned label:not(.vCheckboxLabel):after { - content: ''; - display: inline-block; - vertical-align: middle; - height: 1.625rem; -} - -.aligned label + p, .aligned .checkbox-row + div.help, .aligned label + div.readonly { - padding: 6px 0; - margin-top: 0; - margin-bottom: 0; - margin-left: 0; - overflow-wrap: break-word; -} - -.aligned ul label { - display: inline; - float: none; - width: auto; -} - -.aligned .form-row input { - margin-bottom: 0; -} - -.colMS .aligned .vLargeTextField, .colMS .aligned .vXMLLargeTextField { - width: 350px; -} - -form .aligned ul { - margin-left: 160px; - padding-left: 10px; -} - -form .aligned div.radiolist { - display: inline-block; - margin: 0; - padding: 0; -} - -form .aligned p.help, -form .aligned div.help { - margin-top: 0; - margin-left: 160px; - padding-left: 10px; -} - -form .aligned p.date div.help.timezonewarning, -form .aligned p.datetime div.help.timezonewarning, -form .aligned p.time div.help.timezonewarning { - margin-left: 0; - padding-left: 0; - font-weight: normal; -} - -form .aligned p.help:last-child, -form .aligned div.help:last-child { - margin-bottom: 0; - padding-bottom: 0; -} - -form .aligned input + p.help, -form .aligned textarea + p.help, -form .aligned select + p.help, -form .aligned input + div.help, -form .aligned textarea + div.help, -form .aligned select + div.help { - margin-left: 160px; - padding-left: 10px; -} - -form .aligned ul li { - list-style: none; -} - -form .aligned table p { - margin-left: 0; - padding-left: 0; -} - -.aligned .vCheckboxLabel { - float: none; - width: auto; - display: inline-block; - vertical-align: -3px; - padding: 0 0 5px 5px; -} - -.aligned .vCheckboxLabel + p.help, -.aligned .vCheckboxLabel + div.help { - margin-top: -4px; -} - -.colM .aligned .vLargeTextField, .colM .aligned .vXMLLargeTextField { - width: 610px; -} - -fieldset .fieldBox { - margin-right: 20px; -} - -/* WIDE FIELDSETS */ - -.wide label { - width: 200px; -} - -form .wide p, -form .wide ul.errorlist, -form .wide input + p.help, -form .wide input + div.help { - margin-left: 200px; -} - -form .wide p.help, -form .wide div.help { - padding-left: 50px; -} - -form div.help ul { - padding-left: 0; - margin-left: 0; -} - -.colM fieldset.wide .vLargeTextField, .colM fieldset.wide .vXMLLargeTextField { - width: 450px; -} - -/* COLLAPSED FIELDSETS */ - -fieldset.collapsed * { - display: none; -} - -fieldset.collapsed h2, fieldset.collapsed { - display: block; -} - -fieldset.collapsed { - border: 1px solid var(--hairline-color); - border-radius: 4px; - overflow: hidden; -} - -fieldset.collapsed h2 { - background: var(--darkened-bg); - color: var(--body-quiet-color); -} - -fieldset .collapse-toggle { - color: var(--header-link-color); -} - -fieldset.collapsed .collapse-toggle { - background: transparent; - display: inline; - color: var(--link-fg); -} - -/* MONOSPACE TEXTAREAS */ - -fieldset.monospace textarea { - font-family: var(--font-family-monospace); -} - -/* SUBMIT ROW */ - -.submit-row { - padding: 12px 14px 12px; - margin: 0 0 20px; - background: var(--darkened-bg); - border: 1px solid var(--hairline-color); - border-radius: 4px; - overflow: hidden; - display: flex; - gap: 10px; - flex-wrap: wrap; -} - -body.popup .submit-row { - overflow: auto; -} - -.submit-row input { - height: 2.1875rem; - line-height: 0.9375rem; -} - -.submit-row input, .submit-row a { - margin: 0; -} - -.submit-row input.default { - text-transform: uppercase; -} - -.submit-row a.deletelink { - margin-left: auto; -} - -.submit-row a.deletelink { - display: block; - background: var(--delete-button-bg); - border-radius: 4px; - padding: 0.625rem 0.9375rem; - height: 0.9375rem; - line-height: 0.9375rem; - color: var(--button-fg); -} - -.submit-row a.closelink { - display: inline-block; - background: var(--close-button-bg); - border-radius: 4px; - padding: 10px 15px; - height: 0.9375rem; - line-height: 0.9375rem; - color: var(--button-fg); -} - -.submit-row a.deletelink:focus, -.submit-row a.deletelink:hover, -.submit-row a.deletelink:active { - background: var(--delete-button-hover-bg); - text-decoration: none; -} - -.submit-row a.closelink:focus, -.submit-row a.closelink:hover, -.submit-row a.closelink:active { - background: var(--close-button-hover-bg); - text-decoration: none; -} - -/* CUSTOM FORM FIELDS */ - -.vSelectMultipleField { - vertical-align: top; -} - -.vCheckboxField { - border: none; -} - -.vDateField, .vTimeField { - margin-right: 2px; - margin-bottom: 4px; -} - -.vDateField { - min-width: 6.85em; -} - -.vTimeField { - min-width: 4.7em; -} - -.vURLField { - width: 30em; -} - -.vLargeTextField, .vXMLLargeTextField { - width: 48em; -} - -.flatpages-flatpage #id_content { - height: 40.2em; -} - -.module table .vPositiveSmallIntegerField { - width: 2.2em; -} - -.vIntegerField { - width: 5em; -} - -.vBigIntegerField { - width: 10em; -} - -.vForeignKeyRawIdAdminField { - width: 5em; -} - -.vTextField, .vUUIDField { - width: 20em; -} - -/* INLINES */ - -.inline-group { - padding: 0; - margin: 0 0 30px; -} - -.inline-group thead th { - padding: 8px 10px; -} - -.inline-group .aligned label { - width: 160px; -} - -.inline-related { - position: relative; -} - -.inline-related h3 { - margin: 0; - color: var(--body-quiet-color); - padding: 5px; - font-size: 0.8125rem; - background: var(--darkened-bg); - border-top: 1px solid var(--hairline-color); - border-bottom: 1px solid var(--hairline-color); -} - -.inline-related h3 span.delete { - float: right; -} - -.inline-related h3 span.delete label { - margin-left: 2px; - font-size: 0.6875rem; -} - -.inline-related fieldset { - margin: 0; - background: var(--body-bg); - border: none; - width: 100%; -} - -.inline-related fieldset.module h3 { - margin: 0; - padding: 2px 5px 3px 5px; - font-size: 0.6875rem; - text-align: left; - font-weight: bold; - background: #bcd; - color: var(--body-bg); -} - -.inline-group .tabular fieldset.module { - border: none; -} - -.inline-related.tabular fieldset.module table { - width: 100%; - overflow-x: scroll; -} - -.last-related fieldset { - border: none; -} - -.inline-group .tabular tr.has_original td { - padding-top: 2em; -} - -.inline-group .tabular tr td.original { - padding: 2px 0 0 0; - width: 0; - _position: relative; -} - -.inline-group .tabular th.original { - width: 0px; - padding: 0; -} - -.inline-group .tabular td.original p { - position: absolute; - left: 0; - height: 1.1em; - padding: 2px 9px; - overflow: hidden; - font-size: 0.5625rem; - font-weight: bold; - color: var(--body-quiet-color); - _width: 700px; -} - -.inline-group ul.tools { - padding: 0; - margin: 0; - list-style: none; -} - -.inline-group ul.tools li { - display: inline; - padding: 0 5px; -} - -.inline-group div.add-row, -.inline-group .tabular tr.add-row td { - color: var(--body-quiet-color); - background: var(--darkened-bg); - padding: 8px 10px; - border-bottom: 1px solid var(--hairline-color); -} - -.inline-group .tabular tr.add-row td { - padding: 8px 10px; - border-bottom: 1px solid var(--hairline-color); -} - -.inline-group ul.tools a.add, -.inline-group div.add-row a, -.inline-group .tabular tr.add-row td a { - background: url(../img/icon-addlink.svg) 0 1px no-repeat; - padding-left: 16px; - font-size: 0.75rem; -} - -.empty-form { - display: none; -} - -/* RELATED FIELD ADD ONE / LOOKUP */ - -.related-lookup { - margin-left: 5px; - display: inline-block; - vertical-align: middle; - background-repeat: no-repeat; - background-size: 14px; -} - -.related-lookup { - width: 1rem; - height: 1rem; - background-image: url(../img/search.svg); -} - -form .related-widget-wrapper ul { - display: inline-block; - margin-left: 0; - padding-left: 0; -} - -.clearable-file-input input { - margin-top: 0; -} diff --git a/django/staticfiles/admin/css/login.css b/django/staticfiles/admin/css/login.css deleted file mode 100644 index 389772f5..00000000 --- a/django/staticfiles/admin/css/login.css +++ /dev/null @@ -1,61 +0,0 @@ -/* LOGIN FORM */ - -.login { - background: var(--darkened-bg); - height: auto; -} - -.login #header { - height: auto; - padding: 15px 16px; - justify-content: center; -} - -.login #header h1 { - font-size: 1.125rem; - margin: 0; -} - -.login #header h1 a { - color: var(--header-link-color); -} - -.login #content { - padding: 20px 20px 0; -} - -.login #container { - background: var(--body-bg); - border: 1px solid var(--hairline-color); - border-radius: 4px; - overflow: hidden; - width: 28em; - min-width: 300px; - margin: 100px auto; - height: auto; -} - -.login .form-row { - padding: 4px 0; -} - -.login .form-row label { - display: block; - line-height: 2em; -} - -.login .form-row #id_username, .login .form-row #id_password { - padding: 8px; - width: 100%; - box-sizing: border-box; -} - -.login .submit-row { - padding: 1em 0 0 0; - margin: 0; - text-align: center; -} - -.login .password-reset-link { - text-align: center; -} diff --git a/django/staticfiles/admin/css/nav_sidebar.css b/django/staticfiles/admin/css/nav_sidebar.css deleted file mode 100644 index f76e6ce4..00000000 --- a/django/staticfiles/admin/css/nav_sidebar.css +++ /dev/null @@ -1,144 +0,0 @@ -.sticky { - position: sticky; - top: 0; - max-height: 100vh; -} - -.toggle-nav-sidebar { - z-index: 20; - left: 0; - display: flex; - align-items: center; - justify-content: center; - flex: 0 0 23px; - width: 23px; - border: 0; - border-right: 1px solid var(--hairline-color); - background-color: var(--body-bg); - cursor: pointer; - font-size: 1.25rem; - color: var(--link-fg); - padding: 0; -} - -[dir="rtl"] .toggle-nav-sidebar { - border-left: 1px solid var(--hairline-color); - border-right: 0; -} - -.toggle-nav-sidebar:hover, -.toggle-nav-sidebar:focus { - background-color: var(--darkened-bg); -} - -#nav-sidebar { - z-index: 15; - flex: 0 0 275px; - left: -276px; - margin-left: -276px; - border-top: 1px solid transparent; - border-right: 1px solid var(--hairline-color); - background-color: var(--body-bg); - overflow: auto; -} - -[dir="rtl"] #nav-sidebar { - border-left: 1px solid var(--hairline-color); - border-right: 0; - left: 0; - margin-left: 0; - right: -276px; - margin-right: -276px; -} - -.toggle-nav-sidebar::before { - content: '\00BB'; -} - -.main.shifted .toggle-nav-sidebar::before { - content: '\00AB'; -} - -.main > #nav-sidebar { - visibility: hidden; -} - -.main.shifted > #nav-sidebar { - margin-left: 0; - visibility: visible; -} - -[dir="rtl"] .main.shifted > #nav-sidebar { - margin-right: 0; -} - -#nav-sidebar .module th { - width: 100%; - overflow-wrap: anywhere; -} - -#nav-sidebar .module th, -#nav-sidebar .module caption { - padding-left: 16px; -} - -#nav-sidebar .module td { - white-space: nowrap; -} - -[dir="rtl"] #nav-sidebar .module th, -[dir="rtl"] #nav-sidebar .module caption { - padding-left: 8px; - padding-right: 16px; -} - -#nav-sidebar .current-app .section:link, -#nav-sidebar .current-app .section:visited { - color: var(--header-color); - font-weight: bold; -} - -#nav-sidebar .current-model { - background: var(--selected-row); -} - -.main > #nav-sidebar + .content { - max-width: calc(100% - 23px); -} - -.main.shifted > #nav-sidebar + .content { - max-width: calc(100% - 299px); -} - -@media (max-width: 767px) { - #nav-sidebar, #toggle-nav-sidebar { - display: none; - } - - .main > #nav-sidebar + .content, - .main.shifted > #nav-sidebar + .content { - max-width: 100%; - } -} - -#nav-filter { - width: 100%; - box-sizing: border-box; - padding: 2px 5px; - margin: 5px 0; - border: 1px solid var(--border-color); - background-color: var(--darkened-bg); - color: var(--body-fg); -} - -#nav-filter:focus { - border-color: var(--body-quiet-color); -} - -#nav-filter.no-results { - background: var(--message-error-bg); -} - -#nav-sidebar table { - width: 100%; -} diff --git a/django/staticfiles/admin/css/responsive.css b/django/staticfiles/admin/css/responsive.css deleted file mode 100644 index 1d0a188f..00000000 --- a/django/staticfiles/admin/css/responsive.css +++ /dev/null @@ -1,999 +0,0 @@ -/* Tablets */ - -input[type="submit"], button { - -webkit-appearance: none; - appearance: none; -} - -@media (max-width: 1024px) { - /* Basic */ - - html { - -webkit-text-size-adjust: 100%; - } - - td, th { - padding: 10px; - font-size: 0.875rem; - } - - .small { - font-size: 0.75rem; - } - - /* Layout */ - - #container { - min-width: 0; - } - - #content { - padding: 15px 20px 20px; - } - - div.breadcrumbs { - padding: 10px 30px; - } - - /* Header */ - - #header { - flex-direction: column; - padding: 15px 30px; - justify-content: flex-start; - } - - #branding h1 { - margin: 0 0 8px; - line-height: 1.2; - } - - #user-tools { - margin: 0; - font-weight: 400; - line-height: 1.85; - text-align: left; - } - - #user-tools a { - display: inline-block; - line-height: 1.4; - } - - /* Dashboard */ - - .dashboard #content { - width: auto; - } - - #content-related { - margin-right: -290px; - } - - .colSM #content-related { - margin-left: -290px; - } - - .colMS { - margin-right: 290px; - } - - .colSM { - margin-left: 290px; - } - - .dashboard .module table td a { - padding-right: 0; - } - - td .changelink, td .addlink { - font-size: 0.8125rem; - } - - /* Changelist */ - - #toolbar { - border: none; - padding: 15px; - } - - #changelist-search > div { - display: flex; - flex-wrap: nowrap; - max-width: 480px; - } - - #changelist-search label { - line-height: 1.375rem; - } - - #toolbar form #searchbar { - flex: 1 0 auto; - width: 0; - height: 1.375rem; - margin: 0 10px 0 6px; - } - - #toolbar form input[type=submit] { - flex: 0 1 auto; - } - - #changelist-search .quiet { - width: 0; - flex: 1 0 auto; - margin: 5px 0 0 25px; - } - - #changelist .actions { - display: flex; - flex-wrap: wrap; - padding: 15px 0; - } - - #changelist .actions label { - display: flex; - } - - #changelist .actions select { - background: var(--body-bg); - } - - #changelist .actions .button { - min-width: 48px; - margin: 0 10px; - } - - #changelist .actions span.all, - #changelist .actions span.clear, - #changelist .actions span.question, - #changelist .actions span.action-counter { - font-size: 0.6875rem; - margin: 0 10px 0 0; - } - - #changelist-filter { - flex-basis: 200px; - } - - .change-list .filtered .results, - .change-list .filtered .paginator, - .filtered #toolbar, - .filtered .actions, - - #changelist .paginator { - border-top-color: var(--hairline-color); /* XXX Is this used at all? */ - } - - #changelist .results + .paginator { - border-top: none; - } - - /* Forms */ - - label { - font-size: 0.875rem; - } - - .form-row input[type=text], - .form-row input[type=password], - .form-row input[type=email], - .form-row input[type=url], - .form-row input[type=tel], - .form-row input[type=number], - .form-row textarea, - .form-row select, - .form-row .vTextField { - box-sizing: border-box; - margin: 0; - padding: 6px 8px; - min-height: 2.25rem; - font-size: 0.875rem; - } - - .form-row select { - height: 2.25rem; - } - - .form-row select[multiple] { - height: auto; - min-height: 0; - } - - fieldset .fieldBox + .fieldBox { - margin-top: 10px; - padding-top: 10px; - border-top: 1px solid var(--hairline-color); - } - - textarea { - max-width: 100%; - max-height: 120px; - } - - .aligned label { - padding-top: 6px; - } - - .aligned .related-lookup, - .aligned .datetimeshortcuts, - .aligned .related-lookup + strong { - align-self: center; - margin-left: 15px; - } - - form .aligned div.radiolist { - margin-left: 2px; - } - - .submit-row { - padding: 8px; - } - - .submit-row a.deletelink { - padding: 10px 7px; - } - - .button, input[type=submit], input[type=button], .submit-row input, a.button { - padding: 7px; - } - - /* Related widget */ - - .related-widget-wrapper { - float: none; - } - - .related-widget-wrapper-link + .selector { - max-width: calc(100% - 30px); - margin-right: 15px; - } - - select + .related-widget-wrapper-link, - .related-widget-wrapper-link + .related-widget-wrapper-link { - margin-left: 10px; - } - - /* Selector */ - - .selector { - display: flex; - width: 100%; - } - - .selector .selector-filter { - display: flex; - align-items: center; - } - - .selector .selector-filter label { - margin: 0 8px 0 0; - } - - .selector .selector-filter input { - width: auto; - min-height: 0; - flex: 1 1; - } - - .selector-available, .selector-chosen { - width: auto; - flex: 1 1; - display: flex; - flex-direction: column; - } - - .selector select { - width: 100%; - flex: 1 0 auto; - margin-bottom: 5px; - } - - .selector ul.selector-chooser { - width: 26px; - height: 52px; - padding: 2px 0; - margin: auto 15px; - border-radius: 20px; - transform: translateY(-10px); - } - - .selector-add, .selector-remove { - width: 20px; - height: 20px; - background-size: 20px auto; - } - - .selector-add { - background-position: 0 -120px; - } - - .selector-remove { - background-position: 0 -80px; - } - - a.selector-chooseall, a.selector-clearall { - align-self: center; - } - - .stacked { - flex-direction: column; - max-width: 480px; - } - - .stacked > * { - flex: 0 1 auto; - } - - .stacked select { - margin-bottom: 0; - } - - .stacked .selector-available, .stacked .selector-chosen { - width: auto; - } - - .stacked ul.selector-chooser { - width: 52px; - height: 26px; - padding: 0 2px; - margin: 15px auto; - transform: none; - } - - .stacked .selector-chooser li { - padding: 3px; - } - - .stacked .selector-add, .stacked .selector-remove { - background-size: 20px auto; - } - - .stacked .selector-add { - background-position: 0 -40px; - } - - .stacked .active.selector-add { - background-position: 0 -40px; - } - - .active.selector-add:focus, .active.selector-add:hover { - background-position: 0 -140px; - } - - .stacked .active.selector-add:focus, .stacked .active.selector-add:hover { - background-position: 0 -60px; - } - - .stacked .selector-remove { - background-position: 0 0; - } - - .stacked .active.selector-remove { - background-position: 0 0; - } - - .active.selector-remove:focus, .active.selector-remove:hover { - background-position: 0 -100px; - } - - .stacked .active.selector-remove:focus, .stacked .active.selector-remove:hover { - background-position: 0 -20px; - } - - .help-tooltip, .selector .help-icon { - display: none; - } - - .datetime input { - width: 50%; - max-width: 120px; - } - - .datetime span { - font-size: 0.8125rem; - } - - .datetime .timezonewarning { - display: block; - font-size: 0.6875rem; - color: var(--body-quiet-color); - } - - .datetimeshortcuts { - color: var(--border-color); /* XXX Redundant, .datetime span also sets #ccc */ - } - - .form-row .datetime input.vDateField, .form-row .datetime input.vTimeField { - width: 75%; - } - - .inline-group { - overflow: auto; - } - - /* Messages */ - - ul.messagelist li { - padding-left: 55px; - background-position: 30px 12px; - } - - ul.messagelist li.error { - background-position: 30px 12px; - } - - ul.messagelist li.warning { - background-position: 30px 14px; - } - - /* Login */ - - .login #header { - padding: 15px 20px; - } - - .login #branding h1 { - margin: 0; - } - - /* GIS */ - - div.olMap { - max-width: calc(100vw - 30px); - max-height: 300px; - } - - .olMap + .clear_features { - display: block; - margin-top: 10px; - } - - /* Docs */ - - .module table.xfull { - width: 100%; - } - - pre.literal-block { - overflow: auto; - } -} - -/* Mobile */ - -@media (max-width: 767px) { - /* Layout */ - - #header, #content, #footer { - padding: 15px; - } - - #footer:empty { - padding: 0; - } - - div.breadcrumbs { - padding: 10px 15px; - } - - /* Dashboard */ - - .colMS, .colSM { - margin: 0; - } - - #content-related, .colSM #content-related { - width: 100%; - margin: 0; - } - - #content-related .module { - margin-bottom: 0; - } - - #content-related .module h2 { - padding: 10px 15px; - font-size: 1rem; - } - - /* Changelist */ - - #changelist { - align-items: stretch; - flex-direction: column; - } - - #toolbar { - padding: 10px; - } - - #changelist-filter { - margin-left: 0; - } - - #changelist .actions label { - flex: 1 1; - } - - #changelist .actions select { - flex: 1 0; - width: 100%; - } - - #changelist .actions span { - flex: 1 0 100%; - } - - #changelist-filter { - position: static; - width: auto; - margin-top: 30px; - } - - .object-tools { - float: none; - margin: 0 0 15px; - padding: 0; - overflow: hidden; - } - - .object-tools li { - height: auto; - margin-left: 0; - } - - .object-tools li + li { - margin-left: 15px; - } - - /* Forms */ - - .form-row { - padding: 15px 0; - } - - .aligned .form-row, - .aligned .form-row > div { - max-width: 100vw; - } - - .aligned .form-row > div { - width: calc(100vw - 30px); - } - - .flex-container { - flex-flow: column; - } - - .flex-container.checkbox-row { - flex-flow: row; - } - - textarea { - max-width: none; - } - - .vURLField { - width: auto; - } - - fieldset .fieldBox + .fieldBox { - margin-top: 15px; - padding-top: 15px; - } - - fieldset.collapsed .form-row { - display: none; - } - - .aligned label { - width: 100%; - min-width: auto; - padding: 0 0 10px; - } - - .aligned label:after { - max-height: 0; - } - - .aligned .form-row input, - .aligned .form-row select, - .aligned .form-row textarea { - flex: 1 1 auto; - max-width: 100%; - } - - .aligned .checkbox-row input { - flex: 0 1 auto; - margin: 0; - } - - .aligned .vCheckboxLabel { - flex: 1 0; - padding: 1px 0 0 5px; - } - - .aligned label + p, - .aligned label + div.help, - .aligned label + div.readonly { - padding: 0; - margin-left: 0; - } - - .aligned p.file-upload { - font-size: 0.8125rem; - } - - span.clearable-file-input { - margin-left: 15px; - } - - span.clearable-file-input label { - font-size: 0.8125rem; - padding-bottom: 0; - } - - .aligned .timezonewarning { - flex: 1 0 100%; - margin-top: 5px; - } - - form .aligned .form-row div.help { - width: 100%; - margin: 5px 0 0; - padding: 0; - } - - form .aligned ul, - form .aligned ul.errorlist { - margin-left: 0; - padding-left: 0; - } - - form .aligned div.radiolist { - margin-top: 5px; - margin-right: 15px; - margin-bottom: -3px; - } - - form .aligned div.radiolist:not(.inline) div + div { - margin-top: 5px; - } - - /* Related widget */ - - .related-widget-wrapper { - width: 100%; - display: flex; - align-items: flex-start; - } - - .related-widget-wrapper .selector { - order: 1; - } - - .related-widget-wrapper > a { - order: 2; - } - - .related-widget-wrapper .radiolist ~ a { - align-self: flex-end; - } - - .related-widget-wrapper > select ~ a { - align-self: center; - } - - select + .related-widget-wrapper-link, - .related-widget-wrapper-link + .related-widget-wrapper-link { - margin-left: 15px; - } - - /* Selector */ - - .selector { - flex-direction: column; - } - - .selector > * { - float: none; - } - - .selector-available, .selector-chosen { - margin-bottom: 0; - flex: 1 1 auto; - } - - .selector select { - max-height: 96px; - } - - .selector ul.selector-chooser { - display: block; - float: none; - width: 52px; - height: 26px; - padding: 0 2px; - margin: 15px auto 20px; - transform: none; - } - - .selector ul.selector-chooser li { - float: left; - } - - .selector-remove { - background-position: 0 0; - } - - .active.selector-remove:focus, .active.selector-remove:hover { - background-position: 0 -20px; - } - - .selector-add { - background-position: 0 -40px; - } - - .active.selector-add:focus, .active.selector-add:hover { - background-position: 0 -60px; - } - - /* Inlines */ - - .inline-group[data-inline-type="stacked"] .inline-related { - border: 1px solid var(--hairline-color); - border-radius: 4px; - margin-top: 15px; - overflow: auto; - } - - .inline-group[data-inline-type="stacked"] .inline-related > * { - box-sizing: border-box; - } - - .inline-group[data-inline-type="stacked"] .inline-related .module { - padding: 0 10px; - } - - .inline-group[data-inline-type="stacked"] .inline-related .module .form-row { - border-top: 1px solid var(--hairline-color); - border-bottom: none; - } - - .inline-group[data-inline-type="stacked"] .inline-related .module .form-row:first-child { - border-top: none; - } - - .inline-group[data-inline-type="stacked"] .inline-related h3 { - padding: 10px; - border-top-width: 0; - border-bottom-width: 2px; - display: flex; - flex-wrap: wrap; - align-items: center; - } - - .inline-group[data-inline-type="stacked"] .inline-related h3 .inline_label { - margin-right: auto; - } - - .inline-group[data-inline-type="stacked"] .inline-related h3 span.delete { - float: none; - flex: 1 1 100%; - margin-top: 5px; - } - - .inline-group[data-inline-type="stacked"] .aligned .form-row > div:not([class]) { - width: 100%; - } - - .inline-group[data-inline-type="stacked"] .aligned label { - width: 100%; - } - - .inline-group[data-inline-type="stacked"] div.add-row { - margin-top: 15px; - border: 1px solid var(--hairline-color); - border-radius: 4px; - } - - .inline-group div.add-row, - .inline-group .tabular tr.add-row td { - padding: 0; - } - - .inline-group div.add-row a, - .inline-group .tabular tr.add-row td a { - display: block; - padding: 8px 10px 8px 26px; - background-position: 8px 9px; - } - - /* Submit row */ - - .submit-row { - padding: 10px; - margin: 0 0 15px; - flex-direction: column; - gap: 8px; - } - - .submit-row input, .submit-row input.default, .submit-row a { - text-align: center; - } - - .submit-row a.closelink { - padding: 10px 0; - text-align: center; - } - - .submit-row a.deletelink { - margin: 0; - } - - /* Messages */ - - ul.messagelist li { - padding-left: 40px; - background-position: 15px 12px; - } - - ul.messagelist li.error { - background-position: 15px 12px; - } - - ul.messagelist li.warning { - background-position: 15px 14px; - } - - /* Paginator */ - - .paginator .this-page, .paginator a:link, .paginator a:visited { - padding: 4px 10px; - } - - /* Login */ - - body.login { - padding: 0 15px; - } - - .login #container { - width: auto; - max-width: 480px; - margin: 50px auto; - } - - .login #header, - .login #content { - padding: 15px; - } - - .login #content-main { - float: none; - } - - .login .form-row { - padding: 0; - } - - .login .form-row + .form-row { - margin-top: 15px; - } - - .login .form-row label { - margin: 0 0 5px; - line-height: 1.2; - } - - .login .submit-row { - padding: 15px 0 0; - } - - .login br { - display: none; - } - - .login .submit-row input { - margin: 0; - text-transform: uppercase; - } - - .errornote { - margin: 0 0 20px; - padding: 8px 12px; - font-size: 0.8125rem; - } - - /* Calendar and clock */ - - .calendarbox, .clockbox { - position: fixed !important; - top: 50% !important; - left: 50% !important; - transform: translate(-50%, -50%); - margin: 0; - border: none; - overflow: visible; - } - - .calendarbox:before, .clockbox:before { - content: ''; - position: fixed; - top: 50%; - left: 50%; - width: 100vw; - height: 100vh; - background: rgba(0, 0, 0, 0.75); - transform: translate(-50%, -50%); - } - - .calendarbox > *, .clockbox > * { - position: relative; - z-index: 1; - } - - .calendarbox > div:first-child { - z-index: 2; - } - - .calendarbox .calendar, .clockbox h2 { - border-radius: 4px 4px 0 0; - overflow: hidden; - } - - .calendarbox .calendar-cancel, .clockbox .calendar-cancel { - border-radius: 0 0 4px 4px; - overflow: hidden; - } - - .calendar-shortcuts { - padding: 10px 0; - font-size: 0.75rem; - line-height: 0.75rem; - } - - .calendar-shortcuts a { - margin: 0 4px; - } - - .timelist a { - background: var(--body-bg); - padding: 4px; - } - - .calendar-cancel { - padding: 8px 10px; - } - - .clockbox h2 { - padding: 8px 15px; - } - - .calendar caption { - padding: 10px; - } - - .calendarbox .calendarnav-previous, .calendarbox .calendarnav-next { - z-index: 1; - top: 10px; - } - - /* History */ - - table#change-history tbody th, table#change-history tbody td { - font-size: 0.8125rem; - word-break: break-word; - } - - table#change-history tbody th { - width: auto; - } - - /* Docs */ - - table.model tbody th, table.model tbody td { - font-size: 0.8125rem; - word-break: break-word; - } -} diff --git a/django/staticfiles/admin/css/responsive_rtl.css b/django/staticfiles/admin/css/responsive_rtl.css deleted file mode 100644 index 31dc8ff7..00000000 --- a/django/staticfiles/admin/css/responsive_rtl.css +++ /dev/null @@ -1,84 +0,0 @@ -/* TABLETS */ - -@media (max-width: 1024px) { - [dir="rtl"] .colMS { - margin-right: 0; - } - - [dir="rtl"] #user-tools { - text-align: right; - } - - [dir="rtl"] #changelist .actions label { - padding-left: 10px; - padding-right: 0; - } - - [dir="rtl"] #changelist .actions select { - margin-left: 0; - margin-right: 15px; - } - - [dir="rtl"] .change-list .filtered .results, - [dir="rtl"] .change-list .filtered .paginator, - [dir="rtl"] .filtered #toolbar, - [dir="rtl"] .filtered div.xfull, - [dir="rtl"] .filtered .actions, - [dir="rtl"] #changelist-filter { - margin-left: 0; - } - - [dir="rtl"] .inline-group ul.tools a.add, - [dir="rtl"] .inline-group div.add-row a, - [dir="rtl"] .inline-group .tabular tr.add-row td a { - padding: 8px 26px 8px 10px; - background-position: calc(100% - 8px) 9px; - } - - [dir="rtl"] .related-widget-wrapper-link + .selector { - margin-right: 0; - margin-left: 15px; - } - - [dir="rtl"] .selector .selector-filter label { - margin-right: 0; - margin-left: 8px; - } - - [dir="rtl"] .object-tools li { - float: right; - } - - [dir="rtl"] .object-tools li + li { - margin-left: 0; - margin-right: 15px; - } - - [dir="rtl"] .dashboard .module table td a { - padding-left: 0; - padding-right: 16px; - } -} - -/* MOBILE */ - -@media (max-width: 767px) { - [dir="rtl"] .aligned .related-lookup, - [dir="rtl"] .aligned .datetimeshortcuts { - margin-left: 0; - margin-right: 15px; - } - - [dir="rtl"] .aligned ul, - [dir="rtl"] form .aligned ul.errorlist { - margin-right: 0; - } - - [dir="rtl"] #changelist-filter { - margin-left: 0; - margin-right: 0; - } - [dir="rtl"] .aligned .vCheckboxLabel { - padding: 1px 5px 0 0; - } -} diff --git a/django/staticfiles/admin/css/rtl.css b/django/staticfiles/admin/css/rtl.css deleted file mode 100644 index c349a939..00000000 --- a/django/staticfiles/admin/css/rtl.css +++ /dev/null @@ -1,298 +0,0 @@ -/* GLOBAL */ - -th { - text-align: right; -} - -.module h2, .module caption { - text-align: right; -} - -.module ul, .module ol { - margin-left: 0; - margin-right: 1.5em; -} - -.viewlink, .addlink, .changelink { - padding-left: 0; - padding-right: 16px; - background-position: 100% 1px; -} - -.deletelink { - padding-left: 0; - padding-right: 16px; - background-position: 100% 1px; -} - -.object-tools { - float: left; -} - -thead th:first-child, -tfoot td:first-child { - border-left: none; -} - -/* LAYOUT */ - -#user-tools { - right: auto; - left: 0; - text-align: left; -} - -div.breadcrumbs { - text-align: right; -} - -#content-main { - float: right; -} - -#content-related { - float: left; - margin-left: -300px; - margin-right: auto; -} - -.colMS { - margin-left: 300px; - margin-right: 0; -} - -/* SORTABLE TABLES */ - -table thead th.sorted .sortoptions { - float: left; -} - -thead th.sorted .text { - padding-right: 0; - padding-left: 42px; -} - -/* dashboard styles */ - -.dashboard .module table td a { - padding-left: .6em; - padding-right: 16px; -} - -/* changelists styles */ - -.change-list .filtered table { - border-left: none; - border-right: 0px none; -} - -#changelist-filter { - border-left: none; - border-right: none; - margin-left: 0; - margin-right: 30px; -} - -#changelist-filter li.selected { - border-left: none; - padding-left: 10px; - margin-left: 0; - border-right: 5px solid var(--hairline-color); - padding-right: 10px; - margin-right: -15px; -} - -#changelist table tbody td:first-child, #changelist table tbody th:first-child { - border-right: none; - border-left: none; -} - -.paginator .end { - margin-left: 6px; - margin-right: 0; -} - -.paginator input { - margin-left: 0; - margin-right: auto; -} - -/* FORMS */ - -.aligned label { - padding: 0 0 3px 1em; -} - -.submit-row a.deletelink { - margin-left: 0; - margin-right: auto; -} - -.vDateField, .vTimeField { - margin-left: 2px; -} - -.aligned .form-row input { - margin-left: 5px; -} - -form .aligned ul { - margin-right: 163px; - padding-right: 10px; - margin-left: 0; - padding-left: 0; -} - -form ul.inline li { - float: right; - padding-right: 0; - padding-left: 7px; -} - -form .aligned p.help, -form .aligned div.help { - margin-right: 160px; - padding-right: 10px; -} - -form div.help ul, -form .aligned .checkbox-row + .help, -form .aligned p.date div.help.timezonewarning, -form .aligned p.datetime div.help.timezonewarning, -form .aligned p.time div.help.timezonewarning { - margin-right: 0; - padding-right: 0; -} - -form .wide p.help, form .wide div.help { - padding-left: 0; - padding-right: 50px; -} - -form .wide p, -form .wide ul.errorlist, -form .wide input + p.help, -form .wide input + div.help { - margin-right: 200px; - margin-left: 0px; -} - -.submit-row { - text-align: right; -} - -fieldset .fieldBox { - margin-left: 20px; - margin-right: 0; -} - -.errorlist li { - background-position: 100% 12px; - padding: 0; -} - -.errornote { - background-position: 100% 12px; - padding: 10px 12px; -} - -/* WIDGETS */ - -.calendarnav-previous { - top: 0; - left: auto; - right: 10px; - background: url(../img/calendar-icons.svg) 0 -30px no-repeat; -} - -.calendarbox .calendarnav-previous:focus, -.calendarbox .calendarnav-previous:hover { - background-position: 0 -45px; -} - -.calendarnav-next { - top: 0; - right: auto; - left: 10px; - background: url(../img/calendar-icons.svg) 0 0 no-repeat; -} - -.calendarbox .calendarnav-next:focus, -.calendarbox .calendarnav-next:hover { - background-position: 0 -15px; -} - -.calendar caption, .calendarbox h2 { - text-align: center; -} - -.selector { - float: right; -} - -.selector .selector-filter { - text-align: right; -} - -.selector-add { - background: url(../img/selector-icons.svg) 0 -64px no-repeat; -} - -.active.selector-add:focus, .active.selector-add:hover { - background-position: 0 -80px; -} - -.selector-remove { - background: url(../img/selector-icons.svg) 0 -96px no-repeat; -} - -.active.selector-remove:focus, .active.selector-remove:hover { - background-position: 0 -112px; -} - -a.selector-chooseall { - background: url(../img/selector-icons.svg) right -128px no-repeat; -} - -a.active.selector-chooseall:focus, a.active.selector-chooseall:hover { - background-position: 100% -144px; -} - -a.selector-clearall { - background: url(../img/selector-icons.svg) 0 -160px no-repeat; -} - -a.active.selector-clearall:focus, a.active.selector-clearall:hover { - background-position: 0 -176px; -} - -.inline-deletelink { - float: left; -} - -form .form-row p.datetime { - overflow: hidden; -} - -.related-widget-wrapper { - float: right; -} - -/* MISC */ - -.inline-related h2, .inline-group h2 { - text-align: right -} - -.inline-related h3 span.delete { - padding-right: 20px; - padding-left: inherit; - left: 10px; - right: inherit; - float:left; -} - -.inline-related h3 span.delete label { - margin-left: inherit; - margin-right: 2px; -} diff --git a/django/staticfiles/admin/css/vendor/select2/LICENSE-SELECT2.md b/django/staticfiles/admin/css/vendor/select2/LICENSE-SELECT2.md deleted file mode 100644 index 8cb8a2b1..00000000 --- a/django/staticfiles/admin/css/vendor/select2/LICENSE-SELECT2.md +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2012-2017 Kevin Brown, Igor Vaynberg, and Select2 contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. diff --git a/django/staticfiles/admin/css/vendor/select2/select2.css b/django/staticfiles/admin/css/vendor/select2/select2.css deleted file mode 100644 index 750b3207..00000000 --- a/django/staticfiles/admin/css/vendor/select2/select2.css +++ /dev/null @@ -1,481 +0,0 @@ -.select2-container { - box-sizing: border-box; - display: inline-block; - margin: 0; - position: relative; - vertical-align: middle; } - .select2-container .select2-selection--single { - box-sizing: border-box; - cursor: pointer; - display: block; - height: 28px; - user-select: none; - -webkit-user-select: none; } - .select2-container .select2-selection--single .select2-selection__rendered { - display: block; - padding-left: 8px; - padding-right: 20px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; } - .select2-container .select2-selection--single .select2-selection__clear { - position: relative; } - .select2-container[dir="rtl"] .select2-selection--single .select2-selection__rendered { - padding-right: 8px; - padding-left: 20px; } - .select2-container .select2-selection--multiple { - box-sizing: border-box; - cursor: pointer; - display: block; - min-height: 32px; - user-select: none; - -webkit-user-select: none; } - .select2-container .select2-selection--multiple .select2-selection__rendered { - display: inline-block; - overflow: hidden; - padding-left: 8px; - text-overflow: ellipsis; - white-space: nowrap; } - .select2-container .select2-search--inline { - float: left; } - .select2-container .select2-search--inline .select2-search__field { - box-sizing: border-box; - border: none; - font-size: 100%; - margin-top: 5px; - padding: 0; } - .select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button { - -webkit-appearance: none; } - -.select2-dropdown { - background-color: white; - border: 1px solid #aaa; - border-radius: 4px; - box-sizing: border-box; - display: block; - position: absolute; - left: -100000px; - width: 100%; - z-index: 1051; } - -.select2-results { - display: block; } - -.select2-results__options { - list-style: none; - margin: 0; - padding: 0; } - -.select2-results__option { - padding: 6px; - user-select: none; - -webkit-user-select: none; } - .select2-results__option[aria-selected] { - cursor: pointer; } - -.select2-container--open .select2-dropdown { - left: 0; } - -.select2-container--open .select2-dropdown--above { - border-bottom: none; - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; } - -.select2-container--open .select2-dropdown--below { - border-top: none; - border-top-left-radius: 0; - border-top-right-radius: 0; } - -.select2-search--dropdown { - display: block; - padding: 4px; } - .select2-search--dropdown .select2-search__field { - padding: 4px; - width: 100%; - box-sizing: border-box; } - .select2-search--dropdown .select2-search__field::-webkit-search-cancel-button { - -webkit-appearance: none; } - .select2-search--dropdown.select2-search--hide { - display: none; } - -.select2-close-mask { - border: 0; - margin: 0; - padding: 0; - display: block; - position: fixed; - left: 0; - top: 0; - min-height: 100%; - min-width: 100%; - height: auto; - width: auto; - opacity: 0; - z-index: 99; - background-color: #fff; - filter: alpha(opacity=0); } - -.select2-hidden-accessible { - border: 0 !important; - clip: rect(0 0 0 0) !important; - -webkit-clip-path: inset(50%) !important; - clip-path: inset(50%) !important; - height: 1px !important; - overflow: hidden !important; - padding: 0 !important; - position: absolute !important; - width: 1px !important; - white-space: nowrap !important; } - -.select2-container--default .select2-selection--single { - background-color: #fff; - border: 1px solid #aaa; - border-radius: 4px; } - .select2-container--default .select2-selection--single .select2-selection__rendered { - color: #444; - line-height: 28px; } - .select2-container--default .select2-selection--single .select2-selection__clear { - cursor: pointer; - float: right; - font-weight: bold; } - .select2-container--default .select2-selection--single .select2-selection__placeholder { - color: #999; } - .select2-container--default .select2-selection--single .select2-selection__arrow { - height: 26px; - position: absolute; - top: 1px; - right: 1px; - width: 20px; } - .select2-container--default .select2-selection--single .select2-selection__arrow b { - border-color: #888 transparent transparent transparent; - border-style: solid; - border-width: 5px 4px 0 4px; - height: 0; - left: 50%; - margin-left: -4px; - margin-top: -2px; - position: absolute; - top: 50%; - width: 0; } - -.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__clear { - float: left; } - -.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__arrow { - left: 1px; - right: auto; } - -.select2-container--default.select2-container--disabled .select2-selection--single { - background-color: #eee; - cursor: default; } - .select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear { - display: none; } - -.select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b { - border-color: transparent transparent #888 transparent; - border-width: 0 4px 5px 4px; } - -.select2-container--default .select2-selection--multiple { - background-color: white; - border: 1px solid #aaa; - border-radius: 4px; - cursor: text; } - .select2-container--default .select2-selection--multiple .select2-selection__rendered { - box-sizing: border-box; - list-style: none; - margin: 0; - padding: 0 5px; - width: 100%; } - .select2-container--default .select2-selection--multiple .select2-selection__rendered li { - list-style: none; } - .select2-container--default .select2-selection--multiple .select2-selection__clear { - cursor: pointer; - float: right; - font-weight: bold; - margin-top: 5px; - margin-right: 10px; - padding: 1px; } - .select2-container--default .select2-selection--multiple .select2-selection__choice { - background-color: #e4e4e4; - border: 1px solid #aaa; - border-radius: 4px; - cursor: default; - float: left; - margin-right: 5px; - margin-top: 5px; - padding: 0 5px; } - .select2-container--default .select2-selection--multiple .select2-selection__choice__remove { - color: #999; - cursor: pointer; - display: inline-block; - font-weight: bold; - margin-right: 2px; } - .select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover { - color: #333; } - -.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice, .select2-container--default[dir="rtl"] .select2-selection--multiple .select2-search--inline { - float: right; } - -.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice { - margin-left: 5px; - margin-right: auto; } - -.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove { - margin-left: 2px; - margin-right: auto; } - -.select2-container--default.select2-container--focus .select2-selection--multiple { - border: solid black 1px; - outline: 0; } - -.select2-container--default.select2-container--disabled .select2-selection--multiple { - background-color: #eee; - cursor: default; } - -.select2-container--default.select2-container--disabled .select2-selection__choice__remove { - display: none; } - -.select2-container--default.select2-container--open.select2-container--above .select2-selection--single, .select2-container--default.select2-container--open.select2-container--above .select2-selection--multiple { - border-top-left-radius: 0; - border-top-right-radius: 0; } - -.select2-container--default.select2-container--open.select2-container--below .select2-selection--single, .select2-container--default.select2-container--open.select2-container--below .select2-selection--multiple { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; } - -.select2-container--default .select2-search--dropdown .select2-search__field { - border: 1px solid #aaa; } - -.select2-container--default .select2-search--inline .select2-search__field { - background: transparent; - border: none; - outline: 0; - box-shadow: none; - -webkit-appearance: textfield; } - -.select2-container--default .select2-results > .select2-results__options { - max-height: 200px; - overflow-y: auto; } - -.select2-container--default .select2-results__option[role=group] { - padding: 0; } - -.select2-container--default .select2-results__option[aria-disabled=true] { - color: #999; } - -.select2-container--default .select2-results__option[aria-selected=true] { - background-color: #ddd; } - -.select2-container--default .select2-results__option .select2-results__option { - padding-left: 1em; } - .select2-container--default .select2-results__option .select2-results__option .select2-results__group { - padding-left: 0; } - .select2-container--default .select2-results__option .select2-results__option .select2-results__option { - margin-left: -1em; - padding-left: 2em; } - .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option { - margin-left: -2em; - padding-left: 3em; } - .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { - margin-left: -3em; - padding-left: 4em; } - .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { - margin-left: -4em; - padding-left: 5em; } - .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { - margin-left: -5em; - padding-left: 6em; } - -.select2-container--default .select2-results__option--highlighted[aria-selected] { - background-color: #5897fb; - color: white; } - -.select2-container--default .select2-results__group { - cursor: default; - display: block; - padding: 6px; } - -.select2-container--classic .select2-selection--single { - background-color: #f7f7f7; - border: 1px solid #aaa; - border-radius: 4px; - outline: 0; - background-image: -webkit-linear-gradient(top, white 50%, #eeeeee 100%); - background-image: -o-linear-gradient(top, white 50%, #eeeeee 100%); - background-image: linear-gradient(to bottom, white 50%, #eeeeee 100%); - background-repeat: repeat-x; - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0); } - .select2-container--classic .select2-selection--single:focus { - border: 1px solid #5897fb; } - .select2-container--classic .select2-selection--single .select2-selection__rendered { - color: #444; - line-height: 28px; } - .select2-container--classic .select2-selection--single .select2-selection__clear { - cursor: pointer; - float: right; - font-weight: bold; - margin-right: 10px; } - .select2-container--classic .select2-selection--single .select2-selection__placeholder { - color: #999; } - .select2-container--classic .select2-selection--single .select2-selection__arrow { - background-color: #ddd; - border: none; - border-left: 1px solid #aaa; - border-top-right-radius: 4px; - border-bottom-right-radius: 4px; - height: 26px; - position: absolute; - top: 1px; - right: 1px; - width: 20px; - background-image: -webkit-linear-gradient(top, #eeeeee 50%, #cccccc 100%); - background-image: -o-linear-gradient(top, #eeeeee 50%, #cccccc 100%); - background-image: linear-gradient(to bottom, #eeeeee 50%, #cccccc 100%); - background-repeat: repeat-x; - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0); } - .select2-container--classic .select2-selection--single .select2-selection__arrow b { - border-color: #888 transparent transparent transparent; - border-style: solid; - border-width: 5px 4px 0 4px; - height: 0; - left: 50%; - margin-left: -4px; - margin-top: -2px; - position: absolute; - top: 50%; - width: 0; } - -.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__clear { - float: left; } - -.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow { - border: none; - border-right: 1px solid #aaa; - border-radius: 0; - border-top-left-radius: 4px; - border-bottom-left-radius: 4px; - left: 1px; - right: auto; } - -.select2-container--classic.select2-container--open .select2-selection--single { - border: 1px solid #5897fb; } - .select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow { - background: transparent; - border: none; } - .select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b { - border-color: transparent transparent #888 transparent; - border-width: 0 4px 5px 4px; } - -.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single { - border-top: none; - border-top-left-radius: 0; - border-top-right-radius: 0; - background-image: -webkit-linear-gradient(top, white 0%, #eeeeee 50%); - background-image: -o-linear-gradient(top, white 0%, #eeeeee 50%); - background-image: linear-gradient(to bottom, white 0%, #eeeeee 50%); - background-repeat: repeat-x; - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0); } - -.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single { - border-bottom: none; - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - background-image: -webkit-linear-gradient(top, #eeeeee 50%, white 100%); - background-image: -o-linear-gradient(top, #eeeeee 50%, white 100%); - background-image: linear-gradient(to bottom, #eeeeee 50%, white 100%); - background-repeat: repeat-x; - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0); } - -.select2-container--classic .select2-selection--multiple { - background-color: white; - border: 1px solid #aaa; - border-radius: 4px; - cursor: text; - outline: 0; } - .select2-container--classic .select2-selection--multiple:focus { - border: 1px solid #5897fb; } - .select2-container--classic .select2-selection--multiple .select2-selection__rendered { - list-style: none; - margin: 0; - padding: 0 5px; } - .select2-container--classic .select2-selection--multiple .select2-selection__clear { - display: none; } - .select2-container--classic .select2-selection--multiple .select2-selection__choice { - background-color: #e4e4e4; - border: 1px solid #aaa; - border-radius: 4px; - cursor: default; - float: left; - margin-right: 5px; - margin-top: 5px; - padding: 0 5px; } - .select2-container--classic .select2-selection--multiple .select2-selection__choice__remove { - color: #888; - cursor: pointer; - display: inline-block; - font-weight: bold; - margin-right: 2px; } - .select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover { - color: #555; } - -.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice { - float: right; - margin-left: 5px; - margin-right: auto; } - -.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove { - margin-left: 2px; - margin-right: auto; } - -.select2-container--classic.select2-container--open .select2-selection--multiple { - border: 1px solid #5897fb; } - -.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple { - border-top: none; - border-top-left-radius: 0; - border-top-right-radius: 0; } - -.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple { - border-bottom: none; - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; } - -.select2-container--classic .select2-search--dropdown .select2-search__field { - border: 1px solid #aaa; - outline: 0; } - -.select2-container--classic .select2-search--inline .select2-search__field { - outline: 0; - box-shadow: none; } - -.select2-container--classic .select2-dropdown { - background-color: white; - border: 1px solid transparent; } - -.select2-container--classic .select2-dropdown--above { - border-bottom: none; } - -.select2-container--classic .select2-dropdown--below { - border-top: none; } - -.select2-container--classic .select2-results > .select2-results__options { - max-height: 200px; - overflow-y: auto; } - -.select2-container--classic .select2-results__option[role=group] { - padding: 0; } - -.select2-container--classic .select2-results__option[aria-disabled=true] { - color: grey; } - -.select2-container--classic .select2-results__option--highlighted[aria-selected] { - background-color: #3875d7; - color: white; } - -.select2-container--classic .select2-results__group { - cursor: default; - display: block; - padding: 6px; } - -.select2-container--classic.select2-container--open .select2-dropdown { - border-color: #5897fb; } diff --git a/django/staticfiles/admin/css/vendor/select2/select2.min.css b/django/staticfiles/admin/css/vendor/select2/select2.min.css deleted file mode 100644 index 7c18ad59..00000000 --- a/django/staticfiles/admin/css/vendor/select2/select2.min.css +++ /dev/null @@ -1 +0,0 @@ -.select2-container{box-sizing:border-box;display:inline-block;margin:0;position:relative;vertical-align:middle}.select2-container .select2-selection--single{box-sizing:border-box;cursor:pointer;display:block;height:28px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--single .select2-selection__rendered{display:block;padding-left:8px;padding-right:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-selection--single .select2-selection__clear{position:relative}.select2-container[dir="rtl"] .select2-selection--single .select2-selection__rendered{padding-right:8px;padding-left:20px}.select2-container .select2-selection--multiple{box-sizing:border-box;cursor:pointer;display:block;min-height:32px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--multiple .select2-selection__rendered{display:inline-block;overflow:hidden;padding-left:8px;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-search--inline{float:left}.select2-container .select2-search--inline .select2-search__field{box-sizing:border-box;border:none;font-size:100%;margin-top:5px;padding:0}.select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-dropdown{background-color:white;border:1px solid #aaa;border-radius:4px;box-sizing:border-box;display:block;position:absolute;left:-100000px;width:100%;z-index:1051}.select2-results{display:block}.select2-results__options{list-style:none;margin:0;padding:0}.select2-results__option{padding:6px;user-select:none;-webkit-user-select:none}.select2-results__option[aria-selected]{cursor:pointer}.select2-container--open .select2-dropdown{left:0}.select2-container--open .select2-dropdown--above{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--open .select2-dropdown--below{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-search--dropdown{display:block;padding:4px}.select2-search--dropdown .select2-search__field{padding:4px;width:100%;box-sizing:border-box}.select2-search--dropdown .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-search--dropdown.select2-search--hide{display:none}.select2-close-mask{border:0;margin:0;padding:0;display:block;position:fixed;left:0;top:0;min-height:100%;min-width:100%;height:auto;width:auto;opacity:0;z-index:99;background-color:#fff;filter:alpha(opacity=0)}.select2-hidden-accessible{border:0 !important;clip:rect(0 0 0 0) !important;-webkit-clip-path:inset(50%) !important;clip-path:inset(50%) !important;height:1px !important;overflow:hidden !important;padding:0 !important;position:absolute !important;width:1px !important;white-space:nowrap !important}.select2-container--default .select2-selection--single{background-color:#fff;border:1px solid #aaa;border-radius:4px}.select2-container--default .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--default .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold}.select2-container--default .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--default .select2-selection--single .select2-selection__arrow{height:26px;position:absolute;top:1px;right:1px;width:20px}.select2-container--default .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__arrow{left:1px;right:auto}.select2-container--default.select2-container--disabled .select2-selection--single{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear{display:none}.select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--default .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text}.select2-container--default .select2-selection--multiple .select2-selection__rendered{box-sizing:border-box;list-style:none;margin:0;padding:0 5px;width:100%}.select2-container--default .select2-selection--multiple .select2-selection__rendered li{list-style:none}.select2-container--default .select2-selection--multiple .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-top:5px;margin-right:10px;padding:1px}.select2-container--default .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove{color:#999;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover{color:#333}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice,.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-search--inline{float:right}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice{margin-left:5px;margin-right:auto}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--default.select2-container--focus .select2-selection--multiple{border:solid black 1px;outline:0}.select2-container--default.select2-container--disabled .select2-selection--multiple{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection__choice__remove{display:none}.select2-container--default.select2-container--open.select2-container--above .select2-selection--single,.select2-container--default.select2-container--open.select2-container--above .select2-selection--multiple{border-top-left-radius:0;border-top-right-radius:0}.select2-container--default.select2-container--open.select2-container--below .select2-selection--single,.select2-container--default.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--default .select2-search--dropdown .select2-search__field{border:1px solid #aaa}.select2-container--default .select2-search--inline .select2-search__field{background:transparent;border:none;outline:0;box-shadow:none;-webkit-appearance:textfield}.select2-container--default .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--default .select2-results__option[role=group]{padding:0}.select2-container--default .select2-results__option[aria-disabled=true]{color:#999}.select2-container--default .select2-results__option[aria-selected=true]{background-color:#ddd}.select2-container--default .select2-results__option .select2-results__option{padding-left:1em}.select2-container--default .select2-results__option .select2-results__option .select2-results__group{padding-left:0}.select2-container--default .select2-results__option .select2-results__option .select2-results__option{margin-left:-1em;padding-left:2em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-2em;padding-left:3em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-3em;padding-left:4em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-4em;padding-left:5em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-5em;padding-left:6em}.select2-container--default .select2-results__option--highlighted[aria-selected]{background-color:#5897fb;color:white}.select2-container--default .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic .select2-selection--single{background-color:#f7f7f7;border:1px solid #aaa;border-radius:4px;outline:0;background-image:-webkit-linear-gradient(top, #fff 50%, #eee 100%);background-image:-o-linear-gradient(top, #fff 50%, #eee 100%);background-image:linear-gradient(to bottom, #fff 50%, #eee 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic .select2-selection--single:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--classic .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-right:10px}.select2-container--classic .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--classic .select2-selection--single .select2-selection__arrow{background-color:#ddd;border:none;border-left:1px solid #aaa;border-top-right-radius:4px;border-bottom-right-radius:4px;height:26px;position:absolute;top:1px;right:1px;width:20px;background-image:-webkit-linear-gradient(top, #eee 50%, #ccc 100%);background-image:-o-linear-gradient(top, #eee 50%, #ccc 100%);background-image:linear-gradient(to bottom, #eee 50%, #ccc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0)}.select2-container--classic .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow{border:none;border-right:1px solid #aaa;border-radius:0;border-top-left-radius:4px;border-bottom-left-radius:4px;left:1px;right:auto}.select2-container--classic.select2-container--open .select2-selection--single{border:1px solid #5897fb}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow{background:transparent;border:none}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single{border-top:none;border-top-left-radius:0;border-top-right-radius:0;background-image:-webkit-linear-gradient(top, #fff 0%, #eee 50%);background-image:-o-linear-gradient(top, #fff 0%, #eee 50%);background-image:linear-gradient(to bottom, #fff 0%, #eee 50%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0;background-image:-webkit-linear-gradient(top, #eee 50%, #fff 100%);background-image:-o-linear-gradient(top, #eee 50%, #fff 100%);background-image:linear-gradient(to bottom, #eee 50%, #fff 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0)}.select2-container--classic .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text;outline:0}.select2-container--classic .select2-selection--multiple:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--multiple .select2-selection__rendered{list-style:none;margin:0;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__clear{display:none}.select2-container--classic .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove{color:#888;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover{color:#555}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice{float:right;margin-left:5px;margin-right:auto}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--classic.select2-container--open .select2-selection--multiple{border:1px solid #5897fb}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--classic .select2-search--dropdown .select2-search__field{border:1px solid #aaa;outline:0}.select2-container--classic .select2-search--inline .select2-search__field{outline:0;box-shadow:none}.select2-container--classic .select2-dropdown{background-color:#fff;border:1px solid transparent}.select2-container--classic .select2-dropdown--above{border-bottom:none}.select2-container--classic .select2-dropdown--below{border-top:none}.select2-container--classic .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--classic .select2-results__option[role=group]{padding:0}.select2-container--classic .select2-results__option[aria-disabled=true]{color:grey}.select2-container--classic .select2-results__option--highlighted[aria-selected]{background-color:#3875d7;color:#fff}.select2-container--classic .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic.select2-container--open .select2-dropdown{border-color:#5897fb} diff --git a/django/staticfiles/admin/css/widgets.css b/django/staticfiles/admin/css/widgets.css deleted file mode 100644 index 1104e8b1..00000000 --- a/django/staticfiles/admin/css/widgets.css +++ /dev/null @@ -1,604 +0,0 @@ -/* SELECTOR (FILTER INTERFACE) */ - -.selector { - width: 800px; - float: left; - display: flex; -} - -.selector select { - width: 380px; - height: 17.2em; - flex: 1 0 auto; -} - -.selector-available, .selector-chosen { - width: 380px; - text-align: center; - margin-bottom: 5px; - display: flex; - flex-direction: column; -} - -.selector-available h2, .selector-chosen h2 { - border: 1px solid var(--border-color); - border-radius: 4px 4px 0 0; -} - -.selector-chosen .list-footer-display { - border: 1px solid var(--border-color); - border-top: none; - border-radius: 0 0 4px 4px; - margin: 0 0 10px; - padding: 8px; - text-align: center; - background: var(--primary); - color: var(--header-link-color); - cursor: pointer; -} -.selector-chosen .list-footer-display__clear { - color: var(--breadcrumbs-fg); -} - -.selector-chosen h2 { - background: var(--primary); - color: var(--header-link-color); -} - -.selector .selector-available h2 { - background: var(--darkened-bg); - color: var(--body-quiet-color); -} - -.selector .selector-filter { - border: 1px solid var(--border-color); - border-width: 0 1px; - padding: 8px; - color: var(--body-quiet-color); - font-size: 0.625rem; - margin: 0; - text-align: left; -} - -.selector .selector-filter label, -.inline-group .aligned .selector .selector-filter label { - float: left; - margin: 7px 0 0; - width: 18px; - height: 18px; - padding: 0; - overflow: hidden; - line-height: 1; - min-width: auto; -} - -.selector .selector-available input, -.selector .selector-chosen input { - width: 320px; - margin-left: 8px; -} - -.selector ul.selector-chooser { - align-self: center; - width: 22px; - background-color: var(--selected-bg); - border-radius: 10px; - margin: 0 5px; - padding: 0; - transform: translateY(-17px); -} - -.selector-chooser li { - margin: 0; - padding: 3px; - list-style-type: none; -} - -.selector select { - padding: 0 10px; - margin: 0 0 10px; - border-radius: 0 0 4px 4px; -} -.selector .selector-chosen--with-filtered select { - margin: 0; - border-radius: 0; - height: 14em; -} - -.selector .selector-chosen:not(.selector-chosen--with-filtered) .list-footer-display { - display: none; -} - -.selector-add, .selector-remove { - width: 16px; - height: 16px; - display: block; - text-indent: -3000px; - overflow: hidden; - cursor: default; - opacity: 0.55; -} - -.active.selector-add, .active.selector-remove { - opacity: 1; -} - -.active.selector-add:hover, .active.selector-remove:hover { - cursor: pointer; -} - -.selector-add { - background: url(../img/selector-icons.svg) 0 -96px no-repeat; -} - -.active.selector-add:focus, .active.selector-add:hover { - background-position: 0 -112px; -} - -.selector-remove { - background: url(../img/selector-icons.svg) 0 -64px no-repeat; -} - -.active.selector-remove:focus, .active.selector-remove:hover { - background-position: 0 -80px; -} - -a.selector-chooseall, a.selector-clearall { - display: inline-block; - height: 16px; - text-align: left; - margin: 1px auto 3px; - overflow: hidden; - font-weight: bold; - line-height: 16px; - color: var(--body-quiet-color); - text-decoration: none; - opacity: 0.55; -} - -a.active.selector-chooseall:focus, a.active.selector-clearall:focus, -a.active.selector-chooseall:hover, a.active.selector-clearall:hover { - color: var(--link-fg); -} - -a.active.selector-chooseall, a.active.selector-clearall { - opacity: 1; -} - -a.active.selector-chooseall:hover, a.active.selector-clearall:hover { - cursor: pointer; -} - -a.selector-chooseall { - padding: 0 18px 0 0; - background: url(../img/selector-icons.svg) right -160px no-repeat; - cursor: default; -} - -a.active.selector-chooseall:focus, a.active.selector-chooseall:hover { - background-position: 100% -176px; -} - -a.selector-clearall { - padding: 0 0 0 18px; - background: url(../img/selector-icons.svg) 0 -128px no-repeat; - cursor: default; -} - -a.active.selector-clearall:focus, a.active.selector-clearall:hover { - background-position: 0 -144px; -} - -/* STACKED SELECTORS */ - -.stacked { - float: left; - width: 490px; - display: block; -} - -.stacked select { - width: 480px; - height: 10.1em; -} - -.stacked .selector-available, .stacked .selector-chosen { - width: 480px; -} - -.stacked .selector-available { - margin-bottom: 0; -} - -.stacked .selector-available input { - width: 422px; -} - -.stacked ul.selector-chooser { - height: 22px; - width: 50px; - margin: 0 0 10px 40%; - background-color: #eee; - border-radius: 10px; - transform: none; -} - -.stacked .selector-chooser li { - float: left; - padding: 3px 3px 3px 5px; -} - -.stacked .selector-chooseall, .stacked .selector-clearall { - display: none; -} - -.stacked .selector-add { - background: url(../img/selector-icons.svg) 0 -32px no-repeat; - cursor: default; -} - -.stacked .active.selector-add { - background-position: 0 -32px; - cursor: pointer; -} - -.stacked .active.selector-add:focus, .stacked .active.selector-add:hover { - background-position: 0 -48px; - cursor: pointer; -} - -.stacked .selector-remove { - background: url(../img/selector-icons.svg) 0 0 no-repeat; - cursor: default; -} - -.stacked .active.selector-remove { - background-position: 0 0px; - cursor: pointer; -} - -.stacked .active.selector-remove:focus, .stacked .active.selector-remove:hover { - background-position: 0 -16px; - cursor: pointer; -} - -.selector .help-icon { - background: url(../img/icon-unknown.svg) 0 0 no-repeat; - display: inline-block; - vertical-align: middle; - margin: -2px 0 0 2px; - width: 13px; - height: 13px; -} - -.selector .selector-chosen .help-icon { - background: url(../img/icon-unknown-alt.svg) 0 0 no-repeat; -} - -.selector .search-label-icon { - background: url(../img/search.svg) 0 0 no-repeat; - display: inline-block; - height: 1.125rem; - width: 1.125rem; -} - -/* DATE AND TIME */ - -p.datetime { - line-height: 20px; - margin: 0; - padding: 0; - color: var(--body-quiet-color); - font-weight: bold; -} - -.datetime span { - white-space: nowrap; - font-weight: normal; - font-size: 0.6875rem; - color: var(--body-quiet-color); -} - -.datetime input, .form-row .datetime input.vDateField, .form-row .datetime input.vTimeField { - margin-left: 5px; - margin-bottom: 4px; -} - -table p.datetime { - font-size: 0.6875rem; - margin-left: 0; - padding-left: 0; -} - -.datetimeshortcuts .clock-icon, .datetimeshortcuts .date-icon { - position: relative; - display: inline-block; - vertical-align: middle; - height: 16px; - width: 16px; - overflow: hidden; -} - -.datetimeshortcuts .clock-icon { - background: url(../img/icon-clock.svg) 0 0 no-repeat; -} - -.datetimeshortcuts a:focus .clock-icon, -.datetimeshortcuts a:hover .clock-icon { - background-position: 0 -16px; -} - -.datetimeshortcuts .date-icon { - background: url(../img/icon-calendar.svg) 0 0 no-repeat; - top: -1px; -} - -.datetimeshortcuts a:focus .date-icon, -.datetimeshortcuts a:hover .date-icon { - background-position: 0 -16px; -} - -.timezonewarning { - font-size: 0.6875rem; - color: var(--body-quiet-color); -} - -/* URL */ - -p.url { - line-height: 20px; - margin: 0; - padding: 0; - color: var(--body-quiet-color); - font-size: 0.6875rem; - font-weight: bold; -} - -.url a { - font-weight: normal; -} - -/* FILE UPLOADS */ - -p.file-upload { - line-height: 20px; - margin: 0; - padding: 0; - color: var(--body-quiet-color); - font-size: 0.6875rem; - font-weight: bold; -} - -.file-upload a { - font-weight: normal; -} - -.file-upload .deletelink { - margin-left: 5px; -} - -span.clearable-file-input label { - color: var(--body-fg); - font-size: 0.6875rem; - display: inline; - float: none; -} - -/* CALENDARS & CLOCKS */ - -.calendarbox, .clockbox { - margin: 5px auto; - font-size: 0.75rem; - width: 19em; - text-align: center; - background: var(--body-bg); - color: var(--body-fg); - border: 1px solid var(--hairline-color); - border-radius: 4px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); - overflow: hidden; - position: relative; -} - -.clockbox { - width: auto; -} - -.calendar { - margin: 0; - padding: 0; -} - -.calendar table { - margin: 0; - padding: 0; - border-collapse: collapse; - background: white; - width: 100%; -} - -.calendar caption, .calendarbox h2 { - margin: 0; - text-align: center; - border-top: none; - font-weight: 700; - font-size: 0.75rem; - color: #333; - background: var(--accent); -} - -.calendar th { - padding: 8px 5px; - background: var(--darkened-bg); - border-bottom: 1px solid var(--border-color); - font-weight: 400; - font-size: 0.75rem; - text-align: center; - color: var(--body-quiet-color); -} - -.calendar td { - font-weight: 400; - font-size: 0.75rem; - text-align: center; - padding: 0; - border-top: 1px solid var(--hairline-color); - border-bottom: none; -} - -.calendar td.selected a { - background: var(--primary); - color: var(--button-fg); -} - -.calendar td.nonday { - background: var(--darkened-bg); -} - -.calendar td.today a { - font-weight: 700; -} - -.calendar td a, .timelist a { - display: block; - font-weight: 400; - padding: 6px; - text-decoration: none; - color: var(--body-quiet-color); -} - -.calendar td a:focus, .timelist a:focus, -.calendar td a:hover, .timelist a:hover { - background: var(--primary); - color: white; -} - -.calendar td a:active, .timelist a:active { - background: var(--header-bg); - color: white; -} - -.calendarnav { - font-size: 0.625rem; - text-align: center; - color: #ccc; - margin: 0; - padding: 1px 3px; -} - -.calendarnav a:link, #calendarnav a:visited, -#calendarnav a:focus, #calendarnav a:hover { - color: var(--body-quiet-color); -} - -.calendar-shortcuts { - background: var(--body-bg); - color: var(--body-quiet-color); - font-size: 0.6875rem; - line-height: 0.6875rem; - border-top: 1px solid var(--hairline-color); - padding: 8px 0; -} - -.calendarbox .calendarnav-previous, .calendarbox .calendarnav-next { - display: block; - position: absolute; - top: 8px; - width: 15px; - height: 15px; - text-indent: -9999px; - padding: 0; -} - -.calendarnav-previous { - left: 10px; - background: url(../img/calendar-icons.svg) 0 0 no-repeat; -} - -.calendarbox .calendarnav-previous:focus, -.calendarbox .calendarnav-previous:hover { - background-position: 0 -15px; -} - -.calendarnav-next { - right: 10px; - background: url(../img/calendar-icons.svg) 0 -30px no-repeat; -} - -.calendarbox .calendarnav-next:focus, -.calendarbox .calendarnav-next:hover { - background-position: 0 -45px; -} - -.calendar-cancel { - margin: 0; - padding: 4px 0; - font-size: 0.75rem; - background: #eee; - border-top: 1px solid var(--border-color); - color: var(--body-fg); -} - -.calendar-cancel:focus, .calendar-cancel:hover { - background: #ddd; -} - -.calendar-cancel a { - color: black; - display: block; -} - -ul.timelist, .timelist li { - list-style-type: none; - margin: 0; - padding: 0; -} - -.timelist a { - padding: 2px; -} - -/* EDIT INLINE */ - -.inline-deletelink { - float: right; - text-indent: -9999px; - background: url(../img/inline-delete.svg) 0 0 no-repeat; - width: 16px; - height: 16px; - border: 0px none; -} - -.inline-deletelink:focus, .inline-deletelink:hover { - cursor: pointer; -} - -/* RELATED WIDGET WRAPPER */ -.related-widget-wrapper { - float: left; /* display properly in form rows with multiple fields */ - overflow: hidden; /* clear floated contents */ -} - -.related-widget-wrapper-link { - opacity: 0.3; -} - -.related-widget-wrapper-link:link { - opacity: .8; -} - -.related-widget-wrapper-link:link:focus, -.related-widget-wrapper-link:link:hover { - opacity: 1; -} - -select + .related-widget-wrapper-link, -.related-widget-wrapper-link + .related-widget-wrapper-link { - margin-left: 7px; -} - -/* GIS MAPS */ -.dj_map { - width: 600px; - height: 400px; -} diff --git a/django/staticfiles/admin/img/LICENSE b/django/staticfiles/admin/img/LICENSE deleted file mode 100644 index a4faaa1d..00000000 --- a/django/staticfiles/admin/img/LICENSE +++ /dev/null @@ -1,20 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2014 Code Charm Ltd - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/django/staticfiles/admin/img/README.txt b/django/staticfiles/admin/img/README.txt deleted file mode 100644 index 4eb2e492..00000000 --- a/django/staticfiles/admin/img/README.txt +++ /dev/null @@ -1,7 +0,0 @@ -All icons are taken from Font Awesome (http://fontawesome.io/) project. -The Font Awesome font is licensed under the SIL OFL 1.1: -- https://scripts.sil.org/OFL - -SVG icons source: https://github.com/encharm/Font-Awesome-SVG-PNG -Font-Awesome-SVG-PNG is licensed under the MIT license (see file license -in current folder). diff --git a/django/staticfiles/admin/img/calendar-icons.svg b/django/staticfiles/admin/img/calendar-icons.svg deleted file mode 100644 index dbf21c39..00000000 --- a/django/staticfiles/admin/img/calendar-icons.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/django/staticfiles/admin/img/gis/move_vertex_off.svg b/django/staticfiles/admin/img/gis/move_vertex_off.svg deleted file mode 100644 index 228854f3..00000000 --- a/django/staticfiles/admin/img/gis/move_vertex_off.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/django/staticfiles/admin/img/gis/move_vertex_on.svg b/django/staticfiles/admin/img/gis/move_vertex_on.svg deleted file mode 100644 index 96b87fdd..00000000 --- a/django/staticfiles/admin/img/gis/move_vertex_on.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/django/staticfiles/admin/img/icon-addlink.svg b/django/staticfiles/admin/img/icon-addlink.svg deleted file mode 100644 index e004fb16..00000000 --- a/django/staticfiles/admin/img/icon-addlink.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/django/staticfiles/admin/img/icon-alert.svg b/django/staticfiles/admin/img/icon-alert.svg deleted file mode 100644 index e51ea83f..00000000 --- a/django/staticfiles/admin/img/icon-alert.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/django/staticfiles/admin/img/icon-calendar.svg b/django/staticfiles/admin/img/icon-calendar.svg deleted file mode 100644 index 97910a99..00000000 --- a/django/staticfiles/admin/img/icon-calendar.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/django/staticfiles/admin/img/icon-changelink.svg b/django/staticfiles/admin/img/icon-changelink.svg deleted file mode 100644 index bbb137aa..00000000 --- a/django/staticfiles/admin/img/icon-changelink.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/django/staticfiles/admin/img/icon-clock.svg b/django/staticfiles/admin/img/icon-clock.svg deleted file mode 100644 index bf9985d3..00000000 --- a/django/staticfiles/admin/img/icon-clock.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/django/staticfiles/admin/img/icon-deletelink.svg b/django/staticfiles/admin/img/icon-deletelink.svg deleted file mode 100644 index 4059b155..00000000 --- a/django/staticfiles/admin/img/icon-deletelink.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/django/staticfiles/admin/img/icon-no.svg b/django/staticfiles/admin/img/icon-no.svg deleted file mode 100644 index 2e0d3832..00000000 --- a/django/staticfiles/admin/img/icon-no.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/django/staticfiles/admin/img/icon-unknown-alt.svg b/django/staticfiles/admin/img/icon-unknown-alt.svg deleted file mode 100644 index 1c6b99fc..00000000 --- a/django/staticfiles/admin/img/icon-unknown-alt.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/django/staticfiles/admin/img/icon-unknown.svg b/django/staticfiles/admin/img/icon-unknown.svg deleted file mode 100644 index 50b4f972..00000000 --- a/django/staticfiles/admin/img/icon-unknown.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/django/staticfiles/admin/img/icon-viewlink.svg b/django/staticfiles/admin/img/icon-viewlink.svg deleted file mode 100644 index a1ca1d3f..00000000 --- a/django/staticfiles/admin/img/icon-viewlink.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/django/staticfiles/admin/img/icon-yes.svg b/django/staticfiles/admin/img/icon-yes.svg deleted file mode 100644 index 5883d877..00000000 --- a/django/staticfiles/admin/img/icon-yes.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/django/staticfiles/admin/img/inline-delete.svg b/django/staticfiles/admin/img/inline-delete.svg deleted file mode 100644 index 17d1ad67..00000000 --- a/django/staticfiles/admin/img/inline-delete.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/django/staticfiles/admin/img/search.svg b/django/staticfiles/admin/img/search.svg deleted file mode 100644 index c8c69b2a..00000000 --- a/django/staticfiles/admin/img/search.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/django/staticfiles/admin/img/selector-icons.svg b/django/staticfiles/admin/img/selector-icons.svg deleted file mode 100644 index 926b8e21..00000000 --- a/django/staticfiles/admin/img/selector-icons.svg +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/django/staticfiles/admin/img/sorting-icons.svg b/django/staticfiles/admin/img/sorting-icons.svg deleted file mode 100644 index 7c31ec91..00000000 --- a/django/staticfiles/admin/img/sorting-icons.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/django/staticfiles/admin/img/tooltag-add.svg b/django/staticfiles/admin/img/tooltag-add.svg deleted file mode 100644 index 1ca64ae5..00000000 --- a/django/staticfiles/admin/img/tooltag-add.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/django/staticfiles/admin/img/tooltag-arrowright.svg b/django/staticfiles/admin/img/tooltag-arrowright.svg deleted file mode 100644 index b664d619..00000000 --- a/django/staticfiles/admin/img/tooltag-arrowright.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/django/staticfiles/admin/js/SelectBox.js b/django/staticfiles/admin/js/SelectBox.js deleted file mode 100644 index 3db4ec7f..00000000 --- a/django/staticfiles/admin/js/SelectBox.js +++ /dev/null @@ -1,116 +0,0 @@ -'use strict'; -{ - const SelectBox = { - cache: {}, - init: function(id) { - const box = document.getElementById(id); - SelectBox.cache[id] = []; - const cache = SelectBox.cache[id]; - for (const node of box.options) { - cache.push({value: node.value, text: node.text, displayed: 1}); - } - }, - redisplay: function(id) { - // Repopulate HTML select box from cache - const box = document.getElementById(id); - const scroll_value_from_top = box.scrollTop; - box.innerHTML = ''; - for (const node of SelectBox.cache[id]) { - if (node.displayed) { - const new_option = new Option(node.text, node.value, false, false); - // Shows a tooltip when hovering over the option - new_option.title = node.text; - box.appendChild(new_option); - } - } - box.scrollTop = scroll_value_from_top; - }, - filter: function(id, text) { - // Redisplay the HTML select box, displaying only the choices containing ALL - // the words in text. (It's an AND search.) - const tokens = text.toLowerCase().split(/\s+/); - for (const node of SelectBox.cache[id]) { - node.displayed = 1; - const node_text = node.text.toLowerCase(); - for (const token of tokens) { - if (!node_text.includes(token)) { - node.displayed = 0; - break; // Once the first token isn't found we're done - } - } - } - SelectBox.redisplay(id); - }, - get_hidden_node_count(id) { - const cache = SelectBox.cache[id] || []; - return cache.filter(node => node.displayed === 0).length; - }, - delete_from_cache: function(id, value) { - let delete_index = null; - const cache = SelectBox.cache[id]; - for (const [i, node] of cache.entries()) { - if (node.value === value) { - delete_index = i; - break; - } - } - cache.splice(delete_index, 1); - }, - add_to_cache: function(id, option) { - SelectBox.cache[id].push({value: option.value, text: option.text, displayed: 1}); - }, - cache_contains: function(id, value) { - // Check if an item is contained in the cache - for (const node of SelectBox.cache[id]) { - if (node.value === value) { - return true; - } - } - return false; - }, - move: function(from, to) { - const from_box = document.getElementById(from); - for (const option of from_box.options) { - const option_value = option.value; - if (option.selected && SelectBox.cache_contains(from, option_value)) { - SelectBox.add_to_cache(to, {value: option_value, text: option.text, displayed: 1}); - SelectBox.delete_from_cache(from, option_value); - } - } - SelectBox.redisplay(from); - SelectBox.redisplay(to); - }, - move_all: function(from, to) { - const from_box = document.getElementById(from); - for (const option of from_box.options) { - const option_value = option.value; - if (SelectBox.cache_contains(from, option_value)) { - SelectBox.add_to_cache(to, {value: option_value, text: option.text, displayed: 1}); - SelectBox.delete_from_cache(from, option_value); - } - } - SelectBox.redisplay(from); - SelectBox.redisplay(to); - }, - sort: function(id) { - SelectBox.cache[id].sort(function(a, b) { - a = a.text.toLowerCase(); - b = b.text.toLowerCase(); - if (a > b) { - return 1; - } - if (a < b) { - return -1; - } - return 0; - } ); - }, - select_all: function(id) { - const box = document.getElementById(id); - for (const option of box.options) { - option.selected = true; - } - } - }; - window.SelectBox = SelectBox; -} diff --git a/django/staticfiles/admin/js/SelectFilter2.js b/django/staticfiles/admin/js/SelectFilter2.js deleted file mode 100644 index 9a4e0a3a..00000000 --- a/django/staticfiles/admin/js/SelectFilter2.js +++ /dev/null @@ -1,283 +0,0 @@ -/*global SelectBox, gettext, interpolate, quickElement, SelectFilter*/ -/* -SelectFilter2 - Turns a multiple-select box into a filter interface. - -Requires core.js and SelectBox.js. -*/ -'use strict'; -{ - window.SelectFilter = { - init: function(field_id, field_name, is_stacked) { - if (field_id.match(/__prefix__/)) { - // Don't initialize on empty forms. - return; - } - const from_box = document.getElementById(field_id); - from_box.id += '_from'; // change its ID - from_box.className = 'filtered'; - - for (const p of from_box.parentNode.getElementsByTagName('p')) { - if (p.classList.contains("info")) { - // Remove

, because it just gets in the way. - from_box.parentNode.removeChild(p); - } else if (p.classList.contains("help")) { - // Move help text up to the top so it isn't below the select - // boxes or wrapped off on the side to the right of the add - // button: - from_box.parentNode.insertBefore(p, from_box.parentNode.firstChild); - } - } - - //

or
- const selector_div = quickElement('div', from_box.parentNode); - selector_div.className = is_stacked ? 'selector stacked' : 'selector'; - - //
- const selector_available = quickElement('div', selector_div); - selector_available.className = 'selector-available'; - const title_available = quickElement('h2', selector_available, interpolate(gettext('Available %s') + ' ', [field_name])); - quickElement( - 'span', title_available, '', - 'class', 'help help-tooltip help-icon', - 'title', interpolate( - gettext( - 'This is the list of available %s. You may choose some by ' + - 'selecting them in the box below and then clicking the ' + - '"Choose" arrow between the two boxes.' - ), - [field_name] - ) - ); - - const filter_p = quickElement('p', selector_available, '', 'id', field_id + '_filter'); - filter_p.className = 'selector-filter'; - - const search_filter_label = quickElement('label', filter_p, '', 'for', field_id + '_input'); - - quickElement( - 'span', search_filter_label, '', - 'class', 'help-tooltip search-label-icon', - 'title', interpolate(gettext("Type into this box to filter down the list of available %s."), [field_name]) - ); - - filter_p.appendChild(document.createTextNode(' ')); - - const filter_input = quickElement('input', filter_p, '', 'type', 'text', 'placeholder', gettext("Filter")); - filter_input.id = field_id + '_input'; - - selector_available.appendChild(from_box); - const choose_all = quickElement('a', selector_available, gettext('Choose all'), 'title', interpolate(gettext('Click to choose all %s at once.'), [field_name]), 'href', '#', 'id', field_id + '_add_all_link'); - choose_all.className = 'selector-chooseall'; - - //
    - const selector_chooser = quickElement('ul', selector_div); - selector_chooser.className = 'selector-chooser'; - const add_link = quickElement('a', quickElement('li', selector_chooser), gettext('Choose'), 'title', gettext('Choose'), 'href', '#', 'id', field_id + '_add_link'); - add_link.className = 'selector-add'; - const remove_link = quickElement('a', quickElement('li', selector_chooser), gettext('Remove'), 'title', gettext('Remove'), 'href', '#', 'id', field_id + '_remove_link'); - remove_link.className = 'selector-remove'; - - //
    - const selector_chosen = quickElement('div', selector_div, '', 'id', field_id + '_selector_chosen'); - selector_chosen.className = 'selector-chosen'; - const title_chosen = quickElement('h2', selector_chosen, interpolate(gettext('Chosen %s') + ' ', [field_name])); - quickElement( - 'span', title_chosen, '', - 'class', 'help help-tooltip help-icon', - 'title', interpolate( - gettext( - 'This is the list of chosen %s. You may remove some by ' + - 'selecting them in the box below and then clicking the ' + - '"Remove" arrow between the two boxes.' - ), - [field_name] - ) - ); - - const filter_selected_p = quickElement('p', selector_chosen, '', 'id', field_id + '_filter_selected'); - filter_selected_p.className = 'selector-filter'; - - const search_filter_selected_label = quickElement('label', filter_selected_p, '', 'for', field_id + '_selected_input'); - - quickElement( - 'span', search_filter_selected_label, '', - 'class', 'help-tooltip search-label-icon', - 'title', interpolate(gettext("Type into this box to filter down the list of selected %s."), [field_name]) - ); - - filter_selected_p.appendChild(document.createTextNode(' ')); - - const filter_selected_input = quickElement('input', filter_selected_p, '', 'type', 'text', 'placeholder', gettext("Filter")); - filter_selected_input.id = field_id + '_selected_input'; - - const to_box = quickElement('select', selector_chosen, '', 'id', field_id + '_to', 'multiple', '', 'size', from_box.size, 'name', from_box.name); - to_box.className = 'filtered'; - - const warning_footer = quickElement('div', selector_chosen, '', 'class', 'list-footer-display'); - quickElement('span', warning_footer, '', 'id', field_id + '_list-footer-display-text'); - quickElement('span', warning_footer, ' (click to clear)', 'class', 'list-footer-display__clear'); - - const clear_all = quickElement('a', selector_chosen, gettext('Remove all'), 'title', interpolate(gettext('Click to remove all chosen %s at once.'), [field_name]), 'href', '#', 'id', field_id + '_remove_all_link'); - clear_all.className = 'selector-clearall'; - - from_box.name = from_box.name + '_old'; - - // Set up the JavaScript event handlers for the select box filter interface - const move_selection = function(e, elem, move_func, from, to) { - if (elem.classList.contains('active')) { - move_func(from, to); - SelectFilter.refresh_icons(field_id); - SelectFilter.refresh_filtered_selects(field_id); - SelectFilter.refresh_filtered_warning(field_id); - } - e.preventDefault(); - }; - choose_all.addEventListener('click', function(e) { - move_selection(e, this, SelectBox.move_all, field_id + '_from', field_id + '_to'); - }); - add_link.addEventListener('click', function(e) { - move_selection(e, this, SelectBox.move, field_id + '_from', field_id + '_to'); - }); - remove_link.addEventListener('click', function(e) { - move_selection(e, this, SelectBox.move, field_id + '_to', field_id + '_from'); - }); - clear_all.addEventListener('click', function(e) { - move_selection(e, this, SelectBox.move_all, field_id + '_to', field_id + '_from'); - }); - warning_footer.addEventListener('click', function(e) { - filter_selected_input.value = ''; - SelectBox.filter(field_id + '_to', ''); - SelectFilter.refresh_filtered_warning(field_id); - SelectFilter.refresh_icons(field_id); - }); - filter_input.addEventListener('keypress', function(e) { - SelectFilter.filter_key_press(e, field_id, '_from', '_to'); - }); - filter_input.addEventListener('keyup', function(e) { - SelectFilter.filter_key_up(e, field_id, '_from'); - }); - filter_input.addEventListener('keydown', function(e) { - SelectFilter.filter_key_down(e, field_id, '_from', '_to'); - }); - filter_selected_input.addEventListener('keypress', function(e) { - SelectFilter.filter_key_press(e, field_id, '_to', '_from'); - }); - filter_selected_input.addEventListener('keyup', function(e) { - SelectFilter.filter_key_up(e, field_id, '_to', '_selected_input'); - }); - filter_selected_input.addEventListener('keydown', function(e) { - SelectFilter.filter_key_down(e, field_id, '_to', '_from'); - }); - selector_div.addEventListener('change', function(e) { - if (e.target.tagName === 'SELECT') { - SelectFilter.refresh_icons(field_id); - } - }); - selector_div.addEventListener('dblclick', function(e) { - if (e.target.tagName === 'OPTION') { - if (e.target.closest('select').id === field_id + '_to') { - SelectBox.move(field_id + '_to', field_id + '_from'); - } else { - SelectBox.move(field_id + '_from', field_id + '_to'); - } - SelectFilter.refresh_icons(field_id); - } - }); - from_box.closest('form').addEventListener('submit', function() { - SelectBox.filter(field_id + '_to', ''); - SelectBox.select_all(field_id + '_to'); - }); - SelectBox.init(field_id + '_from'); - SelectBox.init(field_id + '_to'); - // Move selected from_box options to to_box - SelectBox.move(field_id + '_from', field_id + '_to'); - - // Initial icon refresh - SelectFilter.refresh_icons(field_id); - }, - any_selected: function(field) { - // Temporarily add the required attribute and check validity. - field.required = true; - const any_selected = field.checkValidity(); - field.required = false; - return any_selected; - }, - refresh_filtered_warning: function(field_id) { - const count = SelectBox.get_hidden_node_count(field_id + '_to'); - const selector = document.getElementById(field_id + '_selector_chosen'); - const warning = document.getElementById(field_id + '_list-footer-display-text'); - selector.className = selector.className.replace('selector-chosen--with-filtered', ''); - warning.textContent = interpolate(ngettext( - '%s selected option not visible', - '%s selected options not visible', - count - ), [count]); - if(count > 0) { - selector.className += ' selector-chosen--with-filtered'; - } - }, - refresh_filtered_selects: function(field_id) { - SelectBox.filter(field_id + '_from', document.getElementById(field_id + "_input").value); - SelectBox.filter(field_id + '_to', document.getElementById(field_id + "_selected_input").value); - }, - refresh_icons: function(field_id) { - const from = document.getElementById(field_id + '_from'); - const to = document.getElementById(field_id + '_to'); - // Active if at least one item is selected - document.getElementById(field_id + '_add_link').classList.toggle('active', SelectFilter.any_selected(from)); - document.getElementById(field_id + '_remove_link').classList.toggle('active', SelectFilter.any_selected(to)); - // Active if the corresponding box isn't empty - document.getElementById(field_id + '_add_all_link').classList.toggle('active', from.querySelector('option')); - document.getElementById(field_id + '_remove_all_link').classList.toggle('active', to.querySelector('option')); - SelectFilter.refresh_filtered_warning(field_id); - }, - filter_key_press: function(event, field_id, source, target) { - const source_box = document.getElementById(field_id + source); - // don't submit form if user pressed Enter - if ((event.which && event.which === 13) || (event.keyCode && event.keyCode === 13)) { - source_box.selectedIndex = 0; - SelectBox.move(field_id + source, field_id + target); - source_box.selectedIndex = 0; - event.preventDefault(); - } - }, - filter_key_up: function(event, field_id, source, filter_input) { - const input = filter_input || '_input'; - const source_box = document.getElementById(field_id + source); - const temp = source_box.selectedIndex; - SelectBox.filter(field_id + source, document.getElementById(field_id + input).value); - source_box.selectedIndex = temp; - SelectFilter.refresh_filtered_warning(field_id); - SelectFilter.refresh_icons(field_id); - }, - filter_key_down: function(event, field_id, source, target) { - const source_box = document.getElementById(field_id + source); - // right key (39) or left key (37) - const direction = source === '_from' ? 39 : 37; - // right arrow -- move across - if ((event.which && event.which === direction) || (event.keyCode && event.keyCode === direction)) { - const old_index = source_box.selectedIndex; - SelectBox.move(field_id + source, field_id + target); - SelectFilter.refresh_filtered_selects(field_id); - SelectFilter.refresh_filtered_warning(field_id); - source_box.selectedIndex = (old_index === source_box.length) ? source_box.length - 1 : old_index; - return; - } - // down arrow -- wrap around - if ((event.which && event.which === 40) || (event.keyCode && event.keyCode === 40)) { - source_box.selectedIndex = (source_box.length === source_box.selectedIndex + 1) ? 0 : source_box.selectedIndex + 1; - } - // up arrow -- wrap around - if ((event.which && event.which === 38) || (event.keyCode && event.keyCode === 38)) { - source_box.selectedIndex = (source_box.selectedIndex === 0) ? source_box.length - 1 : source_box.selectedIndex - 1; - } - } - }; - - window.addEventListener('load', function(e) { - document.querySelectorAll('select.selectfilter, select.selectfilterstacked').forEach(function(el) { - const data = el.dataset; - SelectFilter.init(el.id, data.fieldName, parseInt(data.isStacked, 10)); - }); - }); -} diff --git a/django/staticfiles/admin/js/actions.js b/django/staticfiles/admin/js/actions.js deleted file mode 100644 index 20a5c143..00000000 --- a/django/staticfiles/admin/js/actions.js +++ /dev/null @@ -1,201 +0,0 @@ -/*global gettext, interpolate, ngettext*/ -'use strict'; -{ - function show(selector) { - document.querySelectorAll(selector).forEach(function(el) { - el.classList.remove('hidden'); - }); - } - - function hide(selector) { - document.querySelectorAll(selector).forEach(function(el) { - el.classList.add('hidden'); - }); - } - - function showQuestion(options) { - hide(options.acrossClears); - show(options.acrossQuestions); - hide(options.allContainer); - } - - function showClear(options) { - show(options.acrossClears); - hide(options.acrossQuestions); - document.querySelector(options.actionContainer).classList.remove(options.selectedClass); - show(options.allContainer); - hide(options.counterContainer); - } - - function reset(options) { - hide(options.acrossClears); - hide(options.acrossQuestions); - hide(options.allContainer); - show(options.counterContainer); - } - - function clearAcross(options) { - reset(options); - const acrossInputs = document.querySelectorAll(options.acrossInput); - acrossInputs.forEach(function(acrossInput) { - acrossInput.value = 0; - }); - document.querySelector(options.actionContainer).classList.remove(options.selectedClass); - } - - function checker(actionCheckboxes, options, checked) { - if (checked) { - showQuestion(options); - } else { - reset(options); - } - actionCheckboxes.forEach(function(el) { - el.checked = checked; - el.closest('tr').classList.toggle(options.selectedClass, checked); - }); - } - - function updateCounter(actionCheckboxes, options) { - const sel = Array.from(actionCheckboxes).filter(function(el) { - return el.checked; - }).length; - const counter = document.querySelector(options.counterContainer); - // data-actions-icnt is defined in the generated HTML - // and contains the total amount of objects in the queryset - const actions_icnt = Number(counter.dataset.actionsIcnt); - counter.textContent = interpolate( - ngettext('%(sel)s of %(cnt)s selected', '%(sel)s of %(cnt)s selected', sel), { - sel: sel, - cnt: actions_icnt - }, true); - const allToggle = document.getElementById(options.allToggleId); - allToggle.checked = sel === actionCheckboxes.length; - if (allToggle.checked) { - showQuestion(options); - } else { - clearAcross(options); - } - } - - const defaults = { - actionContainer: "div.actions", - counterContainer: "span.action-counter", - allContainer: "div.actions span.all", - acrossInput: "div.actions input.select-across", - acrossQuestions: "div.actions span.question", - acrossClears: "div.actions span.clear", - allToggleId: "action-toggle", - selectedClass: "selected" - }; - - window.Actions = function(actionCheckboxes, options) { - options = Object.assign({}, defaults, options); - let list_editable_changed = false; - let lastChecked = null; - let shiftPressed = false; - - document.addEventListener('keydown', (event) => { - shiftPressed = event.shiftKey; - }); - - document.addEventListener('keyup', (event) => { - shiftPressed = event.shiftKey; - }); - - document.getElementById(options.allToggleId).addEventListener('click', function(event) { - checker(actionCheckboxes, options, this.checked); - updateCounter(actionCheckboxes, options); - }); - - document.querySelectorAll(options.acrossQuestions + " a").forEach(function(el) { - el.addEventListener('click', function(event) { - event.preventDefault(); - const acrossInputs = document.querySelectorAll(options.acrossInput); - acrossInputs.forEach(function(acrossInput) { - acrossInput.value = 1; - }); - showClear(options); - }); - }); - - document.querySelectorAll(options.acrossClears + " a").forEach(function(el) { - el.addEventListener('click', function(event) { - event.preventDefault(); - document.getElementById(options.allToggleId).checked = false; - clearAcross(options); - checker(actionCheckboxes, options, false); - updateCounter(actionCheckboxes, options); - }); - }); - - function affectedCheckboxes(target, withModifier) { - const multiSelect = (lastChecked && withModifier && lastChecked !== target); - if (!multiSelect) { - return [target]; - } - const checkboxes = Array.from(actionCheckboxes); - const targetIndex = checkboxes.findIndex(el => el === target); - const lastCheckedIndex = checkboxes.findIndex(el => el === lastChecked); - const startIndex = Math.min(targetIndex, lastCheckedIndex); - const endIndex = Math.max(targetIndex, lastCheckedIndex); - const filtered = checkboxes.filter((el, index) => (startIndex <= index) && (index <= endIndex)); - return filtered; - }; - - Array.from(document.getElementById('result_list').tBodies).forEach(function(el) { - el.addEventListener('change', function(event) { - const target = event.target; - if (target.classList.contains('action-select')) { - const checkboxes = affectedCheckboxes(target, shiftPressed); - checker(checkboxes, options, target.checked); - updateCounter(actionCheckboxes, options); - lastChecked = target; - } else { - list_editable_changed = true; - } - }); - }); - - document.querySelector('#changelist-form button[name=index]').addEventListener('click', function(event) { - if (list_editable_changed) { - const confirmed = confirm(gettext("You have unsaved changes on individual editable fields. If you run an action, your unsaved changes will be lost.")); - if (!confirmed) { - event.preventDefault(); - } - } - }); - - const el = document.querySelector('#changelist-form input[name=_save]'); - // The button does not exist if no fields are editable. - if (el) { - el.addEventListener('click', function(event) { - if (document.querySelector('[name=action]').value) { - const text = list_editable_changed - ? gettext("You have selected an action, but you haven’t saved your changes to individual fields yet. Please click OK to save. You’ll need to re-run the action.") - : gettext("You have selected an action, and you haven’t made any changes on individual fields. You’re probably looking for the Go button rather than the Save button."); - if (!confirm(text)) { - event.preventDefault(); - } - } - }); - } - }; - - // Call function fn when the DOM is loaded and ready. If it is already - // loaded, call the function now. - // http://youmightnotneedjquery.com/#ready - function ready(fn) { - if (document.readyState !== 'loading') { - fn(); - } else { - document.addEventListener('DOMContentLoaded', fn); - } - } - - ready(function() { - const actionsEls = document.querySelectorAll('tr input.action-select'); - if (actionsEls.length > 0) { - Actions(actionsEls); - } - }); -} diff --git a/django/staticfiles/admin/js/admin/DateTimeShortcuts.js b/django/staticfiles/admin/js/admin/DateTimeShortcuts.js deleted file mode 100644 index aa1cae9e..00000000 --- a/django/staticfiles/admin/js/admin/DateTimeShortcuts.js +++ /dev/null @@ -1,408 +0,0 @@ -/*global Calendar, findPosX, findPosY, get_format, gettext, gettext_noop, interpolate, ngettext, quickElement*/ -// Inserts shortcut buttons after all of the following: -// -// -'use strict'; -{ - const DateTimeShortcuts = { - calendars: [], - calendarInputs: [], - clockInputs: [], - clockHours: { - default_: [ - [gettext_noop('Now'), -1], - [gettext_noop('Midnight'), 0], - [gettext_noop('6 a.m.'), 6], - [gettext_noop('Noon'), 12], - [gettext_noop('6 p.m.'), 18] - ] - }, - dismissClockFunc: [], - dismissCalendarFunc: [], - calendarDivName1: 'calendarbox', // name of calendar
    that gets toggled - calendarDivName2: 'calendarin', // name of
    that contains calendar - calendarLinkName: 'calendarlink', // name of the link that is used to toggle - clockDivName: 'clockbox', // name of clock
    that gets toggled - clockLinkName: 'clocklink', // name of the link that is used to toggle - shortCutsClass: 'datetimeshortcuts', // class of the clock and cal shortcuts - timezoneWarningClass: 'timezonewarning', // class of the warning for timezone mismatch - timezoneOffset: 0, - init: function() { - const serverOffset = document.body.dataset.adminUtcOffset; - if (serverOffset) { - const localOffset = new Date().getTimezoneOffset() * -60; - DateTimeShortcuts.timezoneOffset = localOffset - serverOffset; - } - - for (const inp of document.getElementsByTagName('input')) { - if (inp.type === 'text' && inp.classList.contains('vTimeField')) { - DateTimeShortcuts.addClock(inp); - DateTimeShortcuts.addTimezoneWarning(inp); - } - else if (inp.type === 'text' && inp.classList.contains('vDateField')) { - DateTimeShortcuts.addCalendar(inp); - DateTimeShortcuts.addTimezoneWarning(inp); - } - } - }, - // Return the current time while accounting for the server timezone. - now: function() { - const serverOffset = document.body.dataset.adminUtcOffset; - if (serverOffset) { - const localNow = new Date(); - const localOffset = localNow.getTimezoneOffset() * -60; - localNow.setTime(localNow.getTime() + 1000 * (serverOffset - localOffset)); - return localNow; - } else { - return new Date(); - } - }, - // Add a warning when the time zone in the browser and backend do not match. - addTimezoneWarning: function(inp) { - const warningClass = DateTimeShortcuts.timezoneWarningClass; - let timezoneOffset = DateTimeShortcuts.timezoneOffset / 3600; - - // Only warn if there is a time zone mismatch. - if (!timezoneOffset) { - return; - } - - // Check if warning is already there. - if (inp.parentNode.querySelectorAll('.' + warningClass).length) { - return; - } - - let message; - if (timezoneOffset > 0) { - message = ngettext( - 'Note: You are %s hour ahead of server time.', - 'Note: You are %s hours ahead of server time.', - timezoneOffset - ); - } - else { - timezoneOffset *= -1; - message = ngettext( - 'Note: You are %s hour behind server time.', - 'Note: You are %s hours behind server time.', - timezoneOffset - ); - } - message = interpolate(message, [timezoneOffset]); - - const warning = document.createElement('div'); - warning.classList.add('help', warningClass); - warning.textContent = message; - inp.parentNode.appendChild(warning); - }, - // Add clock widget to a given field - addClock: function(inp) { - const num = DateTimeShortcuts.clockInputs.length; - DateTimeShortcuts.clockInputs[num] = inp; - DateTimeShortcuts.dismissClockFunc[num] = function() { DateTimeShortcuts.dismissClock(num); return true; }; - - // Shortcut links (clock icon and "Now" link) - const shortcuts_span = document.createElement('span'); - shortcuts_span.className = DateTimeShortcuts.shortCutsClass; - inp.parentNode.insertBefore(shortcuts_span, inp.nextSibling); - const now_link = document.createElement('a'); - now_link.href = "#"; - now_link.textContent = gettext('Now'); - now_link.addEventListener('click', function(e) { - e.preventDefault(); - DateTimeShortcuts.handleClockQuicklink(num, -1); - }); - const clock_link = document.createElement('a'); - clock_link.href = '#'; - clock_link.id = DateTimeShortcuts.clockLinkName + num; - clock_link.addEventListener('click', function(e) { - e.preventDefault(); - // avoid triggering the document click handler to dismiss the clock - e.stopPropagation(); - DateTimeShortcuts.openClock(num); - }); - - quickElement( - 'span', clock_link, '', - 'class', 'clock-icon', - 'title', gettext('Choose a Time') - ); - shortcuts_span.appendChild(document.createTextNode('\u00A0')); - shortcuts_span.appendChild(now_link); - shortcuts_span.appendChild(document.createTextNode('\u00A0|\u00A0')); - shortcuts_span.appendChild(clock_link); - - // Create clock link div - // - // Markup looks like: - //
    - //

    Choose a time

    - // - //

    Cancel

    - //
    - - const clock_box = document.createElement('div'); - clock_box.style.display = 'none'; - clock_box.style.position = 'absolute'; - clock_box.className = 'clockbox module'; - clock_box.id = DateTimeShortcuts.clockDivName + num; - document.body.appendChild(clock_box); - clock_box.addEventListener('click', function(e) { e.stopPropagation(); }); - - quickElement('h2', clock_box, gettext('Choose a time')); - const time_list = quickElement('ul', clock_box); - time_list.className = 'timelist'; - // The list of choices can be overridden in JavaScript like this: - // DateTimeShortcuts.clockHours.name = [['3 a.m.', 3]]; - // where name is the name attribute of the . - const name = typeof DateTimeShortcuts.clockHours[inp.name] === 'undefined' ? 'default_' : inp.name; - DateTimeShortcuts.clockHours[name].forEach(function(element) { - const time_link = quickElement('a', quickElement('li', time_list), gettext(element[0]), 'href', '#'); - time_link.addEventListener('click', function(e) { - e.preventDefault(); - DateTimeShortcuts.handleClockQuicklink(num, element[1]); - }); - }); - - const cancel_p = quickElement('p', clock_box); - cancel_p.className = 'calendar-cancel'; - const cancel_link = quickElement('a', cancel_p, gettext('Cancel'), 'href', '#'); - cancel_link.addEventListener('click', function(e) { - e.preventDefault(); - DateTimeShortcuts.dismissClock(num); - }); - - document.addEventListener('keyup', function(event) { - if (event.which === 27) { - // ESC key closes popup - DateTimeShortcuts.dismissClock(num); - event.preventDefault(); - } - }); - }, - openClock: function(num) { - const clock_box = document.getElementById(DateTimeShortcuts.clockDivName + num); - const clock_link = document.getElementById(DateTimeShortcuts.clockLinkName + num); - - // Recalculate the clockbox position - // is it left-to-right or right-to-left layout ? - if (window.getComputedStyle(document.body).direction !== 'rtl') { - clock_box.style.left = findPosX(clock_link) + 17 + 'px'; - } - else { - // since style's width is in em, it'd be tough to calculate - // px value of it. let's use an estimated px for now - clock_box.style.left = findPosX(clock_link) - 110 + 'px'; - } - clock_box.style.top = Math.max(0, findPosY(clock_link) - 30) + 'px'; - - // Show the clock box - clock_box.style.display = 'block'; - document.addEventListener('click', DateTimeShortcuts.dismissClockFunc[num]); - }, - dismissClock: function(num) { - document.getElementById(DateTimeShortcuts.clockDivName + num).style.display = 'none'; - document.removeEventListener('click', DateTimeShortcuts.dismissClockFunc[num]); - }, - handleClockQuicklink: function(num, val) { - let d; - if (val === -1) { - d = DateTimeShortcuts.now(); - } - else { - d = new Date(1970, 1, 1, val, 0, 0, 0); - } - DateTimeShortcuts.clockInputs[num].value = d.strftime(get_format('TIME_INPUT_FORMATS')[0]); - DateTimeShortcuts.clockInputs[num].focus(); - DateTimeShortcuts.dismissClock(num); - }, - // Add calendar widget to a given field. - addCalendar: function(inp) { - const num = DateTimeShortcuts.calendars.length; - - DateTimeShortcuts.calendarInputs[num] = inp; - DateTimeShortcuts.dismissCalendarFunc[num] = function() { DateTimeShortcuts.dismissCalendar(num); return true; }; - - // Shortcut links (calendar icon and "Today" link) - const shortcuts_span = document.createElement('span'); - shortcuts_span.className = DateTimeShortcuts.shortCutsClass; - inp.parentNode.insertBefore(shortcuts_span, inp.nextSibling); - const today_link = document.createElement('a'); - today_link.href = '#'; - today_link.appendChild(document.createTextNode(gettext('Today'))); - today_link.addEventListener('click', function(e) { - e.preventDefault(); - DateTimeShortcuts.handleCalendarQuickLink(num, 0); - }); - const cal_link = document.createElement('a'); - cal_link.href = '#'; - cal_link.id = DateTimeShortcuts.calendarLinkName + num; - cal_link.addEventListener('click', function(e) { - e.preventDefault(); - // avoid triggering the document click handler to dismiss the calendar - e.stopPropagation(); - DateTimeShortcuts.openCalendar(num); - }); - quickElement( - 'span', cal_link, '', - 'class', 'date-icon', - 'title', gettext('Choose a Date') - ); - shortcuts_span.appendChild(document.createTextNode('\u00A0')); - shortcuts_span.appendChild(today_link); - shortcuts_span.appendChild(document.createTextNode('\u00A0|\u00A0')); - shortcuts_span.appendChild(cal_link); - - // Create calendarbox div. - // - // Markup looks like: - // - //
    - //

    - // - // February 2003 - //

    - //
    - // - //
    - //
    - // Yesterday | Today | Tomorrow - //
    - //

    Cancel

    - //
    - const cal_box = document.createElement('div'); - cal_box.style.display = 'none'; - cal_box.style.position = 'absolute'; - cal_box.className = 'calendarbox module'; - cal_box.id = DateTimeShortcuts.calendarDivName1 + num; - document.body.appendChild(cal_box); - cal_box.addEventListener('click', function(e) { e.stopPropagation(); }); - - // next-prev links - const cal_nav = quickElement('div', cal_box); - const cal_nav_prev = quickElement('a', cal_nav, '<', 'href', '#'); - cal_nav_prev.className = 'calendarnav-previous'; - cal_nav_prev.addEventListener('click', function(e) { - e.preventDefault(); - DateTimeShortcuts.drawPrev(num); - }); - - const cal_nav_next = quickElement('a', cal_nav, '>', 'href', '#'); - cal_nav_next.className = 'calendarnav-next'; - cal_nav_next.addEventListener('click', function(e) { - e.preventDefault(); - DateTimeShortcuts.drawNext(num); - }); - - // main box - const cal_main = quickElement('div', cal_box, '', 'id', DateTimeShortcuts.calendarDivName2 + num); - cal_main.className = 'calendar'; - DateTimeShortcuts.calendars[num] = new Calendar(DateTimeShortcuts.calendarDivName2 + num, DateTimeShortcuts.handleCalendarCallback(num)); - DateTimeShortcuts.calendars[num].drawCurrent(); - - // calendar shortcuts - const shortcuts = quickElement('div', cal_box); - shortcuts.className = 'calendar-shortcuts'; - let day_link = quickElement('a', shortcuts, gettext('Yesterday'), 'href', '#'); - day_link.addEventListener('click', function(e) { - e.preventDefault(); - DateTimeShortcuts.handleCalendarQuickLink(num, -1); - }); - shortcuts.appendChild(document.createTextNode('\u00A0|\u00A0')); - day_link = quickElement('a', shortcuts, gettext('Today'), 'href', '#'); - day_link.addEventListener('click', function(e) { - e.preventDefault(); - DateTimeShortcuts.handleCalendarQuickLink(num, 0); - }); - shortcuts.appendChild(document.createTextNode('\u00A0|\u00A0')); - day_link = quickElement('a', shortcuts, gettext('Tomorrow'), 'href', '#'); - day_link.addEventListener('click', function(e) { - e.preventDefault(); - DateTimeShortcuts.handleCalendarQuickLink(num, +1); - }); - - // cancel bar - const cancel_p = quickElement('p', cal_box); - cancel_p.className = 'calendar-cancel'; - const cancel_link = quickElement('a', cancel_p, gettext('Cancel'), 'href', '#'); - cancel_link.addEventListener('click', function(e) { - e.preventDefault(); - DateTimeShortcuts.dismissCalendar(num); - }); - document.addEventListener('keyup', function(event) { - if (event.which === 27) { - // ESC key closes popup - DateTimeShortcuts.dismissCalendar(num); - event.preventDefault(); - } - }); - }, - openCalendar: function(num) { - const cal_box = document.getElementById(DateTimeShortcuts.calendarDivName1 + num); - const cal_link = document.getElementById(DateTimeShortcuts.calendarLinkName + num); - const inp = DateTimeShortcuts.calendarInputs[num]; - - // Determine if the current value in the input has a valid date. - // If so, draw the calendar with that date's year and month. - if (inp.value) { - const format = get_format('DATE_INPUT_FORMATS')[0]; - const selected = inp.value.strptime(format); - const year = selected.getUTCFullYear(); - const month = selected.getUTCMonth() + 1; - const re = /\d{4}/; - if (re.test(year.toString()) && month >= 1 && month <= 12) { - DateTimeShortcuts.calendars[num].drawDate(month, year, selected); - } - } - - // Recalculate the clockbox position - // is it left-to-right or right-to-left layout ? - if (window.getComputedStyle(document.body).direction !== 'rtl') { - cal_box.style.left = findPosX(cal_link) + 17 + 'px'; - } - else { - // since style's width is in em, it'd be tough to calculate - // px value of it. let's use an estimated px for now - cal_box.style.left = findPosX(cal_link) - 180 + 'px'; - } - cal_box.style.top = Math.max(0, findPosY(cal_link) - 75) + 'px'; - - cal_box.style.display = 'block'; - document.addEventListener('click', DateTimeShortcuts.dismissCalendarFunc[num]); - }, - dismissCalendar: function(num) { - document.getElementById(DateTimeShortcuts.calendarDivName1 + num).style.display = 'none'; - document.removeEventListener('click', DateTimeShortcuts.dismissCalendarFunc[num]); - }, - drawPrev: function(num) { - DateTimeShortcuts.calendars[num].drawPreviousMonth(); - }, - drawNext: function(num) { - DateTimeShortcuts.calendars[num].drawNextMonth(); - }, - handleCalendarCallback: function(num) { - const format = get_format('DATE_INPUT_FORMATS')[0]; - return function(y, m, d) { - DateTimeShortcuts.calendarInputs[num].value = new Date(y, m - 1, d).strftime(format); - DateTimeShortcuts.calendarInputs[num].focus(); - document.getElementById(DateTimeShortcuts.calendarDivName1 + num).style.display = 'none'; - }; - }, - handleCalendarQuickLink: function(num, offset) { - const d = DateTimeShortcuts.now(); - d.setDate(d.getDate() + offset); - DateTimeShortcuts.calendarInputs[num].value = d.strftime(get_format('DATE_INPUT_FORMATS')[0]); - DateTimeShortcuts.calendarInputs[num].focus(); - DateTimeShortcuts.dismissCalendar(num); - } - }; - - window.addEventListener('load', DateTimeShortcuts.init); - window.DateTimeShortcuts = DateTimeShortcuts; -} diff --git a/django/staticfiles/admin/js/admin/RelatedObjectLookups.js b/django/staticfiles/admin/js/admin/RelatedObjectLookups.js deleted file mode 100644 index 1b96a2ea..00000000 --- a/django/staticfiles/admin/js/admin/RelatedObjectLookups.js +++ /dev/null @@ -1,295 +0,0 @@ -/*global SelectBox, interpolate*/ -// Handles related-objects functionality: lookup link for raw_id_fields -// and Add Another links. -"use strict"; -{ - const $ = django.jQuery; - let popupIndex = 0; - const relatedWindows = []; - - function dismissChildPopups() { - relatedWindows.forEach(function (win) { - if (!win.closed) { - win.dismissChildPopups(); - win.close(); - } - }); - } - - function setPopupIndex() { - if (document.getElementsByName("_popup").length > 0) { - const index = window.name.lastIndexOf("__") + 2; - popupIndex = parseInt(window.name.substring(index)); - } else { - popupIndex = 0; - } - } - - function addPopupIndex(name) { - return name + "__" + (popupIndex + 1); - } - - function removePopupIndex(name) { - return name.replace(new RegExp("__" + (popupIndex + 1) + "$"), ""); - } - - function showAdminPopup(triggeringLink, name_regexp, add_popup) { - const name = addPopupIndex(triggeringLink.id.replace(name_regexp, "")); - const href = new URL(triggeringLink.href); - if (add_popup) { - href.searchParams.set("_popup", 1); - } - const win = window.open( - href, - name, - "height=768,width=1024,resizable=yes,scrollbars=yes" - ); - relatedWindows.push(win); - win.focus(); - return false; - } - - function showRelatedObjectLookupPopup(triggeringLink) { - return showAdminPopup(triggeringLink, /^lookup_/, true); - } - - function dismissRelatedLookupPopup(win, chosenId) { - const name = removePopupIndex(win.name); - const elem = document.getElementById(name); - if (elem.classList.contains("vManyToManyRawIdAdminField") && elem.value) { - elem.value += "," + chosenId; - } else { - document.getElementById(name).value = chosenId; - } - const index = relatedWindows.indexOf(win); - if (index > -1) { - relatedWindows.splice(index, 1); - } - win.close(); - } - - function showRelatedObjectPopup(triggeringLink) { - return showAdminPopup(triggeringLink, /^(change|add|delete)_/, false); - } - - function updateRelatedObjectLinks(triggeringLink) { - const $this = $(triggeringLink); - const siblings = $this.nextAll( - ".view-related, .change-related, .delete-related" - ); - if (!siblings.length) { - return; - } - const value = $this.val(); - if (value) { - siblings.each(function () { - const elm = $(this); - elm.attr( - "href", - elm.attr("data-href-template").replace("__fk__", value) - ); - elm.removeAttr("aria-disabled"); - }); - } else { - siblings.removeAttr("href"); - siblings.attr("aria-disabled", true); - } - } - - function updateRelatedSelectsOptions( - currentSelect, - win, - objId, - newRepr, - newId, - skipIds = [] - ) { - // After create/edit a model from the options next to the current - // select (+ or :pencil:) update ForeignKey PK of the rest of selects - // in the page. - - const path = win.location.pathname; - // Extract the model from the popup url '...//add/' or - // '...///change/' depending the action (add or change). - const modelName = path.split("/")[path.split("/").length - (objId ? 4 : 3)]; - // Select elements with a specific model reference and context of "available-source". - const selectsRelated = document.querySelectorAll( - `[data-model-ref="${modelName}"] [data-context="available-source"]` - ); - - selectsRelated.forEach(function (select) { - if ( - currentSelect === select || - (skipIds && skipIds.includes(select.id)) - ) { - return; - } - - let option = select.querySelector(`option[value="${objId}"]`); - - if (!option) { - option = new Option(newRepr, newId); - select.options.add(option); - // Update SelectBox cache for related fields. - if ( - window.SelectBox !== undefined && - !SelectBox.cache[currentSelect.id] - ) { - SelectBox.add_to_cache(select.id, option); - SelectBox.redisplay(select.id); - } - return; - } - - option.textContent = newRepr; - option.value = newId; - }); - } - - function dismissAddRelatedObjectPopup(win, newId, newRepr) { - const name = removePopupIndex(win.name); - const elem = document.getElementById(name); - if (elem) { - const elemName = elem.nodeName.toUpperCase(); - if (elemName === "SELECT") { - elem.options[elem.options.length] = new Option( - newRepr, - newId, - true, - true - ); - updateRelatedSelectsOptions(elem, win, null, newRepr, newId); - } else if (elemName === "INPUT") { - if ( - elem.classList.contains("vManyToManyRawIdAdminField") && - elem.value - ) { - elem.value += "," + newId; - } else { - elem.value = newId; - } - } - // Trigger a change event to update related links if required. - $(elem).trigger("change"); - } else { - const toId = name + "_to"; - const toElem = document.getElementById(toId); - const o = new Option(newRepr, newId); - SelectBox.add_to_cache(toId, o); - SelectBox.redisplay(toId); - if (toElem && toElem.nodeName.toUpperCase() === "SELECT") { - const skipIds = [name + "_from"]; - updateRelatedSelectsOptions(toElem, win, null, newRepr, newId, skipIds); - } - } - const index = relatedWindows.indexOf(win); - if (index > -1) { - relatedWindows.splice(index, 1); - } - win.close(); - } - - function dismissChangeRelatedObjectPopup(win, objId, newRepr, newId) { - const id = removePopupIndex(win.name.replace(/^edit_/, "")); - const selectsSelector = interpolate("#%s, #%s_from, #%s_to", [id, id, id]); - const selects = $(selectsSelector); - selects - .find("option") - .each(function () { - if (this.value === objId) { - this.textContent = newRepr; - this.value = newId; - } - }) - .trigger("change"); - updateRelatedSelectsOptions(selects[0], win, objId, newRepr, newId); - selects - .next() - .find(".select2-selection__rendered") - .each(function () { - // The element can have a clear button as a child. - // Use the lastChild to modify only the displayed value. - this.lastChild.textContent = newRepr; - this.title = newRepr; - }); - const index = relatedWindows.indexOf(win); - if (index > -1) { - relatedWindows.splice(index, 1); - } - win.close(); - } - - function dismissDeleteRelatedObjectPopup(win, objId) { - const id = removePopupIndex(win.name.replace(/^delete_/, "")); - const selectsSelector = interpolate("#%s, #%s_from, #%s_to", [id, id, id]); - const selects = $(selectsSelector); - selects - .find("option") - .each(function () { - if (this.value === objId) { - $(this).remove(); - } - }) - .trigger("change"); - const index = relatedWindows.indexOf(win); - if (index > -1) { - relatedWindows.splice(index, 1); - } - win.close(); - } - - window.showRelatedObjectLookupPopup = showRelatedObjectLookupPopup; - window.dismissRelatedLookupPopup = dismissRelatedLookupPopup; - window.showRelatedObjectPopup = showRelatedObjectPopup; - window.updateRelatedObjectLinks = updateRelatedObjectLinks; - window.dismissAddRelatedObjectPopup = dismissAddRelatedObjectPopup; - window.dismissChangeRelatedObjectPopup = dismissChangeRelatedObjectPopup; - window.dismissDeleteRelatedObjectPopup = dismissDeleteRelatedObjectPopup; - window.dismissChildPopups = dismissChildPopups; - - // Kept for backward compatibility - window.showAddAnotherPopup = showRelatedObjectPopup; - window.dismissAddAnotherPopup = dismissAddRelatedObjectPopup; - - window.addEventListener("unload", function (evt) { - window.dismissChildPopups(); - }); - - $(document).ready(function () { - setPopupIndex(); - $("a[data-popup-opener]").on("click", function (event) { - event.preventDefault(); - opener.dismissRelatedLookupPopup(window, $(this).data("popup-opener")); - }); - $("body").on( - "click", - '.related-widget-wrapper-link[data-popup="yes"]', - function (e) { - e.preventDefault(); - if (this.href) { - const event = $.Event("django:show-related", { href: this.href }); - $(this).trigger(event); - if (!event.isDefaultPrevented()) { - showRelatedObjectPopup(this); - } - } - } - ); - $("body").on("change", ".related-widget-wrapper select", function (e) { - const event = $.Event("django:update-related"); - $(this).trigger(event); - if (!event.isDefaultPrevented()) { - updateRelatedObjectLinks(this); - } - }); - $(".related-widget-wrapper select").trigger("change"); - $("body").on("click", ".related-lookup", function (e) { - e.preventDefault(); - const event = $.Event("django:lookup-related"); - $(this).trigger(event); - if (!event.isDefaultPrevented()) { - showRelatedObjectLookupPopup(this); - } - }); - }); -} diff --git a/django/staticfiles/admin/js/autocomplete.js b/django/staticfiles/admin/js/autocomplete.js deleted file mode 100644 index d3daeab8..00000000 --- a/django/staticfiles/admin/js/autocomplete.js +++ /dev/null @@ -1,33 +0,0 @@ -'use strict'; -{ - const $ = django.jQuery; - - $.fn.djangoAdminSelect2 = function() { - $.each(this, function(i, element) { - $(element).select2({ - ajax: { - data: (params) => { - return { - term: params.term, - page: params.page, - app_label: element.dataset.appLabel, - model_name: element.dataset.modelName, - field_name: element.dataset.fieldName - }; - } - } - }); - }); - return this; - }; - - $(function() { - // Initialize all autocomplete widgets except the one in the template - // form used when a new formset is added. - $('.admin-autocomplete').not('[name*=__prefix__]').djangoAdminSelect2(); - }); - - document.addEventListener('formset:added', (event) => { - $(event.target).find('.admin-autocomplete').djangoAdminSelect2(); - }); -} diff --git a/django/staticfiles/admin/js/calendar.js b/django/staticfiles/admin/js/calendar.js deleted file mode 100644 index a62d10a7..00000000 --- a/django/staticfiles/admin/js/calendar.js +++ /dev/null @@ -1,221 +0,0 @@ -/*global gettext, pgettext, get_format, quickElement, removeChildren*/ -/* -calendar.js - Calendar functions by Adrian Holovaty -depends on core.js for utility functions like removeChildren or quickElement -*/ -'use strict'; -{ - // CalendarNamespace -- Provides a collection of HTML calendar-related helper functions - const CalendarNamespace = { - monthsOfYear: [ - gettext('January'), - gettext('February'), - gettext('March'), - gettext('April'), - gettext('May'), - gettext('June'), - gettext('July'), - gettext('August'), - gettext('September'), - gettext('October'), - gettext('November'), - gettext('December') - ], - monthsOfYearAbbrev: [ - pgettext('abbrev. month January', 'Jan'), - pgettext('abbrev. month February', 'Feb'), - pgettext('abbrev. month March', 'Mar'), - pgettext('abbrev. month April', 'Apr'), - pgettext('abbrev. month May', 'May'), - pgettext('abbrev. month June', 'Jun'), - pgettext('abbrev. month July', 'Jul'), - pgettext('abbrev. month August', 'Aug'), - pgettext('abbrev. month September', 'Sep'), - pgettext('abbrev. month October', 'Oct'), - pgettext('abbrev. month November', 'Nov'), - pgettext('abbrev. month December', 'Dec') - ], - daysOfWeek: [ - pgettext('one letter Sunday', 'S'), - pgettext('one letter Monday', 'M'), - pgettext('one letter Tuesday', 'T'), - pgettext('one letter Wednesday', 'W'), - pgettext('one letter Thursday', 'T'), - pgettext('one letter Friday', 'F'), - pgettext('one letter Saturday', 'S') - ], - firstDayOfWeek: parseInt(get_format('FIRST_DAY_OF_WEEK')), - isLeapYear: function(year) { - return (((year % 4) === 0) && ((year % 100) !== 0 ) || ((year % 400) === 0)); - }, - getDaysInMonth: function(month, year) { - let days; - if (month === 1 || month === 3 || month === 5 || month === 7 || month === 8 || month === 10 || month === 12) { - days = 31; - } - else if (month === 4 || month === 6 || month === 9 || month === 11) { - days = 30; - } - else if (month === 2 && CalendarNamespace.isLeapYear(year)) { - days = 29; - } - else { - days = 28; - } - return days; - }, - draw: function(month, year, div_id, callback, selected) { // month = 1-12, year = 1-9999 - const today = new Date(); - const todayDay = today.getDate(); - const todayMonth = today.getMonth() + 1; - const todayYear = today.getFullYear(); - let todayClass = ''; - - // Use UTC functions here because the date field does not contain time - // and using the UTC function variants prevent the local time offset - // from altering the date, specifically the day field. For example: - // - // ``` - // var x = new Date('2013-10-02'); - // var day = x.getDate(); - // ``` - // - // The day variable above will be 1 instead of 2 in, say, US Pacific time - // zone. - let isSelectedMonth = false; - if (typeof selected !== 'undefined') { - isSelectedMonth = (selected.getUTCFullYear() === year && (selected.getUTCMonth() + 1) === month); - } - - month = parseInt(month); - year = parseInt(year); - const calDiv = document.getElementById(div_id); - removeChildren(calDiv); - const calTable = document.createElement('table'); - quickElement('caption', calTable, CalendarNamespace.monthsOfYear[month - 1] + ' ' + year); - const tableBody = quickElement('tbody', calTable); - - // Draw days-of-week header - let tableRow = quickElement('tr', tableBody); - for (let i = 0; i < 7; i++) { - quickElement('th', tableRow, CalendarNamespace.daysOfWeek[(i + CalendarNamespace.firstDayOfWeek) % 7]); - } - - const startingPos = new Date(year, month - 1, 1 - CalendarNamespace.firstDayOfWeek).getDay(); - const days = CalendarNamespace.getDaysInMonth(month, year); - - let nonDayCell; - - // Draw blanks before first of month - tableRow = quickElement('tr', tableBody); - for (let i = 0; i < startingPos; i++) { - nonDayCell = quickElement('td', tableRow, ' '); - nonDayCell.className = "nonday"; - } - - function calendarMonth(y, m) { - function onClick(e) { - e.preventDefault(); - callback(y, m, this.textContent); - } - return onClick; - } - - // Draw days of month - let currentDay = 1; - for (let i = startingPos; currentDay <= days; i++) { - if (i % 7 === 0 && currentDay !== 1) { - tableRow = quickElement('tr', tableBody); - } - if ((currentDay === todayDay) && (month === todayMonth) && (year === todayYear)) { - todayClass = 'today'; - } else { - todayClass = ''; - } - - // use UTC function; see above for explanation. - if (isSelectedMonth && currentDay === selected.getUTCDate()) { - if (todayClass !== '') { - todayClass += " "; - } - todayClass += "selected"; - } - - const cell = quickElement('td', tableRow, '', 'class', todayClass); - const link = quickElement('a', cell, currentDay, 'href', '#'); - link.addEventListener('click', calendarMonth(year, month)); - currentDay++; - } - - // Draw blanks after end of month (optional, but makes for valid code) - while (tableRow.childNodes.length < 7) { - nonDayCell = quickElement('td', tableRow, ' '); - nonDayCell.className = "nonday"; - } - - calDiv.appendChild(calTable); - } - }; - - // Calendar -- A calendar instance - function Calendar(div_id, callback, selected) { - // div_id (string) is the ID of the element in which the calendar will - // be displayed - // callback (string) is the name of a JavaScript function that will be - // called with the parameters (year, month, day) when a day in the - // calendar is clicked - this.div_id = div_id; - this.callback = callback; - this.today = new Date(); - this.currentMonth = this.today.getMonth() + 1; - this.currentYear = this.today.getFullYear(); - if (typeof selected !== 'undefined') { - this.selected = selected; - } - } - Calendar.prototype = { - drawCurrent: function() { - CalendarNamespace.draw(this.currentMonth, this.currentYear, this.div_id, this.callback, this.selected); - }, - drawDate: function(month, year, selected) { - this.currentMonth = month; - this.currentYear = year; - - if(selected) { - this.selected = selected; - } - - this.drawCurrent(); - }, - drawPreviousMonth: function() { - if (this.currentMonth === 1) { - this.currentMonth = 12; - this.currentYear--; - } - else { - this.currentMonth--; - } - this.drawCurrent(); - }, - drawNextMonth: function() { - if (this.currentMonth === 12) { - this.currentMonth = 1; - this.currentYear++; - } - else { - this.currentMonth++; - } - this.drawCurrent(); - }, - drawPreviousYear: function() { - this.currentYear--; - this.drawCurrent(); - }, - drawNextYear: function() { - this.currentYear++; - this.drawCurrent(); - } - }; - window.Calendar = Calendar; - window.CalendarNamespace = CalendarNamespace; -} diff --git a/django/staticfiles/admin/js/cancel.js b/django/staticfiles/admin/js/cancel.js deleted file mode 100644 index 3069c6f2..00000000 --- a/django/staticfiles/admin/js/cancel.js +++ /dev/null @@ -1,29 +0,0 @@ -'use strict'; -{ - // Call function fn when the DOM is loaded and ready. If it is already - // loaded, call the function now. - // http://youmightnotneedjquery.com/#ready - function ready(fn) { - if (document.readyState !== 'loading') { - fn(); - } else { - document.addEventListener('DOMContentLoaded', fn); - } - } - - ready(function() { - function handleClick(event) { - event.preventDefault(); - const params = new URLSearchParams(window.location.search); - if (params.has('_popup')) { - window.close(); // Close the popup. - } else { - window.history.back(); // Otherwise, go back. - } - } - - document.querySelectorAll('.cancel-link').forEach(function(el) { - el.addEventListener('click', handleClick); - }); - }); -} diff --git a/django/staticfiles/admin/js/change_form.js b/django/staticfiles/admin/js/change_form.js deleted file mode 100644 index 96a4c62e..00000000 --- a/django/staticfiles/admin/js/change_form.js +++ /dev/null @@ -1,16 +0,0 @@ -'use strict'; -{ - const inputTags = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA']; - const modelName = document.getElementById('django-admin-form-add-constants').dataset.modelName; - if (modelName) { - const form = document.getElementById(modelName + '_form'); - for (const element of form.elements) { - // HTMLElement.offsetParent returns null when the element is not - // rendered. - if (inputTags.includes(element.tagName) && !element.disabled && element.offsetParent) { - element.focus(); - break; - } - } - } -} diff --git a/django/staticfiles/admin/js/collapse.js b/django/staticfiles/admin/js/collapse.js deleted file mode 100644 index c6c7b0f6..00000000 --- a/django/staticfiles/admin/js/collapse.js +++ /dev/null @@ -1,43 +0,0 @@ -/*global gettext*/ -'use strict'; -{ - window.addEventListener('load', function() { - // Add anchor tag for Show/Hide link - const fieldsets = document.querySelectorAll('fieldset.collapse'); - for (const [i, elem] of fieldsets.entries()) { - // Don't hide if fields in this fieldset have errors - if (elem.querySelectorAll('div.errors, ul.errorlist').length === 0) { - elem.classList.add('collapsed'); - const h2 = elem.querySelector('h2'); - const link = document.createElement('a'); - link.id = 'fieldsetcollapser' + i; - link.className = 'collapse-toggle'; - link.href = '#'; - link.textContent = gettext('Show'); - h2.appendChild(document.createTextNode(' (')); - h2.appendChild(link); - h2.appendChild(document.createTextNode(')')); - } - } - // Add toggle to hide/show anchor tag - const toggleFunc = function(ev) { - if (ev.target.matches('.collapse-toggle')) { - ev.preventDefault(); - ev.stopPropagation(); - const fieldset = ev.target.closest('fieldset'); - if (fieldset.classList.contains('collapsed')) { - // Show - ev.target.textContent = gettext('Hide'); - fieldset.classList.remove('collapsed'); - } else { - // Hide - ev.target.textContent = gettext('Show'); - fieldset.classList.add('collapsed'); - } - } - }; - document.querySelectorAll('fieldset.module').forEach(function(el) { - el.addEventListener('click', toggleFunc); - }); - }); -} diff --git a/django/staticfiles/admin/js/core.js b/django/staticfiles/admin/js/core.js deleted file mode 100644 index 0344a13f..00000000 --- a/django/staticfiles/admin/js/core.js +++ /dev/null @@ -1,170 +0,0 @@ -// Core JavaScript helper functions -'use strict'; - -// quickElement(tagType, parentReference [, textInChildNode, attribute, attributeValue ...]); -function quickElement() { - const obj = document.createElement(arguments[0]); - if (arguments[2]) { - const textNode = document.createTextNode(arguments[2]); - obj.appendChild(textNode); - } - const len = arguments.length; - for (let i = 3; i < len; i += 2) { - obj.setAttribute(arguments[i], arguments[i + 1]); - } - arguments[1].appendChild(obj); - return obj; -} - -// "a" is reference to an object -function removeChildren(a) { - while (a.hasChildNodes()) { - a.removeChild(a.lastChild); - } -} - -// ---------------------------------------------------------------------------- -// Find-position functions by PPK -// See https://www.quirksmode.org/js/findpos.html -// ---------------------------------------------------------------------------- -function findPosX(obj) { - let curleft = 0; - if (obj.offsetParent) { - while (obj.offsetParent) { - curleft += obj.offsetLeft - obj.scrollLeft; - obj = obj.offsetParent; - } - } else if (obj.x) { - curleft += obj.x; - } - return curleft; -} - -function findPosY(obj) { - let curtop = 0; - if (obj.offsetParent) { - while (obj.offsetParent) { - curtop += obj.offsetTop - obj.scrollTop; - obj = obj.offsetParent; - } - } else if (obj.y) { - curtop += obj.y; - } - return curtop; -} - -//----------------------------------------------------------------------------- -// Date object extensions -// ---------------------------------------------------------------------------- -{ - Date.prototype.getTwelveHours = function() { - return this.getHours() % 12 || 12; - }; - - Date.prototype.getTwoDigitMonth = function() { - return (this.getMonth() < 9) ? '0' + (this.getMonth() + 1) : (this.getMonth() + 1); - }; - - Date.prototype.getTwoDigitDate = function() { - return (this.getDate() < 10) ? '0' + this.getDate() : this.getDate(); - }; - - Date.prototype.getTwoDigitTwelveHour = function() { - return (this.getTwelveHours() < 10) ? '0' + this.getTwelveHours() : this.getTwelveHours(); - }; - - Date.prototype.getTwoDigitHour = function() { - return (this.getHours() < 10) ? '0' + this.getHours() : this.getHours(); - }; - - Date.prototype.getTwoDigitMinute = function() { - return (this.getMinutes() < 10) ? '0' + this.getMinutes() : this.getMinutes(); - }; - - Date.prototype.getTwoDigitSecond = function() { - return (this.getSeconds() < 10) ? '0' + this.getSeconds() : this.getSeconds(); - }; - - Date.prototype.getAbbrevMonthName = function() { - return typeof window.CalendarNamespace === "undefined" - ? this.getTwoDigitMonth() - : window.CalendarNamespace.monthsOfYearAbbrev[this.getMonth()]; - }; - - Date.prototype.getFullMonthName = function() { - return typeof window.CalendarNamespace === "undefined" - ? this.getTwoDigitMonth() - : window.CalendarNamespace.monthsOfYear[this.getMonth()]; - }; - - Date.prototype.strftime = function(format) { - const fields = { - b: this.getAbbrevMonthName(), - B: this.getFullMonthName(), - c: this.toString(), - d: this.getTwoDigitDate(), - H: this.getTwoDigitHour(), - I: this.getTwoDigitTwelveHour(), - m: this.getTwoDigitMonth(), - M: this.getTwoDigitMinute(), - p: (this.getHours() >= 12) ? 'PM' : 'AM', - S: this.getTwoDigitSecond(), - w: '0' + this.getDay(), - x: this.toLocaleDateString(), - X: this.toLocaleTimeString(), - y: ('' + this.getFullYear()).substr(2, 4), - Y: '' + this.getFullYear(), - '%': '%' - }; - let result = '', i = 0; - while (i < format.length) { - if (format.charAt(i) === '%') { - result += fields[format.charAt(i + 1)]; - ++i; - } - else { - result += format.charAt(i); - } - ++i; - } - return result; - }; - - // ---------------------------------------------------------------------------- - // String object extensions - // ---------------------------------------------------------------------------- - String.prototype.strptime = function(format) { - const split_format = format.split(/[.\-/]/); - const date = this.split(/[.\-/]/); - let i = 0; - let day, month, year; - while (i < split_format.length) { - switch (split_format[i]) { - case "%d": - day = date[i]; - break; - case "%m": - month = date[i] - 1; - break; - case "%Y": - year = date[i]; - break; - case "%y": - // A %y value in the range of [00, 68] is in the current - // century, while [69, 99] is in the previous century, - // according to the Open Group Specification. - if (parseInt(date[i], 10) >= 69) { - year = date[i]; - } else { - year = (new Date(Date.UTC(date[i], 0))).getUTCFullYear() + 100; - } - break; - } - ++i; - } - // Create Date object from UTC since the parsed value is supposed to be - // in UTC, not local time. Also, the calendar uses UTC functions for - // date extraction. - return new Date(Date.UTC(year, month, day)); - }; -} diff --git a/django/staticfiles/admin/js/filters.js b/django/staticfiles/admin/js/filters.js deleted file mode 100644 index f5536ebc..00000000 --- a/django/staticfiles/admin/js/filters.js +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Persist changelist filters state (collapsed/expanded). - */ -'use strict'; -{ - // Init filters. - let filters = JSON.parse(sessionStorage.getItem('django.admin.filtersState')); - - if (!filters) { - filters = {}; - } - - Object.entries(filters).forEach(([key, value]) => { - const detailElement = document.querySelector(`[data-filter-title='${CSS.escape(key)}']`); - - // Check if the filter is present, it could be from other view. - if (detailElement) { - value ? detailElement.setAttribute('open', '') : detailElement.removeAttribute('open'); - } - }); - - // Save filter state when clicks. - const details = document.querySelectorAll('details'); - details.forEach(detail => { - detail.addEventListener('toggle', event => { - filters[`${event.target.dataset.filterTitle}`] = detail.open; - sessionStorage.setItem('django.admin.filtersState', JSON.stringify(filters)); - }); - }); -} diff --git a/django/staticfiles/admin/js/inlines.js b/django/staticfiles/admin/js/inlines.js deleted file mode 100644 index e9a1dfe1..00000000 --- a/django/staticfiles/admin/js/inlines.js +++ /dev/null @@ -1,359 +0,0 @@ -/*global DateTimeShortcuts, SelectFilter*/ -/** - * Django admin inlines - * - * Based on jQuery Formset 1.1 - * @author Stanislaus Madueke (stan DOT madueke AT gmail DOT com) - * @requires jQuery 1.2.6 or later - * - * Copyright (c) 2009, Stanislaus Madueke - * All rights reserved. - * - * Spiced up with Code from Zain Memon's GSoC project 2009 - * and modified for Django by Jannis Leidel, Travis Swicegood and Julien Phalip. - * - * Licensed under the New BSD License - * See: https://opensource.org/licenses/bsd-license.php - */ -'use strict'; -{ - const $ = django.jQuery; - $.fn.formset = function(opts) { - const options = $.extend({}, $.fn.formset.defaults, opts); - const $this = $(this); - const $parent = $this.parent(); - const updateElementIndex = function(el, prefix, ndx) { - const id_regex = new RegExp("(" + prefix + "-(\\d+|__prefix__))"); - const replacement = prefix + "-" + ndx; - if ($(el).prop("for")) { - $(el).prop("for", $(el).prop("for").replace(id_regex, replacement)); - } - if (el.id) { - el.id = el.id.replace(id_regex, replacement); - } - if (el.name) { - el.name = el.name.replace(id_regex, replacement); - } - }; - const totalForms = $("#id_" + options.prefix + "-TOTAL_FORMS").prop("autocomplete", "off"); - let nextIndex = parseInt(totalForms.val(), 10); - const maxForms = $("#id_" + options.prefix + "-MAX_NUM_FORMS").prop("autocomplete", "off"); - const minForms = $("#id_" + options.prefix + "-MIN_NUM_FORMS").prop("autocomplete", "off"); - let addButton; - - /** - * The "Add another MyModel" button below the inline forms. - */ - const addInlineAddButton = function() { - if (addButton === null) { - if ($this.prop("tagName") === "TR") { - // If forms are laid out as table rows, insert the - // "add" button in a new table row: - const numCols = $this.eq(-1).children().length; - $parent.append('' + options.addText + ""); - addButton = $parent.find("tr:last a"); - } else { - // Otherwise, insert it immediately after the last form: - $this.filter(":last").after('"); - addButton = $this.filter(":last").next().find("a"); - } - } - addButton.on('click', addInlineClickHandler); - }; - - const addInlineClickHandler = function(e) { - e.preventDefault(); - const template = $("#" + options.prefix + "-empty"); - const row = template.clone(true); - row.removeClass(options.emptyCssClass) - .addClass(options.formCssClass) - .attr("id", options.prefix + "-" + nextIndex); - addInlineDeleteButton(row); - row.find("*").each(function() { - updateElementIndex(this, options.prefix, totalForms.val()); - }); - // Insert the new form when it has been fully edited. - row.insertBefore($(template)); - // Update number of total forms. - $(totalForms).val(parseInt(totalForms.val(), 10) + 1); - nextIndex += 1; - // Hide the add button if there's a limit and it's been reached. - if ((maxForms.val() !== '') && (maxForms.val() - totalForms.val()) <= 0) { - addButton.parent().hide(); - } - // Show the remove buttons if there are more than min_num. - toggleDeleteButtonVisibility(row.closest('.inline-group')); - - // Pass the new form to the post-add callback, if provided. - if (options.added) { - options.added(row); - } - row.get(0).dispatchEvent(new CustomEvent("formset:added", { - bubbles: true, - detail: { - formsetName: options.prefix - } - })); - }; - - /** - * The "X" button that is part of every unsaved inline. - * (When saved, it is replaced with a "Delete" checkbox.) - */ - const addInlineDeleteButton = function(row) { - if (row.is("tr")) { - // If the forms are laid out in table rows, insert - // the remove button into the last table cell: - row.children(":last").append('"); - } else if (row.is("ul") || row.is("ol")) { - // If they're laid out as an ordered/unordered list, - // insert an
  • after the last list item: - row.append('
  • ' + options.deleteText + "
  • "); - } else { - // Otherwise, just insert the remove button as the - // last child element of the form's container: - row.children(":first").append('' + options.deleteText + ""); - } - // Add delete handler for each row. - row.find("a." + options.deleteCssClass).on('click', inlineDeleteHandler.bind(this)); - }; - - const inlineDeleteHandler = function(e1) { - e1.preventDefault(); - const deleteButton = $(e1.target); - const row = deleteButton.closest('.' + options.formCssClass); - const inlineGroup = row.closest('.inline-group'); - // Remove the parent form containing this button, - // and also remove the relevant row with non-field errors: - const prevRow = row.prev(); - if (prevRow.length && prevRow.hasClass('row-form-errors')) { - prevRow.remove(); - } - row.remove(); - nextIndex -= 1; - // Pass the deleted form to the post-delete callback, if provided. - if (options.removed) { - options.removed(row); - } - document.dispatchEvent(new CustomEvent("formset:removed", { - detail: { - formsetName: options.prefix - } - })); - // Update the TOTAL_FORMS form count. - const forms = $("." + options.formCssClass); - $("#id_" + options.prefix + "-TOTAL_FORMS").val(forms.length); - // Show add button again once below maximum number. - if ((maxForms.val() === '') || (maxForms.val() - forms.length) > 0) { - addButton.parent().show(); - } - // Hide the remove buttons if at min_num. - toggleDeleteButtonVisibility(inlineGroup); - // Also, update names and ids for all remaining form controls so - // they remain in sequence: - let i, formCount; - const updateElementCallback = function() { - updateElementIndex(this, options.prefix, i); - }; - for (i = 0, formCount = forms.length; i < formCount; i++) { - updateElementIndex($(forms).get(i), options.prefix, i); - $(forms.get(i)).find("*").each(updateElementCallback); - } - }; - - const toggleDeleteButtonVisibility = function(inlineGroup) { - if ((minForms.val() !== '') && (minForms.val() - totalForms.val()) >= 0) { - inlineGroup.find('.inline-deletelink').hide(); - } else { - inlineGroup.find('.inline-deletelink').show(); - } - }; - - $this.each(function(i) { - $(this).not("." + options.emptyCssClass).addClass(options.formCssClass); - }); - - // Create the delete buttons for all unsaved inlines: - $this.filter('.' + options.formCssClass + ':not(.has_original):not(.' + options.emptyCssClass + ')').each(function() { - addInlineDeleteButton($(this)); - }); - toggleDeleteButtonVisibility($this); - - // Create the add button, initially hidden. - addButton = options.addButton; - addInlineAddButton(); - - // Show the add button if allowed to add more items. - // Note that max_num = None translates to a blank string. - const showAddButton = maxForms.val() === '' || (maxForms.val() - totalForms.val()) > 0; - if ($this.length && showAddButton) { - addButton.parent().show(); - } else { - addButton.parent().hide(); - } - - return this; - }; - - /* Setup plugin defaults */ - $.fn.formset.defaults = { - prefix: "form", // The form prefix for your django formset - addText: "add another", // Text for the add link - deleteText: "remove", // Text for the delete link - addCssClass: "add-row", // CSS class applied to the add link - deleteCssClass: "delete-row", // CSS class applied to the delete link - emptyCssClass: "empty-row", // CSS class applied to the empty row - formCssClass: "dynamic-form", // CSS class applied to each form in a formset - added: null, // Function called each time a new form is added - removed: null, // Function called each time a form is deleted - addButton: null // Existing add button to use - }; - - - // Tabular inlines --------------------------------------------------------- - $.fn.tabularFormset = function(selector, options) { - const $rows = $(this); - - const reinitDateTimeShortCuts = function() { - // Reinitialize the calendar and clock widgets by force - if (typeof DateTimeShortcuts !== "undefined") { - $(".datetimeshortcuts").remove(); - DateTimeShortcuts.init(); - } - }; - - const updateSelectFilter = function() { - // If any SelectFilter widgets are a part of the new form, - // instantiate a new SelectFilter instance for it. - if (typeof SelectFilter !== 'undefined') { - $('.selectfilter').each(function(index, value) { - SelectFilter.init(value.id, this.dataset.fieldName, false); - }); - $('.selectfilterstacked').each(function(index, value) { - SelectFilter.init(value.id, this.dataset.fieldName, true); - }); - } - }; - - const initPrepopulatedFields = function(row) { - row.find('.prepopulated_field').each(function() { - const field = $(this), - input = field.find('input, select, textarea'), - dependency_list = input.data('dependency_list') || [], - dependencies = []; - $.each(dependency_list, function(i, field_name) { - dependencies.push('#' + row.find('.field-' + field_name).find('input, select, textarea').attr('id')); - }); - if (dependencies.length) { - input.prepopulate(dependencies, input.attr('maxlength')); - } - }); - }; - - $rows.formset({ - prefix: options.prefix, - addText: options.addText, - formCssClass: "dynamic-" + options.prefix, - deleteCssClass: "inline-deletelink", - deleteText: options.deleteText, - emptyCssClass: "empty-form", - added: function(row) { - initPrepopulatedFields(row); - reinitDateTimeShortCuts(); - updateSelectFilter(); - }, - addButton: options.addButton - }); - - return $rows; - }; - - // Stacked inlines --------------------------------------------------------- - $.fn.stackedFormset = function(selector, options) { - const $rows = $(this); - const updateInlineLabel = function(row) { - $(selector).find(".inline_label").each(function(i) { - const count = i + 1; - $(this).html($(this).html().replace(/(#\d+)/g, "#" + count)); - }); - }; - - const reinitDateTimeShortCuts = function() { - // Reinitialize the calendar and clock widgets by force, yuck. - if (typeof DateTimeShortcuts !== "undefined") { - $(".datetimeshortcuts").remove(); - DateTimeShortcuts.init(); - } - }; - - const updateSelectFilter = function() { - // If any SelectFilter widgets were added, instantiate a new instance. - if (typeof SelectFilter !== "undefined") { - $(".selectfilter").each(function(index, value) { - SelectFilter.init(value.id, this.dataset.fieldName, false); - }); - $(".selectfilterstacked").each(function(index, value) { - SelectFilter.init(value.id, this.dataset.fieldName, true); - }); - } - }; - - const initPrepopulatedFields = function(row) { - row.find('.prepopulated_field').each(function() { - const field = $(this), - input = field.find('input, select, textarea'), - dependency_list = input.data('dependency_list') || [], - dependencies = []; - $.each(dependency_list, function(i, field_name) { - // Dependency in a fieldset. - let field_element = row.find('.form-row .field-' + field_name); - // Dependency without a fieldset. - if (!field_element.length) { - field_element = row.find('.form-row.field-' + field_name); - } - dependencies.push('#' + field_element.find('input, select, textarea').attr('id')); - }); - if (dependencies.length) { - input.prepopulate(dependencies, input.attr('maxlength')); - } - }); - }; - - $rows.formset({ - prefix: options.prefix, - addText: options.addText, - formCssClass: "dynamic-" + options.prefix, - deleteCssClass: "inline-deletelink", - deleteText: options.deleteText, - emptyCssClass: "empty-form", - removed: updateInlineLabel, - added: function(row) { - initPrepopulatedFields(row); - reinitDateTimeShortCuts(); - updateSelectFilter(); - updateInlineLabel(row); - }, - addButton: options.addButton - }); - - return $rows; - }; - - $(document).ready(function() { - $(".js-inline-admin-formset").each(function() { - const data = $(this).data(), - inlineOptions = data.inlineFormset; - let selector; - switch(data.inlineType) { - case "stacked": - selector = inlineOptions.name + "-group .inline-related"; - $(selector).stackedFormset(selector, inlineOptions.options); - break; - case "tabular": - selector = inlineOptions.name + "-group .tabular.inline-related tbody:first > tr.form-row"; - $(selector).tabularFormset(selector, inlineOptions.options); - break; - } - }); - }); -} diff --git a/django/staticfiles/admin/js/jquery.init.js b/django/staticfiles/admin/js/jquery.init.js deleted file mode 100644 index f40b27f4..00000000 --- a/django/staticfiles/admin/js/jquery.init.js +++ /dev/null @@ -1,8 +0,0 @@ -/*global jQuery:false*/ -'use strict'; -/* Puts the included jQuery into our own namespace using noConflict and passing - * it 'true'. This ensures that the included jQuery doesn't pollute the global - * namespace (i.e. this preserves pre-existing values for both window.$ and - * window.jQuery). - */ -window.django = {jQuery: jQuery.noConflict(true)}; diff --git a/django/staticfiles/admin/js/nav_sidebar.js b/django/staticfiles/admin/js/nav_sidebar.js deleted file mode 100644 index 7e735db1..00000000 --- a/django/staticfiles/admin/js/nav_sidebar.js +++ /dev/null @@ -1,79 +0,0 @@ -'use strict'; -{ - const toggleNavSidebar = document.getElementById('toggle-nav-sidebar'); - if (toggleNavSidebar !== null) { - const navSidebar = document.getElementById('nav-sidebar'); - const main = document.getElementById('main'); - let navSidebarIsOpen = localStorage.getItem('django.admin.navSidebarIsOpen'); - if (navSidebarIsOpen === null) { - navSidebarIsOpen = 'true'; - } - main.classList.toggle('shifted', navSidebarIsOpen === 'true'); - navSidebar.setAttribute('aria-expanded', navSidebarIsOpen); - - toggleNavSidebar.addEventListener('click', function() { - if (navSidebarIsOpen === 'true') { - navSidebarIsOpen = 'false'; - } else { - navSidebarIsOpen = 'true'; - } - localStorage.setItem('django.admin.navSidebarIsOpen', navSidebarIsOpen); - main.classList.toggle('shifted'); - navSidebar.setAttribute('aria-expanded', navSidebarIsOpen); - }); - } - - function initSidebarQuickFilter() { - const options = []; - const navSidebar = document.getElementById('nav-sidebar'); - if (!navSidebar) { - return; - } - navSidebar.querySelectorAll('th[scope=row] a').forEach((container) => { - options.push({title: container.innerHTML, node: container}); - }); - - function checkValue(event) { - let filterValue = event.target.value; - if (filterValue) { - filterValue = filterValue.toLowerCase(); - } - if (event.key === 'Escape') { - filterValue = ''; - event.target.value = ''; // clear input - } - let matches = false; - for (const o of options) { - let displayValue = ''; - if (filterValue) { - if (o.title.toLowerCase().indexOf(filterValue) === -1) { - displayValue = 'none'; - } else { - matches = true; - } - } - // show/hide parent - o.node.parentNode.parentNode.style.display = displayValue; - } - if (!filterValue || matches) { - event.target.classList.remove('no-results'); - } else { - event.target.classList.add('no-results'); - } - sessionStorage.setItem('django.admin.navSidebarFilterValue', filterValue); - } - - const nav = document.getElementById('nav-filter'); - nav.addEventListener('change', checkValue, false); - nav.addEventListener('input', checkValue, false); - nav.addEventListener('keyup', checkValue, false); - - const storedValue = sessionStorage.getItem('django.admin.navSidebarFilterValue'); - if (storedValue) { - nav.value = storedValue; - checkValue({target: nav, key: ''}); - } - } - window.initSidebarQuickFilter = initSidebarQuickFilter; - initSidebarQuickFilter(); -} diff --git a/django/staticfiles/admin/js/popup_response.js b/django/staticfiles/admin/js/popup_response.js deleted file mode 100644 index 2b1d3dd3..00000000 --- a/django/staticfiles/admin/js/popup_response.js +++ /dev/null @@ -1,16 +0,0 @@ -/*global opener */ -'use strict'; -{ - const initData = JSON.parse(document.getElementById('django-admin-popup-response-constants').dataset.popupResponse); - switch(initData.action) { - case 'change': - opener.dismissChangeRelatedObjectPopup(window, initData.value, initData.obj, initData.new_value); - break; - case 'delete': - opener.dismissDeleteRelatedObjectPopup(window, initData.value); - break; - default: - opener.dismissAddRelatedObjectPopup(window, initData.value, initData.obj); - break; - } -} diff --git a/django/staticfiles/admin/js/prepopulate.js b/django/staticfiles/admin/js/prepopulate.js deleted file mode 100644 index 89e95ab4..00000000 --- a/django/staticfiles/admin/js/prepopulate.js +++ /dev/null @@ -1,43 +0,0 @@ -/*global URLify*/ -'use strict'; -{ - const $ = django.jQuery; - $.fn.prepopulate = function(dependencies, maxLength, allowUnicode) { - /* - Depends on urlify.js - Populates a selected field with the values of the dependent fields, - URLifies and shortens the string. - dependencies - array of dependent fields ids - maxLength - maximum length of the URLify'd string - allowUnicode - Unicode support of the URLify'd string - */ - return this.each(function() { - const prepopulatedField = $(this); - - const populate = function() { - // Bail if the field's value has been changed by the user - if (prepopulatedField.data('_changed')) { - return; - } - - const values = []; - $.each(dependencies, function(i, field) { - field = $(field); - if (field.val().length > 0) { - values.push(field.val()); - } - }); - prepopulatedField.val(URLify(values.join(' '), maxLength, allowUnicode)); - }; - - prepopulatedField.data('_changed', false); - prepopulatedField.on('change', function() { - prepopulatedField.data('_changed', true); - }); - - if (!prepopulatedField.val()) { - $(dependencies.join(',')).on('keyup change focus', populate); - } - }); - }; -} diff --git a/django/staticfiles/admin/js/prepopulate_init.js b/django/staticfiles/admin/js/prepopulate_init.js deleted file mode 100644 index a58841f0..00000000 --- a/django/staticfiles/admin/js/prepopulate_init.js +++ /dev/null @@ -1,15 +0,0 @@ -'use strict'; -{ - const $ = django.jQuery; - const fields = $('#django-admin-prepopulated-fields-constants').data('prepopulatedFields'); - $.each(fields, function(index, field) { - $( - '.empty-form .form-row .field-' + field.name + - ', .empty-form.form-row .field-' + field.name + - ', .empty-form .form-row.field-' + field.name - ).addClass('prepopulated_field'); - $(field.id).data('dependency_list', field.dependency_list).prepopulate( - field.dependency_ids, field.maxLength, field.allowUnicode - ); - }); -} diff --git a/django/staticfiles/admin/js/theme.js b/django/staticfiles/admin/js/theme.js deleted file mode 100644 index 794cd15f..00000000 --- a/django/staticfiles/admin/js/theme.js +++ /dev/null @@ -1,56 +0,0 @@ -'use strict'; -{ - window.addEventListener('load', function(e) { - - function setTheme(mode) { - if (mode !== "light" && mode !== "dark" && mode !== "auto") { - console.error(`Got invalid theme mode: ${mode}. Resetting to auto.`); - mode = "auto"; - } - document.documentElement.dataset.theme = mode; - localStorage.setItem("theme", mode); - } - - function cycleTheme() { - const currentTheme = localStorage.getItem("theme") || "auto"; - const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; - - if (prefersDark) { - // Auto (dark) -> Light -> Dark - if (currentTheme === "auto") { - setTheme("light"); - } else if (currentTheme === "light") { - setTheme("dark"); - } else { - setTheme("auto"); - } - } else { - // Auto (light) -> Dark -> Light - if (currentTheme === "auto") { - setTheme("dark"); - } else if (currentTheme === "dark") { - setTheme("light"); - } else { - setTheme("auto"); - } - } - } - - function initTheme() { - // set theme defined in localStorage if there is one, or fallback to auto mode - const currentTheme = localStorage.getItem("theme"); - currentTheme ? setTheme(currentTheme) : setTheme("auto"); - } - - function setupTheme() { - // Attach event handlers for toggling themes - const buttons = document.getElementsByClassName("theme-toggle"); - Array.from(buttons).forEach((btn) => { - btn.addEventListener("click", cycleTheme); - }); - initTheme(); - } - - setupTheme(); - }); -} diff --git a/django/staticfiles/admin/js/urlify.js b/django/staticfiles/admin/js/urlify.js deleted file mode 100644 index 9fc04094..00000000 --- a/django/staticfiles/admin/js/urlify.js +++ /dev/null @@ -1,169 +0,0 @@ -/*global XRegExp*/ -'use strict'; -{ - const LATIN_MAP = { - 'À': 'A', 'Á': 'A', 'Â': 'A', 'Ã': 'A', 'Ä': 'A', 'Å': 'A', 'Æ': 'AE', - 'Ç': 'C', 'È': 'E', 'É': 'E', 'Ê': 'E', 'Ë': 'E', 'Ì': 'I', 'Í': 'I', - 'Î': 'I', 'Ï': 'I', 'Ð': 'D', 'Ñ': 'N', 'Ò': 'O', 'Ó': 'O', 'Ô': 'O', - 'Õ': 'O', 'Ö': 'O', 'Ő': 'O', 'Ø': 'O', 'Ù': 'U', 'Ú': 'U', 'Û': 'U', - 'Ü': 'U', 'Ű': 'U', 'Ý': 'Y', 'Þ': 'TH', 'Ÿ': 'Y', 'ß': 'ss', 'à': 'a', - 'á': 'a', 'â': 'a', 'ã': 'a', 'ä': 'a', 'å': 'a', 'æ': 'ae', 'ç': 'c', - 'è': 'e', 'é': 'e', 'ê': 'e', 'ë': 'e', 'ì': 'i', 'í': 'i', 'î': 'i', - 'ï': 'i', 'ð': 'd', 'ñ': 'n', 'ò': 'o', 'ó': 'o', 'ô': 'o', 'õ': 'o', - 'ö': 'o', 'ő': 'o', 'ø': 'o', 'ù': 'u', 'ú': 'u', 'û': 'u', 'ü': 'u', - 'ű': 'u', 'ý': 'y', 'þ': 'th', 'ÿ': 'y' - }; - const LATIN_SYMBOLS_MAP = { - '©': '(c)' - }; - const GREEK_MAP = { - 'α': 'a', 'β': 'b', 'γ': 'g', 'δ': 'd', 'ε': 'e', 'ζ': 'z', 'η': 'h', - 'θ': '8', 'ι': 'i', 'κ': 'k', 'λ': 'l', 'μ': 'm', 'ν': 'n', 'ξ': '3', - 'ο': 'o', 'π': 'p', 'ρ': 'r', 'σ': 's', 'τ': 't', 'υ': 'y', 'φ': 'f', - 'χ': 'x', 'ψ': 'ps', 'ω': 'w', 'ά': 'a', 'έ': 'e', 'ί': 'i', 'ό': 'o', - 'ύ': 'y', 'ή': 'h', 'ώ': 'w', 'ς': 's', 'ϊ': 'i', 'ΰ': 'y', 'ϋ': 'y', - 'ΐ': 'i', 'Α': 'A', 'Β': 'B', 'Γ': 'G', 'Δ': 'D', 'Ε': 'E', 'Ζ': 'Z', - 'Η': 'H', 'Θ': '8', 'Ι': 'I', 'Κ': 'K', 'Λ': 'L', 'Μ': 'M', 'Ν': 'N', - 'Ξ': '3', 'Ο': 'O', 'Π': 'P', 'Ρ': 'R', 'Σ': 'S', 'Τ': 'T', 'Υ': 'Y', - 'Φ': 'F', 'Χ': 'X', 'Ψ': 'PS', 'Ω': 'W', 'Ά': 'A', 'Έ': 'E', 'Ί': 'I', - 'Ό': 'O', 'Ύ': 'Y', 'Ή': 'H', 'Ώ': 'W', 'Ϊ': 'I', 'Ϋ': 'Y' - }; - const TURKISH_MAP = { - 'ş': 's', 'Ş': 'S', 'ı': 'i', 'İ': 'I', 'ç': 'c', 'Ç': 'C', 'ü': 'u', - 'Ü': 'U', 'ö': 'o', 'Ö': 'O', 'ğ': 'g', 'Ğ': 'G' - }; - const ROMANIAN_MAP = { - 'ă': 'a', 'î': 'i', 'ș': 's', 'ț': 't', 'â': 'a', - 'Ă': 'A', 'Î': 'I', 'Ș': 'S', 'Ț': 'T', 'Â': 'A' - }; - const RUSSIAN_MAP = { - 'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo', - 'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'j', 'к': 'k', 'л': 'l', 'м': 'm', - 'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u', - 'ф': 'f', 'х': 'h', 'ц': 'c', 'ч': 'ch', 'ш': 'sh', 'щ': 'sh', 'ъ': '', - 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya', - 'А': 'A', 'Б': 'B', 'В': 'V', 'Г': 'G', 'Д': 'D', 'Е': 'E', 'Ё': 'Yo', - 'Ж': 'Zh', 'З': 'Z', 'И': 'I', 'Й': 'J', 'К': 'K', 'Л': 'L', 'М': 'M', - 'Н': 'N', 'О': 'O', 'П': 'P', 'Р': 'R', 'С': 'S', 'Т': 'T', 'У': 'U', - 'Ф': 'F', 'Х': 'H', 'Ц': 'C', 'Ч': 'Ch', 'Ш': 'Sh', 'Щ': 'Sh', 'Ъ': '', - 'Ы': 'Y', 'Ь': '', 'Э': 'E', 'Ю': 'Yu', 'Я': 'Ya' - }; - const UKRAINIAN_MAP = { - 'Є': 'Ye', 'І': 'I', 'Ї': 'Yi', 'Ґ': 'G', 'є': 'ye', 'і': 'i', - 'ї': 'yi', 'ґ': 'g' - }; - const CZECH_MAP = { - 'č': 'c', 'ď': 'd', 'ě': 'e', 'ň': 'n', 'ř': 'r', 'š': 's', 'ť': 't', - 'ů': 'u', 'ž': 'z', 'Č': 'C', 'Ď': 'D', 'Ě': 'E', 'Ň': 'N', 'Ř': 'R', - 'Š': 'S', 'Ť': 'T', 'Ů': 'U', 'Ž': 'Z' - }; - const SLOVAK_MAP = { - 'á': 'a', 'ä': 'a', 'č': 'c', 'ď': 'd', 'é': 'e', 'í': 'i', 'ľ': 'l', - 'ĺ': 'l', 'ň': 'n', 'ó': 'o', 'ô': 'o', 'ŕ': 'r', 'š': 's', 'ť': 't', - 'ú': 'u', 'ý': 'y', 'ž': 'z', - 'Á': 'a', 'Ä': 'A', 'Č': 'C', 'Ď': 'D', 'É': 'E', 'Í': 'I', 'Ľ': 'L', - 'Ĺ': 'L', 'Ň': 'N', 'Ó': 'O', 'Ô': 'O', 'Ŕ': 'R', 'Š': 'S', 'Ť': 'T', - 'Ú': 'U', 'Ý': 'Y', 'Ž': 'Z' - }; - const POLISH_MAP = { - 'ą': 'a', 'ć': 'c', 'ę': 'e', 'ł': 'l', 'ń': 'n', 'ó': 'o', 'ś': 's', - 'ź': 'z', 'ż': 'z', - 'Ą': 'A', 'Ć': 'C', 'Ę': 'E', 'Ł': 'L', 'Ń': 'N', 'Ó': 'O', 'Ś': 'S', - 'Ź': 'Z', 'Ż': 'Z' - }; - const LATVIAN_MAP = { - 'ā': 'a', 'č': 'c', 'ē': 'e', 'ģ': 'g', 'ī': 'i', 'ķ': 'k', 'ļ': 'l', - 'ņ': 'n', 'š': 's', 'ū': 'u', 'ž': 'z', - 'Ā': 'A', 'Č': 'C', 'Ē': 'E', 'Ģ': 'G', 'Ī': 'I', 'Ķ': 'K', 'Ļ': 'L', - 'Ņ': 'N', 'Š': 'S', 'Ū': 'U', 'Ž': 'Z' - }; - const ARABIC_MAP = { - 'أ': 'a', 'ب': 'b', 'ت': 't', 'ث': 'th', 'ج': 'g', 'ح': 'h', 'خ': 'kh', 'د': 'd', - 'ذ': 'th', 'ر': 'r', 'ز': 'z', 'س': 's', 'ش': 'sh', 'ص': 's', 'ض': 'd', 'ط': 't', - 'ظ': 'th', 'ع': 'aa', 'غ': 'gh', 'ف': 'f', 'ق': 'k', 'ك': 'k', 'ل': 'l', 'م': 'm', - 'ن': 'n', 'ه': 'h', 'و': 'o', 'ي': 'y' - }; - const LITHUANIAN_MAP = { - 'ą': 'a', 'č': 'c', 'ę': 'e', 'ė': 'e', 'į': 'i', 'š': 's', 'ų': 'u', - 'ū': 'u', 'ž': 'z', - 'Ą': 'A', 'Č': 'C', 'Ę': 'E', 'Ė': 'E', 'Į': 'I', 'Š': 'S', 'Ų': 'U', - 'Ū': 'U', 'Ž': 'Z' - }; - const SERBIAN_MAP = { - 'ђ': 'dj', 'ј': 'j', 'љ': 'lj', 'њ': 'nj', 'ћ': 'c', 'џ': 'dz', - 'đ': 'dj', 'Ђ': 'Dj', 'Ј': 'j', 'Љ': 'Lj', 'Њ': 'Nj', 'Ћ': 'C', - 'Џ': 'Dz', 'Đ': 'Dj' - }; - const AZERBAIJANI_MAP = { - 'ç': 'c', 'ə': 'e', 'ğ': 'g', 'ı': 'i', 'ö': 'o', 'ş': 's', 'ü': 'u', - 'Ç': 'C', 'Ə': 'E', 'Ğ': 'G', 'İ': 'I', 'Ö': 'O', 'Ş': 'S', 'Ü': 'U' - }; - const GEORGIAN_MAP = { - 'ა': 'a', 'ბ': 'b', 'გ': 'g', 'დ': 'd', 'ე': 'e', 'ვ': 'v', 'ზ': 'z', - 'თ': 't', 'ი': 'i', 'კ': 'k', 'ლ': 'l', 'მ': 'm', 'ნ': 'n', 'ო': 'o', - 'პ': 'p', 'ჟ': 'j', 'რ': 'r', 'ს': 's', 'ტ': 't', 'უ': 'u', 'ფ': 'f', - 'ქ': 'q', 'ღ': 'g', 'ყ': 'y', 'შ': 'sh', 'ჩ': 'ch', 'ც': 'c', 'ძ': 'dz', - 'წ': 'w', 'ჭ': 'ch', 'ხ': 'x', 'ჯ': 'j', 'ჰ': 'h' - }; - - const ALL_DOWNCODE_MAPS = [ - LATIN_MAP, - LATIN_SYMBOLS_MAP, - GREEK_MAP, - TURKISH_MAP, - ROMANIAN_MAP, - RUSSIAN_MAP, - UKRAINIAN_MAP, - CZECH_MAP, - SLOVAK_MAP, - POLISH_MAP, - LATVIAN_MAP, - ARABIC_MAP, - LITHUANIAN_MAP, - SERBIAN_MAP, - AZERBAIJANI_MAP, - GEORGIAN_MAP - ]; - - const Downcoder = { - 'Initialize': function() { - if (Downcoder.map) { // already made - return; - } - Downcoder.map = {}; - for (const lookup of ALL_DOWNCODE_MAPS) { - Object.assign(Downcoder.map, lookup); - } - Downcoder.regex = new RegExp(Object.keys(Downcoder.map).join('|'), 'g'); - } - }; - - function downcode(slug) { - Downcoder.Initialize(); - return slug.replace(Downcoder.regex, function(m) { - return Downcoder.map[m]; - }); - } - - - function URLify(s, num_chars, allowUnicode) { - // changes, e.g., "Petty theft" to "petty-theft" - if (!allowUnicode) { - s = downcode(s); - } - s = s.toLowerCase(); // convert to lowercase - // if downcode doesn't hit, the char will be stripped here - if (allowUnicode) { - // Keep Unicode letters including both lowercase and uppercase - // characters, whitespace, and dash; remove other characters. - s = XRegExp.replace(s, XRegExp('[^-_\\p{L}\\p{N}\\s]', 'g'), ''); - } else { - s = s.replace(/[^-\w\s]/g, ''); // remove unneeded chars - } - s = s.replace(/^\s+|\s+$/g, ''); // trim leading/trailing spaces - s = s.replace(/[-\s]+/g, '-'); // convert spaces to hyphens - s = s.substring(0, num_chars); // trim to first num_chars chars - return s.replace(/-+$/g, ''); // trim any trailing hyphens - } - window.URLify = URLify; -} diff --git a/django/staticfiles/admin/js/vendor/jquery/LICENSE.txt b/django/staticfiles/admin/js/vendor/jquery/LICENSE.txt deleted file mode 100644 index f642c3f7..00000000 --- a/django/staticfiles/admin/js/vendor/jquery/LICENSE.txt +++ /dev/null @@ -1,20 +0,0 @@ -Copyright OpenJS Foundation and other contributors, https://openjsf.org/ - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/django/staticfiles/admin/js/vendor/jquery/jquery.js b/django/staticfiles/admin/js/vendor/jquery/jquery.js deleted file mode 100644 index 7f35c11b..00000000 --- a/django/staticfiles/admin/js/vendor/jquery/jquery.js +++ /dev/null @@ -1,10965 +0,0 @@ -/*! - * jQuery JavaScript Library v3.6.4 - * https://jquery.com/ - * - * Includes Sizzle.js - * https://sizzlejs.com/ - * - * Copyright OpenJS Foundation and other contributors - * Released under the MIT license - * https://jquery.org/license - * - * Date: 2023-03-08T15:28Z - */ -( function( global, factory ) { - - "use strict"; - - if ( typeof module === "object" && typeof module.exports === "object" ) { - - // For CommonJS and CommonJS-like environments where a proper `window` - // is present, execute the factory and get jQuery. - // For environments that do not have a `window` with a `document` - // (such as Node.js), expose a factory as module.exports. - // This accentuates the need for the creation of a real `window`. - // e.g. var jQuery = require("jquery")(window); - // See ticket trac-14549 for more info. - module.exports = global.document ? - factory( global, true ) : - function( w ) { - if ( !w.document ) { - throw new Error( "jQuery requires a window with a document" ); - } - return factory( w ); - }; - } else { - factory( global ); - } - -// Pass this if window is not defined yet -} )( typeof window !== "undefined" ? window : this, function( window, noGlobal ) { - -// Edge <= 12 - 13+, Firefox <=18 - 45+, IE 10 - 11, Safari 5.1 - 9+, iOS 6 - 9.1 -// throw exceptions when non-strict code (e.g., ASP.NET 4.5) accesses strict mode -// arguments.callee.caller (trac-13335). But as of jQuery 3.0 (2016), strict mode should be common -// enough that all such attempts are guarded in a try block. -"use strict"; - -var arr = []; - -var getProto = Object.getPrototypeOf; - -var slice = arr.slice; - -var flat = arr.flat ? function( array ) { - return arr.flat.call( array ); -} : function( array ) { - return arr.concat.apply( [], array ); -}; - - -var push = arr.push; - -var indexOf = arr.indexOf; - -var class2type = {}; - -var toString = class2type.toString; - -var hasOwn = class2type.hasOwnProperty; - -var fnToString = hasOwn.toString; - -var ObjectFunctionString = fnToString.call( Object ); - -var support = {}; - -var isFunction = function isFunction( obj ) { - - // Support: Chrome <=57, Firefox <=52 - // In some browsers, typeof returns "function" for HTML elements - // (i.e., `typeof document.createElement( "object" ) === "function"`). - // We don't want to classify *any* DOM node as a function. - // Support: QtWeb <=3.8.5, WebKit <=534.34, wkhtmltopdf tool <=0.12.5 - // Plus for old WebKit, typeof returns "function" for HTML collections - // (e.g., `typeof document.getElementsByTagName("div") === "function"`). (gh-4756) - return typeof obj === "function" && typeof obj.nodeType !== "number" && - typeof obj.item !== "function"; - }; - - -var isWindow = function isWindow( obj ) { - return obj != null && obj === obj.window; - }; - - -var document = window.document; - - - - var preservedScriptAttributes = { - type: true, - src: true, - nonce: true, - noModule: true - }; - - function DOMEval( code, node, doc ) { - doc = doc || document; - - var i, val, - script = doc.createElement( "script" ); - - script.text = code; - if ( node ) { - for ( i in preservedScriptAttributes ) { - - // Support: Firefox 64+, Edge 18+ - // Some browsers don't support the "nonce" property on scripts. - // On the other hand, just using `getAttribute` is not enough as - // the `nonce` attribute is reset to an empty string whenever it - // becomes browsing-context connected. - // See https://github.com/whatwg/html/issues/2369 - // See https://html.spec.whatwg.org/#nonce-attributes - // The `node.getAttribute` check was added for the sake of - // `jQuery.globalEval` so that it can fake a nonce-containing node - // via an object. - val = node[ i ] || node.getAttribute && node.getAttribute( i ); - if ( val ) { - script.setAttribute( i, val ); - } - } - } - doc.head.appendChild( script ).parentNode.removeChild( script ); - } - - -function toType( obj ) { - if ( obj == null ) { - return obj + ""; - } - - // Support: Android <=2.3 only (functionish RegExp) - return typeof obj === "object" || typeof obj === "function" ? - class2type[ toString.call( obj ) ] || "object" : - typeof obj; -} -/* global Symbol */ -// Defining this global in .eslintrc.json would create a danger of using the global -// unguarded in another place, it seems safer to define global only for this module - - - -var - version = "3.6.4", - - // Define a local copy of jQuery - jQuery = function( selector, context ) { - - // The jQuery object is actually just the init constructor 'enhanced' - // Need init if jQuery is called (just allow error to be thrown if not included) - return new jQuery.fn.init( selector, context ); - }; - -jQuery.fn = jQuery.prototype = { - - // The current version of jQuery being used - jquery: version, - - constructor: jQuery, - - // The default length of a jQuery object is 0 - length: 0, - - toArray: function() { - return slice.call( this ); - }, - - // Get the Nth element in the matched element set OR - // Get the whole matched element set as a clean array - get: function( num ) { - - // Return all the elements in a clean array - if ( num == null ) { - return slice.call( this ); - } - - // Return just the one element from the set - return num < 0 ? this[ num + this.length ] : this[ num ]; - }, - - // Take an array of elements and push it onto the stack - // (returning the new matched element set) - pushStack: function( elems ) { - - // Build a new jQuery matched element set - var ret = jQuery.merge( this.constructor(), elems ); - - // Add the old object onto the stack (as a reference) - ret.prevObject = this; - - // Return the newly-formed element set - return ret; - }, - - // Execute a callback for every element in the matched set. - each: function( callback ) { - return jQuery.each( this, callback ); - }, - - map: function( callback ) { - return this.pushStack( jQuery.map( this, function( elem, i ) { - return callback.call( elem, i, elem ); - } ) ); - }, - - slice: function() { - return this.pushStack( slice.apply( this, arguments ) ); - }, - - first: function() { - return this.eq( 0 ); - }, - - last: function() { - return this.eq( -1 ); - }, - - even: function() { - return this.pushStack( jQuery.grep( this, function( _elem, i ) { - return ( i + 1 ) % 2; - } ) ); - }, - - odd: function() { - return this.pushStack( jQuery.grep( this, function( _elem, i ) { - return i % 2; - } ) ); - }, - - eq: function( i ) { - var len = this.length, - j = +i + ( i < 0 ? len : 0 ); - return this.pushStack( j >= 0 && j < len ? [ this[ j ] ] : [] ); - }, - - end: function() { - return this.prevObject || this.constructor(); - }, - - // For internal use only. - // Behaves like an Array's method, not like a jQuery method. - push: push, - sort: arr.sort, - splice: arr.splice -}; - -jQuery.extend = jQuery.fn.extend = function() { - var options, name, src, copy, copyIsArray, clone, - target = arguments[ 0 ] || {}, - i = 1, - length = arguments.length, - deep = false; - - // Handle a deep copy situation - if ( typeof target === "boolean" ) { - deep = target; - - // Skip the boolean and the target - target = arguments[ i ] || {}; - i++; - } - - // Handle case when target is a string or something (possible in deep copy) - if ( typeof target !== "object" && !isFunction( target ) ) { - target = {}; - } - - // Extend jQuery itself if only one argument is passed - if ( i === length ) { - target = this; - i--; - } - - for ( ; i < length; i++ ) { - - // Only deal with non-null/undefined values - if ( ( options = arguments[ i ] ) != null ) { - - // Extend the base object - for ( name in options ) { - copy = options[ name ]; - - // Prevent Object.prototype pollution - // Prevent never-ending loop - if ( name === "__proto__" || target === copy ) { - continue; - } - - // Recurse if we're merging plain objects or arrays - if ( deep && copy && ( jQuery.isPlainObject( copy ) || - ( copyIsArray = Array.isArray( copy ) ) ) ) { - src = target[ name ]; - - // Ensure proper type for the source value - if ( copyIsArray && !Array.isArray( src ) ) { - clone = []; - } else if ( !copyIsArray && !jQuery.isPlainObject( src ) ) { - clone = {}; - } else { - clone = src; - } - copyIsArray = false; - - // Never move original objects, clone them - target[ name ] = jQuery.extend( deep, clone, copy ); - - // Don't bring in undefined values - } else if ( copy !== undefined ) { - target[ name ] = copy; - } - } - } - } - - // Return the modified object - return target; -}; - -jQuery.extend( { - - // Unique for each copy of jQuery on the page - expando: "jQuery" + ( version + Math.random() ).replace( /\D/g, "" ), - - // Assume jQuery is ready without the ready module - isReady: true, - - error: function( msg ) { - throw new Error( msg ); - }, - - noop: function() {}, - - isPlainObject: function( obj ) { - var proto, Ctor; - - // Detect obvious negatives - // Use toString instead of jQuery.type to catch host objects - if ( !obj || toString.call( obj ) !== "[object Object]" ) { - return false; - } - - proto = getProto( obj ); - - // Objects with no prototype (e.g., `Object.create( null )`) are plain - if ( !proto ) { - return true; - } - - // Objects with prototype are plain iff they were constructed by a global Object function - Ctor = hasOwn.call( proto, "constructor" ) && proto.constructor; - return typeof Ctor === "function" && fnToString.call( Ctor ) === ObjectFunctionString; - }, - - isEmptyObject: function( obj ) { - var name; - - for ( name in obj ) { - return false; - } - return true; - }, - - // Evaluates a script in a provided context; falls back to the global one - // if not specified. - globalEval: function( code, options, doc ) { - DOMEval( code, { nonce: options && options.nonce }, doc ); - }, - - each: function( obj, callback ) { - var length, i = 0; - - if ( isArrayLike( obj ) ) { - length = obj.length; - for ( ; i < length; i++ ) { - if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) { - break; - } - } - } else { - for ( i in obj ) { - if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) { - break; - } - } - } - - return obj; - }, - - // results is for internal usage only - makeArray: function( arr, results ) { - var ret = results || []; - - if ( arr != null ) { - if ( isArrayLike( Object( arr ) ) ) { - jQuery.merge( ret, - typeof arr === "string" ? - [ arr ] : arr - ); - } else { - push.call( ret, arr ); - } - } - - return ret; - }, - - inArray: function( elem, arr, i ) { - return arr == null ? -1 : indexOf.call( arr, elem, i ); - }, - - // Support: Android <=4.0 only, PhantomJS 1 only - // push.apply(_, arraylike) throws on ancient WebKit - merge: function( first, second ) { - var len = +second.length, - j = 0, - i = first.length; - - for ( ; j < len; j++ ) { - first[ i++ ] = second[ j ]; - } - - first.length = i; - - return first; - }, - - grep: function( elems, callback, invert ) { - var callbackInverse, - matches = [], - i = 0, - length = elems.length, - callbackExpect = !invert; - - // Go through the array, only saving the items - // that pass the validator function - for ( ; i < length; i++ ) { - callbackInverse = !callback( elems[ i ], i ); - if ( callbackInverse !== callbackExpect ) { - matches.push( elems[ i ] ); - } - } - - return matches; - }, - - // arg is for internal usage only - map: function( elems, callback, arg ) { - var length, value, - i = 0, - ret = []; - - // Go through the array, translating each of the items to their new values - if ( isArrayLike( elems ) ) { - length = elems.length; - for ( ; i < length; i++ ) { - value = callback( elems[ i ], i, arg ); - - if ( value != null ) { - ret.push( value ); - } - } - - // Go through every key on the object, - } else { - for ( i in elems ) { - value = callback( elems[ i ], i, arg ); - - if ( value != null ) { - ret.push( value ); - } - } - } - - // Flatten any nested arrays - return flat( ret ); - }, - - // A global GUID counter for objects - guid: 1, - - // jQuery.support is not used in Core but other projects attach their - // properties to it so it needs to exist. - support: support -} ); - -if ( typeof Symbol === "function" ) { - jQuery.fn[ Symbol.iterator ] = arr[ Symbol.iterator ]; -} - -// Populate the class2type map -jQuery.each( "Boolean Number String Function Array Date RegExp Object Error Symbol".split( " " ), - function( _i, name ) { - class2type[ "[object " + name + "]" ] = name.toLowerCase(); - } ); - -function isArrayLike( obj ) { - - // Support: real iOS 8.2 only (not reproducible in simulator) - // `in` check used to prevent JIT error (gh-2145) - // hasOwn isn't used here due to false negatives - // regarding Nodelist length in IE - var length = !!obj && "length" in obj && obj.length, - type = toType( obj ); - - if ( isFunction( obj ) || isWindow( obj ) ) { - return false; - } - - return type === "array" || length === 0 || - typeof length === "number" && length > 0 && ( length - 1 ) in obj; -} -var Sizzle = -/*! - * Sizzle CSS Selector Engine v2.3.10 - * https://sizzlejs.com/ - * - * Copyright JS Foundation and other contributors - * Released under the MIT license - * https://js.foundation/ - * - * Date: 2023-02-14 - */ -( function( window ) { -var i, - support, - Expr, - getText, - isXML, - tokenize, - compile, - select, - outermostContext, - sortInput, - hasDuplicate, - - // Local document vars - setDocument, - document, - docElem, - documentIsHTML, - rbuggyQSA, - rbuggyMatches, - matches, - contains, - - // Instance-specific data - expando = "sizzle" + 1 * new Date(), - preferredDoc = window.document, - dirruns = 0, - done = 0, - classCache = createCache(), - tokenCache = createCache(), - compilerCache = createCache(), - nonnativeSelectorCache = createCache(), - sortOrder = function( a, b ) { - if ( a === b ) { - hasDuplicate = true; - } - return 0; - }, - - // Instance methods - hasOwn = ( {} ).hasOwnProperty, - arr = [], - pop = arr.pop, - pushNative = arr.push, - push = arr.push, - slice = arr.slice, - - // Use a stripped-down indexOf as it's faster than native - // https://jsperf.com/thor-indexof-vs-for/5 - indexOf = function( list, elem ) { - var i = 0, - len = list.length; - for ( ; i < len; i++ ) { - if ( list[ i ] === elem ) { - return i; - } - } - return -1; - }, - - booleans = "checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|" + - "ismap|loop|multiple|open|readonly|required|scoped", - - // Regular expressions - - // http://www.w3.org/TR/css3-selectors/#whitespace - whitespace = "[\\x20\\t\\r\\n\\f]", - - // https://www.w3.org/TR/css-syntax-3/#ident-token-diagram - identifier = "(?:\\\\[\\da-fA-F]{1,6}" + whitespace + - "?|\\\\[^\\r\\n\\f]|[\\w-]|[^\0-\\x7f])+", - - // Attribute selectors: http://www.w3.org/TR/selectors/#attribute-selectors - attributes = "\\[" + whitespace + "*(" + identifier + ")(?:" + whitespace + - - // Operator (capture 2) - "*([*^$|!~]?=)" + whitespace + - - // "Attribute values must be CSS identifiers [capture 5] - // or strings [capture 3 or capture 4]" - "*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|(" + identifier + "))|)" + - whitespace + "*\\]", - - pseudos = ":(" + identifier + ")(?:\\((" + - - // To reduce the number of selectors needing tokenize in the preFilter, prefer arguments: - // 1. quoted (capture 3; capture 4 or capture 5) - "('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|" + - - // 2. simple (capture 6) - "((?:\\\\.|[^\\\\()[\\]]|" + attributes + ")*)|" + - - // 3. anything else (capture 2) - ".*" + - ")\\)|)", - - // Leading and non-escaped trailing whitespace, capturing some non-whitespace characters preceding the latter - rwhitespace = new RegExp( whitespace + "+", "g" ), - rtrim = new RegExp( "^" + whitespace + "+|((?:^|[^\\\\])(?:\\\\.)*)" + - whitespace + "+$", "g" ), - - rcomma = new RegExp( "^" + whitespace + "*," + whitespace + "*" ), - rleadingCombinator = new RegExp( "^" + whitespace + "*([>+~]|" + whitespace + ")" + whitespace + - "*" ), - rdescend = new RegExp( whitespace + "|>" ), - - rpseudo = new RegExp( pseudos ), - ridentifier = new RegExp( "^" + identifier + "$" ), - - matchExpr = { - "ID": new RegExp( "^#(" + identifier + ")" ), - "CLASS": new RegExp( "^\\.(" + identifier + ")" ), - "TAG": new RegExp( "^(" + identifier + "|[*])" ), - "ATTR": new RegExp( "^" + attributes ), - "PSEUDO": new RegExp( "^" + pseudos ), - "CHILD": new RegExp( "^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\(" + - whitespace + "*(even|odd|(([+-]|)(\\d*)n|)" + whitespace + "*(?:([+-]|)" + - whitespace + "*(\\d+)|))" + whitespace + "*\\)|)", "i" ), - "bool": new RegExp( "^(?:" + booleans + ")$", "i" ), - - // For use in libraries implementing .is() - // We use this for POS matching in `select` - "needsContext": new RegExp( "^" + whitespace + - "*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\(" + whitespace + - "*((?:-\\d)?\\d*)" + whitespace + "*\\)|)(?=[^-]|$)", "i" ) - }, - - rhtml = /HTML$/i, - rinputs = /^(?:input|select|textarea|button)$/i, - rheader = /^h\d$/i, - - rnative = /^[^{]+\{\s*\[native \w/, - - // Easily-parseable/retrievable ID or TAG or CLASS selectors - rquickExpr = /^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/, - - rsibling = /[+~]/, - - // CSS escapes - // http://www.w3.org/TR/CSS21/syndata.html#escaped-characters - runescape = new RegExp( "\\\\[\\da-fA-F]{1,6}" + whitespace + "?|\\\\([^\\r\\n\\f])", "g" ), - funescape = function( escape, nonHex ) { - var high = "0x" + escape.slice( 1 ) - 0x10000; - - return nonHex ? - - // Strip the backslash prefix from a non-hex escape sequence - nonHex : - - // Replace a hexadecimal escape sequence with the encoded Unicode code point - // Support: IE <=11+ - // For values outside the Basic Multilingual Plane (BMP), manually construct a - // surrogate pair - high < 0 ? - String.fromCharCode( high + 0x10000 ) : - String.fromCharCode( high >> 10 | 0xD800, high & 0x3FF | 0xDC00 ); - }, - - // CSS string/identifier serialization - // https://drafts.csswg.org/cssom/#common-serializing-idioms - rcssescape = /([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g, - fcssescape = function( ch, asCodePoint ) { - if ( asCodePoint ) { - - // U+0000 NULL becomes U+FFFD REPLACEMENT CHARACTER - if ( ch === "\0" ) { - return "\uFFFD"; - } - - // Control characters and (dependent upon position) numbers get escaped as code points - return ch.slice( 0, -1 ) + "\\" + - ch.charCodeAt( ch.length - 1 ).toString( 16 ) + " "; - } - - // Other potentially-special ASCII characters get backslash-escaped - return "\\" + ch; - }, - - // Used for iframes - // See setDocument() - // Removing the function wrapper causes a "Permission Denied" - // error in IE - unloadHandler = function() { - setDocument(); - }, - - inDisabledFieldset = addCombinator( - function( elem ) { - return elem.disabled === true && elem.nodeName.toLowerCase() === "fieldset"; - }, - { dir: "parentNode", next: "legend" } - ); - -// Optimize for push.apply( _, NodeList ) -try { - push.apply( - ( arr = slice.call( preferredDoc.childNodes ) ), - preferredDoc.childNodes - ); - - // Support: Android<4.0 - // Detect silently failing push.apply - // eslint-disable-next-line no-unused-expressions - arr[ preferredDoc.childNodes.length ].nodeType; -} catch ( e ) { - push = { apply: arr.length ? - - // Leverage slice if possible - function( target, els ) { - pushNative.apply( target, slice.call( els ) ); - } : - - // Support: IE<9 - // Otherwise append directly - function( target, els ) { - var j = target.length, - i = 0; - - // Can't trust NodeList.length - while ( ( target[ j++ ] = els[ i++ ] ) ) {} - target.length = j - 1; - } - }; -} - -function Sizzle( selector, context, results, seed ) { - var m, i, elem, nid, match, groups, newSelector, - newContext = context && context.ownerDocument, - - // nodeType defaults to 9, since context defaults to document - nodeType = context ? context.nodeType : 9; - - results = results || []; - - // Return early from calls with invalid selector or context - if ( typeof selector !== "string" || !selector || - nodeType !== 1 && nodeType !== 9 && nodeType !== 11 ) { - - return results; - } - - // Try to shortcut find operations (as opposed to filters) in HTML documents - if ( !seed ) { - setDocument( context ); - context = context || document; - - if ( documentIsHTML ) { - - // If the selector is sufficiently simple, try using a "get*By*" DOM method - // (excepting DocumentFragment context, where the methods don't exist) - if ( nodeType !== 11 && ( match = rquickExpr.exec( selector ) ) ) { - - // ID selector - if ( ( m = match[ 1 ] ) ) { - - // Document context - if ( nodeType === 9 ) { - if ( ( elem = context.getElementById( m ) ) ) { - - // Support: IE, Opera, Webkit - // TODO: identify versions - // getElementById can match elements by name instead of ID - if ( elem.id === m ) { - results.push( elem ); - return results; - } - } else { - return results; - } - - // Element context - } else { - - // Support: IE, Opera, Webkit - // TODO: identify versions - // getElementById can match elements by name instead of ID - if ( newContext && ( elem = newContext.getElementById( m ) ) && - contains( context, elem ) && - elem.id === m ) { - - results.push( elem ); - return results; - } - } - - // Type selector - } else if ( match[ 2 ] ) { - push.apply( results, context.getElementsByTagName( selector ) ); - return results; - - // Class selector - } else if ( ( m = match[ 3 ] ) && support.getElementsByClassName && - context.getElementsByClassName ) { - - push.apply( results, context.getElementsByClassName( m ) ); - return results; - } - } - - // Take advantage of querySelectorAll - if ( support.qsa && - !nonnativeSelectorCache[ selector + " " ] && - ( !rbuggyQSA || !rbuggyQSA.test( selector ) ) && - - // Support: IE 8 only - // Exclude object elements - ( nodeType !== 1 || context.nodeName.toLowerCase() !== "object" ) ) { - - newSelector = selector; - newContext = context; - - // qSA considers elements outside a scoping root when evaluating child or - // descendant combinators, which is not what we want. - // In such cases, we work around the behavior by prefixing every selector in the - // list with an ID selector referencing the scope context. - // The technique has to be used as well when a leading combinator is used - // as such selectors are not recognized by querySelectorAll. - // Thanks to Andrew Dupont for this technique. - if ( nodeType === 1 && - ( rdescend.test( selector ) || rleadingCombinator.test( selector ) ) ) { - - // Expand context for sibling selectors - newContext = rsibling.test( selector ) && testContext( context.parentNode ) || - context; - - // We can use :scope instead of the ID hack if the browser - // supports it & if we're not changing the context. - if ( newContext !== context || !support.scope ) { - - // Capture the context ID, setting it first if necessary - if ( ( nid = context.getAttribute( "id" ) ) ) { - nid = nid.replace( rcssescape, fcssescape ); - } else { - context.setAttribute( "id", ( nid = expando ) ); - } - } - - // Prefix every selector in the list - groups = tokenize( selector ); - i = groups.length; - while ( i-- ) { - groups[ i ] = ( nid ? "#" + nid : ":scope" ) + " " + - toSelector( groups[ i ] ); - } - newSelector = groups.join( "," ); - } - - try { - push.apply( results, - newContext.querySelectorAll( newSelector ) - ); - return results; - } catch ( qsaError ) { - nonnativeSelectorCache( selector, true ); - } finally { - if ( nid === expando ) { - context.removeAttribute( "id" ); - } - } - } - } - } - - // All others - return select( selector.replace( rtrim, "$1" ), context, results, seed ); -} - -/** - * Create key-value caches of limited size - * @returns {function(string, object)} Returns the Object data after storing it on itself with - * property name the (space-suffixed) string and (if the cache is larger than Expr.cacheLength) - * deleting the oldest entry - */ -function createCache() { - var keys = []; - - function cache( key, value ) { - - // Use (key + " ") to avoid collision with native prototype properties (see Issue #157) - if ( keys.push( key + " " ) > Expr.cacheLength ) { - - // Only keep the most recent entries - delete cache[ keys.shift() ]; - } - return ( cache[ key + " " ] = value ); - } - return cache; -} - -/** - * Mark a function for special use by Sizzle - * @param {Function} fn The function to mark - */ -function markFunction( fn ) { - fn[ expando ] = true; - return fn; -} - -/** - * Support testing using an element - * @param {Function} fn Passed the created element and returns a boolean result - */ -function assert( fn ) { - var el = document.createElement( "fieldset" ); - - try { - return !!fn( el ); - } catch ( e ) { - return false; - } finally { - - // Remove from its parent by default - if ( el.parentNode ) { - el.parentNode.removeChild( el ); - } - - // release memory in IE - el = null; - } -} - -/** - * Adds the same handler for all of the specified attrs - * @param {String} attrs Pipe-separated list of attributes - * @param {Function} handler The method that will be applied - */ -function addHandle( attrs, handler ) { - var arr = attrs.split( "|" ), - i = arr.length; - - while ( i-- ) { - Expr.attrHandle[ arr[ i ] ] = handler; - } -} - -/** - * Checks document order of two siblings - * @param {Element} a - * @param {Element} b - * @returns {Number} Returns less than 0 if a precedes b, greater than 0 if a follows b - */ -function siblingCheck( a, b ) { - var cur = b && a, - diff = cur && a.nodeType === 1 && b.nodeType === 1 && - a.sourceIndex - b.sourceIndex; - - // Use IE sourceIndex if available on both nodes - if ( diff ) { - return diff; - } - - // Check if b follows a - if ( cur ) { - while ( ( cur = cur.nextSibling ) ) { - if ( cur === b ) { - return -1; - } - } - } - - return a ? 1 : -1; -} - -/** - * Returns a function to use in pseudos for input types - * @param {String} type - */ -function createInputPseudo( type ) { - return function( elem ) { - var name = elem.nodeName.toLowerCase(); - return name === "input" && elem.type === type; - }; -} - -/** - * Returns a function to use in pseudos for buttons - * @param {String} type - */ -function createButtonPseudo( type ) { - return function( elem ) { - var name = elem.nodeName.toLowerCase(); - return ( name === "input" || name === "button" ) && elem.type === type; - }; -} - -/** - * Returns a function to use in pseudos for :enabled/:disabled - * @param {Boolean} disabled true for :disabled; false for :enabled - */ -function createDisabledPseudo( disabled ) { - - // Known :disabled false positives: fieldset[disabled] > legend:nth-of-type(n+2) :can-disable - return function( elem ) { - - // Only certain elements can match :enabled or :disabled - // https://html.spec.whatwg.org/multipage/scripting.html#selector-enabled - // https://html.spec.whatwg.org/multipage/scripting.html#selector-disabled - if ( "form" in elem ) { - - // Check for inherited disabledness on relevant non-disabled elements: - // * listed form-associated elements in a disabled fieldset - // https://html.spec.whatwg.org/multipage/forms.html#category-listed - // https://html.spec.whatwg.org/multipage/forms.html#concept-fe-disabled - // * option elements in a disabled optgroup - // https://html.spec.whatwg.org/multipage/forms.html#concept-option-disabled - // All such elements have a "form" property. - if ( elem.parentNode && elem.disabled === false ) { - - // Option elements defer to a parent optgroup if present - if ( "label" in elem ) { - if ( "label" in elem.parentNode ) { - return elem.parentNode.disabled === disabled; - } else { - return elem.disabled === disabled; - } - } - - // Support: IE 6 - 11 - // Use the isDisabled shortcut property to check for disabled fieldset ancestors - return elem.isDisabled === disabled || - - // Where there is no isDisabled, check manually - /* jshint -W018 */ - elem.isDisabled !== !disabled && - inDisabledFieldset( elem ) === disabled; - } - - return elem.disabled === disabled; - - // Try to winnow out elements that can't be disabled before trusting the disabled property. - // Some victims get caught in our net (label, legend, menu, track), but it shouldn't - // even exist on them, let alone have a boolean value. - } else if ( "label" in elem ) { - return elem.disabled === disabled; - } - - // Remaining elements are neither :enabled nor :disabled - return false; - }; -} - -/** - * Returns a function to use in pseudos for positionals - * @param {Function} fn - */ -function createPositionalPseudo( fn ) { - return markFunction( function( argument ) { - argument = +argument; - return markFunction( function( seed, matches ) { - var j, - matchIndexes = fn( [], seed.length, argument ), - i = matchIndexes.length; - - // Match elements found at the specified indexes - while ( i-- ) { - if ( seed[ ( j = matchIndexes[ i ] ) ] ) { - seed[ j ] = !( matches[ j ] = seed[ j ] ); - } - } - } ); - } ); -} - -/** - * Checks a node for validity as a Sizzle context - * @param {Element|Object=} context - * @returns {Element|Object|Boolean} The input node if acceptable, otherwise a falsy value - */ -function testContext( context ) { - return context && typeof context.getElementsByTagName !== "undefined" && context; -} - -// Expose support vars for convenience -support = Sizzle.support = {}; - -/** - * Detects XML nodes - * @param {Element|Object} elem An element or a document - * @returns {Boolean} True iff elem is a non-HTML XML node - */ -isXML = Sizzle.isXML = function( elem ) { - var namespace = elem && elem.namespaceURI, - docElem = elem && ( elem.ownerDocument || elem ).documentElement; - - // Support: IE <=8 - // Assume HTML when documentElement doesn't yet exist, such as inside loading iframes - // https://bugs.jquery.com/ticket/4833 - return !rhtml.test( namespace || docElem && docElem.nodeName || "HTML" ); -}; - -/** - * Sets document-related variables once based on the current document - * @param {Element|Object} [doc] An element or document object to use to set the document - * @returns {Object} Returns the current document - */ -setDocument = Sizzle.setDocument = function( node ) { - var hasCompare, subWindow, - doc = node ? node.ownerDocument || node : preferredDoc; - - // Return early if doc is invalid or already selected - // Support: IE 11+, Edge 17 - 18+ - // IE/Edge sometimes throw a "Permission denied" error when strict-comparing - // two documents; shallow comparisons work. - // eslint-disable-next-line eqeqeq - if ( doc == document || doc.nodeType !== 9 || !doc.documentElement ) { - return document; - } - - // Update global variables - document = doc; - docElem = document.documentElement; - documentIsHTML = !isXML( document ); - - // Support: IE 9 - 11+, Edge 12 - 18+ - // Accessing iframe documents after unload throws "permission denied" errors (jQuery #13936) - // Support: IE 11+, Edge 17 - 18+ - // IE/Edge sometimes throw a "Permission denied" error when strict-comparing - // two documents; shallow comparisons work. - // eslint-disable-next-line eqeqeq - if ( preferredDoc != document && - ( subWindow = document.defaultView ) && subWindow.top !== subWindow ) { - - // Support: IE 11, Edge - if ( subWindow.addEventListener ) { - subWindow.addEventListener( "unload", unloadHandler, false ); - - // Support: IE 9 - 10 only - } else if ( subWindow.attachEvent ) { - subWindow.attachEvent( "onunload", unloadHandler ); - } - } - - // Support: IE 8 - 11+, Edge 12 - 18+, Chrome <=16 - 25 only, Firefox <=3.6 - 31 only, - // Safari 4 - 5 only, Opera <=11.6 - 12.x only - // IE/Edge & older browsers don't support the :scope pseudo-class. - // Support: Safari 6.0 only - // Safari 6.0 supports :scope but it's an alias of :root there. - support.scope = assert( function( el ) { - docElem.appendChild( el ).appendChild( document.createElement( "div" ) ); - return typeof el.querySelectorAll !== "undefined" && - !el.querySelectorAll( ":scope fieldset div" ).length; - } ); - - // Support: Chrome 105 - 110+, Safari 15.4 - 16.3+ - // Make sure the the `:has()` argument is parsed unforgivingly. - // We include `*` in the test to detect buggy implementations that are - // _selectively_ forgiving (specifically when the list includes at least - // one valid selector). - // Note that we treat complete lack of support for `:has()` as if it were - // spec-compliant support, which is fine because use of `:has()` in such - // environments will fail in the qSA path and fall back to jQuery traversal - // anyway. - support.cssHas = assert( function() { - try { - document.querySelector( ":has(*,:jqfake)" ); - return false; - } catch ( e ) { - return true; - } - } ); - - /* Attributes - ---------------------------------------------------------------------- */ - - // Support: IE<8 - // Verify that getAttribute really returns attributes and not properties - // (excepting IE8 booleans) - support.attributes = assert( function( el ) { - el.className = "i"; - return !el.getAttribute( "className" ); - } ); - - /* getElement(s)By* - ---------------------------------------------------------------------- */ - - // Check if getElementsByTagName("*") returns only elements - support.getElementsByTagName = assert( function( el ) { - el.appendChild( document.createComment( "" ) ); - return !el.getElementsByTagName( "*" ).length; - } ); - - // Support: IE<9 - support.getElementsByClassName = rnative.test( document.getElementsByClassName ); - - // Support: IE<10 - // Check if getElementById returns elements by name - // The broken getElementById methods don't pick up programmatically-set names, - // so use a roundabout getElementsByName test - support.getById = assert( function( el ) { - docElem.appendChild( el ).id = expando; - return !document.getElementsByName || !document.getElementsByName( expando ).length; - } ); - - // ID filter and find - if ( support.getById ) { - Expr.filter[ "ID" ] = function( id ) { - var attrId = id.replace( runescape, funescape ); - return function( elem ) { - return elem.getAttribute( "id" ) === attrId; - }; - }; - Expr.find[ "ID" ] = function( id, context ) { - if ( typeof context.getElementById !== "undefined" && documentIsHTML ) { - var elem = context.getElementById( id ); - return elem ? [ elem ] : []; - } - }; - } else { - Expr.filter[ "ID" ] = function( id ) { - var attrId = id.replace( runescape, funescape ); - return function( elem ) { - var node = typeof elem.getAttributeNode !== "undefined" && - elem.getAttributeNode( "id" ); - return node && node.value === attrId; - }; - }; - - // Support: IE 6 - 7 only - // getElementById is not reliable as a find shortcut - Expr.find[ "ID" ] = function( id, context ) { - if ( typeof context.getElementById !== "undefined" && documentIsHTML ) { - var node, i, elems, - elem = context.getElementById( id ); - - if ( elem ) { - - // Verify the id attribute - node = elem.getAttributeNode( "id" ); - if ( node && node.value === id ) { - return [ elem ]; - } - - // Fall back on getElementsByName - elems = context.getElementsByName( id ); - i = 0; - while ( ( elem = elems[ i++ ] ) ) { - node = elem.getAttributeNode( "id" ); - if ( node && node.value === id ) { - return [ elem ]; - } - } - } - - return []; - } - }; - } - - // Tag - Expr.find[ "TAG" ] = support.getElementsByTagName ? - function( tag, context ) { - if ( typeof context.getElementsByTagName !== "undefined" ) { - return context.getElementsByTagName( tag ); - - // DocumentFragment nodes don't have gEBTN - } else if ( support.qsa ) { - return context.querySelectorAll( tag ); - } - } : - - function( tag, context ) { - var elem, - tmp = [], - i = 0, - - // By happy coincidence, a (broken) gEBTN appears on DocumentFragment nodes too - results = context.getElementsByTagName( tag ); - - // Filter out possible comments - if ( tag === "*" ) { - while ( ( elem = results[ i++ ] ) ) { - if ( elem.nodeType === 1 ) { - tmp.push( elem ); - } - } - - return tmp; - } - return results; - }; - - // Class - Expr.find[ "CLASS" ] = support.getElementsByClassName && function( className, context ) { - if ( typeof context.getElementsByClassName !== "undefined" && documentIsHTML ) { - return context.getElementsByClassName( className ); - } - }; - - /* QSA/matchesSelector - ---------------------------------------------------------------------- */ - - // QSA and matchesSelector support - - // matchesSelector(:active) reports false when true (IE9/Opera 11.5) - rbuggyMatches = []; - - // qSa(:focus) reports false when true (Chrome 21) - // We allow this because of a bug in IE8/9 that throws an error - // whenever `document.activeElement` is accessed on an iframe - // So, we allow :focus to pass through QSA all the time to avoid the IE error - // See https://bugs.jquery.com/ticket/13378 - rbuggyQSA = []; - - if ( ( support.qsa = rnative.test( document.querySelectorAll ) ) ) { - - // Build QSA regex - // Regex strategy adopted from Diego Perini - assert( function( el ) { - - var input; - - // Select is set to empty string on purpose - // This is to test IE's treatment of not explicitly - // setting a boolean content attribute, - // since its presence should be enough - // https://bugs.jquery.com/ticket/12359 - docElem.appendChild( el ).innerHTML = "" + - ""; - - // Support: IE8, Opera 11-12.16 - // Nothing should be selected when empty strings follow ^= or $= or *= - // The test attribute must be unknown in Opera but "safe" for WinRT - // https://msdn.microsoft.com/en-us/library/ie/hh465388.aspx#attribute_section - if ( el.querySelectorAll( "[msallowcapture^='']" ).length ) { - rbuggyQSA.push( "[*^$]=" + whitespace + "*(?:''|\"\")" ); - } - - // Support: IE8 - // Boolean attributes and "value" are not treated correctly - if ( !el.querySelectorAll( "[selected]" ).length ) { - rbuggyQSA.push( "\\[" + whitespace + "*(?:value|" + booleans + ")" ); - } - - // Support: Chrome<29, Android<4.4, Safari<7.0+, iOS<7.0+, PhantomJS<1.9.8+ - if ( !el.querySelectorAll( "[id~=" + expando + "-]" ).length ) { - rbuggyQSA.push( "~=" ); - } - - // Support: IE 11+, Edge 15 - 18+ - // IE 11/Edge don't find elements on a `[name='']` query in some cases. - // Adding a temporary attribute to the document before the selection works - // around the issue. - // Interestingly, IE 10 & older don't seem to have the issue. - input = document.createElement( "input" ); - input.setAttribute( "name", "" ); - el.appendChild( input ); - if ( !el.querySelectorAll( "[name='']" ).length ) { - rbuggyQSA.push( "\\[" + whitespace + "*name" + whitespace + "*=" + - whitespace + "*(?:''|\"\")" ); - } - - // Webkit/Opera - :checked should return selected option elements - // http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked - // IE8 throws error here and will not see later tests - if ( !el.querySelectorAll( ":checked" ).length ) { - rbuggyQSA.push( ":checked" ); - } - - // Support: Safari 8+, iOS 8+ - // https://bugs.webkit.org/show_bug.cgi?id=136851 - // In-page `selector#id sibling-combinator selector` fails - if ( !el.querySelectorAll( "a#" + expando + "+*" ).length ) { - rbuggyQSA.push( ".#.+[+~]" ); - } - - // Support: Firefox <=3.6 - 5 only - // Old Firefox doesn't throw on a badly-escaped identifier. - el.querySelectorAll( "\\\f" ); - rbuggyQSA.push( "[\\r\\n\\f]" ); - } ); - - assert( function( el ) { - el.innerHTML = "" + - ""; - - // Support: Windows 8 Native Apps - // The type and name attributes are restricted during .innerHTML assignment - var input = document.createElement( "input" ); - input.setAttribute( "type", "hidden" ); - el.appendChild( input ).setAttribute( "name", "D" ); - - // Support: IE8 - // Enforce case-sensitivity of name attribute - if ( el.querySelectorAll( "[name=d]" ).length ) { - rbuggyQSA.push( "name" + whitespace + "*[*^$|!~]?=" ); - } - - // FF 3.5 - :enabled/:disabled and hidden elements (hidden elements are still enabled) - // IE8 throws error here and will not see later tests - if ( el.querySelectorAll( ":enabled" ).length !== 2 ) { - rbuggyQSA.push( ":enabled", ":disabled" ); - } - - // Support: IE9-11+ - // IE's :disabled selector does not pick up the children of disabled fieldsets - docElem.appendChild( el ).disabled = true; - if ( el.querySelectorAll( ":disabled" ).length !== 2 ) { - rbuggyQSA.push( ":enabled", ":disabled" ); - } - - // Support: Opera 10 - 11 only - // Opera 10-11 does not throw on post-comma invalid pseudos - el.querySelectorAll( "*,:x" ); - rbuggyQSA.push( ",.*:" ); - } ); - } - - if ( ( support.matchesSelector = rnative.test( ( matches = docElem.matches || - docElem.webkitMatchesSelector || - docElem.mozMatchesSelector || - docElem.oMatchesSelector || - docElem.msMatchesSelector ) ) ) ) { - - assert( function( el ) { - - // Check to see if it's possible to do matchesSelector - // on a disconnected node (IE 9) - support.disconnectedMatch = matches.call( el, "*" ); - - // This should fail with an exception - // Gecko does not error, returns false instead - matches.call( el, "[s!='']:x" ); - rbuggyMatches.push( "!=", pseudos ); - } ); - } - - if ( !support.cssHas ) { - - // Support: Chrome 105 - 110+, Safari 15.4 - 16.3+ - // Our regular `try-catch` mechanism fails to detect natively-unsupported - // pseudo-classes inside `:has()` (such as `:has(:contains("Foo"))`) - // in browsers that parse the `:has()` argument as a forgiving selector list. - // https://drafts.csswg.org/selectors/#relational now requires the argument - // to be parsed unforgivingly, but browsers have not yet fully adjusted. - rbuggyQSA.push( ":has" ); - } - - rbuggyQSA = rbuggyQSA.length && new RegExp( rbuggyQSA.join( "|" ) ); - rbuggyMatches = rbuggyMatches.length && new RegExp( rbuggyMatches.join( "|" ) ); - - /* Contains - ---------------------------------------------------------------------- */ - hasCompare = rnative.test( docElem.compareDocumentPosition ); - - // Element contains another - // Purposefully self-exclusive - // As in, an element does not contain itself - contains = hasCompare || rnative.test( docElem.contains ) ? - function( a, b ) { - - // Support: IE <9 only - // IE doesn't have `contains` on `document` so we need to check for - // `documentElement` presence. - // We need to fall back to `a` when `documentElement` is missing - // as `ownerDocument` of elements within `