diff --git a/package-lock.json b/package-lock.json index d65129336..9cfec63cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/wger/core/migrations/0020_add_trophies_enabled_to_userprofile.py.txt b/wger/core/migrations/0020_add_trophies_enabled_to_userprofile.py.txt new file mode 100644 index 000000000..a70f23647 --- /dev/null +++ b/wger/core/migrations/0020_add_trophies_enabled_to_userprofile.py.txt @@ -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'), + ), + ] diff --git a/wger/core/models/profile.py b/wger/core/models/profile.py index a1c642437..c73524d42 100644 --- a/wger/core/models/profile.py +++ b/wger/core/models/profile.py @@ -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: """ diff --git a/wger/settings_global.py b/wger/settings_global.py index a3ed09b78..e42391fb1 100644 --- a/wger/settings_global.py +++ b/wger/settings_global.py @@ -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 } # diff --git a/wger/trophies/README.md b/wger/trophies/README.md new file mode 100644 index 000000000..f86b42b13 --- /dev/null +++ b/wger/trophies/README.md @@ -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) + diff --git a/wger/trophies/api/__init__.py b/wger/trophies/api/__init__.py new file mode 100644 index 000000000..1292caeab --- /dev/null +++ b/wger/trophies/api/__init__.py @@ -0,0 +1,15 @@ +# This file is part of wger Workout Manager . +# 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 . diff --git a/wger/trophies/api/filtersets.py b/wger/trophies/api/filtersets.py new file mode 100644 index 000000000..f30ff9c9f --- /dev/null +++ b/wger/trophies/api/filtersets.py @@ -0,0 +1,55 @@ +# This file is part of wger Workout Manager . +# 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 . + +# 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'], + } diff --git a/wger/trophies/api/serializers.py b/wger/trophies/api/serializers.py new file mode 100644 index 000000000..6bd3a0033 --- /dev/null +++ b/wger/trophies/api/serializers.py @@ -0,0 +1,112 @@ +# This file is part of wger Workout Manager . +# 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 . + +# 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) diff --git a/wger/trophies/api/views.py b/wger/trophies/api/views.py new file mode 100644 index 000000000..566c7d256 --- /dev/null +++ b/wger/trophies/api/views.py @@ -0,0 +1,190 @@ +# This file is part of wger Workout Manager . +# 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 . + +# 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) diff --git a/wger/trophies/apps.py b/wger/trophies/apps.py new file mode 100644 index 000000000..f76363369 --- /dev/null +++ b/wger/trophies/apps.py @@ -0,0 +1,26 @@ +# This file is part of wger Workout Manager . +# 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 . + +# Django +from django.apps import AppConfig + + +class TrophiesConfig(AppConfig): + name = 'wger.trophies' + verbose_name = 'Trophies' + + def ready(self): + import wger.trophies.signals # noqa: F401 diff --git a/wger/trophies/checkers/__init__.py b/wger/trophies/checkers/__init__.py new file mode 100644 index 000000000..86c5fadb6 --- /dev/null +++ b/wger/trophies/checkers/__init__.py @@ -0,0 +1,26 @@ +# This file is part of wger Workout Manager . +# 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 . + +# 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 diff --git a/wger/trophies/checkers/base.py b/wger/trophies/checkers/base.py new file mode 100644 index 000000000..f952c8044 --- /dev/null +++ b/wger/trophies/checkers/base.py @@ -0,0 +1,126 @@ +# This file is part of wger Workout Manager . +# 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 . + +# 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})>' diff --git a/wger/trophies/checkers/count_based.py b/wger/trophies/checkers/count_based.py new file mode 100644 index 000000000..fed68b068 --- /dev/null +++ b/wger/trophies/checkers/count_based.py @@ -0,0 +1,72 @@ +# This file is part of wger Workout Manager . +# 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 . + +# 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' diff --git a/wger/trophies/checkers/date_based.py b/wger/trophies/checkers/date_based.py new file mode 100644 index 000000000..84e68d77d --- /dev/null +++ b/wger/trophies/checkers/date_based.py @@ -0,0 +1,109 @@ +# This file is part of wger Workout Manager . +# 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 . + +# 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()}' diff --git a/wger/trophies/checkers/inactivity_return.py b/wger/trophies/checkers/inactivity_return.py new file mode 100644 index 000000000..2533d3b01 --- /dev/null +++ b/wger/trophies/checkers/inactivity_return.py @@ -0,0 +1,118 @@ +# This file is part of wger Workout Manager . +# 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 . + +# 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' diff --git a/wger/trophies/checkers/registry.py b/wger/trophies/checkers/registry.py new file mode 100644 index 000000000..b7b5dee1c --- /dev/null +++ b/wger/trophies/checkers/registry.py @@ -0,0 +1,159 @@ +# This file is part of wger Workout Manager . +# 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 . + +# 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) diff --git a/wger/trophies/checkers/streak.py b/wger/trophies/checkers/streak.py new file mode 100644 index 000000000..d6de7a5fa --- /dev/null +++ b/wger/trophies/checkers/streak.py @@ -0,0 +1,77 @@ +# This file is part of wger Workout Manager . +# 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 . + +# 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' diff --git a/wger/trophies/checkers/time_based.py b/wger/trophies/checkers/time_based.py new file mode 100644 index 000000000..8781f255f --- /dev/null +++ b/wger/trophies/checkers/time_based.py @@ -0,0 +1,149 @@ +# This file is part of wger Workout Manager . +# 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 . + +# 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' diff --git a/wger/trophies/checkers/volume.py b/wger/trophies/checkers/volume.py new file mode 100644 index 000000000..0a7649ed5 --- /dev/null +++ b/wger/trophies/checkers/volume.py @@ -0,0 +1,88 @@ +# This file is part of wger Workout Manager . +# 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 . + +# 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' diff --git a/wger/trophies/checkers/weekend_warrior.py b/wger/trophies/checkers/weekend_warrior.py new file mode 100644 index 000000000..5325d5081 --- /dev/null +++ b/wger/trophies/checkers/weekend_warrior.py @@ -0,0 +1,72 @@ +# This file is part of wger Workout Manager . +# 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 . + +# 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' diff --git a/wger/trophies/fixtures/initial_trophies.json b/wger/trophies/fixtures/initial_trophies.json new file mode 100644 index 000000000..cbeef12ff --- /dev/null +++ b/wger/trophies/fixtures/initial_trophies.json @@ -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 + } + } +] diff --git a/wger/trophies/management/__init__.py b/wger/trophies/management/__init__.py new file mode 100644 index 000000000..1292caeab --- /dev/null +++ b/wger/trophies/management/__init__.py @@ -0,0 +1,15 @@ +# This file is part of wger Workout Manager . +# 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 . diff --git a/wger/trophies/management/commands/__init__.py b/wger/trophies/management/commands/__init__.py new file mode 100644 index 000000000..1292caeab --- /dev/null +++ b/wger/trophies/management/commands/__init__.py @@ -0,0 +1,15 @@ +# This file is part of wger Workout Manager . +# 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 . diff --git a/wger/trophies/management/commands/evaluate_trophies.py b/wger/trophies/management/commands/evaluate_trophies.py new file mode 100644 index 000000000..d1a3cdb20 --- /dev/null +++ b/wger/trophies/management/commands/evaluate_trophies.py @@ -0,0 +1,148 @@ +# This file is part of wger Workout Manager . +# 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 . + +# 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 diff --git a/wger/trophies/management/commands/load_trophies.py b/wger/trophies/management/commands/load_trophies.py new file mode 100644 index 000000000..c1cb0c74d --- /dev/null +++ b/wger/trophies/management/commands/load_trophies.py @@ -0,0 +1,196 @@ +# This file is part of wger Workout Manager . +# 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 . + +# 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)}' + ) + ) diff --git a/wger/trophies/management/commands/recalculate_statistics.py b/wger/trophies/management/commands/recalculate_statistics.py new file mode 100644 index 000000000..50624f31a --- /dev/null +++ b/wger/trophies/management/commands/recalculate_statistics.py @@ -0,0 +1,164 @@ +# This file is part of wger Workout Manager . +# 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 . + +# 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}' + ) + ) diff --git a/wger/trophies/migrations/0001_initial.py b/wger/trophies/migrations/0001_initial.py new file mode 100644 index 000000000..7551c3fa3 --- /dev/null +++ b/wger/trophies/migrations/0001_initial.py @@ -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')}, + }, + ), + ] diff --git a/wger/trophies/migrations/0002_add_last_complete_weekend_date.py b/wger/trophies/migrations/0002_add_last_complete_weekend_date.py new file mode 100644 index 000000000..96a478484 --- /dev/null +++ b/wger/trophies/migrations/0002_add_last_complete_weekend_date.py @@ -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'), + ), + ] diff --git a/wger/trophies/migrations/0003_load_initial_trophies.py b/wger/trophies/migrations/0003_load_initial_trophies.py new file mode 100644 index 000000000..403e394c0 --- /dev/null +++ b/wger/trophies/migrations/0003_load_initial_trophies.py @@ -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), + ] diff --git a/wger/trophies/migrations/__init__.py b/wger/trophies/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/wger/trophies/models/__init__.py b/wger/trophies/models/__init__.py new file mode 100644 index 000000000..8dd94bf23 --- /dev/null +++ b/wger/trophies/models/__init__.py @@ -0,0 +1,20 @@ +# This file is part of wger Workout Manager . +# 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 . + +# Local +from .trophy import Trophy +from .user_statistics import UserStatistics +from .user_trophy import UserTrophy diff --git a/wger/trophies/models/trophy.py b/wger/trophies/models/trophy.py new file mode 100644 index 000000000..a7463ab92 --- /dev/null +++ b/wger/trophies/models/trophy.py @@ -0,0 +1,160 @@ +# This file is part of wger Workout Manager . +# 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 . + +# 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 diff --git a/wger/trophies/models/user_statistics.py b/wger/trophies/models/user_statistics.py new file mode 100644 index 000000000..21a8afb31 --- /dev/null +++ b/wger/trophies/models/user_statistics.py @@ -0,0 +1,139 @@ +# This file is part of wger Workout Manager . +# 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 . + +# 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 diff --git a/wger/trophies/models/user_trophy.py b/wger/trophies/models/user_trophy.py new file mode 100644 index 000000000..3a8a4e353 --- /dev/null +++ b/wger/trophies/models/user_trophy.py @@ -0,0 +1,86 @@ +# This file is part of wger Workout Manager . +# 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 . + +# 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 diff --git a/wger/trophies/services/__init__.py b/wger/trophies/services/__init__.py new file mode 100644 index 000000000..9418bfd88 --- /dev/null +++ b/wger/trophies/services/__init__.py @@ -0,0 +1,20 @@ +# This file is part of wger Workout Manager . +# 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 . + +from .statistics import UserStatisticsService +from .trophy import TrophyService + +__all__ = ['UserStatisticsService', 'TrophyService'] diff --git a/wger/trophies/services/statistics.py b/wger/trophies/services/statistics.py new file mode 100644 index 000000000..14d511ea1 --- /dev/null +++ b/wger/trophies/services/statistics.py @@ -0,0 +1,477 @@ +# This file is part of wger Workout Manager . +# 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 . + +# 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 diff --git a/wger/trophies/services/trophy.py b/wger/trophies/services/trophy.py new file mode 100644 index 000000000..783028c8c --- /dev/null +++ b/wger/trophies/services/trophy.py @@ -0,0 +1,315 @@ +# This file is part of wger Workout Manager . +# 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 . + +# 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, + } diff --git a/wger/trophies/signals.py b/wger/trophies/signals.py new file mode 100644 index 000000000..5309d5ff9 --- /dev/null +++ b/wger/trophies/signals.py @@ -0,0 +1,158 @@ +# This file is part of wger Workout Manager . +# 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 . + +""" +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) diff --git a/wger/trophies/tasks.py b/wger/trophies/tasks.py new file mode 100644 index 000000000..118956bf7 --- /dev/null +++ b/wger/trophies/tasks.py @@ -0,0 +1,139 @@ +# This file is part of wger Workout Manager . +# 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 . + +""" +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') diff --git a/wger/trophies/tests/__init__.py b/wger/trophies/tests/__init__.py new file mode 100644 index 000000000..1292caeab --- /dev/null +++ b/wger/trophies/tests/__init__.py @@ -0,0 +1,15 @@ +# This file is part of wger Workout Manager . +# 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 . diff --git a/wger/trophies/tests/test_api.py b/wger/trophies/tests/test_api.py new file mode 100644 index 000000000..34b42796e --- /dev/null +++ b/wger/trophies/tests/test_api.py @@ -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') diff --git a/wger/trophies/tests/test_checkers.py b/wger/trophies/tests/test_checkers.py new file mode 100644 index 000000000..d02f3fb0a --- /dev/null +++ b/wger/trophies/tests/test_checkers.py @@ -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) diff --git a/wger/trophies/tests/test_integration.py b/wger/trophies/tests/test_integration.py new file mode 100644 index 000000000..9c02885d1 --- /dev/null +++ b/wger/trophies/tests/test_integration.py @@ -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 diff --git a/wger/trophies/tests/test_models.py b/wger/trophies/tests/test_models.py new file mode 100644 index 000000000..23f24a614 --- /dev/null +++ b/wger/trophies/tests/test_models.py @@ -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) diff --git a/wger/trophies/tests/test_services.py b/wger/trophies/tests/test_services.py new file mode 100644 index 000000000..e853ad52b --- /dev/null +++ b/wger/trophies/tests/test_services.py @@ -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) diff --git a/wger/urls.py b/wger/urls.py index e04ecc742..132e3a840 100644 --- a/wger/urls.py +++ b/wger/urls.py @@ -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 #