Merge pull request #2136 from Frenzy-code/trophies

Add Trophy System's Backend
This commit is contained in:
Roland Geider
2025-12-14 12:18:07 +01:00
committed by GitHub
46 changed files with 6514 additions and 85 deletions

123
package-lock.json generated
View File

@@ -52,6 +52,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -384,6 +385,7 @@
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -430,6 +432,7 @@
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -492,7 +495,6 @@
"os": [
"aix"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -510,7 +512,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -528,7 +529,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -546,7 +546,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -564,7 +563,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -582,7 +580,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -600,7 +597,6 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -618,7 +614,6 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -636,7 +631,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -654,7 +648,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -672,7 +665,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -690,7 +682,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -708,7 +699,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -726,7 +716,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -744,7 +733,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -762,7 +750,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -780,7 +767,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -798,7 +784,6 @@
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -816,7 +801,6 @@
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -834,7 +818,6 @@
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -852,7 +835,6 @@
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -870,7 +852,6 @@
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -888,7 +869,6 @@
"os": [
"sunos"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -906,7 +886,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -924,7 +903,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -942,7 +920,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1104,6 +1081,7 @@
"integrity": "sha512-R4DaYF3dgCQCUAkr4wW1w26GHXcf5rCmBRHVBuuvJvaGLmZdD8EjatP80Nz5JCw0KxORAzwftnHzXVnjR8HnFw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.6",
@@ -1217,6 +1195,7 @@
"integrity": "sha512-8fehAazkHNP1imMrdD2m2hbA9sl7Ur6jfuNweh5o4l9YPty4iaZzRXqYvBCWQNwFaSHmMEj2KPbyXGp7Bt73Rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/private-theming": "^7.3.6",
@@ -1458,6 +1437,7 @@
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
@@ -1520,8 +1500,7 @@
"optional": true,
"os": [
"android"
],
"peer": true
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.53.3",
@@ -1535,8 +1514,7 @@
"optional": true,
"os": [
"android"
],
"peer": true
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.53.3",
@@ -1550,8 +1528,7 @@
"optional": true,
"os": [
"darwin"
],
"peer": true
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.53.3",
@@ -1565,8 +1542,7 @@
"optional": true,
"os": [
"darwin"
],
"peer": true
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.53.3",
@@ -1580,8 +1556,7 @@
"optional": true,
"os": [
"freebsd"
],
"peer": true
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.53.3",
@@ -1595,8 +1570,7 @@
"optional": true,
"os": [
"freebsd"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.53.3",
@@ -1610,8 +1584,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.53.3",
@@ -1625,8 +1598,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.53.3",
@@ -1640,8 +1612,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.53.3",
@@ -1655,8 +1626,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.53.3",
@@ -1670,8 +1640,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.53.3",
@@ -1685,8 +1654,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.53.3",
@@ -1700,8 +1668,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.53.3",
@@ -1715,8 +1682,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.53.3",
@@ -1730,8 +1696,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.53.3",
@@ -1745,8 +1710,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.53.3",
@@ -1760,8 +1724,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.53.3",
@@ -1775,8 +1738,7 @@
"optional": true,
"os": [
"openharmony"
],
"peer": true
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.53.3",
@@ -1790,8 +1752,7 @@
"optional": true,
"os": [
"win32"
],
"peer": true
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.53.3",
@@ -1805,8 +1766,7 @@
"optional": true,
"os": [
"win32"
],
"peer": true
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.53.3",
@@ -1820,8 +1780,7 @@
"optional": true,
"os": [
"win32"
],
"peer": true
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.53.3",
@@ -1835,8 +1794,7 @@
"optional": true,
"os": [
"win32"
],
"peer": true
]
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
@@ -2002,8 +1960,7 @@
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/hoist-non-react-statics": {
"version": "3.3.7",
@@ -2206,6 +2163,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -2691,7 +2649,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -2776,7 +2733,6 @@
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.0.0"
},
@@ -2880,7 +2836,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
@@ -3082,6 +3037,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4"
},
@@ -3179,7 +3135,8 @@
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/js-tokens": {
"version": "4.0.0",
@@ -3271,6 +3228,7 @@
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -3347,7 +3305,6 @@
}
],
"license": "MIT",
"peer": true,
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@@ -3494,7 +3451,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -3560,6 +3516,7 @@
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -3641,7 +3598,8 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.1.tgz",
"integrity": "sha512-L7BnWgRbMwzMAubQcS7sXdPdNLmKlucPlopgAzx7FtYbksWZgEWiuYM5x9T6UqS2Ne0rsgQTq5kY2SGqpzUkYA==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.2.0",
@@ -3813,7 +3771,8 @@
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@@ -3876,7 +3835,6 @@
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -3970,7 +3928,6 @@
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -4022,7 +3979,6 @@
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
@@ -4095,6 +4051,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.8 on 2025-11-25 10:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0019_delete_daysofweek'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='trophies_enabled',
field=models.BooleanField(default=True, help_text='Enable or disable the trophy system for this user', verbose_name='Enable trophies'),
),
]

View File

@@ -39,7 +39,6 @@ from wger.utils.units import (
AbstractWeight,
)
from wger.weight.models import WeightEntry
# Local
from .language import Language
@@ -374,6 +373,13 @@ by the US Department of Agriculture. It is extremely complete, with around
behalf over the REST API
"""
# trophies_enabled = models.BooleanField(
# default=True,
# verbose_name=_('Enable trophies'),
# help_text=_('Enable or disable the trophy system for this user'),
# )
# """Flag to enable or disable trophies for this user"""
@property
def is_trustworthy(self) -> bool:
"""

View File

@@ -65,6 +65,7 @@ INSTALLED_APPS = [
'wger.weight',
'wger.gallery',
'wger.measurements',
# 'wger.trophies',
# reCaptcha support, see https://github.com/praekelt/django-recaptcha
'django_recaptcha',
@@ -568,6 +569,10 @@ WGER_SETTINGS = {
'USE_CELERY': False,
'USE_RECAPTCHA': False,
'WGER_INSTANCE': 'https://wger.de',
# Trophy system settings
'TROPHIES_ENABLED': True, # Global toggle to enable/disable trophy system
'TROPHIES_INACTIVE_USER_DAYS': 30, # Days of inactivity before skipping trophy evaluation
}
#

577
wger/trophies/README.md Normal file
View File

@@ -0,0 +1,577 @@
# Trophy System
The trophy (achievement) system allows users to earn trophies based on
their workout activities.
## Features
- **Multiple Trophy Types**: Time-based, volume-based, count-based,
sequence-based, date-based, and custom trophies
- **Progressive Trophies**: Show user progress towards earning a trophy
- **Hidden Trophies**: Secret achievements that are revealed only when
earned
- **Automatic Evaluation**: Trophies are evaluated automatically when
workout data changes
- **Statistics Tracking**: Denormalized statistics for efficient trophy
evaluation
- **API Endpoints**: Full REST API for trophy data and progress tracking
## Configuration
### Global Settings
Add to your `settings.py` or `settings_global.py`:
```python
WGER_SETTINGS = {
# Enable/disable the trophy system globally
'TROPHIES_ENABLED': True,
# Number of days of inactivity before skipping trophy evaluation for a user
'TROPHIES_INACTIVE_USER_DAYS': 30,
}
```
### User Preferences
Users can enable/disable trophies in their profile settings via the
`trophies_enabled` field on `UserProfile`.
## Database Models
### Trophy
Defines an achievement that users can earn.
**Fields:**
- `name`: Trophy name
- `description`: How to earn it
- `trophy_type`: Type (time, volume, count, sequence, date, other)
- `checker_class`: Python class that checks if trophy is earned
(e.g., 'count_based')
- `checker_params`: JSON parameters for the checker
(e.g., `{'count': 10}`)
- `is_hidden`: Hidden until earned
- `is_progressive`: Shows progress percentage
- `is_active`: Can be earned (admins can disable)
- `order`: Display order
### UserTrophy
Links users to their earned trophies.
**Fields:**
- `user`: User who earned the trophy
- `trophy`: The trophy earned
- `earned_at`: Timestamp when earned
- `progress`: Progress percentage (0-100)
- `is_notified`: For future notification system
### UserStatistics
Denormalized statistics for efficient trophy checking.
**Fields:**
- `user`: OneToOne with User
- `total_weight_lifted`: Cumulative weight in kg
- `total_workouts`: Number of workout sessions
- `current_streak`: Current consecutive workout days
- `longest_streak`: Longest streak ever achieved
- `earliest_workout_time`: Earliest workout time ever
- `latest_workout_time`: Latest workout time ever
- `weekend_workout_streak`: Consecutive weekends with workouts
- `last_complete_weekend_date`: Last Saturday with both days worked
- `worked_out_jan_1`: Has worked out on any January 1st
- `last_inactive_date`: Last workout before 30+ day gap
## Trophy Checkers
Trophy checkers are Python classes that determine if a user has earned a trophy.
### Available Checkers
1. **count_based**: Check if user reached a count
- Params: `{'count': 10}`
- Example: "Complete 10 workouts"
2. **streak**: Check for consecutive day streaks
- Params: `{'days': 30}`
- Example: "30-day workout streak"
3. **weekend_warrior**: Check for consecutive complete weekends
- Params: `{'weekends': 4}`
- Example: "Work out on Saturday AND Sunday for 4 weekends"
4. **volume**: Check for cumulative weight lifted
- Params: `{'kg': 5000}`
- Example: "Lift 5,000 kg total"
5. **time_based**: Check for workout at specific time
- Params: `{'before': '06:00'}` or `{'after': '21:00'}`
- Example: "Work out before 6:00 AM"
6. **date_based**: Check for workout on specific date
- Params: `{'month': 1, 'day': 1}`
- Example: "Work out on January 1st"
7. **inactivity_return**: Check for return after inactivity
- Params: `{'inactive_days': 30}`
- Example: "Return to training after 30 days inactive"
## Management Commands
### Load Trophies
Load the initial set of trophies into the database:
```bash
# Load new trophies (skip existing)
python manage.py load_trophies
# Update existing trophies
python manage.py load_trophies --update
# Verbose output
python manage.py load_trophies -v 2
```
### Evaluate Trophies
Manually trigger trophy evaluation:
```bash
# Evaluate for a specific user
python manage.py evaluate_trophies --user username
# Evaluate a specific trophy for all users
python manage.py evaluate_trophies --trophy 5
# Evaluate all trophies for all active users
python manage.py evaluate_trophies --all
# Force re-evaluation (check already earned trophies)
python manage.py evaluate_trophies --all --force-reevaluate
```
### Recalculate Statistics
Rebuild user statistics from workout history:
```bash
# Recalculate for a specific user
python manage.py recalculate_statistics --user username
# Recalculate for all users
python manage.py recalculate_statistics --all
# Recalculate for active users only
python manage.py recalculate_statistics --all --active-only
```
## API Endpoints
### Trophy Endpoints
```text
GET /api/v2/trophy/
List all active trophies
- Hidden trophies excluded unless earned by user
GET /api/v2/trophy/{id}/
Get specific trophy details
GET /api/v2/trophy/progress/
Get progress for all trophies (current user)
- Returns earned status and progress percentage
- Includes current/target values for progressive trophies
```
### User Trophy Endpoints
```text
GET /api/v2/user-trophy/
List current user's earned trophies
- Ordered by earned_at (newest first)
GET /api/v2/user-trophy/{id}/
Get specific earned trophy details
```
### User Statistics Endpoints
```text
GET /api/v2/user-statistics/
Get current user's trophy statistics
```
## API Components
### Serializers
**TrophySerializer** (`wger.trophies.api.serializers`):
- Serializes Trophy model for API responses
- Fields: id, uuid, name, description, trophy_type, checker_class,
checker_params, is_hidden, is_progressive, is_active, order
- Read-only serializer for trophy definitions
**UserTrophySerializer** (`wger.trophies.api.serializers`):
- Serializes UserTrophy model (earned trophies)
- Fields: id, user, trophy (nested), earned_at, progress, is_notified
- Includes nested trophy details in response
**UserStatisticsSerializer** (`wger.trophies.api.serializers`):
- Serializes UserStatistics model
- Fields: All statistics fields (total_weight_lifted, total_workouts,
current_streak, longest_streak, etc.)
- Read-only serializer for statistics data
**TrophyProgressSerializer** (`wger.trophies.api.serializers`):
- Custom serializer for trophy progress tracking
- Fields: trophy (nested), is_earned, earned_at, progress,
current_value, target_value
- Used by `/api/v2/trophy/progress/` endpoint
### Filtersets
**TrophyFilterSet** (`wger.trophies.api.filtersets`):
- Filter trophies by type, active status, hidden status
- Fields: `trophy_type`, `is_active`, `is_hidden`, `is_progressive`
- Example: `/api/v2/trophy/?trophy_type=volume&is_active=true`
**UserTrophyFilterSet** (`wger.trophies.api.filtersets`):
- Filter user's earned trophies
- Fields: `trophy`, `earned_at` (date range), `progress`
- Example: `/api/v2/user-trophy/?trophy=5&earned_at__gte=2024-01-01`
**Example API Usage:**
```python
# Get all volume-based trophies
GET /api/v2/trophy/?trophy_type=volume
# Get trophies earned in 2024
GET /api/v2/user-trophy/?earned_at__year=2024
# Get progress for all trophies (authenticated)
GET /api/v2/trophy/progress/
# Get current user's statistics
GET /api/v2/user-statistics/
```
## Adding New Trophies
### Method 1: Using Code (Recommended for new trophy types)
1. **Create a new checker class** (if needed) in `wger/trophies/checkers/`:
```python
# wger/trophies/checkers/my_checker.py
from .base import BaseTrophyChecker
class MyCustomChecker(BaseTrophyChecker):
def check(self) -> bool:
# Your logic here
return True
def get_progress(self) -> float:
# Calculate progress 0-100
return 50.0
def get_current_value(self):
return "current"
def get_target_value(self):
return "target"
```
2. **Register the checker** in `wger/trophies/checkers/registry.py`:
```python
from .my_checker import MyCustomChecker
class CheckerRegistry:
_registry: Dict[str, Type[BaseTrophyChecker]] = {
# ... existing checkers ...
'my_custom': MyCustomChecker,
}
```
3. **Add the trophy** via Django admin or shell:
```python
from wger.trophies.models import Trophy
Trophy.objects.create(
name="My Custom Trophy",
description="Description of how to earn it",
trophy_type=Trophy.TYPE_OTHER,
checker_class='my_custom',
checker_params={'param1': 'value1'},
is_hidden=False,
is_progressive=True,
order=100,
)
```
### Method 2: Using Existing Checkers
Simply create a new Trophy object with an existing checker:
```python
Trophy.objects.create(
name="Heavy Lifter",
description="Lift 10,000 kg total",
trophy_type=Trophy.TYPE_VOLUME,
checker_class='volume',
checker_params={'kg': 10000},
is_progressive=True,
order=50,
)
```
### Method 3: Using Fixtures
Create a JSON fixture in `wger/trophies/fixtures/`:
```json
[
{
"model": "trophies.trophy",
"pk": 10,
"fields": {
"name": "My Trophy",
"description": "Description",
"trophy_type": "count",
"checker_class": "count_based",
"checker_params": {"count": 100},
"is_hidden": false,
"is_progressive": true,
"is_active": true,
"order": 100
}
}
]
```
Load with: `python manage.py loaddata my_trophies`
## Services API
### UserStatisticsService
Service for managing user workout statistics.
**Methods:**
- `increment_workout(user, workout_date, weight_lifted)`: Incrementally
update statistics after a workout. Updates streak, total workouts,
weight lifted, and workout times.
- `update_statistics(user)`: Recalculate all statistics from scratch
by scanning workout history. Use for fixing inconsistencies.
- `get_or_create_statistics(user)`: Get existing statistics or create
new record with default values.
- `handle_workout_deletion(user, workout_date)`: Update statistics
after a workout is deleted. Recalculates streaks if needed.
**Example Usage:**
```python
from wger.trophies.services.statistics import UserStatisticsService
from decimal import Decimal
import datetime
# Increment after workout
UserStatisticsService.increment_workout(
user=request.user,
workout_date=datetime.date.today(),
weight_lifted=Decimal('150.5')
)
# Recalculate all statistics
stats = UserStatisticsService.update_statistics(request.user)
# Get statistics (create if missing)
stats = UserStatisticsService.get_or_create_statistics(request.user)
```
### TrophyService
Service for evaluating and awarding trophies.
**Methods:**
- `evaluate_all_trophies(user, force=False)`: Evaluate all active
trophies for a user. Returns list of newly awarded UserTrophy
objects. Skip already earned unless force=True.
- `award_trophy(user, trophy, progress=100.0)`: Award a specific
trophy to a user. Creates UserTrophy record. Idempotent (safe to
call multiple times).
- `get_user_trophies(user, include_hidden=False)`: Get all trophies
earned by a user. Filter hidden trophies unless specified.
- `get_all_trophy_progress(user, include_hidden=False)`: Get progress
for all trophies. Returns list of dicts with trophy info, earned
status, progress %, current/target values.
- `reevaluate_trophies(user_ids=None, trophy_id=None,
force_reevaluate=False)`: Batch re-evaluate trophies for multiple
users. Returns dict with users_checked, trophies_awarded counts.
**Example Usage:**
```python
from wger.trophies.services.trophy import TrophyService
# Evaluate all trophies for user
newly_awarded = TrophyService.evaluate_all_trophies(request.user)
for user_trophy in newly_awarded:
print(f"Earned: {user_trophy.trophy.name}")
# Get user's earned trophies
earned = TrophyService.get_user_trophies(request.user)
# Get progress for all trophies
progress = TrophyService.get_all_trophy_progress(request.user)
for item in progress:
print(f"{item['trophy'].name}: {item['progress']}%")
# Batch re-evaluate for all active users
results = TrophyService.reevaluate_trophies()
print(f"Checked {results['users_checked']} users")
print(f"Awarded {results['trophies_awarded']} trophies")
```
## Signals and Auto-Evaluation
### Signal Handlers
The trophy system uses Django signals for automatic evaluation:
**workout_log_saved signal** (`wger.manager.signals`):
- Fires when `WorkoutLog` is saved (user logs exercise sets)
- Handler: Updates `UserStatistics` incrementally
- Then evaluates all trophies for the user
**workout_session_saved signal** (`wger.manager.signals`):
- Fires when `WorkoutSession` is saved (user completes workout)
- Handler: Updates workout count and streak
- Then evaluates all trophies for the user
**workout_deleted signal** (`wger.manager.signals`):
- Fires when workout is deleted
- Handler: Calls `handle_workout_deletion()` to update statistics
**Configuration:**
Signals are automatically connected in `wger/trophies/apps.py`:
```python
class TrophiesConfig(AppConfig):
def ready(self):
import wger.trophies.signals # noqa: F401
```
### How Auto-Evaluation Works
When a user logs a workout:
1. `WorkoutSession` or `WorkoutLog` is saved
2. Django signal fires (`post_save`)
3. `UserStatisticsService.increment_workout()` updates statistics
incrementally
4. `TrophyService.evaluate_all_trophies()` checks for newly earned
trophies
5. Earned trophies create `UserTrophy` records
6. User sees new trophy (notifications in future version)
### Celery Tasks (Optional)
For async evaluation:
```python
from wger.trophies.tasks import evaluate_user_trophies_task
# Evaluate asynchronously
evaluate_user_trophies_task.delay(user_id)
```
### Performance Considerations
- **Denormalized Statistics**: `UserStatistics` table provides O(1)
lookups
- **Incremental Updates**: Statistics update incrementally, not full
recalculation
- **Inactive User Skipping**: Users inactive >30 days are skipped
- **Bulk Operations**: Batch evaluation supports chunking for large
user sets
## Testing
Run the trophy test suite:
```bash
# All trophy tests
python manage.py test wger.trophies.tests
# Specific test files
python manage.py test wger.trophies.tests.test_models
python manage.py test wger.trophies.tests.test_checkers
python manage.py test wger.trophies.tests.test_services
python manage.py test wger.trophies.tests.test_api
python manage.py test wger.trophies.tests.test_integration
```
## Initial Trophies
The system includes 9 initial trophies:
1. **Beginner**: Complete your first workout
2. **Unstoppable**: Maintain a 30-day workout streak
3. **Weekend Warrior**: Work out on Saturday and Sunday for 4 consecutive
weekends
4. **Lifter**: Lift a cumulative total of 5,000 kg
5. **Atlas**: Lift a cumulative total of 100,000 kg
6. **Early Bird**: Complete a workout before 6:00 AM
7. **Night Owl**: Complete a workout after 9:00 PM
8. **New Year, New Me**: Work out on January 1st
9. **Phoenix** (Hidden): Return to training after being inactive for
30 days
Load them with: `python manage.py load_trophies`
## Troubleshooting
### Trophies not being awarded
1. Check if trophy system is enabled: `TROPHIES_ENABLED = True`
2. Check user's profile: `user.userprofile.trophies_enabled`
3. Check user activity: Not inactive >30 days
4. Check trophy is active: `trophy.is_active = True`
5. Recalculate statistics:
`python manage.py recalculate_statistics --user username`
### Statistics not updating
1. Check signals are connected (should happen automatically)
2. Manually recalculate: `python manage.py recalculate_statistics --all`
3. Check for errors in logs
### Performance issues
1. Add database indexes (already included in migrations)
2. Enable Celery for async evaluation
3. Adjust `TROPHIES_INACTIVE_USER_DAYS` to skip more users
4. Disable trophies for inactive users in bulk
## License
AGPL-3.0 (same as wger Workout Manager)

View File

@@ -0,0 +1,15 @@
# This file is part of wger Workout Manager <https://github.com/wger-project>.
# Copyright (C) 2013 - 2021 wger Team
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

View File

@@ -0,0 +1,55 @@
# This file is part of wger Workout Manager <https://github.com/wger-project>.
# Copyright (C) 2013 - 2021 wger Team
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# Third Party
from django_filters import rest_framework as filters
# wger
from wger.trophies.models import (
Trophy,
UserTrophy,
)
class TrophyFilterSet(filters.FilterSet):
"""
Filter set for Trophy model.
"""
class Meta:
model = Trophy
fields = {
'id': ['exact', 'in'],
'trophy_type': ['exact', 'in'],
'is_active': ['exact'],
'is_hidden': ['exact'],
'is_progressive': ['exact'],
}
class UserTrophyFilterSet(filters.FilterSet):
"""
Filter set for UserTrophy model.
"""
class Meta:
model = UserTrophy
fields = {
'id': ['exact', 'in'],
'trophy': ['exact', 'in'],
'earned_at': ['exact', 'gt', 'gte', 'lt', 'lte'],
'is_notified': ['exact'],
}

View File

@@ -0,0 +1,112 @@
# This file is part of wger Workout Manager <https://github.com/wger-project>.
# Copyright (C) 2013 - 2021 wger Team
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# Third Party
from rest_framework import serializers
# wger
from wger.trophies.models import (
Trophy,
UserStatistics,
UserTrophy,
)
class TrophySerializer(serializers.ModelSerializer):
"""
Serializer for Trophy model.
Shows trophy information for listing active trophies.
"""
class Meta:
model = Trophy
fields = (
'id',
'uuid',
'name',
'description',
'image',
'trophy_type',
'is_hidden',
'is_progressive',
'order',
)
read_only_fields = fields
class UserTrophySerializer(serializers.ModelSerializer):
"""
Serializer for UserTrophy model.
Shows user's earned trophies with trophy details.
"""
trophy = TrophySerializer(read_only=True)
class Meta:
model = UserTrophy
fields = (
'id',
'trophy',
'earned_at',
'progress',
'is_notified',
)
read_only_fields = fields
class UserStatisticsSerializer(serializers.ModelSerializer):
"""
Serializer for UserStatistics model.
Shows user's trophy-related statistics.
"""
class Meta:
model = UserStatistics
fields = (
'id',
'total_weight_lifted',
'total_workouts',
'current_streak',
'longest_streak',
'last_workout_date',
'earliest_workout_time',
'latest_workout_time',
'weekend_workout_streak',
'last_complete_weekend_date',
'worked_out_jan_1',
'last_updated',
)
read_only_fields = fields
class TrophyProgressSerializer(serializers.Serializer):
"""
Serializer for trophy progress information.
Used for showing progress on all trophies (earned and unearned).
This is not a ModelSerializer as it aggregates data from multiple sources.
"""
trophy = TrophySerializer(read_only=True)
is_earned = serializers.BooleanField()
earned_at = serializers.DateTimeField(allow_null=True)
progress = serializers.FloatField()
current_value = serializers.CharField(allow_null=True)
target_value = serializers.CharField(allow_null=True)
progress_display = serializers.CharField(allow_null=True)

190
wger/trophies/api/views.py Normal file
View File

@@ -0,0 +1,190 @@
# This file is part of wger Workout Manager <https://github.com/wger-project>.
# Copyright (C) 2013 - 2021 wger Team
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# Third Party
from drf_spectacular.utils import (
OpenApiResponse,
extend_schema,
)
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
# wger
from wger.trophies.api.filtersets import (
TrophyFilterSet,
UserTrophyFilterSet,
)
from wger.trophies.api.serializers import (
TrophyProgressSerializer,
TrophySerializer,
UserStatisticsSerializer,
UserTrophySerializer,
)
from wger.trophies.models import (
Trophy,
UserStatistics,
UserTrophy,
)
from wger.trophies.services import TrophyService
class TrophyViewSet(viewsets.ReadOnlyModelViewSet):
"""
API endpoint for Trophy objects.
Returns active trophies. Hidden trophies are excluded unless:
- The user has earned them, or
- The user is staff
list:
Return a list of active trophies
retrieve:
Return a specific trophy by ID
"""
serializer_class = TrophySerializer
filterset_class = TrophyFilterSet
ordering_fields = ['order', 'name', 'trophy_type']
ordering = ['order', 'name']
def get_queryset(self):
"""
Return active trophies, filtering hidden ones appropriately.
"""
# REST API generation
if getattr(self, 'swagger_fake_view', False):
return Trophy.objects.none()
user = self.request.user
queryset = Trophy.objects.filter(is_active=True)
# Staff can see all trophies
if user.is_staff:
return queryset
# For regular users, exclude hidden trophies unless earned
if user.is_authenticated:
earned_trophy_ids = UserTrophy.objects.filter(user=user).values_list(
'trophy_id', flat=True
)
return queryset.filter(is_hidden=False) | queryset.filter(id__in=earned_trophy_ids)
# Anonymous users only see non-hidden trophies
return queryset.filter(is_hidden=False)
@extend_schema(
summary="Get trophy progress",
description="""
Return all trophies with progress information for the current user.
For each trophy, returns:
- Trophy information (id, name, description, type, etc.)
- Whether the trophy has been earned
- Earned timestamp (if earned)
- Progress percentage (0-100)
- Current and target values (for progressive trophies)
Hidden trophies are excluded unless earned (or user is staff).
""",
responses={
200: TrophyProgressSerializer(many=True),
},
)
@action(detail=False, methods=['get'])
def progress(self, request):
"""
Return all trophies with progress information for the current user.
For each trophy, returns:
- Trophy info
- Whether earned
- Earned timestamp (if earned)
- Progress percentage (0-100)
- Current/target values (for progressive trophies)
"""
if not request.user.is_authenticated:
return Response([])
include_hidden = request.user.is_staff
progress_data = TrophyService.get_all_trophy_progress(
request.user,
include_hidden=include_hidden,
)
serializer = TrophyProgressSerializer(progress_data, many=True)
return Response(serializer.data)
class UserTrophyViewSet(viewsets.ReadOnlyModelViewSet):
"""
API endpoint for user's earned trophies.
Returns the current user's earned trophies.
list:
Return all earned trophies for the current user
retrieve:
Return a specific user trophy by ID
"""
serializer_class = UserTrophySerializer
filterset_class = UserTrophyFilterSet
ordering_fields = ['earned_at', 'trophy__name']
ordering = ['-earned_at']
is_private = True
def get_queryset(self):
"""
Return only the current user's trophies.
"""
# REST API generation
if getattr(self, 'swagger_fake_view', False):
return UserTrophy.objects.none()
return UserTrophy.objects.filter(user=self.request.user).select_related('trophy')
class UserStatisticsViewSet(viewsets.ReadOnlyModelViewSet):
"""
API endpoint for user's trophy statistics.
Returns the current user's trophy-related statistics.
list:
Return the current user's statistics
retrieve:
Return statistics by ID
"""
serializer_class = UserStatisticsSerializer
ordering_fields = '__all__'
is_private = True
def get_queryset(self):
"""
Return only the current user's statistics.
"""
# REST API generation
if getattr(self, 'swagger_fake_view', False):
return UserStatistics.objects.none()
return UserStatistics.objects.filter(user=self.request.user)

26
wger/trophies/apps.py Normal file
View File

@@ -0,0 +1,26 @@
# This file is part of wger Workout Manager <https://github.com/wger-project>.
# Copyright (C) 2013 - 2021 wger Team
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# Django
from django.apps import AppConfig
class TrophiesConfig(AppConfig):
name = 'wger.trophies'
verbose_name = 'Trophies'
def ready(self):
import wger.trophies.signals # noqa: F401

View File

@@ -0,0 +1,26 @@
# This file is part of wger Workout Manager <https://github.com/wger-project>.
# Copyright (C) 2013 - 2021 wger Team
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# Local
from .base import BaseTrophyChecker
from .count_based import CountBasedChecker
from .date_based import DateBasedChecker
from .inactivity_return import InactivityReturnChecker
from .registry import CheckerRegistry
from .streak import StreakChecker
from .time_based import TimeBasedChecker
from .volume import VolumeChecker
from .weekend_warrior import WeekendWarriorChecker

View File

@@ -0,0 +1,126 @@
# This file is part of wger Workout Manager <https://github.com/wger-project>.
# Copyright (C) 2013 - 2021 wger Team
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# Standard Library
from abc import (
ABC,
abstractmethod,
)
from typing import (
Any,
Optional,
)
# Django
from django.contrib.auth.models import User
class BaseTrophyChecker(ABC):
"""
Abstract base class for all trophy checkers.
Each trophy type has a corresponding checker class that knows how to
evaluate whether a user has earned that trophy.
"""
def __init__(self, user: User, trophy: 'Trophy', params: dict):
"""
Initialize the checker.
Args:
user: The user to check the trophy for
trophy: The trophy being checked
params: Parameters from the trophy's checker_params field
"""
self.user = user
self.trophy = trophy
self.params = params
self._statistics = None
@property
def statistics(self):
"""
Lazy-load the user's statistics.
"""
if self._statistics is None:
from wger.trophies.models import UserStatistics
self._statistics, _ = UserStatistics.objects.get_or_create(user=self.user)
return self._statistics
@abstractmethod
def check(self) -> bool:
"""
Check if the user has earned the trophy.
Returns:
True if the trophy has been earned, False otherwise
"""
pass
@abstractmethod
def get_progress(self) -> float:
"""
Get the user's progress towards earning the trophy.
Returns:
A float between 0 and 100 representing percentage progress
"""
pass
@abstractmethod
def get_target_value(self) -> Any:
"""
Get the target value the user needs to achieve.
Returns:
The target value (type depends on trophy type)
"""
pass
@abstractmethod
def get_current_value(self) -> Any:
"""
Get the user's current value towards the target.
Returns:
The current value (type depends on trophy type)
"""
pass
def get_progress_display(self) -> str:
"""
Get a human-readable string describing the progress.
Returns:
A formatted string showing current/target progress
"""
current = self.get_current_value()
target = self.get_target_value()
return f'{current} / {target}'
def validate_params(self) -> bool:
"""
Validate that the required parameters are present.
Override this method in subclasses to add specific validation.
Returns:
True if parameters are valid, False otherwise
"""
return True
def __repr__(self) -> str:
return f'<{self.__class__.__name__}(user={self.user.username}, trophy={self.trophy.name})>'

View File

@@ -0,0 +1,72 @@
# This file is part of wger Workout Manager <https://github.com/wger-project>.
# Copyright (C) 2013 - 2021 wger Team
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# Standard Library
from typing import Any
# Local
from .base import BaseTrophyChecker
class CountBasedChecker(BaseTrophyChecker):
"""
Checker for count-based trophies.
Used for trophies that require completing a certain number of workouts.
Expected params:
count (int): The number of workouts required to earn the trophy
Example:
Beginner trophy: params={'count': 1}
Dedicated trophy: params={'count': 100}
"""
def validate_params(self) -> bool:
"""Validate that count parameter is present and valid."""
count = self.params.get('count')
return count is not None and isinstance(count, int) and count > 0
def check(self) -> bool:
"""Check if user has completed required number of workouts."""
if not self.validate_params():
return False
return self.get_current_value() >= self.get_target_value()
def get_progress(self) -> float:
"""Get progress as percentage of workouts completed."""
if not self.validate_params():
return 0.0
target = self.get_target_value()
if target <= 0:
return 0.0
current = self.get_current_value()
progress = (current / target) * 100
return min(progress, 100.0)
def get_target_value(self) -> int:
"""Get the target number of workouts."""
return self.params.get('count', 0)
def get_current_value(self) -> int:
"""Get the user's current workout count."""
return self.statistics.total_workouts
def get_progress_display(self) -> str:
"""Get human-readable progress string."""
current = self.get_current_value()
target = self.get_target_value()
return f'{current} / {target} workouts'

View File

@@ -0,0 +1,109 @@
# This file is part of wger Workout Manager <https://github.com/wger-project>.
# Copyright (C) 2013 - 2021 wger Team
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# Standard Library
from typing import Any
# Local
from .base import BaseTrophyChecker
class DateBasedChecker(BaseTrophyChecker):
"""
Checker for date-based trophies.
Used for trophies that require working out on a specific date (month/day).
Expected params:
month (int): The month (1-12) when the workout must occur
day (int): The day of the month (1-31) when the workout must occur
Example:
New Year, New Me trophy: params={'month': 1, 'day': 1}
"""
def validate_params(self) -> bool:
"""Validate that month and day parameters are present and valid."""
month = self.params.get('month')
day = self.params.get('day')
if month is None or day is None:
return False
if not isinstance(month, int) or not isinstance(day, int):
return False
if month < 1 or month > 12:
return False
if day < 1 or day > 31:
return False
return True
def check(self) -> bool:
"""Check if user has worked out on the specified date."""
if not self.validate_params():
return False
month = self.params.get('month')
day = self.params.get('day')
# Special case for January 1st - we store this flag directly
if month == 1 and day == 1:
return self.statistics.worked_out_jan_1
# For other dates, we need to query the workout sessions
# This is done in the statistics service when updating
from wger.manager.models import WorkoutSession
return WorkoutSession.objects.filter(
user=self.user,
date__month=month,
date__day=day,
).exists()
def get_progress(self) -> float:
"""
Get progress towards the trophy.
For date-based trophies, progress is binary - either achieved (100%) or not (0%).
"""
return 100.0 if self.check() else 0.0
def get_target_value(self) -> str:
"""Get the target date as a string."""
month = self.params.get('month')
day = self.params.get('day')
if month is None or day is None:
return 'N/A'
# Convert to month name
import calendar
month_name = calendar.month_name[month]
return f'{month_name} {day}'
def get_current_value(self) -> str:
"""Get whether the user has achieved this."""
if self.check():
return 'Achieved'
return 'Not yet'
def get_progress_display(self) -> str:
"""Get human-readable progress string."""
if self.check():
return 'Achieved!'
return f'Work out on {self.get_target_value()}'

View File

@@ -0,0 +1,118 @@
# This file is part of wger Workout Manager <https://github.com/wger-project>.
# Copyright (C) 2013 - 2021 wger Team
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# Standard Library
import datetime
from typing import Any
# Local
from .base import BaseTrophyChecker
class InactivityReturnChecker(BaseTrophyChecker):
"""
Checker for inactivity return trophies (Phoenix trophy).
Used for trophies that reward users for returning to training after
being inactive for a certain period.
Expected params:
inactive_days (int): The minimum number of inactive days before returning
Example:
Phoenix trophy: params={'inactive_days': 30}
"""
def validate_params(self) -> bool:
"""Validate that inactive_days parameter is present and valid."""
inactive_days = self.params.get('inactive_days')
return inactive_days is not None and isinstance(inactive_days, int) and inactive_days > 0
def check(self) -> bool:
"""
Check if user has returned to training after being inactive.
The user earns this trophy if:
1. They have a last_inactive_date recorded (meaning they were inactive)
2. They have a last_workout_date after the inactive period
3. The gap between last_inactive_date and the workout before that was >= inactive_days
"""
if not self.validate_params():
return False
last_inactive_date = self.statistics.last_inactive_date
last_workout_date = self.statistics.last_workout_date
# If no inactive date recorded, user hasn't had a qualifying gap yet
if last_inactive_date is None:
return False
# If no workout after the inactive period, haven't returned yet
if last_workout_date is None:
return False
# Check if they've worked out after the inactive date
# The statistics service sets last_inactive_date when it detects
# a gap of >= inactive_days before a new workout
return last_workout_date > last_inactive_date
def get_progress(self) -> float:
"""
Get progress towards the trophy.
This is a special trophy - progress shows how close to earning it after returning.
If they haven't been inactive long enough, shows 0.
If they've returned after inactivity, shows 100.
"""
return 100.0 if self.check() else 0.0
def get_target_value(self) -> int:
"""Get the required number of inactive days."""
return self.params.get('inactive_days', 0)
def get_current_value(self) -> str:
"""Get the current status."""
if self.check():
return 'Returned after inactivity'
last_workout = self.statistics.last_workout_date
if last_workout is None:
return 'No workouts yet'
# Calculate days since last workout
today = datetime.date.today()
days_inactive = (today - last_workout).days
return f'{days_inactive} days since last workout'
def get_progress_display(self) -> str:
"""Get human-readable progress string."""
if self.check():
return 'Achieved! Welcome back!'
target_days = self.get_target_value()
last_workout = self.statistics.last_workout_date
if last_workout is None:
return f'Complete a workout, then return after {target_days}+ days of rest'
today = datetime.date.today()
days_inactive = (today - last_workout).days
if days_inactive >= target_days:
return 'Return to training to earn this trophy!'
return f'{days_inactive} / {target_days} days inactive'

View File

@@ -0,0 +1,159 @@
# This file is part of wger Workout Manager <https://github.com/wger-project>.
# Copyright (C) 2013 - 2021 wger Team
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# Standard Library
import logging
from typing import (
Dict,
Optional,
Type,
)
# Django
from django.contrib.auth.models import User
# Local
from .base import BaseTrophyChecker
from .count_based import CountBasedChecker
from .date_based import DateBasedChecker
from .inactivity_return import InactivityReturnChecker
from .streak import StreakChecker
from .time_based import TimeBasedChecker
from .volume import VolumeChecker
from .weekend_warrior import WeekendWarriorChecker
logger = logging.getLogger(__name__)
class CheckerRegistry:
"""
Registry for trophy checker classes.
Maps checker class names (as stored in Trophy.checker_class) to their
actual Python classes.
"""
# Registry mapping simple keys to checker classes
# Using simple keys instead of full Python paths to avoid breakage if module structure changes
_registry: Dict[str, Type[BaseTrophyChecker]] = {
'count_based': CountBasedChecker,
'streak': StreakChecker,
'weekend_warrior': WeekendWarriorChecker,
'volume': VolumeChecker,
'time_based': TimeBasedChecker,
'date_based': DateBasedChecker,
'inactivity_return': InactivityReturnChecker,
}
@classmethod
def register(cls, class_path: str, checker_class: Type[BaseTrophyChecker]) -> None:
"""
Register a new checker class.
Args:
class_path: The path string to use in Trophy.checker_class
checker_class: The checker class to register
"""
if not issubclass(checker_class, BaseTrophyChecker):
raise ValueError(f'{checker_class} must be a subclass of BaseTrophyChecker')
cls._registry[class_path] = checker_class
@classmethod
def unregister(cls, class_path: str) -> None:
"""
Unregister a checker class.
Args:
class_path: The path string to remove
"""
cls._registry.pop(class_path, None)
@classmethod
def get_checker_class(cls, class_path: str) -> Optional[Type[BaseTrophyChecker]]:
"""
Get a checker class by its path.
Args:
class_path: The path string from Trophy.checker_class
Returns:
The checker class, or None if not found
"""
return cls._registry.get(class_path)
@classmethod
def get_all_checkers(cls) -> Dict[str, Type[BaseTrophyChecker]]:
"""
Get all registered checker classes.
Returns:
A copy of the registry dictionary
"""
return cls._registry.copy()
@classmethod
def create_checker(
cls,
user: User,
trophy: 'Trophy',
) -> Optional[BaseTrophyChecker]:
"""
Factory method to create a checker instance for a trophy.
Args:
user: The user to check the trophy for
trophy: The trophy to check
Returns:
An instance of the appropriate checker class, or None if the
checker class is not found in the registry
"""
checker_class = cls.get_checker_class(trophy.checker_class)
if checker_class is None:
logger.warning(
f'Checker class not found in registry: {trophy.checker_class} '
f'for trophy: {trophy.name}'
)
return None
try:
return checker_class(
user=user,
trophy=trophy,
params=trophy.checker_params or {},
)
except Exception as e:
logger.error(
f'Error creating checker for trophy {trophy.name}: {e}',
exc_info=True,
)
return None
def get_checker_for_trophy(user: User, trophy: 'Trophy') -> Optional[BaseTrophyChecker]:
"""
Convenience function to get a checker instance for a trophy.
Args:
user: The user to check the trophy for
trophy: The trophy to check
Returns:
An instance of the appropriate checker class, or None if not found
"""
return CheckerRegistry.create_checker(user, trophy)

View File

@@ -0,0 +1,77 @@
# This file is part of wger Workout Manager <https://github.com/wger-project>.
# Copyright (C) 2013 - 2021 wger Team
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# Standard Library
from typing import Any
# Local
from .base import BaseTrophyChecker
class StreakChecker(BaseTrophyChecker):
"""
Checker for streak-based trophies.
Used for trophies that require working out for consecutive days.
Expected params:
days (int): The number of consecutive days required
Example:
Unstoppable trophy: params={'days': 30}
"""
def validate_params(self) -> bool:
"""Validate that days parameter is present and valid."""
days = self.params.get('days')
return days is not None and isinstance(days, int) and days > 0
def check(self) -> bool:
"""Check if user has achieved the required streak."""
if not self.validate_params():
return False
# Check both current streak and longest streak (in case they achieved it before)
target = self.get_target_value()
return (
self.statistics.current_streak >= target
or self.statistics.longest_streak >= target
)
def get_progress(self) -> float:
"""Get progress as percentage of streak achieved."""
if not self.validate_params():
return 0.0
target = self.get_target_value()
if target <= 0:
return 0.0
# Use the maximum of current or longest streak for progress
current = self.get_current_value()
progress = (current / target) * 100
return min(progress, 100.0)
def get_target_value(self) -> int:
"""Get the target streak length in days."""
return self.params.get('days', 0)
def get_current_value(self) -> int:
"""Get the user's best streak (max of current and longest)."""
return max(self.statistics.current_streak, self.statistics.longest_streak)
def get_progress_display(self) -> str:
"""Get human-readable progress string."""
current = self.get_current_value()
target = self.get_target_value()
return f'{current} / {target} days'

View File

@@ -0,0 +1,149 @@
# This file is part of wger Workout Manager <https://github.com/wger-project>.
# Copyright (C) 2013 - 2021 wger Team
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# Standard Library
import datetime
from typing import (
Any,
Optional,
)
# Local
from .base import BaseTrophyChecker
class TimeBasedChecker(BaseTrophyChecker):
"""
Checker for time-based trophies.
Used for trophies that require working out before or after a certain time.
Expected params:
before (str, optional): Time string in HH:MM format - workout must be before this time
after (str, optional): Time string in HH:MM format - workout must be after this time
At least one of 'before' or 'after' must be provided.
Example:
Early Bird trophy: params={'before': '06:00'}
Night Owl trophy: params={'after': '21:00'}
"""
def _parse_time(self, time_str: str) -> Optional[datetime.time]:
"""Parse a time string in HH:MM format."""
try:
parts = time_str.split(':')
hour = int(parts[0])
minute = int(parts[1]) if len(parts) > 1 else 0
return datetime.time(hour=hour, minute=minute)
except (ValueError, IndexError, AttributeError):
return None
def validate_params(self) -> bool:
"""Validate that at least one of before/after is present and valid."""
before = self.params.get('before')
after = self.params.get('after')
if before is None and after is None:
return False
if before is not None and self._parse_time(before) is None:
return False
if after is not None and self._parse_time(after) is None:
return False
return True
def check(self) -> bool:
"""Check if user has worked out at the required time."""
if not self.validate_params():
return False
before = self.params.get('before')
after = self.params.get('after')
if before is not None:
# Check if user has ever worked out before the specified time
target_time = self._parse_time(before)
earliest = self.statistics.earliest_workout_time
if earliest is not None and earliest < target_time:
return True
if after is not None:
# Check if user has ever worked out after the specified time
target_time = self._parse_time(after)
latest = self.statistics.latest_workout_time
if latest is not None and latest > target_time:
return True
return False
def get_progress(self) -> float:
"""
Get progress towards the trophy.
For time-based trophies, progress is binary - either achieved (100%) or not (0%).
"""
return 100.0 if self.check() else 0.0
def get_target_value(self) -> str:
"""Get the target time as a string."""
before = self.params.get('before')
after = self.params.get('after')
if before is not None:
return f'Before {before}'
if after is not None:
return f'After {after}'
return 'N/A'
def get_current_value(self) -> str:
"""Get the user's relevant workout time."""
before = self.params.get('before')
if before is not None:
earliest = self.statistics.earliest_workout_time
if earliest is not None:
return earliest.strftime('%H:%M')
return 'No workouts yet'
# For 'after' condition
latest = self.statistics.latest_workout_time
if latest is not None:
return latest.strftime('%H:%M')
return 'No workouts yet'
def get_progress_display(self) -> str:
"""Get human-readable progress string."""
if self.check():
return 'Achieved!'
before = self.params.get('before')
if before is not None:
earliest = self.statistics.earliest_workout_time
if earliest is not None:
return f'Earliest: {earliest.strftime("%H:%M")} (need before {before})'
return f'Work out before {before}'
after = self.params.get('after')
if after is not None:
latest = self.statistics.latest_workout_time
if latest is not None:
return f'Latest: {latest.strftime("%H:%M")} (need after {after})'
return f'Work out after {after}'
return 'N/A'

View File

@@ -0,0 +1,88 @@
# This file is part of wger Workout Manager <https://github.com/wger-project>.
# Copyright (C) 2013 - 2021 wger Team
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# Standard Library
from decimal import Decimal
from typing import (
Any,
Union,
)
# Local
from .base import BaseTrophyChecker
class VolumeChecker(BaseTrophyChecker):
"""
Checker for volume-based trophies.
Used for trophies that require lifting a cumulative amount of weight.
Expected params:
kg (int|float): The total weight in kg required to earn the trophy
Example:
Lifter trophy: params={'kg': 5000}
Atlas trophy: params={'kg': 100000}
"""
def validate_params(self) -> bool:
"""Validate that kg parameter is present and valid."""
kg = self.params.get('kg')
return kg is not None and isinstance(kg, (int, float)) and kg > 0
def check(self) -> bool:
"""Check if user has lifted the required total weight."""
if not self.validate_params():
return False
return self.get_current_value() >= self.get_target_value()
def get_progress(self) -> float:
"""Get progress as percentage of weight lifted."""
if not self.validate_params():
return 0.0
target = self.get_target_value()
if target <= 0:
return 0.0
current = float(self.get_current_value())
progress = (current / target) * 100
return min(progress, 100.0)
def get_target_value(self) -> float:
"""Get the target weight in kg."""
return float(self.params.get('kg', 0))
def get_current_value(self) -> Decimal:
"""Get the user's total weight lifted in kg."""
return self.statistics.total_weight_lifted
def get_progress_display(self) -> str:
"""Get human-readable progress string."""
current = self.get_current_value()
target = self.get_target_value()
# Format large numbers with commas for readability
if current >= 1000:
current_str = f'{current:,.0f}'
else:
current_str = f'{current:.1f}'
if target >= 1000:
target_str = f'{target:,.0f}'
else:
target_str = f'{target:.1f}'
return f'{current_str} / {target_str} kg'

View File

@@ -0,0 +1,72 @@
# This file is part of wger Workout Manager <https://github.com/wger-project>.
# Copyright (C) 2013 - 2021 wger Team
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# Standard Library
from typing import Any
# Local
from .base import BaseTrophyChecker
class WeekendWarriorChecker(BaseTrophyChecker):
"""
Checker for Weekend Warrior trophy.
Used for trophies that require working out on both Saturday and Sunday
for consecutive weekends.
Expected params:
weekends (int): The number of consecutive complete weekends required
Example:
Weekend Warrior trophy: params={'weekends': 4}
"""
def validate_params(self) -> bool:
"""Validate that weekends parameter is present and valid."""
weekends = self.params.get('weekends')
return weekends is not None and isinstance(weekends, int) and weekends > 0
def check(self) -> bool:
"""Check if user has completed workouts on required consecutive weekends."""
if not self.validate_params():
return False
return self.get_current_value() >= self.get_target_value()
def get_progress(self) -> float:
"""Get progress as percentage of weekend streak achieved."""
if not self.validate_params():
return 0.0
target = self.get_target_value()
if target <= 0:
return 0.0
current = self.get_current_value()
progress = (current / target) * 100
return min(progress, 100.0)
def get_target_value(self) -> int:
"""Get the target number of consecutive complete weekends."""
return self.params.get('weekends', 0)
def get_current_value(self) -> int:
"""Get the user's current weekend workout streak."""
return self.statistics.weekend_workout_streak
def get_progress_display(self) -> str:
"""Get human-readable progress string."""
current = self.get_current_value()
target = self.get_target_value()
return f'{current} / {target} weekends'

View File

@@ -0,0 +1,137 @@
[
{
"model": "trophies.trophy",
"pk": 1,
"fields": {
"name": "Beginner",
"description": "Complete your first workout",
"trophy_type": "count",
"checker_class": "count_based",
"checker_params": {"count": 1},
"is_hidden": false,
"is_progressive": false,
"is_active": true,
"order": 1
}
},
{
"model": "trophies.trophy",
"pk": 2,
"fields": {
"name": "Unstoppable",
"description": "Maintain a 30-day workout streak",
"trophy_type": "sequence",
"checker_class": "streak",
"checker_params": {"days": 30},
"is_hidden": false,
"is_progressive": true,
"is_active": true,
"order": 2
}
},
{
"model": "trophies.trophy",
"pk": 3,
"fields": {
"name": "Weekend Warrior",
"description": "Work out on Saturday and Sunday for 4 consecutive weekends",
"trophy_type": "sequence",
"checker_class": "weekend_warrior",
"checker_params": {"weekends": 4},
"is_hidden": false,
"is_progressive": true,
"is_active": true,
"order": 3
}
},
{
"model": "trophies.trophy",
"pk": 4,
"fields": {
"name": "Lifter",
"description": "Lift a cumulative total of 5,000 kg",
"trophy_type": "volume",
"checker_class": "volume",
"checker_params": {"kg": 5000},
"is_hidden": false,
"is_progressive": true,
"is_active": true,
"order": 4
}
},
{
"model": "trophies.trophy",
"pk": 5,
"fields": {
"name": "Atlas",
"description": "Lift a cumulative total of 100,000 kg",
"trophy_type": "volume",
"checker_class": "volume",
"checker_params": {"kg": 100000},
"is_hidden": false,
"is_progressive": true,
"is_active": true,
"order": 5
}
},
{
"model": "trophies.trophy",
"pk": 6,
"fields": {
"name": "Early Bird",
"description": "Complete a workout before 6:00 AM",
"trophy_type": "time",
"checker_class": "time_based",
"checker_params": {"before": "06:00"},
"is_hidden": false,
"is_progressive": false,
"is_active": true,
"order": 6
}
},
{
"model": "trophies.trophy",
"pk": 7,
"fields": {
"name": "Night Owl",
"description": "Complete a workout after 9:00 PM",
"trophy_type": "time",
"checker_class": "time_based",
"checker_params": {"after": "21:00"},
"is_hidden": false,
"is_progressive": false,
"is_active": true,
"order": 7
}
},
{
"model": "trophies.trophy",
"pk": 8,
"fields": {
"name": "New Year, New Me",
"description": "Work out on January 1st",
"trophy_type": "date",
"checker_class": "date_based",
"checker_params": {"month": 1, "day": 1},
"is_hidden": false,
"is_progressive": false,
"is_active": true,
"order": 8
}
},
{
"model": "trophies.trophy",
"pk": 9,
"fields": {
"name": "Phoenix",
"description": "Return to training after being inactive for 30 days",
"trophy_type": "other",
"checker_class": "inactivity_return",
"checker_params": {"inactive_days": 30},
"is_hidden": true,
"is_progressive": false,
"is_active": true,
"order": 9
}
}
]

View File

@@ -0,0 +1,15 @@
# This file is part of wger Workout Manager <https://github.com/wger-project>.
# Copyright (C) 2013 - 2021 wger Team
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

View File

@@ -0,0 +1,15 @@
# This file is part of wger Workout Manager <https://github.com/wger-project>.
# Copyright (C) 2013 - 2021 wger Team
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

View File

@@ -0,0 +1,148 @@
# This file is part of wger Workout Manager <https://github.com/wger-project>.
# Copyright (C) 2013 - 2021 wger Team
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# Django
from django.contrib.auth.models import User
from django.core.management.base import (
BaseCommand,
CommandError,
)
# wger
from wger.trophies.models import Trophy
from wger.trophies.services.trophy import TrophyService
class Command(BaseCommand):
"""
Manually trigger trophy evaluation for users.
This command allows administrators to evaluate trophies for specific
users, specific trophies, or all trophies for all users.
"""
help = 'Evaluate trophies for users'
def add_arguments(self, parser):
parser.add_argument(
'--user',
type=str,
dest='username',
help='Username of the user to evaluate trophies for',
)
parser.add_argument(
'--trophy',
type=int,
dest='trophy_id',
help='ID of a specific trophy to evaluate',
)
parser.add_argument(
'--all',
action='store_true',
dest='all',
default=False,
help='Evaluate all trophies for all active users',
)
parser.add_argument(
'--force-reevaluate',
action='store_true',
dest='force_reevaluate',
default=False,
help='Re-evaluate all trophies, including already earned ones',
)
def handle(self, **options):
"""
Process the trophy evaluation based on provided options.
"""
verbosity = int(options['verbosity'])
username = options['username']
trophy_id = options['trophy_id']
evaluate_all = options['all']
force_reevaluate = options['force_reevaluate']
# Validate that at least one option is provided
if not username and not trophy_id and not evaluate_all:
raise CommandError(
'Please specify --user, --trophy, or --all. See help for details.'
)
# Case 1: Evaluate for a specific user
if username:
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
raise CommandError(f'User "{username}" does not exist')
if verbosity >= 1:
self.stdout.write(f'Evaluating trophies for user: {username}')
awarded = TrophyService.evaluate_all_trophies(user)
if verbosity >= 2:
for user_trophy in awarded:
self.stdout.write(
self.style.SUCCESS(
f'✓ Awarded trophy "{user_trophy.trophy.name}" to {username}'
)
)
if verbosity >= 1:
self.stdout.write(
self.style.SUCCESS(
f'\nEvaluation complete: {len(awarded)} trophy(ies) awarded'
)
)
# Case 2: Evaluate a specific trophy for all users (or force re-evaluation)
elif trophy_id or evaluate_all or force_reevaluate:
trophy_ids = None
if trophy_id:
# Validate trophy exists
try:
trophy = Trophy.objects.get(id=trophy_id)
trophy_ids = [trophy_id]
if verbosity >= 1:
self.stdout.write(
f'Evaluating trophy "{trophy.name}" for all users'
)
except Trophy.DoesNotExist:
raise CommandError(f'Trophy with ID {trophy_id} does not exist')
else:
if verbosity >= 1:
self.stdout.write('Evaluating all trophies for all users')
# Use the reevaluate service method
results = TrophyService.reevaluate_trophies(
trophy_ids=trophy_ids,
user_ids=None, # All active users
)
if verbosity >= 1:
self.stdout.write(
self.style.SUCCESS(
f'\nEvaluation complete:\n'
f' Users checked: {results["users_checked"]}\n'
f' Trophies awarded: {results["trophies_awarded"]}'
)
)
# Case 3: Force re-evaluation (handled above)
# The force_reevaluate flag is implicit in using reevaluate_trophies method

View File

@@ -0,0 +1,196 @@
# This file is part of wger Workout Manager <https://github.com/wger-project>.
# Copyright (C) 2013 - 2021 wger Team
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# Django
from django.core.management.base import BaseCommand
from django.utils.translation import gettext_lazy as _
# wger
from wger.trophies.models import Trophy
class Command(BaseCommand):
"""
Load initial trophy definitions into the database.
This command is idempotent - it can be run multiple times safely.
Existing trophies with the same name will be updated, not duplicated.
"""
help = 'Load initial trophy definitions into the database'
def add_arguments(self, parser):
parser.add_argument(
'--update',
action='store_true',
dest='update',
default=False,
help='Update existing trophies if they already exist',
)
def handle(self, **options):
"""
Load the initial trophy definitions.
"""
verbosity = int(options['verbosity'])
update_existing = options['update']
# Define the initial trophies
trophies_data = [
{
'name': _('Beginner'),
'description': _('Complete your first workout'),
'trophy_type': Trophy.TYPE_COUNT,
'checker_class': 'count_based',
'checker_params': {'count': 1},
'is_hidden': False,
'is_progressive': False,
'order': 1,
},
{
'name': _('Unstoppable'),
'description': _('Maintain a 30-day workout streak'),
'trophy_type': Trophy.TYPE_SEQUENCE,
'checker_class': 'streak',
'checker_params': {'days': 30},
'is_hidden': False,
'is_progressive': True,
'order': 2,
},
{
'name': _('Weekend Warrior'),
'description': _('Work out on Saturday and Sunday for 4 consecutive weekends'),
'trophy_type': Trophy.TYPE_SEQUENCE,
'checker_class': 'weekend_warrior',
'checker_params': {'weekends': 4},
'is_hidden': False,
'is_progressive': True,
'order': 3,
},
{
'name': _('Lifter'),
'description': _('Lift a cumulative total of 5,000 kg'),
'trophy_type': Trophy.TYPE_VOLUME,
'checker_class': 'volume',
'checker_params': {'kg': 5000},
'is_hidden': False,
'is_progressive': True,
'order': 4,
},
{
'name': _('Atlas'),
'description': _('Lift a cumulative total of 100,000 kg'),
'trophy_type': Trophy.TYPE_VOLUME,
'checker_class': 'volume',
'checker_params': {'kg': 100000},
'is_hidden': False,
'is_progressive': True,
'order': 5,
},
{
'name': _('Early Bird'),
'description': _('Complete a workout before 6:00 AM'),
'trophy_type': Trophy.TYPE_TIME,
'checker_class': 'time_based',
'checker_params': {'before': '06:00'},
'is_hidden': False,
'is_progressive': False,
'order': 6,
},
{
'name': _('Night Owl'),
'description': _('Complete a workout after 9:00 PM'),
'trophy_type': Trophy.TYPE_TIME,
'checker_class': 'time_based',
'checker_params': {'after': '21:00'},
'is_hidden': False,
'is_progressive': False,
'order': 7,
},
{
'name': _('New Year, New Me'),
'description': _('Work out on January 1st'),
'trophy_type': Trophy.TYPE_DATE,
'checker_class': 'date_based',
'checker_params': {'month': 1, 'day': 1},
'is_hidden': False,
'is_progressive': False,
'order': 8,
},
{
'name': _('Phoenix'),
'description': _('Return to training after being inactive for 30 days'),
'trophy_type': Trophy.TYPE_OTHER,
'checker_class': 'inactivity_return',
'checker_params': {'inactive_days': 30},
'is_hidden': True,
'is_progressive': False,
'order': 9,
},
]
created_count = 0
updated_count = 0
skipped_count = 0
for trophy_data in trophies_data:
# Convert lazy translation to string for database lookup
name = str(trophy_data['name'])
# Check if trophy already exists
existing = Trophy.objects.filter(name=name).first()
if existing:
if update_existing:
# Update existing trophy
for key, value in trophy_data.items():
if key != 'name': # Don't update the name
setattr(existing, key, value)
existing.save()
updated_count += 1
if verbosity >= 2:
self.stdout.write(
self.style.SUCCESS(f'✓ Updated trophy: {name}')
)
else:
skipped_count += 1
if verbosity >= 2:
self.stdout.write(
self.style.WARNING(f'- Skipped existing trophy: {name}')
)
else:
# Create new trophy
Trophy.objects.create(**trophy_data)
created_count += 1
if verbosity >= 2:
self.stdout.write(
self.style.SUCCESS(f'+ Created trophy: {name}')
)
# Summary
if verbosity >= 1:
self.stdout.write(
self.style.SUCCESS(
f'\nTrophy loading complete:\n'
f' Created: {created_count}\n'
f' Updated: {updated_count}\n'
f' Skipped: {skipped_count}\n'
f' Total: {len(trophies_data)}'
)
)

View File

@@ -0,0 +1,164 @@
# This file is part of wger Workout Manager <https://github.com/wger-project>.
# Copyright (C) 2013 - 2021 wger Team
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# Standard Library
from datetime import timedelta
# Django
from django.conf import settings
from django.contrib.auth.models import User
from django.core.management.base import (
BaseCommand,
CommandError,
)
from django.utils import timezone
# wger
from wger.trophies.services.statistics import UserStatisticsService
class Command(BaseCommand):
"""
Recalculate user statistics from workout history.
This command performs a full recalculation of UserStatistics for
specified users by analyzing their complete workout history.
"""
help = 'Recalculate user statistics from workout history'
def add_arguments(self, parser):
parser.add_argument(
'--user',
type=str,
dest='username',
help='Username of the user to recalculate statistics for',
)
parser.add_argument(
'--all',
action='store_true',
dest='all',
default=False,
help='Recalculate statistics for all users',
)
parser.add_argument(
'--active-only',
action='store_true',
dest='active_only',
default=False,
help='Only process users who logged in recently (with --all)',
)
def handle(self, **options):
"""
Process the statistics recalculation based on provided options.
"""
verbosity = int(options['verbosity'])
username = options['username']
recalculate_all = options['all']
active_only = options['active_only']
# Validate that at least one option is provided
if not username and not recalculate_all:
raise CommandError(
'Please specify --user or --all. See help for details.'
)
# Case 1: Recalculate for a specific user
if username:
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
raise CommandError(f'User "{username}" does not exist')
if verbosity >= 1:
self.stdout.write(f'Recalculating statistics for user: {username}')
stats = UserStatisticsService.update_statistics(user)
if verbosity >= 2:
self.stdout.write(
self.style.SUCCESS(
f'\n✓ Statistics updated:\n'
f' Total workouts: {stats.total_workouts}\n'
f' Total weight lifted: {stats.total_weight_lifted} kg\n'
f' Current streak: {stats.current_streak} days\n'
f' Longest streak: {stats.longest_streak} days\n'
f' Weekend streak: {stats.weekend_workout_streak} weekends'
)
)
if verbosity >= 1:
self.stdout.write(
self.style.SUCCESS(f'\nRecalculation complete for {username}')
)
# Case 2: Recalculate for all users
elif recalculate_all:
# Get users to process
users = User.objects.all()
if active_only:
# Only process users who logged in recently
inactive_days = settings.WGER_SETTINGS.get('TROPHIES_INACTIVE_USER_DAYS', 30)
inactive_threshold = timezone.now() - timedelta(days=inactive_days)
users = users.filter(last_login__gte=inactive_threshold)
if verbosity >= 1:
self.stdout.write(
f'Recalculating statistics for active users '
f'(logged in within {inactive_days} days)'
)
else:
if verbosity >= 1:
self.stdout.write('Recalculating statistics for all users')
total_users = users.count()
processed = 0
errors = 0
for user in users:
try:
UserStatisticsService.update_statistics(user)
processed += 1
if verbosity >= 2:
self.stdout.write(
self.style.SUCCESS(f'✓ Processed: {user.username}')
)
elif verbosity >= 1 and processed % 100 == 0:
self.stdout.write(f' Processed {processed}/{total_users} users...')
except Exception as e:
errors += 1
if verbosity >= 1:
self.stdout.write(
self.style.ERROR(
f'✗ Error processing {user.username}: {str(e)}'
)
)
if verbosity >= 1:
self.stdout.write(
self.style.SUCCESS(
f'\nRecalculation complete:\n'
f' Total users: {total_users}\n'
f' Processed: {processed}\n'
f' Errors: {errors}'
)
)

View File

@@ -0,0 +1,83 @@
# Generated by Django 5.2.8 on 2025-11-25 10:54
import django.core.validators
import django.db.models.deletion
import uuid
import wger.trophies.models.trophy
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Trophy',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('name', models.CharField(help_text='The name of the trophy', max_length=100, verbose_name='Name')),
('description', models.TextField(blank=True, default='', help_text='A description of how to earn this trophy', verbose_name='Description')),
('image', models.ImageField(blank=True, null=True, upload_to=wger.trophies.models.trophy.trophy_image_upload_path, verbose_name='Image')),
('trophy_type', models.CharField(choices=[('time', 'Time-based'), ('volume', 'Volume-based'), ('count', 'Count-based'), ('sequence', 'Sequence-based'), ('date', 'Date-based'), ('other', 'Other')], default='other', help_text='The type of criteria used to evaluate this trophy', max_length=20, verbose_name='Trophy type')),
('checker_class', models.CharField(help_text='The Python class path used to check if this trophy is earned', max_length=255, verbose_name='Checker class')),
('checker_params', models.JSONField(blank=True, default=dict, help_text='JSON parameters passed to the checker class', verbose_name='Checker parameters')),
('is_hidden', models.BooleanField(default=False, help_text='If true, this trophy is hidden until earned', verbose_name='Hidden')),
('is_progressive', models.BooleanField(default=False, help_text='If true, this trophy shows progress towards completion', verbose_name='Progressive')),
('is_active', models.BooleanField(default=True, help_text='If false, this trophy cannot be earned', verbose_name='Active')),
('order', models.PositiveIntegerField(default=0, help_text='Display order of the trophy', verbose_name='Order')),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Trophy',
'verbose_name_plural': 'Trophies',
'ordering': ['order', 'name'],
},
),
migrations.CreateModel(
name='UserStatistics',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('total_weight_lifted', models.DecimalField(decimal_places=2, default=0, help_text='Cumulative weight lifted in kg', max_digits=12, verbose_name='Total weight lifted')),
('total_workouts', models.PositiveIntegerField(default=0, help_text='Total number of workout sessions completed', verbose_name='Total workouts')),
('current_streak', models.PositiveIntegerField(default=0, help_text='Current consecutive days with workouts', verbose_name='Current streak')),
('longest_streak', models.PositiveIntegerField(default=0, help_text='Longest consecutive days with workouts', verbose_name='Longest streak')),
('last_workout_date', models.DateField(blank=True, help_text='Date of the most recent workout', null=True, verbose_name='Last workout date')),
('earliest_workout_time', models.TimeField(blank=True, help_text='Earliest time a workout was started', null=True, verbose_name='Earliest workout time')),
('latest_workout_time', models.TimeField(blank=True, help_text='Latest time a workout was started', null=True, verbose_name='Latest workout time')),
('weekend_workout_streak', models.PositiveIntegerField(default=0, help_text='Consecutive weekends with workouts on both Saturday and Sunday', verbose_name='Weekend workout streak')),
('last_inactive_date', models.DateField(blank=True, help_text='Last date before the current activity period began', null=True, verbose_name='Last inactive date')),
('worked_out_jan_1', models.BooleanField(default=False, help_text='Whether user has ever worked out on January 1st', verbose_name='Worked out on January 1st')),
('last_updated', models.DateTimeField(auto_now=True, verbose_name='Last updated')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='trophy_statistics', to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'verbose_name': 'User statistics',
'verbose_name_plural': 'User statistics',
},
),
migrations.CreateModel(
name='UserTrophy',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('earned_at', models.DateTimeField(auto_now_add=True, help_text='When the trophy was earned', verbose_name='Earned at')),
('progress', models.FloatField(default=0.0, help_text='Progress towards earning the trophy (0-100)', validators=[django.core.validators.MinValueValidator(0.0), django.core.validators.MaxValueValidator(100.0)], verbose_name='Progress')),
('is_notified', models.BooleanField(default=False, help_text='Whether the user has been notified about earning this trophy', verbose_name='Notified')),
('trophy', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_trophies', to='trophies.trophy', verbose_name='Trophy')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='earned_trophies', to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'verbose_name': 'User trophy',
'verbose_name_plural': 'User trophies',
'ordering': ['-earned_at'],
'unique_together': {('user', 'trophy')},
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.8 on 2025-12-02 11:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('trophies', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='userstatistics',
name='last_complete_weekend_date',
field=models.DateField(blank=True, help_text='Date of the last Saturday where both Sat and Sun had workouts', null=True, verbose_name='Last complete weekend date'),
),
]

View File

@@ -0,0 +1,158 @@
# Generated by Django 5.2.9 on 2025-12-03 18:33
from django.db import migrations
def load_initial_trophies(apps, schema_editor):
"""
Load the initial set of 9 trophies.
This migration is idempotent - it will skip trophies that already exist
based on the trophy name.
"""
Trophy = apps.get_model('trophies', 'Trophy')
# Define the initial trophies (same as in load_trophies management command)
trophies_data = [
{
'name': 'Beginner',
'description': 'Complete your first workout',
'trophy_type': 'count',
'checker_class': 'count_based',
'checker_params': {'count': 1},
'is_hidden': False,
'is_progressive': False,
'order': 1,
},
{
'name': 'Unstoppable',
'description': 'Maintain a 30-day workout streak',
'trophy_type': 'sequence',
'checker_class': 'streak',
'checker_params': {'days': 30},
'is_hidden': False,
'is_progressive': True,
'order': 2,
},
{
'name': 'Weekend Warrior',
'description': 'Work out on Saturday and Sunday for 4 consecutive weekends',
'trophy_type': 'sequence',
'checker_class': 'weekend_warrior',
'checker_params': {'weekends': 4},
'is_hidden': False,
'is_progressive': True,
'order': 3,
},
{
'name': 'Lifter',
'description': 'Lift a cumulative total of 5,000 kg',
'trophy_type': 'volume',
'checker_class': 'volume',
'checker_params': {'kg': 5000},
'is_hidden': False,
'is_progressive': True,
'order': 4,
},
{
'name': 'Atlas',
'description': 'Lift a cumulative total of 100,000 kg',
'trophy_type': 'volume',
'checker_class': 'volume',
'checker_params': {'kg': 100000},
'is_hidden': False,
'is_progressive': True,
'order': 5,
},
{
'name': 'Early Bird',
'description': 'Complete a workout before 6:00 AM',
'trophy_type': 'time',
'checker_class': 'time_based',
'checker_params': {'before': '06:00'},
'is_hidden': False,
'is_progressive': False,
'order': 6,
},
{
'name': 'Night Owl',
'description': 'Complete a workout after 9:00 PM',
'trophy_type': 'time',
'checker_class': 'time_based',
'checker_params': {'after': '21:00'},
'is_hidden': False,
'is_progressive': False,
'order': 7,
},
{
'name': 'New Year, New Me',
'description': 'Work out on January 1st',
'trophy_type': 'date',
'checker_class': 'date_based',
'checker_params': {'month': 1, 'day': 1},
'is_hidden': False,
'is_progressive': False,
'order': 8,
},
{
'name': 'Phoenix',
'description': 'Return to training after being inactive for 30 days',
'trophy_type': 'other',
'checker_class': 'inactivity_return',
'checker_params': {'inactive_days': 30},
'is_hidden': True,
'is_progressive': False,
'order': 9,
},
]
created_count = 0
skipped_count = 0
for trophy_data in trophies_data:
# Check if trophy already exists
if not Trophy.objects.filter(name=trophy_data['name']).exists():
Trophy.objects.create(**trophy_data)
created_count += 1
else:
skipped_count += 1
if created_count > 0:
print(f'Trophy migration: Created {created_count} trophies, skipped {skipped_count}')
def reverse_load_trophies(apps, schema_editor):
"""
Reverse migration - delete the initial trophies.
This is optional and can be left as a no-op if you want to keep
trophies even when rolling back migrations.
"""
Trophy = apps.get_model('trophies', 'Trophy')
trophy_names = [
'Beginner',
'Unstoppable',
'Weekend Warrior',
'Lifter',
'Atlas',
'Early Bird',
'Night Owl',
'New Year, New Me',
'Phoenix',
]
deleted_count = Trophy.objects.filter(name__in=trophy_names).delete()[0]
if deleted_count > 0:
print(f'Trophy migration reverse: Deleted {deleted_count} trophies')
class Migration(migrations.Migration):
dependencies = [
('trophies', '0002_add_last_complete_weekend_date'),
]
operations = [
migrations.RunPython(load_initial_trophies, reverse_load_trophies),
]

View File

View File

@@ -0,0 +1,20 @@
# This file is part of wger Workout Manager <https://github.com/wger-project>.
# Copyright (C) 2013 - 2021 wger Team
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# Local
from .trophy import Trophy
from .user_statistics import UserStatistics
from .user_trophy import UserTrophy

View File

@@ -0,0 +1,160 @@
# This file is part of wger Workout Manager <https://github.com/wger-project>.
# Copyright (C) 2013 - 2021 wger Team
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# Standard Library
import uuid
# Django
from django.db import models
from django.utils.translation import gettext_lazy as _
def trophy_image_upload_path(instance, filename):
"""
Returns the upload path for trophy images
"""
ext = filename.split('.')[-1]
return f'trophies/{instance.uuid}.{ext}'
class Trophy(models.Model):
"""
Model representing a trophy/achievement that users can earn
"""
TYPE_TIME = 'time'
TYPE_VOLUME = 'volume'
TYPE_COUNT = 'count'
TYPE_SEQUENCE = 'sequence'
TYPE_DATE = 'date'
TYPE_OTHER = 'other'
TROPHY_TYPES = (
(TYPE_TIME, _('Time-based')),
(TYPE_VOLUME, _('Volume-based')),
(TYPE_COUNT, _('Count-based')),
(TYPE_SEQUENCE, _('Sequence-based')),
(TYPE_DATE, _('Date-based')),
(TYPE_OTHER, _('Other')),
)
uuid = models.UUIDField(
default=uuid.uuid4,
editable=False,
unique=True,
)
"""Unique identifier for the trophy"""
name = models.CharField(
max_length=100,
verbose_name=_('Name'),
help_text=_('The name of the trophy'),
)
"""The name of the trophy"""
description = models.TextField(
verbose_name=_('Description'),
help_text=_('A description of how to earn this trophy'),
blank=True,
default='',
)
"""Description of the trophy and how to earn it"""
image = models.ImageField(
verbose_name=_('Image'),
upload_to=trophy_image_upload_path,
blank=True,
null=True,
)
"""Optional image for the trophy"""
trophy_type = models.CharField(
max_length=20,
choices=TROPHY_TYPES,
default=TYPE_OTHER,
verbose_name=_('Trophy type'),
help_text=_('The type of criteria used to evaluate this trophy'),
)
"""The type of trophy (time, volume, count, sequence, date, other)"""
checker_class = models.CharField(
max_length=255,
verbose_name=_('Checker class'),
help_text=_('The Python class path used to check if this trophy is earned'),
)
"""Python path to the checker class (e.g., 'wger.trophies.checkers.CountBasedChecker')"""
checker_params = models.JSONField(
default=dict,
blank=True,
verbose_name=_('Checker parameters'),
help_text=_('JSON parameters passed to the checker class'),
)
"""Parameters for the checker class (e.g., {'count': 1} for workout count)"""
is_hidden = models.BooleanField(
default=False,
verbose_name=_('Hidden'),
help_text=_('If true, this trophy is hidden until earned'),
)
"""Whether the trophy is hidden until earned"""
is_progressive = models.BooleanField(
default=False,
verbose_name=_('Progressive'),
help_text=_('If true, this trophy shows progress towards completion'),
)
"""Whether to show progress towards earning the trophy"""
is_active = models.BooleanField(
default=True,
verbose_name=_('Active'),
help_text=_('If false, this trophy cannot be earned'),
)
"""Whether the trophy is active and can be earned"""
order = models.PositiveIntegerField(
default=0,
verbose_name=_('Order'),
help_text=_('Display order of the trophy'),
)
"""Display order for the trophy"""
created = models.DateTimeField(
auto_now_add=True,
editable=False,
)
"""When the trophy was created"""
updated = models.DateTimeField(
auto_now=True,
editable=False,
)
"""When the trophy was last updated"""
class Meta:
ordering = ['order', 'name']
verbose_name = _('Trophy')
verbose_name_plural = _('Trophies')
def __str__(self):
return self.name
def get_owner_object(self):
"""
Trophies don't have an owner - they are global
"""
return None

View File

@@ -0,0 +1,139 @@
# This file is part of wger Workout Manager <https://github.com/wger-project>.
# Copyright (C) 2013 - 2021 wger Team
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# Django
from django.contrib.auth.models import User
from django.db import models
from django.utils.translation import gettext_lazy as _
class UserStatistics(models.Model):
"""
Denormalized statistics table for trophy calculations.
This table is updated incrementally as users log workouts to avoid
expensive recalculations every time a trophy is evaluated.
"""
user = models.OneToOneField(
User,
on_delete=models.CASCADE,
related_name='trophy_statistics',
verbose_name=_('User'),
)
"""The user these statistics belong to"""
total_weight_lifted = models.DecimalField(
max_digits=12,
decimal_places=2,
default=0,
verbose_name=_('Total weight lifted'),
help_text=_('Cumulative weight lifted in kg'),
)
"""Total cumulative weight lifted in kg"""
total_workouts = models.PositiveIntegerField(
default=0,
verbose_name=_('Total workouts'),
help_text=_('Total number of workout sessions completed'),
)
"""Total number of workout sessions completed"""
current_streak = models.PositiveIntegerField(
default=0,
verbose_name=_('Current streak'),
help_text=_('Current consecutive days with workouts'),
)
"""Current consecutive workout streak in days"""
longest_streak = models.PositiveIntegerField(
default=0,
verbose_name=_('Longest streak'),
help_text=_('Longest consecutive days with workouts'),
)
"""Longest consecutive workout streak ever achieved"""
last_workout_date = models.DateField(
null=True,
blank=True,
verbose_name=_('Last workout date'),
help_text=_('Date of the most recent workout'),
)
"""Date of the most recent workout"""
earliest_workout_time = models.TimeField(
null=True,
blank=True,
verbose_name=_('Earliest workout time'),
help_text=_('Earliest time a workout was started'),
)
"""Earliest time a workout was ever started"""
latest_workout_time = models.TimeField(
null=True,
blank=True,
verbose_name=_('Latest workout time'),
help_text=_('Latest time a workout was started'),
)
"""Latest time a workout was ever started"""
weekend_workout_streak = models.PositiveIntegerField(
default=0,
verbose_name=_('Weekend workout streak'),
help_text=_('Consecutive weekends with workouts on both Saturday and Sunday'),
)
"""Consecutive weekends with workouts on both Saturday and Sunday"""
last_complete_weekend_date = models.DateField(
null=True,
blank=True,
verbose_name=_('Last complete weekend date'),
help_text=_('Date of the last Saturday where both Sat and Sun had workouts'),
)
"""Used for tracking consecutive weekend workouts"""
last_inactive_date = models.DateField(
null=True,
blank=True,
verbose_name=_('Last inactive date'),
help_text=_('Last date before the current activity period began'),
)
"""Used for Phoenix trophy - tracks when user was last inactive"""
worked_out_jan_1 = models.BooleanField(
default=False,
verbose_name=_('Worked out on January 1st'),
help_text=_('Whether user has ever worked out on January 1st'),
)
"""Flag for New Year, New Me trophy"""
last_updated = models.DateTimeField(
auto_now=True,
verbose_name=_('Last updated'),
)
"""When these statistics were last updated"""
class Meta:
verbose_name = _('User statistics')
verbose_name_plural = _('User statistics')
def __str__(self):
return f'Statistics for {self.user.username}'
def get_owner_object(self):
"""
Returns the object that has owner information
"""
return self

View File

@@ -0,0 +1,86 @@
# This file is part of wger Workout Manager <https://github.com/wger-project>.
# Copyright (C) 2013 - 2021 wger Team
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# Django
from django.contrib.auth.models import User
from django.core.validators import (
MaxValueValidator,
MinValueValidator,
)
from django.db import models
from django.utils.translation import gettext_lazy as _
# Local
from .trophy import Trophy
class UserTrophy(models.Model):
"""
Model representing a trophy earned by a user (M2M through table)
"""
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='earned_trophies',
verbose_name=_('User'),
)
"""The user who earned the trophy"""
trophy = models.ForeignKey(
Trophy,
on_delete=models.CASCADE,
related_name='user_trophies',
verbose_name=_('Trophy'),
)
"""The trophy that was earned"""
earned_at = models.DateTimeField(
auto_now_add=True,
verbose_name=_('Earned at'),
help_text=_('When the trophy was earned'),
)
"""When the trophy was earned"""
progress = models.FloatField(
default=0.0,
validators=[MinValueValidator(0.0), MaxValueValidator(100.0)],
verbose_name=_('Progress'),
help_text=_('Progress towards earning the trophy (0-100)'),
)
"""Progress towards earning the trophy (0-100%)"""
is_notified = models.BooleanField(
default=False,
verbose_name=_('Notified'),
help_text=_('Whether the user has been notified about earning this trophy'),
)
"""Whether the user has been notified about this trophy (for future notification system)"""
class Meta:
ordering = ['-earned_at']
verbose_name = _('User trophy')
verbose_name_plural = _('User trophies')
unique_together = [['user', 'trophy']]
def __str__(self):
return f'{self.user.username} - {self.trophy.name}'
def get_owner_object(self):
"""
Returns the object that has owner information
"""
return self

View File

@@ -0,0 +1,20 @@
# This file is part of wger Workout Manager <https://github.com/wger-project>.
# Copyright (C) 2013 - 2021 wger Team
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from .statistics import UserStatisticsService
from .trophy import TrophyService
__all__ = ['UserStatisticsService', 'TrophyService']

View File

@@ -0,0 +1,477 @@
# This file is part of wger Workout Manager <https://github.com/wger-project>.
# Copyright (C) 2013 - 2021 wger Team
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# Standard Library
import datetime
import logging
from decimal import Decimal
from typing import Optional
# Django
from django.contrib.auth.models import User
from django.db.models import Sum
# wger
from wger.manager.consts import WEIGHT_UNIT_LB
from wger.manager.models import (
WorkoutLog,
WorkoutSession,
)
from wger.trophies.models import UserStatistics
from wger.utils.units import AbstractWeight
logger = logging.getLogger(__name__)
class UserStatisticsService:
"""
Service class for managing user trophy statistics.
This service handles:
- Full recalculation of statistics from workout history
- Incremental updates when new workouts are logged
- Weight unit normalization (all stored in kg)
"""
@classmethod
def get_or_create_statistics(cls, user: User) -> UserStatistics:
"""
Get or create a UserStatistics record for the user.
Args:
user: The user to get/create statistics for
Returns:
The UserStatistics instance
"""
stats, created = UserStatistics.objects.get_or_create(user=user)
return stats
@classmethod
def update_statistics(cls, user: User) -> UserStatistics:
"""
Perform a full recalculation of user statistics from workout history.
This method recalculates all statistics from scratch by querying
the user's complete workout history. Use this for:
- Initial population of statistics
- Recovery from data inconsistencies
- After bulk data imports
Args:
user: The user to update statistics for
Returns:
The updated UserStatistics instance
"""
stats = cls.get_or_create_statistics(user)
# Get all workout logs for this user
logs = WorkoutLog.objects.filter(user=user).select_related('weight_unit', 'session')
# Calculate total weight lifted (normalized to kg)
total_weight = cls._calculate_total_weight(logs)
stats.total_weight_lifted = total_weight
# Get all workout sessions
sessions = WorkoutSession.objects.filter(user=user).order_by('date')
stats.total_workouts = sessions.count()
# Calculate streaks and other date-based stats
workout_dates = list(sessions.values_list('date', flat=True).distinct().order_by('date'))
current_streak, longest_streak = cls._calculate_streaks(workout_dates)
stats.current_streak = current_streak
stats.longest_streak = longest_streak
# Set last workout date
if workout_dates:
stats.last_workout_date = workout_dates[-1]
# Calculate earliest and latest workout times
earliest, latest = cls._calculate_workout_times(sessions)
stats.earliest_workout_time = earliest
stats.latest_workout_time = latest
# Calculate weekend workout streak
weekend_streak, last_complete_weekend = cls._calculate_weekend_streak(workout_dates)
stats.weekend_workout_streak = weekend_streak
stats.last_complete_weekend_date = last_complete_weekend
# Check if user worked out on January 1st
stats.worked_out_jan_1 = cls._check_jan_1_workout(workout_dates)
# Calculate last inactive date (for Phoenix trophy)
stats.last_inactive_date = cls._calculate_last_inactive_date(workout_dates)
stats.save()
return stats
@classmethod
def increment_workout(
cls,
user: User,
workout_log: Optional[WorkoutLog] = None,
session: Optional[WorkoutSession] = None,
) -> UserStatistics:
"""
Incrementally update statistics when a new workout is logged.
This method performs efficient incremental updates rather than
full recalculation. It's called by signal handlers when:
- A new WorkoutLog is created
- A WorkoutSession is created/updated
Args:
user: The user to update statistics for
workout_log: The new workout log (if triggered by log creation)
session: The workout session (if triggered by session creation)
Returns:
The updated UserStatistics instance
"""
stats = cls.get_or_create_statistics(user)
# Update total weight if a log was provided
if workout_log and workout_log.weight is not None:
weight_kg = cls._normalize_weight(workout_log.weight, workout_log.weight_unit_id)
reps = workout_log.repetitions or Decimal('1')
volume = weight_kg * reps
stats.total_weight_lifted += volume
# Get the session date
session_date = None
if session:
session_date = session.date
elif workout_log and workout_log.session:
session_date = workout_log.session.date
if session_date:
# Convert datetime to date if needed for comparison
if hasattr(session_date, 'date'):
session_date = session_date.date()
# Check if this is a new workout day
is_new_day = stats.last_workout_date is None or session_date > stats.last_workout_date
if is_new_day:
# Update streak
if stats.last_workout_date:
days_gap = (session_date - stats.last_workout_date).days
if days_gap == 1:
# Consecutive day - extend streak
stats.current_streak += 1
elif days_gap > 1:
# Gap in workouts - check for Phoenix trophy trigger
if days_gap >= 30:
stats.last_inactive_date = stats.last_workout_date
# Reset streak
stats.current_streak = 1
else:
# First workout ever
stats.current_streak = 1
# Update longest streak
if stats.current_streak > stats.longest_streak:
stats.longest_streak = stats.current_streak
# Update last workout date
stats.last_workout_date = session_date
# Check for Jan 1st workout
if session_date.month == 1 and session_date.day == 1:
stats.worked_out_jan_1 = True
# Update weekend streak
cls._update_weekend_streak_incremental(stats, session_date)
# Update workout times if session has time info
if session and session.time_start:
if stats.earliest_workout_time is None or session.time_start < stats.earliest_workout_time:
stats.earliest_workout_time = session.time_start
if stats.latest_workout_time is None or session.time_start > stats.latest_workout_time:
stats.latest_workout_time = session.time_start
# Count sessions for total workouts (recalculate to be accurate)
stats.total_workouts = WorkoutSession.objects.filter(user=user).count()
stats.save()
return stats
@classmethod
def handle_workout_deletion(cls, user: User) -> UserStatistics:
"""
Handle statistics update when a workout is deleted.
Since deletion can affect streaks and totals in complex ways,
we perform a full recalculation.
Args:
user: The user whose workout was deleted
Returns:
The updated UserStatistics instance
"""
return cls.update_statistics(user)
@classmethod
def _normalize_weight(cls, weight: Decimal, weight_unit_id: Optional[int]) -> Decimal:
"""
Convert weight to kg using AbstractWeight utility.
Args:
weight: The weight value
weight_unit_id: The weight unit ID (1=kg, 2=lb)
Returns:
Weight in kg
"""
if weight is None:
return Decimal('0')
mode = 'lb' if weight_unit_id == WEIGHT_UNIT_LB else 'kg'
return AbstractWeight(weight, mode).kg
@classmethod
def _calculate_total_weight(cls, logs) -> Decimal:
"""
Calculate total weight lifted from workout logs.
Volume = weight * reps for each set, summed across all logs.
All weights are normalized to kg.
"""
total = Decimal('0')
for log in logs:
if log.weight is not None and log.repetitions is not None:
weight_kg = cls._normalize_weight(log.weight, log.weight_unit_id)
total += weight_kg * log.repetitions
return total
@classmethod
def _calculate_streaks(cls, workout_dates: list) -> tuple:
"""
Calculate current and longest workout streaks.
A streak is consecutive days with at least one workout.
Args:
workout_dates: List of dates with workouts, sorted ascending
Returns:
Tuple of (current_streak, longest_streak)
"""
if not workout_dates:
return 0, 0
# Remove duplicates and sort
unique_dates = sorted(set(workout_dates))
current_streak = 1
longest_streak = 1
streak = 1
today = datetime.date.today()
for i in range(1, len(unique_dates)):
if (unique_dates[i] - unique_dates[i - 1]).days == 1:
streak += 1
else:
streak = 1
if streak > longest_streak:
longest_streak = streak
# Check if current streak is active (includes today or yesterday)
if unique_dates:
last_workout = unique_dates[-1]
days_since_last = (today - last_workout).days
if days_since_last <= 1:
current_streak = streak
else:
current_streak = 0
return current_streak, longest_streak
@classmethod
def _calculate_workout_times(cls, sessions) -> tuple:
"""
Find earliest and latest workout start times.
Args:
sessions: QuerySet of WorkoutSession
Returns:
Tuple of (earliest_time, latest_time)
"""
times = [s.time_start for s in sessions if s.time_start is not None]
if not times:
return None, None
return min(times), max(times)
@classmethod
def _calculate_weekend_streak(cls, workout_dates: list) -> tuple:
"""
Calculate consecutive weekends with workouts on both Saturday and Sunday.
Args:
workout_dates: List of workout dates
Returns:
Tuple of (weekend_streak, last_complete_weekend_date)
"""
if not workout_dates:
return 0, None
date_set = set(workout_dates)
# Find all complete weekends (both Sat and Sun)
complete_weekends = []
checked_saturdays = set()
for d in sorted(date_set):
# Saturday = 5, Sunday = 6 in Python's weekday()
if d.weekday() == 5: # Saturday
sunday = d + datetime.timedelta(days=1)
if sunday in date_set:
complete_weekends.append(d)
checked_saturdays.add(d)
elif d.weekday() == 6: # Sunday
saturday = d - datetime.timedelta(days=1)
if saturday in date_set and saturday not in checked_saturdays:
complete_weekends.append(saturday)
checked_saturdays.add(saturday)
if not complete_weekends:
return 0, None
# Sort by date
complete_weekends = sorted(set(complete_weekends))
# Count consecutive weekends
streak = 1
max_streak = 1
for i in range(1, len(complete_weekends)):
# Check if this is the next weekend (7 days apart)
if (complete_weekends[i] - complete_weekends[i - 1]).days == 7:
streak += 1
if streak > max_streak:
max_streak = streak
else:
streak = 1
# Determine current streak
today = datetime.date.today()
last_complete = complete_weekends[-1]
# Find the most recent Saturday
days_since_saturday = (today.weekday() - 5) % 7
last_saturday = today - datetime.timedelta(days=days_since_saturday)
# Current streak is valid if last complete weekend was within 2 weeks
days_since_last_complete = (last_saturday - last_complete).days
if days_since_last_complete <= 7:
current_streak = streak
else:
current_streak = 0
return current_streak, last_complete
@classmethod
def _update_weekend_streak_incremental(cls, stats: UserStatistics, workout_date: datetime.date):
"""
Incrementally update weekend streak when a workout is logged.
Args:
stats: The UserStatistics to update
workout_date: The date of the workout
"""
# Only process weekend days
weekday = workout_date.weekday()
if weekday not in (5, 6): # Not Saturday or Sunday
return
# Determine the Saturday of this weekend
if weekday == 5: # Saturday
saturday = workout_date
else: # Sunday
saturday = workout_date - datetime.timedelta(days=1)
sunday = saturday + datetime.timedelta(days=1)
# Check if both days have workouts
has_saturday = WorkoutSession.objects.filter(
user=stats.user, date=saturday
).exists()
has_sunday = WorkoutSession.objects.filter(
user=stats.user, date=sunday
).exists()
if has_saturday and has_sunday:
# This weekend is complete
if stats.last_complete_weekend_date:
days_since_last = (saturday - stats.last_complete_weekend_date).days
if days_since_last == 7:
# Consecutive weekend
stats.weekend_workout_streak += 1
elif days_since_last > 7:
# Gap - reset streak
stats.weekend_workout_streak = 1
# If days_since_last < 7, it's the same weekend - no change
else:
# First complete weekend
stats.weekend_workout_streak = 1
stats.last_complete_weekend_date = saturday
@classmethod
def _check_jan_1_workout(cls, workout_dates: list) -> bool:
"""
Check if user has ever worked out on January 1st.
Args:
workout_dates: List of workout dates
Returns:
True if user has worked out on any January 1st
"""
return any(d.month == 1 and d.day == 1 for d in workout_dates)
@classmethod
def _calculate_last_inactive_date(cls, workout_dates: list) -> Optional[datetime.date]:
"""
Calculate the last inactive date (for Phoenix trophy).
The last inactive date is the last workout date before a gap of 30+ days.
Args:
workout_dates: List of workout dates, sorted ascending
Returns:
The last inactive date, or None if no 30+ day gap exists
"""
if not workout_dates:
return None
unique_dates = sorted(set(workout_dates))
last_inactive = None
for i in range(1, len(unique_dates)):
gap = (unique_dates[i] - unique_dates[i - 1]).days
if gap >= 30:
last_inactive = unique_dates[i - 1]
return last_inactive

View File

@@ -0,0 +1,315 @@
# This file is part of wger Workout Manager <https://github.com/wger-project>.
# Copyright (C) 2013 - 2021 wger Team
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# Standard Library
import logging
from datetime import timedelta
from typing import (
Dict,
List,
Optional,
)
# Django
from django.conf import settings
from django.contrib.auth.models import User
from django.db.models import Q
from django.utils import timezone
# wger
from wger.trophies.checkers.registry import CheckerRegistry
from wger.trophies.models import (
Trophy,
UserTrophy,
)
logger = logging.getLogger(__name__)
# Trophy settings from WGER_SETTINGS (defined in settings_global.py)
TROPHIES_ENABLED = settings.WGER_SETTINGS['TROPHIES_ENABLED']
TROPHIES_INACTIVE_USER_DAYS = settings.WGER_SETTINGS['TROPHIES_INACTIVE_USER_DAYS']
class TrophyService:
"""
Service class for trophy evaluation and management.
This service handles:
- Evaluating whether users have earned trophies
- Awarding trophies to users
- Retrieving trophy progress and status
"""
@classmethod
def evaluate_all_trophies(cls, user: User) -> List[UserTrophy]:
"""
Evaluate all unearned trophies for a user.
Checks each active trophy the user hasn't earned yet using the
appropriate checker class. Awards trophies where criteria are met.
Args:
user: The user to evaluate trophies for
Returns:
List of newly awarded UserTrophy instances
"""
if cls.should_skip_user(user):
return []
# Get all active trophies the user hasn't earned
earned_trophy_ids = UserTrophy.objects.filter(user=user).values_list('trophy_id', flat=True)
unevaluated_trophies = Trophy.objects.filter(is_active=True).exclude(id__in=earned_trophy_ids)
awarded = []
for trophy in unevaluated_trophies:
user_trophy = cls.evaluate_trophy(user, trophy)
if user_trophy:
awarded.append(user_trophy)
return awarded
@classmethod
def evaluate_trophy(cls, user: User, trophy: Trophy) -> Optional[UserTrophy]:
"""
Evaluate a single trophy for a user.
Creates a checker instance and checks if the user has met the
trophy criteria. Awards the trophy if earned.
Args:
user: The user to evaluate the trophy for
trophy: The trophy to evaluate
Returns:
UserTrophy if earned, None otherwise
"""
if not trophy.is_active:
return None
# Check if already earned
if UserTrophy.objects.filter(user=user, trophy=trophy).exists():
return None
# Get the checker for this trophy
checker = CheckerRegistry.create_checker(user, trophy)
if checker is None:
logger.warning(f'No checker found for trophy: {trophy.name}')
return None
try:
if checker.check():
return cls.award_trophy(user, trophy, progress=100.0)
except Exception as e:
logger.error(f'Error checking trophy {trophy.name} for user {user.id}: {e}', exc_info=True)
return None
@classmethod
def award_trophy(cls, user: User, trophy: Trophy, progress: float = 100.0) -> UserTrophy:
"""
Award a trophy to a user.
Creates a UserTrophy record marking the trophy as earned.
Args:
user: The user to award the trophy to
trophy: The trophy to award
progress: The progress value (default 100 for earned)
Returns:
The created UserTrophy instance
"""
user_trophy, created = UserTrophy.objects.get_or_create(
user=user,
trophy=trophy,
defaults={'progress': progress},
)
if created:
logger.info(f'Awarded trophy "{trophy.name}" to user {user.username}')
return user_trophy
@classmethod
def get_user_trophies(cls, user: User) -> List[UserTrophy]:
"""
Get all earned trophies for a user.
Args:
user: The user to get trophies for
Returns:
List of UserTrophy instances
"""
return list(
UserTrophy.objects.filter(user=user)
.select_related('trophy')
.order_by('-earned_at')
)
@classmethod
def get_all_trophy_progress(cls, user: User, include_hidden: bool = False) -> List[Dict]:
"""
Get all trophies with progress information for a user.
Returns both earned and unearned trophies with their current progress.
For progressive trophies, calculates progress on-the-fly.
Args:
user: The user to get trophy progress for
include_hidden: If True, include hidden trophies even if not earned
Returns:
List of dicts with trophy info and progress
"""
result = []
# Get all active trophies
trophies = Trophy.objects.filter(is_active=True).order_by('order', 'name')
# Get user's earned trophies
earned = {
ut.trophy_id: ut
for ut in UserTrophy.objects.filter(user=user).select_related('trophy')
}
for trophy in trophies:
user_trophy = earned.get(trophy.id)
is_earned = user_trophy is not None
# Skip hidden trophies unless earned or explicitly included
if trophy.is_hidden and not is_earned and not include_hidden:
continue
progress_data = {
'trophy': trophy,
'is_earned': is_earned,
'earned_at': user_trophy.earned_at if is_earned else None,
'progress': 100.0 if is_earned else 0.0,
'current_value': None,
'target_value': None,
'progress_display': None,
}
# Calculate progress for progressive trophies that aren't earned
if trophy.is_progressive and not is_earned:
checker = CheckerRegistry.create_checker(user, trophy)
if checker:
try:
progress_data['progress'] = checker.get_progress()
progress_data['current_value'] = checker.get_current_value()
progress_data['target_value'] = checker.get_target_value()
# Create display string (e.g., "5000/100000 kg")
current = progress_data['current_value']
target = progress_data['target_value']
if current is not None and target is not None:
progress_data['progress_display'] = f'{current}/{target}'
except Exception as e:
logger.error(f'Error getting progress for trophy {trophy.name}: {e}')
result.append(progress_data)
return result
@classmethod
def should_skip_user(cls, user: User) -> bool:
"""
Check if a user should be skipped for trophy evaluation.
Users are skipped if:
- The trophy system is globally disabled (WGER_SETTINGS['TROPHIES_ENABLED'])
- They have disabled trophies in their profile (userprofile.trophies_enabled)
- They haven't logged in for more than TROPHIES_INACTIVE_USER_DAYS
Args:
user: The user to check
Returns:
True if user should be skipped
"""
# Check if trophy system is globally disabled
if not TROPHIES_ENABLED:
return True
# Check if user has disabled trophies (if profile has this field)
if hasattr(user, 'userprofile'):
profile = user.userprofile
if hasattr(profile, 'trophies_enabled') and not profile.trophies_enabled:
return True
# Check for inactivity
if user.last_login:
inactive_threshold = timezone.now() - timedelta(days=TROPHIES_INACTIVE_USER_DAYS)
if user.last_login < inactive_threshold:
return True
return False
@classmethod
def reevaluate_trophies(
cls,
trophy_ids: Optional[List[int]] = None,
user_ids: Optional[List[int]] = None,
) -> Dict[str, int]:
"""
Re-evaluate trophies for users (admin function).
Can be used when trophy criteria change or to fix data inconsistencies.
This will check if any users now qualify for trophies they didn't before.
Args:
trophy_ids: List of trophy IDs to re-evaluate (None = all)
user_ids: List of user IDs to re-evaluate (None = all active)
Returns:
Dict with counts: {'users_checked': N, 'trophies_awarded': M}
"""
# Get trophies to evaluate
trophy_qs = Trophy.objects.filter(is_active=True)
if trophy_ids:
trophy_qs = trophy_qs.filter(id__in=trophy_ids)
trophies = list(trophy_qs)
# Get users to evaluate
if user_ids:
users = User.objects.filter(id__in=user_ids)
else:
# Get active users only
inactive_threshold = timezone.now() - timedelta(days=TROPHIES_INACTIVE_USER_DAYS)
users = User.objects.filter(last_login__gte=inactive_threshold)
users_checked = 0
trophies_awarded = 0
for user in users:
if cls.should_skip_user(user):
continue
users_checked += 1
for trophy in trophies:
# Don't skip already earned - this is a re-evaluation
user_trophy = cls.evaluate_trophy(user, trophy)
if user_trophy:
trophies_awarded += 1
return {
'users_checked': users_checked,
'trophies_awarded': trophies_awarded,
}

158
wger/trophies/signals.py Normal file
View File

@@ -0,0 +1,158 @@
# This file is part of wger Workout Manager <https://github.com/wger-project>.
# Copyright (C) 2013 - 2021 wger Team
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
Signal handlers for the trophies app.
These signals trigger statistics updates and trophy evaluations
when workouts are logged, edited, or deleted.
"""
# Standard Library
import logging
# Django
from django.db.models.signals import (
post_delete,
post_save,
)
from django.dispatch import receiver
# wger
from wger.manager.models import (
WorkoutLog,
WorkoutSession,
)
from wger.trophies.services import UserStatisticsService
from wger.trophies.tasks import evaluate_user_trophies_task
logger = logging.getLogger(__name__)
def _trigger_trophy_evaluation(user_id: int):
"""
Trigger async trophy evaluation for a user.
Uses Celery if available, otherwise evaluates synchronously.
"""
try:
evaluate_user_trophies_task.delay(user_id)
except Exception:
# Celery not available - evaluate synchronously
from wger.trophies.services import TrophyService
from django.contrib.auth.models import User
try:
user = User.objects.get(id=user_id)
TrophyService.evaluate_all_trophies(user)
except User.DoesNotExist:
pass
except Exception as e:
logger.error(f'Error evaluating trophies for user {user_id}: {e}')
@receiver(post_save, sender=WorkoutLog)
def workout_log_saved(sender, instance, created, **kwargs):
"""
Handle WorkoutLog save events.
Updates user statistics when a new workout log is created.
For edits, triggers a full recalculation to ensure accuracy.
Then triggers trophy evaluation.
"""
if not instance.user_id:
return
try:
if created:
# New log - incremental update
UserStatisticsService.increment_workout(
user=instance.user,
workout_log=instance,
)
else:
# Edited log - full recalculation for accuracy
UserStatisticsService.update_statistics(instance.user)
# Trigger trophy evaluation
_trigger_trophy_evaluation(instance.user_id)
except Exception as e:
logger.error(f'Error updating statistics for user {instance.user_id}: {e}', exc_info=True)
@receiver(post_delete, sender=WorkoutLog)
def workout_log_deleted(sender, instance, **kwargs):
"""
Handle WorkoutLog delete events.
Triggers full statistics recalculation when a log is deleted.
"""
if not instance.user_id:
return
try:
UserStatisticsService.handle_workout_deletion(instance.user)
except Exception as e:
logger.error(f'Error updating statistics after deletion for user {instance.user_id}: {e}', exc_info=True)
@receiver(post_save, sender=WorkoutSession)
def workout_session_saved(sender, instance, created, **kwargs):
"""
Handle WorkoutSession save events.
Updates user statistics when a workout session is created or updated.
This captures session-level data like start/end times.
Then triggers trophy evaluation.
"""
if not instance.user_id:
return
try:
if created:
# New session - incremental update for session data
UserStatisticsService.increment_workout(
user=instance.user,
session=instance,
)
else:
# Session updated (e.g., time_start changed) - update times
UserStatisticsService.increment_workout(
user=instance.user,
session=instance,
)
# Trigger trophy evaluation
_trigger_trophy_evaluation(instance.user_id)
except Exception as e:
logger.error(f'Error updating statistics for session {instance.id}: {e}', exc_info=True)
@receiver(post_delete, sender=WorkoutSession)
def workout_session_deleted(sender, instance, **kwargs):
"""
Handle WorkoutSession delete events.
Triggers full statistics recalculation when a session is deleted.
"""
if not instance.user_id:
return
try:
UserStatisticsService.handle_workout_deletion(instance.user)
except Exception as e:
logger.error(f'Error updating statistics after session deletion for user {instance.user_id}: {e}', exc_info=True)

139
wger/trophies/tasks.py Normal file
View File

@@ -0,0 +1,139 @@
# This file is part of wger Workout Manager <https://github.com/wger-project>.
# Copyright (C) 2013 - 2021 wger Team
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
Celery tasks for trophy evaluation and statistics updates.
"""
# Standard Library
import logging
from datetime import timedelta
# Django
from django.conf import settings
from django.contrib.auth.models import User
from django.utils import timezone
# wger
from wger.celery_configuration import app
from wger.trophies.services import (
TrophyService,
UserStatisticsService,
)
logger = logging.getLogger(__name__)
# Trophy settings from WGER_SETTINGS (defined in settings_global.py)
TROPHIES_INACTIVE_USER_DAYS = settings.WGER_SETTINGS['TROPHIES_INACTIVE_USER_DAYS']
@app.task
def evaluate_user_trophies_task(user_id: int):
"""
Evaluate all trophies for a single user.
This task is typically called after a workout is logged.
Args:
user_id: The ID of the user to evaluate
"""
try:
user = User.objects.get(id=user_id)
awarded = TrophyService.evaluate_all_trophies(user)
if awarded:
logger.info(f'Awarded {len(awarded)} trophies to user {user.username}')
except User.DoesNotExist:
logger.warning(f'User {user_id} not found for trophy evaluation')
except Exception as e:
logger.error(f'Error evaluating trophies for user {user_id}: {e}', exc_info=True)
@app.task
def evaluate_all_users_trophies_task():
"""
Evaluate trophies for all active users.
This task can be run periodically (e.g., daily) to catch any
missed trophy awards or re-evaluate after criteria changes.
Only processes users who have logged in within TROPHIES_INACTIVE_USER_DAYS.
"""
inactive_threshold = timezone.now() - timedelta(days=TROPHIES_INACTIVE_USER_DAYS)
users = User.objects.filter(last_login__gte=inactive_threshold)
total_awarded = 0
users_processed = 0
for user in users.iterator():
try:
awarded = TrophyService.evaluate_all_trophies(user)
if awarded:
total_awarded += len(awarded)
users_processed += 1
except Exception as e:
logger.error(f'Error evaluating trophies for user {user.id}: {e}', exc_info=True)
logger.info(
f'Trophy evaluation complete: processed {users_processed} users, '
f'awarded {total_awarded} trophies'
)
@app.task
def update_user_statistics_task(user_id: int):
"""
Perform a full statistics recalculation for a user.
This task is useful for:
- Initial statistics population
- Recovery from data inconsistencies
- After bulk data imports
Args:
user_id: The ID of the user to update
"""
try:
user = User.objects.get(id=user_id)
UserStatisticsService.update_statistics(user)
logger.info(f'Updated statistics for user {user.username}')
except User.DoesNotExist:
logger.warning(f'User {user_id} not found for statistics update')
except Exception as e:
logger.error(f'Error updating statistics for user {user_id}: {e}', exc_info=True)
@app.task
def recalculate_all_statistics_task():
"""
Recalculate statistics for all active users.
This task can be used for data recovery or after major changes.
Only processes users who have logged in recently.
"""
inactive_threshold = timezone.now() - timedelta(days=TROPHIES_INACTIVE_USER_DAYS)
users = User.objects.filter(last_login__gte=inactive_threshold)
users_processed = 0
for user in users.iterator():
try:
UserStatisticsService.update_statistics(user)
users_processed += 1
except Exception as e:
logger.error(f'Error updating statistics for user {user.id}: {e}', exc_info=True)
logger.info(f'Statistics recalculation complete: processed {users_processed} users')

View File

@@ -0,0 +1,15 @@
# This file is part of wger Workout Manager <https://github.com/wger-project>.
# Copyright (C) 2013 - 2021 wger Team
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

View File

@@ -0,0 +1,389 @@
# This file is part of wger Workout Manager.
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# Standard Library
from decimal import Decimal
# Django
from django.contrib.auth.models import User
from django.urls import reverse
# Third Party
from rest_framework import status
# wger
from wger.core.tests.base_testcase import WgerTestCase
from wger.manager.models import (
WorkoutLog,
WorkoutSession,
)
from wger.trophies.models import (
Trophy,
UserStatistics,
UserTrophy,
)
class TrophyAPITestCase(WgerTestCase):
"""
Test the Trophy API endpoints
"""
def setUp(self):
super().setUp()
self.user = User.objects.get(username='admin')
self.user_login()
# Create some test trophies
self.trophy1 = Trophy.objects.create(
name='Active Trophy 1',
description='Test description',
trophy_type=Trophy.TYPE_COUNT,
checker_class='count_based',
checker_params={'count': 1},
is_active=True,
is_hidden=False,
order=1,
)
self.trophy2 = Trophy.objects.create(
name='Active Trophy 2',
description='Another trophy',
trophy_type=Trophy.TYPE_VOLUME,
checker_class='volume',
checker_params={'kg': 5000},
is_active=True,
is_progressive=True,
order=2,
)
self.inactive_trophy = Trophy.objects.create(
name='Inactive Trophy',
trophy_type=Trophy.TYPE_COUNT,
checker_class='count_based',
checker_params={'count': 10},
is_active=False,
)
def test_list_trophies_authenticated(self):
"""Test listing trophies as authenticated user"""
response = self.client.get(reverse('trophy-list'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
self.assertIn('results', data)
def test_list_trophies_unauthenticated(self):
"""Test listing trophies allows unauthenticated access for non-hidden trophies"""
self.client.logout()
response = self.client.get(reverse('trophy-list'))
# Anonymous users can see non-hidden trophies
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_list_only_active_trophies(self):
"""Test only active trophies are returned"""
response = self.client.get(reverse('trophy-list'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
trophy_ids = [t['id'] for t in data['results']]
self.assertIn(self.trophy1.id, trophy_ids)
self.assertIn(self.trophy2.id, trophy_ids)
self.assertNotIn(self.inactive_trophy.id, trophy_ids)
def test_trophy_detail(self):
"""Test getting trophy detail"""
response = self.client.get(reverse('trophy-detail', kwargs={'pk': self.trophy1.pk}))
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
self.assertEqual(data['id'], self.trophy1.id)
self.assertEqual(data['name'], 'Active Trophy 1')
self.assertEqual(data['description'], 'Test description')
self.assertEqual(data['trophy_type'], Trophy.TYPE_COUNT)
self.assertFalse(data['is_progressive'])
def test_trophy_serialization_fields(self):
"""Test trophy serialization includes all required fields"""
response = self.client.get(reverse('trophy-detail', kwargs={'pk': self.trophy1.pk}))
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
# Check all expected fields are present
expected_fields = ['id', 'name', 'description', 'trophy_type', 'is_hidden', 'is_progressive']
for field in expected_fields:
self.assertIn(field, data)
def test_trophy_ordering(self):
"""Test trophies are ordered correctly"""
# Delete migration trophies and keep only test trophies
Trophy.objects.exclude(
id__in=[self.trophy1.id, self.trophy2.id, self.inactive_trophy.id]
).delete()
response = self.client.get(reverse('trophy-list'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
trophy_names = [t['name'] for t in data['results']]
# Should be ordered by order field then name
self.assertEqual(trophy_names[0], 'Active Trophy 1') # order=1
self.assertEqual(trophy_names[1], 'Active Trophy 2') # order=2
def test_trophy_read_only(self):
"""Test trophies endpoint is read-only"""
# Try to create a trophy via API
response = self.client.post(
reverse('trophy-list'),
data={
'name': 'New Trophy',
'trophy_type': Trophy.TYPE_COUNT,
'checker_class': 'count_based',
},
)
# Should not be allowed
self.assertIn(response.status_code, [status.HTTP_403_FORBIDDEN, status.HTTP_405_METHOD_NOT_ALLOWED])
class UserTrophyAPITestCase(WgerTestCase):
"""
Test the UserTrophy API endpoints
"""
def setUp(self):
super().setUp()
self.user = User.objects.get(username='admin')
self.other_user = User.objects.get(username='test')
self.user_login()
# Delete workout data, migration trophies, and existing data to ensure clean state
WorkoutLog.objects.filter(user=self.user).delete()
WorkoutSession.objects.filter(user=self.user).delete()
Trophy.objects.all().delete()
UserTrophy.objects.all().delete()
UserStatistics.objects.filter(user=self.user).delete()
# Create trophies
self.trophy1 = Trophy.objects.create(
name='Trophy 1',
trophy_type=Trophy.TYPE_COUNT,
checker_class='count_based',
checker_params={'count': 1},
is_active=True,
)
self.trophy2 = Trophy.objects.create(
name='Trophy 2',
trophy_type=Trophy.TYPE_VOLUME,
checker_class='volume',
checker_params={'kg': 5000},
is_active=True,
is_progressive=True,
)
self.hidden_trophy = Trophy.objects.create(
name='Hidden Trophy',
trophy_type=Trophy.TYPE_COUNT,
checker_class='count_based',
checker_params={'count': 10},
is_active=True,
is_hidden=True,
)
# Award trophy1 to current user
self.user_trophy = UserTrophy.objects.create(
user=self.user,
trophy=self.trophy1,
progress=100.0,
)
# Award trophy2 to other user
UserTrophy.objects.create(
user=self.other_user,
trophy=self.trophy2,
progress=100.0,
)
# Create statistics for progress calculation
self.stats, _ = UserStatistics.objects.get_or_create(
user=self.user,
defaults={
'total_workouts': 1,
'total_weight_lifted': Decimal('2500'),
}
)
def test_list_user_trophies_authenticated(self):
"""Test listing user's earned trophies"""
response = self.client.get(reverse('user-trophy-list'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
self.assertIn('results', data)
def test_list_user_trophies_unauthenticated(self):
"""Test listing user trophies requires authentication"""
self.client.logout()
response = self.client.get(reverse('user-trophy-list'))
self.assertIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN])
def test_user_only_sees_own_trophies(self):
"""Test users only see their own earned trophies"""
response = self.client.get(reverse('user-trophy-list'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
# Should only see trophy1 (awarded to self.user)
self.assertEqual(len(data['results']), 1)
self.assertEqual(data['results'][0]['trophy']['id'], self.trophy1.id)
def test_user_trophy_serialization(self):
"""Test user trophy includes earned_at and progress"""
response = self.client.get(reverse('user-trophy-list'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
user_trophy_data = data['results'][0]
self.assertIn('earned_at', user_trophy_data)
self.assertIn('progress', user_trophy_data)
self.assertEqual(user_trophy_data['progress'], 100.0)
def test_trophy_progress_endpoint(self):
"""Test the trophy progress endpoint"""
response = self.client.get(reverse('trophy-progress'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
# Should return progress for all active trophies
self.assertIsInstance(data, list)
self.assertGreater(len(data), 0)
def test_trophy_progress_includes_unearned(self):
"""Test progress endpoint includes unearned trophies"""
response = self.client.get(reverse('trophy-progress'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
trophy_ids = [t['trophy']['id'] for t in data]
# Should include trophy1 (earned) and trophy2 (not earned)
self.assertIn(self.trophy1.id, trophy_ids)
self.assertIn(self.trophy2.id, trophy_ids)
def test_trophy_progress_calculations(self):
"""Test progress calculations are correct"""
response = self.client.get(reverse('trophy-progress'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
# Find trophy2 (progressive volume trophy)
trophy2_data = next((t for t in data if t['trophy']['id'] == self.trophy2.id), None)
self.assertIsNotNone(trophy2_data)
# User has lifted 2500kg, trophy requires 5000kg = 50%
self.assertEqual(trophy2_data['progress'], 50.0)
self.assertFalse(trophy2_data['is_earned'])
def test_trophy_progress_earned_status(self):
"""Test earned trophies show is_earned=True"""
response = self.client.get(reverse('trophy-progress'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
# Find trophy1 (earned)
trophy1_data = next((t for t in data if t['trophy']['id'] == self.trophy1.id), None)
self.assertIsNotNone(trophy1_data)
self.assertTrue(trophy1_data['is_earned'])
self.assertEqual(trophy1_data['progress'], 100.0)
self.assertIsNotNone(trophy1_data['earned_at'])
def test_hidden_trophy_not_in_progress(self):
"""Test hidden trophies not shown in progress unless earned"""
# Temporarily make user non-staff to test hidden trophy filtering
self.user.is_staff = False
self.user.save()
response = self.client.get(reverse('trophy-progress'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
trophy_ids = [t['trophy']['id'] for t in data]
# Hidden trophy should not be in the list (not earned) for non-staff users
self.assertNotIn(self.hidden_trophy.id, trophy_ids)
# Restore staff status
self.user.is_staff = True
self.user.save()
def test_hidden_trophy_shown_when_earned(self):
"""Test hidden trophies appear in progress once earned"""
# Award hidden trophy to user
UserTrophy.objects.create(
user=self.user,
trophy=self.hidden_trophy,
progress=100.0,
)
response = self.client.get(reverse('trophy-progress'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
trophy_ids = [t['trophy']['id'] for t in data]
# Hidden trophy should now be visible
self.assertIn(self.hidden_trophy.id, trophy_ids)
def test_user_trophy_read_only(self):
"""Test user trophy endpoints are read-only"""
# Try to create a user trophy via API
response = self.client.post(
reverse('user-trophy-list'),
data={
'trophy': self.trophy2.id,
'progress': 100.0,
},
)
# Should not be allowed
self.assertIn(response.status_code, [status.HTTP_403_FORBIDDEN, status.HTTP_405_METHOD_NOT_ALLOWED])
def test_trophy_progress_display_format(self):
"""Test progress display includes current and target values"""
response = self.client.get(reverse('trophy-progress'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
# Find trophy2 (progressive trophy)
trophy2_data = next((t for t in data if t['trophy']['id'] == self.trophy2.id), None)
# Should have current_value and target_value
self.assertIn('current_value', trophy2_data)
self.assertIn('target_value', trophy2_data)
# Values are returned as strings from serialization
self.assertEqual(trophy2_data['current_value'], '2500.00')
self.assertEqual(trophy2_data['target_value'], '5000.0')

View File

@@ -0,0 +1,487 @@
# This file is part of wger Workout Manager.
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# Standard Library
import datetime
from decimal import Decimal
# Django
from django.contrib.auth.models import User
# wger
from wger.core.tests.base_testcase import WgerTestCase
from wger.trophies.checkers.count_based import CountBasedChecker
from wger.trophies.checkers.date_based import DateBasedChecker
from wger.trophies.checkers.inactivity_return import InactivityReturnChecker
from wger.trophies.checkers.streak import StreakChecker
from wger.trophies.checkers.time_based import TimeBasedChecker
from wger.trophies.checkers.volume import VolumeChecker
from wger.trophies.checkers.weekend_warrior import WeekendWarriorChecker
from wger.trophies.models import (
Trophy,
UserStatistics,
)
class CountBasedCheckerTestCase(WgerTestCase):
"""
Test the CountBasedChecker
"""
def setUp(self):
super().setUp()
self.user = User.objects.get(username='admin')
self.trophy = Trophy.objects.create(
name='Beginner',
trophy_type=Trophy.TYPE_COUNT,
checker_class='count_based',
checker_params={'count': 10},
)
self.stats, _ = UserStatistics.objects.get_or_create(user=self.user)
def test_check_not_achieved(self):
"""Test check returns False when count not reached"""
self.stats.total_workouts = 5
self.stats.save()
checker = CountBasedChecker(self.user, self.trophy, {'count': 10})
self.assertFalse(checker.check())
def test_check_achieved(self):
"""Test check returns True when count reached"""
self.stats.total_workouts = 10
self.stats.save()
checker = CountBasedChecker(self.user, self.trophy, {'count': 10})
self.assertTrue(checker.check())
def test_check_exceeded(self):
"""Test check returns True when count exceeded"""
self.stats.total_workouts = 15
self.stats.save()
checker = CountBasedChecker(self.user, self.trophy, {'count': 10})
self.assertTrue(checker.check())
def test_progress_calculation(self):
"""Test progress calculation"""
self.stats.total_workouts = 5
self.stats.save()
checker = CountBasedChecker(self.user, self.trophy, {'count': 10})
self.assertEqual(checker.get_progress(), 50.0)
def test_progress_capped_at_100(self):
"""Test progress is capped at 100%"""
self.stats.total_workouts = 15
self.stats.save()
checker = CountBasedChecker(self.user, self.trophy, {'count': 10})
self.assertEqual(checker.get_progress(), 100.0)
def test_get_current_value(self):
"""Test getting current workout count"""
self.stats.total_workouts = 7
self.stats.save()
checker = CountBasedChecker(self.user, self.trophy, {'count': 10})
self.assertEqual(checker.get_current_value(), 7)
def test_get_target_value(self):
"""Test getting target workout count"""
checker = CountBasedChecker(self.user, self.trophy, {'count': 10})
self.assertEqual(checker.get_target_value(), 10)
class StreakCheckerTestCase(WgerTestCase):
"""
Test the StreakChecker
"""
def setUp(self):
super().setUp()
self.user = User.objects.get(username='admin')
self.trophy = Trophy.objects.create(
name='Unstoppable',
trophy_type=Trophy.TYPE_SEQUENCE,
checker_class='streak',
checker_params={'days': 30},
)
self.stats, _ = UserStatistics.objects.get_or_create(user=self.user)
def test_check_not_achieved(self):
"""Test check returns False when streak not reached"""
self.stats.current_streak = 15
self.stats.save()
checker = StreakChecker(self.user, self.trophy, {'days': 30})
self.assertFalse(checker.check())
def test_check_achieved(self):
"""Test check returns True when streak reached"""
self.stats.current_streak = 30
self.stats.save()
checker = StreakChecker(self.user, self.trophy, {'days': 30})
self.assertTrue(checker.check())
def test_check_exceeded(self):
"""Test check returns True when streak exceeded"""
self.stats.current_streak = 45
self.stats.save()
checker = StreakChecker(self.user, self.trophy, {'days': 30})
self.assertTrue(checker.check())
def test_progress_calculation(self):
"""Test progress calculation"""
self.stats.current_streak = 15
self.stats.save()
checker = StreakChecker(self.user, self.trophy, {'days': 30})
self.assertEqual(checker.get_progress(), 50.0)
def test_get_current_value(self):
"""Test getting current streak"""
self.stats.current_streak = 20
self.stats.save()
checker = StreakChecker(self.user, self.trophy, {'days': 30})
self.assertEqual(checker.get_current_value(), 20)
def test_get_target_value(self):
"""Test getting target streak"""
checker = StreakChecker(self.user, self.trophy, {'days': 30})
self.assertEqual(checker.get_target_value(), 30)
class WeekendWarriorCheckerTestCase(WgerTestCase):
"""
Test the WeekendWarriorChecker
"""
def setUp(self):
super().setUp()
self.user = User.objects.get(username='admin')
self.trophy = Trophy.objects.create(
name='Weekend Warrior',
trophy_type=Trophy.TYPE_SEQUENCE,
checker_class='weekend_warrior',
checker_params={'weekends': 4},
)
self.stats, _ = UserStatistics.objects.get_or_create(user=self.user)
def test_check_not_achieved(self):
"""Test check returns False when weekend streak not reached"""
self.stats.weekend_workout_streak = 2
self.stats.save()
checker = WeekendWarriorChecker(self.user, self.trophy, {'weekends': 4})
self.assertFalse(checker.check())
def test_check_achieved(self):
"""Test check returns True when weekend streak reached"""
self.stats.weekend_workout_streak = 4
self.stats.save()
checker = WeekendWarriorChecker(self.user, self.trophy, {'weekends': 4})
self.assertTrue(checker.check())
def test_check_exceeded(self):
"""Test check returns True when weekend streak exceeded"""
self.stats.weekend_workout_streak = 6
self.stats.save()
checker = WeekendWarriorChecker(self.user, self.trophy, {'weekends': 4})
self.assertTrue(checker.check())
def test_progress_calculation(self):
"""Test progress calculation"""
self.stats.weekend_workout_streak = 2
self.stats.save()
checker = WeekendWarriorChecker(self.user, self.trophy, {'weekends': 4})
self.assertEqual(checker.get_progress(), 50.0)
def test_get_current_value(self):
"""Test getting current weekend streak"""
self.stats.weekend_workout_streak = 3
self.stats.save()
checker = WeekendWarriorChecker(self.user, self.trophy, {'weekends': 4})
self.assertEqual(checker.get_current_value(), 3)
def test_get_target_value(self):
"""Test getting target weekend streak"""
checker = WeekendWarriorChecker(self.user, self.trophy, {'weekends': 4})
self.assertEqual(checker.get_target_value(), 4)
class VolumeCheckerTestCase(WgerTestCase):
"""
Test the VolumeChecker
"""
def setUp(self):
super().setUp()
self.user = User.objects.get(username='admin')
self.trophy = Trophy.objects.create(
name='Lifter',
trophy_type=Trophy.TYPE_VOLUME,
checker_class='volume',
checker_params={'kg': 5000},
)
self.stats, _ = UserStatistics.objects.get_or_create(user=self.user)
def test_check_not_achieved(self):
"""Test check returns False when volume not reached"""
self.stats.total_weight_lifted = Decimal('2500.00')
self.stats.save()
checker = VolumeChecker(self.user, self.trophy, {'kg': 5000})
self.assertFalse(checker.check())
def test_check_achieved(self):
"""Test check returns True when volume reached"""
self.stats.total_weight_lifted = Decimal('5000.00')
self.stats.save()
checker = VolumeChecker(self.user, self.trophy, {'kg': 5000})
self.assertTrue(checker.check())
def test_check_exceeded(self):
"""Test check returns True when volume exceeded"""
self.stats.total_weight_lifted = Decimal('7500.00')
self.stats.save()
checker = VolumeChecker(self.user, self.trophy, {'kg': 5000})
self.assertTrue(checker.check())
def test_progress_calculation(self):
"""Test progress calculation"""
self.stats.total_weight_lifted = Decimal('2500.00')
self.stats.save()
checker = VolumeChecker(self.user, self.trophy, {'kg': 5000})
self.assertEqual(checker.get_progress(), 50.0)
def test_progress_with_decimals(self):
"""Test progress calculation with decimal weights"""
self.stats.total_weight_lifted = Decimal('1234.56')
self.stats.save()
checker = VolumeChecker(self.user, self.trophy, {'kg': 5000})
progress = checker.get_progress()
self.assertAlmostEqual(progress, 24.69, places=2)
def test_get_current_value(self):
"""Test getting current weight lifted"""
self.stats.total_weight_lifted = Decimal('3000.00')
self.stats.save()
checker = VolumeChecker(self.user, self.trophy, {'kg': 5000})
self.assertEqual(checker.get_current_value(), 3000)
def test_get_target_value(self):
"""Test getting target weight"""
checker = VolumeChecker(self.user, self.trophy, {'kg': 5000})
self.assertEqual(checker.get_target_value(), 5000)
class TimeBasedCheckerTestCase(WgerTestCase):
"""
Test the TimeBasedChecker
"""
def setUp(self):
super().setUp()
self.user = User.objects.get(username='admin')
self.stats, _ = UserStatistics.objects.get_or_create(user=self.user)
def test_early_bird_achieved(self):
"""Test Early Bird trophy (before 6:00 AM)"""
trophy = Trophy.objects.create(
name='Early Bird',
trophy_type=Trophy.TYPE_TIME,
checker_class='time_based',
checker_params={'before': '06:00'},
)
self.stats.earliest_workout_time = datetime.time(5, 30)
self.stats.save()
checker = TimeBasedChecker(self.user, trophy, {'before': '06:00'})
self.assertTrue(checker.check())
def test_early_bird_not_achieved(self):
"""Test Early Bird trophy not achieved (after 6:00 AM)"""
trophy = Trophy.objects.create(
name='Early Bird',
trophy_type=Trophy.TYPE_TIME,
checker_class='time_based',
checker_params={'before': '06:00'},
)
self.stats.earliest_workout_time = datetime.time(7, 0)
self.stats.save()
checker = TimeBasedChecker(self.user, trophy, {'before': '06:00'})
self.assertFalse(checker.check())
def test_night_owl_achieved(self):
"""Test Night Owl trophy (after 9:00 PM)"""
trophy = Trophy.objects.create(
name='Night Owl',
trophy_type=Trophy.TYPE_TIME,
checker_class='time_based',
checker_params={'after': '21:00'},
)
self.stats.latest_workout_time = datetime.time(22, 30)
self.stats.save()
checker = TimeBasedChecker(self.user, trophy, {'after': '21:00'})
self.assertTrue(checker.check())
def test_night_owl_not_achieved(self):
"""Test Night Owl trophy not achieved (before 9:00 PM)"""
trophy = Trophy.objects.create(
name='Night Owl',
trophy_type=Trophy.TYPE_TIME,
checker_class='time_based',
checker_params={'after': '21:00'},
)
self.stats.latest_workout_time = datetime.time(20, 0)
self.stats.save()
checker = TimeBasedChecker(self.user, trophy, {'after': '21:00'})
self.assertFalse(checker.check())
def test_no_workout_time_recorded(self):
"""Test when no workout time is recorded"""
trophy = Trophy.objects.create(
name='Early Bird',
trophy_type=Trophy.TYPE_TIME,
checker_class='time_based',
checker_params={'before': '06:00'},
)
checker = TimeBasedChecker(self.user, trophy, {'before': '06:00'})
self.assertFalse(checker.check())
class DateBasedCheckerTestCase(WgerTestCase):
"""
Test the DateBasedChecker
"""
def setUp(self):
super().setUp()
self.user = User.objects.get(username='admin')
self.trophy = Trophy.objects.create(
name='New Year, New Me',
trophy_type=Trophy.TYPE_DATE,
checker_class='date_based',
checker_params={'month': 1, 'day': 1},
)
self.stats, _ = UserStatistics.objects.get_or_create(user=self.user)
def test_check_achieved(self):
"""Test check returns True when worked out on Jan 1st"""
self.stats.worked_out_jan_1 = True
self.stats.save()
checker = DateBasedChecker(self.user, self.trophy, {'month': 1, 'day': 1})
self.assertTrue(checker.check())
def test_check_not_achieved(self):
"""Test check returns False when not worked out on Jan 1st"""
self.stats.worked_out_jan_1 = False
self.stats.save()
checker = DateBasedChecker(self.user, self.trophy, {'month': 1, 'day': 1})
self.assertFalse(checker.check())
def test_get_progress(self):
"""Test progress is either 0 or 100 for date-based trophies"""
self.stats.worked_out_jan_1 = False
self.stats.save()
checker = DateBasedChecker(self.user, self.trophy, {'month': 1, 'day': 1})
self.assertEqual(checker.get_progress(), 0.0)
self.stats.worked_out_jan_1 = True
self.stats.save()
checker = DateBasedChecker(self.user, self.trophy, {'month': 1, 'day': 1})
self.assertEqual(checker.get_progress(), 100.0)
class InactivityReturnCheckerTestCase(WgerTestCase):
"""
Test the InactivityReturnChecker
"""
def setUp(self):
super().setUp()
self.user = User.objects.get(username='admin')
self.trophy = Trophy.objects.create(
name='Phoenix',
trophy_type=Trophy.TYPE_OTHER,
checker_class='inactivity_return',
checker_params={'inactive_days': 30},
)
self.stats, _ = UserStatistics.objects.get_or_create(user=self.user)
def test_check_achieved(self):
"""Test check returns True when returned after 30+ days inactive"""
self.stats.last_inactive_date = datetime.date.today() - datetime.timedelta(days=35)
self.stats.last_workout_date = datetime.date.today()
self.stats.save()
checker = InactivityReturnChecker(self.user, self.trophy, {'inactive_days': 30})
self.assertTrue(checker.check())
def test_check_not_achieved_no_inactivity(self):
"""Test check returns False when no inactivity period recorded"""
self.stats.last_inactive_date = None
self.stats.save()
checker = InactivityReturnChecker(self.user, self.trophy, {'inactive_days': 30})
self.assertFalse(checker.check())
def test_check_not_achieved_no_return(self):
"""Test check returns False when inactive but no return"""
self.stats.last_inactive_date = datetime.date.today() - datetime.timedelta(days=35)
self.stats.last_workout_date = None
self.stats.save()
checker = InactivityReturnChecker(self.user, self.trophy, {'inactive_days': 30})
self.assertFalse(checker.check())
def test_get_progress(self):
"""Test progress is either 0 or 100 for inactivity return"""
self.stats.last_inactive_date = None
self.stats.save()
checker = InactivityReturnChecker(self.user, self.trophy, {'inactive_days': 30})
self.assertEqual(checker.get_progress(), 0.0)
self.stats.last_inactive_date = datetime.date.today() - datetime.timedelta(days=35)
self.stats.last_workout_date = datetime.date.today()
self.stats.save()
checker = InactivityReturnChecker(self.user, self.trophy, {'inactive_days': 30})
self.assertEqual(checker.get_progress(), 100.0)

View File

@@ -0,0 +1,369 @@
# This file is part of wger Workout Manager.
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# Standard Library
import datetime
from decimal import Decimal
# Django
from django.contrib.auth.models import User
# wger
from wger.core.tests.base_testcase import WgerTestCase
from wger.manager.models import (
WorkoutLog,
WorkoutSession,
)
from wger.trophies.models import (
Trophy,
UserStatistics,
UserTrophy,
)
from wger.trophies.services.statistics import UserStatisticsService
from wger.trophies.services.trophy import TrophyService
class TrophyIntegrationTestCase(WgerTestCase):
"""
Integration tests for end-to-end trophy workflows
"""
def setUp(self):
super().setUp()
self.user = User.objects.get(username='admin')
# Set recent login to avoid being skipped by should_skip_user
from django.utils import timezone
self.user.last_login = timezone.now()
self.user.save()
# Delete workout data, migration trophies, and existing data to ensure clean state
WorkoutLog.objects.filter(user=self.user).delete()
WorkoutSession.objects.filter(user=self.user).delete()
Trophy.objects.all().delete()
UserTrophy.objects.all().delete()
UserStatistics.objects.all().delete()
# Create the standard trophies
self.beginner_trophy = Trophy.objects.create(
name='Beginner',
trophy_type=Trophy.TYPE_COUNT,
checker_class='count_based',
checker_params={'count': 1},
is_active=True,
)
self.lifter_trophy = Trophy.objects.create(
name='Lifter',
trophy_type=Trophy.TYPE_VOLUME,
checker_class='volume',
checker_params={'kg': 5000},
is_active=True,
is_progressive=True,
)
self.unstoppable_trophy = Trophy.objects.create(
name='Unstoppable',
trophy_type=Trophy.TYPE_SEQUENCE,
checker_class='streak',
checker_params={'days': 30},
is_active=True,
is_progressive=True,
)
def test_first_workout_earns_beginner_trophy(self):
"""Test that completing first workout earns Beginner trophy"""
# Create user statistics
stats, _ = UserStatistics.objects.get_or_create(user=self.user, defaults={'total_workouts': 0})
# Verify no trophies earned yet
self.assertEqual(UserTrophy.objects.filter(user=self.user).count(), 0)
# Simulate first workout
stats.total_workouts = 1
stats.save()
# Evaluate trophies
awarded = TrophyService.evaluate_all_trophies(self.user)
# Should earn Beginner trophy
self.assertEqual(len(awarded), 1)
self.assertEqual(awarded[0].trophy, self.beginner_trophy)
def test_lifting_5000kg_earns_lifter_trophy(self):
"""Test that lifting 5000kg total earns Lifter trophy"""
# Create user statistics
stats, _ = UserStatistics.objects.get_or_create(
user=self.user,
defaults={
'total_workouts': 10,
'total_weight_lifted': Decimal('4999'),
}
)
# Evaluate - should not earn yet
awarded = TrophyService.evaluate_all_trophies(self.user)
lifter_awards = [a for a in awarded if a.trophy == self.lifter_trophy]
self.assertEqual(len(lifter_awards), 0)
# Lift one more kg
stats.total_weight_lifted = Decimal('5000')
stats.save()
# Evaluate again
awarded = TrophyService.evaluate_all_trophies(self.user)
lifter_awards = [a for a in awarded if a.trophy == self.lifter_trophy]
# Should now earn Lifter trophy
self.assertEqual(len(lifter_awards), 1)
def test_30_day_streak_earns_unstoppable_trophy(self):
"""Test that 30-day workout streak earns Unstoppable trophy"""
# Create user statistics
stats, _ = UserStatistics.objects.get_or_create(
user=self.user,
defaults={
'total_workouts': 30,
'current_streak': 29,
'last_workout_date': datetime.date.today(),
}
)
# Evaluate - should not earn yet (only 29 days)
awarded = TrophyService.evaluate_all_trophies(self.user)
unstoppable_awards = [a for a in awarded if a.trophy == self.unstoppable_trophy]
self.assertEqual(len(unstoppable_awards), 0)
# Extend streak to 30 days
stats.current_streak = 30
stats.save()
# Evaluate again
awarded = TrophyService.evaluate_all_trophies(self.user)
unstoppable_awards = [a for a in awarded if a.trophy == self.unstoppable_trophy]
# Should now earn Unstoppable trophy
self.assertEqual(len(unstoppable_awards), 1)
def test_multiple_trophies_earned_together(self):
"""Test user can earn multiple trophies at once"""
# Create user statistics with conditions for multiple trophies
stats, _ = UserStatistics.objects.get_or_create(
user=self.user,
defaults={
'total_workouts': 1, # Qualifies for Beginner
'total_weight_lifted': Decimal('5000'), # Qualifies for Lifter
'current_streak': 30, # Qualifies for Unstoppable
}
)
# Evaluate all trophies
awarded = TrophyService.evaluate_all_trophies(self.user)
# Should earn all three trophies
self.assertEqual(len(awarded), 3)
trophy_ids = {a.trophy.id for a in awarded}
self.assertIn(self.beginner_trophy.id, trophy_ids)
self.assertIn(self.lifter_trophy.id, trophy_ids)
self.assertIn(self.unstoppable_trophy.id, trophy_ids)
def test_progressive_trophy_shows_partial_progress(self):
"""Test progressive trophies show partial progress"""
# Create user statistics
stats, _ = UserStatistics.objects.get_or_create(
user=self.user,
defaults={
'total_weight_lifted': Decimal('2500'), # 50% of 5000kg
}
)
# Get progress for all trophies
progress_list = TrophyService.get_all_trophy_progress(self.user)
# Find Lifter trophy progress
lifter_progress = next(
(p for p in progress_list if p['trophy'].id == self.lifter_trophy.id),
None
)
self.assertIsNotNone(lifter_progress)
self.assertEqual(lifter_progress['progress'], 50.0)
self.assertFalse(lifter_progress['is_earned'])
self.assertEqual(lifter_progress['current_value'], 2500)
self.assertEqual(lifter_progress['target_value'], 5000)
def test_trophy_not_awarded_twice(self):
"""Test same trophy is not awarded twice"""
# Create user statistics
stats, _ = UserStatistics.objects.get_or_create(
user=self.user,
defaults={
'total_workouts': 1,
}
)
# Evaluate and earn Beginner trophy
awarded1 = TrophyService.evaluate_all_trophies(self.user)
self.assertEqual(len(awarded1), 1)
# Do more workouts
stats.total_workouts = 10
stats.save()
# Evaluate again
awarded2 = TrophyService.evaluate_all_trophies(self.user)
# Should not award Beginner again (already earned)
self.assertEqual(len(awarded2), 0)
self.assertEqual(UserTrophy.objects.filter(user=self.user, trophy=self.beginner_trophy).count(), 1)
def test_statistics_service_updates_correctly(self):
"""Test that statistics service updates all fields correctly"""
# Update statistics (with no workout data in test DB)
stats = UserStatisticsService.update_statistics(self.user)
# Verify stats were created and initialized
self.assertIsNotNone(stats)
self.assertEqual(stats.total_workouts, 0)
self.assertEqual(stats.total_weight_lifted, Decimal('0'))
self.assertEqual(stats.current_streak, 0)
self.assertEqual(stats.longest_streak, 0)
def test_hidden_trophy_not_visible_until_earned(self):
"""Test hidden trophies are not visible until earned"""
# Create a hidden trophy
hidden_trophy = Trophy.objects.create(
name='Secret Achievement',
trophy_type=Trophy.TYPE_COUNT,
checker_class='count_based',
checker_params={'count': 100},
is_active=True,
is_hidden=True,
)
# Get progress without including hidden
progress_list = TrophyService.get_all_trophy_progress(self.user, include_hidden=False)
# Hidden trophy should not be in list
trophy_ids = [p['trophy'].id for p in progress_list]
self.assertNotIn(hidden_trophy.id, trophy_ids)
# Award the hidden trophy
UserTrophy.objects.create(user=self.user, trophy=hidden_trophy)
# Get progress again
progress_list = TrophyService.get_all_trophy_progress(self.user, include_hidden=False)
# Hidden trophy should now be visible
trophy_ids = [p['trophy'].id for p in progress_list]
self.assertIn(hidden_trophy.id, trophy_ids)
def test_inactive_trophy_not_evaluated(self):
"""Test inactive trophies are not evaluated"""
# Create user statistics that would qualify for trophy
stats, _ = UserStatistics.objects.get_or_create(
user=self.user,
defaults={
'total_workouts': 1,
}
)
# Deactivate the Beginner trophy
self.beginner_trophy.is_active = False
self.beginner_trophy.save()
# Evaluate trophies
awarded = TrophyService.evaluate_all_trophies(self.user)
# Should not earn the inactive trophy
beginner_awards = [a for a in awarded if a.trophy == self.beginner_trophy]
self.assertEqual(len(beginner_awards), 0)
def test_reevaluate_trophies_for_multiple_users(self):
"""Test re-evaluating trophies for multiple users"""
user2 = User.objects.get(username='test')
# Create statistics for both users
UserStatistics.objects.get_or_create(user=self.user, defaults={'total_workouts': 1})
UserStatistics.objects.get_or_create(user=user2, defaults={'total_workouts': 1})
# Set recent login for both
from django.utils import timezone
self.user.last_login = timezone.now()
self.user.save()
user2.last_login = timezone.now()
user2.save()
# Re-evaluate all trophies
results = TrophyService.reevaluate_trophies()
# Both users should be checked and earn Beginner trophy
self.assertEqual(results['users_checked'], 2)
self.assertEqual(results['trophies_awarded'], 2)
# Verify both users have the trophy
self.assertTrue(UserTrophy.objects.filter(user=self.user, trophy=self.beginner_trophy).exists())
self.assertTrue(UserTrophy.objects.filter(user=user2, trophy=self.beginner_trophy).exists())
def test_complete_user_journey(self):
"""Test complete user journey: signup -> workouts -> earn trophies"""
# Day 1: New user, first workout
stats, _ = UserStatistics.objects.get_or_create(
user=self.user,
defaults={
'total_workouts': 1,
'total_weight_lifted': Decimal('100'),
'current_streak': 1,
'last_workout_date': datetime.date.today(),
}
)
awarded = TrophyService.evaluate_all_trophies(self.user)
# Should earn Beginner
self.assertEqual(len(awarded), 1)
self.assertEqual(awarded[0].trophy.name, 'Beginner')
# Week 1-4: Consistent workouts, building volume
stats.total_workouts = 20
stats.total_weight_lifted = Decimal('2500')
stats.current_streak = 20
stats.save()
# Check progress
progress_list = TrophyService.get_all_trophy_progress(self.user)
lifter_progress = next(p for p in progress_list if p['trophy'].id == self.lifter_trophy.id)
self.assertEqual(lifter_progress['progress'], 50.0) # Halfway to Lifter
# Month 2: Reach 5000kg
stats.total_weight_lifted = Decimal('5000')
stats.save()
awarded = TrophyService.evaluate_all_trophies(self.user)
# Should earn Lifter (Beginner already earned)
lifter_awards = [a for a in awarded if a.trophy.name == 'Lifter']
self.assertEqual(len(lifter_awards), 1)
# Month 2: Complete 30-day streak
stats.current_streak = 30
stats.save()
awarded = TrophyService.evaluate_all_trophies(self.user)
# Should earn Unstoppable
unstoppable_awards = [a for a in awarded if a.trophy.name == 'Unstoppable']
self.assertEqual(len(unstoppable_awards), 1)
# Verify final trophy count
total_trophies = UserTrophy.objects.filter(user=self.user).count()
self.assertEqual(total_trophies, 3) # Beginner, Lifter, Unstoppable

View File

@@ -0,0 +1,355 @@
# This file is part of wger Workout Manager.
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# Standard Library
from decimal import Decimal
# Django
from django.contrib.auth.models import User
from django.db import IntegrityError
# wger
from wger.core.tests.base_testcase import WgerTestCase
from wger.trophies.models import (
Trophy,
UserStatistics,
UserTrophy,
)
class TrophyModelTestCase(WgerTestCase):
"""
Test the Trophy model
"""
def test_create_trophy(self):
"""Test creating a trophy"""
trophy = Trophy.objects.create(
name='Test Trophy',
description='Test Description',
trophy_type=Trophy.TYPE_COUNT,
checker_class='count_based',
checker_params={'count': 10},
)
self.assertEqual(trophy.name, 'Test Trophy')
self.assertEqual(trophy.description, 'Test Description')
self.assertEqual(trophy.trophy_type, Trophy.TYPE_COUNT)
self.assertEqual(trophy.checker_class, 'count_based')
self.assertEqual(trophy.checker_params, {'count': 10})
def test_trophy_defaults(self):
"""Test trophy default values"""
trophy = Trophy.objects.create(
name='Test Trophy',
trophy_type=Trophy.TYPE_COUNT,
checker_class='count_based',
)
self.assertTrue(trophy.is_active)
self.assertFalse(trophy.is_hidden)
self.assertFalse(trophy.is_progressive)
self.assertEqual(trophy.order, 0)
self.assertEqual(trophy.description, '')
def test_trophy_str(self):
"""Test trophy string representation"""
trophy = Trophy.objects.create(
name='Amazing Trophy',
trophy_type=Trophy.TYPE_COUNT,
checker_class='count_based',
)
self.assertEqual(str(trophy), 'Amazing Trophy')
def test_trophy_ordering(self):
"""Test trophies are ordered by order field then name"""
# Delete any existing trophies from migration
Trophy.objects.all().delete()
trophy1 = Trophy.objects.create(
name='B Trophy',
trophy_type=Trophy.TYPE_COUNT,
checker_class='count_based',
order=2,
)
trophy2 = Trophy.objects.create(
name='A Trophy',
trophy_type=Trophy.TYPE_COUNT,
checker_class='count_based',
order=1,
)
trophy3 = Trophy.objects.create(
name='C Trophy',
trophy_type=Trophy.TYPE_COUNT,
checker_class='count_based',
order=1,
)
trophies = list(Trophy.objects.all())
# Should be ordered by order (1, 1, 2), then by name (A, C, B)
self.assertEqual(trophies[0], trophy2) # A Trophy (order 1)
self.assertEqual(trophies[1], trophy3) # C Trophy (order 1)
self.assertEqual(trophies[2], trophy1) # B Trophy (order 2)
def test_trophy_types(self):
"""Test all trophy types are available"""
types = [choice[0] for choice in Trophy.TROPHY_TYPES]
self.assertIn(Trophy.TYPE_TIME, types)
self.assertIn(Trophy.TYPE_VOLUME, types)
self.assertIn(Trophy.TYPE_COUNT, types)
self.assertIn(Trophy.TYPE_SEQUENCE, types)
self.assertIn(Trophy.TYPE_DATE, types)
self.assertIn(Trophy.TYPE_OTHER, types)
def test_trophy_uuid_generated(self):
"""Test UUID is automatically generated"""
trophy = Trophy.objects.create(
name='Test Trophy',
trophy_type=Trophy.TYPE_COUNT,
checker_class='count_based',
)
self.assertIsNotNone(trophy.uuid)
self.assertEqual(len(str(trophy.uuid)), 36) # UUID4 string length
def test_trophy_update(self):
"""Test updating a trophy"""
trophy = Trophy.objects.create(
name='Original Name',
trophy_type=Trophy.TYPE_COUNT,
checker_class='count_based',
)
trophy.name = 'Updated Name'
trophy.is_active = False
trophy.save()
updated = Trophy.objects.get(pk=trophy.pk)
self.assertEqual(updated.name, 'Updated Name')
self.assertFalse(updated.is_active)
def test_trophy_delete(self):
"""Test deleting a trophy"""
trophy = Trophy.objects.create(
name='Test Trophy',
trophy_type=Trophy.TYPE_COUNT,
checker_class='count_based',
)
trophy_id = trophy.pk
trophy.delete()
self.assertEqual(Trophy.objects.filter(pk=trophy_id).count(), 0)
class UserTrophyModelTestCase(WgerTestCase):
"""
Test the UserTrophy model
"""
def setUp(self):
super().setUp()
self.user = User.objects.get(username='admin')
self.trophy = Trophy.objects.create(
name='Test Trophy',
trophy_type=Trophy.TYPE_COUNT,
checker_class='count_based',
)
def test_award_trophy(self):
"""Test awarding a trophy to a user"""
user_trophy = UserTrophy.objects.create(
user=self.user,
trophy=self.trophy,
)
self.assertEqual(user_trophy.user, self.user)
self.assertEqual(user_trophy.trophy, self.trophy)
self.assertIsNotNone(user_trophy.earned_at)
def test_unique_constraint(self):
"""Test a user can't earn the same trophy twice"""
UserTrophy.objects.create(
user=self.user,
trophy=self.trophy,
)
# Try to award the same trophy again
with self.assertRaises(IntegrityError):
UserTrophy.objects.create(
user=self.user,
trophy=self.trophy,
)
def test_progress_field(self):
"""Test progress field for progressive trophies"""
user_trophy = UserTrophy.objects.create(
user=self.user,
trophy=self.trophy,
progress=75.5,
)
self.assertEqual(user_trophy.progress, 75.5)
def test_is_notified_default(self):
"""Test is_notified defaults to False"""
user_trophy = UserTrophy.objects.create(
user=self.user,
trophy=self.trophy,
)
self.assertFalse(user_trophy.is_notified)
def test_multiple_users_same_trophy(self):
"""Test multiple users can earn the same trophy"""
user2 = User.objects.get(username='test')
user_trophy1 = UserTrophy.objects.create(
user=self.user,
trophy=self.trophy,
)
user_trophy2 = UserTrophy.objects.create(
user=user2,
trophy=self.trophy,
)
self.assertNotEqual(user_trophy1, user_trophy2)
self.assertEqual(UserTrophy.objects.filter(trophy=self.trophy).count(), 2)
def test_user_multiple_trophies(self):
"""Test a user can earn multiple different trophies"""
trophy2 = Trophy.objects.create(
name='Another Trophy',
trophy_type=Trophy.TYPE_VOLUME,
checker_class='volume',
)
user_trophy1 = UserTrophy.objects.create(
user=self.user,
trophy=self.trophy,
)
user_trophy2 = UserTrophy.objects.create(
user=self.user,
trophy=trophy2,
)
self.assertEqual(UserTrophy.objects.filter(user=self.user).count(), 2)
def test_cascade_delete_trophy(self):
"""Test deleting a trophy deletes associated UserTrophy records"""
UserTrophy.objects.create(
user=self.user,
trophy=self.trophy,
)
self.assertEqual(UserTrophy.objects.filter(trophy=self.trophy).count(), 1)
self.trophy.delete()
self.assertEqual(UserTrophy.objects.filter(user=self.user).count(), 0)
def test_cascade_delete_user(self):
"""Test deleting a user deletes associated UserTrophy records"""
test_user = User.objects.create_user(username='temp_user', password='temp')
UserTrophy.objects.create(
user=test_user,
trophy=self.trophy,
)
self.assertEqual(UserTrophy.objects.filter(user=test_user).count(), 1)
test_user.delete()
self.assertEqual(UserTrophy.objects.filter(trophy=self.trophy).count(), 0)
class UserStatisticsModelTestCase(WgerTestCase):
"""
Test the UserStatistics model
"""
def setUp(self):
super().setUp()
self.user = User.objects.get(username='admin')
def test_create_statistics(self):
"""Test creating user statistics"""
stats, _ = UserStatistics.objects.get_or_create(user=self.user)
self.assertEqual(stats.user, self.user)
self.assertIsNotNone(stats.last_updated)
def test_default_values(self):
"""Test default values are set correctly"""
# Delete any existing statistics and create fresh ones
UserStatistics.objects.filter(user=self.user).delete()
stats, _ = UserStatistics.objects.get_or_create(user=self.user)
self.assertEqual(stats.total_weight_lifted, Decimal('0'))
self.assertEqual(stats.total_workouts, 0)
self.assertEqual(stats.current_streak, 0)
self.assertEqual(stats.longest_streak, 0)
self.assertEqual(stats.weekend_workout_streak, 0)
self.assertIsNone(stats.last_workout_date)
self.assertIsNone(stats.earliest_workout_time)
self.assertIsNone(stats.latest_workout_time)
self.assertIsNone(stats.last_complete_weekend_date)
self.assertIsNone(stats.last_inactive_date)
self.assertFalse(stats.worked_out_jan_1)
def test_one_to_one_constraint(self):
"""Test one user can only have one statistics record"""
# Delete any existing statistics first
UserStatistics.objects.filter(user=self.user).delete()
UserStatistics.objects.create(user=self.user)
# Try to create another statistics record for the same user
with self.assertRaises(IntegrityError):
UserStatistics.objects.create(user=self.user)
def test_update_statistics(self):
"""Test updating statistics"""
stats, _ = UserStatistics.objects.get_or_create(user=self.user)
stats.total_weight_lifted = Decimal('5000.50')
stats.total_workouts = 25
stats.current_streak = 10
stats.save()
updated = UserStatistics.objects.get(user=self.user)
self.assertEqual(updated.total_weight_lifted, Decimal('5000.50'))
self.assertEqual(updated.total_workouts, 25)
self.assertEqual(updated.current_streak, 10)
def test_cascade_delete_user(self):
"""Test deleting a user deletes their statistics"""
test_user = User.objects.create_user(username='temp_user', password='temp')
UserStatistics.objects.get_or_create(user=test_user)
self.assertEqual(UserStatistics.objects.filter(user=test_user).count(), 1)
user_id = test_user.id
test_user.delete()
# Verify statistics for this user ID no longer exist
self.assertEqual(UserStatistics.objects.filter(user_id=user_id).count(), 0)
def test_str_representation(self):
"""Test string representation of statistics"""
stats, _ = UserStatistics.objects.get_or_create(user=self.user)
str_repr = str(stats)
self.assertIn(self.user.username, str_repr)

View File

@@ -0,0 +1,400 @@
# This file is part of wger Workout Manager.
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# Standard Library
import datetime
from decimal import Decimal
from unittest.mock import patch
# Django
from django.conf import settings
from django.contrib.auth.models import User
from django.utils import timezone
# wger
from wger.core.tests.base_testcase import WgerTestCase
from wger.manager.models import (
WorkoutLog,
WorkoutSession,
)
from wger.trophies.models import (
Trophy,
UserStatistics,
UserTrophy,
)
from wger.trophies.services.statistics import UserStatisticsService
from wger.trophies.services.trophy import TrophyService
class UserStatisticsServiceTestCase(WgerTestCase):
"""
Test the UserStatisticsService
"""
def setUp(self):
super().setUp()
self.user = User.objects.get(username='admin')
# Delete workout data and statistics to ensure clean state
WorkoutLog.objects.filter(user=self.user).delete()
WorkoutSession.objects.filter(user=self.user).delete()
UserStatistics.objects.filter(user=self.user).delete()
def test_get_or_create_creates_new(self):
"""Test get_or_create creates statistics if they don't exist"""
self.assertEqual(UserStatistics.objects.filter(user=self.user).count(), 0)
stats = UserStatisticsService.get_or_create_statistics(self.user)
self.assertIsNotNone(stats)
self.assertEqual(stats.user, self.user)
self.assertEqual(UserStatistics.objects.filter(user=self.user).count(), 1)
def test_get_or_create_returns_existing(self):
"""Test get_or_create returns existing statistics"""
existing, _ = UserStatistics.objects.get_or_create(user=self.user, defaults={'total_workouts': 5})
stats = UserStatisticsService.get_or_create_statistics(self.user)
self.assertEqual(stats.pk, existing.pk)
self.assertEqual(stats.total_workouts, 5)
def test_update_statistics_creates_if_missing(self):
"""Test update_statistics creates statistics if missing"""
self.assertEqual(UserStatistics.objects.filter(user=self.user).count(), 0)
stats = UserStatisticsService.update_statistics(self.user)
self.assertIsNotNone(stats)
self.assertEqual(UserStatistics.objects.filter(user=self.user).count(), 1)
def test_update_statistics_default_values(self):
"""Test update_statistics with no workout data"""
stats = UserStatisticsService.update_statistics(self.user)
self.assertEqual(stats.total_workouts, 0)
self.assertEqual(stats.total_weight_lifted, Decimal('0'))
self.assertEqual(stats.current_streak, 0)
self.assertEqual(stats.longest_streak, 0)
def test_handle_workout_deletion(self):
"""Test handle_workout_deletion triggers full recalculation"""
UserStatistics.objects.get_or_create(
user=self.user,
defaults={
'total_workouts': 10,
'total_weight_lifted': Decimal('5000'),
}
)
stats = UserStatisticsService.handle_workout_deletion(self.user)
# Should recalculate from actual workout data (none in test db)
self.assertEqual(stats.total_workouts, 0)
self.assertEqual(stats.total_weight_lifted, Decimal('0'))
class TrophyServiceTestCase(WgerTestCase):
"""
Test the TrophyService
"""
def setUp(self):
super().setUp()
self.user = User.objects.get(username='admin')
# Set recent login to avoid being skipped by should_skip_user
self.user.last_login = timezone.now()
self.user.save()
# Delete workout data, migration trophies, and existing data to ensure clean state
WorkoutLog.objects.filter(user=self.user).delete()
WorkoutSession.objects.filter(user=self.user).delete()
Trophy.objects.all().delete()
UserTrophy.objects.all().delete()
UserStatistics.objects.filter(user=self.user).delete()
self.trophy = Trophy.objects.create(
name='Test Trophy',
trophy_type=Trophy.TYPE_COUNT,
checker_class='count_based',
checker_params={'count': 1},
is_active=True,
)
# Create statistics for the user
self.stats, _ = UserStatistics.objects.get_or_create(
user=self.user,
defaults={'total_workouts': 0},
)
def test_award_trophy(self):
"""Test awarding a trophy to a user"""
user_trophy = TrophyService.award_trophy(self.user, self.trophy)
self.assertIsNotNone(user_trophy)
self.assertEqual(user_trophy.user, self.user)
self.assertEqual(user_trophy.trophy, self.trophy)
self.assertEqual(user_trophy.progress, 100.0)
def test_award_trophy_idempotent(self):
"""Test awarding same trophy twice doesn't create duplicates"""
user_trophy1 = TrophyService.award_trophy(self.user, self.trophy)
user_trophy2 = TrophyService.award_trophy(self.user, self.trophy)
self.assertEqual(user_trophy1.pk, user_trophy2.pk)
self.assertEqual(UserTrophy.objects.filter(user=self.user, trophy=self.trophy).count(), 1)
def test_evaluate_trophy_earned(self):
"""Test evaluating a trophy that should be earned"""
# Set stats so trophy is earned
self.stats.total_workouts = 1
self.stats.save()
user_trophy = TrophyService.evaluate_trophy(self.user, self.trophy)
self.assertIsNotNone(user_trophy)
self.assertEqual(user_trophy.trophy, self.trophy)
def test_evaluate_trophy_not_earned(self):
"""Test evaluating a trophy that should not be earned"""
# Stats show 0 workouts, trophy requires 1
user_trophy = TrophyService.evaluate_trophy(self.user, self.trophy)
self.assertIsNone(user_trophy)
def test_evaluate_trophy_already_earned(self):
"""Test evaluating a trophy that's already been earned"""
# Award the trophy first
TrophyService.award_trophy(self.user, self.trophy)
# Try to evaluate again
user_trophy = TrophyService.evaluate_trophy(self.user, self.trophy)
# Should return None (already earned)
self.assertIsNone(user_trophy)
def test_evaluate_trophy_inactive(self):
"""Test evaluating an inactive trophy"""
self.trophy.is_active = False
self.trophy.save()
self.stats.total_workouts = 1
self.stats.save()
user_trophy = TrophyService.evaluate_trophy(self.user, self.trophy)
# Inactive trophies should not be awarded
self.assertIsNone(user_trophy)
def test_evaluate_all_trophies(self):
"""Test evaluating all trophies for a user"""
# Create multiple trophies
trophy2 = Trophy.objects.create(
name='Trophy 2',
trophy_type=Trophy.TYPE_SEQUENCE,
checker_class='streak',
checker_params={'days': 5},
is_active=True,
)
# Set stats so first trophy is earned
self.stats.total_workouts = 1
self.stats.current_streak = 0 # Second trophy not earned
self.stats.save()
awarded = TrophyService.evaluate_all_trophies(self.user)
# Should award only the first trophy
self.assertEqual(len(awarded), 1)
self.assertEqual(awarded[0].trophy, self.trophy)
def test_get_user_trophies(self):
"""Test getting all earned trophies for a user"""
# Award some trophies
TrophyService.award_trophy(self.user, self.trophy)
trophy2 = Trophy.objects.create(
name='Trophy 2',
trophy_type=Trophy.TYPE_VOLUME,
checker_class='volume',
checker_params={'kg': 1000},
)
TrophyService.award_trophy(self.user, trophy2)
trophies = TrophyService.get_user_trophies(self.user)
self.assertEqual(len(trophies), 2)
def test_get_all_trophy_progress(self):
"""Test getting progress for all trophies"""
# Create a progressive trophy
progressive_trophy = Trophy.objects.create(
name='Progressive',
trophy_type=Trophy.TYPE_VOLUME,
checker_class='volume',
checker_params={'kg': 5000},
is_progressive=True,
is_active=True,
)
# Set some progress
self.stats.total_weight_lifted = Decimal('2500')
self.stats.save()
progress_list = TrophyService.get_all_trophy_progress(self.user)
# Should include both trophies
self.assertEqual(len(progress_list), 2)
# Find the progressive trophy in the list
prog_trophy_data = next(
(p for p in progress_list if p['trophy'].id == progressive_trophy.id),
None
)
self.assertIsNotNone(prog_trophy_data)
self.assertEqual(prog_trophy_data['progress'], 50.0)
def test_get_all_trophy_progress_hidden_not_earned(self):
"""Test hidden trophies are excluded unless earned"""
hidden_trophy = Trophy.objects.create(
name='Hidden',
trophy_type=Trophy.TYPE_COUNT,
checker_class='count_based',
checker_params={'count': 10},
is_hidden=True,
is_active=True,
)
progress_list = TrophyService.get_all_trophy_progress(self.user, include_hidden=False)
# Hidden trophy should not be in the list
hidden_in_list = any(p['trophy'].id == hidden_trophy.id for p in progress_list)
self.assertFalse(hidden_in_list)
def test_get_all_trophy_progress_hidden_earned(self):
"""Test hidden trophies are included once earned"""
hidden_trophy = Trophy.objects.create(
name='Hidden',
trophy_type=Trophy.TYPE_COUNT,
checker_class='count_based',
checker_params={'count': 1},
is_hidden=True,
is_active=True,
)
# Award the hidden trophy
TrophyService.award_trophy(self.user, hidden_trophy)
progress_list = TrophyService.get_all_trophy_progress(self.user, include_hidden=False)
# Hidden trophy should now be in the list
hidden_in_list = any(p['trophy'].id == hidden_trophy.id for p in progress_list)
self.assertTrue(hidden_in_list)
def test_should_skip_user_inactive(self):
"""Test skipping inactive users"""
# Set user's last login to over 30 days ago
self.user.last_login = timezone.now() - timezone.timedelta(days=35)
self.user.save()
should_skip = TrophyService.should_skip_user(self.user)
self.assertTrue(should_skip)
def test_should_skip_user_active(self):
"""Test not skipping active users"""
# Set user's last login to recent
self.user.last_login = timezone.now() - timezone.timedelta(days=5)
self.user.save()
should_skip = TrophyService.should_skip_user(self.user)
self.assertFalse(should_skip)
@patch('wger.trophies.services.trophy.TROPHIES_ENABLED', False)
def test_should_skip_user_trophies_disabled(self):
"""Test skipping when trophies are globally disabled"""
self.user.last_login = timezone.now()
self.user.save()
should_skip = TrophyService.should_skip_user(self.user)
self.assertTrue(should_skip)
def test_reevaluate_trophies(self):
"""Test re-evaluating trophies for all users"""
# Set recent login for self.user
self.user.last_login = timezone.now()
self.user.save()
# Create a second user
user2 = User.objects.get(username='test')
user2.last_login = timezone.now()
user2.save()
# Create stats for both users
UserStatistics.objects.get_or_create(user=user2, defaults={'total_workouts': 1})
self.stats.total_workouts = 1
self.stats.save()
# Re-evaluate all trophies
results = TrophyService.reevaluate_trophies()
# Both users should be checked
self.assertEqual(results['users_checked'], 2)
# Both should earn the trophy (count=1, both have 1 workout)
self.assertEqual(results['trophies_awarded'], 2)
def test_reevaluate_specific_trophy(self):
"""Test re-evaluating a specific trophy"""
# Create another trophy
trophy2 = Trophy.objects.create(
name='Trophy 2',
trophy_type=Trophy.TYPE_COUNT,
checker_class='count_based',
checker_params={'count': 5},
is_active=True,
)
self.user.last_login = timezone.now()
self.user.save()
self.stats.total_workouts = 1
self.stats.save()
# Re-evaluate only trophy2
results = TrophyService.reevaluate_trophies(trophy_ids=[trophy2.id])
# Trophy2 requires 5 workouts, user has 1, shouldn't be awarded
self.assertEqual(results['trophies_awarded'], 0)
def test_reevaluate_specific_users(self):
"""Test re-evaluating trophies for specific users"""
# Set recent login for self.user
self.user.last_login = timezone.now()
self.user.save()
user2 = User.objects.get(username='test')
user2.last_login = timezone.now()
user2.save()
UserStatistics.objects.get_or_create(user=user2, defaults={'total_workouts': 0})
self.stats.total_workouts = 1
self.stats.save()
# Re-evaluate only for self.user
results = TrophyService.reevaluate_trophies(user_ids=[self.user.id])
# Only one user checked
self.assertEqual(results['users_checked'], 1)
# That user should earn the trophy
self.assertEqual(results['trophies_awarded'], 1)

View File

@@ -25,7 +25,6 @@ from django.contrib.sitemaps.views import (
sitemap,
)
from django.urls import path
# Third Party
from django_email_verification import urls as email_urls
from drf_spectacular.views import (
@@ -48,6 +47,7 @@ from wger.gallery.api import views as gallery_api_views
from wger.manager.api import views as manager_api_views
from wger.measurements.api import views as measurements_api_views
from wger.nutrition.api import views as nutrition_api_views
# from wger.trophies.api import views as trophies_api_views
from wger.utils.generic_views import TextTemplateView
from wger.weight.api import views as weight_api_views
@@ -249,6 +249,15 @@ router.register(
basename='measurement-category',
)
# Trophies app
# router.register(r'trophy', trophies_api_views.TrophyViewSet, basename='trophy')
# router.register(r'user-trophy', trophies_api_views.UserTrophyViewSet, basename='user-trophy')
# router.register(
# r'user-statistics',
# trophies_api_views.UserStatisticsViewSet,
# basename='user-statistics',
# )
#
# Sitemaps
#